@oydual31/more-vaults-sdk
v0.6.1
Published
TypeScript SDK for MoreVaults protocol — viem/wagmi and ethers.js
Downloads
2,390
Readme
@oydual31/more-vaults-sdk
TypeScript SDK for the MoreVaults protocol. Supports viem/wagmi, ethers.js v6, and React hooks.
npm install @oydual31/more-vaults-sdkTable of contents
- Installation
- Quick start
- Module overview
- Feature parity table
- Core concepts
- Deposit flows (D1–D7)
- Redeem flows (R1–R5)
- Cross-chain flows
- Curator operations
- Vault topology & distribution
- Spoke routes
- React hooks reference
- Stargate vs Standard OFT handling
- Supported chains
- LZ timeouts
- Pre-flight validation
- Error types
Installation
npm install @oydual31/more-vaults-sdk
# or
yarn add @oydual31/more-vaults-sdk
# or
pnpm add @oydual31/more-vaults-sdkPeer dependencies (install only what you use — all are optional):
| Package | Version |
|---------|---------|
| viem | >=2 |
| ethers | >=6 |
| react | >=18 |
| wagmi | >=2 |
| @tanstack/react-query | >=5 |
Quick start
viem
import { smartDeposit, smartRedeem, getVaultStatus, waitForAsyncRequest, LZ_TIMEOUTS } from '@oydual31/more-vaults-sdk/viem'
import { createPublicClient, createWalletClient, http, parseUnits } from 'viem'
import { base } from 'viem/chains'
const VAULT = '0x8f740aba022b3fcc934ab75c581c04b75e72aba6'
const RPC = 'https://mainnet.base.org'
const publicClient = createPublicClient({ chain: base, transport: http(RPC) })
const walletClient = createWalletClient({ account, chain: base, transport: http(RPC) })
// --- Deposit 100 USDC ---
const depositResult = await smartDeposit(
walletClient, publicClient,
{ vault: VAULT },
parseUnits('100', 6), // 100 USDC
account.address,
)
if ('guid' in depositResult) {
// Async vault — wait for LZ Read callback (~5 min)
const final = await waitForAsyncRequest(publicClient, VAULT, depositResult.guid)
console.log('Shares minted:', final.result)
} else {
console.log('Shares minted:', depositResult.shares)
}
// --- Redeem shares ---
const redeemResult = await smartRedeem(
walletClient, publicClient,
{ vault: VAULT },
shares,
account.address,
account.address,
)
if ('guid' in redeemResult) {
const final = await waitForAsyncRequest(publicClient, VAULT, redeemResult.guid)
console.log('Assets received:', final.result)
} else {
console.log('Assets received:', redeemResult.assets)
}ethers.js
import { smartDeposit, smartRedeem, getVaultStatus } from '@oydual31/more-vaults-sdk/ethers'
import { Wallet, JsonRpcProvider, parseUnits } from 'ethers'
const provider = new JsonRpcProvider('https://mainnet.base.org')
const signer = new Wallet(PRIVATE_KEY, provider)
const VAULT = '0x8f740aba022b3fcc934ab75c581c04b75e72aba6'
const result = await smartDeposit(signer, { vault: VAULT }, parseUnits('100', 6), signer.address)Module overview
| Import path | Description | Dependencies |
|-------------|-------------|--------------|
| @oydual31/more-vaults-sdk/viem | Full-featured SDK — all flows, curator, topology | viem |
| @oydual31/more-vaults-sdk/ethers | Same feature set, ethers.js v6 API | ethers |
| @oydual31/more-vaults-sdk/react | React hooks built on wagmi + @tanstack/react-query | react, wagmi, @tanstack/react-query |
All three modules expose the same logical features. Choose based on your stack.
Feature parity table
| Feature | viem | ethers | react |
|---------|------|--------|-------|
| smartDeposit / useSmartDeposit | Yes | Yes | Yes |
| smartRedeem / useSmartRedeem | Yes | Yes | Yes |
| depositSimple / useDepositSimple | Yes | Yes | Yes |
| redeemShares / useRedeemShares | Yes | Yes | Yes |
| depositAsync, mintAsync | Yes | Yes | — |
| redeemAsync | Yes | Yes | — |
| depositMultiAsset | Yes | Yes | — |
| requestRedeem, getWithdrawalRequest | Yes | Yes | — |
| withdrawAssets | Yes | Yes | — |
| depositFromSpoke, depositFromSpokeAsync | Yes | Yes | — |
| quoteDepositFromSpokeFee | Yes | Yes | — |
| waitForCompose, quoteComposeFee, executeCompose | Yes | Yes | — |
| bridgeSharesToHub, bridgeAssetsToSpoke | Yes | Yes | — |
| resolveRedeemAddresses, quoteShareBridgeFee | Yes | Yes | — |
| getVaultStatus | Yes | Yes | useVaultStatus |
| getVaultMetadata | Yes | Yes | useVaultMetadata |
| getUserPosition | Yes | Yes | useUserPosition |
| getUserPositionMultiChain | Yes | Yes | useUserPositionMultiChain |
| previewDeposit, previewRedeem | Yes | Yes | — |
| canDeposit | Yes | Yes | — |
| getUserBalances, getMaxWithdrawable | Yes | Yes | — |
| getVaultSummary | Yes | Yes | — |
| quoteLzFee | Yes | Yes | useLzFee |
| getAsyncRequestStatusLabel | Yes | Yes | useAsyncRequestStatus |
| waitForAsyncRequest | Yes | — | — |
| getVaultTopology, getFullVaultTopology, discoverVaultTopology | Yes | Yes | useVaultTopology |
| isOnHubChain, getAllVaultChainIds | Yes | Yes | — |
| getVaultDistribution, getVaultDistributionWithTopology | Yes | Yes | useVaultDistribution |
| getInboundRoutes | Yes | Yes | useInboundRoutes |
| getUserBalancesForRoutes | Yes | Yes | — |
| getOutboundRoutes, quoteRouteDepositFee | Yes | Yes | — |
| getCuratorVaultStatus | Yes | Yes | useCuratorVaultStatus |
| getPendingActions | Yes | Yes | usePendingActions |
| isCurator | Yes | Yes | useIsCurator |
| getVaultAnalysis | Yes | Yes | useVaultAnalysis |
| getVaultAssetBreakdown | Yes | Yes | useVaultAssetBreakdown |
| checkProtocolWhitelist | Yes | Yes | useProtocolWhitelist |
| encodeCuratorAction, buildCuratorBatch | Yes | Yes | — |
| submitActions | Yes | Yes | useSubmitActions |
| executeActions | Yes | Yes | useExecuteActions |
| vetoActions | Yes | Yes | useVetoActions |
| buildUniswapV3Swap, encodeUniswapV3SwapCalldata | Yes | Yes | — |
| quoteCuratorBridgeFee, executeCuratorBridge | Yes | Yes | useCuratorBridgeQuote, useExecuteBridge |
| findBridgeRoute, encodeBridgeParams | Yes | Yes | — |
| getSubVaultPositions, getVaultPortfolio | Yes | Yes | useSubVaultPositions, useVaultPortfolio |
| getSubVaultInfo, detectSubVaultType | Yes | Yes | — |
| getERC7540RequestStatus | Yes | Yes | useERC7540RequestStatus |
| previewSubVaultDeposit, previewSubVaultRedeem | Yes | Yes | — |
| detectStargateOft | Yes | Yes | — |
| preflightSync, preflightAsync | Yes | Yes | — |
| preflightSpokeDeposit, preflightSpokeRedeem | Yes | Yes | — |
| preflightRedeemLiquidity | Yes | Yes | — |
| Chain constants, ABIs, error types | Yes | Yes | — |
Core concepts
Assets and shares
- Asset: the token users deposit (e.g. USDC). Always the same token in and out.
- Shares: what the vault mints when you deposit. Represent your ownership percentage. As the vault earns yield, each share becomes worth more assets. Shares are ERC-20 tokens at the vault address.
- Share price: how many assets one share is worth. Starts at 1:1 and grows over time.
Deposit 100 USDC → receive 100 shares (at launch, price = 1)
Wait 1 year → share price = 1.05
Redeem 100 shares → receive 105 USDCVault shares use more decimals than the underlying token. A vault over USDC (6 decimals) will typically have 8 decimals for shares. Always read
vault.decimals()— never hardcode it.
Hub and spoke
MoreVaults uses a hub-and-spoke model:
- Hub (
isHub = true): the chain where the vault does its accounting — mints/burns shares, accepts deposits and redemptions. - Spoke: a chain where the vault has deployed funds for yield. Users on spoke chains bridge tokens to the hub via LayerZero OFT.
If isHub = false, the vault is a single-chain vault — no cross-chain flows apply, use D1/R1.
Vault modes
Use getVaultStatus() to read the current mode:
| Mode | isHub | Oracle | Description | Applicable flows |
|------|---------|--------|-------------|-----------------|
| local | false | — | Single-chain vault. No cross-chain. | D1, D2, R1, R2 |
| cross-chain-oracle | true | ON | Hub with oracle-fed spoke balances. Synchronous like local. | D1/D3, D2, R1, R2 |
| cross-chain-async | true | OFF | Hub where spoke balances require a LZ Read query. Async deposits/redeems. | D4, D5, R5 |
| paused | — | — | No deposits or redeems accepted. | None |
| full | — | — | Deposit capacity reached. Redeems still work. | R1, R2 only |
Oracle ON vs OFF
When oraclesCrossChainAccounting = true, the vault has a configured oracle that knows the current value of spoke deployments. totalAssets() resolves instantly — flows are synchronous.
When false, the vault must query spokes via LayerZero Read to calculate share prices. Deposits and redeems are async — the user locks funds, waits for the oracle response (~1–5 min), and a keeper finalizes.
Hub liquidity and repatriation
In a cross-chain vault, the hub typically holds only a fraction of TVL as liquid assets. The rest is deployed to spoke chains.
totalAssets()= hub liquid balance + value of all spoke positions.- Redeemable now = hub liquid balance only. Attempting to redeem more than the hub holds fails.
- For async redeems (R5), a failed
executeRequestcauses the vault to refund shares — no assets are lost. - Repatriation (moving funds from spokes to the hub) is a curator-only operation.
Withdrawal queue and timelock
Some vaults require shares to be queued before redemption:
withdrawalQueueEnabled = true: users must callrequestRedeemfirst, thenredeemSharesseparately.withdrawalTimelockSeconds > 0: mandatory waiting period betweenrequestRedeemandredeemShares.
Escrow
The MoreVaultsEscrow temporarily holds user funds during async flows (D4, D5, R5). Tokens go to the escrow while the LZ Read resolves. The SDK handles the approve-to-escrow step internally.
const status = await getVaultStatus(publicClient, VAULT_ADDRESS)
const escrow = status.escrow // address(0) if not configuredVaultAddresses
interface VaultAddresses {
vault: Address // Vault address — same on every chain (CREATE3)
escrow?: Address // MoreVaultsEscrow — required for D4, D5, R5 (auto-resolved if omitted)
hubChainId?: number // Optional chain validation guard
}LayerZero EID
LayerZero identifies chains by an Endpoint ID (EID) — different from the EVM chain ID:
| Chain | Chain ID | LayerZero EID | |-------|----------|---------------| | Ethereum | 1 | 30101 | | Arbitrum | 42161 | 30110 | | Optimism | 10 | 30111 | | Base | 8453 | 30184 | | BNB Chain | 56 | 30102 | | Sonic | 146 | 30332 | | Flow EVM | 747 | 30336 |
GUID (async request ID)
When you call depositAsync, mintAsync, or redeemAsync, the function returns a guid — a bytes32 identifier for that cross-chain request:
const { guid } = await depositAsync(...)
// Wait for finalization (recommended)
const final = await waitForAsyncRequest(publicClient, VAULT, guid)
// final.status: 'completed' | 'refunded'
// final.result: exact shares minted or assets received (bigint)
// Check status once
const info = await getAsyncRequestStatusLabel(publicClient, VAULT, guid)
// info.label: 'pending' | 'fulfilled' | 'finalized' | 'refunded'Clients
viem uses two separate objects:
| Client | Role |
|--------|------|
| publicClient | Read-only. createPublicClient({ chain, transport: http(RPC_URL) }) |
| walletClient | Signs and sends transactions. createWalletClient({ account, chain, transport: http(RPC_URL) }) |
In React with wagmi:
import { usePublicClient, useWalletClient } from 'wagmi'
const publicClient = usePublicClient()
const { data: walletClient } = useWalletClient()ethers.js uses a single Signer:
// Browser
const signer = await new BrowserProvider(window.ethereum).getSigner()
// Node.js
const signer = new Wallet(PRIVATE_KEY, new JsonRpcProvider(RPC_URL))The client's chain must match the chain where the vault lives. Hub flows use the hub chain client. Spoke flows (D6/D7) use the spoke chain client.
Deposit flows
Smart flows (recommended)
smartDeposit auto-detects the vault type and routes to the correct flow:
| Vault mode | What smartDeposit calls |
|------------|--------------------------|
| local or cross-chain-oracle | depositSimple (synchronous) |
| cross-chain-async | depositAsync (async, returns guid) |
import { smartDeposit } from '@oydual31/more-vaults-sdk/viem'
const result = await smartDeposit(walletClient, publicClient, { vault: VAULT }, amount, receiver)
if ('guid' in result) {
// Async vault — poll for finalization
console.log(result.guid)
} else {
// Sync vault — shares available immediately
console.log(result.shares)
}Hub-chain deposit flows
| ID | Function | When to use |
|----|----------|-------------|
| — | smartDeposit | Recommended. Auto-detects vault type. |
| D1 | depositSimple | User on hub chain, oracle ON or local vault |
| D2 | depositMultiAsset | Deposit multiple tokens in one call |
| D3 | depositCrossChainOracleOn | Alias for D1 — hub with oracle ON |
| D4 | depositAsync | Hub with oracle OFF — async LZ Read. Returns guid. |
| D5 | mintAsync | Same as D4 but user specifies exact share amount |
D1 — Simple deposit:
import { depositSimple } from '@oydual31/more-vaults-sdk/viem'
const { txHash, shares } = await depositSimple(
walletClient, publicClient,
{ vault: VAULT },
parseUnits('100', 6),
account.address,
)D4 — Async deposit (oracle OFF):
import { depositAsync, waitForAsyncRequest, quoteLzFee } from '@oydual31/more-vaults-sdk/viem'
const lzFee = await quoteLzFee(publicClient, VAULT)
const { txHash, guid } = await depositAsync(
walletClient, publicClient,
{ vault: VAULT },
parseUnits('100', 6),
account.address,
lzFee,
)
const final = await waitForAsyncRequest(publicClient, VAULT, guid)
// final.status: 'completed' | 'refunded'
// final.result: shares minted (bigint)Redeem flows
Smart flows (recommended)
smartRedeem auto-detects the vault type and routes to the correct flow:
| Vault mode | What smartRedeem calls |
|------------|------------------------|
| local or cross-chain-oracle | redeemShares (synchronous) |
| cross-chain-async | redeemAsync (async, returns guid) |
Hub-chain redeem flows
| ID | Function | When to use |
|----|----------|-------------|
| — | smartRedeem | Recommended. Auto-detects vault type. |
| R1 | redeemShares | Standard redeem, hub chain, no queue |
| R2 | withdrawAssets | Specify exact asset amount to receive |
| R3 | requestRedeem | Withdrawal queue enabled, no timelock |
| R4 | requestRedeem | Withdrawal queue + mandatory wait period |
| R5 | redeemAsync | Hub with oracle OFF — async LZ Read. Returns guid. |
R1 — Simple redeem:
import { redeemShares } from '@oydual31/more-vaults-sdk/viem'
const { txHash, assets } = await redeemShares(
walletClient, publicClient,
{ vault: VAULT },
shares,
account.address, // receiver
account.address, // owner
)R3/R4 — Queued redeem:
import { requestRedeem, redeemShares, getWithdrawalRequest } from '@oydual31/more-vaults-sdk/viem'
// Step 1: queue the request
await requestRedeem(walletClient, publicClient, { vault: VAULT }, shares, account.address)
// Step 2: wait for timelock to expire (if configured), then redeem
// Check status
const request = await getWithdrawalRequest(publicClient, VAULT, account.address)
// Step 3: execute redeem
await redeemShares(walletClient, publicClient, { vault: VAULT }, shares, account.address, account.address)Cross-chain flows
Spoke deposit (D6 / D7)
Deposits from a spoke chain to the hub vault via LayerZero OFT Compose:
- D6 (oracle ON): composer calls
_depositAndSend— shares arrive on spoke in ~1 LZ round-trip. - D7 (oracle OFF): composer calls
_initDeposit— requires an additional LZ Read round-trip.
The interface is identical for both. The SDK detects which path the composer takes.
import {
getInboundRoutes,
quoteDepositFromSpokeFee,
depositFromSpoke,
waitForCompose,
quoteComposeFee,
executeCompose,
} from '@oydual31/more-vaults-sdk/viem'
import { LZ_EIDS } from '@oydual31/more-vaults-sdk/viem'
// 1. Discover available routes
const routes = await getInboundRoutes(hubChainId, VAULT, vaultAsset, userAddress)
// 2. Quote the LZ fee for the chosen route
const lzFee = await quoteDepositFromSpokeFee(
spokePublicClient,
VAULT,
route.spokeOft,
LZ_EIDS.BASE, // hubEid
LZ_EIDS.ETH, // spokeEid
amount,
account.address,
)
// 3. Send from spoke chain
const { txHash, guid, composeData } = await depositFromSpoke(
spokeWalletClient, spokePublicClient,
VAULT,
route.spokeOft,
LZ_EIDS.BASE, // hubEid
LZ_EIDS.ETH, // spokeEid
amount,
account.address,
lzFee,
)
// 4. For Stargate OFTs: execute the pending compose on the hub (2-TX flow)
if (composeData) {
const fullComposeData = await waitForCompose(hubPublicClient, composeData, account.address)
const composeFee = await quoteComposeFee(hubPublicClient, VAULT, LZ_EIDS.ETH, account.address)
const { txHash: composeTxHash, guid: asyncGuid } = await executeCompose(
hubWalletClient, hubPublicClient, fullComposeData, composeFee,
)
// For D7 vaults, asyncGuid is present — poll finalization
if (asyncGuid) {
const final = await waitForAsyncRequest(hubPublicClient, VAULT, asyncGuid)
}
}
// For standard OFTs: no action needed — compose executes automatically in 1 TX.Spoke redeem (3-step flow)
Full spoke redeem moves shares from spoke to hub, redeems, then bridges assets back:
Step 1 (Spoke): bridgeSharesToHub() — bridge shares spoke→hub via SHARE_OFT (~7 min)
Step 2 (Hub): smartRedeem() — redeem on hub (auto-detects async, ~5 min callback)
Step 3 (Hub): bridgeAssetsToSpoke() — bridge assets hub→spoke via Stargate/OFT (~13 min)import {
resolveRedeemAddresses,
preflightSpokeRedeem,
bridgeSharesToHub,
quoteShareBridgeFee,
smartRedeem,
bridgeAssetsToSpoke,
} from '@oydual31/more-vaults-sdk/viem'
// Pre-step: resolve all contract addresses dynamically
const addresses = await resolveRedeemAddresses(publicClient, VAULT, spokeChainId)
// Pre-step: validate balances and gas
const check = await preflightSpokeRedeem(route, shares, userAddress, shareBridgeFee)
// Step 1: bridge shares to hub
const shareFee = await quoteShareBridgeFee(spokePublicClient, VAULT, hubEid, account.address)
const { txHash } = await bridgeSharesToHub(spokeWalletClient, spokePublicClient, route, shares, account.address, shareFee)
// Step 2: redeem on hub (after shares arrive ~7 min)
const redeemResult = await smartRedeem(hubWalletClient, hubPublicClient, { vault: VAULT }, shares, account.address, account.address)
// Step 3: bridge assets back to spoke
await bridgeAssetsToSpoke(hubWalletClient, hubPublicClient, route, assets, account.address, bridgeFee)Compose helpers
| Function | Description |
|----------|-------------|
| waitForCompose | Poll for pending compose in LZ Endpoint's composeQueue. Scans ComposeSent events from hub block captured at TX1. |
| quoteComposeFee | Quote ETH needed for executeCompose (readFee + shareSendFee + 10% buffer) |
| executeCompose | Execute pending compose on hub chain. Returns { txHash, guid? } — guid present for async D7 vaults |
Curator operations
Curator operations are for vault managers, not end users. All reads are multicall-batched. All writes use the simulate-then-write pattern.
Status reads
import { getCuratorVaultStatus, getPendingActions, isCurator, getVaultAnalysis, getVaultAssetBreakdown, checkProtocolWhitelist } from '@oydual31/more-vaults-sdk/viem'
const status = await getCuratorVaultStatus(publicClient, VAULT)
// status.curator — curator address
// status.timeLockPeriod — seconds (0 = immediate execution)
// status.maxSlippagePercent — slippage limit for swaps
// status.currentNonce — latest action nonce
// status.availableAssets — whitelisted token addresses
// status.lzAdapter — cross-chain accounting manager address
// status.paused — vault paused state
const isManager = await isCurator(publicClient, VAULT, myAddress)
// Full analysis — available assets with name/symbol/decimals, depositable assets, whitelist config
const analysis = await getVaultAnalysis(publicClient, VAULT)
// analysis.availableAssets — AssetInfo[] with metadata
// analysis.depositableAssets — AssetInfo[]
// analysis.depositWhitelistEnabled
// analysis.registryAddress
// Per-asset balance breakdown on the hub
const breakdown = await getVaultAssetBreakdown(publicClient, VAULT)
// breakdown.assets — AssetBalance[] (address, name, symbol, decimals, balance)
// breakdown.totalAssets
// breakdown.totalSupply
// Check pending actions for a nonce
const pending = await getPendingActions(publicClient, VAULT, nonce)
// pending.actionsData — raw calldata bytes[]
// pending.pendingUntil — timestamp when executable
// pending.isExecutable — boolean (timelock expired)
// Check protocol whitelist
const whitelist = await checkProtocolWhitelist(publicClient, VAULT, [routerAddress])
// { '0xRouter...': true }Batch actions
Curator actions are encoded and submitted as a batch. When timeLockPeriod == 0, actions execute immediately on submission. With a timelock, they queue and must be executed separately.
import {
buildUniswapV3Swap,
encodeCuratorAction,
buildCuratorBatch,
submitActions,
executeActions,
vetoActions,
} from '@oydual31/more-vaults-sdk/viem'
// Build a Uniswap V3 swap action (router auto-resolved per chainId)
const swapAction = buildUniswapV3Swap({
chainId: 8453, // Base — uses SwapRouter02 (no deadline)
tokenIn: USDC_ADDRESS,
tokenOut: WETH_ADDRESS,
fee: 500, // 0.05% pool
amountIn: parseUnits('1000', 6),
minAmountOut: parseUnits('0.39', 18),
recipient: VAULT,
})
// Build additional actions using the discriminated union type
const depositAction: CuratorAction = {
type: 'erc4626Deposit',
vault: MORPHO_VAULT,
assets: parseUnits('500', 6),
}
// Encode and submit the batch
const batch = buildCuratorBatch([swapAction, depositAction])
const { txHash, nonce } = await submitActions(walletClient, publicClient, VAULT, batch)
// If timeLockPeriod > 0: wait for timelock, then execute
await executeActions(walletClient, publicClient, VAULT, nonce)
// Guardian: cancel pending actions
await vetoActions(guardianWalletClient, publicClient, VAULT, [nonce])Supported CuratorAction types
| Type | Description |
|------|-------------|
| swap | Single Uniswap V3 exactInputSingle swap |
| batchSwap | Multiple swaps in one action |
| erc4626Deposit | Deposit assets into an ERC-4626 vault |
| erc4626Redeem | Redeem shares from an ERC-4626 vault |
| erc7540RequestDeposit | Request deposit into an ERC-7540 async vault |
| erc7540Deposit | Finalize ERC-7540 deposit |
| erc7540RequestRedeem | Request redeem from an ERC-7540 async vault |
| erc7540Redeem | Finalize ERC-7540 redeem |
Swap helpers
buildUniswapV3Swap automatically selects the correct router and ABI variant per chain:
| Chain | Router | ABI variant |
|-------|--------|-------------|
| Base (8453) | SwapRouter02 0x2626... | No deadline field |
| Ethereum (1) | SwapRouter 0xE592... | Has deadline field |
| Arbitrum (42161) | SwapRouter 0xE592... | Has deadline field |
| Optimism (10) | SwapRouter 0xE592... | Has deadline field |
| Flow EVM (747) | FlowSwap V3 0xeEDC... | Has deadline field |
To get raw calldata without wrapping in a CuratorAction:
const { targetContract, swapCallData } = encodeUniswapV3SwapCalldata({
chainId: 8453,
tokenIn: USDC_ADDRESS,
tokenOut: WETH_ADDRESS,
fee: 500,
amountIn: parseUnits('1000', 6),
minAmountOut: 0n,
recipient: VAULT,
})Bridge operations
Curators can bridge assets between hub and spoke vaults via LayerZero. This is a direct curator call (not via multicall) — the vault pauses during bridging for security.
import {
quoteCuratorBridgeFee,
executeCuratorBridge,
findBridgeRoute,
} from '@oydual31/more-vaults-sdk/viem'
// Find the OFT route for USDC between Base and Arbitrum
const route = findBridgeRoute(8453, 42161, USDC_ADDRESS)
// route.oftSrc — OFT on source chain (Stargate USDC on Base)
// route.oftDst — OFT on destination chain
// route.symbol — 'stgUSDC'
// Quote the LayerZero fee
const fee = await quoteCuratorBridgeFee(publicClient, VAULT, {
oftToken: route.oftSrc,
dstEid: 30110, // Arbitrum LZ EID
amount: parseUnits('1000', 6), // 1000 USDC
dstVault: SPOKE_VAULT_ADDRESS,
refundAddress: curatorAddress,
})
// Execute the bridge (curator only)
const txHash = await executeCuratorBridge(
walletClient, publicClient, VAULT,
USDC_ADDRESS, // underlying ERC-20 token
{
oftToken: route.oftSrc,
dstEid: 30110,
amount: parseUnits('1000', 6),
dstVault: SPOKE_VAULT_ADDRESS,
refundAddress: curatorAddress,
},
)Sub-vault operations
Curators invest vault assets into ERC4626/ERC7540 sub-vaults (Aave, Morpho, etc.) to generate yield.
import {
getSubVaultPositions,
getVaultPortfolio,
getSubVaultInfo,
detectSubVaultType,
getERC7540RequestStatus,
previewSubVaultDeposit,
} from '@oydual31/more-vaults-sdk/viem'
// Full portfolio: liquid assets + sub-vault positions
const portfolio = await getVaultPortfolio(publicClient, VAULT)
// portfolio.liquidAssets — AssetBalance[] (tokens held directly)
// portfolio.subVaultPositions — SubVaultPosition[] (shares + underlying value)
// portfolio.totalValue — total in vault underlying units
// portfolio.lockedAssets — locked in pending ERC7540 requests
// Active sub-vault positions with current values
const positions = await getSubVaultPositions(publicClient, VAULT)
for (const p of positions) {
console.log(`${p.symbol}: ${p.sharesBalance} shares = ${p.underlyingValue} ${p.underlyingSymbol}`)
}
// Analyze a target sub-vault before investing
const info = await getSubVaultInfo(publicClient, VAULT, MORPHO_VAULT)
// info.type — 'erc4626' or 'erc7540'
// info.maxDeposit — capacity remaining
// info.isWhitelisted — must be true to invest
// Preview: how many shares would a 1000 USDC deposit yield?
const shares = await previewSubVaultDeposit(publicClient, MORPHO_VAULT, parseUnits('1000', 6))
// For ERC7540 async sub-vaults: check if requests are ready
const status = await getERC7540RequestStatus(publicClient, VAULT, ASYNC_VAULT)
if (status.canFinalizeDeposit) {
// Curator can now call erc7540Deposit via submitActions
}Vault topology & distribution
Topology
Resolve the hub/spoke structure of any vault:
import {
getVaultTopology,
getFullVaultTopology,
discoverVaultTopology,
isOnHubChain,
getAllVaultChainIds,
OMNI_FACTORY_ADDRESS,
} from '@oydual31/more-vaults-sdk/viem'
// Query from a known chain
const topo = await getVaultTopology(baseClient, VAULT)
// { role: 'hub', hubChainId: 8453, spokeChainIds: [1, 42161] }
// Query from any chain — same vault is a spoke on Ethereum
const topo2 = await getVaultTopology(ethClient, VAULT)
// { role: 'spoke', hubChainId: 8453, spokeChainIds: [1] }
// Auto-discover across all supported chains (no wallet needed)
const topo3 = await discoverVaultTopology(VAULT)
// Iterates all supported chains, finds the hub, returns full topology
// Get full spoke list — must use hub-chain client
const fullTopo = await getFullVaultTopology(baseClient, VAULT)
// Helpers
const onHub = isOnHubChain(walletChainId, topo) // boolean
const allChains = getAllVaultChainIds(topo) // [8453, 1, 42161]VaultTopology shape:
interface VaultTopology {
role: 'hub' | 'spoke' | 'local'
hubChainId: number
spokeChainIds: number[]
}Distribution
Read the cross-chain capital distribution (hub liquid, hub strategies, spoke balances):
import { getVaultDistribution, getVaultDistributionWithTopology } from '@oydual31/more-vaults-sdk/viem'
// With explicit spoke clients — reads spoke balances in parallel
const dist = await getVaultDistribution(baseClient, VAULT, {
[1]: ethClient,
[42161]: arbClient,
})
// dist.hubLiquidBalance — idle on hub (not deployed)
// dist.hubStrategyBalance — deployed to hub-side strategies (Morpho, Aave, etc.)
// dist.hubTotalAssets — hubLiquidBalance + hubStrategyBalance
// dist.spokesDeployedBalance — what hub accounting thinks is on spokes
// dist.spokeBalances — SpokeBalance[] { chainId, totalAssets, isReachable }
// dist.totalActual — hub + reachable spoke totals
// dist.oracleAccountingEnabled
// Hub-only — discovers spoke chain IDs but does not read them
const dist2 = await getVaultDistributionWithTopology(baseClient, VAULT)
// dist2.spokeChainIds — list of spoke chain IDs to query if needed
// dist2.spokeBalances === [] (empty — no spoke clients provided)Spoke routes
Discover available deposit and redeem routes across chains:
import {
getInboundRoutes,
getUserBalancesForRoutes,
getOutboundRoutes,
quoteRouteDepositFee,
NATIVE_SYMBOL,
} from '@oydual31/more-vaults-sdk/viem'
// All routes a user can deposit from
const inbound = await getInboundRoutes(hubChainId, VAULT, vaultAsset, userAddress)
// Returns InboundRoute[]:
// - depositType: 'direct' | 'direct-async' | 'oft-compose'
// - spokeChainId, spokeOft, spokeToken, hubOft
// - sourceTokenSymbol — display this to users (e.g. 'USDC', 'weETH')
// - lzFeeEstimate (using 1 USDC placeholder amount)
// - nativeSymbol — gas token for the spoke chain
// Fetch user balances for each route
const withBalances = await getUserBalancesForRoutes(inbound, userAddress)
// Adds userBalance: bigint to each route
// Precise fee quote for a real deposit amount
const fee = await quoteRouteDepositFee(route, hubChainId, amount, userAddress)
// Returns 0n for 'direct' routes (no LZ fee needed)
// All chains a user can receive assets when redeeming
const outbound = await getOutboundRoutes(hubChainId, VAULT)
// Returns OutboundRoute[]:
// - chainId, routeType: 'hub' | 'spoke', eid, nativeSymbol
// Native gas symbol per chain
NATIVE_SYMBOL[8453] // 'ETH'
NATIVE_SYMBOL[747] // 'FLOW'
NATIVE_SYMBOL[146] // 'S'
NATIVE_SYMBOL[56] // 'BNB'InboundRoute deposit types:
| depositType | User location | LZ fee | What happens |
|---------------|--------------|--------|--------------|
| direct | Hub chain, sync vault | None | Standard ERC-4626 deposit() |
| direct-async | Hub chain, async vault | Yes | depositAsync() with LZ Read |
| oft-compose | Spoke chain | Yes | OFT bridge + composer on hub |
React hooks reference
Import from @oydual31/more-vaults-sdk/react. Requires wagmi v2 + @tanstack/react-query v5.
Read hooks
| Hook | Returns | Description |
|------|---------|-------------|
| useVaultStatus(vault) | VaultStatus | Full config snapshot + recommended flow |
| useVaultMetadata(vault) | VaultMetadata | name, symbol, decimals, underlying, TVL, capacity |
| useUserPosition(vault, user) | UserPosition | shares, asset value, share price, pending withdrawal |
| useUserPositionMultiChain(vault, user) | MultiChainUserPosition | shares across hub + all spokes |
| useLzFee(vault) | bigint | Native fee required for async flows |
| useAsyncRequestStatus(vault, guid) | AsyncRequestStatusInfo | Status label for async request |
| useVaultTopology(vault) | VaultTopology | Hub/spoke chain structure |
| useVaultDistribution(vault) | VaultDistribution | TVL breakdown across chains |
| useInboundRoutes(hubChainId, vault, asset, user) | InboundRoute[] | Available deposit routes |
Action hooks
| Hook | Description |
|------|-------------|
| useSmartDeposit() | Auto-routing deposit (sync or async) |
| useSmartRedeem() | Auto-routing redeem (sync or async) |
| useDepositSimple() | D1 — simple hub deposit |
| useRedeemShares() | R1 — standard hub redeem |
| useOmniDeposit() | Full omni-chain deposit with routing |
| useOmniRedeem() | Full omni-chain redeem with routing |
Curator read hooks
| Hook | Returns | Description |
|------|---------|-------------|
| useCuratorVaultStatus(vault) | CuratorVaultStatus | Curator, timelock, nonce, assets, LZ adapter |
| useVaultAnalysis(vault) | VaultAnalysis | Available/depositable assets with metadata |
| useVaultAssetBreakdown(vault) | VaultAssetBreakdown | Per-asset balance breakdown |
| usePendingActions(vault, nonce) | PendingAction | Pending action batch with isExecutable flag |
| useIsCurator(vault, address) | boolean | Whether address is the current curator |
| useProtocolWhitelist(vault, protocols) | Record<string, boolean> | Protocol whitelist status |
Curator write hooks
| Hook | Description |
|------|-------------|
| useSubmitActions() | Submit a batch of curator actions |
| useExecuteActions() | Execute queued actions after timelock |
| useVetoActions() | Guardian: cancel pending actions |
| useCuratorBridgeQuote() | Quote LayerZero fee for curator bridge |
| useExecuteBridge() | Execute curator bridge operation |
| useSubVaultPositions() | Active sub-vault positions with values |
| useVaultPortfolio() | Full portfolio: liquid + deployed + locked |
| useERC7540RequestStatus() | Pending/claimable ERC7540 request status |
React example
import {
useVaultStatus,
useUserPosition,
useSmartDeposit,
} from '@oydual31/more-vaults-sdk/react'
import { parseUnits } from 'viem'
const VAULT = '0x8f740aba022b3fcc934ab75c581c04b75e72aba6'
function VaultDashboard() {
const { data: status } = useVaultStatus(VAULT)
const { data: position } = useUserPosition(VAULT, userAddress)
const { deposit, isPending } = useSmartDeposit()
const handleDeposit = () =>
deposit({ vault: VAULT }, parseUnits('100', 6), userAddress)
return (
<div>
<p>Mode: {status?.mode}</p>
<p>Your shares: {position?.shares?.toString()}</p>
<button onClick={handleDeposit} disabled={isPending}>Deposit 100 USDC</button>
</div>
)
}Stargate vs Standard OFT handling
The SDK auto-detects the OFT type via detectStargateOft():
| OFT type | Examples | extraOptions | Compose delivery | User action after TX1 |
|----------|----------|---------------|-----------------|----------------------|
| Stargate OFT | stgUSDC, USDT, WETH | '0x' (empty) | Compose stays pending in LZ Endpoint composeQueue | Must execute TX2 on hub: waitForCompose → executeCompose |
| Standard OFT | Custom OFT adapters | LZCOMPOSE type-3 option injected with native ETH | LZ executor forwards ETH, compose auto-executes | No action needed |
Stargate's TokenMessaging contract rejects LZCOMPOSE type-3 executor options (InvalidExecutorOption(3)). The SDK handles this transparently — depositFromSpoke returns composeData when a 2nd TX is required.
Detecting Stargate OFTs:
import { detectStargateOft } from '@oydual31/more-vaults-sdk/viem'
const isStargate = await detectStargateOft(publicClient, oftAddress)Supported chains
Chains where the MoreVaults OMNI factory is deployed (OMNI_FACTORY_ADDRESS = 0x7bDB8B17604b03125eFAED33cA0c55FBf856BB0C):
| Chain | Chain ID | LZ EID | Native gas | |-------|----------|--------|------------| | Ethereum | 1 | 30101 | ETH | | Arbitrum | 42161 | 30110 | ETH | | Optimism | 10 | 30111 | ETH | | Base | 8453 | 30184 | ETH | | BNB Chain | 56 | 30102 | BNB | | Sonic | 146 | 30332 | S | | Flow EVM | 747 | 30336 | FLOW |
import { CHAIN_IDS, LZ_EIDS, EID_TO_CHAIN_ID, CHAIN_ID_TO_EID } from '@oydual31/more-vaults-sdk/viem'
CHAIN_IDS.BASE // 8453
LZ_EIDS.BASE // 30184
EID_TO_CHAIN_ID[30184] // 8453
CHAIN_ID_TO_EID[8453] // 30184The createChainTransport and createChainClient helpers (exported from viem) build public-RPC clients for all supported chains using fallback transports:
import { createChainTransport } from '@oydual31/more-vaults-sdk/viem'
// Use with your own wallet client — useful for cross-chain flows
const transport = createChainTransport(8453)
const walletClient = createWalletClient({ account, chain: base, transport })LZ timeouts
Use these constants as timeout values in UI progress indicators:
import { LZ_TIMEOUTS } from '@oydual31/more-vaults-sdk/viem'
LZ_TIMEOUTS.POLL_INTERVAL // 30 s — balance poll interval
LZ_TIMEOUTS.OFT_BRIDGE // 15 min — standard OFT bridge (shares or assets)
LZ_TIMEOUTS.STARGATE_BRIDGE // 30 min — Stargate bridge
LZ_TIMEOUTS.LZ_READ_CALLBACK // 15 min — async deposit/redeem LZ Read callback
LZ_TIMEOUTS.COMPOSE_DELIVERY // 45 min — compose delivery to hub (spoke deposit)
LZ_TIMEOUTS.FULL_SPOKE_REDEEM // 60 min — full spoke→hub→spoke redeemDo not timeout before these values — cross-chain operations can legitimately take this long under network congestion.
Pre-flight validation
Run pre-flight checks before submitting transactions to surface issues early with clear error messages:
import {
preflightSync,
preflightAsync,
preflightRedeemLiquidity,
preflightSpokeDeposit,
preflightSpokeRedeem,
} from '@oydual31/more-vaults-sdk/viem'
// Before D1/D3 — sync hub deposit
await preflightSync(publicClient, vault, escrow)
// Validates: vault not paused, not full
// Before D4/D5/R5 — async flow
await preflightAsync(publicClient, vault, escrow)
// Validates: CCManager configured, escrow registered, isHub, oracle OFF, not paused
// Before R1/R2 — check hub has enough liquidity
await preflightRedeemLiquidity(publicClient, vault, assets)
// Throws InsufficientLiquidityError if hub liquid balance < assets
// Before spoke deposit
await preflightSpokeDeposit(...)
// Validates: spoke balance, spoke gas (LZ fee), hub composer setup
// Before spoke redeem
const check = await preflightSpokeRedeem(route, shares, userAddress, shareBridgeFee)
// Validates: shares on spoke, spoke gas, hub gas
// Returns: estimatedAssetBridgeFee, hubLiquidBalanceError types
All SDK errors extend MoreVaultsError. Import typed errors for instanceof checks:
import {
MoreVaultsError,
VaultPausedError,
CapacityFullError,
NotWhitelistedError,
InsufficientLiquidityError,
CCManagerNotConfiguredError,
EscrowNotConfiguredError,
NotHubVaultError,
MissingEscrowAddressError,
WrongChainError,
} from '@oydual31/more-vaults-sdk/viem'
try {
await smartDeposit(...)
} catch (err) {
if (err instanceof VaultPausedError) {
// vault is paused
} else if (err instanceof CapacityFullError) {
// deposit capacity reached
} else if (err instanceof InsufficientLiquidityError) {
// hub doesn't have enough liquid assets to cover the redeem
} else if (err instanceof WrongChainError) {
// wallet is on the wrong chain
}
}User helpers reference
| Function | Returns |
|----------|---------|
| getUserPosition(publicClient, vault, user) | UserPosition — shares, asset value, share price, pending withdrawal |
| getUserPositionMultiChain(hubClient, vault, user) | MultiChainUserPosition — shares across hub + all spokes |
| previewDeposit(publicClient, vault, assets) | bigint — estimated shares |
| previewRedeem(publicClient, vault, shares) | bigint — estimated assets |
| canDeposit(publicClient, vault, user) | DepositEligibility — { allowed, reason } |
| getVaultMetadata(publicClient, vault) | VaultMetadata — name, symbol, decimals, underlying, TVL, capacity |
| getVaultStatus(publicClient, vault) | VaultStatus — full config + mode + recommended flow |
| quoteLzFee(publicClient, vault) | bigint — native fee for D4/D5/R5 |
| getAsyncRequestStatusLabel(publicClient, vault, guid) | AsyncRequestStatusInfo |
| getUserBalances(publicClient, vault, user) | UserBalances — shares + underlying in one call |
| getMaxWithdrawable(publicClient, vault, user) | MaxWithdrawable — max assets given hub liquidity |
| getVaultSummary(publicClient, vault, user) | VaultSummary — metadata + status + position combined |
Repo structure
more-vaults-sdk/
├── src/
│ ├── viem/ — viem/wagmi SDK
│ ├── ethers/ — ethers.js v6 SDK
│ └── react/ — React hooks (wagmi)
├── docs/
│ ├── flows/ — per-flow detailed documentation
│ ├── user-helpers.md
│ └── testing.md
├── scripts/ — E2E test scripts (mainnet)
└── tests/ — integration tests (require Foundry + Anvil)Integration tests: bash tests/run.sh — runs the full test suite against a forked mainnet.
