Skip to main content
Version: v3.0.0-nightly.20251231

Communicating Cross-Chain

This guide covers cross-chain communication between Ethereum (L1) and Aztec (L2) using portal contracts.

Aztec uses an Inbox/Outbox pattern for cross-chain messaging. Messages sent from L1 are inserted into the Inbox contract and later consumed on L2. Messages sent from L2 are inserted into the Outbox contract and later consumed on L1. Portal contracts are L1 contracts that facilitate this communication for your application.

Prerequisites

  • An Aztec contract project with aztec-nr dependency
  • Access to Ethereum development environment for L1 contracts
  • Deployed portal contract on L1 (see token bridge tutorial)

L1 to L2 messaging

Send a message from L1

Use the Inbox contract's sendL2Message function:

ParameterTypeDescription
_recipientL2ActorL2 contract address and rollup version
_contentbytes32Hash of message content (use Hash.sha256ToField)
_secretHashbytes32Hash of secret for message consumption
deposit_public
/**
* @notice Deposit funds into the portal and adds an L2 message which can only be consumed publicly on Aztec
* @param _to - The aztec address of the recipient
* @param _amount - The amount to deposit
* @param _secretHash - The hash of the secret consumable message. The hash should be 254 bits (so it can fit in a
* Field element)
* @return The key of the entry in the Inbox and its leaf index
*/
function depositToAztecPublic(bytes32 _to, uint256 _amount, bytes32 _secretHash)
external
returns (bytes32, uint256)
Source code: l1-contracts/test/portals/TokenPortal.sol#L47-L59
Message availability

L1 to L2 messages are not available immediately. The proposer batches messages from the Inbox and includes them in the next L2 block. You must wait for this before consuming the message on L2.

Consume the message on L2

Call consume_l1_to_l2_message on the context. The content must match the hash sent from L1, and the secret must be the pre-image of the secretHash. Consuming a message emits a nullifier to prevent double-spending.

The content hash must be computed identically on both L1 and L2. Create a shared library for your content hash functions—see token_portal_content_hash_lib for an example.

claim_public
// Consumes a L1->L2 message and calls the token contract to mint the appropriate amount publicly
#[external("public")]
fn claim_public(to: AztecAddress, amount: u128, secret: Field, message_leaf_index: Field) {
let content_hash = get_mint_to_public_content_hash(to, amount);

let config = self.storage.config.read();

// Consume message and emit nullifier
self.context.consume_l1_to_l2_message(
content_hash,
secret,
config.portal,
message_leaf_index,
);

// Mint tokens
self.call(Token::at(config.token).mint_to_public(to, amount));
}
Source code: noir-projects/noir-contracts/contracts/app/token_bridge_contract/src/main.nr#L53-L72

This function works in both public and private contexts.

L2 to L1 messaging

Send a message from L2

Call message_portal on the context to send messages to your L1 portal:

exit_to_l1_public
// Burns the appropriate amount of tokens and creates a L2 to L1 withdraw message publicly
// Requires `msg.sender` to give approval to the bridge to burn tokens on their behalf using witness signatures
#[external("public")]
fn exit_to_l1_public(
recipient: EthAddress, // ethereum address to withdraw to
amount: u128,
caller_on_l1: EthAddress, // ethereum address that can call this function on the L1 portal (0x0 if anyone can call)
authwit_nonce: Field, // nonce used in the approval message by `msg.sender` to let bridge burn their tokens on L2
) {
let config = self.storage.config.read();

// Send an L2 to L1 message
let content = get_withdraw_content_hash(recipient, amount, caller_on_l1);
self.context.message_portal(config.portal, content);

// Burn tokens
self.call(Token::at(config.token).burn_public(
self.msg_sender().unwrap(),
amount,
authwit_nonce,
));
}
Source code: noir-projects/noir-contracts/contracts/app/token_bridge_contract/src/main.nr#L74-L97

This function works in both public and private contexts.

Consume the message on L1

Use the Outbox contract to consume L2 messages.

Message availability

L2 to L1 messages are only available after the epoch proof is submitted to L1. Since multiple L2 blocks fit within an epoch, there may be a delay—especially if the message was sent near the start of an epoch.

token_portal_withdraw
/**
* @notice Withdraw funds from the portal
* @dev Second part of withdraw, must be initiated from L2 first as it will consume a message from outbox
* @param _recipient - The address to send the funds to
* @param _amount - The amount to withdraw
* @param _withCaller - Flag to use `msg.sender` as caller, otherwise address(0)
* @param _checkpointNumber - The checkpoint number containing the message to consume
* @param _leafIndex - The amount to withdraw
* @param _path - Flag to use `msg.sender` as caller, otherwise address(0)
* Must match the caller of the message (specified from L2) to consume it.
*/
function withdraw(
address _recipient,
uint256 _amount,
bool _withCaller,
uint256 _checkpointNumber,
uint256 _leafIndex,
bytes32[] calldata _path
) external {
// The purpose of including the function selector is to make the message unique to that specific call. Note that
// it has nothing to do with calling the function.
DataStructures.L2ToL1Msg memory message = DataStructures.L2ToL1Msg({
sender: DataStructures.L2Actor(l2Bridge, rollupVersion),
recipient: DataStructures.L1Actor(address(this), block.chainid),
content: Hash.sha256ToField(
abi.encodeWithSignature(
"withdraw(address,uint256,address)", _recipient, _amount, _withCaller ? msg.sender : address(0)
)
)
});

outbox.consume(message, _checkpointNumber, _leafIndex, _path);

underlying.safeTransfer(_recipient, _amount);
}
Source code: l1-contracts/test/portals/TokenPortal.sol#L113-L149
Getting the membership witness

Compute the witness for the L2 to L1 message in TypeScript:

import { computeL2ToL1MembershipWitness } from "@aztec/stdlib/messaging";
import { computeL2ToL1MessageHash } from "@aztec/stdlib/hash";

const l2ToL1Message = computeL2ToL1MessageHash({
l2Sender: l2BridgeAddress,
l1Recipient: EthAddress.fromString(portalAddress),
content: withdrawContentHash,
rollupVersion: new Fr(version),
chainId: new Fr(chainId),
});

const witness = await computeL2ToL1MembershipWitness(
aztecNode,
txReceipt.blockNumber!,
l2ToL1Message
);

// Use witness.leafIndex and witness.siblingPath for the L1 consume call

Example implementations

Next steps

Follow the token bridge tutorial for a complete implementation example.