A full-stack implementation of token bridging between Ethereum (L1) and Starknet (L2) using Solidity and Cairo 1.0.
Bridging is a mechanism that allows you to move digital assets (like tokens or cryptocurrency) between different blockchains. Think of it as a secure tunnel connecting two separate blockchain networks.
Imagine you have tokens on Ethereum, but you want to use them on another blockchain like Polygon or Starknet. Since blockchains are isolated systems that can't directly communicate, you need a "bridge" to transfer your assets.
Bridging moves token representation between blockchains β not actual tokens.
In this project:
- L1: Ethereum Mainnet β handles original tokens
- L2: Starknet β scalable ZK-rollup that mirrors tokens
- π Scalability: L2 is faster & cheaper
- π Interoperability: Seamlessly move assets across chains
- π Access: DeFi apps live on both L1 and L2
Bridging is a Lock & Mint / Burn & Unlock process
- Burn tokens on Ethereum (L1)
- Send message to Starknet (L2)
- Mint tokens on L2
- Burn tokens on Starknet (L2)
- Send message to Ethereum (L1)
- Mint tokens on L1
flowchart LR
%% Ethereum L1 Side
subgraph EthereumL1["Ethereum L1"]
A1["User Wallet (L1)"]
A2["MintableToken.sol"]
A3["TokenBridge.sol"]
end
%% StarkNet L2 Side
subgraph StarkNetL2["StarkNet L2"]
B1["User Wallet (L2)"]
B2["MintableToken.cairo"]
B3["TokenBridge.cairo"]
end
%% L1 β L2 Deposit Flow
A1 -.->|"π½ Call bridgeToL2"| A3
A3 -.->|"π₯ Burn tokens"| A2
A3 ==>|"π¨ Send msg to L2"| B3
B3 -.->|"β‘ Handle deposit"| B2
B2 -.->|"β¨ Mint tokens"| B1
%% L2 β L1 Withdraw Flow
B1 -.->|"πΌ Call bridge_to_l1"| B3
B3 -.->|"π₯ Burn tokens"| B2
B3 ==>|"π¨ Send msg to L1"| A3
A3 -.->|"π₯ Consume msg"| A2
A2 -.->|"β¨ Mint tokens"| A1
%% Styling
classDef l1Color fill:#4fc3f7,stroke:#0277bd,stroke-width:2px,color:#000
classDef l2Color fill:#ba68c8,stroke:#7b1fa2,stroke-width:2px,color:#fff
classDef bridgeColor fill:#ff7043,stroke:#d84315,stroke-width:3px,color:#fff
classDef walletColor fill:#66bb6a,stroke:#2e7d32,stroke-width:2px,color:#fff
class A2,B2 bridgeColor
class A3,B3 bridgeColor
class A1,B1 walletColor
βββββββββββββββββββ Messaging ββββββββββββββββββββββ
β Ethereum L1 β ββββββββββββββββΊβ Starknet L2 β
β β β β
β TokenBridge.sol β β TokenBridge.cairo β
β MintableToken β β MintableToken.cairoβ
βββββββββββββββββββ ββββββββββββββββββββββ
- ERC20 token on L1
- Mintable token on L2
- Solidity-based bridge on L1
- Cairo-based bridge on L2
sendMessageToL2
on L1send_message_to_l1_syscall
on L2
fn bridge_to_l1(ref self: ContractState, l1_recipient: EthAddress, amount: u256) {
// Burn on L2
IMintableTokenDispatcher { contract_address: self.l2_token.read() }
.burn(caller_address, amount);
// Message 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();
}
#[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);
IMintableTokenDispatcher { contract_address: self.l2_token.read() }.mint(account, amount);
}
function bridgeToL2(uint256 recipientAddress, uint256 amount) external payable {
token.burn(msg.sender, amount);
(uint128 low, uint128 high) = splitUint256(amount);
uint256[] memory payload = new uint256[](3);
payload[0] = recipientAddress;
payload[1] = low;
payload[2] = high;
snMessaging.sendMessageToL2{value: msg.value}(
l2Bridge,
l2HandlerSelector,
payload
);
}
function consumeWithdrawal(uint256 fromAddress, address recipient, uint128 low, uint128 high) external {
uint256[] memory payload = new uint256[](3);
payload[0] = uint256(uint160(recipient));
payload[1] = uint256(low);
payload[2] = uint256(high);
snMessaging.consumeMessageFromL2(fromAddress, payload);
uint256 amount = (uint256(high) << 128) | uint256(low);
token.mint(recipient, amount);
}
function splitUint256(uint256 value) private pure returns (uint128 low, uint128 high) {
low = uint128(value & 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF);
high = uint128(value >> 128);
}
// Recombined using u256 struct in Cairo
let amount = u256_from_words(low, high);
- β
Always validate source:
assert(from_address == self.l1_bridge.read())
- π Mint/burn permissions must be tightly controlled
- π‘ Emit events for all cross-chain state changes
- β³ L2 β L1 may require hours (proof generation)
- Unit test: mint/burn, serialization, access control
- Integration test: complete bridge cycle
- Failure test: invalid selectors, mismatched amounts
- β L2 β L1 messages aren't auto-processed: user must call
consumeWithdrawal
- βοΈ Use
uint128
chunks for all messaging payloads - π¨ L1 handler selector mismatch will silently fail
This repo shows how to bridge ERC20-like tokens securely between Ethereum and Starknet using minimal, composable smart contracts. You can extend it with:
- zk-proof verification for withdrawals
- on-chain receipts and history
- batch bridging
PRs & stars welcome β