- 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
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 asbytes32. 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
bytes32value 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.
Message Lifecycle
Overview
Core Data Structures
MessagingParams
The parameters passed to the Endpoint’ssend 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’ssend function:
- The OApp transfers the native fee to the Endpoint:
native_token.transfer(fee_payer, endpoint, native_fee) - If paying in ZRO, transfers ZRO fee similarly
- The Endpoint assigns the next outbound nonce and computes the GUID
- The Endpoint calls the configured send library (ULN-302)
Step 3: Message Library Processing
ULN-302 processes the outbound packet:- Encodes the packet (header + payload)
- Computes the payload hash
- Calculates DVN, executor, and treasury fees
PacketSent event with the encoded packet and options.
Send Events:
Verification Workflow
After thePacketSent 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 callsverify on the destination’s ULN-302:
Step 2: Threshold Check
ULN-302 checks whether enough DVNs have verified:- All required DVNs must verify
- At least
optional_dvn_thresholdoptional DVNs must verify
verifiable function checks the current verification status without committing:
Step 3: Commit Verification
Once the threshold is met, anyone can callcommit_verification (permissionless):
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 callslz_receive on the destination OApp:
Step 2: OApp Validates and Clears
The OApp’slz_receive implementation (provided by the OAppReceiver trait’s default method):
- Validates executor:
executor.require_auth() - Validates sender: Checks
origin.sendermatches the configured peer fororigin.src_eid - Forwards value: If
value != 0, transfers native token from executor to OApp - Clears payload: Calls
endpoint.clear(oapp, origin, oapp, guid, message)to mark the message as delivered - 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:- OApp calls
endpoint.send_compose(from, to, guid, index, compose_msg) - Executor calls
composer.lz_compose(executor, from, guid, index, message, extra_data, value)on the target composer contract - Composer processes the compose message (e.g., swap, stake, etc.)
Recovery Operations
If a message fails to deliver or gets stuck, several recovery operations are available. Thecaller 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 equalinbound_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 inorigin 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 suppliedpayload_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 belowinbound_nonce, and payload_hash must match a hash currently stored for that nonce. Unlike nilify, the removed hash cannot be restored by re-verification:
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:
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
Manual TTL Configuration
Contracts with#[ttl_configurable] allow adjusting TTL thresholds:
Monitoring TTL
For operational monitoring, check the TTL of critical entries:Contract Upgrades
LayerZero Soroban contracts support native upgrades via the#[upgradeable] macro:
- Upload the new WASM to the network (but don’t create a contract instance)
- Call
upgradeon the existing contract with the new WASM hash - The contract code is replaced atomically
- Call
migrateto clear the migration flag and optionally adjust state
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):
- Current owner calls
begin_ownership_transfer(new_owner, ttl)— proposes transfer with a TTL window (in ledgers, ~5s each) - New owner calls
accept_ownership()— confirms transfer within the TTL - If not accepted within the TTL, the proposal expires and the original owner retains ownership
- Current owner calls
Delegate
The delegate can manage endpoint configuration (message libraries, DVN config) without owner-level access: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 + 256cannot 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 callsOApp.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
- Build an OApp: Create your first Omnichain Application.
- Build an OFT: Deploy an Omnichain Fungible Token.
- DVN & Executor Config: Configure your security stack.
- Troubleshooting: Common errors and how to resolve them.