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

Withdraw-lock and bonus-eligibility key on different triggers, so a staker locked in UNDER_ATTACK with no sealed risk window earns zero bonus, which sweeps to the sponsor

Author Revealed upon completion

Root + Impact

Description

  • DESIGN.md §9 states the fairness equivalence that "a staker only forfeits the exit option once risk has actually materialized, which is exactly when they begin earning the risk premium." That is, exit-lock and bonus-earning are meant to start together.

  • They don't: withdraw() is disabled by the live registry state (anything past ATTACK_REQUESTED), while _bonusShare pays zero unless the separate one-way riskWindowStart latch was sealed by some pool interaction. A staker can therefore be locked out of withdraw() in UNDER_ATTACK while riskWindowStart is still zero, and a reverting withdraw rolls back its own _observePoolState seal, so it can't self-heal.

// src/ConfidencePool.sol :: withdraw (gated on LIVE state)
@> if (
@> riskWindowStart != 0
@> || (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED)
) {
@> revert WithdrawsDisabled(); // reverts (and rolls back the seal) in UNDER_ATTACK even when riskWindowStart == 0
}
// src/ConfidencePool.sol :: _bonusShare (gated on the SEPARATE latch)
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0; // locked-but-unsealed staker earns nothing

Risk

Likelihood:

  • Occurs when the registry passes through UNDER_ATTACK and no entrypoint (stake, contributeBonus, pokeRiskWindow, …) is invoked during the whole active-risk interval, leaving riskWindowStart at zero, then the agreement resolves SURVIVED/EXPIRED.

  • The staker cannot rescue their own bonus by attempting withdraw(); the revert rolls back the seal it would have set.

Impact:

  • A staker who bore genuinely un-exitable UNDER_ATTACK exposure earns zero of the bonus they were owed; the bonus is diverted to recoveryAddress (the sponsor).

  • Bounded to the bonus (principal is always returned) and self-curable by any single permissionless pokeRiskWindow(), hence Low, but it breaks the §9 equivalence as written.

Proof of Concept

// test/Metatron_PoC.t.sol :: MetatronPoC
function test_C02_lockedStakerLosesBonus_andWithdrawRevertRollsBackSeal() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 50 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK); // nobody pokes
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
assertEq(pool.riskWindowStart(), 0, "reverting withdraw must not persist the seal");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(block.timestamp + 32 days);
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice), 100 ether, "locked staker paid principal only, zero bonus");
uint256 recBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recBefore, 50 ether, "bonus swept to recovery, not the staker");
}

Run:

forge test --match-test test_C02_lockedStakerLosesBonus_andWithdrawRevertRollsBackSeal -vv

Result:

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

Recommended Mitigation

The seal cannot be persisted from withdraw() itself, because a call that reverts to block the exit also rolls back any _markRiskWindowStart() it performed. Fix the desync at the bonus side instead: make bonus eligibility key on the same live-state condition that locks withdraw (i.e. treat "exit was disabled during active risk" as sufficient to earn bonus), rather than on the separately-sealed riskWindowStart latch. Concretely, seal riskWindowStart from a non-reverting observation and drive both gates from it:

- // withdraw locks on live state, bonus pays on the riskWindowStart latch; the two can diverge.
- if (riskWindowStart == 0) return 0; // in _bonusShare
+ // Any resolution/claim path observes state via _observePoolState(), which seals riskWindowStart
+ // on active risk in a SUCCEEDING call. Ensure at least one such observation is guaranteed before
+ // terminal resolution (e.g. claimExpired/flagOutcome call _observePoolState() first), so a staker
+ // locked out during active risk is credited bonus without depending on an external pokeRiskWindow().

Alternatively, document pokeRiskWindow() as a required keeper action for any pool that may sit idle through UNDER_ATTACK; it is the intended, already-permissionless cure for this exact case.

Support

FAQs

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

Give us feedback!