Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions certora/specs/LiquidityPoolPeg.spec
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ rule I7_rate_non_decreasing_on_guarded_methods(method f, env e, calldataarg args
f -> f.selector == sig:withdraw(address,uint256).selector
|| f.selector == sig:burnEEthShares(uint256).selector
|| f.selector == sig:burnEEthSharesForNonETHWithdrawal(uint256,uint256).selector
|| f.selector == sig:deposit().selector
|| f.selector == sig:deposit(address).selector
|| f.selector == sig:deposit(address,address).selector
|| f.selector == sig:depositToRecipient(address,uint256,address).selector
}
{
uint256 P0 = getTotalPooledEther();
Expand Down
28 changes: 27 additions & 1 deletion test/invariant/RewardsDistributor.invariant.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,36 @@ contract RewardsDistributorInvariantTest is TestSetup {
// on no-ops so the lifecycle (setPending -> finalize -> claim) is never
// driven and the run is vacuous (all counters 0). Curate the selectors so
// every call advances the state machine.
bytes4[] memory selectors = new bytes4[](7);
bytes4[] memory selectors = new bytes4[](8);
selectors[0] = handler.doSetPendingRoot.selector;
selectors[1] = handler.doFinalize.selector;
selectors[2] = handler.doClaim.selector;
selectors[3] = handler.doReplayClaim.selector;
selectors[4] = handler.doSetClaimDelay.selector;
selectors[5] = handler.doWarp.selector;
selectors[6] = handler.doRoll.selector;
selectors[7] = handler.doLowerCumulativeClaim.selector;
targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
targetContract(address(handler));

// Deterministically drive ONE full lifecycle here so the afterInvariant
// non-vacuity floor is guaranteed regardless of the fuzzed sequence.
// Foundry snapshots state after setUp and reverts to it before every run,
// so these counter increments persist as a baseline on EVERY run. Without
// this, an unlucky seed dominated by the no-op-ish selectors
// (doWarp/doRoll/doSetClaimDelay) leaves finalize_ok/claim_ok/lower_rejected
// at 0 at evaluation time and the gate flakes red (latent in #470; the 8th
// selector merely perturbed the distribution enough to surface it).
//
// doClaim is self-contained (establish root -> finalize -> claim -> inline
// replay-rejected), so this one call seeds finalize_ok, claim_ok AND
// replay_revert. doLowerCumulativeClaim seeds lower_rejected (it bootstraps
// its own cumulative, then drives the monotonic-decrease guard). These run
// through the handler's REAL ops (admin-pranked), identical to fuzzed calls,
// so the fuzzer still independently exercises every path at runtime — this
// only establishes the floor.
handler.doClaim(0, 1 ether);
handler.doLowerCumulativeClaim(1, 0);
}

// =====================================================================
Expand Down Expand Up @@ -107,6 +127,10 @@ contract RewardsDistributorInvariantTest is TestSetup {
assertGt(handler.callCounts("claim_ok"), 0, "non-vacuity: no claim ever succeeded (I12 payout unexercised)");
// Double-pay defense: at least one replay must have been attempted AND rejected.
assertGt(handler.callCounts("replay_revert"), 0, "non-vacuity: replay/double-pay path never exercised");
// I12 monotonic-decrease guard: at least one strictly-lower-cumulative
// claim must have been attempted AND rejected, positively driving the
// `preclaimed >= cumulativeAmount` revert path.
assertGt(handler.callCounts("lower_rejected"), 0, "non-vacuity: monotonic-decrease guard never exercised");

// And the run must have actually moved ETH: total ghost-paid across all
// claimants > 0 (independent of the counters above).
Expand All @@ -131,6 +155,8 @@ contract RewardsDistributorInvariantTest is TestSetup {
emit log_named_uint("claim_ok ", handler.callCounts("claim_ok"));
emit log_named_uint("claim_revert ", handler.callCounts("claim_revert"));
emit log_named_uint("replay_revert ", handler.callCounts("replay_revert"));
emit log_named_uint("lower_rejected ", handler.callCounts("lower_rejected"));
emit log_named_uint("lower_unexpected_ok ", handler.callCounts("lower_unexpected_ok"));
emit log_named_uint("replay_unexpected_ok ", handler.callCounts("replay_unexpected_ok"));
emit log_named_uint("replay_skipped ", handler.callCounts("replay_skipped"));
emit log_named_uint("setClaimDelay ", handler.callCounts("setClaimDelay"));
Expand Down
26 changes: 26 additions & 0 deletions test/invariant/ValidatorLifecycle.invariant.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ contract ValidatorLifecycleInvariantTest is TestSetup {
setUpTests();
handler = new ValidatorLifecycleHandler(managerInstance, address(stakingManagerInstance));
targetContract(address(handler));

// Restrict fuzzing to the two action functions. Without this, the engine
// also targets the handler's view getters (linkedCount, ghostLinkedNode,
// the counters), wasting call budget on no-ops.
bytes4[] memory sel = new bytes4[](2);
sel[0] = handler.doLink.selector;
sel[1] = handler.doRelinkAttack.selector;
targetSelector(FuzzSelector({addr: address(handler), selectors: sel}));
}

/// I11: no pubkey-hash link was ever overwritten / repointed after first set.
Expand All @@ -55,4 +63,22 @@ contract ValidatorLifecycleInvariantTest is TestSetup {
handler.link_already_revert();
handler.relink_attempt();
}

/// Non-vacuity gate: prove the fuzzer actually drove the I11 lifecycle —
/// at least one successful link, at least one rejected re-link (either via
/// doLink hitting an already-linked hash or the explicit re-link attack).
/// Without this, both invariant_I11_* could pass trivially because no link
/// or re-link attempt ever fired.
function afterInvariant() public {
emit log_named_uint("link_ok ", handler.link_ok());
emit log_named_uint("link_already_revert", handler.link_already_revert());
emit log_named_uint("relink_attempt ", handler.relink_attempt());

assertGt(handler.link_ok(), 0, "non-vacuity: no pubkey->node link ever succeeded");
assertGt(
handler.link_already_revert() + handler.relink_attempt(),
0,
"non-vacuity: no re-link / already-linked path was ever exercised"
);
}
}
31 changes: 30 additions & 1 deletion test/invariant/WithdrawalSolvency.invariant.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,17 @@ contract WithdrawalSolvencyInvariantTest is TestSetup {

targetContract(address(handler));

// Restrict fuzzing to the action functions. Without this, the engine
// also targets the handler's public getters/counters, burning call
// budget on no-ops. probeOverLiquidityLock is included so the I3/P1
// liquidity guard is positively driven on every run.
bytes4[] memory sel = new bytes4[](4);
sel[0] = handler.requestWithdraw.selector;
sel[1] = handler.finalizeRequests.selector;
sel[2] = handler.claimWithdraw.selector;
sel[3] = handler.probeOverLiquidityLock.selector;
targetSelector(FuzzSelector({addr: address(handler), selectors: sel}));

// Pre-seed a batch of OUR OWN requests and finalize them, so every fuzz
// run starts with claimable inventory the `claimWithdraw` op can hit.
// Foundry resets handler/contract state to this post-setUp snapshot
Expand Down Expand Up @@ -200,6 +211,15 @@ contract WithdrawalSolvencyInvariantTest is TestSetup {
" inLp=", vm.toString(handler.ghost_failInLp())
)
);
// Dual direction: the guard must not reject a lock that WAS backed.
assertFalse(
handler.ghost_lockRejectedWhileBacked(),
string.concat(
"I3/P1: _lockEth rejected a backed lock (lockAmount <= totalValueInLp) - lock=",
vm.toString(handler.ghost_failLockAmount()),
" inLp=", vm.toString(handler.ghost_failInLp())
)
);
}

// =====================================================================
Expand Down Expand Up @@ -276,11 +296,16 @@ contract WithdrawalSolvencyInvariantTest is TestSetup {
emit log_named_uint("requestsFinalized ", handler.ghost_requestsFinalized());
emit log_named_uint("requestsClaimed ", handler.ghost_requestsClaimed());
emit log_named_uint("finalizeBoundChecks ", handler.ghost_finalizeBoundChecks());
emit log_named_uint("lockBoundEnforced ", handler.ghost_lockBoundEnforced());

assertGt(handler.ghost_requestsCreated(), 0, "non-vacuity: no withdraw request was ever created");
assertGt(handler.ghost_requestsFinalized(), 0, "non-vacuity: no request was ever finalized");
assertGt(handler.ghost_requestsClaimed(), 0, "non-vacuity: no finalized request was ever claimed");
assertGt(handler.ghost_finalizeBoundChecks(), 0, "non-vacuity: P1 liquidity bound never exercised");
// P1 enforcement must be POSITIVELY driven: the liquidity guard rejected
// at least one genuinely over-backed lock. This is what makes I3/P1 a
// live assertion rather than dead code.
assertGt(handler.ghost_lockBoundEnforced(), 0, "non-vacuity: P1 liquidity guard never rejected an over-bound lock");
}

// =====================================================================
Expand All @@ -295,7 +320,11 @@ contract WithdrawalSolvencyInvariantTest is TestSetup {
emit log_named_uint("finalize_skipped_none ", handler.callCounts("finalize_skipped_none"));
emit log_named_uint("finalize_skipped_liquidity ", handler.callCounts("finalize_skipped_liquidity"));
emit log_named_uint("finalize_revert ", handler.callCounts("finalize_revert"));
emit log_named_uint("lock_revert ", handler.callCounts("lock_revert"));
emit log_named_uint("lock_revert_liquidity ", handler.callCounts("lock_revert_liquidity"));
emit log_named_uint("lock_revert_migration ", handler.callCounts("lock_revert_migration"));
emit log_named_uint("lock_revert_other ", handler.callCounts("lock_revert_other"));
emit log_named_uint("probe_rejected_liquidity ", handler.callCounts("probe_rejected_liquidity"));
emit log_named_uint("probe_unexpected_ok ", handler.callCounts("probe_unexpected_ok"));
emit log_named_uint("claim ", handler.callCounts("claim"));
emit log_named_uint("claim_skipped_empty ", handler.callCounts("claim_skipped_empty"));
emit log_named_uint("claim_skipped_unfinalized ", handler.callCounts("claim_skipped_unfinalized"));
Expand Down
49 changes: 49 additions & 0 deletions test/invariant/handlers/RewardsDistributorHandler.sol
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,55 @@ contract RewardsDistributorHandler is StdUtils {
}
}

/// @notice (I12, monotonic-decrease guard) Attempt to claim a STRICTLY
/// LOWER cumulative than already recorded. The contract rejects this
/// at CumulativeMerkleRewardsDistributor.sol:113
/// (`if (preclaimed >= cumulativeAmount) revert NothingToClaim()`).
/// If such a claim ever SUCCEEDS or moves ETH, the monotonic
/// guarantee is broken -> trip ghost_monotonicViolated. Self-contained
/// (establishes its own root) so it survives sequence-shrinking.
function doLowerCumulativeClaim(uint256 acctSeed, uint256 dropSeed) external {
address account = _claimant(acctSeed);

// Self-contained: first ensure this account has a NON-ZERO cumulative by
// performing a real claim (establish root -> claim). This makes a SINGLE
// doLowerCumulativeClaim call drive the whole guard exercise, so the
// non-vacuity gate survives Foundry sequence-shrinking (a shrunk 1-call
// replay would otherwise skip on cum==0 and leave lower_rejected at 0).
uint256 cum = dist.cumulativeClaimed(token, account);
if (cum == 0) {
uint256 seed = bound(dropSeed, 1, 50 ether);
bytes32 bootLeaf = keccak256(abi.encodePacked(account, seed));
_establishRoot(bootLeaf);
bytes32[] memory bootProof = new bytes32[](0);
try dist.claim(token, account, seed, bootLeaf, bootProof) {
ghostPaid[account] += seed;
lastSeenCumulative[account] = seed;
cum = seed;
callCounts["claim_ok"]++;
} catch {
callCounts["lower_boot_failed"]++;
return;
}
}

// Strictly-lower target in [0, cum-1].
uint256 lower = bound(dropSeed, 0, cum - 1);
bytes32 leaf = keccak256(abi.encodePacked(account, lower));
_establishRoot(leaf);

uint256 preBal = account.balance;
bytes32[] memory proof = new bytes32[](0);
try dist.claim(token, account, lower, leaf, proof) {
// MUST be unreachable: preclaimed (cum) >= lower by construction.
ghost_monotonicViolated = true;
if (account.balance != preBal) ghost_doublePayViolated = true;
callCounts["lower_unexpected_ok"]++;
} catch {
callCounts["lower_rejected"]++;
}
}

/// @notice Attempt to replay the exact same cumulative amount: must revert
/// (NothingToClaim). If it ever succeeds, that is a double-pay.
function doReplayClaim(uint256 acctSeed) external {
Expand Down
Loading