Skip to main content
Version: Endpoint V2

LayerZero V2 OApp on Starknet

The Omnichain Application (OApp) standard provides the foundational building blocks for cross-chain messaging on Starknet. OApps can send arbitrary data to any supported chain and receive messages from other chains.

What is an OApp on Starknet?

An OApp on Starknet is a Cairo contract that:

  1. Integrates with LayerZero via the OAppCoreComponent
  2. Sends messages through the Endpoint's send function
  3. Receives messages by implementing the OAppHooks trait
  4. Manages peers (trusted remote OApps on other chains)

Differences from EVM OApps

AspectEVM (Solidity)Starknet (Cairo)
Base ContractOApp inheritanceOAppCoreComponent composition
Send Message_lzSend()_lz_send() via OAppSenderImpl
Receive Message_lzReceive() override_lz_receive via OAppHooks trait
Peer Storagemapping(uint32 => bytes32)Map<u32, Bytes32>
AuthorizationonlyOwner modifierassert_only_owner()
Options BuilderOptionsBuilder libraryByteArray encoding

Installation

Step 1: Install LayerZero Cairo Contracts

# Create your project directory
mkdir my-oapp-project && cd my-oapp-project

# Initialize npm and install LayerZero Starknet packages
npm init -y
npm install @layerzerolabs/protocol-starknet-v2

Step 2: Configure Scarb.toml

[package]
name = "my_oapp"
version = "0.1.0"
edition = "2024_07"

[dependencies]
starknet = "2.14.0"
openzeppelin = "2.0.0"
lz_utils = { path = "./node_modules/@layerzerolabs/protocol-starknet-v2/libs/lz_utils" }
layerzero = { path = "./node_modules/@layerzerolabs/protocol-starknet-v2/layerzero" }

[dev-dependencies]
snforge_std = "0.53.0"

[[target.starknet-contract]]
sierra = true
casm = true
Tool versions

Use Scarb 2.14.0 and Starknet Foundry 0.53.0. Mismatched versions can cause class hash mismatch errors during sncast declare.

Step 3: Create Project Structure

mkdir -p src tests
echo 'pub mod my_oapp;' > src/lib.cairo
my-oapp/
├── package.json
├── node_modules/@layerzerolabs/protocol-starknet-v2/
├── Scarb.toml
├── src/
│ ├── lib.cairo
│ └── my_oapp.cairo
└── tests/
└── test_my_oapp.cairo

lib.cairo

pub mod my_oapp;

Step 4: Configure snfoundry.toml

Create a snfoundry.toml with your account name and RPC URL. See Starknet Guidance for the full configuration reference and RPC version compatibility notes.

Deployment

Step 1: Build

scarb build

Build artifacts are generated in target/dev/ by default.

Step 2: Declare

sncast --account <ACCOUNT_NAME> declare \
--contract-name MyOApp \
--url <RPC_URL>

# Returns: class_hash = <CLASS_HASH>

Step 3: Deploy

sncast --account <ACCOUNT_NAME> deploy \
--class-hash <CLASS_HASH> \
--url <RPC_URL> \
--arguments '<ENDPOINT_ADDRESS>, <OWNER_ADDRESS>, <NATIVE_TOKEN_ADDRESS>'

# Constructor parameters:
# - endpoint: LayerZero Endpoint address
# - owner: Contract owner address
# - native_token: Fee payment token (STRK address)
Network flag

If you set url in snfoundry.toml, omit --network (sncast will reject it).

Using --arguments

The --arguments flag allows passing constructor arguments in a human-readable format. sncast automatically serializes them based on the contract's ABI. For more details, see Calldata Transformation.

Step 4: Verify

sncast verify \
--class-hash <CLASS_HASH> \
--contract-name MyOApp \
--verifier voyager \
--network sepolia \
--confirm-verification

For more verification options, see the Starknet Foundry verification guide.

Step 5: Configure

