back to research
LARP-2026-006defi9 min read

CORRUPTED grace period anchored to expiry in BattleChain confidence pools

BattleChain's confidence pools give a moderator 180 days to settle a breached pool before anyone can force the outcome. The 180 days are counted from the pool's expiry, not from the first moment a decision is possible. When the registry turns CORRUPTED late, any account can settle the pool as a bad-faith breach in the very block where the moderator's first valid call becomes possible.

I reported this during the CodeHawks audit of BattleChain’s Confidence Pools, 9 to 16 July 2026. The code is public in CodeHawks-Contests/2026-07-bc-confidence-pools, and every line quoted here is from commit 58e8ba4. The registry it depends on is Cyfrin’s battlechain-safe-harbor-contracts at fde1b2a.

A confidence pool sits next to a BattleChain Safe Harbor agreement. A protocol opens its contracts to legal whitehat attack, stakers deposit tokens as a public bet that the contracts in the pool’s scope come through, and the sponsor adds a bonus for them. If the contracts survive, stakers get their principal back plus a time-weighted share of the bonus. If they are breached, the whole pool, principal included, goes either to the sponsor’s recoveryAddress or, for a good-faith breach, to the whitehat who did it.

The bug is in the fallback the pool uses when no moderator has resolved it.

Resolution paths

Two parties look at a pool. The attack registry knows the agreement’s state, and only at the agreement level: UNDER_ATTACK and PROMOTION_REQUESTED mean the contracts are legally attackable, PRODUCTION means they came through, and CORRUPTED means a breach was recorded. The project’s design document is firm that the first two are the agreement’s normal operating mode, not evidence of a breach.

What the registry can’t say is whether a breach touched this pool’s scope, or whether it was done in good faith. That’s the moderator’s call, made with flagOutcome, and the function only accepts an outcome the registry state supports:

solidityConfidencePool.sol, flagOutcome, shortened
if (newOutcome == PoolStates.Outcome.SURVIVED) {
    // PRODUCTION, or CORRUPTED with the breach outside this pool's scope
    if (state != IAttackRegistry.ContractState.PRODUCTION && state != IAttackRegistry.ContractState.CORRUPTED) {
        revert InvalidOutcome();
    }
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
    // good faith names the attacker; bad faith names nobody
    if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}

Both outcomes need a terminal state. While the registry says UNDER_ATTACK, there is no outcome the moderator can flag.

For pools nobody resolves, there’s claimExpired. After expiry, any account can call it, and it settles the pool from the live registry state: PRODUCTION becomes SURVIVED, a CORRUPTED agreement with an observed risk window becomes bad-faith CORRUPTED, and everything else becomes EXPIRED, which pays the stakers like a survival. The CORRUPTED branch is the dangerous one, because it is scope-blind: it sweeps the pool even when the breach was outside this pool’s contracts, where the moderator would have flagged SURVIVED. So it waits for the moderator first.

The grace check

solidityConfidencePool.sol, claimExpired, the auto-CORRUPTED branch, comments removed
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;
    emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
    return;
}

MODERATOR_CORRUPTED_GRACE is 180 days, and its NatSpec says what it’s for: a “backstop against a permanently-unavailable moderator”. The wait is meant as evidence. If 180 days pass and the moderator hasn’t flagged anything, the moderator is gone, and a pessimistic default is better than funds stuck forever.

That only works if the moderator could have flagged something during those 180 days. The clock starts at expiry, a date fixed when the pool was created, and nothing ties it to the moment flagOutcome first becomes possible. If the registry stays UNDER_ATTACK from expiry to expiry + 180 days, the grace period runs out while every call the moderator could make reverts with InvalidOutcome, so it ends without the moderator ever having had a valid call.

UNDER_ATTACK has no deadline

For that to matter, the registry has to be able to sit in UNDER_ATTACK that long and then turn CORRUPTED. Both hold. The registry doesn’t store the state; it derives it on every read, and the order of the checks decides which deadlines apply:

solidityAttackRegistry.sol, _getAgreementState, shortened
if (info.corrupted) return ContractState.CORRUPTED;
if (info.promoted) return ContractState.PRODUCTION;
// ...
if (info.promotionRequestedTimestamp > 0) {
    if (block.timestamp >= info.promotionRequestedTimestamp + PROMOTION_DELAY) {
        return ContractState.PRODUCTION;
    }
    return ContractState.PROMOTION_REQUESTED;
}
if (info.attackApproved) {
    return ContractState.UNDER_ATTACK;
}
// Check deadline for auto-promotion
if (block.timestamp >= info.deadlineTimestamp) {
    return ContractState.PRODUCTION;
}

PROMOTION_REQUESTED turns into PRODUCTION by itself after three days. An approved attack returns UNDER_ATTACK before the auto-promotion deadline is ever checked, so it has no deadline at all. It lasts until the attack moderator calls promote or markCorrupted, or the registry’s own moderator forces a state, and markCorrupted is accepted from UNDER_ATTACK or PROMOTION_REQUESTED with no time bound. A breach recorded a year after the pool expired is an ordinary, legal transition.

Attack sequence

