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

Accounted bonus can be swept before a good-faith CORRUPTED reflag, stealing part or all of the attacker bounty

Author Revealed upon completion

Summary

sweepUnclaimedBonus() can transfer protocol-accounted bonus to recoveryAddress without setting claimsStarted. The moderator can then use the documented pre-claim correction window to reflag the same pool from SURVIVED to good-faith CORRUPTED, but the corrected snapshot is taken after totalBonus has already been reduced. The latest outcome says the named attacker is entitled to the good-faith CORRUPTED bounty, yet the bonus portion of that bounty is already held by recoveryAddress.

The issue is not a disagreement with the no-risk-window rule. In a final SURVIVED or EXPIRED outcome with riskWindowStart == 0, stakers are owed principal only and the bonus is sweepable. The break appears because the sweep is allowed while the correction window is still open. This path moves accounted bonus but leaves claimsStarted == false, so later accounting acts as if no final value movement occurred.

Finding Description

Affected function: ConfidencePool.sweepUnclaimedBonus().

Relevant behavior:

  1. flagOutcome(SURVIVED) is allowed while the registry is CORRUPTED when the moderator judges the breach out of scope.

  2. Before any claim, flagOutcome() intentionally permits a correction to good-faith CORRUPTED.

  3. If no active-risk window was observed, _bonusShare() returns zero for SURVIVED/EXPIRED stakers, so sweepUnclaimedBonus() treats the tracked bonus as unreserved.

  4. sweepUnclaimedBonus() transfers that tracked bonus to recoveryAddress, decrements totalBonus, and intentionally does not set claimsStarted.

  5. A later good-faith CORRUPTED reflag snapshots snapshotTotalBonus = totalBonus, which is now zero.

  6. claimAttackerBounty() pays only the remaining principal, even though a direct good-faith CORRUPTED flag over the same original pool pays principal plus bonus.

The sweeper does not need privileged access. The only privileged action is the moderator's documented pre-claim correction reflag, which is part of the intended protocol flow. The exploitable action is that any caller can move accounted bonus to recoveryAddress before that correction finalizes.

Impact Explanation

The corrected good-faith attacker bounty can be reduced by the full bonus balance. With stake plus bonus, the attacker receives only principal and recoveryAddress keeps the bonus. With a bonus-only pool, the entire good-faith bounty can be swept before correction and the named attacker has no remaining entitlement. A value-moving sweep of accounted protocol bonus must either finalize the current outcome or be impossible while the moderator correction window remains open. Otherwise, the pool can move funds according to one outcome while still allowing accounting to be resnapshotted under a different outcome.

The PoC uses 100e18 stake and 50e18 bonus. Direct good-faith CORRUPTED pays 150e18. The exploit sequence pays the attacker 100e18 and leaves 50e18 with recoveryAddress. The loss scales linearly with totalBonus.

Likelihood Explanation

The sequence is realistic during the protocol’s intended correction window. The moderator may initially flag SURVIVED for a registry-level CORRUPTED agreement when the breach appears out of scope, then correct to good-faith CORRUPTED after confirming the breach was in scope. A sponsor-controlled recovery address can watch the temporary SURVIVED flag and call sweepUnclaimedBonus() before the correction.

The path requires riskWindowStart == 0, which is also an intended and documented state: the registry can reach terminal CORRUPTED without the pool locally observing active risk, and moderator CORRUPTED remains allowed in that case.

Proof of Concept

