L1-L2 messaging mechanism

Last edit

Contributors

Starknet’s ability to interact with L1 is crucial. Messaging is the mechanism that enables this interaction.

For example, you can perform computations on L2 and use the result on L1.

Bridges on Starknet use the L1-L2 messaging mechanism. Consider that you want to bridge tokens from Ethereum to Starknet. You deposit your tokens in the L1 bridge contract, which automatically triggers the minting of the same token on L2. Another good use case for L1-L2 messaging is Defi pooling. For more information, see DeFi pooling on StarkWare’s site and dApps on https://www.starknet.io.

Be aware that the messaging mechanism is asynchronous and asymmetric.

  • Asynchronous: Your contract code, whether Cairo or Solidity, cannot await the result of the message being sent on the other layer within your contract code’s execution.

  • Asymmetric: Sending a message from Ethereum to Starknet, L1→L2, is fully automated by the Starknet sequencer, so the message is automatically delivered to the target contract on L2. However, when sending a message from Starknet to Ethereum, L2→L1, the sequencer only sends the hash of the message. You must then consume the message manually using a transaction on L1.

L2 → L1 messages

Contracts on L2 can interact asynchronously with contracts on L1 using the L2→L1 messaging protocol.

The protocol consists of the following stages:

  1. During the execution of a transaction, a contract on Starknet sends a message from L2 to L1 by calling the send_message_to_L1 syscall.

  2. The sequencer attaches the message parameters to the block that includes the syscall invocation. The message parameters include the address of the sender on L2, the address of the recipient contract on L1, and the message data.

    For example:

    let mut payload: Array<felt252> = ArrayTrait::new();
    let to_address: EthAddress = 1_felt252.try_into().unwrap();
    payload.append(1);
    // potentially add more elements to payload (payload[1], payload[2],  etc.)
    
    send_message_to_l1_syscall(to_address: to_address.into(), payload: payload.span());
  3. The prover proves the state update that includes this transaction.

  4. The sequencer updates the L1 state.

  5. The message is stored on L1 in the Starknet Core Contract and a counter on the Core Contract increases by one.

  6. The processMessage function, which is part of the Starknet Core Contract, emits the LogMessageToL1 event, which contains the message parameters.

  7. The message recipient on L1 can access and consume the message by calling the consumeMessageFromL2 function, which includes the message parameters within the transaction. This function, which is part of the Starknet Core Contract, verifies the following:

    • The hashes of the L2 sent message parameters, now stored on the Core Contract, and the L1 received message parameters, are the same.

    • The entity calling the function is indeed the recipient on L1.

      In such a case, the counter corresponding to the message hash in the Starknet Core Contract decreases by one. For more information, see the consumeMessageFromL2 function in StarknetMessaging.sol.

L2→L1 Messaging mechanism illustrates this flow:

L2→L1 message mechanism
Figure 1. L2→L1 Messaging mechanism

L2 → L1 message structure

The structure of an L2 → L1 message is described as follows under MSG_TO_L1 in the Starknet API JSON RPC specification:

from_address (felt252)

The address of the L2 contract sending the message.

to_address (EthAddress)

The target L1 address the message is sent to.

payload (Array<felt252>)

The payload of the message.

L2 → L1 message hashing

The hash of an L2 → L1 message is computed on L1 as follows:

keccak256(
    abi.encodePacked(
        FromAddress,
        uint256(ToAddress),
        Payload.length,
        Payload
    )
);

Sending an L2 to L1 message always incurs a fixed cost of 20,000 gas, because the hash of the message being sent must be written to L1 storage in the Starknet Core Contract.

L1 → L2 messages

Contracts on L1 can interact asynchronously with contracts on L2 using the L1→L2 messaging protocol.

The protocol consists of the following stages:

  1. An L1 contract induces a message to an L2 contract on Starknet by calling the sendMessageToL2 function on the Starknet Core Contract with the message parameters.

    The Starknet Core Contract hashes the message parameters and updates the L1→L2 message mapping to indicate that a message with this hash was indeed sent. The L1 contract records the fee that the sender paid. For more information, see L1 → L2 message fees.

  2. The message is then decoded into a Starknet transaction that invokes a function annotated with the l1_handler decorator on the target contract. Transactions like this on L2 are called L1 handler transactions.

    1. The Starknet sequencer, upon receiving enough L1 confirmations for the transaction that sent the message, initiates the corresponding L2 transaction.

    2. The L2 transaction invokes the relevant l1_handler function.

  3. The L1 Handler transaction that was created in the previous step is added to a proof.

  4. The Core Contract receives the state update.

  5. The message is cleared from the Core Contract’s storage to consume the message. Clearing the Core Contract’s storage does the following:

    • incurs a fixed cost of 5,000 gas

    • emits an L1 event logging the message consumption

