Skip to main content

Settlement & Cross‑Layer Messaging 

Aztec finalises every L2 block on Ethereum and ferries data between the two layers without leaking private information. This page first introduces the portal/message‑box abstraction that makes private cross‑layer calls possible, then walks through a single block from proof generation to message consumption.

Portals and Message Boxes: the Big Idea

Portals are the L1 counter‑parts of Aztec contracts. A portal can be an L1 contract or even an EOA, but it is logically linked to a specific L2 address. 

L1 portal  ↔  L2 contract

Because private execution is prepared off‑chain, Aztec cannot do a synchronous CALL from L2 → L1 (or vice‑versa) without leaking inputs. Instead it turns every cross‑domain call into a message that is:

  1. Inserted on the sending layer (pending set).
  2. Moved by the rollup proof to the receiving layer (ready set).
  3. Consumed exactly once by the recipient, producing a nullifier.

We call the data structure that stores these leaves a Message Box. There are two of them:

DirectionPending set lives onReady set lives onContract
L1 → L2Inbox (L1)Private state (L2)Inbox.sol
L2 → L1Private state (L2)Outbox (L1)Outbox.sol
Properties
  • Messages are multi‑sets: identical payload can be inserted many times.
  • Only the recipient can spend a message.
  • The rollup moves leaves atomically (pending → ready). If the proof lies, verification fails.

Why pulling instead of pushing?

Other roll‑ups push calldata from their bridge contract into user contracts, revealing full parameters. Aztec pulls: the L1 portal calls its own logic and then pulls the corresponding message out of the Outbox. For deposits this leaks the amount on L1 (that’s inevitable) but hides the L2 recipient and, for anyone without the message secret, which L2 block the deposit is claimed in (it appears only as an unlinkable nullifier insertion).

Message formats

Below is precisely what gets hashed, stored, and verified at each hop. There are three layers:

LayerPurposeSolidity / TS type
ApplicationArguments your portal passes (recipient, payload …)L1ToL2Msg, L2ToL1Msg
CommitmentSingle 254‑bit field inserted into Merkle treeleaf = sha256ToField(msg)
Sequencer DB / RPCExtra provenance so the sequencer can resume after a re‑orgInboxMessage
// packages/contracts/src/core/libraries/DataStructures.sol

struct L1Actor {
address actor; // who sends on L1
uint256 chainId; // = block.chainid (anti‑replay)
}

struct L2Actor {
bytes32 actor; // Aztec address (field element)
uint256 version; // roll‑up version to prevent fork griefing
}

struct L1ToL2Msg {
L1Actor sender; // auto‑filled as msg.sender + chainId
L2Actor recipient; // supplied by caller (portal knows its L2 pair)
bytes32 content; // 32 B payload or sha256(bigPayload)
bytes32 secretHash; // commitment to random `r` → hides nullifier timing
uint256 index; // globalLeafIdx = treeNum * SIZE + localIdx
}

struct L2ToL1Msg {
L2Actor sender;
L1Actor recipient;
bytes32 content;
// `index` lives in the Outbox leaf, same idea as above
}
Privacy note on L2→L1 origin

The Outbox commits to a hash of L2ToL1Msg (which includes the L2 sender), but only the hash is public in the tree. The preimage—revealing the sender—is provided at consumption time on L1 together with a Merkle proof. This lets L1 portals authenticate the exact L2 contract address without leaking it earlier than necessary.

L1ToL2Msg and L2ToL1Msg are flattened → hashed with SHA‑256 >> Field¹. That 254‑bit field is the only thing the Merkle tree stores.

// packages/foundation/src/message_bridge/inbox_message.ts

export type InboxMessage = {
index: bigint; // == L1ToL2Msg.index (global)
leaf: Fr; // sha256ToField(L1ToL2Msg)
l2BlockNumber: UInt32; // tree number (‘inProgress’ when inserted)
l1BlockNumber: bigint; // for re‑org detection / proofs
l1BlockHash: Buffer32; // header hash at insertion time
rollingHash: Buffer16; // keccak16(chainLeafs), cheap block digest
};
note

sha256ToField(x) = BigInt(SHA256(x)) mod BN254_P. The contracts use Hash.sha256ToField(), the circuits use the exact same constant.

