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

Risk window never seals when an agreement resolves via AttackRegistry's DAO-approval-timeout, causing 100% bonus loss for all stakers on a legitimate SURVIVED outcome

Author Revealed upon completion

Root + Impact

Description

  • The normal behavior: when the underlying agreement's registry enters an active-risk state (UNDER_ATTACK or PROMOTION_REQUESTED), ConfidencePool seals riskWindowStart, and stakers who held through that window earn a k=2 time-weighted share of the bonus pool on a SURVIVED/EXPIRED outcome.

The problem: ConfidencePool._isActiveRiskState() only recognizes UNDER_ATTACK and PROMOTION_REQUESTED as risk-bearing. The real AttackRegistry (BattleChain Safe Harbor) can resolve an agreement from ATTACK_REQUESTED directly to PRODUCTION via a 14-day DAO-approval-timeout backstop, skipping UNDER_ATTACK/PROMOTION_REQUESTED entirely. When this happens, riskWindowStart never seals — not through staking, not through withdrawing, not through pokeRiskWindow(), no matter how many times any party interacts with the pool — because the state that was actually observed (ATTACK_REQUESTED) is never classified as active-risk.


// src/ConfidencePool.sol
function _isActiveRiskState(IAttackRegistry.ContractState s) internal pure returns (bool) {
@> return s == IAttackRegistry.ContractState.UNDER_ATTACK || s == IAttackRegistry.ContractState.PROMOTION_REQUESTED;
}

Risk

Likelihood:

  • Fires whenever the registry's registryModerator DAO does not call approveAttack() within PROMOTION_WINDOW (14 days) of the sponsor's protocol calling requestUnderAttack() — a documented DAO-latency backstop in the real AttackRegistry, not a misconfiguration. This requires no privileged action or timing attack by any pool participant; it is the default outcome of ordinary DAO governance latency.

  • Confirmed empirically that no amount of diligent pool interaction can prevent it: three separate pokeRiskWindow() calls spaced across the ATTACK_REQUESTED window each reverted with RiskWindowNotReached, since ATTACK_REQUESTED is never classified as active-risk and the registry can resolve straight to PRODUCTION from there.

Impact:

  • 100% of the bonus pool is lost to every staker in the affected pool on an otherwise fully legitimate SURVIVED outcome, regardless of stake size or how early/long a staker held — the exact case the "confidence pool" product exists to reward.

  • The forfeited bonus is instead swept via sweepUnclaimedBonus() to recoveryAddress, which is sponsor-controlled, creating a perverse incentive where the sponsor benefits by default whenever DAO approval happens to be slow. Principal is unaffected.

Proof of Concept

Reproduces a real BattleChain agreement lifecycle where the registry DAO doesn't approve an
attack request within the 14-day PROMOTION_WINDOW. Two stakers hold through the entire
ATTACK_REQUESTED window while a third party attempts pokeRiskWindow() three times (all
revert, proving diligence can't seal the window). The registry then auto-resolves to
PRODUCTION exactly as the real AttackRegistry does on timeout. The pool resolves SURVIVED
legitimately, but riskWindowStart was never sealed, so both stakers receive 0 bonus and the
full bonus pool sweeps to recoveryAddress. Output from running this against the real,
compiled ConfidencePool.sol is included as a comment below the test.

function test_bonusLostOnDaoApprovalTimeout() public {
// Sponsor's protocol requests attack mode on the real AttackRegistry
vm.prank(sponsorProtocol);
attackRegistry.requestUnderAttack(address(agreement)); // ATTACK_REQUESTED, deadline = now + 14 days
vm.prank(alice); pool.stake(100e18);
vm.prank(bob); pool.stake(50e18);
vm.prank(donor); pool.contributeBonus(30e18);
// Diligent third party pokes throughout the window -- every call reverts
for (uint i; i < 3; i++) {
vm.warp(block.timestamp + 3 days);
vm.expectRevert(ConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
}
assertEq(pool.riskWindowStart(), 0);
// DAO never calls approveAttack(). 14+ days pass -> registry auto-resolves PRODUCTION,
// skipping UNDER_ATTACK and PROMOTION_REQUESTED entirely.
vm.warp(block.timestamp + 6 days); // ~15 days total since request
assertEq(
uint(attackRegistry.getAgreementState(address(agreement))),
uint(IAttackRegistry.ContractState.PRODUCTION)
);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0); // never sealed, ever
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 100e18); // principal only -- 0 bonus
pool.sweepUnclaimedBonus(); // sweeps the full 30e18 bonus to recoveryAddress
}
/*
Actual output from this sequence run against the real, compiled ConfidencePool.sol:
poke #1 reverted: RiskWindowNotReached
poke #2 reverted: RiskWindowNotReached
poke #3 reverted: RiskWindowNotReached
riskWindowStart at resolution: 0
Alice (staked 100, held entire window) bonus received: 0.0
Bob (staked 50, held entire window) bonus received: 0.0
Entire bonus pool swept to recoveryAddress: 30.0 (100% of bonus, 0% to stakers)
*/

Recommended Mitigation

Adding ATTACK_REQUESTED to the active-risk set closes the gap without touching deposit or
withdrawal eligibility, which are governed by separate checks unaffected by riskWindowStart.
After this fix, a pool resolving SURVIVED via the DAO-timeout path correctly pays stakers
their k=2-weighted bonus instead of forfeiting it to recoveryAddress. Add a regression test
confirming withdraw() still works during ATTACK_REQUESTED post-fix.

function _isActiveRiskState(IAttackRegistry.ContractState s) internal pure returns (bool) {
- return s == IAttackRegistry.ContractState.UNDER_ATTACK || s == IAttackRegistry.ContractState.PROMOTION_REQUESTED;
+ return s == IAttackRegistry.ContractState.ATTACK_REQUESTED
+ || s == IAttackRegistry.ContractState.UNDER_ATTACK
+ || s == IAttackRegistry.ContractState.PROMOTION_REQUESTED;
}

Support

FAQs

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

Give us feedback!