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.
|
|
||
| `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?
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