StarkNet Development Guide
StarkNet is a permissionless validity rollup (ZK-rollup) on Ethereum. Smart contracts are written in Cairo, a provable computation language that compiles to Sierra (Safe Intermediate Representation) and then to CASM (Cairo Assembly) for execution. Every account on StarkNet is a smart contract — there are no EOAs.
What You Probably Got Wrong
AI agents trained on EVM patterns make critical errors when generating StarkNet/Cairo code. Fix these first.
- StarkNet is NOT EVM-compatible — Cairo is a completely different language from Solidity. There is no
msg.value, nopayable, noreceive(). ETH is an ERC-20 token on StarkNet, transferred via the ETH token contract like any other token. felt252is notuint256— The native type isfelt252, a field element modulo a 252-bit prime. It wraps on overflow (not revert). For safe arithmetic or values > 252 bits, useu256(which is a struct of twou128values internally).- Every account is a smart contract — There are no externally owned accounts. Deploying your first contract requires a pre-funded account contract. Account contracts implement
__validate__and__execute__entrypoints. - Deployment is two steps: declare then deploy — First you declare the contract class (uploading the code). Then you deploy instances of that class. Multiple contracts can share one class hash. This is fundamentally different from EVM's single
CREATE/CREATE2. - There is no
constructorkeyword — Cairo contracts use a#[constructor]attribute on a function. It runs once at deployment and cannot be called again. - Sierra compilation is mandatory — You write Cairo, Scarb compiles to Sierra (safe bytecode), and the sequencer compiles Sierra to CASM. You never deploy raw Cairo. Sierra guarantees provability — every execution path can be proven.
- Transaction fees are paid in STRK or ETH — StarkNet supports fee payment in either STRK (native token) or ETH. The fee token is specified per transaction.
- Storage is
felt252-based, not 32-byte slots — StarkNet storage mapsfelt252keys tofelt252values. Complex types likeMap<K, V>use Pedersen hashing of the key + variable address for slot computation. - Function selectors use
sn_keccak— Unlike Solidity's 4-byte keccak256 selectors, StarkNet usessn_keccak(function_name)which is the first 250 bits of keccak256.
Quick Start
Install Scarb (Cairo Package Manager + Build Tool)
# Install via asdf (recommended)
asdf plugin add scarb
asdf install scarb latest
asdf global scarb latest
# Or via the official installer
curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh
# Verify
scarb --version
Install Starknet Foundry (Testing + Deployment)
# Install snfoundryup
curl -L https://raw.githubusercontent.com/foundry-rs/starknet-foundry/master/scripts/install.sh | sh
# Install latest snforge and sncast
snfoundryup
# Verify
snforge --version
sncast --version
Install starkli (CLI for StarkNet Interaction)
# Install starkliup
curl https://get.starkli.sh | sh
# Install starkli
starkliup
# Verify
starkli --version
Create a New Cairo Project
scarb new my_contract
cd my_contract
This generates:
my_contract/
src/
lib.cairo
Scarb.toml
Scarb.toml Configuration
[package]
name = "my_contract"
version = "0.1.0"
edition = "2024_07"
[dependencies]
starknet = ">=2.9.0"
[[target.starknet-contract]]
sierra = true
casm = true
Chain Configuration
Mainnet
| Property | Value |
|---|---|
| Chain ID | SN_MAIN |
| Currency | STRK / ETH (18 decimals) |
| Block Time | ~6 minutes (L2 blocks), with continuous proving |
| RPC (Public) | https://starknet-mainnet.public.blastapi.io/rpc/v0_7 |
| RPC (Alchemy) | https://starknet-mainnet.g.alchemy.com/starknet/version/rpc/v0_7/<KEY> |
| RPC (Infura) | https://starknet-mainnet.infura.io/v3/<KEY> |
| Explorer (Voyager) | https://voyager.online |
| Explorer (Starkscan) | https://starkscan.co |
Sepolia Testnet
| Property | Value |
|---|---|
| Chain ID | SN_SEPOLIA |
| Currency | STRK / ETH (test tokens) |
| RPC (Public) | https://starknet-sepolia.public.blastapi.io/rpc/v0_7 |
| Faucet | https://starknet-faucet.vercel.app |
| Explorer (Voyager) | https://sepolia.voyager.online |
| Explorer (Starkscan) | https://sepolia.starkscan.co |
Cairo Language Basics
Primitive Types
| Type | Description |
|---|---|
felt252 |
Field element, native type (~252 bits). Wraps on overflow. |
u8, u16, u32, u64, u128, u256 |
Unsigned integers. Panic on overflow. |
i8, i16, i32, i64, i128 |
Signed integers. |
bool |
true or false |
ContractAddress |
StarkNet address type |
ClassHash |
Hash of a declared contract class |
ByteArray |
Dynamic byte array for strings |
Storage Types
#[storage]
struct Storage {
owner: ContractAddress,
balance: u256,
name: ByteArray,
// Map is the equivalent of Solidity's mapping
balances: Map<ContractAddress, u256>,
// Nested maps
allowances: Map<(ContractAddress, ContractAddress), u256>,
}
Interfaces
#[starknet::interface]
pub trait IMyContract<TContractState> {
fn get_balance(self: @TContractState, account: ContractAddress) -> u256;
fn transfer(ref self: TContractState, to: ContractAddress, amount: u256);
}
self: @TContractState= read-only (view) functionref self: TContractState= state-mutating (external) function
Events
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Transfer: Transfer,
Approval: Approval,
}
#[derive(Drop, starknet::Event)]
pub struct Transfer {
#[key]
pub from: ContractAddress,
#[key]
pub to: ContractAddress,
pub amount: u256,
}
#[key] fields are indexed (like Solidity's indexed).
Smart Contract Patterns
Basic Contract
#[starknet::contract]
pub mod Counter {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
count: u128,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
CountIncremented: CountIncremented,
}
#[derive(Drop, starknet::Event)]
pub struct CountIncremented {
pub new_count: u128,
}
#[constructor]
fn constructor(ref self: ContractState, initial_count: u128) {
self.count.write(initial_count);
}
#[abi(embed_v0)]
impl CounterImpl of super::ICounter<ContractState> {
fn get_count(self: @ContractState) -> u128 {
self.count.read()
}
fn increment(ref self: ContractState) {
let current = self.count.read();
let new_count = current + 1;
self.count.write(new_count);
self.emit(CountIncremented { new_count });
}
}
}
Ownable Pattern
#[starknet::contract]
pub mod OwnableContract {
use starknet::{ContractAddress, get_caller_address};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use core::num::traits::Zero;
#[storage]
struct Storage {
owner: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
OwnershipTransferred: OwnershipTransferred,
}
#[derive(Drop, starknet::Event)]
pub struct OwnershipTransferred {
pub previous_owner: ContractAddress,
pub new_owner: ContractAddress,
}
mod Errors {
pub const NOT_OWNER: felt252 = 'Caller is not the owner';
pub const ZERO_ADDRESS: felt252 = 'New owner is zero address';
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
assert(owner.is_non_zero(), Errors::ZERO_ADDRESS);
self.owner.write(owner);
}
#[generate_trait]
impl InternalImpl of InternalTrait {
fn assert_only_owner(self: @ContractState) {
let caller = get_caller_address();
assert(caller == self.owner.read(), Errors::NOT_OWNER);
}
}
#[abi(embed_v0)]
impl OwnableImpl of super::IOwnable<ContractState> {
fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
fn transfer_ownership(ref self: ContractState, new_owner: ContractAddress) {
self.assert_only_owner();
assert(new_owner.is_non_zero(), Errors::ZERO_ADDRESS);
let previous_owner = self.owner.read();
self.owner.write(new_owner);
self.emit(OwnershipTransferred { previous_owner, new_owner });
}
}
}
Component Pattern (Reusable Modules)
Components are StarkNet's answer to Solidity's inheritance — reusable contract logic that can be embedded into any contract. A component defines its own storage, events, and implementations. The host contract uses component!() macro to embed it.
// Define a component
#[starknet::component]
pub mod OwnableComponent {
#[storage]
struct Storage { owner: ContractAddress }
#[embeddable_as(OwnableImpl)]
impl Ownable<TContractState, +HasComponent<TContractState>>
of super::IOwnable<ComponentState<TContractState>> { /* ... */ }
}
// Use in a contract
#[starknet::contract]
pub mod MyContract {
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
#[abi(embed_v0)]
impl OwnableImpl = OwnableComponent::OwnableImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
ownable: OwnableComponent::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
#[flat]
OwnableEvent: OwnableComponent::Event,
}
}
See examples/cairo-contract/ for full component implementations.
Native Account Abstraction
Every StarkNet account is a smart contract. There are no EOAs. Account contracts must implement these entrypoints:
Account Contract Interface
#[starknet::interface]
pub trait IAccount<TContractState> {
fn __validate__(ref self: TContractState, calls: Array<Call>) -> felt252;
fn __execute__(ref self: TContractState, calls: Array<Call>) -> Array<Span<felt252>>;
fn is_valid_signature(
self: @TContractState, hash: felt252, signature: Array<felt252>,
) -> felt252;
}
#[starknet::interface]
pub trait IAccountDeployable<TContractState> {
fn __validate_deploy__(
self: @TContractState,
class_hash: felt252,
salt: felt252,
public_key: felt252,
) -> felt252;
}
How Account Transactions Work
- User signs a transaction with their private key
- Sequencer calls
__validate__on the account contract — this verifies the signature - If validation passes, sequencer calls
__execute__— this dispatches the actual calls __validate__must returnVALID('VALID'as a felt252) to proceed
Multicall is Native
Because __execute__ receives an Array<Call>, every account natively supports batching multiple calls in a single transaction. No multicall contract needed.
// Account's __execute__ iterates through calls
fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> {
let mut results: Array<Span<felt252>> = array![];
for call in calls {
let result = starknet::call_contract_syscall(
call.to, call.selector, call.calldata.span(),
).unwrap();
results.append(result);
};
results
}
Deployment
Step 1: Build with Scarb
scarb build
Outputs Sierra JSON to target/dev/<package_name>_<contract_name>.contract_class.json.
Step 2: Declare (Upload Class)
# Using starkli
starkli declare target/dev/my_contract_Counter.contract_class.json \
--account ~/.starkli-wallets/deployer/account.json \
--keystore ~/.starkli-wallets/deployer/keystore.json \
--rpc https://starknet-sepolia.public.blastapi.io/rpc/v0_7
# Returns: class hash 0x...
Step 3: Deploy (Instantiate Contract)
# Deploy with constructor args
starkli deploy <CLASS_HASH> \
<constructor_arg_1> <constructor_arg_2> \
--account ~/.starkli-wallets/deployer/account.json \
--keystore ~/.starkli-wallets/deployer/keystore.json \
--rpc https://starknet-sepolia.public.blastapi.io/rpc/v0_7
# Returns: deployed contract address 0x...
Using sncast (Starknet Foundry)
# Declare
sncast declare --contract-name Counter
# Deploy
sncast deploy --class-hash <CLASS_HASH> --constructor-calldata 0x0
# Invoke a function
sncast invoke --contract-address <ADDRESS> --function increment
# Call a view function
sncast call --contract-address <ADDRESS> --function get_count
Universal Deployer Contract (UDC)
The UDC allows deploying contracts from other contracts or with deterministic addresses (like CREATE2).
Address: 0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf (same on mainnet and testnet).
Testing with snforge
Unit Tests
#[cfg(test)]
mod tests {
use super::{Counter, ICounterDispatcher, ICounterDispatcherTrait};
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
fn deploy_counter(initial_count: u128) -> ICounterDispatcher {
let contract = declare("Counter").unwrap().contract_class();
let mut calldata: Array<felt252> = array![];
calldata.append(initial_count.into());
let (address, _) = contract.deploy(@calldata).unwrap();
ICounterDispatcher { contract_address: address }
}
#[test]
fn test_initial_count() {
let counter = deploy_counter(42);
assert(counter.get_count() == 42, 'Wrong initial count');
}
#[test]
fn test_increment() {
let counter = deploy_counter(0);
counter.increment();
assert(counter.get_count() == 1, 'Should be 1');
}
}
Testing with Cheatcodes
use snforge_std::{start_cheat_caller_address, stop_cheat_caller_address};
use starknet::contract_address_const;
#[test]
fn test_owner_only() {
let owner = contract_address_const::<0x1>();
let not_owner = contract_address_const::<0x2>();
let contract = deploy_ownable(owner);
// Prank caller to be not_owner
start_cheat_caller_address(contract.contract_address, not_owner);
// This should panic
contract.transfer_ownership(not_owner);
stop_cheat_caller_address(contract.contract_address);
}
#[test]
#[should_panic(expected: 'Caller is not the owner')]
fn test_non_owner_reverts() {
let owner = contract_address_const::<0x1>();
let attacker = contract_address_const::<0x2>();
let contract = deploy_ownable(owner);
start_cheat_caller_address(contract.contract_address, attacker);
contract.transfer_ownership(attacker);
}
Running Tests
# Run all tests
snforge test
# Run specific test
snforge test test_increment
# Run with gas reporting
snforge test --detailed-resources
Starknet.js Integration
Installation
npm install starknet
Provider Setup
import { RpcProvider } from "starknet";
const provider = new RpcProvider({
nodeUrl: "https://starknet-mainnet.public.blastapi.io/rpc/v0_7",
});
const block = await provider.getBlockLatestAccepted();
const chainId = await provider.getChainId();
Account Setup
import { Account, RpcProvider } from "starknet";
const provider = new RpcProvider({
nodeUrl: process.env.STARKNET_RPC_URL!,
});
const account = new Account(
provider,
process.env.ACCOUNT_ADDRESS!,
process.env.PRIVATE_KEY!,
);
Contract Interaction
import { Contract, RpcProvider } from "starknet";
const provider = new RpcProvider({
nodeUrl: process.env.STARKNET_RPC_URL!,
});
const abi = [/* ABI JSON from compiled contract */];
const contract = new Contract(abi, contractAddress, provider);
// Read (call)
const balance = await contract.get_balance(accountAddress);
// Write (invoke) — requires account, not just provider
contract.connect(account);
const tx = await contract.increment();
await provider.waitForTransaction(tx.transaction_hash);
Multicall
// Execute multiple calls in a single transaction
const tx = await account.execute([
{
contractAddress: tokenAddress,
entrypoint: "approve",
calldata: [spenderAddress, amountLow, amountHigh],
},
{
contractAddress: routerAddress,
entrypoint: "swap",
calldata: [/* swap params */],
},
]);
await provider.waitForTransaction(tx.transaction_hash);
L1-L2 Messaging
L2 to L1 (Cairo)
use starknet::syscalls::send_message_to_l1_syscall;
fn send_to_l1(ref self: ContractState, to_l1_address: felt252, payload: Span<felt252>) {
send_message_to_l1_syscall(to_l1_address, payload).unwrap();
}
L1 to L2 (Solidity)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IStarknetCore {
function sendMessageToL2(
uint256 toAddress,
uint256 selector,
uint256[] calldata payload
) external payable returns (bytes32 msgHash, uint256 nonce);
function consumeMessageFromL2(
uint256 fromAddress,
uint256[] calldata payload
) external returns (bytes32);
}
Message Lifecycle
- L2->L1: Cairo contract calls
send_message_to_l1_syscall. Message is included in the L2 state update posted to L1. L1 contract callsconsumeMessageFromL2on StarkNet Core to process it. - L1->L2: Solidity contract calls
sendMessageToL2on StarkNet Core (with fee). StarkNet sequencer automatically invokes the#[l1_handler]function on the target L2 contract.
L1 Handler in Cairo
#[l1_handler]
fn handle_deposit(
ref self: ContractState,
from_address: felt252,
// Payload fields follow
user: ContractAddress,
amount: u256,
) {
// from_address is the L1 sender contract address — validate it
assert(from_address == self.l1_bridge_address.read(), 'Invalid L1 sender');
self.balances.write(user, self.balances.read(user) + amount);
}
Key Differences from EVM
| Concept | EVM (Solidity) | StarkNet (Cairo) |
|---|---|---|
| Account model | EOA + contract accounts | All accounts are contracts |
| Native token transfer | msg.value, payable |
Call ETH token contract (ERC-20) |
| Compilation | Solidity -> EVM bytecode | Cairo -> Sierra -> CASM |
| Deployment | Single tx deploys contract | Declare class, then deploy instance |
| Integer type | uint256 native |
felt252 native, u256 is a struct |
| Overflow | Reverts (Solidity 0.8+) | felt252 wraps, u128/u256 panics |
| Function selector | bytes4(keccak256("fn(types)")) |
sn_keccak("fn_name") (first 250 bits) |
| Reentrancy | Major concern | No direct reentrancy (sequential execution) |
| Inheritance | contract A is B, C |
Component pattern |
| Constructor | constructor() |
#[constructor] fn constructor() |
| Events | event Transfer(...) |
#[derive(starknet::Event)] struct Transfer |
| Storage | 32-byte slots, keccak256 | felt252-based, Pedersen hash |
| Proxy pattern | Delegatecall proxy | Class hash replacement (replace_class_syscall) |
| Batch calls | Multicall contract | Native via __execute__ |
| Block timestamp | block.timestamp |
starknet::get_block_timestamp() |
| Caller | msg.sender |
starknet::get_caller_address() |
| This address | address(this) |
starknet::get_contract_address() |
OpenZeppelin Cairo Contracts
OpenZeppelin provides audited, production-ready components for StarkNet. Add to Scarb.toml:
[dependencies]
openzeppelin_token = "0.20.0"
openzeppelin_access = "0.20.0"
Available components: ERC20, ERC721, ERC1155, Ownable, AccessControl, Pausable, Upgradeable, ReentrancyGuard, and more. Each is a #[starknet::component] that you embed via component!() macro. See examples/cairo-contract/ for a full ERC-20 token example using OpenZeppelin.
Ecosystem
Major Protocols
| Protocol | Category | Description |
|---|---|---|
| JediSwap | DEX | AMM based on Uniswap V2/V3 model |
| Ekubo | DEX | Concentrated liquidity DEX, highest TVL on StarkNet |
| mySwap | DEX | AMM with concentrated liquidity |
| Nostra | Lending | Lending and borrowing protocol |
| zkLend | Lending | Money market protocol |
| Carmine | Options | Options AMM |
| Braavos | Wallet | Smart wallet with hardware signer |
| Argent X | Wallet | Most popular StarkNet wallet |
| Pragma | Oracle | Native StarkNet oracle network |
| Realms/Loot | Gaming | On-chain game ecosystem (Dojo framework) |
Development Tools
| Tool | Purpose |
|---|---|
| Scarb | Package manager + build tool (like Cargo for Cairo) |
| snforge | Testing framework (Starknet Foundry) |
| sncast | CLI for contract interaction (Starknet Foundry) |
| starkli | Low-level CLI for StarkNet |
| Voyager | Block explorer |
| Starkscan | Block explorer (alternative) |
| Katana | Local StarkNet node (from Dojo) |
| Dojo | On-chain game engine framework |
RPC Providers
| Provider | Free Tier | URL |
|---|---|---|
| Blast API | Yes | https://starknet-mainnet.public.blastapi.io |
| Alchemy | Yes | https://starknet-mainnet.g.alchemy.com/starknet/version/rpc/v0_7/ |
| Infura | Yes | https://starknet-mainnet.infura.io/v3/ |
| Chainstack | Yes | https://starknet-mainnet.core.chainstack.com/ |
| Nethermind | Yes | Via Juno node |