Cairo Contracts Overview

Author: Alexander Mazaletskiy
Security researcher at MixBytes
Cairo is a programming language for writing provable programs, where one party can prove to another one that a certain computation has been executed correctly. Cairo and similar proof systems can be used to provide scalability to blockchains.

ZK-Rollup StarkNet uses the Cairo programming language both for its infrastructure and for writing StarkNet contracts or otherwise Cairo contracts.

Cairo contracts are just like Solidity contracts. They are stateful, can be deployed and interacted with, and exist inside blocks chained together. Cairo contracts are stapled back to Ethereum as aggregate STARK proofs that summarise key state updates. Proofs make the state data available on Ethereum, and a Solidity contract can verify proofs to the police StarkNet state. StarkNet is an L2 in the sense that it inherits the security of Ethereum.
Structure of Cairo Contracts
The Cairo contract code is a lot like Solidity/Vyper, it has structures, storage vars, events, view and non-view functions as well.
@event - Represents an event
@storage_var - Represents a contract state variable (i.e. mapping, address, etc.)
@constructor - A constructor which runs once at deployment
@view - Used to read from storage_vars
@external - Used to write to storage_vars
@l1_handler - Used to process a message sent from an L1 contract
@constructor - A constructor which is run once at deployment to run smth
@view - Used to read from storage_vars
@external - Used to write to storage_vars

The program usually looks like this
# 1. Structs

struct MyStruct:
    member my_name : felt
    member my_age : felt
end

----------------------------

# 2. Event

# we can pass structs to events
func NewStruct(new_struct : MyStruct):
end

----------------------------

# 3. Storage

@storage_var
func My_Struct(struct_id : felt) -> (struct-i : MyStruct):
end

----------------------------

# 5. Storage getters

@view
func my_struct(struct_id : felt) -> (my_info : MyStruct):
    return My_Struct.read(struct_id)
end

----------------------------

# 7. Non-Constant Functions

@external
func new_struct(id : felt, name : felt, age : felt):
    let my_struct = MyStruct(name, age)
    My_Struct.write(id, my_struct)
    NewStruct.emit(my_struct)
    return ()
end
It was the similarity with Solidity/Vyper and the larger community that contributed to the popularization of Cairo, as well as Starknet Ecosystem in general.

MakerDao, Aave, Argent X are already working on migrating their solutions using Starknet and Cairo Contracts.
But Cairo is not as easy as it seems.
Cairo traps
With all the outward simplicity, everything is not as it seems at first glance.
Let's take a look in more detail on data types.
Felt
Felt stands for Field Element and is the only data type in Cairo. In simple terms, it's an unsigned integer with up to 76 decimals, but it can also be used to store addresses.
Strings
Currently, Cairo does not support strings. It supports, however, short strings of up to 31 characters but they're actually stored in felt.
#  = 448378203247
let hello_string = 'hello'
Arrays
To use arrays in Cairo, you need a pointer that points to the start of the array, which is declared as a felt* using the alloc method.
Adding new elements to the array can be done using an assert (more on that later) and a pointer. See an example below:
%lang starknet
%builtins range_check
# import to use alloc
from starkware.cairo.common.alloc import alloc
# view function that returns a felt
@view
func array_demo(index : felt) -> (value : felt):
    # Creates a pointer to the start of an array.
    let (my_array : felt*) = alloc()
    # sets 3 as the value of the first element of the array
    assert [felt_array] = 3
    # sets 15 as the value of the second element of the array
    assert [felt_array + 1] = 15
    # sets index 2 to value 33.
    assert [felt_array + 2] = 33
    assert [felt_array + 9] = 18
    # Access the list at the selected index.
    let val = felt_array[index]
    return (val)
end
If we try to read a value from an array at an invalid index, the program will fail with the following error: Unknown value for memory cell at address.

You can use arrays as function parameters or in returns, but when declaring it, you should indicate two parameters—the array's length and the array itself. The naming convention is also important and should be my_array_name and my_array_name_len.

For example:
%lang starknet
%builtins pedersen range_check
from starkware.cairo.common.cairo_builtins import HashBuiltin
# Function that receives an array as parameter, so it actually receives the array length and # the array itself
@external
func array_play(array_param_len : felt, array_param : felt*) -> (res: felt):
    # read first element of the array
    let first = array_param[0]
    # read last element of the array
    let last = array_param[array_param_len - 1]
    let res = first + last
    return (res)
end
If you don't follow the proper naming convention, you'll get the following error from the compiler: Array argument "array_param" must be preceded by a length argument named "array_param_len" of type felt.
Structs and mappings
Structs are very similar to Solidity. We just have to define them with the struct keyword and define all its attributes as a member:
# Account struct

struct Account:
    member isOpen: felt
    member balance: felt
