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

Locked stakers lose their entire bonus to the sponsor when the risk window is never observed on-chain

Author Revealed upon completion

Description

Stakers who keep their deposit through the agreement's attack phase are meant to earn a share of the k=2 time-weighted bonus, and are withdraw-locked for the entire active-risk window (UNDER_ATTACK / PROMOTION_REQUESTED) precisely because that is when they are bearing the risk the bonus rewards.

The bonus, however, is only ever paid when riskWindowStart has been sealed, and riskWindowStart is sealed only by an on-chain pool interaction that observes an active-risk registry state. A terminal-state observation can never seal it retroactively, and there is no incentivized actor that guarantees the seal. If the active-risk window elapses with no stake / claim / pokeRiskWindow call, riskWindowStart stays 0, _bonusShare pays every staker zero, and sweepUnclaimedBonus routes the entire bonus to the sponsor-controlled recoveryAddress — even though the stakers were locked in and bore the full risk.

// ConfidencePool.sol — _observePoolState()
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) { // ONLY an active-risk observation opens the window
@> _markRiskWindowStart(); // → terminal-first observation never seals it
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
// ConfidencePool.sol — _bonusShare()
@> if (riskWindowStart == 0) return 0; // no seal → zero bonus for every staker
// ConfidencePool.sol — sweepUnclaimedBonus()
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) { // bonus reserved for stakers ONLY if sealed
reserved += snapshotTotalBonus - claimedBonus;
}
} // else: entire bonus is sweepable to recoveryAddress

Risk

Likelihood:

  • Occurs whenever the agreement transits its active-risk window with no pool interaction sealing riskWindowStart — a DAO instantPromote moves UNDER_ATTACK → PRODUCTION with no minimum dwell, and there is no on-chain reward for anyone to spend gas on pokeRiskWindow.

  • Occurs on the sponsor's behalf: the sponsor funds the bonus and receives it back via recoveryAddress when the seal is missed, so the party that profits has an incentive to keep the active-risk window short and unobserved (moral hazard), while stakers must actively self-defend a payout they already earned.

Impact:

  • Stakers who were withdraw-locked through the attack window and survived to PRODUCTION receive 0% of the bonus they earned.

  • 100% of the bonus pool is transferred to the sponsor's recoveryAddress, converting stakers' risk premium into a sponsor windfall with no privileged action required.

Proof of Concept

test/poc/ConfidencePoolPoC.t.sol — both tests pass against the contest repo. The two differ by a single pokeRiskWindow() call, proving the payout depends on an unguaranteed action, not on whether risk occurred.

function test_PoC_B_lockedStakerLosesBonusToSponsor() public {
_stake(alice, 100 * ONE); // alice underwrites 100
_contributeBonus(dave, 50 * ONE); // sponsor funds 50 bonus
// Agreement becomes attackable — alice is now LOCKED IN and bearing real risk.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// ...but NO pool interaction observes the active-risk window; agreement survives.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "risk window never sealed");
// Alice bore the risk yet receives ZERO bonus.
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "principal only, no bonus");
// The full bonus is captured by the sponsor's recoveryAddress.
uint256 recBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recBefore, 50 * ONE, "full bonus swept to sponsor");
}
// Counterfactual (also passing): a single pokeRiskWindow() during UNDER_ATTACK
// → alice claims 150 (principal + full bonus). One call flips the entire outcome.

Recommended Mitigation

Seal the risk window from the signal that proves risk occurred, not only from a live active-risk read. Since withdraw is already disabled from UNDER_ATTACK onward, drive the bonus-eligibility seal off the same transition so any locked staker is guaranteed eligibility:

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
+ // A terminal state is only reachable from an active-risk state (AttackRegistry
+ // markCorrupted/instantCorrupt/promote all require UNDER_ATTACK/PROMOTION_REQUESTED).
+ // If we observe terminal-first, risk provably occurred — seal the start too so
+ // withdraw-locked stakers are not denied their earned bonus.
+ if (riskWindowStart == 0) _markRiskWindowStart();
_markRiskWindowEnd();
}
}

Support

FAQs

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

Give us feedback!