# EEZ Protocol Documents Specifications for the EEZ core protocol contracts. # EEZ walkthrough Start here to understand how EEZ connects calls across Ethereum and participating rollups. This guide follows a call through preparation, proof verification and local execution, then explains tables, hashes, rollback, ETH accounting and system authority. It describes the protocol contracts and their reference Rollup and proof-system implementations. The composer, proving circuits and L2 node supply the surrounding integration described in [Integration Boundaries](/integration-boundaries). The contracts under `src/periphery/` are test fixtures, not production components. ## 1. What EEZ does EEZ provides the contract machinery for synchronous composability between Ethereum and participating rollups, with based rollups sharing L1 sequencing as the intended setting. An application can call a remote contract through a local proxy and receive its predicted return value during its own transaction. This allows flows such as calling back into L1 while an L1-to-L2 operation is still open, or borrowing, using funds across chains, and repaying within a composed operation. The mechanism combines **precomputation, proof verification, and local execution**: 1. A composer simulates the cross-chain operation and builds an execution table for each chain. Tables describe calls, expected results, nesting, and rollback. 2. Proof systems attest the L1 batch, including the rollup state transitions and execution tables. 3. L1's `EEZ` verifies those proofs. As entries are consumed, it executes the calls belonging on L1 and checks their observed results. 4. Each L2's `EEZL2` executes tables supplied by its trusted node/system integration. It checks local execution against those tables, without verifying proofs itself. A proxy call therefore resolves locally using prepared data. The two chains do not share an EVM call stack. Cross-chain consistency depends on the proofs and node integration connecting the local executions correctly. Solidity rollback supplies atomicity within an execution frame; it does not independently guarantee delivery of every posted entry across chains. For the formal model, see [Core Protocol Specification](/core-protocol-spec). External obligations are collected in [Integration Boundaries](/integration-boundaries). ## 2. Basic terms and execution paths The main actors are the composer, the proof systems, the two execution managers (EEZ on L1 and EEZL2 on each rollup), and the applications calling through proxies. A **cross-chain proxy** is a local contract representing an address on another chain. Calling it lets the local manager resolve the prepared remote call. L1 has protocol rollup ID `0`; registered rollups have nonzero IDs. These registry IDs are separate from EVM chain IDs. | Term | Meaning | | --- | --- | | Root | A stored commitment to a rollup's state. | | Entry | The prepared data for one local execution context: calls, expected results and checks. | | Table | A collection of entries or nested-call rows used to resolve calls. | | Batch | A group of entries and the inputs needed to verify their proofs on L1. | | Frame | One call context, which can contain nested calls. | | Cursor | The position from which a table search resumes; it is not a count of successful calls. | | Rolling hash | A digest built from observed calls and results, compared with the entry's expected digest. | L1 entries have three execution paths. **Immediate** entries belong to the prefix selected for execution during posting. Leading L2 transaction entries in that prefix run automatically; the rest are available to the posting contract through a **meta hook**, a callback in which it can make application calls that consume them. **Deferred** entries go into per-rollup queues for consumption later in the same L1 block. Section 6 follows those paths in detail. ## 3. A first walkthrough: L1 calls an L2 counter Suppose Alice calls `Counter.increment()` on L2 through Counter's proxy on L1. Assume zero ETH, no callbacks, and a successful predicted return value of `1`. :::steps #### Preparation The composer predicts Counter's result and the L2 transition from root S0 to S1. The L1 entry contains the call identity, that root transition, cached return data `abi.encode(1)`, and an expected rolling hash. The entry has no local application calls to execute on L1. Proofs attest the entry. #### L1 posting The account or contract submitting the batch—the poster—calls `postAndVerifyBatch`. For this example the entry is deferred into the destination rollup's queue. #### L1 consumption In the same L1 block, Alice calls Counter's proxy. EEZ reconstructs the call identity and scans for an entry matching that identity, destination rollup, and live S0 root. It runs the entry, applies S1, checks the rolling hash and ETH accounting, and returns the cached `1` to Alice. #### L2 execution The node supplies the corresponding table through the system-only `executeIncomingCrossChainCall`. EEZL2 executes the real Counter call through Alice's proxy on L2 and checks its local rolling hash. Each chain executes its own part of the operation. ::: ```mermaid flowchart TD Composer["Composer: simulate calls, results and roots"] --> Proofs["Proof systems attest L1 batch"] Proofs --> L1["L1 EEZ: verify and publish entry"] Alice["Alice calls Counter's proxy on L1"] --> Consume["L1 EEZ: match and consume entry"] L1 --> Consume Consume --> Result["Apply root transition and return cached result"] Composer --> Node["L2 node/system: supply corresponding table"] Node --> L2["EEZL2: execute real Counter call and check local result"] ``` The diagram separates the two local paths; their scheduling and causal association belong to the integration. The L1 proof must establish the truth of the predicted remote result. L2's internal hash check alone establishes only consistency with the supplied table. For callbacks, extend the example to Alice → L2 Counter → L1 C. The L1 entry now includes the real call to C in `l2ToL1Calls`. During L2 execution, Counter's outward call to C resolves from `expectedOutgoingCalls`. Each side executes its local application code and uses prepared results for the remote portions. See [Execution Entry flow patterns](/execution-entry-spec#flow-patterns) for the paired table layouts. ## 4. How proxies connect local and remote calls A `CrossChainProxy` represents one `(originalAddress, originalRollupId)` pair on its local manager. Anyone can ask the manager to create one. Creation records that identity; it does not verify the remote contract's code or grant arbitrary authority. There are two uses of the same proxy mechanism: * **Calling outward:** an L1 application calls the local proxy representing contract B on L2. The proxy forwards the application's caller identity, calldata, and value to L1's EEZ. * **Executing inward:** when EEZ executes a call from remote contract B into local contract C, it invokes C through B's local proxy. C sees `msg.sender = B's proxy`. Applications authenticate a remote caller through the proxy derived by the expected local manager for that caller's address and rollup ID. The remote address alone is insufficient. Same-network proxies are forbidden. The proxy dispatches `executeOnBehalf` directly only for its manager. Other callers, including callers with a colliding function selector, take the cross-chain path. Return bytes are unwrapped; revert bytes are forwarded. ## 5. What an execution entry contains The counter example needs an entry on each side. The fields below describe what each manager executes locally and what it returns for the remote part. | Concept | L1 field | L2 field | | --- | --- | --- | | Trigger identity | `proxyEntryHash` | `proxyEntryHash` | | Calls executed locally | `l2ToL1Calls` | `incomingCalls` | | Expected nested outward calls | `expectedL1ToL2Calls` | `expectedOutgoingCalls` | | Expected execution digest | `rollingHash` | `rollingHash` | | Result presented to caller | `success`, `returnData` | `success`, `returnData` | | Root and ETH transitions | `rollupUpdates` | Absent | | Queue routing | `destinationRollupId` | Absent | L2 names are relative to the executing chain because its counterparty may be L1 or another L2. The L1 and L2 structs are separate ABI types. Each frame owns its own ordered local call array. One shared expected-call table serves all nested frames within an entry. A nested row is found using: ```text positionKey = keccak256(abi.encodePacked(crossChainCallHash, liveRollingHash)) ``` The key combines the request with the current rolling hash. A forward cursor constrains which rows remain visible; children must appear after their parents. Static reads do not advance that cursor. An enclosing revert restores hash and cursor changes, so the same key can occur again; it is not a unique historical call number. **L2-originated work has a special L1 representation.** An L1 entry with `proxyEntryHash == 0` represents L2 transaction work, including any calls it makes to L1. It must have `success == true` and empty return data. It runs in the immediate prefix or through L1's `executeL2Txs(rollupId)`. EEZL2 has no such entry point. One L2 user transaction can produce several L2 proxy consumptions while being represented by one L1 L2Tx entry. Conversely, several completed top-level L1-to-L2 calls can require separate L2 deliveries. Nested activity stays inside its open frame. See the [E2E frame-coherence guide](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/BUILD_AND_REVIEW_E2E_TESTS.md#the-frame-coherence-invariant). ### Loading and consuming on L2 `executeIncomingCrossChainCall` loads its table and sets `lastLoadBlock` itself; it does not require an earlier same-block load. Entry zero's identity must match its first incoming call. After successful execution, `entryIndex` becomes 1, so later proxy calls can consume the remaining entries in that block. A failed delivery reverts the load and restores the previous table and cursor. ## 6. The batch lifecycle and proof boundary ### Registration and posting Before participation, a rollup deploys its policy contract and registers it with EEZ. EEZ allocates an ID, stores the initial root and a zero ETH ledger balance, then calls the manager's registration callback with the original registrant. The reference Rollup requires that registrant to be its owner and permits registration once. `postAndVerifyBatch` then follows this sequence: :::steps #### Check optional live-root assertions and structural constraints Ordering, registration, proof-system indices, entry membership, and prefix bounds. #### Ask each rollup manager to approve its selected proof-system subset and return its verification keys The reference manager enforces its threshold and membership here. #### Verify one proof per listed proof system Every proof must pass; a false result or verifier revert aborts the post. #### Mark participating rollups verified in the current block and replace their previous queues and cursors #### Attempt the leading zero-hash L2Tx entries directly from calldata #### Load any remaining immediate entries and invoke the poster's `executeMetaCrossChainTransactions()` hook to consume them #### Publish the deferred remainder to per-rollup queues, discard immediate leftovers, and clear the temporary tables ::: ### Immediate work, deferred queues and settlement order The hook is an external callback to the posting contract. While its immediate table is active, top-level mutable consumption and top-level static lookups use only this batch's temporary tables. They cannot fall back to persistent queues, even for a rollup verified by an earlier batch in the same block. Mutable entries share one global cursor across rollups during the hook; nested calls use the active entry's expected-call table. Unconsumed immediate entries are deleted rather than queued. Deferred entries are published after the hook and are available later in the same block. **Proof acceptance and entry execution are separate.** Posting a deferred entry does not apply its root transition. Consumption forward-scans for the first complete match; committing a later match passes over earlier candidates. A matching entry that reverts restores the cursor and may be retried, subject to the mutable retry limit in §8. Every accepted re-verification replaces participating queues, even within the same block. **Settlement order is determined on L1.** Deferred entries in different rollup queues can execute in either order when their checks pass; their positions in the proven batch array do not impose an order across queues. `executeL2Txs` and application calls through proxies are permissionless, although each call must still match its identity, roots, and other execution checks. Applications must enforce dependencies on other entries or L1 state at execution time. See the [settlement model](/core-protocol-spec#settlement-model). Immediate failures have special handling: a caught nonempty revert can be skipped; empty revert data aborts; a nonempty leading run with no successes aborts the whole post. A later outer revert unwinds prior successes and logs in that posting transaction. The meta hook may leave entries unused, so posting does not guarantee complete delivery. ### What the proof commits to The shared proof commitment covers full mutable/static entries, selected blob hashes, the hash of the batch's `callData` field, rollup-keyed custom data, and an optional bound sender. Each verifier additionally receives a digest of the rollup IDs and verification keys assigned to it. The exact encodings are in [Multi-Prover Specification](/multi-prover-spec#per-ps-publicinputshash-two-stage). The two immediate-prefix counts and batch-level root assertions are **outside the proof commitment**. Sender binding can restrict who posts, while the intended poster's policy must enforce the desired dispatch. Solidity still bounds both counts and rejects: * A zero-hash L2Tx entry immediately after the immediate prefix (`ImmediateCountStrandsLeadingL2Tx`), including a boundary after proxy entries. * A codeless poster when remaining immediate entries require the meta hook (`MetaEntriesWithoutReceiver`). * Immediate static entries when no meta hook will run (`ImmediateStaticEntriesWithoutImmediateEntries`). Every listed proof system is verified, even one selected by no rollup, whose rollup/key accumulator remains zero. Setting the reference manager's threshold to zero does not permit proof-free posting: EEZ still requires a nonempty proof-system subset for every participating rollup. ### Verification context and replay The reference manager's `getCustomData` supports no context, a specific available historical block hash, or current timestamp plus previous block hash. This is distinct from the same-block consumption gate. Explicit chain ID, EEZ address, and protocol version are absent from the proof domain; deployment separation and freshness are external responsibilities. **Reposting and execution replay are distinct.** There is no consumed-proof nonce. A proof may be posted again while its verification policy, context, blob inputs, and any supplied root assertions remain valid. Same-block consumption gates do not make it single-use. Replay prevention across queue generations relies on rollup/prover rules: every successfully committed entry must advance at least one pinned root, and root management must not restore old roots. Solidity checks current-root equality but does not enforce those history rules. Restoring old roots can make old entries eligible again if the other checks pass. Even when stale entries cannot execute, an accepted repost still replaces queues and activates the same-block root-escape lock. See [Core §H.3](/core-protocol-spec#h3-forward-scan-consumption) and [Caveats](/caveats). ## 7. How execution is checked On L1, `_executeEntry` first checks every `currentRoot` and establishes the entry's allowed rollup set. It seeds the rolling hash, executes the local calls, then applies the new roots and ETH deltas. Final checks must pass for those writes to survive. Consequently, callbacks observe pre-entry roots; nested rows do not independently advance roots. ### Call identities and rolling hashes The rolling hash is a chained digest of execution observations. L1 seeds it with ordered starting roots and the entry identity. L2 seeds it with the entry identity alone. The shared tags identify call start/result, nested start/end, a missing nested call, and insufficient dispatch gas. The distinction between commitments matters: * The **full entry hash** binds supplied fields into the proof. * The **call hash** identifies a request. * The **rolling hash** compares the local execution observations with the expected trace. These hashes have different purposes. In particular, source and destination rolling hashes generally differ. The rolling hash records observed call identities, outcomes, and nesting; it does not commit to every L1 storage write. Different L1 state effects can produce the same recorded results, and separate entries have no shared rolling hash enforcing their relative order. | Call-hash site | Gas value included in identity | | --- | --- | | All L1 sites | Zero | | Local delivered-call folds on L2 | Zero | | L2 incoming entry binding | First incoming call's supplied `gas` | | Outgoing mutable/static calls from L2 | Observed `gasleft()` if `USE_GAS_LEFT`; otherwise zero | Thus even the same logical cross-chain call can have different identity hashes at different sites. Gas limits still affect actual dispatch. The preflight check is an estimate and excludes some costs; zero gas cap bypasses that check and forwards available gas subject to EVM rules. ### Execution failures and destination checks Valid proofs must exclude `CALL_NOT_FOUND` and `CALL_INSUFFICIENT_GAS` from expected hashes. Solidity cannot inspect an expected digest to enforce that policy. A surviving marker should cause a mismatch against an honest expectation, but ordinary enclosing reverts erase transient observations. Hash equality is not a standalone proof of remote truth or full delivery. Both managers reject a local callback targeting the executing manager's own address with `EEZDestinationForbidden`, including static callbacks. This check compares against the runtime manager address, which is the manager proxy address under delegatecall. See [Core hash matrix](/core-protocol-spec#c-action-hash-computation), [rolling hashes](/core-protocol-spec#e-rolling-hash), and [Integration Boundaries](/integration-boundaries). ## 8. Reads and reverts A call can fail while its caller continues. The important distinction is which frame reverts and which observations survive. ### Reverts and rollback spans | Situation | Behavior | | --- | --- | | Local target naturally reverts | The processor records failure and revert bytes. The entry may still commit if that is the expected outcome. | | Top-level mutable entry has `success == false` | Execute and validate, then revert with cached data, undoing entry effects and cursor movement. | | Nested outward call has `success == false` | Execute its local callbacks, check its sub-hash, then revert the nested frame. | | A successful call's effects must later be undone | `revertNextNCalls` executes a contiguous span in a self-call that deliberately reverts. | | Cross-chain static read | Resolve a static entry/row and check any local read-only callbacks; do not advance a cursor. | The deliberate span uses `ContextResult(rollingHash, reentrantConsumed)` to carry bookkeeping out through revert data. The parent restores those two values while storage changes, ETH transfers, and interior logs stay rolled back. An ordinary revert has no such transport. The table describes paths that reach their final checks. A nested execution can instead fail earlier with a runtime validation error, or fail its sub-hash check. An application can catch that revert, and the outer entry can still commit if its surviving observations match. Outer success does not establish completion of every erased nested frame. See [intermediate-revert tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZIntermediateRevert.t.sol) and the [builder/prover responsibilities](/integration-boundaries#builder-and-prover). A missing nested mutable row has a specific application-visible outcome: `_consumeNestedCall` records `CALL_NOT_FOUND` and returns success with empty bytes. An enclosing application frame can then revert, erasing that marker while passing the apparent success through its revert data. A caller that catches it must not treat that result as proven remote approval. Top-level mutable and static misses revert immediately. The [reverted-probe caveat](/caveats#edge-cases) describes the accepted boundary. ### Static reads A bounded self-call that attempts `TSTORE` detects static context and routes reads to `staticCrossChainCall`. The proxy reads `STATIC_CHECK_GAS` from its manager on each cross-chain routing call; the current cap is 1,000 and an implementation upgrade can change it. Top-level static reads use a separate pool. L1 matches call identity, destination, and live root pins; L2 matches identity and `expectedEntryIndex == entryIndex`. Both top-level paths require tables from the current local block. Nested reads use the active entry's expected-call table. Static callbacks require predeployed proxies, zero value, static mode, and no rollback span. Their result hash is a separate untagged accumulator, starting at zero. **Static candidate selection.** Both managers try candidates in order, skipping those whose callback-result hashes mismatch and using the first hash-valid result. This works for top-level and nested reads. For example, a remote `quote()` that calls back to a local `rate()` can return 1, then 2 after a local write, without advancing a cursor or changing the host rolling hash: the two rows have different expected callback-result hashes. A hash-valid cached revert is terminal; structural or gas-preflight errors also revert immediately rather than trying another candidate. Exhaustion reverts `ExecutionNotFound()` on L1 or `EntryNotFound(hash, callGas)` on L2. The latter supports the observed-gas [probe recipe](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/GasProbe.t.sol). Builders must reject or restructure conflicting answers that share the lookup context and validating callback hash, including gas-sensitive results with empty callback arrays. Trying rejected candidates also spends gas and can exhaust a budget that fits either candidate alone. See the [static-read examples](/static-entry#44-local-writes-between-identical-static-reads) and [remaining call-pattern limits](/call-pattern-limits). ### Mutable retries Mutable resolution selects the first matching row; it does not try alternatives after an execution-hash mismatch. A revert followed by a local write and an identical retry can therefore reuse an outdated row. A successful call undone by its parent can do the same. The [characterization tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZExpressiveness.t.sol) cover rejected retries on both chains. Mutable candidate retries are not implemented; whether to add them remains undecided. Successful nested rows also return a saved answer without including it in the nested closing hash. After rollback, a reused row could run callbacks against new state while returning an old answer. This separate source-level concern has not been reproduced by the retry tests. See [Call-pattern limits](/call-pattern-limits#1-mutable-call-revert-local-write-identical-retry) for the examples and validation scope, and [Caveats](/caveats#delivery-and-execution-semantics) for current support limits. ## 9. ETH accounting On L1, each successfully committed entry checks: ```text sum(rollupUpdates.etherDelta) = ETH received through entry proxy calls - ETH paid by successful local calls ``` Reverted transfers and their accounting unwind together. Each rollup's ledger must remain nonnegative. At completed entry boundaries, protocol-accounted transfers preserve held ETH minus total rollup liabilities, with external/forced ETH treated separately. On L2, loads add pooled funding, incoming calls spend it, and outgoing proxy value is transferred to `SYSTEM_ADDRESS`. Replacing a table retains unused ETH. Actual mint/burn semantics, residual inventory, and end-to-end conservation require node/circuit rules; Solidity performs transfers and has no per-table funding ledger. ### ETH sent before proxy deployment At cross-chain proxy construction, prefunded ETH is swept once to `RECOVERY_ADDRESS` with a 100,000-gas cap. The sweep ignores transfer failure, leaving that ETH in the proxy; it is separate from normal entry accounting. See [L2 funding and inventory](/integration-boundaries#l2-funding-and-inventory) for the node/circuit accounting responsibilities. ## 10. Trust, authority and upgrades | Actor or boundary | Authority / assumption | | --- | --- | | ProxyAdmin owners: EEZ, EEZL2, and each Rollup | Can replace the corresponding implementation, including its logic and immutable configuration. These upgrade authorities are separate from operational ownership and are part of the trust model. | | Rollup policy contract and owner | Choose accepted proof systems, keys, and threshold. The reference owner can initiate direct root replacement, subject to EEZ's active-execution and same-block lockouts; root escape does not adjust the ETH ledger. | | ECDSA verifier owner and signer | The owner can rotate the accepted signer. The reference `DeployL1` uses one ECDSA verifier at threshold 1, so that signer supplies the proof authorization; all runtime entry checks still apply. | | Proof systems | Must establish the correctness of remote execution, results, state transitions, and required policies. An accepting mock only tests contract mechanics. | | Poster, meta hook, and consumers | The poster controls submission and permitted dispatch choices; the hook may omit work or revert. Permissionless consumers can advance a queue past earlier candidates by successfully consuming a later matching entry. | | L2 `SYSTEM_ADDRESS` | Can replace tables and initiate incoming delivery. Assumed node-controlled, non-adversarial, and unreachable by application reentrancy, including on ETH receipt. | | Composer / node | Must preserve causal ordering, correlate source and delivery occurrences, prevent invalid replay, fund execution, and reject or restructure unsupported mutable and static traces. | | Deployment / upgrade integration | The [reference package](https://github.com/eez-association/eez-core-protocol/blob/143d87e/deployment/README.md) already deploys upgradeable EEZ, EEZL2, and Rollup, with atomic Rollup initialization and a directly deployed ECDSA verifier. Production configuration, proof domains, artifact pinning, and storage/transient compatibility require deployment-specific review. | ### Execution guards and root authority EEZ intentionally permits nested proxy execution. It separately blocks `postAndVerifyBatch` reentry during active execution and the meta-hook window. Verification callbacks use static calls. `registerRollup` has no corresponding execution/hook guard and may itself be called during those windows; its callback remains subject to the guards on any other function it calls. Outside those windows, a registration callback may also post an otherwise valid batch. On L1, entry validation restricts local call sources and the routed destination to the entry's proven rollup set. Runtime checks restrict nested targets to that set. These checks limit which remote identities an entry can exercise; they do not replace application authorization. EEZL2 has no equivalent per-entry proven-rollup set. It relies on the trusted system's tables and node/prover policy, while retaining proxy authorization, lookup, and execution checks. There is no manager-handoff API for an existing rollup ID, but a registered proxy manager can be upgraded without changing that address. Reference owner operations can change future verification policy. Direct root escape is blocked during active execution and after that rollup has been verified in the current block. Before verification, it can invalidate prepared entries' root assumptions; restoring an old root can reopen replay eligibility as described in §6. `setRoot` emits `RootUpdated` and leaves `etherBalance` unchanged. See [Multi-Prover Specification](/multi-prover-spec) and [Caveats](/caveats). ### Upgrades and proxy address continuity Cross-chain proxy addresses are deterministic through CREATE2. Proxy addresses depend on the manager address and actual creation bytecode, including compiler/metadata settings. Changing the init-code hash during an upgrade changes predicted addresses: with unchanged authorization storage, existing proxies remain authorized, while `getOrCreateCrossChainProxy` can deploy another proxy for the same remote pair at its new address. Existing balances, approvals, and application authentication therefore depend on preserving address continuity. The [upgrade scripts](https://github.com/eez-association/eez-core-protocol/blob/143d87e/deployment/README.md#upgrades) check the init-code hash. Deployments must pin their actual artifacts and check compatibility across upgrades. Proxies also expose their own local code and balance to introspection. See [Core §B.3](/core-protocol-spec#b3-crosschainproxysol) and [Caveats](/caveats). The [upgrade scripts](https://github.com/eez-association/eez-core-protocol/blob/143d87e/deployment/Upgrade.s.sol) check configured immutable values and manager proxy init-code hashes where applicable, but do not validate storage layouts. These are script checks: a ProxyAdmin owner can call `upgradeAndCall` directly without them. Review each authority, replacement implementation, migration, and address-continuity requirement; the Rollup's owner, keys, threshold, and registered ID are persistent state rather than implementation immutables. ## 11. Events, blobs and tooling ### Events and execution evidence To reconstruct execution, retain committed receipts, decoded posting/load inputs, deployment configuration and ordered table history. Reconstruction depends on which observations survive and which inputs are available: static paths emit no logs, and reverted frames erase theirs. Internal wrapper inputs or erased runtime details may require other evidence. Entry indices and nested call numbers repeat; a call hash or shared public input is not a unique occurrence ID. [Events and execution reconstruction](/events) defines the current event schema, emission order and reconstruction limits. The tracked [execution-log fixtures](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/ExecutionLogReconstruction.t.sol) cover top-level L1 entry mapping; [event-verifier fixtures](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/E2EEventVerification.t.sol) cover evidence matching. Neither establishes a general committed-receipt reconstruction algorithm. ### Blobs and table tooling EEZ commits selected blob hashes and batch calldata into the proof input. It does not parse the blob message stream inside the core contract. The prover must connect that data to valid execution. The [blob format](/blobs/blob-format-spec) describes version `00`, call/return messages, transaction boundaries, and snapshot/revert regions. The [blob/table framework](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/blob/README.md) translates scenarios into tables and back, then exercises managers with scripted actors. Its translator has narrower supported shapes than the byte codec. The [scenario catalog](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/blob/SCENARIO_CATALOG.md) provides concrete test tables. Cases 12, 13, and 15 deliberately attempt reverted L2 deliveries and advance synthetic roots to exercise rollback mechanics. With the current prover, the corresponding failed or subsequently reverted L1-to-L2 calls are lookups: no L2 delivery occurs and no L2 state or root change results from those calls. The synthetic harness behavior is not a production protocol conflict. The [compressed integer codec](/blobs/u256-compressed-codec) and [future blob optimizations](/blobs/future-optimizations) describe future format options; they are not the current wire format. ## 12. Contract map and source/test guide ### Contract map | Component | Responsibility | | --- | --- | | [EEZ.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/EEZ.sol) | L1 registry, roots, per-rollup ETH ledger, batch verification, queues, and execution. Start at `postAndVerifyBatch`. | | [EEZL2.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/L2/EEZL2.sol) | L2 table loading, incoming delivery, and proxy-driven execution. Start at `loadExecutionTable` and `executeIncomingCrossChainCall`. | | [EEZBase.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/base/EEZBase.sol) | Shared proxy creation/authorization, call hashes, rolling-hash helpers, gas estimates, and rollback-result decoding. | | [CrossChainProxy.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/base/CrossChainProxy.sol) | Represents a remote address locally; routes application calls into its manager and manager-driven calls into local targets. | | [ExpectedL1ToL2CallTransient.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/base/ExpectedL1ToL2CallTransient.sol) | Serializes the expected nested-call table of the leading immediate L2Tx entry currently executing. Meta-hook and deferred entries use their stored tables instead. | | [VerifiedRollupsTransient.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/base/VerifiedRollupsTransient.sol) | Holds the active L1 entry's allowed rollup set. A nonempty set also marks execution as active. | | [Rollup.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/rollupContract/Rollup.sol) | Reference policy contract for one rollup: owner, accepted proof systems, verification keys, threshold, L1 context, and root escape. | | [ECDSAProofSystem.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/proofSystems/ECDSAProofSystem.sol) | Temporary signature-based verifier. An authorized signer attests the raw public-input digest; this implementation does not prove EVM execution cryptographically. | | [EEZProxy.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/proxy/EEZProxy.sol) | Transparent upgradeable proxy used for EEZ and EEZL2. Their initial persistent state is zero and constructor configuration is held in implementation immutables, so these proxies use empty initialization data. | | [IEEZRegistry.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/interfaces/IEEZRegistry.sol) | Minimal `setRoot` interface used by the reference Rollup to call EEZ. | | [Deployment and upgrades](https://github.com/eez-association/eez-core-protocol/blob/143d87e/deployment/README.md) | Deploys EEZ and EEZL2 through EEZProxy, and Rollup through a standard TransparentUpgradeableProxy with atomic initialization. Integrations use the manager proxy addresses. | The data structures live in [IEEZ.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/interfaces/IEEZ.sol) and [IEEZL2.sol](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/interfaces/IEEZL2.sol). The extension boundaries are [IRollupContract](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/interfaces/IRollup.sol), [IProofSystem](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/interfaces/IProofSystem.sol), [IMetaCrossChainReceiver](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/interfaces/IMetaCrossChainReceiver.sol), and the proxy forwarding ABI [ICrossChainProxy](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/interfaces/ICrossChainProxy.sol). The contract interfaces define what the composer, prover and node supply. Their implementations determine how those responsibilities are fulfilled. ### Reading the implementation Read the interfaces, then CrossChainProxy and EEZBase, then follow `postAndVerifyBatch → _consumeAndExecuteEntry → _executeEntry → _processL2ToL1Calls` in EEZ. Follow `_consumeNestedCall`, `staticCrossChainCall`, and `executeInContextAndRevert` next. Compare those paths with EEZL2, then inspect the transient helpers, Rollup, and ECDSA verifier. | Topic | Useful tests / reference | | --- | --- | | Posting, proof subsets, queues, and immediate behavior | [EEZ tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZ.t.sol), [coverage cases](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZCoverage.t.sol), [immediate prefix](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZImmediatePrefix.t.sol), [empty expected tables](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZEmptyExpectedCalls.t.sol) | | Queue/table replacement and active bounds | [L1 queue reuse](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZQueueReuse.t.sol), [L2 table reuse](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZL2TableReuse.t.sol) | | L2 loading, consumption, and rollback | [EEZL2 tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZL2.t.sol), [L2 coverage cases](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZL2Coverage.t.sol), [recovery](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZL2Recovery.t.sol) | | Proxy identity, selector behavior, and forbidden destinations | [ProxyBehavior](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/ProxyBehavior.t.sol), [EEZDestination](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZDestination.t.sol) | | Static matching and gas policies | [static lookup](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZStaticLookup.t.sol), [L2 cursor pins](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZL2StaticCursor.t.sol), [L2 local writes](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZL2StaticLocalWrite.t.sol), [gas budgets](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/CallGasBudget.t.sol), [gas probes](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/GasProbe.t.sol) | | Supported retries and caught validation errors | [call-pattern limits](/call-pattern-limits), [EEZExpressiveness](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZExpressiveness.t.sol), [EEZIntermediateRevert](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZIntermediateRevert.t.sol), [builder/prover responsibilities](/integration-boundaries#builder-and-prover) | | Manual transient storage | [expected-call serializer](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/ExpectedL1ToL2CallTransient.t.sol), [allowed-rollup set](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/VerifiedRollupsTransient.t.sol) | | Registration, policy, and signatures | [registration](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/rollupContract/Registration.t.sol), [Rollup](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/rollupContract/Rollup.t.sol), [ECDSA](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/ECDSAProofSystem.t.sol), [block binding](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/BlockNumberBinding.t.sol) | | Deployment and upgrade authority, state, and address continuity | [deployment guide](https://github.com/eez-association/eez-core-protocol/blob/143d87e/deployment/README.md), [deployment tests](https://github.com/eez-association/eez-core-protocol/tree/143d87e/test/deployment) | | Execution evidence and occurrence reconstruction | [event reference](/events), [top-level mapping tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/ExecutionLogReconstruction.t.sol), [event-verifier tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/E2EEventVerification.t.sol) | | Paired chain behavior | [integration notes](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/INTEGRATION_TEST_NOTES.md), [E2E setup](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/README.md), [scenario review guide](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/BUILD_AND_REVIEW_E2E_TESTS.md) | ### Running tests [foundry.toml](https://github.com/eez-association/eez-core-protocol/blob/143d87e/foundry.toml) pins Solidity 0.8.34, via-IR, and optimizer runs of 1. Use the unit/integration command from the [README](https://github.com/eez-association/eez-core-protocol/blob/143d87e/README.md#build--test): ```bash forge build --offline FOUNDRY_ISOLATE=false forge test --offline --summary ``` The transient-storage harnesses require calls within one test to share a transaction. Keep this override local to that command; E2E execution and gas benchmarks that need separate transactions retain `--isolate`. Offline mode requires the compiler and dependencies to be installed already. The [runner guide](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/COMMANDS.md) covers devnet workflows. Local integration tests and mock verifiers exercise contract mechanics. Production cross-chain ordering and proving guarantees depend on the integrations described in [Integration Boundaries](/integration-boundaries). [Bridge](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/periphery/Bridge.sol), [WrappedToken](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/periphery/WrappedToken.sol), and [DeFi examples](https://github.com/eez-association/eez-core-protocol/tree/143d87e/src/periphery/defiMock) illustrate application flows only. These examples are separate from the protocol contracts. # Build with AI Give your coding agent the EEZ protocol docs. The EEZ Docs skill tells the agent which page answers which task, and to read the page instead of guessing signatures, hashes or thresholds. ## Install the EEZ Docs skill :::code-group ```bash [skills CLI] npx skills add https://docs.eez.io ``` ```bash [Manual] curl -fsSL https://docs.eez.io/SKILL.md --create-dirs -o .agents/skills/eez-docs/SKILL.md ``` ::: The skills CLI asks which agents to install into and supports most of them, including Codex, Cursor, GitHub Copilot and Claude Code. For a manual install, `.agents/skills/` is the shared project folder; if your agent reads skills from its own folder, save the file there instead. For an agent without skills, paste this at the start of a conversation: ```text Read https://docs.eez.io/SKILL.md and follow it for EEZ protocol questions. ``` :::note The skill is read-only. It explains the docs; it doesn't sign or send transactions. For live tooling, use [eez.dev](https://eez.dev). ::: ## Start a conversation Give the agent a concrete task and ask it to cite the pages it used: ```text Using the EEZ docs, explain what postAndVerifyBatch checks before an entry can execute. Cite the pages you used. ``` ## Read the docs as Markdown | URL | Contents | | --- | --- | | [`/SKILL.md`](https://docs.eez.io/SKILL.md) | The EEZ Docs skill: a page map and task routing | | [`/llms.txt`](https://docs.eez.io/llms.txt) | Every page with its title and description | | [`/llms-full.txt`](https://docs.eez.io/llms-full.txt) | The whole site in one file | | `/.md` | One page as Markdown, for example [`/core-protocol-spec.md`](https://docs.eez.io/core-protocol-spec.md) | The pages are synced from [`eez-core-protocol`](https://github.com/eez-association/eez-core-protocol) at a pinned commit. The skill names that commit, so an agent can check a contract detail against the Solidity source. # Core Protocol Specification This page specifies how the EEZ contracts post, verify and execute cross-chain entries: the data model, the core functions on L1 and L2, how call hashes and the rolling hash are computed, the entry lifecycle, and the invariants and security considerations behind them. It is the formal reference for implementing the Rust rollup node against the contracts in `src/`, and supersedes informal comments in the source code. **The model at a glance** * **Flat sequential calls.** Every cross-chain entry carries a flat list of top-level calls, processed in order. Each reentrant frame carries its own flat sub-array. * **One reentrant table.** Reentrant calls resolve against a single expected-calls table, content-addressed by the live rolling hash. * **One rolling hash per entry.** `rollingHash` checks the surviving recorded observations. * **Per-rollup queues.** On L1, entries are routed by `destinationRollupId` into per-rollup queues. * **Atomic multi-prover verification.** `postAndVerifyBatch` carries a single `ProofSystemBatchPerVerificationEntries` whose proofs verify atomically. Batch shape, per-PS public inputs and thresholds are in the [Multi-Prover Specification](/multi-prover-spec). :::note The rolling hash does not directly hash all local state changes or successful nested saved answers. See §H.4 and [call-pattern limits](/call-pattern-limits). ::: **Naming per side.** L1 uses absolute directional names. L2 names are self-relative because an L2's counterparty can be L1 or another L2 (§A.1). | | L1 · `src/interfaces/IEEZ.sol` | L2 · `src/interfaces/IEEZL2.sol` | |---|---|---| | Call | `L2ToL1Call` / `l2ToL1Calls` | `CrossChainCall` / `incomingCalls` | | Expected reentrant call | `ExpectedL1ToL2Call` / `expectedL1ToL2Calls` | `ExpectedOutgoingCrossChainCall` / `expectedOutgoingCalls` | *** ## Settlement Model Proofs authorize entries subject to their execution checks. L1 execution determines which eligible entries settle and in what order. Proof acceptance alone does not settle deferred entries or guarantee a particular final L1 state. Deferred entries use separate queues for each destination rollup. Entries in different queues can execute in either order when their checks pass. The proof commits to the batch array, but array position does not impose a settlement order across these queues. This is the protocol's execution model. Root checks enforce each rollup's state requirements. Calls within one entry follow their recorded order, and the rolling hash checks their recorded results. Applications that require an order between entries or a condition on other L1 state must enforce it at execution time. ## A. Data Model ### A.1 Core Structs The execution structs come in **two layout-similar but separately-declared families**: * **L1 (`src/interfaces/IEEZ.sol`)** — absolute directional names: an `L2ToL1Call` is a cross-chain call executed on L1; an `ExpectedL1ToL2Call` is a reentrant L1→L2 call fired during execution. * **L2 (`src/interfaces/IEEZL2.sol`)** — **self-relative** directional names: an `incomingCalls[]` entry is a call executed ON this L2 on behalf of a remote caller; an `expectedOutgoingCalls[]` entry is the pre-computed result of a reentrant call fired FROM this L2. The naming is relative to the chain itself because an L2's counterparty can be L1 (mainnet) OR another L2 — absolute names like `l1ToL2Calls` would frequently be wrong. The L2 structs are deliberately **leaner**: L2 has a single rollup, no state deltas, and no per-rollup queue interleaving, so the L1-only fields (`RollupUpdate[] rollupUpdates`, `destinationRollupId`, `ExpectedRootPerRollup[] expectedRoots`) do not exist on L2 at all. L2 never hashes a whole entry/static entry, so its layout is free to diverge from L1's. #### Cross-chain call hash (off-chain helper) `computeCrossChainCallHash(...)` — `public pure` on `EEZBase`, inherited by both managers. Formula, matching-site table, and off-chain helpers in §C. #### RollupUpdate (L1 only) Describes one rollup's state transition caused by executing one entry. The pre-state binding lives on the entry: `currentRoot` is checked at consumption time against `rollups[delta.rollupId].root`; a mismatch skips a queued candidate (`ExecutionNotFound` if none matches), while direct immediate execution reverts `RootMismatch`. Root checks enforce each rollup's state requirements. They do not determine the execution order across different rollup queues or check other L1 contract state. Every entry must carry **at least one** `RollupUpdate`, and the deltas must be **strictly increasing by `rollupId`** — both enforced **on-chain** at batch validation (`EntryHasNoRollupUpdates`, `RollupUpdatesNotStrictlyIncreasing`; the ≥1 check is re-asserted in `_executeEntry`). An empty `rollupUpdates[]` would leave the entry unpinned from any rollup trajectory (nothing for the `RootMismatch` backstop to check); strict ordering rejects duplicate rollups and (starting above `MAINNET_ROLLUP_ID`) a mainnet delta. The prover remains responsible for `rollupUpdates` being the entry's *true* state transition. ```solidity struct RollupUpdate { uint64 rollupId; // which rollup's state changes int192 etherDelta; // signed change in rollup's ETH balance; packs with rollupId into one slot bytes32 currentRoot; // expected pre-root on rollupId — checked against rollups[rid].root bytes32 newRoot; // post-execution root } ``` #### L2ToL1Call (L1) / CrossChainCall (L2) A single call in a flat call array — the entry's top-level `l2ToL1Calls[]` / `incomingCalls[]`, or a reentrant frame's own sub-array, or a static entry's read-only sub-calls. The two structs are field-identical; only the type name differs. ```solidity // L1 (IEEZ.sol): L2ToL1Call — L2 (IEEZL2.sol): CrossChainCall, same fields struct L2ToL1Call { uint16 revertNextNCalls; // >0 force-reverts the next N calls (this one included); see §D.4 bool isStatic; // dispatch via STATICCALL (read-only, no value); see §D.2 uint64 gas; // gas limit for the target call; 0 = forward all remaining gas address sourceAddress; // originating address on the source rollup uint64 sourceRollupId; // originating rollup address targetAddress; // call target on this chain uint256 value; // ether to send (must be 0 when isStatic) bytes data; // calldata } ``` #### ExpectedL1ToL2Call (L1) / ExpectedOutgoingCrossChainCall (L2) One **unified reentrant table** per entry holds every kind of reentrant cross-chain call fired during execution — plain SUCCESS, read-only STATIC, and try/catch'd REVERTED (`success == false`). Each row is content-addressed by a single position key: ```solidity expectedL1toL2Hash == keccak256(abi.encodePacked(crossChainCallHash, expectedRollingHash)) // L2 field name: expectedOutgoingHash — same formula (_computeExpectedL1toL2Hash on EEZBase) ``` `crossChainCallHash` folds `isStatic` (a static read keys distinctly from a state-changing call) plus the routed rollup, so neither needs its own field; `expectedRollingHash` is the live `_rollingHash` at the instant the call fires, which binds the recorded execution context. Local writes and reverted frames can leave the same key available for another call. ```solidity // L1 (IEEZ.sol): ExpectedL1ToL2Call — L2 (IEEZL2.sol): ExpectedOutgoingCrossChainCall struct ExpectedL1ToL2Call { bytes32 expectedL1toL2Hash; // position key: keccak256(crossChainCallHash, expectedRollingHash) L2ToL1Call[] l2ToL1Calls; // the reentrant frame's OWN sub-calls, processed in order (subject to gas checks) bytes32 revertedOrStaticRollingHash; // expected sub-call rolling hash: checked for STATIC / REVERTED; must be 0 on SUCCESS rows (prover constraint, §A.1) bool success; // whether the reentrant call returns or reverts bytes returnData; // pre-computed return value (revert payload when !success) } ``` Every kind carries its **own** `l2ToL1Calls[]` sub-array, processed in order (subject to gas checks) — there is no shared flat-array partition and no `callCount` field. Resolution: * **SUCCESS** (`success == true`, matched via `_consumeNestedCall` with an `isStatic == false` key): `_resolveNestedReentrant` runs the sub-array as a COMMITTING sub-execution, folding into the host's continuous `_rollingHash` between `NESTED_BEGIN` / `NESTED_END`, then returns `returnData`. `revertedOrStaticRollingHash` is required to be zero on this path (`SuccessRowWithRevertedOrStaticHash` otherwise). * **REVERTED** (`success == false`, same key shape): `_resolveNestedReentrant` runs the sub-array as a mini-entry, checks the resulting `_rollingHash` against `revertedOrStaticRollingHash`, then reverts with `returnData` — the terminal revert rolls back the frame's state, hash, and cursor. * **STATIC** (key computed with `isStatic == true`, matched via `staticCrossChainCall`): `_resolveStaticEntry` runs the sub-array via STATICCALL (untagged hash, §E.2) against `revertedOrStaticRollingHash` and returns `returnData` (reverts with it when `!success`). A reverted sub-execution reuses the host table for its own reentrant calls (Solidity forbids recursive structs); the live `_rollingHash` folded into each key keeps the contexts distinct. #### ExecutionEntry The central execution unit. Loaded by `postAndVerifyBatch` (L1) or `loadExecutionTable` / `executeIncomingCrossChainCall` (L2). The two sides declare separate structs: ```solidity [L1 · IEEZ.sol] struct ExecutionEntry { RollupUpdate[] rollupUpdates; // the entry's true state transition (≥1, enforced on-chain) bytes32 proxyEntryHash; // inbound proxy-entry call hash; bytes32(0) = L2Tx entry L2ToL1Call[] l2ToL1Calls; // the entry's TOP-LEVEL calls (reentrant frames carry their own) ExpectedL1ToL2Call[] expectedL1ToL2Calls; // unified reentrant table; see above bytes32 rollingHash; // expected rolling hash over all calls + nestings uint64 destinationRollupId; // routes to a per-rollup queue; must be ∈ rollupUpdates bool success; // whether the entry returns or reverts bytes returnData; // pre-computed top-level return value (revert payload when !success) } ``` ```solidity [L2 · IEEZL2.sol] // no rollupUpdates, no destinationRollupId struct ExecutionEntry { bytes32 proxyEntryHash; // hash of the inbound call — never bytes32(0) on L2 (no zero-hash consumption path; executeL2Txs is L1-only) CrossChainCall[] incomingCalls; // the entry's TOP-LEVEL calls ExpectedOutgoingCrossChainCall[] expectedOutgoingCalls; // unified reentrant table bytes32 rollingHash; bool success; bytes returnData; } ``` A top-level entry carries a `success` flag. When `success == true`, the entry runs, verifies, and the top-level call returns `entry.returnData`. When `success == false`, the entry is run and verified (rolling hash + ether invariant) exactly as a successful one, then **reverted with `entry.returnData`** so all of its state effects roll back — the top-level call fails and the caller may try/catch it. Because the revert also rolls back the cursor advance, a `success == false` entry is re-callable; the forward-scan consumption (§D.1) reaches past it once a later successful consumption advances beyond it. A top-level reverting *read* is a `StaticExecutionEntry` instead. #### Prover constraints These obligations include remote truth, canonical encoding and funding policy. Local structural checks cannot establish circuit soundness or remote execution truth. Where a cheap local check exists the contracts check defensively (error in parentheses); other guarantees depend on the actual circuit/system implementation. L1: * `rollupUpdates` are the entry's TRUE state transition. On a `success == false` entry, `newRoot` is written then unwound and never compared — supplied for completeness. *Circuit-only.* * L2Tx entries (`proxyEntryHash == bytes32(0)`): `success == true`, empty `returnData` — no proxy consumer. *(Defensive check at batch validation: `L2TxEntryNotCanonical` — an unchecked queued violator would be a poison entry, its revert keeping its pins live so every later `executeL2Txs(rid)` re-matches it that block.)* L2: * `executeIncomingCrossChainCall`: `entries[0].incomingCalls[0]` equals the call that actually arrived from the source rollup — a cross-chain fact, *circuit-only*. Its `gas` is bound on-chain (folded into `proxyEntryHash`); the rest of `entries[0]` stays fully general — `success == false` reverts the whole delivery, and `revertNextNCalls` on the inbound call rolls back its destination effects while the delivery commits. * System funding must back the executed incoming transfers. The contract pools `msg.value`, permits partial consumption, and retains balance when tables are replaced; it does not enforce a per-load equality to committed or consumed values. The node/circuit must define aggregate inventory, funding and residual handling across loads/reverts before claiming conservation. See [L2 funding and inventory](#l2-funding-and-inventory). * **Lookups are never delivered.** L2 state and the L2 root change only for cross-chain work L1 can prove it executed. Three cases are *lookups*: * a top-level static read * an L1→L2 call that reverts on L2 * an L1→L2 call whose L1 frame is reverted afterwards For a lookup, the composer simulates the call and the prover signs its return or revert data (the L1 entry's `returnData`, and the blob so the user can read it). Nothing is applied on L2, the root does not move, and the sync block has no `executeIncomingCrossChainCall` tx for it. The rule is uniform: no root-only checkpoint or synthetic entry catches a root up after a failed delivery. On L1 the matching `success == false` entry unwinds its root update with its revert (above), so both sides agree the root did not move. * **Direction-specific:** an L2→L1 call that reverts on L1 *is* delivered. The L2Tx entry runs it on L1 and `CALL_END(false, revertData)` records the revert, because L1 executes it itself. *Composer / circuit rule.* Both sides: * SUCCESS rows of the unified reentrant table carry `revertedOrStaticRollingHash == bytes32(0)` — the field is an expected-hash target only on the STATIC / REVERTED paths; SUCCESS resolution checks that it is zero. *(Defensive check at resolution: `SuccessRowWithRevertedOrStaticHash`.)* * **Static alternatives per key.** Static reads change neither `_rollingHash` nor the cursor. Multiple rows may share a key: lookup tries each candidate until its callback-result hash matches. Builders must provide/prove the required alternatives and reject incompatible outcomes when multiple candidates can validate; the first hash-valid row wins. *Circuit / builder rule.* See [STATIC_ENTRY §4.4](/static-entry#44-local-writes-between-identical-static-reads) for all four local-write cases, candidate construction, retry outcomes and E2E evidence. Do not deduplicate static rows by key alone; skipped candidates execute local callback validation but do not create additional logical remote frames. * Static sub-calls carry `revertNextNCalls == 0` — static resolution has no state to roll back. *(Defensive check in `_processStaticL2ToL1Calls`: `StaticCallWithRevertSpan`.)* * Tables contain exactly the rows/entries the simulated trace resolves; anything beyond is inert padding. *Circuit-only.* There is no `callCount` field and no flat-array partition: `_processL2ToL1Calls` (L1) / `_processIncomingCalls` (L2) runs each frame's whole array, so call ordering is structural; gas shortage can terminate the current local array (§D.2/§H.4). #### ExpectedRootPerRollup (L1 only) A root pin. Two uses: 1. On a `StaticExecutionEntry`: content-addresses the static entry to a point on each pinned rollup's trajectory — a candidate only MATCHES when every pin equals the live `rollups[rollupId].root` (full-scan semantics: a mismatching candidate is skipped, not reverted on). 2. On the batch (`batch.expectedRootPerRollup`): optional composer-supplied assertions checked at the very top of `postAndVerifyBatch`; any mismatch reverts `ExpectedRootMismatch(rid)`. ```solidity struct ExpectedRootPerRollup { uint64 rollupId; bytes32 root; } ``` #### StaticExecutionEntry (TOP-LEVEL static entries — storage pool) Pre-computed result for a **top-level read-only** cross-chain call, resolved via `staticCrossChainCall` OUTSIDE any execution, from the pool (`_transientStaticEntries` / per-rollup `staticEntryQueue` on L1; the active prefix of the `staticEntries` mapping on L2). Reverting top-level reads land here (`success == false` ⇒ resolution reverts with `returnData`); state-changing top-level calls are `ExecutionEntry`s. There is no reentrant table — a reentrant read during resolution re-enters the pool as ANOTHER `StaticExecutionEntry`. Referenced proxies must already be deployed (CREATE2 is unavailable inside a STATICCALL frame). ```solidity [L1 · IEEZ.sol] struct StaticExecutionEntry { ExpectedRootPerRollup[] expectedRoots; // root pins — part of the MATCH predicate bytes32 proxyEntryHash; // inbound proxy-entry call hash (isStatic = true in the preimage) L2ToL1Call[] l2ToL1Calls; // read-only sub-calls run via STATICCALL during resolution bytes32 rollingHash; // untagged static schema: keccak256(prev, success, retData) uint64 destinationRollupId; // routes the pool entry; must be ∈ expectedRoots bool success; // false ⇒ resolution reverts with returnData bytes returnData; } ``` ```solidity [L2 · IEEZL2.sol] // no expectedRoots, no destinationRollupId struct StaticExecutionEntryL2 { uint256 expectedEntryIndex; // must equal the live next-entry cursor bytes32 proxyEntryHash; CrossChainCall[] incomingCalls; bytes32 rollingHash; bool success; bytes returnData; } ``` Match: `proxyEntryHash` + `destinationRollupId` + all root pins live on L1 (full scan); `proxyEntryHash` + `expectedEntryIndex == entryIndex` on L2. #### ProxyInfo ```solidity struct ProxyInfo { bool isProxy; // existence flag, set on registration address originalAddress; uint64 originalRollupId; } ``` #### RollupConfig (L1 only) ```solidity struct RollupConfig { address rollupContract; // per-rollup IRollupContract-conforming manager (owner / vkeys / threshold live there); immutable after registration bytes32 root; // current committed root uint256 etherBalance; // ETH held on behalf of this rollup } ``` The central registry holds no owner or vkey — those live on each rollup's `rollupContract` (reference impl: `src/rollupContract/Rollup.sol`). See `MULTI_PROVER_SPEC.md` for the per-rollup-manager model. #### RollupVerification (L1 only) Per-rollup entry queue, static-entry queue, cursor, and verified-this-block marker. ```solidity struct RollupVerification { uint64 lastVerifiedBlock; uint64 entryQueueIndex; // consumption cursor uint64 entryQueueLength; // active execution entries / next append index uint64 staticEntryQueueIndex; // active static entries / next append index // All four uint64 counters share one storage slot. mapping(uint256 => ExecutionEntry) entryQueue; mapping(uint256 => StaticExecutionEntry) staticEntryQueue; } ``` Queue resets zero the active bounds and consumption cursor without deleting mapping entries. Appends reuse slots from index zero; lookups stop at the active bounds, so retained entries cannot be consumed. Replacing an entry copies all its fields; Solidity may still clear truncated nested arrays or bytes during that assignment. ### A.2 Storage Layout #### EEZ.sol (L1) — inherits `EEZBase` | Variable | Type | Notes | |----------|------|-------| | `rollupCounter` | uint64 | Last assigned rollup ID (`registerRollup` assigns `++rollupCounter`) | | `rollups` | mapping(uint64 ⇒ RollupConfig) | Per-rollup config (manager pointer + root + ether) | | `verificationByRollup` | internal mapping(uint64 ⇒ RollupVerification) | Per-rollup deferred queues + cursor (public views: `lastVerifiedBlock(rid)`, `queueLength(rid)`, `entryQueueIndex(rid)`) | | `_transientEntries` | mapping(uint256 ⇒ ExecutionEntry) (internal) | Ordinary storage meta-hook entries; each used slot is deleted after the batch | | `_transientStaticEntries` | mapping(uint256 ⇒ StaticExecutionEntry) (internal) | Ordinary storage TOP-LEVEL static entries for the meta-hook phase; each used slot is deleted after the batch | | immediate-L2Tx reentrant table | EIP-1153 transient region (`ExpectedL1ToL2CallTransient`) | Reentrant table of the ONE immediate L2Tx entry currently executing — hand-serialized into transient slots (`src/base/ExpectedL1ToL2CallTransient.sol`) because the immediate run executes entries straight from calldata (never SSTOREd whole) and a proxy re-entry crosses an external boundary; written before `_executeEntry`, cleared right after (a revert rolls it back) | | verified-rollup set | EIP-1153 namespaced count and ID slots (`VerifiedRollupsTransient`) | `_pushVerifiedRollup` appends the entry's allowed rollup IDs; `_clearVerifiedRollups` zeroes the count, hiding stale tail slots. `_verifiedRollupCount() != 0` means executing; `_containsVerifiedRollup` checks membership | | `_transientEntriesLength` / `_transientStaticEntriesLength` | `uint256 transient` | Active batch-scoped mapping bounds; the execution length flags the meta-hook reentry window; both reset after each batch | | `_transientEntryIndex` | `uint256 transient` | Global cursor into `_transientEntries` (cross-rollup, intra-`postAndVerifyBatch`) | | `_currentEntryRollupId` | `uint64 transient` | Rollup whose persistent queue supplies the executing entry (so `_getExpectedL1toL2Calls()` finds the right queue); set only by `_consumeAndExecuteEntry`'s persistent branch, `0` everywhere else | | `_lastL1ToL2CallConsumed` | `uint256 transient` | Forward-scan position into the entry's unified `expectedL1ToL2Calls[]` | | `_entryEtherDelta` | `int256 transient` | Net ether flow for the current entry (`Σ inbound msg.value − Σ successful outbound call value`); the accounting side of the ether-delta invariant (§H.2). Top-level entry points SET it (`executeCrossChainCall`) or require it zero (`executeL2Txs` / `_attemptExecuteImmediateL2Txs` revert `ResidualEntryEtherIn`); reentrant calls ADD; successful value calls SUBTRACT; reset at the end of `_executeEntry`. **L1 only** | Inherited from `EEZBase`: | Variable | Type | Notes | |----------|------|-------| | `authorizedProxies` | mapping(address ⇒ ProxyInfo) | Registered proxies | | `_rollingHash` | `bytes32 transient` | Rolling hash accumulator | | `_currentEntryIndex` | `uint256 transient` | Index of the entry being executed (into `_transientEntries`, the persistent queue, or L2's `entries`) | There is **no transient flat-call cursor**: the position inside a flat call array is a plain local index in `_processL2ToL1Calls` (L1) / `_processIncomingCalls` (L2) (it survives reentrant proxy calls on the call stack), and each frame carries its own array. `MAINNET_ROLLUP_ID = 0` is a constant (uint64). The six rolling-hash tag constants `CALL_BEGIN=1, CALL_END=2, NESTED_BEGIN=3, NESTED_END=4, CALL_NOT_FOUND=5, CALL_INSUFFICIENT_GAS=6` plus the readability constants `NOT_STATIC_CALL = false` / `IS_STATIC = true` are internal constants on `EEZBase`. There is no `_inPostBatch` flag — `postAndVerifyBatch` reentry is detected via `_insideExecution() || _transientEntriesLength != 0`. `_transientEntries` / `_transientStaticEntries` are declared as regular storage mappings (not Solidity `transient`) because the `transient` data location does not yet support reference types with nested dynamic arrays; each used mapping entry is deleted at the end of every `postAndVerifyBatch`. Those storage clears can earn refunds, capped at 20% of transaction gas and applied after execution. Both transient length counters and the cursor are explicitly reset after the batch so another batch can run in the same transaction. The verified-rollup set and immediate-L2Tx reentrant table instead use EIP-1153 transient storage. The latter lives in a hand-serialized EIP-1153 transient region (`src/base/ExpectedL1ToL2CallTransient.sol`), so it never touches storage and rolls back with reverts automatically. #### EEZL2.sol (L2) — inherits `EEZBase` L2-specific storage: | Variable | Type | Notes | |----------|------|-------| | `entries` | mapping(uint256 ⇒ ExecutionEntry) (public) | Reusable execution slots; only indices below `entriesLength` are active | | `staticEntries` | mapping(uint256 ⇒ StaticExecutionEntryL2) (public) | Reusable static slots; only indices below `staticEntriesLength` are active | | `lastLoadBlock` | uint256 | Block of last `loadExecutionTable` / `executeIncomingCrossChainCall` | | `entryIndex` | uint256 | Cursor into the active execution prefix | | `entriesLength` / `staticEntriesLength` | uint256 | Active table bounds, reset on every load; retained slots beyond them cannot be consumed | | `_executing` | `bool transient` | Set at `_executeEntry` start, cleared at its end; backs `_insideExecution()` (L1 derives the same predicate from its proxy-protection array, which L2 doesn't have) | | `_lastOutgoingCallConsumed` | `uint256 transient` | Forward-scan position into the entry's unified `expectedOutgoingCalls[]` | The shared rolling-hash transient fields, proxy registry, and base-event/error set are inherited from `EEZBase`. Immutables: `ROLLUP_ID` (non-zero; 0 reverts `InvalidRollupId` — it is the mainnet sentinel in call hashes), `SYSTEM_ADDRESS` (not validated; `address(0)` is accepted for chains whose system caller is the zero address), and `USE_GAS_LEFT` (`callGas` in outgoing hashes: `gasleft()` when true, `0` when false). `RECOVERY_ADDRESS` is a separate nonzero immutable, configured by the fourth constructor argument; zero reverts `InvalidRecoveryAddress`, as on L1. Proxy construction sweeps prefunded ETH to this address. Outgoing call value still goes to `SYSTEM_ADDRESS`. The constructor is `EEZL2(uint64 rollupId, address systemAddress, bool useGasLeft, address recoveryAddress)`. Indexed mapping getters may expose retained inactive rows; callers must use the active lengths. ### A.3 Transient Variables and `_insideExecution` `_insideExecution()` is `_verifiedRollupCount() != 0` on L1 (every entry has ≥1 delta; namespaced transient ID slots are populated at `_executeEntry` start and their count is cleared at its end) and the transient `_executing` flag on L2. A revert anywhere rolls the marker back automatically. The transient variables are read by `executeCrossChainCall` (to route to `_consumeAndExecuteEntry` vs `_consumeNestedCall`) and by `staticCrossChainCall` (to compute the position key `keccak256(crossChainCallHash, _rollingHash)`). `tload` works in static context, so `staticCrossChainCall` can read them. They are reset implicitly between transactions by EIP-1153. *** ## B. Core Protocol Functions ### B.1 EEZ.sol (L1) #### `registerRollup` ```solidity function registerRollup(address rollupContract, bytes32 initialRoot) external returns (uint64 rollupId) ``` No registry-wide admission allowlist; each manager authorizes its own registrant. EEZ forwards its original `msg.sender` as `registrant`, and the reference Rollup requires that address to equal its current owner (otherwise `UnauthorizedRegistrantAccount(registrant)`). A rejected callback rolls back the counter and initial state. `rollupId = ++rollupCounter` (a `uint64` counter; first assigned id is 1; 0 is `MAINNET_ROLLUP_ID`); `rollups[rollupId] = RollupConfig(rollupContract, initialRoot, 0)`. Reverts `InvalidRollupContract` if `rollupContract` is `address(0)` or the registry itself. Fires `IRollupContract(rollupContract).rollupContractRegistered(rollupId, registrant)` so the manager learns its id (one-shot init callback; the reference manager latches and reverts `AlreadyRegistered` on reuse). The caller is expected to deploy their `IRollupContract`-conforming manager with its verification policy and ownership initialized before calling `registerRollup`. Emits `RollupCreated(rollupId, rollupContract, rollups[rollupId].root)` using the stored root after the registration callback. #### `postAndVerifyBatch` ```solidity function postAndVerifyBatch(ProofSystemBatchPerVerificationEntries calldata batch) external ``` Permissionless. A single struct (NOT an array) carries: ```solidity struct ProofSystemBatchPerVerificationEntries { ExpectedRootPerRollup[] expectedRootPerRollup; // optional composer assertions; mismatch reverts ExecutionEntry[] entries; // execution entries StaticExecutionEntry[] staticEntries; // top-level static entries uint256 immediateEntryCount; // leading prefix executed this tx (immediate L2Txs + meta-hook entries), not queued uint256 immediateStaticEntryCount; // leading static entries resolvable this tx via the meta hook, not queued address[] proofSystems; // strictly increasing, no address(0) RollupIdWithProofSystems[] rollupIdsWithProofSystems; // strictly increasing by rollupId uint256[] blobIndices; // tx-level EIP-4844 blobs this batch consumes bytes callData; // batch-scoped calldata bytes[] proofs; // one proof per proofSystems entry uint64 blockNumber; // block binding: 0 = none, type(uint64).max = latest bool bindMsgSenderInPublicInput; // true = fold msg.sender into the public input (front-run protection); false = fold address(0) } struct RollupIdWithProofSystems { uint64 rollupId; uint64[] proofSystemIndexes; // strictly-increasing indices into the batch's proofSystems[] } ``` `immediateEntryCount` / `immediateStaticEntryCount` are **unproven dispatch params** — not folded into the public input, so the immediate/persistent split can be re-tuned within the structural constraints without re-proving. The batch-level `expectedRootPerRollup` assertions are also outside the commitment; entry-level root fields remain committed. `blockNumber` is forwarded to every rollup's `getCustomData(blockNumber)`. See `MULTI_PROVER_SPEC.md` for the multi-prover model. **Preconditions** (composer pins + `_validateBatchStructure`, no external calls): * Reentry guard: `_insideExecution() || _transientEntriesLength != 0` reverts `PostBatchReentry`. * Every `batch.expectedRootPerRollup` pin equals the live root, else `ExpectedRootMismatch(rid)`. * `proofSystems.length != 0`, `proofSystems.length == proofs.length`, `rollupIdsWithProofSystems.length != 0`, else `InvalidProofSystemConfig`. * `proofSystems[]` strictly increasing by address (rejects duplicates + `address(0)`), else `DuplicateProofSystem(ps)`. * `rollupIdsWithProofSystems[]` strictly ascending by `rollupId`, each `> MAINNET_ROLLUP_ID`, each registered; each row's `proofSystemIndexes[]` non-empty, strictly ascending, all in `[0, proofSystems.length)` — else `InvalidProofSystemConfig`. * **Per entry (proxy protection)**: `rollupUpdates.length >= 1` (`EntryHasNoRollupUpdates`); deltas strictly increasing by rollupId (`RollupUpdatesNotStrictlyIncreasing`); every delta rollup in the batch's rollup set (`RollupNotInBatch`); `destinationRollupId` ∈ the entry's own deltas (`EntryDestinationNotInRollupUpdates`); every call SOURCE — top-level calls plus each reentrant frame's sub-calls — ∈ the entry's deltas (`CallSourceNotVerified`). Reentrant TARGETS carry no clear-text rollup field, so they stay a runtime check (§B.1 `_consumeNestedCall`). * **L2Tx canonicality**: entries with `proxyEntryHash == 0` must have `success == true` and empty `returnData`, else `L2TxEntryNotCanonical(i)`. * **Per static entry**: `expectedRoots` pins strictly increasing (`ExpectedRootsNotStrictlyIncreasing`), each ∈ batch (`RollupNotInBatch`); `destinationRollupId` ∈ its own pins (`StaticEntryDestinationNotPinned`); every sub-call source ∈ the pins (`CallSourceNotVerified`). * `immediateEntryCount <= entries.length` else `ImmediateCountExceedsEntries`; `immediateStaticEntryCount <= staticEntries.length` else `ImmediateStaticEntryCountExceedsStaticEntries`. (A non-zero `immediateStaticEntryCount` additionally requires the meta hook to fire — checked after the immediate L2Tx run in step 6, `ImmediateStaticEntriesWithoutImmediateEntries`.) * If `entries[immediateEntryCount]` exists and has `proxyEntryHash == 0`, revert `ImmediateCountStrandsLeadingL2Tx` — protects honest composers against a poster under-counting the unproven `immediateEntryCount` and stranding a proven leading L2Tx into the queue. **Per-PS public-inputs construction** (two-stage; see `MULTI_PROVER_SPEC.md` for details): ```text entryHashes[i] = keccak256(abi.encode(batch.entries[i])) staticEntryHashes[i] = keccak256(abi.encode(batch.staticEntries[i])) blobHashes[i] = blobhash(batch.blobIndices[i]) customDataHashes[r] = keccak256(abi.encode(rollupId_r, customData_r)) boundSender = batch.bindMsgSenderInPublicInput ? msg.sender : address(0) sharedPublicInput = keccak256(abi.encodePacked( abi.encode(entryHashes), abi.encode(staticEntryHashes), abi.encode(blobHashes), keccak256(batch.callData), abi.encode(customDataHashes), boundSender )) for each PS k in proofSystems: acc_k = bytes32(0) for each rollup r (rollupId-ascending) where k ∈ rollupIdsWithProofSystems[r].proofSystemIndexes: acc_k = keccak256(abi.encode(acc_k, rollupId_r, verificationKeysPerRollup[r][j])) publicInputsHash[k] = keccak256(abi.encodePacked(sharedPublicInput, acc_k)) ``` `customData_r` is the opaque per-rollup blob fetched ONCE via `IRollupContract(rollupContract).getCustomData(batch.blockNumber)` (rollup-defined L1-view commitment; the reference `Rollup` returns ABI-encoded `(timestamp, blockHash)` — an empty blob for `blockNumber == 0`, `(block.timestamp, blockhash(block.number - 1))` for `type(uint64).max`, `(0, blockhash(blockNumber))` for a specific block, reverting `BlockHashUnavailable` when the hash cannot be resolved). Each blob is hashed keyed by its `rollupId` and the array binds into `sharedPublicInput`. It does NOT vary per PS, so it is folded ONCE into the shared input rather than into each per-PS `acc_k`. `boundSender` binds the submitter: with `batch.bindMsgSenderInPublicInput = true` the proof commits to the exact `msg.sender` allowed to land the batch (front-run protection for the meta-hook / AA bundle); with `false` it commits to `address(0)` and anyone may submit. Each PS's `verify(proofs[k], publicInputsHash[k])` must return `true`. A `false` return reverts with `InvalidProof`; a verifier revert propagates its original error. For example, malformed reference ECDSA signatures can raise length/high-s errors, whereas a well-formed wrong-signer signature returns false. Both rejection paths unwind the whole batch atomically. **State transitions** (in order; see `postAndVerifyBatch` in `src/EEZ.sol`): :::steps ##### Reentry check `if (_insideExecution() || _transientEntriesLength != 0) revert PostBatchReentry()`. ##### Composer pins + structural validation `_validateBatchStructure(batch)`, no external calls. ##### Fetch + verify `_getVerificationKeysPerRollup(batch)` calls each rollup's manager via `IRollupContract.checkProofSystemsAndGetVkeys(subset)` (a `view` call; the manager enforces threshold + per-PS membership internally and must return exactly one vkey per subset entry, else `InvalidProofSystemConfig`). Then `_verifyProofSystemBatch(batch, vkMatrix)` computes the public inputs and calls `IProofSystem.verify(...)` for each PS. ##### Mark verified-this-block `_markVerifiedBlockAndResetQueues(rid)` for each rollup: sets `verificationByRollup[rid].lastVerifiedBlock = block.number` AND **resets the rollup's `entryQueueLength` / `staticEntryQueueIndex` / `entryQueueIndex` on EVERY verify without deleting entries** — a same-block re-verify REPLACES (does not append to) the prior batch's entries for that rollup. Done before any non-view external CALL so (a) `executeCrossChainCall` / `executeL2Txs(rid)` work during the meta hook, and (b) `setRoot` is locked out until the next block. ##### Drain the leading run of immediate L2Txs straight from calldata While `i < immediateEntryCount` and `batch.entries[i].proxyEntryHash == bytes32(0)`, self-call `try this._attemptExecuteImmediateL2Txs(batch.entries[i]) catch { emit L2TxSkipped(i, revertData); }` and advance — a skip is not a hard error, but if at least one L2Tx was attempted and NONE succeeded, revert `AllImmediateL2TxsFailed` (a fully-failed immediate prefix unwinds the whole post). Empty revert data from the self-call aborts the post with `ImmediateL2TxOutOfGas(i)` before emitting a skip; empty data alone does not prove out-of-gas. Any outer revert, including `AllImmediateL2TxsFailed`, discards earlier skip logs. These entries are never SSTOREd whole; only each entry's reentrant table is parked in the `ExpectedL1ToL2CallTransient` transient region for the duration of that entry. ##### Meta hook If entries remain in the immediate prefix past the leading L2Tx run (`i < immediateEntryCount`), `msg.sender` must have code to receive the hook (revert `MetaEntriesWithoutReceiver` otherwise — the never-persisted prefix would be silently dropped): copy `entries[i..immediateEntryCount)` into `_transientEntries` and `staticEntries[0..immediateStaticEntryCount)` into `_transientStaticEntries`, incrementing their transient lengths from zero, then call `IMetaCrossChainReceiver(msg.sender).executeMetaCrossChainTransactions()`. The hook may invoke `executeCrossChainCall` and `executeL2Txs(rid)` to consume the transient stream. The static prefix is a companion of the META-HOOK entries, not of the immediate prefix as a whole: it loads only when the hook fires. If no hook fires (empty immediate prefix, or one that is pure L2Txs) a non-zero `immediateStaticEntryCount` would drop the leading static entries (never loaded transiently, never queued), so the post reverts `ImmediateStaticEntriesWithoutImmediateEntries` — composers set it to 0 in that shape (see `STATIC_ENTRY.md` §6). ##### Publish the remainder `_saveRemainderEntries(batch)`: push `entries[immediateEntryCount..]` into `verificationByRollup[entry.destinationRollupId].entryQueue` and `staticEntries[immediateStaticEntryCount..]` into the matching `staticEntryQueue`, **unconditionally** (even if the meta hook left transient entries unconsumed — there is no drain-cleanly gate). Each entry's `RollupUpdate.currentRoot` is checked at consumption time. A later entry is blocked if a skipped transition leaves a required rollup root unmatched. Dependencies on other L1 state need their own checks. ##### Cleanup transient tables Delete every `_transientEntries[i]` below `_transientEntriesLength` and every `_transientStaticEntries[i]` below `_transientStaticEntriesLength`, including unconsumed entries; then zero both transient lengths and `_transientEntryIndex` (zeroing the execution length also closes the reentry window). Finally, `emit BatchPosted(sharedPublicInput, rollupIds)`. ::: **Revert conditions**: `PostBatchReentry`, `ExpectedRootMismatch(rid)`, `L2TxEntryNotCanonical(i)`, `BlobNotFound(index)`, `MetaEntriesWithoutReceiver`, `InvalidProofSystemConfig`, `DuplicateProofSystem(ps)`, `EntryHasNoRollupUpdates`, `RollupUpdatesNotStrictlyIncreasing(rid)`, `RollupNotInBatch(rid)`, `EntryDestinationNotInRollupUpdates(rid)`, `CallSourceNotVerified(rid)`, `ExpectedRootsNotStrictlyIncreasing(rid)`, `StaticEntryDestinationNotPinned(rid)`, `ImmediateCountExceedsEntries`, `ImmediateStaticEntryCountExceedsStaticEntries`, `ImmediateStaticEntriesWithoutImmediateEntries`, `ImmediateCountStrandsLeadingL2Tx`, threshold/vkey reverts from the manager (e.g. `ThresholdNotMet`, `ProofSystemNotAllowed`), `InvalidProof`, propagated verifier errors, `ImmediateL2TxOutOfGas(i)`, `AllImmediateL2TxsFailed`, plus whatever the meta hook reverts with (`RollingHashMismatch`, `EtherDeltaMismatch`, `InsufficientRollupBalance`, `ExecutionNotFound`, `RootMismatch(rid)`, …; only a nonempty *immediate L2Tx* revert can be skipped; empty data aborts, and any outer revert removes skip logs). :::note Same-block re-touch of a rollup is **permitted** — but `_markVerifiedBlockAndResetQueues` resets the queue bounds on every verify without deleting stored entries, so the second batch's entries REPLACE (not append to) the first batch's for that rollup. Discarded entries do not apply their root transitions. Later entries must still match their required rollup roots; dependencies on other L1 state need their own checks. ::: #### `executeCrossChainCall` ```solidity function executeCrossChainCall(address sourceAddress, bytes calldata callData) external payable returns (bytes memory result) ``` **Access control**: caller must be a registered proxy (`authorizedProxies[msg.sender].isProxy`); else `UnauthorizedProxy`. **Preconditions**: `verificationByRollup[proxyInfo.originalRollupId].lastVerifiedBlock == block.number` else `ExecutionNotInCurrentBlock(rollupId)`. **Logic**: ```solidity ProxyInfo storage proxyInfo = authorizedProxies[msg.sender]; address destAddress = proxyInfo.originalAddress; uint64 destRid = proxyInfo.originalRollupId; bytes32 crossChainCallHash = computeCrossChainCallHash( NOT_STATIC_CALL, // isStatic = false sourceAddress, // sourceAddress (msg.sender of the original proxy call) MAINNET_ROLLUP_ID, // sourceRollupId (L1 = 0) destAddress, // targetAddress destRid, // targetRollupId msg.value, // value ZERO_CALL_GAS, // callGas (0 — L1 never keys on gas) callData // data ); emit CrossChainCallExecuted(crossChainCallHash, msg.sender, sourceAddress, callData, msg.value); if (_insideExecution()) { _entryEtherDelta += int256(msg.value); // reentrant: ADD to the entry's net-ether accumulator — §H.2 return _consumeNestedCall(destRid, crossChainCallHash); } _entryEtherDelta = int256(msg.value); // top-level: SET — a fresh entry can never inherit residue return _consumeAndExecuteEntry(destRid, crossChainCallHash); ``` `_consumeAndExecuteEntry` routes to the rollup's queue — the transient stream (global cursor) while a batch is mid-flight, otherwise the persistent per-rollup queue. Consumption is a **forward scan** for the first fully-matching entry (§D.1); if the scan reaches the end with no match, it reverts `ExecutionNotFound`. There is no reverted-top-level fallback structure: a top-level reverting call is a normal `ExecutionEntry` with `success == false`. **Revert conditions**: `UnauthorizedProxy`, `ExecutionNotInCurrentBlock(rollupId)`, `ExecutionNotFound`, `RollingHashMismatch`, `EtherDeltaMismatch`, `InsufficientRollupBalance`, `RootMismatch(rollupId)`, `ReentrantDestinationNotVerified(rollupId)` (reentrant path only), `StaticCallWithValue` (a flat call flagged `isStatic` but carrying non-zero `value` — a STATICCALL cannot transfer ETH, so it is rejected rather than silently dropping the value), `RevertSpanOutOfBounds`, plus a `success == false` entry's own `returnData` revert. A destination's natural revert does NOT propagate — the proxy `.call` captures `(false, retData)` into the rolling hash via `CALL_END`. #### `executeL2Txs` ```solidity function executeL2Txs(uint64 rollupId) external returns (bytes memory result) ``` Permissionless. Consumes the next entry on `rollupId`'s queue whose `proxyEntryHash == bytes32(0)`. Cannot run during an active execution. L2Tx entries are canonically `success == true` with empty `returnData` — a prover constraint (§A.1). ```solidity if (verificationByRollup[rollupId].lastVerifiedBlock != uint64(block.number)) revert ExecutionNotInCurrentBlock(rollupId); if (_insideExecution()) revert L2TxNotAllowedDuringExecution(); if (_entryEtherDelta != 0) revert ResidualEntryEtherIn(); // non-payable + never mid-entry — dirty accumulator = bug emit L2TxExecuted(rollupId); return _consumeAndExecuteEntry(rollupId, bytes32(0)); ``` #### `staticCrossChainCall` ```solidity function staticCrossChainCall(address sourceAddress, bytes calldata callData) external view returns (bytes memory) ``` Called via STATICCALL by `CrossChainProxy._fallback` when the proxy detects static context. Caller must be a registered proxy. The standard same-block restriction applies (§G.4). Branches on `_insideExecution()`: ```solidity uint64 destRid = proxyInfo.originalRollupId; bytes32 crossChainCallHash = computeCrossChainCallHash( IS_STATIC, // isStatic = true — keys distinctly from a state-changing call sourceAddress, MAINNET_ROLLUP_ID, proxyInfo.originalAddress, destRid, 0, // value = 0 in static context ZERO_CALL_GAS, // callGas — L1 static reads never key on gas callData ); // NESTED (inside an execution): scan the active host's unified reentrant table by the // content-addressed position key. The isStatic=true hash means only STATIC rows can match. if (_insideExecution()): if (!_containsVerifiedRollup(destRid)) revert ReentrantDestinationNotVerified(destRid); key = keccak256(abi.encodePacked(crossChainCallHash, _rollingHash)); expectedCalls = _getExpectedL1toL2Calls(); for i in [_lastL1ToL2CallConsumed, expectedCalls.length): // strict-forward window; a read cannot advance the cursor if expectedCalls[i].expectedL1toL2Hash == key: if _resolveStaticEntry(expectedCalls[i].l2ToL1Calls, expectedCalls[i].revertedOrStaticRollingHash, expectedCalls[i].success, expectedCalls[i].returnData): return expectedCalls[i].returnData; revert ExecutionNotFound(); // TOP-LEVEL: the batch's transient pool while one is mid-flight (the transient phase is // self-contained — see docs/CAVEATS.md), otherwise destRid's persistent staticEntryQueue. // Match = proxyEntryHash + destinationRollupId + every root pin live (full scan — // a non-matching candidate is skipped). The destinationRollupId term matters for the // transient pool, which is a single global table not routed by rollup. isTransient = _transientEntriesLength != 0; rec = verificationByRollup[destRid]; poolLength = isTransient ? _transientStaticEntriesLength : rec.staticEntryQueueIndex; for i in [0, poolLength): se = isTransient ? _transientStaticEntries[i] : rec.staticEntryQueue[i]; if se.proxyEntryHash == crossChainCallHash && se.destinationRollupId == destRid && _rootsMatch(se): if _resolveStaticEntry(se.l2ToL1Calls, se.rollingHash, se.success, se.returnData): return se.returnData; revert ExecutionNotFound(); ``` `_resolveStaticEntry(calls, revertedOrStaticRollingHash, success, returnData)` (shared by both branches — the param is the pool entry's `rollingHash` or the table row's `revertedOrStaticRollingHash`): * **Always** run the sub-calls via `_processStaticL2ToL1Calls(calls)` (each via `sourceProxy.staticcall(executeOnBehalf(target, cc.gas, data))`) and return `false` on `computedHash != revertedOrStaticRollingHash` so lookup tries the next candidate — an empty array hashes to `bytes32(0)`, which must match a sub-call-less entry's `rollingHash`. * If `!success`: revert with `returnData` (bubbles back to the proxy and out to the caller). * Else return `true`; the caller returns the row's `returnData`. Exhausting candidates reports `ExecutionNotFound`. `_rootsMatch(se)` returns true iff every `se.expectedRoots[i]` equals the live `rollups[rollupId].root` — part of the MATCH predicate, not a post-match check. #### `createCrossChainProxy` / `computeCrossChainProxyAddress` ```solidity function createCrossChainProxy(address originalAddress, uint64 originalRollupId) external returns (address proxy); function computeCrossChainProxyAddress(address originalAddress, uint64 originalRollupId) public view returns (address); ``` Both permissionless. `createCrossChainProxy` reverts if the proxy already exists. The separate public `getOrCreateCrossChainProxy` returns an existing authorized proxy or creates it; internal mutable execution uses this idempotent helper. Returning an existing proxy emits no creation event. ```solidity salt = keccak256(abi.encodePacked(originalRollupId, originalAddress)) bytecodeHash = keccak256(type(CrossChainProxy).creationCode) // constructor has no arguments address = address(uint160(uint256(keccak256(abi.encodePacked( bytes1(0xff), address(this), // deployer = the manager (EEZ on L1, EEZL2 on L2) salt, bytecodeHash ))))) ``` The constructor captures its manager from `msg.sender`. Under delegatecall the CREATE2 deployer is the manager proxy (`address(this)` in the execution context), not its implementation. The init-code hash is compiler/settings dependent. Deployments must pin the actual creation bytecode and compiler settings; the reference deployment and upgrade scripts are in [deployment/](https://github.com/eez-association/eez-core-protocol/blob/143d87e/deployment/README.md), while compiler-pinned reference vectors are not provided here. The salt is exactly `(originalRollupId, originalAddress)` — no `domain` or `block.chainid` term is mixed in. `createCrossChainProxy` and `computeCrossChainProxyAddress` are defined on `EEZBase` and inherited by both `EEZ` and `EEZL2`. A proxy stands in for a REMOTE address, never one on the manager's own network: `_createCrossChainProxyInternal` reverts `SameNetworkProxy(rollupId)` when `originalRollupId` equals the manager's own network id (`MAINNET_ROLLUP_ID` on L1, `ROLLUP_ID` on L2), so `createCrossChainProxy`, `getOrCreateCrossChainProxy` and the auto-creation path during execution are all covered by the one check. Nothing verifies that `originalAddress` actually holds a contract on rollup `originalRollupId` — proxy creation is permissionless and the manager cannot see other chains. A proxy minted over an address with no code at the destination forwards calls that "succeed" against an empty account (flat entries, empty return data). Always mint proxies over the real remote contract, never over a local twin's address. #### Per-rollup ownership / configuration Per-rollup ownership lives on each rollup's `IRollupContract`-conforming manager (reference impl: `src/rollupContract/Rollup.sol`). The central `EEZ` registry exposes a single manager-callable mutator on the rollup config: ```solidity function setRoot(uint64 rollupId, bytes32 newRoot) external // msg.sender == rollups[rid].rollupContract ``` Subject to three reverts: * `NotRollupContract` if `msg.sender != rollups[rid].rollupContract`. * `SetRootNotAllowedDuringExecution()` if `_insideExecution()` — the manager cannot rewrite state mid-execution via a reentrant proxy path. * `RollupBatchActiveThisBlock(rid)` if `verificationByRollup[rid].lastVerifiedBlock == block.number` (a batch hit `rid` earlier this block). Emits `RootUpdated(rollupId, newRoot)`. `setRoot` does **not** update `lastVerifiedBlock` — it's an owner escape, not a batch post. The registry's manager address is fixed at registration. Proxy-based managers retain that address when upgraded through their own administration. Per-rollup operations like `addProofSystem` / `removeProofSystem`, `updateVerificationKey`, `setThreshold`, `transferOwnership`, and any owner-driven `setRoot` initiation live on the manager itself. See `MULTI_PROVER_SPEC.md` and `src/rollupContract/Rollup.sol`. #### View accessors `verificationByRollup` is `internal`. Public accessors: ```solidity function lastVerifiedBlock(uint64 _rollupId) external view returns (uint256); function queueLength(uint64 _rollupId) external view returns (uint256); // entryQueueLength function entryQueueIndex(uint64 _rollupId) external view returns (uint256); ``` #### Internal helpers ##### `_consumeAndExecuteEntry(uint64 destRid, bytes32 crossChainCallHash) → bytes` ```solidity if (_transientEntriesLength != 0): idx = _findMatchingEntry(_transientEntries, _transientEntryIndex, _transientEntriesLength, crossChainCallHash, destRid) _transientEntryIndex = idx + 1 entry = _transientEntries[idx] else: rec = verificationByRollup[destRid] idx = _findMatchingEntry(rec.entryQueue, rec.entryQueueIndex, rec.entryQueueLength, crossChainCallHash, destRid) rec.entryQueueIndex = uint64(idx + 1) entry = rec.entryQueue[idx] _currentEntryRollupId = destRid // the queue _getExpectedL1toL2Calls() reads emit ExecutionConsumed(crossChainCallHash, destRid, idx) _currentEntryIndex = idx _executeEntry(entry) _currentEntryRollupId = 0 // load-bearing: the immediate L2Tx path relies on 0 _currentEntryIndex = 0 // hygiene/symmetry return entry.returnData ``` `_findMatchingEntry(queue, startIndex, queueLen, hash, destRid)` forward-scans from the cursor up to the active `queueLen` for the **first** entry where `_entryMatches` holds, reverting `ExecutionNotFound` if none matches. `_entryMatches(entry, hash, destRid)` requires all of: * `entry.proxyEntryHash == crossChainCallHash` (identity), * `entry.destinationRollupId == destRid` (routing — load-bearing in the transient branch, whose cursor is global across rollups; holds by construction in the persistent branch), * every `entry.rollupUpdates[i].currentRoot` equals the live `rollups[rid].root` (state preconditions — a stale entry is a *non-match*, skipped rather than reverted on). Skipping intervening non-matches lets a top-level call pass stale-state entries or failed entries whose hashes no longer match. A failed entry that still matches is retried, because its revert restored the cursor. A skipped entry never executes. A later entry is blocked if the skipped transition leaves a required rollup root unmatched. Dependencies on other L1 state need their own checks. Inside an active `postAndVerifyBatch`, `_transientEntriesLength != 0` routes **all** consumption through the transient stream with the global `_transientEntryIndex`. Per-rollup queues are populated only at step 7 of `postAndVerifyBatch`. ##### `_getExpectedL1toL2Calls() → ExpectedL1ToL2Call[] memory` The reentrant table of the entry currently being processed — the live source a proxy re-entry resolves against (it crosses an external boundary and can't see the executing `_executeEntry`'s memory entry). Returned by `memory` (one of the sources is a transient region, which cannot produce a `storage` pointer). Three sources, in priority order: 1. **Immediate L2Tx run** — the `ExpectedL1ToL2CallTransient` transient region when non-empty (the entry never lands in storage; only its table is parked there). 2. **Meta-hook** — `_transientEntries[_currentEntryIndex].expectedL1ToL2Calls` while a batch is mid-flight. 3. **Normal proxy consumption** — `verificationByRollup[_currentEntryRollupId].entryQueue[_currentEntryIndex].expectedL1ToL2Calls`. An immediate L2Tx whose parked table is empty yet still makes a reentrant call reaches here with `_currentEntryRollupId == 0`; the accessor returns an empty table instead of indexing the nonexistent queue. CALL then records `CALL_NOT_FOUND` and returns empty bytes; STATICCALL reverts `ExecutionNotFound`, matching the empty-table behavior of queued and meta-hook entries. ##### `_consumeNestedCall(uint64 destRid, bytes32 crossChainCallHash) → bytes` The reentrant resolution path. The cursor advances **only on a match**; a no-match folds a `CALL_NOT_FOUND` tag into the rolling hash instead of reverting in place: ```solidity // Proxy protection: the reentrant call's target rollup must be in the entry's proven set. if (!_containsVerifiedRollup(destRid)) revert ReentrantDestinationNotVerified(destRid) expectedCalls = _getExpectedL1toL2Calls() expectedL1toL2Hash = keccak256(abi.encodePacked(crossChainCallHash, _rollingHash)) // Strict forward scan from the cursor — the first key match IS the row. for i in [_lastL1ToL2CallConsumed, expectedCalls.length): if expectedCalls[i].expectedL1toL2Hash == expectedL1toL2Hash: _lastL1ToL2CallConsumed = i + 1 // advance PAST the match before resolving return _resolveNestedReentrant(expectedCalls[i], crossChainCallHash) // No match: fold a dedicated tag. Against an honest marker-free expected hash, // a surviving marker causes RollingHashMismatch. ContextResult transports it // across explicit rollback spans; an ordinary enclosing revert can erase it. _rollingHashCallNotFound(crossChainCallHash) return "" ``` The same logic on L2 is `_consumeNestedCall(crossChainCallHash)` — no `destRid` / proxy-protection check (single rollup, trusted system-loaded table), scanning `expectedOutgoingCalls` by `expectedOutgoingHash` with cursor `_lastOutgoingCallConsumed`. ##### `_resolveNestedReentrant(ExpectedL1ToL2Call memory row, bytes32 crossChainCallHash) → bytes` ```solidity _rollingHashNestedBegin(crossChainCallHash) // open the frame, binding the call's identity _processL2ToL1Calls(row.l2ToL1Calls) // process this frame's sub-array; a failed _hasEnoughCallGas check stops it and marks the hash if (row.success): if (row.revertedOrStaticRollingHash != bytes32(0)) revert SuccessRowWithRevertedOrStaticHash() _rollingHashNestedEnd() // close the frame into the host's continuous hash return row.returnData else: if (_rollingHash != row.revertedOrStaticRollingHash) revert RollingHashMismatch() revert-with row.returnData // terminal revert rolls back state, hash, and cursor ``` A SUCCESS frame commits: its sub-calls fold into the host's continuous `_rollingHash` between `NESTED_BEGIN` / `NESTED_END`. A REVERTED frame is a mini-entry: its sub-hash (seeded from the host hash at `NESTED_BEGIN`) is checked against the row's `revertedOrStaticRollingHash`, then the frame reverts with `returnData` — the destination's `try/catch` absorbs the revert, and the EVM rolls back every write inside the frame, including the cursor advance and the hash folds (no save/restore step is needed). ##### `_executeEntry` `_entryEtherDelta` already holds the entry-point call's `msg.value` here (SET by the top-level entry point before consumption), so it is NOT reset in the preamble — only at the end, after the invariant check. The implementation accepts six fields separately: `RollupUpdate[] memory rollupUpdates`, `bytes32 proxyEntryHash`, `L2ToL1Call[] memory l2ToL1Calls`, `bytes32 rollingHash`, `bool success`, and `bytes memory returnData`. This lets immediate L2Txs execute without storing the whole entry. The pseudocode groups these arguments under `entry` for readability. ```solidity if (entry.rollupUpdates.length == 0) revert EntryHasNoRollupUpdates // redundant guard; backs _insideExecution() for delta in entry.rollupUpdates: if (rollups[delta.rollupId].root != delta.currentRoot) revert RootMismatch(delta.rollupId) _pushVerifiedRollup(delta.rollupId) // allowed set; flips _insideExecution() true _rollingHashEntryBegin(entry.rollupUpdates, entry.proxyEntryHash) // seed: starting state + identity (§E) _lastL1ToL2CallConsumed = 0 _processL2ToL1Calls(entry.l2ToL1Calls) // the entry's TOP-LEVEL calls, whole array totalEtherDelta = _applyRollupUpdates(entry.rollupUpdates) // newRoot applies AFTER the calls if (_rollingHash != entry.rollingHash) revert RollingHashMismatch // a CALL_NOT_FOUND fold surfaces here too if (totalEtherDelta != _entryEtherDelta) revert EtherDeltaMismatch emit EntryExecuted(_currentEntryIndex, _rollingHash) if (!entry.success): revert-with entry.returnData // trace verified; unwind state deltas, value, cursor advance, cleanups _clearVerifiedRollups() // resets _insideExecution() to false _entryEtherDelta = 0 _rollingHash = bytes32(0) // next entry's zero-guard (RollingHashNotCleared) passes ``` There is **no flat-call cursor check** (arrays are walked in order, with the gas-shortage early return described below) and **no reentrant table-length check**: the unified `expectedL1ToL2Calls` mixes plain-success rows with static / reverted ones (content-addressed, may legitimately go unused). Omitting an expected surviving `NESTED` frame changes the rolling hash; unused rows are inert. The hash does not identify which same-key row supplied a saved answer, nor retain observations erased by ordinary reverts (§H.4). ##### `_processL2ToL1Calls(L2ToL1Call[] memory calls)` Walks the `calls` array until its end or a gas-shortage return — the entry's top-level calls, a reentrant frame's own sub-array, or a force-revert span slice — walked by a plain **local** index (it auto-survives reentrant proxy calls on the call stack; no transient cursor). For each element, first reject `targetAddress == address(this)` with `EEZDestinationForbidden`, including before opening a rollback span. Then: * If `revertNextNCalls == 0`: 1. Fold `CALL_BEGIN` with the call's identity: `computeCrossChainCallHash(cc.isStatic, cc.sourceAddress, cc.sourceRollupId, cc.targetAddress, MAINNET_ROLLUP_ID, cc.value, ZERO_CALL_GAS, cc.data)` (L2 substitutes `ROLLUP_ID` for the target). 2. Derive `sourceProxy = computeCrossChainProxyAddress(cc.sourceAddress, cc.sourceRollupId)`; auto-create it if not registered. (No source check here — every executed call's `sourceRollupId` was validated ∈ `rollupUpdates` at batch validation.) 3. Encode the forwarding payload and check `_hasEnoughCallGas(cc.gas, payload.length, cc.value)`. On shortage, fold `CALL_INSUFFICIENT_GAS` (tag 6) and return from this local array: this call and later siblings are not dispatched; `CALL_BEGIN` is already folded. A parent can continue after `ContextResult` transports a stopped span. Otherwise dispatch: if `cc.isStatic`, revert `StaticCallWithValue` when `cc.value != 0`, then `sourceProxy.staticcall(executeOnBehalf(target, cc.gas, data))`; else `sourceProxy.call{value: cc.value}(executeOnBehalf(target, cc.gas, data))`, and on success with `cc.value > 0`, `_entryEtherDelta -= int256(cc.value)`. 4. Fold `CALL_END(success, retData)`; emit `CallResult(_currentEntryIndex, i, success, retData)`; `i++`. * If `revertNextNCalls > 0` (force-revert span): 1. `if (i + n > calls.length) revert RevertSpanOutOfBounds(i, n, calls.length)`. 2. Zero `calls[i].revertNextNCalls` in the throwaway memory copy (so the isolated re-run reads it as a normal call), slice `calls[i .. i+n)` into a fresh memory array (`_sliceL2ToL1Calls`). 3. `try this.executeInContextAndRevert(span)` — always reverts; decode `ContextResult` and restore `_rollingHash` and `_lastL1ToL2CallConsumed` from the payload. 4. Emit `CallsReverted(_currentEntryIndex, i, n)`; `i += n`. (L2's variant is identical over `CrossChainCall[]`, minus the ether accounting.) The preflight is an estimate for nonzero caps: payload memory/copy costs, fixed overhead, value transfers and a two-hop forwarding margin. A zero cap bypasses it. New-account costs, returndata handling and the remaining entry/batch are excluded; posters still need enough gas for the whole execution. Valid proofs exclude tag 6; runtime equality alone cannot enforce that proof policy. `CallsReverted` reports the requested span length, not an independently measured dispatch count. ##### `executeInContextAndRevert(L2ToL1Call[] memory calls) external` ```solidity require(msg.sender == address(this)) // NotSelf _processL2ToL1Calls(calls) revert ContextResult(_rollingHash, _lastL1ToL2CallConsumed) ``` The span slice crosses the self-call boundary by `memory` (ABI-encoded), since a `storage` ref cannot. (L2 passes `_lastOutgoingCallConsumed` as the second field.) The unconditional revert rolls back all storage and transient writes inside the self-call, but the `ContextResult` payload escapes via the revert data — the caller decodes it and re-applies the rolling hash and reentrant cursor. A reentrant no-match inside the span is already folded into that rolling hash (`CALL_NOT_FOUND`), so it rides out with no separate flag. ##### `_decodeContextResult(bytes memory revertData) → (bytes32, uint256)` Defined on `EEZBase` with direction-neutral names. Verifies `bytes4(revertData) == ContextResult.selector` AND `revertData.length >= 68` (4 + 2×32) else `UnexpectedContextRevert(revertData)`; then assembly-loads two words at offsets 36 and 68 (`rollingHash`, `reentrantConsumed`). ##### `_applyRollupUpdates(RollupUpdate[] memory deltas) → int256 totalEtherDelta` For each delta (the `currentRoot` precondition was already checked upfront in `_executeEntry`; roots are immutable mid-execution): * `rollups[delta.rollupId].root = delta.newRoot`. * Accumulate `delta.etherDelta` into `totalEtherDelta`. * If `delta.etherDelta < 0`: `etherBalance -= |delta|` (revert `InsufficientRollupBalance` on underflow). * If `delta.etherDelta > 0`: `etherBalance += delta`. * Emit `L2ExecutionPerformed(rollupId, newRoot, etherBalance)`. ##### `_processStaticL2ToL1Calls(L2ToL1Call[] memory calls) → bytes32` ```solidity hash = bytes32(0) for cc in calls: _validateCallDestination(cc.targetAddress) // rejects the runtime manager itself if (!cc.isStatic) revert NonStaticSubCall() if (cc.value != 0) revert StaticCallWithValue() if (cc.revertNextNCalls != 0) revert StaticCallWithRevertSpan() sourceProxy = computeCrossChainProxyAddress(cc.sourceAddress, cc.sourceRollupId) if (sourceProxy.code.length == 0) revert StaticCallProxyNotDeployed(sourceProxy) payload = abi.encodeCall(ICrossChainProxy.executeOnBehalf, (cc.targetAddress, cc.gas, cc.data)) if (!_hasEnoughCallGas(cc.gas, payload.length, 0)) revert InsufficientCallGas(cc.gas) (success, retData) = sourceProxy.staticcall(payload) hash = keccak256(abi.encodePacked(hash, success, retData)) return hash ``` Static callbacks require `revertNextNCalls == 0` both as a prover constraint and at runtime (`StaticCallWithRevertSpan`). A nonzero gas cap also receives the preflight estimate; shortage reverts `InsufficientCallGas(cc.gas)` without folding tag 6. Static context cannot deploy proxies, so all referenced proxies must already exist; a codeless proxy reverts `StaticCallProxyNotDeployed` (a STATICCALL to a codeless address silently succeeds, which the prover could otherwise pre-hash as a no-op). Dispatch here is read-only unconditionally, so the declared fields must agree with it: `isStatic == false` reverts `NonStaticSubCall` and a non-zero `value` reverts `StaticCallWithValue`. Neither field is folded into the untagged hash, so without these checks a proven state-changing call would silently execute as a STATICCALL, and a declared value would be silently dropped. This hashing scheme is **intentionally untagged** and is **distinct from** the entry-level rolling hash described in §E (no `CALL_BEGIN`/`CALL_END`/`NESTED_BEGIN`/`NESTED_END` tags). It is verified against the static entry's own accumulator (`StaticExecutionEntry.rollingHash`, or `ExpectedL1ToL2Call.revertedOrStaticRollingHash` for a nested static row), whose surrounding key already pins the context. See §E.2. ##### `_attemptExecuteImmediateL2Txs(ExecutionEntry calldata entry) external` — self-call only Runs ONE leading immediate L2Tx entry, straight from the batch calldata, in an isolated frame — so `postAndVerifyBatch`'s surrounding `try/catch` can skip a reverting entry (`L2TxSkipped`) instead of aborting the batch. Guards: `NotSelf`, `ResidualEntryEtherIn` (L2Tx entries receive no inbound value). Parks the entry's reentrant table in the `ExpectedL1ToL2CallTransient` transient region, calls `_executeEntry(entry)`, then clears the parked table (a skip's writes roll back with the frame). Neither `_currentEntryIndex` nor `_currentEntryRollupId` is set here (both are already 0), so this entry's events log `entryIndex == 0`. ##### `computeCrossChainCallHash` (`public pure`, on `EEZBase`) ```solidity function computeCrossChainCallHash( bool isStatic, address sourceAddress, uint64 sourceRollupId, address targetAddress, uint64 targetRollupId, uint256 value, uint64 callGas, bytes memory data ) public pure returns (bytes32) { return keccak256( abi.encode(isStatic, sourceAddress, sourceRollupId, targetAddress, targetRollupId, value, callGas, data) ); } ``` Field order is `isStatic` → FROM (source pair) → TO (target pair) → `value` → `callGas` → `data`. `isStatic` makes a read-only call hash distinctly from an otherwise-identical state-changing one. Off-chain tooling mirrors this via `crossChainCallHash` in `script/e2e/scenarios/shared/E2EHelpers.sol` (`abi.encode` left-pads integers to 32 bytes, so uint256 rollupIds produce identical bytes to the contract's uint64 fields). Apart from L2 inbound binding, which uses `incomingCalls[0].gas`, `callGas` is `0` (`ZERO_CALL_GAS`) except calls **leaving an L2** (`EEZL2.executeCrossChainCall` and `staticCrossChainCall` — top-level and nested matching), where the folded value depends on the constructor flag `useGasLeft` (immutable `USE_GAS_LEFT`): * `useGasLeft = true` (requires observed-gas support in the node/prover): `uint64(gasleft())` read when the manager computes the hash, i.e. after proxy authorization and storage reads, plus the block check and ETH transfer for mutable calls. Because it keys both top-level entry matching (`proxyEntryHash`) and the nested `expectedOutgoingHash`, the prover must commit to that exact gas value (deterministic when replaying the block). * `useGasLeft = false` (the shared test fixtures): `callGas` is fixed at `0`, so outgoing hashes are gas-independent and can be pre-computed without observing the forwarded gas. L2 inbound binding still uses the incoming call's own `gas` field. For mutable calls, the folded value is emitted in L2's `CrossChainCallExecuted` event (the six-field overload — different topic0 from L1's five-field form). Any L2 miss — a top-level mutable call, or a static read in either branch — reverts `EntryNotFound(crossChainCallHash, callGas)`, returning the folded value. Which `callGas` value keys what: | Matching site | `callGas` folded | |---|---| | Everything on L1 | `0` | | L2 inbound binding (`executeIncomingCrossChainCall` vs `entries[0].proxyEntryHash`) | `incomingCalls[0].gas` (the inbound call's own field) | | L2 static matching (`staticCrossChainCall`) | `gasleft()` when `USE_GAS_LEFT`, else `0` | | Calls LEAVING an L2 (`executeCrossChainCall`, top-level + nested) | `gasleft()` when `USE_GAS_LEFT`, else `0` | Distinguish the **gas value hashed at the source** from the **gas cap requested at the destination**. The `gas` field on `L2ToL1Call` / `CrossChainCall` supplies the proxy's destination-call cap (`0` forwards available gas subject to EVM limits). The contracts do not enforce equality between that cap and an outgoing source hash's `callGas`. * **L1-sourced** call: the source identity always folds `callGas = 0`. The builder supplies the destination `CrossChainCall.gas`, which may be nonzero; L2's inbound identity binds that supplied cap. * **L2-sourced** call: outgoing identity folds observed `gasleft()` when `USE_GAS_LEFT`, otherwise `0`. Copying that observation into the destination call's `gas` field is a builder/node forwarding policy, not an automatic runtime transfer or contract invariant. The integration must specify and validate the policy, including any use of a zero cap. A supplied cap also does not by itself guarantee delivery of that much gas; the preflight estimate and EVM forwarding limits still apply. ##### `entryHashes` for the public-inputs preimage Each entry's contribution is `keccak256(abi.encode(entry))` — the FULL `ExecutionEntry` struct, including `rollupUpdates` (which carry the entry's `currentRoot` precondition), `proxyEntryHash`, `l2ToL1Calls`, the unified `expectedL1ToL2Calls`, `rollingHash`, `destinationRollupId`, `success`, and `returnData`. Same for static entries (`keccak256(abi.encode(staticEntry))`). See `MULTI_PROVER_SPEC.md` for the full per-PS public-inputs construction. ##### Per-PS verify (inlined in `_verifyProofSystemBatch`) There is no standalone `_verifyProof` function — each PS is verified inline at the end of the per-PS loop in `_verifyProofSystemBatch`: ```solidity if (!IProofSystem(batch.proofSystems[k]).verify(batch.proofs[k], publicInputsHash)) revert InvalidProof(); ``` The per-PS rollup walk resolves each rollup's position in its `proofSystemIndexes[]` via binary search (`_findIndexPosition`; the strictly-increasing invariant was enforced at validation). Each `IProofSystem` is supplied per batch via `proofSystems[]`; there is no central `ZK_VERIFIER` immutable. ### B.2 EEZL2.sol (L2) The L2 contract inherits `EEZBase` and mirrors the L1 contract's execution logic but with no rollup registry, no state deltas, no ether accounting, no proofs, no per-rollup queue map (single rollup), no proxy-protection set, and no transient/deferred split. #### `loadExecutionTable` ```solidity function loadExecutionTable(ExecutionEntry[] calldata _entries, StaticExecutionEntryL2[] calldata _staticEntries) external payable onlySystemAddress ``` ```solidity entryIndex = 0 entriesLength = _entries.length staticEntriesLength = _staticEntries.length for i in [0, entriesLength): entries[i] = _entries[i] for i in [0, staticEntriesLength): staticEntries[i] = _staticEntries[i] lastLoadBlock = block.number emit ExecutionTableLoaded(_entries, _staticEntries) ``` `onlySystemAddress` reverts `Unauthorized` for any other caller. The L2 `ExecutionEntry` struct has no `destinationRollupId` and no `rollupUpdates` (see §A.1 — `IEEZL2.sol`). Payable: system-supplied `msg.value` adds pooled ETH drawn down by successful incoming transfers. Replacing tables does not reset held ETH. Partial consumption and residual inventory require aggregate node/circuit accounting ([L2 funding and inventory](#l2-funding-and-inventory)); no per-load conservation check is implemented. #### `executeIncomingCrossChainCall` (L2 inbound delivery) ```solidity function executeIncomingCrossChainCall( ExecutionEntry[] calldata _entries, StaticExecutionEntryL2[] calldata _staticEntries ) external payable onlySystemAddress returns (bytes memory) ``` System-only top-level delivery path for an inbound cross-chain call from another rollup. `entries[0].incomingCalls[0]` IS the inbound call. One such tx per committed top-level inbound call: a source-chain tx that makes N such calls to this L2 is delivered as N separate `executeIncomingCrossChainCall` txs, each atomically replacing the table (see [delivery units](/execution-entry-spec#delivery-units-and-execution-transactions)). Behavior: 1. Revert `EmptyEntries` if `_entries.length == 0`; revert `EmptyIncomingCalls` if `_entries[0].incomingCalls.length == 0`. 2. `msg.value` adds system funding to the manager's pooled balance, drained by successful incoming transfers through source proxies. Later entries may draw on it; replacement retains unused ETH. Issuance/burn and residual inventory are external node/circuit obligations (§A.1 and §H.2), not a checked per-load equality. 3. `_loadExecutionTable(_entries, _staticEntries)` — atomically replaces the execution table. 4. With `inbound = _entries[0].incomingCalls[0]`: `crossChainCallHash = computeCrossChainCallHash(inbound.isStatic, inbound.sourceAddress, inbound.sourceRollupId, inbound.targetAddress, ROLLUP_ID, inbound.value, inbound.gas, inbound.data)` — the identity binds the inbound call's own `gas`. Revert `EntryHashMismatch` if `entries[0].proxyEntryHash != crossChainCallHash` — the entry's identity must be the hash of its own inbound call, mirroring L1's `_consumeAndExecuteEntry` match. Prover constraints on `entries[0]` and its `incomingCalls[0]`: §A.1. 5. Emit `IncomingCrossChainCallExecuted(crossChainCallHash, inbound.isStatic, inbound.sourceAddress, inbound.sourceRollupId, inbound.targetAddress, inbound.value, inbound.gas, inbound.data)` (hash-formula field order) and `ExecutionConsumed(crossChainCallHash, 0)` — the same consumption signal the proxy-driven path emits, so a log reader sees every entry that ran regardless of which entry point drove it. 6. `_currentEntryIndex = 0; _executeEntry(entries[0])` — the standard entry cycle (seed, flat calls, rolling-hash check, `!success` revert-with-returnData). 7. `entryIndex = 1` — advance past `entries[0]` so follow-up `executeCrossChainCall`s don't re-consume it. 8. Return `entries[0].returnData`. #### `executeCrossChainCall` (L2 variant) Same shape as L1, with these differences: 1. **Hash formula**: outgoing mutable and static L2 calls can fold a non-zero `callGas` — observed `gasleft()` when `USE_GAS_LEFT`, else `0`. It keys both the top-level `proxyEntryHash` match and the nested `expectedOutgoingHash`. See §C.2. 2. **`sourceRollupId`** in the call hash is `ROLLUP_ID` (this L2's ID), not `MAINNET_ROLLUP_ID`; `targetRollupId` is `proxyInfo.originalRollupId` (the proxied counterparty — L1 or another L2). 3. **System value transfer**: if `msg.value > 0`, the manager forwards it to `SYSTEM_ADDRESS` immediately (before hashing). Failure reverts `EtherTransferFailed`. There is no `_entryEtherDelta` accounting on L2; any burn semantics belong to the node/system integration. 4. Block gate is the single `lastLoadBlock != block.number → ExecutionNotInCurrentBlock` check (no per-rollup routing). 5. **Miss error**: a top-level scan that reaches the end of `entries` reverts `EntryNotFound(crossChainCallHash, callGas)` — NOT L1's `ExecutionNotFound`. A static miss (`staticCrossChainCall`, nested or top-level) reverts the same error with the static-kind hash. The two-field payload is deliberate: it hands the builder both the key that was searched for and the `callGas` that went into it, which is what makes observed-gas keying debuggable (see `test/GasProbe.t.sol`). Top-level consumption is `_consumeAndExecute(crossChainCallHash, callGas)`: forward-scan `entries` from `entryIndex` for the first `proxyEntryHash` match (`_findMatchingEntry` — hash alone, no state pins on L2), advance `entryIndex = idx + 1`, emit `ExecutionConsumed(crossChainCallHash, idx)`, run `_executeEntry`, return `entry.returnData`. Reentrant calls route to `_consumeNestedCall(crossChainCallHash)` (§B.1 — same algorithm, no proxy-protection check). There is no reverted-top-level fallback structure — a top-level reverting call is an `ExecutionEntry` with `success == false`. The proxy-driven call emits `CrossChainCallExecuted` — the six-field L2 overload carrying the extra `callGas` field, with a different topic0 from L1's five-field form. L2's `_executeEntry` seeds the hash with `_seedRollingHash(proxyEntryHash)` — `keccak256(abi.encodePacked(bytes32(0), proxyEntryHash))`, i.e. L1's `_rollingHashEntryBegin` with an empty state-delta prefix — keeping the cross-chain hashing scheme identical modulo the dropped deltas. End checks: rolling hash only (no ether invariant, no table-length checks). #### Top-level call delivery on L2 Top-level calls on L2 arrive via two paths: 1. **User txs hitting proxies** → `executeCrossChainCall`. 2. **`SYSTEM_ADDRESS` → `executeIncomingCrossChainCall`** for inbound cross-chain calls from another rollup. There is no `executeL2Txs` on L2 — that mechanism lives on L1 and handles the L1-side commit of L2 user actions. A `bytes32(0)` `proxyEntryHash` is unreachable on L2. #### `staticCrossChainCall` (L2) Same shape as L1: inside an execution it scans the active entry's unified `expectedOutgoingCalls` for a row whose `expectedOutgoingHash` matches `keccak256(crossChainCallHash, _rollingHash)` (forward window from `_lastOutgoingCallConsumed`); outside it scans the `staticEntries` pool matched by `proxyEntryHash` + `expectedEntryIndex == entryIndex`, gated on `lastLoadBlock == block.number` — the L2 `StaticExecutionEntryL2` has no `destinationRollupId` and no `expectedRoots` (single rollup, no roots), so the block gate bounds staleness. `sourceRollupId` in the call hash is `ROLLUP_ID`; `isStatic = true`; `value = 0`; `callGas` follows the outgoing policy (§C.3). A miss in either branch reverts `EntryNotFound(crossChainCallHash, callGas)`, not L1's bare `ExecutionNotFound`, so the builder can recover the static key under `USE_GAS_LEFT` with the same probe recipe as mutable calls. #### `createCrossChainProxy` / `computeCrossChainProxyAddress` Inherited from `EEZBase`. Identical formula on both L1 and L2 (§B.1); L2's `SameNetworkProxy` guard forbids `originalRollupId == ROLLUP_ID`. ### B.3 CrossChainProxy.sol ```solidity constructor() ``` One immutable: `EEZ` (`src/base/CrossChainProxy.sol`), holding the deploying EEZ contract address from `msg.sender` (L1 `EEZ` or L2 `EEZL2`). The `(originalAddress, originalRollupId)` pair lives in the CREATE2 salt and the manager's `authorizedProxies` mapping. The proxy is constructed by `EEZBase._createCrossChainProxyInternal` (reached via the external `createCrossChainProxy` or auto-creation in `_processL2ToL1Calls` (L1) / `_processIncomingCalls` (L2)). The constructor sweeps any ether predeployed at the proxy address (otherwise stuck) to `IEEZ(msg.sender).RECOVERY_ADDRESS()`, best-effort: the transfer result is ignored and failed recovery leaves ETH in the proxy. The sweep is capped at `RECOVERY_ETHER_GAS = 100_000` (the EVM forwards min(cap, available)), so a gas-burning recipient cannot starve the rest of the constructor. `RECOVERY_ADDRESS()` is on the shared `IEEZ` interface and is a separately configured nonzero immutable on both L1 and L2 (`InvalidRecoveryAddress` rejects zero). L2 outgoing value still goes to `SYSTEM_ADDRESS`. #### `executeOnBehalf(address destination, uint64 callGas, bytes calldata data)` (payable ABI through `ICrossChainProxy`) The payable fallback checks the selector and manager identity before decoding arguments. Non-manager calldata is forwarded unchanged, even when it collides with this selector but is invalid for its ABI. If `msg.sender == EEZ`: call `destination.call{value: msg.value}(data)` when `callGas == 0` (forward all remaining gas), else `destination.call{value: msg.value, gas: callGas}(data)`. Return/revert raw bytes via assembly. `callGas` is the executing call's `L2ToL1Call.gas` / `CrossChainCall.gas` field, passed straight through by `_processL2ToL1Calls` (L1) / `_processIncomingCalls` (L2); it caps the destination call. Flat-call `CALL_BEGIN` hashes use zero gas (§C.5); L2 inbound entry identity separately binds the incoming cap (§C). Else: route through `_fallback()` — the transparent-proxy pattern: the manager admin (when the manager itself calls `executeOnBehalf`) gets direct forwarding; any other caller hitting `executeOnBehalf` enters the cross-chain path. #### `staticCheck()` (private selector used by the payable fallback) The payable fallback handles this selector locally only if `msg.sender == address(this)`: attempt `_staticDetector = 0` (a `tstore`), then return. In static context this halts; in normal context it succeeds with empty return data. Other callers route through `_fallback()` with their original calldata and value. `STATIC_CHECK_SELECTOR` is a private constant; `staticCheck` is not declared in the public ABI. `src/interfaces/ICrossChainProxy.sol` exposes only `executeOnBehalf`. Because the check is on `msg.sender`, a destination that makes the proxy call itself with this selector via `executeOnBehalf` also reaches the detector rather than a forwarded cross-chain call. The prover must reproduce that outcome. #### `fallback() external payable` and `_fallback()` internal ```solidity staticCheckGas = IEEZ(EEZ).STATIC_CHECK_GAS() // manager-owned cap, read on every fallback (detectSuccess, _) = address(this).call{gas: staticCheckGas}(abi.encodeWithSelector(STATIC_CHECK_SELECTOR)) if (!detectSuccess): // STATICCALL context — look up cached result (success, result) = EEZ.staticcall(abi.encodeCall(IEEZ.staticCrossChainCall, (msg.sender, msg.data))) else: // Normal context — execute cross-chain call (success, result) = EEZ.call{value: msg.value}(abi.encodeCall(IEEZ.executeCrossChainCall, (msg.sender, msg.data))) if (success): result = abi.decode(result, (bytes)) // unwrap the inner bytes returned by IEEZ.* // assembly return/revert raw result ``` The `abi.decode(result, (bytes))` unwrap is required because `executeCrossChainCall` and `staticCrossChainCall` both return `bytes memory`, and the low-level `.call() / .staticcall()` ABI-encodes that return value. The proxy strips one layer so the caller sees the destination's raw return data (matching the behavior the destination saw via `executeOnBehalf`'s assembly return). On revert, the raw revert data is not ABI-wrapped, so it is forwarded directly. *** ## C. Action Hash Computation The formula: ```solidity // EEZBase.computeCrossChainCallHash, inherited by both managers crossChainCallHash = keccak256(abi.encode( isStatic, sourceAddress, sourceRollupId, targetAddress, targetRollupId, value, callGas, data )) ``` The sites differ only in the `callGas` value they fold: | Matching site | `callGas` folded | |---|---| | Everything on L1 (entry points, flat calls, reentrant rows, static) | `0` (`ZERO_CALL_GAS`) | | L2 inbound binding (`executeIncomingCrossChainCall` vs `entries[0].proxyEntryHash`) | `incomingCalls[0].gas` | | L2 flat-call `CALL_BEGIN` folds (§C.5) | `0` (`ZERO_CALL_GAS`) | | L2 static matching (`staticCrossChainCall`, both branches) | `uint64(gasleft())` when the hash is computed if `USE_GAS_LEFT`, else `0` | | Calls LEAVING an L2 (`EEZL2.executeCrossChainCall` — top-level `proxyEntryHash` match AND the nested `expectedOutgoingHash` key) | **`uint64(gasleft())` when `USE_GAS_LEFT`, else `0`** | Under `useGasLeft = false`, outgoing hashes fold `0`; inbound binding still uses `incomingCalls[0].gas`. See §B.1 for the rationale and the `callGas` ⇄ `CrossChainCall.gas` relationship. Off-chain tooling (`script/e2e/scenarios/shared/E2EHelpers.sol`): `crossChainCallHash` / `crossChainCallHashStatic` fold `callGas = 0`; `crossChainCallHashL2Out` keys L2-outgoing calls. ### C.1 Hash from `executeCrossChainCall` (L1) | Field | Value | |---|---| | `isStatic` | `false` (`NOT_STATIC_CALL`) | | `sourceAddress` | `sourceAddress` (msg.sender of the original proxy call) | | `sourceRollupId` | `MAINNET_ROLLUP_ID = 0` | | `targetAddress` | `proxyInfo.originalAddress` | | `targetRollupId` | `proxyInfo.originalRollupId` | | `value` | `msg.value` | | `callGas` | `0` (`ZERO_CALL_GAS`) — L1 never keys on gas | | `data` | `callData` (forwarded by the proxy as `msg.data`) | ### C.2 Hash from `executeCrossChainCall` (L2) **Outgoing mutable and static L2 calls share this gas-keying policy.** Fields as in C.1, except: | Field | Value | |---|---| | `sourceRollupId` | this L2's `ROLLUP_ID` | | `targetRollupId` | `proxyInfo.originalRollupId` (the proxied counterparty — L1 or another L2) | | `callGas` | `uint64(gasleft())` when the hash is computed if `USE_GAS_LEFT`, else `0` | The folded value is echoed in L2's `CrossChainCallExecuted` event, and on a top-level miss in `EntryNotFound(crossChainCallHash, callGas)`, so a builder can recover the exact key. ### C.3 Hash from `staticCrossChainCall` `isStatic = true` (`IS_STATIC`), `value = 0` (STATICCALL cannot carry ETH), and `callGas = 0` on L1; on L2, `uint64(gasleft())` sampled after proxy authorization/storage reads when `USE_GAS_LEFT`, otherwise `0`; source/target pairs as in the corresponding `executeCrossChainCall`. Because `isStatic` is folded into the hash, a static read keys distinctly from an otherwise-identical state-changing call — this is what routes static rows vs call rows inside the unified reentrant table without a mode field. ### C.4 Hash for reentrant calls Identical to the proxy hash that triggered the reentrant call — so a reentrant call leaving an L2 folds `callGas` exactly like a top-level one (C.2), and every reentrant call on L1 folds `0`. The protocol does not distinguish "top-level" vs "reentrant" in the call hash itself; the routing decision (`_consumeAndExecuteEntry` vs `_consumeNestedCall`) is made at runtime via `_insideExecution()`. Inside the reentrant table, the call hash is bound to its execution point by the position key `keccak256(abi.encodePacked(crossChainCallHash, expectedRollingHash))` (§A.1), computed by `EEZBase._computeExpectedL1toL2Hash`. ### C.5 Hash for flat-call `CALL_BEGIN` folds Each executed flat call folds its own identity hash into `CALL_BEGIN` (§E): `computeCrossChainCallHash(cc.isStatic, cc.sourceAddress, cc.sourceRollupId, cc.targetAddress, MAINNET_ROLLUP_ID, cc.value, ZERO_CALL_GAS, cc.data)` on L1, with `ROLLUP_ID` as the target on L2 — the executing chain is always the target. **`callGas = 0` on both chains**: a flat call is a call being DELIVERED on the executing chain, not one leaving it, so `cc.gas` is passed to the proxy but is not folded into this `CALL_BEGIN` identity. The separate L2 inbound entry identity does bind `incomingCalls[0].gas`. ### C.6 No `crossChainCallHash` for L2Tx entries `executeL2Txs(rollupId)` requires `entry.proxyEntryHash == bytes32(0)`. There is no separate L2Tx hash — the entry is identified by being the next zero-hash entry the forward scan reaches on the rollup's queue. *** ## D. Execution Model ### D.1 Forward-Scan Entry Consumption Entries in `verificationByRollup[rid].entryQueue` (or `_transientEntries` during `postAndVerifyBatch`) are consumed via the rollup's `entryQueueIndex` (or the global `_transientEntryIndex` during the transient phase — per-rollup cursors stay untouched then). Consumption is a **forward scan** from the cursor to the first entry satisfying the full match predicate (`_entryMatches`): ```solidity entry.proxyEntryHash == crossChainCallHash // identity entry.destinationRollupId == destRid // routing every rollupUpdates[i].currentRoot == live root // state preconditions ``` The cursor is then set to `matchIndex + 1`. A non-matching entry (wrong hash, wrong routing, stale state) is **skipped**, not reverted on; the scan reverts `ExecutionNotFound` only at the end of the queue. A consumed entry with `success == false` runs, verifies, then reverts with its `returnData` — rolling back the cursor advance — so it is re-callable and only drops out of reach once a later successful consumption advances past it. There is no swap-and-pop. Deferred queue cursors advance independently. Entries can still depend on roots shared across rollups through `rollupUpdates`, so a missing transition can block another queue's entries. (During the transient phase the cursor is global, which is why `destinationRollupId == destRid` is load-bearing there.) ### D.2 Flat Call Processing Within an entry, the **top-level** calls live in the entry's own flat array (`l2ToL1Calls[]` on L1, `incomingCalls[]` on L2), and every reentrant frame carries its **own** flat sub-array. `_processL2ToL1Calls` (L1) / `_processIncomingCalls` (L2) walks whichever array it is given with a plain local index until completion or a gas-shortage early return — there is no shared global cursor, no `callCount`, and no partition invariant. Proofs must exclude surviving failure markers; the loop alone does not guarantee dispatch of the entire array. A flat call dispatches via `CALL` normally, or via `STATICCALL` when `isStatic == true` (read-only; `value` must be 0 or the entry reverts `StaticCallWithValue`). Each call's identity hash folds into `CALL_BEGIN` and its `(success, retData)` outcome into `CALL_END` (§E) — a naturally-reverting destination is captured as `CALL_END(false, revertData)` without failing the entry. The rolling hash checks surviving recorded call identities, outcomes and nested boundaries (§H.4). Differences in those observations change the final hash. Ordinary rollback can erase observations, and successful nested saved answers are not directly folded into `NESTED_END`. ### D.3 Reentrant Calls (unified `ExpectedL1ToL2Call` table) When the destination contract called by `_processL2ToL1Calls` (L1) / `_processIncomingCalls` (L2) calls back into a proxy, the proxy forwards to `executeCrossChainCall`. Inside that call, `_insideExecution() == true`, so the path branches to `_consumeNestedCall` (L1 additionally checks the target rollup against the entry's proven set — `ReentrantDestinationNotVerified`). Resolution (L1 names; L2 mirrors with `expectedOutgoingCalls` / `expectedOutgoingHash` / `_lastOutgoingCallConsumed`): **1. Compute the position key** `expectedL1toL2Hash = keccak256(abi.encodePacked(crossChainCallHash, _rollingHash))` — the call's identity bound to the live rolling hash, which folds the recorded calls and nesting boundaries. An ordinary revert restores that hash, so an identical retry can reuse the key. **2. Strict forward scan** of the active entry's `expectedL1ToL2Calls[]` from `_lastL1ToL2CallConsumed`. The first key match IS the row; advance `_lastL1ToL2CallConsumed = i + 1` **before** resolving, then `_resolveNestedReentrant`: * `success == true`: fold `NESTED_BEGIN(crossChainCallHash)`, process the row's own `l2ToL1Calls[]` subject to gas checks (folding into the host's continuous hash), require zero `revertedOrStaticRollingHash` (`SuccessRowWithRevertedOrStaticHash` otherwise), fold `NESTED_END`, return `row.returnData` to the destination. * `success == false`: fold `NESTED_BEGIN(crossChainCallHash)`, run the row's own sub-array, check `_rollingHash == row.revertedOrStaticRollingHash` (else `RollingHashMismatch`), then revert with `row.returnData`. The destination's `try/catch` absorbs the revert; the EVM rolls back the frame's state, hash folds, and cursor advance automatically. **3. No match** → fold `CALL_NOT_FOUND(crossChainCallHash)` into `_rollingHash` and return `""`. If the marker survives against an honest marker-free expectation, the entry fails its rolling-hash check (`RollingHashMismatch`) at the `_executeEntry` boundary — `ContextResult` carries the divergence across deliberate revert spans; an ordinary enclosing revert rolls back the marker with the frame. (Returning empty bytes may also revert the *caller's* frame sooner if it ABI-decodes a non-empty payload.) **Why this works for reverts.** A reverting reentrant call needs **only** a `success == false` row — no `revertNextNCalls` wrapper: the destination's `try/catch` is the isolation boundary, the terminal revert restores the cursor and hash to the pre-call values, and the row's `revertedOrStaticRollingHash` pins exactly what the reverted frame executed. **Why static reads don't match here.** The key is computed with `isStatic = false`; a static row's `crossChainCallHash` folds `isStatic = true`, so the keys can never collide. The proxy routes real STATICCALL frames to `staticCrossChainCall` (§F), which scans the same unified table with the `isStatic = true` key and resolves via `_resolveStaticEntry` — without advancing the cursor (a read is position-pinned, not consumed). A reverted sub-execution reuses the host entry's table for its own reentrant calls (Solidity forbids recursive structs); the live `_rollingHash` folded into each key keeps the contexts distinct. #### D.3.1 Nested children must be laid out after their parent (forward-cursor layout) Reentrant rows in `expectedL1ToL2Calls[]` are content-addressed by `(crossChainCallHash, expectedRollingHash)` and located by a **strict forward scan** from `_lastL1ToL2CallConsumed`: a call only ever matches a row at or after the cursor, never one before it. The match is the rolling hash, not the array position — but the cursor governs *which slice of the table is still visible*. When a reentrant row at index `i` is resolved (`_resolveNestedReentrant`), the cursor is advanced to `i + 1` **before** the row's own sub-calls run, not after. This keeps the cursor monotonic: any reentrant call the sub-frame fires scans strictly forward from `i + 1`, and a successful frame leaves the cursor at its high-water mark with no restore step. (On revert the whole frame's writes — including this advance — roll back with the EVM, so the cursor returns to the parent's value automatically.) **Layout rule for provers/builders.** A mutable nested frame's own reentrant children must be placed in `expectedL1ToL2Calls[]` at indices **strictly greater than their parent row's index** — i.e. the table must be in depth-first / execution order. A child placed at an index `≤` its parent is below the cursor when the sub-frame scans, so it is never found: the reentrant call folds `CALL_NOT_FOUND` into the rolling hash and the entry reverts at its boundary (`RollingHashMismatch`). This is a *findability* constraint. Matching identity and hash context does not make a row a unique historical occurrence: enclosing rollback can expose the same key again; see [call-pattern limits](/call-pattern-limits). Sibling rows at the same nesting level are already forward-ordered by the same cursor discipline. ### D.4 Revert Span (`revertNextNCalls`) `revertNextNCalls > 0` on a flat call opens an isolated EVM context for the next `revertNextNCalls` calls (that call included). Mechanism: 1. `_processL2ToL1Calls` (L1) / `_processIncomingCalls` (L2) checks the span fits (`RevertSpanOutOfBounds` otherwise), zeroes the trigger's `revertNextNCalls` in its throwaway memory copy, and slices the span (`_sliceL2ToL1Calls` / `_sliceCrossChainCalls`) into a fresh memory array — so the isolated re-run reads the trigger as a normal call instead of recursing into the span. 2. `try this.executeInContextAndRevert(span)`. The inner self-call runs `_processL2ToL1Calls(span)` — advancing `_rollingHash` and `_lastL1ToL2CallConsumed` based on the calls inside the span — then, if processing returns normally, reverts with `ContextResult(_rollingHash, _lastL1ToL2CallConsumed)` (L2: `_processIncomingCalls(span)` and `_lastOutgoingCallConsumed`). 3. The EVM revert rolls back all storage and transient state inside the self-call, including any ETH transfers and their `_entryEtherDelta` subtractions. The values escape via the revert data. 4. The caller decodes `ContextResult` and writes the rolling hash and reentrant cursor back into transient storage. A reentrant no-match observed inside the span was folded into that rolling hash (`CALL_NOT_FOUND`), so it still surfaces at the entry boundary. The restored hash records the span's execution outcomes while its state effects remain rolled back. 5. The caller emits `CallsReverted(entryIndex, start, n)` and skips its local index past the span. If processing instead aborts with a validation error, the payload decoder rejects a non-`ContextResult` revert with `UnexpectedContextRevert`; no successful span recovery occurs. A single mechanism handles atomic rollback: there are no continuation entries, no per-rollup root restoration, no scope tree to navigate. The "what happened" is encoded by the calls in the span; the "what state survives" is whatever the EVM rolled back. #### D.4.1 When to use `revertNextNCalls` (and when not to) **Use `revertNextNCalls > 0` only for forced rollback of a span** — its state effects must not survive, while individual calls may succeed or fail. The canonical scenario is a cross-chain call from rollup A to rollup B where the destination on B succeeds, but the prover output records that the call must be rolled back (for example, because the higher-level transaction that contained the call was reverted on A). For delivered work such as L2→L1 calls, the rolling hash commits to `CALL_END(success=true, retData=…)` while the EVM rolls the state back; failed calls elsewhere in a span retain their actual failure outcomes. L1→L2 calls erased by a source-frame revert are instead lookups with no L2 delivery under the integration rule in §A.1. **Do not use it just to model a destination that naturally reverts.** With `revertNextNCalls = 0`, `_processL2ToL1Calls` (L1) / `_processIncomingCalls` (L2) already invokes the destination via the proxy's `.call`, captures `(success=false, retData=revertReason)`, and hashes that into `CALL_END`. The destination's own revert rolls back its state. An added span also rolls back surrounding effects such as source-proxy creation and interior logs, and changes gas usage; it is not generally equivalent to the plain naturally reverting call. The protocol's revert paths are distinct — the full situation → structure decision table is `EXECUTION_ENTRY_SPEC.md` § "When to use which structure". In particular, a **reentrant** call that reverts (caller wraps it in `try/catch`) is a `success == false` row in the unified reentrant table (§D.3) and **must not** use `revertNextNCalls`: the destination's `try/catch` already provides the isolation boundary, and the row runs the reverted frame with its own sub-hash check. Wrapping it in a span would consume flat-call slots the prover did not allocate. ### D.5 Flat Call Model The off-chain prover emits, per entry, a flat top-level `l2ToL1Calls[]` array plus the unified `ExpectedL1ToL2Call[]` table (L2: `incomingCalls[]` / `ExpectedOutgoingCrossChainCall[]`) in which each reentrant frame carries its own flat sub-array — it does not thread scope arrays through nested calls and does not partition one shared array. Return data from a call is captured directly into the rolling hash via `CALL_END`; natural failures are captured via `success=false` in the same tag. `revertNextNCalls` is reserved for forced-revert spans where state must be rolled back regardless of individual success/failure outcomes. *** ## E. Rolling Hash A single `bytes32 rollingHash` per entry covers its identity, L1 starting roots, and surviving tagged call observations, including explicit rollback spans carried by `ContextResult`. Ordinary enclosing reverts erase their interior folds. Successful nested saved answers are not included in `NESTED_END`. The six tag constants and the fold helpers (`_rollingHashEntryBegin` / `_rollingHashCallBegin` / `_rollingHashCallEnd` / `_rollingHashNestedBegin` / `_rollingHashNestedEnd` / `_rollingHashCallNotFound` / `_rollingHashCallInsufficientGas`) live on `EEZBase` and are **protocol constants shared by both chains** — the `CALL_*` / `NESTED_*` tags are the neutral rolling-hash frame vocabulary, not a direction. The accumulator is **seeded** at entry start (it must be `bytes32(0)` beforehand, else `RollingHashNotCleared`): ```text // L1 (_rollingHashEntryBegin): ordered (rollupId, currentRoot) state context, closed with identity seed = keccak256(…keccak256(bytes32(0) ‖ rollupId_1 ‖ currentRoot_1)… ‖ rollupId_n ‖ currentRoot_n) _rollingHash = keccak256(abi.encodePacked(seed, proxyEntryHash)) // L2 (_seedRollingHash): same formula with an empty state-delta prefix _rollingHash = keccak256(abi.encodePacked(bytes32(0), proxyEntryHash)) ``` so the hash binds the entry's STARTING STATE + identity, not just call results (nested frames inherit it transitively). It is then updated at six tagged hash transitions (these are not Solidity log events) (all `abi.encodePacked`): ```text CALL_BEGIN = uint8(1) _rollingHash = keccak256(_rollingHash ‖ CALL_BEGIN ‖ crossChainCallHash) CALL_END = uint8(2) _rollingHash = keccak256(_rollingHash ‖ CALL_END ‖ success ‖ retData) NESTED_BEGIN = uint8(3) _rollingHash = keccak256(_rollingHash ‖ NESTED_BEGIN ‖ crossChainCallHash) NESTED_END = uint8(4) _rollingHash = keccak256(_rollingHash ‖ NESTED_END) CALL_NOT_FOUND = uint8(5) _rollingHash = keccak256(_rollingHash ‖ CALL_NOT_FOUND ‖ crossChainCallHash) CALL_INSUFFICIENT_GAS = uint8(6) _rollingHash = keccak256(_rollingHash ‖ CALL_INSUFFICIENT_GAS) ``` `CALL_BEGIN` binds the executed call's full identity (its eight-field `crossChainCallHash`, §C.5) so the hash commits to *which* call ran, not just its result. `NESTED_BEGIN` binds the reentrant call's identity the same way. **No call/frame index is folded in**: `_rollingHash` is a chain (each fold depends on the prior value), so order, count, and nesting are already bound by the chain + the tags; omitting an explicit index is also what lets a `revertNextNCalls` span be processed as a 0-based sub-slice without diverging the hash from a continuous run. `CALL_NOT_FOUND` is folded when a reentrant call finds no matching row in the unified table. The dedicated tag distinguishes a surviving miss from a normal empty return in the hash. The proxy still returns success with empty data, which an application must not treat as remote approval if an enclosing revert later erases the marker. The divergence is caught at the entry's rolling-hash check if the marker remains in the committed trace (`ContextResult` carries it across deliberate revert spans; ordinary enclosing reverts undo it), so no side flag is needed. Valid proofs MUST exclude both `CALL_NOT_FOUND` and `CALL_INSUFFICIENT_GAS` from expected entry and sub-hashes (§H.4). An accepting mock or trusted loader can supply a marker-containing expected hash; matching it is not evidence of a valid production proof. Ordinary enclosing reverts erase marker writes, even if an intervening catch continued execution. After all calls and nestings complete: ```solidity require(_rollingHash == entry.rollingHash); // RollingHashMismatch ``` A difference in the surviving hashed observations changes the final hash. This detects: * Wrong return data recorded by `CALL_END` * Wrong success/failure flags recorded by `CALL_END` * Missing or extra calls * A skipped or extra reentrant frame (missing/extra `NESTED_BEGIN`/`NESTED_END`) * Reordered recorded operations within the same entry * A surviving reentrant miss (`CALL_NOT_FOUND`) or dispatch shortage (`CALL_INSUFFICIENT_GAS`), against a valid marker-free expected hash ### E.1 Rolling Hash and `revertNextNCalls` Inside a force-revert span, the inner self-call updates `_rollingHash` exactly as if the calls were normal (`CALL_BEGIN`/`CALL_END` for each, including failed ones). The `ContextResult` revert payload carries the post-span hash value back out, so the outer flow's rolling hash reflects that the span happened — even though the EVM rolled back the state changes the calls produced. This is essential: the proof's `rollingHash` must commit to the calls regardless of whether their state effects survived. The mechanism relies on EIP-1153 `tload` / `tstore` semantics: * `tload` is **read-only**, so it works inside a STATICCALL context **and** inside a self-call that is about to revert. The inner self-call therefore observes the outer `_rollingHash` and cursor values when it starts. * `tstore` writes are part of the EVM journal and are **rolled back** when the call frame reverts. So when `executeInContextAndRevert` reverts with `ContextResult`, every transient write performed inside the span (including the rolling-hash updates, the reentrant-cursor advances, and the `_entryEtherDelta` subtractions) is undone — except for the values that escape via the revert payload (the rolling hash and the reentrant cursor), which the caller manually re-applies after decoding. ### E.2 Static Sub-Hash (`rollingHash` / `revertedOrStaticRollingHash`) Every static resolution carries its own expected hash — **a separate accumulator**, scoped to that static entry/row and computed over its read-only sub-call array. It is **not** the entry-level `_rollingHash`. The scheme is deliberately simpler and **untagged**, computed by `_processStaticL2ToL1Calls` on L1 and `_processStaticIncomingCalls` on L2: ```solidity hash = bytes32(0) for cc in calls: sourceProxy = computeCrossChainProxyAddress(cc.sourceAddress, cc.sourceRollupId) (success, retData) = sourceProxy.staticcall(executeOnBehalf(cc.targetAddress, cc.gas, cc.data)) hash = keccak256(abi.encodePacked(hash, success, retData)) if (hash != expected) return false; // try the next static candidate ``` The `expected` value is `StaticExecutionEntry.rollingHash` for a top-level static entry, or `ExpectedL1ToL2Call.revertedOrStaticRollingHash` for a static row inside the unified reentrant table. The differences from the entry-level scheme: * **No event tags** (no `CALL_BEGIN` / `CALL_END` / `NESTED_BEGIN` / `NESTED_END` domain bytes) and no call-identity folds. * **No nested-frame tags in this accumulator** — read-only callbacks can reenter `staticCrossChainCall` and resolve independently; STATICCALL forbids state writes, so the proxies' `executeOnBehalf` paths cannot reenter the manager's mutating entrypoints. A reentrant read during resolution re-enters `staticCrossChainCall` and resolves independently. * **No proxy creation and no `revertNextNCalls`** — nothing mutates. This simpler schema is safe because the surrounding key already pins the context that tagged events disambiguate at entry level: a nested static row is content-addressed by `keccak256(crossChainCallHash, _rollingHash-at-fire-point)` within its entry, and a top-level static entry by `proxyEntryHash` + queue routing + (L1) the `expectedRoots[]` pins. The only thing left for the static hash to commit to is the **outcome of the read-only sub-calls, in order**, which is exactly what the untagged `keccak256(prev, success, retData)` chain captures. There is also no cross-contamination with the entry-level accumulator: `_processStaticL2ToL1Calls` returns a local hash and never reads or writes `_rollingHash`. Note the distinction inside the unified table: a **static** row's sub-array runs via STATICCALL under the untagged schema, while a **reverted call** row's sub-array runs as real calls under the tagged entry schema (its `revertedOrStaticRollingHash` is compared against the live `_rollingHash` at the end of the frame, which was seeded from the host hash at `NESTED_BEGIN`). The `isStatic` bit folded into `crossChainCallHash` decides which resolution path can reach the row. ### E.3 Worked Hash Chain Example Setup (L1 names; the L2 chain is identical with `incomingCalls` / `expectedOutgoingCalls` / `_lastOutgoingCallConsumed` and the delta-less seed): ```text entry.rollupUpdates = [ d0 ] entry.proxyEntryHash = P entry.l2ToL1Calls = [c0, c1] // top-level calls entry.expectedL1ToL2Calls = [ R0 ] R0 = { expectedL1toL2Hash = keccak256(H_nested ‖ S1), // computed by the prover in simulation l2ToL1Calls = [n0], // the frame's own sub-call success = true, returnData = 0xaa } entry.rollingHash = ``` While c0 executes, the destination contract re-enters a proxy with reentrant call hash `H_nested`; the frame runs `n0`, then returns `0xaa`. Step-by-step: ```text Seed: seed = keccak256(bytes32(0) ‖ d0.rollupId ‖ d0.currentRoot) S0 = keccak256(seed ‖ P) // _rollingHashEntryBegin _lastL1ToL2CallConsumed = 0 ─── _processL2ToL1Calls([c0, c1]), i = 0 ───────────── hash CALL_BEGIN with c0's identity hash ccHash(c0): S1 = keccak256(S0 ‖ uint8(1) ‖ ccHash(c0)) Execute c0 via the source proxy. During c0, the destination re-enters a proxy → executeCrossChainCall → _insideExecution() == true → _consumeNestedCall(H_nested): key = keccak256(H_nested ‖ S1) // live _rollingHash is S1 forward scan from cursor 0 → matches R0.expectedL1toL2Hash _lastL1ToL2CallConsumed = 1 // advanced BEFORE the frame runs hash NESTED_BEGIN(H_nested): S2 = keccak256(S1 ‖ uint8(3) ‖ H_nested) _processL2ToL1Calls([n0]): // the frame's OWN sub-array hash CALL_BEGIN(ccHash(n0)): S3 = keccak256(S2 ‖ uint8(1) ‖ ccHash(n0)) Execute n0. Succeeds with retData_n0. hash CALL_END(true, retData_n0): S4 = keccak256(S3 ‖ uint8(2) ‖ true ‖ retData_n0) hash NESTED_END: S5 = keccak256(S4 ‖ uint8(4)) return R0.returnData (0xaa) to the destination c0's proxy call returns. Proxy reports success and retData_0. hash CALL_END(true, retData_0): S6 = keccak256(S5 ‖ uint8(2) ‖ true ‖ retData_0) ─── _processL2ToL1Calls([c0, c1]), i = 1 ───────────── hash CALL_BEGIN(ccHash(c1)): S7 = keccak256(S6 ‖ uint8(1) ‖ ccHash(c1)) Execute c1. Succeeds with retData_1. hash CALL_END(true, retData_1): S8 = keccak256(S7 ‖ uint8(2) ‖ true ‖ retData_1) ─── Verification ───────────── _rollingHash (S8) == entry.rollingHash → RollingHashMismatch? no ether invariant: totalEtherDelta == _entryEtherDelta (L1) _rollingHash reset to 0; allowed-rollups set deleted ``` Hash chain summary: ```text S0 = keccak(keccak(0 ‖ rid ‖ currentRoot) ‖ P) S1 = keccak(S0 ‖ CALL_BEGIN ‖ ccHash(c0)) S2 = keccak(S1 ‖ NESTED_BEGIN ‖ H_nested) S3 = keccak(S2 ‖ CALL_BEGIN ‖ ccHash(n0)) S4 = keccak(S3 ‖ CALL_END ‖ true ‖ retData_n0) S5 = keccak(S4 ‖ NESTED_END) S6 = keccak(S5 ‖ CALL_END ‖ true ‖ retData_0) S7 = keccak(S6 ‖ CALL_BEGIN ‖ ccHash(c1)) S8 = keccak(S7 ‖ CALL_END ‖ true ‖ retData_1) require(S8 == entry.rollingHash) ``` ### E.4 Multiple Reads Within One Call (Static Disambiguation) A single call iteration can issue several STATICCALLs at distinct points of its execution, possibly with the same `crossChainCallHash`. The disambiguating coordinate is the live `_rollingHash` folded into each row's position key: an intervening recorded fold (`CALL_BEGIN`, `CALL_END` or a nested frame) changes `_rollingHash`, so the two reads compute different keys and match different rows. Example: while `entry.l2ToL1Calls[0]` executes (live hash `S1` after its `CALL_BEGIN`), the destination contract: ```text STATICCALL to proxy → staticCrossChainCall → key = keccak256(H_read ‖ S1) → matches the row the prover stamped for this point Trigger reentrant call (a successful reentrant frame) → _consumeNestedCall matches at key keccak256(H_nested ‖ S1); the frame's folds move the hash to S5 STATICCALL to proxy → staticCrossChainCall → key = keccak256(H_read ‖ S5) → a DIFFERENT row, even for the same H_read ``` Two reads with the same `crossChainCallHash` and **no** intervening fold compute the same key. Lookup tries sibling rows until the callback-result hash matches, allowing local writes to distinguish reads without a host-hash fold. If incompatible candidates both validate, the first wins; builders must reject or restructure that ambiguity. L2 top-level selection also pins `expectedEntryIndex == entryIndex`. The builder records the live `_rollingHash` at each STATICCALL and stamps `keccak256(crossChainCallHash, thatHash)` into each corresponding row. The forward window still applies: every candidate must be at an index `>= _lastL1ToL2CallConsumed` at read time (§D.3.1); static reads do not advance that cursor. *** ## F. Static Entry Resolution Static reads and reverting mutable reentrant calls both use cached outcomes, but execute their local callbacks differently. Two resolution paths: 1. **`staticCrossChainCall`** (external view) — used when the proxy's `_fallback` detects a real STATICCALL context (a self-call to `staticCheck` whose `tstore` reverts). Inside an execution it scans the active entry's unified reentrant table with an `isStatic = true` key; outside, the top-level static-entry pool (L1: `proxyEntryHash` + `destinationRollupId` + live root pins; L2: `proxyEntryHash` + `expectedEntryIndex == entryIndex`). 2. **Reverted-frame resolution inside `_consumeNestedCall`** — not a read, but the revert twin: a normal-CALL reentrant call matching a `success == false` row runs the frame and reverts with the cached payload (§D.3). It shares the unified table, keyed with `isStatic = false`. The full specification — match keys, resolution mechanics, static sub-calls, root pins, L1/L2 differences, invariants — lives in **`docs/STATIC_ENTRY.md`**. Within this document: the `staticCrossChainCall` pseudocode is in §B.1, the untagged static accumulator in §E.2, and static key disambiguation in §E.4. For which structure models which situation, see `EXECUTION_ENTRY_SPEC.md` § "When to use which structure". *** ## G. Execution Entry Lifecycle ### G.1 L1 Posting `postAndVerifyBatch` resets each touched rollup's `verificationByRollup[rid]` (reset queue bounds and cursor on every verify), drains the leading immediate L2Txs straight from calldata, loads the meta-hook remainder of the immediate prefix into `_transientEntries` / `_transientStaticEntries` and runs the meta hook, publishes the batch's remainder into the per-rollup queues unconditionally, then wipes the transient tables. Within a single `postAndVerifyBatch`: 1. Reentry guard: `_insideExecution() || _transientEntriesLength != 0` (revert `PostBatchReentry` when either is true). Check composer root pins. Validate structure (including per-entry proxy-protection). Verify proofs (`checkProofSystemsAndGetVkeys` + `getCustomData` per rollup — both `view` — then `IProofSystem.verify` per PS). 2. Mark `verificationByRollup[rid].lastVerifiedBlock = block.number` for every touched rollup; reset each touched rollup's queue bounds / cursor on every verify (`_markVerifiedBlockAndResetQueues`). 3. Drain the leading run of immediate L2Txs (`proxyEntryHash == 0`) via try/catch self-calls (skip-on-revert with `L2TxSkipped`; `AllImmediateL2TxsFailed` if a non-empty run had zero successes). 4. Meta hook runs if immediate-prefix entries remain past the L2Tx run (`msg.sender` must have code — `MetaEntriesWithoutReceiver` otherwise) — the remainder is pushed to the transient tables first (consumption advances the global `_transientEntryIndex`; per-rollup cursors stay untouched until persistent consumption). 5. Publish the batch's remainder to per-rollup queues by `destinationRollupId` (unconditionally — even if the transient prefix wasn't fully drained; there is no drain-cleanly gate). `RollupUpdate.currentRoot` enforces rollup state requirements at consumption, with the ordering limits in §H.8. 6. Wipe transient tables; reset `_transientEntryIndex`. Emit `BatchPosted(sharedPublicInput, rollupIds)`. ### G.2 L2 Loading `loadExecutionTable` resets `entriesLength`, `staticEntriesLength` and `entryIndex`, overwrites the active prefixes of the `entries` and `staticEntries` mappings, and sets `lastLoadBlock = block.number`. Inactive rows remain in storage but are never searched. `executeIncomingCrossChainCall` performs the same load atomically before driving `entries[0]`. There is no transient table and no per-rollup queue map on L2. ### G.3 Consumption Forward-scan — per consumption, the cursor jumps to `matchIndex + 1` (per-rollup `entryQueueIndex`, the global `_transientEntryIndex` during the transient phase, or L2's `entryIndex`). Each row can commit at most once within its current queue/table generation; entries skipped by a committed scan (stale state, non-matching hash) are permanently passed over. A `success == false` entry's revert rolls the cursor advance back, so it stays reachable until a later successful consumption advances past it. There is no swap-and-pop. ### G.4 Same-Block Restriction Execution and static lookup tables are valid only in the block they were posted or loaded. L1 requires `verificationByRollup[rollupId].lastVerifiedBlock == block.number`; L2 uses `lastLoadBlock == block.number`. Expired tables are inaccessible, and posting/loading a new table replaces the previous one. ### G.5 Table Replacement Each `postAndVerifyBatch` resets the touched rollups' queue bounds and cursors (on every verify, including same-block re-verifies); `loadExecutionTable` resets the L2 active lengths and cursor, then overwrites slots from zero without deleting inactive rows. Builders must produce self-contained batches. *** ## H. Invariants ### H.1 Root Consistency (L1) `rollups[id].root` is initialized by `registerRollup`. Thereafter it is updated only: * By `_applyRollupUpdates` (during `_executeEntry`, reached from `postAndVerifyBatch`'s immediate run, the meta hook, `executeCrossChainCall`, or `executeL2Txs`). * By `setRoot(rid, newRoot)` from the rollup's manager (subject to the same-block lockout and the mid-execution guard). The previous-state binding lives on the entry: `RollupUpdate.currentRoot` is checked both as part of the consumption match (`_entryMatches`) and as a hard gate at `_executeEntry` start (`RootMismatch(id)` — the gate that covers the immediate L2Tx path, which doesn't pass through `_entryMatches`). `newRoot` applies only **after** the entry's calls, so mid-execution reads see the pre-state. The proof binds to the FULL `ExecutionEntry` struct (including `rollupUpdates`). Even after proof acceptance, an entry cannot execute if its required rollup roots do not match. This check does not cover other L1 contract state or determine the order across different rollup queues. ### H.2 Ether Accounting (L1) For each entry: `totalEtherDelta == _entryEtherDelta`, where `totalEtherDelta` is the sum of the entry's `rollupUpdates[].etherDelta` and `_entryEtherDelta` is the entry's net physical flow — ALL `msg.value` received via `executeCrossChainCall` during the entry (the entry-point call's value plus every reentrant call's; 0 for `executeL2Txs` and immediate entries unless reentrant receipts occur) MINUS the `value` of every **successful** non-static outbound call. Calls inside a `revertNextNCalls` span contribute to neither side — their ETH transfers and their `_entryEtherDelta` subtractions are rolled back by the span's revert, while the rolling hash still records their actual success/failure outcomes. Each entry independently balances — ether accounting is localized to a single entry rather than aggregated across the transaction — which simplifies the prover's job. At successful L1 entry boundaries, protocol-accounted flows satisfy `Δ held ETH = Σ etherDelta` and preserve `surplus = address(EEZ).balance − Σ rollups[rid].etherBalance`. Deposits increase both held ETH and liabilities. Direct or forced ETH can increase surplus. Temporary transfers inside an entry need not satisfy this boundary relation. #### L2 funding and inventory L2 holds pooled system funding without a per-table ledger. Loads add `msg.value`, incoming calls spend the pool, and outgoing calls transfer their value to `SYSTEM_ADDRESS`. Replacing tables retains unused ETH; reverted transfers unwind with their enclosing frame. Proxy recovery sends prefunded ETH to `RECOVERY_ADDRESS`. The node/circuit must account for opening inventory, funding or issuance, actual payouts, system transfers, external balance changes and closing inventory. It must also define how unused funds are retained or reused. No per-load equality or issuance/burning rule is enforced by the Solidity manager. ### H.3 Forward-Scan Consumption Within one queue generation, successful consumption advances the destination rollup's cursor beyond the selected entry. A reverted consumption restores the cursor and can be retried. Each new verification resets that rollup's active bounds and cursor. Skipped entries are not executed, and later entries must still match their live root pins. **Roots prevent execution replay.** Roots commit to block history and advancing transaction nonces. Every successful entry must advance at least one pinned root, and manager updates must never restore old roots. These rollup/prover requirements keep consumed entries ineligible after re-posting, even with `bindMsgSenderInPublicInput == false`; queue cursors alone do not. Solidity checks current-root equality but does not enforce root advancement or prevent a manager from restoring an old root. ### H.4 Rolling Hash Integrity At entry validation, before clearing transient state: ```solidity _rollingHash == entry.rollingHash // RollingHashMismatch ``` Against a valid expected hash, this check attests the surviving tagged execution observations, including explicit rollback spans transported by `ContextResult`. It does not attest observations erased by ordinary enclosing reverts, all local state changes, or the saved answer returned by a successful nested row. `NESTED_END` carries no answer bytes and successful rows have no candidate-specific callback-hash check. See the [stale nested-answer concern](/call-pattern-limits#a-successful-nested-retry-can-return-a-stale-answer-without-a-hash-mismatch). There is no flat-call cursor check (local arrays may stop on gas shortage; valid expected hashes exclude failure markers) and no reentrant table-length check (the unified table mixes call rows with static rows; completeness of the success rows is enforced by the hash — a skipped frame omits its `NESTED` folds — and an unused row is inert). **Prover requirement:** valid entries and expected sub-hashes must never include `CALL_INSUFFICIENT_GAS` or `CALL_NOT_FOUND`. Certifying either is malicious prover behavior. These are failure markers, not permitted successful proof outcomes. The markers deliberately diverge from an honest expected hash, causing validation to revert the enclosing execution and its effects from the start. This is best-effort protection: an ordinary enclosing revert erases the marker, while `ContextResult` carries it through deliberate rollback spans. The hash mechanism does not independently enforce an honest prover; the proof system must reject expected hashes containing these paths. ### H.5 Proxy Determinism The proxy address for a `(originalAddress, originalRollupId)` pair is fully determined by: * The deploying EEZ contract (the proxy captures it from `msg.sender`; there are no constructor arguments) * Salt: `keccak256(abi.encodePacked(originalRollupId, originalAddress))` — the ONLY place the pair enters the derivation; all proxies on a manager share identical init code * `CrossChainProxy` creation code For a fixed manager and proxy init-code hash, the same pair maps to the same proxy address. With that fixed configuration, deploying the pair again would collide. An implementation upgrade that changes `PROXY_INIT_CODE_HASH` changes predicted addresses and can permit another proxy for the same pair; existing authorization storage is not automatically removed. A proxy for the manager's own network is unrepresentable (`SameNetworkProxy`). ### H.6 Same-Block Execution Execution and static lookups follow the same-block restriction (§G.4). ### H.7 Queue Replacement `postAndVerifyBatch` resets each touched rollup's `entryQueueLength` / `staticEntryQueueIndex` / `entryQueueIndex` without deleting stored entries on **every** verify — including same-block re-verifies, where the new batch REPLACES the prior one for that rollup. `loadExecutionTable` (L2) similarly resets `entriesLength`, `staticEntriesLength` and `entryIndex`, then overwrites active slots. Both lookup paths stop at their active bound. Reset cost is independent of the old table size; overwriting shorter nested arrays/bytes can still clear their old tails. Reverted incoming delivery restores the previous rows, bounds, cursor, load block and balances. Unconsumed entries from a prior block are unreachable (the `lastVerifiedBlock` read gate stops them even before the reset). ### H.8 Per-Rollup Queue Soundness The deferred remainder of a `postAndVerifyBatch` is published unconditionally to per-rollup queues. Partial transient drain does not drop persistent entries. Entries whose required rollup roots do not match at consumption are skipped by the match scan and, if reached directly (immediate path), revert `RootMismatch`. These checks enforce rollup state requirements under the [settlement model](#settlement-model). L1 execution determines the order across deferred queues; dependencies on other L1 state need their own checks. ### H.9 Reentry Guard for `postAndVerifyBatch` `postAndVerifyBatch` called during an active entry or meta-hook window reverts `PostBatchReentry` — the guard is `_insideExecution() || _transientEntriesLength != 0` at the top of the function (there is no separate `_inPostBatch` flag). Within posting, the two terms cover the execution/hook windows: `_insideExecution()` during the immediate L2Tx run and any executing entry, `_transientEntriesLength != 0` during the meta hook. The remaining external calls before the immediate run (`checkProofSystemsAndGetVkeys`, `getCustomData`, `verify`) are all `view`/STATICCALL, so a reentrant batch cannot complete state writes there; its error need not be `PostBatchReentry`. An ordinary registration callback outside execution/hook context is not covered by this guard and may post an otherwise valid batch. Posting remains permissionless and all normal checks still apply. Same-block re-touch of a rollup across separate (non-nested) `postAndVerifyBatch` calls is **permitted**: `_markVerifiedBlockAndResetQueues` resets the rollup's queue bounds and cursor on every verify, so the second batch's entries REPLACE the first batch's. Discarded entries do not apply their root transitions. Later entries are blocked if a missing transition leaves a required rollup root unmatched. Dependencies on other L1 state need their own checks. *** ## I. Security Considerations ### I.1 Multi-Prover Verification `postAndVerifyBatch` verifies one proof per `(batch, proofSystem)` pair. Each proof's public-inputs hash covers (see `MULTI_PROVER_SPEC.md` for the exact construction): * Every entry hash — `keccak256(abi.encode(entry))` over the FULL `ExecutionEntry` struct (including `rollupUpdates` with `currentRoot`, `proxyEntryHash`, `l2ToL1Calls`, the unified `expectedL1ToL2Calls`, `rollingHash`, `destinationRollupId`, `success`, `returnData`). * Every static-entry hash — `keccak256(abi.encode(staticEntry))`. * Every selected blob hash (for data availability). * Per-rollup `customData` blob fetched via `IRollupContract.getCustomData(batch.blockNumber)` (rollup-defined L1-view commitment), hashed keyed by rollupId and bound into `sharedPublicInput` (shared across all PS, not per-PS). * `keccak256(callData)`. * `boundSender` — `msg.sender` when `bindMsgSenderInPublicInput`, else `address(0)`. * Per PS: the rolling `(rollupId, vkey)` accumulator over the rollups that selected that PS. All proofs in the batch verify atomically — a single failure reverts. Per-rollup attestation is enforced inside each manager's `checkProofSystemsAndGetVkeys`, which reverts (threshold-not-met / unknown-PS / zero-vkey) if the resolved subset for `rid` is insufficient. A malicious caller producing a forged batch would have to forge proofs from every required PS for every touched rollup at the current rollup states. ### I.2 Reentrancy The protocol is intentionally reentrant. `_processL2ToL1Calls` (L1) / `_processIncomingCalls` (L2) calls into proxies, which forward to destination contracts, which may call back into proxies. Reentrant calls are routed to `_consumeNestedCall` via the `_insideExecution()` check, and resolve against the executing entry's unified table with rolling-hash-pinned keys — the forward-scan cursor plus the chained hash serialize everything within one entry. `postAndVerifyBatch` re-entry is blocked by the compound guard described in §H.9. The three view-only external calls during proof verification (`IRollupContract.checkProofSystemsAndGetVkeys`, `IRollupContract.getCustomData`, and `IProofSystem.verify`) are made inside `STATICCALL` frames, so a malicious manager / verifier cannot mutate state during verification. ### I.3 Untrusted Meta Hook `postAndVerifyBatch` invokes `IMetaCrossChainReceiver(msg.sender).executeMetaCrossChainTransactions()` when non-L2Tx immediate entries remain (a codeless sender reverts `MetaEntriesWithoutReceiver` — the never-persisted prefix would be silently dropped). The hook is untrusted — it may revert, consume some entries, ignore the call, or attempt to re-enter `postAndVerifyBatch`. The protocol's defense: * **Re-entry**: execution/hook windows are guarded by `PostBatchReentry`; static verification and registration callbacks have different rules — see §H.9 / I.2. * **Partial consumption**: the persistent remainder is published anyway; `RollupUpdate.currentRoot` checks rollup state requirements at consumption. Dependencies on other L1 state need their own checks. * **Hook revert**: the entire `postAndVerifyBatch` reverts (atomicity), which rolls back every write — `lastVerifiedBlock`, the queue resets, the immediate entries, the transient stream. The next caller can re-attempt cleanly. ### I.4 Access Control Summary | Function | Who can call | |---|---| | `registerRollup` | Anyone may call; the manager callback must authorize the registrant (the current owner for the reference Rollup) | | `postAndVerifyBatch` | Anyone (proofs verify authorization; `bindMsgSenderInPublicInput` can pin the submitter) | | `executeCrossChainCall` (L1 / L2) | Registered proxies only | | `executeL2Txs(rollupId)` (L1) | Anyone (must not be inside execution) | | `staticCrossChainCall` (L1 / L2) | Registered proxies only (called via STATICCALL by the proxy) | | `executeInContextAndRevert` (L1 / L2) | Self-call only (`address(this)`) | | `_attemptExecuteImmediateL2Txs(entry)` (L1, `external`) | Self-call only (`address(this)`) | | `createCrossChainProxy` (L1 / L2) | Anyone (subject to `SameNetworkProxy`) | | `setRoot(rid, newRoot)` (L1) | Current `rollups[rid].rollupContract` (subject to same-block lockout AND `SetRootNotAllowedDuringExecution`) | | `loadExecutionTable` (L2) | `SYSTEM_ADDRESS` | | `executeIncomingCrossChainCall(...)` (L2) | `SYSTEM_ADDRESS` | Per-rollup ownership operations (`addProofSystem`, `removeProofSystem`, `updateVerificationKey`, `setThreshold`, `transferOwnership`) live on the rollup's manager (`src/rollupContract/Rollup.sol` reference impl). ### I.5 Reentry Guard and Same-Block Touch Semantics `postAndVerifyBatch` reentry during the meta hook or an active entry is blocked by `PostBatchReentry`. Verification callbacks are static and cannot complete state-mutating posting, without guaranteeing this exact error. A registration callback outside those guarded windows can post a valid batch (§H.9). Multiple top-level `postAndVerifyBatch` calls hitting the same rollup in the same block are permitted by design: `_markVerifiedBlockAndResetQueues` resets the rollup's queue bounds and cursor on every verify, so the second batch fully SUPERSEDES the first for that rollup (no append). Builders cannot rely on once-per-block-per-rollup exclusivity; if exclusivity is required, the orchestrator must enforce it at the social layer. ### I.6 Cross-Chain Proxy Identity A `CrossChainProxy` represents exactly one `(originalAddress, originalRollupId)` pair. When the manager calls a destination via `CrossChainProxy.executeOnBehalf`, the destination sees `msg.sender = proxy address` (deterministic from the pair). The cross-chain identity is encoded by the pair, not by `msg.sender` alone — address prediction binds the manager address and proxy init-code hash as well as the remote pair. Integrations must use the expected manager and deployment artifact when authenticating that identity. ### I.7 Static Context Detection The `tstore` / `tload` asymmetry is the basis for STATICCALL detection: `tstore` reverts in static context, `tload` does not. The proxy's `_fallback` self-calls `staticCheck()` (which attempts `tstore`); if the self-call reverts, the proxy is in static context and routes to `staticCrossChainCall`. This works because: * The detector uses the CALL opcode, but inherits its parent's static restriction; it cannot enable writes. * With sufficient gas, the attempted `TSTORE` succeeds in mutable context and halts in static context. The self-call is bounded by the manager's `STATIC_CHECK_GAS` (currently 1,000); probe failure alone does not distinguish a static restriction from insufficient probe gas. A misbehaving destination contract that suppresses the static context would still hit `staticCrossChainCall` via this detector — there is no way to fake a non-static context from inside a STATICCALL. ### I.8 Forward-Scan Matching and Retry Limits `_consumeAndExecuteEntry` advances the cursor only past an entry that fully matches — identity (`proxyEntryHash`), routing (`destinationRollupId`), and live state preconditions. A builder error or a hook error that triggers an unexpected call either skips forward to a genuine match or reverts `ExecutionNotFound` cleanly at the end of the queue. A `success == false` entry's post-verification revert rolls its cursor advance back, so a repeated same-key call can select the same row. Mutable lookup does not retry another candidate after a hash mismatch, and the key does not distinguish local writes or historical occurrences erased by rollback. See [call-pattern limits](/call-pattern-limits). ### I.9 Rolling Hash as Integrity Backbone The `_rollingHash == entry.rollingHash` check compares the recorded calls and their results within one entry. The hash starts from the entry's rollup roots and identity, then records call identities (`CALL_BEGIN`), outcomes (`CALL_END`), nested-call boundaries and surviving failure markers. Differences in these recorded observations change the hash and are rejected at the entry boundary, subject to the rollback limits in §H.4. The hash does not check the order between separate entries or guarantee a particular final L1 state. Different effects on L1 storage can produce the same recorded call results. ### I.10 Proxy Protection (L1) Every proxy an entry can drive must be backed by a rollup the entry actually proved: * **Validation-time (batch)**: every entry's `destinationRollupId` and every flat call's `sourceRollupId` (top-level + each reentrant frame's sub-calls) must be ∈ the entry's `rollupUpdates` rollups (`EntryDestinationNotInRollupUpdates` / `CallSourceNotVerified`); every static entry's `destinationRollupId` and sub-call sources must be ∈ its `expectedRoots` pins (`StaticEntryDestinationNotPinned` / `CallSourceNotVerified`). * **Runtime**: reentrant / static-read TARGETS carry no clear-text rollup field at post time, so `_consumeNestedCall` and `staticCrossChainCall` check the calling proxy's rollup against the executing entry's allowed transient set (`VerifiedRollupsTransient`) (its `rollupUpdates` rollupIds) — `ReentrantDestinationNotVerified` on failure. Together these ensure a batch cannot use a proxy of a rollup it did not verify, in any position of the execution tree. L2 has no equivalent (single rollup, trusted system-loaded tables). *** *End of specification. This document covers the flat sequential execution model with the unified reentrant table and static-entry pools.* # Execution Entry Specification An execution entry tells the contracts what a cross-chain call will do before it runs: the calls it makes, the results its reentrant calls get back, its return data, and a rolling hash that checks the run matched. This page covers how to build entries for both chains: * **L1:** `postAndVerifyBatch` verifies the batch's proofs, then runs or queues its entries. * **L2:** the system address loads entries with `loadExecutionTable`, or delivers an inbound call with `executeIncomingCrossChainCall`. Entries execute per frame, in order. Each entry processes its own top-level calls in sequence. Each reentrant frame has its own sub-calls inside the entry's single expected-reentrant table. One `rollingHash` checks the recorded observations that survive to the end. It doesn't directly hash every local state change or the saved results of successful nested calls, and an ordinary enclosing revert erases what was folded inside it. See [Core §H.4](/core-protocol-spec#h4-rolling-hash-integrity) and [call-pattern limits](/call-pattern-limits). *** ## Entry Structure ```solidity // L1 (src/interfaces/IEEZ.sol) struct ExecutionEntry { RollupUpdate[] rollupUpdates; // the entry's true state transition (≥1, enforced on-chain) bytes32 proxyEntryHash; // inbound proxy-entry call hash; bytes32(0) = L2Tx (immediate / executeL2Txs) L2ToL1Call[] l2ToL1Calls; // the entry's TOP-LEVEL calls only (reentrant frames carry their own) ExpectedL1ToL2Call[] expectedL1ToL2Calls; // unified reentrant table: SUCCESS / STATIC / REVERTED kinds bytes32 rollingHash; // expected rolling hash over all calls + nestings uint64 destinationRollupId; // routes to a per-rollup queue; must be ∈ rollupUpdates bool success; // false ⇒ the entry runs, is verified, then reverts with returnData bytes returnData; // pre-computed top-level return value (revert payload when !success) } // L2 (src/interfaces/IEEZL2.sol) — leaner: single rollup, no state deltas, no per-rollup routing struct ExecutionEntry { bytes32 proxyEntryHash; // hash of the inbound call (never bytes32(0) on L2) CrossChainCall[] incomingCalls; // the entry's TOP-LEVEL calls only ExpectedOutgoingCrossChainCall[] expectedOutgoingCalls; // unified reentrant (outgoing) table bytes32 rollingHash; bool success; bytes returnData; } ``` The L2 struct does **not** share L1's layout: `rollupUpdates` and `destinationRollupId` are L1-only fields and are dropped entirely on L2 (not just left empty). L2's vocabulary is self-relative directional — `incomingCalls` are cross-chain calls executed on this L2 for a remote caller, `expectedOutgoingCalls` are reentrant calls fired from this L2 — because the counterparty can be L1 or another L2, so absolute names like `l1ToL2Calls` would often be wrong. A top-level entry can **succeed or revert** — the `success` flag decides. When `success == true`, `executeCrossChainCall` returns `entry.returnData` regardless of inner-call outcomes (a naturally-reverting inner call is captured by `CALL_END(false, retData)` in the rolling hash). When `success == false`, the entry is fully executed and verified (rolling hash, plus the ether invariant on L1), then **everything is reverted** with `returnData` as the revert payload — state deltas, cursor advance, inbound value all roll back, and the caller may `try/catch` the revert. Reverting REENTRANT calls are `success == false` `ExpectedL1ToL2Call`s (L2: `ExpectedOutgoingCrossChainCall`s) inside the entry's unified table, and a top-level reverting **read** is a `StaticExecutionEntry` in the static pool. Exception — L1 L2Tx entries (`proxyEntryHash == 0`) have no proxy consumer and must be canonical: `success == true`, empty `returnData` (`L2TxEntryNotCanonical` checks this defensively). L2's inbound `entries[0]` stays fully general. Full constraint list: [CORE §A.1, Prover constraints](/core-protocol-spec#prover-constraints). ### Per-rollup queue routing `destinationRollupId` selects which rollup's queue this entry is loaded into during `postAndVerifyBatch`'s deferred publish. The central `EEZ` registry stores per-rollup queues (`verificationByRollup[rid].entryQueue` and `verificationByRollup[rid].staticEntryQueue`) with a per-rollup cursor (`entryQueueIndex`). Each `postAndVerifyBatch` call carries one `ProofSystemBatchPerVerificationEntries` payload covering one or more rollups; entries are routed by `destinationRollupId` into the matching rollup's queue. Validation requires `destinationRollupId` to be among the entry's own `rollupUpdates` rollups (`EntryDestinationNotInRollupUpdates`), so an entry can only be parked in a queue it actually proved. Deferred cursors advance independently, but entries can still depend on shared rollup roots; a missing transition can block entries in another queue. See `MULTI_PROVER_SPEC.md` for the multi-prover / per-rollup-queue specifics. ### IMMEDIATE entries (`proxyEntryHash == 0` — "L2Tx" entries) A leading run of the batch's immediate prefix (`entries[0..immediateEntryCount)`) may have `proxyEntryHash == 0`. These entries are the batch's immediate work: pure L2 transactions, or L2 transactions that touch L1. `postAndVerifyBatch` runs each one inline, **straight from calldata**. It is never SSTOREd whole; only its reentrant table is parked transiently. Each entry runs in a `try/catch` self-call (`_attemptExecuteImmediateL2Txs`) that processes its calls, applies state deltas and verifies the rolling hash, all within `postAndVerifyBatch`. A caught revert with non-empty data emits `L2TxSkipped(entryIndex, revertData)` and advances. Any later outer revert discards earlier skip logs. | Condition | Result | |---|---| | Caught revert with empty revert data (empty data does not prove OOG) | Aborts with `ImmediateL2TxOutOfGas(entryIndex)` before emission | | Every entry in a non-empty leading L2Tx run reverted | Whole post unwinds: `AllImmediateL2TxsFailed` | | `immediateEntryCount` leaves a zero-`proxyEntryHash` entry as the first deferred entry, even when that boundary follows proxy entries | Rejected at validation: `ImmediateCountStrandsLeadingL2Tx` | Immediate-prefix entries past the leading L2Tx run are meta-hook entries (see the Transaction Model section). ### Proxy-consumed entries and deferred L2Txs Loaded into `_transientEntries` (the immediate-prefix remainder, consumed via the meta hook) or into per-rollup `verificationByRollup[rid].entryQueue` (entries past `immediateEntryCount`). Consumed by `executeCrossChainCall` or `executeL2Txs(rollupId)`, and only in the block they were posted (`lastVerifiedBlock(rid) == block.number`; mismatch reverts `ExecutionNotInCurrentBlock`). Each call computes the expected cross-chain call hash from the proxy/call-site context (`executeL2Txs` expects `bytes32(0)`) and **forward-scans** the queue from the cursor for the first entry that fully matches (`_entryMatches`): identity (`proxyEntryHash`), routing (`destinationRollupId`), and state preconditions (every `RollupUpdate.currentRoot` equals the live root). Non-matching entries are skipped. A reverted row that still matches is retried and can shadow later rows with the same identity; no match by end of queue reverts `ExecutionNotFound`. While a batch is mid-flight, consumption is routed through `_transientEntries` with one **global** cursor (`_transientEntryIndex`) instead. Cursor advance is per-rollup on the persistent path. *** ## Action Hash A cross-chain call is identified by its `crossChainCallHash`, computed by the `public pure` helper `computeCrossChainCallHash` on either manager (defined in `EEZBase`). `callGas` is `0` at every keying site except L2 inbound binding (`incomingCalls[0].gas`) and calls LEAVING an L2 (`EEZL2.executeCrossChainCall` and `staticCrossChainCall`, top-level `proxyEntryHash` match and nested `expectedOutgoingHash` alike), where `USE_GAS_LEFT` selects the `gasleft()` sampled after proxy validation (else `0`). Gas-independent outgoing mode (`useGasLeft = false`) does not zero the inbound binding: always use `incomingCalls[0].gas` there. Formula, matching-site table, and off-chain helpers: `CORE_PROTOCOL_SPEC.md` §C. The on-chain contracts reconstruct the hash from the proxy's identity (`originalRollupId`, `originalAddress`) and the live call context (`msg.value`, `callData`, the proxy's caller, `MAINNET_ROLLUP_ID` on L1 or `ROLLUP_ID` on L2). ### Hash semantics by entry point | Entry point | isStatic | sourceAddress | sourceRollupId | targetAddress | targetRollupId | value | callGas | data | |---|---|---|---|---|---|---|---|---| | `executeCrossChainCall` (L1 proxy) | `false` | proxy's caller | `MAINNET_ROLLUP_ID` (0) | proxy's `originalAddress` | proxy's `originalRollupId` | `msg.value` | `0` | original calldata | | `executeCrossChainCall` (L2 proxy) | `false` | proxy's caller | this L2's `ROLLUP_ID` | proxy's `originalAddress` | proxy's `originalRollupId` | `msg.value` | **`gasleft()` if `USE_GAS_LEFT`, else `0`** | original calldata | | Reentrant call (matches an `ExpectedL1ToL2Call` on L1 / `ExpectedOutgoingCrossChainCall` on L2) | same as the `executeCrossChainCall` row for the chain making the call | same | same | same | same | same | same | same | | `executeL2Txs` | n/a — entry has `proxyEntryHash == 0` | — | — | — | — | — | — | — | | `staticCrossChainCall` (both chains) | `true` | proxy's caller | this chain's rollup ID | proxy's `originalAddress` | proxy's `originalRollupId` | `0` (static is value-free) | L1: `0`; L2: `uint64(gasleft())` if `USE_GAS_LEFT`, else `0` | original calldata | | `executeIncomingCrossChainCall` (L2, system) | `incomingCalls[0].isStatic` | `incomingCalls[0].sourceAddress` | `incomingCalls[0].sourceRollupId` | `incomingCalls[0].targetAddress` | this L2's `ROLLUP_ID` | `incomingCalls[0].value` | `incomingCalls[0].gas` (independent of `USE_GAS_LEFT`) | `incomingCalls[0].data` | | Flat-call `CALL_BEGIN` fold (both chains) | `cc.isStatic` | `cc.sourceAddress` | `cc.sourceRollupId` | `cc.targetAddress` | executing chain's ID | `cc.value` | `0` | `cc.data` | Delivered flat-call `CALL_BEGIN` always folds zero gas on both chains, even when the inbound entry identity folds a nonzero cap. Outgoing L2 mutable sampling follows proxy authorization, the block check and value transfer; outgoing L2 static sampling follows proxy authorization/storage reads. Use the canonical matrix in CORE §C; gas observation is not a state-version or occurrence identifier. The hash is fully determined by the fields above; nothing else (caller depth, parent frame, position in the entry) feeds into it. Position is pinned separately, by the rolling hash — see the reentrant table's `expectedL1toL2Hash` key below. ### Cross-chain hash consistency Source, inbound-binding and delivered-call hashes need not coincide for the same logical call. They coincide only when all encoded fields, including the site-specific gas value, agree: * **L1→L2 proxy call** generated by `executeCrossChainCall` on L1 (e.g., user calls B's proxy on L1) has `targetRollupId = L2`, `sourceRollupId = MAINNET (0)`. `executeIncomingCrossChainCall` on L2 recomputes the hash from `entries[0].incomingCalls[0]` (folding its own `gas`; the L1-side key folds `0`, so they coincide when `gas == 0`) and reverts `EntryHashMismatch` if `entries[0].proxyEntryHash` diverges; that the call matches what actually left L1 is an in-circuit obligation. * **Reentrant L2→L1 call** generated mid-execution on L2 (D calls C's proxy on L2) has `targetRollupId = MAINNET`, `sourceRollupId = L2`. L2 folds its outgoing key (`callGas` = observed gas under `USE_GAS_LEFT`, else `0`) into `NESTED_BEGIN` inside its reentrant frame, while L1 folds `callGas = 0` into `CALL_BEGIN` for the call it actually executes — so the two sides share a hash exactly when the L2 runs `useGasLeft = false` (gas-independent outgoing mode), and diverge only in the `callGas` term otherwise. The hash inputs alone determine the hash, with no positional or contextual term mixed in. Note the two chains' **rolling hashes** are NOT equal for a mirrored flow: each side folds only the calls that execute on it, and L1 additionally seeds its hash with the entry's state-delta context. The builder must simulate both sides to predict exact `data`, return data, and `value`. *** ## L2ToL1Call (L2: `CrossChainCall`) ```solidity struct L2ToL1Call { uint16 revertNextNCalls; // >0 = force-revert the next N calls' state effects (this one included) bool isStatic; // dispatch via STATICCALL (read-only; value must be 0) uint64 gas; // gas limit for the target call; 0 = forward all remaining gas address sourceAddress; // caller identity on the source rollup (used to derive sourceProxy) uint64 sourceRollupId; // caller's rollup ID address targetAddress; // contract to call on this chain uint256 value; // ETH sent bytes data; // calldata } ``` L2's `CrossChainCall` (`IEEZL2.sol`) is field-for-field identical; only the struct name differs. The processor (`_processL2ToL1Calls` on L1 / `_processIncomingCalls` on L2) walks the given call array by a plain local index. For each call outside a revert span: 1. Fold the call's identity (its `crossChainCallHash`, with `targetRollupId` = the executing chain's ID) into `CALL_BEGIN`, so the hash commits to *which* call ran, not just its result. 2. Derive the `sourceProxy` address from `(sourceAddress, sourceRollupId)`, auto-creating the proxy if it doesn't exist. 3. Route the call through `CrossChainProxy.executeOnBehalf(targetAddress, gas, data){value: value}`: a plain `.call`, or a `staticcall` when `isStatic`. The call's `gas` field caps the destination call; `0` forwards all remaining gas. 4. Hash the outcome into `CALL_END`. If the destination call itself reverts, the proxy's `.call` returns `(success=false, retData=revertReason)`, so natural reverts need no special wrapping. Two checks reject malformed calls: * A static call carrying value reverts `StaticCallWithValue`. * Every call's `sourceRollupId` must be in the entry's proven set (its `rollupUpdates` on L1), enforced at batch validation (`CallSourceNotVerified`). ### `revertNextNCalls`: forced-revert context `revertNextNCalls > 0` is the **forced-revert** mechanism: the next `revertNextNCalls` calls (including this one) execute, each succeeding or failing on its own, and have their state effects rolled back at the protocol layer. The rolling hash still commits to the calls' real outcomes (typically `success=true` with the captured `returnData`); state changes and interior logs disappear. Mechanically, the processor slices the span and self-calls `executeInContextAndRevert(span)`. If processing returns normally, it reverts with `ContextResult(rollingHash, reentrantConsumed)` — state rolls back, the rolling hash and reentrant cursor escape via the revert payload and are restored by the outer frame. An earlier validation error can instead abort the span without that transport. Full mechanics: `CORE_PROTOCOL_SPEC.md` §D.4. `revertNextNCalls` covers a contiguous run of calls within one call array (an entry's top-level array or one reentrant frame's own sub-array). The processor supports nested spans by recursion. The local blob/DSL generator preserves interior nested markers because an inner rollback can affect later calls before the outer rollback. When spans share a start, it keeps the already-rolled-back prefix and marks the remaining outer suffix separately. Exact source boundaries travel in translator sidecar metadata; this describes the local generator, not a production prover guarantee. #### When `revertNextNCalls` is the right tool A concrete use is an L2→L1 call whose L1 target succeeds but whose enclosing L2 application frame rolls back. The L1 L2Tx entry uses `revertNextNCalls = 1` to execute that call and discard its state effects. For L1→L2 calls erased by an enclosing L1 revert, the integration instead treats them as lookups and sends no L2 delivery; see [delivery units](#delivery-units-and-execution-transactions). For natural failures — a destination contract that simply `revert`s — `revertNextNCalls = 0` is correct and simpler: * The proxy `.call` returns `success=false` with the destination's revert payload as `retData`. * `CALL_END(false, retData)` is hashed into the rolling hash. * The destination's own revert rolls back the destination's state. A naturally reverting target normally needs no extra rollback span. Adding one introduces gas overhead and changes which surrounding effects survive: for example, auto-created source proxies and interior logs are also rolled back. The rolling hashes coincide only when the recorded call identities and outcomes remain the same. **Reentrant reverted calls take a different path entirely.** When the destination contract called from `_processL2ToL1Calls` on L1 / `_processIncomingCalls` on L2 re-enters the manager via a proxy and the caller try/catches, a revert of that nested call is a `success == false` entry in the unified reentrant table — *not* `revertNextNCalls`: the `try/catch` already provides the isolation boundary, and the row runs the reverted frame with its own sub-hash check (see the Reentrant Table section below). For the full situation → structure decision, see "When to use which structure". *** ## ExpectedL1ToL2Call (L2: `ExpectedOutgoingCrossChainCall`) ```solidity struct ExpectedL1ToL2Call { bytes32 expectedL1toL2Hash; // position key: keccak256(crossChainCallHash ‖ expectedRollingHash) L2ToL1Call[] l2ToL1Calls; // the reentrant frame's OWN sub-calls, processed in order (subject to gas checks) bytes32 revertedOrStaticRollingHash; // expected sub-call hash — checked for STATIC / REVERTED kinds; must be 0 for SUCCESS (prover constraint) bool success; // whether the reentrant call returns or reverts bytes returnData; // pre-computed return value (revert payload when !success) } ``` L2's `ExpectedOutgoingCrossChainCall` (`IEEZL2.sol`) is field-for-field identical modulo names (`expectedOutgoingHash`, `incomingCalls`). One unified table holds **every** reentrant kind — plain SUCCESS, read-only STATIC, and try/catch'd REVERTED (`!success`). Each is content-addressed by a single key: ```solidity expectedL1toL2Hash = keccak256(abi.encodePacked(crossChainCallHash, expectedRollingHash)) ``` where `crossChainCallHash` already folds `isStatic` (a static read keys distinctly from a state-changing call) and the routed rollup, and `expectedRollingHash` is the live `_rollingHash` at the instant the call fires — which binds the recorded calls and nesting boundaries. Local writes or an enclosing revert can leave this hash unchanged, so the key does not identify a unique occurrence. When a destination contract called by the processor calls back into a proxy (e.g., contract D on L2 calls C's proxy on L2 to reach C on L1), `executeCrossChainCall` detects `_insideExecution() == true` and routes to `_consumeNestedCall`: 1. Compute `expectedL1toL2Hash = keccak256(crossChainCallHash ‖ _rollingHash)`. 2. Forward-scan the active entry's table from `_lastL1ToL2CallConsumed` (L2: `_lastOutgoingCallConsumed`) — calls are consumed in order, never before the cursor; the first key match IS the entry. On a match, advance the cursor past it, then resolve via `_resolveNestedReentrant`: * Hash `NESTED_BEGIN(crossChainCallHash)` into `_rollingHash`. * Run the frame's **own** `l2ToL1Calls[]` (L2: `incomingCalls[]`) sub-array in order, subject to gas checks, via `_processL2ToL1Calls` on L1 / `_processIncomingCalls` on L2 — these fold into the same continuous `_rollingHash`. * `success == true`: hash `NESTED_END`, return `returnData` to the destination contract (`revertedOrStaticRollingHash` must be zero; otherwise resolution reverts `SuccessRowWithRevertedOrStaticHash`). * `success == false`: check `_rollingHash == revertedOrStaticRollingHash` (else `RollingHashMismatch`), then revert with `returnData` — the terminal revert rolls back the frame's state, hash, and cursor bump. 3. No match: fold `CALL_NOT_FOUND(crossChainCallHash)` into `_rollingHash` and return empty bytes. The divergence surfaces as `RollingHashMismatch` at the entry boundary — it is preserved across explicit `revertNextNCalls` transport through `ContextResult`, but an ordinary enclosing revert erases it. An intervening catch does not protect it from a later enclosing revert. Valid proofs must exclude missing-call and insufficient-gas markers from expected entry/sub-hashes. On L1 there is one additional gate before all of this: the reentrant call's target rollup must be in the executing entry's proven set (`ReentrantDestinationNotVerified`). A reverted sub-execution reuses the **host table** for its own deeper reentrant calls (Solidity forbids recursive structs); the live `_rollingHash` folded into each key keeps the contexts distinct. STATIC-kind entries in this table are matched only via `staticCrossChainCall` (their `crossChainCallHash` folds `isStatic = true`, so a CALL-keyed scan can never hit them) — see the Static Entries section. ### Reverting reentrant calls are table entries too A reentrant call that **must revert** (the caller catches it with `try/catch`) is a `success == false` entry in the same table — no separate structure, no `revertNextNCalls` wrapper. Its `l2ToL1Calls[]` are any sub-calls that execute (then roll back) during the attempt, `revertedOrStaticRollingHash` is the expected `_rollingHash` after `NESTED_BEGIN` plus those sub-call folds, and `returnData` is the revert payload. Because the terminal revert rolls back the cursor bump, the consumed slot is "un-consumed" again — the hash is restored too, so an identical retry can select that same row again even after a local write. Mutable resolution does not try later matching alternatives after a hash mismatch; see [call-pattern limits](/call-pattern-limits). ### Completeness accounting There are no per-frame call counts and no shared flat array: each frame's array is processed in order (subject to gas checks) by `_processL2ToL1Calls` on L1 / `_processIncomingCalls` on L2, so each frame owns its array; a failed gas preflight can stop that local array, and there is no cursor-vs-length check at the entry boundary. Omitting an expected surviving SUCCESS frame changes the rolling hash by omitting its `NESTED_BEGIN`/`NESTED_END` folds. This does not identify which same-key row supplied a saved answer, and ordinary enclosing reverts erase interior observations. An **unconsumed** table entry is inert — the unified table may legitimately contain STATIC and REVERTED entries that a given run never touches (e.g. inside a rolled-back span), so no table-length check is performed. *** ## Static Entries (`StaticExecutionEntry` top-level / STATIC-kind reentrant entries) Read-only cross-chain calls (`STATICCALL`s through a proxy) resolve in two homes — structs, match keys, and full resolution mechanics: **`STATIC_ENTRY.md`**: * **Nested (inside an execution)** — a STATIC-kind row of the unified reentrant table (previous section), keyed by `expectedL1toL2Hash` with `isStatic = true` and `value = 0` folded into the call hash. The key folds the live `_rollingHash`, pinning the read to its recorded hash context, which can recur after local writes or rollback; the scan runs forward from the reentrant cursor without consuming (a `view` frame can't advance it), and a miss reverts immediately: `ExecutionNotFound()` on L1, `EntryNotFound(bytes32 crossChainCallHash, uint64 callGas)` on L2. * **Top-level (outside any execution)** — a `StaticExecutionEntry` in the pool. L1 matches `proxyEntryHash` + `destinationRollupId` + every live `expectedRoots` pin (full scan, non-matches skipped; transient pool only while a batch is mid-flight); L2 matches `proxyEntryHash` + `expectedEntryIndex == entryIndex`. L2's `StaticExecutionEntryL2` replaces root pins with the expected cursor and drops `destinationRollupId`. The standard [same-block restriction](/core-protocol-spec#g4-same-block-restriction) applies. Both homes resolve through `_resolveStaticEntry`: run the entry's read-only sub-calls via STATICCALL, folding the **untagged** accumulator (`keccak256(prev, success, retData)` from `bytes32(0)`, always compared — an empty `calls[]` must match hash `0`), skip the candidate on hash mismatch, otherwise return `returnData` — or revert with it when `!success`. Exhausting candidates reports the normal static lookup miss. Referenced source proxies must already be deployed (`StaticCallProxyNotDeployed`). Local writes between identical reads can require multiple STATIC rows with the same position key (or multiple top-level rows with the same pins/cursor). Each row carries its own callback-result hash and cached outcome; mismatches retry without consuming a row or folding the host hash. A `1 → 2 → 1` callback sequence can therefore select rows A, B, A. All candidates must remain in the valid lookup window. Keep the three logical reads inside their original frame; retries do not create extra deliveries. See [STATIC_ENTRY §4.4](/static-entry#44-local-writes-between-identical-static-reads) for the four cases, builder obligations and regression scenarios. ### When to use which structure | Situation | Use | |---|---| | Reentrant cross-chain call that **succeeds** | `success = true` entry in the unified reentrant table | | Reentrant cross-chain call that **reverts** (caller catches with try/catch) | `success = false` entry in the unified reentrant table | | Reentrant cross-chain `STATICCALL` (read-only; may return or revert) | STATIC-kind entry in the unified reentrant table (`crossChainCallHash` folds `isStatic = true`; `success` as appropriate) | | Top-level static read (returning **or** reverting) | Top-level `StaticExecutionEntry` in the pool (`success` as appropriate) | | Top-level cross-chain call that reverts (state-changing) | `ExecutionEntry` with `success = false` | | Inner natural revert of a non-reentrant call | Plain call in the frame's array with `revertNextNCalls = 0`; `CALL_END(false, retData)` captures it | | Successful call(s) whose state must be force-reverted | `revertNextNCalls > 0` on the first call of the span | **How the manager picks** (for a reentrant call that hits the manager via a proxy): 1. If the proxy is in a real STATICCALL frame (its `tstore` self-check reverts), the proxy routes to `staticCrossChainCall`, which computes the hash with `isStatic = true` and scans the active entry's unified table (forward from the cursor) for the key — only STATIC-kind entries can match. Miss → L1 `ExecutionNotFound()` / L2 `EntryNotFound(crossChainCallHash, callGas)`. 2. Otherwise (normal CALL frame), the proxy routes to `executeCrossChainCall` → `_consumeNestedCall`, which computes the hash with `isStatic = false` and forward-scans for the key. A match resolves as SUCCESS or REVERTED per its `success` flag; a miss folds `CALL_NOT_FOUND` and returns empty bytes (a surviving marker fails against an honest marker-free expected hash; ordinary enclosing rollback can erase it) — identically on L1 and L2. *** ## State Deltas (L1 only) `RollupUpdate` exists only in `IEEZ.sol` — the L2 entry struct has no `rollupUpdates` field. ```solidity struct RollupUpdate { uint64 rollupId; // which rollup's state changes int192 etherDelta; // signed ETH change for this rollup; packs with rollupId into one slot bytes32 currentRoot; // expected pre-root on rollupId — checked against rollups[rid].root bytes32 newRoot; // post-execution root } ``` `currentRoot` is checked at consumption time against `rollups[delta.rollupId].root` — it is part of the entry-match predicate on the queue path (a stale entry is a non-match, skipped by the forward scan), and re-asserted upfront in `_executeEntry` (`RootMismatch`) for the immediate L2Tx path that doesn't go through matching. Root checks enforce each rollup's state requirements. They do not determine the execution order across different rollup queues or check other L1 contract state. Even after proof acceptance, an entry cannot execute if its required rollup roots do not match. **On-chain requirements** (all enforced by `_validateBatchStructure`, not left to prover convention): * Every entry must carry **at least one** `RollupUpdate` (`EntryHasNoRollupUpdates`) — an empty array would leave the entry unpinned from the backstop (and would break `_insideExecution()`, which is backed by the per-entry allowed-rollups array). * `rollupUpdates` must be **strictly increasing by `rollupId`**, starting above `MAINNET_ROLLUP_ID` (`RollupUpdatesNotStrictlyIncreasing`) — rejects duplicates and a mainnet delta. * Every delta's rollup must be in the batch's verified set (`RollupNotInBatch`), and `destinationRollupId` must be among the entry's own deltas (`EntryDestinationNotInRollupUpdates`). * **Proxy protection**: every executed call's `sourceRollupId` (top-level and every reentrant frame's sub-calls) must be ∈ the entry's deltas (`CallSourceNotVerified`, checked at validation); reentrant / static-read **targets** carry no clear-text field at post time, so they're checked at runtime against the same set (`ReentrantDestinationNotVerified`). The `rollupUpdates` attached to an entry must be the entry's true state transition — the proof is what makes them *correct*; the checks above only make them well-formed and pinned. ### Ether bridging (`etherDelta`) `etherDelta` is signed: * **Positive**: the rollup gains ETH (deposit / bridged-in) * **Negative**: the rollup loses ETH (withdrawal / bridged-out); a negative delta exceeding the rollup's tracked balance reverts `InsufficientRollupBalance` * **Zero**: no change Per-entry on L1, the contract verifies at the end of `_executeEntry`: ```text Σ rollupUpdates[i].etherDelta == _entryEtherDelta ``` where `_entryEtherDelta` is a transient net accumulator: SET to the entry-point call's `msg.value` at the top level (0 for `executeL2Txs` and immediate entries — a dirty accumulator there reverts `ResidualEntryEtherIn`), INCREMENTED by every reentrant call's `msg.value`, and DECREMENTED by the `value` of every **successful** non-static call the processor executes (a failed call's ETH never leaves the manager). Calls inside a `revertNextNCalls` span contribute to **neither** side: the span's revert rolls back their ETH transfers and their accumulator writes together — even though the rolling hash still records their actual success/failure outcomes. Mismatch reverts `EtherDeltaMismatch`. L2 has no per-table ether ledger. `executeCrossChainCall` forwards `msg.value` to `SYSTEM_ADDRESS`; system loads add `msg.value` to pooled funds used by incoming transfers. Replacement retains unused balance, and reverts unwind transfers. Issuance/burn semantics and aggregate inventory across partial consumption/replacement require a node/circuit policy; see [L2 funding and inventory](/core-protocol-spec#l2-funding-and-inventory). *** ### Dispatch shortage and completion After `CALL_BEGIN`, proxy resolution and payload encoding, both mutable processors run `_hasEnoughCallGas`. A shortage folds tag 6 and returns from the current local array without dispatching this call or later siblings. A parent may continue after a stopped rollback span returns its `ContextResult`. Static-result callbacks instead revert `InsufficientCallGas(uint64 callGas)`. Zero cap bypasses the estimate; nonzero estimates exclude new-account costs, returndata processing and the remainder of the entry. Full-array descriptions below assume no early stop. Valid proofs must exclude tags 5 and 6; accepting mocks are policy-negative probes, not proof-policy validation. ## Rolling Hash A single `bytes32 rollingHash` per entry covers surviving tagged call observations and explicit rollback spans transported by `ContextResult`. Successful nested saved answers are not folded into `NESTED_END`. The accumulator is **seeded** with the entry's starting context and identity, then updated at tagged events: ```solidity seed (L1) _rollingHash = keccak256(statesHash, proxyEntryHash) where statesHash = fold over rollupUpdates: keccak256(prev, rollupId_i, currentRoot_i), from bytes32(0) seed (L2) _rollingHash = keccak256(bytes32(0), proxyEntryHash) CALL_BEGIN (1) keccak256(prev, 0x01, crossChainCallHash) // binds WHICH call runs CALL_END (2) keccak256(prev, 0x02, success, returnData) // binds its observed outcome NESTED_BEGIN (3) keccak256(prev, 0x03, crossChainCallHash) // reentrant frame opens NESTED_END (4) keccak256(prev, 0x04) // reentrant frame closes (SUCCESS only) CALL_NOT_FOUND (5) keccak256(prev, 0x05, crossChainCallHash) // reentrant no-match; diverges the hash so the // surviving marker fails a valid expected hash CALL_INSUFFICIENT_GAS (6) keccak256(prev, 0x06) // after CALL_BEGIN; stop this local array ``` At the entry boundary the accumulator is compared once against `entry.rollingHash` (`RollingHashMismatch` — also catches a `CALL_NOT_FOUND` divergence), plus L1's ether invariant (`EtherDeltaMismatch`); then, if `!entry.success`, the entry reverts with `entry.returnData`. There are no cursor-vs-length checks: each frame's call array is processed in order, subject to gas checks, and the unified reentrant table may contain legitimately-unused entries (see Completeness accounting). Static resolutions use a simpler **untagged** accumulator (`keccak256(prev, success, retData)` from `bytes32(0)`). Full hash-chain semantics — the no-index rationale, worked examples, static disambiguation: `CORE_PROTOCOL_SPEC.md` §E. *** ## Transaction Model ### Per-block structure Each block may contain repeated setup/execution cycles; there is no once-per-block setup guard: 1. **Setup tx**: `postAndVerifyBatch` (L1) or `loadExecutionTable` (L2) — loads the execution table. 2. **Execution tx(s)**: Calls consume the current generation of entries. Every accepted L1 verification replaces the participating rollups' mutable/static queues and resets their cursors. Each L2 load replaces both active mapping prefixes, resets their lengths and `entryIndex`, and leaves inactive rows outside those lengths unsearchable. Earlier unused work can disappear within the same block. Builders/system integrations own submission ordering, refresh and generation tracking; a block number alone does not identify a table. On L1, `postAndVerifyBatch` itself runs the leading L2Tx entries inline and can run user-driven cross-chain calls via the **meta hook** (see below) — those don't need a separate execution tx. On L2, `executeIncomingCrossChainCall` combines setup + execution in one system tx (one such tx per committed top-level inbound call — see [delivery units](#delivery-units-and-execution-transactions) below). ### Immediate / deferred split (L1 `postAndVerifyBatch`) `postAndVerifyBatch` takes a single `ProofSystemBatchPerVerificationEntries calldata batch` argument (NOT an array). The batch carries `expectedRootPerRollup[]` (optional composer-supplied root assertions), `entries[]`, `staticEntries[]`, `immediateEntryCount`, `immediateStaticEntryCount`, `proofSystems[]` (strictly increasing by address), `rollupIdsWithProofSystems[]` (strictly ascending by `rollupId`, each row carrying a strictly-increasing `proofSystemIndexes[]` into `proofSystems[]`), `blobIndices[]`, `callData`, `proofs[]` (one per PS), `blockNumber` (the single L1 block the batch binds to; `0` = no block context, `type(uint64).max` = latest), and `bindMsgSenderInPublicInput` (true folds `msg.sender` into the public input — front-run protection). `immediateEntryCount` / `immediateStaticEntryCount` are UNPROVEN dispatch params — not folded into the public input, so the immediate/persistent split can be re-tuned without re-proving. See `MULTI_PROVER_SPEC.md` for the proof-public-inputs construction. Flow (numbered as in the code): 0. **Reentry guard** — revert `PostBatchReentry` if `_insideExecution()` or `_transientEntriesLength != 0`. 1. (**1a**) Check every `expectedRootPerRollup` pin against the live root (`ExpectedRootMismatch`), then (**1**) **structural validation** via `_validateBatchStructure(batch)`: PS/rollup sorting + registration, per-rollup PS-index ranges, per-entry `rollupUpdates` rules + `destinationRollupId ∈ deltas` + call-source pinning, per-static-entry pin rules, the immediate-prefix bounds (`ImmediateCountExceedsEntries`, `ImmediateStaticEntryCountExceedsStaticEntries`, `ImmediateCountStrandsLeadingL2Tx`), and L2Tx canonicality (`L2TxEntryNotCanonical` — defensive check, see CORE §A.1). 2. **Fetch vkeys**: `_getVerificationKeysPerRollup` calls each rollup's manager via `IRollupContract.checkProofSystemsAndGetVkeys(subset)` (the manager enforces its own threshold and per-PS membership; static callbacks cannot complete state writes). 3. **Verify all proofs atomically**: `_verifyProofSystemBatch` builds `sharedPublicInput` (entry hashes, static-entry hashes, blob hashes, `keccak(callData)`, per-rollup `customData` fetched via `getCustomData(batch.blockNumber)`, and the bound sender) plus a per-PS accumulator, and calls `IProofSystem.verify(proofs[k], publicInputsHash[k])` for each PS — false returns revert with `InvalidProof`; verifier reverts propagate their original error. Either unwinds the batch. 4. **Mark verified-this-block** (`_markVerifiedBlockAndResetQueues(rid)`): zeros `entryQueueLength` / `staticEntryQueueIndex` and resets `entryQueueIndex` without deleting mapping entries on **every** verify — a same-block re-verify REPLACES (does not append to) the prior batch's entries. Sets the read gate (`lastVerifiedBlock(rid) == block.number`) for `executeCrossChainCall` / `executeL2Txs(rollupId)`. 5. (**5+6**) **Drain the leading L2Tx run inline, straight from calldata**: while `entries[i].proxyEntryHash == 0` and `i < immediateEntryCount`, self-call `_attemptExecuteImmediateL2Txs(entries[i])`; advance after success or a nonempty caught failure (emitting `L2TxSkipped(i, revertData)`). Empty revert data aborts with `ImmediateL2TxOutOfGas(i)` before emission; any outer revert discards skip logs. The entries are never SSTOREd whole — only each entry's reentrant table is parked transiently for the duration of its run. A non-empty run where nothing succeeded reverts `AllImmediateL2TxsFailed`. 6. (**7**) **Meta hook**: if entries remain in the immediate prefix past the leading L2Tx run, `msg.sender` must have code to receive the hook (revert `MetaEntriesWithoutReceiver` otherwise); copy `entries[i..immediateEntryCount)` into `_transientEntries` and `staticEntries[0..immediateStaticEntryCount)` into `_transientStaticEntries`, incrementing their transient lengths from zero, then call `IMetaCrossChainReceiver(msg.sender).executeMetaCrossChainTransactions()`. The hook can invoke `executeCrossChainCall` / `executeL2Txs(rollupId)` to consume the transient entries (one global cursor, in array order). If no hook fires (empty immediate prefix, or one drained entirely by the L2Tx run) a non-zero `immediateStaticEntryCount` reverts `ImmediateStaticEntriesWithoutImmediateEntries` — the transient static pool is only reachable through the hook, so the declared entries would be silently dropped. 7. (**8**) `_saveRemainderEntries(batch)` (**unconditionally** — even if the meta hook left transient entries unconsumed): push `entries[immediateEntryCount..]` to `verificationByRollup[entry.destinationRollupId].entryQueue` and `staticEntries[immediateStaticEntryCount..]` to the corresponding `staticEntryQueue`. Each entry's `RollupUpdate.currentRoot` is checked at consumption time. A later entry is blocked if a skipped transition leaves a required rollup root unmatched. Dependencies on other L1 state need their own checks. 8. (**9**) **Clear the transient tables** (this also closes the re-entry window), then `emit BatchPosted(sharedPublicInput, rollupIds)`. A batch with `immediateEntryCount == 0` means no inline execution and no meta-hook consumption — all entries flow straight to per-rollup queues. ### Delivery units and execution transactions Delivery units differ by direction. An execution entry describes work; several entries can execute within one L1 transaction. * **L1→L2**: Each committed top-level mutable call consumes one L1 entry and is delivered in its own system-only `executeIncomingCrossChainCall` transaction on L2. That delivery replaces the L2 table and executes `entries[0]`; reentrant work stays within its open frame. Separate top-level calls from the same L1 transaction have separate L2 deliveries. The inbound identity includes `incomingCalls[0].gas`, so it equals the L1 source identity only when that gas field is zero; the circuit/integration must correlate the source occurrence and inbound fields explicitly. * **L2→L1**: One source L2 transaction is represented by one L1 L2Tx entry (`proxyEntryHash == 0`), whose `l2ToL1Calls[]` may contain several top-level calls. `postAndVerifyBatch` can execute multiple such entries in one L1 transaction; queued entries can be consumed through `executeL2Txs`. On L2, the original user transaction consumes the previously loaded table. For example, two proxy calls in one L2 transaction consume two L2 entries but execute together in one L1 L2Tx entry; see [the two-call fixture](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/scenarios/multi_call/L2_to_L1/multi-call-twiceL2/E2EMultiCallTwiceL2.s.sol). Keep reentrant calls within their containing entry/frame rather than splitting them into independent deliveries. **L1→L2 lookups have no delivery transaction:** a top-level static read, a call that reverts on L2, or a call whose L1 frame is later reverted is resolved from signed return/revert data. Nothing is applied on L2 and its root does not move (CORE §A.1, L2 prover constraints). L2→L1 static or reverting calls instead execute on L1 within their source transaction's L2Tx entry. *** ## Flow Patterns In the diagrams below, "MAINNET" means rollupId 0 (L1) and "L2" means whichever rollup ID the L2 chain has registered with the `EEZ` registry. `seed` is the per-side entry-begin seed from the Rolling Hash section. Each chain's entry lists only the calls that execute **on that chain**. Two hash notations appear; they differ only in the `callGas` term: * `hash(isStatic, src, srcRollup, dest, destRollup, value, data)` — `callGas = 0`. Used for everything on L1, for L2 outgoing keys only in gas-independent mode, and for every flat-call `CALL_BEGIN` fold on both chains. The L2 inbound binding folds `incomingCalls[0].gas` instead. * `hashL2Out(isStatic, src, srcRollup, dest, destRollup, value, callGas, data)` — explicit `callGas`, marking outgoing keys used by `EEZL2.executeCrossChainCall` and `staticCrossChainCall`: an L2 entry's top-level `proxyEntryHash` and a `expectedOutgoingHash`. The examples assume `incomingCalls[0].gas = 0` and pass outgoing `callGas = 0` (`useGasLeft = false`), where it equals `hash(...)` with the same seven fields; under `useGasLeft = true` the observed gas makes the outgoing key distinct. ### L1→L2 (simple) Alice on L1 calls a contract B on L2. B's proxy on L1 forwards Alice's call to L1's `EEZ.executeCrossChainCall` (consuming entry \[0] — no calls execute on L1, the pre-computed result is returned). The L2 system later delivers the call on L2 via `executeIncomingCrossChainCall`. **L1 execution table** (`postAndVerifyBatch`): ```text [0] DEFERRED proxyEntryHash = hash(false, src=Alice, srcRollup=MAINNET, dest=B, destRollup=L2, value=v, data=calldata) destinationRollupId = L2 // routes into verificationByRollup[L2].entryQueue; must be ∈ rollupUpdates l2ToL1Calls = [] // nothing executes on L1 — B runs on L2 expectedL1ToL2Calls = [] rollingHash = seed(rollupUpdates, proxyEntryHash) // no calls: the hash is just the seed success = true returnData = // pre-computed by the prover rollupUpdates = [ { rollupId=L2, currentRoot=S0, newRoot=S1, etherDelta=v } ] ``` (An immediate L2Tx entry for the batch's other work can live in the same batch ahead of this one, with `proxyEntryHash == 0`.) **L2 execution table** (loaded atomically by `executeIncomingCrossChainCall`): ```text [0] proxyEntryHash = hash(false, Alice, MAINNET, B, L2, v, calldata) // same hash as the L1 entry — executeIncomingCrossChainCall recomputes it from // incomingCalls[0] and reverts EntryHashMismatch on divergence incomingCalls = [{ revertNextNCalls=0, isStatic=false, src=Alice, srcRollup=MAINNET, dest=B, value=v, data=calldata }] expectedOutgoingCalls = [] rollingHash = H( seed(proxyEntryHash) → CALL_BEGIN(hash of the B call, targetRollup=L2) → CALL_END(true, ) ) success = true returnData = // no rollupUpdates / destinationRollupId — the L2 struct has neither field ``` **Execution**: * **L1**: Alice calls B's proxy → `executeCrossChainCall(Alice, calldata)` → `_consumeAndExecuteEntry` matches entry \[0] (identity + routing + live state pins), applies the deltas, verifies the seed-only rolling hash → returns ``. * **L2**: System submits `executeIncomingCrossChainCall(entries, staticEntries)` with `msg.value == v` → atomically replaces the table, checks `entries[0].proxyEntryHash` against the hash of `incomingCalls[0]`, and drives the call processor → B executes on L2 via Alice's proxy. ### L2→L1 (simple) Alice on L2 (through contract D) calls a contract C on L1. **L1 execution table** (`postAndVerifyBatch`): ```text [0] IMMEDIATE (L2Tx) proxyEntryHash = bytes32(0) destinationRollupId = L2 // must be ∈ rollupUpdates (MAINNET can never be a delta); // immediate entries inline-execute on L1 regardless of this field l2ToL1Calls = [{ revertNextNCalls=0, isStatic=false, src=D, srcRollup=L2, dest=C, value=0, data=calldata }] expectedL1ToL2Calls = [] rollingHash = H( seed(rollupUpdates, 0) → CALL_BEGIN(hash(false, D, L2, C, MAINNET, 0, calldata)) → CALL_END(true, ) ) success = true returnData = "" rollupUpdates = [ { rollupId=L2, currentRoot=S0, newRoot=S1, etherDelta=0 } ] ``` (Often this is `entries[0]` with `immediateEntryCount = 1`, executed inline by `postAndVerifyBatch`; a non-leading L2Tx entry is queued and consumed via `executeL2Txs(L2)`.) **L2 execution table** (`loadExecutionTable`): ```text [0] DEFERRED // L2-OUTGOING key (callGas = 0 under useGasLeft = false) proxyEntryHash = hashL2Out(false, src=D, srcRollup=L2, dest=C, destRollup=MAINNET, 0, callGas=0, calldata) incomingCalls = [] // nothing executes on L2 — C runs on L1 expectedOutgoingCalls = [] rollingHash = seed(proxyEntryHash) success = true returnData = ``` **Execution**: * **L2**: Alice's L2 tx calls D, D calls C's proxy on L2 → `executeCrossChainCall(D, calldata)` → consumes entry \[0] (no calls; seed-only hash) → returns `` to D. * **L1**: `postAndVerifyBatch` runs entry \[0] in the immediate L2Tx run (or `executeL2Txs(L2)` consumes it) — the call routes through D's auto-created proxy on L1, executes the real C, and matches the rolling hash. ### L1→L2→L1 (reentrant L2→L1 inside an L1→L2 call) Alice on L1 calls D's proxy on L1 (D lives on L2). D, while executing on L2, calls C's proxy on L2 (C lives on L1) — and that D→C leg is what executes on L1. **L1 execution table** (`postAndVerifyBatch`): ```text [0] DEFERRED proxyEntryHash = hash(false, Alice, MAINNET, D, L2, 0, incrementProxy) destinationRollupId = L2 l2ToL1Calls = [{ revertNextNCalls=0, isStatic=false, src=D, srcRollup=L2, dest=C, value=0, data=increment }] // the D→C leg is all that runs on L1 expectedL1ToL2Calls = [] rollingHash = H( seed → CALL_BEGIN(hash(false, D, L2, C, MAINNET, 0, increment)) → CALL_END(true, abi.encode(1)) ) success = true returnData = rollupUpdates = [ { rollupId=L2, currentRoot=S0, newRoot=S1, etherDelta=0 } ] ``` **L2 execution table** (loaded atomically by `executeIncomingCrossChainCall`): ```text [0] proxyEntryHash = hash(false, Alice, MAINNET, D, L2, 0, incrementProxy) incomingCalls = [{ src=Alice, srcRollup=MAINNET, dest=D, value=0, data=incrementProxy, ... }] expectedOutgoingCalls = [ { // L2-OUTGOING key (callGas = 0 under useGasLeft = false) expectedOutgoingHash = keccak256( hashL2Out(false, D, L2, C, MAINNET, 0, 0, increment) ‖ fireHash ), // fireHash = the L2 _rollingHash right after CALL_BEGIN of the D call — the point the // reentrant call fires incomingCalls = [], // nothing executes on L2 inside this frame revertedOrStaticRollingHash = 0, // required 0 for a SUCCESS frame (prover constraint) success = true, returnData = abi.encode(1) // C's pre-computed return } ] rollingHash = H( seed → CALL_BEGIN(hash of Alice→D, targetRollup=L2) → NESTED_BEGIN(hash of D→C) → NESTED_END → CALL_END(true, ) ) success = true returnData = ``` **Execution**: * **L1**: Alice → D's proxy → `executeCrossChainCall(Alice, incrementProxy)` → consumes entry \[0]. The processor routes the single call through D's proxy on L1 — the real C executes and returns `abi.encode(1)`. The entry then returns `` (pre-computed) to Alice. * **L2**: System tx → `executeIncomingCrossChainCall(entries, staticEntries)` → drives entry \[0]. The real D executes; when D calls C's proxy on L2 → `executeCrossChainCall(D, increment)` → `_insideExecution() == true` → `_consumeNestedCall` matches the frame by `keccak256(crossChainCallHash ‖ _rollingHash)` → runs the frame's (empty) sub-array between `NESTED_BEGIN`/`NESTED_END` → returns `abi.encode(1)` to D. ### L2→L1→L2 (reentrant L1→L2 inside an L2→L1 call) Alice on L2 calls A's proxy on L2 (A lives on L1). A, while executing on L1, calls B's proxy on L1 (B lives on L2) — the A→B leg executes on L2. **L1 execution table** (`postAndVerifyBatch`): ```text [0] IMMEDIATE (L2Tx) proxyEntryHash = bytes32(0) destinationRollupId = L2 l2ToL1Calls = [{ src=Alice, srcRollup=L2, dest=A, value=0, data=callBProxy, ... }] expectedL1ToL2Calls = [ { expectedL1toL2Hash = keccak256( hash(false, A, MAINNET, B, L2, 0, increment) ‖ fireHash ), // fireHash = the L1 _rollingHash right after CALL_BEGIN of the Alice→A call l2ToL1Calls = [], // nothing executes on L1 inside this frame revertedOrStaticRollingHash = 0, // required 0 for a SUCCESS frame (prover constraint) success = true, returnData = abi.encode(1) // B's pre-computed return } ] rollingHash = H( seed → CALL_BEGIN(hash(false, Alice, L2, A, MAINNET, 0, callBProxy)) → NESTED_BEGIN(hash of A→B) → NESTED_END → CALL_END(true, ) ) success = true returnData = "" rollupUpdates = [ { rollupId=L2, currentRoot=S0, newRoot=S1, etherDelta=0 } ] ``` **L2 execution table** (`loadExecutionTable`): ```text [0] DEFERRED // L2-OUTGOING key (callGas = 0 under useGasLeft = false) proxyEntryHash = hashL2Out(false, Alice, L2, A, MAINNET, 0, 0, callBProxy) incomingCalls = [{ src=A, srcRollup=MAINNET, dest=B, value=0, data=increment, ... }] // the A→B leg is what executes on L2 expectedOutgoingCalls = [] rollingHash = H( seed → CALL_BEGIN(hash of A→B, targetRollup=L2) → CALL_END(true, abi.encode(1)) ) success = true returnData = ``` **Execution**: * **L2**: Alice's L2 tx → A's proxy → `executeCrossChainCall(Alice, callBProxy)` → consumes entry \[0]. The processor runs the A→B call through A's proxy on L2 — the real B executes — then returns `` (pre-computed) to Alice. * **L1**: `postAndVerifyBatch` runs entry \[0] in the immediate run (or via `executeL2Txs`). The real A executes via Alice's proxy; when A calls B's proxy on L1 → `executeCrossChainCall(A, increment)` → reentrant → `_consumeNestedCall` matches `expectedL1ToL2Calls[0]` by its position key → returns `abi.encode(1)` to A. ### Forced revert via `revertNextNCalls` When a chain's prover output marks a cross-chain call as reverted even though the destination call would succeed, the protocol uses `revertNextNCalls` to delineate the contiguous run of calls whose **state effects** must be rolled back. The rolling hash still records the calls' real outcomes (typically `success=true`); only the EVM state changes are discarded. Example: SCA on L2 calls SCB which makes a successful cross-chain call to Counter on L1, then SCA reverts. From L1's perspective Counter.increment() ran cleanly, but L2's prover output says the higher-level transaction was rolled back, so L1 must not retain the state change. **L1 execution table** (`postAndVerifyBatch`): ```text [0] IMMEDIATE (L2Tx) proxyEntryHash = bytes32(0) destinationRollupId = L2 l2ToL1Calls = [ // The cross-chain call to Counter is wrapped in a revert span of length 1: { revertNextNCalls=1, isStatic=false, src=SCB, srcRollup=L2, dest=Counter, value=0, data=increment }, ] expectedL1ToL2Calls = [] rollingHash = H( seed → CALL_BEGIN(hash of the Counter call) → CALL_END(true, abi.encode(1)) ) // The hash chain still includes Counter's success — the span rolls back state, not the rolling hash. success = true returnData = "" rollupUpdates = [ ... ] // whatever net delta the prover committed to (the parent tx rolled back on L2) ``` **Mechanism**: when the processor sees `calls[0].revertNextNCalls == 1`, it zeroes the marker in its memory copy, slices the 1-call span, and self-calls `executeInContextAndRevert(span)`, which processes the call (Counter.increment) and then reverts with `ContextResult(rollingHash, reentrantConsumed)`. The revert undoes Counter's effects on L1, but the payload carries the `_rollingHash` computed inside the span back out — so the entry-level hash check still observes Counter's success contribution. `CallsReverted` is emitted and the walk skips past the span. The self-call boundary provides rollback; `ContextResult` carries the recorded execution hash and cursor back to the enclosing frame. ### Reverting reentrant call (`success = false` frame) When a reentrant cross-chain call **must revert** (e.g., the destination wraps the call in `try/catch`), it is a `success = false` entry in the same unified reentrant table: **`entry.expectedL1ToL2Calls`** of the consuming entry: ```text [k] ExpectedL1ToL2Call { expectedL1toL2Hash = keccak256( hash(false, D, MAINNET, B, L2, 0, increment) ‖ fireHash ), // fireHash = the live _rollingHash at the instant B's proxy is called — this pins the // exact execution point, replacing any positional coordinates l2ToL1Calls = [], // sub-calls that run (then roll back) during the attempt revertedOrStaticRollingHash = H( NESTED_BEGIN(crossChainCallHash) folded onto fireHash → ...sub-call folds... ), // tagged schema, checked before reverting success = false, returnData = , } ``` When B's proxy is called from inside D's execution (a normal CALL frame), the call routes to `executeCrossChainCall` → `_consumeNestedCall`, which matches the frame by its position key, advances the cursor, folds `NESTED_BEGIN`, runs the frame's own sub-array, checks the sub-hash against `revertedOrStaticRollingHash`, and reverts with the cached payload. D catches the revert and continues; the terminal revert rolled back the sub-calls' state **and** the cursor bump, so nothing was consumed. ### Same action twice (sequential) The advancing-cursor explanation below assumes the earlier call commits. If it reverts, the cursor and nested hash changes roll back. A local write followed by an identical retry can therefore select the old reverting row again, even when a valid success alternative exists later. See the [mutable retry tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZExpressiveness.t.sol). When the same proxy call happens N times in a single transaction (e.g., a contract calling B's proxy twice in a row), each call consumes the next matching entry in the queue. Each entry has the same `proxyEntryHash`; the forward scan from the advancing cursor differentiates them. ```text [0] DEFERRED proxyEntryHash = hash(B, ...) ... returnData = [1] DEFERRED proxyEntryHash = hash(B, ...) ... returnData = ``` The destination rollup's `entryQueueIndex` advances from 0 → 1 → 2; the second call lands at `verificationByRollup[B's rollupId].entryQueue[1]`. For *reentrant* repeats inside one entry, a committed earlier frame changes the `expectedRollingHash` component of the next key. Enclosing rollback can restore the earlier hash and cursor, so this distinction does not extend to retries after rollback. ### Sequential entries within a flow A single user action that performs multiple top-level cross-chain calls produces multiple entries. Each top-level entry-point call consumes one entry. There is no `nextAction` redirection — the user's contract makes each call explicitly, and the table has one entry per top-level call. For sub-calls **within** a single entry, placement follows the frame structure: calls that execute at the entry's top level live in `entry.l2ToL1Calls[]` (L2: `entry.incomingCalls[]`); calls that execute inside a reentrant frame live in that frame's own `l2ToL1Calls[]` (L2: `incomingCalls[]`) sub-array. *** ## L1 vs L2 Entries | Aspect | L1 (`EEZ`) | L2 (`EEZL2`) | |---|---|---| | **How loaded** | `postAndVerifyBatch(ProofSystemBatchPerVerificationEntries calldata batch)` — single struct (not array) carrying `expectedRootPerRollup[]`, `entries[]`, `staticEntries[]`, `immediateEntryCount`, `immediateStaticEntryCount`, `proofSystems[]`, `rollupIdsWithProofSystems[]`, `blobIndices[]`, `callData`, `proofs[]`, `blockNumber`, `bindMsgSenderInPublicInput` | `loadExecutionTable(entries, staticEntries)` by `SYSTEM_ADDRESS`; OR `executeIncomingCrossChainCall(...)` for inbound delivery from another rollup (system-only, atomically loads + executes `entries[0]`) | | **State deltas** | Required: ≥1 per entry, strictly increasing by rollupId, all in batch, `destinationRollupId` ∈ deltas, call sources ∈ deltas (all validation-time); `currentRoot` checked at consumption (match predicate + `RootMismatch` gate); `etherDelta` accounted | No `rollupUpdates` field at all — the L2 entry struct omits it (no rollup state on L2) | | **Matching logic** | Forward-scan from the per-rollup cursor (`_findMatchingEntry`) for the first FULL match: `proxyEntryHash` + `destinationRollupId == destRid` + every `currentRoot` pin live; non-matches skipped; end-of-queue → `ExecutionNotFound`. Transient branch uses one global cursor with the same predicate | Forward-scan `entries` from `entryIndex` by `proxyEntryHash` alone; end-of-table → `EntryNotFound(bytes32 crossChainCallHash, uint64 callGas)` | | **Top-level reverting call** | Normal `ExecutionEntry` with `success = false` — run, verified, then reverted with `returnData` (no fallback pool) | Same | | **Static entries** | Pool: batch `staticEntries[]` → `_transientStaticEntries` (leading `immediateStaticEntryCount`, scanned exclusively while a batch is mid-flight) / per-rollup `staticEntryQueue`. Match: `proxyEntryHash` + `destinationRollupId` + all `expectedRoots` pins live (full scan). | `staticEntries` pool, matched by `proxyEntryHash` + `expectedEntryIndex == entryIndex` (no roots to pin) | | **Ether accounting** | Per-entry invariant `Σ etherDelta == _entryEtherDelta` (net inbound − successful outbound) | Pooled system funding pays incoming calls; outgoing call value goes to `SYSTEM_ADDRESS`. No per-table ledger. | | **Repeated call identity** | Successful consumption advances to the next matching row. A revert restores the cursor, so an identical retry can select the same row. | Same | | **Immediate/deferred split** | Leading L2Tx run executed straight from calldata; meta-hook remainder in `_transientEntries` / `_transientStaticEntries`; rest published to per-rollup queues **unconditionally** (`currentRoot` checks rollup state; L1 determines settlement order across queues) | No transient tables — all entries go directly to `entries` / `staticEntries` | | **Meta hook** | `IMetaCrossChainReceiver(msg.sender).executeMetaCrossChainTransactions()` if immediate-prefix entries remain past the leading L2Tx run (codeless sender reverts `MetaEntriesWithoutReceiver`) | Not present | | **Immediate entry** | Leading `proxyEntryHash == 0` entries run inline during `postAndVerifyBatch` via `_attemptExecuteImmediateL2Txs` (try/catch self-call); a nonempty failure emits `L2TxSkipped` and is skipped; empty revert data aborts with `ImmediateL2TxOutOfGas`; an all-failed non-empty run reverts `AllImmediateL2TxsFailed`, discarding all logs | Not expressible — no consumption path matches `proxyEntryHash == 0` on L2. Inbound work arrives via `executeIncomingCrossChainCall` (non-zero `proxyEntryHash`); locally-initiated work is consumed by the user's own tx through a proxy | | **`executeL2Txs`** | `executeL2Txs(uint64 rollupId)` — permissionless; consumes the next matching entry on `rollupId`'s queue with `proxyEntryHash == bytes32(0)` | Not present (L2 has no `executeL2Txs` entry point) | | **Inbound system-only call** | Not present | `executeIncomingCrossChainCall(entries, staticEntries)` — `onlySystemAddress`, payable (`msg.value` adds pooled funding; aggregate node/circuit inventory policy required), atomically replaces the table, requires non-empty `incomingCalls` on `entries[0]`, checks `entries[0].proxyEntryHash` against the hash of `incomingCalls[0]` (`EntryHashMismatch`), consumes `entries[0]` (emitting `IncomingCrossChainCallExecuted` and `ExecutionConsumed(hash, 0)`), returns `entries[0].returnData` | ### Atomicity via Solidity revert An EVM revert rolls back the affected frame's state, transfers, cursor advances and transient writes. A caller that catches the revert can continue its transaction. `revertNextNCalls` uses a deliberate inner revert to discard a call span's state effects while transporting its hash and reentrant cursor through `ContextResult`. An entry with `success = false` verifies its recorded execution and then reverts with its cached result. Use Solidity revert for top-level failures (whole-transaction abort). Use `revertNextNCalls` for inner spans whose state must be rolled back while the outer execution continues. *** ## Don'ts ### Never mark a reverting reentrant call `success = true` A `success = true` reentrant entry commits its frame into the host's continuous rolling hash and returns. A call the destination expects to revert must be a `success = false` entry — `_resolveNestedReentrant` verifies the sub-hash and then reverts with `returnData`, rolling back the frame's state and cursor bump so the protocol observes "attempted and failed" exactly. Don't wrap it in `revertNextNCalls` either — that mechanism is for forced-reverting *successful* calls in a frame's flat array, not for reentrant frames. ### Never split nested calls into separate transactions Keep each side's open execution frame in one local transaction. The same leg can be a local call on one chain and an expected outgoing row on the other. For L1→L2→L1, the L1 callback belongs in the L1 entry's `l2ToL1Calls`, while its cached result is a row in L2's `expectedOutgoingCalls`. A further L1→L2 call made by that callback is a row in L1's `expectedL1ToL2Calls`. Wrong: ```text TX1 on L1: Alice → proxy → executeCrossChainCall (CALL into L2's space) TX2 on L1: system → executeCrossChainCall (reentrant CALL back from L2) ← WRONG: separate tx ``` Right: ```text TX1 on L1: Alice → L2 proxy → executeCrossChainCall → consumes one entry and executes its local l2ToL1Calls[] callbacks → if a callback calls another L2 proxy, resolve expectedL1ToL2Calls[k] by keccak256(crossChainCallHash ‖ live rolling hash) → that row's own l2ToL1Calls[] run within the same transaction ``` ### Never use `executeL2Txs` for L1→L2 flows `executeL2Txs(rollupId)` exists only on L1 and consumes the next matching entry on that rollup's queue with `proxyEntryHash == 0` — it commits pure L2 transactions (and L2 transactions that touch L1) on L1. For L1→L2 flows, the user's call enters the protocol via `executeCrossChainCall` on the proxy on L1, and the L2 side is delivered by the system via `executeIncomingCrossChainCall`. Don't call `executeL2Txs` to start an L1→L2 flow. ### Never call `executeL2Txs` while inside a cross-chain execution `executeL2Txs(rollupId)` reverts with `L2TxNotAllowedDuringExecution` if `_insideExecution() == true`. L2Tx entries are top-level only; reentrant calls must use the proxy path. ### Consistent rollupId / sourceRollupId semantics * `targetRollupId` in every call hash = **target** (where the call executes) * `sourceRollupId` in every call hash = **origin** (where the caller lives) * On L1, calls produced by `executeCrossChainCall` have `sourceRollupId = MAINNET (0)` — top-level and reentrant alike. * On L2, calls produced by `executeCrossChainCall` have `sourceRollupId = ROLLUP_ID` (this L2's ID). * `isStatic` is part of the hash: a read-only call and an otherwise-identical state-changing call hash differently. Static hashes always carry `value = 0`. * On L1, every supplied local call's `sourceRollupId` must be in the entry's proven `rollupUpdates` set. L2 has no corresponding proven-rollup set and trusts the system-loaded tables. Both managers reject creating a source proxy for their own rollup ID (`SameNetworkProxy`). ### Don't rely on positional grouping across frames Each call array — the entry's `l2ToL1Calls[]` (L2: `incomingCalls[]`) and every reentrant frame's own sub-array — is processed in array order by its own frame, subject to the gas-shortage early return. The semantic structure (which calls belong to which frame) comes from **where the array lives**, not from counts or a shared cursor. What binds frames to execution points is the `expectedL1toL2Hash` key: the live rolling hash at fire time. The builder must simulate the execution to capture each frame's fire-point hash exactly; a frame keyed at the wrong point simply never matches, and a surviving `CALL_NOT_FOUND` fold fails against an honest expected hash. Ordinary enclosing rollback can erase it. # Static Entry Specification This document specifies **read-only cross-chain calls** (STATICCALLs) and the related resolution of **reverting mutable reentrant calls**. Both return or revert with prepared outcomes after checking their local callbacks; successful mutable paths also use cached remote outcomes and are described in `EXECUTION_ENTRY_SPEC.md`. There are **two homes**, split by execution context: | Situation | Mechanism | Lives in | Match key | |---|---|---|---| | REENTRANT static read, fired `_insideExecution()` | STATIC-kind `ExpectedL1ToL2Call` | the entry's unified `expectedL1ToL2Calls[]` table | `expectedL1toL2Hash == keccak256(crossChainCallHash, _rollingHash)`, with `isStatic = true` folded into `crossChainCallHash` | | REENTRANT call that reverts (caller catches with `try/catch`) | REVERTED-kind `ExpectedL1ToL2Call` (`success == false`) | same table | same key, with `isStatic = false` | | TOP-LEVEL static read (including one that reverts) | `StaticExecutionEntry` | L1: `_transientStaticEntries` while a batch is mid-flight, else per-rollup `staticEntryQueue`; L2: the `staticEntries` pool | L1: `proxyEntryHash` + `destinationRollupId` + every `expectedRoots` pin live (full scan); L2: `proxyEntryHash` + `expectedEntryIndex == entryIndex` | | TOP-LEVEL state-changing call that reverts | normal `ExecutionEntry` with `success == false` | entry queue | see `EXECUTION_ENTRY_SPEC.md` — out of scope here | There is no separate lookup struct and no separate lookup key space: reentrant reads and reverted reentrant calls are ordinary rows of the **one** reentrant table (`ExpectedL1ToL2Call` on L1, `ExpectedOutgoingCrossChainCall` on L2), content-addressed by the same position key as plain-success reentrant calls. Field names below are L1's (`src/interfaces/IEEZ.sol`); L2 (`src/interfaces/IEEZL2.sol`) mirrors with self-relative names (`expectedOutgoingHash`, `incomingCalls`, `expectedOutgoingCalls`, cursor `_lastOutgoingCallConsumed`) and drops the L1-only fields (`destinationRollupId`, `expectedRoots`). Seen from the frame that carries them: * **Top-level mutable frame** (an `ExecutionEntry`). Callbacks to L1 go in `l2ToL1Calls`. Any reentrant call back into a rollup the entry proves, static included, goes in `expectedL1ToL2Calls` as a row — that is why the row struct has `revertedOrStaticRollingHash`: the expected sub-call hash for STATIC and REVERTED rows. The frame is an execution environment, so it can carry expected results for its nested reads. * **Top-level static frame** (a `StaticExecutionEntry`). Callbacks to L1 go in `l2ToL1Calls`, run with STATICCALL. Reentrant reads cannot be carried inside because a static frame cannot mark itself as an execution (`_insideExecution()` stays false), so each becomes another top-level `StaticExecutionEntry` in the pool, matched independently (§4.2). This document complements `EXECUTION_ENTRY_SPEC.md` (how `ExecutionEntry`s are built) and `CORE_PROTOCOL_SPEC.md` §E (rolling hash; §E.2 for the untagged static schema). *** ## 1. Routing: how a read reaches `staticCrossChainCall` A contract performs a cross-chain read by STATICCALLing the counterparty's `CrossChainProxy`. The proxy detects the static frame itself (`CrossChainProxy._fallback`): it self-calls `staticCheck()`, which attempts a transient store — `tstore` reverts inside a STATICCALL, so a failed self-call selects static routing. The probe uses the manager's `STATIC_CHECK_GAS` cap (currently 1,000); insufficient probe gas can also make it fail. The proxy then routes to the manager's `staticCrossChainCall(msg.sender, msg.data)` via STATICCALL (a normal frame routes to `executeCrossChainCall` instead). `staticCrossChainCall` is `view`. It computes the call's identity with the canonical formula (`EEZBase.computeCrossChainCallHash` — `CORE_PROTOCOL_SPEC.md` §C.3), with `isStatic = true` (a read keys DISTINCTLY from a state-changing call), `value = 0`, source = the proxy's caller on this chain's rollup ID, target = the proxy's (`originalAddress`, `originalRollupId`), and the original calldata. Static keys fold `callGas = 0` on L1. On L2 they fold `uint64(gasleft())` sampled after proxy validation/storage reads when `USE_GAS_LEFT`, otherwise `0`. It then branches on `_insideExecution()`: **inside** → the active entry's unified reentrant table (§4.1); **outside** → the top-level `StaticExecutionEntry` pool (§4.2). *** ## 2. Field reference ```solidity // L1 — src/interfaces/IEEZ.sol /// One row of the entry's UNIFIED reentrant table. Serves three kinds: /// plain SUCCESS, read-only STATIC, and try/catch'd REVERTED (`!success`). struct ExpectedL1ToL2Call { bytes32 expectedL1toL2Hash; // position key: keccak256(crossChainCallHash, expectedRollingHash) L2ToL1Call[] l2ToL1Calls; // this frame's OWN sub-calls, processed in order (subject to gas checks) bytes32 revertedOrStaticRollingHash; // expected sub-call hash, checked for STATIC / REVERTED; must be 0 for SUCCESS bool success; // whether resolution returns or reverts bytes returnData; // returned on success / reverted-with when !success } /// TOP-LEVEL static entry — lives in the pool; resolvable only outside an execution. struct StaticExecutionEntry { ExpectedRootPerRollup[] expectedRoots; // root pins — part of the MATCH — see §6 bytes32 proxyEntryHash; // the inbound call's crossChainCallHash (isStatic = true folded in) L2ToL1Call[] l2ToL1Calls; // read-only sub-calls run via STATICCALL during resolution bytes32 rollingHash; // expected untagged hash of the sub-calls — see §5 uint64 destinationRollupId; // routes the pool entry; must match the calling proxy's rollup bool success; // whether resolution returns or reverts bytes returnData; // returned on success / reverted-with when !success } ``` Notes: * **One `success` polarity everywhere.** `ExecutionEntry`, `ExpectedL1ToL2Call`, and `StaticExecutionEntry` all carry `success`; `false` always means "run/verify the sub-calls, then `revert(returnData)`" and `true` means "…then return `returnData`". * **No kind selector on the reentrant row.** STATIC vs CALL is decided by the *key*: `crossChainCallHash` folds `isStatic`, so a static read can only ever match a row whose key was built from a static hash, and `staticCrossChainCall` / `_consumeNestedCall` each compute their own side of it. SUCCESS vs REVERTED within the CALL kind is the row's `success` flag. * **`destinationRollupId`** (top-level, L1) routes publishing into `verificationByRollup[rid].staticEntryQueue` and is re-checked at match time. It is load-bearing for the transient pool (one global table, not queue-routed) and coherent by construction for the persistent queues: the scan targets the calling proxy's `originalRollupId`, which is also the target rollup bound into `crossChainCallHash`. * **No recursion structs needed.** A reverted sub-execution's own reentrant calls resolve from the SAME host table, disambiguated by the live `_rollingHash` folded into each key (§3). *** ## 3. The position key (`expectedL1toL2Hash`) Every reentrant-table row is content-addressed by one value (`EEZBase._computeExpectedL1toL2Hash`): ```solidity expectedL1toL2Hash = keccak256(abi.encodePacked(crossChainCallHash, expectedRollingHash)) ``` where `expectedRollingHash` is the live `_rollingHash` at the instant the reentrant call (or read) fires. The rolling hash is a chain over every prior call result and nesting boundary, so it pins the recorded hash context, rather than a unique occurrence or local state version: * Identical calls key differently when their `_rollingHash` values differ. Local writes with no intervening fold do not change the key. * A read and a state-changing call with identical params key differently (`isStatic` in the call hash). * A reverted sub-execution reuses the host table for its own reentrant calls; its rows can't collide with host-level rows because their keys embed the sub-execution's interior `_rollingHash` values. Matching scans forward from the reentrant cursor `_lastL1ToL2CallConsumed`. A CALL resolves the first key match and advances the cursor past it. A STATIC read also checks each candidate's callback-result hash and skips a mismatch. It cannot advance the cursor (it is a `view` frame), so later reads can reuse any valid row at or after that cursor, including an earlier candidate skipped by a previous read. There is no table-length check: unused rows are inert. Omitting an expected surviving mutable frame changes the entry hash by omitting its NESTED folds. That does not identify a unique row occurrence or retain observations erased by an ordinary enclosing revert; see [call-pattern limits](/call-pattern-limits). *** ## 4. Resolution mechanics ### 4.1 Reentrant (inside an execution) **Static read** — `staticCrossChainCall`, in-execution branch: 1. (L1 only) proxy protection: the read's target rollup must be in the executing entry's allowed set (`_containsVerifiedRollup`, from its `rollupUpdates`) — else `ReentrantDestinationNotVerified`. 2. Compute the key from the static-kind `crossChainCallHash` + live `_rollingHash`; scan the active table forward from `_lastL1ToL2CallConsumed`. 3. On match → `_resolveStaticEntry(l2ToL1Calls, revertedOrStaticRollingHash, success, returnData)` (§4.3); if it returns false, continue scanning. On no match → `ExecutionNotFound` on L1, `EntryNotFound(hash, callGas)` on L2 (a `view` frame cannot fold a `CALL_NOT_FOUND` divergence, so it reverts directly). A reentrant mutable CALL no-match instead folds the tag and returns success with empty bytes. Against an honest expected hash it causes `RollingHashMismatch` only if the marker survives to validation; an ordinary enclosing revert can erase it. See [the reverted-probe caveat](/caveats#edge-cases). **Reverted reentrant call** — `_consumeNestedCall` → `_resolveNestedReentrant`, `success == false` row: ```solidity advance _lastL1ToL2CallConsumed past the matched row _rollingHashNestedBegin(crossChainCallHash) // open the frame _processL2ToL1Calls(row.l2ToL1Calls) // run the frame's OWN sub-array, tagged schema require _rollingHash == row.revertedOrStaticRollingHash // else RollingHashMismatch revert(row.returnData) // terminal revert ``` The terminal revert rolls back the sub-execution's **state**, the cursor advance, and the rolling-hash writes in one shot (all transient/storage writes of the frame unwind with the EVM revert); the caller's `try/catch` observes exactly `returnData`. A `success == true` row takes the other branch of the same resolver: it closes the frame with `NESTED_END` into the host's *continuous* hash and returns the cached `returnData`. This mutable path requires zero `revertedOrStaticRollingHash` and is specified in `EXECUTION_ENTRY_SPEC.md`; it has no candidate-specific callback-hash check for successful rows. ### 4.2 Top-level (outside any execution) `staticCrossChainCall`'s pool branch. Source selection on L1: * While a batch is mid-flight (`_transientEntriesLength != 0`): **only** the batch's transient pool `_transientStaticEntries` — the transient phase is self-contained (see `docs/CAVEATS.md`). * Otherwise: the calling proxy's rollup queue, `verificationByRollup[destRid].staticEntryQueue`. Match predicate (full scan — a non-matching candidate is skipped, never an error): ```solidity staticEntry.proxyEntryHash == crossChainCallHash && staticEntry.destinationRollupId == destRid && _rootsMatch(staticEntry) // every pin equals the live root — §6 ``` The standard [same-block restriction](/core-protocol-spec#g4-same-block-restriction) applies. Every batch verifying the rollup also replaces its static and mutable queues. On L2, lookup scans `staticEntries[0..staticEntriesLength)` and matches `proxyEntryHash` plus `expectedEntryIndex == entryIndex`. Both load entry points replace the active mapping bounds and reset the mutable cursor. The scan restarts at zero for every read, but candidates must also pin the live mutable cursor. Successful entry consumption therefore allows same-key read → write → read with distinct cached results. Failed consumption restores the cursor with its state. Local writes that leave the cursor unchanged can be distinguished by sibling candidates whose callback-result hashes differ (§4.4). The same retry rule applies to nested reads at an unchanged host rolling hash. If incompatible candidates both validate, the first wins; builders must reject or restructure that ambiguity. Observed gas is not a general state-version discriminator. A top-level L1→L2 static read is a *lookup*: it resolves from the pool and never produces an L2 delivery. The same lookup treatment applies to an L1→L2 call that reverts on L2 and to one whose L1 frame is reverted afterwards — a signed prediction (return or revert data), nothing applied on L2, root unchanged (`CORE_PROTOCOL_SPEC.md` §A.1, L2 prover constraints). An L2→L1 top-level static read instead executes on L1 within the source transaction's zero-hash L2Tx entry. ### 4.3 `_resolveStaticEntry` / static call processing The processor is `_processStaticL2ToL1Calls` on L1 and `_processStaticIncomingCalls` on L2. The pseudocode below uses the L1 name. Both the reentrant STATIC branch and the top-level pool resolve through `_resolveStaticEntry`: ```solidity if _processStaticL2ToL1Calls(calls) != rollingHash: return false if (!success) revert(returnData) return true // caller returns this row's cached returnData ``` A false result continues the scan in the current lookup window. Exhaustion reports `ExecutionNotFound` on L1 or `EntryNotFound(hash, callGas)` on L2. A matching cached revert is terminal, and sub-call validation errors still revert immediately. `_processStaticL2ToL1Calls` runs the sub-call array flatly in **static context**: * Each sub-call is dispatched `sourceProxy.staticcall(executeOnBehalf(target, cc.gas, data))` — read-only, no value, reverts on any state write in the target. * Results fold into an **untagged** accumulator seeded at `bytes32(0)` (`EEZBase._rollingHashStaticResult`): `hash = keccak256(prev, success, retData)` per sub-call. No `CALL_BEGIN`/`CALL_END` tags, no call identity — the surrounding static-entry key already pins the context (see `CORE_PROTOCOL_SPEC.md` §E.2). The hash is **always** compared: an empty `calls[]` hashes to `0`, so a sub-call-less static entry must carry `rollingHash == 0`. * Every referenced source proxy **must already be deployed**: CREATE2 is unavailable inside a STATICCALL frame, and a STATICCALL to a codeless address silently returns `(true, "")` — so a codeless proxy reverts `StaticCallProxyNotDeployed` rather than letting the prover pre-hash a no-op. * A callback targeting the runtime manager itself is rejected with `EEZDestinationForbidden`, before the other sub-call checks. * Every sub-call must be marked `isStatic` with `value == 0` — dispatch is read-only whatever the fields say, and the untagged hash folds neither, so a mismatch reverts (`NonStaticSubCall` / `StaticCallWithValue`) instead of silently executing a proven state-changing call read-only. * `revertNextNCalls == 0` is both a prover constraint and a runtime requirement (`StaticCallWithRevertSpan`). * After resolving the already-deployed proxy and encoding the forwarding payload, a nonzero cap is checked by `_hasEnoughCallGas`; shortage reverts `InsufficientCallGas(uint64 callGas)`. Zero cap bypasses the estimate. The exclusions in CORE §B.1 apply. A naturally-reverting *sub-call* is not special: the STATICCALL returns `(false, retData)` and the untagged hash captures it. The entry-level `success == false` is for the *whole read* reverting toward its caller. ### 4.4 Local writes between identical static reads Suppose the remote `quote()` calls back to the reader's local `rate()` and returns that value directly. A single reader invocation performs: ```solidity rate = 1; quoteAtRate1 = remoteProxy.quote(); // must equal 1 rate = 2; quoteAtRate2 = remoteProxy.quote(); // must equal 2 rate = 1; quoteAfterResetToRate1 = remoteProxy.quote(); // must equal 1 ``` The local writes occur outside the static calls. They need not change the lookup key, root pins, entry cursor or host rolling hash. Supply two candidates, in this order: | Candidate | Callback array | Expected callback-result hash | Cached outcome | | --- | --- | --- | --- | | A | STATICCALL the reader's `rate()`, sourced from the remote quote contract | `keccak256(abi.encodePacked(bytes32(0), true, abi.encode(uint256(1))))` | `success = true`, `abi.encode(uint256(1))` | | B | Same callback | `keccak256(abi.encodePacked(bytes32(0), true, abi.encode(uint256(2))))` | `success = true`, `abi.encode(uint256(2))` | Both candidates have the same lookup context. The first read selects A. The second read evaluates A, gets callback result 2, rejects A's hash, then evaluates and selects B. The third read selects A again. The scan starts from the normal lookup start on every read; it never remembers the last successful static candidate. The four E2Es have local Anvil coverage and are excluded from automatic network suites pending live validation. See the [runner status note](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/README.md). This covers four execution paths: | Resolver path | Shared candidate context | E2E scenario | | --- | --- | --- | | L1 top-level | `proxyEntryHash`, `destinationRollupId`, live `expectedRoots` | [staticLocalWrite](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/scenarios/static/L1_to_L2/staticLocalWrite/E2EStaticLocalWrite.s.sol) | | L2 top-level | `proxyEntryHash`, `expectedEntryIndex = 0` | [staticLocalWriteL2](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/scenarios/static/L2_to_L1/staticLocalWriteL2/E2EStaticLocalWriteL2.s.sol) | | L1 nested | `expectedL1toL2Hash = keccak256(staticCallHash, hostHash)`; both rows at/after the cursor | [nestedStaticLocalWriteL1](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/scenarios/static/L2_to_L1/nestedStaticLocalWriteL1/E2ENestedStaticLocalWriteL1.s.sol) | | L2 nested | `expectedOutgoingHash = keccak256(staticCallHash, hostHash)`; both rows at/after the cursor | [nestedStaticLocalWriteL2](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/scenarios/static/L1_to_L2/nestedStaticLocalWriteL2/E2ENestedStaticLocalWriteL2.s.sol) | #### Builder and prover handling 1. Simulate the full source transaction, including direct local writes. Keep the real caller, calldata, static flag, gas-key mode and frame boundaries for each read. 2. Preserve distinct static alternatives under the same key. Do not deduplicate solely by `proxyEntryHash` or the nested position key. Identical complete rows may be reused. 3. For each alternative, record its ordered callback array, callback success/revert bytes, untagged callback-result hash, and the remote read's own success/return or revert bytes. Prove that outcome against the applicable remote state and callbacks. 4. Keep every required candidate in the same valid lookup window: current root pins on L1, the live entry cursor on L2, or indices at/after the nested cursor. Do not insert a mutable consumption or change the host hash to distinguish these reads. 5. Represent only the logical cross-chain frames on the producer chain. Candidate validation retries are local replay checks, not additional logical remote calls or deliveries. In the nested E2Es, one `reader.run()` frame owns all three quotes. On the quote chain, the three real quote executions have successive `CALL_BEGIN` hashes, so their callback lookup rows are keyed to those separate host positions. 6. Budget gas for every attempted candidate and all its callbacks. Attempts can change remaining gas and access warmth even though they cannot write application state. Gas-sensitive callbacks require simulation of those effects too. For top-level L1→L2, obtain the quote results by read-only simulation on L2 with the source trace's callback results; do not invent an L2 delivery or root transition. For top-level L2→L1, execute all three real quotes in the transaction's one L1 L2Tx entry. Nested reads stay within the original mutable frame on both chains. The callback hash is a validation predicate, not a new globally unique key. Two candidates with incompatible outcomes that both validate remain ambiguous: the first wins. Empty callback arrays all hash to zero. A builder must reject or restructure such cases rather than relying on row consumption, occurrence counts or table reloads between reads. #### Retry outcomes | Result while examining a candidate | Action | | --- | --- | | Lookup key/context does not match | Skip without executing its callbacks. | | Callback-result hash differs | `_resolveStaticEntry` returns false; try the next candidate. | | Hash matches and `success == true` | Return this candidate's cached `returnData`. | | Hash matches and `success == false` | Revert with this candidate's cached `returnData`; do not try later candidates. | | Malformed static sub-call, missing source proxy, or insufficient-gas preflight | Revert immediately with the existing validation error. | | All candidates exhausted | `ExecutionNotFound()` on L1; `EntryNotFound(crossChainCallHash, callGas)` on L2. | A callback's own natural revert is folded as `(false, revertData)` into the callback hash; it is not automatically a candidate-validation error. Mutable entry and mutable reentrant hash mismatches retain their existing `RollingHashMismatch` behavior. The four E2Es use the local runner's accepting proof fixture. They check actual execution, table content and frame mapping; they do not establish production circuit acceptance. Commands and expected call counts are in the [E2E authoring guide](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/BUILD_AND_REVIEW_E2E_TESTS.md#static-read--local-write--read-with-callbacks). *** ## 5. Per-frame sub-arrays Every reentrant-table row and every static entry carries its **own** sub-call array, run to completion by its resolver unless a revert or the mutable processor's gas-shortage return interrupts it: * STATIC rows / static entries: run flatly by `_processStaticL2ToL1Calls` (untagged hash). * REVERTED rows: run by `_processL2ToL1Calls` as a mini-entry (tagged schema, may itself contain reentrant calls — resolved from the host table — and `revertNextNCalls` spans). There is no global flat-call cursor and no `callCount` partition: the entry's `l2ToL1Calls[]` holds only its TOP-LEVEL calls, and each frame's completeness is structural (the resolver walks its own array; a mutable gas shortage stops only that array and folds tag 6). *** ## 6. Root pins (top-level, L1 only) `expectedRoots[]` content-addresses a top-level static entry to a point on each pinned rollup's trajectory: a candidate only **matches** when every pin equals the live `rollups[rollupId].root` (`_rootsMatch`, full-scan semantics — a mismatching candidate is skipped and the scan continues; no dedicated error). The pins are: * **State matching** — pins invalidate a cached read once any pinned rollup's root moves on. * **Transient-phase capable** — roots advance entry-by-entry during a batch, so a pin can target an intermediate mid-batch state. * **The validation-time proven set.** `_validateBatchStructure` enforces, per static entry: pins strictly increasing by `rollupId` (rejects duplicates and, bounding above `MAINNET_ROLLUP_ID`, a mainnet pin — `ExpectedRootsNotStrictlyIncreasing`); every pinned rollup in the batch (`RollupNotInBatch`); `destinationRollupId` among the pins (`StaticEntryDestinationNotPinned` — the routing target must be pinned to proven state, mirroring the entry `destination ∈ rollupUpdates` rule); and every sub-call's `sourceRollupId` among the pins (`CallSourceNotVerified`). The prover decides which rollups to pin, but the set can never be empty on L1: `destinationRollupId` must itself be a pin, so a pin-less static entry fails validation (`StaticEntryDestinationNotPinned`). Prover binding: each static entry is hashed whole (`keccak256(abi.encode(staticEntry))`) into the batch's `publicInputsHash`, so its content can't be swapped after proving. `immediateStaticEntryCount` — the leading prefix loaded into `_transientStaticEntries` for the meta-hook window — is an UNPROVEN dispatch parameter (like `immediateEntryCount`); the remainder past it is published to the per-rollup `staticEntryQueue`s regardless of whether the meta hook fired. The static prefix is a companion of the batch's META-HOOK entries, not of the immediate prefix as a whole: it is loaded only when the meta hook fires, i.e. when the immediate prefix contains at least one non-L2Tx entry. If no hook fires (empty immediate prefix, or one that is pure L2Txs), a non-zero `immediateStaticEntryCount` would silently drop the leading static entries — never loaded transiently, never published to the queues (`_saveRemainderEntries` starts past them) — so the contract rejects the post (`ImmediateStaticEntriesWithoutImmediateEntries`, checked after the immediate L2Tx run). Composers whose immediate prefix is pure L2Txs set `immediateStaticEntryCount = 0` so the static entries flow to the persistent `staticEntryQueue`s. *** ## 7. L1 / L2 differences * **Structs**: L2's `StaticExecutionEntryL2` adds `expectedEntryIndex` and drops `expectedRoots` and `destinationRollupId` (single rollup, no roots); its reentrant row is `ExpectedOutgoingCrossChainCall` with `expectedOutgoingHash` / `incomingCalls` (same layout, self-relative names). The key helper (`_computeExpectedL1toL2Hash`) and the untagged accumulator (`_rollingHashStaticResult`) are shared in `EEZBase`. * **Pool**: L1 selects transient-vs-persistent by `_transientEntriesLength` and matches with destination + pins; L2 scans the one `staticEntries` table by hash and live `entryIndex`. * **Call-hash source side**: the static key folds `sourceRollupId = MAINNET_ROLLUP_ID` on L1 and `= ROLLUP_ID` on L2 (the reader lives on this chain), `value = 0`, and `callGas = 0` on L1; on L2 `callGas` follows the outgoing policy — `gasleft()` sampled after proxy validation when `USE_GAS_LEFT`, else `0` (see CORE_PROTOCOL_SPEC §C.2/§C.3). * **Proxy protection**: L1's reentrant static branch checks `_containsVerifiedRollup(destRid)` against the executing entry's `rollupUpdates`; L2 has no allowed-rollups set. * **Reentrant-table source**: L1's `_getExpectedL1toL2Calls()` has three sources (the parked immediate-L2Tx table, the transient entry at `_currentEntryIndex`, or the persistent queue entry of `_currentEntryRollupId`; an empty parked table with `_currentEntryRollupId == 0` yields an empty table, so a static read misses with L1's `ExecutionNotFound` and a CALL folds `CALL_NOT_FOUND`); L2's `_getExpectedOutgoingCalls()` always indexes the single `entries` table. *** ## 8. Invariants (summary) * Once lookup and validation succeed, `success == false` ⇒ resolution ends in `revert(returnData)`; `success == true` ⇒ it returns `returnData`. Earlier validation errors can abort either path; a static callback-hash mismatch instead skips the candidate. Same polarity on `ExecutionEntry`, `ExpectedL1ToL2Call`, and `StaticExecutionEntry`. * A static read never mutates: STATICCALL dispatch, untagged hash, no cursor advance, no proxy auto-creation (`StaticCallProxyNotDeployed` on a codeless proxy), no `revertNextNCalls`. * STATIC rows/entries use the untagged local accumulator seeded at `bytes32(0)`: an empty static array requires expected hash `0`. * REVERTED mutable rows use the tagged host accumulator after `NESTED_BEGIN`. With no sub-calls, their expected hash is `keccak256(abi.encodePacked(hostHash, uint8(3), callHash))`, generally nonzero. * STATIC and CALL kinds can never match each other's keys — `crossChainCallHash` folds `isStatic`. * Matching is strictly forward from the reentrant cursor; a CALL consumes its row (cursor past it), a static read does not. * A REVERTED resolution runs its own sub-array with the tagged schema inside NESTED_BEGIN, checks the sub-hash, then terminal-reverts — state, cursor, and hash all roll back with it. * No-match asymmetry: a reentrant mutable CALL no-match folds `CALL_NOT_FOUND` and returns success with empty bytes. Against an honest expected hash, a surviving marker rejects the entry at hash validation, but an ordinary enclosing revert can erase it. Explicit rollback spans transport their hash through `ContextResult`. A static no-match reverts immediately in both branches — `ExecutionNotFound` on L1, `EntryNotFound(hash, callGas)` on L2. Empty success observed through a reverted probe is not evidence of remote approval. * L1 top-level match = `proxyEntryHash` + `destinationRollupId` + all pins live; full-scan skip semantics. Every re-verify of the rollup resets `staticEntryQueueIndex` to zero without deleting `staticEntryQueue` mapping entries. Publishing overwrites slots from index zero; lookups scan only below the active bound. * Validation (L1): pins strictly increasing and in-batch; `destinationRollupId` ∈ pins; every sub-call source ∈ pins; whole static entries folded into `publicInputsHash`; `immediateStaticEntryCount ≤ staticEntries.length`, and a non-zero count requires the meta hook to actually fire (≥1 non-L2Tx immediate entry) — enforced after the immediate L2Tx run (`ImmediateStaticEntriesWithoutImmediateEntries`), since the transient static pool is only reachable through the hook — see §6. ### L2 cursor pins `StaticExecutionEntryL2.expectedEntryIndex` must equal the live `entryIndex` when the read fires. A mismatching candidate is skipped. Static reads do not advance the cursor. Consuming mutable entry `j` leaves the cursor at `j + 1`; failed consumption rolls it back. Loading a new table replaces the active prefixes of both mappings and resets the cursor to zero. Static lookup is bounded by `staticEntriesLength`, so retained inactive rows cannot match. The builder must use the live cursor, not a count of successful calls. This distinguishes top-level reads across successful consumptions, but does not version local writes that leave the cursor unchanged. Nested lookup remains keyed by the rolling hash. ### Static reads that still share one lookup context The local-write example in §4.4 works because the callback results distinguish the candidates. With identical call inputs, relevant state, callbacks and execution context, deterministic execution must produce the same result; conflicting cached outcomes cannot both be correct. A dependency can nevertheless change without changing the lookup key or callback hash. For example, `return gasleft() < 400_000 ? 1 : 2` produces different answers under different budgets. In gas-independent mode, two rows with empty callback arrays share both the call key and callback hash zero. The first matching row wins. This is an [accepted support limit](/caveats): builders must reject or restructure traces whose required outcomes cannot be selected. Candidate retries spend gas even when results are gas-insensitive. A stale candidate can consume the budget needed by the correct one. Mutable calls also have a separate retry limit: an enclosing revert restores their hash/cursor, and an identical retry selects the first matching row without trying alternatives on a hash mismatch. [EEZExpressiveness tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZExpressiveness.t.sol) cover these cases and the supported static revert/local-write/retry case. ### Other static-read requirements, with examples These are validity and execution prerequisites, in addition to candidate validation above. | Case | Example and current behavior | | --- | --- | | Cached dependencies changed between transactions | Tx A prepares a callback to `rate() = 1`; another transaction changes it to 2; Tx B reuses the old row. Refresh the table when this invalidates its results. Reuse across transactions is allowed while results and lookup contexts remain valid within the load/verification block. | | Table is from an earlier block | Load/verify in block N, then read in N+1: the top-level same-block gate rejects it even if application state is unchanged. Load/verify again. | | Wrong cursor, root pins, call key or lookup window | L2 row expects cursor 1 while the live cursor is 0; L1 row pins R0 while the live root is R1; or a nested row lies before the forward cursor. Such rows do not match; without another matching row the read reverts. Under `USE_GAS_LEFT`, a different sampled gas value also changes the L2 call key. | | Wrong L1 table scope or proven rollup set | A read in the meta-hook window expects a row only in an older persistent pool, or a nested read targets a rollup absent from the active entry's proven set. Supply the row in the active pool and include the required rollup; there is no fallback to the older pool. | | Successful mutation during a static callback | A callback attempts `rate = 2`, emits a log or creates a contract: EVM static execution prevents it. An expected failed callback can still be represented; successful mutation cannot. | | Callback marked mutable or carrying ETH | A static row supplies `isStatic = false` or `value = 1`: the manager rejects it with `NonStaticSubCall` or `StaticCallWithValue`. Static callbacks must be read-only and zero-value. | | Rollback span inside a static row | A callback has `revertNextNCalls = 1`: rejected with `StaticCallWithRevertSpan`. An ordinary reverting read or callback is supported; mutable rollback spans are not part of static execution. | | Missing callback source proxy | The read would need to deploy its callback's source proxy: rejected with `StaticCallProxyNotDeployed`. Predeploy it before the static read. | | Insufficient execution gas | The supplied transaction cannot meet a callback's nonzero gas-cap preflight: `InsufficientCallGas`. A zero cap skips that estimate, not EVM gas limits; it does not guarantee completion. | Supply all required candidates for within-transaction reads. A gas-dependent key is not a general state-version guarantee. # Multi-Prover Specification EEZ verifies batches with multiple proof systems. Each rollup's manager defines its accepted verifiers, verification keys and threshold. ## Architecture * **EEZ** stores roots, ETH balances and per-rollup queues. It queries each manager's policy, calls proof verifiers, and executes the accepted entries. * **Rollup managers** authorize registration and provide verification keys and block context. The reference Rollup also exposes owner-controlled policy and root updates. * **Proof systems** implement `verify(proof, publicInputsHash)`. Every proof listed in a batch must pass before the registry changes state. ### Files | Path | Role | |---|---| | `src/EEZ.sol` | Central registry: roots, queues, `postAndVerifyBatch` flow | | `src/base/EEZBase.sol` | Shared base for L1+L2: rolling-hash machinery, proxy registry, `computeCrossChainCallHash`, the `ContextResult` transport, etc. | | `src/L2/EEZL2.sol` | L2 manager — inherits `EEZBase`; system-driven table loading (`loadExecutionTable`) and inbound delivery (`executeIncomingCrossChainCall`) | | `src/rollupContract/Rollup.sol` | Reference per-rollup manager (PS membership, vkeys, threshold, owner, `getCustomData`) | | `src/interfaces/IRollup.sol` | Declares `IRollupContract` — interface the registry calls back into | | `src/interfaces/IProofSystem.sol` | Interface for proof-verifying contracts | | `src/interfaces/IEEZ.sol` | Shared `ProxyInfo` + `IEEZ` interface, plus the L1 execution structs (`RollupUpdate`, `ExecutionEntry`, `StaticExecutionEntry`, `L2ToL1Call`, `ExpectedL1ToL2Call`, the batch structs, …) | | `src/interfaces/IEEZL2.sol` | L2 execution structs with self-relative directional names (`CrossChainCall`, `ExpectedOutgoingCrossChainCall`, `ExecutionEntry`, `StaticExecutionEntryL2`) — leaner than L1's (no `RollupUpdate` / `destinationRollupId` / `ExpectedRootPerRollup`) | | `src/interfaces/IMetaCrossChainReceiver.sol` | Callback fired on `postAndVerifyBatch`'s sender to drive the transient stream | | `src/base/CrossChainProxy.sol` | CREATE2-deployed proxy per (originalAddress, originalRollupId); immutable `EEZ` points at the manager | | `src/base/ExpectedL1ToL2CallTransient.sol` | EIP-1153 transient-storage implementation for the expected reentrant-call table of the leading inline L2Tx entry currently executing; meta-hook and deferred entries use their stored tables | ## Multi-prover model ### `ProofSystemBatchPerVerificationEntries` Each `postAndVerifyBatch` call carries a single batch struct: ```solidity struct ProofSystemBatchPerVerificationEntries { ExpectedRootPerRollup[] expectedRootPerRollup; // optional composer assertions — checked first ExecutionEntry[] entries; StaticExecutionEntry[] staticEntries; // top-level static (read-only) entries uint256 immediateEntryCount; // leading prefix executed this tx (not queued) uint256 immediateStaticEntryCount; // leading static entries resolvable this tx via the meta hook address[] proofSystems; // strictly increasing, no address(0), no duplicates RollupIdWithProofSystems[] rollupIdsWithProofSystems; // strictly ascending by rollupId uint256[] blobIndices; // selects which tx-level 4844 blobs the batch consumes bytes callData; // batch-scoped application data bytes[] proofs; // parallel to proofSystems — one proof per PS uint64 blockNumber; // single L1 block the batch binds to (0 = no context, uint64.max = latest) bool bindMsgSenderInPublicInput; // true = fold msg.sender into the public input (front-run protection) } struct RollupIdWithProofSystems { uint64 rollupId; uint64[] proofSystemIndexes; // indices into proofSystems[], strictly ascending; nonempty; reference manager additionally requires len >= threshold } struct ExpectedRootPerRollup { uint64 rollupId; bytes32 root; } ``` **Counting rule:** the batch verifies exactly `proofSystems.length` proofs — one per PS in the global list — and all proofs must verify atomically (one revert reverts the whole call). **Per-rollup PS subset (explicit):** each rollup `R` lists `proofSystemIndexes[]` — strictly-ascending indices into the batch's `proofSystems[]`. The rollup's manager is handed the resolved subset via `IRollupContract.checkProofSystemsAndGetVkeys(subset)` and applies its own policy. The reference Rollup enforces (a) every PS is known with a non-zero vkey for `R`, (b) `subset.length >= threshold`. EEZ independently requires a nonempty subset even if the reference threshold is zero. The registry never reads `threshold` separately — single external call per rollup. The registry additionally requires the manager to return exactly one vkey per subset entry (`InvalidProofSystemConfig` otherwise). Every globally listed proof system is verified, including one selected by no rollup; its per-system rollup/key accumulator remains zero. **Composer root pins:** `expectedRootPerRollup` is an optional set of assertions checked before anything else — each pin must equal the live `rollups[rid].root` or the whole call reverts `ExpectedRootMismatch(rid)`. Lets a batch composer refuse to land on an unexpected state. These batch-level assertions are not folded into the public input; the root fields inside each full entry are. **Unproven dispatch params:** `immediateEntryCount` / `immediateStaticEntryCount` are NOT folded into the public input, so the immediate/persistent split can be re-tuned without re-proving. ### Threshold lives on the manager `IRollupContract.checkProofSystemsAndGetVkeys(address[] subset)` does TWO things atomically: 1. Returns the vkey row (one vkey per PS in `subset`). 2. Reverts `ThresholdNotMet` if `subset.length < threshold`, or `ProofSystemNotAllowed` if any PS isn't allowed for this rollup (unknown / zero vkey, non-strictly-increasing input). See `src/rollupContract/Rollup.sol`. Single external call per rollup, no TOCTOU between two reads, no central threshold semantics. Custom managers can use any threshold model they like (fixed M-of-N, governance-driven, time-weighted, etc.) — the registry just consumes the returned vkeys. ### Per-PS publicInputsHash (two-stage) ```text for each rollup r in rollupIdsWithProofSystems (rollupId-ascending): customDataHashes[r] = keccak256(abi.encode(rollupId_r, customData_r)) boundSender = batch.bindMsgSenderInPublicInput ? msg.sender : address(0) sharedPublicInput = keccak256(abi.encodePacked( abi.encode(entryHashes), abi.encode(staticEntryHashes), abi.encode(blobHashes), keccak256(callData), abi.encode(customDataHashes), boundSender )) for each PS k in proofSystems: acc_k = bytes32(0) for each rollup r (rollupId-ascending) where k ∈ rollupIdsWithProofSystems[r].proofSystemIndexes: acc_k = keccak256(abi.encode(acc_k, rollupId_r, verificationKeysPerRollup[r][j])) publicInputsHash[k] = keccak256(abi.encodePacked(sharedPublicInput, acc_k)) ``` * `entryHashes[i] = keccak256(abi.encode(batch.entries[i]))` — binds the FULL `ExecutionEntry` struct (rollupUpdates, proxyEntryHash, l2ToL1Calls, expectedL1ToL2Calls, rollingHash, destinationRollupId, success, returnData). Prevents an orchestrator from swapping inputs at execution time without invalidating the proof. * `staticEntryHashes[i] = keccak256(abi.encode(batch.staticEntries[i]))` — same rationale. * `blobHashes[i] = blobhash(batch.blobIndices[i])` — the selected tx-level 4844 blob hashes; an index the tx carries no blob at (`blobhash == 0`) reverts `BlobNotFound(index)`. * `customData_r` is the opaque blob fetched per-rollup via `IRollupContract.getCustomData(batch.blockNumber)` (rollup-defined L1-view commitment — the reference `Rollup` returns ABI-encoded `(timestamp, blockHash)`, an empty blob for `blockNumber == 0`, and reverts `BlockHashUnavailable` when the hash can't be resolved). Each blob is hashed keyed by `rollupId_r`, and the per-rollup hash ARRAY binds into `sharedPublicInput`. It does NOT vary per PS, so it is shared across all `acc_k` rather than re-folded into each one. * `verificationKeysPerRollup[r][j]` is the vkey of `proofSystems[proofSystemIndexes[r][j]]` for `rollupId_r` (the jagged matrix returned by `_getVerificationKeysPerRollup`). * `boundSender` binds the submitter: with `bindMsgSenderInPublicInput = true` the proof commits to the exact `msg.sender` allowed to land the batch (front-run protection for the meta-hook / AA bundle); with `false` it commits to `address(0)` and anyone may submit. *** ## Per-rollup queue model ### Storage ```solidity struct RollupVerification { uint64 lastVerifiedBlock; uint64 entryQueueIndex; // consumption cursor uint64 entryQueueLength; // active execution entries / next append index uint64 staticEntryQueueIndex; // active static entries / next append index // All four uint64 counters share one storage slot. mapping(uint256 => ExecutionEntry) entryQueue; mapping(uint256 => StaticExecutionEntry) staticEntryQueue; } mapping(uint64 rollupId => RollupVerification record) internal verificationByRollup; ``` * `lastVerifiedBlock` serves as: (a) per-block reset marker (every verify that touches `rid` resets the queue bounds and cursor without deleting entries), (b) read gate for consumers (`lastVerifiedBlock == block.number`), (c) lockout signal for the `setRoot` owner-escape path. * `entryQueue` and `staticEntryQueue` are per-rollup deferred-consumption stores. Each entry's / static entry's `destinationRollupId` selects which queue receives it during `_saveRemainderEntries`. ### Reset on every verify `_markVerifiedBlockAndResetQueues(rid)` zeros `entryQueueLength`, `staticEntryQueueIndex`, and the consumption cursor without deleting mapping entries on **every** verify — including a same-block re-verify, which therefore REPLACES (does not append to) the prior batch's entries. Stale entries from prior blocks are unreachable anyway because consumers gate on `lastVerifiedBlock == block.number`. Appends overwrite retained slots from index zero, and scans stop at the active bounds. Static reads do not advance `staticEntryQueueIndex`; it is the active count, not a consumption cursor. ### Routing * `executeCrossChainCall(...)`: consumer's destination rollupId = `proxyInfo.originalRollupId`. Forward-scans `verificationByRollup[rid].entryQueue` from `entryQueueIndex` for the first entry matching identity (`proxyEntryHash`), routing (`destinationRollupId`), and state preconditions (every `RollupUpdate.currentRoot` == the live root); non-matches are skipped, no match ⇒ `ExecutionNotFound`. * `executeL2Txs(rid)`: explicit `rid` arg; the matched entry must have `proxyEntryHash == bytes32(0)`. Same routing. * `staticCrossChainCall(...)`: consumer's destination rollupId = `proxyInfo.originalRollupId`. Outside an execution it scans ONE table: the batch's transient static pool while a batch is mid-flight, otherwise `verificationByRollup[rid].staticEntryQueue` (match by `proxyEntryHash` + `destinationRollupId` + all `expectedRoots` pins live, full scan). * `_consumeNestedCall` / reentrant static reads: resolved from the executing entry's own unified `expectedL1ToL2Calls[]` table (entry-scoped — no queue routing at all), each entry content-addressed by `expectedL1toL2Hash == keccak256(crossChainCallHash, _rollingHash)`. A mutable reentrant no-match folds `CALL_NOT_FOUND` into the rolling hash and returns success with empty bytes. Against an honest expected hash, a marker that survives to validation causes `RollingHashMismatch`; an ordinary enclosing revert can erase it. `ContextResult` preserves the hash across an explicit rollback span. See the [reverted-probe caveat](/caveats#edge-cases). Static lookup retries candidates whose callback-result hashes mismatch, without advancing a cursor; exhaustion instead reverts `ExecutionNotFound`. Same-key alternatives support local writes between reads ([STATIC_ENTRY §4.4](/static-entry#44-local-writes-between-identical-static-reads)). This also applies to top-level static pools; the active table scope never changes during retry. ### Transient phase (intra-tx) During `postAndVerifyBatch`, the leading `batch.immediateEntryCount` entries form the IMMEDIATE prefix. Its leading run of L2Tx entries (`proxyEntryHash == 0`) executes straight from calldata — never SSTOREd. Only the prefix REMAINDER (the entries past the L2Tx run) is copied into `_transientEntries`, together with the leading `immediateStaticEntryCount` static entries into `_transientStaticEntries`; `msg.sender` must have code to drive them (`MetaEntriesWithoutReceiver` otherwise). The transient stream is consumed via the global `_transientEntryIndex` cursor (with the same forward-scan matching as the persistent path). After the transient stream drains (or doesn't), the persistent remainder is published to per-rollup queues unconditionally. Each entry's `RollupUpdate.currentRoot` is checked at consumption; entries whose required rollup roots do not match are skipped (`ExecutionNotFound` if nothing else matches). A later entry is blocked if a skipped transition leaves a required rollup root unmatched. Dependencies on other L1 state need their own checks. L1 determines settlement order across deferred rollup queues; the proven batch's array order does not impose it. *** ## `postAndVerifyBatch` flow (current) 1. **Reentry check** — `if (_insideExecution() || _transientEntriesLength != 0) revert PostBatchReentry();`. There is no separate `_inPostBatch` flag; the conditions cover executing-entry and meta-hook windows. Ordinary registration callbacks outside them are not blocked. 2. **Composer pins** — every `expectedRootPerRollup` pin must equal the live root, else `ExpectedRootMismatch(rid)`. 3. **Structural validation** (no external calls) via `_validateBatchStructure(batch)`: sorted `proofSystems[]`, strictly-ascending `rollupIdsWithProofSystems[].rollupId` (and `rollupId > MAINNET_ROLLUP_ID`), each rollup registered (`rollupContract != 0`), each row's `proofSystemIndexes[]` strictly ascending and in range; per entry: ≥1 `rollupUpdates` (strictly increasing, all ∈ batch), `destinationRollupId` ∈ its own deltas, every call SOURCE ∈ its deltas (top-level + reentrant sub-calls); per static entry: `expectedRoots` pins strictly increasing and ∈ batch, `destinationRollupId` pinned, sub-call sources pinned; L2Tx canonicality (`success == true`, empty `returnData`), immediate prefix bounds, and `ImmediateCountStrandsLeadingL2Tx`: the first deferred entry, if any, cannot have zero `proxyEntryHash`, even when the boundary follows proxy entries. 4. **Fetch vkeys + verify**: `_getVerificationKeysPerRollup(batch)` calls each rollup's manager via `IRollupContract.checkProofSystemsAndGetVkeys(subset)` — manager enforces threshold and returns one vkey per PS in the subset (length-checked by the registry). Then `_verifyProofSystemBatch(batch, verificationKeysPerRollup)` computes `sharedPublicInput` (folding each rollup's `customData` via `getCustomData(batch.blockNumber)`), builds per-PS `publicInputsHash[k]`, and calls `IProofSystem.verify(proofs[k], publicInputsHash[k])` for each PS. A false return reverts with `InvalidProof`; a verifier revert propagates its original error (including reference ECDSA malformed-signature errors). Either path unwinds the whole batch atomically. 5. **Mark verified-this-block** (`_markVerifiedBlockAndResetQueues(rid)` for each rollup): resets the rollup's queue bounds and cursor without deleting entries on every verify — a same-block re-verify REPLACES (does not append to) the prior batch's entries for that rollup. Sets the read gate for `executeCrossChainCall` / `executeL2Txs`. 6. **Drain the leading immediate L2Tx run straight from calldata**: while `batch.entries[i].proxyEntryHash == 0` (within the immediate prefix), self-call `_attemptExecuteImmediateL2Txs(batch.entries[i])`. A nonempty caught revert emits `L2TxSkipped(i, revertData)` and advances; empty data aborts with `ImmediateL2TxOutOfGas(i)` before emission (it does not prove OOG). If the nonempty run has no successes, `AllImmediateL2TxsFailed` unwinds the post. Every outer revert discards earlier skip logs. 7. **Meta hook**: if immediate-prefix entries remain past the L2Tx run, `msg.sender` must have code to receive the hook (`MetaEntriesWithoutReceiver` otherwise); push them into `_transientEntries` (and the leading `immediateStaticEntryCount` static entries into `_transientStaticEntries`), then fire `IMetaCrossChainReceiver(msg.sender).executeMetaCrossChainTransactions()` so the caller can drive them via cross-chain proxy calls. 8. **Publish the remainder** via `_saveRemainderEntries(batch)` (**unconditionally** — even if the meta hook left transient entries unconsumed): entries past `immediateEntryCount` into `entryQueue[destinationRollupId]`, static entries past `immediateStaticEntryCount` into `staticEntryQueue[destinationRollupId]`. 9. **Cleanup transient tables** (which also closes the re-entry window), then `emit BatchPosted(sharedPublicInput, rollupIds)`. ### Reentrancy reasoning The three external calls during step 4 (`IRollupContract.checkProofSystemsAndGetVkeys`, `IRollupContract.getCustomData`, `IProofSystem.verify`) are all `view`. The Solidity compiler emits `STATICCALL` for view-marked interface calls. Inside a STATICCALL frame, ALL state mutations revert at the EVM level — `SSTORE`, `TSTORE`, `LOG`, `CREATE`, `CALL` with value, AND any nested `CALL` that tries to do those things. The static context propagates down the call stack with no assembly bypass. So a malicious manager or verifier cannot reenter `postAndVerifyBatch` (state-mutating) from inside step 4 — its first `SSTORE` would revert. The other reentrancy windows are non-view callbacks: `IRollupContract.rollupContractRegistered` (called once from `registerRollup`), the immediate L2Tx run's proxy targets (step 6), and the `IMetaCrossChainReceiver` hook (step 7). Those are normal `CALL` → can reenter. Lockouts: * Re-entry into `postAndVerifyBatch` during execution or the meta hook is blocked by the `_insideExecution() || _transientEntriesLength != 0` check in step 1 (`PostBatchReentry`). `_insideExecution()` covers the immediate L2Tx run and any executing entry; `_transientEntriesLength != 0` covers the meta-hook window. This covers both the same-rollup and disjoint-rollup cases without needing a separate flag. A registration callback outside those windows may post an otherwise valid batch; it is not itself an active posting window. Static verification callbacks cannot complete state writes, but need not fail specifically with `PostBatchReentry`. * `EEZ.setRoot` (called from the manager) → gated by `RollupBatchActiveThisBlock` (`lastVerifiedBlock == block.number`) AND `SetRootNotAllowedDuringExecution` (`_insideExecution() == true`). The latter prevents a malicious manager from rewriting state mid-execution via a reentrant proxy path. *** ## Manager registration (no handoff) ### Initial registration ```solidity function registerRollup(address rollupContract, bytes32 initialRoot) external returns (uint64 rollupId); ``` * Deploy and initialize an `IRollupContract`-conforming manager before registration. The reference Rollup proxy initializes its owner, proof systems, vkeys and threshold atomically during deployment; see [the deployment guide](https://github.com/eez-association/eez-core-protocol/blob/143d87e/deployment/README.md). * Registry assigns next `rollupId` (a `uint64`; sequential ids stay well below 2^64), stores `(rollupContract, initialRoot, etherBalance=0)`. * Fires `IRollupContract(rollupContract).rollupContractRegistered(rollupId, registrant)` — one-shot callback so the manager learns its id; `registrant` is the registry's own `msg.sender`. The reference impl requires `registrant == owner()` (else `UnauthorizedRegistrantAccount`), stores the id and rejects a second call (`rollupId != 0` ⇒ `AlreadyRegistered`). * Emits `RollupCreated(rollupId, rollupContract, rollups[rollupId].root)` using the stored root after the registration callback. ### No manager handoff The registry binds each rollup ID to its manager address at registration. A registered proxy manager can be upgraded through its own administration without changing that address. ### Owner escape (root) ```solidity function setRoot(uint64 rollupId, bytes32 newRoot) external; ``` * Callable only by the registered manager (`msg.sender == rollups[rid].rollupContract`). * Reverts `RollupBatchActiveThisBlock` if any batch hit `rid` earlier this block. * Reverts `SetRootNotAllowedDuringExecution` if `_insideExecution()` is true. * The single state-mutating call from manager into registry. Emits `RootUpdated`. *** ## Trust boundaries * **Rollup policy:** the owner chooses trusted proof systems and their threshold. EEZ checks the manager's returned keys and each verifier's result; it does not establish circuit soundness. * **Cross-rollup execution:** each entry must cover its destination and call sources in its proven rollup set. Nested targets must also belong to that set. * **Posting and delivery:** all listed proofs must pass atomically. The poster controls the unproven immediate/deferred split, and unconsumed immediate work can be discarded. Proof acceptance does not guarantee delivery of every entry. * **Registration callbacks:** EEZ forwards the registrant to the manager. The reference Rollup requires its owner and rejects repeated registration. Custom managers define their own registration policy and can permit multiple rollup IDs for one address. * **Callback guards:** key lookup, custom-data lookup and proof verification are static calls. `postAndVerifyBatch` rejects reentry during execution and the meta hook. Registration callbacks outside those windows can perform otherwise permitted operations. * **Root updates:** nested rows share the outer entry's pre-state. Only the outer entry applies root and ETH deltas, subject to validation and EVM rollback. Direct `setRoot` calls are restricted to the registered manager and the registry's execution/block guards. * **Reserved rollup ID:** ID zero identifies L1. Registration starts at one, and batch validation rejects zero as a participating rollup ID. See [Caveats](/caveats) for proof domains, freshness, funding and supported call patterns. # Events and execution reconstruction How to read the events the current contracts emit: their signatures and field order, how they are ordered and rolled back, and what an indexer can reconstruct from committed receipts and execution inputs. The tracked fixtures on this page cover specific mappings and evidence checks. They don't show that every call pattern can be reconstructed from receipts and inputs alone. ## Current event interface Documentation schema revision: **events-2026-09-24**. This names the existing wire schema; it adds no on-chain version field or new events. L1 `EntryExecuted` carries only its entry index and rolling hash; `L2TxExecuted` remains. Consumers must bind an ABI to chain, manager address and implementation/code version, including upgrade activation boundaries. Derive concrete-contract ABIs from compiled artifacts; the narrow IEEZ interfaces do not declare events. `BatchPosted(bytes32 sharedPublicInput, uint64[] rollupIds)` has two non-indexed fields. Its canonical topic signature is `BatchPosted(bytes32,uint64[])`; the participant count is `rollupIds.length`. Use an artifact compiled from the selected implementation source when deriving consumer ABI expectations. ## Identity, counters and rollback Retain full hashes internally. A call hash identifies encoded inputs and can repeat; a shared public input is a commitment, not a batch nonce. Identify a committed log by chain, transaction hash and log index, retain emitter and block hash, and roll back derived state on reorg. Track queue/table generations per manager and rollup with ordered posting/load occurrences; successful replacement resets tables/cursors even within one block. An execution occurrence also needs table kind and row position. L1 immediate entries report index zero, meta-hook indices refer to the copied suffix, and persistent indices refer to a particular rollup queue generation. L2 indices refer to its loaded table. Nested arrays restart call numbering; payload coordinates alone cannot identify a frame. Use complete decoded inputs and ordered receipt history to recover context. The tracked fixtures below cover specific mappings and evidence checks; unavailable inputs or erased runtime details can require other execution evidence. Cross-chain matching needs supported source/delivery context: the gas policies in [CORE §C](/core-protocol-spec#c-action-hash-computation) can produce unequal identities. L2 entry completion counters report direct input-array length and the final forward-scan cursor, not recursive dispatch totals or number of matching rows. L1 `EntryExecuted` does not emit those counters. Rollback-span lengths are requested bounds. With a policy-negative accepting verifier/table, gas shortage can stop an array early while those lengths remain unchanged; valid proofs must exclude failure markers. A successful entry can contain naturally failing target calls. For registration, a custom callback may change the root before `RollupCreated` is emitted. The event reads the root from storage after the callback: registering with root 1 and changing it to 99 in the callback emits `RootUpdated(99)` followed by `RollupCreated(initialRoot=99)`. The reference Rollup's proxy `initialize` call emits `OwnershipTransferred`, `ThresholdChanged` (including for zero), then one `ProofSystemAdded` per initial proof system in input order, followed by `Initialized(1)`. Its implementation constructor instead disables initialization and emits `Initialized(type(uint64).max)` at the implementation address. Replay membership, keys and threshold from the proxy's initialization logs, including any owner updates before registration. Older deployments without these initialization logs still require deployment inputs or an explicit initial snapshot. ## Current protocol and reference-contract events Many events already have indexed fields, marked in the catalog below. Indexing supports RPC filtering; it does not make repeated values unique or add information to a complete receipt. BatchPosted has no indexed fields. `^` marks indexed fields; all other fields are non-indexed. Every listed event is non-anonymous. Tuple order below is part of the wire schema. | Emitting implementation | Ordered event fields | Meaning/qualification | | --- | --- | --- | | Both managers | `CrossChainProxyCreated(address proxy^, address originalAddress^, uint64 originalRollupId^)` | Manager emits after deployment/authorization; reuse emits nothing; CrossChainProxy itself emits no events | | L1 | `CrossChainCallExecuted(bytes32 crossChainCallHash^, address proxy^, address sourceAddress, bytes callData, uint256 value)` | Outgoing initiation before resolution; inherited into L2 ABI but unused there | | L1 | `RollupCreated(uint64 rollupId^, address rollupContract^, bytes32 initialRoot)` | After registration callback; initialRoot is the root then stored for the rollup | | L1 | `RootUpdated(uint64 rollupId^, bytes32 newRoot)` | Root escape write, not ordinary entry updates | | L1 | `L2ExecutionPerformed(uint64 rollupId^, bytes32 newRoot, uint256 etherBalance)` | Per-update post-write root and absolute ledger balance; final entry failure discards it | | L1 | `ExecutionConsumed(bytes32 crossChainCallHash^, uint64 rollupId^, uint256 entryQueueIndex^)` | Cursor advanced before execution; queue/hook context required; absent for inline immediate entries | | L1 | `L2TxExecuted(uint64 rollupId^)` | `executeL2Txs` initiation before consumption; absent from inline immediate path | | L1 | `BatchPosted(bytes32 sharedPublicInput, uint64[] rollupIds)` | After verification/immediate/hook/publication/cleanup; sorted participant IDs; posting is not full delivery | | L1 | `L2TxSkipped(uint256 entryIndex^, bytes revertData)` | Original batch index, caught nonempty failure in a committed outer post | | Both managers | `CallResult(uint256 entryIndex^, uint256 callNumber^, bool success, bytes returnData)` | Local array position; L1 calls the second field `l2ToL1CallNumber`; naturally failed target may be retained | | L1 | `EntryExecuted(uint256 entryIndex^, bytes32 rollingHash)` | Completion marker; terminal entry revert removes it | | L2 | `EntryExecuted(uint256 entryIndex^, bytes32 rollingHash, uint256 callsProcessed, uint256 outgoingCallsConsumed)` | Direct-array length and final nested cursor; terminal entry revert removes it | | Both managers | `CallsReverted(uint256 entryIndex^, uint256 startCallNumber, uint256 nCalls)` | Outer span bounds after context recovery; L1 names start `startL2ToL1Call`; interior logs disappear | | L2 | `ExecutionTableLoaded(ExecutionEntry[] entries, StaticExecutionEntryL2[] staticEntries)` | Both complete arrays after replacement/cursor reset; inbound failure rolls it back | | L2 | `ExecutionConsumed(bytes32 crossChainCallHash^, uint256 entryIndex^)` | Ordinary path advances first; inbound path emits before execution and advances to 1 only on success | | L2 | `IncomingCrossChainCallExecuted(bytes32 crossChainCallHash^, bool isStatic, address sourceAddress, uint64 sourceRollup, address destination, uint256 value, uint64 callGas, bytes data)` | First inbound call after binding, before execution; delivery does not certify target success | | L2 | `CrossChainCallExecuted(bytes32 crossChainCallHash^, address proxy^, address sourceAddress, bytes callData, uint256 value, uint64 callGas)` | Actual six-field outgoing overload, after system value transfer/sampling and before resolution | | Reference Rollup | `ProofSystemAdded(address proofSystem^, bytes32 verificationKey)` | Post-write membership addition, including each initializer entry | | Reference Rollup | `ProofSystemRemoved(address proofSystem^)` | After successful deletion | | Reference Rollup | `VerificationKeyUpdated(address proofSystem^, bytes32 newVerificationKey)` | Assigned key; same-value assignment may emit | | Reference Rollup | `ThresholdChanged(uint256 newThreshold)` | Assigned threshold, including proxy initialization (even zero) | | Reference Rollup | `RootEscape(bytes32 newRoot)` | After registry success; registry `RootUpdated` appears first | | Reference ECDSA | `SignerUpdated(address oldSigner^, address newSigner^)` | Constructor `(0,initialSigner)` and setter, including same-value assignment | | Rollup and ECDSA, inherited Ownable | `OwnershipTransferred(address previousOwner^, address newOwner^)` | Initialization, transfer and renunciation after owner write | | Reference Rollup, inherited Initializable | `Initialized(uint64 version)` | Proxy initialization emits version 1; implementation constructor emits `type(uint64).max` when disabling initializers | L1 has 12 concrete signatures. L2 emits eight signatures and also inherits the unused five-field outgoing signature. Generic custom managers/verifiers are not required by their current interfaces to emit all reference lifecycle events. L2 table tuple order, from [IEEZL2](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/interfaces/IEEZL2.sol): ```text CrossChainCall = (uint16 revertNextNCalls, bool isStatic, uint64 gas, address sourceAddress, uint64 sourceRollupId, address targetAddress, uint256 value, bytes data) ExpectedOutgoingCrossChainCall = (bytes32 expectedOutgoingHash, CrossChainCall[] incomingCalls, bytes32 revertedOrStaticRollingHash, bool success, bytes returnData) ExecutionEntry = (bytes32 proxyEntryHash, CrossChainCall[] incomingCalls, ExpectedOutgoingCrossChainCall[] expectedOutgoingCalls, bytes32 rollingHash, bool success, bytes returnData) StaticExecutionEntryL2 = (uint256 expectedEntryIndex, bytes32 proxyEntryHash, CrossChainCall[] incomingCalls, bytes32 rollingHash, bool success, bytes returnData) ``` ### What committed logs can establish | Domain | Logs alone? | Additional requirement | | --- | --- | --- | | Rollup IDs/manager bindings, proxy remote identities | Yes with complete history | Chain/emitter identity; reference/custom policy and registration ordering | | L1 roots | Generally | Creation plus escape plus entry-update events; creation reports the stored root after the registration callback | | L1 per-rollup ledger balances | Yes | Initialize zero at registration; apply ordered absolute snapshots | | Posting occurrence/participant IDs | Yes | Receipt/log identity; shared commitment is not a unique nonce or full proof | | L1 queue/static-pool contents and full proof batches | No | Posting calldata, routing/counts, generation resets, historical policy/custom-data and relevant transaction context; traces for nested invocations | | L2 complete loaded tables/top-level cursor | Yes with complete manager history | Current two-array schema, replacement semantics, committed consumption and block expiry | | Every call result/nested frame | Partial | Call/table context; coordinates repeat; static resolution and rolled-back results are absent | | Failed/reverted top-level attempts and their details | No | Transaction failure status plus traces/replay for discarded events/results | | Initial reference Rollup proof-system membership/keys/threshold | Yes with deployment history | Proxy initializer `ThresholdChanged` and `ProofSystemAdded`; older deployments without initialization logs need an initial snapshot or deployment calldata/traces | | Reference signers/owners | Yes | History from deployment, including initialization events | | Independent physical ETH-flow reconciliation | No | Values, payloads, traces/recovery outcomes and balance reconciliation | A trace `LOG` instruction is not a committed receipt log. Attempted trace logs can exist even when a terminal revert leaves the receipt with no logs. Forced-span internal logs likewise disappear; outer `CallsReverted` survives only when its own frame commits. Read-only static paths cannot emit LOGs. Hash tags such as `CALL_BEGIN` are hash-domain transitions, not missing Solidity events. Unknown history must not silently become empty queues, zero initial configuration or proof of target success. ## Interface scope Bridge, WrappedToken and defiMock are test-only periphery, outside the protocol audit and supported event-interface scope. Their fixture event quirks are described in source comments; this catalog makes no production indexing guarantee for them. ## Reconstruction evidence Reconstruction assumes complete decoded posting and L2 table-load inputs, association with their successful occurrences, deployment configuration and ordered replacement/consumption history. Internal wrapper inputs may require traces or replay to obtain. Obtaining those inputs is separate from demonstrating reconstruction once they are available; L1 and L2 occurrences remain independent. | Evidence | Established result | Limit | | --- | --- | --- | | [Three top-level mapping tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/ExecutionLogReconstruction.t.sol) | Immediate completions map by input order despite index zero; meta entries map through their suffix offset; interleaved deferred entries map through per-rollup queues. | These are focused `vm.recordLogs()` fixtures, not a universal committed-receipt decoder. | | [Nested-coordinate test](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZ.t.sol) | Parent and child can both emit call/entry zero; child completes before parent, and this fixture's order/results distinguish them. | A collision of payload numbers is not a demonstrated ambiguous reconstruction. | | [E2E event-verifier tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/E2EEventVerification.t.sol) | Completion multiplicity, malformed evidence, distinct L1/L2 layouts and posted-content checks. | Logs are manually constructed; the tests do not assign every nested result to its original frame. | | [Network verifier](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/scenarios/shared/Verify.s.sol) | Queries committed logs and checks content, hashes and consumption evidence. | Exact posting/load attribution remains a separately deferred verification task. | The tracked reconstruction coverage is in `ExecutionLogReconstruction.t.sol` and `E2EEventVerification.t.sol`. The first maps L1 top-level entry coordinates; the second checks synthetic event evidence. Neither demonstrates a general decoder for committed nested execution receipts. `vm.recordLogs()` may include attempted logs that a transaction receipt would discard, so receipt-level claims need their own committed-transaction evidence. ### Interpretation limits * `BatchPosted` follows immediate/meta work. Associate preceding logs using the posting occurrence and inputs, not the potentially repeated public-input hash. * `CallResult` follows completion, so child results can precede parent results. Dispatch signals, decoded arrays and order can help recover their relationship. * `CallsReverted` reports requested span bounds, not every erased inner outcome. `vm.recordLogs()` can expose attempted logs absent from the committed receipt. * Static paths emit no logs; ordinary reverts erase theirs. A hash and instructions do not generally reveal arbitrary runtime return bytes from those frames. * A completed entry may include expected target failures. Hashes do not directly encode selected/skipped table indices, and the cursor is a scan position rather than a count of executed calls. # Call-pattern support and current limits Static candidate retries do not establish a universal guarantee for all CALL, STATICCALL and revert patterns. Rejecting inconsistent tables is required for soundness, but a legitimate application trace can also lack a faithful table encoding. A prover rejecting such a trace preserves safety; it does not make the trace supported. The [characterization tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZExpressiveness.t.sol) distinguish these cases. They use the local fixtures, not a production prover. Tests asserting a current limitation are expected to pass until that limitation is fixed. ## 1. Mutable call: revert, local write, identical retry **Decision:** mutable candidate retries are not implemented. Experiments are planned to assess whether they are worth implementing; no implementation decision has been made. The current support limits are documented in [Caveats](/caveats#delivery-and-execution-semantics). The proposed hash-validated retry approach and regression checklist below guide that evaluation, not committed implementation work. A real remote contract can implement: ```solidity function run() external returns (uint256) { uint256 currentRate = IRate(msg.sender).rate(); if (currentRate == 1) revert RateNotReady(currentRate); successfulCalls++; return currentRate; } ``` For an L1-to-L2 call, `run()` is on L2 and reads the source application's rate through a callback to L1. The L1 application can legitimately do all of this in one transaction: ```solidity rate = 1; try remoteProxy.run() {} catch {} // expected application revert rate = 2; remoteProxy.run(); // should succeed, returning 2 ``` With unchanged call identity (including gas-independent keying), the current mutable resolver selects the first matching row on both attempts. The first attempt's revert restores the entry cursor; nested calls also restore the host rolling hash and the nested cursor. On L1, root updates performed by the failed frame are restored too. The direct local write changes none of these lookup discriminators. The second attempt executes the first row's callback, obtains rate 2, and fails its hash check with `RollingHashMismatch`. The later success row is never tried. This is not a missing-row example: the tests supply both alternatives, and the top-level tests show the success alternative resolves when loaded on its own at rate 2. Putting the success row first cannot faithfully produce the required initial failure. **Current limitation:** this valid sequence has no faithful table encoding under the current resolver rules when the retry preserves the call identity and lookup context. Both outcomes can be stored, but the contracts cannot select the second outcome after the local write. Confirmed on all four mutable lookup paths: * L1 top-level and nested. * L2 top-level and nested with `USE_GAS_LEFT == false`. Code: L1 `_consumeAndExecuteEntry`, `_consumeNestedCall`, `_resolveNestedReentrant`; L2 `_findMatchingEntry`, `_consumeAndExecute`, `_consumeNestedCall`, `_resolveNestedReentrant`. Only the STATIC resolver supports candidate retries. An application can make retries distinguishable by changing calldata/caller or by performing a suitable intervening committed execution, but that changes the original trace. Refreshing tables between transactions does not solve the example within one application invocation. Gas observation on L2 is not a universal occurrence identifier. A future mutable-candidate retry must isolate and roll back rejected attempts: callbacks may change state or transfer value, unlike the static callbacks. ### A successful call can also be affected by an enclosing revert The same family of failures occurs when the remote call succeeds, but its source parent subsequently reverts. The parent rollback undoes the successful consumption and its root/hash/cursor updates. A catch followed by a local write and identical retry can select the old successful row and fail its callback hash. The top-level entry case is reproduced on L1 and L2 by `test_ParentRevertMakesSuccessfulCallRetryReuseOldRow`; ordinary execution of the same pattern succeeds after the catch. Those tests do not cover the successful nested-row case below, where the outer hash can match despite an incorrect answer. ### A successful nested retry can return a stale answer without a hash mismatch **Open source-level finding.** The successful nested resolvers on both L1 and L2 have the relevant behavior. The characterization tests do not reproduce this specific case or establish production-proof acceptance. Concrete asset impact also requires a complete settlement reproduction. Track this case with the [dedicated regression checklist](#regression-checklist-successful-nested-calls-after-parent-rollback); it is not the accepted limitation about proving work inside an erased frame. Here the incorrect answer affects surviving application state. Consider one outer execution entry calling this application: ```solidity function batch() external { amount = 100; try this.onePayment(true) {} catch {} amount = 5; this.onePayment(false); } function onePayment(bool failAfterward) external { uint256 answer = remoteProxy.f(); if (failAfterward) revert(); credit += answer; } function getAmount() external view returns (uint256) { return amount; } ``` This is illustrative code, not a deployable reproduction. The intended remote `f()` calls back to `getAmount()` and returns that amount. Both calls to `f()` have identical call identities, including any gas discriminator; gas-independent keying supplies that assumption. The differing local `onePayment` argument is not passed to `f()`. Both remote calls succeed: the first is subsequently undone by its enclosing local application call. The outer entry contains two successful nested rows with the same lookup key K: | Row | Callback instruction | Saved answer | `revertedOrStaticRollingHash` | | --- | --- | --- | --- | | R100 | `getAmount()` | 100 | Zero | | R5 | `getAmount()` | 5 | Zero | The callback instruction reads live state; it does not prescribe its result. 1. The first attempt selects R100, executes the callback at amount 100 and returns 100. 2. The enclosing application call reverts. Its hash updates and nested-row cursor advance roll back, along with its other state effects. 3. The application catches the failure and sets amount to 5. That local write does not change the lookup hash. 4. The second attempt selects R100 again. Its callback now reads 5, but the resolver still returns R100's saved answer, 100. 5. The application credits 100 and returns empty bytes. The outer hash can match the expected execution, whose surviving callback also reads 5. The distinction between three return values explains the missing detection: | Surviving observation | Intended execution | Replay using R100 | Included in the local entry hash? | | --- | --- | --- | --- | | Callback result | 5 | 5 | Yes, through `CALL_END` | | Saved nested answer | 5 | 100 | No; successful `NESTED_END` has no answer | | Outer application's return | Empty | Empty | Yes, through `CALL_END` | | Application credit | +5 | +100 | Not directly | The nested identity and callback identity also match. If the incorrect answer instead changes an outer return or a later hashed call/outcome, the final hash can detect it. This example requires its effects to remain outside the surviving hashed observations. Hashing callback results does not hash every local state change. A remote payout of 5 alongside local credit of 100 is a potential consequence, not an established asset exploit. In [EEZ](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/EEZ.sol) and [EEZL2](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/L2/EEZL2.sol), `_consumeNestedCall` selects the first matching row from the current cursor. The successful branch of `_resolveNestedReentrant` runs callbacks, requires a zero `revertedOrStaticRollingHash`, closes the nested frame and returns the saved `returnData`. It does not check a candidate-specific callback hash. [EEZBase](https://github.com/eez-association/eez-core-protocol/blob/143d87e/src/base/EEZBase.sol) hashes callback results with `CALL_END, success, retData`, but closes a successful nested frame with `NESTED_END` alone. [TableGenerator](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/blob/TableGenerator.sol) retains appended nested rows when restoring the host hash after rollback. [TableStitcher](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/blob/TableStitcher.sol) advances its reconstruction row cursor without restoring that cursor at the corresponding region boundary. Reconstructing R100 followed by R5 therefore does not establish that the runtime will select them in that order. Generation and stitching of this exact example still need an executable regression. Retrying top-level candidates only after `RollingHashMismatch` cannot by itself address this case: both attempts occur inside one entry, and that entry can pass its hash check. Repeating the entry with unchanged nested selection also selects R100 again. Detection, alternative selection and failure propagation need to be designed together; see the proposed approach and regression checklist below. ## 2. Static results that depend on gas but have no distinguishing callbacks A legitimate view function can return different values for different gas budgets: ```solidity function read() external view returns (uint256) { return gasleft() < 400_000 ? 1 : 2; } ``` The test confirms direct calls with gas caps 200,000 and 600,000 return 1 and 2. In gas-independent L2 mode, the corresponding proxy reads have the same key and cursor. Both candidate rows have empty callback arrays, so both callback hashes are zero. Both proxy reads select the first row and return 1; the second result cannot be selected by the callback-hash retry predicate. This is a limit on preserving arbitrary gas-sensitive read behavior, not an argument that a correct prover should accept contradictory rows. The prover must reject a trace whose required outcomes the resolver cannot select. L1 also keys static reads with zero gas; the executable reproduction here is on gas-independent L2. More generally, speculative static callbacks consume gas and can change access warmth. Exact gas-sensitive behavior requires separate modeling and cannot follow from the callback-hash retry mechanism alone. ## 3. Static retries can exceed a fixed budget even with gas-insensitive results `test_StaticRetryCanExhaustBudgetThatFitsEitherCandidateAlone` uses an expensive, deterministic callback that returns the local rate. Both the native quote and either correct EEZ candidate fit the supplied call cap. Trying a stale candidate first spends that budget, and the later correct candidate fails `InsufficientCallGas`. Reordering the rows merely changes which rate fails. The output does not depend on `gasleft()`; the extra work from candidate search is the cause. Callers must budget for validation attempts, not only the intended remote execution. ## Proposed approach: rolling-hash checks and alternative outcomes (not implemented) Use execution-dependent rolling-hash validation to select among alternative mutable outcomes, with each candidate executed in an isolated, revertible context. This is the proposed approach for both top-level and nested calls on L1 and L2. It needs runtime, table/proof-format and regression-test validation before it can be treated as supported behavior. For the [revert, local write and identical retry scenario](#1-mutable-call-revert-local-write-identical-retry), use the existing top-level `ExecutionEntry.rollingHash`, computed from actual callback results, to select between separate entries carrying the alternative outcomes. Validate the hash after executing the callbacks in the isolated context. ### Candidate validation and rollback Extend static-style candidate search to full execution entries and nested mutable entries on both L1 and L2: 1. Find a candidate with matching call identity and applicable root/context checks. 2. Open a revertible execution context and execute the candidate, including callbacks and its rolling-hash validation at a defined checkpoint. 3. If validation fails, revert that entire attempt and try the next eligible candidate from the original context. 4. If it matches, accept its outcome: commit a successful execution, or roll back and propagate its validated application revert. If no candidate validates, reject. The isolation boundary must include state writes, value transfers, root updates, queue/reentrant cursors and transient hash state. The resolver must retain its search position outside the rejected candidate's rollback boundary so it can advance to another candidate within the same invocation. Internal validation-failure signaling must be distinguishable from arbitrary application revert data. A hash-valid `success == false` is the intended result, not permission to try a success candidate. Each candidate must bind its expected hash and exactly one `(success, returnData)` pair to the same remote execution in the proof/table construction, including its callbacks and applicable root/ether transitions. Alternative outcomes must remain associated with their validated branches; array order alone is not a discriminator. Alternative candidates must also be distinguished from required sequential calls: skipping a required call must not become a successful lookup. ### Successful nested calls need their own validation Retrying whole entries only after a final hash mismatch does not address the stale R100/R5 answer from section 1: the outer entry can pass that check. Successful nested candidates need a locally checkable expected callback/frame hash, validated before returning their saved answer. The current `revertedOrStaticRollingHash` must be zero for successful rows, so the existing field cannot simply be used as that target. Also bind the saved nested outcome into the nested closing hash, with precisely defined encoding, checkpoints and seeds on both chains. Update runtime helpers, generation, reconstruction, proof constraints and test helpers together. Outcome hashing complements candidate validation; it does not itself select the right candidate or prove completion of work erased by a parent revert. This remains an unimplemented design requiring validation. An application can catch a nested validation revert. Do not assume that throwing inside the resolver necessarily reaches a whole-entry retry boundary. Recovery or rejection must prevent an inconsistent surviving outcome from committing while preserving valid application reverts. ### Scope and validation This approach targets mutable retries, including successful calls undone by an enclosing revert, where execution supplies a distinguishing validation result and can reach the matching alternative. Gas is not a dependable replacement for that validation: L1 currently excludes gas from outgoing lookup identity, and distinct attempts can have equal gas even when gas keying is enabled. Candidate search itself also changes the gas available to later attempts. The gas-sensitive static example with identical callback hashes remains outside this guarantee. Adding alternative outcomes cannot distinguish an unobserved dependency. Rejected attempts still spend gas, so this approach also does not remove the fixed-budget limitation or establish exact gas-sensitive equivalence. Validate against the existing mutable retry, parent-rollback, gas-sensitive static and fixed-budget counterexamples. New tests must also cover rejected attempts leaving no state/value effects, valid application reverts remaining terminal, and incorrect hash/outcome associations being rejected. The nested regression checklist below remains required even if the top-level retry tests pass. ## Regression checklist: successful nested calls after parent rollback * \[ ] Reproduce the one-entry R100/R5 example on both L1 and L2 with identical nested identities. Check whether the current code commits credit 100 while the surviving callback reads 5. Distinguish fixture acceptance from production proof acceptance. * \[ ] If a fix is adopted, verify that the first attempt receives 100 before its enclosing rollback and that the second receives 5, credits only 5 and commits. * \[ ] Generate and stitch the table from the trace, then compare reconstructed occurrence order with actual runtime row selection; hand-built rows alone are insufficient for this integration check. * \[ ] Reverse candidate order and include rejected callbacks with writes or transfers. Rejected effects must roll back, and valid application reverts must remain terminal. Characterize the additional gas needed for candidate search. * \[ ] Have the application catch a nested validation failure. Recovery or rejection must prevent an inconsistent surviving outcome from committing. * \[ ] Add a control where the outer application returns the stale answer, making the divergence visible to the existing final hash check. * \[ ] Review EEZ/EEZL2 NatSpec and the integrity claims in the core specification against the implemented guarantees. A lookup key identifies a hash context, not a unique historical occurrence across rollback. Do not close this case solely because top-level retry tests pass. ## DSL support versus runtime support The current DSL supports deeper static callbacks, nested Snapshot/Revert regions and successful children inside failed frames across parsing, generation, reverse decoding and local execution; see [the DSL guide](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/blob/README.md). This does not change the core resolvers or fix the runtime counterexamples above. Unique auto-generated DSL payloads also mean generic call-tree scenarios do not by themselves exercise identical-calldata retries. ## Positive controls and scope Static `revert -> local write -> success` is supported when the callback-result hashes distinguish the alternatives. The characterization tests exercise it on L1 and L2: rate 1 selects a hash-valid row whose cached outcome reverts; rate 2 skips that row and selects the successful sibling. No table reload occurs between reads. The eleven local tests cover one ordinary-execution reference, four mutable retry counterexamples, two successful-call/parent-revert counterexamples, one gas-sensitive static counterexample, one fixed-budget static retry counterexample, and two successful static retry controls. They characterize runtime behavior, not production proof acceptance or a complete classification of all possible EVM traces. They do not establish coverage of the stale successful-nested-answer case described in section 1; its regression checklist remains open. The four static local-write E2Es remain NOT LIVE YET in staged/parallel network testing and remain excluded from automatic `all` suites; see the [E2E status note](https://github.com/eez-association/eez-core-protocol/blob/143d87e/script/e2e/README.md). # Caveats ## Calling through cross-chain proxies * **Opcodes that differ on cross-chain proxies**: * `BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, and `EXTCODEHASH` inspect the local proxy account, not the remote contract. * `DELEGATECALL` executes the proxy bytecode in the caller's context; it does not execute remote contract code locally or preserve the normal manager/proxy authorization path. * Block-state opcodes (`blocknumber`, `blockhash`, `blockgaslimit`, `chainid`, `coinbase`, …) reflect the chain the call is executing on, not the source chain — values will differ when the same logical action is observed on L1 vs L2. * **Proxy self-calls:** A proxy calling itself with `staticCheck()` runs its internal detector instead of making a cross-chain call. * **Missing entries and destination reverts:** A top-level or static lookup miss reverts at the proxy, as does a matched reverting destination; revert data alone is not a reliable way to distinguish them. A nested mutable lookup miss instead records `CALL_NOT_FOUND` and returns success with empty data. Its rejection depends on the marker surviving until hash validation; see the reverted-probe edge case below. * **Proxy ETH recovery is best-effort:** ETH sent to a proxy address before deployment is swept to the recovery address in the constructor, once, ignoring failure, so a recovery address that always rejects ETH cannot block proxy creation. The sweep gets at most 100k gas, so a recipient that burns everything it receives only adds that much to the deployment cost. If the sweep fails the ETH stays in the proxy for good. ## Delivery and execution semantics L1 ordering of deferred entries is defined in the core protocol's [Settlement Model](/core-protocol-spec#settlement-model). * **Gas-budget checks are estimates:** Both L1 and L2 check `_hasEnoughCallGas` before dispatching calls with non-zero `gas`. A detected shortage stops the local call array and folds `CALL_INSUFFICIENT_GAS` into the rolling hash; valid proofs must exclude that marker, so hash validation rejects executions that retain it. An ordinary enclosing revert can erase the marker. Static-entry execution instead reverts with `InsufficientCallGas`. The estimate excludes account-creation costs, return-data processing, and the rest of the entry, so it is not an unconditional gas-delivery or completion guarantee. A `gas` value of `0` skips the check and forwards available gas subject to EVM forwarding limits. Posters must still fund the whole batch. * **Posting does not guarantee delivery of every entry:** Consumption scans forward and permanently skips non-matching entries. During the meta hook, all rollups share one cursor: a third-party operation calling `executeL2Txs(G)` can consume a later G entry and skip pending entries for H, causing H's calls to fail with `ExecutionNotFound`. If the hook catches those failures and returns successfully, the skipped immediate entries are discarded; if it reverts, the whole post rolls back. Executed entries still require valid starting roots and execution checks. Only leading immediate L2Tx entries run automatically; later ones require an explicit `executeL2Txs` call. * **Cross-rollup calls inside the meta hook only work between rollups verified in the same batch:** a rollup verified by an earlier batch this block is unreachable from the hook (`ExecutionNotFound`); interact with it after `postAndVerifyBatch` returns, or verify both rollups together. * **Composer-controlled delivery:** The composer can withhold a batch or, when it controls the posting contract, leave optional entries unexecuted. Including execution settings in the proof would not force it to do that work, though it could stop another submitter from changing those settings. Entries that execute must still pass the existing checks. Applications that require particular entries to run must enforce that requirement themselves. * **Mutable retries after a revert are limited on both L1 and L2:** If a call reverts, or succeeds and an enclosing application call later reverts, the table cursor and relevant hash/root updates roll back. Changing local state and repeating the same call can therefore select the old row again. Mutable lookups do not try another matching row after a hash mismatch, so a valid retry can fail. Candidate retries are not implemented; experiments are planned, and whether to implement them remains undecided. See [call-pattern limits](/call-pattern-limits#1-mutable-call-revert-local-write-identical-retry). * **A nested retry may return an old saved answer:** After an enclosing revert, a successful nested call can reuse a row whose callbacks read the new state but whose saved answer belongs to the old state. The successful nested closing hash does not include that answer, so the outer hash may still match if the wrong answer affects only state not reflected in the hashed calls/results. This is a source-level concern awaiting a dedicated reproduction and production-proof validation; retry support must not be assumed. See the [example and validation checklist](/call-pattern-limits#a-successful-nested-retry-can-return-a-stale-answer-without-a-hash-mismatch). * **L2 table refresh responsibilities:** A reverted entry restores the cursor, so retrying the same call hash in the same loaded table selects the same first matching row. A later row becomes reachable after a committed cursor advance or table replacement. The node must refresh tables when state or other dependencies invalidate their cached results. A transaction boundary alone does not require reloading: reuse is allowed while results and lookup contexts remain valid, subject to the manager's same-block gate. * **Static candidates must be distinguishable:** L1/L2 static lookups select the first candidate whose callback-result hash matches, supporting local writes without changing the cursor, root pins or rolling hash. Identical full execution inputs must produce identical outcomes. Ambiguity arises when a differing dependency, such as gas, is not distinguished by the lookup; builders must reject or restructure conflicting candidates. Retrying callbacks costs gas, and gas alone is not a reliable state identifier. * **Gas-dependent results can be indistinguishable (accepted support limit):** A view such as `return gasleft() < 400_000 ? 1 : 2` can require different answers for identical calldata. L1 outgoing identity always uses zero gas; L2 outgoing identity does so when `USE_GAS_LEFT == false`. If the reads also share the lookup context and callback-result hash (including zero for two empty callback arrays), both candidates validate and the first wins. Such traces are outside the supported protocol behavior: builders/provers must reject or restructure them. L2 observed-gas keying can distinguish some cases, but does not guarantee unique identities or exact gas-equivalent replay. Candidate retries also spend gas even when outputs are gas-insensitive. See [the gas-sensitive and retry-budget tests](https://github.com/eez-association/eez-core-protocol/blob/143d87e/test/EEZExpressiveness.t.sol). * **Immediate skips are conditional:** only nonempty caught failures emit `L2TxSkipped`. Empty data aborts with `ImmediateL2TxOutOfGas` without proving OOG. All-failed immediate runs and any later outer revert discard earlier logs. ## Deployment and trust assumptions * **Accepted proofs replace queues before deferred root checks:** Every accepted batch replaces the participating rollups' execution/static queues and updates `lastVerifiedBlock`. An old proof that still verifies can therefore replace useful queued work or activate the same-block `setRoot` lock even if its deferred entries cannot execute against the current roots. Root advancement prevents stale state transitions; it does not prevent these posting-side effects. Queue replacement is a liveness policy, and rollup-defined verification context can enforce freshness where needed. * **Proof domains are a deployment responsibility:** Each rollup is verified on its designated L1. Public inputs do not explicitly bind `block.chainid`, the registry address, or a protocol version; independent deployments must use distinct verification domains. Reusing identical rollup/proof configurations across them is outside the intended model. * **L2 funding is pooled across loads:** table replacement does not erase unused ETH. Partial consumption, reverts and residual inventory require an explicit node/circuit accounting policy; no per-load equality is checked. See [L2 funding and inventory](/core-protocol-spec#l2-funding-and-inventory). ## Edge cases * **Reverts also undo missing-call markers:** An ordinary enclosing revert rolls back the transient rolling hash, including `CALL_NOT_FOUND`. Only the explicit `ContextResult` path carries that hash across its deliberate revert. A caught validation error is reflected only through the execution outcomes that remain recorded. Applications must not treat empty success from a reverted `try/catch` probe as remote approval. A nested mutable call with no matching entry returns `(true, "")`. If the probe reverts and reports that result in its revert data, it erases the missing-call marker. The caller can then act on the apparent success, and the final entry check may accept it despite no proven remote approval. For example, a small loan is approved during simulation, but the requested amount is increased before execution. The application probes approval again: no proven entry matches the larger amount, yet the proxy returns success with empty data. The probe then reverts, passing that apparent approval back in its revert data and erasing the missing-call marker. The application catches the revert and may pay the larger amount. This can happen on both L1 and L2. For static calls, a missing entry causes an immediate revert. # Integration boundaries These are current support limits and external obligations, not claims that missing node, circuit or deployment behavior has been implemented. Bridge and all `src/periphery/` fixtures are test-only and outside the protocol audit scope. ## Builder and prover * Follow the [call-identity matrix in Core §C](/core-protocol-spec#c-action-hash-computation). L2 incoming identity always binds the supplied cap; outgoing gas-independent mode does not change that rule. Source/destination hashes and rolling hashes need not coincide. * Valid expected entry and sub-hashes exclude CALL_NOT_FOUND and CALL_INSUFFICIENT_GAS. Runtime hash equality cannot inspect a hash for forbidden markers. Accepting-mock probes characterize runtime behavior, not production proof acceptance. Ordinary enclosing reverts erase observations; explicit ContextResult transports the surviving hash/cursor across its rollback span. * Reject or restructure static reads whose distinct required results share both the lookup context and callback hash. L2 top-level static rows match `proxyEntryHash` and `expectedEntryIndex == entryIndex`, so reads separated by successful mutable entry consumption can select different rows. Consuming entry `j` advances the cursor to `j + 1`; failed consumption rolls it back, static reads leave it unchanged, and a new table load resets it to zero. Callback-hash validation now skips mismatching static candidates, allowing local writes to select a different result even when the cursor or host hash is unchanged. Results that differ without a distinguishing callback hash can still alias, and retries need sufficient gas. Observed gas does not establish state-version uniqueness. See [the characterized limits](/call-pattern-limits) for the remaining runtime cases. * Refresh tables when state or other dependencies invalidate cached results; this is a node obligation. A transaction boundary alone does not require reloading if results and lookup contexts remain valid within the load block. Transactions do not interleave, but application-local writes can occur between static reads within one transaction. Model partial consumption, retries and same-block table replacement. Every accepted L1 verification replaces participating queues; every L2 load replaces both active mapping prefixes and resets their lengths/cursor. Indexed getters can expose retained inactive rows; use `entriesLength` and `staticEntriesLength` when inspecting the current table. A valid proof does not guarantee every queued or immediate entry is delivered. * Assign maximum batch/array sizes, nesting depth, scan budgets, returndata limits and delivery obligations to the composer/poster/node. The gas preflight is an estimate, not a universal resource bound. Document and reject work outside the supported integration budgets. ## L2 funding and inventory The manager holds pooled ETH. Loading a table adds msg.value; replacing that table does not delete its balance or allocate funds to individual rows. For example, two 1 ETH loads with only one successful 1 ETH incoming transfer leave 1 ETH in the manager. Reverted transfers and terminally failed inbound loads unwind through EVM rollback. The node/circuit must specify opening inventory, new funding/issuance, actual incoming payouts, transfers to SYSTEM_ADDRESS, direct/external balance changes and closing inventory. It must also choose how unused, skipped and replaced work affects retention/reuse/withdrawal of residual funds. No single per-load equality to committed or consumed values is currently enforced. Production node sequences and their conservation guarantees remain to be supplied and tested; this documentation does not invent that policy. ## Deployment and proof domains The [reference deployment package](https://github.com/eez-association/eez-core-protocol/blob/143d87e/deployment/README.md) deploys EEZ, Rollup and EEZL2 behind upgradeable proxies, initializes Rollup atomically during proxy creation, and deploys the reference ECDSA verifier directly. Production deployments must choose their owners and validate compiler/EVM settings, migrations and storage/transient compatibility. Each ProxyAdmin owner can replace its implementation and immutable configuration independently of operational ownership. The reference upgrade scripts check the applicable immutable configuration and the managers' cross-chain proxy init-code hashes, but do not validate storage layouts. A ProxyAdmin owner can call `upgradeAndCall` directly without those script checks; deployment governance must enforce the intended review process. CREATE2 uses argument-free CrossChainProxy creation code and the runtime manager as deployer. Compiler settings and source metadata affect the init-code hash, including comment-only changes under IPFS metadata. A deployment must pin its actual artifact and retain an address-continuity policy across upgrades for existing balances, approvals and authentication. Compiler-pinned CREATE2 and empty-frame hash reference vectors are not supplied by this document; deployments still need to pin their actual artifacts and review compatibility. Reference Rollup initializes its owner, threshold and keys through `initialize`; its constructor sets the immutable EEZ address and disables implementation initialization. The directly deployed ECDSA verifier initializes its owner and signer in its constructor. Immutable values are not categorically incompatible with delegatecall; inspect each concrete deployment. Proof public inputs do not explicitly bind chain ID, registry or protocol version, so distinct deployment verification domains and freshness rules are external obligations. ## Event consumers [EVENTS.md](/events) defines the existing schema and data requirements. Logs plus posting calldata, deployment configuration, relevant traces and balance reconciliation are needed for the stated reconstruction capabilities. Missing history must stay unknown. ABI migration, reducers, repeated-occurrence correlation and settlement discovery still require implementation checks; documenting them does not repair consumers. ## Test-only periphery Bridge, WrappedToken and defiMock are fixtures for testing cross-rollup flows, not production deliverables or audit targets. No production bridge remediation is requested. Their assumptions include one canonical remote bridge address, first-caller initialization, nominal token amounts, and limited metadata handling. Bridge's EtherBridged field named sender currently carries the recipient, and Initialized is declared but not emitted. These are fixture behavior descriptions, not new event requirements. Proxy authentication in receiveTokens does not establish a general multi-hop asset-origin/backing policy. Metadata fallback catches reverting getters; malformed successful string returns can still revert. Fee-on-transfer amounts, rebases, hooks and decimals conversion are not broadly supported. Tests must choose fixtures consistent with these intentional limits. # Standardized Message Format A binary format for publishing rollup activity as a single stream of messages — no header. **Everything is a message**: rollup-local operations, cross-rollup calls, results, reverts, and transaction boundaries differ only by message type. The one exception is the first byte of each stream — the reserved version byte (§6). Excalidraw: https://excalidraw.com/#json=0Efuogd9EmGs1-dtl7VQs,6TO97Ut9nvF7ePynMCBd-Q *** ## 1. Framing The very first byte of the stream is the **protocol version** (§6) — hardcoded `00` for this version. It is reserved in every stream, including calldata-only streams, and **not encoded as a message** — the one exception. Everything after it is messages. Every message begins with a `message_type` byte, which selects one of two shapes: * **Content messages** carry data: `message_type | …fields…`. * **Marker messages** carry none: a lone `message_type` byte, no fields. Each type's exact byte layout is defined inline with the type in §2. ### 1.1 Wire encoding These conventions apply across the per-type layouts in §2: * All message scalar values are **little-endian, fixed-width**: `u8`/`u16`/`u32`/`u64`/`u128`/`u256` as written, `bool` is one byte, `address` is 20 bytes. * A `bytes` field is encoded as **protobuf** encodes its own `bytes` fields — a [varint](https://protobuf.dev/programming-guides/encoding/#varints) length, then exactly that many payload bytes: ```text ac 02 ← varint(300), 2 bytes d0 9e … (300 bytes) ← the payload itself, as-is ``` Lengths fit a `u32` (prefix 1–5 bytes). **All four `bytes` fields (`operations`, `tx_data`, `data`, `return_data`) are encoded this way** — whether their contents are protocol-defined or opaque to it. * **Blob layout.** The logical byte stream is the batch's EIP-4844 blobs in order, concatenated, with the batch `callData` appended after the last blob — one continuous stream. A message MAY span a blob boundary: the next blob simply *continues* the stream. When the batch contains one or more blobs, a `CloseBlobStream` (§2.1) ends the blob portion — it MUST appear exactly once: every byte after it, up to the end of the last blob, is zero padding. How that stream data is packed into a blob's field elements is detailed in §4. The `callData` tail is part of the same stream: after a `CloseBlobStream`, the padding is skipped and the stream **resumes in `callData`**. A `CloseBlobStream` cannot appear inside `callData` itself — there are no blobs left to close, so its type byte there is invalid (§5). **Zero-blob batches (calldata only).** If the batch contains no blobs, the stream starts directly in `callData` with the protocol version byte (`00`), followed by messages. `CloseBlobStream` MUST be omitted: there is no blob portion to close. The stream ends at the end of `callData`, with no padding. ### 1.2 The current executing rollup (context stack) Several fields are never encoded because they are implied by **where execution currently is**: a `Call`'s `from_rollup` (§2.4), both endpoints of a `ReturnSuccess` / `ReturnFail` (§2.5), and the rollup a cross-rollup transaction ends on (§2.8). All of them read the same stream-level state — the **currently executing rollup** — tracked by a **context stack** that evolves deterministically with each message. Because it feeds the cross-rollup call hash (as `sourceRollupId`), every implementation MUST evolve it identically: | message | effect on the context stack | |---|---| | `InitiateCrossRollupTransaction` | pushes its `rollup_id` — the root context; the stack MUST be empty (transactions never nest) | | `Call` / `StaticCall` | its `from_rollup` **is the current top**; then pushes its `to_rollup` — subsequent messages execute on the callee | | `ReturnSuccess` / `ReturnFail` | pops — execution resumes on the caller | | `FinishCrossRollupTransaction` | pops the root `rollup_id`; the stack MUST then be empty (every call already returned) | | `RollupOperation`, `Snapshot`, `Revert`, `CloseBlobStream` | no effect on the stack | Equivalently: the current rollup is the one established by the most recent **non-finalized** `InitiateCrossRollupTransaction` or `Call` / `StaticCall` — "non-finalized" meaning its matching return (or finish) has not yet arrived. **Example** — nested calls, a return, then a sibling call: ```text InitiateCrossRollupTransaction (rollup_id: R1) # stack: [R1] Call (to_rollup: R2) # from = R1 (top of stack) → stack: [R1, R2] Call (to_rollup: R3) # from = R2 (last call's to_rollup) → stack: [R1, R2, R3] ReturnSuccess # R3 returns to R2 — pop → stack: [R1, R2] Call (to_rollup: R4) # from = R2 — the most recent NON-FINALIZED call's to_rollup # (the R2→R3 call already returned)→ stack: [R1, R2, R4] ReturnSuccess # R4 returns to R2 — pop → stack: [R1, R2] ReturnSuccess # R2 returns to R1 — pop → stack: [R1] FinishCrossRollupTransaction # stack: [] ``` *** ## 2. Message types A rollup id is encoded only when it can't be inferred. Naming a rollup does not establish its approval; readers MUST check the batch's rollup list (§5.2). Each row gives the complete field layout in wire order; §2.1–2.8 add the prose. | type | name | fields (in wire order) | |---|---|---| | `0` | — **reserved, invalid** — | never begins a message | | `1` | `CloseBlobStream` | `u8 message_type` | | `2` | `RollupOperation` | `u8 message_type` · `u64 rollup_id` · `bytes operations` | | `3` | `InitiateCrossRollupTransaction` | `u8 message_type` · `u64 rollup_id` · `bytes tx_data` | | `4` | `Call` | `u8 message_type` · `u64 to_rollup` · `address from_address` · `address to_address` · `u256 value` · `u64 gas` · `bytes data` | | `5` | `StaticCall` | `u8 message_type` · `u64 to_rollup` · `address from_address` · `address to_address` · `u64 gas` · `bytes data` | | `6` | `ReturnSuccess` | `u8 message_type` · `bytes return_data` | | `7` | `ReturnFail` | `u8 message_type` · `bytes return_data` | | `8` | `Snapshot` | `u8 message_type` | | `9` | `Revert` | `u8 message_type` | | `10` | `FinishCrossRollupTransaction` | `u8 message_type` | :::warning **Type `0` is reserved as invalid** so zero padding never parses as messages — a stream with blobs but missing its `CloseBlobStream` fails at the first padding byte instead of decoding it as valid markers. ::: :::info **Pairing.** Three pairs always come matched: every `Call` / `StaticCall` has a result — `ReturnSuccess` (`6`) or `ReturnFail` (`7`); every `Snapshot` a `Revert`; and every `InitiateCrossRollupTransaction` a `FinishCrossRollupTransaction`. `RollupOperation` and `CloseBlobStream` stand alone. ::: ### 2.1 `CloseBlobStream` Marks the end of meaningful content in the **blob portion** of the stream — **mandatory, emitted exactly once when the batch contains one or more blobs**; such a stream without it is invalid (§5). For zero-blob batches it MUST be omitted (§1.1), and it MUST NOT appear in `callData`, including the tail of a batch with blobs. Every byte after it, up to the end of the last blob, is padding and MUST be **zeroed out** (readers skip it regardless); the stream continues in the `callData` tail (§1.1). A **bare marker** (§1.1): ```c struct CloseBlobStream { u8 message_type; } // = 1 ``` ### 2.2 `RollupOperation` Carries the operations of a single rollup (the `rollup_id`). At the protocol level its payload is **opaque** — an ordered list the executing rollup interprets on its own. The operations list can be large; its varint length prefix (§1.1) scales accordingly. A `RollupOperation` MUST NOT appear inside an `InitiateCrossRollupTransaction` … `FinishCrossRollupTransaction` bracket (§5, condition 7): between a `Call` and its return the only legal messages are the cross-rollup ones themselves (`Call` / `StaticCall`, `ReturnSuccess` / `ReturnFail`, `Snapshot` / `Revert`). ```c struct RollupOperation { // type 2 u8 message_type; // = 2 u64 rollup_id; // the executing rollup bytes operations; // opaque to the protocol; the rollup interprets it (length-prefixed) } ``` :::info **Reference implementation (not protocol).** Everything about how `operations` is structured is up to the rollup. The reference implementation encodes it as a `RollupOpItem[]` (a `u32` count, then items), each item a transaction or a new-block marker, where a `NewBlock` implicitly closes the previous block. None of this is mandated — it only makes the examples below concrete. ```c RollupOpItem { u8 item_type; bytes item_data; } // 1 = Transaction, 2 = NewBlock // Transaction: rlp_transaction | NewBlock: block_params (e.g. timestamp) ``` Whether a `Transaction`'s `rlp_transaction` carries a signature is **up to the rollup** — some rollups include it, others omit it. The format does not mandate either way; the rollup that interprets `operations` knows what to expect. ::: **Example** — rollup `7` opens a block, runs two txs, opens a second block, runs one more: ```text message_type = 2 rollup_id = 7 operations = RollupOpItem[5] { [0] NewBlock { timestamp: 1_700_000_000, ... } # block 1 opens [1] Transaction rlp_tx_0 # | [2] Transaction rlp_tx_1 # | block 1 [3] NewBlock { timestamp: 1_700_000_012, ... } # block 1 closes, block 2 opens [4] Transaction rlp_tx_2 # | block 2 } ``` ### 2.3 `InitiateCrossRollupTransaction` Opens one cross-rollup transaction. `rollup_id` is where the originating tx lives. ```c struct InitiateCrossRollupTransaction { // type 3 u8 message_type; // = 3 u64 rollup_id; // where the originating tx lives bytes tx_data; // opaque — the rollup decides what goes here; last, length-prefixed } ``` What `tx_data` contains is **up to the rollup** — like `operations` (§2.2), the protocol treats it as opaque bytes (e.g. an RLP transaction with or without its signature). An `InitiateCrossRollupTransaction` requires an **empty context stack** (§1.2) — verified, not assumed: transactions never nest (§5, condition 6). It MUST be closed by a matching `FinishCrossRollupTransaction` (§2.8); everything the transaction produces lives between the two. ### 2.4 `Call` / `StaticCall` A cross-rollup call. Instead of an `is_static` flag, read-only calls are a **distinct message type** — `StaticCall` (`5`), a `STATICCALL` that carries no value and reverts on state write. A value-bearing `Call` (`4`) and a `StaticCall` (`5`) differ only by the absence of `value`: ```c struct Call { // type 4 u8 message_type; // = 4 u64 to_rollup; // target rollup; from_rollup is implicit — the executing rollup address from_address; address to_address; u256 value; // 32 bytes, little-endian (§1.1) u64 gas; // gas limit forwarded to the call bytes data; // the call's exact calldata; last, length-prefixed } struct StaticCall { // type 5 — read-only STATICCALL u8 message_type; // = 5 u64 to_rollup; // target rollup; from_rollup is implicit — the executing rollup address from_address; address to_address; u64 gas; // gas limit forwarded to the call bytes data; // the call's exact calldata; last, length-prefixed (no value) } ``` Unlike `operations` / `tx_data`, `data` is **not** rollup-defined: it is exactly the calldata of the cross-rollup call. `from_rollup` is **not encoded** — it is the **currently executing rollup**: the top of the context stack (§1.2) at the moment the `Call` is emitted. ### 2.5 `ReturnSuccess` / `ReturnFail` (the Call's result) The outcome of a finished `Call`, flowing back to the caller. Instead of one `Result` with a `success` flag, the outcome is carried by **two distinct message types** — `ReturnSuccess` (`6`) for a successful **return** and `ReturnFail` (`7`) for the call's own **revert**. Either pairs with the last outstanding `Call` / `StaticCall`, so **both** rollups (`from` and `to`) are implicit — the return pops the context stack (§1.2), resuming execution on the caller. The payload layout is identical for both: ```c struct ReturnSuccess { // type 6 u8 message_type; // = 6 bytes return_data; // the call's exact return value; last, length-prefixed } struct ReturnFail { // type 7 u8 message_type; // = 7 bytes return_data; // the call's exact revert data; last, length-prefixed } ``` Like `Call.data`, `return_data` is **not** rollup-defined: it is exactly the call's return (or revert) data. `ReturnFail` means the call **finished by reverting** on the callee: the caller receives the failure and handles it like a same-rollup contract revert. That differs from a `Snapshot`/`Revert` region (§2.6–2.7), which force-reverts calls that already *succeeded*. ### 2.6 `Snapshot` Opens a revertable region — a forced-revert bracket. A **bare marker** (§1.1): ```c struct Snapshot { u8 message_type; } // = 8 ``` Everything executed after it (native ops, cross-rollup `Call`s, nested regions) is rolled back when the region's matching `Revert` (§2.7) arrives. `Snapshot` / `Revert` are **always paired and properly nested**, like balanced brackets: every `Snapshot` is closed by exactly one `Revert`, a `Revert` must have an open `Snapshot`, and each `Revert` closes the innermost still-open `Snapshot`. A `Snapshot` is only needed around calls that `ReturnSuccess` and must be rolled back later. A call that ends in `ReturnFail` already implies its effects reverted (§2.5), so a region whose only content is that call is unnecessary — don't emit one. A `Snapshot` can only open **inside** an open `InitiateCrossRollupTransaction` … `FinishCrossRollupTransaction` bracket — never outside a cross-rollup transaction (§5, condition 7) — and its matching `Revert` must arrive before that bracket closes, at the same context-stack level the `Snapshot` opened at (§2.7). ### 2.7 `Revert` Closes the region opened by the matching `Snapshot` (§2.6), rolling back everything executed since it. A **bare marker** (§1.1): ```c struct Revert { u8 message_type; } // = 9 ``` The region is delimited by the bracket, so no rollup id, count, or call identifier is needed. A `Revert` must arrive at the **same context-stack level** (§1.2) as its matching `Snapshot`: every `Call` opened since the `Snapshot` already has its result (`ReturnSuccess` / `ReturnFail`), and the stack never drops below the `Snapshot`'s depth inside the region. A `Revert` straight after an unreturned `Call` is therefore invalid (§5, condition 6) — a call must have finished before its effects can be force-reverted. A `Revert` is **not** a failed result: a call that fails by itself reports a `ReturnFail` (§2.5). `Revert` is used when calls inside the region completed with a `ReturnSuccess`, but the rollup that initiated them, later reverts that context — forcing those already-succeeded effects to roll back. ### 2.8 `FinishCrossRollupTransaction` Closes the cross-rollup transaction opened by `InitiateCrossRollupTransaction` (§2.3). A **bare marker** (§1.1); the tx ends on the currently-executing rollup, which is implicit (§1.2). ```c struct FinishCrossRollupTransaction { u8 message_type; } // = 10 ``` *** ## 3. Reference examples ### 3.1 Normal flow: open blocks → cross-rollup → close blocks The canonical shape of a slot. Each rollup first runs its native txs and **opens** a cross-rollup block; the cross-rollup transactions are processed between the open blocks; once all are done, each rollup **closes** its block (one `RollupOperation` per rollup): ```text # 1. L2_A: native txs + open its cross-rollup block RollupOperation (rollup_id: L2_A, operations: [ NewBlock{ts}, Tx, Tx ]) # 2. L2_B: native txs + open its cross-rollup block RollupOperation (rollup_id: L2_B, operations: [ NewBlock{ts}, Tx, Tx ]) # 3. process the cross-rollup transaction(s) between the open blocks InitiateCrossRollupTransaction (rollup_id: L2_A, TxData) Call (to L2_B, ...) # from L2_A (implicit) ReturnSuccess (return_data) # pairs with the Call FinishCrossRollupTransaction # ends on L2_A (implicit) # 4. all cross-rollup done — close both blocks (a fresh NewBlock auto-closes the open one) RollupOperation (rollup_id: L2_A, operations: [ NewBlock{ts'} ]) # closeBlock for L2_A RollupOperation (rollup_id: L2_B, operations: [ NewBlock{ts'} ]) # closeBlock for L2_B ``` ### 3.2 Snapshot / Revert around a call A `Snapshot` … `Revert` bracket forces everything inside it to roll back: ```text InitiateCrossRollupTransaction (rollup_id: L2_A) # the bracket the region lives in (§2.6) Snapshot # open revertable region Call (to L2_B, ...) # from L2_A (implicit) ReturnSuccess (return_data) # pairs with the Call Revert # close region → the call's effects roll back FinishCrossRollupTransaction ``` ### 3.3 Nested snapshots Brackets nest; each `Revert` closes the innermost open `Snapshot`: ```text InitiateCrossRollupTransaction (rollup_id: L2_A) Snapshot # outer region opens Call (to L2_B, ...) # from L2_A ReturnSuccess Snapshot # inner region opens (nested) Call (to L2_C, ...) # from L2_A — the L2_B call already returned (§1.2) ReturnSuccess Revert # inner closes → the L2_A → L2_C call rolls back Revert # outer closes → the L2_A → L2_B call (and all nested) rolls back FinishCrossRollupTransaction ``` *** ## 4. Blob data encoding The logical byte stream (§1) is not written into a blob verbatim. An EIP-4844 blob is **4096 field elements of 32 bytes each**, and each element must be a valid BLS12-381 scalar — below the field modulus `r` (`2^254 < r < 2^255`). A raw 32-byte value can exceed `r`, so arbitrary bytes cannot be stored directly. EIP-4844 field elements are serialized **big-endian**: serialized offset `0` is the most significant byte. The consensus specification's [`bytes_to_bls_field`](https://github.com/ethereum/consensus-specs/blob/v1.4.0/specs/deneb/polynomial-commitments.md#bytes_to_bls_field) interprets these bytes as an integer and requires it to be below `r`. This is separate from the **little-endian** encoding of message scalar values within the logical stream (§1.1). Each field element carries **31 bytes of stream data**. Its **first serialized byte (offset `0`, the most significant byte) MUST be zero**, keeping every element `< 2^248 < r`. This format's zero-byte requirement is stricter than EIP-4844's scalar validity check: some valid BLS12-381 scalars have a nonzero first byte and are rejected by this format. **Stream-to-element mapping.** For a 31-byte stream chunk `s[0..30]`, define the integer `v = sum(s[k] * 2^(8*k), k=0..30)` and serialize `v` as 32 big-endian bytes. Thus stream byte `k` occupies serialized offset `31-k`, and the physical layout is: ```text serialized offset: 0 1 2 ... 30 31 serialized byte: 00 s[30] s[29] ... s[1] s[0] ``` For example, the 31-byte stream chunk `80 01 02 ... 1e` becomes the following 32-byte element (hex, in serialized order): ```text 001e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020180 ``` * **Capacity:** `4096 × 31 = 126,976` useful bytes per blob. * **Write:** zero-pad the logical blob portion to a multiple of the blob capacity, split it into 31-byte chunks, and encode each chunk using the mapping above. Elements and blobs retain their stream order. * **Read:** require serialized offset `0` of every element to be zero, then recover each chunk by reading offsets `31, 30, ..., 1` in that order. Concatenate the chunks of all elements of all blobs in order, and parse the version byte and messages (§1) from the result. A `CloseBlobStream` (§2.1) is evaluated against this *decoded* stream. * The trailing `callData` (§1.1) has no field-element constraint — raw bytes, appended to the recovered stream as-is. *** ## 5. Validity and reader checks ### 5.1 Format checks If any condition below is violated, readers MUST reject the **whole stream**, including all blobs and trailing `callData`. Provers implementing this format MUST enforce these conditions. L1 checks proofs over the data's hashes but does not read the stream; each rollup chooses its own verifiers. Acceptance on L1 therefore does not guarantee that the stream follows this format. A correctly formatted stream satisfies all conditions below. Before applying its operations, readers MUST also check whose approval it has (§5.2) and what actually took effect (§5.3). 1. **Encoding layer (§4).** Every field element of every blob has its first serialized byte (offset `0`, most significant) zero. This guarantees a valid BLS12-381 scalar and enforces this format's stricter 31-byte data layout. 2. **Version and stream close.** The first byte of the stream is a known protocol version (§6) — for this spec, `00`. If the batch contains one or more blobs, `CloseBlobStream` (`1`) appears exactly once, in the blob portion. If the batch contains no blobs, the version byte is the first byte of `callData`, and `CloseBlobStream` MUST be omitted. 3. **Known types.** Every message begins with an assigned type byte: `1`–`10` — a `0` (padding, §2) or any of `11`–`255` is invalid. Inside `callData` (whether the entire stream or a tail), `CloseBlobStream` is invalid too (§1.1). 4. **No truncation.** Every message's fields decode completely within the stream; the stream ends exactly at a message boundary with no bracket still open (condition 6). 5. **Invalid encodings.** Every field decodes exactly as its encoding defines. 6. **Bracket discipline.** Every `InitiateCrossRollupTransaction` is closed by a matching `FinishCrossRollupTransaction`; every `Snapshot` by a properly nested `Revert`; every `Call` / `StaticCall` has its `ReturnSuccess` / `ReturnFail`. A closer with no open counterpart (a bare `Revert`, a return with no outstanding call, a `FinishCrossRollupTransaction` with calls still open — §1.2's context stack) is invalid, as is an `InitiateCrossRollupTransaction` on a non-empty stack (nested transaction). A `Revert` closes its `Snapshot` at the **same context-stack depth** it opened at (§2.7): a `Revert` while a `Call` opened after the `Snapshot` is still unreturned is invalid. 7. **Placement.** `Call` / `StaticCall`, `ReturnSuccess` / `ReturnFail`, `Snapshot` / `Revert` appear only inside an open `InitiateCrossRollupTransaction` … `FinishCrossRollupTransaction` bracket (a `Call` needs an executing context for its implicit `from_rollup`, §1.2; a `Snapshot` cannot open outside a cross-rollup transaction, §2.6). Conversely, `RollupOperation` appears only **outside** such a bracket (§2.2) — a `RollupOperation` between a `Call` and its return is invalid. 8. **Minimal encodings — NOT enforced.** An honest encoder always emits the shortest form, but the verifier does not assert it: non-minimal encodings still decode and do not invalidate the stream. Consequence: raw stream bytes are not canonical. ### 5.2 Which rollups approved the stream Readers MUST match the stream to the successful batch that published it and its `BatchPosted(sharedPublicInput, rollupIds)` event from the expected EEZ contract. Use that batch's selected blobs and `callData`, and check them against its hashes. Identify the event by chain, contract, transaction and log position; the same `sharedPublicInput` can appear more than once. Every nonzero rollup ID in the stream MUST appear in `BatchPosted.rollupIds`. This includes `rollup_id`, `to_rollup`, and IDs inferred from the context stack. Otherwise, readers MUST reject the stream. ID zero means L1 where an L1 endpoint is supported; it is not a rollup requiring inclusion in this list. A batch approved only for rollup A cannot speak for rollup B, even if its messages name B. The hashes bind the approved bytes; the event identifies whose verifiers approved them. ### 5.3 What actually took effect `BatchPosted` does not mean every published operation executed. Readers rebuilding settled state MUST match operations to their execution entries and L1 outcomes: * `L2TxSkipped(entryIndex, revertData)` means the entry at that original index in `batch.entries` did not take effect. * Pending entries may never execute. Unused immediate entries are discarded, and later verification replaces a rollup's queues, even within the same block. No skip event does not mean an entry succeeded. * `L2ExecutionPerformed(rollupId, newRoot, etherBalance)` records an applied update. Use events from successful execution only; a revert removes its updates and events. Keep pending work separate from settled state. Match events using the batch inputs and execution order, accounting for queue replacement; entry indices can repeat. If the match is unclear, inspect or replay the execution before applying the operation. The rebuilt root MUST match L1's `rollups[rollupId].root` at the same point in history, including registration and direct root changes. Undo affected changes if L1 history changes. Resolve any mismatch before building further execution entries. *** ## 6. Versioning The first byte of the stream is the **protocol version** — reserved, decoded on its own, never a message (§1). This holds in every version of the format. This document defines version **`00`**. A future version MUST use a different version byte, and may define an entirely different format for everything after it. A verifier that does not recognize the version byte rejects the stream (§5, condition 2), so a newer-format stream can never be misparsed under this spec's rules. *** ## 7. Future optimizations Several size optimizations are planned but not part of this version: a compressed `rollup_id` encoding, a compressed `u256` for `Call.value`, and a denser blob packing (254 bits per field element, ~2.4% more capacity). They are specified in [`FUTURE_OPTIMIZATIONS.md`](/blobs/future-optimizations). # A Compact Byte Encoding for 256-bit Integers ## 1. Motivation We want to serialize any integer in `[0, 2^256 − 1]` (an EVM word) into the **shortest practical byte string**, while keeping the format: * **self-describing** — decodable with no external length prefix, * **byte-oriented** — no bit twiddling, trivial in any language, * **lossless and total** — every value has an encoding, * **great on "round" numbers** — `1 ETH = 10^18` wei, `1 RAY = 10^27`, token amounts, byte-aligned hashes, … collapse to ~2 bytes. The idea: a round number carries almost no information in its trailing zeros. Storing a small **mantissa** `m` and an **exponent** `exp` so that `value = m × base^exp` throws those zeros away. ## 2. Wire format Each value is **one format byte** followed by **0–32 payload bytes**: ```text ┌────────────┬─────────────────────────────┐ │ format (1) │ payload (0 … 32 bytes, LE) │ └────────────┴─────────────────────────────┘ ``` | Format byte | Family | Payload | Decoded value | |-------------|----------------|----------------------------------|--------------------| | `0 … 32` | **literal** | `N = format` bytes (LE) | `int(payload)` | | `33 … 252` | **scientific** | `L` bytes (LE), the mantissa `m` | `m × base^exp` | | `253 … 255` | **reserved** | — | invalid (room to grow) | All multi-byte integers (payloads and mantissas) are **little-endian (LE)**. ### 2.1 Literal family (`0 … 32`) The format byte *is* the payload length `N`; the value is the little-endian integer of the next `N` bytes. * `0x00` → `0` (no payload, the empty number) * `0x01 2a` → `42` * `0x20` + 32 bytes → any full-width word (the universal fallback) Since `N` reaches 32, **every** value is encodable as a literal. The scientific family is therefore a pure optimization, used only when it is shorter. ### 2.2 Scientific family (`33 … 252`) A scientific format encodes ```text value = mantissa × base^exp ``` The format byte fixes the **base**, the **exponent** `exp`, and the **mantissa length** `L` (bytes); the mantissa `m` is the `L` payload bytes. Two bases — two "ladders": | Base | Effect | Use case | |-------|----------------------------------------------|-------------------------------------| | `10` | strip trailing **decimal** zeros | token amounts, `10^18`, `10^27`, … | | `256` | strip trailing **zero bytes** (`256^exp` = shift left by `exp` bytes) | byte-aligned values, padded hashes | #### Exponent range is tuned *per mantissa length* This is the key design rule. For a given length `L`, the exponent runs over **every value `1 … X(base, L)`**, where `X(base, L)` is **the smallest exponent with which an `L`-byte mantissa can still build `10^28`** — our chosen ceiling for "round" magnitudes. Formally: ```text X(base, L) = min { exp ≥ 1 : (2^(8L) − 1) · base^exp ≥ 10^28 } ``` For **base 10** this is exactly the intuitive statement "express `10^28` as `m · 10^exp` with `m` fitting in `L` bytes, using the *smallest* exponent (i.e. the largest mantissa)": * `L = 1`: `10^28 = 100 · 10^26`, and `100` fits in 1 byte (`1000` would not) → **X = 26**. * `L = 2`: `10^28 = 10000 · 10^24`, and `10000` fits in 2 bytes → **X = 24**. * `L = 3`: `10^28 = 10^7 · 10^21` → **X = 21**, … and so on. A longer mantissa absorbs more magnitude, so it needs **fewer** exponent steps — the ladders get shorter as `L` grows, and stop once `10^28` fits in `L` bytes outright (`L = 12`, where no exponent is needed). The result is a *descending triangle* of codes, with no wasted entries. :::note **Note on base 256.** `10^28 = 2^28·5^28` is only divisible by `256^exp` (= `2^{8·exp}`) for `exp ≤ 3`, so you cannot *exactly* build `10^28` from a byte-shift for larger exponents. The formula above uses the **reach** criterion (largest representable value `≥ 10^28`), which coincides with the exact-build statement for base 10 and is the natural generalization for base 256. ::: ### 2.3 Reserved family (`253 … 255`) 3 unused codes. Natural extensions: a base-10 ladder for exponents `> 28` (to cover round numbers up to `2^256`), wider mantissas, or a signed/fixed-point flag. ## 3. The complete format table ### Literals — codes `0–32` Format byte = payload length `N`; value = `int.from_bytes(payload, "little")`. ### Base 10 — codes `33–186` (`value = m × 10^exp`), 154 codes Each row is a length `L`; it contributes `X` consecutive codes for `exp = 1 … X`. | L | X(10,L) | exponents | codes | |---|---------|-----------|-----------| | 1 | 26 | 1 … 26 | 33 … 58 | | 2 | 24 | 1 … 24 | 59 … 82 | | 3 | 21 | 1 … 21 | 83 … 103 | | 4 | 19 | 1 … 19 | 104 … 122 | | 5 | 16 | 1 … 16 | 123 … 138 | | 6 | 14 | 1 … 14 | 139 … 152 | | 7 | 12 | 1 … 12 | 153 … 164 | | 8 | 9 | 1 … 9 | 165 … 173 | | 9 | 7 | 1 … 7 | 174 … 180 | | 10| 4 | 1 … 4 | 181 … 184 | | 11| 2 | 1 … 2 | 185 … 186 | ### Base 256 — codes `187–252` (`value = m × 256^exp`), 66 codes | L | X(256,L) | exponents | codes | |---|----------|-----------|-----------| | 1 | 11 | 1 … 11 | 187 … 197 | | 2 | 10 | 1 … 10 | 198 … 207 | | 3 | 9 | 1 … 9 | 208 … 216 | | 4 | 8 | 1 … 8 | 217 … 224 | | 5 | 7 | 1 … 7 | 225 … 231 | | 6 | 6 | 1 … 6 | 232 … 237 | | 7 | 5 | 1 … 5 | 238 … 242 | | 8 | 4 | 1 … 4 | 243 … 246 | | 9 | 3 | 1 … 3 | 247 … 249 | | 10| 2 | 1 … 2 | 250 … 251 | | 11| 1 | 1 … 1 | 252 … 252 | ### Reserved — codes `253–255` **Code assignment order** (how a code maps to `(base, exp, L)`): base 10 first then base 256; within a base, ascending `L`; within a length, ascending `exp`. The Python `_build_tables()` below is the normative definition. ## 4. Canonical encoding rule A value can have several valid encodings (e.g. `1280 = 5·256 = 128·10`, or `10^27 = 100·10^25 = 10·10^26`). The encoder is deterministic: among all valid candidates it picks the one with 1. the **fewest total bytes**, then 2. **base 256 ("base-2") before base 10**, and either before a literal, then 3. the **lowest exponent** (equivalently, the largest mantissa). This gives a unique canonical form per value (handy for hashing / equality on the encoded bytes). ## 5. Python reference implementation ```python """Compact byte codec for integers in [0, 2**256 - 1] (length-tuned mantissa/exponent table spanning 10**28).""" # ----------------------------------------------------------------- config BASES = (10, 256) # ladder bases LITERAL_MAX = 32 # codes 0..32 are literals TARGET = 10**28 # magnitude the ladders must span MAX_U256 = (1 << 256) - 1 # --------------------------------------------------------- table builder def _max_exp(base, L): """Smallest exp >= 1 such that an L-byte mantissa * base**exp can reach TARGET. Returns 0 when TARGET already fits in L bytes (ladder ends).""" m_max = (1 << (8 * L)) - 1 if m_max >= TARGET: return 0 exp = 1 while m_max * base ** exp < TARGET: exp += 1 return exp def _build_tables(): code, code_to_sci, sci_to_code = 33, {}, {} for base in BASES: L = 1 while True: x = _max_exp(base, L) if x < 1: # ladder for this base is complete break for exp in range(1, x + 1): code_to_sci[code] = (base, exp, L) sci_to_code[(base, exp, L)] = code code += 1 L += 1 assert code <= 256, "format codes overflow a byte" return code_to_sci, sci_to_code CODE_TO_SCI, SCI_TO_CODE = _build_tables() # largest exponent that appears for each base (bounds the encoder's search) _MAX_EXP = {b: max(e for (bb, e, _l) in SCI_TO_CODE if bb == b) for b in BASES} def _byte_len(n): """Minimal little-endian byte length of a non-negative int (0 -> 0).""" return (n.bit_length() + 7) // 8 # --------------------------------------------------------------- encoder def encode(value): """Encode an int in [0, 2**256-1] to its canonical compressed bytes.""" if not (0 <= value <= MAX_U256): raise ValueError("value out of range [0, 2**256-1]") # candidate 1: literal (key rank 2 = lowest priority at equal size) n = _byte_len(value) best, best_key = bytes([n]) + value.to_bytes(n, "little"), (1 + n, 2, 0) # candidates 2..: scientific (only when value == m * base**exp exactly) # tie-break key: (size, base rank [256 first], exponent ascending) for base in BASES: for exp in range(1, _MAX_EXP[base] + 1): p = base ** exp if value % p: continue m = value // p if m == 0: continue L = _byte_len(m) code = SCI_TO_CODE.get((base, exp, L)) # exists only if exp <= X if code is None: continue cand = bytes([code]) + m.to_bytes(L, "little") key = (1 + L, 0 if base == 256 else 1, exp) if key < best_key: best, best_key = cand, key return best # --------------------------------------------------------------- decoder def decode(stream, offset=0): """Decode one value at `offset`; returns (value, next_offset).""" f = stream[offset] offset += 1 if f <= LITERAL_MAX: # literal chunk = stream[offset:offset + f] if len(chunk) != f: raise ValueError("truncated literal payload") return int.from_bytes(chunk, "little"), offset + f if f in CODE_TO_SCI: # scientific base, exp, L = CODE_TO_SCI[f] chunk = stream[offset:offset + L] if len(chunk) != L: raise ValueError("truncated mantissa") value = int.from_bytes(chunk, "little") * base ** exp if value > MAX_U256: raise ValueError("decoded value exceeds 2**256-1") return value, offset + L raise ValueError(f"reserved/invalid format byte {f}") # 253..255 # ------------------------------------------------------- stream helpers def encode_stream(values): return b"".join(encode(v) for v in values) def decode_stream(stream): out, off = [], 0 while off < len(stream): v, off = decode(stream, off) out.append(v) return out ``` ### Self-test ```python if __name__ == "__main__": import random def roundtrip(v): enc = encode(v) dec, off = decode(enc) assert dec == v and off == len(enc), (v, enc.hex(), dec) return enc print("special codes:", len(CODE_TO_SCI), "-> range 33..%d, reserved %d..255" % (32 + len(CODE_TO_SCI), 33 + len(CODE_TO_SCI))) for v in [0, 1, 255, 256, 10**18, 10**27, 10**28, 5 * 10**26, 0xDEADBEEF, 3 * 256**11, 2**255, MAX_U256]: print(f"{v:<32} -> {roundtrip(v).hex():<14} ({len(encode(v))} B)") random.seed(0) for _ in range(300_000): roundtrip(random.randrange(0, MAX_U256 + 1)) for exp in range(29): # bias toward round decimals for _ in range(3000): v = random.randrange(1, 10**8) * 10**exp if v <= MAX_U256: roundtrip(v) print("all round-trips OK") xs = [0, 42, 10**18, 1 << 200, 7 * 256**9] assert decode_stream(encode_stream(xs)) == xs print("stream OK") ``` ## 6. Worked examples | Value | Encoding (hex) | Bytes | Format chosen | |--------------------------|------------------|-------|----------------------------------------| | `0` | `00` | 1 | literal, N=0 | | `42` | `01 2a` | 2 | literal, N=1 | | `256` | `bb 01` | 2 | base 256, exp 1, m=1 | | `1280` (= `5·256` = `128·10`) | `bb 05` | 2 | base 256, exp 1, m=5 (base 256 wins tie) | | `10^18` (1 ETH in wei) | `30 64` | 2 | base 10, exp 16, m=100 (`=100·10^16`) | | `10^27` (1 RAY) | `39 64` | 2 | base 10, exp 25, m=100 (`=100·10^25`) | | `10^28` | `3a 64` | 2 | base 10, exp 26, m=100 (`=100·10^26`) | | `5·10^26` | `39 32` | 2 | base 10, exp 25, m=50 | | `123456789·10^12` | `72 d2 02 96 49` | 5 | base 10, exp 11, m=1234567890 | | `0xDEADBEEF` | `04 ef be ad de` | 5 | literal, N=4 (not round) | | `2^256−1` | `20` + 32×`ff` | 33 | literal, N=32 (worst case) | Note the tie-break in action: `1280` chooses the base-256 form `5·256` over the equal-length base-10 form `128·10`, and the decimals use the **lowest** exponent (largest mantissa) — e.g. `10^27 = 100·10^25` (`exp 25`) rather than `10·10^26`. ### 6.1 Test vectors [`U256_COMPRESSED_CODEC_VECTORS.json`](https://github.com/eez-association/eez-core-protocol/blob/143d87e/docs/blobs/U256_COMPRESSED_CODEC_VECTORS.json) carries machine-readable vectors generated with the reference implementation above (§5): * **`edge_cases`** — canonical `value ↔ hex` pairs for boundary and round values (including every worked example from §6). * **`per_format_code`** — one canonical vector for **each** assigned format byte `0 … 252`: all 33 literal lengths and all 220 scientific codes. * **`decode_only`** — well-formed but **non-canonical** encodings: a decoder accepts them, a canonical encoder (§4) never emits them (each entry carries the `canonical_hex` it should have used). * **`invalid`** — inputs a decoder MUST reject: reserved format bytes `253`–`255`, truncated literal payloads, truncated mantissas. * **`streams`** — concatenated values, exercising self-delimiting decode. A conforming implementation must round-trip every `edge_cases` / `per_format_code` / `streams` entry exactly, decode every `decode_only` entry to its `value`, and reject every `invalid` entry. ## 7. Properties & trade-offs * **Total & lossless.** Every value has a literal fallback, so `decode(encode(v)) == v` for all `v ∈ [0, 2^256−1]`. * **Overhead bound.** Worst case `+1` byte over the raw minimal big-int (32-byte word → 33 bytes). Round numbers → ~2 bytes. * **Canonical.** §4 gives exactly one encoding per value. * **Self-delimiting.** Each value knows its own length; values concatenate into a stream with no separators. * **Compact table.** 154 (base 10) + 66 (base 256) = **220** scientific codes, occupying `33 … 252`; `253 … 255` (3 codes) remain reserved. `10^28` is the largest power-of-ten ceiling that fits in one byte of formats — `10^29` would need 244 scientific codes and overflow. * **Coverage ceiling (by design).** The ladders span exactly up to `10^28`; round numbers between `~10^28` and `2^256` fall back to literals. ## 8. Possible extensions (reserved codes `253–255`) * A base-10 ladder for exponents `> 28`, to compress round decimals up to `2^256`. * Wider mantissas / higher precision. * A base-2 (bit-shift) ladder for sub-byte alignment. * A signed or fixed-point flavor. # Future Optimizations Deferred size optimizations for the blob message format ([`BLOB_FORMAT_SPEC.md`](/blobs/blob-format-spec)). Version 1 of the format deliberately uses the simple encodings — plain `u64` rollup ids, plain 32-byte `u256` values, 31 data bytes per blob field element. Each section below is a drop-in candidate for a future version of the format (spec §6: a new version means a new leading version byte). ## 1. Compressed `rollup_id` Replaces the fixed `u64` with a self-delimiting, variable-length encoding (1–9 bytes): a leading byte gives the byte length of the little-endian value that follows, and its range picks whether `ROLLUP_ID_OFFSET` is applied: | leading byte | the rollup id is… | |---|---| | `0`–`8` | the next `leading` bytes, little-endian (**raw**) | | `9`–`12` | `ROLLUP_ID_OFFSET +` the next `leading − 8` bytes, little-endian (**offset**) | | `13`–`255` | reserved | Raw ids reach `2^64 − 1`; offset ids reach `2^32 + 2^32 − 1`. `ROLLUP_ID_OFFSET` is a protocol constant (`2^32`) — the base auto-assigned ids start from, so a large id like `2^32 + 5` collapses to one payload byte (`09 05`), and id `0` is a single byte (`00`). ## 2. Compressed `u256` (`Call.value`) Replaces the fixed 32-byte `u256` with the **compressed `uint256` codec** — a self-describing, self-delimiting, variable-length encoding (1–33 bytes) defined in [`U256_COMPRESSED_CODEC.md`](/blobs/u256-compressed-codec), with conformance test vectors in [`U256_COMPRESSED_CODEC_VECTORS.json`](https://github.com/eez-association/eez-core-protocol/blob/143d87e/docs/blobs/U256_COMPRESSED_CODEC_VECTORS.json). Typical values (zero, round ether amounts) compress to a few bytes. ## 3. Dense blob packing (254 bits per field element) V1 stores 31 bytes per field element and zeroes the first serialized byte (offset `0`, most significant) — `4096 × 31 = 126,976` useful bytes per blob. Since the BLS12-381 modulus satisfies `2^254 < r < 2^255`, an element can safely carry **254 bits** (top two bits clear), not just 248: * **Data elements** carry 31 bytes **plus the low 6 bits of the most significant byte** (serialized offset `0`) — top two bits clear — so each represents a full 32 bytes of logical data. As in §4 of the current format, logical bytes are assigned from least to most significant: the 32nd logical byte supplies these extra bits. * The **two deferred high bits** per data element are collected in order. Across the 4064 data elements that is `2 × 4064 = 8,128` bits (1,016 bytes), packed (same 254-bit scheme) into the **last 32 field elements** of the blob — an exact fit: `32 × 254 = 8,128` bits, no slack. * **Capacity:** `(4096 − 32) × 32 = 130,048` useful bytes per blob — **+3,072 bytes (~2.4%)** over v1. * **Read:** decode the 32 tail elements to recover the deferred bits, restore each data element's full 32nd logical byte (in-place low 6 bits + its two deferred bits), read each element from least to most significant, concatenate all data elements in order, and parse the stream from the result. ## Canonicality note All three encodings admit non-minimal forms (like the varint length prefixes already in v1). If adopted, the spec's stance stays that of §5, condition 8: the encoder emits the shortest form, the verifier does not assert it.