Users can dodge losses due to StRSR era changes with instant operations
mediumLines of code
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;
SolidityFile: 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
stakeRatewrapping withunstakeandcancelUnstakecalls - sandwiching the
draftRatewrapping withcancelUnstakeandunstakecalls
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
stakeRatewill 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 callscancelUnstake - 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
