Starknet Security: Cairo Contracts and the ZK Execution Model

Starknet is a ZK-rollup built on Ethereum. Its execution environment is not the EVM, and its smart contract language, Cairo, is not a dialect of Solidity. Auditors who carry EVM intuitions into a Cairo review will miss entire categories of vulnerabilities. This article maps the security surface methodically: from the proof system and its trust assumptions, through felt252 field arithmetic, to the account abstraction model that makes every Starknet wallet a smart contract.


1. Cairo vs. Solidity: A Conceptual Gap

Cairo and Solidity share superficial syntax but diverge at every level that matters for security.

1.1 Cairo Is a Provable Language, Not a Bytecode Target

Solidity compiles to EVM bytecode that runs on every Ethereum node. Cairo compiles to Sierra (Safe Intermediate Representation), which is then compiled to CASM (Cairo Assembly). The Sierra layer exists specifically to guarantee that every program can produce a proof of execution — or a proof of failure — without allowing unprovable states. This has a direct security implication: certain classes of denial-of-service attacks that grief provers are structurally prevented at the compiler level.

1.2 The Ownership and Type System

Cairo 2.x is built on a Rust-inspired ownership and linear type system. Values are moved, not copied, unless they implement the Copy trait. This eliminates a class of double-spend and re-entrancy patterns where state can be duplicated across frames. Auditors accustomed to Solidity’s permissive reference semantics must re-learn what aliasing is even possible.

// Cairo 2.x ownership example
#[starknet::contract]
mod Vault {
    use starknet::ContractAddress;

    #[storage]
    struct Storage {
        balances: LegacyMap<ContractAddress, u256>,
    }

    #[external(v0)]
    fn withdraw(ref self: ContractState, amount: u256) {
        let caller = starknet::get_caller_address();
        let balance = self.balances.read(caller);
        // AUDIT: balance check must come before write
        assert(balance >= amount, 'Insufficient balance');
        self.balances.write(caller, balance - amount);
        // Transfer logic follows — state already committed
    }
}

1.3 No Dynamic Dispatch, No Arbitrary Delegatecall

Solidity’s delegatecall is the source of a significant portion of high-severity EVM vulnerabilities. Cairo contracts have no equivalent. Contract calls cross a well-defined boundary and cannot mutate the calling contract’s storage unless explicitly passed as a ref parameter. The proxy upgrade pattern still exists, but it works through class replacement (replace_class_syscall), not through storage aliasing.


2. The STARK Proof System: Security Model and Trust Assumptions

2.1 What STARK Proves

A STARK proof attests that a particular Cairo program was executed correctly over particular inputs, producing particular outputs, without revealing the witness (the private inputs). On Starknet, the prover generates proofs over batches of transactions and submits them to an on-chain verifier contract on Ethereum L1.

The security of the proof system rests on:

  • Collision-resistant hash functions (Pedersen and Poseidon in the Cairo context)
  • The hardness of solving systems of polynomial equations over a large prime field
  • The soundness error of the FRI (Fast Reed-Solomon IOP of Proximity) commitment scheme

Crucially, STARK proofs are transparent — they require no trusted setup. There is no toxic waste ceremony whose compromise could allow forging proofs. This is a meaningful security advantage over SNARK-based systems.

2.2 What STARK Does Not Prove

The proof system guarantees computational integrity, not semantic correctness. A Cairo program that is correctly specified but logically wrong will generate a perfectly valid proof of its wrong behavior. The prover is not an auditor. This distinction is the foundational reason why Cairo contracts require as rigorous a code review as Solidity contracts — perhaps more, given the novelty of the environment.

A valid STARK proof means the computation ran correctly. It says nothing about whether the computation does what the developer intended.

2.3 The Verifier on L1

Starknet’s security inherits Ethereum’s consensus finality once a proof is verified on L1. However, during the period between a transaction being accepted on L2 and the corresponding proof being verified on L1, the transaction has L2 finality only. Protocol-level re-org risk exists during this window, which is relevant for bridges and cross-layer messaging contracts.


3. The felt252 Type and Field Arithmetic

3.1 What Is a felt?

The native type in Cairo is felt252, a field element in the prime field F_p where:

p = 2^251 + 17 * 2^192 + 1

All arithmetic in Cairo is modular arithmetic over this prime. There is no integer overflow in the traditional sense — instead, values wrap around the field modulus silently.

3.2 The Overflow Trap

