Skip to main content
This article covers Stellar’s Soroban smart contract platform and the full LayerZero V2 protocol lifecycle from the perspective of a LayerZero developer. What you’ll learn:
  • Soroban’s WASM-based VM architecture, address types, and gas model
  • The complete message lifecycle: send, verify, receive, and recovery
  • Contract development model: proc macros, storage, TTL, and upgrades
  • Operations and security: ownership, delegation, and RBAC
  • Technical constraints specific to Stellar
If you’re new to Stellar, start with the Getting Started guide to understand the key differences from EVM before diving into the technical details.

VM Architecture

Soroban is Stellar’s smart contract runtime. Contracts are written in Rust, compiled to WebAssembly (WASM), and executed in a sandboxed VM.

Key Characteristics

Address Types

Stellar uses StrKey-encoded addresses with the structure: 1-byte version + 32-byte payload + 2-byte checksum (35 bytes total, base32-encoded to 56 characters):

Crosschain Address Format

LayerZero V2 standardizes all crosschain addresses as bytes32. Since G-addresses and C-addresses share the same 32-byte payload structure, LayerZero uses this payload directly as the bytes32 representation. The payload is guaranteed to be unique across both address types. When receiving crosschain messages, the protocol resolves the bytes32 back to a Stellar address using deterministic address resolution:
  • OApp and Composer addresses: The bytes32 value is interpreted directly as a contract ID (C-address).
  • Native drop and OFT receiver addresses: Contract-first detection is applied — if a contract exists at the address, it is treated as a C-address; otherwise, it is treated as a G-address (account).

XDR Encoding

Where EVM uses ABI encoding for function calls and configuration, Soroban uses XDR (External Data Representation). This applies to message library configuration (ULN configs, executor configs), cross-contract calls, and event data. The TypeScript SDK handles XDR encoding/decoding automatically.

Gas Model

Soroban uses a resource-based fee model rather than EVM-style gas: Fees are calculated based on resource consumption and a network-wide base fee multiplier. Unlike EVM, there is no concept of “gas price” that fluctuates with demand — fees are deterministic based on the resources declared upfront.
Soroban transactions must declare their resource limits upfront (CPU, memory, storage). If the transaction exceeds the declared limits, it fails. The Stellar CLI and SDK estimate these limits automatically when simulating transactions.

Message Lifecycle

Overview

Core Data Structures

MessagingParams

The parameters passed to the Endpoint’s send function:

Origin

Identifies the source of an inbound message:

MessagingFee

Fee breakdown for sending a message:

MessagingReceipt

Returned after a successful send:

Send Workflow

Step 1: Application Prepares Send

The OApp (or OFT) prepares the message and quotes the fee:

Step 2: Endpoint Send

The OApp calls the Endpoint’s send function:
  1. The OApp transfers the native fee to the Endpoint: native_token.transfer(fee_payer, endpoint, native_fee)
  2. If paying in ZRO, transfers ZRO fee similarly
  3. The Endpoint assigns the next outbound nonce and computes the GUID
  4. The Endpoint calls the configured send library (ULN-302)

Step 3: Message Library Processing

ULN-302 processes the outbound packet:
  1. Encodes the packet (header + payload)
  2. Computes the payload hash
  3. Calculates DVN, executor, and treasury fees
After the send library returns, the Endpoint emits the PacketSent event with the encoded packet and options. Send Events:

Verification Workflow

After the PacketSent event is emitted, off-chain DVNs observe the event and verify the message on the destination chain.

Step 1: DVN Submits Verification

Each configured DVN calls verify on the destination’s ULN-302:
The DVN authenticates via Soroban’s custom account contract (multisig with secp256k1 signatures).

Step 2: Threshold Check

ULN-302 checks whether enough DVNs have verified:
  • All required DVNs must verify
  • At least optional_dvn_threshold optional DVNs must verify
The verifiable function checks the current verification status without committing:

Step 3: Commit Verification

Once the threshold is met, anyone can call commit_verification (permissionless):
This calls endpoint.verify(...), which stores the payload hash and emits PacketVerified. Verification Events:

Receive Workflow

After verification, the Executor delivers the message to the destination OApp.

Step 1: Executor Calls lz_receive

The Executor calls lz_receive on the destination OApp:

Step 2: OApp Validates and Clears

The OApp’s lz_receive implementation (provided by the OAppReceiver trait’s default method):
  1. Validates executor: executor.require_auth()
  2. Validates sender: Checks origin.sender matches the configured peer for origin.src_eid
  3. Forwards value: If value != 0, transfers native token from executor to OApp
  4. Clears payload: Calls endpoint.clear(oapp, origin, oapp, guid, message) to mark the message as delivered
  5. Executes business logic: Calls __lz_receive(origin, guid, message, extra_data, executor, value) (your custom implementation)

Step 3: Application Logic

Your __lz_receive implementation processes the decoded message. For OFTs, this includes token crediting (unlock or mint), address resolution, and optional compose handling — see OFT Overview for details. Receive Events:

Step 4: Compose (Optional)

