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 instantly

Note: The deposit function signatures are:

  • deposit(uint256 assets, address receiver) - ERC4626 standard

  • deposit(uint256 assets, address receiver, address controller) - With controller

  • deposit(uint256 assets, address receiver, uint256 minShares) - With slippage protection

  • deposit(uint256 assets, address receiver, address controller, uint256 minShares) - Full control

The mint function signatures are:

  • mint(uint256 shares, address receiver) - ERC4626 standard

  • mint(uint256 shares, address receiver, address controller) - With controller

  • mint(uint256 shares, address receiver, uint256 maxAssets) - With slippage protection

  • mint(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 minutes

Pattern 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 minutes

Pattern 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.

  • withdraw calculates the required shares based on the net asset amount (after fees), while redeem burns 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 minutes

Pattern 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 minutes

Pattern 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 * 1e6

3. 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 message

4. 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 user

10. 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