Skip to main content
Version: Endpoint V2 Docs

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.

OApp Example OApp Example

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:

  1. oapp::oapp (main OApp interface and example usage)

  2. oapp::oapp_compose (handles composable message logic)

  3. oapp::oapp_core (contains core utilities such as sending messages, quoting fees, setting config/delegates/peers)

  4. oapp::oapp_receive (handles low-level message reception logic)

  5. 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 from oapp::oapp_core.

  • Quoting Fees: Uses lz_quote from oapp::oapp_core.

  • Receiving Messages: Handled by lz_receive in oapp::oapp_receive and overridden into your OApp’s logic.

  • Composing Messages: Enabled by lz_compose in oapp::oapp_compose.

  • Admin/Delegate Permissions: Managed through oapp::oapp_core and stored in oapp::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 and combine_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

  1. 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.

  2. Configuration

    • You set up your Admin address and optional Delegate if you want certain calls (e.g. set_send_library, skip, burn, or nilify) to be callable by someone other than the admin.

    • You set peers by calling set_peer(account, remote_eid, remote_peer_address).

  3. Sending a Message

    • Call your custom send function (like example_message_sender) from your main OApp module, which internally calls lz_send.

    • Under the hood, the endpoint collects the message, your fees, and orchestrates cross-chain delivery.

  4. Receiving a Message

    • The LayerZero Executor calls oapp::oapp_receive::lz_receive

    • This function automatically calls lz_receive_impl in your oapp::oapp.

    • You handle the message payload or any FungibleAsset that might have come along with it.

  5. Optional: Composing

    • If you want advanced functionality that re-calls the OApp after clearing, implement lz_compose_impl in oapp::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 to oapp::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 in oapp_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.