diff --git a/rollup_spec/Readme.md b/rollup_spec/Readme.md index 07112937382..5506002720f 100644 --- a/rollup_spec/Readme.md +++ b/rollup_spec/Readme.md @@ -59,13 +59,17 @@ checks modeled separately in `l1_rollup.py`: | Guest program | Reference entry point | Scope | |---|---|---| -| l2-execution | `l2_execution.py::run_l2_execution_guest` | Replays a contiguous L2 block range from canonical `blockRlp` plus `debug_executionWitness`, validates the EVM state transition, extracts bridge events, processes forced transactions, and emits the 15-field l2-execution PI. It does not read blobs, verify KZG, or recursively verify other proofs. | +| l2-execution | `l2_execution.py::run_l2_execution_guest` | Replays a contiguous range of Engine API `NewPayloadRequest`s from vanilla stateless inputs plus Linea rollup-extension fields, validates the EVM state transition, extracts bridge events, processes Linea forced transactions, and emits the 15-field l2-execution PI. It does not read blobs, verify KZG, or recursively verify other proofs. | | rollup | `rollup.py::run_rollup_guest` | For each of `K >= 1` consecutive blobs, recomputes the canonical compressed payload from the witnessed full block RLPs (truncate → RLP-encode → LZ4-compress → zero-pad to `BLOB_BYTES_LENGTH`), computes the KZG commitment from those bytes, checks its versioned hash against the L1-committed `blobHash`, and verifies the KZG proof. Chains the shnarf transition, recursively verifies the `N` l2-execution proofs that tile the blob range, builds L2->L1 root commitments, merges refused-address outputs, and emits the 14-field rollup PI. It does not run the EVM or perform L1 finalization checks. | | rollup-aggregation | `rollup_aggregation.py::run_rollup_aggregation_guest` | Recursively verifies the `M` rollup proofs for a finalization range, checks proof-to-proof continuity, merges root/address commitments, and emits the final 14-field PI consumed by L1. It does not inspect raw blocks, raw blobs, or L1 storage. | `l1_rollup.py` models the contract-facing blob anchoring and finalization checks against L1 storage. It is intentionally not one of the RISC-V guest programs. +**Reference environment.** The Python reference targets **Python 3.11+** (it uses +`enum.StrEnum`) and pins its dependencies in `rollup_spec/requirements.txt` +(`remerkleable`, a commit-pinned `ethereum-execution`, `ckzg`, `lz4`). + ### 2.1 l2-execution Proof The l2-execution proof covers a contiguous range of L2 blocks and proves the EVM state transition, deposit processing, and withdrawal emission. The sequencer's forced-transaction handling decision is supplied per forced @@ -94,16 +98,19 @@ declared outcome is one of the allowed outcomes in §6.5. **Private Inputs (Witness)** -- The complete set of L2 blocks in canonical RLP encoding (header + transaction list [+ withdrawals]); EIP-2718 typed transactions in full signed form. The guest recovers each transaction's sender via `secp256k1` and commits to the recovered list via `txFromsHash`. -- The stateless execution witness per block, as produced by Besu's `debug_executionWitness` (`state`, `keys`, `codes`, `headers`); the parent header within `headers` carries the state root that anchors `parentBlockHash`. The `state` MPT node pool must additionally include proof paths for: (a) the L2MessageService's `L1L2RollingHash` and `L1L2RollingHashMessageNumber` slots at both the parent and end state roots — read at proof-range boundaries even when no block in the range writes them; (b) the sender account of any FTX whose declared outcome is *Invalid* (§6.5), at the parent state root of the block where that FTX would have been included. -- The static chain config: `L2MessageServiceContract`, `coinBase`, `chainID`. `baseFee` — the fourth input to `dynamicChainConfigHash` — is NOT part of this struct; the guest reads it from the first block's header and asserts every subsequent block in the range carries the same value. -- The forced-transaction witnesses for FTXs in the range — see §6 +- The complete set of L2 payloads as length-delimited vanilla stateless-input SSZ `StatelessInput` byte slices, one per block in the conflation. The guest decodes each slice into a `NewPayloadRequest`, execution witness, stateless chain config, and optional transaction public keys before reading Linea's rollup-extension fields. Each request carries `executionPayload`, `versionedHashes`, `parentBeaconBlockRoot`, and typed `executionRequests`. The Linea wrapper consumes normal transactions from `executionPayload.transactions` as canonical signed transaction bytes, derives each sender with execution-specs `recover_sender(chainID, tx)`, then commits to the ordered list via `txFromsHash`. +- The stateless execution witness per payload after stateless-input SSZ decode (`state`, `codes`, `headers`, and optional JSON/debug `keys`). `headers` are RLP-encoded parent/ancestor headers ordered by block number and ending at the payload parent; the final header hash must equal `newPayloadRequest.executionPayload.parentHash`, and that parent header carries the state root that anchors `parentBlockHash`. The canonical `SszExecutionWitness` contains `state`, `codes`, and `headers`; an engine's JSON/debug path (e.g. Zesu's `StateWitness`) also carries `keys`, so the logical schema preserves `keys` for decoded debug fixtures. SSZ-encoded `keys` would require a distinct Linea schema id rather than changing the vanilla stateless-input slice. The `state` MPT node pool must additionally include proof paths for: (a) the L2MessageService's `L1L2RollingHash` and `L1L2RollingHashMessageNumber` slots at both the parent and end state roots — read at proof-range boundaries even when no block in the range writes them; (b) the sender account of any FTX whose declared outcome is *Invalid* (§6.5), at the parent state root of the block where that FTX would have been included. +- Optional transaction public keys, ordered by `executionPayload.transactions` index. They are part of the vanilla stateless execution input, not the Linea rollup extension and not `executionWitness.keys`. The Linea logical spec does not derive senders from this field: signer derivation is stated as `recover_sender(chainID, tx)`. `publicKeys` is not a witness override; any production optimization that consumes it must produce the same accepted/rejected transaction result and sender address as `recover_sender(chainID, tx)`. +- The static Linea proof-range chain config: `L2MessageServiceContract`, `coinBase`, `chainID`. `baseFee` — the fourth input to `dynamicChainConfigHash` — is NOT part of this struct; the guest reads it from the first `NewPayloadRequest.executionPayload.baseFeePerGas` and asserts every subsequent payload in the range carries the same value. `chainID` deliberately duplicates the chain id inside each vanilla `StatelessInput`: the inner copy preserves the unmodified stateless-input boundary, while the outer copy is the Linea range-level preimage for `dynamicChainConfigHash`. The guest rejects the range if any decoded stateless-input `chainID` differs from this range-level value. +- The Linea rollup-extension forced-transaction witnesses for FTXs in the range — see §6 **What it proves:** -* **Validates the EVM state transition**: validating the state-root hash transition. +* **Validates the EVM state transition**: validating the state-root hash transition from each `NewPayloadRequest.executionPayload`. + +* **Validates Engine API payload commitments**: checks the payload block hash, blob versioned hashes, and `parentBeaconBlockRoot` as part of the `NewPayloadRequest` validation path rather than trusting a full block RLP. Typed `executionRequests` are required to be empty — this rollup does not support EIP-7685 requests, so they are rejected if present rather than validated as a commitment. -* **Enforce sequencer consensus rules**: strictly monotonic timestamps across the range, constant `baseFee` (sourced from the first block header and asserted equal in every subsequent header), `coinbase` matching the chain configuration, and a contiguous parent-hash chain anchored at `parentBlockHash`. Standard Ethereum header validation (`validate_header`) is delegated to the state-transition primitive. +* **Enforce sequencer consensus rules**: strictly monotonic timestamps across the range, constant `baseFee` (sourced from the first payload and asserted equal in every subsequent payload), `coinbase` matching the chain configuration, and a contiguous parent-hash chain anchored at `parentBlockHash`. Standard Engine API payload validation is delegated to the state-transition primitive. * **Extract the canonical L2L1Message** hashes from the block receipt and compute their flat-hash using keccak256. @@ -154,7 +161,7 @@ Plus three **new** rollup-level fields: `parentShnarf` (input), `endShnarf` (com | `blobHash_b` | The blob's versioned hash as submitted on L1 — cross-checked against `kzg_commitment_to_versioned_hash(computedBlobCommitment_b)` | | `KzgProof_b` | KZG proof for blob `b` | | `blockRange_b` | The `(startBlockNumber, endBlockNumber)` pair for the blocks contained in blob `b` | -| `blockRlps_b` | The ordered list of canonical full block RLPs for blob `b` (`m_b` entries) — same canonical shape the l2-execution proof receives (header + tx list [+ withdrawals], EIP-2718 typed transactions in full signed form). Truncation per §3.2 happens *inside* the guest; there is no separately witnessed truncated form, and the compressed blob bytes are not witnessed either — the guest recomputes them. | +| `blockRlps_b` | The ordered list of canonical full block RLPs published through the DA path for blob `b` (`m_b` entries: header + tx list [+ withdrawals], EIP-2718 typed transactions in full signed form). The l2-execution proof receives `NewPayloadRequest` inputs instead; the rollup proof cross-checks these DA blocks against l2-execution public block hashes and `txFromsHash`. Truncation per §3.2 happens *inside* the guest; there is no separately witnessed truncated form, and the compressed blob bytes are not witnessed either — the guest recomputes them. | | `E₁ … Eₙ` | The l2-execution proofs, ordered by block range, tiling the combined range of all K blobs | | `PI_E₁ … PI_Eₙ` | The public-input tuple for each l2-execution proof | | `L2L1MsgList_e` | Per-l2-execution-proof L2→L1 message hash list, for `e ∈ [1, N]` | @@ -229,10 +236,11 @@ The same 14-field tuple as the rollup proof (§2.2) and as the final rollup-aggr - Their complete 14-field public-input tuples `PI_B₁ … PI_Bₘ` - For each `i`, the ordered L2L1 root array whose committed hash is `PI_Bᵢ.L2L1BridgeTransactionTree` - For each `i`, the ordered filtered-address list whose committed hash is `PI_Bᵢ.filteredAddressesHash` +- The environment-dependent `isAllowedCircuitID` bitmask gating which inner circuit/program identities step 1 may accept (bit *i*, LSb→MSb, allows circuit ID *i*). It mirrors the prover's `Aggregation.is_allowed_circuit_id` config and is a proving-policy input only — not one of the 14 public-input fields and not part of `dynamicChainConfigHash`. **Statement (RISC-V Guest)** -1. **Verify** all `M` inner proofs cryptographically against their claimed public inputs using recursive STARK verification. +1. **Verify** all `M` inner proofs cryptographically against their claimed public inputs using recursive STARK verification, accepting an inner proof only if its circuit/program identity is permitted by the `isAllowedCircuitID` bitmask. 2. **Assert continuity** in software, for each consecutive pair `(Bᵢ, Bᵢ₊₁)`: ``` @@ -290,7 +298,7 @@ The Python reference uses `raise Exception(...)` as compact notation for proof, Classification: - Guest invariant failures in `l2_execution.py`, `rollup.py`, `rollup_aggregation.py`, `block.py`, and the MPT/account/storage checks in `state_transition.py` are proof rejection points. Zig/Rust implementations should return explicit deterministic error codes and terminate as failed executions rather than relying on an uncontrolled panic path. -- Python-only stubs such as `state_transition.py::materialize_blockchain_from_execution_witness` are reference gaps, not guest semantics. They must not remain on an implementable production guest path. +- `state_transition.py::execute_stateless_input` raises `NotImplementedError`: it marks the delegation boundary to the underlying stateless-execution engine, not a guest rejection point. A production guest satisfies it by calling that engine, not by terminating. - L1 finalization failures in `l1_rollup.py` model Solidity reverts, not zkVM guest termination. - Host/environment failures in this Python reference, such as a missing trusted setup file or a host library/runtime fault, must not be collapsed into ordinary proof-invalid errors in production. The production guest should use fixed trusted-setup semantics and typed errors around KZG, MPT, compression, and recursive-verifier primitives. - The current Python MPT helper rejects inline child nodes as "not supported in this reference". Inline MPT children are valid Ethereum trie encodings; production must either support them or document and enforce a witness-normalization rule that rejects them with a standardized failed-termination code. @@ -331,22 +339,31 @@ The DA blob must contain the exact inputs required to re-execute the L2 blocks f The JSON files under `prover_inputs/` describe a logical schema. The bytes carried into the zkVM guest are binary. -**Transport.** The guest reads input bytes via the zkVM's read-input primitive (`ziskos::read_input()` on Zisk). Transport framing is an 8-byte little-endian length prefix per chunk, padded to 8-byte alignment. +**Transport.** The guest reads input bytes via the zkVM's read-input primitive (`ziskos::read_input()` on Zisk). The Linea l2-execution envelope length-delimits a vanilla SSZ `StatelessInput` byte slice per payload, then carries Linea rollup-extension fields beside that slice. The Python reference models this boundary in `stateless_input.py::decode_stateless_input_ssz`, using the `remerkleable` decoder for the same raw/Ere-prefixed stateless-input container shape while keeping Linea extension parsing outside the stateless-input slice. Do not append Linea bytes to that slice itself: the SSZ decoder treats the final field as consuming the remainder of the slice, so trailing Linea data would be interpreted as stateless input rather than ignored. -**Container.** Inside the payload, the l2-execution layout is: +**Container.** The logical request and witness shapes are defined once in the +Python reference; this section does not restate their field lists, to avoid a +second source of truth that drifts. See: -``` -[u64 BE: block_rlp_len] [block_rlp_bytes] — RLP-encoded block -ExecutionWitness: - state: [u64 BE: count] then [u64 BE: len][bytes]* — debug_executionWitness field "state" - codes: [u64 BE: count] then [u64 BE: len][bytes]* — debug_executionWitness field "codes" - keys: [u64 BE: count] then [u64 BE: len][bytes]* — debug_executionWitness field "keys" - headers: [u64 BE: count] then [u64 BE: len][bytes]* — debug_executionWitness field "headers" -``` +- `l2_execution.py` +- `block.py` +- `state_transition.py` + +The on-wire SSZ schema (the `Ssz*` containers) lives in `stateless_input.py` and +`canonical_ssz.py`, mirroring execution-specs `forks/amsterdam/stateless_ssz.py` — +the same schema the underlying engine (e.g. Zesu) decodes. -Outer framing is length-prefixed binary; inner payloads are RLP. The per-FTX `signedTxRlp` payloads and the `chainConfig` fields are appended in the same `count + length-prefixed bytes` style. Rollup-proof and rollup-aggregation-proof containers follow the same convention and are pinned alongside the corresponding guest implementations. +Full block RLPs still exist in the rollup-proof DA witness (`blockRlps_b`) because the rollup guest recomputes the compressed blob payload from DA data, but l2-execution consumes `NewPayloadRequest` instead. The per-FTX `signedTxRlp` payloads and proof-range `chainConfig` fields are Linea wrapper fields outside the EIP-8025 `StatelessInput`. Rollup-proof and rollup-aggregation-proof containers follow their own schemas and are pinned alongside the corresponding guest implementations. -**Debug format.** The `prover_inputs/` JSONs are the canonical schema source; the coordinator translates them to the binary container before invoking the prover. A separate supporting tool can convert JSON fixtures (e.g. `block.json` + `witness.json`) into the same binary container for local replay. +**Debug format.** The guest input schema contains only +`statelessInputSsz`, not a decoded `StatelessInput` object. Draft JSON +fixtures may show a decoded `_debugStatelessInput` mirror for review, but +fixture loaders must derive or validate that mirror from `statelessInputSsz` +and discard it before constructing `L2ExecutionProofPrivateInput`. The guest +consumes and decodes the stateless-input SSZ bytes; decoded mirrors are not +accepted by `run_l2_execution_guest`, except for explicitly marked JSON-only +debug documentation such as optional witness `keys`, which are not carried by the +canonical stateless-input SSZ schema. --- @@ -459,12 +476,12 @@ where `txHash = keccak256(signedTxRlp)` is the standard Ethereum transaction has **Outcome.** Each FTX carries the sequencer's declared `acceptance`, one of five variants — the cases the guest program can actually observe under RISC-V proving. The five variants and their proof-level treatment: -- *INCLUDED* — the guest asserts `txHash` appears in the declared block's transaction list (decoded from `blockRlp`). -- *Invalid sub-cases* (`BAD_NONCE` / `BAD_BALANCE`) — pre-validation must fail. The guest asserts `txHash` is NOT in the block, then reads the FTX sender's account from the L2 state at the parent state root of the block where the FTX would have been included, via the EVM state interface (a standard SLOAD/`basic`-style read against the in-process state DB backed by `debug_executionWitness.state`). It then asserts the specific failure condition: +- *INCLUDED* — the guest asserts `txHash` appears in the declared payload's transaction list (`newPayloadRequest.executionPayload.transactions`). +- *Invalid sub-cases* (`BAD_NONCE` / `BAD_BALANCE`) — pre-validation must fail. The guest asserts `txHash` is NOT in the payload transaction list, then reads the FTX sender's account from the L2 state at the parent state root of the payload where the FTX would have been included, via the EVM state interface (a standard SLOAD/`basic`-style read against the in-process state DB backed by `ExecutionWitness.state`). It then asserts the specific failure condition: - `BAD_NONCE`: `account.nonce != tx.nonce` - `BAD_BALANCE`: `account.balance < tx.gasLimit × tx.maxFeePerGas + tx.value` (using the canonical gas-cost formula per tx type, including the blob-gas surcharge for Type-3 transactions) - No separate per-FTX state witness is needed; the `debug_executionWitness.state` MPT node pool must include the sender account's proof path at the parent state root (§2.1). + No separate per-FTX state witness is needed; the `ExecutionWitness.state` MPT node pool must include the sender account's proof path at the parent state root (§2.1). - *Refused sub-cases* — the rollup declines for compliance reasons. No governance witness is required inside the proof; the sequencer simply declares the refusal. The L1 contract verifies a-posteriori that each bubbled-up address appears in its reference sanction list — if any entry is absent, the finalization call reverts. - `FILTERED_ADDRESS_FROM`: sender on the sanction list; `fromAddress` is appended to the filtered address list. - `FILTERED_ADDRESS_TO`: recipient on the sanction list; `toAddress` (decoded from `signedTxRlp`; rejected if the FTX is a contract-creation transaction with `to == None`) is appended instead. diff --git a/rollup_spec/block.py b/rollup_spec/block.py index 801c9a6d821..aadbd52e3e0 100644 --- a/rollup_spec/block.py +++ b/rollup_spec/block.py @@ -1,15 +1,18 @@ -from ethereum.forks.osaka.blocks import Block as EthereumBlock, Header -from ethereum.forks.osaka.transactions import ( +from .fork import ( + Block as EthereumBlock, + Header, + Withdrawal, Transaction, recover_sender, LegacyTransaction, decode_transaction, + ProtocolFork, ) from ethereum.state import Address -from dataclasses import dataclass +from dataclasses import dataclass, field from ethereum_types.numeric import U64, Uint from ethereum.crypto.hash import Hash32, keccak256 -from ethereum_types.bytes import Bytes +from ethereum_types.bytes import Bytes, Bytes32 from ethereum_rlp import rlp from enum import Enum from typing import List, TypeAlias @@ -141,37 +144,162 @@ def tx_hash(self) -> Hash32: return keccak256(self.signed_tx_rlp) @dataclass -class RollupBlock: +class DepositRequest: """ - Logical l2-execution block witness. + EIP-7685 deposit request carried by `NewPayloadRequest.executionRequests`. + The SSZ decoder materializes this logical object from the guest input bytes. + """ + pubkey: bytes + withdrawal_credentials: Bytes32 + amount: U64 + signature: bytes + index: U64 + - `block_rlp` is the canonical private input supplied by the coordinator. The - Python reference decodes an `EthereumBlock` from these bytes internally when - it needs an execution view. +@dataclass +class WithdrawalRequest: + """ + EIP-7002 withdrawal request carried by `NewPayloadRequest.executionRequests`. """ - block_rlp: bytes - forced_transactions: List[ForcedTransactionWitness] + source_address: Address + validator_pubkey: bytes + amount: U64 -def block_hash(header: Header) -> Hash32: + +@dataclass +class ConsolidationRequest: + """ + EIP-7251 consolidation request carried by `NewPayloadRequest.executionRequests`. + """ + source_address: Address + source_pubkey: bytes + target_pubkey: bytes + + +@dataclass +class ExecutionRequests: + """ + Typed Engine API execution requests. + + In SSZ these are bounded lists of fixed-size request containers, per + EIP-8025. They are explicit here because the guest validates + `NewPayloadRequest`, not a canonical block RLP. + """ + deposits: List[DepositRequest] = field(default_factory=list) + withdrawals: List[WithdrawalRequest] = field(default_factory=list) + consolidations: List[ConsolidationRequest] = field(default_factory=list) + + +@dataclass +class StatelessChainConfig: + """ + Chain context carried inside the stateless input. + + Linea still carries its proof-range `ChainConfig` outside the standard + stateless payload because it also contains Linea-specific fields such as the + L2MessageService address and coinbase. The guest must reject a payload whose + stateless `chain_id` disagrees with the proof-range chain config. `active_fork` + is the resolved `ProtocolFork` decoded from the SSZ `chain_config.active_fork` + index (validated to be the spec's single supported fork — see `fork.py`). + """ + chain_id: U64 + active_fork: ProtocolFork = ProtocolFork.Amsterdam + + +@dataclass +class ExecutionPayload: + """ + Logical Engine API execution payload inside `NewPayloadRequest`. + + `transactions` are canonical signed tx bytes; the reference decodes an + individual transaction only when it needs sender recovery or the + execution-spec view. + """ + parent_hash: Hash32 + fee_recipient: Address + state_root: Hash32 + receipts_root: Hash32 + logs_bloom: bytes + prev_randao: Bytes32 + block_number: U64 + gas_limit: Uint + gas_used: Uint + timestamp: U64 + extra_data: bytes + base_fee_per_gas: Uint + block_hash: Hash32 + transactions: List[bytes] + withdrawals: List[Withdrawal] + blob_gas_used: U64 + excess_blob_gas: U64 + block_access_list: bytes = b"" + slot_number: U64 | None = None + + +@dataclass +class NewPayloadRequest: + """ + EIP-8025 payload request supplied to the l2-execution guest (replacing the + old `blockRlp` input). Binds the proof to a concrete Engine API payload: + versioned hashes, parent beacon block root, and typed execution requests. + """ + execution_payload: ExecutionPayload + versioned_hashes: List[Hash32] + parent_beacon_block_root: Hash32 + execution_requests: ExecutionRequests = field(default_factory=ExecutionRequests) + + +@dataclass +class StatelessInput: + """ + Decoded stateless input matching the underlying engine's guest boundary. + + Forced-transaction metadata is deliberately not here — it rides on + `LineaPayloadInput.rollup_extension` so the stateless input stays vanilla. + `public_keys` is decoded only because it is part of the stateless input; Linea + derives signers via `recover_sender(chainID, tx)`, not from it. + """ + new_payload_request: NewPayloadRequest + witness: "ExecutionWitness" + chain_config: StatelessChainConfig + public_keys: List[bytes] = field(default_factory=list) + + +@dataclass +class LineaRollupExtension: + """ + Linea-only fields beside the vanilla stateless input. Must not be appended to + the stateless-input SSZ byte slice passed to the decoder. """ - block_hash computes the hash of a block header + forced_transactions: List[ForcedTransactionWitness] = field(default_factory=list) + + +@dataclass +class LineaPayloadInput: """ + One block of Linea l2-execution guest input. + + `stateless_input_ssz` is the vanilla stateless-input byte slice + (length-delimited, decoded on its own); `rollup_extension` is Linea-only + metadata consumed after payload execution, intentionally outside the + stateless input. + """ + stateless_input_ssz: bytes + rollup_extension: LineaRollupExtension = field(default_factory=LineaRollupExtension) + +def block_hash(header: Header) -> Hash32: + """Hash of a block header.""" return keccak256(rlp.encode(header)) def decode_block_rlp(block_rlp: bytes) -> EthereumBlock: """ - Decode the canonical block RLP carried by the l2-execution private input - into the Ethereum execution-specs block view. + Decode a canonical block RLP carried by the rollup DA witness into the + Ethereum execution-specs block view. """ return rlp.decode_to(EthereumBlock, block_rlp) def decode_signed_transaction_rlp(signed_tx_rlp: bytes) -> Transaction: - """ - Decode the canonical signed transaction bytes used for forced transactions. - - Typed EIP-2718 transactions are encoded as type byte || RLP(payload). Legacy - transactions are plain RLP. - """ + """Decode a forced transaction's signed bytes (typed EIP-2718 or legacy).""" if len(signed_tx_rlp) == 0: raise Exception("empty signed transaction RLP") if signed_tx_rlp[0] in (1, 2, 3, 4): @@ -195,33 +323,23 @@ def resolve_forced_transaction(ftx: ForcedTransactionWitness, chain_id: U64) -> ) -def _canonical_transaction_bytes(transaction: BlockTransaction) -> bytes: - """ - Return the canonical signed bytes for a transaction from an execution-spec - block view. - - `Block.transactions` is `Tuple[bytes | LegacyTransaction, ...]`: typed - transactions are already EIP-2718 bytes, while legacy transactions are - decoded objects and need regular RLP encoding. - """ - match transaction: - case bytes(): - return transaction - case LegacyTransaction(): - return rlp.encode(transaction) +def parse_payload_transaction_rlps(payload: ExecutionPayload) -> List[bytes]: + """Return the signed transaction byte strings from an Engine API payload.""" + return [bytes(tx_rlp) for tx_rlp in payload.transactions] -def parse_block_transaction_rlps(block_rlp: bytes) -> List[bytes]: +def payload_transactions_for_execution(payload: ExecutionPayload) -> tuple[BlockTransaction, ...]: """ - Return the signed transaction RLP byte strings decoded from `block_rlp`. - - `blockRlp` is the canonical witness bytes in the logical schema. Decoded - `EthereumBlock` objects are only execution views, so hash checks over block - transactions must use bytes extracted from `block_rlp` rather than - re-encoding decoded transaction objects. + Convert Engine API transaction bytes to the execution-spec `apply_body` + shape: typed transactions stay as EIP-2718 bytes, legacy transactions are + decoded to `LegacyTransaction`. """ - block = decode_block_rlp(block_rlp) - return [ - _canonical_transaction_bytes(transaction) - for transaction in block.transactions - ] + transactions: List[BlockTransaction] = [] + for tx_rlp in parse_payload_transaction_rlps(payload): + if len(tx_rlp) == 0: + raise Exception("empty transaction in payload") + if tx_rlp[0] in (1, 2, 3, 4): + transactions.append(tx_rlp) + else: + transactions.append(rlp.decode_to(LegacyTransaction, tx_rlp)) + return tuple(transactions) diff --git a/rollup_spec/canonical_ssz.py b/rollup_spec/canonical_ssz.py new file mode 100644 index 00000000000..8c81c32750c --- /dev/null +++ b/rollup_spec/canonical_ssz.py @@ -0,0 +1,119 @@ +""" +Canonical consensus-layer SSZ containers, copied verbatim from consensus-specs +@ v1.6.1 so they track upstream as forks evolve. They use `remerkleable` (the +library consensus-specs generates against), so the class bodies are identical to +upstream — only the imports and `source:` comments are local. + +To re-sync: bump the tag, re-copy the affected `class`/alias/constant bodies from +the referenced spec files, and re-run the stateless-input SSZ decode round-trip +(`stateless_input.py::decode_stateless_input_ssz`, guarded by `_strict_decode`). + +Only canonical CL containers live here; the bespoke EIP-8025 stateless-input +envelope is in `stateless_input.py`. +""" + +from remerkleable.basic import uint64, uint256 +from remerkleable.byte_arrays import ByteList, ByteVector, Bytes32, Bytes48, Bytes96 +from remerkleable.complex import Container, List + +# ── Preset constants ───────────────────────────────────────────────────────── +# source: presets/mainnet/bellatrix.yaml +# https://github.com/ethereum/consensus-specs/blob/v1.6.1/presets/mainnet/bellatrix.yaml +MAX_BYTES_PER_TRANSACTION = 2**30 # 1073741824 +MAX_TRANSACTIONS_PER_PAYLOAD = 2**20 # 1048576 +BYTES_PER_LOGS_BLOOM = 2**8 # 256 +MAX_EXTRA_DATA_BYTES = 2**5 # 32 +# source: presets/mainnet/capella.yaml +# https://github.com/ethereum/consensus-specs/blob/v1.6.1/presets/mainnet/capella.yaml +MAX_WITHDRAWALS_PER_PAYLOAD = 2**4 # 16 +# source: presets/mainnet/electra.yaml +# https://github.com/ethereum/consensus-specs/blob/v1.6.1/presets/mainnet/electra.yaml +MAX_DEPOSIT_REQUESTS_PER_PAYLOAD = 2**13 # 8192 +MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD = 2**4 # 16 +MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD = 2**1 # 2 + +# ── Custom type aliases ────────────────────────────────────────────────────── +# source: specs/phase0/beacon-chain.md (Custom types table) +# https://github.com/ethereum/consensus-specs/blob/v1.6.1/specs/phase0/beacon-chain.md +Hash32 = Bytes32 +Gwei = uint64 +ValidatorIndex = uint64 +BLSPubkey = Bytes48 +BLSSignature = Bytes96 +# source: specs/capella/beacon-chain.md (Custom types table) +# https://github.com/ethereum/consensus-specs/blob/v1.6.1/specs/capella/beacon-chain.md +WithdrawalIndex = uint64 +# source: specs/bellatrix/beacon-chain.md (Custom types table) +# https://github.com/ethereum/consensus-specs/blob/v1.6.1/specs/bellatrix/beacon-chain.md +ExecutionAddress = ByteVector[20] # Bytes20 +Transaction = ByteList[MAX_BYTES_PER_TRANSACTION] + +# ── Containers (verbatim) ──────────────────────────────────────────────────── +# source: specs/capella/beacon-chain.md +# https://github.com/ethereum/consensus-specs/blob/v1.6.1/specs/capella/beacon-chain.md + + +class Withdrawal(Container): + index: WithdrawalIndex + validator_index: ValidatorIndex + address: ExecutionAddress + amount: Gwei + + +# source: specs/deneb/beacon-chain.md (unchanged through Electra/Fulu @ v1.6.1) +# https://github.com/ethereum/consensus-specs/blob/v1.6.1/specs/deneb/beacon-chain.md + + +class ExecutionPayload(Container): + parent_hash: Hash32 + fee_recipient: ExecutionAddress + state_root: Bytes32 + receipts_root: Bytes32 + logs_bloom: ByteVector[BYTES_PER_LOGS_BLOOM] + prev_randao: Bytes32 + block_number: uint64 + gas_limit: uint64 + gas_used: uint64 + timestamp: uint64 + extra_data: ByteList[MAX_EXTRA_DATA_BYTES] + base_fee_per_gas: uint256 + block_hash: Hash32 + transactions: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD] + withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + # [New in Deneb:EIP4844] + blob_gas_used: uint64 + # [New in Deneb:EIP4844] + excess_blob_gas: uint64 + + +# source: specs/electra/beacon-chain.md +# https://github.com/ethereum/consensus-specs/blob/v1.6.1/specs/electra/beacon-chain.md + + +class DepositRequest(Container): + pubkey: BLSPubkey + withdrawal_credentials: Bytes32 + amount: Gwei + signature: BLSSignature + index: uint64 + + +class WithdrawalRequest(Container): + source_address: ExecutionAddress + validator_pubkey: BLSPubkey + amount: Gwei + + +class ConsolidationRequest(Container): + source_address: ExecutionAddress + source_pubkey: BLSPubkey + target_pubkey: BLSPubkey + + +class ExecutionRequests(Container): + # [New in Electra:EIP6110] + deposits: List[DepositRequest, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD] + # [New in Electra:EIP7002:EIP7251] + withdrawals: List[WithdrawalRequest, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD] + # [New in Electra:EIP7251] + consolidations: List[ConsolidationRequest, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD] diff --git a/rollup_spec/fork.py b/rollup_spec/fork.py new file mode 100644 index 00000000000..65826db9efe --- /dev/null +++ b/rollup_spec/fork.py @@ -0,0 +1,159 @@ +""" +The single fork binding for the spec: this spec targets one fork, Amsterdam. + +`fork.py` is the only module that names the fork — it re-exports the +execution-specs Amsterdam package and defines the fork identity carried on the +wire. Supporting another fork is a deliberate edit here. + +The SSZ stateless input encodes the fork as `PROTOCOL_FORKS.index(fork)` +(execution-specs `stateless_ssz.py`). That value is *derived* from the +`ProtocolFork` order — pinned by the execution-specs version in requirements.txt +— not hardcoded; Amsterdam is 20 at the pinned commit. + +`ProtocolFork` is copied verbatim below because the pinned branch doesn't package +`execution_engine` (so `ethereum.forks.amsterdam.stateless` can't be imported); +the canonical import is attempted first, this copy is the fallback. Re-sync when +bumping the pin. Source: ethereum/execution-specs@a456712e04 +(`forks/amsterdam/stateless.py`, `stateless_ssz.py`), pruned by PR #2926. + +Downstream gap: a consumer pinned to a pre-prune index reads Amsterdam as 24, not +20. For example, Zesu is still on tests-zkevm@v0.4.1 (pre-prune), so the spec and +that build disagree on the wire until it re-syncs. The spec follows +execution-specs (which such engines mirror), so 20 is intended. +""" + +from enum import StrEnum + +# Single place that names the active fork: other spec modules import these +# fork-specific types from here (re-exported via __all__) instead of importing +# `ethereum.forks.amsterdam` directly. +from ethereum.forks.amsterdam import vm +from ethereum.forks.amsterdam.blocks import Block, Header, Log, Withdrawal +from ethereum.forks.amsterdam.transactions import ( + AccessListTransaction, + BlobTransaction, + FeeMarketTransaction, + LegacyTransaction, + SetCodeTransaction, + Transaction, + decode_transaction, + recover_sender, +) +from ethereum.forks.amsterdam.fork import ( + BlockChain, + apply_body, + get_last_256_block_hashes, +) +from ethereum.forks.amsterdam.bloom import logs_bloom +from ethereum.forks.amsterdam.block_access_lists import ( + BlockAccessListBuilder, + hash_block_access_list, +) +from ethereum.forks.amsterdam.vm.gas import calculate_total_blob_gas + + +try: + # Preferred: the canonical enum straight from execution-specs. + from ethereum.forks.amsterdam.stateless import ProtocolFork # type: ignore +except ImportError: + # Fallback: verbatim copy (see "SOURCE / RE-SYNC" above). Active because the + # pinned branch does not package `execution_engine`, which `stateless.py` + # imports at module load. + class ProtocolFork(StrEnum): + """ + Semantic execution-layer fork names understood by stateless inputs. + + Order is significant: it defines the SSZ `active_fork` index via + `PROTOCOL_FORKS.index(...)`. + """ + + Frontier = "Frontier" + Homestead = "Homestead" + DAOFork = "DAOFork" + TangerineWhistle = "TangerineWhistle" + SpuriousDragon = "SpuriousDragon" + Byzantium = "Byzantium" + StPetersburg = "StPetersburg" + Istanbul = "Istanbul" + MuirGlacier = "MuirGlacier" + Berlin = "Berlin" + London = "London" + ArrowGlacier = "ArrowGlacier" + GrayGlacier = "GrayGlacier" + Paris = "Paris" + Shanghai = "Shanghai" + Cancun = "Cancun" + Prague = "Prague" + Osaka = "Osaka" + BPO1 = "BPO1" + BPO2 = "BPO2" + Amsterdam = "Amsterdam" + + +# Mirrors execution-specs `stateless_ssz.py`: PROTOCOL_FORKS = tuple(ProtocolFork); +# the SSZ enum value is the index into this tuple. +PROTOCOL_FORKS = tuple(ProtocolFork) + +# The one fork this spec supports. +ACTIVE_FORK = ProtocolFork.Amsterdam + +# DERIVED from the pinned ProtocolFork order (not hardcoded). 20 at the pinned +# commit; re-syncing the enum updates this automatically. +ACTIVE_FORK_SSZ_INDEX = PROTOCOL_FORKS.index(ACTIVE_FORK) + + +# Public surface: the fork identity plus the active-fork types re-exported for +# other spec modules to import from here. +__all__ = [ + "ProtocolFork", + "PROTOCOL_FORKS", + "ACTIVE_FORK", + "ACTIVE_FORK_SSZ_INDEX", + "UnsupportedForkError", + "require_active_fork", + "vm", + "Block", + "Header", + "Log", + "Withdrawal", + "AccessListTransaction", + "BlobTransaction", + "FeeMarketTransaction", + "LegacyTransaction", + "SetCodeTransaction", + "Transaction", + "decode_transaction", + "recover_sender", + "BlockChain", + "apply_body", + "get_last_256_block_hashes", + "logs_bloom", + "BlockAccessListBuilder", + "hash_block_access_list", + "calculate_total_blob_gas", +] + + +class UnsupportedForkError(ValueError): + """A stateless input declared a fork other than the one this spec supports.""" + + +def require_active_fork(index: int) -> ProtocolFork: + """ + Validate a decoded SSZ `active_fork` index against the one fork this spec + supports (Amsterdam), returning it on success. + + This spec is single-fork by design; supporting another fork is a deliberate + change here, not a runtime branch. + """ + if index != ACTIVE_FORK_SSZ_INDEX: + seen = ( + PROTOCOL_FORKS[index].value + if 0 <= index < len(PROTOCOL_FORKS) + else f"" + ) + raise UnsupportedForkError( + f"stateless input declares fork index {index} ({seen}); " + f"this spec supports only {ACTIVE_FORK.value} (index {ACTIVE_FORK_SSZ_INDEX})" + ) + return ACTIVE_FORK diff --git a/rollup_spec/l2_execution.py b/rollup_spec/l2_execution.py index 749c7485505..cf65a653c03 100644 --- a/rollup_spec/l2_execution.py +++ b/rollup_spec/l2_execution.py @@ -2,30 +2,35 @@ from typing import List, Sequence, Tuple from ethereum.crypto.hash import Hash32, keccak256 -from ethereum.forks.osaka.blocks import Block as EthereumBlock, Header -from ethereum.forks.osaka.transactions import ( +from .fork import ( BlobTransaction, FeeMarketTransaction, SetCodeTransaction, recover_sender, + calculate_total_blob_gas, ) -from ethereum.forks.osaka.vm.gas import calculate_total_blob_gas from ethereum.state import Address from ethereum_types.bytes import Bytes32 from ethereum_types.numeric import U64, Uint from .block import ( ChainConfig, + ExecutionPayload, ForcedTransactionAcceptance, + ForcedTransactionWitness, + LineaPayloadInput, ResolvedForcedTransaction, - RollupBlock, - block_hash, - decode_block_rlp, + StatelessInput, decode_signed_transaction_rlp, - parse_block_transaction_rlps, + parse_payload_transaction_rlps, resolve_forced_transaction, ) -from .state_transition import ExecutionWitness, L2State, state_transition_modified +from .stateless_input import decode_stateless_input_ssz +from .state_transition import ( + L2State, + StatelessExecutionResult, + execute_stateless_input, +) BRIDGE_L2L1_MESSAGE_SENT_TOPIC_0 = Hash32( bytes.fromhex("e856c2b8bd4eb0027ce32eeaf595c21b0b6b4644b326e5b7bd80a1cf8db72e6c"), @@ -33,17 +38,12 @@ # Storage layout of the L2MessageService contract. # -# The L1->L2 rolling hash is not a single slot — it lives in the -# `l1RollingHashes` mapping keyed by message number, with the latest message -# number tracked separately in `lastAnchoredL1MessageNumber`. The two slot -# positions below are extracted from the compiled storage layout of -# `contracts/src/messaging/l2/L2MessageService.sol` (build-info JSON in -# `contracts/build/build-info/`; the layout is reproduced via -# `_storage_layout_table` in the script that maintains these constants). -# If the contract's storage layout changes — including adding/removing -# upgrade-safety `__gap` slots in any ancestor — these slot indices MUST -# be re-extracted; otherwise the proof reads zero / unrelated values and -# the L1 finalization check on `l1RollingHash[messageNumber]` will fail. +# The L1->L2 rolling hash lives in the `l1RollingHashes` mapping keyed by message +# number, with the latest number in `lastAnchoredL1MessageNumber`. These slot +# indices are extracted from the compiled storage layout of +# `contracts/src/messaging/l2/L2MessageService.sol`. If that layout changes +# (including `__gap` slots in any ancestor) they MUST be re-extracted, or the +# proof reads wrong values and the L1 finalization check fails. LAST_ANCHORED_L1_MESSAGE_NUMBER_SLOT: Bytes32 = Bytes32(int(280).to_bytes(32, "big")) L1_ROLLING_HASHES_MAPPING_BASE_SLOT: Bytes32 = Bytes32(int(281).to_bytes(32, "big")) @@ -59,18 +59,12 @@ def _mapping_slot(base_slot: Bytes32, key: bytes) -> Bytes32: def read_l1l2_bridge_state(state: L2State, l2_message_service_address: Address) -> Tuple[Hash32, U64]: """ - Read the L1->L2 bridge rolling hash and its associated message number - from the L2MessageService contract's storage at `state.state_root`. - Reads through the EVM state interface — the production guest verifies - the corresponding MPT proof paths against `state.state_root` from the - `ExecutionWitness.state` node pool. - - Two reads: (a) `lastAnchoredL1MessageNumber` at a fixed slot, and - (b) `l1RollingHashes[lastAnchoredL1MessageNumber]` at the mapping slot - computed via `keccak256(uint256_be(messageNumber) || base_slot)`. - - PRECOMPILE (production guest): keccak256 (mapping-slot computation in - `_mapping_slot` below) and the MPT-walk hashes inside `state.storage()`. + Read the L1->L2 bridge rolling hash and its message number from the + L2MessageService storage at `state.state_root` (via the EVM state interface; + the guest verifies the MPT paths from `ExecutionWitness.state`). + + Two reads: `lastAnchoredL1MessageNumber` at a fixed slot, then + `l1RollingHashes[thatNumber]` at `keccak256(uint256_be(number) || base_slot)`. """ number_bytes = state.storage(l2_message_service_address, LAST_ANCHORED_L1_MESSAGE_NUMBER_SLOT) rolling_hash_number = U64(int.from_bytes(bytes(number_bytes), "big")) @@ -103,12 +97,12 @@ def validate_forced_transactions( curr_rolling_hash: Hash32, last_processed_ftx_number: U64, chain_config: ChainConfig, - block_header: Header, + payload: ExecutionPayload, parent_state: L2State, - block: RollupBlock, + forced_transactions: Sequence[ForcedTransactionWitness], ) -> Tuple[List[Address], Hash32, U64]: """ - Scan the forced transactions in this block and assert each has the + Scan the forced transactions declared for this payload and assert each has the correct outcome (Included / Invalid sub-case / Refused sub-case, §6.5). For the Invalid sub-cases, the FTX sender's account is read from `parent_state` (the L2 state at the parent of this block) via @@ -119,22 +113,27 @@ def validate_forced_transactions( - FILTERED_ADDRESS_FROM | FILTERED_ADDRESS_TO -> Refused; bubble up the relevant address for the L1 sanction-list check. - INCLUDED -> assert - `txHash` is in the block's tx list. + `txHash` is in `executionPayload.transactions`. - BAD_NONCE | BAD_BALANCE -> Invalid; - assert `txHash` is NOT in the block AND that the specific + assert `txHash` is NOT in the payload AND that the specific pre-validation failure holds against the sender's account read from `parent_state`. """ rejected_addresses: List[Address] = [] - for ftx in block.forced_transactions: + payload_tx_hashes = [ + keccak256(tx_rlp) + for tx_rlp in parse_payload_transaction_rlps(payload) + ] + + for ftx in forced_transactions: # FTXs are processed in ascending L1-assigned number. if ftx.number != last_processed_ftx_number + 1: raise Exception("forced transactions must be processed in ascending sequence") # Deadline constraint (§6.5): the FTX must be handled in a block whose # number does not exceed its declared deadline. - if ftx.deadline < block_header.number: + if ftx.deadline < payload.block_number: raise Exception("deadline exceeded") resolved_ftx = resolve_forced_transaction(ftx, chain_config.chain_id) @@ -162,13 +161,9 @@ def validate_forced_transactions( rejected_addresses.append(transaction.to) continue - # Block-membership check: INCLUDED variants must appear in the - # block; the two Invalid variants must NOT appear. - block_tx_hashes = [ - keccak256(tx_rlp) - for tx_rlp in parse_block_transaction_rlps(block.block_rlp) - ] - tx_in_block = resolved_ftx.tx_hash in block_tx_hashes + # Payload-membership check: INCLUDED variants must appear in the + # Engine API transaction list; the two Invalid variants must NOT. + tx_in_block = resolved_ftx.tx_hash in payload_tx_hashes if resolved_ftx.acceptance == ForcedTransactionAcceptance.INCLUDED: if not tx_in_block: @@ -244,24 +239,38 @@ class L2ExecutionProofPublicInput: @dataclass class L2ExecutionProofPrivateInput: """ - Logical l2-execution request. `blocks` carries canonical block RLP bytes - plus per-block FTX metadata. `execution_witnesses` carries the corresponding - Besu `debug_executionWitness` payload for each block. The first witness must - contain the parent header whose hash is `blocks[0].header.parent_hash`. - - The L1->L2 rolling-hash boundary values are not separate witness fields: - the guest reads them directly from the L2 state at the parent and end - state roots via the EVM state interface (`L2State.storage`). The witness - producer must ensure the relevant MPT paths are in - `ExecutionWitness.state` + l2-execution guest input: one Linea wrapper per block in the conflation. + + Each wrapper's `stateless_input_ssz` is the raw vanilla stateless-input + byte slice, decoded inside the guest path (no decoded-input fallback). The + first input's witness must end with the parent header whose hash equals + `executionPayload.parentHash`. + + The L1->L2 rolling-hash boundary values are not separate fields: the guest + reads them from L2 state at the parent and end roots (`L2State.storage`), so + the witness producer must include those MPT paths in `ExecutionWitness.state`. """ parent_ftx_rolling_hash: Hash32 parent_last_processed_ftx_number: U64 - blocks: List[RollupBlock] - execution_witnesses: List[ExecutionWitness] + payloads: List[LineaPayloadInput] chain_config: ChainConfig +def _decode_payload_stateless_inputs(payloads: Sequence[LineaPayloadInput]) -> List[StatelessInput]: + """ + Decode the vanilla stateless-input SSZ bytes inside the guest path — matching + the underlying engine's boundary, where the guest receives length-delimited + byte slices, not pre-decoded objects. + """ + decoded: List[StatelessInput] = [] + for index, payload in enumerate(payloads): + try: + decoded.append(decode_stateless_input_ssz(payload.stateless_input_ssz)) + except Exception as exc: + raise Exception(f"invalid statelessInputSsz for payload {index}") from exc + return decoded + + @dataclass class L2ExecutionProof: """ @@ -280,133 +289,111 @@ class L2ExecutionProof: def run_l2_execution_guest(execution_input: L2ExecutionProofPrivateInput) -> L2ExecutionProof: """ - l2-execution: validates the EVM state transition for a contiguous block - range and emits the 15-field l2-execution PI (§2.1). - """ - if len(execution_input.blocks) == 0: - raise Exception("l2-execution proof must cover at least one block") - decoded_blocks = [ - decode_block_rlp(rollup_block.block_rlp) - for rollup_block in execution_input.blocks - ] - if len(execution_input.execution_witnesses) != len(execution_input.blocks): - raise Exception("execution witness count must match block count") + l2-execution: emits the 15-field l2-execution PI (§2.1) for a contiguous + block range. - parent_header = parent_header_from_execution_witness( - decoded_blocks[0], - execution_input.execution_witnesses[0], - ) - parent_block_hash = block_hash(parent_header) - start_block_number = parent_header.number + 1 - current_parent_header = parent_header + The per-block state transition is delegated to the underlying engine + (`execute_stateless_input`); this function adds only the Linea logic on top — + conflation-level linking, the empty-`executionRequests` policy, forced + transactions, L2->L1 messages, and the L1->L2 bridge rolling-hash reads. + """ + if len(execution_input.payloads) == 0: + raise Exception("l2-execution proof must cover at least one payload") + + # Parse each vanilla stateless input ONCE via the underlying engine's parser + # (e.g. Zesu); the parsed objects are shared between execution and the Linea + # logic below, so nothing is re-parsed. + stateless_inputs = _decode_payload_stateless_inputs(execution_input.payloads) + all_witnesses = [stateless_input.witness for stateless_input in stateless_inputs] + + first_payload = stateless_inputs[0].new_payload_request.execution_payload + # The engine validates each payload's parentHash against its witness parent + # header, so the range's parent block hash is the first payload's parentHash + # and the start block number is the first payload's block number. + parent_block_hash = first_payload.parent_hash + start_block_number = first_payload.block_number + base_fee = Uint(first_payload.base_fee_per_gas) # asserted constant across the range (§2.1) l2_ms_address = execution_input.chain_config.l2_message_service_address - # `base_fee` is sourced from the first block's header. The loop below - # asserts every other block carries the same value, so any of them would - # do; the first is canonical. (§2.1) - base_fee = Uint(decoded_blocks[0].header.base_fee_per_gas) - - # Read the L1->L2 bridge rolling hash at the parent state root (start of - # this range) via the EVM state interface. The witness pool must contain - # the MPT path for the L2MessageService account + rolling-hash slots. - parent_state = L2State( - state_root=parent_header.state_root, - witnesses=execution_input.execution_witnesses, - ) - parent_rolling_hash, parent_rolling_hash_number = read_l1l2_bridge_state( - parent_state, l2_ms_address, - ) - + current_parent_hash = parent_block_hash current_ftx_rolling_hash = execution_input.parent_ftx_rolling_hash current_last_processed_ftx_number = execution_input.parent_last_processed_ftx_number l2_l1_message_hashes: List[Hash32] = [] tx_froms: List[Address] = [] filtered_addresses: List[Address] = [] - - if decoded_blocks[0].header.number != start_block_number: - raise Exception("l2-execution proof block range does not start after parent block") - - for rollup_block, execution_witness, block in zip( - execution_input.blocks, - execution_input.execution_witnesses, - decoded_blocks, - ): - if Uint(block.header.base_fee_per_gas) != base_fee: + results: List[StatelessExecutionResult] = [] + + for linea_payload, stateless_input in zip(execution_input.payloads, stateless_inputs): + payload = stateless_input.new_payload_request.execution_payload + + # ── Conflation-level invariants the engine cannot know (it validates each + # block in isolation against its own witness parent) ── + if stateless_input.chain_config.chain_id != execution_input.chain_config.chain_id: + raise Exception("stateless input chain_id does not match proof-range chain configuration") + if payload.parent_hash != current_parent_hash: + raise Exception("payload parentHash does not chain from the previous payload") + if Uint(payload.base_fee_per_gas) != base_fee: raise Exception("baseFee must be constant across an l2-execution proof") - if block.header.coinbase != execution_input.chain_config.coinbase: - raise Exception("block coinbase does not match chain configuration") - # Sequencer consensus rules: parent-hash chain & monotonic timestamps - # (validate_header inside state_transition_modified covers the - # standard Ethereum checks; here we restate the chain-level invariants). - if block.header.parent_hash != block_hash(current_parent_header): - raise Exception("block parent_hash does not chain from previous header") - if Uint(block.header.timestamp) <= Uint(current_parent_header.timestamp): - raise Exception("block timestamp must be strictly increasing") - - for tx_rlp in parse_block_transaction_rlps(rollup_block.block_rlp): + if payload.fee_recipient != execution_input.chain_config.coinbase: + raise Exception("payload feeRecipient does not match chain configuration") + # Monotonic timestamps and block-number contiguity follow from the + # engine's per-block timestamp/parent checks plus the parentHash chaining + # asserted above, so they are not restated here. + + # ── Linea policy: this rollup does not support EIP-7685 requests ── + requests = stateless_input.new_payload_request.execution_requests + if requests.deposits or requests.withdrawals or requests.consolidations: + raise Exception("execution requests are not supported by this rollup") + + # ── State transition (delegated) ── + # `execute_stateless_input` validates the witness header chain, the full + # Engine-API payload, and replays the EVM (see its docstring); none of + # that is re-checked here. It returns the boundary state roots and logs. + result = execute_stateless_input(stateless_input) + results.append(result) + + # Linea PI: recover each transaction sender for `txFromsHash`. + for tx_rlp in parse_payload_transaction_rlps(payload): tx_froms.append( - # PRECOMPILE (production guest): secp256k1 ecrecover. - # The zkVM exposes signature-recovery as a native circuit; - # the Python reference defers to the execution-specs - # implementation (which compiles to that primitive). recover_sender( execution_input.chain_config.chain_id, decode_signed_transaction_rlp(tx_rlp), - ), + ) ) - # FTX-invalid pre-validation reads the sender's account against the - # L2 state at this block's parent state root (§6.5 'Invalid'). The - # `L2State` interface is backed by the zkVM's MPT verifier - # (PRECOMPILE: keccak256 for node hashing) over the witness pool. - block_parent_state = L2State( - state_root=current_parent_header.state_root, - witnesses=execution_input.execution_witnesses, - ) + # Forced transactions (§6.5): FTX-invalid reads the sender account at this + # block's parent state root by walking the witness MPT (`L2State`). + block_parent_state = L2State(state_root=result.pre_state_root, witnesses=all_witnesses) block_filtered_addresses, current_ftx_rolling_hash, current_last_processed_ftx_number = ( validate_forced_transactions( current_ftx_rolling_hash, current_last_processed_ftx_number, execution_input.chain_config, - block.header, + payload, block_parent_state, - rollup_block, + linea_payload.rollup_extension.forced_transactions, ) ) filtered_addresses.extend(block_filtered_addresses) - # PRECOMPILE-INTENSIVE (production guest): full EVM state transition. - # `state_transition_modified` is the heart of the l2-execution proof — - # it replays the block and verifies the resulting header (state root, - # receipts root, transactions root, …). Internally it leans on - # zkVM-native primitives for keccak256, secp256k1 ecrecover, sha256 - # (used by some precompiled contracts), modexp, BLS12-381 pairings - # (point evaluation precompile on L1; here it's executed inside the - # EVM), and MPT verification of every state/storage read. - block_output = state_transition_modified( - execution_input.chain_config.chain_id, - current_parent_header, - execution_witness, - block, - rollup_block.block_rlp, - ) - current_parent_header = block.header - current_block = block - - for log in block_output.block_logs: - if log.address != execution_input.chain_config.l2_message_service_address: + # L2->L1 messages from the block's logs. + for log in result.block_logs: + if log.address != l2_ms_address: continue if log.topics[0] == BRIDGE_L2L1_MESSAGE_SENT_TOPIC_0: l2_l1_message_hashes.append(Hash32(log.topics[3])) - # Read the L1->L2 bridge rolling hash at the end state root (after all - # blocks in this range have applied) via the EVM state interface. - end_state = L2State( - state_root=current_block.header.state_root, - witnesses=execution_input.execution_witnesses, + current_parent_hash = payload.block_hash + + last_payload = stateless_inputs[-1].new_payload_request.execution_payload + + # L1->L2 bridge rolling-hash boundary reads, by walking the witness MPT + # (`L2State`), at the range's parent (pre) and end (post) state roots. + parent_rolling_hash, parent_rolling_hash_number = read_l1l2_bridge_state( + L2State(state_root=results[0].pre_state_root, witnesses=all_witnesses), l2_ms_address, ) end_rolling_hash, end_rolling_hash_number = read_l1l2_bridge_state( - end_state, l2_ms_address, + L2State(state_root=results[-1].post_state_root, witnesses=all_witnesses), l2_ms_address, ) if end_rolling_hash_number < parent_rolling_hash_number: @@ -414,9 +401,9 @@ def run_l2_execution_guest(execution_input: L2ExecutionProofPrivateInput) -> L2E public_inputs = L2ExecutionProofPublicInput( parent_block_hash=parent_block_hash, - end_block_hash=block_hash(current_block.header), - end_block_number=current_block.header.number, - end_block_timestamp=U64(current_block.header.timestamp), + end_block_hash=last_payload.block_hash, + end_block_number=last_payload.block_number, + end_block_timestamp=U64(last_payload.timestamp), l2_l1_messages_hash=hash_hash_list(l2_l1_message_hashes), parent_l1_l2_bridge_rolling_hash=parent_rolling_hash, parent_l1_l2_bridge_rolling_hash_message_number=parent_rolling_hash_number, @@ -433,38 +420,16 @@ def run_l2_execution_guest(execution_input: L2ExecutionProofPrivateInput) -> L2E return L2ExecutionProof( public_inputs=public_inputs, start_block_number=start_block_number, - end_block_number=current_block.header.number, + end_block_number=last_payload.block_number, l2_l1_messages=l2_l1_message_hashes, tx_froms=tx_froms, filtered_addresses=filtered_addresses, ) -def parent_header_from_execution_witness(block: EthereumBlock, execution_witness: ExecutionWitness) -> Header: - """ - Extract the parent header for the first execution block from the witness. - - The protocol witness supplies recent headers through - `debug_executionWitness.headers`; the parent header is the one whose block - hash equals the first block's `parent_hash`. - """ - parent_headers = [ - header - for header in execution_witness.headers - if block_hash(header) == block.header.parent_hash - ] - if len(parent_headers) != 1: - raise Exception("execution witness must contain exactly one parent header for the first block") - return parent_headers[0] - - def hash_hash_list(values: Sequence[Hash32]) -> Hash32: return keccak256(b"".join(bytes(value) for value in values)) def hash_address_list(values: Sequence[Address]) -> Hash32: return keccak256(b"".join(bytes(value) for value in values)) - - -def uint256_topic_to_int(topic: Bytes32) -> int: - return int.from_bytes(bytes(topic), "big") diff --git a/rollup_spec/prover_inputs/README.md b/rollup_spec/prover_inputs/README.md index 0f2b05f76b4..54159a884af 100644 --- a/rollup_spec/prover_inputs/README.md +++ b/rollup_spec/prover_inputs/README.md @@ -1,8 +1,8 @@ # Prover I/O Drafts — Type-1 RISC-V Rollup -This directory drafts the prover-request **logical schemas** for the new RISC-V proving stack described in `../Readme.md`. The shapes track the existing Linea conventions (0x-prefixed hex, base64-encoded blob bytes, RLP-encoded blocks, version strings) but cover the new public-input surface (14 fields at the rollup / rollup-aggregation layer, 15 fields at the l2-execution layer, FTX rolling hash, shnarf rebased on `lastBlockHash`). +This directory drafts the prover-request **logical schemas** for the new RISC-V proving stack described in `../Readme.md`. The shapes track the existing Linea conventions (0x-prefixed hex, base64-encoded blob bytes, RLP-encoded DA blocks, version strings) but cover the new public-input surface (14 fields at the rollup / rollup-aggregation layer, 15 fields at the l2-execution layer, FTX rolling hash, shnarf rebased on `lastBlockHash`). -> **JSON ≠ on-wire format.** These files describe *what* the coordinator hands to the prover at each layer (which fields exist, what they mean, how they relate). The bytes actually carried into the zkVM guest are **binary** — a length-prefixed container holding RLP-encoded blocks and the `debug_executionWitness` payload verbatim. See §3.3 of `../Readme.md` for the on-wire format spec. The JSON form here is the canonical source of truth for the schema, and is also accepted directly by the guest in a `--json` debug mode for fixture loading. +> **JSON ≠ on-wire format.** These files describe *what* the coordinator hands to the prover at each layer (which fields exist, what they mean, how they relate). For l2-execution, `payloads[].statelessInputSsz` is the vanilla stateless guest input: SSZ-encoded `StatelessInput` bytes. The Python guest path decodes those bytes with `stateless_input.py::decode_stateless_input_ssz`, backed by `remerkleable`, into `NewPayloadRequest`, `ExecutionWitness`, stateless chain config, and optional transaction public keys. A decoded `_debugStatelessInput` object may appear in draft/debug JSON only as a review mirror; loaders must derive or validate it from `statelessInputSsz` and discard it before constructing `L2ExecutionProofPrivateInput`. Linea rollup-extension metadata wraps the stateless input at the proof-range layer. See §3.3 of `../Readme.md` for the transport details. ## Proving flow @@ -20,7 +20,7 @@ One request file per guest layer: | File | Layer | What it proves | |---|---|---| -| `getZkL2ExecutionProof.request.json` | l2-execution proof (per block range, M ≥ 1 conflated blocks) | EVM state transition for a contiguous range of L2 blocks; emits the 15-field l2-execution PI tuple. | +| `getZkL2ExecutionProof.request.json` | l2-execution proof (per block range, M ≥ 1 conflated payloads) | EVM state transition for a contiguous range of Engine API `NewPayloadRequest`s; emits the 15-field l2-execution PI tuple. | | `getZkRollupProof.request.json` | rollup proof (per K ≥ 1 blobs) | For each blob, recomputes the canonical compressed payload from `blockRlps` (truncate → RLP-encode → LZ4-compress → zero-pad to 131072 bytes), computes the KZG commitment from those bytes, checks its versioned hash against the L1-committed `blobHash`, and verifies `blobKzgProof`; there is no separately witnessed `blobKzgCommitment` or `compressedData`. Also chains the shnarf transition across all K blobs and recursively verifies the N l2-execution proofs whose ranges tile the combined block range. Emits the 14-field rollup PI tuple. | | `getZkRollupAggregationProof.request.json` | rollup-aggregation proof + emulation (the final proof, SNARK-wrapped for L1) | Recursively verifies all M rollup proofs covering the finalization range, asserts pairwise continuity, merges the L2L1 root arrays and FTX filtered-address lists, emits the same 14-field PI tuple over the full range, and performs the STARK→SNARK emulation wrap in the same rollup-aggregation request. Flat (one guest invocation over all M); hierarchical aggregation is a future option. There is no separate emulation file. | @@ -31,10 +31,13 @@ A single rollup proof can fold `K ≥ 1` blobs together. `K = 1` is the simplest ## Common conventions - `proverVersion` — same string the existing prover responds with (e.g. `"4.0.0-riscv"`); coordinator forwards to L1. -- `chainConfig` — the dynamic chain configuration supplied at the l2-execution layer (`l2MessageServiceContract`, `coinbase`, `chainID`, `baseFee`). `dynamicChainConfigHash = keccak256(uint256_be(chainID) || coinbase || L2MessageServiceContract || uint256_be(baseFee))`, where integer fields are 32-byte big-endian values and addresses are canonical 20-byte values. Rollup and rollup-aggregation proofs do not carry `chainConfig` — they inherit the hash from inner-proof PIs. -- `executionWitness` — Besu `debug_executionWitness` payload (stateless witness: account/storage trie proofs, contract code, code hashes, recent headers). The first block's witness must include exactly one parent header whose block hash equals the first block's `parentHash`; there is no separate `parentBlockChain` input. The `state` MPT node pool must additionally include proof paths for any state the guest reads beyond block execution — at minimum, the L2MessageService's `L1L2RollingHash` and `L1L2RollingHashMessageNumber` slots at both the parent and end state roots (§2.1, §4.1), and the sender account of any Invalid FTX (§6.5). -- `blockRlp` and `signedTxRlp` are the canonical bytes for block and forced-transaction hashing. The Python reference decodes execution views from those bytes internally; decoded objects are not prover inputs and should not be re-encoded just to compute hashes. `fromAddress` for a forced transaction is recovered from `signedTxRlp` and is not a separate witness field. -- `forcedTransactions` — per-block witness array mirroring `block.py::ForcedTransactionWitness`. Empty for blocks without FTXs. +- `chainConfig` — the dynamic chain configuration supplied at the l2-execution range layer (`l2MessageServiceContract`, `coinbase`, `chainID`, `baseFee`). `dynamicChainConfigHash = keccak256(uint256_be(chainID) || coinbase || L2MessageServiceContract || uint256_be(baseFee))`, where integer fields are 32-byte big-endian values and addresses are canonical 20-byte values. `baseFee` is read from the first `NewPayloadRequest.executionPayload.baseFeePerGas` and asserted equal across the range. The range-level `chainID` intentionally duplicates the chain id decoded from each vanilla `StatelessInput`; the guest rejects the proof if any inner value differs. Rollup and rollup-aggregation proofs do not carry `chainConfig` — they inherit the hash from inner-proof PIs. +- `statelessInputSsz` — the length-delimited vanilla stateless-input SSZ bytes consumed by the l2-execution guest. The Python reference uses `remerkleable` for container decoding and accepts the same raw/Ere-prefixed shape that the underlying engine's decoder accepts. This is the only `StatelessInput` form accepted by `run_l2_execution_guest`; Linea extension bytes are parsed outside this slice and must not be appended to it. +- `newPayloadRequest` — the decoded Engine API request consumed by the l2-execution guest instead of an RLP-encoded block. It contains `executionPayload`, `versionedHashes`, `parentBeaconBlockRoot`, and typed `executionRequests`. +- `executionWitness` — decoded witness byte lists (`state`, `codes`, `headers`, and optional `keys`). `headers` are RLP-encoded parent/ancestor headers ordered by block number and ending at the payload parent; the parent header hash must equal `newPayloadRequest.executionPayload.parentHash`. The canonical EIP-8025 `SszExecutionWitness` contains `state`, `codes`, and `headers`; `keys` appears only in decoded JSON/debug mirrors unless a future Linea schema id explicitly adds it. The `state` MPT node pool must additionally include proof paths for any state the guest reads beyond block execution — at minimum, the L2MessageService's `L1L2RollingHash` and `L1L2RollingHashMessageNumber` slots at both the parent and end state roots (§2.1, §4.1), and the sender account of any Invalid FTX (§6.5). +- `publicKeys` — optional transaction public keys carried by the vanilla stateless input, ordered by `executionPayload.transactions` index. They are separate from `executionWitness.keys`, which are state-access/debug hints. The Linea logical spec and Python reference derive transaction senders with execution-specs `recover_sender(chainID, tx)`. `publicKeys` is not a witness override; any implementation optimization that consumes it must produce the same accepted/rejected transaction result and sender address as `recover_sender(chainID, tx)`. +- `signedTxRlp` remains the canonical bytes for forced-transaction hashing. Normal block transactions come from `newPayloadRequest.executionPayload.transactions` as canonical signed transaction byte lists; DA `blockRlps` are rollup-proof inputs only. `fromAddress` for a forced transaction is recovered from `signedTxRlp` and is not a separate witness field. +- `rollupExtension.forcedTransactions` — per-block witness array mirroring `block.py::ForcedTransactionWitness`. Empty for blocks without FTXs. - All fixed-size byte fields are `0x`-prefixed hex in JSON; blob bytes stay base64-encoded to match `prover/backend/blobsubmission`. In the Python reference, semantic hashes use `Hash32`, fixed-width non-hash values use `Bytes32`/`BytesN`, and plain `bytes` is reserved for variable-length encodings or payloads. - Cross-proof references use the `--getZk.json` filename convention already used by `prover/backend`. diff --git a/rollup_spec/prover_inputs/getZkL2ExecutionProof.request.json b/rollup_spec/prover_inputs/getZkL2ExecutionProof.request.json index cd6dc88fb54..18012c3942a 100644 --- a/rollup_spec/prover_inputs/getZkL2ExecutionProof.request.json +++ b/rollup_spec/prover_inputs/getZkL2ExecutionProof.request.json @@ -1,5 +1,5 @@ { - "_comment": "l2-execution proof request schema. Covers a contiguous range of L2 blocks; emits the 15-field PI tuple defined in §2.1.", + "_comment": "l2-execution proof request schema. Covers a contiguous range of L2 payloads; each entry carries a vanilla StatelessInput byte slice plus Linea rollup-extension metadata. Emits the 15-field PI tuple defined in §2.1.", "proverVersion": "4.0.0-riscv", "blockRange": { @@ -10,62 +10,186 @@ "_comment_publicInputs": "The 15-field PI tuple defined in §2.1.", "publicInputs": { "parentBlockHash": "0x0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "endBlockHash": "0x0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "endBlockNumber": 1000503, - "endBlockTimestamp": 1763000123, - "L2L1MessagesHash": "0x0000000000000000000000000000000000000000000000000000000000000000", - "parentL1L2BridgeRollingHash": "0x0000000000000000000000000000000000000000000000000000000000000000", - "parentL1L2BridgeRollingHashMessageNumber": 0, - "endL1L2BridgeRollingHash": "0x0000000000000000000000000000000000000000000000000000000000000000", - "endL1L2BridgeRollingHashMessageNumber": 0, - "dynamicChainConfigHash": "0xc0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff", - "parentFtxRollingHash": "0x0000000000000000000000000000000000000000000000000000000000000000", - "endFtxRollingHash": "0x0000000000000000000000000000000000000000000000000000000000000000", - "lastProcessedFtxNumber": 0, - "filteredAddressesHash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", - "txFromsHash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + "endBlockHash": "0x0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "endBlockNumber": 1000503, + "endBlockTimestamp": 1763000123, + "L2L1MessagesHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "parentL1L2BridgeRollingHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "parentL1L2BridgeRollingHashMessageNumber": 0, + "endL1L2BridgeRollingHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "endL1L2BridgeRollingHashMessageNumber": 0, + "dynamicChainConfigHash": "0xc0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff", + "parentFtxRollingHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "endFtxRollingHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "lastProcessedFtxNumber": 0, + "filteredAddressesHash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + "txFromsHash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" }, - "_comment_chainConfig": "Static chain configuration. Forms three of the four preimage inputs of publicInputs.dynamicChainConfigHash (§2.1); the fourth input (baseFee) is sourced from the first block header and asserted equal across every block in the range — it is NOT a private input. dynamicChainConfigHash = keccak256(uint256_be(chainId) || coinbase || l2MessageServiceContract || uint256_be(baseFee)). A change in any of these four values is a finalization boundary.", + "_comment_chainConfig": "Linea proof-range chain configuration. Forms three of the four preimage inputs of publicInputs.dynamicChainConfigHash (§2.1); the fourth input (baseFee) is sourced from the first NewPayloadRequest.executionPayload.baseFeePerGas and asserted equal across every payload in the range. chainId deliberately duplicates the chain id inside each vanilla StatelessInput; the guest rejects the proof if any decoded inner chain id differs from this range-level value. dynamicChainConfigHash = keccak256(uint256_be(chainId) || coinbase || l2MessageServiceContract || uint256_be(baseFee)). A change in any of these four values is a finalization boundary.", "chainConfig": { "l2MessageServiceContract": "0x508Ca82Df566dCD1B0019D2DEdF7e3D6f7Ad6ddE", - "coinbase": "0x0000000000000000000000000000000000000000", - "chainId": 59144 + "coinbase": "0x0000000000000000000000000000000000000000", + "chainId": 59144 }, - "_comment_blocks": "Canonical RLP-encoded L2 blocks (header + transaction list [+ withdrawals]), EIP-2718 typed transactions in full signed form. The parent block is not a top-level field — it lives inside `executionWitness.headers`. `forcedTransactions` is per-block FTX metadata (§6.5); empty for blocks without FTXs.", - "blocks": [ + "_comment_payloads": "One entry per L2 block in canonical block-range order. `statelessInputSsz` is a length-delimited vanilla stateless input byte slice (raw SSZ, or Ere length-prefixed SSZ). `_debugStatelessInput` is a decoded review mirror only; loaders must derive or validate it from `statelessInputSsz` and discard it before constructing L2ExecutionProofPrivateInput. `rollupExtension` carries Linea-specific metadata outside the StatelessInput; those bytes must not be appended to the stateless-input slice passed to the underlying engine.", + "payloads": [ { - "blockRlp": "0xf90215a0...", - "forcedTransactions": [] + "statelessInputSsz": "0x...", + "_debugStatelessInput": { + "newPayloadRequest": { + "executionPayload": { + "parentHash": "0x0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "feeRecipient": "0x0000000000000000000000000000000000000000", + "stateRoot": "0x1111111111111111111111111111111111111111111111111111111111111111", + "receiptsRoot": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logsBloom": "0x0000...", + "prevRandao": "0x3333333333333333333333333333333333333333333333333333333333333333", + "blockNumber": 1000501, + "gasLimit": 30000000, + "gasUsed": 12000000, + "timestamp": 1763000101, + "extraData": "0x", + "baseFeePerGas": "0x01", + "blockHash": "0x0111111111111111111111111111111111111111111111111111111111111111", + "transactions": ["0x02f86b..."], + "withdrawals": [], + "blobGasUsed": 0, + "excessBlobGas": 0, + "blockAccessList": "0x" + }, + "versionedHashes": [], + "parentBeaconBlockRoot": "0x4444444444444444444444444444444444444444444444444444444444444444", + "executionRequests": { + "deposits": [], + "withdrawals": [], + "consolidations": [] + } + }, + "executionWitness": { + "_comment": "Decoded logical execution witness fields. The canonical SszExecutionWitness decodes `state`, `codes`, and `headers`; `keys` is populated only by JSON/debug mirrors unless a future Linea schema id explicitly adds it. `headers` are RLP-encoded parent/ancestor headers ordered by block number and ending at this payload's parent.", + "state": ["0xf8..."], + "codes": ["0x60..."], + "keys": ["0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + "headers": ["0xf9..."] + }, + "chainConfig": { + "chainId": 59144, + "forkName": "Amsterdam" + }, + "_comment_publicKeys": "Optional transaction public keys ordered by executionPayload.transactions index. They are distinct from executionWitness.keys. The Linea logical spec derives senders with execution-specs recover_sender(chainID, tx). publicKeys is not a witness override; any implementation optimization that consumes it must produce the same accepted/rejected transaction result and sender address as recover_sender.", + "publicKeys": [] + }, + "rollupExtension": { + "forcedTransactions": [] + } }, { - "blockRlp": "0xf90215a0...", - "forcedTransactions": [ - { - "_comment": "Per-FTX metadata (§6.5). `ftxNumber`, `deadlineBlockNumber`, and `signedTxRlp` are the canonical fields. `fromAddress` is recovered from `signedTxRlp` inside the guest and is not witnessed separately. `acceptance` is one of the five `ForcedTransactionAcceptance` variants: INCLUDED, BAD_NONCE, BAD_BALANCE, FILTERED_ADDRESS_FROM, FILTERED_ADDRESS_TO. For Invalid sub-cases (BAD_NONCE / BAD_BALANCE) the guest reads the sender's account via the EVM state interface at this block's parent state root — no per-FTX state witness; the witness pool must include the MPT path (see `_comment_executionWitness`).", - "ftxNumber": 17, - "deadlineBlockNumber": 1000600, - "signedTxRlp": "0x02f86b...", - "acceptance": "INCLUDED" - } - ] + "statelessInputSsz": "0x...", + "_debugStatelessInput": { + "newPayloadRequest": { + "executionPayload": { + "parentHash": "0x0111111111111111111111111111111111111111111111111111111111111111", + "feeRecipient": "0x0000000000000000000000000000000000000000", + "stateRoot": "0x5555555555555555555555555555555555555555555555555555555555555555", + "receiptsRoot": "0x6666666666666666666666666666666666666666666666666666666666666666", + "logsBloom": "0x0000...", + "prevRandao": "0x7777777777777777777777777777777777777777777777777777777777777777", + "blockNumber": 1000502, + "gasLimit": 30000000, + "gasUsed": 11000000, + "timestamp": 1763000112, + "extraData": "0x", + "baseFeePerGas": "0x01", + "blockHash": "0x0222222222222222222222222222222222222222222222222222222222222222", + "transactions": ["0x02f86b..."], + "withdrawals": [], + "blobGasUsed": 0, + "excessBlobGas": 0, + "blockAccessList": "0x" + }, + "versionedHashes": [], + "parentBeaconBlockRoot": "0x8888888888888888888888888888888888888888888888888888888888888888", + "executionRequests": { + "deposits": [], + "withdrawals": [], + "consolidations": [] + } + }, + "executionWitness": { + "state": ["0xf8..."], + "codes": ["0x60..."], + "keys": ["0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + "headers": ["0xf9..."] + }, + "chainConfig": { + "chainId": 59144, + "forkName": "Amsterdam" + }, + "_comment_publicKeys": "Optional transaction public keys ordered by executionPayload.transactions index. They are distinct from executionWitness.keys. The Linea logical spec derives senders with execution-specs recover_sender(chainID, tx). publicKeys is not a witness override; any implementation optimization that consumes it must produce the same accepted/rejected transaction result and sender address as recover_sender.", + "publicKeys": [] + }, + "rollupExtension": { + "forcedTransactions": [ + { + "_comment": "Per-FTX metadata (§6.5). `ftxNumber`, `deadlineBlockNumber`, and `signedTxRlp` are the canonical fields. `fromAddress` is recovered from `signedTxRlp` inside the guest and is not witnessed separately. `acceptance` is one of the five ForcedTransactionAcceptance variants: INCLUDED, BAD_NONCE, BAD_BALANCE, FILTERED_ADDRESS_FROM, FILTERED_ADDRESS_TO. For Invalid sub-cases (BAD_NONCE / BAD_BALANCE) the guest reads the sender's account via the EVM state interface at this payload's parent state root; the witness pool must include the MPT path.", + "ftxNumber": 17, + "deadlineBlockNumber": 1000600, + "signedTxRlp": "0x02f86b...", + "acceptance": "INCLUDED" + } + ] + } }, { - "blockRlp": "0xf90215a0...", - "forcedTransactions": [] + "statelessInputSsz": "0x...", + "_debugStatelessInput": { + "newPayloadRequest": { + "executionPayload": { + "parentHash": "0x0222222222222222222222222222222222222222222222222222222222222222", + "feeRecipient": "0x0000000000000000000000000000000000000000", + "stateRoot": "0x9999999999999999999999999999999999999999999999999999999999999999", + "receiptsRoot": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "logsBloom": "0x0000...", + "prevRandao": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "blockNumber": 1000503, + "gasLimit": 30000000, + "gasUsed": 10000000, + "timestamp": 1763000123, + "extraData": "0x", + "baseFeePerGas": "0x01", + "blockHash": "0x0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "transactions": [], + "withdrawals": [], + "blobGasUsed": 0, + "excessBlobGas": 0, + "blockAccessList": "0x" + }, + "versionedHashes": [], + "parentBeaconBlockRoot": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "executionRequests": { + "deposits": [], + "withdrawals": [], + "consolidations": [] + } + }, + "executionWitness": { + "state": ["0xf8..."], + "codes": ["0x60..."], + "keys": [], + "headers": ["0xf9..."] + }, + "chainConfig": { + "chainId": 59144, + "forkName": "Amsterdam" + }, + "_comment_publicKeys": "Optional transaction public keys ordered by executionPayload.transactions index. They are distinct from executionWitness.keys. The Linea logical spec derives senders with execution-specs recover_sender(chainID, tx). publicKeys is not a witness override; any implementation optimization that consumes it must produce the same accepted/rejected transaction result and sender address as recover_sender.", + "publicKeys": [] + }, + "rollupExtension": { + "forcedTransactions": [] + } } - ], - - "_comment_executionWitness": "Output of Besu's `debug_executionWitness`, one entry per block in canonical block-range order. Each entry contains `state` (state-trie nodes touched), `keys` (storage keys accessed), `codes` (accessed contract bytecode), and `headers` (recent block headers for the BLOCKHASH opcode). The first entry's `headers` must contain exactly one parent header whose block hash equals the first block's parentHash; that header anchors `parentBlockHash` and provides the starting state root. The `state` pool MUST additionally include MPT paths for: (a) the L2MessageService's `L1L2RollingHash` and `L1L2RollingHashMessageNumber` slots at both the parent and end state roots (read at proof-range boundaries via the EVM state interface, §2.1 / §4.1) — even when no block in the range writes them; (b) the sender account of any FTX whose declared outcome is Invalid, at the parent state root of the block where that FTX would have been included (§6.5).", - "executionWitness": [ - { - "state": ["..."], - "keys": ["..."], - "codes": ["..."], - "headers": ["..."] - }, - { "state": ["..."], "keys": ["..."], "codes": ["..."], "headers": ["..."] }, - { "state": ["..."], "keys": ["..."], "codes": ["..."], "headers": ["..."] } ] } diff --git a/rollup_spec/prover_inputs/getZkRollupAggregationProof.request.json b/rollup_spec/prover_inputs/getZkRollupAggregationProof.request.json index 6309a0d4a6a..85fcbd43315 100644 --- a/rollup_spec/prover_inputs/getZkRollupAggregationProof.request.json +++ b/rollup_spec/prover_inputs/getZkRollupAggregationProof.request.json @@ -9,6 +9,9 @@ "endBlockNumber": 1000567 }, + "_comment_isAllowedCircuitID": "Environment-dependent bitmask gating which inner circuit/program identities the rollup-aggregation guest may accept during recursive STARK verification (§2.3 step 1). Bit i (LSb->MSb) allows circuit ID i, following the prover's circuits.GlobalCircuitIDMapping convention; computed with circuits.ComputeIsAllowedCircuitID. Mirrors the prover config field Aggregation.is_allowed_circuit_id (prover/config/config.go) and is echoed back in the aggregation response as isAllowedCircuitID. It is a proving-policy input only: it is NOT part of dynamicChainConfigHash and NOT one of the 14 PI-tuple fields, so it does not appear in expectedRollupAggregationPublicInputs. The example value 15932 (0b11111000111100) enables the production payload + invalidity circuits (bits 2-5, 9-13); testnet/dev environments additionally enable the dummy circuits.", + "isAllowedCircuitID": 15932, + "_comment_rollupProofs": "M rollup proofs in shnarf-chain order. Each entry inlines everything the rollup-aggregation guest needs for that rollup proof: the recursive STARK `proof` bytes, the 14-field `publicInputs` tuple, and the preimages of the two hash-committed PI fields — `L2L1Roots` (preimage of publicInputs.L2L1BridgeTransactionTree) and `filteredAddresses` (preimage of publicInputs.filteredAddressesHash).", "rollupProofs": [ { diff --git a/rollup_spec/prover_inputs/getZkRollupProof.request.json b/rollup_spec/prover_inputs/getZkRollupProof.request.json index 267dd0ceebf..a197ced30af 100644 --- a/rollup_spec/prover_inputs/getZkRollupProof.request.json +++ b/rollup_spec/prover_inputs/getZkRollupProof.request.json @@ -12,7 +12,7 @@ "endBlockNumber": 1000520 }, - "_comment_blobs": "K = 2 blobs in this example (K = 1 is the degenerate single-blob case). Ordered by on-chain shnarf-chain order. Each entry carries `blobHash`, `blobKzgProof`, its block range, and `blockRlps` — the canonical full block RLPs (one per block in [startBlockNumber, endBlockNumber]), same canonical form the l2-execution proof receives. The rollup guest truncates each block internally per §3.2, RLP-encodes the truncated form, LZ4-compresses it, zero-pads to BLOB_BYTES_LENGTH (4096 × 32 = 131072 bytes), computes the KZG commitment from those bytes, asserts `kzg_commitment_to_versioned_hash(computedCommitment) == blobHash`, and verifies `blobKzgProof` via `verify_blob_kzg_proof` (§2.2 step 1). `blobKzgCommitment` and compressed blob bytes are *not* witness fields — they are recomputed inside the guest. There is no separately witnessed truncated form either; the full block RLPs' authenticity is anchored by KZG plus downstream cross-checks against the l2-execution proofs (block-hash boundaries, txFromsHash).", + "_comment_blobs": "K = 2 blobs in this example (K = 1 is the degenerate single-blob case). Ordered by on-chain shnarf-chain order. Each entry carries `blobHash`, `blobKzgProof`, its block range, and `blockRlps` — the canonical full block RLPs published through the DA path (one per block in [startBlockNumber, endBlockNumber]). The l2-execution proof now receives Engine API `NewPayloadRequest` inputs, not these RLP blocks; the rollup guest cross-checks DA `blockRlps` against l2-execution public block hashes and txFromsHash. The rollup guest truncates each block internally per §3.2, RLP-encodes the truncated form, LZ4-compresses it, zero-pads to BLOB_BYTES_LENGTH (4096 × 32 = 131072 bytes), computes the KZG commitment from those bytes, asserts `kzg_commitment_to_versioned_hash(computedCommitment) == blobHash`, and verifies `blobKzgProof` via `verify_blob_kzg_proof` (§2.2 step 1). `blobKzgCommitment` and compressed blob bytes are *not* witness fields — they are recomputed inside the guest. There is no separately witnessed truncated form either; the full block RLPs' authenticity is anchored by KZG plus downstream cross-checks against the l2-execution proofs (block-hash boundaries, txFromsHash).", "blobs": [ { "blobInputs": { diff --git a/rollup_spec/requirements.txt b/rollup_spec/requirements.txt index 3b7d0063f71..e9a90dddc4e 100644 --- a/rollup_spec/requirements.txt +++ b/rollup_spec/requirements.txt @@ -1,3 +1,8 @@ -ethereum-execution @ git+https://github.com/ethereum/execution-specs.git +# Pinned to a specific projects/zkevm commit (not the moving branch) for +# reproducibility: ProtocolFork was last changed by PR #2926 (2026-05-27) and is +# still in flux. This commit yields Amsterdam SSZ fork index = 20. +# https://github.com/ethereum/execution-specs/tree/a456712e04153ebeb17ff892446a01b6ba537f65 +ethereum-execution @ git+https://github.com/ethereum/execution-specs.git@a456712e04153ebeb17ff892446a01b6ba537f65 ckzg>=2.1 lz4>=4 +remerkleable>=0.1.28 diff --git a/rollup_spec/rollup.py b/rollup_spec/rollup.py index e2bc2056fc9..4104d665c8b 100644 --- a/rollup_spec/rollup.py +++ b/rollup_spec/rollup.py @@ -11,7 +11,7 @@ KZGCommitment, kzg_commitment_to_versioned_hash, ) -from ethereum.forks.osaka.transactions import ( +from .fork import ( AccessListTransaction, BlobTransaction, FeeMarketTransaction, @@ -113,12 +113,14 @@ class BlobWitness: Fields: - `block_number_range`: `(startBlockNumber, endBlockNumber)` of the L2 blocks contained in this blob. - - `block_rlps`: the canonical full block RLPs (same shape the - l2-execution proof receives — header + tx list [+ withdrawals], EIP-2718 - typed transactions in full signed form), one per block in - `block_number_range`. Truncation per §3.2 happens *inside* the - guest from these full RLPs; there is no separately-witnessed - truncated form. + - `block_rlps`: the canonical full block RLPs published through the DA + blob path (header + tx list [+ withdrawals], EIP-2718 typed + transactions in full signed form), one per block in + `block_number_range`. The l2-execution proof receives Engine API + `NewPayloadRequest` inputs instead; the rollup proof cross-checks this + DA material against l2-execution public block hashes and `txFromsHash`. + Truncation per §3.2 happens *inside* the guest from these full RLPs; + there is no separately-witnessed truncated form. - `blob_hash`: the L1-anchored versioned hash of the DA blob. The rollup guest computes the KZG commitment from the computed padded blob bytes and checks its versioned hash against this value. @@ -457,15 +459,10 @@ def verify_l2_execution_proof(proof: L2ExecutionProof) -> None: """ Verify an inner l2-execution proof against its claimed public inputs. - PRECOMPILE (production guest): recursive STARK verification. - The zkVM exposes the inner-proof verifier as a circuit primitive - (typically wired through the underlying field's hash precompile, - e.g. Poseidon2 for KoalaBear / Goldilocks). In this reference, - the recursive-verify step is implicit — `L2ExecutionProof.proof` - stands in for the recursive STARK bytes the guest would actually - check. The Python reference only re-checks the hash-preimage - bindings (`txFromsHash`, `L2L1MessagesHash`, `filteredAddressesHash`) - the rollup proof consumes alongside the PI tuple. + Recursive STARK verification is a zkVM primitive in the guest; here + `L2ExecutionProof.proof` stands in for those bytes, and the reference only + re-checks the hash-preimage bindings (`txFromsHash`, `L2L1MessagesHash`, + `filteredAddressesHash`) the rollup proof consumes alongside the PI tuple. """ if proof.public_inputs.end_block_number != proof.end_block_number: raise Exception("l2-execution proof range metadata does not match public inputs") diff --git a/rollup_spec/state_transition.py b/rollup_spec/state_transition.py index c1a75f8a1b3..5b3fdc11070 100644 --- a/rollup_spec/state_transition.py +++ b/rollup_spec/state_transition.py @@ -1,47 +1,87 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import cached_property from typing import Dict, List, Optional, Sequence, Tuple -from ethereum.forks.osaka.fork import ( - BlockChain, - MAX_RLP_BLOCK_SIZE, - validate_header, - get_last_256_block_hashes, - apply_body, -) - -from ethereum.exceptions import InvalidBlock -from ethereum.forks.osaka import vm -from ethereum.forks.osaka.blocks import Block, Header -from ethereum.forks.osaka.bloom import logs_bloom -from ethereum.forks.osaka.requests import compute_requests_hash -from ethereum.state import Address, Account, state_root from ethereum.crypto.hash import Hash32, keccak256 -from ethereum.merkle_patricia_trie import root +from ethereum.state import Account, Address from ethereum_rlp import rlp -from ethereum_types.bytes import Bytes, Bytes32 -from ethereum_types.numeric import U64, U256, Uint +from ethereum_types.bytes import Bytes32 +from ethereum_types.numeric import U256, Uint + +from .block import StatelessInput +from .fork import Log + @dataclass class ExecutionWitness: """ - Logical form of Besu's `debug_executionWitness` payload for one block. + Decoded execution witness for one payload (part of the parsed + `StatelessInput`). - The binary/JSON schema carries these fields as encoded bytes. The Python - reference treats headers as decoded `Header` objects so it can state the - parent-header matching rule directly. + `headers` are RLP ancestor headers ordered by block number, ending at the + payload parent. `keys` is not in the SSZ wire format (JSON/debug only), + defaulted empty. - The `state` pool must include MPT paths for every account/slot the - l2-execution guest reads — both what block execution naturally touches - and any extra reads the guest performs (e.g., L1->L2 rolling-hash slots at - boundary state roots, FTX-sender accounts for §6.5 'Invalid' checks). + The pool must cover every account/slot read — both what block execution + touches (served by the underlying engine) and the Linea-extra reads + (L1->L2 rolling-hash slots at the boundary roots, FTX-sender accounts for + §6.5 'Invalid'). """ state: List[bytes] codes: List[bytes] - keys: List[bytes] - headers: List[Header] + headers: List[bytes] + keys: List[bytes] = field(default_factory=list) + + +@dataclass +class StatelessExecutionResult: + """ + Result of executing one stateless input (see `execute_stateless_input`). + + Mirrors the proof output an underlying engine exposes; the Linea layer + consumes these without re-running execution. + """ + pre_state_root: Hash32 # parent (pre-execution) state root of the block + post_state_root: Hash32 # post-execution state root (== executionPayload.stateRoot) + block_logs: List[Log] # ordered logs emitted by the block, for L2->L1 message extraction +def execute_stateless_input(stateless_input: StatelessInput) -> StatelessExecutionResult: + """ + Validate and execute one block from its parsed `StatelessInput`. + + This is the spec's boundary to the underlying stateless block-execution + engine (for example a Zesu-style guest, imported as a dependency or + reimplemented). The spec does not model its internals; an underlying + implementation is expected to perform, from the parsed `StatelessInput` + alone: + + - witness header-chain validation and parent-header anchoring (the last + witness header hash must equal `executionPayload.parentHash`); + - full Engine-API payload validation (block hash, versioned hashes, + `parentBeaconBlockRoot`, base-fee correctness, gas / blob-gas, timestamp + vs parent, state root, receipts root, logs bloom, EIP-7928 block access + list); + - the EVM state transition itself. + + It returns the boundary state roots and the block logs the Linea layer builds + on. It does NOT enforce Linea policy (e.g. empty `executionRequests`) or + conflation-level invariants — those live in `run_l2_execution_guest`. + """ + raise NotImplementedError( + "stateless block execution is provided by the underlying engine " + "(e.g. Zesu); the spec models only the Linea-specific logic on top" + ) + + +# ─── Witness-backed MPT state reads ──────────────────────────────────────────── +# +# The Linea layer reads a little L2 state on top of delegated block execution +# (L1->L2 bridge rolling hash, FTX-sender accounts). Those reads are proven +# against a state root by walking the witness node pool directly, so the spec +# stays self-contained and checkable against a fixture rather than depending on +# the engine to expose its internal state database. + # keccak256(rlp.encode(b"")) — the canonical Ethereum "empty trie" root. EMPTY_TRIE_ROOT_HASH: Hash32 = Hash32( bytes.fromhex("56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"), @@ -57,15 +97,6 @@ def _build_node_index(witnesses: Sequence["ExecutionWitness"]) -> Dict[Hash32, b return index -def _build_code_index(witnesses: Sequence["ExecutionWitness"]) -> Dict[Hash32, bytes]: - """Build `{keccak256(code) -> code}` over `witnesses[*].codes`.""" - index: Dict[Hash32, bytes] = {} - for w in witnesses: - for code in w.codes: - index[Hash32(keccak256(code))] = bytes(code) - return index - - def _bytes_to_nibbles(data: bytes) -> List[int]: """Expand a byte string into a flat list of 4-bit nibbles, high first.""" out: List[int] = [] @@ -77,12 +108,9 @@ def _bytes_to_nibbles(data: bytes) -> List[int]: def _compact_to_nibbles(compact: bytes) -> Tuple[List[int], bool]: """ - Decode the Ethereum MPT compact-encoded path (see "Hex-Prefix Encoding" - in the Yellow Paper). - - The first nibble carries two flag bits: 0x20 = leaf, 0x10 = odd-length - path. If odd, the low half of the first byte is the first path nibble. - Remaining bytes each contribute two nibbles. + Decode the Ethereum MPT compact-encoded path (Yellow Paper "Hex-Prefix + Encoding"). The first nibble carries two flags: 0x20 = leaf, 0x10 = odd + length. If odd, the low half of the first byte is the first path nibble. """ if len(compact) == 0: raise Exception("empty compact-encoded path") @@ -106,14 +134,13 @@ def _mpt_lookup( """ Walk the Ethereum MPT rooted at `state_root` along the path `keccak256(key)` and return the leaf value bytes, or None on proof of - absence (path diverges or terminates at an empty branch slot). - - Raises if a referenced node is missing from `node_index` (the witness - pool does not cover the path) or if the proof is malformed. + absence (the path diverges or terminates at an empty branch slot). - Inline children (nodes whose RLP is < 32 bytes and stored inline rather - than referenced by hash) are not supported by this reference verifier; - they are vanishingly rare in real-world account/storage tries. + Raises if a referenced node is missing from `node_index` (the witness pool + does not cover the path) or if the proof is malformed. Inline children + (nodes whose RLP is < 32 bytes, stored inline rather than by hash) are not + supported by this reference verifier; they are vanishingly rare in + real-world account/storage tries. """ if state_root == EMPTY_TRIE_ROOT_HASH: return None @@ -168,11 +195,10 @@ def _mpt_lookup( def _decode_account_leaf(leaf_rlp: bytes) -> Tuple[Account, Hash32]: """ - Decode an account-trie leaf value (`RLP([nonce, balance, storageRoot, - codeHash])`) into the pair `(Account, storage_root)`. `Account` carries - nonce/balance/code_hash; `storage_root` is returned separately because - the in-memory `Account` dataclass does not expose it (storage is - materialized as a per-account map in execution-specs). + Decode an account-trie leaf (`RLP([nonce, balance, storageRoot, codeHash])`) + into `(Account, storage_root)`. `storage_root` is returned separately + because the in-memory `Account` does not expose it (execution-specs + materializes storage as a per-account map). """ decoded = rlp.decode(leaf_rlp) if not isinstance(decoded, list) or len(decoded) != 4: @@ -181,34 +207,23 @@ def _decode_account_leaf(leaf_rlp: bytes) -> Tuple[Account, Hash32]: nonce = Uint(int.from_bytes(nonce_b, "big")) balance = U256(int.from_bytes(balance_b, "big")) storage_root = Hash32(storage_root_b.rjust(32, b"\x00")) - code_hash = Bytes32(code_hash_b.rjust(32, b"\x00")) + code_hash = Hash32(code_hash_b.rjust(32, b"\x00")) return Account(nonce=nonce, balance=balance, code_hash=code_hash), storage_root @dataclass class L2State: """ - Read-only view of the L2 state at a particular state root. - - Backed by the proof-range `ExecutionWitness` payload that the guest - already receives as private input: the aggregated MPT node pool - (`witnesses[*].state` plus any extra paths the witness producer - included for boundary reads) supports `account()` and `storage()` - via inclusion-proof verification against `state_root`, and the codes - pool (`witnesses[*].codes`) supports `code()` lookups by `code_hash`. - - The production guest treats this as the EVM Database interface - (zesu-style: `basic`, `storage`, `codeByHash`). This Python reference - implements the MPT walk explicitly so the data flow is checkable end - to end against a fixture. - - PRECOMPILE (production guest): keccak256. - The MPT walk implemented below by `_mpt_lookup` invokes keccak256 - once per node (to look up by hash in `_node_index`) — in the - production guest each of these is a zkVM-native primitive call. - The walk logic itself (RLP-decode the node, classify branch / - extension / leaf, follow path nibbles) runs as ordinary in-guest - code on top of that primitive. + Read-only, witness-backed view of L2 state at a state root. + + Exposes the EVM state reads the Linea layer needs on top of delegated block + execution: the L1->L2 bridge rolling hash (`storage`) and forced-tx sender + accounts (`account`). Both are served by an explicit MPT inclusion walk over + the witness node pool (`witnesses[*].state`) against `state_root`. The + production guest performs the same walk on top of its EVM state-database + interface (e.g. Zesu's). keccak256 (one call per node, to resolve the next + node by hash) is a zkVM-native primitive there; the walk itself is ordinary + in-guest code. """ state_root: Hash32 witnesses: Sequence["ExecutionWitness"] @@ -217,33 +232,24 @@ class L2State: def _node_index(self) -> Dict[Hash32, bytes]: return _build_node_index(self.witnesses) - @cached_property - def _code_index(self) -> Dict[Hash32, bytes]: - return _build_code_index(self.witnesses) - def _account_with_storage_root(self, address: Address) -> Optional[Tuple[Account, Hash32]]: leaf = _mpt_lookup(self.state_root, bytes(address), self._node_index) return None if leaf is None else _decode_account_leaf(leaf) def account(self, address: Address) -> Optional[Account]: - """ - Read the account at `address`. Returns None if the account is - absent (its absence proven by the MPT walk). - """ + """Account at `address`, or None if absent (proven by the MPT walk).""" result = self._account_with_storage_root(address) return None if result is None else result[0] def storage(self, address: Address, slot: Bytes32) -> Bytes32: """ - Read storage[`slot`] of `address`. Returns the zero `Bytes32` if - the account is absent or the slot is unset. Internally: look up - the account leaf for `storage_root`, then walk the per-account - storage trie at `keccak256(slot)`. - - Raises on a malformed leaf (RLP decoding that does not yield raw - bytes is not a valid Ethereum storage slot — silently treating it - as zero would mask witness tampering, so the proof must reject - the read outright). + Storage value at (`address`, `slot`), or zero if the account is absent + or the slot is unset. Looks up the account leaf for its `storage_root`, + then walks the per-account storage trie at `keccak256(slot)`. + + Raises on a malformed leaf: RLP that does not decode to raw bytes is not + a valid storage slot, and silently zeroing it would mask witness + tampering, so the read is rejected outright. """ result = self._account_with_storage_root(address) if result is None: @@ -252,8 +258,8 @@ def storage(self, address: Address, slot: Bytes32) -> Bytes32: slot_leaf = _mpt_lookup(storage_root, bytes(slot), self._node_index) if slot_leaf is None: return Bytes32(b"\x00" * 32) - # Storage values are RLP(value) where `value` is the big-endian - # integer with leading zeros stripped. Left-pad back to 32 bytes. + # Storage values are RLP(value) where `value` is the big-endian integer + # with leading zeros stripped. Left-pad back to 32 bytes. decoded = rlp.decode(slot_leaf) if not isinstance(decoded, (bytes, bytearray)): raise Exception( @@ -261,121 +267,3 @@ def storage(self, address: Address, slot: Bytes32) -> Bytes32: f"RLP decode produced {type(decoded).__name__}, expected bytes" ) return Bytes32(bytes(decoded).rjust(32, b"\x00")) - - def code(self, code_hash: Hash32) -> Bytes: - """ - Read contract code by hash from `witnesses[*].codes`. Returns - empty bytes if the code is absent from the pool (caller is - expected to handle that case — e.g. EOAs whose codeHash is - `EMPTY_CODE_HASH`). - """ - return Bytes(self._code_index.get(code_hash, b"")) - - -def materialize_blockchain_from_execution_witness( - chain_id: U64, - parent_header: Header, - execution_witness: ExecutionWitness, -) -> BlockChain: - """ - Build the transient execution-spec `BlockChain` adapter from the stateless - witness. - - This adapter is not a prover input. The production guest reconstructs the - same execution context from `execution_witness.state`, `codes`, `keys`, and - `headers`. - """ - raise NotImplementedError("stateless execution witness replay is not implemented in this Python reference") - - -def state_transition_modified( - chain_id: U64, - parent_header: Header, - execution_witness: ExecutionWitness, - block: Block, - block_rlp: bytes, -) -> vm.BlockOutput: - """ - This function mirrors [ethereum.forks.osaka.fork.state_transition] but takes - the protocol witness shape. It materializes the execution-spec `BlockChain` - adapter internally and returns `block_output` so the caller can inspect logs. - - PRECOMPILE-INTENSIVE (production guest): - This is the most precompile-heavy step in the entire proof stack. - Replaying the block exercises (at minimum): - - keccak256 — block-hash, MPT node hashes, tx hashes - - secp256k1 ecrecover — every external tx (one per signer) - - sha256 / ripemd160 — precompiled contracts 0x02 / 0x03 - - modexp — precompile 0x05 - - BN254 add/mul/pairing — precompiles 0x06 / 0x07 / 0x08 - - BLS12-381 ops — EIP-2537 precompiles - - KZG point evaluation — precompile 0x0A (EIP-4844) - - MPT verification — every SLOAD / SSTORE / account touch - Each of these is a zkVM-native primitive in the production guest; - the Python reference defers to execution-specs' Python implementation, - which in turn would compile to those primitives in a real build. - """ - if len(block_rlp) > MAX_RLP_BLOCK_SIZE: - raise InvalidBlock("Block rlp size exceeds MAX_RLP_BLOCK_SIZE") - - chain = materialize_blockchain_from_execution_witness( - chain_id, - parent_header, - execution_witness, - ) - validate_header(chain, block.header) - if block.ommers != (): - raise InvalidBlock - - block_env = vm.BlockEnvironment( - chain_id=chain.chain_id, - state=chain.state, - block_gas_limit=block.header.gas_limit, - block_hashes=get_last_256_block_hashes(chain), - coinbase=block.header.coinbase, - number=block.header.number, - base_fee_per_gas=block.header.base_fee_per_gas, - time=block.header.timestamp, - prev_randao=block.header.prev_randao, - excess_blob_gas=block.header.excess_blob_gas, - parent_beacon_block_root=block.header.parent_beacon_block_root, - ) - - block_output = apply_body( - block_env=block_env, - transactions=block.transactions, - withdrawals=block.withdrawals, - ) - block_state_root = state_root(block_env.state) - transactions_root = root(block_output.transactions_trie) - receipt_root = root(block_output.receipts_trie) - block_logs_bloom = logs_bloom(block_output.block_logs) - withdrawals_root = root(block_output.withdrawals_trie) - requests_hash = compute_requests_hash(block_output.requests) - - if block_output.block_gas_used != block.header.gas_used: - raise InvalidBlock( - f"{block_output.block_gas_used} != {block.header.gas_used}" - ) - if transactions_root != block.header.transactions_root: - raise InvalidBlock - if block_state_root != block.header.state_root: - raise InvalidBlock - if receipt_root != block.header.receipt_root: - raise InvalidBlock - if block_logs_bloom != block.header.bloom: - raise InvalidBlock - if withdrawals_root != block.header.withdrawals_root: - raise InvalidBlock - if block_output.blob_gas_used != block.header.blob_gas_used: - raise InvalidBlock - if requests_hash != block.header.requests_hash: - raise InvalidBlock - - chain.blocks.append(block) - if len(chain.blocks) > 255: - # Real clients have to store more blocks to deal with reorgs, but the - # protocol only requires the last 255 - chain.blocks = chain.blocks[-255:] - - return block_output diff --git a/rollup_spec/stateless_input.py b/rollup_spec/stateless_input.py new file mode 100644 index 00000000000..2bd05f2f14d --- /dev/null +++ b/rollup_spec/stateless_input.py @@ -0,0 +1,300 @@ +from typing import Any, TypeAlias + +from ethereum.crypto.hash import Hash32 +from ethereum.state import Address +from ethereum_types.bytes import Bytes32 +from ethereum_types.numeric import U64, U256, Uint +from remerkleable.basic import uint64 +from remerkleable.byte_arrays import ByteList, ByteVector, Bytes32 as SszBytes32 +from remerkleable.complex import Container, List + +from . import canonical_ssz as cl +from . import fork +from .fork import Withdrawal +from .block import ( + ConsolidationRequest, + DepositRequest, + ExecutionPayload, + ExecutionRequests, + NewPayloadRequest, + StatelessChainConfig, + StatelessInput, + WithdrawalRequest, +) +from .state_transition import ExecutionWitness + + +# Two-byte big-endian schema id that every stateless input is prefixed with +# (execution-specs `stateless_ssz.py::SCHEMA_ID`). Required by the decoder. +STATELESS_INPUT_SCHEMA_ID = 0x0001 +STATELESS_INPUT_SCHEMA_ID_SIZE = 2 + +# ── SSZ list/vector bounds ─────────────────────────────────────────────────── +# Mirrors execution-specs `stateless_ssz.py` at the pinned commit (see +# requirements.txt). Re-sync if the pin moves. +# https://github.com/ethereum/execution-specs/blob/a456712e04153ebeb17ff892446a01b6ba537f65/src/ethereum/forks/amsterdam/stateless_ssz.py +MAX_BLOB_COMMITMENTS_PER_BLOCK = 4096 +MAX_WITNESS_NODES = 2**20 +MAX_WITNESS_CODES = 2**16 +MAX_WITNESS_HEADERS = 256 +MAX_BYTES_PER_WITNESS_NODE = 2**20 +MAX_BYTES_PER_CODE = 2**24 +MAX_BYTES_PER_HEADER = 2**10 +MAX_OPTIONAL_FORK_ACTIVATION_VALUES = 1 +MAX_BLOB_SCHEDULES_PER_FORK = 1 +MAX_PUBLIC_KEYS = 2**15 +PUBLIC_KEY_BYTES = 65 + + +class InvalidSsz(ValueError): + pass + + +# ── SSZ wire schema (remerkleable) ─────────────────────────────────────────── +# +# The execution-specs Amsterdam stateless-input schema, mirrored from +# `stateless_ssz.py` (see link above). Canonical consensus-layer leaf types +# (`cl.ExecutionPayload`, `cl.Withdrawal`, `cl.ExecutionRequests`) are copy-pasted +# verbatim in `canonical_ssz.py` and reused here. + + +class SszExecutionWitness(Container): + # No `keys` field: it is not in the SSZ wire format (it is carried only in a + # JSON/debug witness path, e.g. Zesu's). The logical `ExecutionWitness` keeps + # it for that path; a 4-field witness here would be rejected. + state: List[ByteList[MAX_BYTES_PER_WITNESS_NODE], MAX_WITNESS_NODES] + codes: List[ByteList[MAX_BYTES_PER_CODE], MAX_WITNESS_CODES] + headers: List[ByteList[MAX_BYTES_PER_HEADER], MAX_WITNESS_HEADERS] + + +# Amsterdam payload: canonical `ExecutionPayload` (reused from `canonical_ssz`) +# plus the two Amsterdam fields the wire carries inline — the EIP-7928 block +# access list (an opaque RLP blob, like `transactions`) and `slot_number`. Per +# `stateless_ssz.py::SszExecutionPayload`. +class SszExecutionPayload(cl.ExecutionPayload): + block_access_list: ByteList[cl.MAX_BYTES_PER_TRANSACTION] + slot_number: uint64 + + +class SszNewPayloadRequest(Container): + execution_payload: SszExecutionPayload + versioned_hashes: List[SszBytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK] + parent_beacon_block_root: SszBytes32 + execution_requests: cl.ExecutionRequests + + +# ── ChainConfig (mirrored from stateless_ssz.py) ───────────────────────────── +# The fork is identified by `active_fork.fork`, the index of the active +# `ProtocolFork` in `PROTOCOL_FORKS` (see fork.py). Optional fields are modeled +# as SSZ lists of max length 1, exactly as execution-specs does. +SszOptionalForkActivationValue: TypeAlias = List[uint64, MAX_OPTIONAL_FORK_ACTIVATION_VALUES] + + +class SszForkActivation(Container): + block_number: SszOptionalForkActivationValue + timestamp: SszOptionalForkActivationValue + + +class SszBlobSchedule(Container): + target: uint64 + max: uint64 + base_fee_update_fraction: uint64 + + +SszOptionalBlobSchedule: TypeAlias = List[SszBlobSchedule, MAX_BLOB_SCHEDULES_PER_FORK] + + +class SszForkConfig(Container): + fork: uint64 + activation: SszForkActivation + blob_schedule: SszOptionalBlobSchedule + + +class SszChainConfig(Container): + chain_id: uint64 + active_fork: SszForkConfig + + +class SszStatelessInput(Container): + new_payload_request: SszNewPayloadRequest + witness: SszExecutionWitness + chain_config: SszChainConfig + public_keys: List[ByteVector[PUBLIC_KEY_BYTES], MAX_PUBLIC_KEYS] + + +# ── Decode helper ──────────────────────────────────────────────────────────── + + +def _strict_decode(data: bytes, container: type) -> Any: + """ + Decode `data` as `container`, rejecting anything that is not its canonical + SSZ encoding. `remerkleable.decode_bytes` is lax about length (it ignores or + absorbs trailing bytes), so we re-encode and require equality — SSZ encoding + is bijective. + """ + try: + view = container.decode_bytes(data) + except Exception as exc: # remerkleable raises a variety of decode errors + raise InvalidSsz(f"{container.__name__}: {exc}") from exc + if view.encode_bytes() != data: + raise InvalidSsz( + f"{container.__name__}: input is not the canonical SSZ encoding" + ) + return view + + +# ── View → logical dataclass converters ────────────────────────────────────── + + +def _convert_withdrawal(view: Any) -> Withdrawal: + return Withdrawal( + index=U64(int(view.index)), + validator_index=U64(int(view.validator_index)), + address=Address(bytes(view.address)), + amount=U256(int(view.amount)), + ) + + +def _convert_execution_payload(view: Any) -> ExecutionPayload: + return ExecutionPayload( + parent_hash=Hash32(bytes(view.parent_hash)), + fee_recipient=Address(bytes(view.fee_recipient)), + state_root=Hash32(bytes(view.state_root)), + receipts_root=Hash32(bytes(view.receipts_root)), + logs_bloom=bytes(view.logs_bloom), + prev_randao=Bytes32(bytes(view.prev_randao)), + block_number=U64(int(view.block_number)), + gas_limit=Uint(int(view.gas_limit)), + gas_used=Uint(int(view.gas_used)), + timestamp=U64(int(view.timestamp)), + extra_data=bytes(view.extra_data), + base_fee_per_gas=Uint(int(view.base_fee_per_gas)), + block_hash=Hash32(bytes(view.block_hash)), + transactions=[bytes(transaction) for transaction in view.transactions], + withdrawals=[_convert_withdrawal(withdrawal) for withdrawal in view.withdrawals], + blob_gas_used=U64(int(view.blob_gas_used)), + excess_blob_gas=U64(int(view.excess_blob_gas)), + block_access_list=bytes(view.block_access_list), + slot_number=U64(int(view.slot_number)), + ) + + +def _convert_deposit_request(view: Any) -> DepositRequest: + return DepositRequest( + pubkey=bytes(view.pubkey), + withdrawal_credentials=Bytes32(bytes(view.withdrawal_credentials)), + amount=U64(int(view.amount)), + signature=bytes(view.signature), + index=U64(int(view.index)), + ) + + +def _convert_withdrawal_request(view: Any) -> WithdrawalRequest: + return WithdrawalRequest( + source_address=Address(bytes(view.source_address)), + validator_pubkey=bytes(view.validator_pubkey), + amount=U64(int(view.amount)), + ) + + +def _convert_consolidation_request(view: Any) -> ConsolidationRequest: + return ConsolidationRequest( + source_address=Address(bytes(view.source_address)), + source_pubkey=bytes(view.source_pubkey), + target_pubkey=bytes(view.target_pubkey), + ) + + +def _convert_execution_requests(view: Any) -> ExecutionRequests: + return ExecutionRequests( + deposits=[_convert_deposit_request(deposit) for deposit in view.deposits], + withdrawals=[ + _convert_withdrawal_request(withdrawal) + for withdrawal in view.withdrawals + ], + consolidations=[ + _convert_consolidation_request(consolidation) + for consolidation in view.consolidations + ], + ) + + +def _convert_new_payload_request(view: Any) -> NewPayloadRequest: + return NewPayloadRequest( + execution_payload=_convert_execution_payload(view.execution_payload), + versioned_hashes=[ + Hash32(bytes(versioned_hash)) for versioned_hash in view.versioned_hashes + ], + parent_beacon_block_root=Hash32(bytes(view.parent_beacon_block_root)), + execution_requests=_convert_execution_requests(view.execution_requests), + ) + + +def _convert_execution_witness(view: Any) -> ExecutionWitness: + return ExecutionWitness( + state=[bytes(node) for node in view.state], + codes=[bytes(code) for code in view.codes], + headers=[bytes(header) for header in view.headers], + ) + + +def _convert_chain_config(view: Any) -> StatelessChainConfig: + # `active_fork.fork` is the ProtocolFork index; reject any fork but the one + # this spec supports (see fork.py). + active_fork = fork.require_active_fork(int(view.active_fork.fork)) + return StatelessChainConfig( + chain_id=U64(int(view.chain_id)), + active_fork=active_fork, + ) + + +def _convert_stateless_input(view: Any) -> StatelessInput: + return StatelessInput( + new_payload_request=_convert_new_payload_request(view.new_payload_request), + witness=_convert_execution_witness(view.witness), + chain_config=_convert_chain_config(view.chain_config), + public_keys=[bytes(public_key) for public_key in view.public_keys], + ) + + +def _strip_stateless_input_framing(data: bytes) -> bytes: + """ + Strip the optional Ere length prefix and the required 0x0001 schema id, + returning the raw `SszStatelessInput` bytes. Input without the schema id is + rejected. + """ + payload = bytes(data) + + # Ere wraps stdin with a 4-byte little-endian length prefix immediately + # followed by the schema id. Strip it only when BOTH the declared length + # matches AND the schema id appears right after: requiring the schema id + # prevents a raw SSZ payload whose first four bytes happen to satisfy the + # length relation from being mis-framed (which would let two distinct byte + # strings decode to the same input, or reject a valid raw input outright). + if ( + len(payload) >= 4 + STATELESS_INPUT_SCHEMA_ID_SIZE + and int.from_bytes(payload[:4], "little") == len(payload) - 4 + and int.from_bytes(payload[4 : 4 + STATELESS_INPUT_SCHEMA_ID_SIZE], "big") + == STATELESS_INPUT_SCHEMA_ID + ): + payload = payload[4:] + + if ( + len(payload) < STATELESS_INPUT_SCHEMA_ID_SIZE + or int.from_bytes(payload[:STATELESS_INPUT_SCHEMA_ID_SIZE], "big") + != STATELESS_INPUT_SCHEMA_ID + ): + raise InvalidSsz( + "stateless input must begin with the 0x0001 schema id" + ) + return payload[STATELESS_INPUT_SCHEMA_ID_SIZE:] + + +def decode_stateless_input_ssz(data: bytes) -> StatelessInput: + """ + Decode the Amsterdam stateless input consumed by the guest: a 0x0001 schema + id (optionally Ere-length-wrapped) followed by SSZ `SszStatelessInput`. The + `active_fork` index is validated to be Amsterdam (see `fork.py`). + """ + payload = _strip_stateless_input_framing(data) + return _convert_stateless_input(_strict_decode(payload, SszStatelessInput))