Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
30 changes: 19 additions & 11 deletions src/Assertion.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

// ---------------------------------------------------------------
Expand Down
17 changes: 17 additions & 0 deletions src/CredibleTestWithBacktesting.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -52,6 +67,7 @@ abstract contract CredibleTestWithBacktesting is CredibleTest, Test {
bytes4 assertionSelector,
string memory rpcUrl
) public returns (BacktestingTypes.BacktestingResults memory results) {
_requireFfi();
return _executeBacktestForSingleTransaction(
txHash, targetContract, assertionCreationCode, assertionSelector, rpcUrl
);
Expand All @@ -62,6 +78,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
Expand Down
15 changes: 12 additions & 3 deletions src/PhEvm.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions src/Sensitivity.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
22 changes: 12 additions & 10 deletions src/TriggerRecorder.sol
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,17 @@ 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 score back
/// via `ph.anomalyContext(target)`.
/// @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;
}
8 changes: 2 additions & 6 deletions src/protection/anomaly/AnomalyCompositeAssertion.sol
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,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;
Expand Down Expand Up @@ -84,7 +84,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();
}
Expand Down Expand Up @@ -134,10 +134,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 {
Comment thread
0xAlcibiades marked this conversation as resolved.
if (!_anomalous()) {
return; // pass: not anomalous
}

bool anyEnabled;
bool corroborated = requireAll; // the operator's identity: AND folds from true, OR from false

Expand Down
3 changes: 0 additions & 3 deletions src/protection/anomaly/AnomalyGatedAccountingAssertion.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading
Loading