If the message includes a compose payload, the Endpoint stores it and the Executor triggers the compose call in a separate transaction:
  1. OApp calls endpoint.send_compose(from, to, guid, index, compose_msg)
  2. Executor calls composer.lz_compose(executor, from, guid, index, message, extra_data, value) on the target composer contract
  3. Composer processes the compose message (e.g., swap, stake, etc.)
Compose Events:

Recovery Operations

If a message fails to deliver or gets stuck, several recovery operations are available. The caller must be the receiving OApp itself or its registered delegate.

Skip

Skip the next expected inbound nonce without processing the message. The provided nonce must equal inbound_nonce + 1 — you cannot skip arbitrary nonces. Useful for ordered messaging when a message should be bypassed:

Clear

Remove a verified payload that hasn’t been delivered. The nonce in origin must be at or below inbound_nonce, and the hash derived from guid and message must match the stored payload hash:

Nilify

Mark a message as nil (void), preventing execution until re-verified. The supplied payload_hash must exactly match the currently stored value. For a verified, unexecuted nonce, pass its stored hash. To pre-emptively nilify a future nonce, the nonce must be greater than inbound_nonce but no greater than inbound_nonce + 256; pass None when no hash is currently stored. A nonce at or below inbound_nonce can be nilified only while it still has a stored payload hash:

Burn

Permanently burn a nonce. The nonce must be at or below inbound_nonce, and payload_hash must match a hash currently stored for that nonce. Unlike nilify, the removed hash cannot be restored by re-verification:
Recovery Operations Summary:

Contract Development Model

Trait Composition with Proc Macros

Soroban contracts use Rust’s proc macros instead of inheritance: Example:

Storage Model

Soroban contracts use a typed enum with the #[storage] macro to define storage layout:
The macro generates typed get, set, has, and remove methods for each variant.

Storage Tiers

TTL Management

All Soroban storage entries have a time-to-live. LayerZero contracts use the #[ttl_configurable] and #[ttl_extendable] macros to manage TTL automatically:
  • Instance storage: TTL extended automatically on every contract call via #[contract_impl]
  • Persistent storage: TTL extended automatically when entries are read or written
  • Temporary storage: Short-lived, no automatic extension
If a persistent entry’s TTL expires, it becomes archived. It still exists on the ledger but must be restored (with a fee) before it can be read. Ensure your operational processes monitor and extend TTLs for critical state.

Manual TTL Configuration

Contracts with #[ttl_configurable] allow adjusting TTL thresholds:

Monitoring TTL

For operational monitoring, check the TTL of critical entries:
Monitor persistent storage TTLs in production. If a peer mapping entry expires and gets archived, the OApp won’t be able to validate inbound messages from that chain until the entry is restored.

Contract Upgrades

LayerZero Soroban contracts support native upgrades via the #[upgradeable] macro:
Upgrade flow:
  1. Upload the new WASM to the network (but don’t create a contract instance)
  2. Call upgrade on the existing contract with the new WASM hash
  3. The contract code is replaced atomically
  4. Call migrate to clear the migration flag and optionally adjust state
Always test upgrades on testnet first. Verify that storage layout is compatible — adding new storage variants is safe, but removing or reordering existing ones is not.

Operations & Security

Ownership

LayerZero Soroban contracts support both single-step and 2-step ownership transfer:
  • Single-step: transfer_ownership(new_owner) — immediate, irreversible (will fail if a 2-step transfer is already pending)
  • 2-step (recommended):
    1. Current owner calls begin_ownership_transfer(new_owner, ttl) — proposes transfer with a TTL window (in ledgers, ~5s each)
    2. New owner calls accept_ownership() — confirms transfer within the TTL
    3. If not accepted within the TTL, the proposal expires and the original owner retains ownership
This prevents accidental ownership loss from typos or incorrect addresses.

Delegate

The delegate can manage endpoint configuration (message libraries, DVN config) without owner-level access:
Set to None to remove the delegate.

Role-Based Access Control

RoleBasedAccessControl adds OpenZeppelin-style role membership so administrative actions can be delegated without surrendering ownership. A vanilla #[lz_contract] #[oapp] contract exposes both layers automatically. Some contracts (e.g., SAC Manager) use RBAC with role-gated functions:

Technical Constraints

Pending Inbound Nonce Cap

LayerZero’s Stellar Endpoint uses an eager inbound nonce model with a bounded pending nonce list:
  • Maximum 256 pending nonces (PENDING_INBOUND_NONCE_MAX_LEN)
  • Nonces beyond inbound_nonce + 256 cannot be verified
  • Pending nonces drain automatically as they become consecutive

Reentrancy Prohibition

Soroban prohibits reentrancy — contracts cannot call themselves (directly or indirectly) in the same transaction. This is why LayerZero uses pull mode for message delivery: the executor calls OApp.lz_receive() directly, and the OApp calls endpoint.clear() internally to verify and clear the payload. This avoids the Endpoint -> OApp -> Endpoint reentrancy that would occur if the Endpoint delivered messages to the OApp directly.

Next Steps