LayerZero V2 Aptos Move OApp
The OApp Standard provides developers with a generic message passing interface to send and receive arbitrary pieces of data between contracts existing on different blockchain networks.
This interface can easily be extended to include anything from specific financial logic in a DeFi application, a voting mechanism in a DAO, and broadly any smart contract use case.
Below is an overview of how the Aptos Move OApp Standard aligns with the LayerZero V2 OApp Contract Standard on EVM and/or Solana:
oapp::oapp
(main OApp interface and example usage)oapp::oapp_compose
(handles composable message logic)oapp::oapp_core
(contains core utilities such as sending messages, quoting fees, setting config/delegates/peers)oapp::oapp_receive
(handles low-level message reception logic)oapp::oapp_store
(internal persistent storage and admin/delegate logic)
This structure replicates in Aptos Move the same interface and flow you would expect from an OApp-based contract on EVM or Solana using LayerZero V2.
Overview
A LayerZero OApp (Omnichain Application) is a contract/module that can:
Send and Receive messages across chains
Optionally Compose messages (which is a feature to re-enter the OApp with new logic after a message is processed)
Quote fees for sending cross-chain messages
Manage Admin and Delegate roles for secure cross-chain interactions
In Move, these responsibilities are broken out into the above modules to keep the code well-organized.
Key Components
Sending Messages: Uses the
lz_send
function fromoapp::oapp_core
.Quoting Fees: Uses
lz_quote
fromoapp::oapp_core
.Receiving Messages: Handled by
lz_receive
inoapp::oapp_receive
and overridden into your OApp’s logic.Composing Messages: Enabled by
lz_compose
inoapp::oapp_compose
.Admin/Delegate Permissions: Managed through
oapp::oapp_core
and stored inoapp::oapp_store
.
Main OApp Module (oapp::oapp
)
The main OApp Module defines entry functions that an application developer can call (for example, to send or quote cross-chain messages).
This contract can house your custom logic for receiving messages (though the base code is handled in oapp_receive
, you can add extra handling via lz_receive_impl
).
module oapp::oapp {
use std::signer::address_of;
use std::primary_fungible_store;
use std::option::{self, Option};
use endpoint_v2_common::bytes32::Bytes32;
use oapp::oapp_core::{combine_options, lz_quote, lz_send, refund_fees};
use oapp::oapp_store::OAPP_ADDRESS;
const STANDARD_MESSAGE_TYPE: u16 = 1;
/// An example "send" entry function for cross-chain messages.
public entry fun example_message_sender(
account: &signer,
dst_eid: u32,
message: vector<u8>,
extra_options: vector<u8>,
native_fee: u64,
) {
let sender = address_of(account);
// Withdraw fees
let native_metadata = object::address_to_object<Metadata>(@native_token_metadata_address);
let native_fee_fa = primary_fungible_store::withdraw(account, native_metadata, native_fee);
let zro_fee_fa = option::none();
// Build + send the message
lz_send(
dst_eid,
message,
combine_options(dst_eid, STANDARD_MESSAGE_TYPE, extra_options),
&mut native_fee_fa,
&mut zro_fee_fa,
);
// Refund any unused fees to the user
refund_fees(sender, native_fee_fa, zro_fee_fa);
}
#[view]
/// Quoting the fees for sending a cross-chain message
public fun example_message_quoter(
dst_eid: u32,
message: vector<u8>,
extra_options: vector<u8>,
): (u64, u64) {
let options = combine_options(dst_eid, STANDARD_MESSAGE_TYPE, extra_options);
lz_quote(dst_eid, message, options, false)
}
public(friend) fun lz_receive_impl(
_src_eid: u32,
_sender: Bytes32,
_nonce: u64,
_guid: Bytes32,
_message: vector<u8>,
_extra_data: vector<u8>,
receive_value: Option<FungibleAsset>,
) {
// Deposit the received token, if any
option::destroy(receive_value, |value| primary_fungible_store::deposit(OAPP_ADDRESS(), value));
// TODO: OApp developer can add custom logic for incoming messages here.
}
...
}
Key Points
example_message_sender
is a reference entry function. Developers can create their own, based on the same pattern, to send a message cross-chain.lz_receive_impl
is the function that your OApp can override/extend with your custom "on-message" logic.
By default, this module imports functions from oapp::oapp_core
and oapp::oapp_store
to make its job easier.
OApp Core Module (oapp::oapp_core
)
The Core Module provides lower-level helper functions to send messages, quote fees, manage OApp configuration, handle admin or delegate actions, and keep track of enforced configuration options.
module oapp::oapp_core {
use endpoint_v2::endpoint;
use endpoint_v2_common::bytes32::Bytes32;
use std::option::{self, Option};
friend oapp::oapp;
/// Sends a cross-chain message.
public(friend) fun lz_send(
dst_eid: u32,
message: vector<u8>,
options: vector<u8>,
native_fee: &mut FungibleAsset,
zro_fee: &mut Option<FungibleAsset>,
): MessagingReceipt {
endpoint::send(&oapp_store::call_ref(), dst_eid, get_peer_bytes32(dst_eid), message, options, native_fee, zro_fee)
}
#[view]
/// Quotes the cost of a cross-chain message in both native & ZRO tokens.
public fun lz_quote(
dst_eid: u32,
message: vector<u8>,
options: vector<u8>,
pay_in_zro: bool,
): (u64, u64) {
endpoint::quote(OAPP_ADDRESS(), dst_eid, get_peer_bytes32(dst_eid), message, options, pay_in_zro)
}
...
}
lz_send
: Calls the underlying LayerZero Endpoint to perform cross-chain message sending.lz_quote
: Returns the quote for fees needed to send the message in the native gas token or ZRO if enabled.Peer Management: The concept of peers (i.e., the paired OApp addresses) is captured by
set_peer(...)
,has_peer(...)
, etc. per blockchain pathway (i.e., from Aptos to ETH).Admin & Delegate: Functions like
transfer_admin
,set_delegate
,assert_authorized
, etc. manage who can update the OApp configuration or call certain restricted functions.Enforced Options: By default, the system can enforce specific message options (like certain gas limits, native gas drops, etc.) for sending to specific destination pathways. This is done via
get_enforced_options
andcombine_options
.
OApp Receive Module (oapp::oapp_receive
)
When a cross-chain message arrives on Aptos, the OApp's configured Executor will route the call into this module’s lz_receive
or lz_receive_with_value
.
This module then calls lz_receive_impl
in your main oapp::oapp
(or whichever module is designated).
module oapp::oapp_receive {
use endpoint_v2::endpoint;
/// Main entry for receiving a cross-chain message.
public entry fun lz_receive(
src_eid: u32,
sender: vector<u8>,
nonce: u64,
guid: vector<u8>,
message: vector<u8>,
extra_data: vector<u8>,
) {
lz_receive_with_value(
src_eid,
sender,
nonce,
wrap_guid(to_bytes32(guid)),
message,
extra_data,
option::none(),
)
}
/// The actual function that can carry a token value
public fun lz_receive_with_value(
src_eid: u32,
sender: vector<u8>,
nonce: u64,
wrapped_guid: WrappedGuid,
message: vector<u8>,
extra_data: vector<u8>,
value: Option<FungibleAsset>,
) {
// Validation, clearing, then calls your custom logic
endpoint::clear(&oapp_store::call_ref(), src_eid, to_bytes32(sender), nonce, wrapped_guid, message);
lz_receive_impl(
src_eid,
to_bytes32(sender),
nonce,
get_guid_from_wrapped(&wrapped_guid),
message,
extra_data,
value,
);
}
}
This means that:
The configured Executor contract on Aptos calls
lz_receive(...)
on your OApp.The message is checked to see if it was sent from an authorized peer (i.e. checking if
sender
is one of your OApp’s configured peers).The function
lz_receive_impl
is invoked from your main OApp module to perform any final business logic.
Compose Module (oapp::oapp_compose
)
"Compose" is a LayerZero feature that allows an OApp to schedule a subsequent call to itself after a message is processed.
In EVM, this is typically invoked via specialized calls to the Endpoint contract in the child OApp's lzReceive implementation, and delivered to a contract which implements ILayerZeroComposer.sol
.
In Aptos Move, oapp::oapp_compose
includes the logic to handle the composition of messages after they are cleared or to initiate them from the local OApp.
module oapp::oapp_compose {
public entry fun lz_compose(
from: address,
guid: vector<u8>,
index: u16,
message: vector<u8>,
extra_data: vector<u8>,
) {
endpoint::clear_compose(&oapp_store::call_ref(), from, wrap_guid_and_index(guid, index), message);
lz_compose_impl(
from,
to_bytes32(guid),
index,
message,
extra_data,
option::none(),
)
}
public fun lz_compose_with_value(
from: address,
guid_and_index: WrappedGuidAndIndex,
message: vector<u8>,
extra_data: vector<u8>,
value: Option<FungibleAsset>,
) {
// Similar logic, but includes the possibility of receiving a token in the compose
endpoint::clear_compose(&oapp_store::call_ref(), from, guid_and_index, message);
lz_compose_impl(from, guid, index, message, extra_data, value);
}
// Developer can override or fill in the body of lz_compose_impl with custom logic
}
In typical OApp implementations, you will only need to implement lz_compose_impl
if your OApp truly needs the advanced external call style logic after a cross-chain message has been received.
Internal Store Module (oapp::oapp_store
)
The internal store manages the global OApp state:
The OApp’s own address
The current Admin and Delegate addresses
A table of recognized Peers (paired addresses from other chains)
A table of enforced messaging options
module oapp::oapp_store {
struct OAppStore has key {
contract_signer: ContractSigner,
admin: address,
peers: Table<u32, Bytes32>,
delegate: address,
enforced_options: Table<EnforcedOptionsKey, vector<u8>>,
}
public(friend) fun get_admin(): address acquires OAppStore {
store().admin
}
public(friend) fun has_peer(eid: u32): bool acquires OAppStore {
table::contains(&store().peers, eid)
}
public(friend) fun set_peer(eid: u32, peer: Bytes32) acquires OAppStore {
table::upsert(&mut store_mut().peers, eid, peer)
}
...
}
On Aptos, you typically store data via move_to<T>(account, T { ... })
. This module sets up a global OAppStore
resource at @oapp
.
Functions like has_peer()
, set_peer()
, get_delegate()
, etc., let the other modules read and write data in a structured manner.
Putting It All Together
Initialization
On "init", the modules are registered with the
endpoint_v2
contract.The OApp store (
oapp::oapp_store::OAppStore
) is created at the address@oapp
.
Configuration
You set up your Admin address and optional Delegate if you want certain calls (e.g.
set_send_library
,skip
,burn
, ornilify
) to be callable by someone other than the admin.You set peers by calling
set_peer(account, remote_eid, remote_peer_address)
.
Sending a Message
Call your custom send function (like
example_message_sender
) from your main OApp module, which internally callslz_send
.Under the hood, the endpoint collects the message, your fees, and orchestrates cross-chain delivery.
Receiving a Message
The LayerZero Executor calls
oapp::oapp_receive::lz_receive
This function automatically calls
lz_receive_impl
in youroapp::oapp
.You handle the message payload or any FungibleAsset that might have come along with it.
Optional: Composing
If you want advanced functionality that re-calls the OApp after clearing, implement
lz_compose_impl
inoapp::oapp_compose
.Typically only needed for specialized re-entrancy or bridging flows.
Customizing for Your Own OApp
Rename your main modules if desired (e.g., from
oapp::oapp
tooapp::my_app
). Update the friend usage accordingly.Implement your own send/receive logic in
oapp::oapp
entry functions.Override
lz_receive_impl
to process the cross-chain message data (e.g., parse the vector bytes).Implement or skip the
lz_compose_impl
inoapp_compose
if your OApp doesn’t need composition logic.Manage your OApp’s admin and delegate roles carefully. The admin can set local storage options (like peers), while the delegate can call endpoint-level changes (like DVNs, Executors, Message Libraries).
Conclusion
The Aptos Move OApp Standard mirrors the LayerZero V2 OApp Contract Standard on EVM and Solana by:
Splitting cross-chain responsibilities into send, receive, and optional compose modules.
Offering a straightforward pattern for quoting fees, paying them, and optionally paying them in the ZRO token.
Enforcing the same security patterns around admin/delegates, ensuring that the correct roles handle the correct privileges.
Providing a strong separation of concerns in well-structured modules to keep your OApp’s logic clean and maintainable.
Use these modules as your foundation for building powerful, omnichain Move applications on Aptos with the same design concepts you would expect from a LayerZero V2 OApp on other chains.