USM “minimalist stablecoin” part 3: uh this is happening guys
This is a further update on the USM “minimalist decentralized stablecoin” I proposed in part 1 and updated in part 2. The short of it is that thanks to some awesome smart-contract-guru collaborators (Alberto Cuesta Cañada and Alex Roan), this has moved from an idea, to a project, to a codebase (https://github.com/usmfum/USM) nearing release! We’re going to deploy this thing and we would absolutely love to get feedback/bug reports before we do… So all feedback welcome, from the theoretical math to the nitty-gritty code.
(See also part 4: fee math decisions.)
Contents:
A. Reminder of what USM is
B. No flat fee
C. Updated sliding-price math, based on change in debt ratio
D. Oracle design: median of three
E. Code architecture
F. Known vulnerabilities
G. Current rollout status: entering audit
H. Updated Python proof-of-concept
I. Future work
J. Appendix: gory details on the sliding-price math, via an example
A. Reminder of what USM is
USM (“minimalist USD”) is a stablecoin pegged to the dollar: the goal is for 1 USM to always have a market price of $1, or at least reliably return to $1. The peg is preserved by a simple pair of operations, mint
and burn
: eg, if the current ETH/USD price is $500, calling USM.mint(10 ETH)
should give you back ~5,000 USM, or calling USM.burn(500 USM)
should give you back ~1 ETH. The ETH passed in by minters is stored in the USM smart contract and used to redeem future burns
.
The system also has a second token, FUM (“minimalist funding”), which lets speculators provide additional ETH as a backstop in case a dropping ETH price causes the pool to “go underwater” — not contain enough ETH to redeem all outstanding USM. FUM is not a stablecoin: FUM holders have leveraged exposure to ETH/USD, and collect the fees the system charges on user operations.
The USM system is ownerless and governance-free. We’re creating it, but we have zero admin privileges. All we can do is release a new version if it breaks.
See the original part 1 post for a more detailed intro with pretty pictures.
B. No flat fee
The original design featured a flat % fee applied to every operation, like Uniswap’s 0.3%, to make it harder to exploit oracle inaccuracies, and to incentive funding (FUM holders collect the fees). The sliding prices introduced in part 2 serve both these purposes, so to simplify the code we scrapped the flat fee: a tiny trade, that’s not in the same direction as recent trades, will pay a ~0% fee. (Apart from the gas fee.)
C. Updated sliding-price math
As discussed in part 2, one way to prevent an inexact price oracle from resulting in the USM contract’s ETH pool getting drained, is to follow a basic market-making principle: “buys should push price up and sells should pull price down.” So a buyer should pay not a fixed unit price, but a price that increases the more they buy; and similarly a seller should see the sell price “slide” down the more they sell. In particular, this provides the sensible safety feature that very large sudden trades (the sort that smart contract hackers love…) pay a steep premium over the oracle price, or receive only a steep discount to it.
That part 2 post laid out a way of doing this that involved two price adjustments: one tracking whether there have been more mints
than burns
(USM buys/sells) recently, or vice versa; the other tracking whether there have been more funds
than defunds
(FUM buys/sells).
This was sometimes ugly: eg, if there had been a lot of (short-ETH) mints
recently, but also a lot of (long-ETH) funds
, the result would be that all four operations (mint
/burn
/fund
/defund
) were “adjusted”, ie, paid a premium.
Intuitively, long-ETH trades and short-ETH trades should offset each other. If recent trade activity has leaned long, further long trades should pay a premium and short trades shouldn’t; and vice versa. So, in the latest code, we boil down these two adjustments (mint/burn and fund/defund) into a single adjustment, summarized as follows:
A trade that changes the system’s debt ratio by a factor of k, changes the price adjustment by a factor of (1/k)².
(Recall that the “debt ratio” is a ratio of the value of total USM issued, to the value of all ETH held in the pool.)
This formula uses “change to debt ratio” as a proxy for “long/shortness” of a trade. This is imperfect (as described in the comments for buySellAdjustment()
), but I believe it’s good enough for government work.
See the appendix below for in-depth details on how the sliding price is calculated.
D. Oracle design
The USM system relies fundamentally on an external ETH/USD price source: many of the most plausible exploits involve manipulating its oracle, or the oracle just being inaccurate. We’ve put some thought into making this part of the system as robust as we can, but it remains one of the more likely angles of attack.
After umpteen debates and refinements, the current design calculates the ETH price as the median of three external sources:
- Chainlink’s ETH/USD feed, sourcing a bunch of off-chain sources.
- A Uniswap v2 TWAP (time-weighted average price) vs a stablecoin: specifically, ETH/USDC.
- Compound’s Open Oracle system, combining ETH/USD prices from providers like Coinbase and (at least in principle…) Kraken and Binance.
The motive for depending on a mix of oracle infrastructures, rather than just one, is to protect against the case where one of them stops updating or feeds incorrect prices. Chainlink has had such outages at least twice before; Compound’s Open Oracle is still relatively new and seems to have limited traction; Uniswap prices are not truly vs USD but vs other stablecoins, which are all at significant risk of breaking their dollar pegs someday.
There are other oracles we’ve considered — other good candidates for our median remain welcome! MakerDAO’s “medianizer” is widely used, but v1 is deprecated and v2 is permissioned. Uniswap spot prices are more up to date than the TWAP prices, which become stale if none of our users have done mints
/burns
etc recently; but Uniswap spot prices are also cheap and easy to manipulate temporarily, making them a notorious entry points for smart contract exploits. There are ways to access up-to-date TWAP prices without depending on user actions, but they’re painful to implement in an on-chain feed.
The fact is keeping an on-chain oracle reliably updated is a significant lift. Naively it requires at least a dozen or so on-chain transactions a day, so when you consider that the $10-plus (or $100-plus) transaction fees of 2017 are reasonably likely to return sometime, it’s not surprising that the market isn’t flooded with free, accurate, outage-free oracles.
We discussed at length which oracles to reference (see this earlier analysis), and how to balance system complexity vs defense in depth. It’s likely that as oracles continue to evolve, we’ll release a USM v2 pointing at an updated combination of oracles. For this v1, our premise is that any outage in a source oracle will either a) be temporary, so that our median-of-three logic will protect against it; or b) give us time to release a fresh USM version pointing at a fixed basket of oracles, before either of the remaining two sources also goes down or is manipulated.
Thanks to several people who’ve made invaluable contributions to USM’s oracle design: Alberto and Alex, James Prestwich, Daniel Goldman, Nick Johnson, Patrick McCorry, Dan Robinson, Mike McDonald, Harry Glynn and Elliot Olds, probably among others I’m forgetting. The blame is all ours if problems do crop up, but I’m confident our design has come a long way in robustness.
(As an aside, the median price is publicly accessible to any other projects wanting to use it, via USM.latestPrice()
.)
E. Code architecture
Brief overview of how the code is organized:
- At its heart, the system is three contracts: the ERC20s
USM.sol
andFUM.sol
, and an ETH/USD priceOracle
(though it’s actually folded intoUSM
— see below). - The bulk of the logic is in
contracts/USMTemplate.sol
, including:
- The four keyexternal
functions:mint()
,burn()
,fund()
,defund()
.
-buySellAdjustment()
andminFumBuyPrice()
.
- The sliding-price logic, implemented by the fourinternal view
functions they call:usmFromMint()
,ethFromBurn()
,fumFromFund()
,ethFromDefund()
. USMTemplate
is actually anabstract
contract: the concrete type of the contract we deploy isUSM
, which inherits from bothUSMTemplate
andMedianOracle
. If you’re on the ball you’re thinking “Ha ha no that’s wrong what you mean isUSM
has a state variable of typeMedianOracle
”, and you’re right that that’s the more orthodox architecture; but merging them this way offers a nontrivial gas saving (at the time ~40k, though a lot less after fine-tuning we’ve done since), becauseUSM
just callinglatestPrice()
on itself is cheaper than calling an external contract as inoracle.latestPrice()
.- The oracle code is in
contracts/oracles
, including the top-levelOracle
implementation we use,MedianOracle
. - The only bits of “frontier” math (deserving extra scrutiny…) I can think of are in
contracts/WadMath
:wadHalfExp()
,wadPow()
, andwadCbrtDown()
/wadCbrtUp()
. The cube root functions in particular are quite heavily gas-optimized but they seem to work… - The tests are in the
test
directory. The biggie (not to say monster… We may split it up) istest/03_USM.test.js
.
I’m especially indebted to Alberto and Alex for their tutelage (and hard work!) on the professional smart contract engineering side: Solidity code organization, contract inheritance, web3 testing, gas pitfalls etc — all subjects I knew practically nothing about before this project! Without them this would still be just some mathy farting around on Medium. Big 🙏❤️ guys. Their mentorship often reminded me of the old interview with Dave Lebling, creator of the classic ’70s text adventure game Zork:
“We use a very high-level language and you can learn the rudiments in a few hours. From then on it’s just a question of when you get in a sticky spot you come to someone else, like myself or Steve Meretzky, and say ‘Well, I’ve got this rope…how do I do a rope? It can be in two rooms at once if you tie it to something and take the end with you, and can you tie things up with it and drag them around with you?’
“Then we’ll stop and think and say, ‘You don’t want to have a rope in your game,’ and that makes it much easier for the new writers, you see.”
F. Known vulnerabilities
A shortlist:
- The price oracle could be manipulated — in particular the Uniswap spot prices.
- A price oracle could stop updating/give incorrect prices. The median logic is supposed to make one going down bearable, but if one goes down, manipulating one of the others becomes a lot more feasible.
- Even if functioning properly, the oracle might update slowly enough that exploiters are able to drain the ETH pool via many small price arbitrages.
- The system could go underwater (ETH pool value < USM outstanding) due to a (genuine) rapid drop in ETH’s price, and stay there (no fresh FUM buyers step up). In particular, if the system goes underwater, making fresh
defunds
and potentiallyburns
disabled, USM or FUM panic selling on other exchanges could crash their prices and permanently imperil the system. - The usual smart contract comedy of errors: reentrancy bugs, normal dumbass bugs, etc.
- Maybe nobody uses it.
G. Current rollout status
As of this writing (November 26, 2020), after 3 months of development the code is in a near-final state, though we may tinker a little more. It’s currently undergoing a security audit. Launch date will depend how the audit goes, though it would sure be sweet to launch in 2020, or at least before the stablecoin fad passes…
H. Updated Python proof-of-concept
Coming soonish…
I. Future work
We have limited post-deployment plans — this is a project for fun (we all have day jobs) and anyway, lacking admin powers, we can only release new versions, not make actual changes. I do envision that at some point we’ll release a v2 with an updated oracle: ideally because time has passed and attractive new oracle options have emerged that can make our median more accurate/robust; or perhaps because something goes wrong with our existing source oracles and we need to roll out an urgent replacement.
The only other major feature I have in mind is an on-chain limit order book for FUM buy limit orders, as described in the previous posts. This could provide significant extra protection against the pool going underwater on ETH price drops, though implementing it on-chain could get hairy… We have coded up a first poke at this.
J. Appendix: gory details on the sliding-price math, via an example
Let’s try to calculate how many ETH are returned by a burn
of USM. Suppose:
- The pool contains 100 ETH, at an oracle price of $500 each, for a total pool value of $50,000.
- 30,000 USM have been issued, for a debt ratio of $30,000 / $50,000 = 60%.
- 5,000 FUM have been issued, giving them a current value of $50,000 − $30,000 = $20,000, or $4 each.
- The current
buySellAdjustment
= 1.1: > 1 means “There have been more long-ETH than short-ETH trades lately.” This means that long-ETH operations (burn
/fund
) will start at an “adjusted” 1.1x price (eg, 1.1 * $500 = $550), and gradually increase. Short-ETH operations (mint
/defund
) will start at the unadjusted price (eg $500), then decrease.
Now user A burns
10,000 USM:
- As noted above, the burn starts at an adjusted price of $550. As the USM are burned, debt ratio is reduced, pushing the adjustment — and thus the adjusted price — up further. By the end of the operation, USM are being burned at a price of $896.07. (
burn
is a long-ETH operation because the user is selling USM for ETH, which is equivalent to buying ETH for USD.) - The
burn
returns 14.9061 ETH for the 10,000 USM. So the amount of USM outstanding has dropped from 30,000 to 20,000, and the ETH in the pool has dropped from 100 to 85.0939. (The average ETH price paid is $10,000 / 14.9061 = $670.86, partway between the starting price $550 and the final price $896.07.) So the debt ratio is down from 60% to 20,000 / (85.0939 * $500) = 47.0069%. - The change in the price adjustment is the reciprocal of the change in the debt ratio squared: new adjustment = 1.1 * (60% / 47.0069%)² = 1.7921. So if another burn is done immediately after, it will start at a price of $500 * 1.7921 = $896.07 — the same price the previous burn finished at.
In the above, we glossed over how the burn
’s output amount of ETH, 14.9061, is calculated. Let’s work through it step by step.
- Define e(u). We want to define a function e(u), which maps u, the quantity of unburned USM remaining, to e, the quantity of ETH remaining in the pool. We know e₀ = e(u₀ = 30,000) = 100, and we want to calculate e₁ = e(u₁ = 20,000).
- Define pu(u). Let pu(u) = the instantaneous USM burn price, in terms of ETH, when the amount of USM remaining is u. When the burn begins, pu₀ = pu(30,000) = 1 / $550 = 0.00181818 ETH per USM; when it ends, pu₁ = pu(20,000) = 1 / $896.07 = 0.00111599.
- e’(u) = pu(u). Let e’(u) be the derivative of e(u) — the amount by which e decreases, when we reduce u by some tiny amount ε. This is actually the same thing as pu(u), the instantaneous price of u in terms of e. Eg, the burn starting at price $550 means that the first 0.01 USM we burn will yield ~0.01 / $550 = 0.0000181818 ETH: we could calculate this as 0.01 * e’(u) = 0.01 * 0.00181818.
- Express pu(u) in terms of dr(u). As described above, we’ve chosen to slide the sell price down proportionally to the square of the debt ratio. Mathematically, pu(u) = pu₀ * (dr(u) / dr(u₀))².
- Expand dr(u). dr(u) = u / (e(u) * pe), where pe is the unadjusted oracle price, $500.
Now we work backwards, substituting until we get back to e(u): - Substitute the dr(u) formula into pu(u).
pu(u) = pu₀ * (dr(u) / dr(u₀))²
= pu₀ * ((u / (e(u) * pe)) / (u₀ / (e₀ * pe)))²
= pu₀ * ((u / u₀) / (e(u) / e₀))² - Substitute the pu(u) formula into e’(u).
e’(u) = pu(u)
= pu₀ * ((u / u₀) / (e(u) / e₀))²) - Use this e’(u) equation to solve for e(u). Now comes the most challenging step. We have an equation in terms of u, e(u), and e’(u), and we want to find the function e(u) that satisfies it. Fortunately I’m a calculus whiz* and managed to grind out the solution:
e(u) = (pu₀ * (e₀ / u₀)² * u³ + K)¹ᐟ³, for some constant K.
And knowing e(u₀) = e₀ lets us solve for K:
e₀ = e(u₀) = (pu₀ * (e₀ / u₀)² * u₀³ + K)¹ᐟ³
e₀ = (pu₀ * e₀² * u₀ + K)¹ᐟ³
e₀³ = pu₀ * e₀² * u₀ + K
K = e₀² * (e₀ − pu₀ * u₀)
So, expanding K in the e(u) formula:
e(u) = (pu₀ * (e₀ / u₀)² * u³ + (e₀² * (e₀ − pu₀ * u₀)))¹ᐟ³
e(u) = (e₀² * (e₀ − pu₀ * u₀ * (1 − (u / u₀)³)))¹ᐟ³
*Not a calculus whiz at all but enough of a math hacker to type “f’(x) = A * ((x / B) / (f(x) / C))^2” into the amazing wolframalpha.com - Plug u₁ into the e(u) formula.
e1 = e(u₁)
= (e₀² * (e₀ − pu₀ * u₀ * (1 − (u₁ / u₀)³)))¹ᐟ³
= (100² * (100 − 0.00181818 * 30,000 * (1 − (20,000 / 30,000)³)))¹ᐟ³
= 85.0939
So after burning 10,000 USM, the pool has dropped from 100 to 85.0939 ETH: ie, user A gets back 100 − 85.0939 = 14.9061 ETH.
Sanity check. A useful quick exercise is to plug in a few values to verify that our e(u) formula has the correct derivatives at the start and end points. Eg:
- At u₀ = 30,000, e(u₀) = 100
At u = u₀+0.0001 = 30,000.0001, e(u) = 100.000000181818
So e’(u₀) ≈ (100.000000181818 − 100) / 0.0001 = 0.00181818. - At u₁ = 20,000, e(u₁) = 85.0938578530181
At u = u₁+0.0001 = 20,000.0001, e(u) = 85.0938579646166
So e’(u₁) ≈ (85.0938579646166 − 85.0938578530181) / 0.0001 = 0.001115985. - As discussed above, the derivative e’(u) is the USM price. So the ratio of the derivatives should match the ratio of the prices, which should be the square of the ratio of the debt ratios, (dr₁ / dr₀)²:
Ratio of derivatives: 0.001115985 / 0.0018181818 = 0.61379176
dr₀ = 30,000 / (100 * 500) = 0.6
dr₁ = 20,000 / (85.0938579646166 * 500) = 0.4700691796
Ratio of debt ratios squared: (0.4700691796 / 0.6)² = 0.61379176
So the sanity-check checks out: our formula appears to be sliding the price as intended.
Formulas for the other three operations, mint
/fund
/defund
. These can be derived by similar reasoning. (In particular, as a shortcut for mint
we can just calculate the inverse u(e) of burn
’s e(u) above.) The other three full derivations are left as an exercise, but you end up with these four core formulas:
mint
: u(e) = ((((e / e₀)³ − 1) * e₀ / pu₀ + u₀) * u₀²)¹ᐟ³burn
: e(u) = (e₀² * (e₀ − pu₀ * u₀ * (1 − (u / u₀)³)))¹ᐟ³fund
: f(e) = f₀ + e₀ * (1 − e₀ / e) / pf₀
(pf₀ = the initial FUM buy price in ETH terms)defund
: e(f) = e₀ * (1 − 1 / (1 + e₀ / (pf₀ * (f₀ − f))))
You can check that these formulas match the corresponding functions in USMTemplate.sol
: usmFromMint()
, ethFromBurn()
, fumFromFund()
, and ethFromDefund()
.