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

Stakers forfeit entire bonus to `recoveryAddress` on SURVIVED happy path when `riskWindowStart` is never sealed

Author Revealed upon completion

Root + Impact

The entire bonus pool is redirected from stakers to the sponsor-controlled recoveryAddress on what should be the protocol's normal success path. Bonus contributors lose their contribution and stakers earn nothing for the risk they bore.

Description

  • Stakers who back a surviving agreement receive their principal plus a pro-rata share of the bonus pool distributed via a k=2 time-weighted formula. Bonus credit accrues once the registry enters an active-risk state, marked by the riskWindowStart timestamp.

  • On the canonical success path PROMOTION_REQUESTED → PRODUCTION, riskWindowStart is never sealed. No entrypoint can seal it: stake() and contributeBonus() are blocked during PROMOTION_REQUESTED, withdraw() and setPoolScope() call _observePoolState() but then revert (rolling back the latch write), and flagOutcome() at PRODUCTION only seals riskWindowEnd, never riskWindowStart. Only a discretionary pokeRiskWindow() can seal it. If nobody calls it, every staker gets zero bonus and the entire bonus pool sweeps to the sponsor-controlled recoveryAddress.

// ConfidencePool.sol:_bonusShare — line 699
@> if (riskWindowStart == 0) return 0; // zero bonus when risk never observed
// ConfidencePool.sol:_observePoolState — line 793
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) { // ONLY active-risk states seal it
@> _markRiskWindowStart();
}
// ConfidencePool.sol:_isActiveRiskState — lines 833-835
@> return s == IAttackRegistry.ContractState.UNDER_ATTACK
@> || s == IAttackRegistry.ContractState.PROMOTION_REQUESTED; // PRODUCTION is NOT active-risk
// ConfidencePool.sol:sweepUnclaimedBonus — line 485
@> if (riskWindowStart != 0) { // when riskWindowStart==0, bonus is UNRESERVED
reserved += snapshotTotalBonus - claimedBonus;
}

Risk

Likelihood:

Likelihood:

Reason 1 — The canonical success path `PROMOTION_REQUESTED → PRODUCTION` never seals `riskWindowStart`. Deposits are blocked during the promotion window, withdraw and setPoolScope revert (rolling back the latch), and the terminal observation in `flagOutcome` only seals `riskWindowEnd`. The pool reaches resolution with `riskWindowStart == 0` by default.
Reason 2 — `pokeRiskWindow()` is the sole mechanism to seal `riskWindowStart` during the active-risk window, and it is entirely discretionary. In a dormant pool with no active monitors, nobody will call it. The zero-bonus outcome requires no attacker — it is a systemic design gap.

Impact:

Impact 1 — Every staker in a SURVIVED pool receives zero bonus. A bonus contributor who sent 100,000 tokens to reward stakers sees the entire amount swept to the sponsor's `recoveryAddress`. The stakers' risk-bearing earns nothing.
Impact 2 — The sponsor receives the entire bonus pool as a windfall. This violates the fundamental promise of the protocol: bonus contributors intend to reward stakers who back a surviving scope, not the sponsor.

Proof of Concept

function test_M1_StakersForfeitBonusOnSurvivedHappyPath() public {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
// 1. Staker deposits, contributor adds bonus
_stake(alice, stakeAmount);
_contributeBonus(bob, bonusAmount);
// 2. Canonical happy path: PROMOTION_REQUESTED → PRODUCTION — no pokeRiskWindow
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PROMOTION_REQUESTED);
vm.warp(block.timestamp + 7 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// 3. Moderator flags SURVIVED
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 4. riskWindowStart is 0 — never sealed during the active-risk window
assertEq(pool.riskWindowStart(), 0);
// 5. Staker claims — gets ONLY principal, ZERO bonus
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceReceived = token.balanceOf(alice) - aliceBefore;
assertEq(aliceReceived, stakeAmount);
// 6. Entire bonus sweeps to recoveryAddress
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
uint256 swept = token.balanceOf(recovery) - recoveryBefore;
assertEq(swept, bonusAmount);
}

Forge output:

[PASS] test_M1_StakersForfeitBonusOnSurvivedHappyPath()
Alice staked: 100000000000000000000
Alice received: 100000000000000000000
Alice bonus: 0
Bonus swept to recovery: 50000000000000000000

Recommended Mitigation

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
// ... existing scope lock and risk window logic ...
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
+ // If a terminal state is reached without ever observing active-risk,
+ // seal riskWindowStart at expiry so stakers receive their bonus.
+ if (riskWindowStart == 0 && riskWindowEnd != 0) {
+ uint256 t = expiry;
+ riskWindowStart = uint32(t);
+ sumStakeTime = totalEligibleStake * t;
+ sumStakeTimeSq = totalEligibleStake * t * t;
+ }
}

Support

FAQs

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

Give us feedback!