Auditors trained on Solidity expect integer overflow protection via Solidity 0.8’s built-in checked arithmetic. In Cairo, felt252 arithmetic is always unchecked modular arithmetic. The integers u8, u16, u32, u64, u128, and u256 do have overflow checks, but only because the standard library explicitly includes range checks as part of their implementation. If a developer uses raw felt252 for arithmetic that should be bounded, they lose those checks entirely.

// DANGEROUS: using felt252 for a counter that should be bounded
fn increment_felt(counter: felt252) -> felt252 {
    counter + 1 // Wraps silently at p — no panic, no revert
}

// SAFE: using u64 which has overflow protection
fn increment_u64(counter: u64) -> u64 {
    counter + 1_u64 // Panics on overflow
}

3.3 Implicit felt252 Coercion

Because many Cairo primitives and syscall return values are felt252, developers sometimes store the result of a comparison or a hash directly without converting to a bounded integer type. Comparisons on felt252 are field comparisons, meaning the ordering is not the intuitive integer ordering for values near the modulus boundary.

// AUDIT TRAP: felt252 comparison near the modulus
// The value (p - 1) as a felt252 compares as "less than" 0 is meaningless
// because felt252 has no inherent ordering for security checks.
// Use integer types for any comparison-dependent access control.

fn is_admin_level(level: felt252) -> bool {
    // This is NOT a safe numeric comparison
    level == 1 // Only safe for exact equality, not ordering
}

3.4 Division and Inversion

In field arithmetic, division is multiplication by the modular inverse. a / b in felt252 always succeeds and returns the unique field element c such that b * c ≡ a (mod p). There is no division-by-zero revert. If b is zero, the result is zero (since zero has no inverse and Cairo will panic via the underlying felt252_div hint), but developers sometimes forget that division semantics here are not the same as integer division.

// Cairo felt252 division — not integer floor division
fn ratio(a: felt252, b: felt252) -> felt252 {
    // If b == 0, this will panic — but the panic message may be opaque
    // If a and b are "large" field elements, the result is not intuitive
    a / b
}

// For financial calculations, use u256 with explicit checked division
use integer::u256_safe_divmod;
fn safe_ratio(a: u256, b: u256) -> u256 {
    let (q, _r) = u256_safe_divmod(a, b, integer::u256_as_non_zero(b));
    q
}

4. Account Abstraction: The Security Model

4.1 Every Account Is a Contract

Starknet has no externally owned accounts in the Ethereum sense. Every account — including end-user wallets — is a deployed smart contract. This is native account abstraction, not a layer built on top of an EOA model. The implications are profound:

  • Transaction validation logic lives in user-controlled code.
  • Signature schemes are not hardcoded at the protocol level.
  • Multicall is a first-class primitive.
  • Session keys, spending limits, and social recovery are implementable without protocol changes.

4.2 The __validate__ and __execute__ Entry Points

Every account contract must implement two special entry points:

#[starknet::contract]
mod Account {
    use starknet::account::Call;

    #[external(v0)]
    fn __validate__(ref self: ContractState, calls: Array<Call>) -> felt252 {
        // Signature verification happens here
        // Must return starknet::VALIDATED if valid
        self._validate_signature()
    }

    #[external(v0)]
    fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> {
        // Actual execution happens here, after sequencer confirms validation
        self._execute_calls(calls)
    }
}

The sequencer calls __validate__ first. Only if it returns starknet::VALIDATED does execution proceed. This separation means that validation failures cannot consume user gas by design (they incur no fee if rejected at the mempool level). However, this also means that any state mutation in __validate__ is a critical vulnerability.

Any state mutation inside `__validate__` is a severe vulnerability. An attacker can repeatedly trigger validation to drain protocol resources or corrupt state without paying fees.

4.3 Signature Malleability and Custom Schemes

Because signature schemes are user-defined, an auditor cannot assume ECDSA semantics. Common issues include:

  • Missing nonce validation: Without a nonce check in __validate__, signed transactions can be replayed.
  • Signature malleability: If the developer implements their own signature scheme incorrectly, multiple valid signatures may exist for the same message.
  • Hash domain separation: Signing hash(calldata) without a domain separator allows cross-contract or cross-chain replay.
// CORRECT nonce handling in __validate__
fn _validate_signature(ref self: ContractState) -> felt252 {
    let tx_info = starknet::get_tx_info().unbox();

    // 1. Check nonce matches stored nonce
    let current_nonce = self.nonce.read();
    assert(tx_info.nonce == current_nonce, 'Invalid nonce');

    // 2. Verify signature over transaction hash
    let is_valid = self._is_valid_signature(
        tx_info.transaction_hash,
        tx_info.signature
    );
    assert(is_valid, 'Invalid signature');

    // 3. Increment nonce — NOTE: this is a state write in __validate__
    // This is one of the few acceptable mutations here because it is
    // required for replay protection and is explicitly blessed by the spec.
    self.nonce.write(current_nonce + 1);

    starknet::VALIDATED
}