At this point, the message is handled.

An L1→L2 message consists of the following:

  • L1 sender’s address

  • L2 recipient’s contract address

  • Function selector

  • Calldata array

  • Message nonce

    The message nonce is maintained on the Starknet Core Contract on L1, and is incremented whenever a message is sent to L2. The nonce is used to avoid a hash collision between different L1 handler transactions that is caused by the same message being sent on L1 multiple times.

    For more information, see L1→L2 structure.

L1 → L2 message cancellation

The flow described here should only be used in edge cases such as bugs on the Layer 2 contract preventing message consumption.

Consider that Alice sends an L1 asset to a Starknet bridge to transfer it to L2, which generates the corresponding L1→L2 message. Now, consider that the L2 message consumption doesn’t function, which might happen due to a bug in the dApp’s Cairo contract. This bug could result in Alice losing custody of their asset forever.

To mitigate this risk, the contract that initiated the L1→L2 message can cancel it by declaring the intent to cancel, waiting five days, and then completing the cancellation. This delay protects the sequencer from a DoS attack in the form of repeatedly sending and canceling a message before it is included in L1, rendering the L2 block which contains the activation of the corresponding L1 handler invalid.

The steps in this flow are as follows:

  1. The user that initiated the L1→L2 message calls the startL1ToL2MessageCancellation function in the Starknet Core Contract.

  2. The user waits five days until she can finalize the cancellation.

  3. The user calls the cancelL1ToL2Message function.

L1 → L2 message fees

An L1 → L2 message induces a transaction on L2, which, unlike regular transactions, is not sent by an account. This calls for a different mechanism for paying the transaction’s fee, for otherwise the sequencer has no incentive of including L1 handler transactions inside a block.

To avoid having to interact with both L1 and L2 when sending a message, L1 → L2 messages are payable on L1, by sending ETH with the call to the payable function sendMessageToL2 on the Starknet Core Contract.

The sequencer takes this fee in exchange for handling the message. The sequencer charges the fee in full upon updating the L1 state with the consumption of this message.

The fee itself is calculated in the same manner as "regular" L2 transactions. You can use the CLI to get an estimate of an L1 → L2 message fee.

L1 → L2 structure

For completeness, L1 → L2 structure describes the precise structure of both the message as it appears on L1 and the induced transaction as it appears on L2.

Table 1. L1 → L2 message structure
FromAddress ToAddress Selector Payload Nonce

EthereumAddress

FieldElement

FieldElement

List

FieldElement

L1 → L2 hashing

The hash of the message is computed on L1 as follows:

keccak256(
    abi.encodePacked(
        uint256(FromAddress),
        ToAddress,
        Nonce,
        Selector,
        Payload.length,
        Payload
    )
);
Table 2. L1 handler transaction
Version ContractAddress Selector Calldata Nonce

FieldElement

FieldElement

FieldElement

List

FieldElement

The hash of the corresponding L1 handler transaction on L2 is computed as follows:

l1_handler_tx_hash = ℎ(
    "l1_handler",
    version, // at the moment only version 0 is supported
    contract_address,
    entry_point_selector,
    ℎ(calldata),
    0,
    chain_id,
    nonce
)

Where:

  • l1_handler is a constant prefix, encoded in bytes (ASCII), as big-endian.

  • chain_id is a constant value that specifies the network to which this transaction is sent.

  • h is the Pedersen hash (note that since we’re hashing an array, the # of inputs needs to be appended to the hash).

In an l1_handler transaction, the first element of the calldata is always the Ethereum address of the sender.

Additional resources

  • send_message_to_L1 syscall

  • sendMessageToL2 function on the Starknet Core Contract

  • For more information on how messaging works within the Starknet Core Contract, including details on coding, see L1-L2 Messaging in The Cairo Book: The Cairo Programming Language