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

The entire bonus pool is swept to the sponsor when no one seals the risk window, even though stakers were locked in and bore the full risk

Author Revealed upon completion

Root + Impact

Description

The pool splits its bonus with a k=2, time-weighted formula. Its lower bound, riskWindowStart, is sealed only when some pool call observes the registry in an active-risk state (UNDER_ATTACK / PROMOTION_REQUESTED). If the registry passes through active risk and reaches a terminal state (PRODUCTION) without anyone interacting, riskWindowStart stays 0 forever - it can never be sealed retroactively, because a terminal state is not an active-risk state.

The bug is that two mechanisms key off different signals, and they desynchronize exactly in this state:

  • withdraw() closes on the LIVE registry state. During UNDER_ATTACK it reverts regardless of riskWindowStart, so stakers are captive and bear the real risk.

  • _bonusShare opens on the LATCH (riskWindowStart). With the latch never sealed it returns 0 for everyone.

So the stakers are locked in and carry the full downside, yet earn nothing. Worse, sweepUnclaimedBonus then does not reserve the bonus when riskWindowStart == 0, so the entire bonus pool is swept to recoveryAddress - the sponsor.

ConfidencePool.sol - withdraw() is gated on the live state, not the latch:

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
@> IAttackRegistry.ContractState state = _observePoolState(); // would seal riskWindowStart...
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
@> revert WithdrawsDisabled(); // ...but this revert ROLLS BACK the seal (see lemma below)
}
...

_bonusShare pays zero to everyone while the latch is unsealed:

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0; // no latch -> zero bonus for EVERY staker
...
}

sweepUnclaimedBonus then refuses to reserve the bonus, sending it all to the sponsor:

uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) { // latch unsealed -> condition false
@> reserved += snapshotTotalBonus - claimedBonus; // ...so the bonus is NOT reserved
}
}
...
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
...
@> stakeToken.safeTransfer(recoveryAddress, amount); // entire bonus -> sponsor

The decisive lemma - the natural defence cannot seal the window. A staker who sees UNDER_ATTACK and reacts will call withdraw(). That runs _observePoolState() (which would seal riskWindowStart), then reverts WithdrawsDisabled - and the revert rolls the seal back with the rest of the transaction.

Risk

Likelihood:

  • Two triggers, one signal apart. withdraw() locks on the live state while the bonus opens on the latch - they desync whenever the registry is in active risk but nobody has poked. Any UNDER_ATTACK → PRODUCTION transition with no sealing interaction reaches this state.

  • The victim cannot save themselves by reacting. Their instinctive withdraw() reverts and rolls back the very seal that would pay them; only an undocumented pokeRiskWindow() works.

  • The sponsor is both the beneficiary and the informed party.

Impact:

  • Stakers are captive but unpaid. Locked in for the whole attack phase (real downside borne), they receive principal back and zero bonus.

  • The entire bonus pool is exfiltrated to the sponsor via sweepUnclaimedBonus, not merely redistributed among stakers.

Proof of Concept

Both pass.

// Captive staker loses the ENTIRE bonus to the sponsor because the latch is never sealed.
function test_M1_captiveStakerLosesEntireBonusToSponsor() public {
_stake(alice, 100e18);
_contributeBonus(address(this), 50e18); // pool holds 150
// Registry enters UNDER_ATTACK. NOBODY pokes / stakes / interacts to seal riskWindowStart.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// (1) alice IS captive: her natural defensive move — withdraw() — reverts on live state,
// and because it reverts, it does NOT persist the riskWindowStart seal.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
assertEq(pool.riskWindowStart(), 0, "risk borne live, but latch never sealed");
// Registry reaches PRODUCTION. riskWindowStart can NEVER be sealed retroactively.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "flagging at PRODUCTION does not seal start");
// (2) alice claims: principal back, ZERO bonus.
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice), 100e18, "principal only, no risk premium");
// (3) the WHOLE bonus sweeps to recoveryAddress (sponsor-controlled).
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50e18, "entire bonus captured by sponsor");
}
// The remedy exists but is undocumented & non-obvious: ONE pokeRiskWindow() flips the outcome.
function test_M1_singlePokeDuringAttackSecuresTheBonus() public {
_stake(alice, 100e18);
_contributeBonus(address(this), 50e18);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // the one arcane call stakers are never told to make
assertNotEq(pool.riskWindowStart(), 0);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice), 150e18, "with one poke, alice keeps her risk premium");
}

Running the suite:

[PASS] test_M1_captiveStakerLosesEntireBonusToSponsor()
[PASS] test_M1_singlePokeDuringAttackSecuresTheBonus()

The two tests are identical except for a single pokeRiskWindow() call: without it the sponsor takes 100% of the bonus while the captive staker gets nothing; with it the staker keeps her full premium.

Recommended Mitigation

The root cause is that withdraw() locks on the live registry state while the bonus is gated on the riskWindowStart latch, so stakers can be captive with the latch still unsealed. Align the two so a staker cannot bear risk without accruing the premium:

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
+ // If risk is live, the caller is now captive — persist the seal (do not roll it back)
+ // so the bonus premium begins the moment the exit closes.
+ if (_isActiveRiskState(state) && riskWindowStart != 0) {
+ emit WithdrawLockedRiskWindowSealed(msg.sender, riskWindowStart);
+ }
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}

Support

FAQs

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

Give us feedback!