UpshiftDocs

Cross-Chain Vaults

Execute cross-chain vault deposits and redemptions using LayerZero OVault. Functions are available as direct imports from `@upshiftfinance/sdk`.

Overview

Cross-chain vault operations allow users to deposit into and redeem from ERC4626 vaults across different chains using LayerZero's OVault protocol. The SDK provides functions to build, quote, and execute cross-chain transactions.

Architecture:

  • Hub chain — where the ERC4626 vault contract lives
  • Spoke chains — chains users can deposit from (assets are bridged to hub via LayerZero)
  • LayerZero OFT — Omnichain Fungible Token handles the cross-chain asset transport

Prerequisites

Cross-chain vault functions require the @layerzerolabs/ovault-evm SDK as an injected dependency. This design avoids ESM compatibility issues with the LayerZero package.

import {
  OVaultSyncMessageBuilder,
  OVaultSyncOperations,
} from '@layerzerolabs/ovault-evm';
import {
  buildCrossChainVaultTx,
  crossChainVaultDeposit,
  crossChainVaultRedeem,
  quoteCrossChainDeposit,
  quoteCrossChainRedeem,
  needsCrossChainApproval,
  approveCrossChain,
  classifyCrossChainError,
  canDepositFromChain,
  canRedeemFromChain,
  isCrossChainOperation,
  getAvailableChains,
  formatCrossChainError,
  getLayerZeroScanUrl,
  LAYERZERO_ENDPOINT_IDS,
} from '@upshiftfinance/sdk';

// Injected dependency — pass this to all cross-chain functions
const ovaultSdk = { OVaultSyncMessageBuilder, OVaultSyncOperations };

Vault Configuration

Every cross-chain vault requires an ICrossChainVaultConfig describing its deployment:

import type { ICrossChainVaultConfig } from '@upshiftfinance/sdk';

const vaultConfig: ICrossChainVaultConfig = {
  vaultName: 'Upshift USDC',
  vaultSymbol: 'upUSDC',
  hubChainId: 1, // Ethereum
  vaultAddress: '0xE9B725010A9E419412ed67d0fA5f3A5f40159D32',
  spokeChainIds: [42161, 8453], // Arbitrum, Base
  contracts: {
    assetToken: {
      1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Ethereum
      42161: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // USDC on Arbitrum
      8453: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
    },
    assetOFT: {
      1: '0xc026395860Db2d07ee33e05fE50ed7bD583189C7',
      42161: '0xe8CDF27AcD73a434D661C84887215F7598e7d0d3',
      8453: '0x27a16dc786820B16E5c9028b75B99F6f604b5d26',
    },
    shareOFTAdapter: '0x4a21dcba38c3816c78144f33bb42d8fa17f38067',
    composerSync: '0xc2637d7721791cc9cb196628d1e5e06887b1d4b9',
    shareOFT: {
      42161: '0x50a31ff4d57841681011837c17aa201c6a34203c',
      8453: '0x1fa931bf85248ad2edf0004dd9a284698358e708',
    },
  },
  layerZeroEids: {
    hub: 30101, // Ethereum LZ Endpoint ID
    spokes: {
      42161: 30110, // Arbitrum LZ Endpoint ID
      8453: 30184, // Base LZ Endpoint ID
    },
  },
};

For multi-asset vaults, add the multiAsset field:

const multiAssetConfig: ICrossChainVaultConfig = {
  ...vaultConfig,
  multiAsset: {
    hubAssetAddress: '0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a',
  },
};

LayerZero Endpoint IDs

The SDK exports well-known LayerZero Endpoint IDs for convenience:

import { LAYERZERO_ENDPOINT_IDS } from '@upshiftfinance/sdk';

LAYERZERO_ENDPOINT_IDS.ETHEREUM; // 30101
LAYERZERO_ENDPOINT_IDS.ARBITRUM; // 30110
LAYERZERO_ENDPOINT_IDS.AVALANCHE; // 30106
LAYERZERO_ENDPOINT_IDS.BASE; // 30184
LAYERZERO_ENDPOINT_IDS.SONIC; // 30332
LAYERZERO_ENDPOINT_IDS.MONAD; // 30390

Quote a Cross-Chain Deposit

Estimate fees and expected output before executing. Does not send a transaction.

