Overview
For developers familiar with my previous articles, you'll notice I've followed a similar learning path—starting with ERC20 tokens and progressing to upgradable contracts. This article explores Cairo's approach to upgradable contract programming.
Key differences from Solidity:
- Cairo 1's upgrade mechanism doesn't require proxy patterns
- Storage slot collisions are inherently prevented
- StarkNet's architecture enables simpler cross-chain implementations
We'll build upon Cairo 2 Practical Guide: Writing, Testing, and Deploying ERC-20 Tokens to create a native cross-chain ERC20 token supporting:
transfer_to_L2
function (Ethereum → StarkNet)transferToL1
function (StarkNet → Ethereum)
Upgradable Contracts in Cairo
Class Hash Architecture
StarkNet's fundamental separation between:
- Class: Logic (registered in StarkNet's state)
- Contract: Runtime instance storing state
This provides native separation of logic and state—similar to logic contracts vs. proxy contracts in Solidity.
Upgrade Mechanism
use starknet::syscalls::replace_class_syscall;
fn upgrade(self: @ContractState, new_class_hash: ClassHash) {
replace_class_syscall(new_class_hash);
}
Key advantages over Solidity:
- No storage slot conflicts (storage uses variable name hashes)
- Direct class replacement without migration
- No need for complex proxy patterns
⚠️ Projects using upgradable contracts on StarkNet may carry higher governance risks.
Cross-Chain Communication
L2 → L1 Messaging
Workflow
- Cairo contract calls
send_message_to_l1_syscall
StarkNet node computes message hash:
keccak256(abi.encodePacked( FromAddress, ToAddress, Payload.length, Payload ));
- Core contract emits
LogMessageToL1
event - L1 contract consumes message via
consumeMessageFromL2
Implementation Example
let mut message_payload = array![
l1_recipient.into(),
amount.low.into(),
amount.high.into()
];
send_message_to_l1_syscall(
to_address: self.l1_token.read(),
payload: message_payload.span()
);
Gas Costs: ~20k gas for L1 state write (automatically handled by StarkNet gas estimation)
L1 → L2 Messaging
Workflow
- L1 calls
sendMessageToL2
on core contract - StarkNet node detects event and triggers L2 contract
- Only functions with
#[l1_handler]
annotation are callable
Implementation Example
#[l1_handler]
fn despoit_from_L1(from_address: felt252, account: ContractAddress, amount: u256) {
assert(from_address == l1_token::read(), 'UNAUTHORIZED_L1_CALL');
_mint(account, amount);
}
Error Handling:
- Failed transactions require manual cancellation after 5 days
- Use
startL1ToL2MessageCancellation
→cancelL1ToL2Message
flow
Contract Implementation
L2 Cairo Contract
Key additions to standard ERC20:
Burn Function:
fn burn(ref self: ContractState, amount: u256) { let sender = get_caller_address(); self._total_supply.write(self._total_supply.read() - amount); self._balances.write(sender, self._balances.read(sender) - amount); }
Cross-chain Transfer:
fn transfer_to_L1(ref self: ContractState, l1_recipient: EthAddress, amount: u256) { self.burn(amount); send_message_to_l1_syscall(...); }
L1 Solidity Contract
Using Solmate ERC20 as base:
function despoitFromL2(uint256 fromAddress, uint256[] calldata payload) external {
IStarknetMessaging(starkNetAddress).consumeMessageFromL2(fromAddress, payload);
(address receiver, uint256 amount) = parsePayload(payload);
_mint(receiver, amount);
}
Testing Cross-Chain Transfers
L2 → L1 Test Flow
- Call
transfer_to_L1
on Cairo contract - Monitor message hash on StarkScan
- After 4-8 hours, verify message appears in core contract
- Consume message on L1
L1 → L2 Test Flow
cast send $bridge "transferToL2(uint256,uint256)" $L2ADDRESS 0.5ether \
--value 0.003ether \
--rpc-url $RPC_URL \
--private-key $PRIVATE_KEY
FAQ
Why doesn't Cairo need proxy contracts?
StarkNet's architecture inherently separates logic (class) from state (contract), eliminating the need for proxy patterns used in Solidity.
How long do cross-chain messages take?
- L2→L1: 4-8 hours for finality
- L1→L2: Typically 2-5 minutes
What happens if L1→L2 transaction fails?
After 5 days, users can:
- Call
startL1ToL2MessageCancellation
- Wait 5 days
- Call
cancelL1ToL2Message
to recover assets
How to estimate L2 gas costs?
Use Starkli's estimation:
starkli invoke --estimate-only 0x0437...1e81e3 mint u256:100
Conclusion
Key takeaways:
- Simplified Upgrades: Cairo's class/contract separation enables easier upgrades than Solidity
- Native Cross-Chain: StarkNet's architecture provides built-in messaging primitives
- New Patterns: Developers must adapt to StarkNet's unique storage and upgrade patterns
This implementation demonstrates how to build truly chain-agnostic tokens using StarkNet's cross-chain capabilities. Future improvements could include: