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

`sweepUnclaimedBonus` does not latch finality, so a SURVIVED→good-faith-CORRUPTED re-flag under-pays the named whitehat by the entire swept bonus

Author Revealed upon completion

Root + Impact

Description

  • Normally, once the moderator flags a good-faith CORRUPTED naming a whitehat, DESIGN.md §12 guarantees the entire pool (snapshotTotalStaked + snapshotTotalBonus) is that whitehat's bounty.

  • sweepUnclaimedBonus deliberately does not set claimsStarted (to stop a 1-wei donation from blocking the moderator's re-flag window). But when it runs after a first SURVIVED flag with riskWindowStart == 0, it moves the genuine bonus to recoveryAddress and zeroes totalBonus while the re-flag window is still open. A later corrective good-faith CORRUPTED re-snapshots snapshotTotalBonus = totalBonus (now 0), so the whitehat's bountyEntitlement excludes the swept bonus. Value left the pool during a window the design treats as still-correctable.

// src/ConfidencePool.sol :: sweepUnclaimedBonus
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // genuine bonus removed from accounting
}
@> stakeToken.safeTransfer(recoveryAddress, amount); // value leaves the pool...
@> // ...but claimsStarted is intentionally NOT set, so the re-flag window stays open
// src/ConfidencePool.sol :: flagOutcome (the corrective re-flag re-snapshots the now-empty bonus)
@> snapshotTotalBonus = totalBonus; // == 0 after the sweep
...
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0; // principal only

Risk

Likelihood:

  • Occurs when the registry reaches CORRUPTED with riskWindowStart == 0 (no pool interaction observed active risk, a reachable case per DESIGN.md §5), the moderator first flags SURVIVED (a valid out-of-scope judgment), any address calls sweepUnclaimedBonus, then the moderator corrects to good-faith CORRUPTED.

  • Any unprivileged account can trigger the value-moving step by front-running the moderator's correction with sweepUnclaimedBonus.

Impact:

  • The named good-faith whitehat receives only principal; the full bonus portion of the promised bounty is permanently stranded at recoveryAddress (the sponsor). In the PoC that is 50 of 150, a 33% shortfall against the §12 whole-pool guarantee.

  • The loss is not recoverable: bountyEntitlement is snapshot-capped and contributeBonus reverts post-resolution.

Proof of Concept

// test/Metatron_PoC.t.sol :: MetatronPoC
function test_C03_whitehatUnderpaidAfterBonusSweepThenReflag() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 50 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(pool.riskWindowStart(), 0, "no active-risk observed");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0)); // valid out-of-scope judgment
assertFalse(pool.claimsStarted());
uint256 recBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus(); // genuine bonus leaves, no latch
assertEq(token.balanceOf(recovery) - recBefore, 50 ether, "bonus swept to recovery");
assertFalse(pool.claimsStarted(), "sweep did not latch finality");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // corrective re-flag, window still open
assertEq(pool.bountyEntitlement(), 100 ether, "bounty recomputed from depleted totalBonus");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 ether, "whitehat under-paid: 100 not 150");
assertEq(token.balanceOf(recovery) - recBefore, 50 ether, "bonus stranded at recovery (sponsor)");
}

Run:

forge test --match-test test_C03_whitehatUnderpaidAfterBonusSweepThenReflag -vv

Result:

Ran 2 tests for test/Metatron_PoC.t.sol:MetatronPoC
[PASS] test_C03_whitehatUnderpaidAfterBonusSweepThenReflag() (gas: 593787)
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Block a SURVIVED→CORRUPTED re-flag once genuine (non-donation) bonus has been swept, keying on real bonus outflow (not any transfer) so the 1-wei-donation protection is preserved:

function sweepUnclaimedBonus() external nonReentrant {
...
+ // Record that genuine (non-donation) bonus has permanently left the pool.
+ if (totalEligibleStake == 0 || riskWindowStart == 0) {
+ bonusSwept = true;
+ }
stakeToken.safeTransfer(recoveryAddress, amount);
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
+ if (newOutcome == PoolStates.Outcome.CORRUPTED && bonusSwept) revert OutcomeAlreadySet();

Support

FAQs

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

Give us feedback!