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?
- Scalability: L2 networks like Starknet offer faster and cheaper transactions
- Interoperability: Users want to move assets between different networks
- 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:
-
L1 → L2 (Deposit):
- Lock/burn tokens on L1
- Send a message to L2
- Mint equivalent tokens on L2
-
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 │
└─────────────────┘ └─────────────────┘
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);
}
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);
}
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();
}
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);
}
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
);
}
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);
}
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);
}
On Starknet side:
// Received as amount.low and amount.high
// Automatically reconstructed as u256
Transaction Flow Examples
Depositing (L1 → L2)
-
User calls
bridgeToL2(starknet_address, 100)
on L1 - L1 Bridge burns 100 tokens from user
-
L1 Bridge sends message to L2:
[starknet_address, 100_low, 100_high]
-
Starknet Sequencer automatically calls
handle_deposit
on L2 - L2 Bridge mints 100 tokens to user's Starknet address
Withdrawing (L2 → L1)
-
User calls
bridge_to_l1(ethereum_address, 50)
on L2 - L2 Bridge burns 50 tokens from user
-
L2 Bridge sends message to L1:
[ethereum_address, 50_low, 50_high]
-
User manually calls
consumeWithdrawal(l2_bridge, ethereum_address, 50_low, 50_high)
on L1 - 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);
Amount Validation
// Prevent zero amounts
assert(amount.is_non_zero(), Errors::INVALID_AMOUNT);
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 });
}
Common Pitfalls
- Wrong Selector: L2 handler selector must match exactly
- Data Serialization: uint256 must be split for Cairo
- Message Source: Always validate message sender
- Timing: L2→L1 messages need manual consumption
- Gas Estimation: L1→L2 requires payment for L2 execution
Best Practices
- Use Events: Emit detailed events for off-chain tracking
- Validate Inputs: Check addresses and amounts
- Access Control: Implement proper governance
- Error Handling: Provide clear error messages
- 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)