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).
- 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:
- Effect of update on state is know upfront even without full transaction simualtion.
- The cost of doing an update transaction is fixed.
- 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; 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
updateTimestampchosen by the writer. Readers supply a[minTimestamp, maxTimestamp]window andgetStatereverts if the stored value is outside 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 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 withInvalidUpdateTimestamp.MAX_UPDATE_AGEandMAX_UPDATE_LEAD_TIMEare immutable constructor parameters. -
It must be
>=the timestamp currently stored for that lane; older writes revert withStaleUpdate. 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.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 minTimestamp, uint32 maxTimestamp) → (uint32 updateTimestamp, uint256[] slots)— called bytargetitself. RevertsStaleUpdateif the stored timestamp is outside[minTimestamp, maxTimestamp](inclusive).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 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.
Each lane spans up to 255 contiguous slots from a caller-chosen base.
- Lane base:
keccak256(abi.encode(target, laneIndex)) isUpdatervalue: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.
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.
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.
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 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.
| 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 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.
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.
- After a user transaction is executed, a priority update transaction should be inserted in front of the user transaction.
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.
just test- Address:
0xda7afeed01fe625cf15d187a19f94b45f00b8c5f - Constructor:
MAX_UPDATE_AGE = 0,MAX_UPDATE_LEAD_TIME = 0 - CREATE2 factory:
0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7 - Salt:
0x0000000000000000000000000000000000000000000000000000012809051083