Introduction
In this article, we will be looking at the recently released Cairo 1.0, Starknet’s native language. We will give a brief introduction to Cairo and Starknet, explore some security features of Cairo, and look at some potential pitfalls when writing contracts in Cairo. For anyone considering writing contracts in Cairo, this article will give you a starting point and some things to consider when writing secure code.
Meet Cairo 1.0
Cairo 1.0 is a Rust-inspired language designed to allow anyone to create STARK-provable smart contracts. It is the native language of Starknet, which is a zkRollup designed for high throughput and low gas costs. For the purposes of this article, we will be focusing on the security features of Cairo when used to write smart contracts on Starknet.
First, let’s begin by walking through a simple contract written in Cairo 1.0:
#[contract]
mod hello {
use starknet::get_caller_address;
use starknet::ContractAddress;
struct Storage {
last_caller: ContractAddress,
}
#[event]
fn Hello(from: ContractAddress, value: felt252) {}
#[external]
fn say_hello(message: felt252) {
let caller = get_caller_address();
last_caller::write(caller);
Hello(caller, message);
}
#[view]
fn get_last_caller() -> ContractAddress {
last_caller::read()
}
}
If you have used Rust before, then the above code might look familiar, as Cairo 1.0 is heavily inspired by it. If you are unfamiliar with it, then starklings-cairo1↗ is a great place to start. Starklings is to Cairo what Rustlings is to Rust, a set of small, interactive exercises to help you learn the language.
The default type of variable in Cairo 1.0 is a field element called
felt252
, which is an integer in the range , where is
a very large prime . (For a more detailed
explanation, see the felt-type
section↗
in The Cairo Programming Language book.) All other types in Cairo are
built on top of felt252
such as the integer types of u8
to u256
.
It is recommended to use these higher-level types when possible as they
provide additional safety features such as overflow protection.
When writing Starknet contracts, there are a few special attributes that
are used to allow the compiler to generate the correct code. The
#[contract]
attribute is used to define a Starknet contract, similar
to the contract
keyword in Solidity.
A contract may be required to interact with another contract or to have some knowledge about the current execution state (for example, the caller address). This is where system calls come in, which allow the contract to interact with and use services from the Starknet OS. Most of the time the system calls are abstracted away or hidden behind helper methods, but you can see a list of the available system calls here↗.
The #[event]
attribute is used to define an event that can be emitted
by the contract. Similar to Solidity, events are used to notify the
outside world of state changes in the contract and are emitted using the
emit_event_syscall
system call or by calling the helper function
generated by the compiler, which is annotated with the #[event]
attribute.
The #[external]
attribute is used to define a function that can be
called by the outside world, similar to the external
keyword in
Solidity. The #[view]
attribute is designed to indicate that a
function does not modify the contract state, although this is not
enforced by the compiler, so state changes are possible if the function
is called on chain.
The struct Storage
is a special struct that the compiler uses to
generate helper methods for interacting with the contract’s storage
using the low-level system calls storage_read_syscall
and
storage_write_syscall
. In a Starknet contract, the storage is a map of
slots that can each be read or modified. Each slot is a felt
that is initially set to 0. The fields in the Storage
struct are
turned into modules with read
and write
methods that automatically
calculate the correct location in the storage map (see
here↗
for how the address is calculated) and can be used to read and write to
the storage.
Before you can deploy a contract on Starknet, the contract class must first be declared on the network. Each declared class on the network is represented by a clash hash (see here↗ for how the hash is calculated), which uniquely identifies it and can be used to deploy new contract instances.
Beyond Ethereum: Starknet Accounts
Unlike Ethereum, Starknet does not have externally owned accounts (EOAs). Instead, accounts are special contracts that can define their own logic and rules. Here is the interface for a generic account contract:
#[account_contract]
mod Account {
use starknet::ContractAddress;
#[constructor]
fn constructor(public_key_: felt252);
fn isValidSignature() -> felt252;
#[external]
fn __validate_deploy__(
class_hash: felt252, contract_address_salt: felt252, public_key_: felt252
) -> felt252;
#[external]
fn __validate_declare__(class_hash: felt252) -> felt252;
#[external]
fn __validate__(
contract_address: ContractAddress, entry_point_selector: felt252, calldata: Array<felt252>
) -> felt252;
#[external]
#[raw_output]
fn __execute__(mut calls: Array<Call>) -> Span<felt252>;
}
For a contract to be a valid account, it must at least implement the
__validate__
and __execute__
functions and optionally implement the
others. The __validate__
function should ensure that the transaction
was initiated by the account owner, and the __execute__
function will
perform the remaining actions. (See the “Validate and
execute↗”
section of the Starknet documentation for more details.)
The implementation could be as simple as checking an ECDSA signature, or it could be anything from a multi-sig to allowing multicalls. For a more in-depth look at accounts, see the Starknet documentation↗, the chapter “Account Abstraction↗” in The Starknet Book, and OpenZepplin’s account implementation↗.
Potential Pitfalls in Cairo
One of the main benefits of Starknet being a zkRollup is that contracts written in Cairo allow for execution traces to be proved and verified on Ethereum L1. It has been designed to provide flexibility, but this also could lead to insecure code. In this section, we will look at some potential pitfalls.
Overflows
When using integer types such as u128
and u256
, there is now some
nice built-in overflow protection that will cause a panic — for
example,
let a: u128 = 0xffffffffffffffffffffffffffffffff;
let b: u128 = 1;
let c: u128 = a + b;
// Run panicked with [39878429859757942499084499860145094553463 ('u128_add Overflow'), ].
This is not the case when using felts directly as overflows are still possible:
let a: felt252 = 0x800000000000011000000000000000000000000000000000000000000000000;
let b: felt252 = 1;
let c: felt252 = a + b;
c.print();
// [DEBUG] (raw: 0)
Reentrancy
If you mark a trait with the #[abi]
attribute, then the compiler will
automatically generate two dispatchers based on the trait name; for
example, for the trait ICallback
, the generated names will be
ICallbackDispatcher
and ICallbackLibraryDispatcher
. A dispatcher is
a simple struct that wraps the call_contract
syscall, allowing you to
call other contracts. The library dispatcher is different in that the
current contract’s context and storage will be used when executing the
external code, similar to delegatecall
in Solidity. (See The Cairo
Programming Language’s section on
dispatchers↗
for more details.)
Since the contract dispatcher passes control to the external contract, it is possible for the external contract to call back into the current contract, which could lead to reentrancy bugs. For example, consider the following contract:
#[abi]
trait ICallback {
#[external]
fn callback();
}
#[contract]
mod reentrancy {
use option::OptionTrait;
use starknet::get_caller_address;
use starknet::ContractAddress;
use super::ICallbackDispatcher;
use super::ICallbackDispatcherTrait;
struct Storage {
balances: LegacyMap::<ContractAddress, u256>,
claimed: LegacyMap::<ContractAddress, bool>,
}
#[external]
fn claim(callback: ContractAddress) {
let caller = get_caller_address();
if !claimed::read(caller) {
ICallbackDispatcher { contract_address: callback }.callback();
balances::write(caller, balances::read(caller) + 100);
claimed::write(caller, true);
}
}
#[external]
fn transfer(to: ContractAddress, amount: u256) {
let caller = get_caller_address();
balances::write(caller, balances::read(caller) - amount);
balances::write(to, balances::read(to) + amount);
}
#[view]
fn get_balance(addr: ContractAddress) -> u256 {
balances::read(addr)
}
}
The claim
function allows a user to claim 100 tokens from the contract
if they have not already claimed them, but since the callback happens
before the state is updated, it’s possible for a contract to call the
claim
function repeatedly and claim as many tokens as they want:
use starknet::ContractAddress;
#[abi]
trait IClaim {
#[external]
fn claim(callback: ContractAddress);
}
#[contract]
mod hello {
use starknet::get_caller_address;
use starknet::get_contract_address;
use super::IClaimDispatcher;
use super::IClaimDispatcherTrait;
struct Storage {
count: u256,
}
#[external]
fn callback() {
if (count::read() < 10) {
count::write(count::read() + 1);
IClaimDispatcher { contract_address: get_caller_address() }.claim(get_contract_address());
} else {
count::write(0);
}
}
}
When using the library dispatcher, you must provide a class hash instead of a contract address, so you cannot accidentally use the wrong dispatcher. The executed code will use the same context and storage as the current contract, so the class hash must be trusted.
Storage Clashes
When using the storage struct, the underlying address of the storage
slot is calculated using sn_keccak(variable_name)
(sn_keccak
is the
first 250 bits of the Keccak256
hash). If you are using other modules
or external libraries that have a similar storage struct, then it’s
possible for the storage slots to be identical and overwrite each other.
For example, consider the following contract:
// foo.cairo
#[contract]
mod foo {
struct Storage {
num: u256,
}
fn get_num() -> u256 {
num::read()
}
fn set_num(n: u256) {
num::write(n);
}
}
// bar.cairo
use super::foo::foo;
#[contract]
mod bar {
struct Storage {
num: u128,
}
#[external]
fn set_num(n: u128) {
num::write(n)
}
#[view]
fn get_num() -> u128 {
num::read()
}
#[view]
fn foo_get_num() -> u256 {
super::foo::get_num()
}
#[external]
fn foo_set_num(n: u256) {
super::foo::set_num(n);
}
}
Both of the setters are writing to the same storage slot, except one is
expecting a u256
and the other a u128
, so when calling set_num
,
the bottom 128 bits of num
will be set and the top 128 bits will not
be changed. For example, if we call foo_set_num
with
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
,
then set_num
with 0x1234
then foo_get_num
, we get the following
output:
starknet call --address 0x005e942196b3e1adfac0e1d2664d69671188237db343067ab61048e63957487c --function foo_get_num
4660 0xffffffffffffffffffffffffffffffff
The Next Chapter: Cairo 2.0
Even though 1.0 has only just been released, the language keeps
evolving, and 2.0 will be released soon. The majority of code will be
compatible, and there will be a six-month period where both syntaxes
will be valid. The main incoming changes are designed to allow the
compiler to enforce that view
functions do not modify the state of the
contract, making it much more explicit and easier to know if a function
will modify the state. The new interface syntax will be something
similar to the following:
#[starknet::interface]
trait ICounterContract<TContractState> {
fn increase_counter(ref self: TContractState, amount: u128);
fn decrease_counter(ref self: TContractState, amount: u128);
fn get_counter(self: @TContractState) -> u128;
}
The trait for a Starknet interface is now required to explicitly state
whether it requires a ref
to the contract state, which allows it to be
modified and implicitly returned at the end of a function, whereas the
”@” symbol indicates that the contract state is an immutable snapshot
and cannot be modified. For more information, see the full details of
the upcoming changes at the official community
post↗.
Cairo: The Verdict
The changes from Cairo 0 to 1.0 are a great step towards making the language easy to use and adding some nice security features. The upcoming changes in Cairo 2.0 will make it easier to reason about where the state is modified and help catch mistakes earlier. However, it is still possible to write insecure code, and so it is important to understand the underlying system, the potential pitfalls, and how to avoid them.
Digging Deeper: Cairo Resources
About Us
Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.
Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.
Contact us↗ for an audit that’s better than the rest. Real audits, not rubber stamps.