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

Surviving agreement pays stakers zero bonus and strands the full bonus pool to the sponsor when it reaches PRODUCTION without an active-risk interval

Author Revealed upon completion

Root + Impact

Description

  • Stakers commit capital for an agreement's full term and are rewarded from the bonus pool when it survives (the README states stakers are "rewarded if the agreement survives"). In the DESIGN.md doc, point 5 gates that reward on riskWindowStart != 0, and point 9 defends the "no window → no bonus" rule on the premise that stakers could seal the window during an active-risk interval.

  • On this path that premise is false: an agreement reaches terminal PRODUCTION (survived) without any active-risk state, so riskWindowStart stays 0 and pokeRiskWindow() reverts RiskWindowNotReached throughout ATTACK_REQUESTED — the window can never be sealed. _bonusShare then returns 0 for every staker and sweepUnclaimedBonus sends 100% of the bonus to the sponsor's recoveryAddress. A surviving agreement, underwritten for its full term, pays its stakers nothing — contradicting both the protocol's stated survival-reward purpose and the justification given in point 9 of the DESIGN.md doc.

// src/ConfidencePool.sol
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0; // surviving agreement, no observed window => every staker gets 0
uint256 T = outcomeFlaggedAt;
// ...
}
// sweepUnclaimedBonus(): with riskWindowStart == 0 the bonus is unreserved and swept to the sponsor
@> if (totalEligibleStake == 0 || riskWindowStart == 0) {
// full snapshotTotalBonus is sent to recoveryAddress
}

Risk

Likelihood:

  • Occurs whenever an agreement reaches `PRODUCTION` via the registry's deadline auto-promotion: an agreement with `attackRequested && !attackApproved` returns `PRODUCTION` once `block.timestamp >= deadlineTimestamp` (`AttackRegistry._getAgreementState`), purely time-based, requiring only that the DAO does not activate the requested attack before its deadline — an ordinary, non-adversarial lifecycle event, not a trusted-actor attack.

  • Occurs on every such promotion regardless of staker behavior: `pokeRiskWindow()` reverts throughout `ATTACK_REQUESTED`, so stakers have no action available to seal the window and avoid the outcome.

  • Occurs on a genuine survival: the agreement reaches the terminal survived state, so the bonus that exists to reward survival is owed by the protocol's stated design, yet paid to no one who earned it.

Impact:

  • The full bonus pool is routed to the sponsor's recoveryAddress instead of the stakers, on the exact "survived" outcome the bonus is meant to reward — a mismatch between the implementation and the protocol's stated "rewarded if they survive" purpose, on a path DESIGN.md poinr 9's fairness rationale does not actually cover.

  • Stakers who committed capital for the agreement's full term receive zero reward. Principal is not at risk (`sweepUnclaimedBonus` reserves `totalEligibleStake`), which bounds this to reward misallocation rather than fund loss.

Proof of Concept

Save as test/unit/ZZBonusStrand.t.sol (depends only on the shipped test/helpers/BaseConfidencePoolTest.sol) and run forge test --match-path 'test/unit/ZZBonusStrand.t.sol' -vv → passes.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
/// FINDING: An agreement that reaches PRODUCTION (survived) via a registry-legal path that SKIPS
/// the active-risk states (instantPromote ATTACK_REQUESTED->PRODUCTION, or deadline auto-promotion
/// of an un-approved ATTACK_REQUESTED agreement) leaves riskWindowStart == 0. Because the bonus
/// payout is gated on riskWindowStart != 0, stakers who underwrote a SURVIVING agreement receive
/// ZERO bonus and the entire bonus pool sweeps to the sponsor's recoveryAddress. DESIGN §9 justifies
/// "no window -> no bonus" on the premise that stakers could seal the window during the active-risk
/// interval -- but here NO such interval exists (pokeRiskWindow reverts), so the premise is false.
contract ZZBonusStrandTest is BaseConfidencePoolTest {
uint256 constant BONUS = 100 ether;
function test_bonusStrandedWhenProductionSkipsActiveRisk() public {
// alice, bob = stakers (victims). carol = bonus contributor. recovery = sponsor sink.
_stake(alice, 10 ether);
_stake(bob, 30 ether);
_contributeBonus(carol, BONUS);
// ATTACK_REQUESTED is NOT an active-risk state: riskWindowStart stays 0 and cannot be sealed.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow(); // proof: impossible to seal the window on this path
// ATTACK_REQUESTED -> PRODUCTION directly (models instantPromote / deadline auto-promotion).
// UNDER_ATTACK / PROMOTION_REQUESTED are NEVER entered.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "risk window should never have opened");
// Stakers claim: principal ONLY, zero bonus.
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 10 ether, "alice got bonus?!");
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(bob) - bobBefore, 30 ether, "bob got bonus?!");
// The ENTIRE bonus is swept to the sponsor's recoveryAddress.
uint256 recBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recBefore, BONUS, "bonus not fully swept to sponsor");
// OUTCOME: agreement survived, stakers underwrote it for the full term, yet 100% of the
// bonus went to the sponsor and 0 to the stakers who earned it.
}
}

Recommended Mitigation

The riskWindowStart == 0 branch currently pays every staker zero and sweeps the full bonus to the sponsor. Routing a survived/expired outcome into the contract's existing amount-weighted fallback (the globalScore == 0 branch already present in _bonusShare) instead pays the bonus to the stakers who underwrote the surviving agreement, resolving the spec-vs-implementation inconsistency. Alternatively, if the zero-bonus-on-no-window behavior is intended, update the protocol's "rewarded if they survive" description to document the carve-out.

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
- // No observable risk → no bonus (see contract natspec).
- if (riskWindowStart == 0) return 0;
+ // No observable risk window. On a SURVIVED/EXPIRED outcome reached without any active-risk
+ // interval (e.g. instantPromote / deadline auto-promotion), fall back to the amount-weighted
+ // split below instead of paying zero, so a surviving agreement still rewards the stakers who
+ // underwrote it. `_bonusShare` is only reached on non-CORRUPTED payout paths, so no extra
+ // outcome guard is needed.
+ if (riskWindowStart == 0) {
+ if (snapshotTotalStaked == 0) return 0;
+ return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
+ }
uint256 T = outcomeFlaggedAt;

Support

FAQs

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

Give us feedback!