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

Attacker Bounty Underfunded After Pre-Claim Sweep via sweepUnclaimedBonus()

Author Revealed upon completion

Description

  • ConfidencePool allows the moderator to correct a typo'd outcome before any participant claims (claimsStarted = false). However, sweepUnclaimedBonus() can be called in this same window — it sweeps the bonus to recoveryAddress AND sets totalBonus = 0, but intentionally does NOT set claimsStarted = true.

    If the moderator then re-flags to good-faith CORRUPTED, flagOutcome() snapshots the current totalBonus (now 0) to calculate bountyEntitlement:

    bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus = stake + 0

    The whitehat attacker receives only the staked principal — the entire bonus pool has been permanently sent to recoveryAddress and is unrecoverable.

// sweepUnclaimedBonus() — sets totalBonus = 0 but NOT claimsStarted
if (totalEligibleStake == 0 || riskWindowStart == 0) {
// @> totalBonus depleted to 0
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// @> claimsStarted intentionally NOT set — re-flag window stays open
// flagOutcome() — snapshots depleted totalBonus
snapshotTotalBonus = totalBonus; // @> = 0 after sweep
bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus; // @> = stake + 0

Risk

Likelihood:

  • Requires moderator to flag wrong outcome first (typo/mistake)

  • sweepUnclaimedBonus() must be called before the correction

  • Moderator must then re-flag to good-faith CORRUPTED

  • Sequence is rare but realistic in production

Impact:

  • Whitehat attacker receives only staked principal, losing entire bonus

  • In a $100K bonus pool: whitehat loses$100,000 permanently

  • Bonus tokens are sent to recoveryAddress and cannot be reclaimed

  • Verified: 90% bounty loss in Scenario 3 (10,000 received vs 110,000 owed)

Proof of Concept

Run: forge test --match-test test_M02_Scenario3_LargePool_EconomicImpact -vvv

Attack steps:

  1. Alice stakes 10,000 tokens, Carol contributes 100,000 bonus

  2. Moderator flags SURVIVED (no risk window — riskWindowStart = 0)

  3. Anyone calls sweepUnclaimedBonus() — sweeps 100,000 tokens to recoveryAddress, totalBonus = 0

  4. Moderator re-flags to good-faith CORRUPTED (allowed: claimsStarted = false)

  5. bountyEntitlement = 10,000 + 0 = 10,000 (instead of 110,000)

  6. Whitehat receives 10,000 tokens — loses 100,000 tokens (90%)

function test_M02_Scenario3_LargePool_EconomicImpact() public {
uint256 stakeAmount = 10_000 ether;
uint256 bonusAmount = 100_000 ether;
_stake(pool, alice, stakeAmount);
_bonus(pool, carol, bonusAmount);
// Flag SURVIVED (no risk window)
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderatorAddr);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Sweep depletes totalBonus to 0
pool.sweepUnclaimedBonus();
// Re-flag to CORRUPTED — bountyEntitlement snapshots depleted totalBonus
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderatorAddr);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// Whitehat receives 10,000 instead of 110,000 — loses 100,000 tokens (90%)
assertEq(pool.bountyEntitlement(), stakeAmount);
}

Forge output:

Fair bounty: 110,000 tokens
Actual bounty: 10,000 tokens
Whitehat LOST: 100,000 tokens (90%)

Recommended Mitigation

sweepUnclaimedBonus() should set claimsStarted = true when sweeping
a non-zero amount. A sweep is a value-movement event that permanently
removes tokens from the pool — it should close the moderator's re-flag
window exactly like a claim does.

- stakeToken.safeTransfer(recoveryAddress, amount);
+ if (amount > 0) {
+ claimsStarted = true;
+ stakeToken.safeTransfer(recoveryAddress, amount);
+ }

Support

FAQs

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

Give us feedback!