4.4 Multicall Atomicity

The standard account contract executes all calls in a single transaction atomically. A partial failure reverts all calls. This is generally safe, but auditors should check whether a target contract correctly handles the case where it is called multiple times within the same multicall batch — for instance, whether reentrancy guards account for recursive invocation within the same transaction.


5. Storage Layout in Cairo Contracts

5.1 Storage Slots Are Pedersen Hashes

Unlike the EVM, where storage slots are sequential integers, Cairo storage slots are computed as the Pedersen hash of the variable name (as a felt252). For mappings, the slot is Pedersen(mapping_name, key). For nested mappings, hashing is applied recursively.

This means:

  • Storage collisions are cryptographically impossible under Pedersen preimage resistance.
  • There is no packed storage optimization analogous to Solidity’s slot packing — each variable occupies its own slot.
  • Upgradeability via replace_class_syscall is safe with respect to storage layout because the storage namespace is defined by variable names, not positions.

5.2 LegacyMap vs. Map

Cairo’s standard library provides LegacyMap (using Pedersen hashing) and the newer Map (using Poseidon hashing). Auditors should verify that contracts use consistent map types and that no off-chain tool assumptions about slot derivation conflict with the actual on-chain implementation.

#[storage]
struct Storage {
    // LegacyMap: Pedersen(sn_keccak("balances"), address)
    balances: LegacyMap<ContractAddress, u256>,

    // Nested: Pedersen(Pedersen(sn_keccak("allowances"), owner), spender)
    allowances: LegacyMap<(ContractAddress, ContractAddress), u256>,
}

5.3 Storage Collisions in Upgradeable Contracts

While Pedersen hashing prevents accidental collisions, a malicious or careless upgrade can intentionally reuse variable names with incompatible types. If a contract upgrades its implementation and changes the type of a storage variable while keeping the same name, reads and writes will silently interpret old data through the new type. This is the Cairo equivalent of storage collision in upgradeable proxy patterns.

// V1 storage
#[storage]
struct Storage {
    // Slot = sn_keccak("owner") -> single ContractAddress
    owner: ContractAddress,
}

// V2 storage — DANGEROUS if upgrading from V1
#[storage]
struct Storage {
    // Same slot name, different semantic type
    // Old ContractAddress value will be read as the first element of a struct
    owner: OwnerInfo, // struct { address: ContractAddress, role: u8 }
}

6. Common Vulnerability Patterns in Cairo Contracts

6.1 Access Control Failures

Cairo does not enforce any default access control on external functions. Any function decorated with #[external(v0)] or exposed via an interface is callable by any account.

// VULNERABLE: No access control
#[external(v0)]
fn set_fee(ref self: ContractState, new_fee: u256) {
    self.fee.write(new_fee); // Anyone can call this
}

// CORRECT: Owner check
#[external(v0)]
fn set_fee(ref self: ContractState, new_fee: u256) {
    let caller = starknet::get_caller_address();
    assert(caller == self.owner.read(), 'Not owner');
    self.fee.write(new_fee);
}

6.2 Reentrancy in Cairo

Cairo does not have a reentrancy guard in the language itself. While the linear type system eliminates some reentrancy vectors, cross-contract calls can still cause classic reentrancy if a contract makes an external call before updating its state.

// VULNERABLE: External call before state update
#[external(v0)]
fn withdraw(ref self: ContractState, amount: u256) {
    let caller = starknet::get_caller_address();
    let balance = self.balances.read(caller);
    assert(balance >= amount, 'Insufficient');

    // State NOT updated yet
    IERC20Dispatcher { contract_address: self.token.read() }
        .transfer(caller, amount); // Caller's __execute__ could call back here

    self.balances.write(caller, balance - amount); // Too late
}

// CORRECT: Checks-Effects-Interactions
#[external(v0)]
fn withdraw(ref self: ContractState, amount: u256) {
    let caller = starknet::get_caller_address();
    let balance = self.balances.read(caller);
    assert(balance >= amount, 'Insufficient');

    self.balances.write(caller, balance - amount); // State updated first

    IERC20Dispatcher { contract_address: self.token.read() }
        .transfer(caller, amount);
}

6.3 Integer Downcast Truncation

Casting from a wider type to a narrower type using try_into() returns an Option. Developers who unwrap with .unwrap() or use into() without checking may silently truncate values.

