AZIP-4 L1 Block Header Access via an L1 Portal#44
Conversation
|
|
||
| ## Impacted Stakeholders | ||
|
|
||
| **Sequencers / proposers.** Proposing a checkpoint gains one new duty: include `BlockHashPortal.pushLatest()` before `propose()` in the same L1 transaction. Omission or out-of-order placement is slashable. The added L1 gas is ~10k gas in-protocol plus ~20–50k gas for the portal call per checkpoint, depending on whether the inbox tree was already touched between checkpoints. The call takes no arguments, and proposers are not responsible for consuming the message on L2. |
There was a problem hiding this comment.
Is this effectively introducing a proposer slash for missing a checkpoint proposal?
There was a problem hiding this comment.
No — this offense is conditioned on a checkpoint actually being proposed. Detection anchors on the rollup's CheckpointProposed log in the proposer's L1 tx receipt and asks: "did a BlockHashPushed log from the configured portal appear before it?" A slot with no CheckpointProposed log produces no anchor, so this offense doesn't fire.
| - **Witnesses.** `l1BlockHash` and `rlp_header` are supplied to `submit()`. The store recomputes `content`, verifies `keccak256(rlp_header) == l1BlockHash`, and stores `poseidon2(pack_to_fields(rlp_header))`. | ||
| - **Number binding.** `l1BlockNumber` MUST be in the preimage; otherwise a caller could store a real hash under the wrong block number. | ||
| - **Canonical serialization.** Field order, concatenation, endianness, and reduction MUST be identical in Solidity and Noir. | ||
| - **`secretHash`.** MUST be `0`. |
There was a problem hiding this comment.
Shouldn't secretHash be the hash of zero, instead of zero, in order to consume the message from L2?
|
|
||
| Ordering also lands the message in the earlier in-progress tree when `propose()` crosses the `LAG` boundary, surfacing the value one checkpoint sooner; freshness is the latency rationale, but the slashing rule in (d) is what makes it enforceable. | ||
|
|
||
| ### (d) New slashing offense |
There was a problem hiding this comment.
Fable found a nasty issue with this slashing offense: it is triggerable by a third party that frontruns the proposer.
propose()authenticates the proposer by their signature inside the attestations, not bymsg.sender(ProposeLib.sol— "Only the designated proposer for the current slot can propose a checkpoint, enforced by validating the proposer validator signature among attestations"; only the escape-hatch path checksmsg.sender). A checkpoint proposal is therefore relayable: an attacker who observes the proposer's multicall in the public mempool (calldata, attestations, and blob sidecars are all visible) can re-bundle the barepropose()call into their own transaction withoutpushLatest()and front-run. The checkpoint lands attributed to the honest proposer, the receipt hasCheckpointProposedbut noBlockHashPushed, and the watcher slashes deterministically with no appeal path — the proposal explicitly states detection is "decided purely by inspection of the proposer's finalized L1 transaction."This would be the first offense in the framework whose trigger is not solely a function of the validator's own behavior. The cost is bounded (the attacker pays gas + blob fees to burn 10e18 of the victim's stake), but a slashing spec needs to either acknowledge and accept this, recommend private orderflow, or weaken the rule. Section (c) considers accomplice pushes masking an omission but not adversarial re-bundling creating one.
There was a problem hiding this comment.
Thanks a lot for catching this (and kudos to Fable) — this is a serious issue and might even be a blocker for this design. I can see two options to fix this:
- In-protocol — propose() sends the message itself. Move the blockhash(block.number-1) read + sendL2Message into propose(). Because the send is now atomic with the checkpoint, the frontrun is impossible and the entire slashing offense disappears. Notably this needs no circuit changes (it still rides the existing inbox), so it's far lighter than the in-protocol commitment in PR AZIP-4: L1 Block Header Access #24. On addressing, the L1 side actually simplifies — the standalone portal and its L1ContractAddresses registration go away, and the store just re-pins its expected sender from the portal to the already-configured Rollup address. The one wrinkle is the reverse direction: the Rollup needs the store's L2 address as the message recipient, and since the Rollup is deployed with plain CREATE today, this requires either a deployment-process change (CREATE2-predict the Rollup address, or a one-time setter) or — more cleanly — giving the store a fixed canonical address referenced as a constant (mirroring how FeeJuicePortal references Constants.FEE_JUICE_ADDRESS), which needs no deploy changes. Costs: a critical-path ProposeLib audit, a new Rollup version (so this will not be available soon) and it keeps the inbox LAG.
- Decouple the push from propose() (standalone tx) with window-based detection (BlockHashPushed within L1 block B ± W). Keeps the immutable-Rollup compatibility, but it's not a clean win: push and checkpoint can now land in different L1 blocks, so a reorg can split them across the window and false-slash an honest proposer — same harm class as the original bug, just rarer/accidental (adjudicating against finalized L1 mitigates most of it). It also makes the offense fuzzier: W becomes a consensus parameter and attribution on total omission is unresolved.
So the trade is roughly enforced atomicity (no slashing, but a new Rollup) vs windowed detection (immutable-Rollup-compatible, but reorg-fragile). Do you see any other option to solve this that I'm missing — particularly anything that keeps the immutable-Rollup property without the windowed-attribution complexity?
There was a problem hiding this comment.
This just feels like we need to make the slashing rule weaker i.e
The proposer is slashed if there is no BlockHashPushed event within N L1 slots from the last checkpoint. You can seperate them out, front run or do whatever, but there must be an event within 6 slots from the last checkpoint or you are slashed.
There was a problem hiding this comment.
As I mentioned on the previous ACD, I think that bundling the L1 root inbox message with the propose call makes the most sense, both in terms of gas efficiency in simplicity. Note that the inbox lag is not a strong enough argument, since we're working to reduce inbox lag for all messages via a different protocol change.
Also, it was mentioned during the last ACD that doing this in-protocol was shot down due to a set of cons, but none of those apply to bundling the L1 calls. They do apply to enshrining reads of L1 specific fields in the protocol.
This then becomes a question of whether we need a solution for this in v5. I'd push for a stopgap solution for v5, that works for the specific apps that require it, and then implementing the proper solution along with v6, especially considering that the slash-based solution will also take time to develop and move the timeline closer to v6.
|
|
||
| `submit()` MUST verify `keccak256(rlp_header) == l1BlockHash` before writing `header_commitments[l1BlockNumber]`. The keccak is paid once per L1 block by the submitter (a keeper or first caller) in AVM gas; every subsequent reader skips it, verifying the cheap Poseidon2 commitment instead (see "Application-side verification" below). If the assertion fails, the entire `submit()` call reverts — including the inbox consume — leaving the message available for re-submission with the correct `rlp_header`. | ||
|
|
||
| `submit()` SHOULD emit a `BlockHashMemoized(l1BlockNumber)` public log on the success path (i.e. when memoization actually occurs), mirroring the L1 portal's `BlockHashPushed(n)` event so off-chain indexers, explorers, and keepers can detect "block `n` is now memoized" without polling public state. The log MUST NOT fire on the idempotent early-return branch, so the presence of a log carries the meaning "this call performed the memoization." The log's shape is non-normative; consumers MUST NOT couple to its field encoding. The authoritative signal that block `n` is memoized is `blockhashes[n] != 0` — the log is an off-chain notification convenience, not a substitute for the storage read. |
There was a problem hiding this comment.
Should this be header_commitments[n]? The store mapping is called header_commitments everywhere else in the spec (the submit() pseudocode, (f), and Security Considerations) — blockhashes only appears in the rejected "memoizing the canonical bytes32 block hash" alternative, so this looks like a leftover from that draft.
| `submit()` SHOULD emit a `BlockHashMemoized(l1BlockNumber)` public log on the success path (i.e. when memoization actually occurs), mirroring the L1 portal's `BlockHashPushed(n)` event so off-chain indexers, explorers, and keepers can detect "block `n` is now memoized" without polling public state. The log MUST NOT fire on the idempotent early-return branch, so the presence of a log carries the meaning "this call performed the memoization." The log's shape is non-normative; consumers MUST NOT couple to its field encoding. The authoritative signal that block `n` is memoized is `blockhashes[n] != 0` — the log is an off-chain notification convenience, not a substitute for the storage read. | |
| `submit()` SHOULD emit a `BlockHashMemoized(l1BlockNumber)` public log on the success path (i.e. when memoization actually occurs), mirroring the L1 portal's `BlockHashPushed(n)` event so off-chain indexers, explorers, and keepers can detect "block `n` is now memoized" without polling public state. The log MUST NOT fire on the idempotent early-return branch, so the presence of a log carries the meaning "this call performed the memoization." The log's shape is non-normative; consumers MUST NOT couple to its field encoding. The authoritative signal that block `n` is memoized is `header_commitments[n] != 0` — the log is an off-chain notification convenience, not a substitute for the storage read. |
|
|
||
| The store consumes the inbox message exactly once, verifies that the canonical RLP-encoded L1 block header (supplied as a witness) keccak-hashes to the inbox-committed hash, and memoizes a Poseidon2 commitment of the verified RLP header into public state keyed by L1 block number. Applications then **read** the header commitment — never re-consuming the message — verify their RLP-header witness against it with a cheap Poseidon2 check (~1–2k gates in place of a ~25k-gate keccak), and run Merkle-Patricia-trie, receipt, or beacon-state proofs against the extracted roots, exactly as the in-protocol design does. | ||
|
|
||
| The store is a "standard contract" with a deterministic address (the same derivation recipe as today's `AuthRegistry` / `PublicChecks`: canonical salt, zero deployer, no-arg constructor), exposed to apps via an aztec-nr constant and published once by a permissionless L2 transaction — **not** a magic-slot protocol contract. The design therefore introduces no kernel, protocol-circuit, or AVM changes, no recompile of deployed application circuits, and no change to the L1 `GenesisState`; a new standard contract can be added to a running rollup without a new Rollup version. The only consensus-layer change is the new slashing offense; nodes adopt it via a grace window (detection enabled, voting disarmed) before the AZUP-scheduled activation epoch arms slashing — see Backwards Compatibility. The trade-offs are higher L1 gas per checkpoint, higher AVM gas in `submit()` (one keccak + one Poseidon2 over the ~550-byte RLP header, amortized once per L1 block across all readers), and an L1→L2 readability latency bounded below by the inbox lag (~144 s expected, plus ~72 s of additional latency per consecutive missed push). |
There was a problem hiding this comment.
How is the L1 portal address baked into the standard contract, given there's no ctor? Immutables? Wouldn't that yield a different address per network?
There was a problem hiding this comment.
You're absolutely right. The address will be different per network. I updated the AZIP with this information
| ### Application-side verification | ||
|
|
||
| Applications verify against the memoized header commitment before using roots extracted from their witnessed header: | ||
|
|
||
| ```noir | ||
| // commitment = store.header_commitments[n] (public read, or historical read for private callers) | ||
| assert(poseidon2(pack_to_fields(rlp_header)) == commitment); // ~1–2k-gate check, no keccak | ||
| let roots = rlp::extract(rlp_header); // state_root, receipts_root, ... | ||
| mpt::verify_account(roots.state_root, account, account_proof); | ||
| // ... and storage / receipt / beacon proofs as needed. | ||
| // Apps that need the canonical 32-byte keccak block hash (e.g. matching an external `bytes32` blockhash | ||
| // identifier) derive it on demand via `keccak256(rlp_header)` after the Poseidon2 check passes (~25k gates). | ||
| ``` | ||
|
|
||
| A shared, audited aztec-nr library SHOULD wrap the RLP header check and the MPT, receipt, and beacon proofs so consumers share one verification surface. |
There was a problem hiding this comment.
Question about this part: how do apps gather the rlp_header? Let's say a user interacts with app A, which in turn calls app B, which calls app C (you know where this is going), which eventually needs to do a proof over L1 state. How does the application-code the user is interacting with know that it needs to gather rlp_header for app C? Or how is it wired through the callstack?
We discussed some alternatives today with @nventuro, but it'd be good to cover those in the AZIP as well, in order not to kill composability in apps that require access to that L1 data:
- Adding a new oracle call to fetch that data, which needs to be resolved by a wallet with L1 RPC access
- Storing the entire block header in the public data tree
- Emitting an unencrypted tagged log with the block preimage, and piggybacking on PXE's note-discovery mechanism for loading it
- Abusing capsules, though still requires the app A frontend to know about app C requiring block data
- Implementing some new hook mechanism, which is still unplanned
There was a problem hiding this comment.
That's a really good question where I don't have the immediate answer. Let's discuss on ACD
| return (n, header_commitments[n]) // (0, 0) before any submit | ||
| ``` | ||
|
|
||
| `submit()` MUST be a **public** function, for two reasons: only public functions can write public state, and the public consume checks the live message-tree tip, so it can consume as soon as the message lands without assembling a historical witness. It is **permissionless** — any L2 actor MAY call it (see (f)). |
There was a problem hiding this comment.
only public functions can write public state
We could still run the hashing in a private function, and then call an internal (only_self) public function that writes to public state. This way we save a lot on L2 gas, by keeping the keccack proofs private. Has this mixed approach been considered? In general, I'd try and push expensive operations that don't depend on public mutable state to private-land.
Also, we could keep the entire thing in private-land, and write the poseidon commitment to the block as a nullifier, which can then be read from both private and public.
There was a problem hiding this comment.
I started considering it when thinking if we really need a new opcode if we decide to go in-protocol way.
Adds AZIP-4 (Core, status Draft) under AZIPs/azip-4.md.
This proposal is an out-of-protocol alternative to the in-protocol "L1 Block Header Access" design in #24. It reaches roughly the same capability — letting Noir contracts read and prove Ethereum L1 state — with no changes to protocol circuits, the AVM, the kernels, or any deployed application ABI.
Trade-offs vs. #24: higher L1 gas per checkpoint, higher per-read gas for header-field-only reads, and an L1→L2 readability latency floor of LAG × AZTEC_SLOT_DURATION = 144 s. In exchange: no critical-path / circuit / AVM audit, no recompile of deployed application circuits, and no change to the L1 GenesisState — a new standard contract added to a running rollup without a new Rollup version.
Credit for the original out-of-protocol design to @iAmMichaelConnor, seeded as a comment (#12 (comment)) by @joeandrews on the AZIP-4 discussion.
cc @iAmMichaelConnor @joeandrews