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

SURVIVED/EXPIRED reserves a non-claiming staker's principal and bonus indefinitely with no time-bounded sweep, unlike the 180-day CORRUPTED path

Author Revealed upon completion

Description

After a SURVIVED or EXPIRED resolution, unclaimed value is recovered through sweepUnclaimedBonus, which
is designed to move only excess funds (donations, dust) to recoveryAddress while protecting every
staker's entitlement.

To protect entitlements it reserves, for every not-yet-claimed staker, their full principal plus their
owed bonus, and only sweeps the balance above that reserve. The side effect is that a staker who never
claims keeps their principal + bonus locked inside the pool with no upper time bound: claimSurvived
/ claimExpired have no deadline, and — unlike the CORRUPTED path, which bounds recovery at 180 days via
sweepUnclaimedCorrupted — there is no forced distribution or time-bounded sweep of staker-reserved
funds. The reserved value can therefore sit in the contract permanently if the staker abandons the
position.

function sweepUnclaimedBonus() external nonReentrant {
// ...
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake; // all non-claimers' principal
if (riskWindowStart != 0) {
@> reserved += snapshotTotalBonus - claimedBonus; // + all non-claimers' bonus; held with NO time bound
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
@> if (amount == 0) revert NothingToSweep(); // a non-claimer's reserve is never sweepable
// ...
}

Contrast with the CORRUPTED path, which does bound the wait:

function sweepUnclaimedCorrupted() external nonReentrant {
// ...
@> if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired(); // 180-day bound exists here
// ... sweeps the remainder to recoveryAddress
}

Risk

Likelihood:

  • Occurs whenever a staker in a resolved SURVIVED/EXPIRED pool never calls claimSurvived /
    claimExpired — a lost key, an abandoned position, or a wound-down participant.

  • Occurs for the full life of the deployed clone: the clone is non-upgradeable and has no admin path to
    release a stuck reserve, so the funds stay locked from every other actor's perspective permanently.

Impact:

  • The abandoned staker's principal + bonus are held in the pool indefinitely; no other actor
    (sponsor, moderator, recovery) can ever move them, and there is no cleanup path for the clone.

  • No loss to other participants and no loss to the abandoning staker themselves (they can still claim at
    any future time) — the effect is value permanently at rest plus an inconsistency with the CORRUPTED
    path's bounded recovery, which is why this is Low rather than a fund-loss issue.

Proof of Concept

test/audit/LowFindingsPoC.t.sol::testL03_nonClaimerReserveHeldIndefinitely (passes). Alice claims, Bob
never does; even 10 years later sweepUnclaimedBonus reverts NothingToSweep because Bob's principal +
bonus stay fully reserved, yet Bob can still claim them at any time:

function testL03_nonClaimerReserveHeldIndefinitely() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(vm.getBlockTimestamp() + 5 days);
_contributeBonus(carol, 80 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// alice claims; bob never does
vm.prank(alice);
pool.claimSurvived();
// even 10 years later, bob's reserved principal+bonus is untouchable by any sweep
vm.warp(vm.getBlockTimestamp() + 3650 days);
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertGt(token.balanceOf(address(pool)), 0, "bob's reserve is held in the pool");
// it is not lost to bob — he can still claim principal + bonus at any time
uint256 b0 = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
assertGt(token.balanceOf(bob) - b0, 100 * ONE, "bob can still claim principal+bonus");
}

Run:

forge test --match-test testL03_nonClaimerReserveHeldIndefinitely -vv

Recommended Mitigation

Give SURVIVED/EXPIRED reserves the same bounded recovery the CORRUPTED path already has: after a long
abandonment window measured from resolution, let anyone sweep still-unclaimed staker reserves to
recoveryAddress (or document explicitly that these reserves are intentionally held forever).

+ uint256 public constant UNCLAIMED_STAKE_WINDOW = 365 days; // measured from outcomeFlaggedAt
function sweepUnclaimedBonus() external nonReentrant {
// ...
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
+ // After a long abandonment window, stop reserving unclaimed staker funds so they can be
+ // recovered to recoveryAddress instead of being locked in the clone forever.
+ if (block.timestamp > uint256(outcomeFlaggedAt) + UNCLAIMED_STAKE_WINDOW) reserved = 0;
}
// ...
}

Support

FAQs

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

Give us feedback!