diff --git a/README.md b/README.md index bab35b9f..a7b57959 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,4 @@ Please check out existing EIPs, such as [EIP-1](eip-0001.md), to understand the | [EIP-0022](eip-0022.md) | Auction Contract | | [EIP-0024](eip-0024.md) | Artwork contract | | [EIP-0025](eip-0025.md) | Payment Request URI | +| [EIP-0030](eip-0030.md) | Dexy StableCoin design | diff --git a/eip-0030.md b/eip-0030.md new file mode 100644 index 00000000..c8f4f9b9 --- /dev/null +++ b/eip-0030.md @@ -0,0 +1,526 @@ +# Dexy StableCoin + +* Author: @kushti, @scalahub, @code-for-uss +* Status: Proposed +* Created: 20-April-2022 +* License: CC0 +* Forking: not needed + +## Description + +This EIP defines a design for a stablecoin called "Dexy", first proposed by @kushti. +Dexy uses a combination of oracle-pool and a liquidity pool. + +Below are the main aspects of Dexy. + +1. **One-way tethering**: There is a Bank that emits Dexy tokens (example DexyUSD) in a one-way swap using the oracle pool rate. + The swap is one-way in the sense that we can only buy Dexy tokens by selling ergs to the box. We cannot do the reverse swap. + This one-way swap is called a "mint" operation, and is captured using the *Free Mint* contract. + +2. **Liquidity Pool**: The reverse swap, selling of Dexy tokens, is done via a Liquidity Pool (LP) which also permits buying Dexy tokens. The LP + primarily uses the logic of UniSwap V2. The difference is that the LP also takes as input the oracle pool rate and uses that to modify certain logic. In particular, + redeeming of LP tokens is not allowed when the oracle pool rate is below a certain percent (say 90%) of the LP rate. + +3. In case the oracle pool rate is higher than LP rate, then traders can do arbitrage by minting Dexy tokens from the emission box and + selling them to the LP. This is captured using the *Arbitrage Mint* contract. + +4. In case the oracle pool rate is lower than LP rate, then the Ergs collected in the emission box can be used to bring the rate back up by performing a swap. + We call this the operation an *Intervention*. + +The intervention logic is encoded in a **intervention** contract. This logic allows anyone to swap Ergs for DexyUSD (i.e., it allows the intervention contract to buy DexyUSD from the LP). +The intervention is possible only under certain conditions: (1) The oracle pool rate is lower than LP rate, and (2) It remains so for a certain number of blocks. +This is done with the help of register R5 of the LP box (see below), which stores the height at which the oracle rate dropped below the LP rate. +The remaining part of the contract replicates the LP's swap logic to ensure that the correct amount is exchanged. + +The LP uses a "cross-tracker" to keep track of the height at which the oracle pool rate dropped below the LP rate after a swap, that is +the height at which the oracle pool rate was above the LP-box in rate but was below the LP-box out rate. +When this even happens, R5 of the LP box will store the height (within an error margin) at which this happened. +If the oracle pool rate again becomes higher than the LP rate, the register is set to Long.MaxValue (9223372036854775807). +This register can only be changed when the oracle pool and LP rates cross each other during an exchange. At all other times +this register must be preserved. + +The intervention contract looks at R5 of the LP box and if the value is below a certain threshold (say 50 blocks) then the swap is allowed. +This implies that a swap is valid only when the oracle pool rate falls below the LP rate and stays below for at least 50 blocks. + +## Bank Contract + +```scala +{ + // This box: (dexyUSD bank box) + // tokens(0): bankNFT identifying the box + // tokens(1): dexyUSD tokens to be emitted + + // Bank box will be spent as follows + // Arbitrage Mint + // Input | Output | Data-Input + // ------------------------------------------------ + // 0 ArbitrageMint | ArbitrageMint | Oracle + // 1 Bank | Bank | LP + + // Free Mint + // Input | Output | Data-Input + // ------------------------------------- + // 0 FreeMint | FreeMint | Oracle + // 1 Bank | Bank | LP + + // Intervention + // Input | Output | Data-Input + // ----------------------------------------------- + // 0 LP | LP | Oracle + // 1 Bank | Bank | + // 2 Intervention | Intervention | + + val selfOutIndex = 1 // 2nd output is self copy + val mintInIndex = 0 // 1st input is mint or LP box + val interventionInIndex = 2 // 3rd input is intervention box + + val interventionNFT = fromBase64("Fho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify intervention box for future use + val freeMintNFT = fromBase64("Bho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") + val arbitrageMintNFT = fromBase64("lho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") + + val selfOut = OUTPUTS(selfOutIndex) + val validSelfOut = selfOut.tokens(0) == SELF.tokens(0) && // bankNFT and quantity preserved + selfOut.propositionBytes == SELF.propositionBytes && // script preserved + selfOut.tokens(1)._1 == SELF.tokens(1)._1 // dexyUSD tokenId preserved + + val validMint = INPUTS(mintInIndex).tokens(0)._1 == freeMintNFT || + INPUTS(mintInIndex).tokens(0)._1 == arbitrageMintNFT + + val validIntervention = INPUTS(interventionInIndex).tokens(0)._1 == interventionNFT + + sigmaProp(validSelfOut && (validMint || validIntervention)) +} +``` + +## Free Mint Contract +```scala +{ // ToDo: Add fee + // + // this box: (free-mint box) + // tokens(0): Free-mint NFT + // + // R4: (Int) height at which counter will reset + // R5: (Long) remaining stablecoins available to be purchased before counter is reset + + // Free Mint box will be spent as follows: + // Free Mint + // Input | Output | Data-Input + // ------------------------------------- + // 0 FreeMint | FreeMint | Oracle + // 1 Bank | Bank | LP + + val bankInIndex = 1 + val selfOutIndex = 0 + val bankOutIndex = 1 + val oracleBoxIndex = 0 + val lpBoxIndex = 1 + + val oracleNFT = fromBase64("RytLYlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify oracle pool box + val bankNFT = fromBase64("hho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") + val lpNFT = fromBase64("Nho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") + val t_free = 100 + + val feeNum = 10 + val feeDenom = 1000 + // actual fee ratio is feeNum / feeDenom + // example if feeNum = 10 and feeDenom = 1000 then fee = 0.01 = 1 % + + val oracleBox = CONTEXT.dataInputs(oracleBoxIndex) // oracle-pool (v1 and v2) box containing rate in R4 + val lpBox = CONTEXT.dataInputs(lpBoxIndex) + val bankBoxIn = INPUTS(bankInIndex) + + val selfOut = OUTPUTS(selfOutIndex) + val bankBoxOut = OUTPUTS(bankOutIndex) + + val selfInR4 = SELF.R4[Int].get + val selfInR5 = SELF.R5[Long].get + val selfOutR4 = selfOut.R4[Int].get + val selfOutR5 = selfOut.R5[Long].get + + val isCounterReset = HEIGHT > selfInR4 + + val oracleRateWithoutFee = oracleBox.R4[Long].get // can assume always > 0 (ref oracle pool contracts) NanoErgs per USD + val oracleRate = oracleRateWithoutFee * (feeNum + feeDenom) / feeDenom + + val lpReservesX = lpBox.value + val lpReservesY = lpBox.tokens(2)._2 // dexyReserves + val lpRate = lpReservesX / lpReservesY + + val validRateFreeMint = 98 * lpRate < oracleRate * 100 && + oracleRate * 100 < 102 * lpRate + + val dexyMinted = bankBoxIn.tokens(1)._2 - bankBoxOut.tokens(1)._2 + val ergsAdded = bankBoxOut.value - bankBoxIn.value + val validDelta = ergsAdded >= dexyMinted * oracleRate && ergsAdded > 0 // dexyMinted must be (+)ve, since both ergsAdded and oracleRate are (+)ve + + val maxAllowedIfReset = lpReservesY / 100 + + val availableToMint = if (isCounterReset) maxAllowedIfReset else selfInR5 + + val validAmount = dexyMinted <= availableToMint + + val validSelfOutR4 = selfOutR4 == (if (isCounterReset) HEIGHT + t_free else selfInR4) + val validSelfOutR5 = selfOutR5 == availableToMint - dexyMinted + + val validBankBoxInOut = bankBoxIn.tokens(0)._1 == bankNFT && bankBoxOut.tokens(0)._1 == bankNFT + val validLpBox = lpBox.tokens(0)._1 == lpNFT + val validOracleBox = oracleBox.tokens(0)._1 == oracleNFT + val validSelfOut = selfOut.tokens == SELF.tokens && // NFT preserved + selfOut.propositionBytes == SELF.propositionBytes && // script preserved + selfOut.value > SELF.value && validSelfOutR5 && validSelfOutR4 + + sigmaProp(validAmount && validBankBoxInOut && validLpBox && validOracleBox && validSelfOut && validDelta && validRateFreeMint) +} + +``` + +## Arbitrage Mint Contract +```scala +{ + + // this box: (arbitrage-mint box) + // tokens(0): Arbitrage-mint NFT + // + // R4: (Int) height at which counter will reset + // R5: (Long) remaining stablecoins available to be purchased before counter is reset + + // Arbitrage Mint box will be spent as follows + // Arbitrage Mint + // Input | Output | Data-Input + // ------------------------------------------------ + // 0 ArbitrageMint | ArbitrageMint | Oracle + // 1 Bank | Bank | LP + + val bankInIndex = 1 + val selfOutIndex = 0 + val bankOutIndex = 1 + val oracleBoxIndex = 0 + val lpBoxIndex = 1 + + val oracleNFT = fromBase64("RytLYlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify oracle pool box + val bankNFT = fromBase64("hho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") + val lpNFT = fromBase64("Nho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") + val T_arb = 30 // 30 blocks = 1 hour + val thresholdPercent = 101 // 101% or more value (of LP in terms of OraclePool) will trigger action + + val feeNum = 5 + val feeDenom = 1000 + // actual fee ratio is feeNum / feeDenom + // example if feeNum = 5 and feeDenom = 1000 then fee = 0.005 = 0.5 % + + val oracleBox = CONTEXT.dataInputs(oracleBoxIndex) // oracle-pool (v1 and v2) box containing rate in R4 + val lpBox = CONTEXT.dataInputs(lpBoxIndex) + val bankBoxIn = INPUTS(bankInIndex) + + val selfOut = OUTPUTS(selfOutIndex) + val bankBoxOut = OUTPUTS(bankOutIndex) + + val selfInR4 = SELF.R4[Int].get + val selfInR5 = SELF.R5[Long].get + val selfOutR4 = selfOut.R4[Int].get + val selfOutR5 = selfOut.R5[Long].get + + val isCounterReset = HEIGHT > selfInR4 + + val oracleRateWithoutFee = oracleBox.R4[Long].get // can assume always > 0 (ref oracle pool contracts) NanoErgs per USD + val oracleRate = oracleRateWithoutFee * (feeNum + feeDenom) / feeDenom + + val lpReservesX = lpBox.value + val lpReservesY = lpBox.tokens(2)._2 // dexyReserves + val lpRate = lpReservesX / lpReservesY + + val dexyMinted = bankBoxIn.tokens(1)._2 - bankBoxOut.tokens(1)._2 + val ergsAdded = bankBoxOut.value - bankBoxIn.value + val validDelta = ergsAdded >= dexyMinted * oracleRate && ergsAdded > 0 // dexyMinted must be (+)ve, since both ergsAdded and oracleRate are (+)ve + + val maxAllowedIfReset = (lpReservesX - oracleRate * lpReservesY) / oracleRate + + // above formula: + // Before mint rate is lpReservesX / lpReservesY, which should be greater than oracleRate + // After mint rate is lpReservesX / (lpReservesY + dexyMinted), which should be same or less than than oracleRate + // Thus: + // lpReservesX / lpReservesY > oracleRate + // lpReservesX / (lpReservesY + dexyMinted) <= oracleRate + // above gives min value of dexyMinted = (lpReservesX - oracleRate * lpReservesY) / oracleRate + + val availableToMint = if (isCounterReset) maxAllowedIfReset else selfInR5 + + val validAmount = dexyMinted <= availableToMint + + val validSelfOutR4 = selfOutR4 == (if (isCounterReset) HEIGHT + T_arb else selfInR4) + val validSelfOutR5 = selfOutR5 == availableToMint - dexyMinted + + val validBankBoxInOut = bankBoxIn.tokens(0)._1 == bankNFT && bankBoxOut.tokens(0)._1 == bankNFT + val validLpBox = lpBox.tokens(0)._1 == lpNFT + val validOracleBox = oracleBox.tokens(0)._1 == oracleNFT + val validSelfOut = selfOut.tokens == SELF.tokens && // NFT preserved + selfOut.propositionBytes == SELF.propositionBytes && // script preserved + selfOut.value > SELF.value && validSelfOutR5 && validSelfOutR4 + + val validDelay = lpBox.R5[Int].get < HEIGHT - T_arb // at least T_arb blocks have passed since the tracking started + val validThreshold = lpRate * 100 > thresholdPercent * oracleRate + + sigmaProp(validDelay && validThreshold && validAmount && validBankBoxInOut && validLpBox && validOracleBox && validSelfOut && validDelta) +} +``` +## Liquidity Pool Contract +```scala +{ + // Notation: + // + // X is the primary token + // Y is the secondary token + // When using Erg-USD oracle v1, X is NanoErg and Y is USD + + // This box: (LP box) + // R1 (value): X tokens in NanoErgs + // R4: How many LP in circulation (long). This can be non-zero when bootstrapping, to consider the initial token burning in UniSwap v2 + // R5: Stores the height where oracle pool rate becomes lower than LP rate. Reset to Long.MaxValue when rate crossed back. Called crossTrackerLow + // R6: Stores the height where oracle pool rate becomes higher than LP rate. Reset to Long.MaxValue when rate crossed back. Called crossTrackerHigh + // Tokens(0): LP NFT to uniquely identify NFT box. (Could we possibly do away with this?) + // Tokens(1): LP tokens + // Tokens(2): Y tokens (Note that X tokens are NanoErgs (the value) + // + // Data Input #0: (oracle pool box) + // R4: Rate in units of X per unit of Y + // Token(0): OP NFT to uniquely identify Oracle Pool + + // LP box will be spent as follows + // Intervention + // Input | Output | Data-Input + // ----------------------------------------------- + // 0 LP | LP | Oracle + // 1 Bank | Bank | + // 2 Intervention | Intervention | + + // Swap + // Input | Output | Data-Input + // ----------------------------------------------- + // 0 LP | LP | Oracle + + // Redeem LP tokens + // Input | Output | Data-Input + // ----------------------------------------------- + // 0 LP | LP | Oracle + + // Mint LP tokens + // Input | Output | Data-Input + // ----------------------------------------------- + // 0 LP | LP | Oracle + + val selfOutIndex = 0 + val oracleBoxIndex = 0 + + // constants + val threshold = 3 // error threshold in crossTrackerLow + val feeNum = 3 // 0.3 % if feeDenom is 1000 + val feeDenom = 1000 + + // the value feeNum / feeDenom is the fraction of fee + // for example if feeNum = 3 and feeDenom = 1000 then fee is 0.003 = 0.3% + + val minStorageRent = 10000000L // this many number of nanoErgs are going to be permanently locked + + val successor = OUTPUTS(selfOutIndex) // copy of this box after exchange + val oracleBox = CONTEXT.dataInputs(oracleBoxIndex) // oracle pool box + val validOraclePoolBox = oracleBox.tokens(0)._1 == fromBase64("RytLYlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify oracle pool box + + val lpNFT0 = SELF.tokens(0) + val reservedLP0 = SELF.tokens(1) + val tokenY0 = SELF.tokens(2) + + val lpNFT1 = successor.tokens(0) + val reservedLP1 = successor.tokens(1) + val tokenY1 = successor.tokens(2) + + val supplyLP0 = SELF.R4[Long].get // LP tokens in circulation in input LP box + val supplyLP1 = successor.R4[Long].get // LP tokens in circulation in output LP box + + val validSuccessorScript = successor.propositionBytes == SELF.propositionBytes + + val preservedLpNFT = lpNFT1 == lpNFT0 + val validLpBox = reservedLP1._1 == reservedLP0._1 + val validY = tokenY1._1 == tokenY0._1 + val validSupplyLP1 = supplyLP1 >= 0 + + // 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 reservesX0 = SELF.value + val reservesY0 = tokenY0._2 + val reservesX1 = successor.value + val reservesY1 = tokenY1._2 + + val oracleRateXY = oracleBox.R4[Long].get + val lpRateXY0 = reservesX0 / reservesY0 // we can assume that reservesY0 > 0 (since at least one token must exist) + val lpRateXY1 = reservesX1 / reservesY1 // we can assume that reservesY1 > 0 (since at least one token must exist) + val isCrossing = (lpRateXY0 - oracleRateXY) * (lpRateXY1 - oracleRateXY) < 0 // if (and only if) oracle pool rate falls in between, then this will be negative + + val crossTrackerLowIn = SELF.R5[Int].get + val crossTrackerLowOut = successor.R5[Int].get + + val crossTrackerHighIn = SELF.R6[Int].get + val crossTrackerHighOut = successor.R6[Int].get + + val validCrossCounter = { + if (isCrossing) { + if (lpRateXY1 > oracleRateXY) { + crossTrackerLowOut >= HEIGHT - threshold && + crossTrackerHighOut == 9223372036854775807L + } else { + crossTrackerHighOut >= HEIGHT - threshold && + crossTrackerLowOut == 9223372036854775807L + } + } else { + crossTrackerLowOut == crossTrackerLowIn && + crossTrackerHighOut == crossTrackerHighIn + } + } + + val validRateForRedeemingLP = oracleRateXY > lpRateXY0 * 98 / 100 // lpRate must be >= 0.98 * oracleRate // these parameters need to be tweaked + // Do we need above if we also have the tracking contract? + + val deltaSupplyLP = supplyLP1 - supplyLP0 + val deltaReservesX = reservesX1 - reservesX0 + val deltaReservesY = reservesY1 - reservesY0 + + // LP formulae below using UniSwap v2 (with initial token burning by bootstrapping with positive R4) + 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 + } && validRateForRedeemingLP + + val validSwap = + if (deltaReservesX > 0) + reservesY0.toBigInt * deltaReservesX * feeNum >= -deltaReservesY * (reservesX0.toBigInt * feeDenom + deltaReservesX * feeNum) + else + reservesX0.toBigInt * deltaReservesY * feeNum >= -deltaReservesX * (reservesY0.toBigInt * feeDenom + deltaReservesY * feeNum) + + val validAction = + if (deltaSupplyLP == 0) + validSwap + else + if (deltaReservesX > 0 && deltaReservesY > 0) validDepositing + else validRedemption + + sigmaProp( + validSupplyLP1 && + validSuccessorScript && + validOraclePoolBox && + preservedLpNFT && + validLpBox && + validY && + noMoreTokens && + validAction && + validStorageRent && + validCrossCounter + ) +} +``` + +## Intervention Contract + +```scala +{ + + // Intervention box will be spent as follows + // Intervention + // Input | Output | Data-Input + // ----------------------------------------------- + // 0 LP | LP | Oracle + // 1 Bank | Bank | + // 2 Intervention | Intervention | + + val lpInIndex = 0 + val lpOutIndex = 0 + val bankInIndex = 1 + val bankOutIndex = 1 + val selfOutIndex = 2 // SELF should be third input + val oracleBoxIndex = 0 + + val lastIntervention = SELF.creationInfo._1 + val buffer = 3 // error margin in height + val T = 100 // from paper, gap between two interventions + val T_int = 20 // blocks after which a trigger swap event can be completed, provided rate has not crossed oracle pool rate + val bankNFT = fromBase64("hho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") + val lpNFT = fromBase64("Nho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") + val oracleNFT = fromBase64("RytLYlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") + + val thresholdPercent = 98 // 98% or less value (of LP in terms of OraclePool) will trigger action (ensure less than 100) + + val oracleBox = CONTEXT.dataInputs(oracleBoxIndex) + + val lpBoxIn = INPUTS(lpInIndex) + val bankBoxIn = INPUTS(bankInIndex) + + val lpBoxOut = OUTPUTS(lpOutIndex) + val bankBoxOut = OUTPUTS(bankOutIndex) + + val successor = OUTPUTS(selfOutIndex) + + val tokenYIn = lpBoxIn.tokens(2) + val tokenYOut = lpBoxOut.tokens(2) + + val reservesXIn = lpBoxIn.value + val reservesYIn = tokenYIn._2 + + val reservesXOut = lpBoxOut.value + val reservesYOut = tokenYOut._2 + + val lpRateXYIn = reservesXIn / reservesYIn // we can assume that reservesYIn > 0 (since at least one token must exist) + val lpRateXYOut = reservesXOut / reservesYOut // we can assume that reservesYOut > 0 (since at least one token must exist) + + val oracleRateXY = oracleBox.R4[Long].get + + val validThreshold = lpRateXYIn * 100 < thresholdPercent * oracleRateXY + + val validOraclePoolBox = oracleBox.tokens(0)._1 == oracleNFT + val validLpBox = lpBoxIn.tokens(0)._1 == lpNFT + + val validSuccessor = successor.propositionBytes == SELF.propositionBytes && + successor.tokens == SELF.tokens && + successor.value == SELF.value && + successor.creationInfo._1 >= HEIGHT - buffer + + val validBankBoxIn = bankBoxIn.tokens(0)._1 == bankNFT + val validBankBoxOut = bankBoxOut.tokens(0) == bankBoxIn.tokens(0) && + bankBoxOut.tokens(1)._1 == bankBoxIn.tokens(1)._1 + + val validGap = lastIntervention < HEIGHT - T + + val deltaBankTokens = bankBoxOut.tokens(1)._2 - bankBoxIn.tokens(1)._2 + val deltaBankErgs = bankBoxIn.value - bankBoxOut.value + val deltaLpX = reservesXOut - reservesXIn + val deltaLpY = reservesYIn - reservesYOut + + val validLpIn = lpBoxIn.R5[Int].get < HEIGHT - T_int // at least T_int blocks have passed since the tracking started + + val lpRateXYOutTimes100 = lpRateXYOut * 100 + + val validSwap = lpRateXYOutTimes100 >= oracleRateXY * 105 && // new rate must be >= 1.05 times oracle rate + lpRateXYOutTimes100 <= oracleRateXY * 110 && // new rate must be <= 1.1 times oracle rate + deltaBankErgs <= deltaLpX && // ergs reduced in bank box must be <= ergs gained in LP + deltaBankTokens >= deltaLpY && // tokens gained in bank box must be >= tokens reduced in LP + validBankBoxIn && + validBankBoxOut && + validSuccessor && + validLpBox && + validOraclePoolBox && + validThreshold && + validLpIn && + validGap + + sigmaProp(validSwap) +} +``` \ No newline at end of file