Cairo Practical Guide: Upgradable Contracts and Cross-Chain Messaging

·

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

  1. Your Cairo contract calls send_message_to_l1_syscall.
  2. The StarkNet sequencer picks up the message and emits a LogMessageToL1 event on Ethereum.
  3. The message content is not stored on-chain — only its hash is recorded.
  4. 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


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

  1. An Ethereum contract calls sendMessageToL2() on StarkNet’s core messaging contract.
  2. Parameters include:

    • toAddress: Target StarkNet contract
    • selector: Function identifier
    • payload: Data array
    • msg.value: Fee paid in ETH
function sendMessageToL2(
    uint256 toAddress,
    uint256 selector,
    uint256[] calldata payload
) external payable returns (bytes32, uint256);
  1. 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:

Use these functions to recover 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

DirectionFunctionAction
L2 → L1transfer_to_L1(recipient, amount)Burns tokens on L2, sends message to L1
L1 → L2transferToL2(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:

Example:

starkli invoke --estimate-only <contract> mint u256:100

Set environment variables:

export STARKNET_ACCOUNT=~/.starknet_accounts/starkli.json
export STARKNET_KEYSTORE=~/.starknet_accounts/key.json

Final 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.