From cf6e70ffed78419c7b4a51267836ff336b20bfbc Mon Sep 17 00:00:00 2001 From: Quintus Kilbourn Date: Mon, 17 Aug 2026 21:10:50 +0000 Subject: [PATCH] Propose PUR v2: block-native freshness, raw slots, two write paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PrioUpdateRegistryV2 (a superset of v1, deployed separately; v1 stays live) plus demo propAMMs and a test suite. - Freshness is a block number, not a seconds timestamp: chain-native via an overridable _blockHeight() (block.number on L1/BSC/Base; ArbSys.arbBlockNumber() on Arbitrum). The block is calldata-only, range-checked on write, and NOT stored — read-side staleness is the consumer's own policy (matching how signed-oracle consumers gate). - Raw storage: full 32-byte slots, no reserved header, in a domain-separated, MAX_SLOTS-bounded lane region. Reads are self-scoped (on-chain read-gating). - Two write paths: lean verbatim updateState (authorized updater), and a permissionless updateStateWithDecoder that STATICCALLs a target-registered view decoder to verify+unpack signed payloads and return the slots to store — preserving "a tx to PUR writes only PUR storage, known from `to` alone" for arbitrary decoder code. Demos: SimplePricePropAMM (lean) and OracleReportPropAMM + SignedReportDecoder (custom). 20 tests incl. adversarial coverage for storage-collision, out-of-range reads, and permissionless replay. Co-Authored-By: Claude Opus 4.8 --- src/PrioUpdateRegistryV2.sol | 364 ++++++++++++++++++++++++++++++++ src/demo/DemoPropAMMs.sol | 224 ++++++++++++++++++++ test/PrioUpdateRegistryV2.t.sol | 327 ++++++++++++++++++++++++++++ 3 files changed, 915 insertions(+) create mode 100644 src/PrioUpdateRegistryV2.sol create mode 100644 src/demo/DemoPropAMMs.sol create mode 100644 test/PrioUpdateRegistryV2.t.sol diff --git a/src/PrioUpdateRegistryV2.sol b/src/PrioUpdateRegistryV2.sol new file mode 100644 index 0000000..191434a --- /dev/null +++ b/src/PrioUpdateRegistryV2.sol @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +/// @title IPrioUpdateDecoder — the custom-path verify/unpack hook +/// @notice A per-lane hook that verifies and unpacks an arbitrary payload into the words the registry +/// stores. It is reached ONLY via `STATICCALL` ({PrioUpdateRegistryV2.updateStateWithDecoder}), +/// so arbitrary decoder code cannot `SSTORE` / `LOG` / move value / `CREATE` / `SELFDESTRUCT` +/// — the registry performs the only write, into its own storage. This is what preserves +/// write-scoping for a programmable decoder. +interface IPrioUpdateDecoder { + /// @notice Verify `aux` and return the words to store for `(target, laneIndex)`. Revert to reject. + /// @dev MUST be `view` (invoked via `STATICCALL`); a non-view body or any nested write reverts. + /// Verification must be `view` and fee-less (a `STATICCALL` cannot pay), e.g. a DON + /// `verifyView` or `ecrecover` + trusted-signer check. Freshness: bind the payload to + /// `freshnessNow` (the current tick — an includer cannot shift it to another block/tick). + /// On a block-number clock that pins the BLOCK but not wall-clock time, so a decoder that + /// must bound real-time staleness against an includer who DELAYS inclusion should ALSO + /// check `block.timestamp <= deadline` with a maker-signed deadline (see the demo). + /// @param target the lane owner (bind the payload to it — one decoder may serve many targets). + /// @param laneIndex the lane (bind to it — one decoder may serve many lanes). + /// @param freshnessNow the registry's current {PrioUpdateRegistryV2.freshnessNow} — the chain + /// clock, NOT the caller's calldata field. Binding `signed == freshnessNow` + /// pins the report to the tick it lands in (closes replay / early-install). + /// @param aux the opaque payload to verify + unpack. + /// @return slots the words to store, in lane order; length must be in [1, MAX_SLOTS]. + function validateAndUnpack(address target, uint256 laneIndex, uint256 freshnessNow, bytes calldata aux) + external + view + returns (uint256[] memory slots); +} + +/// @title Priority Update Registry (v2) +/// @author Flashbots +/// @notice A shared singleton for *priority updates*: small per-block state an off-chain updater +/// refreshes top-of-block so later transactions in the same block read fresh values (e.g. an +/// actively-managed on-chain curve re-quoting once per block before it is traded against). +/// +/// WRITE-SCOPING (the load-bearing property): a tx whose `to` is this registry can only ever +/// write THIS registry's storage, knowable from the destination address ALONE without +/// simulation — which is what lets builders place these updates top-of-block. The only +/// external interaction is the read-only `STATICCALL` to a decoder; nothing here writes +/// foreign storage, moves value, `CREATE`s, or `SELFDESTRUCT`s. +/// +/// Changes from v1 (full rationale in the PR): +/// 1. Freshness is validated against an OVERRIDABLE chain-native clock {freshnessNow} +/// (default `block.timestamp` — the L1 answer) rather than a fixed clock. The right clock +/// is chain-dependent; override it per deployment (see {freshnessNow} for the threat +/// model and the per-chain criterion). `uint256`, calldata-only, never stored. +/// 2. The freshness field is CALLDATA-ONLY and NOT stored: range-checked on write (builder- +/// legible ordering + a staleness band) then discarded. Read-side staleness is the +/// consumer's own policy, held in its data — matching how signed-oracle consumers gate. +/// 3. Storage is RAW: just the caller's words, full 32-byte slots, no header, in a +/// domain-separated {_laneBase} region bounded to {MAX_SLOTS} (v1 stole 5 bytes of slot0). +/// 4. Two write paths: {updateState} (lean, verbatim, no external call — the ~99% case) and +/// {updateStateWithDecoder} (STATICCALLs a registered decoder; only this path pays it). +/// 5. {getSlot} — single-word read alongside whole-lane {getState}. +contract PrioUpdateRegistryV2 { + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + /// @notice `target` authorized `updater` for its low-gas ({updateState}) path. + event UpdaterAdded(address indexed target, address indexed updater); + /// @notice `target` revoked `updater` from its low-gas path. + event UpdaterRemoved(address indexed target, address indexed updater); + /// @notice `target` bound `decoder` to `laneIndex` (immutable once set — see {setDecoder}). + event DecoderSet(address indexed target, uint256 indexed laneIndex, address indexed decoder); + + // Writes emit no event (v1 behaviour, keeps the hot path cheap); an optional inclusion-signal + // event for custom lanes is left to the proposal discussion. + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + /// @notice `msg.sender` is not an authorized updater of `target`. + error NotAuthorized(); + /// @notice A write supplied zero slots. + error EmptySlots(); + /// @notice A write / decoder return exceeded {MAX_SLOTS} words. + error TooManySlots(); + /// @notice A read requested a slot at or beyond {MAX_SLOTS} (out of any lane's range). + error SlotIndexOutOfRange(); + /// @notice The decoder returned no slots. + error DecoderReturnedNoSlots(); + /// @notice A decoder is already bound to this lane (immutable-once-set). + error DecoderAlreadySet(); + /// @notice No decoder is bound to this lane; the custom path requires one. + error DecoderNotSet(); + /// @notice The low-gas path was used on a decoder-bound lane — use the custom path. + error DecoderBoundLane(); + /// @notice A zero decoder address was supplied to {setDecoder}. + error ZeroDecoder(); + /// @notice The decoder has no code — binding it would brick the lane (staticcall to an EOA returns + /// empty data, so every update would revert on decode). + error DecoderHasNoCode(); + /// @notice `freshness` is older than {freshnessNow} by more than {MAX_AGE} (in the clock's units). + error FreshnessTooOld(uint256 freshness, uint256 nowRef); + /// @notice `freshness` is further ahead of {freshnessNow} than {MAX_LEAD} (in the clock's units). + error FreshnessTooFarAhead(uint256 freshness, uint256 nowRef); + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Max words a lane may hold / a read may request. Bounds each lane to `[base, base+255)`; + /// with the domain-separated {_laneBase} this is what keeps reads/writes inside a lane's + /// own region — an unbounded offset would otherwise address any storage slot. 255 = v1 cap. + uint256 internal constant MAX_SLOTS = 255; + + /// @dev Domain tag in every lane base. Mapping value slots are `keccak256(key ‖ slot)` (2 words); + /// lane bases are `keccak256(tag ‖ target ‖ laneIndex)` (3 words), so a lane base cannot equal + /// a mapping slot (preimage lengths differ), and landing within {MAX_SLOTS} of one needs a + /// keccak grind. Without the tag, `keccak256(target ‖ laneIndex)` collides with + /// `isUpdater[victim][attacker]` at `laneIndex = keccak256(abi.encode(victim, 0))`. + bytes32 private constant LANE_NAMESPACE = keccak256("PrioUpdateRegistryV2.lane.v1"); + + /*////////////////////////////////////////////////////////////// + FRESHNESS WINDOW + //////////////////////////////////////////////////////////////*/ + + /// @notice Max the `freshness` field may lag {freshnessNow} and still be accepted, in the clock's + /// units (0 = must be the current tick or newer). + /// @dev Per-deployment. The WRITE-side band on the calldata field: bounds how far a write's + /// claimed freshness can trail reality (builder-legibility + a staleness cap). It is NOT + /// the custom path's replay/deadline defence — that is the decoder. Units follow the + /// chosen {freshnessNow} clock (seconds by default; blocks if overridden); pick per chain. + // slither-disable-next-line naming-convention + uint256 public immutable MAX_AGE; + + /// @notice Max the `freshness` field may lead {freshnessNow} (0 = may not be ahead). + /// @dev A lead gives a pusher slack (submit for tick N while N-1 lands). A >0 lead lets the + /// (trusted) updater pre-stamp a future tick, so strict "landed this tick" needs lead 0. + /// CANONICAL deployment = lead 0; a non-zero lead is a per-lane opt-in. + // slither-disable-next-line naming-convention + uint256 public immutable MAX_LEAD; + + /*////////////////////////////////////////////////////////////// + STORAGE + //////////////////////////////////////////////////////////////*/ + + /// @notice target => updater => allowed. Low-gas path only; each target manages its own set. The + /// custom path is authorized by its decoder instead. + mapping(address target => mapping(address updater => bool)) public isUpdater; + + /// @notice target => laneIndex => decoder. A non-zero entry marks a CUSTOM lane (low-gas writes are + /// refused; use {updateStateWithDecoder}). Immutable once set (see {setDecoder}). + mapping(address target => mapping(uint256 laneIndex => address decoder)) public laneDecoder; + + // Lane data is not in a mapping: each lane's words live at `_laneBase(target, laneIndex) + i`, a + // keccak region provably disjoint from the mappings above ({LANE_NAMESPACE}). Raw — no header. + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + /// @param maxAge {MAX_AGE} (chain-specific, in {freshnessNow} units — seconds by default). + /// @param maxLead {MAX_LEAD} (chain-specific; canonical 0). + constructor(uint256 maxAge, uint256 maxLead) { + MAX_AGE = maxAge; + MAX_LEAD = maxLead; + } + + /*////////////////////////////////////////////////////////////// + AUTHORIZATION / REGISTRATION + //////////////////////////////////////////////////////////////*/ + + /// @notice Authorize `updater` for the caller's low-gas writes. Caller is the target. Idempotent. + function addUpdater(address updater) external { + isUpdater[msg.sender][updater] = true; + emit UpdaterAdded(msg.sender, updater); + } + + /// @notice Revoke `updater` from the caller's low-gas writes. Caller is the target. Idempotent. + function removeUpdater(address updater) external { + isUpdater[msg.sender][updater] = false; + emit UpdaterRemoved(msg.sender, updater); + } + + /// @notice Bind `decoder` to the caller's `laneIndex`, making it a custom lane. Caller is the target. + /// @dev IMMUTABLE-ONCE-SET so readers can rely on a vetted lane's verification (deploy a new lane + /// to change it). Requires code (an EOA would brick the lane); note this cannot stop a + /// PROXY decoder from changing behaviour, so a consumer relying on immutability should vet + /// that the bound decoder's logic is itself immutable. + function setDecoder(uint256 laneIndex, address decoder) external { + if (decoder == address(0)) revert ZeroDecoder(); + if (decoder.code.length == 0) revert DecoderHasNoCode(); + if (laneDecoder[msg.sender][laneIndex] != address(0)) revert DecoderAlreadySet(); + laneDecoder[msg.sender][laneIndex] = decoder; + emit DecoderSet(msg.sender, laneIndex, decoder); + } + + /*////////////////////////////////////////////////////////////// + WRITE PATH 1 — LOW GAS + //////////////////////////////////////////////////////////////*/ + + /// @notice LOW-GAS write: store `slots` verbatim for `(target, laneIndex)`. No external call. + /// @dev Caller must be an authorized updater of `target`, and the lane must not be decoder-bound + /// (else a verbatim write would bypass that lane's verification — hence the second SLOAD). + /// `freshness` is range-checked against {freshnessNow} then discarded (calldata-only). + /// @param slots the words to store, slot 0..n-1 (1 <= n <= MAX_SLOTS). + function updateState(address target, uint256 laneIndex, uint256 freshness, uint256[] calldata slots) external { + if (!isUpdater[target][msg.sender]) revert NotAuthorized(); + if (laneDecoder[target][laneIndex] != address(0)) revert DecoderBoundLane(); + _checkFreshness(freshness); + _writeSlotsCalldata(target, laneIndex, slots); + } + + /*////////////////////////////////////////////////////////////// + WRITE PATH 2 — CUSTOM DECODER + //////////////////////////////////////////////////////////////*/ + + /// @notice CUSTOM write: `STATICCALL` the lane's decoder to verify + unpack `aux`, store its slots. + /// @dev PERMISSIONLESS — the decoder is the authorization (it verifies `aux` and reverts on + /// anything it rejects), so anyone may relay a validly-signed report. The decoder is handed + /// {freshnessNow} so it can pin the report to the tick it lands in — the `freshness` window + /// alone does not stop replay of an old-but-in-window report under permissionless relay. + /// Runs under `STATICCALL`, so write-scoping holds; a buggy decoder's blast radius is the + /// target's own lane and it cannot be repointed (immutable binding). + /// @param freshness caller-supplied freshness; range-checked, not stored (builder field). + /// @param aux the payload the decoder verifies + unpacks. + function updateStateWithDecoder(address target, uint256 laneIndex, uint256 freshness, bytes calldata aux) external { + address decoder = laneDecoder[target][laneIndex]; + if (decoder == address(0)) revert DecoderNotSet(); + uint256 nowRef = _checkFreshness(freshness); + + // STATICCALL (solc lowers a `view` external call to it): the decoder cannot write / log / move + // value / create / selfdestruct. It gets the true clock value (not the calldata field) to pin + // freshness. The registry does the only write, below. + uint256[] memory slots = IPrioUpdateDecoder(decoder).validateAndUnpack(target, laneIndex, nowRef, aux); + + _writeSlotsMemory(target, laneIndex, slots); + } + + /*////////////////////////////////////////////////////////////// + READS + //////////////////////////////////////////////////////////////*/ + + /// @notice Read word `slotIndex` of the CALLER's lane `laneIndex` (0 if never written). + /// @dev SELF-SCOPED to `msg.sender`. The EVM has no cross-contract `SLOAD`, so this plus the + /// {MAX_SLOTS} bound (keeping `base + slotIndex` in the caller's own region) means a lane is + /// only readable on-chain by its owner. A gated feed fronts its lane with its own contract + /// (the owner) and exposes what it chooses; a self-consuming maker reads from its swap path. + /// (Off-chain callers read storage directly — this scoping is an on-chain property.) + function getSlot(uint256 laneIndex, uint256 slotIndex) external view returns (uint256 value) { + if (slotIndex >= MAX_SLOTS) revert SlotIndexOutOfRange(); + uint256 slot = _laneBase(msg.sender, laneIndex) + slotIndex; + assembly { + value := sload(slot) + } + } + + /// @notice Read the first `count` words of the CALLER's lane `laneIndex`. + /// @dev SELF-SCOPED (see {getSlot}); `count` is bounded by {MAX_SLOTS}. RAW-STORAGE CAVEAT: lane + /// length is not stored, so the reader supplies `count` (a fixed-shape feed passes its known + /// length). A shorter write does not clear words left by a longer earlier one, so a + /// variable-length feed must encode its own length; this is the one bookkeeping cost of + /// header-free slots. + function getState(uint256 laneIndex, uint256 count) external view returns (uint256[] memory slots) { + if (count > MAX_SLOTS) revert SlotIndexOutOfRange(); + uint256 base = _laneBase(msg.sender, laneIndex); + slots = new uint256[](count); + for (uint256 i; i < count; ++i) { + uint256 slot = base + i; + uint256 v; + assembly { + v := sload(slot) + } + slots[i] = v; + } + } + + /*////////////////////////////////////////////////////////////// + FRESHNESS CLOCK + //////////////////////////////////////////////////////////////*/ + + /// @notice The chain-native clock the `freshness` field is validated against and that a decoder + /// binds to. Defaults to `block.timestamp` (the L1 answer); OVERRIDE PER DEPLOYMENT to the + /// local clock that fits the chain. Consumers gate their read-side staleness against this + /// SAME value. + /// @dev Threat model: a malicious builder / sequencer chooses which update to include and can + /// influence timing (delay production, reorder). Its move is to include the OLDEST update + /// that still passes {_checkFreshness}, so swaps execute against a stale price. The clock + /// must therefore be the local value that includer can inflate LEAST, judged against how + /// fast the market moves: + /// - a clock's per-tick gameability ≈ the block time: an includer that delays a block by + /// one slot adds that much hidden wall-clock staleness while the block COUNT is + /// unchanged. On ~12s L1 that is up to a full slot; on a sub-second chain it is a + /// fraction of a second — relatively small, though not negligible for latency-sensitive + /// flow. + /// - `block.timestamp` grows with real delay (so it caps wall-clock staleness) but its + /// resolution is seconds — too coarse to distinguish sub-second blocks. + /// => use the FINEST local clock whose per-tick gameability is small relative to market + /// speed. This registry DEFAULTS to `block.timestamp` — right on ~12s L1, where + /// block-number age is inflatable a full slot per missed slot. Override to `block.number` + /// on sub-second chains (e.g. BSC, where a seconds timestamp cannot even separate blocks), + /// and to `ArbSys(0x64).arbBlockNumber()` on Arbitrum/Orbit (where `block.number` is the + /// coarse ~L1 number; ~2.6k-gas precompile). + /// MUST read only LOCAL / precompile values (`block.number`, `block.timestamp`, a chain + /// precompile) — never external contract state, or a builder can no longer tell an update + /// is valid from the `to` address alone without simulation (the write-scoping premise). + /// Residual limit: where the includer also controls the clock (a centralized-sequencer L2 + /// sets both block production and `block.timestamp` within L1-anchoring bounds), no + /// on-chain clock fully constrains it; the bound shrinks to the clock-slack consensus + /// permits. A stable cross-chain address (CREATE3) despite the override is a deployment + /// concern, out of scope here. + function freshnessNow() public view virtual returns (uint256) { + return block.timestamp; + } + + /*////////////////////////////////////////////////////////////// + INTERNALS + //////////////////////////////////////////////////////////////*/ + + /// @dev Require `freshness` in [now - MAX_AGE, now + MAX_LEAD] where now = {freshnessNow}; return + /// now (computed once). Subtraction only (never `now + LEAD`) so a large band cannot overflow. + /// VIRTUAL: a deployment may override for non-window semantics (e.g. a combined block-number + + /// `block.timestamp`-deadline check). Must stay `view` and read only local/precompile values + /// (see {freshnessNow}). + function _checkFreshness(uint256 freshness) internal view virtual returns (uint256 nowRef) { + nowRef = freshnessNow(); + if (freshness < nowRef) { + if (nowRef - freshness > MAX_AGE) revert FreshnessTooOld(freshness, nowRef); + } else if (freshness > nowRef) { + if (freshness - nowRef > MAX_LEAD) revert FreshnessTooFarAhead(freshness, nowRef); + } + } + + /// @dev Base storage slot of `(target, laneIndex)`, domain-separated so its 3-word preimage cannot + /// alias a 2-word mapping slot ({LANE_NAMESPACE}). Words are at base + i, 0 <= i < MAX_SLOTS. + function _laneBase(address target, uint256 laneIndex) internal pure returns (uint256) { + return uint256(keccak256(abi.encode(LANE_NAMESPACE, target, laneIndex))); + } + + /// @dev Store calldata `slots` verbatim at the lane base. Reverts on empty / over-length. + function _writeSlotsCalldata(address target, uint256 laneIndex, uint256[] calldata slots) internal { + uint256 n = slots.length; + if (n == 0) revert EmptySlots(); + if (n > MAX_SLOTS) revert TooManySlots(); + uint256 base = _laneBase(target, laneIndex); + assembly { + let off := slots.offset + for { let i := 0 } lt(i, n) { i := add(i, 1) } { + sstore(add(base, i), calldataload(add(off, mul(i, 0x20)))) + } + } + } + + /// @dev Store memory `slots` (from a decoder) verbatim at the lane base. Reverts on empty (a decoder + /// returning nothing is a rejection) / over-length. + function _writeSlotsMemory(address target, uint256 laneIndex, uint256[] memory slots) internal { + uint256 n = slots.length; + if (n == 0) revert DecoderReturnedNoSlots(); + if (n > MAX_SLOTS) revert TooManySlots(); + uint256 base = _laneBase(target, laneIndex); + for (uint256 i; i < n; ++i) { + uint256 v = slots[i]; + uint256 slot = base + i; + assembly { + sstore(slot, v) + } + } + } +} diff --git a/src/demo/DemoPropAMMs.sol b/src/demo/DemoPropAMMs.sol new file mode 100644 index 0000000..e703f3f --- /dev/null +++ b/src/demo/DemoPropAMMs.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {IPrioUpdateDecoder} from "../PrioUpdateRegistryV2.sol"; + +/// @title Demo consumers of PrioUpdateRegistryV2 — one per write path +/// @notice Minimal, self-contained illustrations of how an actively-managed on-chain curve +/// ("propAMM") uses the two v2 write paths. NOT production code — the AMM math is a trivial +/// `price * amountIn` so the focus stays on the registry integration: how state is written, +/// how a same-tick freshness lock is enforced on read, and how the two paths differ. +/// +/// Read-side pattern (the crux of the v2 design): the registry does NOT store the freshness +/// field, so a consumer records freshness in its OWN slot data and checks it on read. Here +/// each writer stores `[fresh, price]` and each swap requires `fresh == registry.freshnessNow()` +/// — a fresh update must have landed in the CURRENT tick (block or timestamp, per the +/// deployment's clock) or the swap reverts. That defends against an includer that DROPS the +/// fresh update and leaves a stale one. The custom decoder below adds the complementary +/// defence — a wall-clock `deadline` — against an includer that DELAYS inclusion. + +/// @dev The subset of the registry ABI these demos use. +interface IPrioUpdateRegistryV2 { + function addUpdater(address updater) external; + function setDecoder(uint256 laneIndex, address decoder) external; + function updateState(address target, uint256 laneIndex, uint256 freshness, uint256[] calldata slots) external; + function updateStateWithDecoder(address target, uint256 laneIndex, uint256 freshness, bytes calldata aux) external; + function getState(uint256 laneIndex, uint256 count) external view returns (uint256[] memory slots); + function freshnessNow() external view returns (uint256); +} + +/*////////////////////////////////////////////////////////////// + DEMO 1 — LOW-GAS PATH (maker computes its own quote) +//////////////////////////////////////////////////////////////*/ + +/// @notice A propAMM whose operator computes the quote off-chain and pushes it verbatim each tick +/// through the registry's LOW-GAS path (`updateState`, no external call). This is the ~99% +/// case: the maker trusts its own pusher, so no on-chain verification is needed. The pusher +/// submits live, so a delayed/missed tick just means it recomputes and re-pushes next tick. +/// +/// Lifecycle: +/// 1. deploy this contract (it is the lane TARGET — it owns lane {LANE}). +/// 2. `authorizePusher(pusherEOA)` → the contract calls `registry.addUpdater(pusher)`. +/// 3. each tick the pusher (off-chain) calls +/// `registry.updateState(address(this), LANE, now, [now, price])` +/// where `now == registry.freshnessNow()`. The registry range-checks it and stores the +/// two words verbatim; it does not store the freshness itself — the maker mirrors it into +/// slot 0 as its own freshness field. +/// 4. a swap calls {quote}; it reads the lane (self-scoped) and enforces the same-tick lock. +contract SimplePricePropAMM { + IPrioUpdateRegistryV2 public immutable registry; + + /// @dev This demo uses a single lane and a fixed 2-word layout: slot0 = fresh, slot1 = price. + uint256 public constant LANE = 0; + uint256 internal constant SLOTS = 2; + + address public immutable owner; + + error NotOwner(); + error StaleQuote(uint256 fresh, uint256 nowRef); + + constructor(IPrioUpdateRegistryV2 _registry) { + registry = _registry; + owner = msg.sender; + } + + /// @notice Authorize the off-chain price pusher on the registry's low-gas path. + /// @dev Calls `registry.addUpdater` with `msg.sender == address(this)`, i.e. this contract is + /// the target authorizing its own updater. + function authorizePusher(address pusher) external { + if (msg.sender != owner) revert NotOwner(); + registry.addUpdater(pusher); + } + + /// @notice The current, same-tick-fresh price. Reverts unless the stored freshness equals the + /// current clock tick. On the low-gas path the (trusted) pusher stamps it, so this proves + /// the pusher marked the quote for THIS tick — as good as landed-this-tick given the pusher + /// is trusted. (Deploy with lead 0 so the pusher cannot pre-stamp a future tick.) + /// @dev Self-scoped read: `registry.getState` is called with `msg.sender == address(this)`. + function currentPrice() public view returns (uint256 price) { + uint256[] memory s = registry.getState(LANE, SLOTS); + uint256 fresh = s[0]; + uint256 nowRef = registry.freshnessNow(); + if (fresh != nowRef) revert StaleQuote(fresh, nowRef); + return s[1]; + } + + /// @notice Trivial swap quote using the fresh price (demo math only). + function quote(uint256 amountIn) external view returns (uint256 amountOut) { + return (amountIn * currentPrice()) / 1e18; + } +} + +/*////////////////////////////////////////////////////////////// + DEMO 2 — CUSTOM PATH (maker ingests SIGNED price reports) +//////////////////////////////////////////////////////////////*/ + +/// @notice A `view` decoder that verifies a signed price report and unpacks it into lane slots — the +/// generic shape of a signed-oracle consumer. It applies BOTH freshness defences against an +/// adversarial includer (builder/sequencer) that wants a stale price to land: +/// - PIN to the current tick: require `signed == freshnessNow`, so the report cannot be +/// replayed into a different tick or pre-installed early (defends against reorder/replay). +/// - WALL-CLOCK deadline: require `block.timestamp <= deadline`, so the report is void once +/// its real-time deadline passes (defends against an includer that DELAYS inclusion — the +/// tick pin alone can't catch this, because on a block-number clock the block count is +/// unchanged by a delayed block, while `block.timestamp` grows with the delay). +/// Reached ONLY via the registry's `STATICCALL`, so it cannot write, log, or move value. +/// +/// Same-tick note: within one tick, identical reports are a no-op (last write wins with the +/// same value). But if the signer emits MULTIPLE distinct reports for the SAME tick, a relayer +/// chooses which one lands — add a signed nonce / monotonic sequence to the digest if +/// intra-tick ordering must be enforced. +/// +/// A production decoder would swap the `ecrecover` below for a DON `verifyView` (gasless, +/// `view`) or a signed-feed contract's `isValidSigner` — both `view`, both staticcall-safe. +/// Only fee-less, `view` verification fits here (a `STATICCALL` cannot pay a billed verify). +contract SignedReportDecoder is IPrioUpdateDecoder { + /// @notice The trusted report signer (stands in for an oracle DON / feed signing key). + address public immutable signer; + + error ZeroSigner(); + error BadSignatureLength(); + error UntrustedSigner(address recovered); + error NotCurrentTick(uint256 signedFreshness, uint256 freshnessNow); + error Expired(uint256 timestamp, uint256 deadline); + + constructor(address _signer) { + if (_signer == address(0)) revert ZeroSigner(); // else a malformed sig recovering 0 would pass + signer = _signer; + } + + /// @param target lane owner (bound into the signed digest). + /// @param laneIndex lane (bound into the signed digest). + /// @param freshnessNow the registry's current clock value. We require the SIGNED freshness to + /// equal it, pinning the report to the tick it lands in. + /// @param aux `abi.encode(uint256 signedFreshness, uint256 deadline, uint256 price, bytes signature)`. + /// @return slots `[signedFreshness, price]` — the 2-word layout the demo AMM reads. + function validateAndUnpack(address target, uint256 laneIndex, uint256 freshnessNow, bytes calldata aux) + external + view + override + returns (uint256[] memory slots) + { + (uint256 signedFreshness, uint256 deadline, uint256 price, bytes memory sig) = + abi.decode(aux, (uint256, uint256, uint256, bytes)); + + // Bind the report to (this decoder, chainid, target, lane, freshness, deadline, price): a + // signature for one lane, chain, tick, or price cannot be relayed onto another. + bytes32 digest = + keccak256(abi.encode(address(this), block.chainid, target, laneIndex, signedFreshness, deadline, price)); + bytes32 ethDigest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); + address recovered = _recover(ethDigest, sig); + if (recovered != signer) revert UntrustedSigner(recovered); + + // Defence 1: pin to the actual landing tick (the true clock value, not the calldata field). + if (signedFreshness != freshnessNow) revert NotCurrentTick(signedFreshness, freshnessNow); + // Defence 2: wall-clock cap — reject a report an includer delayed past its real-time deadline. + if (block.timestamp > deadline) revert Expired(block.timestamp, deadline); + + slots = new uint256[](2); + slots[0] = signedFreshness; + slots[1] = price; + } + + /// @dev Minimal ECDSA recovery (demo). `ecrecover` is a precompile — `view`/staticcall-safe. + function _recover(bytes32 digest, bytes memory sig) internal pure returns (address) { + if (sig.length != 65) revert BadSignatureLength(); + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := mload(add(sig, 0x20)) + s := mload(add(sig, 0x40)) + v := byte(0, mload(add(sig, 0x60))) + } + return ecrecover(digest, v, r, s); + } +} + +/// @notice A propAMM that consumes SIGNED oracle reports through the registry's CUSTOM path. It binds +/// a {SignedReportDecoder} to its lane; thereafter ANYONE may relay a validly-signed report +/// (the decoder is the authorization — an unsigned/stale/future/expired report cannot get in), +/// and the registry writes the decoded slots. Read side is identical to the low-gas demo. +/// +/// Lifecycle: +/// 1. deploy a {SignedReportDecoder} with the trusted signer. +/// 2. deploy this contract with (registry, decoder) — the constructor binds the decoder to +/// {LANE} (immutable-once-set). +/// 3. each tick a relayer (permissionless) calls +/// `registry.updateStateWithDecoder(address(this), LANE, now, +/// abi.encode(now, deadline, price, signature))`, +/// signed for the CURRENT tick with a wall-clock `deadline`. The registry range-checks +/// the field, STATICCALLs the decoder (which pins the tick AND enforces the deadline), +/// and stores `[now, price]`. +/// 4. a swap calls {quote}; same same-tick lock as the low-gas demo. +contract OracleReportPropAMM { + IPrioUpdateRegistryV2 public immutable registry; + + uint256 public constant LANE = 0; + uint256 internal constant SLOTS = 2; + + error StaleQuote(uint256 fresh, uint256 nowRef); + + constructor(IPrioUpdateRegistryV2 _registry, address decoder) { + registry = _registry; + // This contract is the target; it binds its own verify/unpack decoder for LANE. + registry.setDecoder(LANE, decoder); + } + + /// @notice The current, same-tick-fresh price. Reverts unless a fresh update landed this tick. On + /// the custom path the decoder pinned it to the clock value AND enforced the wall-clock + /// deadline at write time, so this read proves the update was verified, current-tick, and + /// not delayed past its deadline. + function currentPrice() public view returns (uint256 price) { + uint256[] memory s = registry.getState(LANE, SLOTS); + uint256 fresh = s[0]; + uint256 nowRef = registry.freshnessNow(); + if (fresh != nowRef) revert StaleQuote(fresh, nowRef); + return s[1]; + } + + /// @notice Trivial swap quote using the fresh price (demo math only). + function quote(uint256 amountIn) external view returns (uint256 amountOut) { + return (amountIn * currentPrice()) / 1e18; + } +} diff --git a/test/PrioUpdateRegistryV2.t.sol b/test/PrioUpdateRegistryV2.t.sol new file mode 100644 index 0000000..b5f295f --- /dev/null +++ b/test/PrioUpdateRegistryV2.t.sol @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {PrioUpdateRegistryV2, IPrioUpdateDecoder} from "../src/PrioUpdateRegistryV2.sol"; +import { + IPrioUpdateRegistryV2, + SimplePricePropAMM, + SignedReportDecoder, + OracleReportPropAMM +} from "../src/demo/DemoPropAMMs.sol"; + +contract PrioUpdateRegistryV2Test is Test { + PrioUpdateRegistryV2 internal reg; + + uint256 internal constant AGE = 5; // ticks behind allowed + uint256 internal constant LEAD = 1; // ticks ahead allowed + uint256 internal constant MAX_SLOTS = 255; // mirrors the contract constant + uint256 internal constant FUTURE = 1000; // seconds of deadline headroom for happy-path reports + + address internal pusher = makeAddr("pusher"); + address internal relayer = makeAddr("relayer"); + address internal rando = makeAddr("rando"); + + function setUp() public { + reg = new PrioUpdateRegistryV2(AGE, LEAD); + vm.roll(1000); // a non-trivial height + vm.warp(1_700_000_000); // a realistic wall clock for deadlines + } + + /*////////////////////////////////////////////////////////////// + LOW-GAS PATH (updateState) + //////////////////////////////////////////////////////////////*/ + + function test_lowGas_writeReadFresh() public { + SimplePricePropAMM amm = new SimplePricePropAMM(IPrioUpdateRegistryV2(address(reg))); + amm.authorizePusher(pusher); + uint256 lane = amm.LANE(); + + uint256 price = 2_000e18; + uint256[] memory slots = new uint256[](2); + slots[0] = block.timestamp; // maker's own freshness field + slots[1] = price; + + vm.prank(pusher); + reg.updateState(address(amm), lane, block.timestamp, slots); + + assertEq(amm.currentPrice(), price); + assertEq(amm.quote(1e18), price); // 1 * price / 1e18 + } + + function test_lowGas_staleReverts() public { + SimplePricePropAMM amm = new SimplePricePropAMM(IPrioUpdateRegistryV2(address(reg))); + amm.authorizePusher(pusher); + uint256 lane = amm.LANE(); + + uint256[] memory slots = new uint256[](2); + slots[0] = block.timestamp; + slots[1] = 1e18; + vm.prank(pusher); + reg.updateState(address(amm), lane, block.timestamp, slots); + + vm.warp(block.timestamp + 1); // no fresh update landed this tick + vm.expectRevert( + abi.encodeWithSelector(SimplePricePropAMM.StaleQuote.selector, block.timestamp - 1, block.timestamp) + ); + amm.currentPrice(); + } + + function test_lowGas_unauthorizedReverts() public { + uint256[] memory slots = new uint256[](1); + slots[0] = 1; + vm.prank(rando); + vm.expectRevert(PrioUpdateRegistryV2.NotAuthorized.selector); + reg.updateState(address(this), 0, block.timestamp, slots); + } + + function test_lowGas_emptySlotsReverts() public { + reg.addUpdater(pusher); // address(this) is the target + uint256[] memory slots = new uint256[](0); + vm.prank(pusher); + vm.expectRevert(PrioUpdateRegistryV2.EmptySlots.selector); + reg.updateState(address(this), 0, block.timestamp, slots); + } + + function test_lowGas_tooManySlotsReverts() public { + reg.addUpdater(pusher); + uint256[] memory slots = new uint256[](MAX_SLOTS + 1); + vm.prank(pusher); + vm.expectRevert(PrioUpdateRegistryV2.TooManySlots.selector); + reg.updateState(address(this), 0, block.timestamp, slots); + } + + /*////////////////////////////////////////////////////////////// + FRESHNESS WINDOW VALIDATION + //////////////////////////////////////////////////////////////*/ + + function test_window_tooOldReverts() public { + reg.addUpdater(pusher); + uint256[] memory slots = new uint256[](1); + slots[0] = 1; + uint256 tooOld = block.timestamp - AGE - 1; + vm.prank(pusher); + vm.expectRevert(abi.encodeWithSelector(PrioUpdateRegistryV2.FreshnessTooOld.selector, tooOld, block.timestamp)); + reg.updateState(address(this), 0, tooOld, slots); + } + + function test_window_tooFarAheadReverts() public { + reg.addUpdater(pusher); + uint256[] memory slots = new uint256[](1); + slots[0] = 1; + uint256 tooAhead = block.timestamp + LEAD + 1; + vm.prank(pusher); + vm.expectRevert( + abi.encodeWithSelector(PrioUpdateRegistryV2.FreshnessTooFarAhead.selector, tooAhead, block.timestamp) + ); + reg.updateState(address(this), 0, tooAhead, slots); + } + + function test_window_edgesAccepted() public { + reg.addUpdater(pusher); + uint256[] memory slots = new uint256[](1); + slots[0] = 1; + // exactly AGE behind and exactly LEAD ahead both pass + vm.prank(pusher); + reg.updateState(address(this), 0, block.timestamp - AGE, slots); + vm.prank(pusher); + reg.updateState(address(this), 1, block.timestamp + LEAD, slots); + } + + /*////////////////////////////////////////////////////////////// + CUSTOM PATH (decoder / staticcall) + //////////////////////////////////////////////////////////////*/ + + function _signAux( + uint256 pk, + address decoder, + address target, + uint256 lane, + uint256 fresh, + uint256 deadline, + uint256 price + ) internal view returns (bytes memory aux) { + bytes32 digest = keccak256(abi.encode(decoder, block.chainid, target, lane, fresh, deadline, price)); + bytes32 ethDigest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethDigest); + aux = abi.encode(fresh, deadline, price, abi.encodePacked(r, s, v)); + } + + function test_custom_writeReadFresh() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + SignedReportDecoder decoder = new SignedReportDecoder(signer); + OracleReportPropAMM amm = new OracleReportPropAMM(IPrioUpdateRegistryV2(address(reg)), address(decoder)); + uint256 lane = amm.LANE(); + + uint256 price = 3_500e18; + bytes memory aux = + _signAux(pk, address(decoder), address(amm), lane, block.timestamp, block.timestamp + FUTURE, price); + + // permissionless relay: an arbitrary address may submit a validly-signed report + vm.prank(relayer); + reg.updateStateWithDecoder(address(amm), lane, block.timestamp, aux); + + assertEq(amm.currentPrice(), price); + } + + function test_custom_badSignatureReverts() public { + (address signer,) = makeAddrAndKey("signer"); + (, uint256 wrongPk) = makeAddrAndKey("attacker"); + SignedReportDecoder decoder = new SignedReportDecoder(signer); + OracleReportPropAMM amm = new OracleReportPropAMM(IPrioUpdateRegistryV2(address(reg)), address(decoder)); + uint256 lane = amm.LANE(); + + bytes memory aux = + _signAux(wrongPk, address(decoder), address(amm), lane, block.timestamp, block.timestamp + FUTURE, 1e18); + + vm.prank(relayer); + vm.expectRevert(); // UntrustedSigner (recovered != signer) + reg.updateStateWithDecoder(address(amm), lane, block.timestamp, aux); + } + + /// @dev An OLD signed report (tick N) relayed at N+1 with a calldata `freshness=N` that still + /// passes the registry window must be rejected by the decoder, because it pins to the TRUE + /// clock value (N+1), not the calldata field. + function test_custom_inWindowReplayReverts() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + SignedReportDecoder decoder = new SignedReportDecoder(signer); + OracleReportPropAMM amm = new OracleReportPropAMM(IPrioUpdateRegistryV2(address(reg)), address(decoder)); + uint256 lane = amm.LANE(); + + uint256 signedFresh = block.timestamp; // report signed for tick N + bytes memory aux = + _signAux(pk, address(decoder), address(amm), lane, signedFresh, block.timestamp + FUTURE, 1e18); + + vm.warp(block.timestamp + 1); // now at N+1; freshness=N is still within AGE + vm.prank(relayer); + vm.expectRevert(); // NotCurrentTick(signedFresh=N, freshnessNow=N+1) + reg.updateStateWithDecoder(address(amm), lane, signedFresh, aux); + } + + /// @dev A report pinned to the current tick but whose wall-clock deadline has passed (an includer + /// delayed inclusion) must be rejected — the tick pin alone can't catch a same-tick delay. + function test_custom_expiredDeadlineReverts() public { + (address signer, uint256 pk) = makeAddrAndKey("signer"); + SignedReportDecoder decoder = new SignedReportDecoder(signer); + OracleReportPropAMM amm = new OracleReportPropAMM(IPrioUpdateRegistryV2(address(reg)), address(decoder)); + uint256 lane = amm.LANE(); + + // signed for the current tick, but with a deadline already in the past + bytes memory aux = + _signAux(pk, address(decoder), address(amm), lane, block.timestamp, block.timestamp - 1, 1e18); + + vm.prank(relayer); + vm.expectRevert(); // Expired(block.timestamp, deadline) + reg.updateStateWithDecoder(address(amm), lane, block.timestamp, aux); + } + + /*////////////////////////////////////////////////////////////// + PATH ISOLATION / DECODER BINDING + //////////////////////////////////////////////////////////////*/ + + function test_setDecoder_immutableOnceSet() public { + reg.setDecoder(0, address(reg)); // any code-bearing address for the binding test + vm.expectRevert(PrioUpdateRegistryV2.DecoderAlreadySet.selector); + reg.setDecoder(0, address(this)); + } + + function test_setDecoder_zeroReverts() public { + vm.expectRevert(PrioUpdateRegistryV2.ZeroDecoder.selector); + reg.setDecoder(0, address(0)); + } + + function test_setDecoder_noCodeReverts() public { + vm.expectRevert(PrioUpdateRegistryV2.DecoderHasNoCode.selector); + reg.setDecoder(0, makeAddr("eoa")); // an EOA has no code + } + + function test_lowGasOnDecoderLaneReverts() public { + // address(this) is the target: authorize a pusher AND bind a decoder to lane 0 + reg.addUpdater(pusher); + reg.setDecoder(0, address(reg)); // code-bearing; never actually called on this path + uint256[] memory slots = new uint256[](1); + slots[0] = 1; + vm.prank(pusher); + vm.expectRevert(PrioUpdateRegistryV2.DecoderBoundLane.selector); + reg.updateState(address(this), 0, block.timestamp, slots); + } + + function test_decoderPathOnPlainLaneReverts() public { + vm.prank(relayer); + vm.expectRevert(PrioUpdateRegistryV2.DecoderNotSet.selector); + reg.updateStateWithDecoder(address(this), 7, block.timestamp, hex"00"); + } + + /*////////////////////////////////////////////////////////////// + STORAGE-COLLISION HARDENING + //////////////////////////////////////////////////////////////*/ + + /// @dev Without domain separation, `_laneBase(attacker, keccak256(abi.encode(victim, 0)))` equals + /// the storage slot of `isUpdater[victim][attacker]`, letting an attacker set that bit by + /// writing to its OWN lane and then seize the victim's lanes. Prove the namespaced lane base + /// no longer aliases the mapping. + function test_namespaceCollision_cannotForgeUpdater() public { + address victim = makeAddr("victim"); + address attacker = makeAddr("attacker"); + + uint256 craftedLane = uint256(keccak256(abi.encode(victim, uint256(0)))); + + vm.prank(attacker); + reg.addUpdater(attacker); + uint256[] memory slots = new uint256[](1); + slots[0] = 1; + vm.prank(attacker); + reg.updateState(attacker, craftedLane, block.timestamp, slots); + + // the victim's updater bit was NOT forged + assertFalse(reg.isUpdater(victim, attacker)); + + // and the attacker still cannot write the victim's lanes + vm.prank(attacker); + vm.expectRevert(PrioUpdateRegistryV2.NotAuthorized.selector); + reg.updateState(victim, 0, block.timestamp, slots); + } + + /*////////////////////////////////////////////////////////////// + SELF-SCOPED READS + //////////////////////////////////////////////////////////////*/ + + function test_reads_areSelfScoped() public { + reg.addUpdater(address(this)); // this contract authorizes itself as its own updater + uint256[] memory slots = new uint256[](2); + slots[0] = block.timestamp; + slots[1] = 42; + reg.updateState(address(this), 0, block.timestamp, slots); // target == this + + uint256[] memory mine = reg.getState(0, 2); + assertEq(mine[1], 42); + assertEq(reg.getSlot(0, 1), 42); + + // a DIFFERENT reader reads its OWN (empty) lane, not this contract's + Reader other = new Reader(reg); + assertEq(other.readSlot(0, 1), 0); + } + + function test_getSlot_outOfRangeReverts() public { + vm.expectRevert(PrioUpdateRegistryV2.SlotIndexOutOfRange.selector); + reg.getSlot(0, MAX_SLOTS); // slotIndex must be < MAX_SLOTS + } + + function test_getState_countTooLargeReverts() public { + vm.expectRevert(PrioUpdateRegistryV2.SlotIndexOutOfRange.selector); + reg.getState(0, MAX_SLOTS + 1); + } +} + +/// @dev A distinct contract to prove reads are scoped to `msg.sender`. +contract Reader { + PrioUpdateRegistryV2 internal immutable reg; + + constructor(PrioUpdateRegistryV2 _reg) { + reg = _reg; + } + + function readSlot(uint256 lane, uint256 i) external view returns (uint256) { + return reg.getSlot(lane, i); + } +}