On February 23 2024, Uniswap Foundation's key leader Erin Koen submitted a proposal to overhaul the protocol's governance system and distribute protocol fees among UNI token holders, in the following minutes, UNI's price jumped 50%[1]. This is such an exciting news for the whole crypto community except the borrowing defi protocol Compound, specifically Compound V2.
According to RISK DAO, by taking advantage of the price update delay on Compound V2, four addresses(hackers, MEV bots or thieves whatever you may call them) make a lucrative profit and left debts(56045 UNI worth roughly $560,000 as of this writing) that will never be repaid.
There are some analysis about this incident[2][3], but they are either too simple or interest-related. This is why I do this independent investigation to explain what happened and how it happened in details with code and evidences, some reflections and possible improvements about Compound V2's unique price oracle will also be included in the end.
Thanks to the transparency and permisionless of the block-chain technology, we don't have to trust any authorities but verify by ourselves, all the code can be found on Codeberge or Github, let's dive into.
One of four bad debts addresses recorded at RISK DAO is
0x6980a47beE930a4584B09Ee79eBe46484FbDBDD0
, its financial state on Compound
V2 can be checked.
We start by getting the account liquidity with the Comptroller's
function getAccountLiquidity(address account) view returns (uint, uint, uint)
[4],
three return values are explained as follow
RETURN: Tuple of values (error
,liquidity
,shortfall
). Theerror
shall be 0 on success, otherwise an error code. A non-zeroliquidity
value indicates the account has available account liquidity. A non-zeroshortfall
value indicates the account is currently below his/her collateral requirement and is subject to liquidation. At most one of liquidity or shortfall shall be non-zero.
The outcome of the query
(CompoundTask.printAccountLiquidity)
is: 0 0 153062691165337132810343
,
so the account should be liquidated in theory, but in practice, that's impossible
due to the shortage of collateral. You'll see this obvious fact
after we check all supplied and borrowed token of this account on Compound V2.
Not all tokens are supported as a collateral on Compound, we can query all
supported tokens(or so called cToken) one by one from the price oracle contract
with function getTokenConfigByIndex
, the address of the price oracle can be
found on Compound V2 document[5](contract name is UniswapAnchoredView)
or we can query it from the comptroller contract since the price oracle is a
public state of the comptroller contract, its name is oracle
[6].
The outcome of the query(CompooundTask.printAccountLiquidity) is:
token | borrowed | supplied |
---|---|---|
ETH | 0 | 0 |
DAI | 0 | 2.0863 |
USDC | 0 | 0.0581 |
USDT | 0 | 0 |
WBTC | 0 | 0 |
BAT | 0 | 0 |
ZRX | 0 | 0 |
REP | 0 | 0 |
UNI | 16148.5344 | 0 |
COMP | 0 | 0 |
LINK | 0 | 0 |
TUSD | 0 | 0 |
AAVE | 0 | 0 |
SUSHI | 0 | 0 |
MKR | 0 | 0 |
YFI | 0 | 0 |
USDP | 0 | 0 |
FEI | 0 | 0 |
Table 1 the financial state of a "thief" on Compound V2
As you can see the value of supplied assets far exceeds the value of borrowed
assets, in the wild defi world, the address 0x6980a47beE930a4584B09Ee79eBe46484FbDBDD0
could completely ignore this debt without any consequences, this is the
so-called bad debt. You can also check it on
debank.
The next question is how it happened?
In the normal case, an address can never borrow more than a reasonable portion of its collateral value. Under a dynamic market environment, once the borrowing value exceeds the borrowing capacity, the addresses is subject to liquidation to reduce its exposure and protect the protocol from bad debts.
In order to operate safely, the protocol must evaluate the value of assets(namely crypto tokens) precisely, both borrowed or supplied. In order to evaluate the value of assets precisely, the protocol must get the right prices of assets.
In this incident, things went wrong when the protocol fail to update the UNI's
price during its price pump. Let's look at one of the profitable transaction that
cased bad debts, the transaction hash is
0x5c5582403760d18aecba5189726fada3bf57677619493f7bfab98f5e7ade6213
.
Figure 1 Token Flow from EigenPi
The main actions of this transaction is as follow:
The result is that the "thief" gained 646,164.55832 - 639,114.87432 = 7049.684 USDC and 225.3543 - 220.6777 = 4.6766 Eth. You can also see more about this transaction on EtherScan, EigenPi or Tenderly.
Anyway the key step is step 3, with a collateral of 639,114.87432 USDC Compound shouldn't approve a borrow of 65,389.9895 UNI.
This transaction was executed at 2024-02-23T14:39:47Z[UTC], the price of UNI was
$8.96085, the price of USDC is fixed at $1[7], the collateral factor of
USDC is 85.5%(It's can be queried from comptroller contract, the code is
CompoundService.getUSDCCollateralFactor
), so the borrowing capacity of UNI can
be evaluated as:
639114.87432 * 0.855 / 8.96085 ≈ 60981 < 65389
However, Compound price oracle didn't update the UNI price, it used the stale
price $8.34, the borrowing capacity became:
639114.87432 * 0.855 / 8.34 ≈ 65520 < 65389
Why the UNI price didn't get updated on Compound V2? We'll explore this in the next section and give evidences about the price update actions with the help of Ethereum's log system.
According to the Compound document, this is how its price get updated:
The Open Price Feed accounts price data for the Compound protocol. The protocol’s Comptroller contract uses it as a source of truth for prices. Prices are updated by Chainlink Price Feeds. The Compound Protocol uses a View contract (“Price Feed”) which verifies that reported prices fall within an acceptable bound of the time-weighted average price of the token/ETH pair on Uniswap v3, a sanity check referred to as the Anchor price.
In short, Compound update its price from Chainlink price feed, but only accept Chainlink's prices when they fall within a bound of the anchor prices from Uniswap.
Every time the price was updated, the price oracle will emit PriceUpdated
event
or reject the price update and emit PriceGuarded
event.
/**
* @notice This is called by the reporter whenever a new price is posted on-chain
* @dev called by AccessControlledOffchainAggregator
* @param currentAnswer the price
* @return valid bool
*/
function validate(
uint256, /* previousRoundId */
int256, /* previousAnswer */
uint256, /* currentRoundId */
int256 currentAnswer
) external override returns (bool valid) {
// NOTE: We don't do any access control on msg.sender here. The access control is done in getTokenConfigByReporter,
// which will REVERT if an unauthorized address is passed.
TokenConfig memory config = getTokenConfigByReporter(msg.sender);
uint256 reportedPrice = convertReportedPrice(config, currentAnswer);
uint256 anchorPrice = calculateAnchorPriceFromEthPrice(config);
PriceData memory priceData = prices[config.symbolHash];
if (priceData.failoverActive) {
require(anchorPrice < 2**248, "Anchor too big");
prices[config.symbolHash].price = uint248(anchorPrice);
emit PriceUpdated(config.symbolHash, anchorPrice);
} else if (isWithinAnchor(reportedPrice, anchorPrice)) {
require(reportedPrice < 2**248, "Reported too big");
prices[config.symbolHash].price = uint248(reportedPrice);
emit PriceUpdated(config.symbolHash, reportedPrice);
valid = true;
} else {
emit PriceGuarded(config.symbolHash, reportedPrice, anchorPrice);
}
}
Code snippet of price oracle price update event
But before we query these events from Ethereum, we need verify which oracle was used during the incident. We can do this in two steps: 1. Query the oracle contract that's used right now; 2. Query oracle update events from the time of the incident to now.
If there's no oracle update events since the time of the incident, then it indicates the oracle used right now is the same as the oracle used during the incident.
We can confirm this by running the code CompoundTask.getPriceOracle()
and
CompoundTask.getNewPriceOracleEvent()
.
It's true that the price oracle contract address is
0x50ce56A3239671Ab62f185704Caedf626352741e
. One thing worth mentioning is that
the source code of the oracle is not in Compound Github Repository, but in
repository due to the
Uniswap Anchor Price upgraded to Uniswap V3[9][10].
Now we can query the price related event of the oracle from Ethereum
(CompoundTask.getUniswapAnchoredViewEvent()
)
Here's the results(only include price events of the UNI token):
eventName(topic1) | blockTime | token(topic2) | data |
---|---|---|---|
priceUpdate | 2024-02-23T14:28:11Z[UTC] | cUNI | 8235910 |
priceUpdate | 2024-02-23T14:28:35Z[UTC] | cUNI | 8340000 |
priceGuarded | 2024-02-23T14:29:11Z[UTC] | cUNI | 8617324 7319215 |
priceGuarded | 2024-02-23T14:29:47Z[UTC] | cUNI | 8960850 7352224 |
priceGuarded | 2024-02-23T14:30:35Z[UTC] | cUNI | 9167836 7397947 |
priceGuarded | 2024-02-23T14:31:11Z[UTC] | cUNI | 9273831 7435771 |
priceGuarded | 2024-02-23T14:31:35Z[UTC] | cUNI | 8894508 7458111 |
priceGuarded | 2024-02-23T14:33:35Z[UTC] | cUNI | 9015368 7569306 |
priceGuarded | 2024-02-23T14:34:23Z[UTC] | cUNI | 9287322 7620950 |
priceGuarded | 2024-02-23T14:34:35Z[UTC] | cUNI | 9474361 7634679 |
priceGuarded | 2024-02-23T14:35:11Z[UTC] | cUNI | 9713000 7678319 |
priceGuarded | 2024-02-23T14:35:35Z[UTC] | cUNI | 10158692 7711405 |
priceGuarded | 2024-02-23T14:36:11Z[UTC] | cUNI | 9864067 7760138 |
priceGuarded | 2024-02-23T14:36:35Z[UTC] | cUNI | 9994048 7794356 |
priceGuarded | 2024-02-23T14:37:11Z[UTC] | cUNI | 9824306 7845182 |
priceGuarded | 2024-02-23T14:37:35Z[UTC] | cUNI | 10156745 7880563 |
priceGuarded | 2024-02-23T14:38:11Z[UTC] | cUNI | 10287417 7933538 |
priceGuarded | 2024-02-23T14:39:11Z[UTC] | cUNI | 10500752 8029309 |
priceGuarded | 2024-02-23T14:40:35Z[UTC] | cUNI | 10266613 8166150 |
priceGuarded | 2024-02-23T14:42:35Z[UTC] | cUNI | 10467075 8364499 |
priceGuarded | 2024-02-23T14:44:11Z[UTC] | cUNI | 10274830 8529199 |
priceUpdate | 2024-02-23T14:48:11Z[UTC] | cUNI | 10378954 |
priceUpdate | 2024-02-23T14:48:35Z[UTC] | cUNI | 10210978 |
priceUpdate | 2024-02-23T14:49:11Z[UTC] | cUNI | 10381361 |
The data field of priceUpdate
event is the new price(scaled by 1e6); the
data priceGuarded
event is the reported price and the anchor price(scaled by
1e6).
These events show that Compound V2 started reject UNI price update from 2024-02-23T14:29:11Z[UTC] and keep the old price $8.34 all the way to 2024-02-23T14:48:11Z[UTC].
When transaction 0x5c5582403760d18aecba5189726fada3bf57677619493f7bfab98f5e7ade6213
was executed at 2024-02-23T14:29:47Z[UTC], the UNI's price on Compound v2 was
still $8.34.
There're three critical parameters that affect the price update: anchorPeriod , lowerBoundAnchorRatio and upperBoundAnchorRatio.
anchorPeriod is the time interval to search for Uniswap's Time Weighted Average Prices(TWAPs), namely anchor prices used in Compound V2. The longer the anchorPeriod is, the more expensive to manipulate the TWAP and more safer, but it results in a less up-to-date price. See more about the TWAP at the appendix Time-weighted Average Price in Uniswap v3 Explained
The current anchorPeriod is 1800 seconds, we can see totally different TWAP by choosing different periods.
Figure 2 Different periods results in different TWAP
lowerBoundAnchorRation and upperBoundAnchorRatio is the lower and upper bound of the legal discrepancy between the anchor price and the reported price.
The current lowerBoundAnchorRation is 85%
and upperBoundAnchorRatio is
115%
, in the priceGuarded
events these anchor Rations of reported prices are
eventName | blockTime | reported | anchor | anchorRation |
---|---|---|---|---|
priceGuarded | 2024-02-23T14:29:11Z[UTC] | 8617324 | 7319215 | 117% |
priceGuarded | 2024-02-23T14:29:47Z[UTC] | 8960850 | 7352224 | 121% |
priceGuarded | 2024-02-23T14:30:35Z[UTC] | 9167836 | 7397947 | 123% |
priceGuarded | 2024-02-23T14:31:11Z[UTC] | 9273831 | 7435771 | 124% |
priceGuarded | 2024-02-23T14:31:35Z[UTC] | 8894508 | 7458111 | 119% |
priceGuarded | 2024-02-23T14:33:35Z[UTC] | 9015368 | 7569306 | 119% |
priceGuarded | 2024-02-23T14:34:23Z[UTC] | 9287322 | 7620950 | 121% |
priceGuarded | 2024-02-23T14:34:35Z[UTC] | 9474361 | 7634679 | 124% |
priceGuarded | 2024-02-23T14:35:11Z[UTC] | 9713000 | 7678319 | 126% |
priceGuarded | 2024-02-23T14:35:35Z[UTC] | 10158692 | 7711405 | 131% |
priceGuarded | 2024-02-23T14:36:11Z[UTC] | 9864067 | 7760138 | 127% |
priceGuarded | 2024-02-23T14:36:35Z[UTC] | 9994048 | 7794356 | 128% |
priceGuarded | 2024-02-23T14:37:11Z[UTC] | 9824306 | 7845182 | 125% |
priceGuarded | 2024-02-23T14:37:35Z[UTC] | 10156745 | 7880563 | 128% |
priceGuarded | 2024-02-23T14:38:11Z[UTC] | 10287417 | 7933538 | 129% |
priceGuarded | 2024-02-23T14:39:11Z[UTC] | 10500752 | 8029309 | 130% |
priceGuarded | 2024-02-23T14:40:35Z[UTC] | 10266613 | 8166150 | 125% |
priceGuarded | 2024-02-23T14:42:35Z[UTC] | 10467075 | 8364499 | 125% |
priceGuarded | 2024-02-23T14:44:11Z[UTC] | 10274830 | 8529199 | 120% |
Table anchorRation of reported prices during UNI pump
No, I don't think so, at least in theory.
A good oracle never rely on a single price data source but aggregate multiple sources to provide accurate and reliable prices that reflect the overall market situation.
Before the DAI liquidation event[11], the compound v2 uses Coinbase Oracle whose data come from a single source: the Coinbase market. So it's necessary to check it with the other data source, here is the anchor price from Uniswap.
The weakness or flaw is that both Coinbase oracle and Uniswap oracle use only a single data source.
Then Compound v2 switched its price oracle from Coinbase to Chainlink price feed. Unlike Coinbase oracle, Chainlink is much more reliable by using multiple layers of aggregation that smooth outliers and prevent manipulated data from being delivered to smart contracts[12].
Even though Chainlink oracle is very reliable, it seems that checking it with the Uniswap Anchor Price is still a good idea and avoid the so called a single point of failure, because no matter how reliable the Chainlink is, it has a very small chance of failure.
The rationale here is that we believe anchor price must be in the some bound of the real price or conversely the real price must be in the same bound of the anchor price. We trust this fact more than Chainlink, so when the price provided by Chainlink is not within the bound, the Compound rejects it.
But that's not true in this incident due to the UNI price changed so fast that the Uniswap time-weighted average price fail to track it well with the current parameters.
The simple solution of this issue is to disable UAV[13]. Since I think UAV is not a bad idea, so I came up with some improvements to UAV.
Besides these improvements, there is another important situation worth thinking. What to do if the chainlink price is out of the bound of the anchor price?
As nothing is 100% reliable, so a compensation fund is necessary before any incidents.
1 CoinDesk. uniswaps uni jumps 60% on proposal to reward token holders in major goverance overhaul 2 ChaosLab founder Omer. Compound V2 Oracle Failure Causing Bad Debt 3 黄世亮. compound因uni瞬间拉盘而产生66万美元的坏账 4 Compound V2 Document. account-liquidity 5 Compound V2 Dcoment. Open Price Feed 6 Compound V2 Source Code. ComptrollerStorage.sol 7 Compound V2 Document. Open price Feed Architecture 8 open oracle source code 9 CL_Michael. Upgrade Compound II’s Oracle to UAV3 10 Songziyu2003. The UniswapAnchoredView contract source code deployed is different from github source code 11 DAI Liquidation Event 12 Proposal to Integrate Chainlink Price Feeds 13 CIP-4: Upgrade Compound v2 Oracle To Disable UAV
Written by Songziyu @China Apr. 2024