USM “minimalist decentralized stablecoin” part 4: fee math decisions
USM has now had a testnet release, a security audit, and a 1-month “baby USM” (v0.1) trial mainnet release. We’re nearing the full mainnet v1 release… But we have some final design decisions to make before we do. There are three main questions:
- Fee math: what mathematical formula to use to calculate the fees
- Moving mid: whether the system’s mid ETH/USD price should stay fixed between oracle updates, or be moved around by mint/burn operations
- Migration: whether to implement a fee-saving migration mechanism to a future v2, or stick with simple user-withdraws-from-v1-and-deposits-to-v2
This post summarizes some arguments for and against making further changes on these three fronts. The whole point of this post is to gather feedback — don’t be shy! You can contact/follow @usmfum on Twitter, join our Discord for discussion, or reply to this post directly.
Contents:
A. Current concrete proposals for v1
B. Risks and tradeoffs of different fee approaches
C. Migration mechanism
D. Appendix: messy parts of the fee math
If you’re new to USM check out the previous posts: part 1 in July 2020 that proposed the idea, and part 2 (August) and part 3 (November) that further fleshed it out.
(Note: this post is still in draft form and may be revised.)
A. Current concrete proposals for v1
Our 1-month v0.1 trial release had no reported problems, so the simplest path forward would be to just release that code as v1. Still, there are scenarios we foresee that ideally we’d like to make the system more robust against. The following is the behavior we currently propose for v1:
- Fee math: Changed to be based on % change in the system’s ETH pool. For example, suppose the pool contains 100 ETH @ $1,200 for a total pool value of $120,000, and you call
mint(5 ETH)
, increasing the pool by 5%: this increases the USM mint price by 5%, ie, makes minting 5% more expensive. Since minting USM is economically equivalent to selling ETH, this is equivalent to reducing the ETH/USD price used inmints
by 5%, from $1,200 to $1,140: before yourmint
, minting 1 ETH gets you 1,200 USM; after it, 1 ETH gets you 1,140 USM.
Formerly (eg, in v0.1, and as described in part 3), the fee was based on % change in debt ratio, not in ETH pool. See section B below for the rationale for this change.
Forfund
/defund
, the new math is based on % change in ETH pool as above, but with an extra “FUM delta” factor based on how leveraged FUM is relative to USM right now. For example, when the debt ratio is 90% (the system is almost underwater), every 1% change in the ETH price moves the FUM price 10x as much — by 10%. So we makefund(1 ETH)
move the price by 10x as much asmint(1 ETH)
. Again, see section B. - Moving mid: Under the old v0.1 design, the “sliding prices” — impact of mint/burn operations on price — are temporary: over the minutes after the operation, the system’s ETH price gradually reverts to the oracle’s price. Under the new v1 proposal, half of an operation’s price impact is permanent, at least until the next oracle update.
Eg: under the old approach, the price is $1,200, then amint
pushes it down to $1,140, and then over the ensuing minutes it climbs back to $1,200. Under the new approach, the price is $1,200, amint
pushes it down to $1,140, and then it climbs back to $1,170. See section B (in particular, the “price over time” charts) for why we do this. - Migration: Our decision whether to include a discounted-fee migration mechanism is still pending. It comes down to a tradeoff between simplicity/system safety, and user convenience (fees). But by default, unless we feel very confident we have a migration mechanism that’s safe and clean, we’ll skip it in the name of simplicity, and stick with an external (no special permissions, no fee discount) migration function.
B. Risks and tradeoffs of different fee approaches
To understand the rationale for our latest fees proposal, it helps to understand the evolution of our thinking on this topic from the beginning. We want the system to be as simple as possible — but no simpler!
Method #1: flat fee
The simplest thing is a flat x% fee on all operations, like Uniswap’s 0.3%. But as discussed in detail in part 2, this makes the system highly vulnerable to an even temporarily inaccurate or laggy oracle price: a flat fee “does nothing to prevent these opportunities being exploited in size.” Since our oracle price will be at least a little inaccurate at least some of the time, “buys should push price up and sells should pull price down, so that exploiting a price inaccuracy also reduces it.”
Method #2: sliding prices, two separate price adjustments
So, part 2 introduced “sliding prices”: the more you buy (USM or FUM), the more expensive buying gets (temporarily). This ensures that when our oracle’s price is laggy, traders can only exploit it with small trades, which then push up the price, making further arbitrage unprofitable.
Part 2’s solution involved two separate price adjustments: one based on whether there had been more mints
than burns
recently or vice versa; the other based on whether there had been more funds
than defunds
. But as part 3 said, “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.”
Method #3: one unified price adjustment, based on debt ratio
To avoid overcharging users this way, part 3 merged the two adjustments into a single buy/sell adjustment: “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)².”
This basically works! It’s what we deployed as v0.1. But “impact on debt ratio” is a somewhat unreliable proxy for a trade’s “long-/shortness”. In particular, short-ETH operations normally increase debt ratio — except a mint
while debt ratio is > 100%, which reduces debt ratio. For v0.1 we worked around this with a hack: handling that > 100% mint
as a special case. But if a simpler accurate proxy for long-/shortness exists, it could keep the code clean (special cases are generally risk-prone) and perhaps prevent traders from exploiting our inexact accounting of long vs short.
Method #4: adjustment based on change in ETH pool, not debt ratio
A simple alternative to “If a trade moves debt ratio by 5%, it moves buy/sell price by 5%” is “If a trade moves the ETH pool by 5%, it moves buy/sell price by 5%.” In fact, this is so simple that it was the first sliding-price approach I tried: it closely resembles Uniswap’s constant-product rule. (Eg, if the pool contains 100 ETH @ $1,200 and you mint(5 ETH)
= increase the pool by 5%, this rule reduces the price by 5% to $1,143. And indeed this keeps the product — the pool’s $ value — constant: 100 × $1,200 = $120,000, 105 × $1,143 = $120,000.)
The reason I originally gave up on this approach is that, if implemented naively, it gives FUM funds
/defunds
the same price impact as USM mints
/burns
: mint(1 ETH)
reduces the system’s ETH price; fund(1 ETH)
increases it by the same amount. And intuitively this seems wrong: FUM is leveraged ETH, so fund(1 ETH)
should move the price more than mint(1 ETH)
. Perhaps a trader could do a bunch of low-leverage mints
to keep the price of their high-leverage funds
cheap? This doesn’t sound like a very damaging attack, but as we’ll see in #6 below, under some conditions it can be a concern.
Method #5: add “FUM delta” factor for fund
/defund
One possible tweak to the “% change in ETH pool” price adjustment scheme above is, keep it unchanged for mint
/burn
, but increase the price impact of FUM operations (fund
/defund
) based on how much “riskier” (more leveraged) FUM currently is than ETH. The idea is that at any given moment, we can measure how much riskier FUM is based on the debt ratio. Eg, the example from above: if the debt ratio is currently 90% (“almost underwater”), then FUM is 10x-leveraged ETH — has a “delta” of 10. So when the debt ratio is 90%, fund(1 ETH)
moves the price 10x as much as mint(1 ETH)
; when debt ratio = 60%, fund
moves price 2.5x as much as mint
; and in general, when debt ratio = x%, fund
moves price 1/(1−x/100) as much as mint
.
Trying to work this out becomes a bit of a mathematical rabbit hole, because the delta changes as the ETH pool changes, ie, during the operation… So calculating price impact based on the exact delta involves some brutal integrals. But we can approximate by just fixing the delta at whatever it is at the beginning of the operation. And this does indeed result in somewhat cleaner, less special-casey math than the debt-ratio-based math in #3 above.
Method #6: add “moving mid”
All the “sliding price” effects described so far are temporary. You mint (say) some USM; this pushes up the price of USM for a few minutes, so that if you were getting USM at an unreasonably cheap price, you only get a small amount of it. This protects the system from the oracle price being laggy or otherwise off for a few minutes.
But what if the oracle is off for more than a few minutes — due to an outage, or just an infrequently updated source + volatile ETH/USD price action, or even something silly like skyrocketing gas prices making oracle updates fail? If our system’s price keeps regressing to the oracle price, and the oracle price remains stale for more than a few minutes, then traders can keep exploiting the inaccurate price — eg, minting USM at 1,200 USM per ETH, even though the live market price of ETH has dropped to $1,100 (mint(1 ETH)
should now give you only 1,100 USM, but is still giving you 1,200).
If we can make our system robust against these cases, we can significantly relax our expectations of our oracle. Ideally, the USM system would resist price exploits as long as the oracle is accurate “eventually in expectation”, rather than “at every moment in time.” One approach that may provide this robustness is a “moving mid”: making trades have not just a temporary price impact, but additionally a lasting impact on the ETH/USD price (until the next oracle update).
Implementing this “moving mid” isn’t hard: in addition to the temporary price adjustment from before, we store an ETH/USD price separate from the oracle’s price (resetting to the oracle’s price when it sends a fresh one — the moving mid protects against a laggy/stuck oracle, not a repeatedly mispricing one).
But, there is a risk tradeoff here. The good news is that the system is better protected against an oracle outage. But the bad news (perhaps?) is that system safety becomes more dependent on correctly calculating the delta of funds
/defunds
vs mints
/burns
. With a static oracle mid (plus temporary price adjustment), no matter how many mints
you do, funds
will never be cheaper than the oracle price; but with a moving mid, you could do a bunch of mints
to push the system’s ETH price down, then use that manipulated price to do cheap funds
. This is a complex issue: there’s reason to think the system fees are sufficient to prevent any such exploit from being profitable, but it does need scrutiny.
An example of the sort of exploit we have to be mindful of when we make mint
/burn
and fund
/defund
share a single price adjustment, or both move mid up and down, is the following:
- User wants to buy f FUM with ETH. The simple way for them to do this is a
fund
operation, spending an amount e1 of ETH that returns f FUM. - But alternatively, the user could do this:
1. Spend e2 ETH on amint
op, getting back u USM (and pushing down the system’s ETH price, and the FUM price by even more)
2. Spend e3 (< e1) ETH on afund
op at the reduced FUM price, calculating e3 so that it returns the same f amount of FUM as before
3.burn
the u USM minted, getting back e4 ETH - In the first trade (plain
fund
), the user spends e1 ETH for f FUM. In the second trade (mint
+fund
+burn
), the user spends a total of (e2 + e3 − e4) ETH for f FUM. - So, if, for a given target FUM amount f, we can choose an e2 such that e2 + e3 − e4 < e1, then the second trade is strictly cheaper for the user than the first (at least in principle — leaving aside gas fees etc). This would be bad: we want
fund
to be the cheapest way to buy FUM, rather than incentivize users to manipulate the price like this.
I don’t think fee methods like our #6 above are vulnerable to such exploits, but it’s a nontrivial question. (I suspect #4 might be.)
So, which of these should we use?
Several of the approaches discussed here seem reasonably likely to work (avoid getting drained by price arbitrageurs), eg:
- #3: unified debt-ratio-based sliding price, static mid (like v0.1)
- #6: ETH-pool-based sliding price (with FUM delta), moving mid
Or there are even other combinations, like #2 (two separate sliding price adjustments, mint
/burn
and fund
/defund
) with a moving mid… There’s no one obviously optimal choice. As of this writing (Jan 27 2021), our repo uses method #6 above, and we may well go with that for v1.
But one thing that’s clear from this whole multi-month exploration of the problem is that the fee design is still a live research area. I expect our thinking will continue to evolve as to the best balance of robustness to imperfect oracles, user-friendly fees, and simplicity/comprehensibility (a key design criterion for USM!). But at this point, some of these refinements are best left for v2 so we can get v1 out the door!
C. Migration mechanism
The v1->v2 migration mechanism decision is much simpler and less important than the fee math decision! We’re not going to take full admin-keys control and impose updates on users: USM will be an immutable, ownerless, opt-in-upgrades system. Our choice is relatively narrow, but we still need to make it: do we give ourselves the limited power to store a (future) v2 contract’s address in v1, so that v1 can give a fee discount to users migrating their USM/FUM to v2? (This was the proposal sketched in section F of my very first July 2020 USM post part 1.)
Or, do we eschew admin keys entirely, create v2 as an entirely separate contract v1 doesn’t know about, and make “migration” just a gloss for “burn
/defund
your v1 USM/FUM out of v1, and use the resulting ETH to mint
/fund
fresh v2 USM/FUM”?
I originally thought the tradeoff involved convenience as well as fees: but as Alberto rightly pointed out, we can make it convenient even without admin keys, via a proxy contract migrate()
function that does the withdraw-from-v1-and-deposit-into-v2 for you. What a proxy function can’t do is save you fees.
See some related discussion in github issue #25 and, most recently, Alex’s PR #89.
D. Appendix: messy parts of the fee math
These are some notes about the math involved in the different fee approaches from section B above. A good case to start with is fees method #4 (price adjustment based on change in ETH pool). Calculating the change in price is easy:
- We start with 100 ETH (at $1,200 = $120,000) in the pool, and 60,000 USM outstanding: debt ratio = 60,000 / $120,000 = 50%.
- The user calls
mint(10 ETH)
. This increases the pool from 100 to 110. So, using method #4, this operation increases the USM buy price by the same factor, from 1 / $1,200 = 0.0008333 ETH per USM, to 1 / $1,200 × (110 / 100) = 0.0009167 ETH per USM.
That’s fine: but how do we calculate the total USM u returned by the call, with the price sliding up during the operation? As described in part 2, this is a standard integral: u = the integral of 1/pᵤ(E) from E = E₀ to E₁, where E₀ and E₁ are the starting and ending amounts of ETH in the pool, and pᵤ(E) is the USM price in ETH terms. So we have:
E₀ = 100, E₁ = 110
pᵤ(E) = (1 / $1,200) × (E / E₀)
u = integral of 1/pᵤ(E) from E = E₀ to E₁
= integral of (1 / $1,200) × (E / E₀) from E = E₀ to E₁
= $1,200 × E₀ × ln(E / E₀) from E = E₀ to E₁
= $1,200 × 100 × ln(110 / 100)
= 11,437 USM
Which passes a sniff test: if we’d paid our starting USM price of 1 ETH per 1,200 USM the whole way, we’d have minted 10 ETH × 1,200 = 12,000 USM. Since our buy price increased slightly during our mint
, it makes sense that we ended up with somewhat less than 12,000.
Now, what if we incorporate method #6’s “moving mid”? Then the USM returned by mint
is exactly the same as above, since the USM buy price increases along exactly the same curve. The difference is that the system’s mid moves too. Using method #4 above, the state we ended up in was:
- 110 ETH (at $1,200 = $132,000) in the pool, 71,437 USM, debt ratio 71,437 / $132,000 = 54.12%.
The difference in method #6 is that the mid moves as well: since the ETH pool increased by a factor of 110 / 100, the ETH mid decreases by a factor of sqrt(100 / 110) = 0.9535 (by a factor “half as much” as 100 / 110 = 0.9091, in log terms): $1,200 × 0.9535 = $1,144.16. So under this method our mint
’s end state is:
- 110 ETH (at $1,144.16 = $125,857) in the pool, 71,437 USM, debt ratio 71,437 / $125,857 = 56.76%.
So far this is all quite clean and simple (OK, we have to calculate logarithms in Solidity, not super pleasant but doable). But now what happens when we add in method #5’s “FUM delta” factor, and try to calculate the delta-influenced return values of fund
/defund
? This is a much hairier story I may spell out in detail at some point: for now just note that the delta itself depends on the current ETH mid price, which is shifting during the operation, so the integrals get considerably more challenging… Fortunately there are approximations that get the job done (I think).