Starknet Fundamentals for LayerZero Developers
This page introduces the Starknet-specific concepts you need to understand before building LayerZero applications. If you're coming from EVM chains, this guide explains how Starknet differs and why LayerZero's implementation works the way it does.
What you'll learn:
- Cairo's
felt252type system andu256representation - Starknet storage model and component-based composition
- Transaction types, versions, and typed dispatcher calls
- Multi-call patterns for atomic configuration
- Resource bounds, fee estimation, and common gas pitfalls
- Reentrancy protections and clear-then-execute behavior
- Bytes32 address encoding for cross-chain peers
For complete protocol workflows with detailed code, see Protocol Overview. For hands-on implementation, see OApp or OFT guides.
VM Architecture
Starknet uses the Cairo programming language and a field-element-based type system. These fundamentals shape how LayerZero contracts represent addresses, amounts, and payloads.
The felt252 Type
Starknet's native type is felt252 (field element), a ~251-bit unsigned integer:
// felt252 is the native Cairo type
let value: felt252 = 123;
// Maximum value is approximately 2^251
// Operations are performed modulo a prime field
Key Properties:
- Native to the STARK proof system (efficient proving)
- Wraps around on overflow (unlike Solidity's revert behavior)
- Can represent addresses, integers, and short strings
Common Types
// Unsigned integers (built on felt252)
let a: u8 = 255;
let b: u32 = 4294967295;
let c: u64 = 18446744073709551615;
let d: u128 = 340282366920938463463374607431768211455;
let e: u256 = 0xffffffff_u256; // Two felt252 values internally
// Signed integers
let f: i32 = -100;
let g: i128 = -1000000;
// Boolean
let flag: bool = true;
// Contract Address (wrapper around felt252)
let addr: ContractAddress = contract_address_const::<0x123>();
// ByteArray for dynamic bytes (like Solidity's bytes)
let data: ByteArray = "Hello, World!";
u256 Representation
Unlike EVM's native 256-bit integers, Starknet represents u256 as two felt252 values:
// u256 is stored as (low: u128, high: u128)
let amount: u256 = 1000000000000000000_u256;
// Conversion to/from felt252 requires care
let as_felt: felt252 = amount.low.into(); // Only works if high == 0
Implications for LayerZero:
- Cross-chain amount encoding must handle this difference
- OFT uses
u64for shared decimals to ensure compatibility
Message Flow Overview
LayerZero messages on Starknet flow through the Endpoint and verification system before reaching the destination OApp:
- Send: OApp -> Endpoint -> message library -> workers (DVNs/Executor)
- Verify: DVNs verify and submit to the receive library
- Receive: Executor calls
lz_receiveon the destination OApp
For detailed send/verify/receive workflows with contract code and event flows, see Protocol Overview.
Transaction Execution Model
Starknet uses distinct transaction types and typed dispatchers. These patterns determine how LayerZero contracts are deployed, called, and configured.
Transaction Types
Starknet has distinct transaction types for different operations:
DECLARE
Publishes contract code to the network:
sncast declare --contract-name MyContract
Result: class_hash - unique identifier for the contract code
When to use: First time deploying a new contract version
DEPLOY_ACCOUNT
Deploys an account contract:
sncast account deploy --name my_account
Prerequisite: The computed account address must be pre-funded
When to use: Setting up a new wallet/signer
INVOKE
Executes contract functions:
sncast invoke --contract-address 0x... --function set_peer --arguments '...'
When to use: All regular contract interactions
Transaction Versions
- v0/v1/v2: Deprecated and unsupported on current Starknet networks
- v3: Current transaction format with resource bounds (recommended)
// INVOKE v3 includes resource bounds
struct InvokeTransactionV3 {
resource_bounds: ResourceBoundsMapping, // l1_gas, l2_gas, l1_data_gas limits
tip: u64, // Priority tip
// ... other fields
}
Dispatcher Pattern
Starknet doesn't support dynamic dispatch (no delegatecall equivalent). Instead, cross-contract calls use typed dispatchers. For more details, see the Cairo Book: Dispatcher Pattern.
Interface Definition
#[starknet::interface]
pub trait IEndpointV2<TContractState> {
fn send(ref self: TContractState, params: MessagingParams, refund_address: ContractAddress) -> MessageReceipt;
fn quote(self: @TContractState, params: MessagingParams, sender: ContractAddress) -> MessagingFee;
fn get_eid(self: @TContractState) -> u32;
}
Generated Dispatcher
The compiler generates a dispatcher for each interface:
// Auto-generated by the compiler
pub struct IEndpointV2Dispatcher {
pub contract_address: ContractAddress,
}
impl IEndpointV2DispatcherTrait of IEndpointV2Dispatcher {
fn send(self: IEndpointV2Dispatcher, params: MessagingParams, refund_address: ContractAddress) -> MessageReceipt {
// Serializes params, calls contract, deserializes result
}
}
Using Dispatchers
use layerzero::endpoint::interfaces::endpoint_v2::{IEndpointV2Dispatcher, IEndpointV2DispatcherTrait};
fn call_endpoint(endpoint_address: ContractAddress, params: MessagingParams) -> MessagingFee {
let endpoint = IEndpointV2Dispatcher { contract_address: endpoint_address };
// Type-safe cross-contract call
endpoint.quote(params, get_contract_address())
}
Benefits:
- Compile-time type checking
- Automatic serialization/deserialization
- Clear error messages
vs EVM:
| EVM | Starknet |
|---|---|
interface.function{value: x}(args) | dispatcher.function(args) |
| Dynamic dispatch via address | Typed dispatcher |
abi.encode/decode | Automatic Serde |
Multi-Call Transactions
Multicall is implemented at the account-contract level: a single INVOKE can execute multiple calls atomically when the account supports it. Most major account implementations (Ready Wallet, formerly Argent; Braavos; OpenZeppelin Account) expose multicall by default. If an account contract does not implement multicall, batching is not available for that account.
Batching with Account.execute
// Using starknet.js
const calls = [
{
contractAddress: oftAddress,
entrypoint: 'set_peer',
calldata: [dstEid, peerAddressLow, peerAddressHigh],
},
{
contractAddress: oftAddress,
entrypoint: 'set_enforced_options',
calldata: [...],
},
{
contractAddress: oftAddress,
entrypoint: 'set_dvn_config',
calldata: [...],
},
];
// All calls execute atomically
const response = await account.execute(calls);
Benefits
- Atomicity: All calls succeed or all fail
- Gas efficiency: Single transaction overhead
- Configuration safety: Set all config before enabling pathway
LayerZero Configuration Pattern
// Recommended: Configure everything in one transaction
const configCalls = [
// 1. Set library (optional)
{ contractAddress: oft, entrypoint: 'set_send_library', calldata: [...] },
// 2. Configure DVNs
{ contractAddress: oft, entrypoint: 'set_dvn_config', calldata: [...] },
// 3. Set enforced options
{ contractAddress: oft, entrypoint: 'set_enforced_options', calldata: [...] },
// 4. Set peer (LAST - enables pathway)
{ contractAddress: oft, entrypoint: 'set_peer', calldata: [...] },
];
await account.execute(configCalls);
State Management Model
Starknet contracts use a key-value storage model and component-based composition rather than inheritance. These patterns shape how LayerZero contracts store configuration and expose functionality.
Contract Storage Model
Storage Structure
Starknet contracts use a key-value storage model with felt252 keys:
#[storage]
struct Storage {
// Simple values
owner: ContractAddress,
total_supply: u256,
// Mappings
balances: Map<ContractAddress, u256>,
allowances: Map<(ContractAddress, ContractAddress), u256>,
// Component substorages
#[substorage(v0)]
erc20: ERC20Component::Storage,
#[substorage(v0)]
oapp_core: OAppCoreComponent::Storage,
}
Storage Access
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
fn example(ref self: ContractState) {
// Read
let current_owner = self.owner.read();
let balance = self.balances.entry(some_address).read();
// Write
self.owner.write(new_owner);
self.balances.entry(some_address).write(new_balance);
}
Storage Layout
Storage keys are computed deterministically:
- Simple variables:
sn_keccak(variable_name) - Mappings:
h(h(variable_name), key1, key2, ...)
This is abstracted by the compiler, but understanding it helps with:
- Debugging storage reads/writes
- Computing storage proofs for cross-chain verification
Component System
Cairo uses a component system instead of inheritance:
Defining a Component
#[starknet::component]
pub mod OAppCoreComponent {
#[storage]
pub struct Storage {
OAppCore_endpoint: ContractAddress,
OAppCore_peers: Map<u32, Bytes32>,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
PeerSet: PeerSet,
}
#[embeddable_as(OAppCoreImpl)]
impl OAppCore<TContractState, +HasComponent<TContractState>> of IOAppCore<ComponentState<TContractState>> {
fn set_peer(ref self: ComponentState<TContractState>, eid: u32, peer: Bytes32) {
// Implementation
}
}
}
Using Components in a Contract
#[starknet::contract]
mod MyOApp {
use layerzero::oapps::oapp::oapp_core::OAppCoreComponent;
use openzeppelin::access::ownable::OwnableComponent;
// Declare components
component!(path: OAppCoreComponent, storage: oapp_core, event: OAppCoreEvent);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
// Embed implementations (exposes external functions)
#[abi(embed_v0)]
impl OAppCoreImpl = OAppCoreComponent::OAppCoreImpl<ContractState>;
// Internal implementations (not exposed)
impl OAppCoreInternalImpl = OAppCoreComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
oapp_core: OAppCoreComponent::Storage,
#[substorage(v0)]
ownable: OwnableComponent::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
OAppCoreEvent: OAppCoreComponent::Event,
#[flat]
OwnableEvent: OwnableComponent::Event,
}
}
vs Solidity Inheritance:
| Solidity | Cairo |
|---|---|
contract A is B, C | component!(path: B, ...) |
override | Trait impl |
| Diamond problem | No conflicts (explicit embedding) |
Security & Permission Model
Starknet's execution model affects how reentrancy is handled in LayerZero contracts.
Reentrancy Model
Unlike EVM, Starknet's execution model provides some inherent reentrancy protections:
Sequential Execution
Transactions are executed sequentially within a block, not concurrently. However, within a single transaction, reentrancy is still possible.
ReentrancyGuard Component
LayerZero contracts use OpenZeppelin's ReentrancyGuard:
use openzeppelin::security::ReentrancyGuardComponent;
component!(path: ReentrancyGuardComponent, storage: reentrancy_guard, event: ReentrancyGuardEvent);
fn protected_function(ref self: ContractState) {
self.reentrancy_guard.start(); // Acquire lock
// Protected logic here
// External calls are safe
self.reentrancy_guard.end(); // Release lock
}
Clear-Then-Execute Pattern
The Endpoint uses this pattern for lz_receive:
fn lz_receive(ref self: ContractState, ...) {
// 1. Clear payload hash first (marks as executed)
self._clear_payload(receiver, @origin, @payload);
// 2. Transfer value
native_token.transfer_from(executor, receiver, value);
// 3. Execute callback (safe even if it calls back)
receiver_dispatcher.lz_receive(origin, guid, message, executor, extra_data, value);
}
Gas Model
Starknet's fee model differs from EVM and uses explicit resource bounds.
Gas Model
| Resource | Description |
|---|---|
| L1 Gas | Cost for DA on Ethereum L1 |
| L2 Gas | Compute cost on Starknet |
| L1 Data Gas | Calldata size on L1 |
Setting Resource Bounds
# Using sncast (fee cap; tooling derives resource bounds)
sncast invoke \
--contract-address 0x... \
--function transfer \
--network sepolia \
--arguments '0x123, 1000'
Estimating Fees
// Using starknet.js
const { suggestedMaxFee } = await account.estimateInvokeFee({
contractAddress: oftAddress,
entrypoint: 'send',
calldata: [...],
});
// Add buffer for safety and use as a fee cap
const maxFee = suggestedMaxFee * 1.2n;
Common Issues
| Error | Cause | Solution |
|---|---|---|
| "Insufficient max fee" | Resource bounds too low | Increase fee cap (--max-fee) or resource bounds |
| "Insufficient balance" | Account underfunded | Add STRK/ETH |
| "Transaction reverted" | Contract logic error | Check calldata |
Key Starknet Concepts for LayerZero
Address encoding is critical for cross-chain peer verification.
Address Encoding for Cross-Chain
Bytes32 for Cross-VM Compatibility
LayerZero uses Bytes32 for addresses to support different address sizes:
// Starknet addresses (felt252) -> Bytes32
use lz_utils::bytes::{Bytes32, ContractAddressIntoBytes32};
let starknet_addr: ContractAddress = ...;
let as_bytes32: Bytes32 = starknet_addr.into();
// EVM addresses (20 bytes) come padded to 32 bytes
let evm_peer: Bytes32 = Bytes32 { value: 0x000000000000000000000000abcdef... };
Setting Peers
fn set_peer(ref self: ContractState, eid: u32, peer: Bytes32) {
self.ownable.assert_only_owner();
self.peers.write(eid, peer);
}
// When receiving, verify the peer
fn _lz_receive(ref self: ..., origin: Origin, ...) {
let expected_peer = self.peers.read(origin.src_eid);
assert(origin.sender == expected_peer, 'invalid peer');
// Process message
}
Key Takeaways
felt252andu256encoding affect how LayerZero represents amounts and addresses.- Storage and components replace inheritance; configuration lives in structured storage.
- Typed dispatchers provide safe cross-contract calls without dynamic dispatch.
- Multicall enables atomic configuration before opening pathways.
- Resource bounds and fee estimation require explicit handling on Starknet.
- Clear-then-execute prevents reentrancy during
lz_receive. - Bytes32 encoding standardizes peer addresses across chains.
Next Steps
- OApp Overview - Building OApps
- OFT Overview - Token transfers
- Technical Reference - Toolchain guide
- Troubleshooting - Common errors