Skip to main content
Version: Endpoint V2

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 felt252 type system and u256 representation
  • 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
tip

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 u64 for 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_receive on the destination OApp
Complete Protocol Details

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:

EVMStarknet
interface.function{value: x}(args)dispatcher.function(args)
Dynamic dispatch via addressTyped dispatcher
abi.encode/decodeAutomatic 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:

SolidityCairo
contract A is B, Ccomponent!(path: B, ...)
overrideTrait impl
Diamond problemNo 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

ResourceDescription
L1 GasCost for DA on Ethereum L1
L2 GasCompute cost on Starknet
L1 Data GasCalldata 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

ErrorCauseSolution
"Insufficient max fee"Resource bounds too lowIncrease fee cap (--max-fee) or resource bounds
"Insufficient balance"Account underfundedAdd STRK/ETH
"Transaction reverted"Contract logic errorCheck 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

  • felt252 and u256 encoding 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