quoteCrossChainDeposit(props: IQuoteCrossChainDepositRequest): Promise<IQuoteCrossChainResult | null>

Parameters

ParameterTypeRequiredDescription
ovaultSdkIOVaultSdkDependencyYesInjected LayerZero SDK dependency
configICrossChainVaultConfigYesVault deployment configuration
amountstringYesHuman-readable deposit amount
decimalsnumberYesToken decimals
userChainIdnumberYesChain the user is depositing from
walletAddressstringYesUser's wallet address
hubChainChainYesviem Chain object for hub chain
sourceChainChainYesviem Chain object for source chain
destinationChainIdnumberNoChain to receive shares (defaults to user's)
slippageBpsnumberNoSlippage in basis points (default: 100 = 1%)
feeBufferPercentnumberNoBuffer on LZ fees (default: 50 = 1.5x)

Returns

IQuoteCrossChainResult with nativeFee, dstAmount, and minDstAmount, or null if the quote fails.

Example

import { arbitrum } from 'viem/chains';
import { mainnet } from 'viem/chains';

const quote = await quoteCrossChainDeposit({
  ovaultSdk,
  config: vaultConfig,
  amount: '1000',
  decimals: 6,
  userChainId: 42161,
  walletAddress: '0xYourWallet...',
  hubChain: mainnet,
  sourceChain: arbitrum,
});

if (quote) {
  console.log('LZ fee:', quote.nativeFee, 'wei');
  console.log('Expected shares:', quote.dstAmount);
  console.log('Min shares (after slippage):', quote.minDstAmount);
}

Quote a Cross-Chain Redeem

quoteCrossChainRedeem(props: IQuoteCrossChainRedeemRequest): Promise<IQuoteCrossChainResult | null>

Same parameter pattern as deposit quote. The userChainId is automatically set to the hub chain.

const quote = await quoteCrossChainRedeem({
  ovaultSdk,
  config: vaultConfig,
  amount: '500',
  decimals: 6,
  walletAddress: '0xYourWallet...',
  hubChain: mainnet,
  sourceChain: mainnet,
  destinationChainId: 42161, // receive assets on Arbitrum
});

Build a Cross-Chain Transaction

Build transaction inputs without executing. Useful for UIs that need to display details before user confirmation.

buildCrossChainVaultTx(props: ICrossChainBuildRequest): Promise<ICrossChainBuildResult>

Parameters

ParameterTypeRequiredDescription
ovaultSdkIOVaultSdkDependencyYesInjected LayerZero SDK dependency
configICrossChainVaultConfigYesVault deployment configuration
operation'DEPOSIT' | 'REDEEM'YesOperation type
amountstring | number | bigintYesAmount to deposit or redeem
decimalsnumberYesToken decimals
userChainIdnumberYesChain the user is currently on
walletAddressstringYesUser's wallet address
hubChainChainYesviem Chain object for hub chain
sourceChainChainYesviem Chain object for source chain
destinationChainIdnumberNoDestination chain (defaults to user's chain)
recipientAddressstringNoRecipient address (defaults to walletAddress)
slippageBpsnumberNoSlippage in basis points (1-10000, default: 100)
feeBufferPercentnumberNoBuffer on LZ fees (default: 50)
hubLzComposeGasLimitbigintNoGas limit for lzCompose (default: 2,000,000)

Returns

interface ICrossChainBuildResult {
  success: boolean;
  data?: {
    contractAddress: string;
    contractFunctionName: string;
    abi: readonly unknown[];
    txArgs: readonly unknown[];
    messageValue: bigint; // native value to send (includes LZ fees)
    messageFee: {
      nativeFee: bigint;
      lzTokenFee: bigint;
    };
    dstAmount: bigint; // expected output
    minDstAmount: bigint; // minimum after slippage
    approval?: {
      tokenAddress: string;
      spender: string;
      amount: bigint;
    };
  };
  error?: string;
}

Example

const result = await buildCrossChainVaultTx({
  ovaultSdk,
  config: vaultConfig,
  operation: 'DEPOSIT',
  amount: '1000',
  decimals: 6,
  userChainId: 42161,
  walletAddress: '0xYourWallet...',
  hubChain: mainnet,
  sourceChain: arbitrum,
});

if (result.success && result.data) {
  console.log('Contract:', result.data.contractAddress);
  console.log('Function:', result.data.contractFunctionName);
  console.log('LZ fee:', result.data.messageFee.nativeFee, 'wei');
  console.log('Expected output:', result.data.dstAmount);

  if (result.data.approval) {
    console.log('Approval needed for:', result.data.approval.tokenAddress);
  }
} else {
  console.error('Build failed:', result.error);
}

Execute a Cross-Chain Deposit

Full execution flow: build tx, check/execute approval, estimate gas, send transaction, wait for confirmation.

crossChainVaultDeposit(props: ICrossChainDepositRequest): Promise<ICrossChainDepositResult>

Additional Parameters (beyond build)

ParameterTypeRequiredDescription
walletClientWalletClientYesviem WalletClient for signing
publicClientPublicClientYesviem PublicClient for chain reads
skipApprovalCheckbooleanNoSkip approval (if caller handles it)
gasBufferPercentnumberNoGas multiplier (default: 150 = 1.5x)

Returns

{
  transactionHash: string;
  success: boolean;
}

Example

import { createWalletClient, createPublicClient, custom, http } from 'viem';
import { arbitrum, mainnet } from 'viem/chains';

const walletClient = createWalletClient({
  chain: arbitrum,
  transport: custom(window.ethereum),
});

const publicClient = createPublicClient({
  chain: arbitrum,
  transport: http('https://arb-rpc.example.com'),
});

try {
  const result = await crossChainVaultDeposit({
    ovaultSdk,
    config: vaultConfig,
    amount: '1000',
    decimals: 6,
    userChainId: 42161,
    walletAddress: '0xYourWallet...',
    walletClient,
    publicClient,
    hubChain: mainnet,
    sourceChain: arbitrum,
    slippageBps: 100, // 1%
  });

  console.log('Deposit tx:', result.transactionHash);
  console.log(
    'Track on LayerZero:',
    getLayerZeroScanUrl(result.transactionHash),
  );
} catch (error) {
  const errorType = classifyCrossChainError(error);
  console.error('Error type:', errorType);
  console.error('Message:', formatCrossChainError(error.message));
}

Execute a Cross-Chain Redeem

Redemptions must be executed from the hub chain.

crossChainVaultRedeem(props: ICrossChainRedeemRequest): Promise<ICrossChainRedeemResult>

Additional Parameters (beyond build)

ParameterTypeRequiredDescription
walletClientWalletClientYesviem WalletClient for signing
publicClientPublicClientYesviem PublicClient for chain reads
skipApprovalCheckbooleanNoSkip approval (if caller handles it)
gasBufferPercentnumberNoGas multiplier (default: 200 = 2x)
minGasLimitbigintNoFloor gas limit (default: 5,000,000)

Example

// User must be on hub chain (Ethereum) for redemptions
const result = await crossChainVaultRedeem({
  ovaultSdk,
  config: vaultConfig,
  amount: '500',
  decimals: 6,
  userChainId: 1, // must match config.hubChainId
  walletAddress: '0xYourWallet...',
  walletClient,
  publicClient,
  hubChain: mainnet,
  sourceChain: mainnet,
  destinationChainId: 42161, // receive assets on Arbitrum
});

console.log('Redeem tx:', result.transactionHash);

Token Approval

Check and execute approvals for cross-chain operations.

Check Approval

const needsApproval = await needsCrossChainApproval(
  '0xTokenAddress...', // token to approve
  '0xSpenderAddress...', // contract that will spend
  '0xYourWallet...', // owner
  1000000n, // amount in smallest unit
  publicClient,
);

Execute Approval

if (needsApproval) {
  const approvalHash = await approveCrossChain(
    '0xTokenAddress...',
    '0xSpenderAddress...',
    1000000n,
    walletClient,
    publicClient,
  );
  console.log('Approval tx:', approvalHash);
}

Utility Helpers

Chain Validation

// Can the user deposit from their current chain?
canDepositFromChain(vaultConfig, 42161); // true (spoke chain)
canDepositFromChain(vaultConfig, 1); // true (hub chain)
canDepositFromChain(vaultConfig, 999); // false (not configured)

// Can the user redeem from their current chain? (must be hub)
canRedeemFromChain(vaultConfig, 1); // true
canRedeemFromChain(vaultConfig, 42161); // false

// Is this a cross-chain operation?
isCrossChainOperation(vaultConfig, 42161); // true (spoke)
isCrossChainOperation(vaultConfig, 1); // false (hub, no dest)
isCrossChainOperation(vaultConfig, 1, 42161); // true (hub to spoke)

// Get all supported chains
getAvailableChains(vaultConfig); // [1, 42161, 8453]

LayerZero Scan

const url = getLayerZeroScanUrl('0xTxHash...');
// https://layerzeroscan.com/tx/0xTxHash...

const testnetUrl = getLayerZeroScanUrl('0xTxHash...', true);
// https://testnet.layerzeroscan.com/tx/0xTxHash...

Error Handling

The SDK provides error classification and formatting for cross-chain operations.

Error Types

TypeDescriptionExample Trigger
max-depositVault deposit cap exceededERC4626ExceededMaxDeposit
nonceTransaction nonce conflictinvalid nonce
no-peerMissing LayerZero peer configurationNoPeer / 0xf6ff4fb7
bridge-feeLayerZero fee estimation failedquoteSend errors
insufficient-balanceNot enough tokensinsufficient balance
user-rejectedUser cancelled in walletuser rejected
gasGas estimation or limit errorout of gas
networkNetwork connectivity issueconnection refused
unknownUnrecognized errorAny other error

Example

try {
  await crossChainVaultDeposit({ ... });
} catch (error) {
  const errorType = classifyCrossChainError(error);

  switch (errorType) {
    case 'user-rejected':
      console.log('Transaction cancelled');
      break;
    case 'insufficient-balance':
      console.log('Not enough tokens');
      break;
    case 'bridge-fee':
      console.log('LayerZero bridge unavailable, try again later');
      break;
    case 'no-peer':
      console.log('Cross-chain route not configured');
      break;
    default:
      console.log(formatCrossChainError(error.message));
  }
}

Complete Integration Example

import { createWalletClient, createPublicClient, custom, http } from 'viem';
import { arbitrum, mainnet } from 'viem/chains';
import {
  OVaultSyncMessageBuilder,
  OVaultSyncOperations,
} from '@layerzerolabs/ovault-evm';
import {
  crossChainVaultDeposit,
  quoteCrossChainDeposit,
  canDepositFromChain,
  isCrossChainOperation,
  classifyCrossChainError,
  formatCrossChainError,
  getLayerZeroScanUrl,
} from '@upshiftfinance/sdk';

const ovaultSdk = { OVaultSyncMessageBuilder, OVaultSyncOperations };

async function depositFromArbitrum(amount: string) {
  const userChainId = 42161;

  // 1. Validate chain support
  if (!canDepositFromChain(vaultConfig, userChainId)) {
    throw new Error('Chain not supported for deposits');
  }

  const isCrossChain = isCrossChainOperation(vaultConfig, userChainId);
  console.log('Cross-chain:', isCrossChain); // true

  // 2. Get a fee quote
  const quote = await quoteCrossChainDeposit({
    ovaultSdk,
    config: vaultConfig,
    amount,
    decimals: 6,
    userChainId,
    walletAddress: '0xYourWallet...',
    hubChain: mainnet,
    sourceChain: arbitrum,
  });

  if (!quote) {
    throw new Error('Failed to get quote');
  }

  console.log('LZ fee:', quote.nativeFee, 'wei');
  console.log('Expected shares:', quote.dstAmount);

  // 3. Execute deposit
  const walletClient = createWalletClient({
    chain: arbitrum,
    transport: custom(window.ethereum),
  });

  const publicClient = createPublicClient({
    chain: arbitrum,
    transport: http('https://arb-rpc.example.com'),
  });

  try {
    const result = await crossChainVaultDeposit({
      ovaultSdk,
      config: vaultConfig,
      amount,
      decimals: 6,
      userChainId,
      walletAddress: '0xYourWallet...',
      walletClient,
      publicClient,
      hubChain: mainnet,
      sourceChain: arbitrum,
    });

    console.log('Success!');
    console.log('Tx:', result.transactionHash);
    console.log('Track:', getLayerZeroScanUrl(result.transactionHash));
  } catch (error) {
    const type = classifyCrossChainError(error);
    console.error(`[${type}]`, formatCrossChainError(error.message));
  }
}