Root + Impact
Description
-
For good-faith CORRUPTED, the named attacker gets CORRUPTED_CLAIM_WINDOW, 180 days, to claim their bounty. DESIGN.md is explicit that this window exists so the pool stays reserved for them for that entire period. corruptedClaimDeadline anchors to the first good-faith flag and never resets, by design, so a moderator can't stall by repeatedly re-flagging.
The problem is the deadline being fixed doesn't just stop stalling, it also means nothing can extend it for a legitimate reason either. If the stake token supports address-level blacklisting, standard behavior on USDC and USDT, and the named attacker's own wallet gets blacklisted at any point during the window, every single claim attempt reverts for the rest of that window. The 180 days DESIGN.md describes as reserved for them stops being usable the moment the blacklist lands, regardless of how much time is technically still left on the clock.
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
...
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
@> corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
uint256 remaining = bountyEntitlement - bountyClaimed;
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
...
@> stakeToken.safeTransfer(attacker, payout);
Risk
Likelihood:
USDC and USDT are the most likely stake token choices for something meant to hold real, meaningful value, and both have real, actively used blacklisting. A named whitehat's wallet ending up blacklisted, whether from unrelated activity or compliance flags tied to security research tooling, isn't a contrived scenario.
There's no on-chain signal anywhere that alerts anyone when a claim attempt fails. A reverted call rolls back completely, nothing gets logged. The moderator's re-flag fix genuinely works if triggered in time, but nothing forces that awareness to happen, it depends entirely on the whitehat noticing and reporting it, or the moderator proactively checking on a claim that hasn't landed yet.
Impact:
-
The entitlement doesn't disappear, it lands at recoveryAddress once the deadline passes. But there's no on-chain mechanism forcing or facilitating restitution to the whitehat afterward, the same soft-recovery shape as the other findings in this audit that route through recoveryAddress.
-
Pausing the pool, the sponsor's only self-serve emergency lever, provides no protection at all. sweepUnclaimedCorrupted has no pause awareness, the forfeiture proceeds on schedule regardless.
-
This can involve real value. bountyEntitlement here is the entire pool for a genuinely attacked, genuinely staked pool, not a small donation to an early one.
Proof of Concept
The first test proves the core problem. A whitehat is correctly named good-faith CORRUPTED with 180 days still ahead of them, then their wallet gets blacklisted. Ten days later, with 170 days still nominally left, their own claim reverts, and the deadline hasn't moved to compensate. The second test confirms the moderator's fix genuinely works, re-flagging to a fresh address recovers the claim without resetting the window to a fresh 180 days. The third test shows what happens without that intervention, the entire entitlement forfeits to recoveryAddress once the deadline passes, and the whitehat never receives anything.
function testBlacklistedAttackerCannotClaimEvenWithTimeRemaining() external {
vm.prank(moderator);
ConfidencePool(pool).flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
uint256 deadline = ConfidencePool(pool).corruptedClaimDeadline();
assertEq(deadline, block.timestamp + 180 days);
token.setBlacklisted(whitehat, true);
vm.warp(block.timestamp + 10 days);
vm.prank(whitehat);
vm.expectRevert();
ConfidencePool(pool).claimAttackerBounty();
assertEq(token.balanceOf(whitehat), 0);
assertEq(ConfidencePool(pool).corruptedClaimDeadline(), deadline, "deadline never moved to compensate");
}
function testDeadlineExpiresWithoutReflagEntitlementForfeitsToRecoveryAddress() external {
vm.prank(moderator);
ConfidencePool(pool).flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
token.setBlacklisted(whitehat, true);
vm.warp(ConfidencePool(pool).corruptedClaimDeadline() + 1);
vm.prank(whitehat);
vm.expectRevert();
ConfidencePool(pool).claimAttackerBounty();
ConfidencePool(pool).sweepUnclaimedCorrupted();
assertEq(token.balanceOf(recovery), 100 * ONE);
assertEq(token.balanceOf(whitehat), 0, "the whitehat never received anything, despite doing nothing wrong");
}
The blacklisted whitehat's balance staying at zero across both tests is the core proof. The second test's final assertion is the worst case actually landing, the entire entitlement gone to recoveryAddress with no path back to the person who earned it.
Two more checks round this out. Pausing the pool, tested directly, does nothing to stop the forfeiture sweep. And there's a second, independent mitigation worth knowing about that doesn't need the moderator at all, the sponsor can redirect recoveryAddress itself to a clean wallet the whitehat controls before the deadline passes, confirmed working in a fourth test.
Recommended Mitigation
A naive fix, extending the deadline on every re-flag, reopens the exact stalling problem the fixed deadline was built to prevent. The safer version guarantees whoever is currently named gets a minimum real window from the moment they're named, while capping how far the deadline can move in total so repeated re-flags still can't stall indefinitely.
+ uint256 public constant MIN_WINDOW_AFTER_REFLAG = 14 days;
+ uint256 public constant MAX_DEADLINE_EXTENSION = 30 days;
+
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
- corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
+ uint32 baseDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
+ uint32 hardCap = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW + MAX_DEADLINE_EXTENSION);
+ uint32 minForThisFlag = uint32(block.timestamp + MIN_WINDOW_AFTER_REFLAG);
+ corruptedClaimDeadline = minForThisFlag > baseDeadline
+ ? (minForThisFlag < hardCap ? minForThisFlag : hardCap)
+ : baseDeadline;
} else {
In the normal case this changes nothing, minForThisFlag only ever exceeds baseDeadline when a re-flag happens very late in the original window, which is exactly the blacklist-landed-near-the-end scenario this finding is about. A moderator trying to abuse this by re-flagging repeatedly still hits the hard cap, so the maximum possible stall is a small, fixed 30 days, not an open-ended delay. This is a tradeoff, not a one-line patch, and worth discussing with the team directly since it touches the same deadline logic they were deliberate about when they made it non-extendable in the first place.