Skip to main content
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. How the data is interpreted and what actions it triggers depend on the specific OApp implementation.

What is an OApp on Stellar?

An Omnichain Application (OApp) on Stellar is a Soroban smart contract that can send and receive crosschain messages via the LayerZero protocol. OApps serve as the base for all LayerZero integrations on Stellar, including OFTs.

Differences from EVM OApps

Build a Minimal OApp

Follow these steps to set up your project, configure dependencies, and implement a minimal OApp contract.
For a complete working example, see the Counter OApp in the LayerZero monorepo.

Prerequisites

Step 1: Set Up Your Project

Create a new Soroban project:

Step 2: Configure Dependencies

Add LayerZero dependencies to your Cargo.toml:
Check the OApp contracts, protocol contracts on LayerZero GitHub for the latest Stellar contract packages.

Step 3: Project Structure

Organize your contract:

Step 4: Implement the Contract

Here’s a minimal OApp that sends and receives crosschain messages:

OApp Components

The #[oapp] macro generates implementations for these traits: You must always implement: LzReceiveInternal — this is your custom receive logic. To provide a custom implementation for any generated trait, use:

How OApp Messaging Works

Peer Configuration

Before sending or receiving messages, configure the trusted peer address for each remote chain: The set_peer function requires the contract Owner.
Peers are stored in persistent storage (keyed by endpoint ID) and validated on every inbound message.
You must set peers on both sides of the connection. If chain A’s OApp sets chain B as a peer, chain B’s OApp must also set chain A as a peer. Messages from unregistered peers are rejected.

Message Flow

Send Flow

Receive Flow

The OApp is the entry point, not the Endpoint. This avoids reentrancy issues and supports ABA messaging patterns.

Sending Messages

Sending a crosschain message involves four steps:

Step 1: Encode Your Message

Structure your message payload. LayerZero transports raw bytes — how you encode them depends on your application:

Step 2: Build Execution Options

Options specify how the message should be executed on the destination chain (gas limit, native value, etc.):

Step 3: Quote the Fee

Step 4: Send the Message

The FeePayer enum tracks whether the payer has already been authorized, preventing duplicate require_auth() calls.

Receiving Messages

When a message arrives, the lz_receive flow (provided by the OAppReceiver trait’s default implementation) handles validation and routing:
  1. Executor authenticates: executor.require_auth()
  2. Peer validation: Asserts origin.sender matches the configured peer for origin.src_eid
  3. Value forwarding: Transfers native token from executor to OApp if value != 0
  4. Payload clearing: Calls endpoint.clear() to mark the message as delivered
  5. Your logic: Calls __lz_receive() with the decoded message

Message Inspection

Optionally, set an external inspector contract to validate outbound messages before they’re sent:
The inspector implements the IOAppMsgInspector trait:
The inspector can reject a message by returning false or by panicking. Note: the OFT standard ignores the return value and relies on panics for rejection — if building a custom OApp, you can check the boolean return in your send logic.
The set_msg_inspector function is available on OFT contracts. If building a custom OApp, you can implement message inspection in your send logic directly.

Deployment

Step 1: Build the Contract

This compiles your contract to WASM at target/wasm32v1-none/release/my_oapp.wasm.

Step 2: Deploy to Testnet

The constructor arguments are passed after --. The contract is deployed and initialized atomically.

Step 3: Configure Peers

After deployment, set the peer addresses for each remote chain:
The Stellar CLI (v25.1.0) has a known bug where Option<BytesN<32>> arguments (like --peer) are always set to None regardless of the format provided. If the CLI set_peer command does not work, use the Stellar JavaScript SDK as a workaround:

Step 4: Change Delegate (Optional)

The delegate is set during deployment via the constructor’s delegate parameter. The delegate is the address authorized to call endpoint configuration functions (set_config, set_send_library, set_receive_library) on behalf of your OApp. To change the delegate after deployment:

Step 5: Set Message Libraries (Optional)

Configure custom send and receive libraries if you don’t want the default. The new_lib parameter is Option<Address> — pass None to reset to the default library:

Step 6: Set Enforced Options (Optional)

