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

Stakers of protocol that went under attack and survived may not receive any bonus rewards

Author Revealed upon completion

Root + Impact

The riskWindowStart is updated lazily in the _observePoolState() function. When a protocol goes UNDER_ATTACK in the AttackRegistry it is not guaranteed that this function gets called. If the protocol survives the attack and goes into PRODUCTION then the riskWindowEnd is set but the riskWindowStart remains 0. This will result in _bonusShare(...) returning 0, so no bonus is claimed by honest stakers.

Description

  • Describe the normal behavior in one or more sentences

When a protocol goes UNDER_ATTACK and it survives, stakers should be able to call claimSurvived() and claim their capital + the bonus rewards.

  • Explain the specific issue or problem in one or more sentences

However, this invariant is breached if the riskWindowStart is never updated. A protocol going UNDER_ATTACK doesn't guarantee that riskWindowStart is set. When users try to call claimSurvived(), which calls _bonusShare(...) to calculate each user's bonus share it will result in 0 bonus amount for everyone:

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
// No observable risk → no bonus (see contract natspec).
if (riskWindowStart == 0) return 0;
// ...
}

This makes the expected flow of NOT_DEPLOYED -> ATTACK_REQUESTED -> UNDER_ATTACK -> PRODUCTION not reward stakers, while this is the whole point of staking.

Risk

Likelihood:

_observePoolState() is not called in the time window between UNDER_ATTACK and PRODUCTION.

Impact:

Honest stakers don't receive their bonus rewards.

Proof of Concept

Paste the following test in the test/unit/ConfidencePool.t.sol file and run through forge test --mt test_bonusForfeited_whenRiskWindowUnobserved

function test_bonusForfeited_whenRiskWindowUnobserved() public {
// --- t0: Alice stakes early; Sponsor-side seeds a big bonus pot ---
_stake(alice, 100 * ONE);
_contributeBonus(bob, 50 * ONE); // 50e18 reward pot, funded by a well-wisher
assertEq(pool.totalBonus(), 50 * ONE, "bonus pot funded");
assertEq(pool.riskWindowStart(), 0, "no risk yet");
// --- t1: registry goes UNDER_ATTACK. Time passes. NOBODY pokes / stakes / withdraws. ---
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(block.timestamp + 5 days); // a real, multi-day attack window elapses
// Still 0 because observation is lazy and no tx touched the pool during the window.
assertEq(pool.riskWindowStart(), 0, "risk window never observed on-chain");
// --- t3: attack repelled -> registry PRODUCTION (the stakers WON) ---
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// Moderator resolves. flagOutcome observes a *terminal* state, so it seals riskWindowEnd
// but never sets riskWindowStart.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "survived");
assertEq(pool.riskWindowStart(), 0, "riskWindowStart still 0 at resolution");
assertTrue(pool.riskWindowEnd() != 0, "riskWindowEnd sealed");
// --- Alice claims: principal back, but ZERO bonus despite winning a real attack ---
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceGained = token.balanceOf(alice) - aliceBefore;
assertEq(aliceGained, 100 * ONE, "Alice gets principal ONLY - bonus zeroed");
// --- The entire 50e18 bonus pot is swept to the sponsor's recovery address ---
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus(); // permissionless
uint256 recoveryGained = token.balanceOf(recovery) - recoveryBefore;
assertEq(recoveryGained, 50 * ONE, "full bonus pot drained to sponsor, not the winning staker");
}

Recommended Mitigation

Consider implementing a Keeper bot that scans for Attack registry changes and calls pokeRiskWindow() in every change.

Support

FAQs

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

Give us feedback!