The Omnichain Application (OApp) standard provides the foundational building blocks for crosschain 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
send function
- Receives messages by implementing the
OAppHooks trait
- 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
[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 versionsUse 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
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
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 flagIf you set url in snfoundry.toml, omit --network (sncast will reject it).
Using —argumentsThe --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.
# 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 EncodingPeer 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 address
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:
| 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 });
}
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,
}
DelegateSet EventThe 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