// DANGEROUS: Silent truncation possible if value > u64::MAX
let large_value: u128 = get_some_large_value();
let truncated: u64 = large_value.try_into().unwrap(); // Panics if overflow — OK
// But: using into() where the compiler allows implicit narrowing can silently truncate

6.4 Hash Collision in Custom Data Structures

When developers build custom Merkle trees or commitment schemes using Pedersen or Poseidon directly, they must apply domain separation between leaf nodes and internal nodes. Without separation, a second-preimage attack can substitute an internal node as a leaf.

// VULNERABLE: No domain separation
fn hash_leaf(data: felt252) -> felt252 {
    pedersen::pedersen(data, 0)
}
fn hash_internal(left: felt252, right: felt252) -> felt252 {
    pedersen::pedersen(left, right) // Same scheme as leaf
}

// CORRECT: Domain separation via prefix
const LEAF_PREFIX: felt252 = 0x00;
const NODE_PREFIX: felt252 = 0x01;

fn hash_leaf(data: felt252) -> felt252 {
    pedersen::pedersen(LEAF_PREFIX, data)
}
fn hash_internal(left: felt252, right: felt252) -> felt252 {
    let inner = pedersen::pedersen(left, right);
    pedersen::pedersen(NODE_PREFIX, inner)
}

6.5 Oracle and Price Feed Manipulation

Cairo contracts interacting with on-chain price feeds face the same economic manipulation risks as their EVM counterparts. Spot price oracles are vulnerable to flash loan manipulation; TWAP oracles require a minimum observation window. The ZK execution layer does not provide any additional protection against economic oracle attacks.

6.6 Selector Clashes

Every function in a Cairo contract is identified by a felt252 selector computed as sn_keccak(function_name). In the extremely unlikely event that two function names produce the same selector, the dispatcher will route calls incorrectly. While the probability is negligible for honest naming, developers using code generation or cross-language interfaces should verify that selectors do not collide with standard interface selectors (ERC-20, ERC-721, etc.).

6.7 Uninitialized Storage

Cairo’s default storage value for any type is zero (the zero felt252, ContractAddress::zero(), false, etc.). Contracts that rely on the absence of initialization as a sentinel must explicitly test for the zero value and should not treat zero as a valid initialized state if it could also represent uninitialized storage.

// AUDIT: Is 0 a valid owner? Or does zero mean "not yet initialized"?
fn get_owner(self: @ContractState) -> ContractAddress {
    let owner = self.owner.read();
    // If owner was never set, this returns the zero address
    // Caller may interpret zero address as having permissions or as invalid
    owner
}

7. Auditing Cairo Contracts: A Different Mental Model

7.1 Read Sierra, Not Only Cairo Source

Cairo source compiles to Sierra, which compiles to CASM. A sophisticated attacker or a compiler bug could produce Sierra that diverges from the intent of the Cairo source. Auditors of high-value contracts should review the Sierra output for critical functions, particularly those involving arithmetic and storage writes. The Sierra IR is verbose but human-readable.

7.2 Syscall Surface

Cairo contracts interact with the Starknet OS via syscalls. Each syscall carries specific security properties:

SyscallRisk Surface
call_contract_syscallReentrancy, arbitrary code execution
emit_event_syscallNo execution risk, but events are not authoritative state
get_execution_info_syscallSpoofable via relayers if misused
replace_class_syscallArbitrary upgrade — highest severity if access-uncontrolled
deploy_syscallDeployer controls initial state and class
library_call_syscallAnalagous to delegatecall — full storage mutation risk
`library_call_syscall` is the Cairo equivalent of `delegatecall`. It executes code in the context of the calling contract's storage. Any contract exposing this syscall without strict access control is critically vulnerable.

7.3 The Role of Components

Cairo 2.x introduces components — reusable logic modules that inject storage and functions into a host contract. Components have their own storage namespaced by their module path. Auditors must:

  1. Identify all components used by a contract.
  2. Read the full component implementation — it is part of the contract’s trusted codebase.
  3. Check for storage namespace collisions if a custom component uses the same variable names as a standard library component.
  4. Verify that component hooks (before_update, after_transfer, etc.) are invoked correctly and in the right order.

7.4 Testing Coverage Is Not Proof Coverage

Cairo’s testing framework (cairo-test, Foundry’s snforge) can achieve 100% line coverage on a contract that still has unchecked arithmetic paths reachable only near the field modulus. Coverage tools measure source-level line execution, not the full space of felt252 inputs. Fuzz testing using snforge property tests is essential for arithmetic-heavy contracts.

