Common Patterns
The SendParam in the following pattern are based on LayerZero patterns extensions: https://docs.layerzero.network/v2/developers/evm/oft/oft-patterns-extensions
Integration Patterns
Pattern 1: Same-Chain Deposit (Hub Only)
Fastest and cheapest—no LayerZero fees.
// Direct deposit on hub chain
uint256 usdtAmount = 1000 * 1e6;
uint256 minShares = 990 * 1e18;
IERC20(usdt).approve(depositPipe, usdtAmount);
uint256 shares = IDepositPipe(depositPipe).deposit(
usdtAmount,
msg.sender,
msg.sender,
minShares
);
// Shares minted instantlyNote: The deposit function signatures are:
deposit(uint256 assets, address receiver)- ERC4626 standarddeposit(uint256 assets, address receiver, address controller)- With controllerdeposit(uint256 assets, address receiver, uint256 minShares)- With slippage protectiondeposit(uint256 assets, address receiver, address controller, uint256 minShares)- Full control
The mint function signatures are:
mint(uint256 shares, address receiver)- ERC4626 standardmint(uint256 shares, address receiver, address controller)- With controllermint(uint256 shares, address receiver, uint256 maxAssets)- With slippage protectionmint(uint256 shares, address receiver, address controller, uint256 maxAssets)- Full control
Pattern 2: Cross-Chain Deposit (Spoke → Hub)
Deposit from any spoke, receive shares on hub.
// On Arbitrum (spoke), send USDT0 to hub for shares
uint256 usdtAmount = 1000 * 1e6;
uint32 hubEid = 30167; // HyperEVM (example)
// Build compose message for deposit
// ACTION_DEPOSIT_ASSET params: (address targetAsset, bytes32 receiver, SendParam, uint256 minMsgValue, bytes32 feeRefundRecipient, uint32 originEid)
bytes memory composeMsg = abi.encode(
uint8(1), // ACTION_DEPOSIT_ASSET
abi.encode(
usdtAddressOnHub, // targetAsset
addressToBytes32(msg.sender), // receiver (shares recipient if same chain)
SendParam({
dstEid: 0, // 0 = same chain, non-zero = cross-chain
to: addressToBytes32(msg.sender), // Share recipient if cross-chain
amountLD: 0, // Will be set by composer
minAmountLD: 990 * 1e18, // Min shares (slippage protection)
extraOptions: "",
composeMsg: "",
oftCmd: ""
}),
uint256(0), // minMsgValue (native fee for compose)
addressToBytes32(msg.sender), // feeRefundRecipient
uint32(0) // originEid (source chain for multi-hop refunds)
)
);
SendParam memory sendParam = SendParam({
dstEid: hubEid,
to: addressToBytes32(composer),
amountLD: usdtAmount,
minAmountLD: usdtAmount,
extraOptions: OptionsBuilder.newOptions().addExecutorLzComposeOption(0, 700_000, 0),
composeMsg: composeMsg,
oftCmd: ""
});
MessagingFee memory fee = IOFT(usdtOFT).quoteSend(sendParam, false);
IERC20(usdt).approve(usdtOFT, usdtAmount);
IOFT(usdtOFT).send{value: fee.nativeFee}(sendParam, fee, msg.sender);
// Shares received on hub in ~1-3 minutesPattern 3: Cross-Chain Deposit (Spoke → Hub → Different Spoke)
Deposit on one spoke, receive shares on another spoke.
// Deposit USDT on Arbitrum, receive shares on Ethereum
uint256 usdtAmount = 1000 * 1e6;
uint32 hubEid = 30167; // HyperEVM
uint32 ethereumEid = 30101; // Ethereum
// Build compose message with cross-chain share distribution
bytes memory composeMsg = abi.encode(
uint8(1), // ACTION_DEPOSIT_ASSET
abi.encode(
usdtAddressOnHub,
addressToBytes32(address(msg.sender)),
SendParam({
dstEid: ethereumEid, // Send shares to Ethereum
to: addressToBytes32(msg.sender), // Share recipient on Ethereum
amountLD: 0,
minAmountLD: 990 * 1e18,
extraOptions: OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0),
composeMsg: "",
oftCmd: ""
}),
uint256(0), // minMsgValue for compose
addressToBytes32(msg.sender), // feeRefundRecipient
getSourceEid() // originEid (Arbitrum)
)
);
SendParam memory sendParam = SendParam({
dstEid: hubEid,
to: addressToBytes32(composer),
amountLD: usdtAmount,
minAmountLD: usdtAmount,
extraOptions: OptionsBuilder.newOptions().addExecutorLzComposeOption(0, 700_000, 0),
composeMsg: composeMsg,
oftCmd: ""
});
MessagingFee memory fee = IOFT(usdtOFT).quoteSend(sendParam, false);
IERC20(usdt).approve(usdtOFT, usdtAmount);
IOFT(usdtOFT).send{value: fee.nativeFee}(sendParam, fee, msg.sender);
// Shares received on Ethereum in ~1-3 minutesPattern 4: Same-Chain Instant Redemption
Fastest redemption with highest fee.
// Redeem shares for USDT on hub
uint256 shares = 1000 * 1e18;
IShareManager(shareManager).approve(redemptionPipe, shares);
uint256 usdtReceived = IRedemptionPipe(redemptionPipe).redeem(
shares,
msg.sender,
msg.sender
);
// USDT received instantly (after fee)Alternative: Withdraw specific asset amount
// Withdraw specific amount of assets (net, after fees)
uint256 desiredAssets = 1000 * 1e6; // Want 1000 USDT net
IShareManager(shareManager).approve(redemptionPipe, type(uint256).max);
uint256 sharesBurned = IRedemptionPipe(redemptionPipe).withdraw(
desiredAssets,
msg.sender,
msg.sender
);
// Shares burned, assets received (after fee)Note:
Instant redemption (
redeem) applies a fee (instantRedeemFeeBps) which is deducted from the assets received. The fee stays with the liquidity provider.withdrawcalculates the required shares based on the net asset amount (after fees), whileredeemburns a specific number of shares.
Pattern 5: Cross-Chain Redemption (Spoke → Hub → Spoke)
Redeem shares on spoke for assets on hub or different spoke.
// On Ethereum, redeem shares for USDT on hub
uint256 shares = 1000 * 1e18;
uint32 hubEid = 30167; // HyperEVM
// Build compose message for redemption
// ACTION_REDEEM_SHARES params: (address receiver, SendParam, uint256 minMsgValue, uint256 minAssets, bytes32 feeRefundRecipient, uint32 originEid)
bytes memory composeMsg = abi.encode(
uint8(2), // ACTION_REDEEM_SHARES
abi.encode(
msg.sender, // receiver (USDT recipient if same chain)
SendParam({
dstEid: 0, // 0 = same chain, non-zero = cross-chain
to: addressToBytes32(msg.sender), // Asset recipient if cross-chain
amountLD: 0, // Will be set by composer
minAmountLD: 990 * 1e6, // Min USDT (slippage protection)
extraOptions: "",
composeMsg: "",
oftCmd: ""
}),
uint256(0), // minMsgValue (native fee for compose)
990 * 1e6, // minAssets (slippage protection)
addressToBytes32(msg.sender), // feeRefundRecipient
getSourceEid() // originEid (Ethereum
)
)
);
SendParam memory sendParam = SendParam({
dstEid: hubEid,
to: addressToBytes32(composer),
amountLD: shares,
minAmountLD: shares,
extraOptions: OptionsBuilder.newOptions().addExecutorLzComposeOption(0, 700_000, 0),
composeMsg: composeMsg,
oftCmd: ""
});
MessagingFee memory fee = IOFT(shareOFT).quoteSend(sendParam, false);
IShareManager(shareERC20).approve(shareOFT, shares);
IOFT(shareOFT).send{value: fee.nativeFee}(sendParam, fee, msg.sender);
// USDT received on hub in ~1-3 minutesPattern 6: Transfer Shares Between Spokes
Simple cross-chain share transfer (no vault operation).
// Transfer shares from Arbitrum to Ethereum (no deposit/redeem)
uint256 shares = 1000 * 1e18;
uint32 ethereumEid = 30101;
SendParam memory sendParam = SendParam({
dstEid: ethereumEid,
to: addressToBytes32(msg.sender),
amountLD: shares,
minAmountLD: shares,
extraOptions: OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0),
composeMsg: "", // No compose message
oftCmd: ""
});
MessagingFee memory fee = IOFT(shareOFT).quoteSend(sendParam, false);
IShareManager(shareERC20).approve(shareOFT, shares);
IOFT(shareOFT).send{value: fee.nativeFee}(sendParam, fee, msg.sender);
// Shares arrive on Ethereum in ~1-3 minutesPattern 7: Direct Composer Methods (Hub Only)
For same-chain operations on the hub, you can use direct composer methods:
// Deposit and send shares cross-chain in one call
uint256 usdtAmount = 1000 * 1e6;
uint32 ethereumEid = 30101;
SendParam memory shareSendParam = SendParam({
dstEid: ethereumEid,
to: addressToBytes32(msg.sender),
amountLD: 0, // Will be set by composer
minAmountLD: 990 * 1e18,
extraOptions: OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0),
composeMsg: "",
oftCmd: ""
});
IERC20(usdt).approve(composer, usdtAmount);
MessagingFee memory fee = IOFT(shareOFT).quoteSend(shareSendParam, false);
IOVaultComposerMulti(composer).depositAssetAndSend{value: fee.nativeFee}(
usdt,
usdtAmount,
shareSendParam,
msg.sender // refund address
);
// Redeem and send assets cross-chain in one call
uint256 shares = 1000 * 1e18;
SendParam memory assetSendParam = SendParam({
dstEid: ethereumEid,
to: addressToBytes32(msg.sender),
amountLD: 0, // Will be set by composer
minAmountLD: 990 * 1e6,
extraOptions: OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0),
composeMsg: "",
oftCmd: ""
});
IShareManager(shareERC20).approve(composer, shares);
MessagingFee memory fee = IOFT(underlyingAssetOFT).quoteSend(assetSendParam, false);
IOVaultComposerMulti(composer).redeemAndSend{value: fee.nativeFee}(
shares,
assetSendParam,
msg.sender // refund address
);Pattern 8: Standard Redemption Request (Hub Only)
Standard redemption requests are fulfilled later by the protocol.
// Request standard redemption
uint256 shares = 1000 * 1e18;
// Transfer shares to redemption pipe (they will be held in custody)
IShareManager(shareManager).transfer(redemptionPipe, shares);
uint256 requestId = IRedemptionPipe(redemptionPipe).requestRedeem(
shares,
msg.sender, // receiver
msg.sender, // controller
msg.sender // owner
);
// Later, a FULFILL_MANAGER_ROLE will fulfill the request
// Users receive assets after fulfillment (no fees)Developer Guide
1. Reading NAV and Share Prices
// Get current NAV (18 decimals)
uint256 nav = INAVOracle(navOracle).getNAV();
// Get total share supply (18 decimals)
uint256 supply = IShareManager(shareManager).totalSupply();
// Calculate share price (18 decimals)
uint256 sharePrice = (nav * 1e18) / supply;
// Calculate user's USD value
uint256 userShares = IShareManager(shareManager).balanceOf(user);
uint256 userValue = (userShares * nav) / supply;2. Handling Different Asset Decimals
All internal calculations use 18 decimals, but deposit assets may vary.
// Normalize asset amount to 18 decimals
function normalizeToDecimals18(uint256 amount, uint8 decimals)
internal
pure
returns (uint256)
{
if (decimals == 18) return amount;
if (decimals < 18) {
return amount * (10 ** (18 - decimals));
} else {
return amount / (10 ** (decimals - 18));
}
}
// Denormalize from 18 decimals to asset decimals
function normalizeFromDecimals18(uint256 amount18, uint8 decimals)
internal
pure
returns (uint256)
{
if (decimals == 18) return amount18;
if (decimals < 18) {
return amount18 / (10 ** (18 - decimals));
} else {
return amount18 * (10 ** (decimals - 18));
}
}Example:
// User wants to deposit $1000 worth of USDT (6 decimals)
uint256 usdtAmount = 1000 * 1e6; // 1000 USDT
// Preview shares (returns 18 decimals)
uint256 expectedShares = IDepositPipe(depositPipe).previewDeposit(usdtAmount);
// expectedShares might be 1000 * 1e18 (if 1:1 ratio)
// User wants 1000 shares (18 decimals)
uint256 desiredShares = 1000 * 1e18;
// Preview required USDT (returns 6 decimals)
uint256 requiredUSDT = IDepositPipe(depositPipe).previewMint(desiredShares);
// requiredUSDT might be 1000 * 1e63. Slippage Protection Best Practices
Always use slippage protection for better UX.
// For deposits: calculate minimum shares
uint256 depositAmount = 1000 * 1e6; // USDT
uint256 expectedShares = IDepositPipe(depositPipe).previewDeposit(depositAmount);
uint256 minShares = (expectedShares * 99) / 100; // 1% slippage
IDepositPipe(depositPipe).deposit(depositAmount, receiver, controller, minShares);
// For mints: calculate maximum assets
uint256 desiredShares = 1000 * 1e18;
uint256 expectedAssets = IDepositPipe(depositPipe).previewMint(desiredShares);
uint256 maxAssets = (expectedAssets * 101) / 100; // 1% slippage
// Note: Use mint(shares, receiver, maxAssets) or mint(shares, receiver, controller, maxAssets)
IDepositPipe(depositPipe).mint(desiredShares, receiver, maxAssets);
// For redemptions: calculate minimum assets
uint256 sharesToRedeem = 1000 * 1e18;
uint256 expectedAssets = IRedemptionPipe(redemptionPipe).previewRedeem(sharesToRedeem);
uint256 minAssets = (expectedAssets * 99) / 100; // 1% slippage
// Note: For instant redemptions, slippage is implicit in fee
// For cross-chain redemptions, include minAssets in compose message4. Gas Estimation for LayerZero Operations
Always get fee quotes before cross-chain operations.
// Quote fee for cross-chain send
SendParam memory sendParam = /* ... */;
MessagingFee memory fee = IOFT(oft).quoteSend(sendParam, false);
// Total gas needed
uint256 totalGas = fee.nativeFee;
// For compose operations, add compose gas
if (sendParam.composeMsg.length > 0) {
totalGas += estimateComposeGas(); // 0.01-0.05 ETH typical
}
// Execute with proper value
IOFT(oft).send{value: totalGas}(sendParam, fee, refundAddress); 5. Error Handling
Common errors and how to handle them:
// DepositPipe errors
try IDepositPipe(depositPipe).deposit(amount, receiver, controller, minShares)
returns (uint256 shares) {
// Success
} catch Error(string memory reason) {
if (keccak256(bytes(reason)) == keccak256("DepositPipe: slippage exceeded")) {
// Handle slippage - increase minShares or retry
}
if (keccak256(bytes(reason)) == keccak256("DepositPipe: shares below minimum")) {
// Amount too small - increase deposit
}
if (keccak256(bytes(reason)) == keccak256("ShareManager: max deposit exceeded")) {
// User or global deposit limit exceeded
}
if (keccak256(bytes(reason)) == keccak256("DepositPipe: max supply exceeded")) {
// Global supply limit exceeded
}
}
// RedemptionPipe errors
try IRedemptionPipe(redemptionPipe).redeem(shares, receiver, controller)
returns (uint256 assets) {
// Success
} catch Error(string memory reason) {
if (keccak256(bytes(reason)) == keccak256("RedemptionPipe: insufficient liquidity")) {
// Instant redemption unavailable - use fast/standard
}
if (keccak256(bytes(reason)) == keccak256("RedemptionPipe: insufficient shares")) {
// User doesn't have enough shares
}
if (keccak256(bytes(reason)) == keccak256("RedemptionPipe: shares below minimum")) {
// Amount too small
}
if (keccak256(bytes(reason)) == keccak256("RedemptionPipe: maximum redeem per user exceeded")) {
// User redemption limit exceeded
}
}
// LayerZero errors
try IOFT(oft).send{value: fee.nativeFee}(sendParam, fee, refund)
returns (MessagingReceipt memory receipt) {
// Success - track receipt.guid on LayerZero scanner
} catch {
// Insufficient gas, invalid parameters, or OFT paused
}6. Monitoring Cross-Chain Transactions
Track LayerZero messages using the GUID.
// After sending cross-chain
MessagingReceipt memory receipt = IOFT(oft).send{value: fee}(sendParam, fee, refund);
bytes32 guid = receipt.guid;
// Emit event for frontend tracking
emit CrossChainOperationInitiated(guid, sendParam.dstEid, user);
// Users can track on: https://layerzeroscan.com/tx/{guid}Frontend Integration:
// Listen for LayerZero events
const receipt = await oft.send(sendParam, fee, refund, {value: totalGas});
const guid = receipt.guid;
// Poll LayerZero scanner API
const checkStatus = async (guid) => {
const response = await fetch(`https://api.layerzeroscan.com/tx/${guid}`);
const data = await response.json();
return data.status; // "INFLIGHT", "DELIVERED", "FAILED"
};7. Important Limits and Constraints
// Check minimum share amounts
uint256 minShares = IDepositPipe(depositPipe).MIN_AMOUNT_SHARES();
uint256 minRedeemShares = IRedemptionPipe(redemptionPipe).MIN_AMOUNT_SHARES();
// Check deposit limits
uint256 maxDeposit = IShareManager(shareManager).maxDeposit();
uint256 maxSupply = IShareManager(shareManager).maxSupply();
uint256 userBalance = IShareManager(shareManager).balanceOf(user);
uint256 remainingCapacity = maxDeposit > userBalance ? maxDeposit - userBalance : 0;
// Check redemption limits
uint256 maxWithdraw = IShareManager(shareManager).maxWithdraw();
uint256 userBalance = IShareManager(shareManager).balanceOf(user);
uint256 maxRedeem = IRedemptionPipe(redemptionPipe).maxRedeem(user);
uint256 maxWithdrawAssets = IRedemptionPipe(redemptionPipe).maxWithdraw(user);8. Fee Information
// Get redemption fees
(uint256 instantFeeBps, _) = IRedemptionPipe(redemptionPipe).fees();
// Fees are in basis points (10000 = 100%)
// Calculate instant redemption fee
uint256 shares = 1000 * 1e18;
uint256 assets = IRedemptionPipe(redemptionPipe).convertToAssets(shares);
uint256 fee = (assets * instantFeeBps) / 10000;
uint256 assetsAfterFee = assets - fee;9. Operator Pattern
The ShareManager supports an operator pattern for contract-based integrations:
// User sets operator
IShareManager(shareManager).setOperator(operatorContract, true);
// Operator can now act on behalf of user10. Blacklist Handling
Users can be blacklisted, preventing deposits, redemptions, and transfers:
// Check if user is blacklisted
bool isBlacklisted = IShareManager(shareManager).isBlacklisted(user);
// If blacklisted, operations will revert with:
// "ShareManager: receiver address is blacklisted"
// "ShareManager: sender address is blacklisted"11. Pause Handling
All contracts support emergency pause:
// Check if contract is paused
bool isPaused = Pausable(depositPipe).paused();
// If paused, operations will revert with:
// "Pausable: paused"12. Composer Message Structure Reference
ACTION_DEPOSIT_ASSET (1):
abi.encode(
address targetAsset, // Asset to deposit on hub
bytes32 receiver, // Share recipient if same chain (bytes32(0) if cross-chain)
SendParam shareSendParam, // Share distribution (dstEid=0 for same chain)
uint256 minMsgValue, // Minimum native fee for compose operation
bytes32 feeRefundRecipient, // Fee refund recipient (bytes32(0) = depositor)
uint32 originEid // Source chain for multi-hop refunds
)ACTION_REDEEM_SHARES (2):
abi.encode(
address receiver, // Asset recipient if same chain
SendParam assetSendParam, // Asset distribution (dstEid=0 for same chain)
uint256 minMsgValue, // Minimum native fee for compose operation
uint256 minAssets, // Minimum assets (slippage protection)
bytes32 feeRefundRecipient, // Fee refund recipient (bytes32(0) = redeemer)
uint32 originEid // Source chain for multi-hop refunds
)13. Helper Functions
// Convert address to bytes32 for LayerZero
function addressToBytes32(address _addr) internal pure returns (bytes32) {
return bytes32(uint256(uint160(_addr)));
}
// Convert bytes32 to address
function bytes32ToAddress(bytes32 _b) internal pure returns (address) {
return address(uint160(uint256(_b)));
}Last updated