USM “minimalist stablecoin” part 2: protecting against price exploits
This is an update on the USM “minimalist stablecoin” proposal I sketched in part 1 (read that first), adding a couple of features for robustness, especially against front-running or arbitrage of the ETH/USD price feed. These features trade off some simplicity — clearly a top priority for “minimalist” USM — for, potentially, some important added safety.
(See also the latest part 3: uh this is happening guys and part 4: fee math decisions.)
Contents:
- Brief descriptions and rationales of the two new features
- Walk-through of some examples
- Link to an updated simple Python simulation you can run to see them at work, with a sample transcript paralleling the examples
- Next steps
I’m now pretty enthusiastic about this stablecoin design, and we’re going to ship it if we can! In addition to my simple simulation, we have a fledgling Telegram group for discussion, and Alex Roan and Alberto Cuesta Cañada are leading the way on a Solidity implementation. If you’re interested in participating, get in touch.
To me the main appeal of a design like this, apart from that it’s elegant and fun, is that more-or-less centralized stablecoins are an increasingly dominant part of the crypto landscape. If we want to keep our cryptomoney permissionless, we have some work to do.
Feature 1: make larger trades more expensive
An important risk highlighted by Daniel Goldman, mentioned in part 1, is slowness/inaccuracy of the price oracle. The USM system’s basic mint
/burn
/fund
/defund
operations all amount to exchanging ETH for dollars (stablecoins) at the current ETH/USD price. So if the price lags a bit, and/or is highly volatile, that could present profit opportunities for fast-moving arbitrageurs. And no on-chain price feed is ever going to update as fast as centralized exchanges.
Furthermore, and arguably more importantly, the original system does nothing to prevent these opportunities being exploited in size. Suppose an opportunity arises where one can mint
USM at a laggy price of $400 minus fees — say $399 net — whereas the live market ETH price is actually $390. Then a nimble trader can earn approximately $9 ($399 - $390) per ETH. In principle, they could even pass in 1,000,000 ETH to a mint
op, getting back 399,000,000 USM; wait for the USM oracle price to catch up with the market, dropping to $390; then burn
the 399,000,000 USM to get back 399,000,000 / $390 = 1,023,077 ETH minus fees, for a 2+% profit at the expense of the pool (and thus, FUM holders).
The simplest way to mitigate this risk is to make the four operations’ fees high enough to make such exploits unprofitable: but this would hurt UX for regular users too.
An important way market makers avoid exploits like this is by following the principle that buys should push price up and sells should pull price down, so that exploiting a price inaccuracy also reduces it. Our original USM design lacked such a mechanism. To add one, we can look (as always!) to Uniswap, whose “constant-product” market making formula says that eating 1% of the liquidity pool raises the price by 1%.
Specifically, we add two price adjustments, mint_burn_adjustment
and fund_defund_adjustment
, reflecting the degree to which recent mints
outweighed burns
(or vice versa), and the same for funds
vs defunds
. We update these adjustments every time an op is called, and use them to move price away from the user. burn
and fund
are “long ETH” operations, so they increase the effective ETH price; mint
and defund
are “short ETH”, so they reduce the effective ETH price. Finally, we make the adjustments temporary, so that the effective buy/sell prices both gravitate back towards the oracle price as time passes without trades.
The net effect is to ensure that large trades, or those performed in quick succession, are at less favorable prices; but small or spaced-out trades are done near the oracle price.
Example. Suppose:
- The pool contains 1,000 ETH, at an oracle price of $398 (what you can sell it for)/$402 (buy), for a mid pool value of $400 *1,000 = $400,000.
- 300,000 USM have been issued, so the debt ratio is $300,000 / $400,000 = 75%, and the buffer value is $400,000 - $300,000 = $100,000.
- 80,000 FUM have been issued, for a mid FUM price of $100,000 / 80,000 = $1.25.
- The
mint
/fund
fee is 0.1%, and theburn
/defund
fee is 0.2%. - There’s been no recent trading, so both the
mint_burn_adjustment
andfund_defund_adjustment
are currently at 1 (no adjustment).
Now user A does a mint
operation, passing in 100 ETH:
- The very first USM is minted at an ETH price of $398 * (1 - 0.1%) = $397.602. So that first USM costs 1 / $397.602 = 0.002515 ETH.
- But as more ETH is passed in,
mint_burn_adjustment
drops below 1 (pushing down the effective ETH price). By the time the 100th ETH is passed in, increasing the pool by a factor of 1,100 / 1,000 (1.111111), the adjustment is down to 1,000 / 1,100 = 0.909091, and the effective ETH price is $398 * (1 - 0.1%) * 0.909091 = $361.46. So the last USM A buys costs 1 / $361.46 = 0.002767 ETH. - Because the ETH sell price was sliding downward (USM buy price sliding upward) during the operation, calculating the total USM minted requires a basic integral, which gives: total USM bought = 1,000 * $398 * (1 - 0.1%) * ln(1,100/1,000) = 37,895.52. This equates to an average buy price of 100 / 37,895.52 = 0.002639 ETH, between the starting and ending values above, which makes sense.
- So now:
- The pool now contains 1,100 ETH and 337,895.52 USM
- Mid pool value = 1,100 * $400 = $440,000
- Debt ratio = $337,895.52 / $440,000 = 76.79% (up)
- Mid buffer value = $440,000 - $337,895.52 = $102,104.48 (up)
- Mid FUM price = $102,104.48 / 80,000 = $1.276306 (up)
-mint_burn_adjustment
= 0.909091,fund_defund_adjustment
= 1
Next, let’s suppose 120 seconds pass without a trade:
- Assuming an ADJUSTMENT_HALF_LIFE of 60 seconds, this means
mint_burn_adjustment
has contracted halfway back to 1, and then halfway again, to 0.909091^(0.5^(120/60)) = 0.976454.
Finally, suppose now B does a defund
op, redeeming 10,000 of their FUM tokens. If this had been a long-ETH fund
op, its price would be unaffected by the prior short-ETH mint
. But since this is a second short-ETH op in a row, its initial price is similarly adjusted:
- The unadjusted FUM sell price would be calculated as follows: since we’re selling, calculate the buffer value based on the (pre-fee) ETH oracle sell price of $398. So the FUM sell price is buffer value = 1,100 * $398 - $337,895.52 = $99,904.48. Dividing by the 80,000 FUM units and subtracting the 0.2%
defund
fee gives a sell price of $1.246308. - But because
mint_burn_adjustment
is set, at 0.976454, B’s first FUM unit is actually sold for 0.976454 * $1.246308 =$1.216962. - Furthermore, as B redeems, this drives down
fund_defund_adjustment
as well, further reducing the FUM sell price. Again this leads to an integral: the total ETH obtained bydefunding
10,000 FUM is: 1,100 * (1 - e^(-10,000 / (1,100 * ($400 / $1.216962)))) = 30.0072 ETH. - So in the end:
- Pool contains 1,100 - 30.0072 = 1,069.9928 ETH, 337,895.52 USM, 80,000 - 10,000 = 70,000 FUM
- Mid pool value = 1,069.9928 * $400 = $427,997.12
- Debt ratio = $337,895.52 / $427,997.12 = 78.95% (up)
- Mid buffer value = $427,997.12 - $337,895.52 = $90,101.60 (down)
- Mid FUM price = $90,101.60 / 70,000 = $1.287166 (up slightly, due to the fees and bid/ask collected)
-mint_burn_adjustment
= 0.976454,fund_defund_adjustment
= 1,069.9928 / 1,100 = 0.972721
Feature 2: when FUM buyers are needed, reduce its price over time
The second new feature is simpler. As Elliot Olds noted in part 1, and by eg denett on ethresear.ch, one risk the system faces (common to all such collateral-pool systems) is that the collateral plunges in value, the peg fails, and no one is incentivized to insert fresh funding to replenish it. The new feature, suggested by Elliot, is to make the FUM buy price decline over time when the system is underwater. Thus, providing needed funding becomes a better and better deal.
We do this by giving it a similar half-life to the one we used above to return the adjustments to 1, though this one can be much slower: we just want to make sure eventually the price will drop enough for bottom-feeding buyers to dive in.
Example. Continuing where we left off above, the debt ratio was 78.95%, precariously close to the max threshold of 80%. Let’s suppose the mid ETH price (via our oracle) then drops to $350. This will reduce the pool value, and trigger our min_fum_buy_price_in_eth
to be set:
- - Pool contains 1,069.9928 ETH, 337,895.52 USM, 70,000 FUM
- Mid pool value = 1,069.9928 * $350 = $374,497.48
- Debt ratio = $337,895.52 / $374,497.48 = 90.23% (up)
- Mid buffer value = $374,497.48 - $337,895.52 = $36,601.96 (sharply down)
- Mid FUM price = $36,601.96 / 70,000 = $0.522885 (sharply down)
With debt ratio exceeding the max 80%, min_fum_buy_price
is set to the FUM price, in ETH, at which point debt ratio crossed 80%. This is calculated via (1,069.9928 * (1 - 80%)) / 70,000 / (1 - 0.1%) = 0.00306018 ETH. You can check this by observing that debt ratio crossed 80% when the mid ETH price crossed $394.7404: $337,895.52 / (1,069.9928 * $394.7404 = $422,369.40) = 80.00%. And at this price, the FUM buy price was ($422,369.40 - $337,895.52 = $84,473.88) / 70,000 / (1 - 0.1%) = $1.207978; or in ETH, $1.207978 / $394.7404 = 0.00306018 ETH. So:
min_fum_buy_price
= 0.00306018 ETH ($1.0711, at least until the ETH price changes again!)
Now, ideally this low FUM buy price incentivizes some buyers to swoop in and do fund
ops, recapitalizing the system and pulling debt ratio back below 80%. But suppose no one does for 3 days (72 hours), and suppose MIN_FUM_BUY_PRICE_HALF_LIFE = 24 hours. Then min_fum_buy_price
drops further (gradually) — by a factor of 0.5^(72/24) = 1/8:
min_fum_buy_price
= 0.5^(72/24) * 0.00306018 = 0.000382523 ETH
With this mechanism, I’m optimistic that fresh funders will materialize. This seems to me a lesser risk to the USM system than the oracle/price exploits that motivated our first feature above.
Proof-of-concept implementation
I’ve extended the ~200-line usm.py
simulation described in part 1 into a now ~300-line usm_constproduct.py
version incorporating these two new features. You can run it yourself and see it echo the examples described above, as shown in this transcript:
Next steps
As mentioned at the top, we’re working on building this (hey it’s minimal, should be easy!), and I think it’s feature-complete enough to be useful as described. But some important further steps I envision to make it better:
- Flesh out which price oracle/oracles to use — ideally fully decentralized, on-chain sources, like Uniswap. Note that feature 1 above (price adjustment) is specifically supposed to make the system less vulnerable to an imperfect oracle.
- The limit buy order functionality described in part 1, where FUM buyers can specify a maximum price (in ETH or USM) they’re willing to fund at, and if the current price is higher their bid is queued up for execution if the price falls. This would a) let users get a better price (fees could also be reduced for such trades), b) stabilize the FUM price, and above all of course c) provide a pool of reserve funding to defend against an ETH price drop or other undercapitalization event.
I’m quite optimistic that if these limit bids were implemented, the system might never go underwater. - Flesh out a smart contract upgrade mechanism (aka “governance”). I think what I described in part 1 ought to work: we the contract maintainers have the special privilege of specifying a default contract to upgrade to, but actual upgrade is entirely opt-in by users (USM/FUM holders).
- Potentially reduce (or raise) fees. Fees are needed to protect against arbitrageurs and reward funders, and should be raised if necessary; but I’d hope they can be kept very low, perhaps lower than the 0.1%/0.2% assumed above, especially once you add in the Uniswap-like implicit fees from feature 1 above — this project isn’t really intended as a profit-maker. (Gas fees also provide some implicit protection, for better or worse…) Along with oracle choices, fees are a natural feature to tweak up or (preferably) down in future versions of the system.
It’s quite tempting to just eliminate the fixed 0.1%/0.2%-type fees and rely only on the Uniswap-like dynamic-pricing fees. Simpler, great for small txs/users (which then become close to 0-fee), and the higher dynamic fees collected from occasional large transaction might be enough to prevent abuse. - Make the operations cheaper by using Layer 2, eg rollup? I’m a little skeptical L2 approaches would help here, since all four ops involve sending USM/FUM, but conceivably some sort of batching might help.
- Audit/review of the Solidity, once it’s complete (and the design). Anyone who’s been around smart contracts a while knows why…