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-nrdependency - 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:
| Parameter | Type | Description | 
|---|---|---|
actor | L2Actor | Your L2 contract address and rollup version | 
contentHash | bytes32 | Hash of your message content (use Hash.sha256ToField) | 
secretHash | bytes32 | Hash 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_hashmust match the hash that was sent from L1 - The 
secretis the pre-image of thesecretHashsent from L1 - The 
senderis the L1 portal contract address - The 
message_leaf_indexhelps 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
 
#[external("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:
#[external("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
You can get the witness for the l2 to l1 message as follows:
import { computeL2ToL1MembershipWitness } from "@aztec/stdlib/messaging";
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),
});
const witness = await computeL2ToL1MembershipWitness(
  node,
  exitReceipt.blockNumber!,
  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.