Light ModeLight
Light ModeDark

One Bug Per Day

One H/M every day from top Wardens

Checkmark

Join over 1130 wardens!

Checkmark

Receive the email at any hour!

Ad

Users can dodge losses due to StRSR era changes with instant operations

mediumCode4rena

Lines of code

https://github.com/code-423n4/2024-07-reserve/blob/3f133997e186465f4904553b0f8e86ecb7bbacbf/contracts/p1/StRSR.sol#L440-L469

Vulnerability details

The StRSR contract implements an era-based wrapping of the stakeRate and draftRate exchange rates whenever these pass the maximum accepted value of MAX_STAKE_RATE and MAX_DRAFT_RATE; the effect of this action is that StRSR token holders see their balance reset (stakeRate reset) and/or all StRSR vesting withdraws are forgotten and the relative funds lost (draftRate reset).

From the code below, we can see that these two rates can be reset independently;

Solidity
File: StRSR.sol 424: function seizeRSR(uint256 rsrAmount) external { --- 440: // Remove RSR from stakeRSR 441: uint256 stakeRSRToTake = (stakeRSR * rsrAmount + (rsrBalance - 1)) / rsrBalance; 442: stakeRSR -= stakeRSRToTake; 443: seizedRSR = stakeRSRToTake; 444: 445: // update stakeRate, possibly beginning a new stake era 446: if (stakeRSR != 0) { 447: // Downcast is safe: totalStakes is 1e38 at most so expression maximum value is 1e56 448: stakeRate = uint192((FIX_ONE_256 * totalStakes + (stakeRSR - 1)) / stakeRSR); 449: } 450: if (stakeRSR == 0 || stakeRate > MAX_STAKE_RATE) { 451: seizedRSR += stakeRSR; 452: beginEra(); 453: } 454: 455: // Remove RSR from draftRSR 456: uint256 draftRSRToTake = (draftRSR * rsrAmount + (rsrBalance - 1)) / rsrBalance; 457: draftRSR -= draftRSRToTake; 458: seizedRSR += draftRSRToTake; 459: 460: // update draftRate, possibly beginning a new draft era 461: if (draftRSR != 0) { 462: // Downcast is safe: totalDrafts is 1e38 at most so expression maximum value is 1e56 463: draftRate = uint192((FIX_ONE_256 * totalDrafts + (draftRSR - 1)) / draftRSR); 464: } 465: 466: if (draftRSR == 0 || draftRate > MAX_DRAFT_RATE) { 467: seizedRSR += draftRSR; 468: beginDraftEra(); 469: }

... and we'd then argue that they will most likely happen at different times, at the very least because rewards contribute to the evolution of stakeRate only.

In the event only one of the two rates reaches the cap and undergoes a reset, stakers can dodge the bullet of collectivized losses, by:

  • sandwiching the stakeRate wrapping with unstake and cancelUnstake calls
  • sandwiching the draftRate wrapping with cancelUnstake and unstake calls

Impact

Users can avoid the collectivization of rate wrapping losses, and artificially get a share of any RSR left in the StRSR contract.

This latter quantity may be considerable, because while unlikely, it's not impossible that the rate wrapping happens when little RSR is seized from an otherwise large RSR balance in the StRSR contract.

Proof of Concept

Ashley is an StRSR token holder:

  • she notices that a transaction will soon cause RSR to be seized and the stakeRate will wrap to 1.0
  • she immediately initiates an unstake, so her funds are moved to the drafts bucket
  • immediately after the transaction that wraps the stakeRate, she calls cancelUnstake
  • Alice lost only the pro-rata RSR that was seized (which may be minimal) while others lost everything

A similar sequence can be performed to avoid losses on funds that are being unstaked by instead calling cancelUnstake first and unstake later.

Tools Used

Code review, Foundry

Recommended Mitigation Steps

Consider wrapping both rates when at least one reaches the maximum.

Assessed type

Other