L1-L2 messaging
L2 → L1 messages
Contracts on L2 can interact asynchronously with contracts on L1 via the L2→L1 messaging protocol.
During the execution of a Starknet transaction, a contract on Starknet sends an L2→L1 message by calling the send_message_to_L1
syscall. The message parameters (which contain the recipient contract on L1 and the relevant data) are then attached to the relevant state update that includes this syscall invocation.
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());
After the state update that included this transaction is proved and the L1 state is updated, the message is stored on L1 in the Starknet Core Contract (and the relevant counter is increased), and the LogMessageToL1
event (which contains the message parameters) is emitted.
Later, the recipient address on L1 can access and consume the message as part of an L1 transaction by re-supplying the message parameters.
This is done by calling consumeMessageFromL2
in the Starknet Core Contract, who verifies that the hash corresponds to a stored message and that the caller is indeed the recipient on L1. In such a case, the reference count of the message hash in the Starknet Core Contract decreases by 1.
The above flow is illustrated in the following diagram:

L2 → L1 structure and hashing
As demonstrated above, the structure of an L2 → L1 message is given by:
FromAddress | ToAddress | Payload |
---|---|---|
|
|
|
The hash of an L2 → L1 message is computed on L1 as follows:
keccak256(
abi.encodePacked(
FromAddress,
ToAddress,
Payload.length,
Payload
)
);
As the hash of the message being sent needs to be written to L1 storage (in the Starknet Core contract) there is always a fixed 20k gas cost associated with sending an L2 to L1 message. |
L1 → L2 messages
Contracts on L1 can interact asynchronously with contracts on L2 via the L1→L2 messaging protocol. The protocol consists of the following stages:
-
An L1 contract initiates a message to an L2 contract on Starknet. It does so 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. In fact, the L1 contract records the fee that the sender paid. For more information, see L1 → L2 message fees.
-
-
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.-
The Starknet sequencer, upon seeing enough L1 confirmations for the transaction that sent the message, initiates the corresponding L2 transaction.
-
The L2 transaction invokes the relevant
l1_handler
.
-
-
The L1 Handler transaction that was created in the previous step is added to a proof.
-
The state update is received on the Core contract.
-
the message is cleared from the Core contract’s storage. At this point, the message is handled.
An L1→L2 message consists of:
-
The L1 sender address
-
The recipient contract address on Starknet
-
Function selector
-
Calldata array
-
Message nonce
Message nonce The message nonce is maintained on the Starknet Core contract on L1, and is bumped whenever a message is sent to L2. It is used to avoid hash collisions between different L1 handler transactions that are induced by the same message being sent on L1 multiple times (see below). |
L1 → L2 message cancellation
Imagine a scenario where a user transfers an asset from L1 to L2. The flow starts with the user sending the asset to a Starknet bridge and the corresponding L1→L2 message generation. Now, imagine that the L2 message consumption doesn’t function (this might happen due to a bug in the dApp’s Cairo contract). This could result in the user losing custody over their asset forever.
To mitigate this risk, we allow the contract that initiated the L1→L2 message to cancel it after declaring the intent and waiting a suitable amount of time.
The user starts by calling startL1ToL2MessageCancellation
with the relevant message parameters in the Starknet Core Contract. Then, after a five days delay, the user can finalize the cancellation by calling cancelL1ToL2Message
.
The reason for the delay is to protect 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.
Note that this flow should only be used in edge cases such as bugs on the Layer 2 contract preventing message consumption.
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 and hashing
For completeness, we describe the precise structure of both the message as it appears on L1 and the induced transaction as it appears on L2.
FromAddress | ToAddress | Selector | Payload | Nonce | |
---|---|---|---|---|---|
|
|
|
|
|
The hash of the message is computed on L1 as follows:
keccak256(
abi.encodePacked(
uint256(FromAddress),
ToAddress,
Nonce,
Selector,
Payload.length,
Payload
)
);
Version | ContractAddress | Selector | Calldata | Nonce | |
---|---|---|---|---|---|
|
|
|
|
|
The hash of the corresponding L1 handler transaction on L2 is computed as follows:
l1_handler_tx_hash = ℎ(
"l1_handler",
version,
contract_address,
entry_point_selector,
ℎ(calldata),
chain_id,
nonce
)
Where:
-
\(\text{l1_handler}\) is a constant prefix, encoded in bytes (ASCII), with big-endian.
-
\(\text{chain_id}\) is a constant value that specifies the network to which this transaction is sent.
-
\(h\) is the Pedersen hash
In an |