Skip to content

flashbots/priority-update-registry

Repository files navigation

PrioUpdateRegistry

On-chain registry that allows authorized updaters to publish per-target priority updates that are only valid for the current block. Targets (e.g. contracts) can read their current priority update during execution.

Priority updates for the current block are constantly sent to the block builder. The block builder ensures that priority updates for a contract always land in the block before any transaction that interacts with that contract, and that updates for contracts not touched in the block are excluded. The fixed storage layout of this contract ensures that block builders can write an efficient implementation of this functionality. Using a global contract makes it easy for the builder to ensure the prio updates are not doing anything unexpected (e.g. arbitraging other pools).

Motivation

  • Priority updates allow any integrated smart contract to set per-block state that will be inserted in the block before any interaction that reads this state.
  • Updates that are not used in the block do not land onchain.
  • An update transaction can only update the state of the registry smart contract. This makes block builder integration easier to reason about. There is no risk for this priority update to be used in an unintended way.
  • One fixed contract design is more scalable and more composable. Because of the defined logic of this update for all smart contracts, it's easy to process updates for many contracts at the same time. Multiple updates from different users can be batched to reduce costs.

Why we propose one priority update registry vs allowing each smart contract to define their own priority update transaction.

An alternative design would be to allow each contract to have their own way to execute priority update. Each contract would send some opaque transaction that must be inserted before anything else that touched their smart contract in the block.

The main downside of this is the complexity of execution when inserting priority update.

With fixed priority update structure we get these benefits:

  1. Effect of update on state is know upfront even without full transaction simualtion.
  2. The cost of doing an update transaction is fixed.
  3. If priority update can execute arbitrary code then updates for different contracts might conflict with each other and it hurts composability.
  4. There is a risk that priority update can be abused to do something that is not desired by the user of that contract.

Contract Interface

Priority Updates

  • Each target manages its own set of authorized updaters; registered updaters must be EOAs (ECDSA signing). A target can additionally authorize itself by signing via ERC-1271, without prior registration (see Signed Updates and ERC-1271).
  • A priority update consists of a 27-byte (216-bit) base value plus k additional 32-byte slots. Each additional slot increases the gas cost of an update. The number of slots is stored on-chain (max 255).
  • Each target can have multiple independent lanes (identified by laneIndex). Updates to different lanes are independent — they land separately and carry their own timestamp.
  • Each update carries an updateTimestamp chosen by the writer. Readers supply a [minTimestamp, maxTimestamp] window and getState reverts if the stored value is outside it.
  • Priority updates can only be read by the target contract itself (via msg.sender).

Writing Priority Updates

All write methods require at least one slot (max 255). slots[0] must fit in 27 bytes (216 bits), as it is packed into the base storage word alongside the timestamp and slot count. slots[1..] are full uint256 values.

The updateTimestamp is a uint32 chosen by the writer and subject to two checks:

  • It must lie within [block.timestamp - MAX_UPDATE_AGE, block.timestamp + MAX_UPDATE_LEAD_TIME] (inclusive); otherwise the call reverts with InvalidUpdateTimestamp. MAX_UPDATE_AGE and MAX_UPDATE_LEAD_TIME are immutable constructor parameters.

  • It must be >= the timestamp currently stored for that lane; older writes revert with StaleUpdate. Writes with an equal or newer timestamp overwrite the previous value.

  • updateState(address target, uint256 laneIndex, uint32 updateTimestamp, uint256[] slots) Direct call from the authorized updater (msg.sender must match the stored updater for target).

  • batchUpdateStateWithSignature(SignedUpdate[] updates) Batch multiple signed updates in a single transaction. Each element contains (address target, address signer, uint256 laneIndex, uint32 updateTimestamp, uint256[] slots, bytes signature). The signature is verified against signer either via ECDSA recovery (EOA) or via ERC-1271 (when signer == target). See Signed Updates and ERC-1271.

Reading Priority Updates

  • getState(uint256 laneIndex, uint32 minTimestamp, uint32 maxTimestamp) → (uint32 updateTimestamp, uint256[] slots) — called by target itself. Reverts StaleUpdate if the stored timestamp is outside [minTimestamp, maxTimestamp] (inclusive).
  • isUpdater(address target, address updater) → bool — whether updater is authorized to write state for target.

Updater Management

Each target manages its own set of updaters. Authorizations are scoped to msg.sender.

  • addUpdater(address updater) — authorize updater to write state for msg.sender.
  • removeUpdater(address updater) — revoke updater's authorization for msg.sender.

Signed Updates and ERC-1271

Each SignedUpdate carries an explicit signer. Verification dispatches on signer == target:

  • signer != target — ECDSA: ecrecover(digest, signature) must equal signer, and isUpdater[target][signer] must be true.
  • signer == targetERC-1271: target.isValidSignature(digest, signature) must return 0x1626ba7e. No addUpdater registration needed — the target authorizes by signing.

Either failure reverts with NotAuthorized. Anyone may relay the batch.

