AnomalyGatedBaseAssertion
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 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 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
| Name | Type | Description |
|---|---|---|
_target | address | The watched contract the model scores. Must be non-zero. |
_sensitivity | uint8 | The 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();