Cairo's syntax looks like Rust, so the first surprise for a Solidity developer is the language. The bigger ones are underneath: the basic type is a field element rather than a 256-bit word, every account is a contract, there is no msg.value, and the code you write is compiled so that every execution, even a failing one, can be proven. This guide covers what changes when you move from the EVM to Starknet.
Cairo 1, Sierra and CASM
Starknet is a validity rollup. Every block is proven with a STARK proof, so contracts run on the Cairo VM, not the EVM. The pipeline has three stages:
| Stage | What it is |
|---|---|
| Cairo (1.x / 2.x) | The high-level, Rust-like language you write. "Cairo 0" is the older, lower-level language that preceded it. |
| Sierra | Safe Intermediate Representation. This is what you upload in a DECLARE transaction. |
| CASM | Cairo Assembly, the bytecode the VM actually runs. The sequencer compiles Sierra to CASM. |
Sierra exists because in raw CASM a failing assertion makes the execution unprovable. A sequencer couldn't then show that a reverted transaction happened, and couldn't charge a fee for it, which invites denial-of-service. Sierra guarantees that every run, including a panic, ends in a provable state. For you, this mainly means you can't hand-write arbitrary bytecode, and reverted transactions still pay fees.
Deployment is also two steps. You declare a contract class once, which registers its code under a class hash, then deploy any number of instances of that class, each with its own address and storage. It's closer to "upload a class, then instantiate it" than to Solidity's single deployment transaction.
felt252 and the integer types
The native type is felt252, an element of a prime field with modulus P = 2^251 + 17·2^192 + 1. Arithmetic on felts wraps modulo P silently: there's no overflow check, and subtraction below zero gives a huge number, not an error. Division on felts, where it's available, is field division (multiplying by a modular inverse), not integer division.
For anything that is a quantity, use the integer types:
| Type | Notes |
|---|---|
u8 … u128 | Unsigned, overflow and underflow panic, like Solidity 0.8. |
u256 | A struct of two u128 limbs (low, high). Token amounts use it for ERC-20 compatibility. |
i8 … i128 | Signed integers. |
usize | Alias for u32. |
ContractAddress | A felt-sized address, not 20 bytes. |
ByteArray | Arbitrary-length strings. |
A literal like 'not owner' is a short string: up to 31 ASCII characters packed into one felt252. Assertion messages are usually short strings.
A complete contract
Here is a counter that tracks a total per caller, with an owner-only reset. It uses Cairo 2.x syntax: an interface trait, a #[storage] struct with a Map, an embedded ABI impl and one standalone external function.
use starknet::ContractAddress;
#[starknet::interface]
pub trait ICounter<TContractState> {
fn increment(ref self: TContractState, amount: u128);
fn get_count(self: @TContractState, user: ContractAddress) -> u128;
fn get_total(self: @TContractState) -> u128;
}
#[starknet::contract]
pub mod Counter {
use starknet::{ContractAddress, get_caller_address};
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess,
StoragePointerWriteAccess,
};
#[storage]
struct Storage {
owner: ContractAddress,
counts: Map<ContractAddress, u128>,
total: u128,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Incremented: Incremented,
}
#[derive(Drop, starknet::Event)]
pub struct Incremented {
#[key]
pub user: ContractAddress,
pub amount: u128,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.owner.write(owner);
}
#[abi(embed_v0)]
impl CounterImpl of super::ICounter<ContractState> {
fn increment(ref self: ContractState, amount: u128) {
assert(amount > 0, 'amount is zero');
let caller = get_caller_address();
let current = self.counts.read(caller);
self.counts.write(caller, current + amount);
self.total.write(self.total.read() + amount);
self.emit(Incremented { user: caller, amount });
}
fn get_count(self: @ContractState, user: ContractAddress) -> u128 {
self.counts.read(user)
}
fn get_total(self: @ContractState) -> u128 {
self.total.read()
}
}
#[external(v0)]
fn reset_total(ref self: ContractState) {
assert(get_caller_address() == self.owner.read(), 'not owner');
self.total.write(0);
}
}
The Solidity equivalents:
ref self: ContractStatemarks a function that can write state;self: @ContractStateis a read-only snapshot, the equivalent ofview.#[abi(embed_v0)]exposes every function of the impl as a public entry point.#[external(v0)]does the same for a single free-standing function. Functions without either attribute are internal.Map<K, V>ismapping(K => V). It can only live in storage, and you access it withread/write(orentry(key)for nested paths).get_caller_address()ismsg.sender.- Declaring the interface with
#[starknet::interface]also generatesICounterDispatcher, which other contracts and tests use to call this one.
Every account is a contract
Starknet has native account abstraction. There are no EOAs: a user's account is a contract that implements __validate__ (check the signature and anything else it wants) and __execute__ (run the calls). Wallets such as Argent and Braavos deploy one for each user.
Practical consequences:
- Multicall is built in. One transaction can contain several calls, so "approve then deposit" is a single user action.
- Signature schemes are flexible. Accounts can verify Stark-curve keys, secp256r1 (passkeys), multisig or session keys. Off-chain signatures are checked by calling the account's
is_valid_signature(SNIP-6), not by recovering an address. get_caller_address()for a direct user call is the account contract. There's no "is this an EOA" check to make, andtx.origin-style logic doesn't map cleanly. The originating account is available from the transaction info if you really need it.
No msg.value: ETH and STRK are ERC-20s
Calls don't carry native value. ETH and STRK on Starknet are ordinary ERC-20 contracts, and fees are paid in STRK with v3 transactions. Payable functions don't exist: to charge a user, the contract calls transfer_from on the token after the user approves it, and the account bundles the approve and your call in one multicall. Amounts are u256, so 1 ETH is 1_000_000_000_000_000_000_u256.
Tooling: Scarb and Starknet Foundry
- Scarb is the build tool and package manager, like Cargo.
scarb new,scarb build, and dependencies inScarb.toml, including OpenZeppelin Contracts for Cairo, which ships ERC-20, ERC-721, Ownable and account components you embed into a contract. - Starknet Foundry provides
snforgefor tests andsncastfor declaring, deploying and calling contracts. Thestarkupinstaller sets up both, along with Scarb.
A test for the contract above, in a Scarb package named counter, run with snforge test:
use counter::{ICounterDispatcher, ICounterDispatcherTrait};
use snforge_std::{ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address};
use starknet::ContractAddress;
#[test]
fn increment_tracks_caller() {
let owner: ContractAddress = 0x123.try_into().unwrap();
let user: ContractAddress = 0x456.try_into().unwrap();
let class = declare("Counter").unwrap().contract_class();
let (address, _) = class.deploy(@array![owner.into()]).unwrap();
let counter = ICounterDispatcher { contract_address: address };
start_cheat_caller_address(address, user);
counter.increment(5);
assert(counter.get_count(user) == 5, 'wrong count');
assert(counter.get_total() == 5, 'wrong total');
}
Pitfalls for Solidity developers
- Using
felt252for balances. It wraps instead of reverting. Useu128oru256for anything that can overflow or underflow. - Assuming 20-byte addresses. Starknet addresses are field elements. Ethereum addresses, for L1 messaging, have their own
EthAddresstype. - Renaming storage variables across upgrades. A variable's slot is derived from its name, and a map entry's from hashing its keys. Starknet upgrades by swapping a contract's class hash with
replace_class_syscall, no proxy needed, but renamingtotaltogrand_totalsilently points at empty storage. - Forgetting reentrancy. Dispatcher calls to other contracts can call back into you. Update state before external calls, or use a reentrancy guard component.
- Expecting
payable. Design payment flows around ERC-20transfer_fromand multicall from the start. - Treating short strings as strings.
'...'is capped at 31 characters. UseByteArrayfor names, URIs and messages.
Summary
| Solidity / EVM | Cairo / Starknet |
|---|---|
| EVM bytecode | Cairo → Sierra → CASM |
uint256 word | felt252, plus checked u8…u256 |
mapping(K => V) | Map<K, V> in #[storage] |
public / external | #[abi(embed_v0)] impl or #[external(v0)] |
msg.sender | get_caller_address() |
| EOAs plus contracts | Every account is a contract |
msg.value, payable | ETH and STRK as ERC-20s, approve plus multicall |
| Proxy upgrades | replace_class_syscall |
| Foundry / Hardhat | Scarb, snforge, sncast |
Get the weekly commit
New blockchain deep dives every week.