General Recommendations
We've been focusing so far on learning how to write Cairo Code, which is the minimum for your programs to come to life; but writing secure code is just as important. This chapter distills was written inspired from a large corpus of real Cairo/Starknet audits compiled into concrete instructions you can use while coding, testing, and reviewing your contracts.
We'll focus on:
- Access control and upgrades
- Safe ERC20 token integrations
- Cairo-specific pitfalls that could lead to vulnerabilities
- Cross-domain/bridging safety
- Economic/DoS must-knowns on Starknet
Access Control, Upgrades & Initializers
The most common criticals in Starknet audits are still “who can call this?” and “can this be (re)initialized?” issues. Cairo has great simple building blocks whose logic you should reuse to focus on the core security aspects of your program.
Own your privileged paths
Always make sure that upgrades can only be done by authorized roles. If a non-authorized user can upgrade your contract, it can replace the class with anything and get full control over the contract. The same applies for pause/resume functions, bridge handlers (who can call this contract from L1), and meta-execution. All these critical functions should be guarded using the OwnableComponent from OpenZeppelin.
// components
component!(path: OwnableComponent, storage: ownable);
component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);
#[abi(embed_v0)]
impl OwnableImpl = OwnableComponent::OwnableImpl<ContractState>;
impl InternalUpgradeableImpl = UpgradeableComponent::InternalImpl<ContractState>;
#[event]
fn Upgraded(new_class_hash: felt252) {}
fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {
self.ownable.assert_only_owner();
self.upgradeable._upgrade(new_class_hash);
Upgraded(new_class_hash); // emit explicit upgrade event
}
Why emit events? Incident response and indexers depend on them. Emit for upgrades, configuration changes, pausing, liquidations, and any privileged action; include addresses (e.g., token) to remove ambiguity.
Initializers should only be called once
A frequent vulnerability vector is a publicly exposed initializer that can be called post-deploy. The purpose of an initializer is to de-couple the deployment and the initialization of the contract. However, if the initializer can be called multiple times, it can have unexpected consequences. Make sure the behavior is idempotent.
#[storage]
struct Storage {
_initialized: u8,
// ...
}
fn initializer(ref self: ContractState, owner: ContractAddress) {
assert!(self._initialized.read() == 0, "ALREADY_INIT");
self._initialized.write(1);
self.ownable.initialize(owner);
// init the rest…
}
Rule: if it must be external during deployment, make sure it can only be called once; if it doesn't need to be external, keep it internal.
Token Integrations
Always check boolean returns
While the OpenZeppelin ERC20 implementation reverts on failure, it is not all
ERC-20 implementations that do. Some might return false instead, without
panicking. The transfer and transfer_from return boolean flags; verify them
to ensure the transfers were successful.
CamelCase / snake_case dual interfaces
Most ERC20 tokens on starknet should use the snake_case naming style. However,
for legacy reasons, some old ERC20 tokens have camelCase entrypoints, which
might cause issues if your contracts calls them expecting to find snake_case.
Handling both naming styles is cumbersome; but you should at least ensure that
most tokens you'll be interacting with use the snake_case naming style, or
adapt your contracts.
Cairo-Specific Pitfalls
The Cairo language itself does not have very complicated semantics that could introduce vulnerabilities, but there are some regular programming patterns that could lead to unwanted behavior.
Operator precedence in expressions
In Cairo, && has higher precedence than ||. Make sure that combined
expressions are properly parenthesized to force precedence between operators.
// ❌ buggy: ctx.coll_ok and ctx.debt_ok are only required in Recovery
assert!(
mode == Mode::None || mode == Mode::Recovery && ctx.coll_ok && ctx.debt_ok,
"EMERGENCY_MODE"
);
// ✅ fixed
assert!(
(mode == Mode::None || mode == Mode::Recovery) && (ctx.coll_ok && ctx.debt_ok),
"EMERGENCY_MODE"
);
Unsigned loop underflow
Using a u32 for a loop counter could lead to an underflow panic if that
counter is decremented past 0. If the counter is supposed to handle negative
values, use a i32 instead.
// ✅ prefer signed counters or explicit break
let mut i: i32 = (n.try_into().unwrap()) - 1;
while i >= 0 { // This would never trigger if `i` was a u32.
// ...
i -= 1;
}
Bit-packing into felt252
Packing multiple fields into one felt252 is great for optimizations, but it is
also common and dangerous without tight bounds. Make sure to check the bounds of
the fields before packing them into a felt252. Notably, the sum of the size of
the values packed should not exceed 251 bits.
fn pack_order(book_id: u256, tick_u24: u256, index_u40: u256) -> felt252 {
// width checks
assert!(book_id < (1_u256 * POW_2_187), "BOOK_OVER");
assert!(tick_u24 < (1_u256 * POW_2_24), "TICK_OVER");
assert!(index_u40 < (1_u256 * POW_2_40), "INDEX_OVER");
let packed: u256 =
(book_id * POW_2_64) + (tick_u24 * POW_2_40) + index_u40;
packed.try_into().expect("PACK_OVERFLOW")
}
A bit packing that could fail if the values are too big.
deploy_syscall(deploy_from_zero=true) collisions
Deterministic deployment from zero enables could lead to collisions if two
contracts are attempted to be deployed with the same calldata. Make sure to set
deploy_from_zero to false unless you are sure you want to deploy from zero.
Don’t check get_caller_address().is_zero()
Inherited from Solidity are zero-address checks. On Starknet,
get_caller_address() is never the zero address. Thus, these checks are
useless.
Cross-Domain / Bridging Safety
L1-L2 interactions are specific to how Starknet works, and can be a source of mistakes.
L1 handler must validate the caller address
The #[l1_handler] attribute marks an entrypoint as callable from a contract on
L1. In most cases, you will want to ensure that the source of that call is a
trusted L1 contract - and as such, you should validate the caller address.
#[l1_handler]
fn handle_deposit(
ref self: ContractState,
from_address: ContractAddress,
account: ContractAddress,
amount: u256
) {
let l1_bridge = self._l1_bridge.read();
assert!(!l1_bridge.is_zero(), 'UNINIT_BRIDGE');
assert!(from_address == l1_bridge, 'ONLY_L1_BRIDGE');
// credit account…
}
Economic/DoS & Griefing
Unbounded loops
User-controlled iterations (claims, batch withdrawals, order sweeps) can exceed the Starknet steps limit. Make sure to cap the number of iterations and/or use a pagination pattern to split the work into multiple transactions.
Notably, imagine that you are implementing a system in which when called, a function will iterate over a list of items in storage and process them. If the list is not bounded, an attacker could increase the amount of items in that list, such that the function will never terminate as it will reach the execution step limit of Starknet.
In that case, the contract is bricked: It will not be possible for anyone to interact with it anymore, as any interaction will trigger the step limit.
To bypass that, you could for example use a pagination pattern, where the function will process a maximum number of items at a time, and return the next cursor to the caller. The caller can then call the function again with the next cursor to process the next batch of items.
fn claim_withdrawals(ref self: ContractState, start: u64, max: u64) -> u64 {
let mut i = start;
let end = core::cmp::min(self.pending_count.read(), start + max);
while i < end {
self._process(i);
i += 1;
}
end // next cursor
}