A minipool is funded with 16 ETH from the node operator and 16 ETH from rETH stakers. Upon
withdrawal any incurred losses are taken out of the node operator's share first. If a minipool
ends up with a balance below 16 ETH, an appropriate part of the node operators RPL collateral is
supposed to be sold to make up for the shortfall and protect rETH stakers. This report outlines
how a flawed auction mechanism can be exploited to enrich the exploiter and cause the loss of
rETH staker funds in this scenario.
When a slashing of RPL happens the amount of RPL tokens slashed is based on the shortfall of
the minipool balance compared to the user deposit balance (see here and here).
The slashed tokens are assigned to the RocketAuctionManager by slashRPL(). Whenever it has at least 1 ETH worth of RPL anyone can call createLot() to start an auction. According to the settings, the price starts at 100% of the RPL price and decreases every block until it reaches 50% of the starting price (see getLotPriceAtBlock()).After an auction was started anyone can call placeBid()
and send some ETH with it.
The critical flaw in the mechanism is the way in which bids are settled in claimBid():
ββββ// Get lot price info
ββββuint256 blockPrice = getLotPriceAtCurrentBlock(_lotIndex);
ββββuint256 bidPrice = getLotPriceByTotalBids(_lotIndex);
ββββ// Check lot can be claimed from
ββββrequire(block.number >= getLotEndBlock(_lotIndex) || bidPrice >= blockPrice, "Lot has not cleared yet");
ββββ// Get & check address bid amount
ββββuint256 bidAmount = getLotAddressBidAmount(_lotIndex, msg.sender);
ββββrequire(bidAmount > 0, "Address has no RPL to claim");
ββββ// Calculate current lot price
ββββuint256 currentPrice;
ββββif (bidPrice > blockPrice) { currentPrice = bidPrice; }
ββββelse { currentPrice = blockPrice; }
ββββ// Calculate RPL claim amount
ββββuint256 rplAmount = calcBase.mul(bidAmount).div(currentPrice);
getLotPriceByTotalBids() calculates the RPL price as the fraction of total bids and total RPL in the lot. This means thatif someone places a bid for less than the maximum amount(based on the auction price at that time) and then some blocks pass without someone else bidding on the lot, the initial bidder's settling price improves over time. Because there is a gas cost associated with placing and claiming of bids, this leads to a first mover advantage: The first bidder can place a bid for most of the lot and leave just enough over so that it is not profitable for anyone else to bid. Eventually the first bidder will be able to claim the entire lot for a below market rate, resulting in losses for rETH stakers.
One could take advantage of this with a simple flashbot bundle. The most reliable way would be to watch the mempool for an appropriate RPL slashing and including it in the bundle, but it's also possible to just wait for the slashig to happen on chain and to submit the bundle then.
Assuming that RocketAuctionManager has less than 1 ETH worth of RPL assigned to it, wait for a slashing that increases the balance to 1 ETH+, then submit the following bundle:
createLot()
placeBid()
with an appropriate bid amount < maximum bid amountTo estimate the impact of this, one has to take into account the gas needed for various
transactions involved (see RocketAuctionManager on Goerli Testnet):
createLot()
: 250,000+placeBid()
: 210,000+claimBid()
: 110,000+This means that a potential first mover has to spend a total of ~700k gas to execute the exploit
and then sell the RPL for a profit, while a second bidder would have to spend ~450k gas to place a bid and then sell for a profit.
I considered a simplified model with constant gas price and constant RPL to ETH ratio to gain some intuition:
Let bid_first
be the bid amount of the first mover and bid_max
the maximal possible bid for the lot at the start of the auction. It can be shown that the optimal strategy for a secondary bidder is to bid immediately with bid_second = sqrt(bid_max * bid_first) - bid_first
. The profit before gas for a secondary bidder can be calculated as:
ββββprofit_second = bid_max * (1 - sqrt(bid_first/bid_max)) - sqrt(bid_max * bid_first) + bid_first
So the first mover would choose bid_first
such that:
ββββprofit_second < 450,000 * gas_price
To make it unprofitable for anyone to bid after them and eventually claim the entire lot for
bid_first
.
For example, if a lot has a value of 2 ETH (bid_max = 2
) and the gas price is 50 gwei, a first mover could choose bid_first = 1.5982
. The best a second bidder could do is bid bid_second = 0.1896
to achieve profit_second
just shy of the necessary gas cost. The first mover would purchase RPL at 20% below market value.
Similarly, for bid_max = 1.6
and gas price of 75 gwei, the optimal value is bid_first = 1.1690
(leading to 27% below market) and forbid_max = 5
and a gas price of 50 gwei we get bid_first = 4.3517
(13% below market).
In general a smaller lot size and a higher gas price leads to bigger damage relative to the fair value. In reality an exploiter might choose slightly less aggressive first bids to account for variable gas price and RPL to ETH ratio.
Bidders should receive RPL based on the auction price when placeBid()
is called, there shouldn't be a price adjustment over time. This would result in a dutch auction. Bidders would end up buying RPL close to market value (after accounting for gas cost).
It seems possible to combine placeBid()
and claimBid()
into a single transaction in that case, which could lead to gas savings and further improve the efficiency of the mechanism.