From c9c3e64f0cda777348afabcf8baa8d33b01ccab8 Mon Sep 17 00:00:00 2001 From: seongyun Date: Sun, 14 Jun 2026 07:39:37 -0400 Subject: [PATCH 1/2] test: I5 oracle-integrity deterministic stateful test Stateful sequence test for I5 (oracle integrity) on EtherFiOracle + EtherFiAdmin: every applied OracleReport (advancing lastHandledReportRefSlot via executeTasks) must satisfy all three gates -- quorum (>= quorumSize sigs), APR cap (|rebase APR| <= acceptableRebaseAprInBps), freshness (past postReportWaitTimeInSlots). Proven via an independent, byte-faithful gate mirror that flips a ghost if any gate-violating apply ever occurs; mirror fidelity cross-checked against the contract's own revert reasons. Deterministic (not randomized invariant) because EtherFiOracle's strict slot/epoch state machine can't be driven reliably under Foundry's stateful- fuzz scheduling/shrinking -- matches how EtherFiOracle.t.sol tests it. 38 reports applied + 19/20/19 quorum/apr/freshness rejections, reproducible, 0 uncategorised reverts. src untouched. --- test/OracleIntegrity.t.sol | 118 ++++++ .../handlers/OracleIntegrityHandler.sol | 379 ++++++++++++++++++ 2 files changed, 497 insertions(+) create mode 100644 test/OracleIntegrity.t.sol create mode 100644 test/invariant/handlers/OracleIntegrityHandler.sol diff --git a/test/OracleIntegrity.t.sol b/test/OracleIntegrity.t.sol new file mode 100644 index 00000000..6559e5ad --- /dev/null +++ b/test/OracleIntegrity.t.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "./TestSetup.sol"; +import "../src/interfaces/ILiquidityPool.sol"; +import "./invariant/handlers/OracleIntegrityHandler.sol"; + +/// @notice Deterministic STATEFUL sequence test for invariant I5 (Oracle Integrity). +/// +/// I5 (from protocol-ops/security/architecture/invariants.md): every applied +/// OracleReport — one that advances EtherFiAdmin.lastHandledReportRefSlot via +/// executeTasks — MUST satisfy ALL THREE gates at execution time: +/// (a) quorum — consensus reached with >= EtherFiOracle.quorumSize() sigs +/// (b) APR cap — abs(rebase APR) <= EtherFiAdmin.acceptableRebaseAprInBps() +/// (c) freshness — currentSlot >= postReportWaitTimeInSlots + consensusSlot +/// Equivalently: executeTasks reverts (ReportValidationFailed) on any report +/// failing quorum / APR / freshness, and only advances state when all hold. +/// +/// Defenses under test: EtherFiAdmin._validateReport -> _validateReportFreshness +/// (consensus + refSlotFrom + postReportWaitTimeInSlots) and _validateRebaseApr +/// (|apr| > acceptableRebaseAprInBps); quorum enforced upstream in +/// EtherFiOracle.submitReport (support >= quorumSize). +/// +/// WHY A DETERMINISTIC SEQUENCE TEST (not a randomized invariant): +/// EtherFiOracle is a strict slot/epoch state machine — submitReport's +/// verifyReport rejects any report whose refSlot/refBlock stamps don't match +/// the live blockStampForNextReport() and whose epoch isn't finalized. Driving +/// that reliably requires precise, ordered clock control, which does not +/// survive Foundry's randomized stateful-fuzz scheduling / sequence shrinking +/// (the run goes vacuous, numApplied==0). The property itself is fully +/// exercised here by deterministically driving every scenario via the shared +/// OracleIntegrityHandler, which mirrors the three gates INDEPENDENTLY and +/// flips a ghost if a state-advancing executeTasks ever occurred while any gate +/// was violated. This matches how the rest of the oracle suite +/// (EtherFiOracle.t.sol) is tested. Non-vacuity is asserted explicitly below. +/// +/// SOUNDNESS ASSUMPTIONS (also documented in the handler): +/// * committee == {alice, bob}, quorumSize == 2 (TestSetup) — a single +/// submission is strictly sub-quorum. +/// * TVL seeded > 0 so the APR formula is non-trivial; postReportWaitTimeInSlots +/// set > 0 so the freshness gate is non-vacuous. +/// * The gate mirror is asserted byte-faithful to EtherFiAdmin via the +/// mirror-consistency check, so the safety ghosts cannot be vacuously +/// satisfied by a broken mirror. +contract OracleIntegrityTest is TestSetup { + OracleIntegrityHandler internal handler; + + function setUp() public { + setUpTests(); + + // SOUNDNESS: non-zero, stable TVL so the APR formula is non-trivial + // (currentTVL > 0). Without it getTotalPooledEther()==0 makes APR + // identically 0 and the APR gate could never be exercised. + vm.deal(alice, 1_000 ether); + vm.prank(alice); + liquidityPoolInstance.deposit{value: 1_000 ether}(); + + // SOUNDNESS: setUpTests initializes postReportWaitTimeInSlots == 0, which + // would make the freshness gate vacuous. Set a strictly-positive window. + // alice holds the admin role in setUpTests. + vm.prank(alice); + etherFiAdminInstance.updatePostReportWaitTimeInSlots(16); + + handler = new OracleIntegrityHandler( + etherFiOracleInstance, + etherFiAdminInstance, + ILiquidityPool(address(liquidityPoolInstance)), + alice, // committee member A + bob, // committee member B + owner, // holds OPERATION_MULTISIG_ROLE (unpublishReport recovery) + genesisSlotTimestamp + ); + } + + /// Drives the full I5 scenario set across many rounds. Each handler.step() + /// runs all four sub-scenarios (apply / quorum-fail / apr-fail / fresh-fail), + /// each self-contained and leaving the oracle un-stuck. After each step we + /// assert the I5 safety ghosts have NOT tripped (no gate-violating apply ever + /// occurred) and the gate mirror stayed consistent with the contract. + function test_i5_oracle_integrity_stateful() public { + uint256 ROUNDS = 24; + for (uint256 i = 0; i < ROUNDS; i++) { + // vary the rebase magnitude each round to exercise the APR arithmetic + handler.step(0.05 ether + i * 0.02 ether); + + // ---- I5 SAFETY (the actual proof) ---- + assertFalse( + handler.ghost_appliedWithoutQuorum(), + "I5(a): a report advanced state without reaching quorum consensus" + ); + assertFalse( + handler.ghost_appliedAprViolation(), + "I5(b): a report advanced state with APR above acceptableRebaseAprInBps" + ); + assertFalse( + handler.ghost_appliedWhileStale(), + "I5(c): a report advanced state before the post-report wait window" + ); + // ---- mirror fidelity: keeps the safety ghosts from being vacuous ---- + assertFalse(handler.ghost_mirrorMismatch(), handler.mismatchReason()); + } + + // ---- NON-VACUITY: the run must have actually exercised BOTH the + // accepting path (>=1 applied) AND each rejecting gate. ---- + assertGt(handler.numApplied(), 0, "non-vacuity: no report was ever APPLIED"); + assertGt(handler.numRejected(), 0, "non-vacuity: no report was ever REJECTED"); + assertGt(handler.numRejQuorum(), 0, "non-vacuity: quorum gate never exercised"); + assertGt(handler.numRejApr(), 0, "non-vacuity: APR gate never exercised"); + assertGt(handler.numRejFresh(), 0, "non-vacuity: freshness gate never exercised"); + // structural reverts must never be miscategorised into a gate bucket + assertEq(handler.numRejOther(), 0, "an executeTasks revert was uncategorised"); + + emit log_named_uint("I5 reports APPLIED", handler.numApplied()); + emit log_named_uint("I5 quorum-gate rejections", handler.numRejQuorum()); + emit log_named_uint("I5 apr-gate rejections", handler.numRejApr()); + emit log_named_uint("I5 freshness-gate rejections", handler.numRejFresh()); + } +} diff --git a/test/invariant/handlers/OracleIntegrityHandler.sol b/test/invariant/handlers/OracleIntegrityHandler.sol new file mode 100644 index 00000000..d69ec293 --- /dev/null +++ b/test/invariant/handlers/OracleIntegrityHandler.sol @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../../../src/EtherFiOracle.sol"; +import "../../../src/EtherFiAdmin.sol"; +import "../../../src/interfaces/IEtherFiOracle.sol"; +import "../../../src/interfaces/ILiquidityPool.sol"; + +/// @notice Stateful-fuzz handler for invariant I5 (Oracle Integrity). +/// +/// I5: an OracleReport may only ADVANCE EtherFiAdmin.lastHandledReportRefSlot +/// (i.e. be "applied" by executeTasks) when ALL THREE gates hold at the +/// moment of execution: +/// (a) quorum — consensus reached with >= quorumSize() submissions +/// (b) APR cap — abs(rebase APR) <= acceptableRebaseAprInBps() +/// (c) freshness — currentSlot >= postReportWaitTimeInSlots + consensusSlot +/// +/// The handler drives ONE fuzzer-selectable, self-healing action (`step`) +/// whose seed selects among four scenarios against the real EtherFiOracle + +/// EtherFiAdmin contracts: +/// - apply : a fully valid report -> MUST apply (all gates hold) +/// - quorum-fail : only ONE committee member submits (< quorum=2) +/// -> consensus never reached -> executeTasks MUST revert +/// - apr-fail : accruedRewards sized so |APR| > cap +/// -> executeTasks MUST revert ("TVL changed too much"), +/// then unpublished to leave the oracle un-stuck +/// - fresh-fail : consensus reached but executeTasks called BEFORE the +/// post-report wait window -> MUST revert ("too fresh"), +/// then (after warping past it) applied to leave state +/// un-stuck. +/// +/// Before every executeTasks call the handler computes an INDEPENDENT mirror +/// of the three gates (re-deriving the APR exactly as EtherFiAdmin does) and: +/// 1. SAFETY: if lastHandledReportRefSlot advanced, asserts all three +/// mirror gates held — any false flips a ghost that the invariant +/// functions assert against (this is the actual I5 proof). +/// 2. MIRROR-CONSISTENCY: when executeTasks reverts with a known +/// ReportValidationFailed reason, asserts our independent mirror agrees +/// on which gate failed — cross-validating that the mirror is faithful, +/// so the safety check above is sound (not vacuously satisfied by a +/// broken oracle). +/// +/// SOUNDNESS ASSUMPTIONS (all documented inline below): +/// * The committee is exactly {alice, bob} with quorumSize == 2, as set up by +/// TestSetup. doQuorumFail relies on a single submission being strictly +/// below quorum. +/// * Reports are built so that ONLY the gate under test (or none) can fail: +/// refSlot/refBlock stamps are taken from blockStampForNextReport(), +/// protocolFees == 0, no validator approvals, no withdrawals. Thus a revert +/// is attributable to quorum / freshness / APR (or, harmlessly, a +/// structural reason which we simply don't attribute). +contract OracleIntegrityHandler is Test { + // --- I5 contract constants mirrored from EtherFiAdmin --- + uint256 internal constant BASIS_POINTS_DENOMINATOR = 10_000; + uint256 internal constant SECONDS_PER_SLOT = 12; + + EtherFiOracle internal immutable oracle; + EtherFiAdmin internal immutable admin; + ILiquidityPool internal immutable lp; + address internal immutable memberA; // alice + address internal immutable memberB; // bob + address internal immutable multisig; // holds OPERATION_MULTISIG_ROLE (unpublishReport) + uint256 internal immutable genesisTime; // BEACON_GENESIS_TIME used by oracle + + // ---- I5 violation ghosts (any true => invariant broken) ---- + bool public ghost_appliedWithoutQuorum; + bool public ghost_appliedWhileStale; + bool public ghost_appliedAprViolation; + // mirror-consistency ghost: a revert reason disagreed with our gate mirror + bool public ghost_mirrorMismatch; + string public mismatchReason; + + // ---- coverage / non-vacuity counters ---- + uint256 public numApplied; // executeTasks that advanced lastHandledReportRefSlot + uint256 public numRejected; // executeTasks that reverted + uint256 public numRejQuorum; // reverts attributed to the quorum gate + uint256 public numRejFresh; // reverts attributed to the freshness gate + uint256 public numRejApr; // reverts attributed to the APR gate + uint256 public numRejOther; // reverts for structural/uncategorised reasons + + constructor( + EtherFiOracle _oracle, + EtherFiAdmin _admin, + ILiquidityPool _lp, + address _memberA, + address _memberB, + address _multisig, + uint256 _genesisTime + ) { + oracle = _oracle; + admin = _admin; + lp = _lp; + memberA = _memberA; + memberB = _memberB; + multisig = _multisig; + genesisTime = _genesisTime; + } + + // ------------------------------------------------------------------------- + // clock helpers (mirror TestSetup._moveClock for the local non-fork setup, + // where BEACON_GENESIS_TIME == genesisTime and slot == block.number) + // ------------------------------------------------------------------------- + function _moveClock(uint256 numSlots) internal { + vm.roll(block.number + numSlots); + vm.warp(genesisTime + SECONDS_PER_SLOT * block.number); + } + + /// @dev Advance the clock so that the report range ending at `refSlotTo` + /// is considered finalized by the oracle (>= 3 epochs past), mirroring + /// TestSetup._executeAdminTasks / _submitForConsensus. + function _finalizeEpochFor(uint32 refSlotTo) internal { + uint32 currentSlot = oracle.computeSlotAtTimestamp(block.timestamp); + uint32 currentEpoch = currentSlot / 32; + uint32 reportEpoch = (refSlotTo / 32) + 3; + if (currentEpoch < reportEpoch) { + _moveClock(32 * uint256(reportEpoch - currentEpoch)); + } + } + + function _baseReport() internal view returns (IEtherFiOracle.OracleReport memory r) { + uint256[] memory emptyVals = new uint256[](0); + uint32 cv = oracle.consensusVersion(); + r = IEtherFiOracle.OracleReport(cv, 0, 0, 0, 0, 0, 0, emptyVals, 0, 0); + (uint32 slotFrom, uint32 slotTo, uint32 blockFrom) = oracle.blockStampForNextReport(); + r.refSlotFrom = slotFrom; + r.refSlotTo = slotTo; + r.refBlockFrom = blockFrom; + r.refBlockTo = slotTo; // same convention as TestSetup._initReportBlockStamp + } + + // ------------------------------------------------------------------------- + // independent gate mirror — re-derives the three I5 gates from live state, + // WITHOUT calling EtherFiAdmin's internal validators. Kept byte-for-byte + // faithful to EtherFiAdmin._validateReportFreshness / _validateRebaseApr so + // the consistency cross-check below can confirm fidelity. + // ------------------------------------------------------------------------- + function _gatesHold(IEtherFiOracle.OracleReport memory r, bytes32 reportHash) + internal + view + returns (bool quorum, bool fresh, bool apr) + { + // (a) quorum: consensus flag is only set once support >= quorumSize. + quorum = oracle.isConsensusReached(reportHash); + + // (c) freshness: contract checks this only after consensus; getConsensusSlot + // reverts when no consensus, so guard on `quorum`. Without consensus the + // freshness gate is, by construction, not satisfiable. + if (quorum) { + uint32 curSlot = oracle.computeSlotAtTimestamp(block.timestamp); + uint32 consSlot = oracle.getConsensusSlot(reportHash); + fresh = curSlot >= uint256(admin.postReportWaitTimeInSlots()) + consSlot; + } else { + fresh = false; + } + + // (b) APR cap: identical arithmetic to EtherFiAdmin._validateRebaseApr. + int256 currentTVL = int128(uint128(lp.getTotalPooledEther())); + uint256 elapsedSlots = uint256(r.refSlotTo) - uint256(admin.lastHandledReportRefSlot()); + uint256 elapsedTime = elapsedSlots * SECONDS_PER_SLOT; + int256 aprVal; + if (currentTVL > 0 && elapsedTime > 0) { + aprVal = int256(BASIS_POINTS_DENOMINATOR) * (int256(r.accruedRewards) * int256(365 days)) + / (currentTVL * int256(elapsedTime)); + } + int256 absApr = aprVal > 0 ? aprVal : -aprVal; + apr = absApr <= admin.acceptableRebaseAprInBps(); + } + + function _strEq(string memory a, string memory b) internal pure returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); + } + + /// @dev Decode the reason string out of a ReportValidationFailed(string) revert. + function _decodeReason(bytes memory err) internal pure returns (bool ok, string memory reason) { + // selector(4) + offset(32) + length(32) + data + if (err.length < 4) return (false, ""); + bytes4 sel; + assembly { sel := mload(add(err, 0x20)) } + if (sel != EtherFiAdmin.ReportValidationFailed.selector) return (false, ""); + bytes memory payload = new bytes(err.length - 4); + for (uint256 i = 0; i < payload.length; i++) payload[i] = err[i + 4]; + reason = abi.decode(payload, (string)); + ok = true; + } + + /// @dev Core driver: snapshots lastHandledReportRefSlot, computes the mirror + /// gates, calls executeTasks, then runs the SAFETY + MIRROR checks. + function _executeAndCheck(IEtherFiOracle.OracleReport memory r) internal { + bytes32 reportHash = oracle.generateReportHash(r); + (bool quorum, bool fresh, bool apr) = _gatesHold(r, reportHash); + uint32 before = admin.lastHandledReportRefSlot(); + + try admin.executeTasks(r) { + uint32 afterSlot = admin.lastHandledReportRefSlot(); + bool applied = afterSlot != before; + if (applied) { + numApplied++; + // ---- I5 SAFETY: a state-advancing report MUST satisfy all gates ---- + if (!quorum) ghost_appliedWithoutQuorum = true; + if (!fresh) ghost_appliedWhileStale = true; + if (!apr) ghost_appliedAprViolation = true; + } + } catch (bytes memory err) { + numRejected++; + (bool ok, string memory reason) = _decodeReason(err); + if (ok) { + // ---- MIRROR-CONSISTENCY: the first failing gate the contract + // reports must match our independent mirror. ---- + if (_strEq(reason, "EtherFiAdmin: report didn't reach consensus")) { + numRejQuorum++; + if (quorum) { ghost_mirrorMismatch = true; mismatchReason = "quorum gate disagreement"; } + } else if (_strEq(reason, "EtherFiAdmin: report is too fresh")) { + numRejFresh++; + // contract reached the freshness stage => consensus held but window not elapsed + if (!quorum || fresh) { ghost_mirrorMismatch = true; mismatchReason = "freshness gate disagreement"; } + } else if (_strEq(reason, "EtherFiAdmin: TVL changed too much")) { + numRejApr++; + // contract reached the APR stage => consensus + freshness held, APR failed + if (!quorum || !fresh || apr) { ghost_mirrorMismatch = true; mismatchReason = "apr gate disagreement"; } + } else { + numRejOther++; + } + } else { + numRejOther++; + } + } + } + + // ------------------------------------------------------------------------- + // FUZZ ACTION — a SINGLE self-healing step. + // + // The oracle is a strict state machine: it can only accept a new report when + // `lastPublishedReportRefSlot == lastHandledReportRefSlot` (enforced by + // shouldSubmitReport's LastReportNotHandled guard). A half-finished + // (published-but-unapplied) state between fuzz calls bricks every subsequent + // submitReport. To be fully robust to fuzzer call ordering AND to Foundry's + // sequence shrinking (which replays arbitrary single-call subsequences), a + // single `step` call runs ALL FOUR scenarios in sequence — apply, quorum-fail, + // apr-fail, fresh-fail — each self-contained and each leaving the oracle in the + // un-stuck state on exit. This makes EVERY single call non-vacuous by + // construction (>=1 apply and >=1 of each reject gate), so the non-vacuity + // gates in afterInvariant hold regardless of how the fuzzer schedules or + // shrinks calls. The fuzzer's `magnitude` varies the rebase size across calls, + // exercising the safety property over a wide state space. + // ------------------------------------------------------------------------- + + /// @param magnitude bounded magnitude used for the rebase reward (fuzzed) + function step(uint256 magnitude) external { + // Self-heal: never proceed from a stuck state (defensive; by construction + // each sub-scenario below leaves the oracle un-stuck). + if (oracle.lastPublishedReportRefSlot() != admin.lastHandledReportRefSlot()) return; + + _scenarioApply(magnitude); + _scenarioQuorumFail(magnitude); + _scenarioAprFail(magnitude); + _scenarioFreshFail(magnitude); + } + + /// @dev Open a fresh, finalized, un-handled report range. Returns (ok, report). + /// ok=false when no new range is available yet (caller should skip). + function _freshRange() internal returns (bool ok, IEtherFiOracle.OracleReport memory r) { + _moveClock(1024 + 2 * 32); + r = _baseReport(); + if (r.refSlotTo <= admin.lastHandledReportRefSlot()) return (false, r); + _finalizeEpochFor(r.refSlotTo); + r = _resyncStamps(r); + ok = true; + } + + /// APPLY: valid report, all three gates hold => MUST advance state. + function _scenarioApply(uint256 magnitude) internal { + if (oracle.lastPublishedReportRefSlot() != admin.lastHandledReportRefSlot()) return; + (bool ok, IEtherFiOracle.OracleReport memory r) = _freshRange(); + if (!ok) return; + uint16 wait = admin.postReportWaitTimeInSlots(); + r.accruedRewards = int128(int256(bound(magnitude, 0, 0.5 ether))); + r = _resyncStamps(r); + vm.prank(memberA); + try oracle.submitReport(r) {} catch { return; } + vm.prank(memberB); + try oracle.submitReport(r) {} catch { return; } + _moveClock(uint256(wait) + 1); + _executeAndCheck(r); + } + + /// QUORUM-FAIL: only ONE member submits => consensus never reached. + /// SOUNDNESS: quorumSize == 2, committee == {memberA, memberB}; a single + /// submission is strictly below quorum. Report never published => un-stuck. + function _scenarioQuorumFail(uint256 magnitude) internal { + if (oracle.lastPublishedReportRefSlot() != admin.lastHandledReportRefSlot()) return; + (bool ok, IEtherFiOracle.OracleReport memory r) = _freshRange(); + if (!ok) return; + uint16 wait = admin.postReportWaitTimeInSlots(); + r.accruedRewards = int128(int256(bound(magnitude, 0, 0.5 ether))); + r = _resyncStamps(r); + vm.prank(memberA); + try oracle.submitReport(r) {} catch { return; } + _moveClock(uint256(wait) + 1); + _executeAndCheck(r); + } + + /// APR-FAIL: size accruedRewards so |APR| strictly exceeds the cap => MUST + /// revert on the APR gate. Unpublish afterward to leave the oracle un-stuck. + function _scenarioAprFail(uint256 magnitude) internal { + if (oracle.lastPublishedReportRefSlot() != admin.lastHandledReportRefSlot()) return; + (bool ok, IEtherFiOracle.OracleReport memory r) = _freshRange(); + if (!ok) return; + // unpublish recovery computes refSlotFrom-1, so needs refSlotFrom > 0. + if (r.refSlotFrom == 0) return; + uint16 wait = admin.postReportWaitTimeInSlots(); + uint32 lastHandled = admin.lastHandledReportRefSlot(); + int256 tvl = int128(uint128(lp.getTotalPooledEther())); + uint256 elapsedTime = (uint256(r.refSlotTo) - uint256(lastHandled)) * SECONDS_PER_SLOT; + if (tvl <= 0 || elapsedTime == 0) return; + int256 cap = admin.acceptableRebaseAprInBps(); + // reward at the cap boundary: cap = 10000 * (reward*365d)/(tvl*elapsedTime) + int256 boundaryReward = + cap * tvl * int256(elapsedTime) / (int256(BASIS_POINTS_DENOMINATOR) * int256(365 days)); + int256 extra = int256(bound(magnitude, 1 ether, 50 ether)); + int256 reward = boundaryReward + extra; + if (magnitude % 2 == 0) reward = -reward; // exercise both positive and negative over-cap + if (reward > type(int128).max || reward < type(int128).min) return; + r.accruedRewards = int128(reward); + + bytes32 reportHash = oracle.generateReportHash(r); + vm.prank(memberA); + try oracle.submitReport(r) {} catch { return; } + vm.prank(memberB); + try oracle.submitReport(r) {} catch { return; } + _moveClock(uint256(wait) + 1); + // consensus + freshness hold, APR does not => MUST revert on APR gate. + _executeAndCheck(r); + // RECOVERY: published-but-unappliable. Unpublish to un-stick the oracle. + if (oracle.isConsensusReached(reportHash) && r.refSlotTo > admin.lastHandledReportRefSlot()) { + vm.prank(multisig); + try oracle.unpublishReport(r) {} catch {} + } + } + + /// FRESH-FAIL: consensus reached but executeTasks called BEFORE the wait + /// window => MUST revert on freshness; then warp past it and apply, leaving + /// the oracle un-stuck. No-op when wait window is 0 (then freshness is vacuous). + function _scenarioFreshFail(uint256 magnitude) internal { + if (oracle.lastPublishedReportRefSlot() != admin.lastHandledReportRefSlot()) return; + uint16 wait = admin.postReportWaitTimeInSlots(); + if (wait == 0) return; + (bool ok, IEtherFiOracle.OracleReport memory r) = _freshRange(); + if (!ok) return; + r.accruedRewards = int128(int256(bound(magnitude, 0, 0.5 ether))); + r = _resyncStamps(r); + vm.prank(memberA); + try oracle.submitReport(r) {} catch { return; } + vm.prank(memberB); + try oracle.submitReport(r) {} catch { return; } + // do NOT advance the clock yet -> too fresh. + _executeAndCheck(r); + // now elapse the window and apply for real, leaving the system un-stuck. + _moveClock(uint256(wait) + 1); + _executeAndCheck(r); + } + + /// @dev After any extra clock movement the block stamps must be re-derived + /// so submitReport's verifyReport (which checks against the live + /// blockStampForNextReport) passes. refSlotTo can grow as the clock + /// advances; we re-read just before submitting. + function _resyncStamps(IEtherFiOracle.OracleReport memory r) + internal + view + returns (IEtherFiOracle.OracleReport memory) + { + (uint32 slotFrom, uint32 slotTo, uint32 blockFrom) = oracle.blockStampForNextReport(); + r.refSlotFrom = slotFrom; + r.refSlotTo = slotTo; + r.refBlockFrom = blockFrom; + r.refBlockTo = slotTo; + return r; + } +} From ea899506d9d7473ceb26c669551f53ef09f39d93 Mon Sep 17 00:00:00 2001 From: seongyun Date: Sun, 14 Jun 2026 11:15:37 -0400 Subject: [PATCH 2/2] test(invariant): I5 oracle integrity as a stateful fuzz invariant Replaces the deterministic sequence test with a true randomized stateful fuzz-invariant (Foundry invariant engine, 64 runs x 1920 calls, 0 reverts). Root cause of the earlier vacuity (numApplied==0 under the invariant runner): the 'valid apply' scenario bounded its rebase reward to a blind 0..0.5 ether constant. The APR gate caps |apr| = 10000*reward*365d/(tvl*elapsedTime); on reports after the first, elapsedTime is just the ~1100-slot range moved, so 0.5 ether computes to ~11900 bps > the 10000 cap -> the supposedly-valid apply reverted on APR, leaving the report published-but-unapplied -> oracle stuck -> every later step bailed at the un-stuck guard. The fuzzer found this; the deterministic loop (tiny uniform rewards) never did. Fix: _maxSafeReward() sizes the apply/fresh-fail reward to half the per-range APR-cap boundary, so the accepting path is genuinely accepted regardless of the fuzzed magnitude or the range length the clock produces. Result: 60 reports applied + 30/30/30 quorum/apr/freshness rejections, mirror-consistent, stable across seeds. No gate-violating apply ever observed. No assertion weakened; src untouched. --- test/OracleIntegrity.t.sol | 118 --------------- .../invariant/OracleIntegrity.invariant.t.sol | 138 ++++++++++++++++++ .../handlers/OracleIntegrityHandler.sol | 28 +++- 3 files changed, 164 insertions(+), 120 deletions(-) delete mode 100644 test/OracleIntegrity.t.sol create mode 100644 test/invariant/OracleIntegrity.invariant.t.sol diff --git a/test/OracleIntegrity.t.sol b/test/OracleIntegrity.t.sol deleted file mode 100644 index 6559e5ad..00000000 --- a/test/OracleIntegrity.t.sol +++ /dev/null @@ -1,118 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import "./TestSetup.sol"; -import "../src/interfaces/ILiquidityPool.sol"; -import "./invariant/handlers/OracleIntegrityHandler.sol"; - -/// @notice Deterministic STATEFUL sequence test for invariant I5 (Oracle Integrity). -/// -/// I5 (from protocol-ops/security/architecture/invariants.md): every applied -/// OracleReport — one that advances EtherFiAdmin.lastHandledReportRefSlot via -/// executeTasks — MUST satisfy ALL THREE gates at execution time: -/// (a) quorum — consensus reached with >= EtherFiOracle.quorumSize() sigs -/// (b) APR cap — abs(rebase APR) <= EtherFiAdmin.acceptableRebaseAprInBps() -/// (c) freshness — currentSlot >= postReportWaitTimeInSlots + consensusSlot -/// Equivalently: executeTasks reverts (ReportValidationFailed) on any report -/// failing quorum / APR / freshness, and only advances state when all hold. -/// -/// Defenses under test: EtherFiAdmin._validateReport -> _validateReportFreshness -/// (consensus + refSlotFrom + postReportWaitTimeInSlots) and _validateRebaseApr -/// (|apr| > acceptableRebaseAprInBps); quorum enforced upstream in -/// EtherFiOracle.submitReport (support >= quorumSize). -/// -/// WHY A DETERMINISTIC SEQUENCE TEST (not a randomized invariant): -/// EtherFiOracle is a strict slot/epoch state machine — submitReport's -/// verifyReport rejects any report whose refSlot/refBlock stamps don't match -/// the live blockStampForNextReport() and whose epoch isn't finalized. Driving -/// that reliably requires precise, ordered clock control, which does not -/// survive Foundry's randomized stateful-fuzz scheduling / sequence shrinking -/// (the run goes vacuous, numApplied==0). The property itself is fully -/// exercised here by deterministically driving every scenario via the shared -/// OracleIntegrityHandler, which mirrors the three gates INDEPENDENTLY and -/// flips a ghost if a state-advancing executeTasks ever occurred while any gate -/// was violated. This matches how the rest of the oracle suite -/// (EtherFiOracle.t.sol) is tested. Non-vacuity is asserted explicitly below. -/// -/// SOUNDNESS ASSUMPTIONS (also documented in the handler): -/// * committee == {alice, bob}, quorumSize == 2 (TestSetup) — a single -/// submission is strictly sub-quorum. -/// * TVL seeded > 0 so the APR formula is non-trivial; postReportWaitTimeInSlots -/// set > 0 so the freshness gate is non-vacuous. -/// * The gate mirror is asserted byte-faithful to EtherFiAdmin via the -/// mirror-consistency check, so the safety ghosts cannot be vacuously -/// satisfied by a broken mirror. -contract OracleIntegrityTest is TestSetup { - OracleIntegrityHandler internal handler; - - function setUp() public { - setUpTests(); - - // SOUNDNESS: non-zero, stable TVL so the APR formula is non-trivial - // (currentTVL > 0). Without it getTotalPooledEther()==0 makes APR - // identically 0 and the APR gate could never be exercised. - vm.deal(alice, 1_000 ether); - vm.prank(alice); - liquidityPoolInstance.deposit{value: 1_000 ether}(); - - // SOUNDNESS: setUpTests initializes postReportWaitTimeInSlots == 0, which - // would make the freshness gate vacuous. Set a strictly-positive window. - // alice holds the admin role in setUpTests. - vm.prank(alice); - etherFiAdminInstance.updatePostReportWaitTimeInSlots(16); - - handler = new OracleIntegrityHandler( - etherFiOracleInstance, - etherFiAdminInstance, - ILiquidityPool(address(liquidityPoolInstance)), - alice, // committee member A - bob, // committee member B - owner, // holds OPERATION_MULTISIG_ROLE (unpublishReport recovery) - genesisSlotTimestamp - ); - } - - /// Drives the full I5 scenario set across many rounds. Each handler.step() - /// runs all four sub-scenarios (apply / quorum-fail / apr-fail / fresh-fail), - /// each self-contained and leaving the oracle un-stuck. After each step we - /// assert the I5 safety ghosts have NOT tripped (no gate-violating apply ever - /// occurred) and the gate mirror stayed consistent with the contract. - function test_i5_oracle_integrity_stateful() public { - uint256 ROUNDS = 24; - for (uint256 i = 0; i < ROUNDS; i++) { - // vary the rebase magnitude each round to exercise the APR arithmetic - handler.step(0.05 ether + i * 0.02 ether); - - // ---- I5 SAFETY (the actual proof) ---- - assertFalse( - handler.ghost_appliedWithoutQuorum(), - "I5(a): a report advanced state without reaching quorum consensus" - ); - assertFalse( - handler.ghost_appliedAprViolation(), - "I5(b): a report advanced state with APR above acceptableRebaseAprInBps" - ); - assertFalse( - handler.ghost_appliedWhileStale(), - "I5(c): a report advanced state before the post-report wait window" - ); - // ---- mirror fidelity: keeps the safety ghosts from being vacuous ---- - assertFalse(handler.ghost_mirrorMismatch(), handler.mismatchReason()); - } - - // ---- NON-VACUITY: the run must have actually exercised BOTH the - // accepting path (>=1 applied) AND each rejecting gate. ---- - assertGt(handler.numApplied(), 0, "non-vacuity: no report was ever APPLIED"); - assertGt(handler.numRejected(), 0, "non-vacuity: no report was ever REJECTED"); - assertGt(handler.numRejQuorum(), 0, "non-vacuity: quorum gate never exercised"); - assertGt(handler.numRejApr(), 0, "non-vacuity: APR gate never exercised"); - assertGt(handler.numRejFresh(), 0, "non-vacuity: freshness gate never exercised"); - // structural reverts must never be miscategorised into a gate bucket - assertEq(handler.numRejOther(), 0, "an executeTasks revert was uncategorised"); - - emit log_named_uint("I5 reports APPLIED", handler.numApplied()); - emit log_named_uint("I5 quorum-gate rejections", handler.numRejQuorum()); - emit log_named_uint("I5 apr-gate rejections", handler.numRejApr()); - emit log_named_uint("I5 freshness-gate rejections", handler.numRejFresh()); - } -} diff --git a/test/invariant/OracleIntegrity.invariant.t.sol b/test/invariant/OracleIntegrity.invariant.t.sol new file mode 100644 index 00000000..21589c4b --- /dev/null +++ b/test/invariant/OracleIntegrity.invariant.t.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "../TestSetup.sol"; +import "../../src/interfaces/ILiquidityPool.sol"; +import "./handlers/OracleIntegrityHandler.sol"; + +/// @notice Stateful FUZZ-INVARIANT suite for invariant I5 (Oracle Integrity). +/// +/// I5 (protocol-ops/security/architecture/invariants.md): every applied +/// OracleReport — one that advances EtherFiAdmin.lastHandledReportRefSlot via +/// executeTasks — MUST satisfy ALL THREE gates at execution time: +/// (a) quorum — consensus reached with >= EtherFiOracle.quorumSize() sigs +/// (b) APR cap — abs(rebase APR) <= EtherFiAdmin.acceptableRebaseAprInBps() +/// (c) freshness — currentSlot >= postReportWaitTimeInSlots + consensusSlot +/// Equivalently: executeTasks reverts (ReportValidationFailed) on any report +/// failing quorum / APR / freshness, and only advances state when all hold. +/// +/// Defenses under test: EtherFiAdmin._validateReport -> _validateReportFreshness +/// (consensus + refSlotFrom + postReportWaitTimeInSlots) and _validateRebaseApr +/// (|apr| > acceptableRebaseAprInBps); quorum enforced upstream in +/// EtherFiOracle.submitReport (support >= quorumSize). +/// +/// HANDLER MODEL: the fuzzer drives a single `step(magnitude)` action. Each +/// call exercises all four scenarios (apply / quorum-fail / apr-fail / +/// fresh-fail) against the real EtherFiOracle + EtherFiAdmin, each leaving the +/// oracle in the un-stuck state. The fuzzed `magnitude` (and the random call +/// ordering / sequence length the invariant engine chooses) vary the rebase +/// sizes and the slot/epoch timeline across the run, so the safety property is +/// exercised over a wide state space. Before every executeTasks the handler +/// computes an INDEPENDENT, contract-faithful mirror of the three gates and +/// flips a ghost if a state-advancing report ever violated any gate (the I5 +/// proof), with a mirror-consistency cross-check against the contract's own +/// revert reasons so the safety ghosts can't be vacuously satisfied. +/// +/// SOUNDNESS ASSUMPTIONS (also in the handler): +/// * committee == {alice, bob}, quorumSize == 2 (TestSetup) — a single +/// submission is strictly sub-quorum. +/// * TVL seeded > 0 so the APR formula is non-trivial; postReportWaitTimeInSlots +/// set > 0 so the freshness gate is non-vacuous. +/// * A valid "apply" bounds its reward under the per-range APR-cap boundary +/// (the contract caps APR over the elapsed window), so the accepting path is +/// genuinely accepted rather than spuriously tripping the APR gate. +/// +/// fail-on-revert is false: the handler INTENTIONALLY drives reverting paths +/// (sub-quorum, over-cap APR, too-fresh) and asserts the safety post-condition +/// via ghosts. Non-vacuity is enforced in afterInvariant(). +/// +/// forge-config: default.invariant.runs = 64 +/// forge-config: default.invariant.depth = 30 +/// forge-config: default.invariant.fail-on-revert = false +contract OracleIntegrityInvariantTest is TestSetup { + OracleIntegrityHandler internal handler; + + function setUp() public { + setUpTests(); + + // SOUNDNESS: non-zero, stable TVL so the APR formula is non-trivial + // (currentTVL > 0). Without it getTotalPooledEther()==0 makes APR + // identically 0 and the APR gate could never be exercised. + vm.deal(alice, 1_000 ether); + vm.prank(alice); + liquidityPoolInstance.deposit{value: 1_000 ether}(); + + // SOUNDNESS: setUpTests initializes postReportWaitTimeInSlots == 0, which + // would make the freshness gate vacuous. Set a strictly-positive window. + vm.prank(alice); + etherFiAdminInstance.updatePostReportWaitTimeInSlots(16); + + handler = new OracleIntegrityHandler( + etherFiOracleInstance, + etherFiAdminInstance, + ILiquidityPool(address(liquidityPoolInstance)), + alice, // committee member A + bob, // committee member B + owner, // holds OPERATION_MULTISIG_ROLE (unpublishReport recovery) + genesisSlotTimestamp + ); + + targetContract(address(handler)); + bytes4[] memory sels = new bytes4[](1); + sels[0] = handler.step.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: sels})); + } + + // ===================================================================== + // I5 core safety invariants — proven by handler ghosts flipped ONLY if a + // state-advancing executeTasks ever occurred while a gate was violated + // (per an independent, contract-faithful gate mirror). + // ===================================================================== + + /// (a) No report may advance lastHandledReportRefSlot without quorum consensus. + function invariant_i5_quorum_required() public view { + assertFalse( + handler.ghost_appliedWithoutQuorum(), + "I5(a): a report advanced state without reaching quorum consensus" + ); + } + + /// (b) No report may advance state while its |rebase APR| exceeds the cap. + function invariant_i5_apr_capped() public view { + assertFalse( + handler.ghost_appliedAprViolation(), + "I5(b): a report advanced state with APR above acceptableRebaseAprInBps" + ); + } + + /// (c) No report may advance state before the post-report freshness window. + function invariant_i5_freshness_enforced() public view { + assertFalse( + handler.ghost_appliedWhileStale(), + "I5(c): a report advanced state before the post-report wait window" + ); + } + + /// Mirror fidelity: every categorised ReportValidationFailed reason agrees + /// with our independent gate mirror, keeping the safety ghosts non-vacuous. + function invariant_i5_mirror_consistent() public view { + assertFalse(handler.ghost_mirrorMismatch(), handler.mismatchReason()); + } + + // ===================================================================== + // Non-vacuity: the run must have actually exercised BOTH the accepting + // path (>=1 applied) AND each rejecting gate (quorum / APR / freshness). + // ===================================================================== + function afterInvariant() public { + emit log_named_uint("I5 reports APPLIED", handler.numApplied()); + emit log_named_uint("I5 quorum-gate rejections", handler.numRejQuorum()); + emit log_named_uint("I5 apr-gate rejections", handler.numRejApr()); + emit log_named_uint("I5 freshness-gate rejections", handler.numRejFresh()); + assertGt(handler.numApplied(), 0, "non-vacuity: no report was ever APPLIED"); + assertGt(handler.numRejected(), 0, "non-vacuity: no report was ever REJECTED"); + assertGt(handler.numRejQuorum(), 0, "non-vacuity: quorum gate never exercised"); + assertGt(handler.numRejApr(), 0, "non-vacuity: APR gate never exercised"); + assertGt(handler.numRejFresh(), 0, "non-vacuity: freshness gate never exercised"); + assertEq(handler.numRejOther(), 0, "an executeTasks revert was uncategorised"); + } +} diff --git a/test/invariant/handlers/OracleIntegrityHandler.sol b/test/invariant/handlers/OracleIntegrityHandler.sol index d69ec293..029272bd 100644 --- a/test/invariant/handlers/OracleIntegrityHandler.sol +++ b/test/invariant/handlers/OracleIntegrityHandler.sol @@ -268,13 +268,33 @@ contract OracleIntegrityHandler is Test { ok = true; } + /// @dev Largest rebase reward that keeps |APR| strictly under the cap for the + /// given report range, i.e. the boundary reward at which apr == cap. + /// A valid "apply" must stay BELOW this; we use half of it for safe margin. + function _maxSafeReward(IEtherFiOracle.OracleReport memory r) internal view returns (int256) { + int256 tvl = int128(uint128(lp.getTotalPooledEther())); + uint256 elapsedTime = (uint256(r.refSlotTo) - uint256(admin.lastHandledReportRefSlot())) * SECONDS_PER_SLOT; + if (tvl <= 0 || elapsedTime == 0) return 0; + int256 cap = admin.acceptableRebaseAprInBps(); + // reward at apr==cap boundary: cap = 10000 * (reward*365d)/(tvl*elapsedTime) + return cap * tvl * int256(elapsedTime) / (int256(BASIS_POINTS_DENOMINATOR) * int256(365 days)); + } + /// APPLY: valid report, all three gates hold => MUST advance state. function _scenarioApply(uint256 magnitude) internal { if (oracle.lastPublishedReportRefSlot() != admin.lastHandledReportRefSlot()) return; (bool ok, IEtherFiOracle.OracleReport memory r) = _freshRange(); if (!ok) return; uint16 wait = admin.postReportWaitTimeInSlots(); - r.accruedRewards = int128(int256(bound(magnitude, 0, 0.5 ether))); + // SOUNDNESS: the APR gate caps |reward| relative to the (short) elapsed + // window for this range. Bound the reward to HALF the cap-boundary so the + // apply is guaranteed under the APR cap regardless of the fuzzed magnitude + // and the range length the clock happens to produce. Without this, a large + // reward over a short range trips the APR gate and the "valid" apply + // reverts -> report stays published-but-unapplied -> oracle bricked. + int256 safeMax = _maxSafeReward(r); + if (safeMax <= 1) return; // range too short to carry any reward safely + r.accruedRewards = int128(int256(bound(magnitude, 0, uint256(safeMax / 2)))); r = _resyncStamps(r); vm.prank(memberA); try oracle.submitReport(r) {} catch { return; } @@ -347,7 +367,11 @@ contract OracleIntegrityHandler is Test { if (wait == 0) return; (bool ok, IEtherFiOracle.OracleReport memory r) = _freshRange(); if (!ok) return; - r.accruedRewards = int128(int256(bound(magnitude, 0, 0.5 ether))); + // Same APR-safety bound as _scenarioApply: this report is applied after the + // freshness window elapses, so it must stay under the APR cap. + int256 safeMax = _maxSafeReward(r); + if (safeMax <= 1) return; + r.accruedRewards = int128(int256(bound(magnitude, 0, uint256(safeMax / 2)))); r = _resyncStamps(r); vm.prank(memberA); try oracle.submitReport(r) {} catch { return; }