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.
@> if (riskWindowStart == 0) return 0;
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
@> _markRiskWindowStart();
}
@> return s == IAttackRegistry.ContractState.UNDER_ATTACK
@> || s == IAttackRegistry.ContractState.PROMOTION_REQUESTED;
@> if (riskWindowStart != 0) {
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;
_stake(alice, stakeAmount);
_contributeBonus(bob, bonusAmount);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PROMOTION_REQUESTED);
vm.warp(block.timestamp + 7 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceReceived = token.balanceOf(alice) - aliceBefore;
assertEq(aliceReceived, stakeAmount);
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;
+ }
}