Skip to content
EEZ Protocol Documents home

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.

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.solL2 · src/interfaces/IEEZL2.sol
CallL2ToL1Call / l2ToL1CallsCrossChainCall / incomingCalls
Expected reentrant callExpectedL1ToL2Call / expectedL1ToL2CallsExpectedOutgoingCrossChainCall / 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.

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.

// 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:

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.

// 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:

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)
}
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.

  • 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 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).
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 ExecutionEntrys. 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).

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;
}
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

struct ProxyInfo {
    bool    isProxy;          // existence flag, set on registration
    address originalAddress;
    uint64  originalRollupId;
}

RollupConfig (L1 only)

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.

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

VariableTypeNotes
rollupCounteruint64Last assigned rollup ID (registerRollup assigns ++rollupCounter)
rollupsmapping(uint64 ⇒ RollupConfig)Per-rollup config (manager pointer + root + ether)
verificationByRollupinternal mapping(uint64 ⇒ RollupVerification)Per-rollup deferred queues + cursor (public views: lastVerifiedBlock(rid), queueLength(rid), entryQueueIndex(rid))
_transientEntriesmapping(uint256 ⇒ ExecutionEntry) (internal)Ordinary storage meta-hook entries; each used slot is deleted after the batch
_transientStaticEntriesmapping(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 tableEIP-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 setEIP-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 / _transientStaticEntriesLengthuint256 transientActive batch-scoped mapping bounds; the execution length flags the meta-hook reentry window; both reset after each batch
_transientEntryIndexuint256 transientGlobal cursor into _transientEntries (cross-rollup, intra-postAndVerifyBatch)
_currentEntryRollupIduint64 transientRollup whose persistent queue supplies the executing entry (so _getExpectedL1toL2Calls() finds the right queue); set only by _consumeAndExecuteEntry's persistent branch, 0 everywhere else
_lastL1ToL2CallConsumeduint256 transientForward-scan position into the entry's unified expectedL1ToL2Calls[]
_entryEtherDeltaint256 transientNet 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:

VariableTypeNotes
authorizedProxiesmapping(address ⇒ ProxyInfo)Registered proxies
_rollingHashbytes32 transientRolling hash accumulator
_currentEntryIndexuint256 transientIndex 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:

VariableTypeNotes
entriesmapping(uint256 ⇒ ExecutionEntry) (public)Reusable execution slots; only indices below entriesLength are active
staticEntriesmapping(uint256 ⇒ StaticExecutionEntryL2) (public)Reusable static slots; only indices below staticEntriesLength are active
lastLoadBlockuint256Block of last loadExecutionTable / executeIncomingCrossChainCall
entryIndexuint256Cursor into the active execution prefix
entriesLength / staticEntriesLengthuint256Active table bounds, reset on every load; retained slots beyond them cannot be consumed
_executingbool transientSet at _executeEntry start, cleared at its end; backs _insideExecution() (L1 derives the same predicate from its proxy-protection array, which L2 doesn't have)
_lastOutgoingCallConsumeduint256 transientForward-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

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

function postAndVerifyBatch(ProofSystemBatchPerVerificationEntries calldata batch) external

Permissionless. A single struct (NOT an array) carries:

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):

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):

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).

executeCrossChainCall

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:

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

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).

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

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():

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

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.

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/, 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:

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:

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
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:

// 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
_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.

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
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
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)
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 sitecallGas folded
Everything on L10
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:

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

function loadExecutionTable(ExecutionEntry[] calldata _entries, StaticExecutionEntryL2[] calldata _staticEntries)
    external payable onlySystemAddress
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); no per-load conservation check is implemented.

executeIncomingCrossChainCall (L2 inbound delivery)

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). 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 executeCrossChainCalls 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

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

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:

// 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 sitecallGas 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)

FieldValue
isStaticfalse (NOT_STATIC_CALL)
sourceAddresssourceAddress (msg.sender of the original proxy call)
sourceRollupIdMAINNET_ROLLUP_ID = 0
targetAddressproxyInfo.originalAddress
targetRollupIdproxyInfo.originalRollupId
valuemsg.value
callGas0 (ZERO_CALL_GAS) — L1 never keys on gas
datacallData (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:

FieldValue
sourceRollupIdthis L2's ROLLUP_ID
targetRollupIdproxyInfo.originalRollupId (the proxied counterparty — L1 or another L2)
callGasuint64(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):

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. 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):

// 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):

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:

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:

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):

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         = <expected final hash>

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:

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:

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:

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:

_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. 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. 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

FunctionWho can call
registerRollupAnyone may call; the manager callback must authorize the registrant (the current owner for the reference Rollup)
postAndVerifyBatchAnyone (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.

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.