FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: high

Rebase contractions causes FundLoss for stakers

Author Revealed upon completion

Root + Impact

Since totalEligibleStake and totalBonus are cached and not scaled according to token balance of pool, calling claimSurvived() would revert for late callers since actual balance of pool is less than totalEligibleStake + totalBonus.

Description

  • totalEligibleStake is calculated as a sum of funds recieved from each staker in the form of token balance, totalBonus is calculated based on time and amount of stake recieved. when a pool is finalized by flagOutcome() or claimExpired(), totalBonus && totalEligibleStake are logged.

  • Under Normal behaviour totalEligibleStake + totalBonus is always less than or equal to token balance of pool.

  • The issue is this assumption fails In case of rebase tokens when a rebase contraction event occurs, balance of pool address is lower than required balance for all stakers to claim stake and bonus rewards after staking period ends and outcome is set to SURVIVED. since payout is calculated based on eligibleStake + bonusShare per user, depending on the amount rebased late callers of claimSurvived() will not be able to recieve their promised rewards.

function stake(.....)
...
@> totalEligibleStake += received;
...
function contributeBonus(......)
...
@> totalBonus += received;
...
function claimSurvived()
uint256 bonusShare = _bonusShare(msg.sender, userEligible);
@> uint256 payout = userEligible + bonusShare;
totalEligibleStake -= userEligible;

Risk

Likelihood is high:

  • since the variable calculation only relies on funds recieved and does not scale with actual pool balance.

  • since depending on the kind of rebase token contraction can be frequent and severe.

Impact is high:

  • since Depending on the amount left after rebase a huge chunk of stakers's funds will be lost.

Proof of Concept

add to test/unit/ConfidencePool.t.sol

contract RebaseFun is BaseConfidencePoolTest {
function testClaimsStuckOnRebase() external {
address[] memory stakers = new address[](100);
//STAKING CONTRIBUTING
for (uint256 i = 0; i < 100; i++) {
stakers[i] = makeAddr(string.concat("staker", vm.toString(i)));
_stake(stakers[i], 1000 * ONE);
_contributeBonus(stakers[i], 100 * ONE);
}
//OUTCOME SET
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
//BONUS LOGIC
uint256 bonusTotal = pool.snapshotTotalBonus();
console.log("TOTAL BONUS: ", bonusTotal);
uint256 stakeTotal = pool.snapshotTotalStaked();
console.log("TOTAL STAKE: ", stakeTotal);
uint256 preRebaseBalance = token.balanceOf(address(pool));
console.log("Pool Balance before rebase: ", preRebaseBalance);
assertEq(stakeTotal + bonusTotal, preRebaseBalance);
//SIMULATING REBASE
uint256 newBal = preRebaseBalance - (10000 * ONE);
deal(address(token), address(pool), newBal);
assertEq(token.balanceOf(address(pool)), newBal);
console.log("Pool Balance after rebase: ", newBal);
//PREVIOUS ASSUMPTION FAILS
assert(stakeTotal + bonusTotal > newBal);
uint256 numUsersStuck;
for (uint256 i = 0; i < stakers.length; i++) {
uint256 bonusClaimed = pool.claimedBonus();
uint256 bonusRemaining = bonusTotal - bonusClaimed;
bool willRevert = pool.eligibleStake(stakers[i]) + bonusRemaining > token.balanceOf(address(pool));
vm.prank(stakers[i]);
if (willRevert) {
vm.expectRevert();
numUsersStuck++;
}
pool.claimSurvived();
}
console.log("Total users with stuck funds:", numUsersStuck);
console.log("Pool Balance", token.balanceOf(address(pool)));
console.log("CLAIMED BONUS", pool.claimedBonus());
assert(bonusTotal > pool.claimedBonus());
}
}

Recommended Mitigation

scale eligibleStake, bonusShare as a percentage of pool token balance, under normal conditions this would work as expected and in rebase contraction or expansion events since they are percentages and not absolute values all funds will be distributed without fail.

- eligibleStake[msg.sender] += received;
- totalEligibleStake += received;
+ if (totalEligibleStake == 0 || token.balanceOf(address(this)) == 0) {
+ stakeShares = received;
+ } else {
+ stakeShares = Math.mulDiv(received, token.balanceOf(address(this)), totalEligibleStake);
+ }
+ eligibleStake[msg.sender] += stakeShares;
+ totalEligibleStake += stakeShares;

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!