Mainnet environment validation used BattleChain mainnet block 84. The exploit itself is deterministic against the local Foundry mocks.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {console2} from "forge-std/console2.sol";
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 ConfidencePoolBonusSweepReflagPoC is BaseConfidencePoolTest {
function test_bonusSweepBeforeGoodFaithReflagStealsAttackerBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
uint256 recoveryGain = token.balanceOf(recovery) - recoveryBefore;
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
uint256 attackerPayout = token.balanceOf(attacker) - attackerBefore;
assertEq(recoveryGain, 50 * ONE, "recovery address keeps the bonus");
assertEq(attackerPayout, 100 * ONE, "attacker receives only principal");
assertEq(150 * ONE - attackerPayout, 50 * ONE, "bonus stolen from bounty");
}
function test_directGoodFaithCorruptedPaysStakeAndBonusControl() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker) - attackerBefore, 150 * ONE);
}
function test_bonusOnlyPoolCanHaveEntireGoodFaithBountySweptBeforeReflag() external {
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
uint256 recoveryGain = token.balanceOf(recovery) - recoveryBefore;
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(attacker);
vm.expectRevert(IConfidencePool.BountyAlreadyClaimed.selector);
pool.claimAttackerBounty();
assertEq(recoveryGain, 50 * ONE);
assertEq(pool.bountyEntitlement(), 0);
}
}

PoC output:

Ran 3 tests for my-poc/ConfidencePoolBonusSweepReflagPoC.t.sol
[PASS] test_bonusOnlyPoolCanHaveEntireGoodFaithBountySweptBeforeReflag()
Logs:
bonus-only recovery gain 50000000000000000000
bonus-only bounty entitlement after reflag 0
[PASS] test_bonusSweepBeforeGoodFaithReflagStealsAttackerBonus()
Logs:
recovery gain from pre-reflag sweep 50000000000000000000
claimsStarted after sweep false
corrected latest outcome CORRUPTED goodFaith
snapshotTotalBonus after reflag 0
bountyEntitlement after reflag 100000000000000000000
attacker payout after corrected outcome 100000000000000000000
expected direct good-faith bounty 150000000000000000000
attacker shortfall 50000000000000000000
[PASS] test_directGoodFaithCorruptedPaysStakeAndBonusControl()
Logs:
direct good-faith bounty payout 150000000000000000000

Recommended Mitigation

Separate donation/dust sweeps from accounted-bonus sweeps while the reflag window is open. A pre-finality sweep may recover untracked donations, but it must not move tracked totalBonus unless the outcome has already been finalized by a claim or the sweep itself deliberately finalizes the outcome.

Minimal patch direction:

function sweepUnclaimedBonus() external nonReentrant {
...
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
+ uint256 accountedBonusSwept;
if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ accountedBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ if (accountedBonusSwept != 0 && !claimsStarted) revert OutcomeNotFinal();
+ totalBonus -= accountedBonusSwept;
}
...
}

Blocking accounted-bonus sweeps before finality preserves the moderator correction window while still allowing donation-only sweeps.

Validation steps

Environment

Use the repository root:

cd "C:\Users\eshaa\OneDrive\Desktop\Bug Hunting\CodeHawks\2026-07-bc-confidence-pools"

Foundry is installed at:

C:\Users\eshaa\.foundry\bin\forge.exe
C:\Users\eshaa\.foundry\bin\cast.exe

The project .env contains:

BATTLECHAIN_RPC=https://rpc.battlechain.com

The required hostname did not resolve from the validation machine. The exact required command fails before reaching JSON-RPC:

$env:BATTLECHAIN_RPC='https://rpc.battlechain.com'
& "$HOME\.foundry\bin\cast.exe" chain-id --rpc-url $env:BATTLECHAIN_RPC

Expected observed output on the validation machine:

Error: error sending request for url (https://rpc.battlechain.com/)
Context:
- Error #0: client error (Connect)
- Error #1: dns error
- Error #2: No such host is known. (os error 11001)

The reachable BattleChain mainnet endpoint verifies the expected chain id:

$env:BATTLECHAIN_RPC='https://mainnet.battlechain.com'
& "$HOME\.foundry\bin\cast.exe" chain-id --rpc-url $env:BATTLECHAIN_RPC
& "$HOME\.foundry\bin\cast.exe" block latest --rpc-url $env:BATTLECHAIN_RPC

Expected output:

626
number 84
hash 0xf6aa5af4bd1c779d794802a8d679e280d525e8518840583bca51745d2952567f
timestamp 1783435251 (Tue, 7 Jul 2026 14:40:51 +0000)

Reproduction

Run the PoC from the repository root while keeping the PoC under my-poc:

$env:FOUNDRY_TEST='my-poc'
& "$HOME\.foundry\bin\forge.exe" test -vv

The PoC contains three tests. The exploit test performs the following sequence:

  1. Alice stakes 100e18.

  2. Carol contributes 50e18 bonus.

  3. The registry mock is moved directly to CORRUPTED, leaving riskWindowStart == 0.

  4. The moderator temporarily flags SURVIVED.

  5. sweepUnclaimedBonus() is called before any claim.

  6. The sweep transfers 50e18 to recoveryAddress, reduces totalBonus to zero, and leaves claimsStarted == false.

  7. The moderator corrects the outcome to good-faith CORRUPTED and names attacker.

  8. The corrected CORRUPTED snapshot has snapshotTotalBonus == 0.

  9. attacker claims only 100e18.

  10. The control path proves direct good-faith CORRUPTED would have paid 150e18.

Expected output:

Ran 3 tests for my-poc/ConfidencePoolBonusSweepReflagPoC.t.sol:ConfidencePoolBonusSweepReflagPoC
[PASS] test_bonusOnlyPoolCanHaveEntireGoodFaithBountySweptBeforeReflag()
Logs:
bonus-only recovery gain 50000000000000000000
bonus-only bounty entitlement after reflag 0
[PASS] test_bonusSweepBeforeGoodFaithReflagStealsAttackerBonus()
Logs:
initial pool balance 150000000000000000000
initial totalEligibleStake 100000000000000000000
initial totalBonus 50000000000000000000
temporary outcome SURVIVED
riskWindowStart 0
snapshotTotalStaked 100000000000000000000
snapshotTotalBonus 50000000000000000000
recovery gain from pre-reflag sweep 50000000000000000000
pool balance after sweep 100000000000000000000
totalBonus after sweep 0
claimsStarted after sweep false
corrected latest outcome CORRUPTED goodFaith
snapshotTotalStaked after reflag 100000000000000000000
snapshotTotalBonus after reflag 0
bountyEntitlement after reflag 100000000000000000000
attacker payout after corrected outcome 100000000000000000000
final pool balance 0
expected direct good-faith bounty 150000000000000000000
attacker shortfall 50000000000000000000
[PASS] test_directGoodFaithCorruptedPaysStakeAndBonusControl()
Logs:
direct good-faith bounty payout 150000000000000000000

Validation logic

The exploit is confirmed if all of the following are true:

  1. pool.riskWindowStart() is zero when the temporary SURVIVED outcome is flagged.

  2. The first snapshot records snapshotTotalBonus == 50e18.

  3. sweepUnclaimedBonus() transfers 50e18 to recoveryAddress.

  4. pool.claimsStarted() remains false immediately after that sweep.

  5. A subsequent flagOutcome(CORRUPTED, true, attacker) succeeds.

  6. The corrected snapshot records snapshotTotalBonus == 0.

  7. pool.bountyEntitlement() is 100e18, not 150e18.

  8. claimAttackerBounty() transfers only 100e18.

  9. The direct-control path transfers 150e18 under good-faith CORRUPTED when the intermediate sweep is skipped.

The bonus-only test validates the maximum version of the same accounting break. With no stake and 50e18 bonus, a direct good-faith CORRUPTED outcome would make the bonus the entire bounty basis. The temporary SURVIVED sweep transfers that full 50e18 to recoveryAddress. The reflag then sets bountyEntitlement == 0, and claimAttackerBounty() reverts with BountyAlreadyClaimed.

Baseline tests

Run the non-fork test suite:

& "$HOME\.foundry\bin\forge.exe" test --no-match-path 'test/fork/*'

Expected summary:

Ran 12 test suites ... 256 tests passed, 0 failed, 0 skipped (256 total tests)

Run fork tests against the reachable mainnet endpoint:

$env:BATTLECHAIN_RPC='https://mainnet.battlechain.com'
& "$HOME\.foundry\bin\forge.exe" test --match-path 'test/fork/*'

Expected summary:

Ran 2 tests for test/fork/BattleChainFactoryIntegration.fork.t.sol
[PASS] testCreatePoolAgainstLiveAgreementSucceeds()
[PASS] testCreatePoolRejectsAccountNotInLiveAgreementScope()
Ran 4 tests for test/fork/BattleChainInterfaceDrift.fork.t.sol
[PASS] testAgreementRoundTrips()
[PASS] testAttackRegistryRoundTrips()
[PASS] testEnumOrdinalsAreStable()
[PASS] testSafeHarborRegistryRoundTrips()
6 tests passed, 0 failed, 0 skipped

Patch validation

Apply a patch that prevents sweepUnclaimedBonus() from moving tracked totalBonus while claimsStarted == false, while still allowing donation-only sweeps. After the patch:

  1. The exploit test should revert at sweepUnclaimedBonus() before any tracked bonus leaves.

  2. The direct-good-faith control should keep passing and pay 150e18.

  3. Existing donation-only sweep tests should keep passing.

  4. Existing dust sweep tests after all stakers claim should keep passing.

  5. Existing no-risk principal-claim behavior should keep passing.

The invariant restored by the patch is:

A value-moving sweep of accounted protocol bonus must either finalize the current outcome or be impossible while the moderator correction window remains open. Otherwise, the pool can move funds according to one outcome while still allowing accounting to be resnapshotted under a different outcome.

If the implementation instead chooses to set claimsStarted = true when tracked bonus is swept, the exploit test should fail at the later reflag with OutcomeAlreadySet. That also prevents the false corrected outcome, but it lets a sweep finalize a temporary SURVIVED outcome. Blocking tracked-bonus sweeps before finality gives cleaner correction semantics.

BattleChain Confidence Pools State Machine

Registry-State Function Matrix

Function NOT_DEPLOYED NEW_DEPLOYMENT ATTACK_REQUESTED UNDER_ATTACK PROMOTION_REQUESTED PRODUCTION CORRUPTED
stake Allowed before expiry/outcome/pause; locks expiryLocked Allowed before expiry/outcome/pause; locks expiryLocked Allowed; also locks scope through _observePoolState Allowed by design; opens riskWindowStart, disables later withdraw Reverts StakingClosed Reverts StakingClosed Reverts StakingClosed
contributeBonus Allowed before expiry/outcome/pause Allowed before expiry/outcome/pause Allowed; locks scope Allowed; opens riskWindowStart Reverts StakingClosed Reverts StakingClosed Reverts StakingClosed
withdraw Allowed if riskWindowStart == 0 and stake exists Allowed if riskWindowStart == 0 and stake exists Allowed if riskWindowStart == 0; locks scope Reverts WithdrawsDisabled and opens riskWindowStart Reverts WithdrawsDisabled and opens riskWindowStart Reverts WithdrawsDisabled; seals riskWindowEnd Reverts WithdrawsDisabled; seals riskWindowEnd
flagOutcome(SURVIVED) Reverts InvalidOutcome Reverts InvalidOutcome Reverts InvalidOutcome Reverts InvalidOutcome Reverts InvalidOutcome Moderator-only; snapshots SURVIVED Moderator-only; snapshots SURVIVED for out-of-scope corruption
flagOutcome(CORRUPTED) Reverts InvalidOutcome Reverts InvalidOutcome Reverts InvalidOutcome Reverts InvalidOutcome Reverts InvalidOutcome Reverts InvalidOutcome Moderator-only; snapshots CORRUPTED
claimSurvived Ignores live state; requires prior SURVIVED outcome Same Same Same Same Same Same
claimExpired After expiry, unresolved pool resolves EXPIRED After expiry, unresolved pool resolves EXPIRED After expiry, unresolved pool resolves EXPIRED After expiry, unresolved pool resolves EXPIRED and may open/clamp risk start After expiry, unresolved pool resolves EXPIRED and may open/clamp risk start After expiry, unresolved pool resolves SURVIVED and seals end If riskWindowStart != 0, reverts during grace then auto-CORRUPTED after grace; if riskWindowStart == 0, resolves EXPIRED
claimCorrupted Ignores live state; requires CORRUPTED outcome Same Same Same Same Same Same
claimAttackerBounty Ignores live state; requires good-faith CORRUPTED, caller attacker, before deadline Same Same Same Same Same Same
sweepUnclaimedBonus Ignores live state; requires SURVIVED or EXPIRED outcome Same Same Same Same Same Same
sweepUnclaimedCorrupted Ignores live state; requires good-faith CORRUPTED after deadline Same Same Same Same Same Same
pokeRiskWindow Reverts RiskWindowNotReached if unresolved Reverts RiskWindowNotReached if unresolved Reverts RiskWindowNotReached; revert undoes observed scope lock Opens riskWindowStart Opens riskWindowStart Seals riskWindowEnd Seals riskWindowEnd
setPoolScope Owner-only; allowed Owner-only; allowed Reverts because _observePoolState would lock scope Reverts because _observePoolState would lock scope Reverts because _observePoolState would lock scope Reverts because _observePoolState would lock scope Reverts because _observePoolState would lock scope
setExpiry Owner-only until first stake; live state ignored Same Same Same Same Same Same
setRecoveryAddress Owner-only; live state ignored Same Same Same Same Same Same

Pool-Local Latches

Latch Who flips it Too early? Can be prevented? Stale/snapshot risk
scopeLocked Any successful _observePoolState call seeing a state other than NOT_DEPLOYED or NEW_DEPLOYMENT Yes for stakers if sponsor expected scope changes in ATTACK_REQUESTED, but documented as post-staging lock If all observing calls revert, no persistent lock; successful stake/bonus/withdraw in ATTACK_REQUESTED locks Later flagOutcome assumes the published local scope is final
expiryLocked First successful stake Not under current model; first stake creates reliance Cannot be reset even if all stake withdraws Protects expiry input to EXPIRED bonus formula
riskWindowStart Any successful observation of UNDER_ATTACK or PROMOTION_REQUESTED while unresolved Can be observed at/after expiry; capped to expiry and documented Can be missed if no pool call occurs during active risk Global sums reset eagerly; per-user sums clamp lazily on next user touch
riskWindowEnd Any successful observation of PRODUCTION or CORRUPTED while unresolved Can be observed late; capped to expiry Can be missed until resolution SURVIVED/CORRUPTED outcomeFlaggedAt uses this first observed terminal timestamp
outcome Moderator flagOutcome or mechanical claimExpired Can be set by non-staker claimExpired; documented Reflag allowed only before claimsStarted Latest outcome must determine later value movement; this is where the confirmed bug breaks
claimsStarted Successful claim paths and CORRUPTED sweeps; not sweepUnclaimedBonus Mechanical claimExpired sets it even for non-staker; documented Not set by bonus sweep by design Accounted bonus can leave while reflag remains open, desynchronizing finality from value movement
outcomeFlaggedAt Outcome resolution Uses riskWindowEnd for SURVIVED/CORRUPTED and expiry for EXPIRED No after resolution Bonus numerator/denominator rely on this being consistent with snapshots
corruptedClaimDeadline First-ever good-faith CORRUPTED flag anchors it; later good-faith re-entry reuses it Can be stale by design if moderator toggles away and back Cannot be extended Old deadline intentionally influences later good-faith re-entry
_firstGoodFaithCorruptedAt First good-faith CORRUPTED flag Same as deadline Never reset Prevents deadline refresh, but can make later corrected attacker inherit old window

Confirmed Broken Assumption

docs/DESIGN.md describes claimsStarted as the value-movement finality latch: once value leaves, a corrective reflag cannot be honored without breaking balance accounting. A value-moving sweep of accounted protocol bonus must either finalize the current outcome or be impossible while the moderator correction window remains open. sweepUnclaimedBonus() violates that rule for accounted bonus. In a no-risk SURVIVED/EXPIRED snapshot, the function can sweep the tracked bonus to recoveryAddress, decrement totalBonus, and leave claimsStarted == false. A subsequent good-faith CORRUPTED reflag snapshots only the remaining principal, so the latest outcome says the attacker bounty is active while the bonus portion has already been moved to recovery.

Support

FAQs

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

Give us feedback!