diff --git a/foundry.lock b/foundry.lock index 28f9883..f1d239d 100644 --- a/foundry.lock +++ b/foundry.lock @@ -4,5 +4,17 @@ }, "lib/openzeppelin-contracts": { "rev": "c64a1edb67b6e3f4a15cca8909c9482ad33a02b0" + }, + "lib/safe-smart-account": { + "rev": "21dc82410445637820f600c7399a804ad55841d5" + }, + "lib/safe-smart-account-1.3.0": { + "rev": "186a21a74b327f17fc41217a927dea7064f74604" + }, + "lib/safe-smart-account-1.4.0": { + "rev": "e870f514ad34cd9654c72174d6d4a839e3c6639f" + }, + "lib/safe-smart-account-1.5.0": { + "rev": "dc437e8fba8b4805d76bcbd1c668c9fd3d1e83be" } } \ No newline at end of file diff --git a/src/Assertion.sol b/src/Assertion.sol index aabee42..a6753c1 100644 --- a/src/Assertion.sol +++ b/src/Assertion.sol @@ -133,18 +133,26 @@ abstract contract Assertion is ForkUtils, StateChanges { triggerRecorder.watchCumulativeInflow(token, thresholdBps, windowDuration, fnSelector); } - /// @notice Registers an anomaly-detection trigger. Fires whenever the - /// executor's configured AnomalySubsystem produces a score for - /// `target` in a transaction that touches it. - /// @dev The model owns the firing decision: the trigger fires whenever - /// the subsystem returns a score at all. The assertion reads the - /// score back via `ph.anomalyContext(target)` and decides whether - /// to revert, run extra checks, or ignore. + /// @notice Registers an anomaly-detection trigger at a sensitivity level. + /// Fires when the executor's configured AnomalySubsystem scores + /// `target` anomalously enough to clear `sensitivity`. + /// @dev The level is a point on the detector's recall-versus-false-positive + /// curve, fixed to the same budget on every contract, see + /// `Sensitivity`. The threshold behind it is resolved from `target`'s + /// own model where the trigger is evaluated, so this code is portable + /// across contracts and survives a retrain untouched. + /// + /// ```solidity + /// function triggers() external view override { + /// watchAnomaly(aUSDC, this.checkSolvency.selector, Sensitivity.LEVEL_7); + /// } + /// ``` /// @param target The address whose anomaly score this assertion observes. - /// @param fnSelector The assertion function to invoke when `target` is - /// scored. - function watchAnomaly(address target, bytes4 fnSelector) internal view { - triggerRecorder.watchAnomaly(target, fnSelector); + /// @param fnSelector The assertion function to invoke when `target` clears + /// the level. + /// @param sensitivity The level from `Sensitivity`, 1..=10. + function watchAnomaly(address target, bytes4 fnSelector, uint8 sensitivity) internal view { + triggerRecorder.watchAnomaly(target, fnSelector, sensitivity); } // --------------------------------------------------------------- diff --git a/src/CredibleTestWithBacktesting.sol b/src/CredibleTestWithBacktesting.sol index a3b6fe8..b700bf6 100644 --- a/src/CredibleTestWithBacktesting.sol +++ b/src/CredibleTestWithBacktesting.sol @@ -38,6 +38,21 @@ abstract contract CredibleTestWithBacktesting is CredibleTest, Test { /// @dev Cached script path to avoid repeated filesystem lookups string private _cachedScriptPath; + /// @notice Skip the calling test unless the profile grants FFI. + /// @dev Backtesting shells out to `transaction_fetcher.sh` through `vm.ffi`, so it only runs + /// under a profile that sets `ffi = true`, `FOUNDRY_PROFILE=backtesting` here. Without it + /// every `vm.ffi` reverts, and the script lookup below would report the script as missing + /// when it is sitting right where it belongs. Skipping names the real requirement and + /// keeps a full-suite run under another profile green. + function _requireFfi() internal { + string[] memory probe = new string[](1); + probe[0] = "true"; + try vm.ffi(probe) {} + catch { + vm.skip(true); + } + } + /// @notice Execute backtesting for a single transaction by hash (overload for single tx mode) /// @param txHash The transaction hash to backtest /// @param targetContract The target contract address @@ -52,9 +67,11 @@ abstract contract CredibleTestWithBacktesting is CredibleTest, Test { bytes4 assertionSelector, string memory rpcUrl ) public returns (BacktestingTypes.BacktestingResults memory results) { - return _executeBacktestForSingleTransaction( - txHash, targetContract, assertionCreationCode, assertionSelector, rpcUrl - ); + _requireFfi(); + return + _executeBacktestForSingleTransaction( + txHash, targetContract, assertionCreationCode, assertionSelector, rpcUrl + ); } /// @notice Execute backtesting with config struct (block range mode) @@ -62,6 +79,7 @@ abstract contract CredibleTestWithBacktesting is CredibleTest, Test { public returns (BacktestingTypes.BacktestingResults memory results) { + _requireFfi(); uint256 startBlock = config.endBlock > config.blockRange ? config.endBlock - config.blockRange + 1 : 1; // Print configuration at the start diff --git a/src/PhEvm.sol b/src/PhEvm.sol index a6433f1..e4edccc 100644 --- a/src/PhEvm.sol +++ b/src/PhEvm.sol @@ -34,7 +34,7 @@ interface PhEvm { /// @dev The `get*CallInputs` queries (getAllCallInputs/getCallInputs/getStaticCallInputs/ /// getDelegateCallInputs/getCallCodeInputs) key on `selector`, so it is stripped from /// this field and only the argument tail remains. To rebuild full calldata, prepend the - /// selector: `bytes.concat(selector, input)`. Do NOT slice `input[4:]` — the selector is + /// selector: `bytes.concat(selector, input)`. Do NOT slice `input[4:]`, the selector is /// already gone, and slicing would drop the first argument word. Contrast `callinputAt`, /// which returns the raw selector-prefixed calldata. bytes input; @@ -144,7 +144,7 @@ interface PhEvm { } // --------------------------------------------------------------- - // Legacy fork-switching (deprecated — prefer ForkId-based access) + // Legacy fork-switching (deprecated, prefer ForkId-based access) // --------------------------------------------------------------- /// @notice Fork to the state before the assertion-triggering transaction @@ -328,7 +328,7 @@ interface PhEvm { /// @notice Returns calls matching the given target, selector, and filter criteria. /// @dev Each returned `TriggerCall.input` is the ABI-encoded arguments WITHOUT the 4-byte - /// selector — prepend `selector` before decoding (see the `TriggerCall.input` field docs). + /// selector, prepend `selector` before decoding (see the `TriggerCall.input` field docs). /// @param target The target contract address. /// @param selector The function selector to filter by. /// @param filter Filtering criteria (call type, depth, success). @@ -401,7 +401,7 @@ interface PhEvm { returns (bytes32 pre, bytes32 post, bool changed); // --------------------------------------------------------------- - // V2: Protection suite — ERC4626 share price + // V2: Protection suite. ERC4626 share price // --------------------------------------------------------------- /// @notice Checks ERC4626 share price consistency across all fork points. @@ -422,7 +422,7 @@ interface PhEvm { returns (bool); // --------------------------------------------------------------- - // V2: Protection suite — balance conservation + // V2: Protection suite, balance conservation // --------------------------------------------------------------- /// @notice Checks that an account's ERC20 balance is unchanged between two forks. @@ -437,7 +437,7 @@ interface PhEvm { returns (bool); // --------------------------------------------------------------- - // V2: Protection suite — cumulative outflow circuit breaker + // V2: Protection suite, cumulative outflow circuit breaker // --------------------------------------------------------------- /// @notice Context about the outflow that triggered an assertion via watchCumulativeOutflow. @@ -468,7 +468,7 @@ interface PhEvm { function outflowContext() external view returns (OutflowContext memory ctx); // --------------------------------------------------------------- - // V2: Protection suite — cumulative inflow circuit breaker + // V2: Protection suite, cumulative inflow circuit breaker // --------------------------------------------------------------- /// @notice Context about the inflow that triggered an assertion via watchCumulativeInflow. @@ -508,7 +508,7 @@ interface PhEvm { /// extra storage). A single 10s bucket draining the whole snapshot reads /// ~1000 bps/s. Use this to suppress false positives of the cumulative /// breaker: a large-but-slow withdrawal reads a benign peak rate. Never - /// gate the cumulative alert on it — treat it as OR-style escalation only. + /// gate the cumulative alert on it, treat it as OR-style escalation only. struct FlowRateContext { /// @notice The ERC20 token that triggered the assertion. address(0) if no flow trigger fired. address token; @@ -546,10 +546,19 @@ interface PhEvm { /// @notice Context returned by `anomalyContext(target)` describing the /// anomaly detector's view of `target` for the current tx. - /// @dev `scoreBps` is in basis points (0..=10_000), where 0 is - /// "very likely not anomalous" and 10_000 is "very likely anomalous". + /// @dev `firesAt` is the strictest sensitivity level (1..=10) the model's + /// score clears against `target`'s own ladder; 0 clears none. An + /// assertion registered at level `L` is anomalous when + /// `firesAt != 0 && L >= firesAt`, so a zero-filled context, meaning an + /// unscored target, fails open by construction. + /// @dev The level is the whole verdict. The raw score is spent resolving it + /// against the ladder that lives with the model, and never reaches + /// Solidity: basis points mean nothing without that ladder, and a + /// threshold written against them belongs to one contract and one model + /// version. A level carries over to another contract and survives a + /// retrain. struct AnomalyContext { - uint16 scoreBps; + uint8 firesAt; } /// @notice Returns the anomaly detector's view of `target` for the @@ -561,7 +570,7 @@ interface PhEvm { function anomalyContext(address target) external view returns (AnomalyContext memory ctx); // --------------------------------------------------------------- - // V2: Protection suite — oracle sanity + // V2: Protection suite, oracle sanity // --------------------------------------------------------------- /// @notice Checks oracle price consistency across all fork points. diff --git a/src/Sensitivity.sol b/src/Sensitivity.sol new file mode 100644 index 0000000..2c83954 --- /dev/null +++ b/src/Sensitivity.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @title Sensitivity +/// @notice How aggressively an anomaly trigger fires, as a level rather than a threshold. +/// @dev Each level is a point on the detector's recall-versus-false-positive curve, fixed to a +/// false-positive budget that is the same on every contract: +/// +/// | Level | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | +/// | ----- | ----- | ----- | ----- | ---- | ---- | ---- | -- | -- | -- | --- | +/// | Fires on | 0.01% | 0.02% | 0.05% | 0.1% | 0.2% | 0.5% | 1% | 2% | 5% | 10% | +/// +/// Higher is more sensitive: it catches more, and fires on more benign traffic. The +/// *threshold* behind a level is resolved per contract, from that contract's own history, +/// where the trigger is evaluated. One level therefore means one budget everywhere, and a +/// retrain moves the threshold without touching this code. +/// +/// An assertion never names a basis-point score for that reason. A threshold belongs to one +/// contract and one model version; copy it to a second contract, or keep it across a retrain, +/// and the trigger mis-configures in the direction that hurts. It stops firing while still +/// looking healthy. +/// +/// Firing is not blocking. A trigger that fires runs the assertion, and the assertion decides. +/// Pair an anomaly trigger with a damage check and only what is both unusual *and* doing +/// damage gets blocked. See `src/protection/anomaly`. +library Sensitivity { + /// @notice Fires on 0.01% of this contract's transactions. Catches the least. + uint8 internal constant LEVEL_1 = 1; + uint8 internal constant LEVEL_2 = 2; + uint8 internal constant LEVEL_3 = 3; + uint8 internal constant LEVEL_4 = 4; + uint8 internal constant LEVEL_5 = 5; + uint8 internal constant LEVEL_6 = 6; + /// @notice Fires on 1% of this contract's transactions. The recommended operating point. + uint8 internal constant LEVEL_7 = 7; + uint8 internal constant LEVEL_8 = 8; + uint8 internal constant LEVEL_9 = 9; + /// @notice Fires on 10% of this contract's transactions. Catches the most. + uint8 internal constant LEVEL_10 = 10; + + /// @notice The recommended level for a protocol without a reason to choose otherwise. + uint8 internal constant RECOMMENDED = LEVEL_7; + + /// @notice The strictest level, and the loosest, the bounds a valid level lies within. + uint8 internal constant MIN = LEVEL_1; + uint8 internal constant MAX = LEVEL_10; + + /// @notice Whether `level` names a rung of the ladder. + /// @dev `0` is not a level: it is the "cleared nothing" sentinel an unscored target reads + /// back in `PhEvm.AnomalyContext.firesAt`. + function isValid(uint8 level) internal pure returns (bool) { + return level >= MIN && level <= MAX; + } +} diff --git a/src/TriggerRecorder.sol b/src/TriggerRecorder.sol index 4b8ddfd..827d6ed 100644 --- a/src/TriggerRecorder.sol +++ b/src/TriggerRecorder.sol @@ -86,15 +86,18 @@ interface TriggerRecorder { external view; - /// @notice Registers an anomaly-detection trigger. Fires whenever the - /// executor's configured AnomalySubsystem produces a score for - /// `target` in a transaction that touches it. - /// @dev The model owns the firing decision: the trigger fires whenever - /// anomaly detection returns a score at all. The assertion reads the - /// score back via `ph.anomalyContext(target)` and decides whether - /// to revert, run extra checks, or ignore. + /// @notice Registers an anomaly-detection trigger at a sensitivity level. + /// Fires when the executor's configured AnomalySubsystem scores + /// `target` anomalously enough to clear `sensitivity`. + /// @dev The level is resolved against `target`'s own model where the + /// trigger is evaluated, so a sub-threshold transaction never spawns + /// the assertion at all. The assertion may still read the level back + /// via `ph.anomalyContext(target)`, which reports `firesAt` and + /// nothing else. There is no score to re-read. /// @param target The address whose anomaly score this assertion observes. - /// @param fnSelector The assertion function to invoke when `target` is - /// scored. - function watchAnomaly(address target, bytes4 fnSelector) external view; + /// @param fnSelector The assertion function to invoke when `target` clears + /// the level. + /// @param sensitivity The level from `Sensitivity`, 1..=10. Reverts the + /// registration if outside that range. + function watchAnomaly(address target, bytes4 fnSelector, uint8 sensitivity) external view; } diff --git a/src/protection/anomaly/AnomalyCompositeAssertion.sol b/src/protection/anomaly/AnomalyCompositeAssertion.sol index 00e1c0b..f23e899 100644 --- a/src/protection/anomaly/AnomalyCompositeAssertion.sol +++ b/src/protection/anomaly/AnomalyCompositeAssertion.sol @@ -25,8 +25,10 @@ import {AnomalyGatedBaseAssertion} from "./AnomalyGatedBaseAssertion.sol"; /// negations. It must be a non-revert outcome of the same evaluation that would otherwise /// block, or the alert cell is not derivable. /// -/// The disposition (see `AnomalyGatedBaseAssertion`): `block = a AND H` reverts, `pass = NOT a` -/// returns early, and `alert = a AND NOT H` falls through without reverting. +/// The disposition (see `AnomalyGatedBaseAssertion`): `block = a AND H` reverts and +/// `alert = a AND NOT H` falls through without reverting. `pass = NOT a` costs nothing here at +/// all: the trigger carries the sensitivity level, so a transaction that clears no level never +/// dispatches this function and the corroboration reads are never reached. /// /// The corroboration reads use the base primitives, the same ones the individual mixins call. /// Deploy one composite per protocol, parameters as the only difference. Override `_extra` to @@ -48,7 +50,7 @@ contract AnomalyCompositeAssertion is AnomalyGatedBaseAssertion { /// storage, and `bareGateBaseline`, which only gates the constructor. struct Config { address target; - uint16 anomalyThresholdBps; + uint8 sensitivity; // a level from `Sensitivity`, 1..=10 bool requireAll; // true: block on AND of the enabled heuristics; false: OR bool bareGateBaseline; // explicit opt-in: with no heuristic enabled, block on the score alone bool useDrain; @@ -84,7 +86,7 @@ contract AnomalyCompositeAssertion is AnomalyGatedBaseAssertion { /// @dev Stored, not immutable: `bytes` cannot be immutable. Read only when `useOracle`. bytes internal oracleQuery; - constructor(Config memory c) AnomalyGatedBaseAssertion(c.target, c.anomalyThresholdBps) { + constructor(Config memory c) AnomalyGatedBaseAssertion(c.target, c.sensitivity) { if (!(c.bareGateBaseline || c.useDrain || c.useUpgrade || c.useAccounting || c.useOracle)) { revert NoHeuristicEnabled(); } @@ -134,10 +136,6 @@ contract AnomalyCompositeAssertion is AnomalyGatedBaseAssertion { /// fold hits the operator's absorbing value: a silent leg under AND, a corroborating leg /// under OR. The alert cell (`a AND NOT H`) is the deliberate fall-through with no revert. function assertComposite() external { - if (!_anomalous()) { - return; // pass: not anomalous - } - bool anyEnabled; bool corroborated = requireAll; // the operator's identity: AND folds from true, OR from false diff --git a/src/protection/anomaly/AnomalyGatedAccountingAssertion.sol b/src/protection/anomaly/AnomalyGatedAccountingAssertion.sol index a02db12..6d0657d 100644 --- a/src/protection/anomaly/AnomalyGatedAccountingAssertion.sol +++ b/src/protection/anomaly/AnomalyGatedAccountingAssertion.sol @@ -43,9 +43,6 @@ abstract contract AnomalyGatedAccountingAssertion is AnomalyGatedBaseAssertion { /// @notice Reverts only when the transaction is anomalous and the share price leaves tolerance. function assertAnomalousAccounting() external view { - if (!_anomalous()) { - return; - } if (_accountingCorroborates()) { revert AnomalousAccounting(); } diff --git a/src/protection/anomaly/AnomalyGatedBaseAssertion.sol b/src/protection/anomaly/AnomalyGatedBaseAssertion.sol index 9ed618f..61cf63a 100644 --- a/src/protection/anomaly/AnomalyGatedBaseAssertion.sol +++ b/src/protection/anomaly/AnomalyGatedBaseAssertion.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.13; import {Assertion} from "../../Assertion.sol"; import {AssertionSpec} from "../../SpecRecorder.sol"; import {PhEvm} from "../../PhEvm.sol"; +import {Sensitivity} from "../../Sensitivity.sol"; interface IERC20 { function balanceOf(address account) external view returns (uint256); @@ -17,21 +18,26 @@ interface IERC20 { /// blocking signal on its own. An anomaly-gated assertion fires on the score, then requires a /// deterministic damage check to confirm before it reverts. /// -/// Over the anomaly bit `a = score >= anomalyThresholdBps` and the enabled damage set `H`, a -/// transaction's disposition is: +/// Over the anomaly bit `a = the score cleared this assertion's sensitivity level` and the +/// enabled damage set `H`, a transaction's disposition is: /// /// | | H confirms | H silent | /// | --- | --- | --- | /// | **a** | block (revert) | alert (the exclusive set) | /// | **not a** | pass (the benign whale) | pass (normal traffic) | /// -/// `block = a AND H`, `alert = a AND NOT H`, `pass = NOT a`. The assertion implements this in -/// control flow: the gate returns early on `NOT a` (pass), the corroboration reverts on -/// `a AND H` (block), and the fall-through with no revert is `a AND NOT H` (the alert cell, read -/// off-chain from the executor seeing a score and no invalidation). The alert cell does not -/// revert, so a benign-but-unusual transaction is not blocked on the model score alone. +/// `block = a AND H`, `alert = a AND NOT H`, `pass = NOT a`. The trigger and the body split +/// this between them: `NOT a` never reaches the assertion, because the trigger fires only when +/// the score clears the level, so the whole bottom row costs no execution. Inside the body, +/// the corroboration reverts on `a AND H` (block), and the fall-through with no revert is +/// `a AND NOT H` (the alert cell, read off-chain from the executor seeing a score and no +/// invalidation). The alert cell does not revert, so a benign-but-unusual transaction is +/// never blocked on the model score alone. /// -/// This base holds the target, the operating threshold, and the corroboration primitives the +/// A body therefore checks damage and nothing else. The level comparison happens where the +/// model's ladder lives, so the body has no score to re-read. +/// +/// This base holds the target, the sensitivity level, and the corroboration primitives the /// heuristic mixins and the composite share. Inherit it through a mixin or the composite, then /// implement `triggers()`. /// @@ -39,7 +45,7 @@ interface IERC20 { /// ```solidity /// contract MyDrainGuard is AnomalyGatedOutflowAssertion { /// constructor(address pool, address reserveToken) -/// AnomalyGatedBaseAssertion(pool, 205) // 205 bps == a 2% probability +/// AnomalyGatedBaseAssertion(pool, Sensitivity.RECOMMENDED) /// AnomalyGatedOutflowAssertion(pool, reserveToken, 250) // drain >= 2.5% of the reserve /// {} /// @@ -62,11 +68,11 @@ abstract contract AnomalyGatedBaseAssertion is Assertion { /// never score it, so the gate would never open and the assertion would be permanently /// inert. error ZeroTarget(); - /// @notice Constructor guard: the operating threshold must be in `[1, 10_000]`. At zero the - /// gate is satisfied by the zero-filled context of an unscored target, turning the - /// damage heuristics into ungated blockers; above 10_000 the gate is unreachable - /// (`scoreBps` caps at 10_000) and the assertion permanently inert. - error ThresholdOutOfRange(); + /// @notice Constructor guard: the sensitivity must name a rung of the ladder, `[1, 10]`. + /// Level 0 is the "cleared nothing" sentinel an unscored target reads back, so + /// accepting it would turn the damage heuristics into ungated blockers; above 10 names + /// no level at all and the assertion would be permanently inert. + error SensitivityOutOfRange(); /// @notice EIP-1967 implementation slot, `keccak256("eip1967.proxy.implementation") - 1`. bytes32 internal constant EIP1967_IMPLEMENTATION = @@ -77,43 +83,44 @@ abstract contract AnomalyGatedBaseAssertion is Assertion { /// @notice The watched contract whose anomaly score gates this assertion (the adopter). address internal immutable target; - /// @notice Scores at or above this (out of 10_000) are treated as anomalous. On the Aave family - /// the calibrated operating point for a 1% false-positive budget is 205, a 2% probability. - uint16 internal immutable anomalyThresholdBps; + /// @notice How aggressively the trigger fires, as a level on the `Sensitivity` ladder rather + /// than a basis-point score. Level 7, the recommended point, fires on 1% of the + /// contract's own transactions. The threshold behind it is resolved per contract at + /// evaluation time, so this assertion is portable and survives a retrain untouched. + uint8 internal immutable sensitivity; /// @param _target The watched contract the model scores. Must be non-zero. - /// @param _anomalyThresholdBps The operating point, in bps of anomaly probability. Must be in - /// `[1, 10_000]`. - constructor(address _target, uint16 _anomalyThresholdBps) { + /// @param _sensitivity The level from `Sensitivity`, 1..=10. + constructor(address _target, uint8 _sensitivity) { if (_target == address(0)) { revert ZeroTarget(); } - if (_anomalyThresholdBps == 0 || _anomalyThresholdBps > 10_000) { - revert ThresholdOutOfRange(); + if (!Sensitivity.isValid(_sensitivity)) { + revert SensitivityOutOfRange(); } registerAssertionSpec(AssertionSpec.Reshiram); target = _target; - anomalyThresholdBps = _anomalyThresholdBps; + sensitivity = _sensitivity; } // --------------------------------------------------------------- // The anomaly gate // --------------------------------------------------------------- - /// @notice Whether the model scored this transaction at or above the operating threshold. - /// @dev `ph.anomalyContext` fails open: an unscored target reads 0, so a contract with no model - /// (too new to have history) does not gate true and the assertion stays inert; the - /// constructor's `[1, 10_000]` threshold range guarantees this. Virtual so an adopter can - /// override the gate, e.g. a per-function threshold or a second signal. - function _anomalous() internal view virtual returns (bool) { - return ph.anomalyContext(target).scoreBps >= anomalyThresholdBps; - } - - /// @notice Register the anomaly trigger for `selector`. - /// @dev Fires `selector` whenever the AnomalySubsystem produces a score for `target`. Call this - /// inside your `triggers()`. + /// @notice Register the anomaly trigger for `selector` at this assertion's sensitivity level. + /// @dev The trigger is the gate. `selector` runs only when the AnomalySubsystem scores `target` + /// anomalously enough to clear `sensitivity`, so the assertion body checks damage and + /// nothing else. A transaction below the level never spawns the assertion at all. + /// + /// The gate fails open at both ends. An unscored target, or one whose model carries no + /// resolved ladder, clears no level, so nothing fires. Call this inside your `triggers()`. + /// + /// `ph.anomalyContext(target)` reports `firesAt`, the strictest level the model cleared, + /// and nothing else, for an assertion that wants to act on *how* strictly it cleared. There + /// is no score behind it to read: basis points name nothing without the ladder that + /// produced them, and that ladder stays with the model. function _registerAnomalyTrigger(bytes4 selector) internal view { - watchAnomaly(target, selector); + watchAnomaly(target, selector, sensitivity); } // --------------------------------------------------------------- diff --git a/src/protection/anomaly/AnomalyGatedOracleAssertion.sol b/src/protection/anomaly/AnomalyGatedOracleAssertion.sol index 2f196ae..32a563b 100644 --- a/src/protection/anomaly/AnomalyGatedOracleAssertion.sol +++ b/src/protection/anomaly/AnomalyGatedOracleAssertion.sol @@ -50,9 +50,6 @@ abstract contract AnomalyGatedOracleAssertion is AnomalyGatedBaseAssertion { /// @notice Reverts only when the transaction is anomalous and the oracle answer deviates. function assertAnomalousOracle() external { - if (!_anomalous()) { - return; - } if (_oracleCorroborates()) { revert AnomalousOracle(); } diff --git a/src/protection/anomaly/AnomalyGatedOutflowAssertion.sol b/src/protection/anomaly/AnomalyGatedOutflowAssertion.sol index 91fd30a..f814873 100644 --- a/src/protection/anomaly/AnomalyGatedOutflowAssertion.sol +++ b/src/protection/anomaly/AnomalyGatedOutflowAssertion.sol @@ -49,9 +49,6 @@ abstract contract AnomalyGatedOutflowAssertion is AnomalyGatedBaseAssertion { /// @notice Reverts only when the transaction is anomalous and drains the reserve. function assertAnomalousOutflow() external view { - if (!_anomalous()) { - return; - } if (_outflowCorroborates()) { revert AnomalousOutflow(); } diff --git a/src/protection/anomaly/AnomalyGatedUpgradeAssertion.sol b/src/protection/anomaly/AnomalyGatedUpgradeAssertion.sol index 2f477ea..eba181e 100644 --- a/src/protection/anomaly/AnomalyGatedUpgradeAssertion.sol +++ b/src/protection/anomaly/AnomalyGatedUpgradeAssertion.sol @@ -43,9 +43,6 @@ abstract contract AnomalyGatedUpgradeAssertion is AnomalyGatedBaseAssertion { /// @notice Reverts only when the transaction is anomalous and a watched config slot changes. function assertAnomalousUpgrade() external view { - if (!_anomalous()) { - return; - } if (_upgradeCorroborates()) { revert AnomalousUpgrade(); } diff --git a/src/protection/anomaly/AnomalyUngatedAssertion.sol b/src/protection/anomaly/AnomalyUngatedAssertion.sol new file mode 100644 index 0000000..406e54a --- /dev/null +++ b/src/protection/anomaly/AnomalyUngatedAssertion.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {AnomalyGatedBaseAssertion} from "./AnomalyGatedBaseAssertion.sol"; + +/// @title AnomalyUngatedAssertion +/// @author Phylax Systems +/// @notice Reverts on every transaction the anomaly trigger fires on, with no damage check. +/// @dev Blocks benign traffic by design: the invalidation count is the level's false-positive +/// budget. Use it to measure what a level costs, not as a production posture. +abstract contract AnomalyUngatedAssertion is AnomalyGatedBaseAssertion { + /// @notice The transaction cleared the registered sensitivity level. + error AnomalousTransaction(uint8 firesAt); + + /// @notice Register the trigger for the bare check. Call this inside `triggers()`. + function _registerUngatedTrigger() internal view { + _registerAnomalyTrigger(this.assertNotAnomalous.selector); + } + + /// @notice Reverts unconditionally. + /// @dev The trigger is the gate, so reaching here means the model already scored `target` past + /// `sensitivity`. + function assertNotAnomalous() external view { + revert AnomalousTransaction(ph.anomalyContext(target).firesAt); + } +} diff --git a/src/protection/anomaly/README.md b/src/protection/anomaly/README.md index c794cb4..0f38497 100644 --- a/src/protection/anomaly/README.md +++ b/src/protection/anomaly/README.md @@ -6,36 +6,41 @@ The parameters are the only per-deployment difference. No per-protocol code is n ## The disposition -Over the anomaly bit `a = score >= anomalyThresholdBps` and the enabled damage set `H`: +Over the anomaly bit `a = the score cleared the assertion's sensitivity level` and the enabled damage set `H`: | | H confirms | H silent | | --- | --- | --- | | **a** | block (revert) | alert (the exclusive set) | | **not a** | pass (the benign whale) | pass (normal traffic) | -`block = a AND H`, `alert = a AND NOT H`, `pass = NOT a`. The assertion implements this in control flow: the gate returns early on `NOT a`, a corroborated check reverts, and the fall-through with no revert is the alert cell, which the executor reads off-chain from a score with no invalidation. The alert cell does not revert, so a benign-but-unusual transaction is not blocked on the model score alone. +`block = a AND H`, `alert = a AND NOT H`, `pass = NOT a`. The trigger and the body split this between them. `NOT a` never reaches the assertion: the trigger fires only when the score clears the level, so the whole bottom row costs no execution. Inside the body a corroborated check reverts, and the fall-through with no revert is the alert cell, which the executor reads off-chain from a score with no invalidation. The alert cell does not revert, so a benign-but-unusual transaction is never blocked on the model score alone. + +A body therefore checks damage and nothing else. The level comparison happens where the model's ladder lives, so the body has no score to re-read. ## Contracts -- `AnomalyGatedBaseAssertion`: the target, the operating threshold, the `_anomalous()` gate, and the corroboration primitives every heuristic shares (`_drains`, `_upgraded`, `_accountingBroke`, `_oracleDeviated`). +- `AnomalyGatedBaseAssertion`: the target, the sensitivity level, the trigger registration, and the corroboration primitives every heuristic shares (`_drains`, `_upgraded`, `_accountingBroke`, `_oracleDeviated`). - `AnomalyGatedOutflowAssertion`, a **drain**: net outflow of a reserve token from the fund-holding contract, at or above a fraction of its pre-tx balance. - `AnomalyGatedUpgradeAssertion`, an **upgrade**: an EIP-1967 implementation/admin slot, or a named owner slot, changed on the watched contract (the anomaly focal by default, or a named `upgradeTarget` such as a custody proxy). - `AnomalyGatedAccountingAssertion`, an **accounting break**: an ERC-4626 share price moved beyond tolerance. - `AnomalyGatedOracleAssertion`, an **oracle deviation**: an oracle answer moved beyond tolerance across the transaction. - `AnomalyCompositeAssertion`: several heuristics under one operator (AND or OR) in one function, plus a protocol-specific extension leg. +- `AnomalyUngatedAssertion`: reverts on every firing, with no damage check. Blocks benign traffic by design, so the invalidation count is the level's false-positive budget. ## How to use it ### The composite, parameters only (the common case) -Deploy `AnomalyCompositeAssertion` with a `Config`. Enable the heuristics the protocol needs and pick the operator. The threshold is the model's recommended `threshold_bps` for your false-positive budget (see `developing-anomaly-triggers.md`). +Deploy `AnomalyCompositeAssertion` with a `Config`. Enable the heuristics the protocol needs and pick the operator. + +`sensitivity` is a level from `Sensitivity`, 1–10, rather than a score. Each level fixes a false-positive budget; level 7, the recommended point, fires on 1% of the contract's own transactions. The threshold behind it is resolved from that contract's own history, where the trigger is evaluated, so one level means one budget on every contract and a retrain moves the threshold without touching this code. ```solidity // A lending pool: block when an anomalous tx drains the reserve OR rewrites a proxy slot. AnomalyCompositeAssertion.Config({ - target: pool, // the watched contract the model scores - anomalyThresholdBps: 205, // the calibrated operating point (a 2% probability) - requireAll: false, // OR: either heuristic blocks + target: pool, // the watched contract the model scores + sensitivity: Sensitivity.RECOMMENDED, // level 7: fires on ~1% of this pool's transactions + requireAll: false, // OR: either heuristic blocks bareGateBaseline: false, // true only for a score-only baseline deploy (see below) useDrain: true, outflowTarget: aToken, // the reserve custody contract (may differ from `target`) @@ -51,7 +56,7 @@ AnomalyCompositeAssertion.Config({ `requireAll: true` blocks only when **every** enabled heuristic corroborates, e.g. a proxy that both drains and upgrades in one transaction. A fleet of single-heuristic assertions can only OR, since any revert invalidates the transaction, so an AND across heuristics and the exclusive-set fall-through have to live in one function. That is what the composite provides. -A `Config` with no heuristic enabled reverts at deploy (`NoHeuristicEnabled`) unless `bareGateBaseline` is set: with nothing to corroborate, the assertion would block on the score alone and inherit the model's recall-first flag rate. Setting the flag is the explicit opt-in for that deployment, used only to measure the baseline. An enabled leg missing a parameter it reads also reverts at deploy (`HeuristicMisconfigured`): the drain leg needs its custody address, token, and a fraction in `[1, 10_000]` (net outflow is capped by the pre-transaction balance, so a larger fraction can never corroborate); the accounting leg its vault; the oracle leg its feed and a selector-sized query. Deploying such a leg would ship it silently inert or falsely blocking. The base constructor rejects a zero `target` (`ZeroTarget`) and a threshold outside `[1, 10_000]` (`ThresholdOutOfRange`) for the same reason: a zero target or an over-range threshold can never gate, and a zero threshold gates on unscored contracts. The mixins enforce the same checks in their constructors. +A `Config` with no heuristic enabled reverts at deploy (`NoHeuristicEnabled`) unless `bareGateBaseline` is set: with nothing to corroborate, the assertion would block on the score alone and inherit the model's recall-first flag rate. Setting the flag is the explicit opt-in for that deployment, used only to measure the baseline. An enabled leg missing a parameter it reads also reverts at deploy (`HeuristicMisconfigured`): the drain leg needs its custody address, token, and a fraction in `[1, 10_000]` (net outflow is capped by the pre-transaction balance, so a larger fraction can never corroborate); the accounting leg its vault; the oracle leg its feed and a selector-sized query. Deploying such a leg would ship it silently inert or falsely blocking. The base constructor rejects a zero `target` (`ZeroTarget`) and a sensitivity outside `[1, 10]` (`SensitivityOutOfRange`) for the same reason: a zero target can never be scored, a level above 10 names no rung of the ladder and could never fire, and level 0 is the "cleared nothing" sentinel an unscored contract reads back, accepting it would gate true on every contract the model never scored. The mixins enforce the same checks in their constructors. The oracle query is full calldata: `abi.encodeWithSignature("latestAnswer()")` for a Chainlink-style feed, or `abi.encodeWithSignature("getAssetPrice(address)", asset)` for an asset-priced feed. @@ -62,7 +67,7 @@ Inherit the mixins you want and register each trigger in `triggers()`. Several m ```solidity contract MyGuard is AnomalyGatedOutflowAssertion, AnomalyGatedUpgradeAssertion { constructor(address pool, address token) - AnomalyGatedBaseAssertion(pool, 205) + AnomalyGatedBaseAssertion(pool, Sensitivity.RECOMMENDED) AnomalyGatedOutflowAssertion(pool, token, 250) AnomalyGatedUpgradeAssertion(address(0), bytes32(0)) {} @@ -89,13 +94,25 @@ contract MyComposite is AnomalyCompositeAssertion { } ``` -## Operating point +## Choosing a level + +| Level | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | +| -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | +| Fires on | 0.01% | 0.02% | 0.05% | 0.1% | 0.2% | 0.5% | **1%** | 2% | 5% | 10% | + +Higher is more sensitive: it catches more, and fires on more benign traffic. Start at `Sensitivity.RECOMMENDED` (level 7) and move only on a measurement. -The threshold is not a round number. Each model emits a `threshold_bps` calibrated to a false-positive budget; start there and tighten. Gate below it and you inherit the recall-first flag rate. A contract too new to have a model fails open (`anomalyContext` reads 0), so the gate does not fire. Run the checks unconditionally on a fresh contract, and gate them where the contract has history. +The percentages above are budgets. What each level costs and buys **on your contract** is measured: the platform's preview returns, per level, the false-positive rate realised on that contract's own traffic and the recall estimated from exploits against protocols in the same model family. Ask for that table before choosing anything but the recommended level. + +Some levels are unavailable on a given contract. A 0.01% budget needs 10,000 transactions of history behind it, so a shorter history cannot resolve the bottom of the ladder. The preview reports those levels disabled instead of estimating them, and a trigger set to one never fires. + +A contract too new to have a model at all also fails open: `anomalyContext` reads `firesAt == 0`, which clears no level, so the gate never opens. Run the checks unconditionally on a fresh contract and gate them once it has history. The platform's release check refuses to ship a trigger pointed at a contract with no usable model, so production should never see this state. ## Testing -Released `pcl` does not implement the anomaly precompile or a score cheatcode, so the tests in `test/protection/anomaly/` fire the assertions from a tx-end trigger and override the virtual `_anomalous()` gate to drive the disposition. That exercises the corroboration and operator logic: the composite's dispositions in `AnomalyCompositeAssertion.t.sol`, and each single-heuristic mixin (plus the `MyGuard` composition above) in `AnomalyGatedMixinAssertions.t.sol`. The score-to-gate wiring and the false-positive rate are validated by the executor's own tests and by backtesting against the real model; see `developing-anomaly-triggers.md` in the anomaly-detection repo. +Released `pcl` does not implement the anomaly precompile, so the tests in `test/protection/anomaly/` fire the assertions from a tx-end trigger standing in for the anomaly trigger. What they cover is the damage half, which is all the body does: the composite's dispositions in `AnomalyCompositeAssertion.t.sol`, each single-heuristic mixin (plus the `MyGuard` composition above) in `AnomalyGatedMixinAssertions.t.sol`, and the drain ratio's boundaries and overflow behaviour in `AnomalyGatedBaseAssertion.t.sol`. `AnomalySensitivityGate.t.sol` pins the ladder's bounds and the constructor's refusal to deploy a level that is not on it. + +Whether the trigger fires at a given level is the executor's decision, tested in its own selection suite alongside the level resolution. The false-positive rate is validated by backtesting against the real model; see `developing-anomaly-triggers.md` in the anomaly-detection repo. ## What it does not do diff --git a/test/backtesting/UsdcOpSepoliaBacktesting.t.sol b/test/backtesting/UsdcOpSepoliaBacktesting.t.sol index a1a996f..44c565a 100644 --- a/test/backtesting/UsdcOpSepoliaBacktesting.t.sol +++ b/test/backtesting/UsdcOpSepoliaBacktesting.t.sol @@ -57,36 +57,47 @@ contract BacktestingIntegrationTest is CredibleTestWithBacktesting { /// @title Single Transaction Backtesting Tests /// @notice Tests single transaction backtesting with known fixtures contract SingleTxBacktestingTest is CredibleTestWithBacktesting { - // Known USDC transfer on Optimism Sepolia - // https://sepolia-optimism.etherscan.io/tx/0x... + // USDC on Optimism Sepolia address constant USDC_OP_SEPOLIA = 0x5fd84259d66Cd46123540766Be93DFE6D43130D7; - /// @notice Test single transaction backtesting with a known transfer - /// @dev This test requires RPC access to Optimism Sepolia + /// @notice A `transfer(address,uint256)` of 10 USDC in block 31336940, the one transaction the + /// block-range suite above discovers over its window, pinned here by hash. + bytes32 constant TRANSFER_TX = 0xbdfa042cfaa2c5305dc131e4fb1ef50bf43b2654ab511a2913444ea614f5eba7; + + /// @notice Backtest one transaction by hash and check the ERC20 invariant holds on it. function testSingleTransactionBacktest() public { - // Skip if no RPC available (for CI without RPC secrets) - try vm.envString("OP_SEPOLIA_RPC_URL") returns (string memory rpcUrl) { - // Use a real transaction hash from Optimism Sepolia - // This should be a USDC transfer transaction - bytes32 txHash = 0x0000000000000000000000000000000000000000000000000000000000000000; + BacktestingTypes.BacktestingResults memory results = executeBacktestForTransaction( + TRANSFER_TX, + USDC_OP_SEPOLIA, + type(ERC20Assertion).creationCode, + ERC20Assertion.assertionTransferInvariant.selector, + _rpcUrl() + ); - if (txHash == bytes32(0)) { - console.log("SKIP: No fixture transaction hash configured"); - return; - } + _assertExactlyOneSuccessfulValidation(results); + } - BacktestingTypes.BacktestingResults memory results = executeBacktestForTransaction( - txHash, - USDC_OP_SEPOLIA, - type(ERC20Assertion).creationCode, - ERC20Assertion.assertionTransferInvariant.selector, - rpcUrl - ); + /// @notice Assert the backtest actually validated the transaction. + /// @dev `assertionFailures == 0` alone is satisfied by a transaction that was skipped, failed to + /// replay, or errored, so the assertion may never have run and the test still passes. The + /// counters are tracked independently, so all of them have to be pinned for the result to + /// mean "one transaction was replayed and the invariant held on it". + function _assertExactlyOneSuccessfulValidation(BacktestingTypes.BacktestingResults memory results) internal pure { + assertEq(results.totalTransactions, 1, "the pinned transaction is the only one backtested"); + assertEq(results.successfulValidations, 1, "the assertion did not run successfully"); + assertEq(results.assertionFailures, 0, "a plain transfer keeps the invariant"); + assertEq(results.skippedTransactions, 0, "the transaction was skipped rather than validated"); + assertEq(results.replayFailures, 0, "the transaction failed to replay"); + assertEq(results.unknownErrors, 0, "the backtest hit an unknown error"); + } - assertEq(results.totalTransactions, 1, "Should process exactly 1 transaction"); - assertEq(results.assertionFailures, 0, "Assertion should pass"); - } catch { - console.log("SKIP: OP_SEPOLIA_RPC_URL not set"); - } + /// @dev `OP_SEPOLIA_RPC_URL` overrides the public endpoint the block-range suite also uses. + function _rpcUrl() internal view returns (string memory) { + try vm.envString("OP_SEPOLIA_RPC_URL") returns (string memory url) { + if (bytes(url).length > 0) { + return url; + } + } catch {} + return "https://sepolia.optimism.io"; } } diff --git a/test/protection/anomaly/AnomalyCompositeAssertion.t.sol b/test/protection/anomaly/AnomalyCompositeAssertion.t.sol index 79efd1b..1508065 100644 --- a/test/protection/anomaly/AnomalyCompositeAssertion.t.sol +++ b/test/protection/anomaly/AnomalyCompositeAssertion.t.sol @@ -3,54 +3,46 @@ pragma solidity ^0.8.28; import {Test} from "forge-std/Test.sol"; import {CredibleTest} from "credible-std/CredibleTest.sol"; +import {Sensitivity} from "credible-std/Sensitivity.sol"; import {PhEvm} from "credible-std/PhEvm.sol"; import {AnomalyCompositeAssertion} from "credible-std/protection/anomaly/AnomalyCompositeAssertion.sol"; import {AnomalyGatedBaseAssertion} from "credible-std/protection/anomaly/AnomalyGatedBaseAssertion.sol"; import {MockERC20, MockOracle, MockVault4626, Vault} from "./AnomalyTestMocks.sol"; -// The `anomalyContext` precompile and the `setAnomalyScore` cheatcode are not in released pcl, so -// these tests fire `assertComposite` from a tx-end trigger and override the virtual `_anomalous()` -// gate to a constructor bool. That drives the disposition (anomalous vs not) without reading the -// score, exercising the real corroboration, operator, and exclusive-set logic. The score read and -// the watchAnomaly wiring are covered by the executor's own anomaly tests. +// `assertComposite` runs only once the anomaly trigger has fired, so these tests cover the +// corroboration, operator, and exclusive-set logic that follows it. +// +// Released pcl has no anomaly precompile, so they fire from a tx-end trigger standing in for the +// anomaly trigger. Whether that trigger fires at a given sensitivity level is the executor's +// decision, tested in its own selection suite. -/// @notice The composite fired by a tx-end trigger with the gate overridden to a constructor bool, -/// so the logic runs without the anomaly precompile. `anomalous = true` clears the gate. +/// @notice The composite fired by a tx-end trigger standing in for the anomaly trigger, so the +/// corroboration logic runs without the anomaly precompile. contract CompositeTxEndHarness is AnomalyCompositeAssertion { - bool internal immutable anomalous; - - constructor(Config memory c, bool anomalous_) AnomalyCompositeAssertion(c) { - anomalous = anomalous_; - } + constructor(Config memory c) AnomalyCompositeAssertion(c) {} function triggers() external view override { + // Stands in for the anomaly trigger, which in production fires this selector only when + // the score clears the sensitivity level. registerTxEndTrigger(this.assertComposite.selector); } - - function _anomalous() internal view override returns (bool) { - return anomalous; - } } /// @notice The composite with a protocol-specific `_extra` leg. The leg corroborates when the /// protocol reports itself unhealthy (`flag == false`) post-tx. contract CompositeWithHealthTxEnd is AnomalyCompositeAssertion { - bool internal immutable anomalous; address internal immutable healthTarget; - constructor(Config memory c, address healthTarget_, bool anomalous_) AnomalyCompositeAssertion(c) { + constructor(Config memory c, address healthTarget_) AnomalyCompositeAssertion(c) { healthTarget = healthTarget_; - anomalous = anomalous_; } function triggers() external view override { + // Stands in for the anomaly trigger, which in production fires this selector only when + // the score clears the sensitivity level. registerTxEndTrigger(this.assertComposite.selector); } - function _anomalous() internal view override returns (bool) { - return anomalous; - } - function _extra() internal override returns (bool enabled, bool corroborates) { PhEvm.StaticCallResult memory result = ph.staticcallAt(healthTarget, abi.encodeWithSignature("flag()"), 50_000, _postTx()); @@ -59,11 +51,11 @@ contract CompositeWithHealthTxEnd is AnomalyCompositeAssertion { } } -/// @notice Proves the composite disposition: `block = anomalous AND H`, `pass = NOT anomalous`, and -/// the exclusive-set fall-through `anomalous AND NOT H`, under both the OR and the AND operator. The -/// AND is the case a fleet of single-heuristic assertions cannot express. +/// @notice Proves the composite disposition once the trigger has fired: `block = H`, and the +/// exclusive-set fall-through when `H` is silent, under both the OR and the AND operator. The AND +/// is the case a fleet of single-heuristic assertions cannot express. contract TestAnomalyCompositeAssertion is CredibleTest, Test { - uint16 internal constant THRESHOLD_BPS = 205; + uint8 internal constant LEVEL = Sensitivity.RECOMMENDED; uint256 internal constant DRAIN_FRAC_BPS = 250; // 2.5% of the reserve uint256 internal constant SUPPLY = 100 ether; address internal constant SINK = address(0x5117); @@ -83,7 +75,7 @@ contract TestAnomalyCompositeAssertion is CredibleTest, Test { /// A config with every heuristic off; each test turns on what it needs. function _base() internal view returns (AnomalyCompositeAssertion.Config memory c) { c.target = address(vault); - c.anomalyThresholdBps = THRESHOLD_BPS; + c.sensitivity = LEVEL; } function _withDrain(AnomalyCompositeAssertion.Config memory c) @@ -107,10 +99,10 @@ contract TestAnomalyCompositeAssertion is CredibleTest, Test { return c; } - function _register(AnomalyCompositeAssertion.Config memory c, bool anomalous) internal { + function _register(AnomalyCompositeAssertion.Config memory c) internal { cl.assertion({ adopter: address(vault), - createData: abi.encodePacked(type(CompositeTxEndHarness).creationCode, abi.encode(c, anomalous)), + createData: abi.encodePacked(type(CompositeTxEndHarness).creationCode, abi.encode(c)), fnSelector: AnomalyCompositeAssertion.assertComposite.selector }); } @@ -119,37 +111,31 @@ contract TestAnomalyCompositeAssertion is CredibleTest, Test { /// Anomalous and the tx drains: OR blocks. function test_or_drain_blocks() public { - _register(_withUpgrade(_withDrain(_base())), true); + _register(_withUpgrade(_withDrain(_base()))); vm.expectRevert(); vault.drain(SINK, 90 ether); } /// Anomalous and the tx upgrades: OR blocks on the other leg. function test_or_upgrade_blocks() public { - _register(_withUpgrade(_withDrain(_base())), true); + _register(_withUpgrade(_withDrain(_base()))); vm.expectRevert(); vault.upgradeTo(IMPL); } /// Anomalous but neither heuristic corroborates: the exclusive set. No revert (alert cell). function test_or_neither_is_exclusive_set_and_passes() public { - _register(_withUpgrade(_withDrain(_base())), true); + _register(_withUpgrade(_withDrain(_base()))); vault.poke(); } - /// Not anomalous: a draining tx passes. The gate suppresses the drain heuristic. - function test_not_anomalous_suppresses_drain() public { - _register(_withUpgrade(_withDrain(_base())), false); - vault.drain(SINK, 90 ether); - } - // --- AND operator: every enabled heuristic must corroborate --- /// Anomalous, the tx drains AND upgrades in one tx: AND blocks. function test_and_both_blocks() public { AnomalyCompositeAssertion.Config memory c = _withUpgrade(_withDrain(_base())); c.requireAll = true; - _register(c, true); + _register(c); vm.expectRevert(); vault.drainAndUpgrade(SINK, 90 ether, IMPL); } @@ -158,7 +144,7 @@ contract TestAnomalyCompositeAssertion is CredibleTest, Test { function test_and_drain_only_passes() public { AnomalyCompositeAssertion.Config memory c = _withUpgrade(_withDrain(_base())); c.requireAll = true; - _register(c, true); + _register(c); vault.drain(SINK, 90 ether); } @@ -166,7 +152,7 @@ contract TestAnomalyCompositeAssertion is CredibleTest, Test { function test_and_upgrade_only_passes() public { AnomalyCompositeAssertion.Config memory c = _withUpgrade(_withDrain(_base())); c.requireAll = true; - _register(c, true); + _register(c); vault.upgradeTo(IMPL); } @@ -176,7 +162,7 @@ contract TestAnomalyCompositeAssertion is CredibleTest, Test { function test_upgrade_target_watches_named_contract() public { AnomalyCompositeAssertion.Config memory c = _withUpgrade(_base()); c.upgradeTarget = address(remote); - _register(c, true); + _register(c); vm.expectRevert(); vault.upgradeRemote(remote, IMPL); } @@ -186,25 +172,17 @@ contract TestAnomalyCompositeAssertion is CredibleTest, Test { function test_upgrade_target_ignores_focal_upgrade() public { AnomalyCompositeAssertion.Config memory c = _withUpgrade(_base()); c.upgradeTarget = address(remote); - _register(c, true); + _register(c); vault.upgradeTo(IMPL); } // --- the gate and the baseline --- - /// Not anomalous: pass regardless of damage, even a tx that both drains and upgrades. - function test_not_anomalous_passes_with_damage() public { - AnomalyCompositeAssertion.Config memory c = _withUpgrade(_withDrain(_base())); - c.requireAll = true; - _register(c, false); - vault.drainAndUpgrade(SINK, 90 ether, IMPL); - } - /// A config with no heuristic enabled and no baseline opt-in reverts at deploy: blocking on the /// score alone must be explicit, not a default-initialized `Config`. function test_config_with_no_heuristic_reverts_at_deploy() public { vm.expectRevert(AnomalyCompositeAssertion.NoHeuristicEnabled.selector); - new CompositeTxEndHarness(_base(), true); + new CompositeTxEndHarness(_base()); } /// An enabled leg missing a parameter it reads reverts at deploy rather than shipping a @@ -246,7 +224,7 @@ contract TestAnomalyCompositeAssertion is CredibleTest, Test { function _expectMisconfigured(AnomalyCompositeAssertion.Config memory c) internal { vm.expectRevert(AnomalyGatedBaseAssertion.HeuristicMisconfigured.selector); - new CompositeTxEndHarness(c, true); + new CompositeTxEndHarness(c); } /// A zero target reverts at deploy: `anomalyContext` can never score it, so the gate would @@ -255,62 +233,54 @@ contract TestAnomalyCompositeAssertion is CredibleTest, Test { AnomalyCompositeAssertion.Config memory c = _withDrain(_base()); c.target = address(0); vm.expectRevert(AnomalyGatedBaseAssertion.ZeroTarget.selector); - new CompositeTxEndHarness(c, true); + new CompositeTxEndHarness(c); } - /// The threshold must sit in [1, 10_000], boundaries included. Zero gates true on the - /// zero-filled context of an unscored target; above 10_000 the gate is unreachable because - /// `scoreBps` caps at 10_000. - function test_threshold_range_boundaries_at_deploy() public { + /// The sensitivity must name a rung of the ladder, [1, 10], boundaries included. Level 0 is + /// the "cleared nothing" sentinel an unscored target reads back, so accepting it would gate + /// true on contracts the model never scored; above 10 names no level and could never fire. + function test_sensitivity_range_boundaries_at_deploy() public { AnomalyCompositeAssertion.Config memory c = _withDrain(_base()); - c.anomalyThresholdBps = 0; - vm.expectRevert(AnomalyGatedBaseAssertion.ThresholdOutOfRange.selector); - new CompositeTxEndHarness(c, true); + c.sensitivity = 0; + vm.expectRevert(AnomalyGatedBaseAssertion.SensitivityOutOfRange.selector); + new CompositeTxEndHarness(c); c = _withDrain(_base()); - c.anomalyThresholdBps = 10_001; - vm.expectRevert(AnomalyGatedBaseAssertion.ThresholdOutOfRange.selector); - new CompositeTxEndHarness(c, true); + c.sensitivity = 11; + vm.expectRevert(AnomalyGatedBaseAssertion.SensitivityOutOfRange.selector); + new CompositeTxEndHarness(c); c = _withDrain(_base()); - c.anomalyThresholdBps = 1; - new CompositeTxEndHarness(c, true); + c.sensitivity = Sensitivity.MIN; + new CompositeTxEndHarness(c); c = _withDrain(_base()); - c.anomalyThresholdBps = 10_000; - new CompositeTxEndHarness(c, true); + c.sensitivity = Sensitivity.MAX; + new CompositeTxEndHarness(c); } /// The baseline opt-in, anomalous: the bare gate blocks on the score alone. function test_bare_gate_blocks_when_anomalous() public { AnomalyCompositeAssertion.Config memory c = _base(); c.bareGateBaseline = true; - _register(c, true); + _register(c); vm.expectRevert(); vault.poke(); } - /// The baseline opt-in, not anomalous: nothing blocks. - function test_bare_gate_passes_when_not_anomalous() public { - AnomalyCompositeAssertion.Config memory c = _base(); - c.bareGateBaseline = true; - _register(c, false); - vault.poke(); - } - /// `bareGateBaseline` alongside an enabled heuristic changes nothing: the leg set drives the /// disposition, so an anomalous tx with no damage stays in the alert cell. function test_baseline_flag_inert_when_heuristic_enabled() public { AnomalyCompositeAssertion.Config memory c = _withDrain(_base()); c.bareGateBaseline = true; - _register(c, true); + _register(c); vault.poke(); } } /// @notice Proves the `_extra` leg participates in the operator alongside the generic heuristics. contract TestAnomalyCompositeExtraLeg is CredibleTest, Test { - uint16 internal constant THRESHOLD_BPS = 205; + uint8 internal constant LEVEL = Sensitivity.RECOMMENDED; uint256 internal constant DRAIN_FRAC_BPS = 250; uint256 internal constant SUPPLY = 100 ether; address internal constant SINK = address(0x5117); @@ -327,7 +297,7 @@ contract TestAnomalyCompositeExtraLeg is CredibleTest, Test { /// AND over {drain, extra}: block only when the reserve drains AND the protocol is unhealthy. function _config() internal view returns (AnomalyCompositeAssertion.Config memory c) { c.target = address(vault); - c.anomalyThresholdBps = THRESHOLD_BPS; + c.sensitivity = LEVEL; c.requireAll = true; c.useDrain = true; c.outflowTarget = address(vault); @@ -339,7 +309,7 @@ contract TestAnomalyCompositeExtraLeg is CredibleTest, Test { cl.assertion({ adopter: address(vault), createData: abi.encodePacked( - type(CompositeWithHealthTxEnd).creationCode, abi.encode(_config(), address(vault), true) + type(CompositeWithHealthTxEnd).creationCode, abi.encode(_config(), address(vault)) ), fnSelector: AnomalyCompositeAssertion.assertComposite.selector }); @@ -362,7 +332,7 @@ contract TestAnomalyCompositeExtraLeg is CredibleTest, Test { /// @notice Regression coverage for the oracle leg: the query is an arg-taking reader /// (`getAssetPrice(address)`), the shape a bare `bytes4` selector could not encode. contract TestAnomalyCompositeOracleLeg is CredibleTest, Test { - uint16 internal constant THRESHOLD_BPS = 205; + uint8 internal constant LEVEL = Sensitivity.RECOMMENDED; uint256 internal constant ORACLE_TOL_BPS = 200; // 2% address internal constant ASSET = address(0xA55E7); uint256 internal constant BASE_PRICE = 1000e8; @@ -381,45 +351,39 @@ contract TestAnomalyCompositeOracleLeg is CredibleTest, Test { /// OR over {oracle} only: block iff the oracle answer leaves tolerance across the transaction. function _config() internal view returns (AnomalyCompositeAssertion.Config memory c) { c.target = address(vault); - c.anomalyThresholdBps = THRESHOLD_BPS; + c.sensitivity = LEVEL; c.useOracle = true; c.oracle = address(oracle); c.oracleQuery = abi.encodeWithSignature("getAssetPrice(address)", ASSET); c.oracleToleranceBps = ORACLE_TOL_BPS; } - function _register(bool anomalous) internal { + function _register() internal { cl.assertion({ adopter: address(vault), - createData: abi.encodePacked(type(CompositeTxEndHarness).creationCode, abi.encode(_config(), anomalous)), + createData: abi.encodePacked(type(CompositeTxEndHarness).creationCode, abi.encode(_config())), fnSelector: AnomalyCompositeAssertion.assertComposite.selector }); } /// Anomalous and the oracle jumps 10% (past the 2% tolerance): the oracle leg corroborates, block. function test_oracle_leg_blocks_on_deviation() public { - _register(true); + _register(); vm.expectRevert(); vault.moveOracle(address(oracle), ASSET, 1100e8); } /// Anomalous but the oracle stays within tolerance (+1%): the leg is silent, pass (exclusive set). function test_oracle_leg_passes_within_tolerance() public { - _register(true); + _register(); vault.moveOracle(address(oracle), ASSET, 1010e8); } - - /// Not anomalous: a large oracle move passes because the gate suppresses it. - function test_oracle_leg_suppressed_when_not_anomalous() public { - _register(false); - vault.moveOracle(address(oracle), ASSET, 1100e8); - } } /// @notice Coverage for the accounting leg: block when the ERC4626 share price moves beyond /// tolerance across the transaction. contract TestAnomalyCompositeAccountingLeg is CredibleTest, Test { - uint16 internal constant THRESHOLD_BPS = 205; + uint8 internal constant LEVEL = Sensitivity.RECOMMENDED; uint256 internal constant SHARE_TOL_BPS = 200; // 2% MockERC20 internal token; @@ -435,36 +399,30 @@ contract TestAnomalyCompositeAccountingLeg is CredibleTest, Test { /// OR over {accounting} only: block iff the share price leaves tolerance across the transaction. function _config() internal view returns (AnomalyCompositeAssertion.Config memory c) { c.target = address(vault); - c.anomalyThresholdBps = THRESHOLD_BPS; + c.sensitivity = LEVEL; c.useAccounting = true; c.accountingVault = address(vault4626); c.shareToleranceBps = SHARE_TOL_BPS; } - function _register(bool anomalous) internal { + function _register() internal { cl.assertion({ adopter: address(vault), - createData: abi.encodePacked(type(CompositeTxEndHarness).creationCode, abi.encode(_config(), anomalous)), + createData: abi.encodePacked(type(CompositeTxEndHarness).creationCode, abi.encode(_config())), fnSelector: AnomalyCompositeAssertion.assertComposite.selector }); } /// Anomalous and the share price jumps 10% (past the 2% tolerance): the leg corroborates, block. function test_accounting_leg_blocks_on_deviation() public { - _register(true); + _register(); vm.expectRevert(); vault.moveSharePrice(address(vault4626), 1100 ether); } /// Anomalous but the share price stays within tolerance (+1%): the leg is silent, pass. function test_accounting_leg_passes_within_tolerance() public { - _register(true); + _register(); vault.moveSharePrice(address(vault4626), 1010 ether); } - - /// Not anomalous: a large share-price move passes because the gate suppresses it. - function test_accounting_leg_suppressed_when_not_anomalous() public { - _register(false); - vault.moveSharePrice(address(vault4626), 1100 ether); - } } diff --git a/test/protection/anomaly/AnomalyGatedBaseAssertion.t.sol b/test/protection/anomaly/AnomalyGatedBaseAssertion.t.sol index 8a539d2..eccbc20 100644 --- a/test/protection/anomaly/AnomalyGatedBaseAssertion.t.sol +++ b/test/protection/anomaly/AnomalyGatedBaseAssertion.t.sol @@ -3,18 +3,19 @@ pragma solidity ^0.8.28; import {Test} from "forge-std/Test.sol"; import {CredibleTest} from "credible-std/CredibleTest.sol"; +import {Sensitivity} from "credible-std/Sensitivity.sol"; import {AnomalyCompositeAssertion} from "credible-std/protection/anomaly/AnomalyCompositeAssertion.sol"; import {CompositeTxEndHarness} from "./AnomalyCompositeAssertion.t.sol"; import {MockERC20, Vault} from "./AnomalyTestMocks.sol"; // Base-primitive coverage for the drain ratio in `_drains`: exact threshold boundaries, the -// 512-bit `mulDivDown` overflow regression, codeless-token fail-open, and two fuzz properties. -// The verdict oracle: while anomalous, block iff `net * 10_000 / preBalance >= fracBps`; while -// not anomalous, never block. The gate is overridden to a constructor bool as in the other -// anomaly suites. +// 512-bit `mulDivDown` overflow regression, codeless-token fail-open, and a fuzz property. The +// verdict oracle, given the anomaly trigger has fired: block iff +// `net * 10_000 / preBalance >= fracBps`. As in the other anomaly suites, a tx-end trigger stands +// in for the anomaly trigger. contract TestAnomalyDrainRatio is CredibleTest, Test { - uint16 internal constant THRESHOLD_BPS = 205; + uint8 internal constant LEVEL = Sensitivity.RECOMMENDED; uint256 internal constant DRAIN_FRAC_BPS = 250; // 2.5% of the reserve uint256 internal constant SUPPLY = 100 ether; address internal constant SINK = address(0x5117); @@ -34,18 +35,18 @@ contract TestAnomalyDrainRatio is CredibleTest, Test { returns (AnomalyCompositeAssertion.Config memory c) { c.target = address(vault); - c.anomalyThresholdBps = THRESHOLD_BPS; + c.sensitivity = LEVEL; c.useDrain = true; c.outflowTarget = address(vault); c.outflowToken = token_; c.outflowFracBps = fracBps; } - function _register(uint256 fracBps, bool anomalous) internal { + function _register(uint256 fracBps) internal { cl.assertion({ adopter: address(vault), createData: abi.encodePacked( - type(CompositeTxEndHarness).creationCode, abi.encode(_config(address(token), fracBps), anomalous) + type(CompositeTxEndHarness).creationCode, abi.encode(_config(address(token), fracBps)) ), fnSelector: AnomalyCompositeAssertion.assertComposite.selector }); @@ -55,27 +56,27 @@ contract TestAnomalyDrainRatio is CredibleTest, Test { /// A drain of exactly the fraction corroborates: the comparison is `>=`. function test_drain_at_exact_fraction_blocks() public { - _register(DRAIN_FRAC_BPS, true); + _register(DRAIN_FRAC_BPS); vm.expectRevert(); vault.drain(SINK, SUPPLY * DRAIN_FRAC_BPS / 10_000); } /// One wei below the fraction stays in the alert cell. function test_drain_one_wei_below_fraction_passes() public { - _register(DRAIN_FRAC_BPS, true); + _register(DRAIN_FRAC_BPS); vault.drain(SINK, SUPPLY * DRAIN_FRAC_BPS / 10_000 - 1); } /// At the 10_000 cap only a full drain corroborates. function test_full_drain_blocks_at_cap_fraction() public { - _register(10_000, true); + _register(10_000); vm.expectRevert(); vault.drain(SINK, SUPPLY); } /// At the cap fraction, one wei short of a full drain rounds down to 9_999 bps and passes. function test_near_full_drain_passes_at_cap_fraction() public { - _register(10_000, true); + _register(10_000); vault.drain(SINK, SUPPLY - 1); } @@ -86,14 +87,14 @@ contract TestAnomalyDrainRatio is CredibleTest, Test { /// without corroboration. The `mulDivDown` ratio reads 156 bps and passes. function test_huge_balance_below_threshold_does_not_block() public { token.mint(address(vault), (1 << 250) - SUPPLY); - _register(DRAIN_FRAC_BPS, true); + _register(DRAIN_FRAC_BPS); vault.drain(SINK, 1 << 244); } /// The same huge balance still blocks above threshold: 2^245 of 2^250 is 312 bps. function test_huge_balance_above_threshold_blocks() public { token.mint(address(vault), (1 << 250) - SUPPLY); - _register(DRAIN_FRAC_BPS, true); + _register(DRAIN_FRAC_BPS); vm.expectRevert(); vault.drain(SINK, 1 << 245); } @@ -106,8 +107,7 @@ contract TestAnomalyDrainRatio is CredibleTest, Test { cl.assertion({ adopter: address(vault), createData: abi.encodePacked( - type(CompositeTxEndHarness).creationCode, - abi.encode(_config(makeAddr("codeless"), DRAIN_FRAC_BPS), true) + type(CompositeTxEndHarness).creationCode, abi.encode(_config(makeAddr("codeless"), DRAIN_FRAC_BPS)) ), fnSelector: AnomalyCompositeAssertion.assertComposite.selector }); @@ -121,18 +121,10 @@ contract TestAnomalyDrainRatio is CredibleTest, Test { function testFuzz_blocks_iff_ratio_reaches_fraction(uint256 amount, uint256 fracBps) public { fracBps = bound(fracBps, 1, 10_000); amount = bound(amount, 0, SUPPLY); - _register(fracBps, true); + _register(fracBps); if (amount * 10_000 / SUPPLY >= fracBps) { vm.expectRevert(); } vault.drain(SINK, amount); } - - /// Gate invariant: while not anomalous, no drain blocks, whatever its size or the fraction. - function testFuzz_never_blocks_when_not_anomalous(uint256 amount, uint256 fracBps) public { - fracBps = bound(fracBps, 1, 10_000); - amount = bound(amount, 0, SUPPLY); - _register(fracBps, false); - vault.drain(SINK, amount); - } } diff --git a/test/protection/anomaly/AnomalyGatedMixinAssertions.t.sol b/test/protection/anomaly/AnomalyGatedMixinAssertions.t.sol index 433cde9..c5b9386 100644 --- a/test/protection/anomaly/AnomalyGatedMixinAssertions.t.sol +++ b/test/protection/anomaly/AnomalyGatedMixinAssertions.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.28; import {Test} from "forge-std/Test.sol"; import {CredibleTest} from "credible-std/CredibleTest.sol"; +import {Sensitivity} from "credible-std/Sensitivity.sol"; import {AnomalyGatedBaseAssertion} from "credible-std/protection/anomaly/AnomalyGatedBaseAssertion.sol"; import {AnomalyGatedOutflowAssertion} from "credible-std/protection/anomaly/AnomalyGatedOutflowAssertion.sol"; import {AnomalyGatedUpgradeAssertion} from "credible-std/protection/anomaly/AnomalyGatedUpgradeAssertion.sol"; @@ -10,117 +11,88 @@ import {AnomalyGatedAccountingAssertion} from "credible-std/protection/anomaly/A import {AnomalyGatedOracleAssertion} from "credible-std/protection/anomaly/AnomalyGatedOracleAssertion.sol"; import {MockERC20, MockOracle, MockVault4626, Vault} from "./AnomalyTestMocks.sol"; -// Single-heuristic mixin coverage. Each mixin's assert function proves its own disposition: -// block on anomalous-and-corroborated, the exclusive-set pass on anomalous-only, and the gate -// suppressing a corroborated-only transaction. As in the composite tests, released pcl has no -// anomaly precompile, so the harnesses fire from a tx-end trigger and override the virtual -// `_anomalous()` gate to a constructor bool; the `_registerAnomalyTrigger` wiring is covered by -// the executor's own anomaly tests. +// Single-heuristic mixin coverage. Each mixin body runs only once the anomaly trigger has fired, +// so what these prove is the damage half: block on a corroborated check, and the exclusive-set pass +// when nothing corroborates. +// +// Released pcl has no anomaly precompile, so the harnesses fire from a tx-end trigger standing in +// for the anomaly trigger. Whether that trigger fires at a given sensitivity level is the +// executor's decision, tested in its own selection suite. -/// @notice The outflow mixin fired by a tx-end trigger with the gate overridden. +/// @notice The outflow mixin fired by a tx-end trigger standing in for the anomaly trigger. contract OutflowTxEndHarness is AnomalyGatedOutflowAssertion { - bool internal immutable anomalous; - - constructor(address target_, address token_, uint256 fracBps, bool anomalous_) - AnomalyGatedBaseAssertion(target_, 205) + constructor(address target_, address token_, uint256 fracBps) + AnomalyGatedBaseAssertion(target_, Sensitivity.RECOMMENDED) AnomalyGatedOutflowAssertion(target_, token_, fracBps) - { - anomalous = anomalous_; - } + {} function triggers() external view override { + // Stands in for the anomaly trigger, which in production fires this selector only when + // the score clears the sensitivity level. registerTxEndTrigger(this.assertAnomalousOutflow.selector); } - - function _anomalous() internal view override returns (bool) { - return anomalous; - } } -/// @notice The upgrade mixin fired by a tx-end trigger with the gate overridden. +/// @notice The upgrade mixin fired by a tx-end trigger standing in for the anomaly trigger. contract UpgradeTxEndHarness is AnomalyGatedUpgradeAssertion { - bool internal immutable anomalous; - - constructor(address target_, address upgradeTarget_, bytes32 ownerSlot_, bool anomalous_) - AnomalyGatedBaseAssertion(target_, 205) + constructor(address target_, address upgradeTarget_, bytes32 ownerSlot_) + AnomalyGatedBaseAssertion(target_, Sensitivity.RECOMMENDED) AnomalyGatedUpgradeAssertion(upgradeTarget_, ownerSlot_) - { - anomalous = anomalous_; - } + {} function triggers() external view override { + // Stands in for the anomaly trigger, which in production fires this selector only when + // the score clears the sensitivity level. registerTxEndTrigger(this.assertAnomalousUpgrade.selector); } - - function _anomalous() internal view override returns (bool) { - return anomalous; - } } -/// @notice The accounting mixin fired by a tx-end trigger with the gate overridden. +/// @notice The accounting mixin fired by a tx-end trigger standing in for the anomaly trigger. contract AccountingTxEndHarness is AnomalyGatedAccountingAssertion { - bool internal immutable anomalous; - - constructor(address target_, address vault4626_, uint256 toleranceBps, bool anomalous_) - AnomalyGatedBaseAssertion(target_, 205) + constructor(address target_, address vault4626_, uint256 toleranceBps) + AnomalyGatedBaseAssertion(target_, Sensitivity.RECOMMENDED) AnomalyGatedAccountingAssertion(vault4626_, toleranceBps) - { - anomalous = anomalous_; - } + {} function triggers() external view override { + // Stands in for the anomaly trigger, which in production fires this selector only when + // the score clears the sensitivity level. registerTxEndTrigger(this.assertAnomalousAccounting.selector); } - - function _anomalous() internal view override returns (bool) { - return anomalous; - } } -/// @notice The oracle mixin fired by a tx-end trigger with the gate overridden. +/// @notice The oracle mixin fired by a tx-end trigger standing in for the anomaly trigger. contract OracleTxEndHarness is AnomalyGatedOracleAssertion { - bool internal immutable anomalous; - - constructor(address target_, address oracle_, bytes memory query, uint256 toleranceBps, bool anomalous_) - AnomalyGatedBaseAssertion(target_, 205) + constructor(address target_, address oracle_, bytes memory query, uint256 toleranceBps) + AnomalyGatedBaseAssertion(target_, Sensitivity.RECOMMENDED) AnomalyGatedOracleAssertion(oracle_, query, toleranceBps) - { - anomalous = anomalous_; - } + {} function triggers() external view override { + // Stands in for the anomaly trigger, which in production fires this selector only when + // the score clears the sensitivity level. registerTxEndTrigger(this.assertAnomalousOracle.selector); } - - function _anomalous() internal view override returns (bool) { - return anomalous; - } } /// @notice The README's `MyGuard` shape: two mixins inherited together, composing as OR since any -/// revert invalidates. Gate overridden and triggers moved to tx-end, as above. +/// revert invalidates. Triggers moved to tx-end, as above. contract MyGuardTxEndHarness is AnomalyGatedOutflowAssertion, AnomalyGatedUpgradeAssertion { - bool internal immutable anomalous; - - constructor(address target_, address token_, bool anomalous_) - AnomalyGatedBaseAssertion(target_, 205) + constructor(address target_, address token_) + AnomalyGatedBaseAssertion(target_, Sensitivity.RECOMMENDED) AnomalyGatedOutflowAssertion(target_, token_, 250) AnomalyGatedUpgradeAssertion(address(0), bytes32(0)) - { - anomalous = anomalous_; - } + {} function triggers() external view override { + // Stands in for the anomaly trigger, which in production fires this selector only when + // the score clears the sensitivity level. registerTxEndTrigger(this.assertAnomalousOutflow.selector); registerTxEndTrigger(this.assertAnomalousUpgrade.selector); } - - function _anomalous() internal view override returns (bool) { - return anomalous; - } } -/// @notice The drain mixin's disposition: block, exclusive set, and gate suppression. +/// @notice The drain mixin: block on a corroborated drain, exclusive-set pass below the fraction. contract TestAnomalyGatedOutflowAssertion is CredibleTest, Test { uint256 internal constant DRAIN_FRAC_BPS = 250; // 2.5% of the reserve uint256 internal constant SUPPLY = 100 ether; @@ -135,12 +107,11 @@ contract TestAnomalyGatedOutflowAssertion is CredibleTest, Test { token.mint(address(vault), SUPPLY); } - function _register(bool anomalous) internal { + function _register() internal { cl.assertion({ adopter: address(vault), createData: abi.encodePacked( - type(OutflowTxEndHarness).creationCode, - abi.encode(address(vault), address(token), DRAIN_FRAC_BPS, anomalous) + type(OutflowTxEndHarness).creationCode, abi.encode(address(vault), address(token), DRAIN_FRAC_BPS) ), fnSelector: AnomalyGatedOutflowAssertion.assertAnomalousOutflow.selector }); @@ -148,36 +119,30 @@ contract TestAnomalyGatedOutflowAssertion is CredibleTest, Test { /// Anomalous and the tx drains 90% (past the 2.5% fraction): block. function test_blocks_anomalous_drain() public { - _register(true); + _register(); vm.expectRevert(); vault.drain(SINK, 90 ether); } /// Anomalous but the drain stays under the fraction (1%): the exclusive set, no revert. function test_passes_drain_below_fraction() public { - _register(true); + _register(); vault.drain(SINK, 1 ether); } - /// Not anomalous: a large drain passes because the gate suppresses it. - function test_gate_suppresses_drain() public { - _register(false); - vault.drain(SINK, 90 ether); - } - /// A zero token address reverts at deploy: the leg would read a zero balance and stay inert. function test_zero_token_reverts_at_deploy() public { vm.expectRevert(AnomalyGatedBaseAssertion.HeuristicMisconfigured.selector); - new OutflowTxEndHarness(address(vault), address(0), DRAIN_FRAC_BPS, true); + new OutflowTxEndHarness(address(vault), address(0), DRAIN_FRAC_BPS); } /// A fraction above 10_000 reverts at deploy: net outflow is capped by the pre-transaction /// balance, so the leg could never corroborate. The 10_000 boundary itself deploys. function test_fraction_above_cap_reverts_at_deploy() public { vm.expectRevert(AnomalyGatedBaseAssertion.HeuristicMisconfigured.selector); - new OutflowTxEndHarness(address(vault), address(token), 10_001, true); + new OutflowTxEndHarness(address(vault), address(token), 10_001); - new OutflowTxEndHarness(address(vault), address(token), 10_000, true); + new OutflowTxEndHarness(address(vault), address(token), 10_000); } } @@ -194,11 +159,11 @@ contract TestAnomalyGatedUpgradeAssertion is CredibleTest, Test { vault = new Vault(token); } - function _register(bytes32 ownerSlot, bool anomalous) internal { + function _register(bytes32 ownerSlot) internal { cl.assertion({ adopter: address(vault), createData: abi.encodePacked( - type(UpgradeTxEndHarness).creationCode, abi.encode(address(vault), address(0), ownerSlot, anomalous) + type(UpgradeTxEndHarness).creationCode, abi.encode(address(vault), address(0), ownerSlot) ), fnSelector: AnomalyGatedUpgradeAssertion.assertAnomalousUpgrade.selector }); @@ -206,36 +171,30 @@ contract TestAnomalyGatedUpgradeAssertion is CredibleTest, Test { /// Anomalous and the tx rewrites the EIP-1967 implementation slot: block. function test_blocks_anomalous_upgrade() public { - _register(bytes32(0), true); + _register(bytes32(0)); vm.expectRevert(); vault.upgradeTo(IMPL); } /// Anomalous and the tx rewrites the EIP-1967 admin slot: block. function test_blocks_anomalous_admin_change() public { - _register(bytes32(0), true); + _register(bytes32(0)); vm.expectRevert(); vault.changeAdmin(SINK); } /// Anomalous and the tx rewrites the named owner slot: block. function test_blocks_anomalous_owner_slot_write() public { - _register(vault.OWNER_SLOT(), true); + _register(vault.OWNER_SLOT()); vm.expectRevert(); vault.setOwner(SINK); } /// Anomalous but no watched slot changes: the exclusive set, no revert. function test_passes_without_slot_change() public { - _register(vault.OWNER_SLOT(), true); + _register(vault.OWNER_SLOT()); vault.poke(); } - - /// Not anomalous: an upgrade passes because the gate suppresses it. - function test_gate_suppresses_upgrade() public { - _register(bytes32(0), false); - vault.upgradeTo(IMPL); - } } /// @notice The accounting mixin's disposition over an ERC4626 share-price move. @@ -252,12 +211,11 @@ contract TestAnomalyGatedAccountingAssertion is CredibleTest, Test { vault4626 = new MockVault4626(1000 ether, 1000 ether); // share price 1.0 } - function _register(bool anomalous) internal { + function _register() internal { cl.assertion({ adopter: address(vault), createData: abi.encodePacked( - type(AccountingTxEndHarness).creationCode, - abi.encode(address(vault), address(vault4626), SHARE_TOL_BPS, anomalous) + type(AccountingTxEndHarness).creationCode, abi.encode(address(vault), address(vault4626), SHARE_TOL_BPS) ), fnSelector: AnomalyGatedAccountingAssertion.assertAnomalousAccounting.selector }); @@ -265,27 +223,21 @@ contract TestAnomalyGatedAccountingAssertion is CredibleTest, Test { /// Anomalous and the share price jumps 10% (past the 2% tolerance): block. function test_blocks_anomalous_share_price_move() public { - _register(true); + _register(); vm.expectRevert(); vault.moveSharePrice(address(vault4626), 1100 ether); } /// Anomalous but the share price stays within tolerance (+1%): the exclusive set, no revert. function test_passes_within_tolerance() public { - _register(true); + _register(); vault.moveSharePrice(address(vault4626), 1010 ether); } - /// Not anomalous: a large share-price move passes because the gate suppresses it. - function test_gate_suppresses_share_price_move() public { - _register(false); - vault.moveSharePrice(address(vault4626), 1100 ether); - } - /// A zero vault address reverts at deploy: the leg would skip the read and stay inert. function test_zero_vault_reverts_at_deploy() public { vm.expectRevert(AnomalyGatedBaseAssertion.HeuristicMisconfigured.selector); - new AccountingTxEndHarness(address(vault), address(0), SHARE_TOL_BPS, true); + new AccountingTxEndHarness(address(vault), address(0), SHARE_TOL_BPS); } } @@ -306,7 +258,7 @@ contract TestAnomalyGatedOracleAssertion is CredibleTest, Test { oracle.setPrice(ASSET, BASE_PRICE); } - function _register(bool anomalous) internal { + function _register() internal { cl.assertion({ adopter: address(vault), createData: abi.encodePacked( @@ -315,8 +267,7 @@ contract TestAnomalyGatedOracleAssertion is CredibleTest, Test { address(vault), address(oracle), abi.encodeWithSignature("getAssetPrice(address)", ASSET), - ORACLE_TOL_BPS, - anomalous + ORACLE_TOL_BPS ) ), fnSelector: AnomalyGatedOracleAssertion.assertAnomalousOracle.selector @@ -325,28 +276,22 @@ contract TestAnomalyGatedOracleAssertion is CredibleTest, Test { /// Anomalous and the oracle jumps 10% (past the 2% tolerance): block. function test_blocks_anomalous_oracle_move() public { - _register(true); + _register(); vm.expectRevert(); vault.moveOracle(address(oracle), ASSET, 1100e8); } /// Anomalous but the oracle stays within tolerance (+1%): the exclusive set, no revert. function test_passes_within_tolerance() public { - _register(true); + _register(); vault.moveOracle(address(oracle), ASSET, 1010e8); } - /// Not anomalous: a large oracle move passes because the gate suppresses it. - function test_gate_suppresses_oracle_move() public { - _register(false); - vault.moveOracle(address(oracle), ASSET, 1100e8); - } - /// An empty oracle query reverts at deploy: the read would error on every anomalous tx and /// falsely invalidate. function test_empty_query_reverts_at_deploy() public { vm.expectRevert(AnomalyGatedBaseAssertion.HeuristicMisconfigured.selector); - new OracleTxEndHarness(address(vault), address(oracle), "", ORACLE_TOL_BPS, true); + new OracleTxEndHarness(address(vault), address(oracle), "", ORACLE_TOL_BPS); } } @@ -365,11 +310,11 @@ contract TestMyGuardComposition is CredibleTest, Test { token.mint(address(vault), SUPPLY); } - function _register(bytes4 fnSelector, bool anomalous) internal { + function _register(bytes4 fnSelector) internal { cl.assertion({ adopter: address(vault), createData: abi.encodePacked( - type(MyGuardTxEndHarness).creationCode, abi.encode(address(vault), address(token), anomalous) + type(MyGuardTxEndHarness).creationCode, abi.encode(address(vault), address(token)) ), fnSelector: fnSelector }); @@ -377,23 +322,15 @@ contract TestMyGuardComposition is CredibleTest, Test { /// Anomalous and the tx drains: the outflow leg blocks. function test_drain_leg_blocks() public { - _register(AnomalyGatedOutflowAssertion.assertAnomalousOutflow.selector, true); + _register(AnomalyGatedOutflowAssertion.assertAnomalousOutflow.selector); vm.expectRevert(); vault.drain(SINK, 90 ether); } /// Anomalous and the tx upgrades: the upgrade leg blocks. function test_upgrade_leg_blocks() public { - _register(AnomalyGatedUpgradeAssertion.assertAnomalousUpgrade.selector, true); + _register(AnomalyGatedUpgradeAssertion.assertAnomalousUpgrade.selector); vm.expectRevert(); vault.upgradeTo(IMPL); } - - /// Not anomalous: a tx that both drains and upgrades passes the outflow leg because the gate - /// suppresses it (`cl.assertion` registers one assertion per call; the upgrade leg's - /// suppression is covered in `TestAnomalyGatedUpgradeAssertion`). - function test_gate_suppresses_drain_leg() public { - _register(AnomalyGatedOutflowAssertion.assertAnomalousOutflow.selector, false); - vault.drainAndUpgrade(SINK, 90 ether, IMPL); - } } diff --git a/test/protection/anomaly/AnomalySensitivityGate.t.sol b/test/protection/anomaly/AnomalySensitivityGate.t.sol new file mode 100644 index 0000000..79e9fea --- /dev/null +++ b/test/protection/anomaly/AnomalySensitivityGate.t.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {Sensitivity} from "credible-std/Sensitivity.sol"; +import {AnomalyCompositeAssertion} from "credible-std/protection/anomaly/AnomalyCompositeAssertion.sol"; +import {AnomalyGatedBaseAssertion} from "credible-std/protection/anomaly/AnomalyGatedBaseAssertion.sol"; +import {CompositeTxEndHarness} from "./AnomalyCompositeAssertion.t.sol"; +import {MockERC20, Vault} from "./AnomalyTestMocks.sol"; + +// The `Sensitivity` ladder and the constructor guard standing on it. The level *comparison* is not +// here: the trigger performs it, against the target's own model, so it lives in the executor and is +// tested there. What credible-std owns is the ladder's shape and the refusal to deploy an assertion +// naming a level that is not on it. + +contract TestAnomalySensitivity is Test { + MockERC20 internal token; + Vault internal vault; + + function setUp() public { + token = new MockERC20(); + vault = new Vault(token); + } + + function _config(uint8 level) internal view returns (AnomalyCompositeAssertion.Config memory c) { + c.target = address(vault); + c.sensitivity = level; + c.useDrain = true; + c.outflowTarget = address(vault); + c.outflowToken = address(token); + c.outflowFracBps = 250; + } + + /// The ladder's bounds and its recommended rung, pinned so a change to the product decision has + /// to be made in `Sensitivity` rather than drifting in. + function test_ladder_bounds_and_recommended_level() public pure { + assertEq(Sensitivity.MIN, 1); + assertEq(Sensitivity.MAX, 10); + assertEq(Sensitivity.RECOMMENDED, Sensitivity.LEVEL_7); + assertEq(Sensitivity.LEVEL_1, 1); + assertEq(Sensitivity.LEVEL_10, 10); + } + + /// `0` is the "cleared nothing" sentinel an unscored target reads back, not a level. Treating + /// it as one would gate true on every contract the model never scored. + function test_zero_is_not_a_level() public pure { + assertFalse(Sensitivity.isValid(0)); + assertFalse(Sensitivity.isValid(11)); + assertFalse(Sensitivity.isValid(type(uint8).max)); + for (uint8 level = Sensitivity.MIN; level <= Sensitivity.MAX; level++) { + assertTrue(Sensitivity.isValid(level)); + } + } + + /// Every rung deploys, and nothing off the ladder does. An assertion naming a level the trigger + /// cannot register would ship protecting nothing. + function test_every_rung_deploys_and_nothing_else_does() public { + for (uint8 level = Sensitivity.MIN; level <= Sensitivity.MAX; level++) { + new CompositeTxEndHarness(_config(level)); + } + + vm.expectRevert(AnomalyGatedBaseAssertion.SensitivityOutOfRange.selector); + new CompositeTxEndHarness(_config(0)); + + vm.expectRevert(AnomalyGatedBaseAssertion.SensitivityOutOfRange.selector); + new CompositeTxEndHarness(_config(Sensitivity.MAX + 1)); + } +} diff --git a/test/protection/anomaly/AnomalyUngatedAssertion.t.sol b/test/protection/anomaly/AnomalyUngatedAssertion.t.sol new file mode 100644 index 0000000..6a2ce89 --- /dev/null +++ b/test/protection/anomaly/AnomalyUngatedAssertion.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {Sensitivity} from "credible-std/Sensitivity.sol"; +import {AnomalyGatedBaseAssertion} from "credible-std/protection/anomaly/AnomalyGatedBaseAssertion.sol"; +import {AnomalyUngatedAssertion} from "credible-std/protection/anomaly/AnomalyUngatedAssertion.sol"; +import {MockERC20, Vault} from "./AnomalyTestMocks.sol"; + +// The firing decision belongs to the executor, which resolves the level against the target's own +// model. What credible-std owns is the ladder guard and the unconditional revert. + +contract UngatedHarness is AnomalyUngatedAssertion { + constructor(address target_, uint8 sensitivity_) AnomalyGatedBaseAssertion(target_, sensitivity_) {} + + function triggers() external view override { + _registerUngatedTrigger(); + } +} + +contract TestAnomalyUngatedAssertion is Test { + /// The precompile's address, from `Credible`. Nothing is deployed there under `forge`, so the + /// context read has to be mocked or it reverts on decoding empty return data. + address internal constant PH = address(uint160(uint256(keccak256("Kim Jong Un Sucks")))); + + MockERC20 internal token; + Vault internal vault; + + function setUp() public { + token = new MockERC20(); + vault = new Vault(token); + } + + /// Answer `anomalyContext(target)` with `firesAt`, so the body reaches its own revert instead of + /// failing on the absent precompile. + function _mockFiresAt(address target, uint8 firesAt) internal { + vm.mockCall( + PH, + abi.encodeWithSelector(PhEvm.anomalyContext.selector, target), + abi.encode(PhEvm.AnomalyContext({firesAt: firesAt})) + ); + } + + /// One invalidation per firing, with nothing left to decide in the body. + /// + /// Asserted on the encoded custom error rather than any revert: the body reads the anomaly + /// context before it reverts, so a bare `expectRevert` also passes when that read is what + /// failed, which proves nothing about this assertion. + function test_body_reverts_with_the_anomalous_transaction_error() public { + UngatedHarness bare = new UngatedHarness(address(vault), Sensitivity.RECOMMENDED); + _mockFiresAt(address(vault), Sensitivity.RECOMMENDED); + + vm.expectRevert( + abi.encodeWithSelector(AnomalyUngatedAssertion.AnomalousTransaction.selector, Sensitivity.RECOMMENDED) + ); + bare.assertNotAnomalous(); + } + + /// The error carries the context's own `firesAt`, so an operator reading an invalidation learns + /// the rung the transaction cleared rather than the rung the assertion was registered at. + function test_error_carries_the_contexts_fires_at() public { + UngatedHarness bare = new UngatedHarness(address(vault), Sensitivity.RECOMMENDED); + + for (uint8 firesAt = Sensitivity.MIN; firesAt <= Sensitivity.MAX; firesAt++) { + _mockFiresAt(address(vault), firesAt); + vm.expectRevert(abi.encodeWithSelector(AnomalyUngatedAssertion.AnomalousTransaction.selector, firesAt)); + bare.assertNotAnomalous(); + } + } + + /// The body is ungated, so it reverts on a context that cleared nothing too. The trigger is what + /// decides whether it runs at all, and the executor never dispatches on `firesAt == 0`. + function test_body_reverts_even_when_the_context_cleared_no_level() public { + UngatedHarness bare = new UngatedHarness(address(vault), Sensitivity.RECOMMENDED); + _mockFiresAt(address(vault), 0); + + vm.expectRevert(abi.encodeWithSelector(AnomalyUngatedAssertion.AnomalousTransaction.selector, uint8(0))); + bare.assertNotAnomalous(); + } + + /// A level off the ladder would be permanently inert, so it is refused at deploy. + function test_rejects_a_level_off_the_ladder() public { + vm.expectRevert(AnomalyGatedBaseAssertion.SensitivityOutOfRange.selector); + new UngatedHarness(address(vault), Sensitivity.MAX + 1); + + vm.expectRevert(AnomalyGatedBaseAssertion.SensitivityOutOfRange.selector); + new UngatedHarness(address(vault), 0); + + vm.expectRevert(AnomalyGatedBaseAssertion.ZeroTarget.selector); + new UngatedHarness(address(0), Sensitivity.RECOMMENDED); + } + + /// Every rung deploys. + function test_every_rung_deploys() public { + for (uint8 level = Sensitivity.MIN; level <= Sensitivity.MAX; level++) { + UngatedHarness bare = new UngatedHarness(address(vault), level); + assertTrue(address(bare) != address(0)); + } + } +}