Skip to content

Propose PUR v2: block-native freshness, raw slots, two write paths - #2

Closed
quintuskilbourn wants to merge 1 commit into
flashbots:mainfrom
quintuskilbourn:agg-pur-v2-proposal
Closed

Propose PUR v2: block-native freshness, raw slots, two write paths#2
quintuskilbourn wants to merge 1 commit into
flashbots:mainfrom
quintuskilbourn:agg-pur-v2-proposal

Conversation

@quintuskilbourn

Copy link
Copy Markdown
Collaborator

Propose PUR v2 — block-native freshness, raw slots, two write paths

Draft for discussion (not a merge request). Adds PrioUpdateRegistryV2.sol alongside 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 to is 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 a STATICCALL (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.number on Ethereum/BSC/Base; override to ArbSys(0x64).arbBlockNumber() on Arbitrum/Orbit, where block.number is 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. updateBlock is 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:

  • the block field stays a permissionless, builder-legible calldata value (not hidden behind the decoder);
  • the registry imposes no read-side freshness policy of its own. A consumer that must reject stale state records freshness in its own slot data and checks it on read — which is how signed-oracle consumers already gate staleness (on the report's own monotonic timestamp), not on a chain clock.

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 slot0 to 27 bytes. v2 stores nothing but the words: a lane is a uint256[] at keccak256(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 supplies count and encodes its own length/version if its writes vary.

4. Two write paths:

updateState (lean) updateStateWithDecoder (custom)
For makers who compute their own quote makers who ingest signed oracle reports
Auth authorized updater (addUpdater) the decoder verifies — permissionless relay
Cost one call, no SLOAD/staticcall beyond the auth check one STATICCALL to the decoder
Verify none (trusted pusher) decoder does verify + unpack, returns slots

The decoder is reached only via STATICCALL, so it (and anything it calls) cannot SSTORE, LOG, move value, CREATE, or SELFDESTRUCT — 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, view verification (e.g. a DON verifyView, or ecrecover + a trusted-signer/isValidSigner check). 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-lane getState, for heavily-packed lanes.

Reads are self-scoped

getSlot/getState read the lane owned by msg.sender. Because the EVM has no cross-contract SLOAD, 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 + the IPrioUpdateDecoder interface.
  • src/demo/DemoPropAMMs.sol — one demo per path: a SimplePricePropAMM (lean path, self-computed quote) and an OracleReportPropAMM + SignedReportDecoder (custom path, signed report verified in a view decoder), 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:

  • Lane storage is domain-separated from the contract's own mappings — 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 with isUpdater[victim][attacker].
  • Reads and writes are bounded by MAX_SLOTS (255), so a lane can only address its own [base, base+255) region — an unbounded slot offset would otherwise let getSlot read any storage slot and defeat the self-scoped gating.
  • The decoder receives the true _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.
  • updateBlock is uint256; the decoder demo rejects a zero signer, binds chainid into the digest, and setDecoder requires 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

  1. Custom-path replay: we push cross-block replay defence into the decoder (bind the signed report to its block/nonce). Should the registry offer an optional built-in monotonic guard for makers who don't want to hand-roll it (costs a stored word + SLOAD)?
  2. updateBlock window 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.
  3. _blockHeight() selection — hardcode Arbitrum/Orbit by chainid vs a deploy-time immutable height source vs the virtual-override shown here.
  4. Lane length in raw storage — leave it fully to the caller (current), or offer an optional length-aware getState?
  5. Optional update events / multi-pool-per-update inclusion signalling — standardize, or leave to builder config?

🤖 Generated with Claude Code

@quintuskilbourn
quintuskilbourn force-pushed the agg-pur-v2-proposal branch 3 times, most recently from 720a31d to ba82c57 Compare August 17, 2026 22:28
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>
@quintuskilbourn

Copy link
Copy Markdown
Collaborator Author

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), MAX_SLOTS-bounded reads/writes, the decoder-bypass guard on the direct path, and a code-length check on decoder registration — so consolidating loses nothing there.

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant