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

Missed active-risk observations can zero staker bonus and sweep it to `recoveryAddress`

Author Revealed upon completion

Description

The pool reads AttackRegistry state through state-touching calls such as stake(), withdraw(), claim*(), pokeRiskWindow(), and flagOutcome(). It only sets riskWindowStart when _observePoolState() happens to see an active-risk state.

If the registry enters and exits an active-risk state entirely between two pool interactions, riskWindowStart remains 0 even though stakers were actually exposed during that window. At resolution, _bonusShare() interprets riskWindowStart == 0 as if no risk ever occurred, so stakers receive no bonus. Because the bonus is never reserved for them, sweepUnclaimedBonus() can transfer the full bonus pot to recoveryAddress.

Risk

Stakers can lose their full time-weighted bonus despite remaining exposed during a real active-risk interval. Principal is not affected, and the swept funds go to the protocol-controlled recoveryAddress, not an attacker.

This requires the active-risk interval to begin and end without any pool interaction observing it, and also requires nobody to call the permissionless pokeRiskWindow() during that time. Because the harm is limited to bonus loss and is preventable with a single poke, the impact is bounded.

Impact

Stakers can lose their full time-weighted bonus for a pool cycle despite remaining exposed during an actual active-risk interval. The loss is bonus-only, not principal loss, and the swept funds go to the protocol-controlled recoveryAddress rather than to an attacker. Because any participant can prevent the condition by calling pokeRiskWindow() once during the window, the harm is bounded and best classified as Low severity.

Proof of Concept

Place this file at test/audit/ConfidencePoolUnobservedRiskBonusSweep.t.sol and run:

forge test --match-path test/audit/ConfidencePoolUnobservedRiskBonusSweep.t.sol -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolUnobservedRiskBonusSweepAuditTest is BaseConfidencePoolTest {
uint256 internal constant STAKE_AMOUNT = 100 * ONE;
uint256 internal constant BONUS_AMOUNT = 50 * ONE;
// MECHANISM: an unobserved active-risk window zeroes the bonus and sweeps it to recovery.
function testMechanism_unobservedActiveRiskSweepsFullBonusToRecovery() external {
_stake(alice, STAKE_AMOUNT);
_contributeBonus(carol, BONUS_AMOUNT);
// Registry enters (and later leaves) active risk, but no pool call observes it.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + 5 days);
assertEq(pool.riskWindowStart(), 0, "under-attack interval went unobserved");
// Moderator later sees PRODUCTION and resolves SURVIVED — riskWindowStart still 0.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "missed active-risk observation leaves start unset");
// Entire bonus is unreserved and swept to recovery.
uint256 recoveryBefore = token.balanceOf(recovery);
vm.expectEmit(true, true, false, true, address(pool));
emit IConfidencePool.BonusSwept(address(this), recovery, BONUS_AMOUNT);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, BONUS_AMOUNT, "full bonus sweeps to recovery");
// Alice, who bore the risk, recovers principal but earns 0 bonus.
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, STAKE_AMOUNT, "staker receives principal only, no bonus");
}
// MITIGATION: one permissionless poke during the window preserves the bonus.
function testMitigation_singlePokeDuringWindowPreservesBonus() external {
_stake(alice, STAKE_AMOUNT);
_contributeBonus(carol, BONUS_AMOUNT);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + 5 days);
// A single permissionless poke by any actor seals the risk window.
vm.prank(attacker);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "poke sealed the active-risk window");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Bonus is now reserved for the staker: the sweep finds nothing to recover.
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
// Alice keeps principal PLUS full bonus.
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(
token.balanceOf(alice) - aliceBefore,
STAKE_AMOUNT + BONUS_AMOUNT,
"staker keeps principal and full bonus after poke"
);
}
}

Recommended Mitigation

Do not rely exclusively on local observation to decide bonus eligibility. If possible, bonus entitlement should depend on whether active risk actually occurred on the registry, not only on whether the pool observed it live.

If historical registry state cannot be queried, the minimum mitigation is to operationally require at least one pokeRiskWindow() call during every active-risk interval and document that assumption clearly.

Support

FAQs

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

Give us feedback!