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

Auto-CORRUPTED in `claimExpired()` Traps Staker Callers — Principal Lost to Recovery

Author Revealed upon completion

Description

  • claimExpired() is the standard "claim after expiry" entrypoint. Stakers expect to receive principal + bonus.

  • When called post-expiry with registry=CORRUPTED and riskWindowStart != 0 (after 180-day grace), the function:

    1. Sets outcome = CORRUPTED (bad-faith)

    2. Sets claimsStarted = true (locking outcome)

    3. Returns early WITHOUT paying the caller — even if caller is a staker with eligible stake

  • The staker's funds are now in corruptedReserve, sweepable only to recoveryAddress via claimCorrupted(). The staker cannot call claimSurvived() (outcome is CORRUPTED) and claimCorrupted() sends to recovery, not to them.

if (state == ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true; // Locks outcome
emit OutcomeFlagged(...);
@> return; // ← EARLY RETURN, NO PAYOUT TO CALLER
}

Risk

Likelihood:

  • Auto-CORRUPTED triggers when registry=CORRUPTED, risk window was observed (180-day grace elapsed), and moderator hasn't flagged a different outcome

  • Stakers naturally call claimExpired() to retrieve funds, unaware it may auto-resolve to CORRUPTED and trap their principal

Impact:

  • Staker calling claimExpired() loses 100% of principal with no recourse

  • Funds diverted to recoveryAddress instead of returning to staker

  • No event emitted to indicate staker funds are now earmarked for recovery


Proof of Concept

_stake(alice, 100 * ONE);
_passThroughUnderAttack(); // riskWindowStart set
attackRegistry.setAgreementState(CORRUPTED);
vm.warp(expiry + MODERATOR_CORRUPTED_GRACE + 1 days);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired(); // Alice expects principal + bonus
uint256 aliceReceived = token.balanceOf(alice) - aliceBefore;
// PROOF: Alice receives 0!
assertEq(aliceReceived, 0, "Staker receives NOTHING");
// Outcome locked to CORRUPTED, claimsStarted = true
// Alice's 100 principal now belongs to recovery

Recommended Mitigation

function claimExpired() external nonReentrant {
// ...
if (outcome == Outcome.UNRESOLVED) {
// ... auto-resolution logic ...
if (state == ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE()) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), Outcome.CORRUPTED, false, address(0));
+ // Prevent staker trap: revert if caller has stake
+ if (eligibleStake[msg.sender] > 0) {
+ revert StakerCannotAutoResolveCorrupted();
+ }
return; // Bad-faith pays recovery, not caller
}
// ...
}
}

Support

FAQs

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

Give us feedback!