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

Risk premium is misallocated to the sponsor when a real risk window elapses unobserved on-chain

Author Revealed upon completion

Root + Impact

Description

  • On a SURVIVED outcome, stakers are meant to receive their principal plus a share of the sponsor's bonus — the premium for having locked capital through the risk period.

  • Bonus entitlement is gated on riskWindowStart != 0, which is set only if the pool observes the registry in an active-risk state on-chain during the window — not by the risk actually occurring. If a real UNDER_ATTACK interval elapses with no pool interaction, riskWindowStart stays 0, _bonusShare returns 0 for every staker, and sweepUnclaimedBonus sends the whole bonus to the sponsor's recoveryAddress — even though stakers' capital was locked and un-exitable throughout the real risk.

// _bonusShare (L699)
@> if (riskWindowStart == 0) return 0; // unobserved real risk => 0 premium
// _observePoolState (L793) — only seals if observed *during* the active-risk state; no retroactive seal
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) { _markRiskWindowStart(); }
// sweepUnclaimedBonus (L485) — when riskWindowStart == 0, bonus is NOT reserved for stakers -> swept to sponsor
@> if (riskWindowStart != 0) { reserved += snapshotTotalBonus - claimedBonus; }

Risk

Likelihood: Low — occurs when an UNDER_ATTACK interval elapses with no staker or watcher calling pokeRiskWindow() (or any observing entrypoint); no attacker can force it, and any single costless poke prevents it.

Impact: Low — stakers who bore real, locked risk get zero premium; the bonus is misallocated to the sponsor (recoveryAddress). Value is conserved and principal is safe, but the premium is permanently forfeited.

Proof of Concept

On unmodified contracts (project fixture, standard ERC20, registry driven via its normal setter), Alice stakes and a bonus is funded; the registry enters UNDER_ATTACK (her withdraw() reverts — capital locked) with no interaction; it survives to PRODUCTION and is flagged SURVIVED with riskWindowStart still 0. Alice then receives principal only, and the full bonus is swept to the sponsor. (A contrast run with a single pokeRiskWindow() during the window instead pays Alice the bonus.)

function test_unobservedRisk_stakerGetsZeroBonus_sponsorGetsAll() public {
_stake(alice, 100e18);
_contributeBonus(bob, 50e18);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(block.timestamp + 5 days);
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw(); // capital locked, un-exitable
assertEq(uint256(pool.riskWindowStart()), 0); // window never sealed
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.riskWindowStart()), 0); // resolving call seals End only, never Start
uint256 aBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aBefore, 100e18); // principal only, bonus = 0
uint256 rBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - rBefore, 50e18); // entire bonus -> sponsor
}

Recommended Mitigation

When riskWindowStart == 0 at resolution but stakers exist, distribute the bonus amount-weighted among them instead of returning 0 (and remove the matching sweep-to-sponsor path). Preferably, seal riskWindowStart from a registry-provided active-risk timestamp so entitlement never depends on live observation.

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
- if (riskWindowStart == 0) return 0;
+ if (riskWindowStart == 0) {
+ if (snapshotTotalStaked == 0) return 0;
+ return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked); // amount-weighted fallback
+ }
...

Support

FAQs

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

Give us feedback!