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:
- Integrates with LayerZero via the OAppCoreComponent
- Sends messages through the Endpoint's
sendfunction - Receives messages by implementing the
OAppHookstrait - Manages peers (trusted remote OApps on other chains)
Differences from EVM OApps
| Aspect | EVM (Solidity) | Starknet (Cairo) |
|---|---|---|
| Base Contract | OApp inheritance | OAppCoreComponent composition |
| Send Message | _lzSend() | _lz_send() via OAppSenderImpl |
| Receive Message | _lzReceive() override | _lz_receive via OAppHooks trait |
| Peer Storage | mapping(uint32 => bytes32) | Map<u32, Bytes32> |
| Authorization | onlyOwner modifier | assert_only_owner() |
| Options Builder | OptionsBuilder library | ByteArray 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
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)
If you set url in snfoundry.toml, omit --network (sncast will reject it).
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
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):
eid- endpoint ID as hex (e.g.,0x7595= 30101 for Ethereum Mainnet)peer.value.low- lower 128 bits of the padded addresspeer.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:
| Function | Description |
|---|---|
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:
- Transfer tokens from caller to the contract
- Approve the endpoint to spend the tokens
- 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,
}
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
| Network | Resource | Address |
|---|---|---|
| Starknet Sepolia | LayerZero Endpoint | 0x0316d70a6e0445a58c486215fac8ead48d3db985acde27efca9130da4c675878 |
| Starknet Sepolia | STRK Token | 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d |
| Starknet Mainnet | LayerZero Endpoint | 0x524e065abff21d225fb7b28f26ec2f48314ace6094bc085f0a7cf1dc2660f68 |
| Starknet Mainnet | STRK Token | 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d |
Next Steps
- OFT Overview - Token transfers
- Protocol Overview - Message lifecycle
- Configuration Guide - DVN setup
- Troubleshooting - Common errors