Interacting with L1 contracts

Background

One important property of a good L2 system is the ability to interact with the L1 system it’s built on (since otherwise the system is, in fact, isolated). In this section we describe how StarkNet contracts can interact with Ethereum L1 contracts.

Every StarkNet contract may send and receive messages to/from any L1 contract. Usually, it’s recommended to design a pair of contracts: an L2 contract and its L1 contract counterpart (for example, written in Solidity) and decide on the message protocol between the two contracts.

Messages from L2 to L1

Messages from L2 to L1 work as follows:

  1. The StarkNet (L2) contract function calls the library function send_message_to_l1() in order to send the message. It specifies:

    1. The destination L1 contract (“to”),

    2. The data to send (“payload”)

    The StarkNet OS adds the “from” address, which is the L2 address of the contract sending the message.

  2. Once a state update containing the L2 transaction is accepted on-chain, the message is stored on the L1 StarkNet core contract, waiting to be consumed.

  3. The L1 contract specified by the “to” address invokes the consumeMessageFromL2() of the StarkNet core contract.

Note: Since any L2 contract can send messages to any L1 contract it is recommended that the L1 contract check the “from” address before processing the transaction.

Below we will show how one can use this mechanism to implement a withdrawal transaction.

Messages from L1 to L2

The other direction is similar:

  1. The L1 contract calls (on L1) the send_message() function of the StarkNet core contract, which stores the message. In this case the message include an additional field - the “selector”, which determines what function to call in the corresponding L2 contract.

  2. The StarkNet Sequencer automatically consumes the message and invokes the requested L2 function of the contract designated by the “to” address.

This direction is useful, for example, for “deposit” transactions.

Note that while honest Sequencers automatically consume L1 -> L2 messages, it is not enforced by the protocol (so a Sequencer may choose to skip a message). This should be taken into account when designing the message protocol between the two contracts.

Notes

Important note: The StarkNet Alpha system is still under development, and therefore from time to time the state of the system will reset and all contracts will be removed. This means that you shouldn’t move valuable assets to the StarkNet system, unless you have a way to withdraw them given the removal of the L2 contract. That being said, recall that StarkNet Alpha only runs on the Ropsten testnet.

Note: In the current version of StarkNet, contracts cannot interact with one another. This means that any contract that needs some kind of interaction with L1, such as deposit/withdraw of tokens, needs to use this message mechanism. In the future, token transfers between L1 and L2 will be handled by bridge token contracts (much like an L2 ERC20 contract), and the contract developer will be able to simply interact with those L2 contract. Then, the message mechanism will be required only if a developer needs a custom messaging protocol.

An example of a simple token bridge

In this section we’ll build a simple token bridge – the user will be able to deposit L1 tokens and their L2 balance will increase. Then, they will be able to withdraw some tokens, which will decrease their L2 balance and increase their L1 balance in return.

Some preparations

Start with the %lang and %builtins directives, a few imports and a few constants:

%lang starknet
%builtins pedersen range_check

from starkware.cairo.common.alloc import alloc
from starkware.cairo.common.cairo_builtins import HashBuiltin
from starkware.cairo.common.math import assert_nn
from starkware.starknet.common.messages import send_message_to_l1
from starkware.starknet.common.storage import Storage

const L1_CONTRACT_ADDRESS = (
    0xce08635cc6477f3634551db7613cc4f36b4e49dc)
const MESSAGE_WITHDRAW = 0

Note that in real applications you may want to maintain the address of the L1 contract as a storage variable, rather than a constant.

Then, define the storage variable that holds the balances, together with a getter @view function:

# A mapping from a user (L1 Ethereum address) to their balance.
@storage_var
func balance(user : felt) -> (res : felt):
end

@view
func get_balance{
        storage_ptr : Storage*, pedersen_ptr : HashBuiltin*,
        range_check_ptr}(user : felt) -> (balance : felt):
    let (res) = balance.read(user=user)
    return (res)
end

Just so we’ll have some “funds” to play with, define a function that can mint new tokens (in real applications you probably wouldn’t want a function that lets the user effectively “print” money. In addition, you’ll want to check that amount is nonnegative):

@external
func increase_balance{
        storage_ptr : Storage*, pedersen_ptr : HashBuiltin*,
        range_check_ptr}(user : felt, amount : felt):
    let (res) = balance.read(user=user)
    balance.write(user, res + amount)
    return ()
end

Sending a message to L1

Sending a message to L1 can be useful for withdrawals: The user requesting the withdrawal invokes a withdraw (L2) transaction. The transaction decreases their L2 balance and sends a message to the L1 contract, indicating that the user’s L1 balance should be increased by the withdrawn amount. The L1 counterpart should allow the user to consume the message and increase their balance on L1 when doing so.