modclaimday 0registry UNDER_ATTACK, risk window sealedflagOutcome reverts for both outcomes: each needs a terminal registry stateexpirythe pool expiresa claimExpired here would settle EXPIRED and pay the stakers back+180 dexpiry + MODERATOR_CORRUPTED_GRACEthe grace period is over, and the moderator never had a valid call in ittime compressed, still UNDER_ATTACKTmarkCorrupted: the registry reads CORRUPTEDthe first block in which flagOutcome can succeedTany account calls claimExpiredbad-faith CORRUPTED, and claimsStarted = trueafterflagOutcome reverts OutcomeAlreadySetclaimCorrupted sends all principal and bonus to recoveryAddressthe moderator's window:
zero seconds
moderator can flagno valid flagclaimExpired settles EXPIREDclaimExpired settles CORRUPTED
fig. 1One pool at commit 58e8ba4. The first bar says whether the moderator's flagOutcome can succeed; the second says what a permissionless claimExpired would settle. The grace period is counted from expiry, so by the time the registry reads CORRUPTED it has already run out, and both bars change on the same block. Time is compressed after expiry + 180 days.
  1. Stakers deposit, the registry goes UNDER_ATTACK, and any interaction with the pool, pokeRiskWindow included, records riskWindowStart.
  2. The pool reaches expiry and nobody calls claimExpired.
  3. expiry + 180 days passes. The registry still says UNDER_ATTACK, and both of the moderator’s possible flags still revert.
  4. The agreement’s attack moderator calls markCorrupted. This is the correct action for a real breach, and nothing about it is malicious.
  5. In the same block, or the next, any account calls claimExpired. The registry reads CORRUPTED, the risk window is set, and the deadline passed long ago, so the pool settles as bad-faith CORRUPTED and sets claimsStarted.
  6. The moderator’s flagOutcome now reverts with OutcomeAlreadySet, and claimCorrupted sends every staker’s principal and the whole bonus to recoveryAddress.

This happens even if the moderator is online the whole time. The first block in which flagOutcome can succeed is also the first block in which claimExpired can force bad-faith CORRUPTED, and from then on the outcome depends on transaction ordering. The caller receives nothing, since the pool goes to recoveryAddress. The effect is that the outcome is fixed by whoever transacts first, not by the moderator.

The claimsStarted latch is what makes it permanent, and the code says why it’s there: to stop the moderator from overriding a mechanical bad-faith CORRUPTED with a good-faith one, which would redirect the pool to a named address. That is correct after a real 180-day absence. In this sequence it locks out a moderator who never had a valid call.

Impact

The registry records that a breach happened, but not when or through which contracts. That is why the design leaves the decision to a moderator, and the forced outcome gets two cases wrong:

  • An out-of-scope breach. The agreement was breached through contracts outside this pool’s scope. The pool’s own contracts survived, so SURVIVED was correct and the stakers should get principal plus bonus. They get nothing.
  • A good-faith breach. A whitehat broke in under the agreement’s terms. Good-faith CORRUPTED would reserve the pool for them through claimAttackerBounty for 180 days. It goes to recoveryAddress instead.

Only an in-scope, bad-faith breach is settled correctly.

The precondition is narrow. It needs a pool that nobody settled for more than 180 days after expiry. Any claimExpired in that stretch, while the registry still says UNDER_ATTACK, settles EXPIRED and pays the stakers, and stakers have a reason to make that call. Swap only the order of markCorrupted and that one claimExpired, and the same pool, holding the same balance, pays the stakers instead of recoveryAddress. But nothing in the protocol makes anyone settle a pool: there is no keeper and no deadline on resolution, and an unresolved pool is a valid on-chain state indefinitely.

The documented trust assumption

The design document already accepts one bad outcome from this branch. If the moderator is absent for the whole grace window and the breach was out of scope, stakers lose principal and bonus to recoveryAddress. That is stated as a trust assumption.

That assumption covers an absent moderator. In this sequence the moderator can be present for the whole period and the same loss still happens, because no valid action existed during the grace period. The documented trade-off assumes the timer measures absence, and anchored to expiry it doesn’t.

Fix

The fix is to start the grace period when the pool first sees CORRUPTED, the first moment the moderator’s decision exists. One detail matters. If claimExpired is the first call to observe CORRUPTED, it can’t revert with AgreementCorruptedAwaitingModerator after recording the timestamp, because a revert rolls back every write in the call, the timestamp included. The pool would never remember the observation, and the clock would never start. So that first observation has to return successfully:

soliditythe fix, as I proposed it, shortened
uint256 public firstCorruptedObservationAt;

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
    state = _getAgreementState();
    if (state == IAttackRegistry.ContractState.CORRUPTED && firstCorruptedObservationAt == 0) {
        firstCorruptedObservationAt = block.timestamp;
    }
    // ...
}

// in claimExpired, while the pool is UNRESOLVED
uint256 observedBefore = firstCorruptedObservationAt;
IAttackRegistry.ContractState state = _observePoolState();
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0 && observedBefore == 0) {
    return; // succeed, so the timestamp above survives
}
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
if (block.timestamp < firstCorruptedObservationAt + MODERATOR_CORRUPTED_GRACE) {
    revert AgreementCorruptedAwaitingModerator();
}

pokeRiskWindow and flagOutcome both go through _observePoolState, so the timestamp is set by whichever successful call sees the state first. If the pool sits unobserved for a while after the breach, the backstop only waits longer, which is the safe direction. And taking the later of expiry and the first observation would keep today’s timing for every pool breached before it expires, changing only the late case.

Conclusion

Each part of this branch matches its comment: the constant, the comparison and the latch. The problem is the anchor. A fallback meant to fire after the moderator has been silent for 180 days has to start counting when there is first something to decide, and that moment is set by a different contract, whose UNDER_ATTACK state has no deadline. A timer in one contract that stands in for state in another has to be checked against every state the other contract can be in while the timer runs.

R4Y4N3
back to research