Integrating Asset0 Transfers
Asset0 contracts implement the standard IOFT interface for Omnichain Fungible Tokens (OFTs). However, transferring between the Native Mesh (issuer-controlled) and the 0Asset Mesh (LayerZero-managed) requires routing through a Hub Chain using the MultiHopComposer.
For architecture and concepts, see Asset0 Managed Service.
The IOFT Interface
All Asset0 token contracts (on both Native and 0Asset chains) implement the standard IOFT interface. You interact with them using the standard send() and quoteSend() methods, identical to any other OFT deployment.
// Asset0 tokens use the standard IOFT interface
import { IOFT, SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol";
// Quote and send work exactly like any OFT
MessagingFee memory fee = IOFT(asset0Token).quoteSend(sendParam, false);
IOFT(asset0Token).send{value: fee.nativeFee}(sendParam, fee, refundAddress);
Transfer Types
There are two types of transfers in the Asset0 ecosystem:
-
Direct Transfers (Within the same mesh)
- 0Asset to 0Asset: Standard OFT transfer.
- Native to Native: Standard OFT transfer (if multiple native chains exist).
-
Cross-Mesh Transfers (Between meshes)
- Native to 0Asset: Must route through the Hub Chain.
- 0Asset to Native: Must route through the Hub Chain.
Direct Transfers (Standard)
For transfers within the same mesh (e.g., 0Asset Chain A to 0Asset Chain B), simply construct a standard SendParam with the destination endpoint ID and recipient address. No special composition is required.
// Standard OFT Transfer (Direct)
SendParam memory sendParam = SendParam({
dstEid: dstEid, // Destination 0Asset Chain
to: bytes32(uint256(uint160(recipient))),
amountLD: amount,
minAmountLD: amount,
extraOptions: options, // Standard gas options
composeMsg: "",
oftCmd: ""
});
Cross-Mesh Transfers (Multi-Hop)
To move assets between the Native Mesh and the 0Asset Mesh, the transfer must be routed through the MultiHopComposer on the Hub Chain. This is a two-hop process:
- Hop 1: Source Chain → Hub Chain (Composer)
- Hop 2: Hub Chain (Composer) → Destination Chain
You initiate this entire flow from the source chain by encoding the instructions for the second hop into the composeMsg of the first hop.
Constructing the Send
You must construct two SendParam structs: one for the final destination (Hop 2) and one for the Hub (Hop 1).
1. Prepare the Second Hop (Hub → Destination)
First, define where the tokens should go after they reach the Hub.
// The parameters for the transfer from Hub to Final Destination
SendParam memory nextHopParam = SendParam({
dstEid: finalDstEid, // The final destination chain ID
to: bytes32(uint256(uint160(finalRecipient))),
amountLD: amount, // The amount to forward
minAmountLD: 0, // Set to 0 (slippage handled by Composer)
extraOptions: nextHopOptions, // Options for delivery to final recipient
composeMsg: "", // Usually empty unless chaining further
oftCmd: "" // Standard transfer
});
// Encode this struct to be passed as the compose message
bytes memory composeMsg = abi.encode(nextHopParam);
2. Prepare the First Hop (Source → Hub)
Next, wrap the second hop inside the parameters for the first hop. The destination is the Hub Chain, and the recipient is the MultiHopComposer contract.
Critical Requirements:
dstEid: Must be the Hub Chain ID.to: Must be the address of theMultiHopComposeron the Hub.composeMsg: Must be the encodednextHopParamfrom Step 1.- Gas & Value: You must pay for the second hop's gas and fee on the source chain.
// Calculate the fee required for the second hop (native fee on Hub)
// This implies quoting the OFT lockbox on the Hub chain off-chain (see Fee Estimation below)
// You must call IOFT.quoteSend() on the Hub chain and use the returned MessagingFee.nativeFee
uint128 gasForLzReceive = 100_000; // Gas for Hub OFT's _lzReceive (credits tokens + calls sendCompose)
uint128 gasForCompose = 500_000; // Gas to execute lzCompose on Hub (MultiHopComposer logic)
uint128 msgValueForNextHop = quoteFromHub.nativeFee;
// Add execution options for the Hub - BOTH lzReceive AND lzCompose are required
bytes memory firstHopOptions = OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(gasForLzReceive, 0)
.addExecutorLzComposeOption(0, gasForCompose, msgValueForNextHop);
// The parameters for the transfer from Source to Hub
SendParam memory sendParam = SendParam({
dstEid: hubEid, // The Hub Chain ID
to: bytes32(uint256(uint160(composerAddress))), // The MultiHopComposer address
amountLD: amount,
minAmountLD: amount,
extraOptions: firstHopOptions,
composeMsg: composeMsg, // The encoded nextHopParam
oftCmd: "" // Must use Taxi mode for lzCompose
});
Full Implementation Example
Here is a complete Solidity example of how to format and send a cross-mesh Asset0 transfer.
import { OptionsBuilder } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol";
import { IOFT, SendParam, MessagingFee } from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol";
contract Asset0Sender {
using OptionsBuilder for bytes;
// The Asset0 token contract on this chain
IOFT public asset0Token;
// The address of the MultiHopComposer on the Hub Chain
address public composerOnHub;
// The Endpoint ID of the Hub Chain
uint32 public hubEid;
constructor(address _token, address _composer, uint32 _hubEid) {
asset0Token = IOFT(_token);
composerOnHub = _composer;
hubEid = _hubEid;
}
function sendCrossMesh(
uint32 _finalDstEid,
address _finalRecipient,
uint256 _amount,
uint128 _nextHopNativeFee
) external payable {
// 1. PREPARE SECOND HOP (Hub -> Destination)
// Options for the final delivery (Executor gas on destination)
bytes memory nextHopOptions = OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(200000, 0);
SendParam memory nextHopParam = SendParam({
dstEid: _finalDstEid,
to: bytes32(uint256(uint160(_finalRecipient))),
amountLD: _amount,
minAmountLD: 0, // Composer handles amount adjustment
extraOptions: nextHopOptions,
composeMsg: "",
oftCmd: ""
});
// 2. PREPARE FIRST HOP (Source -> Hub)
// Encode the second hop instructions as the composeMsg
// The OFT will wrap this with (nonce, srcEid, amountLD, composeFrom) before delivery
// The MultiHopComposer extracts it via OFTComposeMsgCodec.composeMsg()
bytes memory composeMsg = abi.encode(nextHopParam);
// Options for the Hub: BOTH lzReceive AND lzCompose gas are required
// - lzReceive: Hub OFT credits tokens and calls endpoint.sendCompose()
// - lzCompose: MultiHopComposer executes and calls OFT.send() for second hop
bytes memory firstHopOptions = OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(65_000, 0)
.addExecutorLzComposeOption(0, 500_000, _nextHopNativeFee);
SendParam memory sendParam = SendParam({
dstEid: hubEid,
to: bytes32(uint256(uint160(composerOnHub))),
amountLD: _amount,
minAmountLD: _amount, // Normal slippage for first hop
extraOptions: firstHopOptions,
composeMsg: composeMsg,
oftCmd: ""
});
// 3. QUOTE AND SEND
MessagingFee memory fee = asset0Token.quoteSend(sendParam, false);
// Ensure enough value was sent
require(msg.value >= fee.nativeFee, "Insufficient fee");
// Send
asset0Token.send{value: fee.nativeFee}(
sendParam,
fee,
msg.sender // Refund address
);
}
}
When you call send() with a composeMsg, the OFT automatically wraps your data with additional context before delivering it to the composer:
nonce- Transaction trackingsrcEid- Source chain endpoint IDamountLD- Amount transferredcomposeFrom- Original sender address (bytes32)- Your
composeMsgpayload
The MultiHopComposer uses OFTComposeMsgCodec.composeMsg(_message) to extract your encoded SendParam from the full message.
Fee Estimation
Estimating the fee for a multi-hop transaction requires a two-step process because the source chain cannot directly quote the cost of the second hop (Hub → Destination).
Step 1: Quote the Second Hop (Hub → Destination)
You must call IOFT.quoteSend() on the Hub Chain's OFT contract (the lockbox or adapter) to get the exact fee for the transfer from Hub to final destination.
This quote must be obtained off-chain before constructing your source transaction:
import {ethers} from 'ethers';
// Connect to Hub Chain
const hubProvider = new ethers.JsonRpcProvider(HUB_RPC_URL);
const hubOFT = new ethers.Contract(HUB_OFT_ADDRESS, IOFT_ABI, hubProvider);
// Construct the nextHopParam (same as what you'll encode in composeMsg)
const nextHopParam = {
dstEid: FINAL_DESTINATION_EID,
to: ethers.zeroPadValue(recipientAddress, 32),
amountLD: amountToSend,
minAmountLD: 0,
extraOptions: nextHopOptions, // lzReceive gas for final destination
composeMsg: '0x',
oftCmd: '0x',
};
// Quote the second hop on the Hub chain
const nextHopQuote = await hubOFT.quoteSend(nextHopParam, false);
const nextHopNativeFee = nextHopQuote.nativeFee; // Use this in Step 2
Step 2: Pack Quote into First Hop Options
Once you have the nativeFee for the second hop, include it in the options for the first hop (Source → Hub):
- Add
lzReceiveOption: Gas for the Hub OFT's_lzReceive(credits tokens + callssendCompose) - Add
lzComposeOption: Gas forMultiHopComposer.lzCompose()execution, plus thenativeFeeasmsg.value - Quote First Hop: Call
quoteSend()on the source chain. The returned fee includes everything needed for the full journey.
// Build first hop options with the quoted second hop fee
const firstHopOptions = OptionsBuilder.newOptions()
.addExecutorLzReceiveOption(65000, 0) // Hub _lzReceive gas
.addExecutorLzComposeOption(0, 500000, nextHopNativeFee); // Compose gas + second hop fee
// Now quote the first hop on source chain
const sourceOFT = new ethers.Contract(SOURCE_OFT_ADDRESS, IOFT_ABI, sourceProvider);
const firstHopQuote = await sourceOFT.quoteSend(sendParam, false);
// firstHopQuote.nativeFee is your total msg.value for the entire cross-mesh transfer
The msg.value passed via addExecutorLzComposeOption is delivered to the MultiHopComposer on the Hub. The composer then uses this value to pay for the IOFT.send() call that initiates the second hop.
Always verify the correct MultiHopComposer address for your specific Asset0 token pair. Some deployments may use different Hub chains or composer instances.