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

An expiry claim can finalize the pool before a successful attack is published

Author Revealed upon completion

Root + Impact

Description

After a successful in-scope attack, the upstream AttackRegistry must first be changed to CORRUPTED. The ConfidencePool moderator can then mark the pool as good-faith CORRUPTED and assign the pool bounty to the whitehat.

The Agreement owner is assigned as the initial AttackRegistry moderator when the Agreement is registered:

// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol
s_agreementInfo[agreementAddress] = AgreementInfo({
attackModerator: agreementOwner, // @> The sponsor initially controls the corruption signal
deadlineTimestamp: block.timestamp + PROMOTION_WINDOW,
promotionRequestedTimestamp: 0,
attackRequested: true,
attackApproved: false,
promoted: false,
corrupted: false,
isRegistered: true
});

The attack moderator publishes a successful attack by calling markCorrupted():

// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol
function markCorrupted(address agreementAddress) external onlyAttackModerator(agreementAddress) {
ContractState currentState = _getAgreementState(agreementAddress);
if (currentState != ContractState.UNDER_ATTACK && currentState != ContractState.PROMOTION_REQUESTED) {
revert AttackRegistry__InvalidState(currentState);
}
s_agreementInfo[agreementAddress].corrupted = true;
// ...
}

This creates a gap between a real exploit and the on-chain CORRUPTED signal. During that gap, the ConfidencePool moderator cannot flag the correct outcome. flagOutcome(CORRUPTED) requires the upstream state to already be CORRUPTED:

// src/ConfidencePool.sol
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external
onlyModerator
{
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) {
revert OutcomeAlreadySet();
}
IAttackRegistry.ContractState state = _observePoolState();
// ...
if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome(); // @>
}
}

At the pool expiry, anyone can call claimExpired(). When the registry still reports UNDER_ATTACK or PROMOTION_REQUESTED, the function resolves the pool as EXPIRED and immediately sets claimsStarted:

// src/ConfidencePool.sol
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
} else {
// @> UNDER_ATTACK and PROMOTION_REQUESTED both reach this branch
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}
claimsStarted = true; // @> The moderator can no longer correct the outcome

The sponsor can therefore delay markCorrupted() until after claimExpired():

  1. A whitehat completes a successful in-scope attack shortly before the pool expiry.

  2. The sponsor still controls the upstream attack-moderator role and leaves the Agreement as UNDER_ATTACK.

  3. The sponsor can do it to not pay an attacker at all or to save his own staked amount in the confidence pool.

  4. At expiry, the sponsor or any other account calls claimExpired().

  5. The pool becomes EXPIRED, starts claims, and pays stakers.

  6. The sponsor publishes the real CORRUPTED state afterward.

  7. The pool moderator tries to flag good-faith CORRUPTED, but the call reverts with OutcomeAlreadySet.

  8. The whitehat receives nothing from the ConfidencePool.

There is no point where the pool moderator can apply the correct result:

Before claimExpired: registry is UNDER_ATTACK -> flagOutcome reverts with InvalidOutcome
After claimExpired: claimsStarted is true -> flagOutcome reverts with OutcomeAlreadySet

If the sponsor contributed a bonus, then the sponsor can also recover most of its own bonus when the pool has not observed the risk window before the final minute. A large sponsor stake becomes the first observation, which clamps all earlier stakes to the same effective entry time. The k=2 time factor is then equal for all stakers, so the bonus is split by stake amount.

For example:

Existing stake: 10,000 tokens
Sponsor-funded bonus: 100,000 tokens
Sponsor's late stake: 990,000 tokens
Time until expiry: 60 seconds

The sponsor owns 99% of the total stake and receives 99% of the bonus. The whitehat receives zero from the ConfidencePool. This bonus recovery is an impact amplifier, it is not required for the incorrect expiry result.

Risk

Likelihood:

  • A successful in-scope attack must occur shortly before the pool expiry.

  • The pool must still be unresolved when expiry is reached.

  • The upstream state must remain UNDER_ATTACK or PROMOTION_REQUESTED until claimExpired() executes.

  • Deliberate sponsor control requires the sponsor to still hold the AttackRegistry moderator role, which is the default assignment but can be transferred.

Impact:

  • A good-faith whitehat can lose the entire ConfidencePool bounty.

  • The pool pays stakers under EXPIRED even though a covered attack succeeded before expiry.

  • With an unobserved risk window and enough capital, the sponsor can recover most of the bonus through a late stake.

  • The separate Safe Harbor Agreement bounty is not affected by this finding.

Proof of Concept

Add the following test to test/unit/DelayedCorruptionExpiry.t.sol and run:

forge test --match-contract DelayedCorruptionExpiryTest -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 DelayedCorruptionExpiryTest is BaseConfidencePoolTest {
function testPoC_sponsorRecoversBonusBeforePublishingCorruption() external {
address sponsor = carol;
// Alice stakes before the Agreement becomes attackable. The sponsor advertises a
// 100,000-token bonus.
_stake(alice, 10_000 * ONE);
_contributeBonus(sponsor, 100_000 * ONE);
// The Agreement enters UNDER_ATTACK, but nobody interacts with the pool, so the local
// risk window remains unobserved.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
assertEq(pool.riskWindowStart(), 0);
// A real exploit has succeeded, but the sponsor has not published CORRUPTED yet. One
// minute before expiry, the sponsor becomes the first pool observer with a large stake.
vm.warp(pool.expiry() - 60);
_stake(sponsor, 990_000 * ONE);
assertEq(pool.riskWindowStart(), pool.expiry() - 60);
// Both Alice and the sponsor now have the same effective entry time. At expiry the
// sponsor resolves EXPIRED before publishing the truthful CORRUPTED state.
vm.warp(pool.expiry());
vm.prank(sponsor);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(pool.claimsStarted());
// Simulates the sponsor's subsequent AttackRegistry.markCorrupted() call.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// The independent pool moderator can no longer give the pool to the whitehat.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(alice);
pool.claimExpired();
// Same one-minute duration means k=2 reduces to amount weighting:
// Alice owns 1% of total stake; the sponsor owns 99%.
assertEq(token.balanceOf(alice), 11_000 * ONE, "Alice gets principal plus 1% of bonus");
assertEq(token.balanceOf(sponsor), 1_089_000 * ONE, "sponsor recovers stake plus 99% of bonus");
assertEq(token.balanceOf(attacker), 0, "whitehat receives no ConfidencePool bounty");
}
}

Recommended Mitigation

One practical fix is to add a short post-expiry challenge window so an unresolved pool cannot immediately pay out while the Agreement is still attackable.

uint32 public expiredAt;
uint32 public challengeDeadline;
uint32 public constant EXPIRY_CHALLENGE_WINDOW = 3 days;
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
IAttackRegistry.ContractState state = _observePoolState();
if (
outcome == PoolStates.Outcome.UNRESOLVED
&& (state == IAttackRegistry.ContractState.UNDER_ATTACK
|| state == IAttackRegistry.ContractState.PROMOTION_REQUESTED)
) {
if (expiredAt == 0) {
expiredAt = uint32(block.timestamp);
challengeDeadline = uint32(block.timestamp + EXPIRY_CHALLENGE_WINDOW);
}
revert ChallengeWindowActive();
}
// existing immediate resolution / payout logic
}
function finalizeExpired() external nonReentrant {
if (expiredAt == 0 || block.timestamp < challengeDeadline) revert ChallengeWindowActive();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert InvalidOutcome();
// snapshot totals
// outcome = PoolStates.Outcome.EXPIRED;
// outcomeFlaggedAt = expiry;
}

Support

FAQs

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

Give us feedback!