From b959c87c311f854866654a49a08589cf99375854 Mon Sep 17 00:00:00 2001 From: Manank Patni Date: Thu, 15 Oct 2026 00:23:26 +0530 Subject: [PATCH] feat: add synchronous vault support --- src/components/EarnIntentWidget.tsx | 7 ++ src/components/MarketPickerPage.tsx | 48 +++++++++++--- src/components/WithdrawDetailPanel.tsx | 7 +- src/components/earn/EarnMainView.tsx | 1 + src/components/earn/VaultMarketRowCard.tsx | 19 ++++++ src/earn/api.ts | 76 +++++++++++++++++++--- src/earn/use-earn-intent-flow.ts | 46 ++++++------- src/earn/use-earn-market-picker.ts | 16 +++++ src/types.ts | 3 + 9 files changed, 178 insertions(+), 45 deletions(-) create mode 100644 src/components/earn/VaultMarketRowCard.tsx diff --git a/src/components/EarnIntentWidget.tsx b/src/components/EarnIntentWidget.tsx index 00ba718..df22a75 100644 --- a/src/components/EarnIntentWidget.tsx +++ b/src/components/EarnIntentWidget.tsx @@ -201,6 +201,9 @@ export function EarnIntentWidget(props: EarnIntentWidgetProps) { content: ( { + setEarnSelectedMarket(m); + setView("main"); + }} /> ), }; diff --git a/src/components/MarketPickerPage.tsx b/src/components/MarketPickerPage.tsx index 4c77c42..9905739 100644 --- a/src/components/MarketPickerPage.tsx +++ b/src/components/MarketPickerPage.tsx @@ -10,10 +10,12 @@ import type { } from "@epoch-protocol/epoch-flows-sdk"; import type { EpochEarnMarket, + EpochEarnVault, OneDeltaConfig, OneDeltaMarketRow, } from "../types"; import { MarketRowCard } from "./earn/MarketRowCard"; +import { VaultMarketRowCard } from "./earn/VaultMarketRowCard"; import { ArrowDownUpIcon } from "./Icons"; import { FilterDropdown, type FilterOption } from "./ui/FilterDropdown"; import { SearchInput } from "./ui/SearchInput"; @@ -29,6 +31,9 @@ interface Props { * re-sort, re-filter (except the page-local text search), or slice. */ rows: EarnMarketRow[]; + vaults?: EpochEarnVault[]; + vaultsLoading?: boolean; + vaultsError?: Error | null; selectedId?: string; isLoading: boolean; error: Error | null; @@ -37,6 +42,7 @@ interface Props { row: OneDeltaMarketRow, config: OneDeltaConfig, ) => void; + onSelectVault?: (market: EpochEarnMarket) => void; /** Chain filter — forwarded to the API as `chainId` (or all). */ chainFilter: number | "all"; @@ -172,10 +178,14 @@ function rowMatches(item: EarnMarketRow, query: string): boolean { export function MarketPickerPage({ rows, + vaults = [], + vaultsLoading = false, + vaultsError = null, selectedId, isLoading, error, onSelect, + onSelectVault, chainFilter, onChainChange, lenderFilter, @@ -241,17 +251,14 @@ export function MarketPickerPage({ () => rows.filter((item) => rowMatches(item, query)), [rows, query], ); - - if (error) { - return ( -

- Failed to load markets: {error.message} -

- ); - } + const visibleVaults = useMemo(() => { + const q = query.trim().toLowerCase(); + return vaults.filter((v) => !q || `${v.share.symbol} ${v.share.address} ${v.underlying.symbol} ${v.provider} ${v.providerName}`.toLowerCase().includes(q)); + }, [vaults, query]); return (
+ {error &&

Failed to load lending markets: {error.message}

}
- {isLoading ? ( + {error ? null : isLoading ? (
@@ -349,6 +356,29 @@ export function MarketPickerPage({
)} + +
+ Synchronous vaults +
+ {vaultsError &&

Vault listings unavailable: {vaultsError.message}

} + {vaultsLoading ? ( +
+ ) : visibleVaults.length === 0 ? ( +

No synchronous vaults found on this page.

+ ) : ( +
+ {visibleVaults.map((vault) => ( + onSelectVault?.({ + id: vault.id, displayName: vault.share.symbol, chainLabel: getEpochChainById(vault.chainId)?.name ?? `Chain ${vault.chainId}`, + aprDecimal: vault.apyPercent / 100, vaultAddress: vault.vaultAddress, underlyingAddress: vault.underlying.address, + productKind: 'vault', providerKey: vault.provider, + token: { address: vault.underlying.address, symbol: vault.underlying.symbol, decimals: vault.underlying.decimals, logoURI: vault.underlying.logoURI }, + destinationChainName: getEpochChainById(vault.chainId)?.name ?? `Chain ${vault.chainId}`, chainId: vault.chainId, + lenderName: vault.providerName, + })} /> + ))} +
+ )} ); } diff --git a/src/components/WithdrawDetailPanel.tsx b/src/components/WithdrawDetailPanel.tsx index 4541678..521755a 100644 --- a/src/components/WithdrawDetailPanel.tsx +++ b/src/components/WithdrawDetailPanel.tsx @@ -91,12 +91,9 @@ export function WithdrawDetailPanel({ approxUsd={approxUsd} /> - + {market.productKind !== 'vault' && } - {smartWithdraw && ( + {market.productKind !== 'vault' && smartWithdraw && ( ) : ( <> + {positionsState.partial &&

Some position sources did not respond. Showing available positions.

} void }) { + const chain = getEpochChainById(vault.chainId); + const provider = vault.provider === 'euler-earn' ? 'Euler Earn' : vault.provider[0].toUpperCase() + vault.provider.slice(1); + return ; +} diff --git a/src/earn/api.ts b/src/earn/api.ts index af601af..f87b038 100644 --- a/src/earn/api.ts +++ b/src/earn/api.ts @@ -4,6 +4,8 @@ import { fetchLendingPoolsPage, fetchLendingPoolsPageMulti, fetchUserPositions, + fetchVaultPositions, + fetchVaultsPage, flattenConfigsToMarkets, HARDCODED_ONEDELTA_CONFIGS, } from '@epoch-protocol/epoch-flows-sdk'; @@ -12,10 +14,13 @@ import type { PoolSortBy, PoolSortDir, } from '@epoch-protocol/epoch-flows-sdk'; +import { getEpochChainById } from '../epoch-config'; import type { ApiConfig, EpochEarnMarket, EpochEarnPosition, + EpochEarnVault, + EpochEarnVaultPosition, EpochEarnPositionsSummary, OneDeltaConfig, } from '../types'; @@ -58,6 +63,43 @@ export interface PositionsState { summary: EpochEarnPositionsSummary | null; isLoading: boolean; error: Error | null; + partial?: boolean; +} + +export interface VaultsPageState { + vaults: EpochEarnVault[]; + hasMore: boolean; + isLoading: boolean; + error: Error | null; +} + +export function useVaultsPage(opts: { + api: ApiConfig; enabled?: boolean; chainIds: number[]; start: number; limit: number; +}): VaultsPageState { + const positionsBaseUrl = opts.api.positionsBaseUrl?.replace(/\/$/, ''); + const chainIdsKey = opts.chainIds.join(','); + const [state, setState] = useState({ vaults: [], hasMore: false, isLoading: false, error: null }); + const requestId = useRef(0); + useEffect(() => { + if (!opts.enabled || !positionsBaseUrl || !chainIdsKey) { + setState({ vaults: [], hasMore: false, isLoading: false, error: null }); + return; + } + const id = ++requestId.current; + const controller = new AbortController(); + setState((old) => ({ ...old, isLoading: true, error: null })); + Promise.allSettled(chainIdsKey.split(',').map((chain) => fetchVaultsPage({ + positionsBaseUrl, chainId: Number(chain), start: opts.start, limit: opts.limit, signal: controller.signal, + }))).then((results) => { + if (id !== requestId.current) return; + const successful = results.flatMap((r) => r.status === 'fulfilled' ? [r.value] : []); + const failures = results.filter((r) => r.status === 'rejected'); + setState({ vaults: successful.flatMap((r) => r.items), hasMore: successful.some((r) => r.hasMore), isLoading: false, + error: successful.length ? null : failures.length ? new Error(String((failures[0] as PromiseRejectedResult).reason)) : null }); + }); + return () => { controller.abort(); }; + }, [positionsBaseUrl, opts.enabled, chainIdsKey, opts.start, opts.limit]); + return state; } /** @@ -434,17 +476,33 @@ export function useUserPositions(opts: { const run = async () => { try { - const { positions, summary } = await fetchUserPositions({ - address, - network, - positionsBaseUrl, - configs, - chainsOverride, - lendersOverride, - signal: controller.signal, + const lendingPromise = fetchUserPositions({ + address, network, positionsBaseUrl, configs, chainsOverride, lendersOverride, signal: controller.signal, }); + const chainIds = (chainsOverride ?? '').split(',').map(Number).filter((n) => Number.isSafeInteger(n) && n > 0); + const vaultPromise = positionsBaseUrl && network === 'mainnet' + ? fetchVaultPositions({ positionsBaseUrl, account: address, chainIds, signal: controller.signal }) + : Promise.resolve({ items: [] as EpochEarnVaultPosition[], partial: false, failedChains: [] as number[] }); + const [lendingResult, vaultResult] = await Promise.allSettled([lendingPromise, vaultPromise]); if (cancelled || id !== reqIdRef.current) return; - setState({ positions, summary, isLoading: false, error: null }); + const lending = lendingResult.status === 'fulfilled' ? lendingResult.value : { positions: [], summary: null }; + const vaults = vaultResult.status === 'fulfilled' ? vaultResult.value : { items: [], partial: true, failedChains: chainIds }; + const vaultPositions = vaults.items.filter((p) => BigInt(p.sharesRaw) > 0n).map((p) => { + const chainName = getEpochChainById(p.chainId)?.name ?? `Chain ${p.chainId}`; + const providerName = p.provider === 'euler-earn' ? 'Euler Earn' : p.provider[0].toUpperCase() + p.provider.slice(1); + const market: EpochEarnMarket = { + id: `vault:${p.chainId}:${p.vaultAddress.toLowerCase()}`, displayName: `${p.shareSymbol} · ${providerName}`, + chainLabel: chainName, aprDecimal: 0, vaultAddress: p.vaultAddress, underlyingAddress: p.underlyingAddress, + productKind: 'vault', providerKey: p.provider, + token: { address: p.underlyingAddress, symbol: p.underlyingSymbol, decimals: p.underlyingDecimals }, + destinationChainName: chainName, chainId: p.chainId, lenderName: providerName, + }; + return { id: market.id, market, shareBalanceRaw: p.sharesRaw, underlyingBalanceRaw: p.assetsRaw, + withdrawableRaw: p.assetsRaw, underlyingUsdValue: p.usdValue } as EpochEarnPosition; + }); + const lendingFailed = lendingResult.status === 'rejected'; + setState({ positions: [...lending.positions, ...vaultPositions], summary: lending.summary, isLoading: false, + error: null, partial: lendingFailed || vaults.partial || vaultResult.status === 'rejected' }); } catch (err) { if (cancelled || id !== reqIdRef.current) return; if (err instanceof DOMException && err.name === 'AbortError') return; diff --git a/src/earn/use-earn-intent-flow.ts b/src/earn/use-earn-intent-flow.ts index 8382339..206fcd8 100644 --- a/src/earn/use-earn-intent-flow.ts +++ b/src/earn/use-earn-intent-flow.ts @@ -138,6 +138,7 @@ const swLog = (stage: string, data?: Record) => { function isSmartWithdrawInput(input: EarnQuoteInput): boolean { return ( input.tab === 'withdraw' && + (input.market ?? input.position?.market)?.productKind !== 'vault' && input.smartWithdraw === true && input.smartDestChainId != null && (!!input.smartDestTokenAddress || !!input.midenDest) @@ -346,8 +347,9 @@ export function useEarnIntentFlow({ (input: EarnQuoteInput) => { const market = input.market ?? input.position?.market; if (!market) throw new Error('Market missing'); + const isVault = market.productKind === 'vault'; const marketUid = market.oneDeltaMarketUid; - if (!marketUid) throw new Error('Market is missing the 1delta marketUid'); + if (!isVault && !marketUid) throw new Error('Market is missing the 1delta marketUid'); const sourceToken = input.sourceToken; if (!sourceToken) throw new Error('Source token missing'); @@ -359,7 +361,7 @@ export function useEarnIntentFlow({ // tuple and settle against another. Fail fast at intent-build time with // a message that points at the offending field — easier than chasing a // revert in the solver. - const muidParts = marketUid.split(':'); + const muidParts = marketUid?.split(':') ?? []; const muidChain = muidParts[1]; const muidUnderlying = muidParts[2]; if ( @@ -384,11 +386,15 @@ export function useEarnIntentFlow({ const isWithdraw = input.tab === 'withdraw'; const isMidenDeposit = !isWithdraw && !!input.midenSource; + if (isVault && input.midenSource) throw new Error('Synchronous vault deposits require an EVM source token'); - const protocolHashIdentifier = keccak256(toBytes(earnProtocolName(marketUid))); - const underlyingAddress = market.token.address as string; - const actionChainId = - market.chainId != null ? String(market.chainId) : muidChain ?? '1'; + const protocolHashIdentifier = keccak256(toBytes(isVault ? ONEDELTA_PROTOCOL_NAME : earnProtocolName(marketUid!))); + const underlyingAddress = (isVault ? market.underlyingAddress : market.token.address) as string; + if (isVault && (!underlyingAddress || !/^0x[\da-f]{40}$/i.test(underlyingAddress) || !/^0x[\da-f]{40}$/i.test(market.vaultAddress))) { + throw new Error('Vault selection is missing valid vault or underlying addresses'); + } + const actionChainId = market.chainId != null ? String(market.chainId) : muidChain ?? '1'; + if (isVault && market.chainId == null) throw new Error('Vault selection is missing its chain ID'); // Smart Withdraw is handled by `submitSmartWithdraw` (a 2-leg batch: // withdraw → cross-chain swap), so buildParams only ever builds the plain @@ -407,18 +413,16 @@ export function useEarnIntentFlow({ const baseTypestring = isWithdraw ? WITHDRAW_EXTRADATA_TYPESTRING : DEPOSIT_EXTRADATA_TYPESTRING; - const extraDataTypestring = isMidenDeposit - ? `${baseTypestring},${EARN_MIDEN_EXTRA_FIELDS.join(',')}` - : baseTypestring; - - const extraData: Record = { - marketUid, - action: input.tab, - payAsset, - // Declared by the canonical typestring, so it must be present. Pinned - // false: a full-exit withdraw resolves its size at execution, and - // deposits pass a real amount either way. - isAll: false, + const vaultTypestring = 'string productKind,string action,uint256 chainId,address vault,address underlying,address payAsset,bool isAll'; + const extraDataTypestring = isVault + ? vaultTypestring + : isMidenDeposit ? `${baseTypestring},${EARN_MIDEN_EXTRA_FIELDS.join(',')}` : baseTypestring; + + const extraData: Record = isVault ? { + productKind: 'vault', action: input.tab, chainId: Number(actionChainId), + vault: market.vaultAddress, underlying: underlyingAddress, payAsset, isAll: false, + } : { + marketUid: marketUid!, action: input.tab, payAsset, isAll: false, }; if (isMidenDeposit && input.midenSource) { extraData.midenSourceAccount = normalizeMidenId(input.midenSource.accountId); @@ -427,9 +431,7 @@ export function useEarnIntentFlow({ extraData.midenNoteId = ''; } - const tokenInDecimals = isMidenDeposit - ? input.midenSource!.decimals - : sourceToken.decimals; + const tokenInDecimals = isMidenDeposit ? input.midenSource!.decimals : isWithdraw ? market.token.decimals : sourceToken.decimals; const tokenInAmount = parseUnits( input.amount.trim().replace(/,/g, ''), tokenInDecimals, @@ -437,7 +439,7 @@ export function useEarnIntentFlow({ const tokenInAddress = isMidenDeposit ? (EVM_ZERO_ADDRESS as `0x${string}`) - : (sourceToken.address as `0x${string}`); + : (isWithdraw ? underlyingAddress : sourceToken.address) as `0x${string}`; return { tokenInAmount, diff --git a/src/earn/use-earn-market-picker.ts b/src/earn/use-earn-market-picker.ts index 00874eb..67bda5a 100644 --- a/src/earn/use-earn-market-picker.ts +++ b/src/earn/use-earn-market-picker.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; import type { + EpochEarnVault, EarnMarketRow, PoolSortBy, PoolSortDir, @@ -9,6 +10,7 @@ import { DEFAULT_EARN_CONFIGS, useEarnConfigs, useLendingPoolsPage, + useVaultsPage, } from './api'; import { ALL_LENDERS, @@ -42,6 +44,9 @@ export interface UseEarnMarketPickerOptions { export interface EarnMarketPicker { rows: EarnMarketRow[]; + vaults: EpochEarnVault[]; + vaultsLoading: boolean; + vaultsError: Error | null; hasMore: boolean; isLoading: boolean; error: Error | null; @@ -162,6 +167,14 @@ export function useEarnMarketPicker({ // once the upstream behavior is stable. }); + const vaultsPage = useVaultsPage({ + api, + enabled: enabled && useLivePools, + chainIds: requestedChainIds, + start: page * MARKETS_PAGE_SIZE, + limit: MARKETS_PAGE_SIZE, + }); + const staticConfigsState = useEarnConfigs({ enabled: configsEnabled && !useLivePools, source: earnMarketsSource ?? DEFAULT_EARN_CONFIGS, @@ -246,6 +259,9 @@ export function useEarnMarketPicker({ return { rows: source.rows, + vaults: vaultsPage.vaults, + vaultsLoading: vaultsPage.isLoading, + vaultsError: vaultsPage.error, hasMore: source.hasMore, isLoading: source.isLoading, error: source.error, diff --git a/src/types.ts b/src/types.ts index 198268a..f91b4da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,9 @@ export type { EpochChain, EpochEarnMarket, EpochEarnPosition, + EpochEarnVault, + EpochEarnVaultPosition, + EpochEarnVaultProvider, EpochEarnPositionsSummary, EpochToken, IntentCompletePayload,