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

# `sweepUnclaimedBonus` moves value under a provisional SURVIVED outcome, causing a later CORRUPTED re-flag to underfund the whitehat bounty ##

Author Revealed upon completion

Root + Impact

Description

By design, the moderator may correct a previously flagged outcome while claimsStarted == false. This allows a provisional judgment to be corrected before the distribution is considered final.

The issue is that sweepUnclaimedBonus() can irreversibly transfer recorded bonus value out of the pool without setting claimsStarted to true.

In the riskWindowStart == 0 case, the recorded bonus is not included in the reserved balance:

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
) {
@> totalBonus -=
amount <= totalBonus
? amount
: totalBonus;
}
@> // Intentionally does NOT set claimsStarted.
@> stakeToken.safeTransfer(
recoveryAddress,
amount
);
}

When the pool contains eligible principal plus recorded bonus and riskWindowStart == 0, the bonus is treated as unreserved. As a result, sweepUnclaimedBonus() can transfer the full recorded bonus to recoveryAddress and reduce totalBonus to zero.

However, claimsStarted remains false.

This means the contract simultaneously considers:

  • value to have been irreversibly distributed according to the current outcome; and

  • the outcome itself to still be mutable.

A valid scenario is:

  1. The live registry state is CORRUPTED.

  2. No local risk window was observed, so:

riskWindowStart == 0
  1. The moderator initially determines that the breach was outside the pool's scope and legitimately flags:

SURVIVED
  1. Before any claim starts, any caller invokes the permissionless:

sweepUnclaimedBonus()
  1. Because riskWindowStart == 0, the recorded bonus is not reserved and can be transferred to recoveryAddress.

  2. The transfer reduces:

totalBonus

while:

claimsStarted == false
  1. The moderator subsequently corrects the original scope judgment and re-flags the same pool as good-faith:

CORRUPTED
  1. The new CORRUPTED snapshot is taken after totalBonus has already been reduced.

As a result, the previously swept bonus is permanently excluded from:

snapshotTotalBonus
bountyEntitlement
corruptedReserve

The corrected CORRUPTED outcome therefore cannot restore the value that was distributed under the earlier provisional SURVIVED outcome.

For example, with:

Eligible stake: 100 tokens
Recorded bonus: 10,000 tokens

the expected full-pool value before the provisional sweep is:

10,100 tokens

After the SURVIVED sweep:

10,000 bonus tokens
→ recoveryAddress
totalBonus
0
claimsStarted
false

The later good-faith CORRUPTED re-flag succeeds, but the resulting bounty becomes:

bountyEntitlement = 100 tokens
corruptedReserve = 100 tokens

The entire 10,000 token bonus component that existed before the provisional SURVIVED distribution is no longer included in the whitehat's eventual bounty.

The root cause is an inconsistency between outcome finality and value-movement finality:

Irreversible value movement is allowed
while
the outcome authorizing that movement remains re-flaggable.

Risk

Likelihood

Reason 1: The registry is CORRUPTED, but no active-risk state was locally observed by the pool, leaving:

riskWindowStart == 0

The moderator initially determines that the breach was outside the pool's scope and flags the outcome as SURVIVED.

This is a valid use of the moderator's scope judgment: the agreement-level registry state can be CORRUPTED while the pool outcome is SURVIVED when the breach is considered outside the pool's committed scope.

Reason 2: Before any claim begins, any caller invokes the permissionless sweepUnclaimedBonus() function while the provisional SURVIVED outcome is active.

Because riskWindowStart == 0, the recorded bonus is not included in the reserved balance. Depending on the pool balance, the full recorded bonus can therefore be transferred to recoveryAddress.

The function reduces totalBonus but does not set:

claimsStarted = true

Reason 3: Before the first claim, the moderator corrects the original scope judgment and re-flags the pool as good-faith CORRUPTED.

Because claimsStarted is still false, the re-flag succeeds.

However, the new snapshot is taken after the earlier sweep has already reduced totalBonus, so the transferred bonus is permanently excluded from the corrected CORRUPTED bounty.


Impact

Impact 1: The named whitehat can permanently lose the entire bonus component that would otherwise have been included in the good-faith CORRUPTED full-pool bounty.

The swept value has already left the contract and cannot be restored by the later outcome correction.

Impact 2: The contract permits irreversible value movement under a provisional outcome while still allowing that outcome to be changed.

Consequently, the corrected CORRUPTED snapshot is calculated from already-mutated accounting state and cannot represent the pool value that existed before the provisional SURVIVED distribution.

Impact 3: The documented correction window becomes economically incomplete.

Although the moderator can successfully correct:

SURVIVED
CORRUPTED

before claimsStarted becomes true, the corrected outcome cannot recover value that sweepUnclaimedBonus() already distributed under the previous provisional outcome.

