L1 Realtime Orderbased Exchange
Concept inspired by “Offchain Bank operating at Layer 2”.
Value Proposition
Self custodial Exchange with CEX-like UX and clear KYA.
Model
A service “CrystalPool” enables realtime exchange transactions on L1. First questions, how is it possible to have real time transaction with self custody?
self custody comes with time limited multisig smart contracts (see full source below). Before time limit each operation is signed by userPK and poolPK. After the time limit userPK is in full control.
Real time UX: the realtime balance/state is provided by the CrystalPool service. Each trading action creates a new transaction. CrystalPool maintains and extends a chain of temporary unpublished, signed transactions. The publishing process happens asynchronously in the background.
Example:
Bob puchases rsBTC for 1k Erdoge from Alice. Exchange price 1 Erdoge = 1 sat
Steps:
- Bob deposits 1k Erdoge, 0.01ERG to DEPOSIT_CONTRACT (signed by bobPK)
- Bob creates a rsBTC/Erdoge SWAP_CONTRACT order (signed by bobPK && poolPk)
- Alice deposits 0.00001 rsBTC, 0.01ERG to DEPOSIT_CONTRACT (signed by alicePK)
- Alice swaps 0.00001 rsBTC for 1k Erdoge from SWAP_CONTRACT to to DEPOSIT_CONTRACT (signed by alicePK && poolPk)
- Alice withdraws 1k Erdoge (signed by alicePK && poolPk)
- Bob withdraws 0.00001 rsBTC (signed by bobPK && poolPk)
Given example only demonstrates a minimal life cycle from deposit to withdraw without highlighting the core proposition of real time trading between Alice and Bob, that can occur between deposit and withdraw.
Transactions:
- Bob deposits 1k Erdoge
┌─────────────────────────┐ ┌─────────────────────────┐
│ address: BOB │ │ address: DEPOSIT │
│ value: 0.1Erg │ │ value: 0.0989Erg │
│ assets:[{ │ │ assets:[{ │
│ tokenId: Erdoge │ ────┬────► │ tokenId: Erdoge │
│ amount: 1000 │ │ │ amount: 1000 │
│ }] │ │ │ }] │
└─────────────────────────┘ │ │ additionalRegisters:{ │
│ │ R4: [BOB,POOL] │
│ │ R5: unlockHeight │
│ │ } │
│ └─────────────────────────┘
│ ┌─────────────────────────┐
│ │ address: BOB │
├────► │ ... │
│ └─────────────────────────┘
│ ┌─────────────────────────┐
│ │ address: FEE │
└────► │ value: 0.0011Erg │
└─────────────────────────┘
- Bob creates a swap order
┌─────────────────────────┐ ┌─────────────────────────┐
│ address: DEPOSIT │ │ address: SWAP │
│ value: 0989Erg │ │ value: 0978Erg │
│ assets:[{ │ │ assets:[{ │
│ tokenId: Erdoge │ ────┬────► │ tokenId: Erdoge │
│ amount: 1000 │ │ │ amount: 1000 │
│ }] │ │ │ }] │
│ additionalRegisters:{ │ │ │ additionalRegisters:{ │
│ R4: [BOB,POOL] │ │ │ R4: [BOB,POOL] │
│ R5: unlockHeight │ │ │ R5: unlockHeight │
│ } │ │ │ R6: [Erdoge,rsBTC] │
└─────────────────────────┘ │ │ R7: swapRate │
│ │ R8: DEPOSIT │
│ │ } │
│ └─────────────────────────┘
│ ┌─────────────────────────┐
│ │ address: FEE │
└────► │ value: 0.0011Erg │
└─────────────────────────┘
- Alice deposits 0.00001 rsBTC
┌─────────────────────────┐ ┌─────────────────────────┐
│ address: ALICE │ │ address: DEPOSIT │
│ value: 0.1Erg │ │ value: 0.0989Erg │
│ assets:[{ │ │ assets:[{ │
│ tokenId: rsBTC │ ────┬────► │ tokenId: rsBTC │
│ amount: 0.00001 │ │ │ amount: 0.00001 │
│ }] │ │ │ }] │
└─────────────────────────┘ │ │ additionalRegisters:{ │
│ │ R4: [ALICE,POOL] │
│ │ R5: unlockHeight │
│ │ } │
│ └─────────────────────────┘
│ ┌─────────────────────────┐
│ │ address: ALICE │
├────► │ ... │
│ └─────────────────────────┘
│ ┌─────────────────────────┐
│ │ address: FEE │
└────► │ value: 0.0011Erg │
└─────────────────────────┘
- Alice swaps 0.00001 rsBTC for 1k Erdoge
┌─────────────────────────┐ ┌─────────────────────────┐
│ address: SWAP │ │ address: DEPOSIT │
│ value: 0.0978Erg │ │ value: 0.0978Erg │
│ assets:[{ │ │ assets:[{ │
│ tokenId: Erdoge │ ──┐ ┌──► │ tokenId: Erdoge │
│ amount: 1000 │ │ │ │ amount: 1000 │
│ }] │ │ │ │ }] │
│ additionalRegisters:{ │ │ │ │ additionalRegisters:{ │
│ R4: [BOB,POOL] │ │ │ │ R4: [ALICE,POOL] │
│ R5: unlockHeight │ │ │ │ R5: unlockHeight │
│ R6: [Erdoge,rsBTC] │ │ │ │ } │
│ R7: swapRate │ ├───┤ └─────────────────────────┘
│ R8: DEPOSIT │ │ │ ┌─────────────────────────┐
│ } │ │ │ │ address: DEPOSIT │
└─────────────────────────┘ │ │ │ value: 0.0978Erg │
┌─────────────────────────┐ │ │ │ assets:[{ │
│ address: DEPOSIT │ │ │ │ tokenId: rsBTC │
│ value: 0.0989Erg │ ──┘ ├──► │ amount: 0.00001 │
│ assets:[{ │ │ │ }] │
│ tokenId: rsBTC │ │ │ additionalRegisters:{ │
│ amount: 0.00001 │ │ │ R4: [BOB,POOL] │
│ }] │ │ │ R5: unlockHeight │
│ additionalRegisters:{ │ │ │ R6: [Erdoge,rsBTC] │
│ R4: [ALICE,POOL] │ │ │ } │
│ R5: unlockHeight │ │ └─────────────────────────┘
│ } │ │ ┌─────────────────────────┐
└─────────────────────────┘ │ │ address: FEE │
└──► │ value: 0.0011Erg │
└─────────────────────────┘
- Alice withdraws 1k Erdoge
┌─────────────────────────┐ ┌─────────────────────────┐
│ address: DEPOSIT │ │ address: ALICE │
│ value: 0.0978Erg │ │ value: 0.0967Erg │
│ assets:[{ │ │ assets:[{ │
│ tokenId: Erdoge │ ────┬────► │ tokenId: Erdoge │
│ amount: 1000 │ │ │ amount: 1000 │
│ }] │ │ │ }] │
│ additionalRegisters:{ │ │ └─────────────────────────┘
│ R4: [ALICE,POOL] │ │ ┌─────────────────────────┐
│ R5: unlockHeight │ │ │ address: FEE │
│ } │ └────► │ value: 0.0011Erg │
└─────────────────────────┘ └─────────────────────────┘
- Bob withdraws 0.00001 rsBTC
┌─────────────────────────┐ ┌─────────────────────────┐
│ address: DEPOSIT │ │ address: ALICE │
│ value: 0.0978Erg │ │ value: 0.0967Erg │
│ assets:[{ │ │ assets:[{ │
│ tokenId: Erdoge │ ────┬────► │ tokenId: Erdoge │
│ amount: 1000 │ │ │ amount: 1000 │
│ }] │ │ │ }] │
│ additionalRegisters:{ │ │ └─────────────────────────┘
│ R4: [ALICE,POOL] │ │ ┌─────────────────────────┐
│ R5: unlockHeight │ │ │ address: FEE │
│ } │ └────► │ value: 0.0011Erg │
└─────────────────────────┘ └─────────────────────────┘
Smart Contracts:
DEPOSIT
{
def getSellerPk(box: Box) = box.R4[Coll[SigmaProp]].get(0)
def getPoolPk(box: Box) = box.R4[Coll[SigmaProp]].get(1)
def unlockHeight(box: Box) = box.R5[Int].get
if(HEIGHT > unlockHeight(SELF)){
getSellerPk(SELF)
}else{
getSellerPk(SELF) && getPoolPk(SELF)
}
}
SWAP
{
def getSellerPk(box: Box) = box.R4[Coll[SigmaProp]].getOrElse(Coll[SigmaProp](sigmaProp(false),sigmaProp(false)))(0)
def getPoolPk(box: Box) = box.R4[Coll[SigmaProp]].getOrElse(Coll[SigmaProp](sigmaProp(false),sigmaProp(false)))(1)
def unlockHeight(box: Box) = box.R5[Int].get
def getSellingTokenId(box: Box) = box.R6[(Coll[Byte],Coll[Byte])].getOrElse((Coll[Byte](),Coll[Byte]()))._1
def getBuyingTokenId(box: Box) = box.R6[(Coll[Byte],Coll[Byte])].getOrElse((Coll[Byte](),Coll[Byte]()))._2
def getRate(box: Box) = box.R7[Long].get
def getSellerMultisigAddress(box: Box) = box.R8[Coll[Byte]].get
def tokenId(box: Box) = box.tokens(0)._1
def tokenAmount(box: Box) = box.tokens(0)._2
def sumTokenAmount(a:Long, b: Box) = a + tokenAmount(b)
def sumTokenAmountXRate(a:Long, b: Box) = a + tokenAmount(b) * getRate(b)
def isSameContract(box: Box) =
box.propositionBytes == SELF.propositionBytes
def isSameTokenPair (box: Box) =
getSellingTokenId(SELF) == getSellingTokenId(box) &&
getBuyingTokenId(SELF) == getBuyingTokenId(box)
def hasSellingToken(box: Box) =
getSellingTokenId(SELF) == getSellingTokenId(box) &&
box.tokens.size > 0 &&
getSellingTokenId(SELF) == tokenId(box)
def hasBuyingToken(box: Box) =
getBuyingTokenId(SELF) == getBuyingTokenId(box) &&
box.tokens.size > 0 &&
getBuyingTokenId(SELF) == tokenId(box)
def isGreaterZeroRate(box:Box) =
getRate(box) > 0
def isSameSeller(box: Box) =
getSellerPk(SELF) == getSellerPk(box) &&
getPoolPk(SELF) == getPoolPk(box)
def isSameUnlockHeight(box: Box) =
unlockHeight(SELF) == unlockHeight(box)
def isSameMultisig(box: Box) =
getSellerMultisigAddress(SELF) == getSellerMultisigAddress(box)
def isLegitInput(box: Box) =
isSameContract(box) &&
isSameSeller(box) &&
isSameUnlockHeight(box) &&
isSameTokenPair(box) &&
hasSellingToken(box) &&
isGreaterZeroRate(box) &&
isSameMultisig(box)
val maxSellRate: Long = INPUTS
.filter(isLegitInput)
.fold(0L, {(r:Long, box:Box) => {
if(r > getRate(box)) r else getRate(box)
}})
def hasMaxSellRate(box: Box) =
getRate(box) == maxSellRate
def isLegitSellOrderOutput(box: Box) =
isLegitInput(box)&&
hasMaxSellRate(box)
def isPaymentBox(box:Box) =
isSameSeller(box) &&
isSameUnlockHeight(box) &&
hasBuyingToken(box) &&
getSellerMultisigAddress(SELF) == box.propositionBytes
def sumSellTokensIn(boxes: Coll[Box]): Long = boxes
.filter(isLegitInput)
.fold(0L, sumTokenAmount)
def sumSellTokensOut(boxes: Coll[Box]): Long = boxes
.filter(isLegitSellOrderOutput)
.fold(0L, sumTokenAmount)
def sumBuyTokensPaid(boxes: Coll[Box]): Long = boxes
.filter(isPaymentBox)
.fold(0L, sumTokenAmount)
val tokensSold = sumSellTokensIn(INPUTS) - sumSellTokensOut(OUTPUTS)
val tokensPaid = sumBuyTokensPaid(OUTPUTS)
val inSellTokensXRate = INPUTS
.filter(isLegitInput)
.fold(0L, sumTokenAmountXRate)
val outSellTokensXRate = OUTPUTS
.filter(isLegitSellOrderOutput)
.fold(0L, sumTokenAmountXRate)
val sellTokensXRate = inSellTokensXRate - outSellTokensXRate
val expectedRate = sellTokensXRate / tokensSold
val isPaidAtFairRate = tokensPaid/tokensSold >= expectedRate
if(HEIGHT > unlockHeight(SELF)){
getSellerPk(SELF)
}else{
getSellerPk(SELF) && getPoolPk(SELF) || sigmaProp(isPaidAtFairRate) && getPoolPk(SELF)
}
}
BUY
{
def getBuyerPk(box: Box) = box.R4[Coll[SigmaProp]].getOrElse(Coll[SigmaProp](sigmaProp(false),sigmaProp(false)))(0)
def getPoolPk(box: Box) = box.R4[Coll[SigmaProp]].getOrElse(Coll[SigmaProp](sigmaProp(false),sigmaProp(false)))(1)
def unlockHeight(box: Box) = box.R5[Int].get
def getTokenId(box: Box) = box.R6[Coll[Byte]].getOrElse(Coll[Byte]())
def getBuyRate(box: Box) = box.R7[Long].get
def getBuyerMultisigAddress(box: Box) = box.R8[Coll[Byte]].get
def tokenId(box: Box) =
box.tokens(0)._1
def tokenAmount(box: Box) =
box.tokens(0)._2
def isSameContract(box: Box) =
box.propositionBytes == SELF.propositionBytes
def isSameTokenId (box: Box) =
getTokenId(SELF) == getTokenId(box)
def includesToken(box: Box) =
getTokenId(SELF) == getTokenId(box) &&
box.tokens.size > 0 &&
getTokenId(SELF) == tokenId(box)
def isGreaterZeroRate(box:Box) =
getBuyRate(box) > 0
def isSameBuyer(box: Box) =
getBuyerPk(SELF) == getBuyerPk(box) &&
getPoolPk(SELF) == getPoolPk(box)
def isSameUnlockHeight(box: Box) =
unlockHeight(SELF) == unlockHeight(box)
def isSameMultisig(box: Box) =
getBuyerMultisigAddress(SELF) == getBuyerMultisigAddress(box)
def isLegitBuyOrderInput(box: Box) =
isSameBuyer(box) &&
isSameUnlockHeight(box) &&
isSameTokenId(box) &&
isGreaterZeroRate(box) &&
isSameMultisig(box) &&
isSameContract(box)
val minBuyRate = INPUTS
.filter(isLegitBuyOrderInput)
.fold(0L, {(r:Long, box:Box) => {
if(r < getBuyRate(box)) r else getBuyRate(box)
}})
def isLegitBuyOrderOutput(box: Box) =
isLegitBuyOrderInput(box)&&
minBuyRate == getBuyRate(box)
def isPaymentBox(box:Box) =
isSameBuyer(box) &&
isSameUnlockHeight(box) &&
includesToken(box) &&
getBuyerMultisigAddress(SELF) == box.propositionBytes
def sumValuesIn(boxes: Coll[Box]): Long = boxes
.filter(isLegitBuyOrderInput)
.fold(0L, {(a:Long, b: Box) => a + b.value})
def sumValuesOut(boxes: Coll[Box]): Long = boxes
.filter(isLegitBuyOrderOutput)
.fold(0L, {(a:Long, b: Box) => a + b.value})
def sumAmountsIn(boxes: Coll[Box]): Long = boxes
.filter(isLegitBuyOrderInput)
.fold(0L, {(a:Long, b: Box) => a + b.value/getBuyRate(b)})
def sumAmountsOut(boxes: Coll[Box]): Long = boxes
.filter(isLegitBuyOrderOutput)
.fold(0L, {(a:Long, b: Box) => a + b.value/getBuyRate(b)})
val valuesIn: Long = sumValuesIn(INPUTS)
val amountsIn: Long = sumAmountsIn(INPUTS)
val valuesOut: Long = sumValuesOut(OUTPUTS)
val amountsOut: Long = sumAmountsOut(OUTPUTS)
val deltaAmounts = amountsIn - amountsOut
val deltaValues = valuesIn - valuesOut
def tokensBought(boxes: Coll[Box]): Long = boxes
.filter(isPaymentBox)
.fold(0L, {(a:Long, b: Box) => a + tokenAmount(b)})
val sentToBuyer = tokensBought(OUTPUTS)
val isBuyerPaid = deltaAmounts <= sentToBuyer
if(HEIGHT > unlockHeight(SELF)){
getBuyerPk(SELF)
}else{
getBuyerPk(SELF) && getPoolPk(SELF) || sigmaProp(isBuyerPaid) && getPoolPk(SELF)
}
}
SELL
{
def getSellerPk(box: Box) = box.R4[Coll[SigmaProp]].getOrElse(Coll[SigmaProp](sigmaProp(false),sigmaProp(false)))(0)
def getPoolPk(box: Box) = box.R4[Coll[SigmaProp]].getOrElse(Coll[SigmaProp](sigmaProp(false),sigmaProp(false)))(1)
def unlockHeight(box: Box) = box.R5[Int].get
def getTokenId(box: Box) = box.R6[Coll[Byte]].getOrElse(Coll[Byte]())
def getSellRate(box: Box) = box.R7[Long].get
def getSellerMultisigAddress(box: Box) = box.R8[Coll[Byte]].get
def tokenId(box: Box) = box.tokens(0)._1
def tokenAmount(box: Box) = box.tokens(0)._2
def isSameContract(box: Box) =
box.propositionBytes == SELF.propositionBytes
def isSameToken(box: Box) =
getTokenId(SELF) == getTokenId(box) &&
box.tokens.size > 0 &&
getTokenId(SELF) == tokenId(box)
def isGreaterZeroRate(box:Box) =
getSellRate(box) > 0
def isSameSeller(box: Box) =
getSellerPk(SELF) == getSellerPk(box) &&
getPoolPk(SELF) == getPoolPk(box)
def isSameUnlockHeight(box: Box) =
unlockHeight(SELF) == unlockHeight(box)
def isSameMultisig(box: Box) =
getSellerMultisigAddress(SELF) == getSellerMultisigAddress(box)
def isLegitInputBox(b: Box) = {
isSameContract(b) &&
isSameToken(b) &&
isSameMultisig(b) &&
isSameSeller(b) &&
isGreaterZeroRate(b)
}
def isPaymentBox(box:Box) = {
isSameSeller(box) &&
isSameUnlockHeight(box) &&
getTokenId(SELF) == getTokenId(box) &&
getSellerMultisigAddress(SELF) == box.propositionBytes
}
def sumTokensIn(boxes: Coll[Box]): Long = boxes
.filter(isLegitInputBox)
.fold(0L, {(a:Long, b: Box) => a + b.tokens(0)._2})
val tokensIn: Long = sumTokensIn(INPUTS)
val avgRateInputs: Long = INPUTS
.filter(isLegitInputBox)
.fold(0L, {(a:Long, b: Box) => {
a + getSellRate(b)*tokenAmount(b)
}}) / tokensIn
val maxSellRate = INPUTS
.filter(isLegitInputBox)
.fold(0L, {(r:Long, box:Box) => {
if(r > getSellRate(box)) r else getSellRate(box)
}})
def sumTokensInAtMaxRate(boxes: Coll[Box]): Long = boxes
.filter(isLegitInputBox)
.filter({(b: Box)=> getSellRate(b) == maxSellRate})
.fold(0L, {(a:Long, b: Box) => a + tokenAmount(b)})
def isMaxRateChangeBox(box: Box) = {
isSameSeller(box) &&
isSameUnlockHeight(box) &&
isSameToken(box) &&
maxSellRate == getSellRate(box) &&
isSameMultisig(box) &&
isSameContract(box)
}
def tokensRemaining(boxes: Coll[Box]): Long = boxes
.filter(isMaxRateChangeBox)
.fold(0L, {(a:Long, b: Box) => a + tokenAmount(b)})
val tokensBack: Long = tokensRemaining(OUTPUTS)
val tokensSold: Long = tokensIn - tokensBack
val nanoErgsPaid: Long = OUTPUTS
.filter(isPaymentBox)
.fold(0L, {(a:Long, b: Box) => a + b.value})
val valueOfSoldTokens: Long = tokensIn * avgRateInputs - tokensBack * maxSellRate
val amountOfSoldTokens: Long = tokensIn - tokensBack
val avgTokenPrice: Long = valueOfSoldTokens / amountOfSoldTokens
val tokensInputAtMaxRate = sumTokensInAtMaxRate(INPUTS)
val sellOrderChangeBoxIsFine = tokensInputAtMaxRate > tokensBack
val sellerPaid: Boolen = tokensSold * avgTokenPrice <= nanoErgsPaid
val orderFilled = sellerPaid && sellOrderChangeBoxIsFine
if(HEIGHT > unlockHeight(SELF)){
getSellerPk(SELF)
}else{
getSellerPk(SELF) && getPoolPk(SELF) || sigmaProp(orderFilled) && getPoolPk(SELF)
}
}
PART II Built-in-Wallet
To make the User Experience seemless we need to introduce a built in wallet. Open Source Exchange UI maintains a simple headless wallet that manages user balance based on his DEPOSIT, SWAP, BUY, SELL Boxes.
1 click swap order:
When user Bob creates a swap order (see example above), the following background process is executed:
- Bob sends a swap order request to CrystalPool
- CrystalPool creates a multisig transaction with a public commitment
- Bob receives an unsigned swap tx + commitment
- Bob’s built in wallet partially signs swap tx and sends his hints bag to CrystalPool
- CrystalPool signs swap tx and updates Bobs balance based on latest known unspent Boxes
KYA
All transactions are settled on L1 and thus require a transaction fee
CrystalPool maintains a public set of yet unsubmitted transactions
User balance is calculated in real time based on the latest known state of all unsubmitted transactions
CrystalPool cannot move user balance, each transaction requires userPK.
If CrystalPool service shuts down, the user can withdraw his deposits/oders after unlockHeight.
Riskvectors and metigations:
- Smart Contracts
potential bugs in DEPOSIT, SWAP, BUY, SELL can cause a loss of user funds
- Built-in-wallet
Note that Bob can run his own verified instance of the UI to protect himself against mallicios web interface updates. Bob also could enable explicit transaction signing either via built in or external wallet to increase his confidence/security. Bugs in the wallet/ui code are a potential risk factor.
- CrystalPool maintans unsubmited transactions
All new unsubmitted Transactions are publicly available, users can maintain a copy of those to metigate the risk of losing latest balance changes due to service shutting down for ever.
- Multisig spending path requires poolPK to move funds
If CrystalPool refuses to sign transactions at any point, at worst the user is forced to hodl until unlockHeight. After unlockHeight user is in full control of his funds.