# Set peer for destination chain (e.g., Ethereum Mainnet eid=30101)
# For EVM address 0x1234567890abcdef1234567890abcdef12345678:
# - Pad to 32 bytes: 0x0000000000000000000000001234567890abcdef1234567890abcdef12345678
# - Split into u256 (low, high): low=0x90abcdef1234567890abcdef12345678, high=0x12345678
sncast --account <ACCOUNT_NAME> invoke \
--contract-address <YOUR_OAPP> \
--function set_peer \
--url <RPC_URL> \
--calldata 0x7595 0x90abcdef1234567890abcdef12345678 0x12345678
Bytes32 Encoding

Peer addresses are stored as Bytes32 (a struct containing a u256). For EVM addresses (20 bytes), left-pad with zeros to 32 bytes.

Calldata format for set_peer(eid: u32, peer: Bytes32):

  1. eid - endpoint ID as hex (e.g., 0x7595 = 30101 for Ethereum Mainnet)
  2. peer.value.low - lower 128 bits of the padded address
  3. peer.value.high - upper 128 bits of the padded address

Use --calldata with space-separated hex values (not --arguments) for complex types like Bytes32.


Working Example: Minimal OApp

A minimal OApp on Starknet:

#[starknet::contract]
pub mod MyOApp {
use layerzero::oapps::oapp::oapp_core::OAppCoreComponent;
use layerzero::common::structs::packet::Origin;
use lz_utils::bytes::Bytes32;
use openzeppelin::access::ownable::OwnableComponent;
use starknet::ContractAddress;

// Declare components
component!(path: OAppCoreComponent, storage: oapp_core, event: OAppCoreEvent);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);

// Embed OAppCore implementation (exposes external functions)
#[abi(embed_v0)]
impl OAppCoreImpl = OAppCoreComponent::OAppCoreImpl<ContractState>;
#[abi(embed_v0)]
impl ILayerZeroReceiverImpl = OAppCoreComponent::LayerZeroReceiverImpl<ContractState>;
#[abi(embed_v0)]
impl IOAppReceiverImpl = OAppCoreComponent::OAppReceiverImpl<ContractState>;
impl OAppCoreInternalImpl = OAppCoreComponent::InternalImpl<ContractState>;

// Embed Ownable implementation
#[abi(embed_v0)]
impl OwnableImpl = OwnableComponent::OwnableImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;

#[storage]
struct Storage {
#[substorage(v0)]
oapp_core: OAppCoreComponent::Storage,
#[substorage(v0)]
ownable: OwnableComponent::Storage,
// Your custom storage here
data: felt252,
}

#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
#[flat]
OAppCoreEvent: OAppCoreComponent::Event,
#[flat]
OwnableEvent: OwnableComponent::Event,
}

#[constructor]
fn constructor(
ref self: ContractState,
endpoint: ContractAddress,
owner: ContractAddress,
native_token: ContractAddress,
) {
// Initialize OAppCore with endpoint, owner (delegate), and native token
self.oapp_core.initializer(endpoint, owner, native_token);
self.ownable.initializer(owner);
}

// Implement OAppHooks to handle incoming messages
impl OAppHooks of OAppCoreComponent::OAppHooks<ContractState> {
fn _lz_receive(
ref self: OAppCoreComponent::ComponentState<ContractState>,
origin: Origin,
guid: Bytes32,
message: ByteArray,
executor: ContractAddress,
extra_data: ByteArray,
value: u256,
) {
// Your receive logic here
// Access contract state via get_contract_mut!
}
}
}

Required Components

OAppCoreComponent

The core LayerZero integration:

// Storage fields
pub struct Storage {
pub OAppCore_endpoint: ContractAddress, // LayerZero Endpoint
pub OAppCore_native_token: ContractAddress, // Fee payment token
pub OAppCore_peers: Map<u32, Bytes32>, // Trusted peers per chain
}

Provided Functions:

FunctionDescription
set_peer(eid, peer)Set trusted peer for a chain
get_peer(eid)Get peer address for a chain
set_delegate(delegate)Set configuration delegate
endpoint()Get Endpoint address

OwnableComponent

OpenZeppelin's ownership management:

// Provided Functions
owner() -> ContractAddress
transfer_ownership(new_owner)
renounce_ownership()