Optionally, configure enforced execution options for each destination:
See Message Execution Options for how to build the options bytes for worker configuration (gas limits, native value, etc.).

Core Methods

These are the primary functions your OApp will use to send and receive crosschain messages.

__quote()

Estimates the fee required to send a crosschain message without actually sending it. Returns a MessagingFee containing the native and ZRO fee breakdown. When to use: Before sending to determine the required fee, or to display estimated costs to users.

__lz_send()

Sends a crosschain message to a destination chain. Internally transfers the fee to the Endpoint, looks up the peer for the destination, and calls the Endpoint’s send function. Key parameters:
  • dst_eid: Destination chain endpoint ID
  • message: Your encoded payload (raw bytes)
  • options: Execution parameters (gas limits, native value)
  • fee_payer: FeePayer::Unverified(addr) or FeePayer::Verified(addr)
  • fee: The MessagingFee from __quote()
  • refund_address: Where to send excess fees
FeePayer enum:
  • FeePayer::Unverified(addr) — Safe default. __lz_send will call addr.require_auth().
  • FeePayer::Verified(addr) — Use when the caller has already called require_auth() to avoid duplicate authorization in the Soroban auth tree.

lz_receive()

Processes incoming messages delivered by the Executor. The default implementation (provided by OAppReceiver) performs validation, payload clearing, and delegates to your __lz_receive() implementation. Your implementation: Implement LzReceiveInternal with your custom business logic. The base validation (executor auth, peer check, payload clearing) is handled before your code runs.

Message Encoding

Your OApp is responsible for encoding and decoding message payloads. LayerZero transports raw bytes — how you structure them depends on your application. Key principles:
  • Use consistent byte order (big-endian recommended for cross-VM compatibility with EVM)
  • Use fixed-width fields where possible for deterministic parsing
  • Test encoding/decoding on both source and destination chains

Configuration

Configuration functions have different authorization requirements:

Events

Configuring Remote Chains to Send to Stellar

When configuring OApps on other chains (e.g., EVM, Solana) to send messages to Stellar, follow standard LayerZero configuration with these Stellar-specific details:

1. Use Payload as Peer

Stellar addresses use StrKey encoding: 1-byte version + 32-byte payload + 2-byte CRC16 checksum. LayerZero uses the 32-byte payload (contract ID hash) as the bytes32 peer address.
To extract the 32-byte payload from a C-address, decode the base32 StrKey and take bytes 1–32 (skipping the version byte and CRC16 checksum).

2. Set Enforced Options for Stellar Destination

Configure minimum execution options for messages sent to Stellar:

3. Standard DVN Configuration

DVN configuration on remote chains follows standard LayerZero patterns — no Stellar-specific changes needed. See the platform-specific implementation guides for your source chain’s configuration.

Best Practices

Configure Security Before Setting Peers

Configure DVNs, Executor, and enforced options before setting peers. Setting a peer opens the messaging pathway for that remote chain.

Use FeePayer::Verified When Appropriate

If your OApp’s send function already calls require_auth() on the caller, pass FeePayer::Verified(caller) to __lz_send() to avoid a duplicate authorization node in Soroban’s auth tree.

Monitor TTL for Persistent Storage

Peer mappings are stored in persistent storage with TTL. LayerZero contracts automatically extend TTL when entries are accessed, but low-activity OApps should monitor and extend TTL for critical state to prevent archival.

Test Bidirectional Messaging

Always test both sending and receiving on testnet before deploying to mainnet. Verify that messages are correctly encoded on the source chain and decoded on the destination chain.

Security Considerations

Critical Validations

  • Peer verification: Every inbound message is checked against the configured peer. Only messages from the registered peer address for a given source chain are accepted.
  • Payload hash verification: The endpoint.clear() operation verifies the message against the stored payload hash, ensuring the delivered message matches what was verified by DVNs. If your contract uses the #[oapp] macro, this is already handled for you.

Common Pitfalls

  • Forgetting to set peers on both sides of the connection
  • Not setting enforced options, resulting in failed execution on the destination chain
  • Using FeePayer::Unverified when the caller is already authorized, causing auth tree issues
  • Not monitoring TTL for low-activity contracts, leading to archived storage entries

Next Steps