-
Notifications
You must be signed in to change notification settings - Fork 34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
EIP-30 Dexy stablecoin #62
base: master
Are you sure you want to change the base?
Changes from 2 commits
fd3b7c3
9d7282c
cea90c2
547a2d2
a2e3466
f706d6d
e4b30e7
716ee53
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,329 @@ | ||
# 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 minting (or "emission") contract 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. | ||
|
||
2. **Liquidify 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. | ||
|
||
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 "top-up swap". | ||
|
||
The swap logic is encoded in a **swapping** contract. | ||
|
||
There is another contract, the **tracking** contract that is responsible for tracking the LP's state. In particular, this contract | ||
tracks the block at which the "top-up-swap" is initiated. The swap can be initiated when the LP rate falls below 90%. | ||
Once initiated, if the LP rate remains below the oracle pool rate for a certain threshold number of blocks, the swap can be compleded. | ||
On the other hand, if before the threshold the rate goes higher than oracle pool then the swap must be aborted. | ||
|
||
The LP uses a "cross-counter" to keep count of the number of times the LP rate has crossed the oracle pool rate (from below or above) in a swap transaction. | ||
If the cross-counter is preserved at swap initiation and completion then swap is valid, else it is aborted. This logic is present in the swapping box. | ||
|
||
## Emission Contract | ||
|
||
```scala | ||
{ | ||
// This box: (dexyUSD emission box) | ||
// tokens(0): emissionNFT identifying the box | ||
// tokens(1): dexyUSD tokens to be emitted | ||
|
||
val selfOutIndex = getVar[Int](0).get | ||
|
||
val oraclePoolNFT = fromBase64("RytLYlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify oracle pool box | ||
val swappingNFT = fromBase64("Fho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify swapping box for future use | ||
|
||
val validEmission = { | ||
val oraclePoolBox = CONTEXT.dataInputs(0) // oracle-pool (v1 and v2) box containing rate in R4 | ||
|
||
val validOP = oraclePoolBox.tokens(0)._1 == oraclePoolNFT | ||
|
||
val oraclePoolRate = oraclePoolBox.R4[Long].get // can assume always > 0 (ref oracle pool contracts) NanoErgs per USD | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. per USD or USD cent? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes its USD-cent if we consider v1 oracle |
||
|
||
val selfOut = OUTPUTS(selfOutIndex) | ||
|
||
val validSelfOut = selfOut.tokens(0) == SELF.tokens(0) && // emissionNFT and quantity preserved | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "and quantity" could be missed There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. agreed, we can remove "and quantity" |
||
selfOut.propositionBytes == SELF.propositionBytes && // script preserved | ||
selfOut.tokens(1)._1 == SELF.tokens(1)._1 && // dexyUSD tokenId preserved | ||
selfOut.value > SELF.value // can only purchase dexyUSD, not sell it | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "can only purchase dexyUSD, not sell it" => "can only sell dexyUSD, not purchase it", so There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe the comment is misinterpreted. The full comment should read "users can only purchase dexyUSD". Maybe we can also write it as "box can only sell dexyUSD"? Also since the contract sells dexyUSD, shouldn't selfOut.value be more than SELF.value? (i.e., shouldn't Ergs increase?) |
||
|
||
val inTokens = SELF.tokens(1)._2 | ||
val outTokens = selfOut.tokens(1)._2 | ||
|
||
val deltaErgs = selfOut.value - SELF.value // deltaErgs must be (+)ve because ergs must increase | ||
|
||
val deltaTokens = inTokens - outTokens // outTokens must be < inTokens (see below) | ||
|
||
val validDelta = deltaErgs >= deltaTokens * oraclePoolRate // deltaTokens must be (+)ve, since both deltaErgs and oraclePoolRate are (+)ve | ||
|
||
validOP && validSelfOut && validDelta | ||
} | ||
|
||
val validTopping = INPUTS(0).tokens(0)._1 == swappingNFT | ||
|
||
sigmaProp(validEmission || validTopping) | ||
} | ||
``` | ||
## Liquidity Pool Contract | ||
|
||
```scala | ||
{ | ||
// Notation: | ||
// | ||
// X is the primary token | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. X is the primary token (ERG) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think better to keep X as nanoErgs |
||
// Y is the secondary token | ||
// When using Erg-USD oracle v1, X is NanoErg and Y is USD | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Y is USD cent ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes comment is incorrect. It should be USD cent with oracle v1 |
||
|
||
// 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: Cross-counter. A counter to track how many times the rate has "crossed" the oracle pool rate. That is the oracle pool rate falls in between the before and after rates | ||
// 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 | ||
|
||
// constants | ||
val feeNum = 3 // 0.3 % | ||
val feeDenom = 1000 | ||
val minStorageRent = 10000000L // this many number of nanoErgs are going to be permanently locked | ||
|
||
val successor = OUTPUTS(0) // copy of this box after exchange | ||
val oraclePoolBox = CONTEXT.dataInputs(0) // oracle pool box | ||
val validOraclePoolBox = oraclePoolBox.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 validLP = 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 oraclePoolRateXY = oraclePoolBox.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 - oraclePoolRateXY) * (lpRateXY1 - oraclePoolRateXY) < 0 // if (and only if) oracle pool rate falls in between, then this will be negative | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why to check this in LP contract? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This variable captures whether the oracle pool rate falls in between the rates at input and output LP box in a swap transaction. Each time this happens, a counter is incremented in R5. This is done in every swap, independent of the top-up requirement |
||
|
||
val crossCounterIn = SELF.R5[Int].get | ||
val crossCounterOut = successor.R5[Int].get | ||
|
||
val validCrossCounter = crossCounterOut == {if (isCrossing) crossCounterIn + 1 else crossCounterIn} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why to check this in LP contract? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to keep track of how many times its crossed (see above comment). Note that the requirement we are trying to capture is that between a top-up initiation and top-up completion, the LP rate should not have crossed the oracle pool rate. If it does, then the top-up becomes invalid and we need to start afresh. |
||
|
||
val validRateForRedeemingLP = oraclePoolRateXY > lpRateXY0 * 9 / 10 // lpRate must be >= 0.9 oraclePoolRate // 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 && | ||
validLP && | ||
validY && | ||
noMoreTokens && | ||
validAction && | ||
validStorageRent && | ||
validCrossCounter | ||
) | ||
} | ||
``` | ||
## Tracking Contract | ||
|
||
```scala | ||
{ | ||
// Tracking box | ||
// R4: Crossing Counter of LP box | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need for the counter? I guess it would be enough to write down in register height when price condition violated (within certain margin), and then a special action can nullify the register if price is okay again. On receiver side (in the bank contract), we check that HEIGHT - trackingBox.R4 > threshold There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, there can be other ways to achieve the goal stated in previous comment. So in the LP, we would need to store two values. One contains boolean when condition is violated and another height at which violated. We can consider this approach as well. That way we can skip "initiation" and "completion" of top-up and just have a single step. |
||
// tokens(0): TrackingNFT | ||
|
||
val thresholdPercent = 90 // 90% or less value (of LP in terms of OraclePool) will trigger action (ensure less than 100) | ||
val errorMargin = 3 // number of blocks where tracking error is allowed | ||
|
||
val lpNFT = fromBase64("Nho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify LP box for future use | ||
val oraclePoolNFT = fromBase64("RytLYlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify oracle pool box | ||
|
||
val lpBox = CONTEXT.dataInputs(0) | ||
val oraclePoolBox = CONTEXT.dataInputs(1) | ||
|
||
val validLpBox = lpBox.tokens(0)._1 == lpNFT | ||
val validOraclePoolBox = oraclePoolBox.tokens(0)._1 == oraclePoolNFT | ||
|
||
val tokenY = lpBox.tokens(2) | ||
|
||
val reservesX = lpBox.value | ||
val reservesY = tokenY._2 | ||
|
||
val lpRateXY = reservesX / reservesY // we can assume that reservesY > 0 (since at least one token must exist) | ||
|
||
val oraclePoolRateXY = oraclePoolBox.R4[Long].get | ||
|
||
val crossCounter = lpBox.R5[Int].get // stores how many times LP rate has crossed oracle pool rate (by cross, we mean going from above to below or vice versa) | ||
|
||
val successor = OUTPUTS(0) | ||
|
||
val validThreshold = lpRateXY * 100 < thresholdPercent * oraclePoolRateXY | ||
|
||
val validSuccessor = successor.propositionBytes == SELF.propositionBytes && | ||
successor.tokens == SELF.tokens && | ||
successor.value >= SELF.value | ||
|
||
val validTracking = successor.R4[Int].get == crossCounter && | ||
successor.creationInfo._1 > (HEIGHT - errorMargin) | ||
|
||
sigmaProp( | ||
validLpBox && | ||
validOraclePoolBox && | ||
validThreshold && | ||
validSuccessor && | ||
validTracking | ||
) | ||
} | ||
``` | ||
|
||
## Swapping Contract | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Description of the contract is needed. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Going to add in next commit |
||
|
||
```scala | ||
{ | ||
val waitingPeriod = 20 // blocks after which a trigger swap event can be completed, provided rate has not crossed oracle pool rate | ||
val emissionNFT = fromBase64("Bho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify LP box for future use | ||
val lpNFT = fromBase64("Nho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify LP box for future use | ||
val trackingNFT = fromBase64("Jho6UlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify LP box for future use | ||
val oraclePoolNFT = fromBase64("RytLYlBlU2hWbVlxM3Q2dzl6JEMmRilKQE1jUWZUalc=") // to identify oracle pool box | ||
|
||
val thresholdPercent = 90 // 90% or less value (of LP in terms of OraclePool) will trigger action (ensure less than 100) | ||
|
||
val oraclePoolBox = CONTEXT.dataInputs(0) | ||
val trackingBox = CONTEXT.dataInputs(1) | ||
|
||
val lpBoxIn = INPUTS(0) | ||
val emissionBoxIn = INPUTS(1) | ||
|
||
val lpBoxOut = OUTPUTS(0) | ||
val emissionBoxOut = OUTPUTS(1) | ||
|
||
val successor = OUTPUTS(2) // SELF should be INPUTS(2) | ||
|
||
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 oraclePoolRateXY = oraclePoolBox.R4[Long].get | ||
|
||
val validThreshold = lpRateXYIn * 100 < thresholdPercent * oraclePoolRateXY | ||
|
||
val validTrackingBox = trackingBox.tokens(0)._1 == trackingNFT | ||
val validOraclePoolBox = oraclePoolBox.tokens(0)._1 == oraclePoolNFT | ||
val validLpBox = lpBoxIn.tokens(0)._1 == lpNFT | ||
|
||
val validSuccessor = successor.propositionBytes == SELF.propositionBytes && | ||
successor.tokens == SELF.tokens && | ||
successor.value == SELF.value | ||
|
||
val validEmissionBoxIn = emissionBoxIn.tokens(0)._1 == emissionNFT | ||
val validEmissionBoxOut = emissionBoxOut.tokens(0) == emissionBoxIn.tokens(0) && | ||
emissionBoxOut.tokens(1)._1 == emissionBoxIn.tokens(1)._1 | ||
|
||
val deltaEmissionTokens = emissionBoxOut.tokens(1)._2 - emissionBoxIn.tokens(1)._2 | ||
val deltaEmissionErgs = emissionBoxIn.value - emissionBoxOut.value | ||
val deltaLpX = reservesXOut - reservesXIn | ||
val deltaLpY = reservesYIn - reservesYOut | ||
|
||
val validLpIn = lpBoxIn.R5[Int].get == trackingBox.R4[Int].get && // no change in cross-counter | ||
trackingBox.creationInfo._1 < HEIGHT - waitingPeriod // at least waitingPeriod blocks have passed since the tracking started | ||
|
||
val lpRateXYOutTimes100 = lpRateXYOut * 100 | ||
|
||
val validSwap = lpRateXYOutTimes100 >= oraclePoolRateXY * 105 && // new rate must be >= 1.05 times oracle rate | ||
lpRateXYOutTimes100 <= oraclePoolRateXY * 110 && // new rate must be <= 1.1 times oracle rate | ||
deltaEmissionErgs <= deltaLpX && // ergs reduced in emission box must be <= ergs gained in LP | ||
deltaEmissionTokens >= deltaLpY && // tokens gained in emission box must be >= tokens reduced in LP | ||
validEmissionBoxIn && | ||
validEmissionBoxOut && | ||
validSuccessor && | ||
validLpBox && | ||
validOraclePoolBox && | ||
validTrackingBox && | ||
validThreshold && | ||
validLpIn | ||
|
||
sigmaProp(validSwap) | ||
} | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not a constant?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes it more flexible due to possible AOTC issues with hard-wiring (box can be spent in two different transactions: top-up and mint)