An Independent Investigation Report of Compound Bad Debts Caused by the UNI Price Surge

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.

1 Verify the bad debts

One of four bad debts addresses recorded at RISK DAO is 0x6980a47beE930a4584B09Ee79eBe46484FbDBDD0, its financial state on Compound V2 can be checked.

1.1 Liquidity of accounts

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).

The error shall be 0 on success, otherwise an error code.
A non-zero liquidity value indicates the account has available account liquidity.
A non-zero shortfall 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.

1.2 Details of supplied and borrowed assets

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.

2 How it happened

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.

Pic 1 token flow of the transaction caused a bad debt

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.

3 Open Price Feed

3.1 How price update works on Compound V2

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.

3.2 Verify the price oracle contract

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].

3.3 Price events during the incidence

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.

3.4 Critical parameters affect the price update

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.

prices

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

4 Some thoughts on the oracle

4.1 Is Compound V2's UniswapAnchoredView(UAV) a bad idea?

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.

4.2 Possible improvements to UniswapAnchoredView(UAV)

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?

4.3 Compensation fund

As nothing is 100% reliable, so a compensation fund is necessary before any incidents.

5 Acknowledgments

6 Reference

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