How OApp Messaging Works

Peer Configuration: Establishing Trust

Peers must be set bidirectionally for two OApps to communicate:

Setting a Peer

The OAppCoreComponent provides set_peer automatically when you embed OAppCoreImpl. You call it directly on your deployed contract:

sncast invoke --contract-address <YOUR_OAPP> --function set_peer --calldata <eid> <peer_low> <peer_high>

Internally, the component implements it as:

// Inside OAppCoreComponent::OAppCoreImpl (already embedded)
fn set_peer(ref self: ComponentState<TContractState>, eid: u32, peer: Bytes32) {
self._assert_only_owner();
self.OAppCore_peers.entry(eid).write(peer);
self.emit(PeerSet { eid, peer });
}

Peer Address Format

Peers are stored as Bytes32 for cross-VM compatibility:

// Starknet address → Bytes32
let starknet_peer: Bytes32 = starknet_address.into();

// EVM address (20 bytes) → Bytes32 (left-padded with zeros)
let evm_peer: Bytes32 = Bytes32 {
value: 0x000000000000000000000000_ABCDEF1234567890ABCDEF1234567890ABCDEF12
};

Bidirectional Setup

Chain A (Starknet)              Chain B (EVM)
┌─────────────────┐ ┌─────────────────┐
│ OApp A │ │ OApp B │
│ │ │ │
│ peers[B] = 0xB │◄──────────►│ peers[A] = 0xA │
└─────────────────┘ └─────────────────┘

Both sides must set peers before messages can flow.


Sending Messages

Step 1: Quote the Fee

use layerzero::oapps::oapp::oapp_core::OAppCoreComponent;
use layerzero::common::structs::messaging::{MessagingParams, MessagingFee};

fn quote(
self: @ContractState,
dst_eid: u32,
message: ByteArray,
options: ByteArray,
pay_in_lz_token: bool,
) -> MessagingFee {
let oapp_core = get_dep_component!(self, OAppCore);
OAppCoreComponent::OAppSenderImpl::_quote(
oapp_core,
dst_eid,
message,
options,
pay_in_lz_token,
)
}

Step 2: Build Options

Options specify execution parameters on the destination chain:

use lz_utils::byte_array_ext::byte_array_ext::ByteArrayTraitExt;

const EXECUTOR_WORKER_ID: u8 = 1;
const OPTION_TYPE_LZRECEIVE: u8 = 1;

/// Build executor options for lz_receive with specified gas limit
fn build_options(gas_limit: u128) -> ByteArray {
// Build params (gas only, no native value)
let mut params: ByteArray = Default::default();
params.append_u128(gas_limit);

// Build options with Type 3 format
let mut options: ByteArray = Default::default();
options.append_u16(3); // Option type 3 header
options.append_u8(EXECUTOR_WORKER_ID); // Worker ID (Executor = 1)
options.append_u16(params.len().try_into().unwrap() + 1); // Length (params + option type)
options.append_u8(OPTION_TYPE_LZRECEIVE); // LzReceive option type
options.append(@params); // Gas limit (16 bytes)

options
}

Step 3: Send the Message

The _lz_send function handles fee payment internally. It expects the caller to have approved the OApp contract (not the endpoint) to spend their tokens. The function will:

  1. Transfer tokens from caller to the contract
  2. Approve the endpoint to spend the tokens
  3. Send the message via the endpoint
fn send(
ref self: ContractState,
caller: ContractAddress,
dst_eid: u32,
message: ByteArray,
options: ByteArray,
fee: MessagingFee,
refund_address: ContractAddress,
) -> MessageReceipt {
// _lz_send handles token transfer and endpoint approval internally
let mut oapp_core = get_dep_component_mut!(ref self, OAppCore);
OAppCoreComponent::OAppSenderImpl::_lz_send(
ref oapp_core,
caller, // Caller who approved this contract for fee payment
dst_eid,
message,
options,
fee,
refund_address,
)
}

Complete Send Example