In the demonstrated scenario:

Initial eligible stake: 100 tokens
Initial recorded bonus: 10,000 tokens
Initial pool value: 10,100 tokens
Bonus swept: 10,000 tokens
Final bountyEntitlement: 100 tokens
Final corruptedReserve: 100 tokens
Whitehat payout: 100 tokens

The whitehat therefore receives only the remaining principal component, while the entire previously recorded bonus component is excluded from the corrected good-faith CORRUPTED bounty.


Proof of Concept

function testWhitehatBountyLossViaProvisionalSweep() external {
address whitehat = makeAddr("whitehat");
_stake(alice, 100 * ONE);
_contributeBonus(carol, 10_000 * ONE);
assertEq(
pool.totalEligibleStake(),
100 * ONE,
"Initial stake should be 100"
);
assertEq(
pool.totalBonus(),
10_000 * ONE,
"Initial bonus should be 10,000"
);
assertEq(
pool.riskWindowStart(),
0,
"No active risk was locally observed"
);
// Agreement is terminally CORRUPTED.
// Moderator initially judges the breach as out-of-scope
// and therefore legitimately flags SURVIVED.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.SURVIVED)
);
assertFalse(
pool.claimsStarted(),
"Outcome should still be re-flaggable"
);
uint256 recoveryBalanceBefore =
token.balanceOf(pool.recoveryAddress());
// Permissionless value movement under the provisional SURVIVED outcome.
pool.sweepUnclaimedBonus();
uint256 recoveryBalanceAfter =
token.balanceOf(pool.recoveryAddress());
assertEq(
recoveryBalanceAfter - recoveryBalanceBefore,
10_000 * ONE,
"Entire bonus was swept out of the pool"
);
assertEq(
pool.totalBonus(),
0,
"Recorded bonus is now zero"
);
assertFalse(
pool.claimsStarted(),
"Sweep moved value without finalizing the outcome"
);
// Moderator corrects the original out-of-scope judgment:
// the breach was actually in-scope.
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
whitehat
);
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED)
);
assertTrue(pool.goodFaith());
assertEq(pool.attacker(), whitehat);
uint256 entitlement = pool.bountyEntitlement();
uint256 reserve = pool.corruptedReserve();
console.log(
"Whitehat Bounty Entitlement:",
entitlement
);
console.log(
"Corrupted Reserve:",
reserve
);
assertEq(
entitlement,
100 * ONE,
"Whitehat bounty excludes the previously swept 10,000 bonus"
);
assertEq(
reserve,
100 * ONE,
"Corrupted reserve excludes the previously swept bonus"
);
vm.prank(whitehat);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(whitehat),
100 * ONE,
"Whitehat receives only principal after bonus was irreversibly swept"
);
}

Observed result

The PoC successfully demonstrates the complete sequence:

SURVIVED
sweepUnclaimedBonus()
10,000 bonus tokens transferred to recoveryAddress
totalBonus becomes 0
claimsStarted remains false
SURVIVED is successfully re-flagged to good-faith CORRUPTED
bountyEntitlement = 100 tokens
corruptedReserve = 100 tokens
whitehat receives only 100 tokens

Observed output:

Whitehat Bounty Entitlement: 100000000000000000000
Corrupted Reserve: 100000000000000000000

The PoC confirms that the bonus value is irreversibly transferred before the outcome is finalized, while the contract still permits the outcome to be corrected afterward.


Recommended Mitigation

Any transfer that removes recorded pool value according to the current outcome should also finalize the distribution state, or the transfer should be prevented while the outcome remains re-flaggable.

A minimal mitigation is to close the correction window whenever sweepUnclaimedBonus() actually reduces the recorded totalBonus.

For example:

function sweepUnclaimedBonus()
external
nonReentrant
{
// ... existing validation and accounting ...
uint256 bonusReduction;
if (
totalEligibleStake == 0 ||
riskWindowStart == 0
) {
bonusReduction =
amount <= totalBonus
? amount
: totalBonus;
totalBonus -= bonusReduction;
}
if (bonusReduction != 0) {
claimsStarted = true;
}
stakeToken.safeTransfer(
recoveryAddress,
amount
);
}

This preserves the existing ability to sweep unrelated excess balance without necessarily finalizing the outcome, while ensuring that a transfer which consumes recorded bonus value cannot occur under an outcome that remains mutable.

Alternatively, the protocol can use a dedicated distribution-finality latch and set it whenever any outcome-dependent transfer of recorded pool value occurs.

The intended invariant should be:

Once recorded pool value has been irreversibly distributed
according to an outcome,
that outcome must no longer be re-flaggable.

Support

FAQs

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

Give us feedback!