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

L-03 — Zero-stake caller latches `claimsStarted` in `claimExpired` without moving value, contradicting the "value-movement finality" spec

Author Revealed upon completion

Root + Impact

Description

docs/DESIGN.md §4 grounds the re-flag correction window closing on the premise that finality is tied to value movement: "Once value has left the contract, a corrective re-flag cannot be honored... claimsStarted is a value-movement finality latch." In claimExpired, a non-staker with zero eligible stake sets claimsStarted = true during the mechanical auto-resolution (lines 549 / 575), then hits the soft-success early return at userEligible == 0 (line 584). No tokens move, yet claimsStarted is latched and every subsequent moderator flagOutcome reverts OutcomeAlreadySet. The stated "value-movement" premise is therefore not strictly true.

The practical harm is bounded: the branch claimExpired selects is fully registry-determined (§13) — PRODUCTION→SURVIVED, terminal-CORRUPTED-within-grace→revert (protecting the moderator's CORRUPTED window), else→EXPIRED. Because registry states are monotonic and terminal, there is no reachable outcome the lock-out denies the moderator that the current registry state would still legitimately permit. This is a spec-vs-behavior deviation, not an exploitable fund path.

Risk

Likelihood: Low — anyone can call claimExpired at expiry, but no economically-different outcome is actually denied.

Impact: Low — no principal or bonus impact; only the theoretical loss of a moderator re-flag that the registry state would not permit anyway.

Proof of Concept

A user with zero stake calls claimExpired after expiry. No funds are transferred, but claimsStarted becomes true, permanently blocking the moderator from calling flagOutcome.

function test_L03_nonStakerLatchesFinalityWithoutValueMovement() external {
_stake(alice, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(pool.expiry() + 1);
uint256 recBefore = token.balanceOf(recovery);
vm.prank(dave); // zero-stake caller
pool.claimExpired(); // soft-success: NO tokens move...
assertEq(token.balanceOf(recovery), recBefore, "no value moved");
assertTrue(pool.claimsStarted(), "...yet finality is latched");
vm.prank(moderator);
vm.expectRevert(); // OutcomeAlreadySet
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}

Recommended Mitigation

Prefer a documentation fix: update DESIGN §4/§13 to state that mechanical auto-resolution (not only a value-moving claim) also closes the re-flag window — the auto-CORRUPTED/EXPIRED branches latch claimsStarted deliberately (lines 549/575) as an anti-override lock, so removing it is undesirable. If aligning code to the current wording is preferred, latch finality only alongside an actual payout:

uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) {
- // Soft-success: caller had nothing to claim...
return;
}

(but retain the explicit claimsStarted = true in the auto-CORRUPTED/EXPIRED branches, which is the intended lock).

Support

FAQs

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

Give us feedback!