From aeb90cd6745a289a6cdfe78285370b57cb2d8666 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 12:43:44 -0400 Subject: [PATCH 01/13] docs: restore prover input definitions Restore the concrete prover witness type definitions so the unified zone spec remains self-contained for implementers. Add a schematic of the witness tree and restore the missing queued-deposit type definitions referenced by the batch input interfaces. Made-with: Cursor --- docs/specs/zone_spec.md | 213 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 212 insertions(+), 1 deletion(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index 46246b81a..f924d502c 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -58,6 +58,8 @@ - [Proving System](#proving-system) - [State Transition Function](#state-transition-function) - [Witness Structure](#witness-structure) + - [Input Schematic](#input-schematic) + - [Detailed Input Definitions](#detailed-input-definitions) - [Batch Output](#batch-output) - [Block Execution](#block-execution) - [Tempo State Proofs](#tempo-state-proofs) @@ -386,6 +388,8 @@ sequenceDiagram Withdrawals move tokens from a zone back to Tempo. The user requests a withdrawal on the zone, tokens are burned, and the sequencer eventually processes the withdrawal on Tempo, releasing tokens from the portal. +### Withdrawal Request + A user withdraws by calling `requestWithdrawal(token, to, amount, memo, gasLimit, fallbackRecipient, data, revealTo)` on the `ZoneOutbox`. The user must first approve the outbox to spend `amount + fee` of the token. The outbox transfers `amount + fee` from the user via `transferFrom`, burns the tokens, and stores the withdrawal in a pending array. The `WithdrawalRequested` event is emitted with the plaintext sender (zone events are private). @@ -756,9 +760,206 @@ The witness contains everything needed to re-execute the batch: - **PublicInputs**: `prev_block_hash`, `tempo_block_number`, `anchor_block_number`, `anchor_block_hash`, `expected_withdrawal_batch_index`, `sequencer`. These are the values the portal passes to the verifier and the proof must be consistent with. - **BatchWitness**: the public inputs, the previous batch's block header, the zone blocks to execute, the initial zone state, Tempo state proofs, and Tempo ancestry headers (for ancestry validation). -- **ZoneBlock**: `number`, `parent_hash`, `timestamp`, `beneficiary`, `tempo_header_rlp` (optional), `deposits`, `decryptions`, `finalize_withdrawal_batch_count` (optional), and user `transactions`. +- **ZoneBlock**: `number`, `parent_hash`, `timestamp`, `beneficiary`, `protocol_version`, `tempo_header_rlp` (optional), `deposits`, `decryptions`, `finalize_withdrawal_batch_count` (optional), and user `transactions`. - **ZoneStateWitness**: account data with MPT proofs and the zone state root at the start of the batch. Only accounts and storage slots accessed during execution are included. Missing witness data must produce an error, not default to zero, to prevent the prover from omitting non-zero state. +### Input Schematic + +The prover inputs form a nested witness tree. `BatchWitness` is the top-level object passed into `prove_zone_batch`, and the remaining types hang off it as follows: + +```mermaid +flowchart TD + BW["BatchWitness"] + BW --> PI["public_inputs: PublicInputs"] + BW --> PH["prev_block_header: ZoneHeader"] + BW --> ZBL["zone_blocks: Vec"] + BW --> ZSW["initial_zone_state: ZoneStateWitness"] + BW --> BSP["tempo_state_proofs: BatchStateProof"] + BW --> AH["tempo_ancestry_headers: Vec>"] + + ZBL --> DEP["deposits: Vec"] + ZBL --> DEC["decryptions: Vec"] + ZBL --> TX["transactions: Vec"] + DEP --> KIND["deposit_type: DepositType"] + DEC --> CPP["cp_proof: ChaumPedersenProof"] + + ZSW --> ACC["accounts: HashMap"] + + BSP --> POOL["node_pool: HashMap>"] + BSP --> READS["reads: Vec"] +``` + +### Detailed Input Definitions + +The prover-side inputs are defined concretely below. Types that mirror the onchain ABI (`QueuedDeposit`, `DecryptionData`, `ChaumPedersenProof`) keep the same field ordering and semantics as the interface definitions in [Common Types](#common-types). + +```rust +pub struct PublicInputs { + /// Previous batch's block hash (must equal portal.blockHash) + pub prev_block_hash: B256, + + /// Tempo block number for the batch (must equal portal's tempoBlockNumber) + pub tempo_block_number: u64, + + /// Anchor Tempo block number (tempo_block_number or recent block in EIP-2935 window) + pub anchor_block_number: u64, + + /// Anchor Tempo block hash (must equal portal's EIP-2935 lookup) + pub anchor_block_hash: B256, + + /// Expected withdrawal batch index (passed by portal as withdrawalBatchIndex + 1) + pub expected_withdrawal_batch_index: u64, + + /// Registered sequencer (passed by portal; zone block beneficiary must match) + pub sequencer: Address, +} + +pub struct BatchWitness { + /// Public inputs committed by the proof system + pub public_inputs: PublicInputs, + + /// Previous batch's block header (for state-root binding) + pub prev_block_header: ZoneHeader, + + /// Zone blocks to execute + pub zone_blocks: Vec, + + /// Initial zone state + pub initial_zone_state: ZoneStateWitness, + + /// Tempo state proofs for Tempo reads + pub tempo_state_proofs: BatchStateProof, + + /// Tempo headers for ancestry verification (only in ancestry mode) + /// Ordered from tempo_block_number + 1 to anchor_block_number. + pub tempo_ancestry_headers: Vec>, +} + +pub struct ZoneHeader { + pub parent_hash: B256, + pub beneficiary: Address, + pub state_root: B256, + pub transactions_root: B256, + pub receipts_root: B256, + pub number: u64, + pub timestamp: u64, + pub protocol_version: u64, +} + +pub struct ZoneBlock { + /// Block number + pub number: u64, + + /// Parent block hash + pub parent_hash: B256, + + /// Timestamp + pub timestamp: u64, + + /// Beneficiary (must match registered sequencer) + pub beneficiary: Address, + + /// Protocol version encoded into the zone block header + pub protocol_version: u64, + + /// Tempo header RLP used by the call (ZoneInbox.advanceTempo). + /// If None, the block does not advance Tempo and the binding carries over. + pub tempo_header_rlp: Option>, + + /// Deposits processed by the system tx (oldest first, unified queue). + /// Must be empty if tempo_header_rlp is None. + pub deposits: Vec, + + /// Decryption data for encrypted deposits in the system tx. + /// Must be empty if tempo_header_rlp is None. + pub decryptions: Vec, + + /// Sequencer-only: finalize a batch (only in final block, must be last) + /// Required for the final block in a batch; must be absent in intermediate blocks. + /// Uses U256 to match Solidity `finalizeWithdrawalBatch(uint256 count)`. + pub finalize_withdrawal_batch_count: Option, + + /// Transactions to execute + pub transactions: Vec, +} + +/// Mirrors the Solidity `QueuedDeposit` struct from IZone.sol +pub struct QueuedDeposit { + pub deposit_type: DepositType, + pub deposit_data: Vec, // abi.encode(Deposit) or abi.encode(EncryptedDeposit) +} + +pub enum DepositType { + Regular, + Encrypted, +} + +/// Mirrors the Solidity `DecryptionData` struct from IZone.sol +/// Provided by the sequencer for each encrypted deposit +pub struct DecryptionData { + pub shared_secret: B256, // ECDH shared secret (x-coordinate) + pub shared_secret_y_parity: u8, // Y coordinate parity of the shared secret point + pub to: Address, // Decrypted recipient + pub memo: B256, // Decrypted memo + pub cp_proof: ChaumPedersenProof, +} + +pub struct ChaumPedersenProof { + pub s: B256, // Response: s = r + c * privSeq (mod n) + pub c: B256, // Challenge: c = hash(G, ephemeralPub, pubSeq, sharedSecretPoint, R1, R2) +} + +pub struct ZoneStateWitness { + /// Account data with storage proofs + pub accounts: HashMap, + + /// Zone state root at start of batch + pub state_root: B256, +} + +pub struct AccountWitness { + pub nonce: u64, + pub balance: U256, + pub code_hash: B256, + pub code: Option>, + + /// Storage slots with values + pub storage: HashMap, + + /// MPT proof for account + pub account_proof: Vec>, + + /// MPT proofs for storage slots + pub storage_proofs: HashMap>>, +} + +pub struct BatchStateProof { + /// Deduplicated pool of all MPT nodes + pub node_pool: HashMap>, + + /// Tempo state reads with proof paths + pub reads: Vec, +} + +pub struct L1StateRead { + /// Which zone block performed this read + pub zone_block_index: u64, + + /// Which Tempo block to read from (must match TempoState for this block) + pub tempo_block_number: u64, + + /// Tempo account and storage slot + pub account: Address, + pub slot: U256, + + /// Path through node_pool from leaf to state root + pub node_path: Vec, + + /// Expected value + pub value: U256, +} +``` + ### Batch Output The state transition function produces: @@ -974,6 +1175,16 @@ struct EncryptedDepositPayload { bytes16 tag; } +enum DepositType { + Regular, + Encrypted +} + +struct QueuedDeposit { + DepositType depositType; + bytes depositData; // abi.encode(Deposit) or abi.encode(EncryptedDeposit) +} + struct DecryptionData { bytes32 sharedSecret; uint8 sharedSecretYParity; From 4c516589a5a16082b12c1e15deccb6a2349443e8 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 13:57:12 -0400 Subject: [PATCH 02/13] docs: make prover schematic nested Replace the prover input flowchart with a nested-box schematic that shows the full BatchWitness container structure. Make the repeated collections clearer by showing representative entries for blocks, deposits, account witnesses, and Tempo reads. Made-with: Cursor --- docs/specs/zone_spec.md | 129 ++++++++++++++++++++++++++++++++++------ 1 file changed, 110 insertions(+), 19 deletions(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index f924d502c..e910b27f0 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -765,28 +765,119 @@ The witness contains everything needed to re-execute the batch: ### Input Schematic -The prover inputs form a nested witness tree. `BatchWitness` is the top-level object passed into `prove_zone_batch`, and the remaining types hang off it as follows: +The prover inputs are nested containers. `BatchWitness` is the top-level object passed into `prove_zone_batch`, and the schematic below shows one representative entry for repeated collections such as `ZoneBlock[i]`, `QueuedDeposit[j]`, `AccountWitness[address]`, and `L1StateRead[k]`. ```mermaid flowchart TD - BW["BatchWitness"] - BW --> PI["public_inputs: PublicInputs"] - BW --> PH["prev_block_header: ZoneHeader"] - BW --> ZBL["zone_blocks: Vec"] - BW --> ZSW["initial_zone_state: ZoneStateWitness"] - BW --> BSP["tempo_state_proofs: BatchStateProof"] - BW --> AH["tempo_ancestry_headers: Vec>"] - - ZBL --> DEP["deposits: Vec"] - ZBL --> DEC["decryptions: Vec"] - ZBL --> TX["transactions: Vec"] - DEP --> KIND["deposit_type: DepositType"] - DEC --> CPP["cp_proof: ChaumPedersenProof"] - - ZSW --> ACC["accounts: HashMap"] - - BSP --> POOL["node_pool: HashMap>"] - BSP --> READS["reads: Vec"] + subgraph BW["BatchWitness"] + direction TB + + subgraph PI["public_inputs: PublicInputs"] + direction TB + pi1["prev_block_hash: B256"] + pi2["tempo_block_number: u64"] + pi3["anchor_block_number: u64"] + pi4["anchor_block_hash: B256"] + pi5["expected_withdrawal_batch_index: u64"] + pi6["sequencer: Address"] + end + + subgraph PH["prev_block_header: ZoneHeader"] + direction TB + ph1["parent_hash: B256"] + ph2["beneficiary: Address"] + ph3["state_root: B256"] + ph4["transactions_root: B256"] + ph5["receipts_root: B256"] + ph6["number: u64"] + ph7["timestamp: u64"] + ph8["protocol_version: u64"] + end + + subgraph ZBL["zone_blocks: list of ZoneBlock"] + direction TB + subgraph ZB["ZoneBlock[i]"] + direction TB + zb1["number: u64"] + zb2["parent_hash: B256"] + zb3["timestamp: u64"] + zb4["beneficiary: Address"] + zb5["protocol_version: u64"] + zb6["tempo_header_rlp: optional header bytes"] + + subgraph DEP["deposits: list of QueuedDeposit"] + direction TB + subgraph QD["QueuedDeposit[j]"] + direction TB + qd1["deposit_type: Regular or Encrypted"] + qd2["deposit_data: ABI-encoded Deposit or EncryptedDeposit"] + end + end + + subgraph DEC["decryptions: list of DecryptionData"] + direction TB + subgraph DD["DecryptionData[k]"] + direction TB + dd1["shared_secret: B256"] + dd2["shared_secret_y_parity: u8"] + dd3["to: Address"] + dd4["memo: B256"] + + subgraph CP["cp_proof: ChaumPedersenProof"] + direction TB + cp1["s: B256"] + cp2["c: B256"] + end + end + end + + zb7["finalize_withdrawal_batch_count: optional U256"] + zb8["transactions: list of Transaction"] + end + end + + subgraph ZSW["initial_zone_state: ZoneStateWitness"] + direction TB + zsw1["state_root: B256"] + + subgraph ACCS["accounts: map of Address to AccountWitness"] + direction TB + subgraph AW["AccountWitness[address]"] + direction TB + aw1["nonce: u64"] + aw2["balance: U256"] + aw3["code_hash: B256"] + aw4["code: optional bytecode"] + aw5["storage: map of slot to value"] + aw6["account_proof: list of MPT nodes"] + aw7["storage_proofs: map of slot to proof"] + end + end + end + + subgraph BSP["tempo_state_proofs: BatchStateProof"] + direction TB + bsp1["node_pool: map of node hash to RLP node bytes"] + + subgraph READS["reads: list of L1StateRead"] + direction TB + subgraph READ["L1StateRead[k]"] + direction TB + lr1["zone_block_index: u64"] + lr2["tempo_block_number: u64"] + lr3["account: Address"] + lr4["slot: U256"] + lr5["node_path: list of B256"] + lr6["value: U256"] + end + end + end + + subgraph AH["tempo_ancestry_headers: list of header RLP bytes"] + direction TB + ah1["header[h]: bytes"] + end + end ``` ### Detailed Input Definitions From d3b70506baadfe47904ed05a936fccdf9dc2cb06 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 14:35:02 -0400 Subject: [PATCH 03/13] docs: simplify prover schematic layout Replace the dense per-field diagram with a smaller set of multiline boxes so the witness structure stays readable in rendered docs. Keep the nested container shape and include the deposit payload variants without shrinking the entire figure. Made-with: Cursor --- docs/specs/zone_spec.md | 130 +++++++++++++--------------------------- 1 file changed, 42 insertions(+), 88 deletions(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index e910b27f0..1560478f9 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -765,118 +765,72 @@ The witness contains everything needed to re-execute the batch: ### Input Schematic -The prover inputs are nested containers. `BatchWitness` is the top-level object passed into `prove_zone_batch`, and the schematic below shows one representative entry for repeated collections such as `ZoneBlock[i]`, `QueuedDeposit[j]`, `AccountWitness[address]`, and `L1StateRead[k]`. +The prover inputs are nested containers. `BatchWitness` is the top-level object passed into `prove_zone_batch`, and the schematic below shows one representative entry for repeated collections such as `ZoneBlock[i]`, `QueuedDeposit[j]`, `AccountWitness[address]`, and `L1StateRead[k]`. To keep the picture readable, the boxes list field names rather than repeating every Rust scalar type. ```mermaid -flowchart TD +flowchart TB subgraph BW["BatchWitness"] direction TB - subgraph PI["public_inputs: PublicInputs"] - direction TB - pi1["prev_block_hash: B256"] - pi2["tempo_block_number: u64"] - pi3["anchor_block_number: u64"] - pi4["anchor_block_hash: B256"] - pi5["expected_withdrawal_batch_index: u64"] - pi6["sequencer: Address"] - end + PI["PublicInputs
prev_block_hash
tempo_block_number
anchor_block_number
anchor_block_hash
expected_withdrawal_batch_index
sequencer"] - subgraph PH["prev_block_header: ZoneHeader"] - direction TB - ph1["parent_hash: B256"] - ph2["beneficiary: Address"] - ph3["state_root: B256"] - ph4["transactions_root: B256"] - ph5["receipts_root: B256"] - ph6["number: u64"] - ph7["timestamp: u64"] - ph8["protocol_version: u64"] - end + PH["ZoneHeader
parent_hash
beneficiary
state_root
transactions_root
receipts_root
number
timestamp
protocol_version"] - subgraph ZBL["zone_blocks: list of ZoneBlock"] + subgraph ZBL["zone_blocks"] direction TB - subgraph ZB["ZoneBlock[i]"] + ZB["ZoneBlock[i]
number
parent_hash
timestamp
beneficiary
protocol_version
tempo_header_rlp
finalize_withdrawal_batch_count
transactions"] + + subgraph DEP["deposits"] direction TB - zb1["number: u64"] - zb2["parent_hash: B256"] - zb3["timestamp: u64"] - zb4["beneficiary: Address"] - zb5["protocol_version: u64"] - zb6["tempo_header_rlp: optional header bytes"] - - subgraph DEP["deposits: list of QueuedDeposit"] - direction TB - subgraph QD["QueuedDeposit[j]"] - direction TB - qd1["deposit_type: Regular or Encrypted"] - qd2["deposit_data: ABI-encoded Deposit or EncryptedDeposit"] - end - end + QD["QueuedDeposit[j]
deposit_type
deposit_data"] - subgraph DEC["decryptions: list of DecryptionData"] + subgraph PAYLOAD["deposit_data payload"] direction TB - subgraph DD["DecryptionData[k]"] - direction TB - dd1["shared_secret: B256"] - dd2["shared_secret_y_parity: u8"] - dd3["to: Address"] - dd4["memo: B256"] - - subgraph CP["cp_proof: ChaumPedersenProof"] - direction TB - cp1["s: B256"] - cp2["c: B256"] - end - end + D["Deposit
token
sender
to
amount
memo"] + + ED["EncryptedDeposit
token
sender
amount
keyIndex
encrypted"] + + EDP["EncryptedDepositPayload
ephemeralPubkeyX
ephemeralPubkeyYParity
ciphertext
nonce
tag"] + + D ~~~ ED + ED ~~~ EDP end - zb7["finalize_withdrawal_batch_count: optional U256"] - zb8["transactions: list of Transaction"] + QD ~~~ D end - end - subgraph ZSW["initial_zone_state: ZoneStateWitness"] - direction TB - zsw1["state_root: B256"] - - subgraph ACCS["accounts: map of Address to AccountWitness"] + subgraph DEC["decryptions"] direction TB - subgraph AW["AccountWitness[address]"] - direction TB - aw1["nonce: u64"] - aw2["balance: U256"] - aw3["code_hash: B256"] - aw4["code: optional bytecode"] - aw5["storage: map of slot to value"] - aw6["account_proof: list of MPT nodes"] - aw7["storage_proofs: map of slot to proof"] - end + DD["DecryptionData[k]
shared_secret
shared_secret_y_parity
to
memo
cp_proof"] + CP["ChaumPedersenProof
s
c"] + DD ~~~ CP end + + ZB ~~~ QD + QD ~~~ DD end - subgraph BSP["tempo_state_proofs: BatchStateProof"] + subgraph ZSW["initial_zone_state"] direction TB - bsp1["node_pool: map of node hash to RLP node bytes"] - - subgraph READS["reads: list of L1StateRead"] - direction TB - subgraph READ["L1StateRead[k]"] - direction TB - lr1["zone_block_index: u64"] - lr2["tempo_block_number: u64"] - lr3["account: Address"] - lr4["slot: U256"] - lr5["node_path: list of B256"] - lr6["value: U256"] - end - end + ZSWBOX["ZoneStateWitness
state_root"] + AW["AccountWitness[address]
nonce
balance
code_hash
code
storage
account_proof
storage_proofs"] + ZSWBOX ~~~ AW end - subgraph AH["tempo_ancestry_headers: list of header RLP bytes"] + subgraph BSP["tempo_state_proofs"] direction TB - ah1["header[h]: bytes"] + BSPBOX["BatchStateProof
node_pool"] + READ["L1StateRead[k]
zone_block_index
tempo_block_number
account
slot
node_path
value"] + BSPBOX ~~~ READ end + + AH["tempo_ancestry_headers
header bytes [0..n]"] + + PI ~~~ PH + PH ~~~ ZB + ZB ~~~ ZSWBOX + ZSWBOX ~~~ BSPBOX + BSPBOX ~~~ AH end ``` From 64ccf8f5f302c0ca6cf468c36fdfeac7b0a773cd Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 16:56:53 -0400 Subject: [PATCH 04/13] docs: remove node path from state reads Simplify the prover witness by removing the per-read node_path field from L1StateRead. Clarify that reads are verified by walking the bound Tempo trie using nodes from the shared deduplicated pool. Made-with: Cursor --- docs/specs/zone_spec.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index 1560478f9..0f1b5598b 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -820,7 +820,7 @@ flowchart TB subgraph BSP["tempo_state_proofs"] direction TB BSPBOX["BatchStateProof
node_pool"] - READ["L1StateRead[k]
zone_block_index
tempo_block_number
account
slot
node_path
value"] + READ["L1StateRead[k]
zone_block_index
tempo_block_number
account
slot
value"] BSPBOX ~~~ READ end @@ -982,7 +982,7 @@ pub struct BatchStateProof { /// Deduplicated pool of all MPT nodes pub node_pool: HashMap>, - /// Tempo state reads with proof paths + /// Tempo state reads verified against the shared node pool pub reads: Vec, } @@ -997,9 +997,6 @@ pub struct L1StateRead { pub account: Address, pub slot: U256, - /// Path through node_pool from leaf to state root - pub node_path: Vec, - /// Expected value pub value: U256, } @@ -1031,9 +1028,9 @@ For each block in the batch, the prover: System contracts read Tempo state during execution (deposit queue hash, sequencer address, token registry, TIP-403 policies). The witness includes a `BatchStateProof` containing: - A deduplicated `node_pool` of MPT nodes, keyed by `keccak256(rlp(node))`. Each node is verified exactly once. -- A list of `L1StateRead` entries, each specifying the zone block index, Tempo block number, account, storage slot, node path through the pool, and expected value. +- A list of `L1StateRead` entries, each specifying the zone block index, Tempo block number, account, storage slot, and expected value. -Reads are indexed and verified on demand during execution. Because many reads access the same accounts and storage trie paths, the deduplicated pool significantly reduces proof size and prover cost compared to including separate MPT proofs per read. +Reads are indexed and verified on demand during execution. For each read, the prover walks the account trie and storage trie starting from the `TempoState` root currently bound for that block, using nodes from the shared `node_pool` to prove the requested `(account, slot) -> value` mapping. Because many reads access the same accounts and storage trie paths, the deduplicated pool significantly reduces proof size and prover cost compared to including separate MPT proofs per read. Anchor validation ensures the zone's view of Tempo is correct. If `anchor_block_number` equals `tempo_block_number`, the zone's `tempoBlockHash` must match `anchor_block_hash` directly. If `anchor_block_number` is greater (for zones that have been offline longer than the EIP-2935 window), the proof verifies the parent-hash chain from `tempo_block_number` to `anchor_block_number` using the ancestry headers in the witness. From e54adfab3975a443936b75283603dea5a7af3538 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 17:05:10 -0400 Subject: [PATCH 05/13] docs: explain state proof interpretation Clarify how to read and verify the zone-state and Tempo-state proof witnesses in the unified spec. Add plain-language guidance for interpreting storage proof vectors and the shared deduplicated node pool. Made-with: Cursor --- docs/specs/zone_spec.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index 0f1b5598b..f6d07828f 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -1002,6 +1002,10 @@ pub struct L1StateRead { } ``` +`ZoneStateWitness` uses conventional per-object Merkle Patricia Trie proofs. `accounts` maps each zone account touched during the batch to an `AccountWitness`. Within an `AccountWitness`, `account_proof` is the ordered list of RLP-encoded trie nodes needed to prove that account's existence (or emptiness) against the `ZoneStateWitness.state_root`. `storage_proofs` applies the same idea one level deeper: for each accessed storage slot, the witness includes the ordered list of RLP-encoded storage-trie nodes proving that slot's value under the account's storage root. In other words, `HashMap>>` means "for each storage slot, give me the sequence of trie nodes needed to prove that slot." + +To verify a `ZoneStateWitness`, the prover first checks that the initial execution state root matches `ZoneStateWitness.state_root` and is consistent with `prev_block_header.state_root`. Then, whenever execution touches an account, it replays `account_proof` from the state root using `keccak256(address)` as the trie key, decodes the resulting account leaf, and checks that the decoded nonce, balance, code hash, and storage root agree with the fields in `AccountWitness`. Whenever execution touches a storage slot, it starts from that decoded storage root and replays the corresponding `storage_proofs[slot]` using `keccak256(slot)` as the storage-trie key, checking that the terminal leaf matches `storage[slot]`. Missing account or storage proofs are errors; they must not silently default to zero. + ### Batch Output The state transition function produces: @@ -1032,6 +1036,10 @@ System contracts read Tempo state during execution (deposit queue hash, sequence Reads are indexed and verified on demand during execution. For each read, the prover walks the account trie and storage trie starting from the `TempoState` root currently bound for that block, using nodes from the shared `node_pool` to prove the requested `(account, slot) -> value` mapping. Because many reads access the same accounts and storage trie paths, the deduplicated pool significantly reduces proof size and prover cost compared to including separate MPT proofs per read. +The interpretation of `BatchStateProof` is slightly different from `ZoneStateWitness`. Here the witness does not attach a separate proof vector to each read. Instead, `node_pool` is a shared repository of RLP-encoded trie nodes, and each `L1StateRead` says only which account, slot, and value must be proven at a particular bound Tempo block. Verification proceeds from the root down: use the `tempoStateRoot` currently bound in `TempoState`, derive the account-trie key from `keccak256(account)`, fetch the referenced branch / extension / leaf nodes from `node_pool`, and reconstruct the walk until the account leaf is reached. From the account leaf, extract the storage root, derive the storage-trie key from `keccak256(slot)`, and repeat the same process in the storage trie until the slot leaf is reached. The read is valid only if the final decoded slot value equals `L1StateRead.value`. + +This split is intentional. `ZoneStateWitness` proves the starting zone state with explicit per-account and per-slot proof lists, while `BatchStateProof` proves many Tempo reads against a shared deduplicated pool because large batches may reuse the same trie nodes across thousands of reads. The two structures serve the same cryptographic purpose, but they optimize the witness differently. + Anchor validation ensures the zone's view of Tempo is correct. If `anchor_block_number` equals `tempo_block_number`, the zone's `tempoBlockHash` must match `anchor_block_hash` directly. If `anchor_block_number` is greater (for zones that have been offline longer than the EIP-2935 window), the proof verifies the parent-hash chain from `tempo_block_number` to `anchor_block_number` using the ancestry headers in the witness. ### Deployment Modes From 862199863927c044a84b375c38ac2985df621799 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 17:16:53 -0400 Subject: [PATCH 06/13] docs: clarify zone state witness timing Clarify that ZoneStateWitness proofs are relative to the initial batch state and are verified once during initialization. Spell out that block execution then operates on the materialized state rather than re-verifying Merkle proofs on each access. Made-with: Cursor --- docs/specs/zone_spec.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index f6d07828f..113c91b32 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -1004,7 +1004,9 @@ pub struct L1StateRead { `ZoneStateWitness` uses conventional per-object Merkle Patricia Trie proofs. `accounts` maps each zone account touched during the batch to an `AccountWitness`. Within an `AccountWitness`, `account_proof` is the ordered list of RLP-encoded trie nodes needed to prove that account's existence (or emptiness) against the `ZoneStateWitness.state_root`. `storage_proofs` applies the same idea one level deeper: for each accessed storage slot, the witness includes the ordered list of RLP-encoded storage-trie nodes proving that slot's value under the account's storage root. In other words, `HashMap>>` means "for each storage slot, give me the sequence of trie nodes needed to prove that slot." -To verify a `ZoneStateWitness`, the prover first checks that the initial execution state root matches `ZoneStateWitness.state_root` and is consistent with `prev_block_header.state_root`. Then, whenever execution touches an account, it replays `account_proof` from the state root using `keccak256(address)` as the trie key, decodes the resulting account leaf, and checks that the decoded nonce, balance, code hash, and storage root agree with the fields in `AccountWitness`. Whenever execution touches a storage slot, it starts from that decoded storage root and replays the corresponding `storage_proofs[slot]` using `keccak256(slot)` as the storage-trie key, checking that the terminal leaf matches `storage[slot]`. Missing account or storage proofs are errors; they must not silently default to zero. +All of these proofs are relative to the initial zone state at the start of the batch, not to the later mutated state after some zone blocks have already executed. `ZoneStateWitness` is therefore a bootstrap witness: it proves the starting contents of the accounts and storage slots that execution may touch, and the prover materializes that verified data into its in-memory execution state before replaying any blocks. + +To verify a `ZoneStateWitness`, the prover first checks that the initial execution state root matches `ZoneStateWitness.state_root` and is consistent with `prev_block_header.state_root`. It then replays each `account_proof` from the state root using `keccak256(address)` as the trie key, decodes the resulting account leaf, and checks that the decoded nonce, balance, code hash, and storage root agree with the fields in `AccountWitness`. For each witnessed storage slot, it starts from that decoded storage root and replays the corresponding `storage_proofs[slot]` using `keccak256(slot)` as the storage-trie key, checking that the terminal leaf matches `storage[slot]`. Once this initialization step succeeds, block execution reads and writes the materialized account / storage values directly; it does not re-verify Merkle proofs on every in-block access. Missing account or storage proofs are errors; they must not silently default to zero. ### Batch Output @@ -1019,7 +1021,7 @@ The state transition function produces: ### Block Execution -For each block in the batch, the prover: +After the initial `ZoneStateWitness` has been verified and loaded into the execution state, the prover executes each block in the batch: 1. Validates `parent_hash` matches the previous block's hash, `number` increments by one, `timestamp` is non-decreasing, and `beneficiary` equals the registered sequencer. 2. Executes `advanceTempo` if present (start of block): finalizes the Tempo header, processes deposits, verifies encrypted deposit decryptions. From 31a7465b3f27bd38291c2b260546a87f6d99f3f6 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 17:27:11 -0400 Subject: [PATCH 07/13] docs: clarify non-membership proof semantics Explain how missing account and storage leaves are represented in the witness and interpreted during verification. Clarify that deleted accounts and zeroed storage slots are absent from the current trie rather than preserved as explicit zero-valued leaves. Made-with: Cursor --- docs/specs/zone_spec.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index 113c91b32..ba85a659e 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -1004,9 +1004,11 @@ pub struct L1StateRead { `ZoneStateWitness` uses conventional per-object Merkle Patricia Trie proofs. `accounts` maps each zone account touched during the batch to an `AccountWitness`. Within an `AccountWitness`, `account_proof` is the ordered list of RLP-encoded trie nodes needed to prove that account's existence (or emptiness) against the `ZoneStateWitness.state_root`. `storage_proofs` applies the same idea one level deeper: for each accessed storage slot, the witness includes the ordered list of RLP-encoded storage-trie nodes proving that slot's value under the account's storage root. In other words, `HashMap>>` means "for each storage slot, give me the sequence of trie nodes needed to prove that slot." +Missing leaves are represented by non-membership proofs, not by omitting the proof and not by storing an explicit all-zero leaf in the trie. If `account_proof` proves that an account leaf is absent from the state trie, the prover interprets that account as the canonical empty account: `nonce = 0`, `balance = 0`, `code = None`, `code_hash = KECCAK_EMPTY`, and an empty storage trie. Likewise, if `storage_proofs[slot]` proves that a storage leaf is absent from the account's storage trie, the prover interprets that slot value as zero. This matches Ethereum's canonical trie semantics: deleted accounts and zeroed storage slots are removed from the current trie, so the trie root looks as if that leaf were absent rather than preserving an explicit zero-valued entry. + All of these proofs are relative to the initial zone state at the start of the batch, not to the later mutated state after some zone blocks have already executed. `ZoneStateWitness` is therefore a bootstrap witness: it proves the starting contents of the accounts and storage slots that execution may touch, and the prover materializes that verified data into its in-memory execution state before replaying any blocks. -To verify a `ZoneStateWitness`, the prover first checks that the initial execution state root matches `ZoneStateWitness.state_root` and is consistent with `prev_block_header.state_root`. It then replays each `account_proof` from the state root using `keccak256(address)` as the trie key, decodes the resulting account leaf, and checks that the decoded nonce, balance, code hash, and storage root agree with the fields in `AccountWitness`. For each witnessed storage slot, it starts from that decoded storage root and replays the corresponding `storage_proofs[slot]` using `keccak256(slot)` as the storage-trie key, checking that the terminal leaf matches `storage[slot]`. Once this initialization step succeeds, block execution reads and writes the materialized account / storage values directly; it does not re-verify Merkle proofs on every in-block access. Missing account or storage proofs are errors; they must not silently default to zero. +To verify a `ZoneStateWitness`, the prover first checks that the initial execution state root matches `ZoneStateWitness.state_root` and is consistent with `prev_block_header.state_root`. It then replays each `account_proof` from the state root using `keccak256(address)` as the trie key. If the proof ends at an account leaf, the prover decodes that leaf and checks that the nonce, balance, and code hash agree with `AccountWitness`, retaining the decoded storage root for subsequent slot verification. If the proof is a valid non-membership proof, the prover materializes the canonical empty account instead. For each witnessed storage slot, it starts from the storage root determined by the account proof and replays the corresponding `storage_proofs[slot]` using `keccak256(slot)` as the storage-trie key. If the proof ends at a slot leaf, the decoded value must match `storage[slot]`; if it is a valid non-membership proof, the slot value is interpreted as zero. Once this initialization step succeeds, block execution reads and writes the materialized account / storage values directly; it does not re-verify Merkle proofs on every in-block access. Missing account or storage proofs are errors; they must not silently default to zero. ### Batch Output @@ -1040,6 +1042,8 @@ Reads are indexed and verified on demand during execution. For each read, the pr The interpretation of `BatchStateProof` is slightly different from `ZoneStateWitness`. Here the witness does not attach a separate proof vector to each read. Instead, `node_pool` is a shared repository of RLP-encoded trie nodes, and each `L1StateRead` says only which account, slot, and value must be proven at a particular bound Tempo block. Verification proceeds from the root down: use the `tempoStateRoot` currently bound in `TempoState`, derive the account-trie key from `keccak256(account)`, fetch the referenced branch / extension / leaf nodes from `node_pool`, and reconstruct the walk until the account leaf is reached. From the account leaf, extract the storage root, derive the storage-trie key from `keccak256(slot)`, and repeat the same process in the storage trie until the slot leaf is reached. The read is valid only if the final decoded slot value equals `L1StateRead.value`. +As with `ZoneStateWitness`, missing leaves are proved by non-membership. If the account walk proves that the account leaf is absent, the read is interpreted against the canonical empty account; if the storage walk proves that the slot leaf is absent, the slot value is interpreted as zero. The current trie therefore contains no explicit tombstone for deleted accounts or zeroed storage slots. Client databases may still retain historical nodes that are no longer reachable from the current root, but those stale nodes are irrelevant to proof verification because only nodes reachable from the bound root contribute to the proof. + This split is intentional. `ZoneStateWitness` proves the starting zone state with explicit per-account and per-slot proof lists, while `BatchStateProof` proves many Tempo reads against a shared deduplicated pool because large batches may reuse the same trie nodes across thousands of reads. The two structures serve the same cryptographic purpose, but they optimize the witness differently. Anchor validation ensures the zone's view of Tempo is correct. If `anchor_block_number` equals `tempo_block_number`, the zone's `tempoBlockHash` must match `anchor_block_hash` directly. If `anchor_block_number` is greater (for zones that have been offline longer than the EIP-2935 window), the proof verifies the parent-hash chain from `tempo_block_number` to `anchor_block_number` using the ancestry headers in the witness. From 223cbb9cfdfbe212406b022b5a952b510f76edb7 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 17:40:20 -0400 Subject: [PATCH 08/13] docs: clarify tempo proof root timing Make the contrast between zone-state and Tempo-state witnesses explicit in the unified spec. State clearly that Tempo reads are verified against the root currently bound in TempoState at each read site, not the Tempo root at batch start. Made-with: Cursor --- docs/specs/zone_spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index ba85a659e..4a105e02b 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -1033,7 +1033,7 @@ After the initial `ZoneStateWitness` has been verified and loaded into the execu ### Tempo State Proofs -System contracts read Tempo state during execution (deposit queue hash, sequencer address, token registry, TIP-403 policies). The witness includes a `BatchStateProof` containing: +System contracts read Tempo state during execution (deposit queue hash, sequencer address, token registry, TIP-403 policies). Unlike `ZoneStateWitness`, which is verified once against the initial zone-state root at batch start, `BatchStateProof` is interpreted against the Tempo root currently bound in `TempoState` at the moment of each read. If `advanceTempo()` runs during the batch, later reads are therefore verified against the newer Tempo root, not the root from the start of the batch. The witness includes a `BatchStateProof` containing: - A deduplicated `node_pool` of MPT nodes, keyed by `keccak256(rlp(node))`. Each node is verified exactly once. - A list of `L1StateRead` entries, each specifying the zone block index, Tempo block number, account, storage slot, and expected value. From 502db0980e1698e76701ebcba3dc38ab46b252ed Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 17:43:49 -0400 Subject: [PATCH 09/13] docs: use shared node pool for zone proofs Update ZoneStateWitness to use the same deduplicated node_pool design as BatchStateProof. Replace per-account proof vectors with decoded account and storage read descriptors, and rewrite the schematic and proof interpretation text to match. Made-with: Cursor --- docs/specs/zone_spec.md | 50 +++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index 4a105e02b..342065577 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -761,11 +761,11 @@ The witness contains everything needed to re-execute the batch: - **PublicInputs**: `prev_block_hash`, `tempo_block_number`, `anchor_block_number`, `anchor_block_hash`, `expected_withdrawal_batch_index`, `sequencer`. These are the values the portal passes to the verifier and the proof must be consistent with. - **BatchWitness**: the public inputs, the previous batch's block header, the zone blocks to execute, the initial zone state, Tempo state proofs, and Tempo ancestry headers (for ancestry validation). - **ZoneBlock**: `number`, `parent_hash`, `timestamp`, `beneficiary`, `protocol_version`, `tempo_header_rlp` (optional), `deposits`, `decryptions`, `finalize_withdrawal_batch_count` (optional), and user `transactions`. -- **ZoneStateWitness**: account data with MPT proofs and the zone state root at the start of the batch. Only accounts and storage slots accessed during execution are included. Missing witness data must produce an error, not default to zero, to prevent the prover from omitting non-zero state. +- **ZoneStateWitness**: the initial zone state root, a deduplicated pool of zone-state trie nodes, and decoded account / storage reads needed to bootstrap execution. Only accounts and storage slots accessed during execution are included. Missing witness data must produce an error, not default to zero, to prevent the prover from omitting non-zero state. ### Input Schematic -The prover inputs are nested containers. `BatchWitness` is the top-level object passed into `prove_zone_batch`, and the schematic below shows one representative entry for repeated collections such as `ZoneBlock[i]`, `QueuedDeposit[j]`, `AccountWitness[address]`, and `L1StateRead[k]`. To keep the picture readable, the boxes list field names rather than repeating every Rust scalar type. +The prover inputs are nested containers. `BatchWitness` is the top-level object passed into `prove_zone_batch`, and the schematic below shows one representative entry for repeated collections such as `ZoneBlock[i]`, `QueuedDeposit[j]`, `ZoneAccountRead[k]`, `ZoneStorageRead[k]`, and `L1StateRead[k]`. To keep the picture readable, the boxes list field names rather than repeating every Rust scalar type. ```mermaid flowchart TB @@ -812,9 +812,11 @@ flowchart TB subgraph ZSW["initial_zone_state"] direction TB - ZSWBOX["ZoneStateWitness
state_root"] - AW["AccountWitness[address]
nonce
balance
code_hash
code
storage
account_proof
storage_proofs"] - ZSWBOX ~~~ AW + ZSWBOX["ZoneStateWitness
state_root
node_pool"] + ZAR["ZoneAccountRead[k]
account
nonce
balance
code_hash
code"] + ZSR["ZoneStorageRead[k]
account
slot
value"] + ZSWBOX ~~~ ZAR + ZAR ~~~ ZSR end subgraph BSP["tempo_state_proofs"] @@ -955,27 +957,31 @@ pub struct ChaumPedersenProof { } pub struct ZoneStateWitness { - /// Account data with storage proofs - pub accounts: HashMap, - /// Zone state root at start of batch pub state_root: B256, + + /// Deduplicated pool of all zone-state MPT nodes + pub node_pool: HashMap>, + + /// Decoded account leaves needed to bootstrap execution + pub account_reads: Vec, + + /// Decoded storage leaves needed to bootstrap execution + pub storage_reads: Vec, } -pub struct AccountWitness { +pub struct ZoneAccountRead { + pub account: Address, pub nonce: u64, pub balance: U256, pub code_hash: B256, pub code: Option>, +} - /// Storage slots with values - pub storage: HashMap, - - /// MPT proof for account - pub account_proof: Vec>, - - /// MPT proofs for storage slots - pub storage_proofs: HashMap>>, +pub struct ZoneStorageRead { + pub account: Address, + pub slot: U256, + pub value: U256, } pub struct BatchStateProof { @@ -1002,13 +1008,13 @@ pub struct L1StateRead { } ``` -`ZoneStateWitness` uses conventional per-object Merkle Patricia Trie proofs. `accounts` maps each zone account touched during the batch to an `AccountWitness`. Within an `AccountWitness`, `account_proof` is the ordered list of RLP-encoded trie nodes needed to prove that account's existence (or emptiness) against the `ZoneStateWitness.state_root`. `storage_proofs` applies the same idea one level deeper: for each accessed storage slot, the witness includes the ordered list of RLP-encoded storage-trie nodes proving that slot's value under the account's storage root. In other words, `HashMap>>` means "for each storage slot, give me the sequence of trie nodes needed to prove that slot." +`ZoneStateWitness` now uses the same shared `node_pool` pattern as `BatchStateProof`. Instead of attaching a separate proof vector to each account and storage slot, the witness carries one deduplicated repository of RLP-encoded trie nodes plus decoded `ZoneAccountRead` and `ZoneStorageRead` entries describing which account / slot values must be proven against the initial zone-state root. -Missing leaves are represented by non-membership proofs, not by omitting the proof and not by storing an explicit all-zero leaf in the trie. If `account_proof` proves that an account leaf is absent from the state trie, the prover interprets that account as the canonical empty account: `nonce = 0`, `balance = 0`, `code = None`, `code_hash = KECCAK_EMPTY`, and an empty storage trie. Likewise, if `storage_proofs[slot]` proves that a storage leaf is absent from the account's storage trie, the prover interprets that slot value as zero. This matches Ethereum's canonical trie semantics: deleted accounts and zeroed storage slots are removed from the current trie, so the trie root looks as if that leaf were absent rather than preserving an explicit zero-valued entry. +Missing leaves are represented by non-membership proofs, not by omitting the read and not by storing an explicit all-zero leaf in the trie. If the account walk proves that an account leaf is absent from the state trie, the prover interprets that account as the canonical empty account: `nonce = 0`, `balance = 0`, `code = None`, `code_hash = KECCAK_EMPTY`, and an empty storage trie. Likewise, if the storage walk proves that a storage leaf is absent from the account's storage trie, the prover interprets that slot value as zero. This matches Ethereum's canonical trie semantics: deleted accounts and zeroed storage slots are removed from the current trie, so the trie root looks as if that leaf were absent rather than preserving an explicit zero-valued entry. All of these proofs are relative to the initial zone state at the start of the batch, not to the later mutated state after some zone blocks have already executed. `ZoneStateWitness` is therefore a bootstrap witness: it proves the starting contents of the accounts and storage slots that execution may touch, and the prover materializes that verified data into its in-memory execution state before replaying any blocks. -To verify a `ZoneStateWitness`, the prover first checks that the initial execution state root matches `ZoneStateWitness.state_root` and is consistent with `prev_block_header.state_root`. It then replays each `account_proof` from the state root using `keccak256(address)` as the trie key. If the proof ends at an account leaf, the prover decodes that leaf and checks that the nonce, balance, and code hash agree with `AccountWitness`, retaining the decoded storage root for subsequent slot verification. If the proof is a valid non-membership proof, the prover materializes the canonical empty account instead. For each witnessed storage slot, it starts from the storage root determined by the account proof and replays the corresponding `storage_proofs[slot]` using `keccak256(slot)` as the storage-trie key. If the proof ends at a slot leaf, the decoded value must match `storage[slot]`; if it is a valid non-membership proof, the slot value is interpreted as zero. Once this initialization step succeeds, block execution reads and writes the materialized account / storage values directly; it does not re-verify Merkle proofs on every in-block access. Missing account or storage proofs are errors; they must not silently default to zero. +To verify a `ZoneStateWitness`, the prover first checks that the initial execution state root matches `ZoneStateWitness.state_root` and is consistent with `prev_block_header.state_root`. It then validates each node in `node_pool` once by recomputing `keccak256(rlp(node))`. For each `ZoneAccountRead`, the prover derives the account-trie key from `keccak256(account)` and walks the state trie from `state_root` using nodes from `node_pool`. If the walk ends at an account leaf, the decoded nonce, balance, and code hash must match the read, and `code` must be either absent with `code_hash = KECCAK_EMPTY` or present with `keccak256(code) == code_hash`. If the walk is a valid non-membership proof, the read is interpreted as the canonical empty account instead. For each `ZoneStorageRead`, the prover first determines the account's storage root from the verified account walk, then derives the storage-trie key from `keccak256(slot)` and walks that storage trie using nodes from `node_pool`. If the walk ends at a slot leaf, the decoded value must match `ZoneStorageRead.value`; if it is a valid non-membership proof, the slot value is interpreted as zero. Once this initialization step succeeds, block execution reads and writes the materialized account / storage values directly; it does not re-verify Merkle proofs on every in-block access. Missing account or storage reads are errors; they must not silently default to zero. ### Batch Output @@ -1040,11 +1046,11 @@ System contracts read Tempo state during execution (deposit queue hash, sequence Reads are indexed and verified on demand during execution. For each read, the prover walks the account trie and storage trie starting from the `TempoState` root currently bound for that block, using nodes from the shared `node_pool` to prove the requested `(account, slot) -> value` mapping. Because many reads access the same accounts and storage trie paths, the deduplicated pool significantly reduces proof size and prover cost compared to including separate MPT proofs per read. -The interpretation of `BatchStateProof` is slightly different from `ZoneStateWitness`. Here the witness does not attach a separate proof vector to each read. Instead, `node_pool` is a shared repository of RLP-encoded trie nodes, and each `L1StateRead` says only which account, slot, and value must be proven at a particular bound Tempo block. Verification proceeds from the root down: use the `tempoStateRoot` currently bound in `TempoState`, derive the account-trie key from `keccak256(account)`, fetch the referenced branch / extension / leaf nodes from `node_pool`, and reconstruct the walk until the account leaf is reached. From the account leaf, extract the storage root, derive the storage-trie key from `keccak256(slot)`, and repeat the same process in the storage trie until the slot leaf is reached. The read is valid only if the final decoded slot value equals `L1StateRead.value`. +`BatchStateProof` uses the same shared-pool pattern as `ZoneStateWitness`. `node_pool` is a shared repository of RLP-encoded trie nodes, and each `L1StateRead` says only which account, slot, and value must be proven at a particular bound Tempo block. Verification proceeds from the root down: use the `tempoStateRoot` currently bound in `TempoState`, derive the account-trie key from `keccak256(account)`, fetch the referenced branch / extension / leaf nodes from `node_pool`, and reconstruct the walk until the account leaf is reached. From the account leaf, extract the storage root, derive the storage-trie key from `keccak256(slot)`, and repeat the same process in the storage trie until the slot leaf is reached. The read is valid only if the final decoded slot value equals `L1StateRead.value`. As with `ZoneStateWitness`, missing leaves are proved by non-membership. If the account walk proves that the account leaf is absent, the read is interpreted against the canonical empty account; if the storage walk proves that the slot leaf is absent, the slot value is interpreted as zero. The current trie therefore contains no explicit tombstone for deleted accounts or zeroed storage slots. Client databases may still retain historical nodes that are no longer reachable from the current root, but those stale nodes are irrelevant to proof verification because only nodes reachable from the bound root contribute to the proof. -This split is intentional. `ZoneStateWitness` proves the starting zone state with explicit per-account and per-slot proof lists, while `BatchStateProof` proves many Tempo reads against a shared deduplicated pool because large batches may reuse the same trie nodes across thousands of reads. The two structures serve the same cryptographic purpose, but they optimize the witness differently. +`ZoneStateWitness` and `BatchStateProof` now intentionally share the same witness shape: a bound root, a shared deduplicated `node_pool`, and per-read descriptors that say which decoded account / slot values must be proven. The difference is timing, not structure. `ZoneStateWitness` is verified once against the initial zone-state root at batch start, while `BatchStateProof` reads are verified against the Tempo root currently bound in `TempoState` at the moment of each read. Anchor validation ensures the zone's view of Tempo is correct. If `anchor_block_number` equals `tempo_block_number`, the zone's `tempoBlockHash` must match `anchor_block_hash` directly. If `anchor_block_number` is greater (for zones that have been offline longer than the EIP-2935 window), the proof verifies the parent-hash chain from `tempo_block_number` to `anchor_block_number` using the ancestry headers in the witness. From 916f954c17a4b79d9e98b103e17370c8ff6ec313 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 17:52:56 -0400 Subject: [PATCH 10/13] docs: factor out shared trie proof format Clean up the proving spec by defining the shared node_pool trie-proof encoding once and referencing it from both ZoneStateWitness and BatchStateProof. Remove wording that depended on earlier spec revisions and make the zone-vs-Tempo distinction purely about when the shared format is applied. Made-with: Cursor --- docs/specs/zone_spec.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index 342065577..c541d05d8 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -60,6 +60,7 @@ - [Witness Structure](#witness-structure) - [Input Schematic](#input-schematic) - [Detailed Input Definitions](#detailed-input-definitions) + - [Shared Trie Proof Format](#shared-trie-proof-format) - [Batch Output](#batch-output) - [Block Execution](#block-execution) - [Tempo State Proofs](#tempo-state-proofs) @@ -1008,13 +1009,17 @@ pub struct L1StateRead { } ``` -`ZoneStateWitness` now uses the same shared `node_pool` pattern as `BatchStateProof`. Instead of attaching a separate proof vector to each account and storage slot, the witness carries one deduplicated repository of RLP-encoded trie nodes plus decoded `ZoneAccountRead` and `ZoneStorageRead` entries describing which account / slot values must be proven against the initial zone-state root. +### Shared Trie Proof Format -Missing leaves are represented by non-membership proofs, not by omitting the read and not by storing an explicit all-zero leaf in the trie. If the account walk proves that an account leaf is absent from the state trie, the prover interprets that account as the canonical empty account: `nonce = 0`, `balance = 0`, `code = None`, `code_hash = KECCAK_EMPTY`, and an empty storage trie. Likewise, if the storage walk proves that a storage leaf is absent from the account's storage trie, the prover interprets that slot value as zero. This matches Ethereum's canonical trie semantics: deleted accounts and zeroed storage slots are removed from the current trie, so the trie root looks as if that leaf were absent rather than preserving an explicit zero-valued entry. +`ZoneStateWitness` and `BatchStateProof` both use the same trie-proof encoding: -All of these proofs are relative to the initial zone state at the start of the batch, not to the later mutated state after some zone blocks have already executed. `ZoneStateWitness` is therefore a bootstrap witness: it proves the starting contents of the accounts and storage slots that execution may touch, and the prover materializes that verified data into its in-memory execution state before replaying any blocks. +- `node_pool` is a deduplicated map from `keccak256(rlp(node))` to the node's raw RLP bytes. The prover validates each node once by recomputing the hash. +- Each read descriptor (`ZoneAccountRead`, `ZoneStorageRead`, or `L1StateRead`) states which decoded account or storage value must be proven against a bound trie root. +- Verification walks the account trie using `keccak256(account)` and, when needed, the storage trie using `keccak256(slot)`, fetching branch, extension, and leaf nodes from `node_pool`. +- Missing leaves are represented by valid non-membership proofs. An absent account is interpreted as the canonical empty account: `nonce = 0`, `balance = 0`, `code = None`, `code_hash = KECCAK_EMPTY`, and an empty storage trie. An absent storage leaf is interpreted as zero. Deleted accounts and zeroed storage slots are absent from the current trie rather than preserved as explicit zero-valued leaves. +- Client databases may still retain historical nodes that are no longer reachable from the current root, but those stale nodes are irrelevant to proof verification because only nodes reachable from the bound root contribute to the proof. -To verify a `ZoneStateWitness`, the prover first checks that the initial execution state root matches `ZoneStateWitness.state_root` and is consistent with `prev_block_header.state_root`. It then validates each node in `node_pool` once by recomputing `keccak256(rlp(node))`. For each `ZoneAccountRead`, the prover derives the account-trie key from `keccak256(account)` and walks the state trie from `state_root` using nodes from `node_pool`. If the walk ends at an account leaf, the decoded nonce, balance, and code hash must match the read, and `code` must be either absent with `code_hash = KECCAK_EMPTY` or present with `keccak256(code) == code_hash`. If the walk is a valid non-membership proof, the read is interpreted as the canonical empty account instead. For each `ZoneStorageRead`, the prover first determines the account's storage root from the verified account walk, then derives the storage-trie key from `keccak256(slot)` and walks that storage trie using nodes from `node_pool`. If the walk ends at a slot leaf, the decoded value must match `ZoneStorageRead.value`; if it is a valid non-membership proof, the slot value is interpreted as zero. Once this initialization step succeeds, block execution reads and writes the materialized account / storage values directly; it does not re-verify Merkle proofs on every in-block access. Missing account or storage reads are errors; they must not silently default to zero. +`ZoneStateWitness` applies this shared trie proof format to the initial zone-state root at batch start. `account_reads` and `storage_reads` describe the decoded account and storage values needed to bootstrap execution. To initialize execution, the prover checks that `ZoneStateWitness.state_root` is consistent with `prev_block_header.state_root`, validates `node_pool`, proves each `ZoneAccountRead` and `ZoneStorageRead` against that initial root, materializes the resulting account and storage values into the execution state, and only then starts replaying blocks. Missing account or storage reads are errors; they must not silently default to zero. ### Batch Output @@ -1039,18 +1044,12 @@ After the initial `ZoneStateWitness` has been verified and loaded into the execu ### Tempo State Proofs -System contracts read Tempo state during execution (deposit queue hash, sequencer address, token registry, TIP-403 policies). Unlike `ZoneStateWitness`, which is verified once against the initial zone-state root at batch start, `BatchStateProof` is interpreted against the Tempo root currently bound in `TempoState` at the moment of each read. If `advanceTempo()` runs during the batch, later reads are therefore verified against the newer Tempo root, not the root from the start of the batch. The witness includes a `BatchStateProof` containing: +System contracts read Tempo state during execution (deposit queue hash, sequencer address, token registry, TIP-403 policies). `BatchStateProof` applies the [shared trie proof format](#shared-trie-proof-format) to the Tempo root currently bound in `TempoState` at the moment of each read. If `advanceTempo()` runs during the batch, later reads are therefore verified against the newer Tempo root, not the root from the start of the batch. The witness includes a `BatchStateProof` containing: - A deduplicated `node_pool` of MPT nodes, keyed by `keccak256(rlp(node))`. Each node is verified exactly once. - A list of `L1StateRead` entries, each specifying the zone block index, Tempo block number, account, storage slot, and expected value. -Reads are indexed and verified on demand during execution. For each read, the prover walks the account trie and storage trie starting from the `TempoState` root currently bound for that block, using nodes from the shared `node_pool` to prove the requested `(account, slot) -> value` mapping. Because many reads access the same accounts and storage trie paths, the deduplicated pool significantly reduces proof size and prover cost compared to including separate MPT proofs per read. - -`BatchStateProof` uses the same shared-pool pattern as `ZoneStateWitness`. `node_pool` is a shared repository of RLP-encoded trie nodes, and each `L1StateRead` says only which account, slot, and value must be proven at a particular bound Tempo block. Verification proceeds from the root down: use the `tempoStateRoot` currently bound in `TempoState`, derive the account-trie key from `keccak256(account)`, fetch the referenced branch / extension / leaf nodes from `node_pool`, and reconstruct the walk until the account leaf is reached. From the account leaf, extract the storage root, derive the storage-trie key from `keccak256(slot)`, and repeat the same process in the storage trie until the slot leaf is reached. The read is valid only if the final decoded slot value equals `L1StateRead.value`. - -As with `ZoneStateWitness`, missing leaves are proved by non-membership. If the account walk proves that the account leaf is absent, the read is interpreted against the canonical empty account; if the storage walk proves that the slot leaf is absent, the slot value is interpreted as zero. The current trie therefore contains no explicit tombstone for deleted accounts or zeroed storage slots. Client databases may still retain historical nodes that are no longer reachable from the current root, but those stale nodes are irrelevant to proof verification because only nodes reachable from the bound root contribute to the proof. - -`ZoneStateWitness` and `BatchStateProof` now intentionally share the same witness shape: a bound root, a shared deduplicated `node_pool`, and per-read descriptors that say which decoded account / slot values must be proven. The difference is timing, not structure. `ZoneStateWitness` is verified once against the initial zone-state root at batch start, while `BatchStateProof` reads are verified against the Tempo root currently bound in `TempoState` at the moment of each read. +Reads are indexed and verified on demand during execution. Each `L1StateRead` is additionally tagged with `zone_block_index` and `tempo_block_number` so the prover can bind that read to the correct in-batch `TempoState`. The proof shape is the same as `ZoneStateWitness`; the difference is timing. `ZoneStateWitness` is verified once against the initial zone-state root at batch start, while `BatchStateProof` reads are verified against the Tempo root currently bound in `TempoState` at the moment of each read. Anchor validation ensures the zone's view of Tempo is correct. If `anchor_block_number` equals `tempo_block_number`, the zone's `tempoBlockHash` must match `anchor_block_hash` directly. If `anchor_block_number` is greater (for zones that have been offline longer than the EIP-2935 window), the proof verifies the parent-hash chain from `tempo_block_number` to `anchor_block_number` using the ancestry headers in the witness. From 34a64f52b77bd023332d133ef707e80fe7b71f12 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Tue, 14 Apr 2026 18:00:15 -0400 Subject: [PATCH 11/13] docs: expand stateless block execution steps Replace the brief block execution summary with a full step-by-step stateless verification procedure for BatchWitness. Factor out the shared trie proof format so the zone-state and Tempo-state witness sections read top-down for a new reader. Made-with: Cursor --- docs/specs/zone_spec.md | 53 +++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index c541d05d8..f9041a2f3 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -62,7 +62,7 @@ - [Detailed Input Definitions](#detailed-input-definitions) - [Shared Trie Proof Format](#shared-trie-proof-format) - [Batch Output](#batch-output) - - [Block Execution](#block-execution) + - [Block Execution](#block-execution-stateless-prover-execution-function) - [Tempo State Proofs](#tempo-state-proofs) - [Deployment Modes](#deployment-modes) - [Batch Submission](#batch-submission) @@ -1016,8 +1016,8 @@ pub struct L1StateRead { - `node_pool` is a deduplicated map from `keccak256(rlp(node))` to the node's raw RLP bytes. The prover validates each node once by recomputing the hash. - Each read descriptor (`ZoneAccountRead`, `ZoneStorageRead`, or `L1StateRead`) states which decoded account or storage value must be proven against a bound trie root. - Verification walks the account trie using `keccak256(account)` and, when needed, the storage trie using `keccak256(slot)`, fetching branch, extension, and leaf nodes from `node_pool`. -- Missing leaves are represented by valid non-membership proofs. An absent account is interpreted as the canonical empty account: `nonce = 0`, `balance = 0`, `code = None`, `code_hash = KECCAK_EMPTY`, and an empty storage trie. An absent storage leaf is interpreted as zero. Deleted accounts and zeroed storage slots are absent from the current trie rather than preserved as explicit zero-valued leaves. -- Client databases may still retain historical nodes that are no longer reachable from the current root, but those stale nodes are irrelevant to proof verification because only nodes reachable from the bound root contribute to the proof. +- Missing leaves are represented by valid non-membership proofs. An absent account is interpreted as the canonical empty account: `nonce = 0`, `balance = 0`, `code = None`, `code_hash = KECCAK_EMPTY`, and an empty storage trie. An absent storage leaf is interpreted as zero. +- Client databases may still retain historical trie nodes that are no longer reachable from the current root, but those stale nodes are irrelevant to proof verification because only nodes reachable from the bound root contribute to the proof. `ZoneStateWitness` applies this shared trie proof format to the initial zone-state root at batch start. `account_reads` and `storage_reads` describe the decoded account and storage values needed to bootstrap execution. To initialize execution, the prover checks that `ZoneStateWitness.state_root` is consistent with `prev_block_header.state_root`, validates `node_pool`, proves each `ZoneAccountRead` and `ZoneStorageRead` against that initial root, materializes the resulting account and storage values into the execution state, and only then starts replaying blocks. Missing account or storage reads are errors; they must not silently default to zero. @@ -1032,15 +1032,48 @@ The state transition function produces: | `withdrawal_queue_hash` | Hash chain of withdrawals finalized in this batch (`0` if none) | | `last_batch_commitment` | `withdrawal_batch_index` read from `ZoneOutbox.lastBatch` | -### Block Execution +### Block Execution (Stateless prover execution function) -After the initial `ZoneStateWitness` has been verified and loaded into the execution state, the prover executes each block in the batch: +The stateless execution function must reject the witness on any failed check, missing read, or inconsistent state transition. A correct implementation proceeds in the following order: -1. Validates `parent_hash` matches the previous block's hash, `number` increments by one, `timestamp` is non-decreasing, and `beneficiary` equals the registered sequencer. -2. Executes `advanceTempo` if present (start of block): finalizes the Tempo header, processes deposits, verifies encrypted deposit decryptions. -3. Executes user transactions in order. -4. Executes `finalizeWithdrawalBatch` if present (required in the final block, absent in intermediate blocks). -5. Computes the block hash from the simplified zone header fields (see [Block Header Format](#block-header-format)). +1. **Bind the previous block header to the public inputs.** + Require `keccak256(rlp(prev_block_header)) == public_inputs.prev_block_hash`. Require `prev_block_header.state_root == initial_zone_state.state_root`. These checks ensure that the witness starts from the exact predecessor block already committed on Tempo. + +2. **Verify and materialize the initial zone state.** + Apply the [shared trie proof format](#shared-trie-proof-format) to `initial_zone_state`: validate every node in `initial_zone_state.node_pool`, prove each `ZoneAccountRead` and `ZoneStorageRead` against `initial_zone_state.state_root`, interpret non-membership as the canonical empty account or zero storage, and load the decoded results into the prover's in-memory execution state. After this step, ordinary zone-state reads during execution come from the materialized state, not from repeated Merkle-proof checks. + +3. **Verify and index the Tempo proof pool.** + Validate every node in `tempo_state_proofs.node_pool` once by recomputing `keccak256(rlp(node))`. Index the `L1StateRead` descriptors by `(zone_block_index, tempo_block_number, account, slot)` or an equivalent key so that later `TempoState.readTempoStorageSlot` calls can be checked on demand against the root currently bound in `TempoState`. + +4. **Initialize batch-level accumulators.** + Set `prev_block_hash = public_inputs.prev_block_hash` and `prev_header = prev_block_header`. Read and store the initial `ZoneInbox.processedDepositQueueHash` as `prev_processed_hash`; this becomes the starting point for `deposit_queue_transition`. + +5. **For each `zone_blocks[i]`, verify the block witness before executing it.** + Require `block.parent_hash == prev_block_hash`. Require `block.number == prev_header.number + 1`. Require `block.timestamp >= prev_header.timestamp`. Require `block.beneficiary == public_inputs.sequencer`. Require `finalize_withdrawal_batch_count` to be absent in intermediate blocks and present in the final block of the batch. If `tempo_header_rlp` is absent, require `deposits` and `decryptions` to be empty. + +6. **Execute `advanceTempo` if the block imports a Tempo header.** + If `tempo_header_rlp` is present, call `TempoState.finalizeTempo(header)` in the modeled execution environment. This must validate header continuity, update the bound `tempoBlockNumber`, `tempoBlockHash`, and `tempoStateRoot`, and make the new Tempo root available for subsequent `TempoState.readTempoStorageSlot` calls in this block. Require the finalized `tempoBlockHash` to equal `keccak256(tempo_header_rlp)`. + +7. **Process deposits and encrypted deposit decryptions inside `advanceTempo`.** + Using the now-bound Tempo root for this block, verify the Tempo-side reads needed by `ZoneInbox` such as the portal's current deposit queue hash. Process the `deposits` in witness order, enforcing the queue semantics specified in [Deposit Queue](#deposit-queue). For encrypted deposits, verify the supplied `DecryptionData` and Chaum-Pedersen proof, decode the recipient and memo when valid, and apply the fallback mint-to-sender path when decryption verification fails as specified in [Onchain Decryption Verification](#onchain-decryption-verification). + +8. **Execute user transactions in order.** + Run each user transaction against the materialized zone state using the current block environment. Whenever execution calls `TempoState.readTempoStorageSlot`, satisfy that call by locating the corresponding `L1StateRead`, proving it against the Tempo root currently bound for this block, and requiring the decoded value to match the witness entry. Any zone-state or Tempo-state access not covered by the witness is an error. + +9. **Execute `finalizeWithdrawalBatch` at the end of the final block.** + If `finalize_withdrawal_batch_count` is present, execute `ZoneOutbox.finalizeWithdrawalBatch(count)` after all user transactions in that block. This must update the outbox's last-batch state and compute the `withdrawal_queue_hash` committed by the batch. Intermediate blocks must not execute this call. + +10. **Compute the resulting block header and carry it forward.** + After block execution, compute the `transactionsRoot` and `receiptsRoot` over the full ordered list of transactions and receipts for that block. Construct the simplified `ZoneHeader` from `parent_hash`, `beneficiary`, `state_root`, `transactions_root`, `receipts_root`, `number`, `timestamp`, and `protocol_version`, then compute `next_block_hash = keccak256(rlp(header))`. Set `prev_block_hash = next_block_hash` and `prev_header = header` before moving to the next block. + +11. **Extract the final batch commitments from the post-state.** + Read the final `ZoneInbox.processedDepositQueueHash`, `ZoneOutbox.lastBatch`, `TempoState.tempoBlockNumber`, and `TempoState.tempoBlockHash` from the executed state. + +12. **Verify the batch's final Tempo binding and anchor.** + Require `TempoState.tempoBlockNumber == public_inputs.tempo_block_number`. If `anchor_block_number == tempo_block_number`, require `TempoState.tempoBlockHash == anchor_block_hash`. Otherwise, verify the parent-hash chain from `tempo_block_number` to `anchor_block_number` using `tempo_ancestry_headers`, ending at `anchor_block_hash`. + +13. **Return the batch outputs.** + Set `block_transition.prev_block_hash = public_inputs.prev_block_hash` and `block_transition.next_block_hash = prev_block_hash` after the final block. Set `deposit_queue_transition.prev_processed_hash` to the value captured in step 4 and `deposit_queue_transition.next_processed_hash` to the final inbox processed hash. Set `withdrawal_queue_hash` and `last_batch_commitment.withdrawal_batch_index` from the final `ZoneOutbox.lastBatch` state. ### Tempo State Proofs From 8402c115dc2a8c8138b24fada48a9d46e5c86a8a Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Wed, 15 Apr 2026 10:25:57 -0400 Subject: [PATCH 12/13] docs: refine stateless execution steps Tighten the stateless prover execution procedure so it reads as a cleaner ordered algorithm. Remove redundant setup wording and renumber the verification steps to match the current proof flow. Made-with: Cursor --- docs/specs/zone_spec.md | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index f9041a2f3..bd03411bf 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -1043,36 +1043,33 @@ The stateless execution function must reject the witness on any failed check, mi Apply the [shared trie proof format](#shared-trie-proof-format) to `initial_zone_state`: validate every node in `initial_zone_state.node_pool`, prove each `ZoneAccountRead` and `ZoneStorageRead` against `initial_zone_state.state_root`, interpret non-membership as the canonical empty account or zero storage, and load the decoded results into the prover's in-memory execution state. After this step, ordinary zone-state reads during execution come from the materialized state, not from repeated Merkle-proof checks. 3. **Verify and index the Tempo proof pool.** - Validate every node in `tempo_state_proofs.node_pool` once by recomputing `keccak256(rlp(node))`. Index the `L1StateRead` descriptors by `(zone_block_index, tempo_block_number, account, slot)` or an equivalent key so that later `TempoState.readTempoStorageSlot` calls can be checked on demand against the root currently bound in `TempoState`. + Validate every node in `tempo_state_proofs.node_pool` once by recomputing `keccak256(rlp(node))` for each node. -4. **Initialize batch-level accumulators.** - Set `prev_block_hash = public_inputs.prev_block_hash` and `prev_header = prev_block_header`. Read and store the initial `ZoneInbox.processedDepositQueueHash` as `prev_processed_hash`; this becomes the starting point for `deposit_queue_transition`. - -5. **For each `zone_blocks[i]`, verify the block witness before executing it.** +4. **For each `zone_blocks[i]`, verify the block witness before executing it.** Require `block.parent_hash == prev_block_hash`. Require `block.number == prev_header.number + 1`. Require `block.timestamp >= prev_header.timestamp`. Require `block.beneficiary == public_inputs.sequencer`. Require `finalize_withdrawal_batch_count` to be absent in intermediate blocks and present in the final block of the batch. If `tempo_header_rlp` is absent, require `deposits` and `decryptions` to be empty. -6. **Execute `advanceTempo` if the block imports a Tempo header.** - If `tempo_header_rlp` is present, call `TempoState.finalizeTempo(header)` in the modeled execution environment. This must validate header continuity, update the bound `tempoBlockNumber`, `tempoBlockHash`, and `tempoStateRoot`, and make the new Tempo root available for subsequent `TempoState.readTempoStorageSlot` calls in this block. Require the finalized `tempoBlockHash` to equal `keccak256(tempo_header_rlp)`. +5. **Execute `advanceTempo` if the block imports a Tempo header.** + If `tempo_header_rlp` is present, call `TempoState.finalizeTempo(header)` in the modeled execution environment. This validates header continuity, updates the bound `tempoBlockNumber`, `tempoBlockHash`, and `tempoStateRoot`, and make the new Tempo root available for subsequent `TempoState.readTempoStorageSlot` calls in this block. Require the finalized `tempoBlockHash` to equal `keccak256(tempo_header_rlp)`. -7. **Process deposits and encrypted deposit decryptions inside `advanceTempo`.** +6. **Process deposits and encrypted deposit decryptions inside `advanceTempo`.** Using the now-bound Tempo root for this block, verify the Tempo-side reads needed by `ZoneInbox` such as the portal's current deposit queue hash. Process the `deposits` in witness order, enforcing the queue semantics specified in [Deposit Queue](#deposit-queue). For encrypted deposits, verify the supplied `DecryptionData` and Chaum-Pedersen proof, decode the recipient and memo when valid, and apply the fallback mint-to-sender path when decryption verification fails as specified in [Onchain Decryption Verification](#onchain-decryption-verification). -8. **Execute user transactions in order.** +7. **Execute user transactions in order.** Run each user transaction against the materialized zone state using the current block environment. Whenever execution calls `TempoState.readTempoStorageSlot`, satisfy that call by locating the corresponding `L1StateRead`, proving it against the Tempo root currently bound for this block, and requiring the decoded value to match the witness entry. Any zone-state or Tempo-state access not covered by the witness is an error. -9. **Execute `finalizeWithdrawalBatch` at the end of the final block.** +8. **Execute `finalizeWithdrawalBatch` at the end of the final block.** If `finalize_withdrawal_batch_count` is present, execute `ZoneOutbox.finalizeWithdrawalBatch(count)` after all user transactions in that block. This must update the outbox's last-batch state and compute the `withdrawal_queue_hash` committed by the batch. Intermediate blocks must not execute this call. -10. **Compute the resulting block header and carry it forward.** +9. **Compute the resulting block header and carry it forward.** After block execution, compute the `transactionsRoot` and `receiptsRoot` over the full ordered list of transactions and receipts for that block. Construct the simplified `ZoneHeader` from `parent_hash`, `beneficiary`, `state_root`, `transactions_root`, `receipts_root`, `number`, `timestamp`, and `protocol_version`, then compute `next_block_hash = keccak256(rlp(header))`. Set `prev_block_hash = next_block_hash` and `prev_header = header` before moving to the next block. -11. **Extract the final batch commitments from the post-state.** +10. **Extract the final batch commitments from the post-state.** Read the final `ZoneInbox.processedDepositQueueHash`, `ZoneOutbox.lastBatch`, `TempoState.tempoBlockNumber`, and `TempoState.tempoBlockHash` from the executed state. -12. **Verify the batch's final Tempo binding and anchor.** +11. **Verify the batch's final Tempo binding and anchor.** Require `TempoState.tempoBlockNumber == public_inputs.tempo_block_number`. If `anchor_block_number == tempo_block_number`, require `TempoState.tempoBlockHash == anchor_block_hash`. Otherwise, verify the parent-hash chain from `tempo_block_number` to `anchor_block_number` using `tempo_ancestry_headers`, ending at `anchor_block_hash`. -13. **Return the batch outputs.** +12. **Return the batch outputs.** Set `block_transition.prev_block_hash = public_inputs.prev_block_hash` and `block_transition.next_block_hash = prev_block_hash` after the final block. Set `deposit_queue_transition.prev_processed_hash` to the value captured in step 4 and `deposit_queue_transition.next_processed_hash` to the final inbox processed hash. Set `withdrawal_queue_hash` and `last_batch_commitment.withdrawal_batch_index` from the final `ZoneOutbox.lastBatch` state. ### Tempo State Proofs From db3a71bbeda5f0cec68e4df6b494c77b9e1eb844 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Wed, 15 Apr 2026 14:40:36 -0400 Subject: [PATCH 13/13] docs: verify zone witness code hash Require zone witness ingestion to check that any supplied account bytecode preimage matches the witnessed code hash. This makes the batch-start materialization rules explicit in both the shared proof format and the step-by-step execution flow. Made-with: Cursor --- docs/specs/zone_spec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/specs/zone_spec.md b/docs/specs/zone_spec.md index bd03411bf..bb188e551 100644 --- a/docs/specs/zone_spec.md +++ b/docs/specs/zone_spec.md @@ -1016,10 +1016,11 @@ pub struct L1StateRead { - `node_pool` is a deduplicated map from `keccak256(rlp(node))` to the node's raw RLP bytes. The prover validates each node once by recomputing the hash. - Each read descriptor (`ZoneAccountRead`, `ZoneStorageRead`, or `L1StateRead`) states which decoded account or storage value must be proven against a bound trie root. - Verification walks the account trie using `keccak256(account)` and, when needed, the storage trie using `keccak256(slot)`, fetching branch, extension, and leaf nodes from `node_pool`. +- For `ZoneAccountRead`, the account leaf proves the committed `code_hash`, but not the bytecode preimage itself. If the witness supplies `code`, the prover must additionally require `keccak256(code) == code_hash` before materializing that account into the execution state. - Missing leaves are represented by valid non-membership proofs. An absent account is interpreted as the canonical empty account: `nonce = 0`, `balance = 0`, `code = None`, `code_hash = KECCAK_EMPTY`, and an empty storage trie. An absent storage leaf is interpreted as zero. - Client databases may still retain historical trie nodes that are no longer reachable from the current root, but those stale nodes are irrelevant to proof verification because only nodes reachable from the bound root contribute to the proof. -`ZoneStateWitness` applies this shared trie proof format to the initial zone-state root at batch start. `account_reads` and `storage_reads` describe the decoded account and storage values needed to bootstrap execution. To initialize execution, the prover checks that `ZoneStateWitness.state_root` is consistent with `prev_block_header.state_root`, validates `node_pool`, proves each `ZoneAccountRead` and `ZoneStorageRead` against that initial root, materializes the resulting account and storage values into the execution state, and only then starts replaying blocks. Missing account or storage reads are errors; they must not silently default to zero. +`ZoneStateWitness` applies this shared trie proof format to the initial zone-state root at batch start. `account_reads` and `storage_reads` describe the decoded account and storage values needed to bootstrap execution. To initialize execution, the prover checks that `ZoneStateWitness.state_root` is consistent with `prev_block_header.state_root`, validates `node_pool`, proves each `ZoneAccountRead` and `ZoneStorageRead` against that initial root, checks `keccak256(code) == code_hash` for every supplied account-code preimage, materializes the resulting account and storage values into the execution state, and only then starts replaying blocks. Missing account or storage reads are errors; they must not silently default to zero. ### Batch Output @@ -1040,7 +1041,7 @@ The stateless execution function must reject the witness on any failed check, mi Require `keccak256(rlp(prev_block_header)) == public_inputs.prev_block_hash`. Require `prev_block_header.state_root == initial_zone_state.state_root`. These checks ensure that the witness starts from the exact predecessor block already committed on Tempo. 2. **Verify and materialize the initial zone state.** - Apply the [shared trie proof format](#shared-trie-proof-format) to `initial_zone_state`: validate every node in `initial_zone_state.node_pool`, prove each `ZoneAccountRead` and `ZoneStorageRead` against `initial_zone_state.state_root`, interpret non-membership as the canonical empty account or zero storage, and load the decoded results into the prover's in-memory execution state. After this step, ordinary zone-state reads during execution come from the materialized state, not from repeated Merkle-proof checks. + Apply the [shared trie proof format](#shared-trie-proof-format) to `initial_zone_state`: validate every node in `initial_zone_state.node_pool`, prove each `ZoneAccountRead` and `ZoneStorageRead` against `initial_zone_state.state_root`, require `keccak256(code) == code_hash` for every supplied account-code preimage, interpret non-membership as the canonical empty account or zero storage, and load the decoded results into the prover's in-memory execution state. After this step, ordinary zone-state reads during execution come from the materialized state, not from repeated Merkle-proof checks. 3. **Verify and index the Tempo proof pool.** Validate every node in `tempo_state_proofs.node_pool` once by recomputing `keccak256(rlp(node))` for each node.