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

Communicating Cross-Chain

This guide shows you how to implement cross-chain communication between Ethereum (L1) and Aztec (L2) contracts using portal contracts.

Prerequisites

  • An Aztec contract project set up with aztec-nr dependency
  • Understanding of Aztec L1/L2 architecture
  • Access to Ethereum development environment for L1 contracts
  • Deployed portal contract on L1 (see token bridge tutorial)

Send messages from L1 to L2

Send a message from your L1 portal contract

Use the Inbox contract to send messages from L1 to L2. Call sendL2Message with these parameters:

ParameterTypeDescription
actorL2ActorYour L2 contract address and rollup version
contentHashbytes32Hash of your message content (use Hash.sha256ToField)
secretHashbytes32Hash of a secret for message consumption

In your Solidity contract:

import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
import {DataStructures} from "@aztec/core/libraries/DataStructures.sol";
import {Hash} from "@aztec/core/libraries/crypto/Hash.sol";

// ... initialize inbox, get rollupVersion from rollup contract ...

DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2ContractAddress, rollupVersion);

// Hash your message content with a unique function signature
bytes32 contentHash = Hash.sha256ToField(
abi.encodeWithSignature("your_action_name(uint256,address)", param1, param2)
);

// Send the message
(bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, secretHash);

Consume the message in your L2 contract

To consume a message coming from L1, use the consume_l1_to_l2_message function within the context:

  • The content_hash must match the hash that was sent from L1
  • The secret is the pre-image of the secretHash sent from L1
  • The sender is the L1 portal contract address
  • The message_leaf_index helps the RPC find the correct message
  • If the content or secret doesn't match, the transaction will revert
  • "Consuming" a message pushes a nullifier to prevent double-spending
#[public]
fn consume_message_from_l1(
secret: Field,
message_leaf_index: Field,
// your function parameters
) {
// Recreate the same content hash as on L1
let content_hash = /* compute your content hash */;

// Consume the L1 message
context.consume_l1_to_l2_message(
content_hash,
secret,
portal_address, // Your L1 portal contract address
message_leaf_index
);

// Execute your contract logic here
}

Send messages from L2 to L1

Send a message from your L2 contract

Use message_portal in your context to send messages from L2 to L1:

#[public]
fn send_message_to_l1(
// your function parameters
) {
// Note: This can be called from both public and private functions
// Create your message content (must fit in a single Field)
let content = /* compute your content hash */;

// Send message to L1 portal
context.message_portal(portal_address, content);
}

Consume the message in your L1 portal

Use the Outbox to consume L2 messages on L1:

import {IOutbox} from "@aztec/core/interfaces/messagebridge/IOutbox.sol";
import {DataStructures} from "@aztec/core/libraries/DataStructures.sol";
import {Hash} from "@aztec/core/libraries/crypto/Hash.sol";

function consumeMessageFromL2(
// your parameters
uint256 _l2BlockNumber,
uint256 _leafIndex,
bytes32[] calldata _path
) external {
// Recreate the message structure
DataStructures.L2ToL1Msg memory message = DataStructures.L2ToL1Msg({
sender: DataStructures.L2Actor(l2ContractAddress, rollupVersion),
recipient: DataStructures.L1Actor(address(this), block.chainid),
content: Hash.sha256ToField(
abi.encodeWithSignature(
"your_action_name(address,uint256,address)",
param1, param2, param3
)
)
});

// Consume the message
outbox.consume(message, _l2BlockNumber, _leafIndex, _path);

// Execute your L1 logic here
}
info

The _leafIndex and _path parameters are merkle tree proofs needed to verify the message exists. Get them using JavaScript:

import { computeL2ToL1MessageHash } from '@aztec/stdlib/hash';

// Compute the message hash
const l2ToL1Message = computeL2ToL1MessageHash({
l2Sender: l2ContractAddress,
l1Recipient: EthAddress.fromString(portalAddress),
content: messageContent,
rollupVersion: new Fr(version),
chainId: new Fr(chainId),
});

// Get the merkle proof
const [leafIndex, siblingPath] = await pxe.getL2ToL1MembershipWitness(
await pxe.getBlockNumber(),
l2ToL1Message
);

Best practices

Structure messages properly

Use function signatures to prevent message misinterpretation:

// ❌ Ambiguous format
bytes memory message = abi.encode(_value, _contract, _recipient);

// ✅ Clear function signature
bytes memory message = abi.encodeWithSignature(
"execute_action(uint256,address,address)",
_value, _contract, _recipient
);

Use designated callers

Control message execution order with designated callers:

bytes memory message = abi.encodeWithSignature(
"execute_action(uint256,address,address)",
_value, _recipient,
_withCaller ? msg.sender : address(0)
);

Example implementations

Next steps

Follow the cross-chain messaging tutorial for a complete implementation example.