@external
func withdraw{
        syscall_ptr : felt*, storage_ptr : Storage*,
        pedersen_ptr : HashBuiltin*, range_check_ptr}(
        user : felt, amount : felt):
    # Make sure 'amount' is positive.
    assert_nn(amount)

    let (res) = balance.read(user=user)
    tempvar new_balance = res - amount

    # Make sure the new balance will be positive.
    assert_nn(new_balance)

    # Update the new balance.
    balance.write(user, new_balance)

    # Send the withdrawal message.
    let (message_payload : felt*) = alloc()
    assert message_payload[0] = MESSAGE_WITHDRAW
    assert message_payload[1] = user
    assert message_payload[2] = amount
    send_message_to_l1(
        to_address=L1_CONTRACT_ADDRESS,
        payload_size=3,
        payload=message_payload)

    return ()
end

Note that a new implicit argument was added – the system call pointer (syscall_ptr). This argument allows us to invoke some functions of the StarkNet OS, including the “send message” function.

Sending a message is done at the end of withdraw() by calling send_message_to_l1(), which gets the L1 contract address, the size of the message and the message itself (as a felt*). Note that the message itself is given as a pointer, and therefore the message length must be passed explicitly. In our example, the message data is: MESSAGE_WITHDRAW, user, amount. We choose to use the first element as an indicator of the message type (note that we don’t really need it here since we only have one message type).

Now let’s take a look at how the L1 contract counterpart may be written. Consider the withdraw() function: It gets the user and the amount, consumes the message (this part will fail if the message wasn’t received on-chain) and updates the user’s balance accordingly. As you’ll see below, we passed the address of the L2 contract as an argument to the function, so that the contract can be deployed once and used by anyone doing this tutorial. However, normally it doesn’t make sense to get the address of the L2 contract as an argument – the address should be fixed for each instance of the contract.

Receiving a message from L1

In order to handle a message that was sent from an L1 contract, you should declare an L1 handler:

@l1_handler
func deposit{
        storage_ptr : Storage*, pedersen_ptr : HashBuiltin*,
        range_check_ptr}(
        from_address : felt, user : felt, amount : felt):
    # Make sure the message was sent by the intended L1 contract.
    assert from_address = L1_CONTRACT_ADDRESS

    # Read the current balance.
    let (res) = balance.read(user=user)

    # Compute and update the new balance.
    tempvar new_balance = res + amount
    balance.write(user, new_balance)

    return ()
end

An L1 handler is called by the StarkNet OS in order to process a message sent from an L1 contract. A StarkNet contract may define a few L1 handlers, and they are identified by an integer value called the selector. You can compute the selector based on the L1 handler name using the following python code:

from starkware.starknet.compiler.compile import \
    get_selector_from_name

print(get_selector_from_name('deposit'))

You should get:

352040181584456735608515580760888541466059565068553383579463728554843487745

When an L1 contract wants to send a message, it calls the sendMessageToL2() function of the StarkNet Core contract and it specifies the L2 contract address and the selector for the handler to be invoked. Take a look at the deposit function in the example L1 contract.

Using the contract

Save the new contract file as l1l2.cairo. You can find the full Cairo file here.

Compile and deploy the contract:

starknet-compile l1l2.cairo \
    --output=l1l2_compiled.json \
    --abi=l1l2_abi.json

starknet deploy --contract l1l2_compiled.json

Don’t forget to set the STARKNET_NETWORK environment variable to alpha before running starknet deploy.

Invoke the increase_balance function and then the withdraw function:

starknet invoke \
    --address CONTRACT_ADDRESS \
    --abi l1l2_abi.json \
    --function increase_balance \
    --inputs \
        12345678 \
        3333

starknet invoke \
    --address CONTRACT_ADDRESS \
    --abi l1l2_abi.json \
    --function withdraw \
    --inputs \
        12345678 \
        1000

Call get_balance to check that the balance was computed correctly (remember that you’ll have to wait until the second transaction is included in a block):

starknet call \
    --address CONTRACT_ADDRESS \
    --abi l1l2_abi.json \
    --function get_balance \
    --inputs \
        12345678

You should get:

2333

Wait for the transaction to be accepted on-chain (this may take some time) – you can use starknet tx_status to track the transaction’s progress. Then, invoke the withdraw() function of the example contract, deployed at address 0xce08635cc6477f3634551db7613cc4f36b4e49dc, with the following arguments: CONTRACT_ADDRESS, 12345678, 1000 (where, as before, CONTRACT_ADDRESS is the address of the L2 contract you deployed). After the withdraw() transaction, the user’s L1 balance should be 1000 and their L2 balance should be 2333.

After your withdraw() transaction is accepted on-chain, call the deposit() function of the example contract. Use the following arguments: CONTRACT_ADDRESS, 12345678, 600 to simulate a partial deposit out of the 1000 tokens. It may take some time until StarkNet processes the incoming message and calls the L1 handler (for example, the system waits for a few blockchain confirmations). But after that time, you’ll be able to see the updated balance of the user by invoking starknet call for get_balance again. The new balances should be: L1 balance – 400 and L2 balance – 2933.