DEV Community

Cover image for L2 <-> L1 Tokens Bridging
Aditya41205
Aditya41205

Posted on

L2 <-> L1 Tokens Bridging

Mint/lock burn bridge


Complete Guide to Token Bridging: L1 ↔ L2

What is Bridging?

Bridging is the process of moving tokens or assets between different blockchain networks. In the context of Ethereum and Starknet:

  • L1 (Layer 1): Ethereum mainnet - the main blockchain
  • L2 (Layer 2): Starknet - a scaling solution built on top of Ethereum

Why Do We Need Bridging?

  1. Scalability: L2 networks like Starknet offer faster and cheaper transactions
  2. Interoperability: Users want to move assets between different networks
  3. Ecosystem Access: Different DeFi applications exist on different layers

How Bridging Works

Bridging doesn't actually "move" tokens between chains. Instead, it uses a lock-and-mint mechanism:

  1. L1 → L2 (Deposit):

    • Lock/burn tokens on L1
    • Send a message to L2
    • Mint equivalent tokens on L2
  2. L2 → L1 (Withdrawal):

    • Burn tokens on L2
    • Send a message to L1
    • Unlock/mint tokens on L1

Architecture Overview

┌─────────────────┐    Message     ┌─────────────────┐
│   Ethereum L1   │ ◄────────────► │   Starknet L2   │
│                 │                │                 │
│ TokenBridge.sol │                │ TokenBridge.cairo│
│ MintableToken   │                │ MintableToken   │
└─────────────────┘                └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Components

1. Token Contracts

  • L1 Token: ERC20-like contract on Ethereum that can mint/burn
  • L2 Token: Starknet contract that can mint/burn
  • Both represent the same asset on different layers

2. Bridge Contracts

  • L1 Bridge: Ethereum contract that handles L1 operations
  • L2 Bridge: Starknet contract that handles L2 operations
  • Both communicate via cross-chain messaging

3. Messaging System

  • L1 → L2: Uses Starknet's sendMessageToL2 function
  • L2 → L1: Uses Starknet's send_message_to_l1_syscall

Implementation Deep Dive

L2 (Starknet) Implementation

1. Token Interface

#[starknet::interface]
pub trait IMintableToken<TContractState> {
    fn mint(ref self: TContractState, account: ContractAddress, amount: u256);
    fn burn(ref self: TContractState, account: ContractAddress, amount: u256);
}
Enter fullscreen mode Exit fullscreen mode

2. Bridge Interface

#[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);
}
Enter fullscreen mode Exit fullscreen mode

3. Key Functions

Bridging to L1 (Withdrawal):

fn bridge_to_l1(ref self: ContractState, l1_recipient: EthAddress, amount: u256) {
    // 1. Burn tokens on L2
    IMintableTokenDispatcher { contract_address: self.l2_token.read() }
        .burn(caller_address, amount);

    // 2. Send message to L1
    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();
}
Enter fullscreen mode Exit fullscreen mode

Handling Deposits from L1:

#[l1_handler]
pub fn handle_deposit(
    ref self: ContractState, 
    from_address: felt252, 
    account: ContractAddress, 
    amount: u256,
) {
    // 1. Verify message came from L1 bridge
    assert(from_address == self.l1_bridge.read(), Errors::EXPECTED_FROM_BRIDGE_ONLY);

    // 2. Mint tokens on L2
    IMintableTokenDispatcher { contract_address: self.l2_token.read() }
        .mint(account, amount);
}
Enter fullscreen mode Exit fullscreen mode

L1 (Ethereum) Implementation

1. Key Functions

Bridging to L2 (Deposit):

function bridgeToL2(uint256 recipientAddress, uint256 amount) external payable {
    // 1. Burn tokens on L1
    token.burn(msg.sender, amount);

    // 2. Split uint256 for Cairo compatibility
    (uint128 low, uint128 high) = splitUint256(amount);
    uint256[] memory payload = new uint256[](3);
    payload[0] = recipientAddress;
    payload[1] = low;
    payload[2] = high;

    // 3. Send message to L2
    snMessaging.sendMessageToL2{value: msg.value}(
        l2Bridge,
        l2HandlerSelector,
        payload
    );
}
Enter fullscreen mode Exit fullscreen mode

