L1 <> L2 Token Bridge
In Starknet, it is possible to interact with Ethereum by using the L1 <> L2 messaging system.
In this example, we will demonstrate the usage of this messaging system to send and receive messages from L1 to L2 for a token bridge.
It will require creating two contracts, one on Starknet and one on Ethereum, that will communicate cross-chain, and notify each other how many tokens to mint and who to assign them to. We will also create very simple mock token implementations that will simply emit appropriate events on burning and minting. This will allow us to verify that the cross-chain communication actually succeeded.
First, we create the TokenBridge
contract on Starknet:
use starknet::{ContractAddress, EthAddress};
/// Represents any time of token that can be minted/burned
/// In a real contract this would probably be an ERC20 contract,
/// but here it's represented as a generic token for simplicity.
#[starknet::interface]
pub trait IMintableToken<TContractState> {
fn mint(ref self: TContractState, account: ContractAddress, amount: u256);
fn burn(ref self: TContractState, account: ContractAddress, amount: u256);
}
#[starknet::interface]
pub trait ITokenBridge<TContractState> {
fn bridge_to_l1(ref self: TContractState, l1_recipient: EthAddress, amount: u256);
fn set_l1_bridge(ref self: TContractState, l1_bridge_address: EthAddress);
fn set_token(ref self: TContractState, l2_token_address: ContractAddress);
fn governor(self: @TContractState) -> ContractAddress;
fn l1_bridge(self: @TContractState) -> felt252;
fn l2_token(self: @TContractState) -> ContractAddress;
}
#[starknet::contract]
pub mod TokenBridge {
use core::num::traits::Zero;
use starknet::{ContractAddress, EthAddress, get_caller_address, syscalls, SyscallResultTrait};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use super::{IMintableTokenDispatcher, IMintableTokenDispatcherTrait};
#[storage]
pub struct Storage {
// The address of the L2 governor of this contract. Only the governor can set the other
// storage variables.
pub governor: ContractAddress,
// The L1 bridge address. Zero when unset.
pub l1_bridge: felt252,
// The L2 token contract address. Zero when unset.
pub l2_token: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
WithdrawInitiated: WithdrawInitiated,
DepositHandled: DepositHandled,
L1BridgeSet: L1BridgeSet,
L2TokenSet: L2TokenSet,
}
// An event that is emitted when bridge_to_l1 is called.
// * l1_recipient is the l1 recipient address.
// * amount is the amount to withdraw.
// * caller_address is the address from which the call was made.
#[derive(Drop, starknet::Event)]
pub struct WithdrawInitiated {
pub l1_recipient: EthAddress,
pub amount: u256,
pub caller_address: ContractAddress,
}
// An event that is emitted when handle_deposit is called.
// * account is the recipient address.
// * amount is the amount to deposit.
#[derive(Drop, starknet::Event)]
pub struct DepositHandled {
pub account: ContractAddress,
pub amount: u256,
}
// An event that is emitted when set_l1_bridge is called.
// * l1_bridge_address is the new l1 bridge address.
#[derive(Drop, starknet::Event)]
struct L1BridgeSet {
l1_bridge_address: EthAddress,
}
// An event that is emitted when set_token is called.
// * l2_token_address is the new l2 token address.
#[derive(Drop, starknet::Event)]
struct L2TokenSet {
l2_token_address: ContractAddress,
}
pub mod Errors {
pub const EXPECTED_FROM_BRIDGE_ONLY: felt252 = 'Expected from bridge only';
pub const INVALID_ADDRESS: felt252 = 'Invalid address';
pub const INVALID_AMOUNT: felt252 = 'Invalid amount';
pub const UNAUTHORIZED: felt252 = 'Unauthorized';
pub const TOKEN_NOT_SET: felt252 = 'Token not set';
pub const L1_BRIDGE_NOT_SET: felt252 = 'L1 bridge address not set';
}
#[constructor]
fn constructor(ref self: ContractState, governor: ContractAddress) {
assert(governor.is_non_zero(), Errors::INVALID_ADDRESS);
self.governor.write(governor);
}
#[abi(embed_v0)]
impl TokenBridge of super::ITokenBridge<ContractState> {
/// Initiates a withdrawal of tokens on the L1 contract.
fn bridge_to_l1(ref self: ContractState, l1_recipient: EthAddress, amount: u256) {
assert(l1_recipient.is_non_zero(), Errors::INVALID_ADDRESS);
assert(amount.is_non_zero(), Errors::INVALID_AMOUNT);
self._assert_l1_bridge_set();
self._assert_token_set();
// burn tokens on L2
let caller_address = get_caller_address();
IMintableTokenDispatcher { contract_address: self.l2_token.read() }
.burn(caller_address, amount);
// Send the message to L1 to mint tokens there.
let mut payload: Array<felt252> = array![
l1_recipient.into(), amount.low.into(), amount.high.into(),
];
syscalls::send_message_to_l1_syscall(self.l1_bridge.read(), payload.span())
.unwrap_syscall();
self.emit(WithdrawInitiated { l1_recipient, amount, caller_address });
}
fn set_l1_bridge(ref self: ContractState, l1_bridge_address: EthAddress) {
self._assert_only_governor();
assert(l1_bridge_address.is_non_zero(), Errors::INVALID_ADDRESS);
self.l1_bridge.write(l1_bridge_address.into());
self.emit(L1BridgeSet { l1_bridge_address });
}
fn set_token(ref self: ContractState, l2_token_address: ContractAddress) {
self._assert_only_governor();
assert(l2_token_address.is_non_zero(), Errors::INVALID_ADDRESS);
self.l2_token.write(l2_token_address);
self.emit(L2TokenSet { l2_token_address });
}
// Getters
fn governor(self: @ContractState) -> ContractAddress {
self.governor.read()
}
fn l1_bridge(self: @ContractState) -> felt252 {
self.l1_bridge.read()
}
fn l2_token(self: @ContractState) -> ContractAddress {
self.l2_token.read()
}
}
#[generate_trait]
impl Internal of InternalTrait {
fn _assert_only_governor(self: @ContractState) {
assert(get_caller_address() == self.governor.read(), Errors::UNAUTHORIZED);
}
fn _assert_token_set(self: @ContractState) {
assert(self.l2_token.read().is_non_zero(), Errors::TOKEN_NOT_SET);
}
fn _assert_l1_bridge_set(self: @ContractState) {
assert(self.l1_bridge.read().is_non_zero(), Errors::L1_BRIDGE_NOT_SET);
}
}
#[l1_handler]
pub fn handle_deposit(
ref self: ContractState, from_address: felt252, account: ContractAddress, amount: u256,
) {
assert(from_address == self.l1_bridge.read(), Errors::EXPECTED_FROM_BRIDGE_ONLY);
assert(account.is_non_zero(), Errors::INVALID_ADDRESS);
assert(amount.is_non_zero(), Errors::INVALID_AMOUNT);
self._assert_token_set();
// Call mint on l2_token contract.
IMintableTokenDispatcher { contract_address: self.l2_token.read() }.mint(account, amount);
self.emit(Event::DepositHandled(DepositHandled { account, amount }));
}
}
The IMintableToken
interface represents any mintable/burnable token, and will allow our contract to perform these operations without
regard for the actual token it's working with.
Let's immediately create the mock token implementation that we'll use with the contract:
#[starknet::contract]
pub mod MintableTokenMock {
use core::num::traits::Zero;
use starknet::event::EventEmitter;
use starknet::{ContractAddress, get_caller_address};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use l1_l2_token_bridge::contract::IMintableToken;
#[storage]
struct Storage {
// The address of the L2 bridge contract. Only the bridge can
// invoke burn and mint methods
bridge: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Minted: Minted,
Burned: Burned,
}
#[derive(Drop, starknet::Event)]
pub struct Minted {
pub account: ContractAddress,
pub amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Burned {
pub account: ContractAddress,
pub amount: u256,
}
pub mod Errors {
pub const INVALID_ADDRESS: felt252 = 'Invalid address';
pub const UNAUTHORIZED: felt252 = 'Unauthorized';
}
#[constructor]
fn constructor(ref self: ContractState, bridge: ContractAddress) {
assert(bridge.is_non_zero(), Errors::INVALID_ADDRESS);
self.bridge.write(bridge);
}
#[abi(embed_v0)]
impl MintableTokenMock of IMintableToken<ContractState> {
fn mint(ref self: ContractState, account: ContractAddress, amount: u256) {
self._assert_only_bridge();
self.emit(Minted { account, amount });
}
fn burn(ref self: ContractState, account: ContractAddress, amount: u256) {
self._assert_only_bridge();
self.emit(Burned { account, amount });
}
}
#[generate_trait]
impl Internal of InternalTrait {
fn _assert_only_bridge(self: @ContractState) {
assert(get_caller_address() == self.bridge.read(), Errors::UNAUTHORIZED);
}
}
}
Next, we'll implement the mock token on the Ethereum side, which behaves more or less the same as its Starknet counterpart.
Let's just quickly define the interfaces we'll need:
// SPDX-License-Identifier: MIT.
pragma solidity ^0.8.0;
interface IMintableTokenEvents {
event Minted(address indexed account, uint256 amount);
event Burned(address indexed account, uint256 amount);
}
// SPDX-License-Identifier: MIT.
pragma solidity ^0.8.0;
import "./IMintableTokenEvents.sol";
interface IMintableToken is IMintableTokenEvents {
function mint(address account, uint256 amount) external;
function burn(address account, uint256 amount) external;
}
Now we can create a mock token implementation:
// SPDX-License-Identifier: MIT.
pragma solidity ^0.8.0;
import "./IMintableToken.sol";
contract MintableTokenMock is IMintableToken {
address public bridge;
/**
* @dev The address is invalid (e.g. `address(0)`).
*/
error InvalidAddress();
/**
* @dev The sender is not the bridge.
*/
error Unauthorized();
/**
@dev Constructor.
@param _bridge The address of the L1 bridge.
*/
constructor(address _bridge) {
if (_bridge == address(0)) {
revert InvalidAddress();
}
bridge = _bridge;
}
/**
* @dev Throws if the sender is not the bridge.
*/
modifier onlyBridge() {
if (bridge != msg.sender) {
revert Unauthorized();
}
_;
}
function mint(address account, uint256 amount) external onlyBridge {
emit Minted(account, amount);
}
function burn(address account, uint256 amount) external onlyBridge {
emit Burned(account, amount);
}
}
Finally, we will implement the TokenBridge
contract on Ethereum:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./IMintableToken.sol";
import "starknet/IStarknetMessaging.sol";
/**
* @title Contract to bridge tokens to and from Starknet. Has basic access control
* with the `governor` being the only one able to set other storage variables.
*/
contract TokenBridge {
address public governor;
IMintableToken public token;
IStarknetMessaging public snMessaging;
uint256 public l2Bridge;
// In our case the value for the `handle_deposit` method on Starknet will be:
// `0x2D757788A8D8D6F21D1CD40BCE38A8222D70654214E96FF95D8086E684FBEE5`
uint256 public l2HandlerSelector;
/**
* @dev The amount is zero.
*/
error InvalidAmount();
/**
* @dev The address is invalid (e.g. `address(0)`).
*/
error InvalidAddress(string);
/**
* @dev The Starknet address is invalid (e.g. `0`).
*/
error InvalidRecipient();
/**
* @dev The L2 handler selector on Starknet is invalid (e.g. `0`).
*/
error InvalidSelector();
/**
* @dev The sender is not the governor.
*/
error OnlyGovernor();
/**
* @dev The L2 bridge address is not set.
*/
error UninitializedL2Bridge();
/**
* @dev The L1 token address is not set.
*/
error UninitializedToken();
event L2BridgeSet(uint256 l2Bridge);
event TokenSet(address token);
event SelectorSet(uint256 selector);
/**
@dev Constructor.
@param _governor The address of the governor.
@param _snMessaging The address of Starknet Core contract, responsible for messaging.
@param _l2HandlerSelector The selector of Starknet contract's #[l1_handler], responsible handling the L1 bridge request.
@notice To read how Starknet selectors are calculated, see docs:
https://docs.starknet.io/architecture-and-concepts/cryptography/hash-functions/#starknet_keccak
*/
constructor(
address _governor,
address _snMessaging,
uint256 _l2HandlerSelector
) {
if (_governor == address(0)) {
revert InvalidAddress("_governor");
}
if (_snMessaging == address(0)) {
revert InvalidAddress("_snMessaging");
}
if (_l2HandlerSelector == 0) {
revert InvalidSelector();
}
governor = _governor;
snMessaging = IStarknetMessaging(_snMessaging);
l2HandlerSelector = _l2HandlerSelector;
}
/**
* @dev Throws if the sender is not the governor.
*/
modifier onlyGovernor() {
if (governor != msg.sender) {
revert OnlyGovernor();
}
_;
}
/**
* @dev Throws if the L2 bridge address is not set.
*/
modifier onlyWhenL2BridgeInitialized() {
if (l2Bridge == 0) {
revert UninitializedL2Bridge();
}
_;
}
/**
* @dev Throws if the L2 bridge address is not set.
*/
modifier onlyWhenTokenInitialized() {
if (address(token) == address(0)) {
revert UninitializedToken();
}
_;
}
/**
* @dev Sets a new L2 (Starknet) bridge address.
*
* @param newL2Bridge New bridge address.
*/
function setL2Bridge(uint256 newL2Bridge) external onlyGovernor {
if (newL2Bridge == 0) {
revert InvalidAddress("newL2Bridge");
}
l2Bridge = newL2Bridge;
emit L2BridgeSet(newL2Bridge);
}
/**
* @dev Sets a new L1 address for the bridge token.
*
* @param newToken New token address.
*/
function setToken(address newToken) external onlyGovernor {
if (newToken == address(0)) {
revert InvalidAddress("newToken");
}
token = IMintableToken(newToken);
emit TokenSet(newToken);
}
/**
* @dev Sets a new Starknet contract's #[l1_handler] selector.
*
* @param newSelector New selector value.
*/
function setL2HandlerSelector(uint256 newSelector) external onlyGovernor {
if (newSelector == 0) {
revert InvalidSelector();
}
l2HandlerSelector = newSelector;
emit SelectorSet(newSelector);
}
/**
@dev Bridges tokens to Starknet.
@param recipientAddress The contract's address on starknet.
@param amount Token amount to bridge.
@notice Consider that Cairo only understands felts252.
So the serialization on solidity must be adjusted. For instance, a uint256
must be split into two uint128 parts, and Starknet will be able to
deserialize the low and high part as a single u256 value.
*/
function bridgeToL2(
uint256 recipientAddress,
uint256 amount
) external payable onlyWhenL2BridgeInitialized onlyWhenTokenInitialized {
if (recipientAddress == 0) {
revert InvalidRecipient();
}
if (amount == 0) {
revert InvalidAmount();
}
(uint128 low, uint128 high) = splitUint256(amount);
uint256[] memory payload = new uint256[](3);
payload[0] = recipientAddress;
payload[1] = low;
payload[2] = high;
token.burn(msg.sender, amount);
snMessaging.sendMessageToL2{value: msg.value}(
l2Bridge,
l2HandlerSelector,
payload
);
}
/**
@dev Manually consumes the bridge token request that was received from L2.
@param fromAddress L2 contract (account) that has sent the message.
@param recipient account to withdraw to.
@param low lower 128-bit half of the uint256.
@param high higher 128-bit half of the uint256.
@notice There's no need to validate any of the input parameters, because
the message hash from "invalid" data simply won't be found by the
StarknetMessaging contract.
*/
function consumeWithdrawal(
uint256 fromAddress,
address recipient,
uint128 low,
uint128 high
) external onlyWhenTokenInitialized {
// recreate payload
uint256[] memory payload = new uint256[](3);
payload[0] = uint256(uint160(recipient));
payload[1] = uint256(low);
payload[2] = uint256(high);
// Will revert if the message is not consumable.
snMessaging.consumeMessageFromL2(fromAddress, payload);
// recreate amount from 128-bit halves
uint256 amount = (uint256(high) << 128) | uint256(low);
token.mint(recipient, amount);
}
/**
* @dev Splits the 256-bit integer into 128-bit halves that can be
* deserialized by Starknet as a single u256 value.
*
* @param value 256-bit integer value
* @return low lower/rightmost 128 bits of the integer
* @return high upper/leftmost 128 bits of the integer
*/
function splitUint256(
uint256 value
) private pure returns (uint128 low, uint128 high) {
// Extract the lower 128 bits by masking with 128 ones (0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
low = uint128(value & 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF);
// Extract the upper 128 bits by shifting right by 128 bits
high = uint128(value >> 128);
}
}
Note: Bridging tokens from Ethereum to Starknet usually takes a couple of minutes, and the
#[l1_handler]
function is automatically invoked by the Sequencer, minting tokens to our Starknet wallet address. On the other hand, for bridging transactions to be successfully sent from Starknet to Ethereum can take a couple of hours, and will require us to manually consume the withdrawal in order for the tokens to be sent to our Ethereum wallet address.