Propose PUR v2: block-native freshness, raw slots, two write paths - #2
Propose PUR v2: block-native freshness, raw slots, two write paths#2quintuskilbourn wants to merge 1 commit into
Conversation
720a31d to
ba82c57
Compare
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 <noreply@anthropic.com>
ba82c57 to
cf6e70f
Compare
|
Closing in favor of #3, which takes the more minimal design: registry-level freshness is removed and delegated to the consuming contract. #3 already carries the hardening surfaced in this PR's review — domain-separated lane namespace (no aliasing with the updater/decoder mapping slots), The freshness threat-model notes from this PR now apply to the consumer-side freshness impl: a malicious includer landing the oldest still-valid update; a missed slot / delayed inclusion being invisible to a block-number bound (so a wall-clock deadline belongs in the decoder/consumer); and the per-chain clock choice (block.timestamp on ~12s L1, block.number on sub-second chains, arbBlockNumber on Arbitrum). |
Propose PUR v2 — block-native freshness, raw slots, two write paths
Draft for discussion (not a merge request). Adds
PrioUpdateRegistryV2.solalongside v1 — a new deployment / superset design, v1 stays live. Written to preserve the load-bearing property while addressing feedback gathered from adopters and from a BSC builder that has already forked v1.The invariant this must not break
A transaction whose
tois the registry can only ever write the registry's own storage — and a block producer can establish that from the destination address alone, without simulation. That is what lets builders place these updates top-of-block permissionlessly. Every change below keeps it: the only external interaction is aSTATICCALL(read-only by construction).What changed vs v1
1. Freshness is a block number, not a unix timestamp. A seconds timestamp can't distinguish two blocks on a sub-second chain, and on an L2 the EVM clocks disagree. v2 locks on a block number read from a chain-native
_blockHeight()(block.numberon Ethereum/BSC/Base; override toArbSys(0x64).arbBlockNumber()on Arbitrum/Orbit, whereblock.numberis the coarse L1-derived number). This also reunifies the live BSC fork that already enforces a max block number.2. The block number is calldata-only and is NOT stored.
updateBlockis a plain calldata arg on both paths. The registry range-checks it against the current height on write (so a write can't claim a block far from reality, and a builder can order/expire from calldata) — then discards it. Consequences, both intended:3. Storage is raw — only the caller's slot words, no reserved header. v1 packed a 4-byte timestamp + 1-byte count into the base word, cramping
slot0to 27 bytes. v2 stores nothing but the words: a lane is auint256[]atkeccak256(target, laneIndex) + i. Makers get full 32-byte slots and choose their own layout (including where, if anywhere, freshness lives). The one responsibility this pushes to the caller: lane length — a reader suppliescountand encodes its own length/version if its writes vary.4. Two write paths:
updateState(lean)updateStateWithDecoder(custom)addUpdater)STATICCALLto the decoderThe decoder is reached only via
STATICCALL, so it (and anything it calls) cannotSSTORE,LOG, move value,CREATE, orSELFDESTRUCT— the registry performs the single write, into its own storage. A builder verifies this once from the registry's bytecode and it holds for any decoder code. This generalizes v1's 1271 path: signature-verifying, fee-less,viewverification (e.g. a DONverifyView, orecrecover+ a trusted-signer/isValidSignercheck). The binding is immutable-once-set so a lane's verification can't be swapped for a permissive one under its readers, and a buggy decoder's blast radius is its own lane.5.
getSlot— a single-word read alongside whole-lanegetState, for heavily-packed lanes.Reads are self-scoped
getSlot/getStateread the lane owned bymsg.sender. Because the EVM has no cross-contractSLOAD, this means a lane's words are only readable on-chain by the lane owner — a consumer that must not be read directly by third parties (a metered/gated feed) fronts its lane with its own contract and exposes only what it chooses. A self-consuming maker just reads from its own swap path.What's in this PR
src/PrioUpdateRegistryV2.sol— the registry + theIPrioUpdateDecoderinterface.src/demo/DemoPropAMMs.sol— one demo per path: aSimplePricePropAMM(lean path, self-computed quote) and anOracleReportPropAMM+SignedReportDecoder(custom path, signed report verified in aviewdecoder), sharing a same-block freshness lock on read.test/PrioUpdateRegistryV2.t.sol— 20 tests, all passing: both paths, block-window edges, decoder-bound/immutable guards, permissionless relay, self-scoped reads, plus adversarial coverage (below).Security notes (draft — not audited)
A cross-family review pass surfaced and this PR fixes several issues that are worth calling out for reviewers, each with a regression test:
keccak256(LANE_NAMESPACE, target, laneIndex)(a 3-word preimage) provably cannot alias a 2-word mapping-slot preimage. Without the tag, a lane base structurally collides withisUpdater[victim][attacker].MAX_SLOTS(255), so a lane can only address its own[base, base+255)region — an unbounded slot offset would otherwise letgetSlotread any storage slot and defeat the self-scoped gating._blockHeight()(not the caller's calldata block), so a decoder can pin a signed report to the block it actually lands in — closing permissionless-relay replay and future-install of an old/early report.updateBlockisuint256; the decoder demo rejects a zero signer, bindschainidinto the digest, andsetDecoderrequires the decoder to have code.This has not been audited — the invariant argument (write-scoping holds under
STATICCALL) and the storage-layout reasoning both want independent eyes before any deployment.Open questions for discussion
updateBlockwindow default per chain — exact-match (AGE=0,LEAD=0) vs a small lead for pusher slack. These are per-deployment immutables; setting them large makes the block field effectively maker-self-reported._blockHeight()selection — hardcode Arbitrum/Orbit by chainid vs a deploy-time immutable height source vs the virtual-override shown here.getState?🤖 Generated with Claude Code