StarkNet has emerged as a leading Layer 2 scaling solution for Ethereum, leveraging the power of Cairo — a Turing-complete language designed for zero-knowledge proofs. In this comprehensive guide, we’ll explore two advanced yet essential features in modern StarkNet development: upgradable contracts and cross-chain messaging between StarkNet (L2) and Ethereum (L1).
We'll build upon the foundational knowledge of Cairo-based ERC-20 token development and extend it into real-world use cases. By the end, you’ll understand how to create a native cross-chain ERC-20 token that supports bidirectional asset transfers while being fully upgradable.
Understanding Upgradable Contracts in Cairo
In traditional Solidity development, upgradable contracts rely on proxy patterns — often involving complex implementations like UUPS or Transparent Proxies. These patterns separate logic from state but come with risks such as storage collisions and access control vulnerabilities.
Cairo takes a fundamentally different approach.
Class Hash and Contract State Separation
Every Cairo contract is registered on StarkNet as a class, identified by its class_hash. The deployed contract instance is simply a runtime instantiation of that class, holding only state data. This architectural design inherently decouples logic (class) from state (contract storage), making upgrades more secure and straightforward.
Think of the class as the blueprint and the contract as the house built from it. You can change the blueprint without tearing down the house.This separation eliminates one of the biggest pain points in Solidity: storage slot conflicts. In Cairo, each storage variable is mapped using a hash of its name via sn_keccak, ensuring unique and collision-free storage locations.
fn write(ref self: ContractState, value: felt252) {
starknet::StorageAccess::write(
0_u32,
self.address(),
value
).unwrap_syscall();
}The actual storage address is derived from the variable name’s hash, not its position in a linear layout. This means you can freely restructure your contract logic during upgrades without worrying about corrupting existing state.
Implementing Contract Upgrades
Cairo provides a built-in system call for upgrading:
use starknet::syscalls::replace_class_syscall;
#[external]
fn upgrade(self: @ContractState, new_class_hash: ClassHash) {
replace_class_syscall(new_class_hash);
}Yes — that’s all it takes to swap out the entire logic of a running contract. No proxy delegation, no initializer guards — just a direct class replacement.
👉 Discover how to deploy and manage upgradable contracts securely on OKX.
However, always add access control in production:
assert(get_caller_address() == self.governor.read(), 'UNAUTHORIZED');⚠️ Warning: Because upgrades are so simple, malicious actors could exploit poorly secured contracts. Always implement role-based permissions or multi-sig governance before deploying to mainnet.
Cross-Chain Communication: L2 to L1 & L1 to L2
StarkNet enables seamless communication between Ethereum and itself through a messaging mechanism built into its core contracts. This allows developers to build true cross-chain dApps — like bridges, interoperable NFTs, and multi-layered DeFi protocols.
Let’s break down both directions.
L2 → L1: Sending Messages from StarkNet to Ethereum
To send data from StarkNet to Ethereum, use the send_message_to_l1_syscall:
let mut payload = array![
recipient.into(),
amount.low.into(),
amount.high.into()
];
send_message_to_l1_syscall(to_address: self.l1_token.read(), payload: payload.span());How It Works
- Your Cairo contract calls
send_message_to_l1_syscall. - The StarkNet sequencer picks up the message and emits a
LogMessageToL1event on Ethereum. - The message content is not stored on-chain — only its hash is recorded.
- To consume the message, an L1 contract must call
consumeMessageFromL2().
function consumeMessageFromL2(uint256 fromAddress, uint256[] calldata payload)
external
returns (bytes32)
{
bytes32 msgHash = keccak256(
abi.encodePacked(fromAddress, msg.sender, payload.length, payload)
);
require(l2ToL1Messages[msgHash] > 0, "INVALID_MESSAGE_TO_CONSUME");
l2ToL1Messages[msgHash] -= 1;
return msgHash;
}🔍 Important: You must track message hashes off-chain or via events to verify delivery.
Gas Considerations
- Writing to Ethereum state costs ~20k gas.
- StarkNet accounts for this in fee estimation.
- Event emission adds minor overhead.
L1 → L2: Calling StarkNet Contracts from Ethereum
Unlike L2→L1, which sends passive messages, L1→L2 triggers active function calls on StarkNet.
Step-by-Step Flow
- An Ethereum contract calls
sendMessageToL2()on StarkNet’s core messaging contract. Parameters include:
toAddress: Target StarkNet contractselector: Function identifierpayload: Data arraymsg.value: Fee paid in ETH
function sendMessageToL2(
uint256 toAddress,
uint256 selector,
uint256[] calldata payload
) external payable returns (bytes32, uint256);- Sequencers pick up the message and invoke the target Cairo function only if it's marked with
#[l1_handler].
#[l1_handler]
fn deposit_from_l1(from_address: felt252, account: ContractAddress, amount: u256) {
// Validate sender
assert(from_address == self.l1_bridge.read(), 'UNAUTHORIZED');
// Mint tokens on L2
_balances::write(account, _balances::read(account) + amount);
_total_supply::write(_total_supply::read() + amount);
}🛑 Without #[l1_handler], the call will fail silently.Handling Failures
If gas is insufficient or parameters are invalid:
- The message isn’t consumed.
- ETH remains locked unless canceled.
Use these functions to recover funds:
startL1ToL2MessageCancellation()— Starts a 5-day countdown.cancelL1ToL2Message()— After waiting period, releases funds.
👉 Learn how to estimate cross-chain gas fees accurately using OKX tools.
Building a Cross-Chain ERC-20 Token
Now let’s integrate everything into a working example: a bi-directional bridge-enabled ERC-20 token.
Key Features
| Direction | Function | Action |
|---|---|---|
| L2 → L1 | transfer_to_L1(recipient, amount) | Burns tokens on L2, sends message to L1 |
| L1 → L2 | transferToL2(L2Address, amount) | Burns tokens on L1, triggers mint on L2 |
Cairo Contract Additions
Extend your ERC-20 implementation with:
#[storage]
struct Storage {
_total_supply: u256,
_balances: LegacyMap<ContractAddress, u256>,
governor: ContractAddress,
l1_token: felt252,
}
#[external]
fn set_l1_token(ref self: ContractState, l1_token_address: EthAddress) {
assert(get_caller_address() == self.governor.read(), 'GOVERNOR_ONLY');
self.l1_token.write(l1_token_address.into());
}
#[external]
fn transfer_to_L1(ref self: ContractState, l1_recipient: EthAddress, amount: u256) {
self.burn(amount); // Internal burn
let mut payload = array![l1_recipient.into(), amount.low.into(), amount.high.into()];
send_message_to_l1_syscall(to_address: self.l1_token.read(), payload: payload.span());
}Solidity Counterpart (L1)
function depositFromL2(uint256 fromAddress, uint256[] calldata payload) external {
IStarknetMessaging(core).consumeMessageFromL2(fromAddress, payload);
(address receiver, uint amount) = parsePayload(payload);
_mint(receiver, amount);
}
function parsePayload(uint256[] memory p) internal pure returns (address, uint) {
address receiver = address(uint160(p[0]));
uint amount = (p[2] << 128) | p[1];
return (receiver, amount);
}Frequently Asked Questions (FAQ)
Q: Can anyone upgrade my Cairo contract?
A: Yes — unless you implement access control. Always restrict upgrade() to trusted addresses like a DAO or multi-sig wallet.
Q: How long does L2→L1 message confirmation take?
A: Typically 4–8 hours due to StarkNet’s finality window on Ethereum.
Q: What happens if I don’t consume an L2→L1 message?
A: It remains unclaimed forever. The hash stays in logs but cannot be reused.
Q: Why do I need to pay ETH for L1→L2 calls?
A: To cover execution costs on StarkNet. The fee compensates sequencers for processing your request.
Q: Can I cancel an L1→L2 message immediately?
A: No — there’s a mandatory 5-day delay to prevent front-running and ensure fairness.
Q: Is cross-chain messaging trustless?
A: Yes — all messages are cryptographically verified via Merkle proofs and enforced by the StarkNet consensus.
Testing Strategy
For reliable results:
- Use Goerli testnet for StarkNet.
- Fork Ethereum mainnet using Foundry’s
--forkmode. - Mock core contracts for unit tests.
- Use
starkli invoke --estimate-onlyto check gas usage.
Example:
starkli invoke --estimate-only <contract> mint u256:100Set environment variables:
export STARKNET_ACCOUNT=~/.starknet_accounts/starkli.json
export STARKNET_KEYSTORE=~/.starknet_accounts/key.jsonFinal Thoughts
Cairo simplifies smart contract evolution and cross-chain interoperability like no other language today. With native support for upgradable logic and secure messaging between layers, developers can now build resilient, future-proof dApps that span multiple chains seamlessly.
Whether you're launching a cross-chain stablecoin or building a modular protocol stack, mastering these concepts is key to unlocking StarkNet’s full potential.
👉 Start building your own cross-chain dApp with OKX developer resources.