diff --git a/README.md b/README.md index 7269e22..4016464 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,76 @@ -# PrioUpdateRegistry +# PrioUpdateRegistry V2 -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. +For the V1 design and documentation, see [`README.v1.md`](README.v1.md). -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). +On-chain registry that allows authorized updaters to publish raw per-target priority updates. Targets (e.g. contracts) can read their current priority update during execution and interpret it according to their own application logic. + +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 that direct priority updates only write to registry storage. Decoder updates may call up to two configured contracts. + +V2 intentionally has no freshness logic. The registry does not know whether a slot contains a timestamp, block number, sequence number, price, or any other value. Targets define their own slot layout and validate freshness and all other application-specific properties when they read it. ## 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. +- Direct update transactions only update the state of the registry smart contract. This makes block builder integration easier to reason about. +- One fixed contract design is more scalable and more composable. Targets remain free to define their own data layout and validation rules. +- Freshness is application-specific. Removing it from the registry lets targets use timestamps, block numbers, sequence numbers, or no freshness marker at all. ## 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. +An alternative design would be to allow each contract to have its own way to execute priority updates. Each contract would send some opaque transaction that must be inserted before anything else that touched its smart contract in the block. -The main downside of this is the complexity of execution when inserting priority update. +The main downside of this is the complexity of execution when inserting priority updates. -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. +With a fixed priority update structure we get these benefits: + +1. Direct updates have a known and narrow effect: they write raw words to a target's lane in the registry. +2. The amount of registry write work is determined by the number of slots supplied by the updater or returned by the decoder. +3. Decoder validation runs under `STATICCALL`, so it cannot introduce external state writes. +4. Targets can opt into a decoder for authorization or payload validation without adding those rules to the registry. +5. Decoder updates may call up to two configured contracts before validation. ## 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](https://eips.ethereum.org/EIPS/eip-1271), without prior registration (see [Signed Updates and ERC-1271](#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`). +- Each target can authorize addresses to write raw slot values on its behalf. +- Each target can have multiple independent **lanes**, identified by `laneIndex`. +- Each lane contains up to 255 full `uint256` slots. The registry does not reserve a header or interpret any bits. +- The registry does not store the number of slots in a lane. Readers choose how many slots to read. +- Priority updates can only be read through the contract interface by the target itself, via `msg.sender`. + +Targets can choose either of two write paths for each lane: + +- **Updater-managed lane** — an authorized updater writes raw slots directly. +- **Decoder-managed lane** — the target permanently assigns a decoder that validates an opaque payload and returns the raw slots to store. Anyone can relay the payload. ### 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. +All write methods require between 1 and 255 slots. Every slot is stored as a full `uint256` value. + +The registry performs no freshness, ordering, monotonicity, or application-level validation. A target that needs a timestamp, block number, or sequence number must include it in its own slot layout and check it when reading. -The `updateTimestamp` is a `uint32` chosen by the writer and subject to two checks: +Writes replace only the supplied prefix of a lane. A shorter write does not clear values left by an earlier longer write. -- 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, uint256[] slots)`** + Direct write from an authorized updater. `msg.sender` must be authorized for `target`, and the lane must not have a decoder. -- **`updateState(address target, uint256 laneIndex, uint32 updateTimestamp, uint256[] slots)`** - Direct call from the authorized updater (`msg.sender` must match the stored updater for `target`). +- **`updateStateWithDecoder(address target, uint256 laneIndex, bytes aux, TrustedCall[] calls)`** + Permissionless relay for a decoder-managed lane. Executes `calls`, validates through the decoder, and stores the returned slots. -- **`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](#signed-updates-and-erc-1271). +If multiple valid writes to the same lane land in a block, the last write determines the value of every slot it supplies. ### 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`. +- **`getSlot(uint256 laneIndex, uint256 slotIndex) → uint256 value`** — returns one slot from `msg.sender`'s lane. `slotIndex` must be less than 255. +- **`getSlots(uint256 laneIndex, uint256 slotIndex, uint256 slotCount) → uint256[] slots`** — returns the contiguous range `[slotIndex, slotIndex + slotCount)` from `msg.sender`'s lane. The complete range must fit within the lane's 255 slots. +- **`getState(uint256 laneIndex, uint256 count) → uint256[] slots`** — returns the first `count` slots from `msg.sender`'s lane. `count` may be between 0 and 255. +- `isUpdater(address target, address updater) → bool` — whether `updater` is authorized to write directly for `target`. +- `laneDecoder(address target, uint256 laneIndex) → address` — the decoder assigned to a lane, or the zero address if the lane is updater-managed. +- `isTrustedCallTarget(address target) → bool` — whether decoder updates may call `target` directly. + +Unwritten slots return zero. The registry does not return a stored length, timestamp, or freshness result. ### Updater Management @@ -60,23 +79,58 @@ Each target manages its own set of updaters. Authorizations are scoped to `msg.s - `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 +Updater authorization applies only to lanes without a decoder. -Each `SignedUpdate` carries an explicit `signer`. Verification dispatches on `signer == target`: +### Trusted Calls -- **`signer != target`** — ECDSA: `ecrecover(digest, signature)` must equal `signer`, and `isUpdater[target][signer]` must be `true`. -- **`signer == target`** — [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271): `target.isValidSignature(digest, signature)` must return `0x1626ba7e`. No `addUpdater` registration needed — the target authorizes by signing. +The constructor accepts up to two trusted targets. Zero leaves a position unused; nonzero targets must have code. -Either failure reverts with `NotAuthorized`. Anyone may relay the batch. +```solidity +struct TrustedCall { + address target; + bytes data; +} +``` + +Calls execute in order with zero value. The decoder receives their return data and `keccak256(abi.encode(calls))`. Failures, state-changing callbacks, and lane reads revert. + +### Decoder Management + +- **`setDecoder(uint256 laneIndex, address decoder)`** — permanently assign a decoder to `msg.sender`'s lane. + +A decoder must have code when it is registered. Once set, it cannot be removed or replaced, and direct updater writes to that lane are disabled. + +The decoder implements: + +```solidity +function validateAndUnpack( + address target, + uint256 laneIndex, + bytes calldata aux, + bytes32 trustedCallsHash, + bytes[] calldata callResults +) + external + view + returns (uint256[] memory slots); +``` -### EIP-712 +The decoder is responsible for authorization, signatures, replay protection, freshness, payload decoding, and any other validation required by the target. It should bind its authorization to `target` and `laneIndex` where appropriate. Authenticated `aux` must also bind `trustedCallsHash` when calls are used. It must return between 1 and 255 slots. -- `DOMAIN_SEPARATOR() → bytes32` -- `UPDATE_TYPEHASH` — `keccak256("UpdateState(address target,uint256 laneIndex,uint32 updateTimestamp,uint256[] slots)")` +The registry calls the decoder with `STATICCALL`, so the decoder cannot modify state during validation. A proxy decoder can still change behavior through upgrades even though its registered address is permanent. -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). +### Freshness and Application Validation -Domain name: `"PrioUpdateRegistry"`, version: `"1"`. +V2 performs no freshness checks on writes or reads. + +A target that needs freshness should store its chosen marker in a slot and validate it every time it reads registry state. For example, a target may store a timestamp in slot 0 and accept it only when: + +```solidity +updateTimestamp <= block.timestamp + && block.timestamp - updateTimestamp <= maxUpdateAge +``` + +The same pattern can be implemented with block numbers or an application-defined sequence. The registry does not require one convention. ## Storage Layout @@ -87,89 +141,68 @@ 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: +**Decoder storage.** `laneDecoder` is a nested mapping at storage slot `1`: ``` -[ updateTimestamp (32 bits) | numSlots (8 bits) | slot0 value (216 bits) ] - bits 255..224 bits 223..216 bits 215..0 +slot = keccak256(abi.encode(laneIndex, keccak256(abi.encode(target, 1)))) +value = decoder address, or 0 if no decoder is set ``` -**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. +**Callback lock.** Transient slot `keccak256("PrioUpdateRegistryV2.callbackLock")` (EIP-1153). -### Collision resistance +**Trusted call targets.** Two immutable addresses; no storage slots. -Each lane spans up to 255 contiguous slots from a caller-chosen base. +**Lane state storage.** Each `(target, laneIndex)` pair has a domain-separated contiguous range of slots: -- Lane base: `keccak256(abi.encode(target, laneIndex))` -- `isUpdater` value: `keccak256(abi.encode(updater, keccak256(abi.encode(target, 0))))` +``` +LANE_NAMESPACE = keccak256("PrioUpdateRegistryV2.lane.v1") +base = keccak256(abi.encode(LANE_NAMESPACE, target, laneIndex)) +slot[i] = base + i, for 0 <= i < 255 +``` -A collision requires finding a keccak output within 255 of a chosen slot — `≈ 2^248` work. Reduces to keccak preimage/collision resistance. +Every lane slot stores one raw `uint256`. There is no packed header, timestamp, or stored slot count. The namespace separates lane bases from the registry's mapping storage domains; overlap between independent lane ranges reduces to keccak collision or near-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. +The block builder receives a continuous stream of priority updates for the upcoming block and may insert any one of them. The registry does not distinguish the newest update from an older update. Builder bugs, propagation issues, or transaction ordering can cause a different valid write to land or a later write to overwrite an earlier one. -### Target contracts choose their freshness window +### Targets validate freshness on reads -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. +The registry accepts raw slot values without checking their age or order. Every target that relies on freshness must encode a timestamp, block number, or other marker and validate it on every read before using the remaining values. Missing, zero, future, and stale markers must be handled by the target's own policy. -### Signed updates are replayable within their window +### Authorized updaters control raw lane contents -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. +An authorized updater can write any values to every updater-managed lane for its target. Removing an updater prevents future writes but does not clear previously written state. -## Gas Costs +### Decoders define their lane's security policy -Gas costs are measured via `test/GasBenchmark.t.sol`. +Anyone can relay `updateStateWithDecoder`. The decoder must authenticate and validate the payload, call hash, and results. A decoder that accepts arbitrary input gives arbitrary callers control over its lane. Decoder addresses are permanent, but proxy decoders may remain upgradeable. -| 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` | +### Trusted targets execute user-selected calldata -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. +Relayers choose calldata for trusted targets. The allowlist trusts all code reachable through those targets, including upgrades and downstream calls. -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 `SSTORE`s. +### Registry reads are target-scoped -### 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. +`getSlot`, `getSlots`, and `getState` read lanes belonging to `msg.sender`. One contract cannot use these methods to read as another target. Registry storage remains public and can always be inspected offchain. ## 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. +Block builders accept these transactions via a special endpoint. If another priority update arrives at the block builder, it replaces the previous one. Only one priority update for a target and lane should land in the block, and the builder verifies that it is the latest one received. ### Simulating priority updates inside the block builder -We suggest this approach to applying priority update in the builder. +We suggest this approach to applying priority updates in the builder. -1. Keep separate "mempool" of unlanded priority updates and maintain it with new updates as they arrive. +1. Keep a 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. +3. Before a user transaction is executed, insert the relevant priority update transaction in front of it. ## Example Integration -[`src/ExamplePropAmm.sol`](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](https://github.com/fahimahmedx/prop-amm), which uses a different top-of-block storage mechanism. +[`src/ExamplePropAmm.sol`](src/ExamplePropAmm.sol) is a minimal proprietary AMM that reads its per-pair pricing parameters from `PrioUpdateRegistryV2`. The market maker writes `[updateTimestamp, concentration, multX, multY]` as raw lane data. The registry does not interpret the timestamp; the AMM checks that it is not in the future and is no older than `maxParameterAge` whenever it reads the parameters. Adapted from [fahimahmedx/prop-amm](https://github.com/fahimahmedx/prop-amm), which uses a different top-of-block storage mechanism. ## Testing @@ -179,9 +212,4 @@ just test ## Deployments -### Ethereum mainnet - -- Address: `0xda7afeed01fe625cf15d187a19f94b45f00b8c5f` -- Constructor: `MAX_UPDATE_AGE = 0`, `MAX_UPDATE_LEAD_TIME = 0` -- CREATE2 factory: `0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7` -- Salt: `0x0000000000000000000000000000000000000000000000000000012809051083` +No PrioUpdateRegistry V2 deployments are listed yet. diff --git a/README.v1.md b/README.v1.md new file mode 100644 index 0000000..b9ec239 --- /dev/null +++ b/README.v1.md @@ -0,0 +1,187 @@ +# PrioUpdateRegistry V1 + +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](https://eips.ethereum.org/EIPS/eip-1271), without prior registration (see [Signed Updates and ERC-1271](#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](#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 == target`** — [ERC-1271](https://eips.ethereum.org/EIPS/eip-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_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"`. + +## 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 `SSTORE`s. + +### 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 + +`ExamplePropAmm` is a minimal proprietary AMM that reads its per-pair pricing parameters (`concentration`, `multX`, `multY`) from the V1 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](https://github.com/fahimahmedx/prop-amm), which uses a different top-of-block storage mechanism. + +## Testing + +```shell +just test +``` + +## Deployments + +### Ethereum mainnet + +- Address: `0xda7afeed01fe625cf15d187a19f94b45f00b8c5f` +- Constructor: `MAX_UPDATE_AGE = 0`, `MAX_UPDATE_LEAD_TIME = 0` +- CREATE2 factory: `0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7` +- Salt: `0x0000000000000000000000000000000000000000000000000000012809051083` diff --git a/foundry.toml b/foundry.toml index 8a3b382..39f075a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,5 +5,6 @@ libs = ["lib"] remappings = ["solady/=lib/solady/src/", "@openzeppelin/=lib/openzeppelin-contracts/"] optimizer = true optimizer_runs = 200 +evm_version = "cancun" # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/src/ExamplePropAmm.sol b/src/ExamplePropAmm.sol index 5f4390b..e34c17d 100644 --- a/src/ExamplePropAmm.sol +++ b/src/ExamplePropAmm.sol @@ -6,16 +6,16 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -import {PrioUpdateRegistry} from "./PrioUpdateRegistry.sol"; +import {PrioUpdateRegistryV2} from "./PrioUpdateRegistryV2.sol"; /** * @title ExamplePropAmm * @notice A Proprietary Automated Market Maker where only the market maker can provide liquidity - * @dev Reads pricing parameters from a PrioUpdateRegistry that publishes top-of-block updates. + * @dev Reads pricing parameters from a PrioUpdateRegistryV2 that publishes top-of-block updates. * Adapted from https://github.com/fahimahmedx/prop-amm. */ // Slither's `timestamp` detector taints any comparison whose data path touches `block.timestamp`. -// Because `_readParametersFromRegistry` forwards `block.timestamp` as a freshness bound, every +// Because `_readParametersFromRegistry` compares stored data against `block.timestamp`, every // downstream amount/reserve comparison gets reported. These comparisons are not timestamp-based; // disable the detector for this example contract. // slither-disable-start timestamp @@ -46,7 +46,7 @@ contract ExamplePropAmm is Ownable, ReentrancyGuard { // ============ State Variables ============ address public marketMaker; - PrioUpdateRegistry public immutable prioRegistry; + PrioUpdateRegistryV2 public immutable prioRegistry; uint256 public immutable maxParameterAge; mapping(bytes32 => TradingPair) public pairs; @@ -87,6 +87,7 @@ contract ExamplePropAmm is Ownable, ReentrancyGuard { error SlippageExceeded(); error InvalidDecimalConfiguration(); error ParametersNotSet(); + error StaleParameters(); // ============ Modifiers ============ @@ -102,7 +103,9 @@ contract ExamplePropAmm is Ownable, ReentrancyGuard { // ============ Constructor ============ - constructor(address _marketMaker, PrioUpdateRegistry _prioRegistry, uint256 _maxParameterAge) Ownable(msg.sender) { + constructor(address _marketMaker, PrioUpdateRegistryV2 _prioRegistry, uint256 _maxParameterAge) + Ownable(msg.sender) + { if (_marketMaker == address(0)) revert InvalidAmount(); marketMaker = _marketMaker; prioRegistry = _prioRegistry; @@ -160,8 +163,8 @@ contract ExamplePropAmm is Ownable, ReentrancyGuard { // Seed initial parameters in the registry (multX/multY default to 0; market maker // must publish real values via prioRegistry.updateState before any swap). - uint256[] memory slots = _encodeSlots(initialConcentration, 0, 0); - prioRegistry.updateState(address(this), uint256(pairId), uint32(block.timestamp), slots); + uint256[] memory slots = _encodeSlots(block.timestamp, initialConcentration, 0, 0); + prioRegistry.updateState(address(this), uint256(pairId), slots); return pairId; } @@ -233,7 +236,7 @@ contract ExamplePropAmm is Ownable, ReentrancyGuard { /** * @notice Swap token X for token Y - * @dev Reads latest parameters from PrioUpdateRegistry (top-of-block values) + * @dev Reads latest parameters from PrioUpdateRegistryV2 (top-of-block values) * @param pairId The pair identifier * @param amountXIn Amount of token X to swap * @param minAmountYOut Minimum amount of token Y expected (slippage protection) @@ -272,7 +275,7 @@ contract ExamplePropAmm is Ownable, ReentrancyGuard { /** * @notice Swap token Y for token X - * @dev Reads latest parameters from PrioUpdateRegistry (top-of-block values) + * @dev Reads latest parameters from PrioUpdateRegistryV2 (top-of-block values) * @param pairId The pair identifier * @param amountYIn Amount of token Y to swap * @param minAmountXOut Minimum amount of token X expected (slippage protection) @@ -367,12 +370,13 @@ contract ExamplePropAmm is Ownable, ReentrancyGuard { /** * @notice Encode parameters into the slot array expected by the registry * @dev Market maker uses these slots to call prioRegistry.updateState() directly for ToB priority. + * @param updateTimestamp Timestamp the AMM checks for freshness when reading * @param concentration Concentration parameter (1-2000) * @param multX Price multiplier for token X * @param multY Price multiplier for token Y * @return slots Slot array to pass to prioRegistry.updateState() */ - function encodeParameterSlots(uint256 concentration, uint256 multX, uint256 multY) + function encodeParameterSlots(uint256 updateTimestamp, uint256 concentration, uint256 multX, uint256 multY) external pure returns (uint256[] memory slots) @@ -380,7 +384,7 @@ contract ExamplePropAmm is Ownable, ReentrancyGuard { if (concentration < 1 || concentration >= 2000) { revert InvalidConcentration(); } - return _encodeSlots(concentration, multX, multY); + return _encodeSlots(updateTimestamp, concentration, multX, multY); } // ============ Internal Functions ============ @@ -388,31 +392,33 @@ contract ExamplePropAmm is Ownable, ReentrancyGuard { /** * @notice Read parameters from the registry, requiring the stored timestamp to be no * older than `maxParameterAge` seconds and no newer than the current block. - * @dev The registry reverts with `PrioUpdateRegistry.StaleUpdate` if the bounds are violated. + * @dev The timestamp is ordinary registry data. This contract enforces the freshness bounds when reading. */ function _readParametersFromRegistry(bytes32 pairId) internal view returns (PairParameters memory params) { - // The discarded first return is the stored timestamp; the registry already enforced it - // is within `[now - maxParameterAge, now]`, so the AMM has no further use for it. - // forge-lint: disable-next-line(unsafe-typecast) - // slither-disable-next-line unused-return - (, uint256[] memory slots) = - prioRegistry.getState(uint256(pairId), uint32(block.timestamp - maxParameterAge), uint32(block.timestamp)); - if (slots.length < 3) revert ParametersNotSet(); - params.concentration = slots[0]; - params.multX = slots[1]; - params.multY = slots[2]; + uint256[] memory slots = prioRegistry.getState(uint256(pairId), 4); + uint256 updateTimestamp = slots[0]; + if (updateTimestamp == 0) revert ParametersNotSet(); + // Comparing application-provided data with the current timestamp is the intended read-side freshness check. + // forge-lint: disable-next-line(block-timestamp) + if (updateTimestamp > block.timestamp || block.timestamp - updateTimestamp > maxParameterAge) { + revert StaleParameters(); + } + params.concentration = slots[1]; + params.multX = slots[2]; + params.multY = slots[3]; return params; } - function _encodeSlots(uint256 concentration, uint256 multX, uint256 multY) + function _encodeSlots(uint256 updateTimestamp, uint256 concentration, uint256 multX, uint256 multY) internal pure returns (uint256[] memory slots) { - slots = new uint256[](3); - slots[0] = concentration; - slots[1] = multX; - slots[2] = multY; + slots = new uint256[](4); + slots[0] = updateTimestamp; + slots[1] = concentration; + slots[2] = multX; + slots[3] = multY; } /** diff --git a/src/PrioUpdateRegistryV2.sol b/src/PrioUpdateRegistryV2.sol new file mode 100644 index 0000000..0875ad4 --- /dev/null +++ b/src/PrioUpdateRegistryV2.sol @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +/// @notice Interface for a lane decoder that validates an opaque payload and returns the slot values to store. +/// @dev The registry calls decoders with `STATICCALL`, so a decoder cannot modify state, emit events, +/// transfer value, create contracts, or self-destruct. A decoder should bind its payload to `target` +/// and `laneIndex` when those values are part of its authorization scheme. +interface IPrioUpdateDecoder { + /// @notice Validates `aux` for `target` and `laneIndex` and returns the slot values to store. + /// @dev Revert to reject the update. The returned array must have between 1 and 255 entries. + /// @param target The address whose state is being updated. + /// @param laneIndex The lane to write, scoped to `target`. + /// @param aux The opaque payload interpreted by the decoder. + /// @param trustedCallsHash `keccak256(abi.encode(calls))`. + /// @param callResults Ordered call return data. + /// @return slots The validated slot values to store. + function validateAndUnpack( + address target, + uint256 laneIndex, + bytes calldata aux, + bytes32 trustedCallsHash, + bytes[] calldata callResults + ) external view returns (uint256[] memory slots); +} + +/// @notice Stores raw per-target state written by authorized updaters or target-selected decoders. +/// @dev The registry does not interpret, generate, store, or validate freshness metadata. A target that +/// requires a timestamp, block number, sequence number, or other validity marker must include it in its +/// own slot layout and validate it when reading. Writes only replace the supplied slot prefix and are +/// not required to be monotonic. +contract PrioUpdateRegistryV2 { + struct TrustedCall { + address target; + bytes data; + } + + event UpdaterAdded(address indexed target, address indexed updater); + event UpdaterRemoved(address indexed target, address indexed updater); + event DecoderSet(address indexed target, uint256 indexed laneIndex, address indexed decoder); + + /// @notice Thrown when `msg.sender` is not authorized to update state on behalf of `target`. + error NotAuthorized(); + /// @notice Thrown when `slots` has length zero. + error EmptySlots(); + /// @notice Thrown when `slots` has more than 255 entries. + error TooManySlots(); + /// @notice Thrown when a read requests a slot outside the 255-slot lane region. + error SlotIndexOutOfRange(); + /// @notice Thrown when a decoder returns an empty slot array. + error DecoderReturnedNoSlots(); + /// @notice Thrown when a decoder has already been set for the lane. + error DecoderAlreadySet(); + /// @notice Thrown when a decoder update is requested for a lane without a decoder. + error DecoderNotSet(); + /// @notice Thrown when the updater path is used for a lane that has a decoder. + error DecoderBoundLane(); + /// @notice Thrown when `decoder` is the zero address. + error ZeroDecoder(); + /// @notice Thrown when `decoder` has no code at registration time. + error DecoderHasNoCode(); + error TrustedCallTargetHasNoCode(address target); + error UntrustedCallTarget(address target); + error CallbackNotAllowed(); + + /// @notice Maximum number of raw storage words in a lane. + /// @dev The bound prevents reads or writes from escaping the lane's reserved storage region. + uint256 internal constant MAX_SLOTS = 255; + + /// @dev Domain-separates lane storage from Solidity mapping storage and other hashed storage regions. + bytes32 private constant LANE_NAMESPACE = keccak256("PrioUpdateRegistryV2.lane.v1"); + + bytes32 private constant CALLBACK_LOCK_SLOT = keccak256("PrioUpdateRegistryV2.callbackLock"); + + address private immutable _trustedCallTarget0; + address private immutable _trustedCallTarget1; + + /// @notice Tracks whether `updater` is authorized to write state on behalf of `target`. + /// @dev Each target manages its own set of updaters via `addUpdater` and `removeUpdater`. + mapping(address target => mapping(address updater => bool)) public isUpdater; + + /// @notice Returns the decoder permanently assigned to `target` and `laneIndex`, or zero if none is set. + /// @dev A non-zero decoder marks the lane as decoder-managed and disables direct updater writes. + mapping(address target => mapping(uint256 laneIndex => address decoder)) public laneDecoder; + + modifier noCallback() { + if (_callbackLocked()) revert CallbackNotAllowed(); + _; + } + + modifier withCallbackLock() { + if (_callbackLocked()) revert CallbackNotAllowed(); + _setCallbackLock(true); + _; + _setCallbackLock(false); + } + + /// @param trustedCallTarget0 First trusted call target, or zero if unused. + /// @param trustedCallTarget1 Second trusted call target, or zero if unused. + constructor(address trustedCallTarget0, address trustedCallTarget1) { + if (trustedCallTarget0 != address(0) && trustedCallTarget0.code.length == 0) { + revert TrustedCallTargetHasNoCode(trustedCallTarget0); + } + if (trustedCallTarget1 != address(0) && trustedCallTarget1.code.length == 0) { + revert TrustedCallTargetHasNoCode(trustedCallTarget1); + } + _trustedCallTarget0 = trustedCallTarget0; + _trustedCallTarget1 = trustedCallTarget1; + } + + /// @notice Returns whether `target` is one of this deployment's trusted call targets. + function isTrustedCallTarget(address target) external view returns (bool) { + return _isTrustedCallTarget(target); + } + + /// @notice Authorizes `updater` to write state on behalf of `msg.sender`. + /// @dev The stored authorization is idempotent. The event is emitted even if `updater` is already authorized. + /// @param updater The address being granted write authorization. + function addUpdater(address updater) external noCallback { + isUpdater[msg.sender][updater] = true; + emit UpdaterAdded(msg.sender, updater); + } + + /// @notice Revokes authorization for `updater` to write state on behalf of `msg.sender`. + /// @dev The stored authorization is idempotent. The event is emitted even if `updater` is not authorized. + /// @param updater The address whose write authorization is being revoked. + function removeUpdater(address updater) external noCallback { + isUpdater[msg.sender][updater] = false; + emit UpdaterRemoved(msg.sender, updater); + } + + /// @notice Permanently assigns `decoder` to `msg.sender` at `laneIndex`. + /// @dev Once set, the decoder cannot be removed or replaced and direct updater writes to the lane are disabled. + /// The code-length check only applies at registration time. A proxy decoder may still change behavior. + /// @param laneIndex The lane to assign the decoder to, scoped to `msg.sender`. + /// @param decoder The contract that validates and unpacks updates for the lane. + function setDecoder(uint256 laneIndex, address decoder) external noCallback { + if (decoder == address(0)) revert ZeroDecoder(); + if (decoder.code.length == 0) revert DecoderHasNoCode(); + if (laneDecoder[msg.sender][laneIndex] != address(0)) revert DecoderAlreadySet(); + laneDecoder[msg.sender][laneIndex] = decoder; + emit DecoderSet(msg.sender, laneIndex, decoder); + } + + /* + * State + */ + + /// @notice Writes raw slot values for `target` at `laneIndex`. + /// @dev `msg.sender` must be an authorized updater for `target`, and the lane must not have a decoder. + /// The registry performs no freshness, ordering, or application-level validation. Each supplied word + /// is stored verbatim. A shorter write does not clear words left by an earlier longer write. + /// @param target The address whose state is being updated. + /// @param laneIndex The lane to write, scoped to `target`. + /// @param slots The raw slot values to write. Length must be in `[1, 255]`. + function updateState(address target, uint256 laneIndex, uint256[] calldata slots) external noCallback { + if (!isUpdater[target][msg.sender]) revert NotAuthorized(); + if (laneDecoder[target][laneIndex] != address(0)) revert DecoderBoundLane(); + _writeSlotsCalldata(target, laneIndex, slots); + } + + /// @notice Calls trusted targets, validates their results with the lane's decoder, and stores its slots. + /// @dev Anyone may relay an update. The decoder is responsible for authorization and all + /// application-level validation. Calls and validation are atomic. A shorter decoded update does not + /// clear words left by an earlier longer update. + /// @param target The address whose state is being updated. + /// @param laneIndex The decoder-managed lane to write, scoped to `target`. + /// @param aux The opaque payload passed to the lane's decoder. + /// @param calls Ordered zero-value calls. + function updateStateWithDecoder(address target, uint256 laneIndex, bytes calldata aux, TrustedCall[] calldata calls) + external + withCallbackLock + { + address decoder = laneDecoder[target][laneIndex]; + if (decoder == address(0)) revert DecoderNotSet(); + + uint256 callCount = calls.length; + for (uint256 i; i < callCount; ++i) { + if (!_isTrustedCallTarget(calls[i].target)) revert UntrustedCallTarget(calls[i].target); + } + + bytes32 trustedCallsHash = keccak256(abi.encode(calls)); + bytes[] memory callResults = new bytes[](callCount); + for (uint256 i; i < callCount; ++i) { + // slither-disable-next-line calls-loop,low-level-calls + (bool success, bytes memory result) = calls[i].target.call(calls[i].data); + if (!success) { + // slither-disable-next-line assembly + assembly { + revert(add(result, 0x20), mload(result)) + } + } + callResults[i] = result; + } + + uint256[] memory slots = + IPrioUpdateDecoder(decoder).validateAndUnpack(target, laneIndex, aux, trustedCallsHash, callResults); + _writeSlotsMemory(target, laneIndex, slots); + } + + /// @notice Returns one raw slot from `msg.sender`'s lane, or zero if it has never been written. + /// @dev Reads are scoped to `msg.sender`; a caller cannot use this function to read another target's lane. + /// No freshness or application-level validation is performed. + /// @param laneIndex The lane to read, scoped to `msg.sender`. + /// @param slotIndex The zero-based slot index. Must be less than 255. + /// @return value The raw stored value. + // Assembly is used to read the lane's computed storage slot directly. + // slither-disable-next-line assembly + function getSlot(uint256 laneIndex, uint256 slotIndex) external view noCallback returns (uint256 value) { + if (slotIndex >= MAX_SLOTS) revert SlotIndexOutOfRange(); + uint256 slot = _laneBase(msg.sender, laneIndex) + slotIndex; + assembly { + value := sload(slot) + } + } + + /// @notice Returns a contiguous range of raw slots from `msg.sender`'s lane. + /// @dev Reads are scoped to `msg.sender`. The returned range is + /// `[slotIndex, slotIndex + slotCount)`. Unwritten words return zero, and words left by a + /// shorter overwrite remain visible. No freshness or application-level validation is performed. + /// @param laneIndex The lane to read, scoped to `msg.sender`. + /// @param slotIndex The zero-based index of the first slot to return. + /// @param slotCount The number of slots to return. The requested range must fit within 255 slots. + /// @return slots The requested raw stored slot values. + function getSlots(uint256 laneIndex, uint256 slotIndex, uint256 slotCount) + external + view + noCallback + returns (uint256[] memory slots) + { + return _readSlots(msg.sender, laneIndex, slotIndex, slotCount); + } + + /// @notice Returns the first `count` raw slots from `msg.sender`'s lane. + /// @dev Reads are scoped to `msg.sender`. The registry does not store a lane length, so the caller + /// supplies `count`. Unwritten words return zero, and words left by a shorter overwrite remain visible. + /// No freshness or application-level validation is performed. + /// @param laneIndex The lane to read, scoped to `msg.sender`. + /// @param count The number of slots to return. Must not exceed 255. + /// @return slots The raw stored slot values. + function getState(uint256 laneIndex, uint256 count) external view noCallback returns (uint256[] memory slots) { + return _readSlots(msg.sender, laneIndex, 0, count); + } + + /// @notice Returns the base storage slot for `target` and `laneIndex`. + /// @dev Slot `i` of the lane is stored at `_laneBase(target, laneIndex) + i` for `0 <= i < 255`. + function _laneBase(address target, uint256 laneIndex) internal pure returns (uint256) { + return uint256(keccak256(abi.encode(LANE_NAMESPACE, target, laneIndex))); + } + + /// @notice Reads a contiguous range of raw slots for `target` at `laneIndex`. + /// @dev The range check uses subtraction to avoid overflowing `slotIndex + slotCount`. + // Assembly is used to read each computed storage slot directly. + // slither-disable-next-line assembly + function _readSlots(address target, uint256 laneIndex, uint256 slotIndex, uint256 slotCount) + internal + view + returns (uint256[] memory slots) + { + if (slotIndex > MAX_SLOTS || slotCount > MAX_SLOTS - slotIndex) revert SlotIndexOutOfRange(); + + slots = new uint256[](slotCount); + if (slotCount == 0) return slots; + + uint256 base = _laneBase(target, laneIndex) + slotIndex; + for (uint256 i; i < slotCount; ++i) { + uint256 slot = base + i; + uint256 value; + assembly { + value := sload(slot) + } + slots[i] = value; + } + } + + function _isTrustedCallTarget(address target) internal view returns (bool) { + return target != address(0) && (target == _trustedCallTarget0 || target == _trustedCallTarget1); + } + + // Assembly is required because Solidity does not expose transient storage directly. + // slither-disable-next-line assembly + function _callbackLocked() internal view returns (bool locked) { + bytes32 slot = CALLBACK_LOCK_SLOT; + assembly ("memory-safe") { + locked := tload(slot) + } + } + + // Assembly is required because Solidity does not expose transient storage directly. + // slither-disable-next-line assembly + function _setCallbackLock(bool locked) internal { + bytes32 slot = CALLBACK_LOCK_SLOT; + assembly ("memory-safe") { + tstore(slot, locked) + } + } + + /// @notice Writes calldata slot values verbatim for `target` at `laneIndex`. + /// @dev Does not perform authorization or application-level validation. The caller must enforce + /// the appropriate write path before invoking this function. + /// @param target The address whose state is being updated. + /// @param laneIndex The lane to write, scoped to `target`. + /// @param slots The raw slot values to write. Length must be in `[1, 255]`. + // Assembly is used to store words directly from calldata without copying the array to memory. + // slither-disable-next-line assembly + function _writeSlotsCalldata(address target, uint256 laneIndex, uint256[] calldata slots) internal { + uint256 count = slots.length; + if (count == 0) revert EmptySlots(); + if (count > MAX_SLOTS) revert TooManySlots(); + uint256 base = _laneBase(target, laneIndex); + assembly { + let offset := slots.offset + for { let i := 0 } lt(i, count) { i := add(i, 1) } { + sstore(add(base, i), calldataload(add(offset, mul(i, 0x20)))) + } + } + } + + /// @notice Writes decoder-returned slot values verbatim for `target` at `laneIndex`. + /// @dev Does not perform decoder selection or application-level validation. The caller must invoke + /// the registered decoder before calling this function. + /// @param target The address whose state is being updated. + /// @param laneIndex The lane to write, scoped to `target`. + /// @param slots The decoder-returned slot values. Length must be in `[1, 255]`. + // Assembly is used to store each memory word at its computed lane slot. + // slither-disable-next-line assembly + function _writeSlotsMemory(address target, uint256 laneIndex, uint256[] memory slots) internal { + uint256 count = slots.length; + if (count == 0) revert DecoderReturnedNoSlots(); + if (count > MAX_SLOTS) revert TooManySlots(); + uint256 base = _laneBase(target, laneIndex); + for (uint256 i; i < count; ++i) { + uint256 value = slots[i]; + uint256 slot = base + i; + assembly { + sstore(slot, value) + } + } + } +} diff --git a/test/ExamplePropAmm.t.sol b/test/ExamplePropAmm.t.sol index 0346838..bf5e528 100644 --- a/test/ExamplePropAmm.t.sol +++ b/test/ExamplePropAmm.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "../src/ExamplePropAmm.sol"; -import "../src/PrioUpdateRegistry.sol"; +import "../src/PrioUpdateRegistryV2.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; // Mock ERC20 token for testing @@ -24,7 +24,7 @@ contract MockERC20 is ERC20Burnable { } contract ExamplePropAmmTest is Test { - PrioUpdateRegistry public registry; + PrioUpdateRegistryV2 public registry; ExamplePropAmm public amm; MockERC20 public weth; MockERC20 public usdc; @@ -35,8 +35,6 @@ contract ExamplePropAmmTest is Test { bytes32 public wethUsdcPairId; - uint256 constant MAX_UPDATE_AGE = 1 hours; - uint256 constant MAX_UPDATE_LEAD_TIME = 1 hours; uint256 constant MAX_PARAMETER_AGE = 12; uint256 constant WETH_DECIMALS = 18; @@ -51,7 +49,7 @@ contract ExamplePropAmmTest is Test { weth = new MockERC20("Wrapped Ether", "WETH", 18); usdc = new MockERC20("USD Coin", "USDC", 6); - registry = new PrioUpdateRegistry(MAX_UPDATE_AGE, MAX_UPDATE_LEAD_TIME); + registry = new PrioUpdateRegistryV2(address(0), address(0)); amm = new ExamplePropAmm(marketMaker, registry, MAX_PARAMETER_AGE); weth.mint(marketMaker, INITIAL_WETH_LIQUIDITY); @@ -62,9 +60,19 @@ contract ExamplePropAmmTest is Test { } function _publishParameters(bytes32 pairId, uint256 concentration, uint256 multX, uint256 multY) internal { - uint256[] memory slots = amm.encodeParameterSlots(concentration, multX, multY); + _publishParametersAt(pairId, block.timestamp, concentration, multX, multY); + } + + function _publishParametersAt( + bytes32 pairId, + uint256 updateTimestamp, + uint256 concentration, + uint256 multX, + uint256 multY + ) internal { + uint256[] memory slots = amm.encodeParameterSlots(updateTimestamp, concentration, multX, multY); vm.prank(marketMaker); - registry.updateState(address(amm), uint256(pairId), uint32(block.timestamp), slots); + registry.updateState(address(amm), uint256(pairId), slots); } function test_CreatePair() public { @@ -243,19 +251,61 @@ contract ExamplePropAmmTest is Test { vm.startPrank(trader); weth.approve(address(amm), 1 ether); - vm.expectRevert(PrioUpdateRegistry.StaleUpdate.selector); + vm.expectRevert(ExamplePropAmm.StaleParameters.selector); amm.swapXtoY(wethUsdcPairId, 1 ether, 0); vm.stopPrank(); } + function test_FutureParametersRevert() public { + vm.prank(marketMaker); + wethUsdcPairId = amm.createPair(address(weth), address(usdc), 1, 0, 12); + + _publishParametersAt(wethUsdcPairId, block.timestamp + 1, 1, 4000, 10 ** 12); + + vm.expectRevert(ExamplePropAmm.StaleParameters.selector); + amm.getParameters(wethUsdcPairId); + } + + function test_ZeroTimestampParametersAreNotSet() public { + vm.prank(marketMaker); + wethUsdcPairId = amm.createPair(address(weth), address(usdc), 1, 0, 12); + + _publishParametersAt(wethUsdcPairId, 0, 1, 4000, 10 ** 12); + + vm.expectRevert(ExamplePropAmm.ParametersNotSet.selector); + amm.getParameters(wethUsdcPairId); + } + + function test_ParameterAgeBoundaryIsAccepted() public { + vm.prank(marketMaker); + wethUsdcPairId = amm.createPair(address(weth), address(usdc), 1, 0, 12); + + _publishParametersAt(wethUsdcPairId, block.timestamp - MAX_PARAMETER_AGE, 2, 3000, 10 ** 12); + + ExamplePropAmm.PairParameters memory params = amm.getParameters(wethUsdcPairId); + assertEq(params.concentration, 2); + assertEq(params.multX, 3000); + assertEq(params.multY, 10 ** 12); + } + + function test_EncodeParameterSlotsIncludesTimestamp() public view { + uint256[] memory slots = amm.encodeParameterSlots(123, 2, 3000, 10 ** 12); + + assertEq(slots.length, 4); + assertEq(slots[0], 123); + assertEq(slots[1], 2); + assertEq(slots[2], 3000); + assertEq(slots[3], 10 ** 12); + } + function test_UnauthorizedCannotPublishParameters() public { vm.prank(marketMaker); wethUsdcPairId = amm.createPair(address(weth), address(usdc), 1, 0, 12); - uint256[] memory slots = amm.encodeParameterSlots(1, 4000, 10 ** 12); + uint256[] memory slots = amm.encodeParameterSlots(block.timestamp, 1, 4000, 10 ** 12); vm.prank(trader); - vm.expectRevert(PrioUpdateRegistry.NotAuthorized.selector); - registry.updateState(address(amm), uint256(wethUsdcPairId), uint32(block.timestamp), slots); + vm.expectRevert(PrioUpdateRegistryV2.NotAuthorized.selector); + registry.updateState(address(amm), uint256(wethUsdcPairId), slots); } function test_SetMarketMakerRotatesUpdater() public { diff --git a/test/PrioUpdateRegistryV2.t.sol b/test/PrioUpdateRegistryV2.t.sol new file mode 100644 index 0000000..da268d9 --- /dev/null +++ b/test/PrioUpdateRegistryV2.t.sol @@ -0,0 +1,897 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {PrioUpdateRegistryV2, IPrioUpdateDecoder} from "../src/PrioUpdateRegistryV2.sol"; + +contract PrioUpdateRegistryV2Test is Test { + event UpdaterAdded(address indexed target, address indexed updater); + event UpdaterRemoved(address indexed target, address indexed updater); + event DecoderSet(address indexed target, uint256 indexed laneIndex, address indexed decoder); + + PrioUpdateRegistryV2 internal registry; + + address internal target = makeAddr("target"); + address internal otherTarget = makeAddr("otherTarget"); + address internal updater = makeAddr("updater"); + address internal otherUpdater = makeAddr("otherUpdater"); + address internal relayer = makeAddr("relayer"); + + uint256 internal constant MAX_SLOTS = 255; + + function setUp() public { + registry = new PrioUpdateRegistryV2(address(0), address(0)); + _authorize(target, updater); + } + + function _noCalls() internal pure returns (PrioUpdateRegistryV2.TrustedCall[] memory calls) { + return new PrioUpdateRegistryV2.TrustedCall[](0); + } + + function _trustedCall(address callTarget, bytes memory data) + internal + pure + returns (PrioUpdateRegistryV2.TrustedCall memory) + { + return PrioUpdateRegistryV2.TrustedCall({target: callTarget, data: data}); + } + + function _authorize(address target_, address updater_) internal { + vm.prank(target_); + registry.addUpdater(updater_); + } + + function _setDecoder(address target_, uint256 laneIndex, address decoder) internal { + vm.prank(target_); + registry.setDecoder(laneIndex, decoder); + } + + function _write(address target_, address updater_, uint256 laneIndex, uint256[] memory slots) internal { + vm.prank(updater_); + registry.updateState(target_, laneIndex, slots); + } + + function _read(address target_, uint256 laneIndex, uint256 count) internal returns (uint256[] memory slots) { + vm.prank(target_); + return registry.getState(laneIndex, count); + } + + function _readRange(address target_, uint256 laneIndex, uint256 slotIndex, uint256 slotCount) + internal + returns (uint256[] memory slots) + { + vm.prank(target_); + return registry.getSlots(laneIndex, slotIndex, slotCount); + } + + /* + * Updaters + */ + + function test_addUpdater() public { + vm.expectEmit(true, true, false, true, address(registry)); + emit UpdaterAdded(target, otherUpdater); + vm.prank(target); + registry.addUpdater(otherUpdater); + + assertTrue(registry.isUpdater(target, otherUpdater)); + } + + function test_addUpdater_emitsWhenAlreadyAuthorized() public { + vm.expectEmit(true, true, false, true, address(registry)); + emit UpdaterAdded(target, updater); + vm.prank(target); + registry.addUpdater(updater); + + assertTrue(registry.isUpdater(target, updater)); + } + + function test_removeUpdater() public { + vm.expectEmit(true, true, false, true, address(registry)); + emit UpdaterRemoved(target, updater); + vm.prank(target); + registry.removeUpdater(updater); + + assertFalse(registry.isUpdater(target, updater)); + } + + function test_removeUpdater_emitsWhenNotAuthorized() public { + vm.expectEmit(true, true, false, true, address(registry)); + emit UpdaterRemoved(target, otherUpdater); + vm.prank(target); + registry.removeUpdater(otherUpdater); + + assertFalse(registry.isUpdater(target, otherUpdater)); + } + + function test_updaterAuthorizationIsTargetScoped() public view { + assertTrue(registry.isUpdater(target, updater)); + assertFalse(registry.isUpdater(otherTarget, updater)); + } + + function test_removedUpdaterCannotWrite() public { + vm.prank(target); + registry.removeUpdater(updater); + + uint256[] memory slots = new uint256[](1); + vm.prank(updater); + vm.expectRevert(PrioUpdateRegistryV2.NotAuthorized.selector); + registry.updateState(target, 0, slots); + } + + /* + * Raw State + */ + + function test_writeAndReadSingleFullWidthSlot() public { + uint256[] memory slots = new uint256[](1); + slots[0] = type(uint256).max; + _write(target, updater, 0, slots); + + uint256[] memory stored = _read(target, 0, 1); + assertEq(stored, slots); + + vm.prank(target); + assertEq(registry.getSlot(0, 0), type(uint256).max); + } + + function test_writeAndReadMultipleSlots() public { + uint256[] memory slots = new uint256[](3); + slots[0] = 11; + slots[1] = 22; + slots[2] = 33; + _write(target, updater, 7, slots); + + uint256[] memory stored = _read(target, 7, 3); + assertEq(stored, slots); + } + + function test_getSlotsReturnsRequestedRange() public { + uint256[] memory slots = new uint256[](5); + slots[0] = 11; + slots[1] = 22; + slots[2] = 33; + slots[3] = 44; + slots[4] = 55; + _write(target, updater, 7, slots); + + uint256[] memory stored = _readRange(target, 7, 1, 3); + assertEq(stored.length, 3); + assertEq(stored[0], 22); + assertEq(stored[1], 33); + assertEq(stored[2], 44); + } + + function test_unwrittenSlotsReturnZero() public { + uint256[] memory stored = _read(target, 9, 3); + assertEq(stored.length, 3); + assertEq(stored[0], 0); + assertEq(stored[1], 0); + assertEq(stored[2], 0); + + vm.prank(target); + assertEq(registry.getSlot(9, 42), 0); + } + + function test_getStateAllowsZeroCount() public { + uint256[] memory stored = _read(target, 0, 0); + assertEq(stored.length, 0); + } + + function test_getSlotsAllowsEmptyRangeAtLaneEnd() public { + uint256[] memory stored = _readRange(target, 0, MAX_SLOTS, 0); + assertEq(stored.length, 0); + } + + function test_readsAreSelfScoped() public { + _authorize(otherTarget, updater); + + uint256[] memory targetSlots = new uint256[](1); + targetSlots[0] = 111; + _write(target, updater, 0, targetSlots); + + uint256[] memory otherSlots = new uint256[](1); + otherSlots[0] = 222; + _write(otherTarget, updater, 0, otherSlots); + + assertEq(_read(target, 0, 1)[0], 111); + assertEq(_read(otherTarget, 0, 1)[0], 222); + assertEq(_readRange(target, 0, 0, 1)[0], 111); + assertEq(_readRange(otherTarget, 0, 0, 1)[0], 222); + } + + function test_lanesAreIndependent() public { + uint256[] memory first = new uint256[](1); + first[0] = 111; + _write(target, updater, 1, first); + + uint256[] memory second = new uint256[](1); + second[0] = 222; + _write(target, updater, 2, second); + + assertEq(_read(target, 1, 1)[0], 111); + assertEq(_read(target, 2, 1)[0], 222); + } + + function test_shorterWritePreservesTrailingSlots() public { + uint256[] memory first = new uint256[](3); + first[0] = 1; + first[1] = 2; + first[2] = 3; + _write(target, updater, 0, first); + + uint256[] memory second = new uint256[](1); + second[0] = 9; + _write(target, updater, 0, second); + + uint256[] memory stored = _read(target, 0, 3); + assertEq(stored[0], 9); + assertEq(stored[1], 2); + assertEq(stored[2], 3); + } + + function test_applicationValueCanMoveBackward() public { + uint256[] memory first = new uint256[](1); + first[0] = 1_000; + _write(target, updater, 0, first); + + uint256[] memory second = new uint256[](1); + second[0] = 1; + _write(target, updater, 0, second); + + assertEq(_read(target, 0, 1)[0], 1); + } + + function test_blockAndTimestampDoNotAffectState() public { + uint256[] memory slots = new uint256[](2); + slots[0] = block.timestamp; + slots[1] = block.number; + _write(target, updater, 0, slots); + + vm.warp(block.timestamp + 365 days); + vm.roll(block.number + 1_000_000); + + uint256[] memory stored = _read(target, 0, 2); + assertEq(stored, slots); + } + + function test_updateStateRevertsForUnauthorizedCaller() public { + uint256[] memory slots = new uint256[](1); + vm.prank(otherUpdater); + vm.expectRevert(PrioUpdateRegistryV2.NotAuthorized.selector); + registry.updateState(target, 0, slots); + } + + function test_updateStateRevertsForEmptySlots() public { + uint256[] memory slots = new uint256[](0); + vm.prank(updater); + vm.expectRevert(PrioUpdateRegistryV2.EmptySlots.selector); + registry.updateState(target, 0, slots); + } + + function test_updateStateRevertsForTooManySlots() public { + uint256[] memory slots = new uint256[](MAX_SLOTS + 1); + vm.prank(updater); + vm.expectRevert(PrioUpdateRegistryV2.TooManySlots.selector); + registry.updateState(target, 0, slots); + } + + function test_maximumSlotCountRoundTrip() public { + uint256[] memory slots = new uint256[](MAX_SLOTS); + for (uint256 i; i < slots.length; ++i) { + slots[i] = i + 1; + } + _write(target, updater, 0, slots); + + assertEq(_read(target, 0, MAX_SLOTS), slots); + assertEq(_readRange(target, 0, 0, MAX_SLOTS), slots); + assertEq(_readRange(target, 0, MAX_SLOTS - 1, 1)[0], MAX_SLOTS); + } + + function test_getSlotRevertsAtMaximumSlotIndex() public { + vm.prank(target); + vm.expectRevert(PrioUpdateRegistryV2.SlotIndexOutOfRange.selector); + registry.getSlot(0, MAX_SLOTS); + } + + function test_getStateRevertsAboveMaximumCount() public { + vm.prank(target); + vm.expectRevert(PrioUpdateRegistryV2.SlotIndexOutOfRange.selector); + registry.getState(0, MAX_SLOTS + 1); + } + + function test_getSlotsRevertsWhenStartIsPastLaneEnd() public { + vm.prank(target); + vm.expectRevert(PrioUpdateRegistryV2.SlotIndexOutOfRange.selector); + registry.getSlots(0, MAX_SLOTS + 1, 0); + } + + function test_getSlotsRevertsWhenRangeExceedsLaneEnd() public { + vm.prank(target); + vm.expectRevert(PrioUpdateRegistryV2.SlotIndexOutOfRange.selector); + registry.getSlots(0, MAX_SLOTS - 1, 2); + } + + function test_getSlotsLargeIndexUsesCustomError() public { + vm.prank(target); + vm.expectRevert(PrioUpdateRegistryV2.SlotIndexOutOfRange.selector); + registry.getSlots(0, type(uint256).max, 1); + } + + function testFuzz_rawWordsRoundTrip(uint256 laneIndex, uint256 first, uint256 second, uint256 third) public { + uint256[] memory slots = new uint256[](3); + slots[0] = first; + slots[1] = second; + slots[2] = third; + _write(target, updater, laneIndex, slots); + + assertEq(_read(target, laneIndex, 3), slots); + } + + function testFuzz_getSlotsReturnsRange(uint256 laneIndex, uint256 slotIndex, uint256 slotCount) public { + uint256[] memory slots = new uint256[](8); + for (uint256 i; i < slots.length; ++i) { + slots[i] = i + 1; + } + _write(target, updater, laneIndex, slots); + + slotIndex = bound(slotIndex, 0, slots.length); + slotCount = bound(slotCount, 0, slots.length - slotIndex); + uint256[] memory stored = _readRange(target, laneIndex, slotIndex, slotCount); + + assertEq(stored.length, slotCount); + for (uint256 i; i < slotCount; ++i) { + assertEq(stored[i], slots[slotIndex + i]); + } + } + + /* + * Trusted Call Configuration + */ + + function test_trustedCallTargetMembershipSupportsZeroOneOrTwoTargets() public { + TrustedCallTarget first = new TrustedCallTarget(); + TrustedCallTarget second = new TrustedCallTarget(); + + PrioUpdateRegistryV2 none = new PrioUpdateRegistryV2(address(0), address(0)); + assertFalse(none.isTrustedCallTarget(address(0))); + assertFalse(none.isTrustedCallTarget(address(first))); + + PrioUpdateRegistryV2 one = new PrioUpdateRegistryV2(address(first), address(0)); + assertTrue(one.isTrustedCallTarget(address(first))); + assertFalse(one.isTrustedCallTarget(address(second))); + assertFalse(one.isTrustedCallTarget(address(0))); + + PrioUpdateRegistryV2 two = new PrioUpdateRegistryV2(address(first), address(second)); + assertTrue(two.isTrustedCallTarget(address(first))); + assertTrue(two.isTrustedCallTarget(address(second))); + } + + function test_duplicateTrustedCallTargetsAreAllowed() public { + TrustedCallTarget callTarget = new TrustedCallTarget(); + PrioUpdateRegistryV2 duplicate = new PrioUpdateRegistryV2(address(callTarget), address(callTarget)); + + assertTrue(duplicate.isTrustedCallTarget(address(callTarget))); + } + + function test_constructorRevertsForFirstTargetWithoutCode() public { + vm.expectRevert(abi.encodeWithSelector(PrioUpdateRegistryV2.TrustedCallTargetHasNoCode.selector, relayer)); + new PrioUpdateRegistryV2(relayer, address(0)); + } + + function test_constructorRevertsForSecondTargetWithoutCode() public { + TrustedCallTarget first = new TrustedCallTarget(); + + vm.expectRevert(abi.encodeWithSelector(PrioUpdateRegistryV2.TrustedCallTargetHasNoCode.selector, relayer)); + new PrioUpdateRegistryV2(address(first), relayer); + } + + function test_trustedTargetAddressesHaveNoIndividualGetters() public { + TrustedCallTarget callTarget = new TrustedCallTarget(); + PrioUpdateRegistryV2 configured = new PrioUpdateRegistryV2(address(callTarget), address(0)); + + (bool firstSuccess,) = address(configured).staticcall(abi.encodeWithSignature("trustedCallTarget0()")); + (bool secondSuccess,) = address(configured).staticcall(abi.encodeWithSignature("trustedCallTarget1()")); + + assertFalse(firstSuccess); + assertFalse(secondSuccess); + } + + /* + * Decoders + */ + + function test_setDecoder() public { + RawDecoder decoder = new RawDecoder(); + + vm.expectEmit(true, true, true, true, address(registry)); + emit DecoderSet(target, 4, address(decoder)); + vm.prank(target); + registry.setDecoder(4, address(decoder)); + + assertEq(registry.laneDecoder(target, 4), address(decoder)); + } + + function test_setDecoderRevertsForZeroAddress() public { + vm.prank(target); + vm.expectRevert(PrioUpdateRegistryV2.ZeroDecoder.selector); + registry.setDecoder(0, address(0)); + } + + function test_setDecoderRevertsForAddressWithoutCode() public { + vm.prank(target); + vm.expectRevert(PrioUpdateRegistryV2.DecoderHasNoCode.selector); + registry.setDecoder(0, relayer); + } + + function test_setDecoderCannotReplaceExistingDecoder() public { + RawDecoder first = new RawDecoder(); + RawDecoder second = new RawDecoder(); + _setDecoder(target, 0, address(first)); + + vm.prank(target); + vm.expectRevert(PrioUpdateRegistryV2.DecoderAlreadySet.selector); + registry.setDecoder(0, address(second)); + } + + function test_decoderIsTargetAndLaneScoped() public { + RawDecoder decoder = new RawDecoder(); + _setDecoder(target, 3, address(decoder)); + + assertEq(registry.laneDecoder(target, 3), address(decoder)); + assertEq(registry.laneDecoder(target, 4), address(0)); + assertEq(registry.laneDecoder(otherTarget, 3), address(0)); + } + + function test_directWriteRevertsForDecoderManagedLane() public { + RawDecoder decoder = new RawDecoder(); + _setDecoder(target, 0, address(decoder)); + + uint256[] memory slots = new uint256[](1); + vm.prank(updater); + vm.expectRevert(PrioUpdateRegistryV2.DecoderBoundLane.selector); + registry.updateState(target, 0, slots); + } + + function test_decoderWriteRevertsForLaneWithoutDecoder() public { + vm.prank(relayer); + vm.expectRevert(PrioUpdateRegistryV2.DecoderNotSet.selector); + registry.updateStateWithDecoder(target, 0, bytes(""), _noCalls()); + } + + function test_decoderWriteIsPermissionless() public { + RawDecoder decoder = new RawDecoder(); + _setDecoder(target, 0, address(decoder)); + + uint256[] memory slots = new uint256[](2); + slots[0] = type(uint256).max; + slots[1] = 42; + + vm.prank(relayer); + registry.updateStateWithDecoder(target, 0, abi.encode(slots), _noCalls()); + + assertEq(_read(target, 0, 2), slots); + } + + function test_decoderReceivesTargetAndLane() public { + ArgumentDecoder decoder = new ArgumentDecoder(); + _setDecoder(target, 123, address(decoder)); + + vm.prank(relayer); + registry.updateStateWithDecoder(target, 123, bytes(""), _noCalls()); + + uint256[] memory stored = _read(target, 123, 2); + assertEq(stored[0], uint256(uint160(target))); + assertEq(stored[1], 123); + } + + function test_decoderWriteRevertsForEmptySlots() public { + RawDecoder decoder = new RawDecoder(); + _setDecoder(target, 0, address(decoder)); + + uint256[] memory slots = new uint256[](0); + vm.prank(relayer); + vm.expectRevert(PrioUpdateRegistryV2.DecoderReturnedNoSlots.selector); + registry.updateStateWithDecoder(target, 0, abi.encode(slots), _noCalls()); + } + + function test_decoderWriteRevertsForTooManySlots() public { + RawDecoder decoder = new RawDecoder(); + _setDecoder(target, 0, address(decoder)); + + uint256[] memory slots = new uint256[](MAX_SLOTS + 1); + vm.prank(relayer); + vm.expectRevert(PrioUpdateRegistryV2.TooManySlots.selector); + registry.updateStateWithDecoder(target, 0, abi.encode(slots), _noCalls()); + } + + function test_decoderRevertBubbles() public { + RevertingDecoder decoder = new RevertingDecoder(); + _setDecoder(target, 0, address(decoder)); + + vm.prank(relayer); + vm.expectRevert(RevertingDecoder.Rejected.selector); + registry.updateStateWithDecoder(target, 0, bytes(""), _noCalls()); + } + + function test_decoderRunsUnderStaticcall() public { + StateWritingDecoder decoder = new StateWritingDecoder(); + _setDecoder(target, 0, address(decoder)); + + vm.prank(relayer); + (bool success,) = address(registry).call{gas: 100_000}( + abi.encodeCall(PrioUpdateRegistryV2.updateStateWithDecoder, (target, 0, bytes(""), _noCalls())) + ); + + assertFalse(success); + assertEq(decoder.value(), 0); + } + + function test_shorterDecoderWritePreservesTrailingSlots() public { + RawDecoder decoder = new RawDecoder(); + _setDecoder(target, 0, address(decoder)); + + uint256[] memory first = new uint256[](3); + first[0] = 1; + first[1] = 2; + first[2] = 3; + registry.updateStateWithDecoder(target, 0, abi.encode(first), _noCalls()); + + uint256[] memory second = new uint256[](1); + second[0] = 9; + registry.updateStateWithDecoder(target, 0, abi.encode(second), _noCalls()); + + uint256[] memory stored = _read(target, 0, 3); + assertEq(stored[0], 9); + assertEq(stored[1], 2); + assertEq(stored[2], 3); + } + + /* + * Trusted Calls + */ + + function test_trustedCallsExecuteInOrderAndResultsReachDecoder() public { + TrustedCallTarget callTarget = new TrustedCallTarget(); + registry = new PrioUpdateRegistryV2(address(callTarget), address(0)); + ResultsDecoder decoder = new ResultsDecoder(); + _setDecoder(target, 7, address(decoder)); + + PrioUpdateRegistryV2.TrustedCall[] memory calls = new PrioUpdateRegistryV2.TrustedCall[](2); + calls[0] = _trustedCall(address(callTarget), abi.encodeCall(TrustedCallTarget.setAndReturn, (0, 11))); + calls[1] = _trustedCall(address(callTarget), abi.encodeCall(TrustedCallTarget.setAndReturn, (11, 22))); + + vm.prank(relayer); + registry.updateStateWithDecoder(target, 7, bytes(""), calls); + + assertEq(callTarget.value(), 22); + assertEq(callTarget.lastCaller(), address(registry)); + uint256[] memory stored = _read(target, 7, 4); + assertEq(stored[0], 11); + assertEq(stored[1], uint256(uint160(address(registry)))); + assertEq(stored[2], 22); + assertEq(stored[3], uint256(uint160(address(registry)))); + } + + function test_auxCommitmentBindsExactTrustedCallsEvenWhenResultsMatch() public { + TrustedCallTarget callTarget = new TrustedCallTarget(); + registry = new PrioUpdateRegistryV2(address(callTarget), address(0)); + CallCommitmentDecoder decoder = new CallCommitmentDecoder(); + _setDecoder(target, 0, address(decoder)); + + PrioUpdateRegistryV2.TrustedCall[] memory authorizedCalls = new PrioUpdateRegistryV2.TrustedCall[](1); + authorizedCalls[0] = + _trustedCall(address(callTarget), abi.encodeCall(TrustedCallTarget.setValueAndReturn, (11, 42))); + bytes memory aux = abi.encode(keccak256(abi.encode(authorizedCalls))); + + registry.updateStateWithDecoder(target, 0, aux, authorizedCalls); + + assertEq(callTarget.value(), 11); + assertEq(_read(target, 0, 1)[0], 42); + + PrioUpdateRegistryV2.TrustedCall[] memory alteredCalls = new PrioUpdateRegistryV2.TrustedCall[](1); + alteredCalls[0] = + _trustedCall(address(callTarget), abi.encodeCall(TrustedCallTarget.setValueAndReturn, (22, 42))); + + vm.expectRevert(CallCommitmentDecoder.TrustedCallsHashMismatch.selector); + registry.updateStateWithDecoder(target, 0, aux, alteredCalls); + + assertEq(callTarget.value(), 11); + assertEq(_read(target, 0, 1)[0], 42); + } + + function test_emptyTrustedCallsForwardEmptyResults() public { + EmptyResultsDecoder decoder = new EmptyResultsDecoder(); + _setDecoder(target, 0, address(decoder)); + + registry.updateStateWithDecoder(target, 0, bytes(""), _noCalls()); + + assertEq(_read(target, 0, 1)[0], 1); + } + + function test_untrustedTargetRevertsBeforeAnyCallExecutes() public { + TrustedCallTarget allowed = new TrustedCallTarget(); + TrustedCallTarget untrusted = new TrustedCallTarget(); + registry = new PrioUpdateRegistryV2(address(allowed), address(0)); + ResultsDecoder decoder = new ResultsDecoder(); + _setDecoder(target, 0, address(decoder)); + + PrioUpdateRegistryV2.TrustedCall[] memory calls = new PrioUpdateRegistryV2.TrustedCall[](2); + calls[0] = _trustedCall(address(allowed), abi.encodeCall(TrustedCallTarget.setAndReturn, (0, 11))); + calls[1] = _trustedCall(address(untrusted), abi.encodeCall(TrustedCallTarget.setAndReturn, (0, 22))); + + vm.expectRevert(abi.encodeWithSelector(PrioUpdateRegistryV2.UntrustedCallTarget.selector, address(untrusted))); + registry.updateStateWithDecoder(target, 0, bytes(""), calls); + + assertEq(allowed.value(), 0); + assertEq(untrusted.value(), 0); + } + + function test_trustedCallRevertBubblesAndRollsBackEarlierCalls() public { + TrustedCallTarget callTarget = new TrustedCallTarget(); + registry = new PrioUpdateRegistryV2(address(callTarget), address(0)); + ResultsDecoder decoder = new ResultsDecoder(); + _setDecoder(target, 0, address(decoder)); + + PrioUpdateRegistryV2.TrustedCall[] memory calls = new PrioUpdateRegistryV2.TrustedCall[](2); + calls[0] = _trustedCall(address(callTarget), abi.encodeCall(TrustedCallTarget.setAndReturn, (0, 11))); + calls[1] = _trustedCall(address(callTarget), abi.encodeCall(TrustedCallTarget.reject, (42))); + + vm.expectRevert(abi.encodeWithSelector(TrustedCallTarget.Rejected.selector, 42)); + registry.updateStateWithDecoder(target, 0, bytes(""), calls); + + assertEq(callTarget.value(), 0); + assertEq(_read(target, 0, 1)[0], 0); + } + + function test_decoderRevertRollsBackTrustedCalls() public { + TrustedCallTarget callTarget = new TrustedCallTarget(); + registry = new PrioUpdateRegistryV2(address(callTarget), address(0)); + RevertingDecoder decoder = new RevertingDecoder(); + _setDecoder(target, 0, address(decoder)); + + PrioUpdateRegistryV2.TrustedCall[] memory calls = new PrioUpdateRegistryV2.TrustedCall[](1); + calls[0] = _trustedCall(address(callTarget), abi.encodeCall(TrustedCallTarget.setAndReturn, (0, 11))); + + vm.expectRevert(RevertingDecoder.Rejected.selector); + registry.updateStateWithDecoder(target, 0, bytes(""), calls); + + assertEq(callTarget.value(), 0); + assertEq(_read(target, 0, 1)[0], 0); + } + + function test_trustedTargetCallbacksToProtectedRegistryEntryPointsAreBlocked() public { + CallbackTarget callbackTarget = new CallbackTarget(); + registry = new PrioUpdateRegistryV2(address(callbackTarget), address(0)); + CallbackResultsDecoder decoder = new CallbackResultsDecoder(); + _setDecoder(target, 0, address(decoder)); + + uint256[] memory emptySlots = new uint256[](0); + bytes[] memory callbackData = new bytes[](8); + callbackData[0] = abi.encodeCall(PrioUpdateRegistryV2.addUpdater, (updater)); + callbackData[1] = abi.encodeCall(PrioUpdateRegistryV2.removeUpdater, (updater)); + callbackData[2] = abi.encodeCall(PrioUpdateRegistryV2.setDecoder, (1, address(decoder))); + callbackData[3] = abi.encodeCall(PrioUpdateRegistryV2.updateState, (target, 0, emptySlots)); + callbackData[4] = + abi.encodeCall(PrioUpdateRegistryV2.updateStateWithDecoder, (target, 0, bytes(""), _noCalls())); + callbackData[5] = abi.encodeCall(PrioUpdateRegistryV2.getSlot, (0, 0)); + callbackData[6] = abi.encodeCall(PrioUpdateRegistryV2.getState, (0, 0)); + callbackData[7] = abi.encodeCall(PrioUpdateRegistryV2.getSlots, (0, 0, 0)); + + PrioUpdateRegistryV2.TrustedCall[] memory calls = new PrioUpdateRegistryV2.TrustedCall[](callbackData.length); + for (uint256 i; i < calls.length; ++i) { + calls[i] = _trustedCall( + address(callbackTarget), + abi.encodeCall(CallbackTarget.attemptCallback, (address(registry), callbackData[i])) + ); + } + + registry.updateStateWithDecoder(target, 0, bytes(""), calls); + + assertEq(_read(target, 0, 1)[0], callbackData.length); + } + + function test_decoderCanReadPublicMappingGetterDuringValidation() public { + CallbackDecoder decoder = new CallbackDecoder(registry); + _setDecoder(target, 0, address(decoder)); + + registry.updateStateWithDecoder(target, 0, bytes(""), _noCalls()); + + assertEq(_read(target, 0, 1)[0], 1); + } + + /* + * Storage Isolation + */ + + function test_craftedLaneCannotForgeUpdaterAuthorization() public { + address victim = makeAddr("victim"); + address attacker = makeAddr("attacker"); + uint256 craftedLane = uint256(keccak256(abi.encode(victim, uint256(0)))); + + _authorize(attacker, attacker); + uint256[] memory slots = new uint256[](1); + slots[0] = 1; + _write(attacker, attacker, craftedLane, slots); + + assertFalse(registry.isUpdater(victim, attacker)); + + vm.prank(attacker); + vm.expectRevert(PrioUpdateRegistryV2.NotAuthorized.selector); + registry.updateState(victim, 0, slots); + } +} + +contract RawDecoder is IPrioUpdateDecoder { + function validateAndUnpack(address, uint256, bytes calldata aux, bytes32, bytes[] calldata) + external + pure + returns (uint256[] memory slots) + { + return abi.decode(aux, (uint256[])); + } +} + +contract ArgumentDecoder is IPrioUpdateDecoder { + function validateAndUnpack(address target, uint256 laneIndex, bytes calldata, bytes32, bytes[] calldata) + external + pure + returns (uint256[] memory slots) + { + slots = new uint256[](2); + slots[0] = uint256(uint160(target)); + slots[1] = laneIndex; + } +} + +contract RevertingDecoder is IPrioUpdateDecoder { + error Rejected(); + + function validateAndUnpack(address, uint256, bytes calldata, bytes32, bytes[] calldata) + external + pure + returns (uint256[] memory) + { + revert Rejected(); + } +} + +contract StateWritingDecoder { + uint256 public value; + + function validateAndUnpack(address, uint256, bytes calldata, bytes32, bytes[] calldata) + external + returns (uint256[] memory slots) + { + value = 1; + slots = new uint256[](1); + slots[0] = 1; + } +} + +contract TrustedCallTarget { + error UnexpectedValue(); + error Rejected(uint256 reason); + + uint256 public value; + address public lastCaller; + + function setAndReturn(uint256 expectedValue, uint256 newValue) + external + returns (uint256 returnedValue, address caller) + { + if (value != expectedValue) revert UnexpectedValue(); + value = newValue; + lastCaller = msg.sender; + return (newValue, msg.sender); + } + + function setValueAndReturn(uint256 newValue, uint256 returnedValue) external returns (uint256) { + value = newValue; + lastCaller = msg.sender; + return returnedValue; + } + + function reject(uint256 reason) external pure { + revert Rejected(reason); + } +} + +contract ResultsDecoder is IPrioUpdateDecoder { + function validateAndUnpack(address, uint256, bytes calldata, bytes32, bytes[] calldata callResults) + external + pure + returns (uint256[] memory slots) + { + slots = new uint256[](callResults.length * 2); + for (uint256 i; i < callResults.length; ++i) { + (uint256 value, address caller) = abi.decode(callResults[i], (uint256, address)); + slots[i * 2] = value; + slots[i * 2 + 1] = uint256(uint160(caller)); + } + } +} + +contract CallCommitmentDecoder is IPrioUpdateDecoder { + error TrustedCallsHashMismatch(); + error UnexpectedResults(); + + function validateAndUnpack( + address, + uint256, + bytes calldata aux, + bytes32 trustedCallsHash, + bytes[] calldata callResults + ) external pure returns (uint256[] memory slots) { + if (abi.decode(aux, (bytes32)) != trustedCallsHash) revert TrustedCallsHashMismatch(); + if (callResults.length != 1) revert UnexpectedResults(); + + slots = new uint256[](1); + slots[0] = abi.decode(callResults[0], (uint256)); + } +} + +contract EmptyResultsDecoder is IPrioUpdateDecoder { + error UnexpectedResults(); + + function validateAndUnpack(address, uint256, bytes calldata, bytes32, bytes[] calldata callResults) + external + pure + returns (uint256[] memory slots) + { + if (callResults.length != 0) revert UnexpectedResults(); + slots = new uint256[](1); + slots[0] = 1; + } +} + +contract CallbackTarget { + error CallbackSucceeded(); + error UnexpectedCallbackError(); + + function attemptCallback(address registry, bytes calldata data) external returns (bytes4 selector) { + (bool success, bytes memory result) = registry.call(data); + if (success) revert CallbackSucceeded(); + if (result.length < 4) revert UnexpectedCallbackError(); + assembly { + selector := mload(add(result, 0x20)) + } + if (selector != PrioUpdateRegistryV2.CallbackNotAllowed.selector) revert UnexpectedCallbackError(); + } +} + +contract CallbackResultsDecoder is IPrioUpdateDecoder { + error UnexpectedCallbackResult(); + + function validateAndUnpack(address, uint256, bytes calldata, bytes32, bytes[] calldata callResults) + external + pure + returns (uint256[] memory slots) + { + for (uint256 i; i < callResults.length; ++i) { + if (abi.decode(callResults[i], (bytes4)) != PrioUpdateRegistryV2.CallbackNotAllowed.selector) { + revert UnexpectedCallbackResult(); + } + } + slots = new uint256[](1); + slots[0] = callResults.length; + } +} + +contract CallbackDecoder is IPrioUpdateDecoder { + PrioUpdateRegistryV2 private immutable registry; + + constructor(PrioUpdateRegistryV2 registry_) { + registry = registry_; + } + + function validateAndUnpack(address, uint256, bytes calldata, bytes32, bytes[] calldata) + external + view + returns (uint256[] memory slots) + { + registry.isUpdater(address(this), address(this)); + slots = new uint256[](1); + slots[0] = 1; + } +}