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; // 30390Quote a Cross-Chain Deposit
Estimate fees and expected output before executing. Does not send a transaction.
quoteCrossChainDeposit(props: IQuoteCrossChainDepositRequest): Promise<IQuoteCrossChainResult | null>Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
ovaultSdk | IOVaultSdkDependency | Yes | Injected LayerZero SDK dependency |
config | ICrossChainVaultConfig | Yes | Vault deployment configuration |
amount | string | Yes | Human-readable deposit amount |
decimals | number | Yes | Token decimals |
userChainId | number | Yes | Chain the user is depositing from |
walletAddress | string | Yes | User's wallet address |
hubChain | Chain | Yes | viem Chain object for hub chain |
sourceChain | Chain | Yes | viem Chain object for source chain |
destinationChainId | number | No | Chain to receive shares (defaults to user's) |
slippageBps | number | No | Slippage in basis points (default: 100 = 1%) |
feeBufferPercent | number | No | Buffer 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
| Parameter | Type | Required | Description |
|---|---|---|---|
ovaultSdk | IOVaultSdkDependency | Yes | Injected LayerZero SDK dependency |
config | ICrossChainVaultConfig | Yes | Vault deployment configuration |
operation | 'DEPOSIT' | 'REDEEM' | Yes | Operation type |
amount | string | number | bigint | Yes | Amount to deposit or redeem |
decimals | number | Yes | Token decimals |
userChainId | number | Yes | Chain the user is currently on |
walletAddress | string | Yes | User's wallet address |
hubChain | Chain | Yes | viem Chain object for hub chain |
sourceChain | Chain | Yes | viem Chain object for source chain |
destinationChainId | number | No | Destination chain (defaults to user's chain) |
recipientAddress | string | No | Recipient address (defaults to walletAddress) |
slippageBps | number | No | Slippage in basis points (1-10000, default: 100) |
feeBufferPercent | number | No | Buffer on LZ fees (default: 50) |
hubLzComposeGasLimit | bigint | No | Gas 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)
| Parameter | Type | Required | Description |
|---|---|---|---|
walletClient | WalletClient | Yes | viem WalletClient for signing |
publicClient | PublicClient | Yes | viem PublicClient for chain reads |
skipApprovalCheck | boolean | No | Skip approval (if caller handles it) |
gasBufferPercent | number | No | Gas 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)
| Parameter | Type | Required | Description |
|---|---|---|---|
walletClient | WalletClient | Yes | viem WalletClient for signing |
publicClient | PublicClient | Yes | viem PublicClient for chain reads |
skipApprovalCheck | boolean | No | Skip approval (if caller handles it) |
gasBufferPercent | number | No | Gas multiplier (default: 200 = 2x) |
minGasLimit | bigint | No | Floor 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
| Type | Description | Example Trigger |
|---|---|---|
max-deposit | Vault deposit cap exceeded | ERC4626ExceededMaxDeposit |
nonce | Transaction nonce conflict | invalid nonce |
no-peer | Missing LayerZero peer configuration | NoPeer / 0xf6ff4fb7 |
bridge-fee | LayerZero fee estimation failed | quoteSend errors |
insufficient-balance | Not enough tokens | insufficient balance |
user-rejected | User cancelled in wallet | user rejected |
gas | Gas estimation or limit error | out of gas |
network | Network connectivity issue | connection refused |
unknown | Unrecognized error | Any 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));
}
}Sui Actions
Read, deposit into and instantly redeem from Upshift Sui vaults. Write operations build an unsigned transaction that the user's wallet signs; the SDK can then execute it and wait for confirmation.
ABI Functions
Contract ABIs bundled with the Upshift SDK — vaults, receipt tokens, the swap router, loans, rewards, and the infrastructure contracts the SDK reads.