7.5 Differences From Solidity Auditing at a Glance

DimensionSolidity / EVMCairo / Starknet
Arithmetic base type256-bit integerfelt252 field element
Overflow behaviorChecked (≥0.8) or wrappingModular (silent near p) for felt252
Reentrancy vectorAll external callsExternal calls + library_call_syscall
Proxy patterndelegatecall storage aliasingreplace_class_syscall + class hash
Account modelEOA + contractAll contracts (native AA)
Signature verificationProtocol-level ECDSAUser-defined in __validate__
Storage layoutSlot packing, sequentialPedersen/Poseidon hashed slots
Upgrade riskStorage collision via proxyType mismatch on variable rename/retype
Denial of serviceGas griefingProver resource griefing (partially mitigated by Sierra)

8. Starknet Audit Checklist

Use this checklist as a structured baseline. It does not replace contract-specific threat modeling.

8.1 Arithmetic and Types

  • All financial and bounded calculations use u256, u128, or explicitly ranged integer types — not raw felt252.
  • No implicit felt252 arithmetic in comparison-dependent access control paths.
  • All try_into() narrowing casts check the Option result and handle None explicitly.
  • Division operations using felt252 are justified; financial calculations use integer division with explicit floor/ceiling semantics.
  • Custom hash schemes apply domain separation between leaves and internal nodes.

8.2 Access Control

  • Every #[external] function that mutates state has an explicit caller check.
  • The owner/admin address is never the zero address post-initialization.
  • Two-step ownership transfer is implemented for privileged admin functions.
  • replace_class_syscall is gated behind the highest-privilege role with a timelock or multisig.
  • library_call_syscall usage is audited for full storage mutation risk.

8.3 Account Contract (if applicable)

  • __validate__ performs no state mutations except the nonce increment.
  • Nonce is validated and incremented atomically in __validate__.
  • Transaction hash includes a domain separator that encodes the chain ID.
  • Custom signature schemes are reviewed for malleability.
  • __execute__ verifies that get_caller_address() is the contract itself (to prevent direct calls to __execute__).

8.4 Storage

  • Storage variable names are stable across upgrade versions; any rename is treated as a storage layout break.
  • Type changes to existing storage variables are evaluated for stale data interpretation risk.
  • Default-zero storage values are handled correctly: zero is not confused with a valid initialized sentinel where it should not be.
  • LegacyMap and Map are used consistently; no mixed usage for the same logical data structure across versions.

8.5 Reentrancy

  • All cross-contract calls follow the Checks-Effects-Interactions pattern.
  • Reentrancy guards (ReentrancyGuard component) are applied to any function that makes an external call and subsequently reads state.
  • Multicall atomicity is considered for contracts that may be called multiple times in a single batch.

8.6 Upgrades and Initialization

  • Initializer functions can only be called once (guarded by an initialized boolean in storage).
  • Upgradeability is restricted to authorized callers with no pathway for a non-owner to trigger replace_class_syscall.
  • Post-upgrade migration logic (if any) is idempotent.

8.7 Oracle and Economic Security

  • Price feeds use TWAP or aggregated sources; spot price oracles are documented as attack vectors.
  • Flash loan vectors are identified for any protocol that uses single-block price observations.
  • AMM-derived prices include a minimum observation window or staleness check.

8.8 Events and Off-Chain Indexing

  • Events are not used as the sole source of truth for on-chain logic.
  • Critical state changes emit events to enable monitoring and incident response.
  • Event data does not leak sensitive information that would not otherwise be visible.

8.9 Component Security

  • All Cairo components are reviewed in full — treat them as first-party code.
  • Component storage namespaces do not shadow host contract storage variables.
  • Component hooks are invoked in the correct order and are not skippable via alternative call paths.

8.10 Cross-Layer and Messaging

  • L1→L2 messages validate the L1 sender address inside the Cairo handler.
  • L2→L1 messages are idempotent or protected against replay on L1.
  • Bridge contracts account for the L2-finality-to-L1-finality delay in any value-release logic.

Closing Remarks

Cairo contracts demand rigor at every layer: the field arithmetic is a different mathematical universe from 256-bit integers, account abstraction moves wallet security into user-controlled code, and the upgrade mechanism bypasses EVM proxy pitfalls only to introduce its own. The STARK proof system provides computational integrity guarantees that no EVM execution does, but it proves only what the code says — not what the developer meant. An audit that treats Cairo as “Solidity with different syntax” will produce a report that misses the most critical findings. The checklist above is a starting point; the mental model shift is the real work.