| name | cairo-contract-authoring-legacy-full |
|---|---|
| description | Comprehensive reference for Cairo contract structure, components, and security hardening patterns. |
Reference for writing Cairo smart contracts on Starknet. Covers structure, storage, events, interfaces, components, and OpenZeppelin v3 patterns.
Optimization: After your contract compiles and tests pass, use cairo-optimization as a separate pass.
- Writing a new Starknet smart contract from scratch
- Adding storage, events, or interfaces to an existing contract
- Using OpenZeppelin components (Ownable, ERC20, ERC721, AccessControl, Upgradeable)
- Implementing the component pattern with
embeddable_as - Structuring a multi-contract project with Scarb
Not for: Gas optimization (use cairo-optimization), testing (use cairo-testing), deployment (use cairo-toolchain)
Before finalizing implementation for security-sensitive logic, cross-check:
../../datasets/distilled/fix-patterns/../../datasets/distilled/vuln-cards/
Never accept now as a user argument for timelock checks.
// BAD
fn execute_upgrade(ref self: ContractState, now: u64) {
assert!(now >= self.executable_after.read(), "timelock");
}
// GOOD
use starknet::get_block_timestamp;
fn execute_upgrade(ref self: ContractState) {
let now = get_block_timestamp();
assert!(now >= self.executable_after.read(), "timelock");
}For every storage-mutating #[external(v0)] function, declare the posture explicitly:
- guarded path (
assert_only_owner/ role check), or - intentionally public path with an inline comment justifying public mutability.
Silent implicit posture is not acceptable in security-sensitive modules.
Reject zero class hash in both schedule and immediate-upgrade flows:
assert!(new_class_hash != 0, "class_hash_zero");Use these versions for new projects (as of March 2026):
[package]
name = "my_contract"
version = "0.1.0"
edition = "2024_07"
[dependencies]
starknet = "^2.16.0"
openzeppelin_access = "3.0.0"
openzeppelin_introspection = "3.0.0"
openzeppelin_token = "3.0.0"
openzeppelin_upgrades = "3.0.0"
openzeppelin_security = "3.0.0"
[dev-dependencies]
snforge_std = "0.57.0"
[cairo]
sierra-replace-ids = true
[[target.starknet-contract]]
[tool.scarb]
allow-prebuilt-plugins = ["snforge_std"]Version pinning: This template targets Scarb 2.16.x with
starknet = "^2.16.0"andsnforge_std = "0.57.0". If you use an older toolchain, pin compatible versions from the release matrix before production use. Check scarbs.dev for updates.
Every Starknet contract follows this skeleton:
#[starknet::contract]
mod MyContract {
use starknet::ContractAddress;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
owner: ContractAddress,
balance: u256,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
Transfer: Transfer,
}
#[derive(Drop, starknet::Event)]
struct Transfer {
#[key]
from: ContractAddress,
#[key]
to: ContractAddress,
amount: u256,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.owner.write(owner);
}
#[abi(embed_v0)]
impl MyContractImpl of super::IMyContract<ContractState> {
fn get_balance(self: @ContractState) -> u256 {
self.balance.read()
}
fn transfer(ref self: ContractState, to: ContractAddress, amount: u256) {
// implementation
}
}
}Define interfaces outside the contract module. Use #[starknet::interface]:
#[starknet::interface]
trait IMyContract<TContractState> {
fn get_balance(self: @TContractState) -> u256;
fn transfer(ref self: TContractState, to: ContractAddress, amount: u256);
}self: @TContractState— read-only (view function)ref self: TContractState— read-write (external function)
#[storage]
struct Storage {
value: felt252, // single felt
counter: u128, // unsigned integer
owner: ContractAddress, // address
is_active: bool, // boolean
}use starknet::storage::Map;
#[storage]
struct Storage {
balances: Map<ContractAddress, u256>,
allowances: Map<(ContractAddress, ContractAddress), u256>,
}
// Usage:
fn get_balance(self: @ContractState, account: ContractAddress) -> u256 {
self.balances.read(account)
}
fn set_allowance(ref self: ContractState, owner: ContractAddress, spender: ContractAddress, amount: u256) {
self.allowances.write((owner, spender), amount);
}Prefer composite key tuples over nested Maps:
use starknet::storage::Map;
#[storage]
struct Storage {
// Map<(owner, spender), amount> — preferred over nested Map
allowances: Map<(ContractAddress, ContractAddress), u256>,
}
// Usage:
let amount = self.allowances.entry((owner, spender)).read();
self.allowances.entry((owner, spender)).write(new_amount);#[event]
#[derive(Drop, starknet::Event)]
enum Event {
Transfer: Transfer,
Approval: Approval,
}
#[derive(Drop, starknet::Event)]
struct Transfer {
#[key] // indexed — used for filtering
from: ContractAddress,
#[key]
to: ContractAddress,
amount: u256, // not indexed — stored in data
}
// Emit:
self.emit(Transfer { from, to, amount });Components are reusable contract modules. This is the standard pattern in Cairo / OZ v3:
The Mixin pattern is the most common approach in OZ v3 — it exposes all standard interface methods (e.g., balance_of, transfer, approve) in a single impl block:
#[starknet::contract]
mod MyToken {
use openzeppelin_access::ownable::OwnableComponent;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
// Embed external implementations (makes functions callable from outside)
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
// Internal implementations (for use inside the contract)
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
ownable: OwnableComponent::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
OwnableEvent: OwnableComponent::Event,
#[flat]
ERC20Event: ERC20Component::Event,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.ownable.initializer(owner);
self.erc20.initializer("MyToken", "MTK");
}
}- Add the
useimport for each component. - Register each component with
component!(...). - Embed external ABI impls with
#[abi(embed_v0)]. - Add internal impl aliases for internal-only calls.
- Add
#[substorage(v0)]fields inStorage. - Add
#[flat]variants inEvent. - Call each required
.initializer(...)in constructor.
If MixinImpl is not embedded, selectors are not exposed in the contract ABI.
#[starknet::component]
mod MyComponent {
use starknet::ContractAddress;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
value: u256,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
ValueChanged: ValueChanged,
}
#[derive(Drop, starknet::Event)]
struct ValueChanged {
new_value: u256,
}
#[embeddable_as(MyComponentImpl)]
impl MyComponent<
TContractState, +HasComponent<TContractState>
> of super::IMyComponent<ComponentState<TContractState>> {
fn get_value(self: @ComponentState<TContractState>) -> u256 {
self.value.read()
}
fn set_value(ref self: ComponentState<TContractState>, new_value: u256) {
self.value.write(new_value);
self.emit(ValueChanged { new_value });
}
}
}[dependencies]
starknet = "^2.16.0"
openzeppelin_access = "3.0.0"
openzeppelin_token = "3.0.0"
openzeppelin_upgrades = "3.0.0"
openzeppelin_introspection = "3.0.0"
openzeppelin_security = "3.0.0"Note: OZ packages are on the Scarb registry. No git tags needed. Check
scarbs.devfor the latest version.
use openzeppelin_access::ownable::OwnableComponent;
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
// In constructor:
self.ownable.initializer(owner);
// In functions:
self.ownable.assert_only_owner();use openzeppelin_upgrades::UpgradeableComponent;
use openzeppelin_upgrades::interface::IUpgradeable;
component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);
impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl<ContractState>;
#[abi(embed_v0)]
impl UpgradeableImpl of IUpgradeable<ContractState> {
fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {
self.ownable.assert_only_owner();
self.upgradeable.upgrade(new_class_hash);
}
}use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
// In constructor:
self.erc20.initializer("TokenName", "TKN");
self.erc20.mint(recipient, initial_supply);use openzeppelin_access::accesscontrol::AccessControlComponent;
use openzeppelin_access::accesscontrol::DEFAULT_ADMIN_ROLE;
component!(path: AccessControlComponent, storage: access_control, event: AccessControlEvent);
#[abi(embed_v0)]
impl AccessControlMixinImpl = AccessControlComponent::AccessControlMixinImpl<ContractState>;
impl AccessControlInternalImpl = AccessControlComponent::InternalImpl<ContractState>;
const MINTER_ROLE: felt252 = selector!("MINTER_ROLE");
// In constructor:
self.access_control.initializer();
self.access_control._grant_role(DEFAULT_ADMIN_ROLE, admin);
self.access_control._grant_role(MINTER_ROLE, minter);
// In functions:
self.access_control.assert_only_role(MINTER_ROLE);my-project/
Scarb.toml
src/
lib.cairo # mod declarations
contract.cairo # main contract
interfaces.cairo # trait definitions
components/
mod.cairo
my_component.cairo
tests/
test_contract.cairo
mod contract;
mod interfaces;
mod components;Use compiler-generated dispatchers from an interface trait:
#[starknet::interface]
trait ITokenContract<TContractState> {
fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
fn transfer(ref self: TContractState, to: ContractAddress, amount: u256);
}
use super::{ITokenContractDispatcher, ITokenContractDispatcherTrait};
fn check_balance(token_address: ContractAddress, account: ContractAddress) -> u256 {
let token = ITokenContractDispatcher { contract_address: token_address };
token.balance_of(account)
}IFooDispatcherfor external calls (ref self).IFooLibraryDispatcherfor library/delegate-style calls.
- Map each embedded component to externally reachable selectors.
- Verify owner/role checks on every privileged selector.
- Verify initializer/upgrade selectors are unauthorized for non-admins.
- Validate substorage layout compatibility before upgrades.
- Add regression tests for unauthorized upgrade/initializer paths.
#[storage]
struct Storage {
entered: bool,
}
fn _enter(ref self: ContractState) {
assert(!self.entered.read(), 'ReentrancyGuard: reentrant');
self.entered.write(true);
}
fn _exit(ref self: ContractState) {
self.entered.write(false);
}use openzeppelin_security::pausable::PausableComponent;
component!(path: PausableComponent, storage: pausable, event: PausableEvent);
// In functions:
self.pausable.assert_not_paused();#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
assert(!owner.is_zero(), 'Owner cannot be zero');
self.ownable.initializer(owner);
}