end
To create a mapping in Cairo, you have to define the types and use the -> sign between the key and the value. For example:
# Mapping named "accounts_storage" that holds the account details for
# each user using his address as key
@storage_var
func accounts_storage(address: felt) -> (account: Account):
end
We can also return structs from a Cairo function. Such restrictions in data types lead to interesting situations.
Cairo vulnerabilities
Let's take a look at some of the vulnerabilities in Cairo contracts.
Integer division
Yes, Integer Division!

Unlike in Solidity, where division is carried out as if the values were real numbers and anything after the decimal place is truncated, in Cairo it's more intuitive to think of division as the inverse of multiplication. When a number divides a whole number of times into another number, the result is what we would expect, for example 30/6=5. But if we try to divide numbers that don't quite match up so perfectly, like 30/9, the result can be a bit surprising, in this case 1206167596222043737899107594365023368541035738443865566657697352045290673497. That's because 120…97 * 9 = 30
#bad case
@external
func bad_normalize_tokens{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}() -> (normalized_balance : felt):
    let (user) = get_caller_address()

    let (user_current_balance) = user_balances.read(user)
    let (normalized_balance) = user_current_balance / 10**18

    return (normalized_balance)
end
Arithmetic overflow
The default primitive type, the felt or field element, behaves a lot like an integer does in any other language but it has a few important differences to keep in mind. The range of valid felt values is (-P/2,P/2). P here is the prime used by Cairo, which is currently a 252-bit number. Arithemtic using felts is unchecked for overflow and can lead to unexpected results if this isn't properly accounted for. And since the range of values spans both negative and positive values, things like multiplying two positive numbers can have a negative value as a result, and vice versa, multiplying a two negative numbers doesn't always have a positive result.
State modifications in a view function
Cairo provides the @view decorator to signal that a function should not make state modifications. However, this is not currently enforced by the compiler. :(
#bad case
@view
func bad_get_nonce{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}() -> (nonce : felt):
    let (user) = get_caller_address()
    let (nonce) = user_nonces.read(user)
    user_nonces.write(user, nonce + 1)

    return (nonce)
end
Incorrect Felt Comparison
And felt again :) In Cairo, there are two built-in methods for the less than or equal to comparison operator: assert_le and assert_nn_le. assert_le asserts that a number a is less than or equal to b, regardless of the size of a, while assert_nn_le additionally asserts that a is non-negative, i.e. not greater than or equal to the RANGE_CHECK_BOUND value of 2^128.
Access controls & account abstraction
The account abstraction model used by StarkNet has some important differences from what Solidity developers might be used to. There are no EOA addresses in StarkNet, only contract addresses. Rather than interact with contracts directly, users will usually deploy a contract that authenticates them and makes further calls on the user's behalf. At its simplest, this contract checks that the transaction is signed by the expected key, but it could also represent a multisig or DAO, or have more complex logic for what kinds of transactions it will allow (e.g. deposits and withdrawals could be handled by separate contracts or it could prevent unprofitable trades).

It is still possible to interact with contracts directly. But from the perspective of the contract, the caller's address will be 0. Since 0 is also the default value for uninitialized storage, it's possible to accidentally construct access control checks that fail open instead of properly restricting access to only the intended users.
@external
func bad_claim_tokens{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}():
    #can be zero
    let (user) = get_caller_address()

    let (user_current_balance) = user_balances.read(sender_address)
    user_balances.write(user_current_balance + 200)

    return ()
end
L1 to L2 Address Conversion
In Starknet, addresses are of type felt while on L1 addresses are of type uint160. Thus, in order to pass around address types during cross layer messaging, the address variable is typically given as a uint256. However, this can create an issue where an address on L1 can map to the zero address (or an unexpected address) on L2.
Other types of vulnerabilities
Due to the architectural features of Starknet, the following vulnerabilities also occur:

  1. Sandwich attack
  2. Reentrancy attack
  3. Economic attack
  4. MEV (transfers between L1 and L2)
How to be?
Cairo is relatively young, it's like the early versions of Solidity. In order to write better code, you need to use proven solutions:

  1. https://github.com/OpenZeppelin/cairo-contracts
  2. https://github.com/OpenZeppelin/nile
  3. https://github.com/crytic/amarna
  4. https://github.com/Veridise/Medjai
Conclusion
Cairo contracts is a relatively young technology proposed by Starknet, popular among Solidity and Vyper developers due to its visual simplicity and actively growing community. But Cairo contracts are fraught with serious vulnerabilities and require in-depth knowledge of Cairo and a serious approach to security auditing.
Links
  • Who is MixBytes?
    MixBytes is a team of expert blockchain auditors and security researchers specializing in providing comprehensive smart contract audits and technical advisory services for EVM-compatible and Substrate-based projects. Join us on X to stay up-to-date with the latest industry trends and insights.
  • Disclaimer
    The information contained in this Website is for educational and informational purposes only and shall not be understood or construed as financial or investment advice.
Other posts