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. See summary for a visual overview.
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).
- 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.
- When the block builder executes a user transaction that requires the builder to insert a priority update in front of it, the builder would need to stop simulating the user transaction, simulate the priority update, and resimulate the user transaction. With the registry contract, the builder can modify registry state without stopping the user transaction.
- The cost of doing an update transaction is known upfront.
- If priority update can execute arbitrary code then updates for different contracts might conflict with each other and it hurts composability.
- There is a risk that priority update can be abused to do something that is not desired by the user of that contract.
- Each target manages its own set of authorized updaters. An updater can be an EOA or, via ERC-1271, a smart contract wallet (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
updateTimestampchosen by the writer, stored alongside the data and returned to readers so they can decide whether to act on it. - Priority updates can only be read by the target contract itself (via
msg.sender).
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 stored verbatim — it is not validated against block.timestamp, and any write overwrites the previous value for that lane.
-
updateState(address target, uint256 laneIndex, uint32 updateTimestamp, uint256[] slots)Direct call from the authorized updater (msg.sendermust match the stored updater fortarget). -
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 againstsignereither via ECDSA recovery (EOA) or via ERC-1271 (whensigner == target). See Signed Updates and ERC-1271.
getState(uint256 laneIndex) → (uint32 updateTimestamp, uint256[] slots)— called bytargetitself (msg.senderis the target). Never reverts. Returns the storedupdateTimestamp(0if no update was ever written) together with exactly the number of slots that were written (empty array if no update was ever written). Callers decide how to interpret freshness fromupdateTimestamp.isUpdater(address target, address updater) → bool— whetherupdateris authorized to write state fortarget.
Each target manages its own set of updaters. Authorizations are scoped to msg.sender.
addUpdater(address updater)— authorizeupdaterto write state formsg.sender.removeUpdater(address updater)— revokeupdater's authorization formsg.sender.
Each SignedUpdate carries an explicit signer. Verification dispatches on signer == target:
signer != target— ECDSA:ecrecover(digest, signature)must equalsigner, andisUpdater[target][signer]must betrue.signer == target— ERC-1271:target.isValidSignature(digest, signature)must return0x1626ba7e. NoaddUpdaterregistration needed — the target authorizes by signing.
Either failure reverts with NotAuthorized. Anyone may relay the batch.
DOMAIN_SEPARATOR() → bytes32UPDATE_TYPEHASH—keccak256("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".
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 returns the packed updateTimestamp alongside the slots without any freshness check — readers decide how to interpret it. 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.
Gas costs are measured via test/GasBenchmark.t.sol.
| Method | Formula |
|---|---|
Direct updateState |
21000 + 9434 + k × 5212 |
Batched batchUpdateStateWithSignature (EOA path) |
21000 + 894 + n × (17110 + k × 5235) |
getState (warm) |
1287 + k × 269 |
getState (cold) |
3287 + 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.
| n (updates) | n × direct txs | 1 batched tx | Savings |
|---|---|---|---|
| 1 | 30,434 | 39,004 | -28% |
| 2 | 60,868 | 56,114 | 8% |
| 5 | 152,170 | 107,444 | 30% |
| 10 | 304,340 | 192,994 | 37% |
Batching breaks even at ~2 updates and saves increasingly more as n grows.
Block builders accept these transactions via eth_sendBundle. The priority update must be included as a transaction in the bundle.
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.
- The call must be made in a top-level transaction signed by the authorized updater address.
- The transaction must be an EIP-1559 transaction with 0 priority fee (to ensure that transaction execution does not conflict with other transactions). Set a high max fee so the transaction remains valid if the base fee changes.
- Updates should be sent as part of a valid transaction calling
batchUpdateStateWithSignature. - The builder may parse signed updates from the transaction and apply them as part of a different transaction.
We suggest this approach to applying priority update in the builder.
- Keep separate "mempool" of unlanded priority updates and maintain it with new updates as they arrive.
- Prohibit priority updates from landing in the block except if the builder explicitly inserts them.
- When simulating a user transaction that needs a priority update, apply it directly to the state that EVM reads.
- After a user transaction is executed, a priority update transaction can be inserted in front of the user transaction (e.g. batched on top of block). This is safe to do since only the builder is allowed to modify the registry contract and all priority updates are non-conflicting with other transactions in the block.
- Allow transactions that change the updater set (
addUpdater/removeUpdater) to land at the bottom of the block.
Build and run the full test suite:
forge build
forge testRun gas benchmarks (uses --isolate via per-test config):
forge test --match-contract GasBenchmarkTest -vvRun only the main unit tests:
forge test --match-contract PrioUpdateRegistryTest -vv