diff --git a/eip-0014.md b/eip-0014.md new file mode 100644 index 00000000..ced1fc61 --- /dev/null +++ b/eip-0014.md @@ -0,0 +1,942 @@ +# Automated Decentralized Exchange + +* Authors: kushti, Ilya Oskin, scalahub +* Status: Proposed +* Created: 12-Mar-2021 +* Last edited: 31-May-2021 +* License: CC0 +* Track: Standards + +## Motivation + +Act of exchange without trusted parties is a most basic primitive for decentralized finance on top of blockchains. Thus contracts for that were introduced early, and basic single-chain swap contract was introduced early in the [ErgoScript whitepaper](https://ergoplatform.org/docs/ErgoScript.pdf). Then a lot of other order contracts appeared: with partial filling, buyback guarantee and so on. What is good for traders in decentralized worlds, such contracts are usually composable. +While swap order contracts allows for orderbook-based decentralized exchanges (DEXes), now popular AMM-based DEXes (where AMM stands for Automated Market Maker) are also possible on Ergo. +Interestingly, unlike other known blockchains, thanks to the extended UTXO model, liquidity pool contracts for AMM-based DEXes can be combined with order contracts (for orderbook-based DEXes). This gives unique possibility to have shared liquidity among different types of exchanges on top of the Ergo blockchain. + +This PR provides a description of the Automated Decentralized Exchange protocol on top of the Ergo. + +## Order-book DEX + +Orders are waiting for another orders to be matched, or for a refund command. There're the following three types of orders — "buy" (i.e. buy tokens with ERG), "sell" (i.e. sell tokens for ERG), or "swap" (buy non-ERG tokens with other non-ERG tokens) orders. Order-book DEX has the advantage of working best for those pairs with high liquidity. + +### Atomic orders + +Atomic orders can only be executed completely. Such orders can be either be aggregated by the ErgoDEX client so that users can choose from them or matched with partial orders which will be defined next. + +#### Buy order [ERG -> Token] + +```scala +{ + val quoteId = SELF.R4[Coll[Byte]].get // R4 - quote tokenId + val price = SELF.R5[Long].get // R5 - price per token + val feePerToken = SELF.R6[Long].get // R6 - fee per token + + val maybeRewardBox = OUTPUTS(0) + val isValidRewardProposition = maybeRewardBox.propositionBytes == pk.propBytes + val maybeRewardToken = maybeRewardBox.tokens(0) + + val rewardTokens = + if (isValidRewardProposition && maybeRewardToken._1 == quoteId) maybeRewardToken._2 + else 0L + + val feeCharged = rewardTokens * feePerToken + val isValidReward = (SELF.value.toBigInt - feeCharged) <= rewardTokens * price + + sigmaProp(pk || isValidReward) +} +``` + +#### Sell order [Token -> ERG] + +```scala +{ + val price = SELF.R5[Long].get // R5 - price per token + val feePerToken = SELF.R6[Long].get // R6 - fee per token + + val maybeRewardBox = OUTPUTS(0) + val isValidRewardProposition = maybeRewardBox.propositionBytes == pk.propBytes + + val ergs0 = SELF.value + val ergs1 = + if (isValidRewardProposition) maybeRewardBox.value + else 0L + + val deltaErgs = ergs1 - ergs0 + + val soldTokens = SELF.tokens(0)._2 + + val feeCharged = soldTokens * feePerToken + val isValidReward = deltaErgs.toBigInt >= soldTokens.toBigInt * price - feeCharged + + sigmaProp(pk || isValidReward) +} +``` + +#### Swap [TokenX -> TokenY] + +```scala +{ + val quoteAssetId = SELF.R4[Coll[Byte]].get // R4 - quote asset ID + val price = SELF.R5[Long].get // R5 - price per token + val feePerToken = SELF.R6[Long].get // R6 - fee per quote token + + val maybeRewardBox = OUTPUTS(0) + val maybeOutputQuoteAsset = maybeRewardBox.tokens(0) + val isValidRewardProposition = maybeRewardBox.propositionBytes == pk.propBytes + val isValidQuoteAsset = maybeOutputQuoteAsset._1 == quoteAssetId + + val ergs0 = SELF.value + val ergs1 = + if (isValidRewardProposition) maybeRewardBox.value + else 0L + + val baseInput = SELF.tokens(0)._2 + val quoteOutput = + if (isValidRewardProposition && isValidQuoteAsset) maybeOutputQuoteAsset._2 + else 0L + + val deltaErgs = ergs0 - ergs1 + + val isValidOutput = baseInput <= quoteOutput * price + val isValidFee = deltaErgs <= quoteOutput * feePerToken + + sigmaProp(pk || (isValidOutput && isValidFee)) +} +``` + +### Orders with partial filling support: + +Partial orders are something more familiar to those who've ever used classical CEX'es. These orders can be partially executed so the best way to work with them is an order-book, where they can be aggregated, matched and executed by ErgoDEX bots. + +#### Buy order [ERG -> Token] + +```scala +{ + val quoteId = SELF.R4[Coll[Byte]].get // R4 - quote tokenId + val price = SELF.R5[Long].get // R5 - price per token + val feePerToken = SELF.R6[Long].get // R6 - fee per token + + val maybeRewardBox = OUTPUTS(0) + val isValidRewardProposition = maybeRewardBox.propositionBytes == pk.propBytes + val maybeRewardToken = maybeRewardBox.tokens(0) + + val rewardTokens = + if (isValidRewardProposition && maybeRewardToken._1 == quoteId) maybeRewardToken._2 + else 0L + + val hasResidualBox = OUTPUTS.size > 1 + val maybeResidualBox = OUTPUTS(1) + val isValidResidualProposition = maybeResidualBox.propositionBytes == SELF.propositionBytes + val isValidResidualRegisters = + maybeResidualBox.R4[Coll[Byte]].get == quoteId && + maybeResidualBox.R5[Long].get == price && + maybeResidualBox.R6[Long].get == feePerToken + + val validResidualBoxExists = hasResidualBox && isValidResidualProposition && isValidResidualRegisters + + val leftErgs = + if (validResidualBoxExists) maybeResidualBox.value + else 0L + + val feeCharged = rewardTokens * feePerToken + val nanoErgsConsumed = SELF.value.toBigInt - feeCharged - leftErgs + val isValidReward = nanoErgsConsumed <= rewardTokens.toBigInt * price + + sigmaProp(pk || isValidReward) +} +``` + +#### Sell order [Token -> ERG] + +```scala +{ + val quoteAsset = SELF.tokens(0) + val price = SELF.R5[Long].get // R5 - price per token + val feePerToken = SELF.R6[Long].get // R6 - fee per token + + val maybeRewardBox = OUTPUTS(0) + val isValidRewardProposition = maybeRewardBox.propositionBytes == pk.propBytes + + val ergs0 = SELF.value + val ergs1 = + if (isValidRewardProposition) maybeRewardBox.value + else 0L + + val deltaErgs = ergs1 - ergs0 + + val hasResidualBox = OUTPUTS.size > 1 + val maybeResidualBox = OUTPUTS(1) + val maybeResidualAsset = maybeResidualBox.tokens(0) + val isValidResidualProposition = maybeResidualBox.propositionBytes == SELF.propositionBytes + val isValidResidualAsset = maybeResidualAsset._1 == quoteAsset._1 + val isValidResidualRegisters = + maybeResidualBox.R5[Long].get == price && + maybeResidualBox.R6[Long].get == feePerToken + + val validResidualBoxExists = hasResidualBox && isValidResidualProposition && isValidResidualAsset && isValidResidualRegisters + + val tokens0 = quoteAsset._2 + val tokens1 = + if (validResidualBoxExists) maybeResidualAsset._2 + else 0L + + val soldTokens = tokens0 - tokens1 + + val feeCharged = soldTokens * feePerToken + val isValidReward = deltaErgs.toBigInt >= soldTokens.toBigInt * price - feeCharged + + sigmaProp(pk || isValidReward) +} +``` + +### On-chain matching vs Off-chain + +It is not neccessary to publish orders on chain in order for them to be matched. ErgoDEX bots can synchronize orders off-chain, match them and only then execute in chained transactions. This approach allows to avoid committing cancelled orders on-chain. + +## Automated Liquidity Pools + +Unlike order-book based DEX which relies on an order-book to represent liquidity and determine prices AMM DEX uses an automated market maker mechanism to provide instant feedback on rates and slippage. AMM DEX suits best for pairs with low liquidity. + +Each AMM liquidity pool is a trading venue for a pair of assets. In order to facilitate trades a liquidity pool accepts deposits of underlying assets proportional to their price rates. Whenever deposit happens a proportional amount of unique tokens known as liquidity tokens is minted. Minted liquidity tokens are distributed among liquidity providers proportional to their deposits. Liquidity providers can later exchange their liquidity tokens share for a proportional amount of underlying reserves. + +## Economics of Ergo AMM DEX + +There are three types of economic agents in an AMM DEX ecosystem: +* DEXes (Parties which run DEX bots and UI) +* Liquidity providers (LPs) +* Traders + +Each agent type benefits from using DEX in their own way +* DEXes are earning fees from traders' swaps in ERGs +* LPs benefit from protocol fees paid in tokens and accumulated in liquidity pools +* Traders benefit from DEX services they use + +### Ergo AMM DEX Contracts [Arbitrary Pairs] + +Ergo AMM DEX relies on two types of contracts: + +- Pool contracts +- Swap contracts + +#### Pool contracts + +Pool contract ensures the following operations are performed according to protocol rules: + +- Depositing. An amount of LP tokens taken from LP reserves is proportional to an amount of underlying assets deposited. `LP = min(X_deposited * LP_supply / X_reserved, Y_deposited * LP_supply / Y_reserved)` +- Redemption. Amounts of underlying assets redeemed are proportional to an amount of LP tokens returned. `X_redeemed = LP_returned * X_reserved / LP_supply`, `Y_redeemed = LP_returned * Y_reserved / LP_supply` +- Swap. Tokens are exchanged at a price corresponding to a relation of a pair’s reserve balances while preserving constant product constraint (`CP = X_reserved * Y_reserved`). Correct amount of protocol fees is paid (0.03% currently). `X_output = X_reserved * Y_input * 997 / (Y_reserved * 1000 + Y_input * 997)` + +Variables: +- `X_deposited` - Amount of the first asset being deposited to a pool +- `Y_deposited` - Amount of the second asset being deposited to a pool +- `X_reserved` - Amount of the first asset locked in a pool +- `Y_reserved` - Amount of the second asset locked in a pool +- `LP_supply` - LP tokens circulating supply + +#### Tracking pool identity + +In order to preserve pool uniqueness a non-fungible token (NFT) is used. Then concrete pool can be identified by a unique NFT containing in pool UTXO. +Pool NFT is created at pool initialization stage. The pool bootstrapping contract ensures the NFT is issued while the main pool contract ensures its preservation along the whole lifecycle. + +#### Liquidity pool bootstrapping + +A liquidity pool is bootstrapped in two steps: + +1. In order to track pro-rata LP shares of the total reserves of a new pair a unique token called "LP token" must be issued. As soon as tokens can’t be re-issued on Ergo the whole LP emission has to be done at once. +2. Initial reserves of token pair and a remainder of LP tokens left after initial depositing are locked with the pool contract. Also pool NFT is issued. Thus the pool is created in an initial state. Correctness of the state can be checked off-chain at any time by querying the genesis transaction (i.e. the transaction which created the pool). This can be done easily using pool NFT pointing to the first input of that transaction. Note: Any unknown pool must be validated by a client before use, it's critical for security. + +Off-chain pool validation rules: +1. `emission(NFT) == 1` +2. `emission(LP) == K - Bi`, where `K` is predefined total `LP` supply, `Bi` is the amount of LP tokens to be burned initially. Initial LP burning is required in order to address so called "Donation Attack" - an attack when the smallest fraction of LP token becomes so overpriced, that small LPs can't provide liquidity to the pool anymore. +3. `sqrt(GenesisBox.tokens[X] * GenesisBox.tokens[Y]) >= K - GenesisBox.tokens[LP]` - initial depositing is done according to `S = sqrt(X_deposited * Y_deposited)`, where `S` is initial LP reward. + +``` + InitialInput#2 [X:N, Y:M] + 1. Issue LP tokens | + | + InitialInput#1 [LP:K] | + | | | + 2a. Reward LP | | 2b. Create pool | + | | | + LPRewardOut [LP:sqrt(N*M)] Pool [NFT:1, LP:K-sqrt(N*M), X:N, Y:M] +``` + +#### Schema of the pool UTXO + +Section | Description +----------|------------------------------------------------------ +value | Constant amount of ERGs +tokens[0] | Pool NFT +tokens[1] | LP token reserves +tokens[2] | Asset X +tokens[3] | Asset Y +R4[Long] | Fee multiplier numerator (e.g. 0.3% fee -> 997 fee_num) + +#### Pool contract + +```scala +{ + val InitiallyLockedLP = 0x7fffffffffffffffL + val FeeDenom = 1000 + + val ergs0 = SELF.value + val poolNFT0 = SELF.tokens(0) + val reservedLP0 = SELF.tokens(1) + val tokenX0 = SELF.tokens(2) + val tokenY0 = SELF.tokens(3) + + val successor = OUTPUTS(0) + + val feeNum0 = SELF.R4[Int].get + val feeNum1 = successor.R4[Int].get + + val ergs1 = successor.value + val poolNFT1 = successor.tokens(0) + val reservedLP1 = successor.tokens(1) + val tokenX1 = successor.tokens(2) + val tokenY1 = successor.tokens(3) + + val validSuccessorScript = successor.propositionBytes == SELF.propositionBytes + val preservedFeeConfig = feeNum1 == feeNum0 + val preservedErgs = ergs1 >= ergs0 + val preservedPoolNFT = poolNFT1 == poolNFT0 + val validLP = reservedLP1._1 == reservedLP0._1 + val validPair = tokenX1._1 == tokenX0._1 && tokenY1._1 == tokenY0._1 + // since tokens can be repeated, we ensure for sanity that there are no more tokens + val noMoreTokens = successor.tokens.size == 4 + + val supplyLP0 = InitiallyLockedLP - reservedLP0._2 + val supplyLP1 = InitiallyLockedLP - reservedLP1._2 + + val reservesX0 = tokenX0._2 + val reservesY0 = tokenY0._2 + val reservesX1 = tokenX1._2 + val reservesY1 = tokenY1._2 + + val deltaSupplyLP = supplyLP1 - supplyLP0 + val deltaReservesX = reservesX1 - reservesX0 + val deltaReservesY = reservesY1 - reservesY0 + + val validDepositing = { + val sharesUnlocked = min( + deltaReservesX.toBigInt * supplyLP0 / reservesX0, + deltaReservesY.toBigInt * supplyLP0 / reservesY0 + ) + deltaSupplyLP <= sharesUnlocked + } + + val validRedemption = { + val _deltaSupplyLP = deltaSupplyLP.toBigInt + // note: _deltaSupplyLP, deltaReservesX and deltaReservesY are negative + deltaReservesX.toBigInt * supplyLP0 >= _deltaSupplyLP * reservesX0 && deltaReservesY.toBigInt * supplyLP0 >= _deltaSupplyLP * reservesY0 + } + + val validSwap = + if (deltaReservesX > 0) + reservesY0.toBigInt * deltaReservesX * feeNum0 >= -deltaReservesY * (reservesX0.toBigInt * FeeDenom + deltaReservesX * feeNum0) + else + reservesX0.toBigInt * deltaReservesY * feeNum0 >= -deltaReservesX * (reservesY0.toBigInt * FeeDenom + deltaReservesY * feeNum0) + + val validAction = + if (deltaSupplyLP == 0) + validSwap + else + if (deltaReservesX > 0 && deltaReservesY > 0) validDepositing + else validRedemption + + sigmaProp( + validSuccessorScript && + preservedFeeConfig && + preservedErgs && + preservedPoolNFT && + validLP && + validPair && + noMoreTokens && + validAction + ) +} +``` + +#### Swap proxy-contract + +Swap contract ensures a swap is executed fairly from a user's perspective. The contract checks that: +* Assets are swapped at actual price derived from pool reserves. `X_output = X_reserved * Y_input * fee_num / (Y_reserved * 1000 + Y_input * fee_num)` +* Fair amount of DEX fee held in ERGs. `F = X_output * F_per_token` +* A minimal amount of quote asset received as an output in order to prevent front-running attacks. + +Once published swap contracts are tracked and executed by ErgoDEX bots automatically. Until a swap is executed it can be cancelled by a user who created it by simply spending the swap UTXO. + +##### Contract parameters: +Constant | Type | Description +--------------------|------------|--------------- +Pk | SigmaProp | User PublicKey +FeeNum | Long | Pool fee numerator (must taken from pool params) +QuoteId | Coll[Byte] | Quote asset ID +MinQuoteAmount | Long | Minimal amount of quote asset +DexFeePerTokenNum | Long | Numerator of the DEX fee in nanoERGs per one unit of quote asset +DexFeePerTokenDenom | Long | Denominator of the DEX fee in nanoERGs per one unit of quote asset +MaxMinerFee | Long | Max miner fee allowed at execution stage +PoolNFT | Coll[Byte] | ID of the pool NFT (Used as a reference to a concrete unique pool) +MinerPropBytes | Coll[Byte] | Miner script + +```scala +{ + val FeeDenom = 1000 + + val poolIn = INPUTS(0) + + val validTrade = + if (INPUTS.size == 2 && poolIn.tokens.size == 4) { + val base = SELF.tokens(0) + val baseId = base._1 + val baseAmount = base._2.toBigInt + + val poolNFT = poolIn.tokens(0)._1 + val poolAssetX = poolIn.tokens(2) + val poolAssetY = poolIn.tokens(3) + + val validPoolIn = poolNFT == PoolNFT + + val rewardBox = OUTPUTS(1) + val quoteAsset = rewardBox.tokens(0) + val quoteAmount = quoteAsset._2.toBigInt + val dexFee = quoteAmount * DexFeePerTokenNum / DexFeePerTokenDenom + val fairDexFee = rewardBox.value >= SELF.value - dexFee + val relaxedOutput = quoteAmount + 1L // handle rounding loss + val poolX = poolAssetX._2.toBigInt + val poolY = poolAssetY._2.toBigInt + val fairPrice = + if (poolAssetX._1 == QuoteId) + poolX * baseAmount * FeeNum <= relaxedOutput * (poolY * FeeDenom + baseAmount * FeeNum) + else + poolY * baseAmount * FeeNum <= relaxedOutput * (poolX * FeeDenom + baseAmount * FeeNum) + + val validMinerFee = OUTPUTS.map { (o: Box) => + if (o.propositionBytes == MinerPropBytes) o.value else 0L + }.fold(0L, { (a: Long, b: Long) => a + b }) <= MaxMinerFee + + validPoolIn && + rewardBox.propositionBytes == Pk.propBytes && + quoteAsset._1 == QuoteId && + quoteAsset._2 >= MinQuoteAmount && + fairDexFee && + fairPrice && + validMinerFee + } else false + + sigmaProp(Pk || validTrade) +} +``` + +#### Depositing proxy-contract + +Depositing contract ensures a liquidity provider gets fair amount of LP tokens. + +##### Contract parameters: +Constant | Type | Description +---------------|------------|--------------- +Pk | SigmaProp | User PublicKey +DexFee | Long | DEX fee in nanoERGs +PoolNFT | Coll[Byte] | Pool NFT ID +MaxMinerFee | Long | Max miner fee allowed at execution stage +MinerPropBytes | Coll[Byte] | Miner script + +```scala +{ + val InitiallyLockedLP = 0x7fffffffffffffffL + + val selfX = SELF.tokens(0) + val selfY = SELF.tokens(1) + + val poolIn = INPUTS(0) + + val validDeposit = + if (INPUTS.size == 2 && poolIn.tokens.size == 4) { + val validPoolIn = poolIn.tokens(0) == (PoolNFT, 1L) + + val poolLP = poolIn.tokens(1) + val reservesX = poolIn.tokens(2) + val reservesY = poolIn.tokens(3) + + val reservesXAmount = reservesX._2 + val reservesYAmount = reservesY._2 + + val supplyLP = InitiallyLockedLP - poolLP._2 + + val minByX = selfX._2.toBigInt * supplyLP / reservesXAmount + val minByY = selfY._2.toBigInt * supplyLP / reservesYAmount + + val minimalReward = min(minByX, minByY) + + val rewardOut = OUTPUTS(1) + val rewardLP = rewardOut.tokens(0) + + val validErgChange = rewardOut.value >= SELF.value - DexFee + + val validTokenChange = + if (minByX < minByY && rewardOut.tokens.size == 2) { + val diff = minByY - minByX + val excessY = diff * reservesYAmount / supplyLP + + val changeY = rewardOut.tokens(1) + + changeY._1 == reservesY._1 && + changeY._2 >= excessY + } else if (minByX > minByY && rewardOut.tokens.size == 2) { + val diff = minByX - minByY + val excessX = diff * reservesXAmount / supplyLP + + val changeX = rewardOut.tokens(1) + + changeX._1 == reservesX._1 && + changeX._2 >= excessX + } else if (minByX == minByY) { + true + } else { + false + } + + val validMinerFee = OUTPUTS.map { (o: Box) => + if (o.propositionBytes == MinerPropBytes) o.value else 0L + }.fold(0L, { (a: Long, b: Long) => a + b }) <= MaxMinerFee + + validPoolIn && + rewardOut.propositionBytes == Pk.propBytes && + validErgChange && + validTokenChange && + rewardLP._1 == poolLP._1 && + rewardLP._2 >= minimalReward && + validMinerFee + } else false + + sigmaProp(Pk || validDeposit) +} +``` + +#### Redemption proxy-contract + +Redemption contract ensures a liquidity provider gets fair amount of liquidity for LP tokens in exchange. + +##### Contract parameters: +Constant | Type | Description +---------------|------------|--------------- +Pk | SigmaProp | User PublicKey +DexFee | Long | DEX fee in nanoERGs +PoolNFT | Coll[Byte] | Pool NFT ID +MaxMinerFee | Long | Max miner fee allowed at execution stage +MinerPropBytes | Coll[Byte] | Miner script + +```scala +{ + val InitiallyLockedLP = 0x7fffffffffffffffL + + val selfLP = SELF.tokens(0) + + val poolIn = INPUTS(0) + + val validRedeem = + if (INPUTS.size == 2 && poolIn.tokens.size == 4) { + val validPoolIn = poolIn.tokens(0) == (PoolNFT, 1L) + + val poolLP = poolIn.tokens(1) + val reservesX = poolIn.tokens(2) + val reservesY = poolIn.tokens(3) + + val supplyLP = InitiallyLockedLP - poolLP._2 + + val minReturnX = selfLP._2.toBigInt * reservesX._2 / supplyLP + val minReturnY = selfLP._2.toBigInt * reservesY._2 / supplyLP + + val returnOut = OUTPUTS(1) + + val returnX = returnOut.tokens(0) + val returnY = returnOut.tokens(1) + + val validMinerFee = OUTPUTS.map { (o: Box) => + if (o.propositionBytes == MinerPropBytes) o.value else 0L + }.fold(0L, { (a: Long, b: Long) => a + b }) <= MaxMinerFee + + validPoolIn && + returnOut.propositionBytes == Pk.propBytes && + returnOut.value >= SELF.value - DexFee && + returnX._1 == reservesX._1 && + returnY._1 == reservesY._1 && + returnX._2 >= minReturnX && + returnY._2 >= minReturnY && + validMinerFee + } else false + + sigmaProp(Pk || validRedeem) +} +``` + +### Ergo AMM DEX Contracts [Ergo to Token] + +The Ergo-to-token or the native-to-token (N2T) exchange is an exchange between Ergo's native token (nanoErgs) and some other token. +There are two approaches to create a N2T exchange: +1. Use a T2T (token-to-token) exchange, where one of the tokens maps to Ergs and have a separate dApp that exchanges Ergs to tokens at 1:1 rate. +2. Implement N2T directly in the exchange contract. Here we use this approach. + +#### Pool contracts + +The following is the modified pool contract representing a Liquidity Pool of the N2T AMM DEX. +As before, the pool contract ensures the following operations are performed according to protocol rules: + +- Depositing. An amount of LP tokens taken from LP reserves is proportional to an amount of underlying assets deposited. `LP = min(X_deposited * LP_supply / X_reserved, Y_deposited * LP_supply / Y_reserved)` +- Redemption. Amounts of underlying assets redeemed are proportional to an amount of LP tokens returned. `X_redeemed = LP_returned * X_reserved / LP_supply`, `Y_redeemed = LP_returned * Y_reserved / LP_supply` +- Swap. Tokens are exchanged at a price corresponding to a relation of a pair’s reserve balances while preserving constant product constraint (`CP = X_reserved * Y_reserved`). Correct amount of protocol fees is paid (0.03% currently). `X_output = X_reserved * Y_input * 997 / (Y_reserved * 1000 + Y_input * 997)` + +Variables: +- `X_deposited` - Amount of the first asset (nanoErgs) being deposited to the pool box +- `Y_deposited` - Amount of the second asset being deposited to the pool box +- `X_reserved` - Amount of the first asset (nanoErgs) locked in the pool box +- `Y_reserved` - Amount of the second asset locked in the pool box +- `LP_supply` - LP tokens circulating supply corresponding to the pool box + +#### Schema of the pool UTXO + +Section | Description +----------|------------------------------------------------------ +value | Asset X reserves (nanoErgs) +tokens[0] | Pool NFT +tokens[1] | Locked LP tokens +tokens[2] | Asset Y reserves +R4[Long] | Fee multiplier numerator (e.g. 0.3% fee -> 997 fee_num). This represents the *non-fee* part of the sold asset + +#### Pool contract (N2T) + +```scala +{ + val InitiallyLockedLP = 0x7fffffffffffffffL + val FeeDenom = 1000 + val minStorageRent = 10000000L // this many number of nanoErgs are going to be permanently locked + + val poolNFT0 = SELF.tokens(0) + val reservedLP0 = SELF.tokens(1) + val tokenY0 = SELF.tokens(2) + + val successor = OUTPUTS(0) + + val feeNum0 = SELF.R4[Int].get + val feeNum1 = successor.R4[Int].get + + val poolNFT1 = successor.tokens(0) + val reservedLP1 = successor.tokens(1) + val tokenY1 = successor.tokens(2) + + val validSuccessorScript = successor.propositionBytes == SELF.propositionBytes + val preservedFeeConfig = feeNum1 == feeNum0 + + val preservedPoolNFT = poolNFT1 == poolNFT0 + val validLP = reservedLP1._1 == reservedLP0._1 + val validY = tokenY1._1 == tokenY0._1 + // since tokens can be repeated, we ensure for sanity that there are no more tokens + val noMoreTokens = successor.tokens.size == 3 + + val validStorageRent = successor.value > minStorageRent + + val supplyLP0 = InitiallyLockedLP - reservedLP0._2 + val supplyLP1 = InitiallyLockedLP - reservedLP1._2 + + val reservesX0 = SELF.value + val reservesY0 = tokenY0._2 + val reservesX1 = successor.value + val reservesY1 = tokenY1._2 + + val deltaSupplyLP = supplyLP1 - supplyLP0 + val deltaReservesX = reservesX1 - reservesX0 + val deltaReservesY = reservesY1 - reservesY0 + + val validDepositing = { + val sharesUnlocked = min( + deltaReservesX.toBigInt * supplyLP0 / reservesX0, + deltaReservesY.toBigInt * supplyLP0 / reservesY0 + ) + deltaSupplyLP <= sharesUnlocked + } + + val validRedemption = { + val _deltaSupplyLP = deltaSupplyLP.toBigInt + // note: _deltaSupplyLP, deltaReservesX and deltaReservesY are negative + deltaReservesX.toBigInt * supplyLP0 >= _deltaSupplyLP * reservesX0 && deltaReservesY.toBigInt * supplyLP0 >= _deltaSupplyLP * reservesY0 + } + + val validSwap = + if (deltaReservesX > 0) + reservesY0.toBigInt * deltaReservesX * feeNum0 >= -deltaReservesY * (reservesX0.toBigInt * FeeDenom + deltaReservesX * feeNum0) + else + reservesX0.toBigInt * deltaReservesY * feeNum0 >= -deltaReservesX * (reservesY0.toBigInt * FeeDenom + deltaReservesY * feeNum0) + + val validAction = + if (deltaSupplyLP == 0) + validSwap + else + if (deltaReservesX > 0 && deltaReservesY > 0) validDepositing + else validRedemption + + sigmaProp( + validSuccessorScript && + preservedFeeConfig && + preservedPoolNFT && + validLP && + validY && + noMoreTokens && + validAction && + validStorageRent + ) +} +``` + +#### Swap proxy-contract (N2T) + +Swap contract ensures a swap is executed fairly from a user's perspective. The contract checks that: +* Assets are swapped at actual price derived from pool reserves. `X_output = X_reserved * Y_input * fee_num / (Y_reserved * 1000 + Y_input * fee_num)` +* Fair amount of DEX fee held in ERGs. `F = X_output * F_per_token` +* A minimal amount of quote asset received as an output in order to prevent front-running attacks. + +Once published swap contracts are tracked and executed by ErgoDEX bots automatically. +Until a swap is executed, it can be cancelled by a user who created it by simply spending the swap UTXO. + +##### Sell Ergs +###### Contract parameters +Constant | Type | Description +--------------------|------------|--------------- +Pk | SigmaProp | User PublicKey +FeeNum | Long | Pool fee numerator (must be taken from pool params) +QuoteId | Coll[Byte] | Quote asset ID. This is the asset we are buying from the pool +MinQuoteAmount | Long | Minimal amount of quote asset +BaseAmount | Long | The amount of nanoErgs to sell +DexFeePerTokenNum | Long | Numerator of the DEX fee in nanoERGs per one unit of quote asset +DexFeePerTokenDenom | Long | Denominator of the DEX fee in nanoERGs per one unit of quote asset +MaxMinerFee | Long | Max miner fee allowed at execution stage +PoolNFT | Coll[Byte] | ID of the pool NFT (Used as a reference to a concrete unique pool) +MinerPropBytes | Coll[Byte] | Miner script + +```scala +{ // ERG -> Token + val FeeDenom = 1000 + + val poolIn = INPUTS(0) + + val validTrade = + if (INPUTS.size == 2 && poolIn.tokens.size == 3) { + val poolNFT = poolIn.tokens(0)._1 + + val poolY = poolIn.tokens(2) + + val poolReservesX = poolIn.value.toBigInt + val poolReservesY = poolY._2.toBigInt + val validPoolIn = poolNFT == PoolNFT + + val rewardBox = OUTPUTS(1) + + val quoteAsset = rewardBox.tokens(0) + val quoteAmount = quoteAsset._2.toBigInt + + val fairDexFee = rewardBox.value >= SELF.value - quoteAmount * DexFeePerTokenNum / DexFeePerTokenDenom - BaseAmount + + val relaxedOutput = quoteAmount + 1 // handle rounding loss + val fairPrice = poolReservesY * BaseAmount * FeeNum <= relaxedOutput * (poolReservesX * FeeDenom + BaseAmount * FeeNum) + + val validMinerFee = OUTPUTS.map { (o: Box) => + if (o.propositionBytes == MinerPropBytes) o.value else 0L + }.fold(0L, { (a: Long, b: Long) => a + b }) <= MaxMinerFee + + validPoolIn && + rewardBox.propositionBytes == Pk.propBytes && + quoteAsset._1 == QuoteId && + quoteAmount >= MinQuoteAmount && + fairDexFee && + fairPrice && + validMinerFee + } else false + + sigmaProp(Pk || validTrade) +} +``` + +##### Sell Tokens + +###### Contract parameters: +Constant | Type | Description +--------------------|------------|--------------- +Pk | SigmaProp | User PublicKey +FeeNum | Long | Pool fee numerator (must taken from pool params) +MinQuoteAmount | Long | Minimal amount of quote asset +DexFeePerTokenNum | Long | Numerator of the DEX fee in nanoERGs per one unit of quote asset +DexFeePerTokenDenom | Long | Denominator of the DEX fee in nanoERGs per one unit of quote asset +MaxMinerFee | Long | Max miner fee allowed at execution stage +PoolNFT | Coll[Byte] | ID of the pool NFT (Used as a reference to a concrete unique pool) +MinerPropBytes | Coll[Byte] | Miner script + +```scala +{ // Token -> ERG + val FeeDenom = 1000 + + val poolIn = INPUTS(0) + + val validTrade = + if (INPUTS.size == 2 && poolIn.tokens.size == 3) { + val baseAmount = SELF.tokens(0)._2 + + val poolNFT = poolIn.tokens(0)._1 // first token id is NFT + + val poolReservesX = poolIn.value.toBigInt // nanoErgs is X asset amount + val poolReservesY = poolIn.tokens(2)._2.toBigInt // third token amount is Y asset amount + + val validPoolIn = poolNFT == PoolNFT + + val rewardBox = OUTPUTS(1) + + val deltaNErgs = rewardBox.value - SELF.value // this is quoteAmount - fee + val quoteAmount = deltaNErgs.toBigInt * DexFeePerTokenDenom / (DexFeePerTokenDenom - DexFeePerTokenNum) + val relaxedOutput = quoteAmount + 1 // handle rounding loss + val fairPrice = poolReservesX * baseAmount * FeeNum <= relaxedOutput * (poolReservesY * FeeDenom + baseAmount * FeeNum) + + val validMinerFee = OUTPUTS.map { (o: Box) => + if (o.propositionBytes == MinerPropBytes) o.value else 0L + }.fold(0L, { (a: Long, b: Long) => a + b }) <= MaxMinerFee + + validPoolIn && + rewardBox.propositionBytes == Pk.propBytes && + quoteAmount >= MinQuoteAmount && + fairPrice && + validMinerFee + } else false + + sigmaProp(Pk || validTrade) +} +``` +#### Depositing proxy-contract (N2T) + +Depositing contract ensures a liquidity provider gets fair amount of LP tokens. + +##### Contract parameters: +Constant | Type | Description +---------------|------------|--------------- +Pk | SigmaProp | User PublicKey +DexFee | Long | DEX fee in nanoERGs +PoolNFT | Coll[Byte] | Pool NFT ID +SelfXAmount | Long | NanoErgs to deposit +MaxMinerFee | Long | Max miner fee allowed at execution stage +MinerPropBytes | Coll[Byte] | Miner script + +```scala +{ + val InitiallyLockedLP = 0x7fffffffffffffffL + + val poolIn = INPUTS(0) + + val validDeposit = + if (INPUTS.size == 2 && poolIn.tokens.size == 3) { + val selfY = SELF.tokens(0) + + val validPoolIn = poolIn.tokens(0)._1 == PoolNFT + + val poolLP = poolIn.tokens(1) + val reservesXAmount = poolIn.value + val reservesY = poolIn.tokens(2) + val reservesYAmount = reservesY._2 + + val supplyLP = InitiallyLockedLP - poolLP._2 + + val _selfX = SelfX + + val minByX = _selfX.toBigInt * supplyLP / reservesXAmount + val minByY = selfY._2.toBigInt * supplyLP / reservesYAmount + + val minimalReward = min(minByX, minByY) + + val rewardOut = OUTPUTS(1) + val rewardLP = rewardOut.tokens(0) + + val validChange = + if (minByX < minByY && rewardOut.tokens.size == 2) { + val diff = minByY - minByX + val excessY = diff * reservesYAmount / supplyLP + + val changeY = rewardOut.tokens(1) + + rewardOut.value >= SELF.value - DexFee - _selfX && + changeY._1 == reservesY._1 && + changeY._2 >= excessY + } else if (minByX >= minByY) { + val diff = minByX - minByY + val excessX = diff * reservesXAmount / supplyLP + + rewardOut.value >= SELF.value - DexFee - (_selfX - excessX) + } else { + false + } + + val validMinerFee = OUTPUTS.map { (o: Box) => + if (o.propositionBytes == MinerPropBytes) o.value else 0L + }.fold(0L, { (a: Long, b: Long) => a + b }) <= MaxMinerFee + + validPoolIn && + rewardOut.propositionBytes == Pk.propBytes && + validChange && + rewardLP._1 == poolLP._1 && + rewardLP._2 >= minimalReward && + validMinerFee + } else false + + sigmaProp(Pk || validDeposit) +} +``` + +#### Redemption proxy-contract (N2T) + +Redemption contract ensures a liquidity provider gets fair amount of liquidity for LP tokens in exchange. + +##### Contract parameters: +Constant | Type | Description +---------------|------------|--------------- +Pk | SigmaProp | User PublicKey +DexFee | Long | DEX fee in nanoERGs +PoolNFT | Coll[Byte] | Pool NFT ID +MaxMinerFee | Long | Max miner fee allowed at execution stage +MinerPropBytes | Coll[Byte] | Miner script + +```scala +{ + val InitiallyLockedLP = 0x7fffffffffffffffL + + val poolIn = INPUTS(0) + + val validRedeem = + if (INPUTS.size == 2 && poolIn.tokens.size == 3) { + val selfLP = SELF.tokens(0) + + val validPoolIn = poolIn.tokens(0)._1 == PoolNFT + + val poolLP = poolIn.tokens(1) + val reservesXAmount = poolIn.value + val reservesY = poolIn.tokens(2) + + val supplyLP = InitiallyLockedLP - poolLP._2 + + val minReturnX = selfLP._2.toBigInt * reservesXAmount / supplyLP + val minReturnY = selfLP._2.toBigInt * reservesY._2 / supplyLP + + val returnOut = OUTPUTS(1) + + val returnXAmount = returnOut.value - SELF.value + DexFee + val returnY = returnOut.tokens(0) + + val validMinerFee = OUTPUTS.map { (o: Box) => + if (o.propositionBytes == MinerPropBytes) o.value else 0L + }.fold(0L, { (a: Long, b: Long) => a + b }) <= MaxMinerFee + + validPoolIn && + returnOut.propositionBytes == Pk.propBytes && + returnY._1 == reservesY._1 && // token id matches + returnXAmount >= minReturnX && + returnY._2 >= minReturnY && + validMinerFee + } else false + + sigmaProp(Pk || validRedeem) +} +``` +