EIP-712

  • DOMAIN_SEPARATOR() → bytes32
  • UPDATE_TYPEHASHkeccak256("UpdateState(address target,uint256 laneIndex,uint32 updateTimestamp,uint256[] slots)")

Note: the signer field in SignedUpdate is not part of the typed-data hash. It's claimed by the relayer and either checked against ECDSA recovery (must match) or used as the contract to call isValidSignature on (which decides for itself).

Domain name: "PrioUpdateRegistry", version: "1".

Storage Layout

Updater storage. isUpdater is a nested mapping at storage slot 0:

slot = keccak256(abi.encode(updater, keccak256(abi.encode(target, 0))))
value = 1 if authorized, else 0

Lane state storage. Each (target, laneIndex) pair has a contiguous range of slots:

base = keccak256(abi.encode(target, laneIndex))
slot[i] = base + i

Slot 0 (base slot) packs three fields into a single word:

[ updateTimestamp (32 bits) | numSlots (8 bits) | slot0 value (216 bits) ]
  bits 255..224              bits 223..216        bits 215..0

Slots 1..k store raw uint256 values.

getState reverts StaleUpdate if the unpacked updateTimestamp is outside the caller's window. The numSlots field records how many slots were written so getState returns exactly that many (and an empty array when no update has ever been written). Different lanes are fully independent — updating one lane does not affect others.

Collision resistance

Each lane spans up to 255 contiguous slots from a caller-chosen base.

  • Lane base: keccak256(abi.encode(target, laneIndex))
  • isUpdater value: keccak256(abi.encode(updater, keccak256(abi.encode(target, 0))))

A collision requires finding a keccak output within 255 of a chosen slot — ≈ 2^248 work. Reduces to keccak preimage/collision resistance.

Threat Model

Builder selects which update lands

The block builder receives a continuous stream of priority updates for the upcoming block and may insert any one of them. The contract trusts the builder to insert the most recent update it received. Builder bugs or propagation issues can cause a stale (but still within the validity window) update to land instead of the freshest one. The registry cannot distinguish "stale but valid" from "freshest" on-chain.

Target contracts choose their freshness window

Targets pick [minTimestamp, maxTimestamp] on each getState call and the registry enforces it. The write-side MAX_UPDATE_AGE / MAX_UPDATE_LEAD_TIME bounds are not a substitute.

Signed updates are replayable within their window

A SignedUpdate is not single-use. As long as (a) the update's updateTimestamp is >= the lane's stored timestamp and (b) updateTimestamp still lies within [block.timestamp - MAX_UPDATE_AGE, block.timestamp + MAX_UPDATE_LEAD_TIME], any party can re-relay the signature. A replay produces the same on-chain state as the original write, so it cannot corrupt state.

Gas Costs

Gas costs are measured via test/GasBenchmark.t.sol.

Method Formula
Direct updateState 21000 + 9712 + k × 5212
Batched batchUpdateStateWithSignature (EOA path) 21000 + 916 + n × (17366 + k × 5235)
getState (warm) 1524 + k × 269
getState (cold) 3524 + k × 2269

Where k = number of additional slots (beyond the packed slot 0) and n = number of updates in the batch. The batched formula is calibrated for ECDSA-signed updates; the ERC-1271 path adds a staticcall whose cost depends on the target's isValidSignature implementation.

These formulas measure steady-state overwrites on already-initialized storage, which is the benchmark setup used in test/GasBenchmark.t.sol. They do not model first writes or cases where a write grows into previously zero slots, which are more expensive because they include zero-to-nonzero SSTOREs.

Comparison: n direct transactions vs 1 batched transaction (k = 0)

n (updates) n × direct txs 1 batched tx Savings
1 30,712 39,282 -27%
2 61,424 56,648 8%
5 153,560 108,746 30%
10 307,120 195,576 37%

Batching breaks even at ~2 updates and saves increasingly more as n grows.

Block Builder Integration

Block builders accept these transactions via special endpoint. If another prio update arrives at the block builder, it replaces the previous one. Only one priority update can land in the block and the builder verifies that it's the latest that it received.

Simulating priority updates inside the block builder

We suggest this approach to applying priority update in the builder.

  1. Keep separate "mempool" of unlanded priority updates and maintain it with new updates as they arrive.
  2. Prohibit priority updates from landing in the block except if the builder explicitly inserts them.
  3. After a user transaction is executed, a priority update transaction should be inserted in front of the user transaction.

Example Integration

src/ExamplePropAmm.sol is a minimal proprietary AMM that reads its per-pair pricing parameters (concentration, multX, multY) from this registry. The market maker publishes a priority update each block. Swappers read the latest parameters via getState, with the registry enforcing a maxParameterAge freshness window. Adapted from fahimahmedx/prop-amm, which uses a different top-of-block storage mechanism.

Testing

just test

Deployments

Ethereum mainnet

  • Address: 0xda7afeed01fe625cf15d187a19f94b45f00b8c5f
  • Constructor: MAX_UPDATE_AGE = 0, MAX_UPDATE_LEAD_TIME = 0
  • CREATE2 factory: 0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7
  • Salt: 0x0000000000000000000000000000000000000000000000000000012809051083

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors