Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AnomalyGatedBaseAssertion

Git Source

Inherits: Assertion

Title: AnomalyGatedBaseAssertion

Author: Phylax Systems

Base contract for anomaly-gated assertions.

The anomaly model is a recall-first trigger: it scores each transaction touching a watched contract for the probability that it is anomalous. It over-fires by design, so it is not a 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 = the score cleared this assertion's sensitivity level and the enabled damage set H, a transaction’s disposition is:

H confirmsH silent
ablock (revert)alert (the exclusive set)
not apass (the benign whale)pass (normal traffic)
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.
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().
Example, an anomaly-gated drain check with one heuristic:
contract MyDrainGuard is AnomalyGatedOutflowAssertion {
constructor(address pool, address reserveToken)
AnomalyGatedBaseAssertion(pool, Sensitivity.RECOMMENDED)
AnomalyGatedOutflowAssertion(pool, reserveToken, 250) // drain >= 2.5% of the reserve
{}
function triggers() external view override {
_registerOutflowTrigger();
}
}

Constants

EIP1967_IMPLEMENTATION

EIP-1967 implementation slot, keccak256("eip1967.proxy.implementation") - 1.

bytes32 internal constant EIP1967_IMPLEMENTATION =
    0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

EIP1967_ADMIN

EIP-1967 admin slot, keccak256("eip1967.proxy.admin") - 1.

bytes32 internal constant EIP1967_ADMIN = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

target

The watched contract whose anomaly score gates this assertion (the adopter).

address internal immutable target

sensitivity

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

Functions

constructor

constructor(address _target, uint8 _sensitivity) ;

Parameters

NameTypeDescription
_targetaddressThe watched contract the model scores. Must be non-zero.
_sensitivityuint8The level from Sensitivity, 1..=10.

_registerAnomalyTrigger

Register the anomaly trigger for selector at this assertion’s sensitivity level.

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;

_drains

Whether token left outflowTarget this transaction by at least fracBps of its pre-transaction balance. outflowTarget may differ from target: the anomaly focal is often a pool while the drained reserve sits in a separate aToken.

Net outflow from the reduced ERC-20 balance deltas over the post-transaction fork, scaled by the balance read at the pre-transaction fork. A zero pre-balance corroborates nothing. The ratio uses ph.mulDivDown, whose 512-bit intermediates keep a huge-balance token from overflowing net * 10_000 and blocking without corroboration.

function _drains(address outflowTarget, address token, uint256 fracBps) internal view returns (bool);

_upgraded

Whether an EIP-1967 implementation or admin slot, or the supplied ownerSlot when non-zero, changed on watched across the transaction. A zero watched reads target: the rewritten proxy is usually the anomaly focal, but a custody contract behind its own proxy (an aToken) can be named instead.

bytes32(0) disables the owner-slot leg, so an owner stored at slot 0 cannot be watched through ownerSlot; the EIP-1967 slots are always watched.

function _upgraded(address watched, bytes32 ownerSlot) internal view returns (bool);

_accountingBroke

Whether the ERC-4626 share price of vault moved beyond toleranceBps across the transaction. An empty vault (zero supply) is skipped by the precompile.

function _accountingBroke(address vault, uint256 toleranceBps) internal view returns (bool);

_oracleDeviated

Whether the oracle answer returned by query on oracleTarget moved beyond toleranceBps across the transaction. Non-view: the oracle read executes.

query is the full ABI-encoded call: a zero-argument reader is abi.encodeWithSignature("latestAnswer()"); an asset-priced feed is abi.encodeWithSignature("getAssetPrice(address)", asset).

function _oracleDeviated(address oracleTarget, bytes memory query, uint256 toleranceBps) internal returns (bool);

_slotChanged

Whether slot on account differs between the pre- and post-transaction forks.

function _slotChanged(address account, bytes32 slot) internal view returns (bool);

_balanceAt

token.balanceOf(account) read at fork; a failed probe reads 0. The length check keeps a codeless token (whose staticcall succeeds with empty returndata) on the reads-0 path instead of reverting in abi.decode.

function _balanceAt(address token, address account, PhEvm.ForkId memory fork) internal view returns (uint256);

Errors

HeuristicMisconfigured

Constructor guard: an enabled heuristic is missing a parameter it needs, whether a zero custody, token, vault, or oracle address, an oracle query too short to hold a selector, or a drain fraction outside [1, 10_000]. A zero fraction corroborates on any transaction while custody holds a balance; above 10_000 the leg can never corroborate because net outflow is capped by the pre-transaction balance. The constructor rejects these instead of shipping a silently inert leg or a per-transaction false block.

error HeuristicMisconfigured();

ZeroTarget

Constructor guard: the watched target is the zero address. anomalyContext can never score it, so the gate would never open and the assertion would be permanently inert.

error ZeroTarget();

SensitivityOutOfRange

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();