Field‑by‑field cheat‑sheet

FieldWhere storedWhy it existsContract ref
indexInbox tree leaf & bitmapUniqueness + nullifier derivationInbox.sendL2Message() lines 73‑80
secretHashLeaf only (not in Outbox)Lets user prove they own the message without revealing whenSame file, constructor comment
rollingHashOff‑chain DB / RPCLets sequencer stream blocks with O(1) integrityupdateRollingHash() in inbox_message.ts
l1BlockHashOff‑chain onlyDetect L1 re‑orgs before proposing-

The updateRollingHash helper:

function updateRollingHash(h: Buffer16, leaf: Fr): Buffer16 {
const input = Buffer.concat([h.toBuffer(), leaf.toBuffer()]);
return Buffer16.fromBuffer(keccak256(input));
}

This mirrors the contract logic that emits MessageSent(block, idx, leaf, newRollingHash) so any observer can verify no leaves were skipped.

Reference

See this contract and this line for reference.

Only the commitment (sha256ToField(struct)) is inserted; the full struct is reconstructed by the circuit.

Off‑chain proof construction

  • Users create Kernel proofs, each proof contains: note‑hashes, nullifiers, inbox nullifiers to spend, outbox leaves to emit.
  • Sequencer batches kernels inside RollupCircuit → outputs new roots + proof π.

Rollup.sol propose() on L1

require(VERIFIER.verify(proof, publicInputs));   // 1
stateRoot = publicInputs.stateRoot; // 2
inboxRoot = publicInputs.inboxRoot;
outboxRoot = publicInputs.outboxRoot;
emit BlockProven(blockNum, stateRoot);

If step (1) fails, nothing changes. If it passes, all three roots are final.

Inbox: L1 → L2

(bytes32 leaf, uint256 idx) = INBOX.sendL2Message(recipient, cHash, sHash);
  • Inserts leaf into FrontierTree of the current L2 block (inProgressBlock).
  • Updates InboxState.rollingHash for cheap indexing.
  • RollupCircuit later calls Inbox.consume(blockNum) → Merkle root. The circuit must
    • prove membership for each leaf it wants to spend, and
    • create a nullifier that will be inserted into the nullifier tree.

Outbox: L2 → L1

During proof generation the kernel emits an array out_msgs. Rollup inserts these into an Outbox tree and outputs the new outboxRoot.

On‑chain:

OUTBOX.insert(l2BlockNum, outboxRoot);

A portal redeems:

OUTBOX.consume(msg, idx, siblingPath);  // marks bitmap nullified[idx] = 1

Circuit consistency checks

  • Kernel: verifies sender/recipient in contract tree, ranges, secretHash linking.
  • Rollup: recomputes Merkle roots and compares to public inputs passed to Rollup.sol.

If any leaf or nullifier is out of place, IVerifier.verify() fails and the block is rejected.

Putting it all together

 StepWho callsCode pathResulting state change
 1AliceFeeJuicePortal.depositToAztecPublic()ETH/ERC‑20 escrowed; Inbox.sendL2Message() inserts leaf_A, returns (leaf, idx)
 2SequencerIncludes leaf_A’s nullifier in Alice’s kernel proofKernel ensures (sender == portal && recipient == L2Contract); outputs inboxNullifier_A
 3RollupCircuitConsumes inboxNullifier_A, inserts Alice’s L2→L1 receipt into Outbox treeProduces new roots inboxRoot'outboxRoot'
 4Rollup.solpropose() verifies π and writes the three rootsEmits BlockProven event; Inbox block N becomes ready
 5Bob (portal owner)Calls Outbox.consume(receipt, idx, path)Bitmap nullified[idx] = 1; Bob’s L1 logic mints/moves the funds
note

All intermediate steps (e.g. pointer updates inside FrontierTree) are enforced by circuit equality constraints, a single bad hash breaks verification.

The 3 things you need to remember:

  • Async everywhere: your L1 tx fires‑and‑forgets; handle retries/timeouts.
  • Use content = sha256(bigPayload) when 32 bytes is not enough; reveal the payload off‑chain or via an L2 event.
  • secretHash + nullifiers hide the exact consumption slot.

References