Consuming Withdrawals from L2:

function consumeWithdrawal(
    uint256 fromAddress,
    address recipient,
    uint128 low,
    uint128 high
) external {
    // 1. Recreate payload
    uint256[] memory payload = new uint256[](3);
    payload[0] = uint256(uint160(recipient));
    payload[1] = uint256(low);
    payload[2] = uint256(high);

    // 2. Consume message from L2
    snMessaging.consumeMessageFromL2(fromAddress, payload);

    // 3. Mint tokens on L1
    uint256 amount = (uint256(high) << 128) | uint256(low);
    token.mint(recipient, amount);
}
Enter fullscreen mode Exit fullscreen mode

Data Serialization

Why Split uint256?

Cairo uses felt252 (252-bit field elements), while Solidity uses uint256. To bridge a uint256:

function splitUint256(uint256 value) private pure returns (uint128 low, uint128 high) {
    low = uint128(value & 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF);
    high = uint128(value >> 128);
}
Enter fullscreen mode Exit fullscreen mode

On Starknet side:

// Received as amount.low and amount.high
// Automatically reconstructed as u256
Enter fullscreen mode Exit fullscreen mode

Transaction Flow Examples

Depositing (L1 → L2)

  1. User calls bridgeToL2(starknet_address, 100) on L1
  2. L1 Bridge burns 100 tokens from user
  3. L1 Bridge sends message to L2: [starknet_address, 100_low, 100_high]
  4. Starknet Sequencer automatically calls handle_deposit on L2
  5. L2 Bridge mints 100 tokens to user's Starknet address

Withdrawing (L2 → L1)

  1. User calls bridge_to_l1(ethereum_address, 50) on L2
  2. L2 Bridge burns 50 tokens from user
  3. L2 Bridge sends message to L1: [ethereum_address, 50_low, 50_high]
  4. User manually calls consumeWithdrawal(l2_bridge, ethereum_address, 50_low, 50_high) on L1
  5. L1 Bridge mints 50 tokens to user's Ethereum address

Security Considerations

Access Control

  • Only bridge contracts can mint/burn tokens
  • Only governors can update bridge addresses
  • Validate all message sources

Message Validation

// Always verify message source
assert(from_address == self.l1_bridge.read(), Errors::EXPECTED_FROM_BRIDGE_ONLY);
Enter fullscreen mode Exit fullscreen mode

Amount Validation

// Prevent zero amounts
assert(amount.is_non_zero(), Errors::INVALID_AMOUNT);
Enter fullscreen mode Exit fullscreen mode

Timing and Gas Costs

L1 → L2 (Deposits)

  • Time: ~10-20 minutes
  • Cost: Ethereum gas + L2 message fee
  • Automatic: Sequencer calls L1 handler automatically

L2 → L1 (Withdrawals)

  • Time: ~2-8 hours (state proof generation)
  • Cost: L2 gas + Ethereum gas for consumption
  • Manual: User must call consumeWithdrawal

Testing Strategy

Unit Tests

  • Test token minting/burning
  • Test message payload construction
  • Test access control

Integration Tests

  • Test full deposit flow
  • Test full withdrawal flow
  • Test edge cases and failures

Mock Contracts

Use simple mock tokens that emit events instead of managing balances:

fn mint(ref self: ContractState, account: ContractAddress, amount: u256) {
    self.emit(Minted { account, amount });
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls

  1. Wrong Selector: L2 handler selector must match exactly
  2. Data Serialization: uint256 must be split for Cairo
  3. Message Source: Always validate message sender
  4. Timing: L2→L1 messages need manual consumption
  5. Gas Estimation: L1→L2 requires payment for L2 execution

Best Practices

  1. Use Events: Emit detailed events for off-chain tracking
  2. Validate Inputs: Check addresses and amounts
  3. Access Control: Implement proper governance
  4. Error Handling: Provide clear error messages
  5. Documentation: Document selector calculations and message formats

This architecture provides a secure, efficient way to bridge tokens between Ethereum and Starknet, enabling users to access applications on both layers while maintaining asset security.

Top comments (0)