#[external(v0)]
fn send_message(
ref self: ContractState,
dst_eid: u32,
message: ByteArray,
) {
let caller = get_caller_address();

// Build options (200,000 gas for lz_receive)
let options = build_options(200000);

// Quote fee
let fee = self.quote(dst_eid, message.clone(), options.clone(), false);

// IMPORTANT: Caller must have approved THIS CONTRACT (not the endpoint)
// to spend native_fee amount of the native token BEFORE calling this function.
// The _lz_send function will:
// 1. transfer_from(caller, this_contract, fee)
// 2. approve(endpoint, fee)
// 3. endpoint.send(...)

// Send message - _lz_send handles all token transfers internally
let mut oapp_core = get_dep_component_mut!(ref self, OAppCore);
let receipt = OAppCoreComponent::OAppSenderImpl::_lz_send(
ref oapp_core,
caller,
dst_eid,
message,
options,
fee,
caller, // refund_address
);

// Emit event with guid for tracking
self.emit(MessageSent { guid: receipt.guid, dst_eid });
}

Receiving Messages

Implementing OAppHooks

The OAppHooks trait defines how your OApp handles incoming messages:

impl OAppHooks of OAppCoreComponent::OAppHooks<ContractState> {
fn _lz_receive(
ref self: OAppCoreComponent::ComponentState<ContractState>,
origin: Origin, // Source chain info
guid: Bytes32, // Message unique ID
message: ByteArray, // Your payload
executor: ContractAddress, // Who executed the message
extra_data: ByteArray, // Additional data from executor
value: u256, // Native tokens forwarded
) {
// 1. Decode your message payload
let (action, data) = decode_message(@message);

// 2. Access contract state if needed
let mut contract = self.get_contract_mut();

// 3. Execute your logic
match action {
Action::Store => {
contract.data.write(data);
},
Action::Execute => {
// Call other contracts, update state, etc.
},
}

// 4. Emit events for tracking
contract.emit(MessageReceived {
guid,
src_eid: origin.src_eid,
sender: origin.sender,
});
}
}

Origin Verification

The OAppCore ensures only the Endpoint can call lz_receive and that the sender matches the trusted peer:

// Inside OAppCoreComponent::LayerZeroReceiverImpl
fn lz_receive(
ref self: ComponentState<TContractState>,
origin: Origin,
guid: Bytes32,
message: ByteArray,
executor: ContractAddress,
extra_data: ByteArray,
value: u256,
) {
// Only the Endpoint can call lz_receive
self._assert_only_endpoint();

// Verify peer is set and matches sender
let expected_peer = self._get_peer_or_revert(origin.src_eid);
assert_with_byte_array(
expected_peer == origin.sender,
err_only_peer(origin.src_eid, origin.sender),
);

// Call your _lz_receive implementation (via OAppHooks trait)
self._lz_receive(origin, guid, message, executor, extra_data, value);
}

Events

Standard OApp Events

// Peer configuration changed (emitted by OAppCoreComponent)
#[derive(Drop, starknet::Event)]
pub struct PeerSet {
#[key]
pub eid: u32,
#[key]
pub peer: Bytes32,
}
DelegateSet Event

The DelegateSet event is emitted by the Endpoint contract (not the OApp) when set_delegate is called. Listen for it on the Endpoint address, not your OApp.

Custom Events

Add your own events for tracking:

#[derive(Drop, starknet::Event)]
pub struct MessageSent {
#[key]
pub guid: Bytes32,
pub dst_eid: u32,
}

#[derive(Drop, starknet::Event)]
pub struct MessageReceived {
#[key]
pub guid: Bytes32,
pub src_eid: u32,
pub sender: Bytes32,
}

Network Addresses

NetworkResourceAddress
Starknet SepoliaLayerZero Endpoint0x0316d70a6e0445a58c486215fac8ead48d3db985acde27efca9130da4c675878
Starknet SepoliaSTRK Token0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d
Starknet MainnetLayerZero Endpoint0x524e065abff21d225fb7b28f26ec2f48314ace6094bc085f0a7cf1dc2660f68
Starknet MainnetSTRK Token0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d

Next Steps