From 161640e8e6f4d1dfa67bbb8f3709b90bddf423fc Mon Sep 17 00:00:00 2001 From: Timidan Date: Sat, 23 May 2026 05:37:30 +0100 Subject: [PATCH 1/7] feat: implement LI.FI intents API integration and enhance composer route handling - Added intentsApi.ts for handling intents-related API calls. - Introduced useIntentOrderStatus.ts for managing intent order status with React Query. - Created withdrawComposerRoute.ts to manage the withdrawal process through composer routes. - Enhanced error handling and user feedback during the withdrawal process. - Updated earnApi.ts to separate quote URL building from fetching logic. - Improved type definitions in types.ts and added nullable symbol handling for EarnToken. - Implemented nonce generation for unique order identification in nonce.ts. - Added deadline management for orders in deadlines.ts. - Created standardOrder.ts for building and managing standard order structures. - Updated Vercel and Vite configurations to support new API endpoints. --- .codegraph/.gitignore | 16 + api/lifi-intents.ts | 191 +++ edb | 2 +- .../integrations/lifi-earn/DepositFlow.tsx | 655 +++++++++- .../lifi-earn/IntentBridgeStep.tsx | 885 +++++++++++++ .../lifi-earn/IntentStatusTimeline.tsx | 217 ++++ .../integrations/lifi-earn/TokenIcon.tsx | 7 +- .../integrations/lifi-earn/VaultList.tsx | 5 +- .../integrations/lifi-earn/WithdrawFlow.tsx | 1145 +++++++++++++++-- .../lifi-earn/WithdrawIntentRouteStep.tsx | 560 ++++++++ .../lifi-earn/concierge/ExecutionQueue.tsx | 222 +++- .../lifi-earn/concierge/FlowDiagram.tsx | 24 +- .../lifi-earn/concierge/IdleSweepPanel.tsx | 21 +- .../lifi-earn/concierge/LlmErrorAlert.tsx | 6 +- .../concierge/VaultRecommendations.tsx | 4 +- .../lifi-earn/concierge/executionMachine.ts | 291 ++++- .../lifi-earn/concierge/fallback.ts | 20 +- .../concierge/intent/IntentPanel.tsx | 130 +- .../concierge/intent/RebalancePlanCard.tsx | 566 ++++++++ .../intent/hooks/useIntentRecommendation.ts | 1 + .../intent/hooks/useVaultsByIntent.ts | 6 +- .../lifi-earn/concierge/intent/intentLegs.ts | 216 ++++ .../concierge/intent/useIntentLegPipeline.ts | 578 +++++++++ .../integrations/lifi-earn/concierge/types.ts | 65 + .../lifi-earn/crossChainComposerDeposit.ts | 773 +++++++++++ .../lifi-earn/destinationTokenOptions.ts | 100 ++ .../integrations/lifi-earn/earnApi.ts | 43 +- .../lifi-earn/hooks/useWithdrawQuote.ts | 3 + .../integrations/lifi-earn/intentsApi.ts | 224 ++++ .../integrations/lifi-earn/txUtils.ts | 92 ++ .../integrations/lifi-earn/types.ts | 5 +- .../lifi-earn/useIntentOrderStatus.ts | 58 + .../lifi-earn/withdrawComposerRoute.ts | 317 +++++ src/lib/intents/addressBytes.ts | 12 + src/lib/intents/contracts.ts | 128 ++ src/lib/intents/deadlines.ts | 45 + src/lib/intents/eip7930.ts | 27 + src/lib/intents/nonce.ts | 15 + src/lib/intents/standardOrder.ts | 95 ++ vercel.json | 4 + vite.config.ts | 9 + 41 files changed, 7557 insertions(+), 226 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 api/lifi-intents.ts create mode 100644 src/components/integrations/lifi-earn/IntentBridgeStep.tsx create mode 100644 src/components/integrations/lifi-earn/IntentStatusTimeline.tsx create mode 100644 src/components/integrations/lifi-earn/WithdrawIntentRouteStep.tsx create mode 100644 src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx create mode 100644 src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts create mode 100644 src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts create mode 100644 src/components/integrations/lifi-earn/crossChainComposerDeposit.ts create mode 100644 src/components/integrations/lifi-earn/destinationTokenOptions.ts create mode 100644 src/components/integrations/lifi-earn/intentsApi.ts create mode 100644 src/components/integrations/lifi-earn/useIntentOrderStatus.ts create mode 100644 src/components/integrations/lifi-earn/withdrawComposerRoute.ts create mode 100644 src/lib/intents/addressBytes.ts create mode 100644 src/lib/intents/contracts.ts create mode 100644 src/lib/intents/deadlines.ts create mode 100644 src/lib/intents/eip7930.ts create mode 100644 src/lib/intents/nonce.ts create mode 100644 src/lib/intents/standardOrder.ts diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/api/lifi-intents.ts b/api/lifi-intents.ts new file mode 100644 index 0000000..215d511 --- /dev/null +++ b/api/lifi-intents.ts @@ -0,0 +1,191 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import * as crypto from "crypto"; + +export const config = { + api: { bodyParser: false }, + maxDuration: 30, +}; + +const ORDER_BASE = "https://order.li.fi"; + +// Allowlist keeps solver-only endpoints out of the browser surface; they'd +// 401 upstream, but a clean 404 here is friendlier and saves a round trip. +const ALLOWED_PATHS = new Set([ + "quote/request", + "orders/submit", + "orders/status", + "orders", + "routes", + "chains/supported", +]); + +const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]); +const ALLOWED_ORIGINS = new Set( + (process.env.ALLOWED_ORIGINS || "").split(",").filter(Boolean), +); +const PROXY_SECRET = process.env.PROXY_SECRET || ""; + +// Real /quote/request payloads are ~1-2 KiB; cap at 16 KiB to bound serverless +// CPU on abuse traffic. +const MAX_BODY_BYTES = 16 * 1024; + +// Best-effort per-IP rate limit (resets on cold start). Friction, not auth. +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX = 120; +const rateBuckets = new Map(); + +function rateLimit(req: VercelRequest): boolean { + const fwd = req.headers["x-forwarded-for"]; + const ip = (Array.isArray(fwd) ? fwd[0] : fwd ?? req.socket?.remoteAddress ?? "") + .toString() + .split(",")[0] + .trim(); + if (!ip) return true; // can't identify caller — let it through, upstream will protect + const now = Date.now(); + const slot = rateBuckets.get(ip); + if (!slot || slot.resetAt < now) { + rateBuckets.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); + return true; + } + if (slot.count >= RATE_LIMIT_MAX) return false; + slot.count += 1; + return true; +} + +function getAllowedOrigin(req: VercelRequest): string | null { + const origin = req.headers.origin; + if (!origin) return null; + if (ALLOWED_ORIGINS.has(origin)) return origin; + if (origin.startsWith("http://localhost:")) return origin; + const host = req.headers.host; + if (host && origin === `https://${host}`) return origin; + return null; +} + +function hasValidSecret(req: VercelRequest): boolean { + if (!PROXY_SECRET) return false; + const header = req.headers["x-proxy-secret"]; + if (typeof header !== "string") return false; + const a = Buffer.from(header); + const b = Buffer.from(PROXY_SECRET); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +class BodyTooLargeError extends Error { + constructor() { + super("body_too_large"); + } +} + +async function readBody(req: VercelRequest, cap: number): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const buf = Buffer.from(chunk); + total += buf.length; + if (total > cap) throw new BodyTooLargeError(); + chunks.push(buf); + } + return Buffer.concat(chunks).toString("utf8"); +} + +export default async function handler( + req: VercelRequest, + res: VercelResponse, +) { + const allowedOrigin = getAllowedOrigin(req); + + if (req.method === "OPTIONS") { + if (allowedOrigin) { + res.setHeader("Access-Control-Allow-Origin", allowedOrigin); + } + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, x-proxy-secret", + ); + return res.status(204).end(); + } + + if (PROXY_SECRET) { + if (!hasValidSecret(req)) { + return res.status(403).json({ error: "Forbidden" }); + } + } else { + // Without PROXY_SECRET we require a known Origin — rejecting missing-Origin + // requests (curl, server-to-server) keeps the public endpoint scrape-resistant. + if (!allowedOrigin || !req.headers.origin) { + return res.status(403).json({ error: "Origin required" }); + } + } + + if (!ALLOWED_METHODS.has(req.method || "")) { + return res.status(405).json({ error: "Method not allowed" }); + } + + if (!rateLimit(req)) { + return res.status(429).json({ error: "rate_limited" }); + } + + const pathParam = req.query?.path; + const subPath = ( + Array.isArray(pathParam) + ? pathParam.join("/") + : typeof pathParam === "string" + ? pathParam + : "" + ).replace(/^\/+/, ""); + + if (!ALLOWED_PATHS.has(subPath)) { + return res.status(404).json({ error: "unsupported_intents_path" }); + } + + const params = new URLSearchParams(); + for (const [key, val] of Object.entries(req.query || {})) { + if (key === "path") continue; + if (Array.isArray(val)) { + val.forEach((v) => params.append(key, v)); + } else if (typeof val === "string") { + params.append(key, val); + } + } + const qs = params.toString(); + + const upstream = `${ORDER_BASE}/${subPath}${qs ? `?${qs}` : ""}`; + const method = (req.method || "GET").toUpperCase(); + let body: string | undefined; + if (method === "POST") { + try { + body = await readBody(req, MAX_BODY_BYTES); + } catch (err) { + if (err instanceof BodyTooLargeError) { + return res.status(413).json({ error: "body_too_large", maxBytes: MAX_BODY_BYTES }); + } + throw err; + } + } + + try { + const upstreamRes = await fetch(upstream, { + method, + headers: { + Accept: "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body, + signal: AbortSignal.timeout(25_000), + }); + + const text = await upstreamRes.text(); + + if (allowedOrigin) { + res.setHeader("Access-Control-Allow-Origin", allowedOrigin); + } + res.setHeader("Content-Type", "application/json"); + return res.status(upstreamRes.status).send(text); + } catch (err) { + console.error("[lifi-intents] upstream error:", err); + return res.status(502).json({ error: "Upstream request failed" }); + } +} diff --git a/edb b/edb index 1ba2fca..c5b32ca 160000 --- a/edb +++ b/edb @@ -1 +1 @@ -Subproject commit 1ba2fcaca73cee96bf10107b7b0f98ab1ceab1a4 +Subproject commit c5b32ca53e7c2146e647830af486b6481aa37548 diff --git a/src/components/integrations/lifi-earn/DepositFlow.tsx b/src/components/integrations/lifi-earn/DepositFlow.tsx index d2817b7..05d717d 100644 --- a/src/components/integrations/lifi-earn/DepositFlow.tsx +++ b/src/components/integrations/lifi-earn/DepositFlow.tsx @@ -33,6 +33,15 @@ import { fetchComposerQuote } from "./earnApi"; import { useTokenAllowance } from "./hooks/useTokenAllowance"; import { useTokenBalance } from "./hooks/useTokenBalance"; import { TokenIcon } from "./TokenIcon"; +import { IntentBridgeStep } from "./IntentBridgeStep"; +import { useIdleBalances } from "./concierge/hooks/useIdleBalances"; +import { + executeCrossChainComposerDeposit, + type CrossChainDepositState, +} from "./crossChainComposerDeposit"; +import { getAccount as wagmiGetAccount } from "@wagmi/core"; +import type { Address } from "viem"; +import type { DepositExecutionEvent } from "./concierge/types"; import type { EarnToken, EarnVault } from "./types"; import { formatTxError, shortAddress, isNativeToken } from "./txUtils"; import EdbBadge from "../../EdbBadge"; @@ -175,6 +184,7 @@ interface DepositFlowProps { onBroadcast?: (txHash: string) => void; onConfirmed?: () => void; onError?: (message: string) => void; + onExecutionEvent?: (event: DepositExecutionEvent) => void; } @@ -184,15 +194,12 @@ export function DepositFlow({ onBroadcast, onConfirmed, onError, + onExecutionEvent, }: DepositFlowProps) { const { address, isConnected, chain: walletChain } = useAccount(); const wagmiConfig = useConfig(); const { switchChainAsync } = useSwitchChain(); - const fromChainForQuote = override?.fromChain ?? vault.chainId; - - const supportedChain = SUPPORTED_CHAINS.find((c) => c.id === fromChainForQuote); - const underlyingTokens = useMemo( () => vault.underlyingTokens ?? [], [vault.underlyingTokens], @@ -202,11 +209,41 @@ export function DepositFlow({ // Stable symbol list for the Composer error hint — avoids busting the // react-query cache with a new array reference every render. const underlyingSymbols = useMemo( - () => underlyingTokens.map((t) => t.symbol), + () => underlyingTokens.flatMap((t) => (t.symbol ? [t.symbol] : [])), [underlyingTokens], ); - const tokens = useMemo(() => { + // Pull wallet-wide idle balances so the picker can offer cross-chain sources + // (e.g. USDC on Base when the user is opening a USDT vault on Polygon). + // Skipped when an override is present — that path already nails the source. + const { idleAssets } = useIdleBalances( + override ? null : (address ?? null), + ); + + // Cross-chain idle balances that the LI.FI Intent path can actually use: + // - chain ≠ vault chain (same-chain already covered by the existing groups) + // - non-native (Intents escrow is ERC-20-only; native shows as 'unsupported') + // - balance > 0 + const crossChainHoldings = useMemo(() => { + if (override) return []; + return idleAssets + .filter((a) => a.chainId !== vault.chainId) + .filter((a) => !isNativeToken(a.token.address)) + .filter((a) => { + try { + return BigInt(a.amountRaw) > 0n; + } catch { + return false; + } + }) + .sort((a, b) => (b.amountUsd ?? 0) - (a.amountUsd ?? 0)) + .map((a) => ({ + ...a.token, + chainId: a.chainId, + })); + }, [idleAssets, vault.chainId, override]); + + const sameChainTokens = useMemo(() => { const seen = new Set(underlyingTokens.map((t) => t.address.toLowerCase())); const extras = getCommonTokensForChain(vault.chainId).filter( (t) => !seen.has(t.address.toLowerCase()), @@ -214,6 +251,16 @@ export function DepositFlow({ return [...underlyingTokens, ...extras]; }, [underlyingTokens, vault.chainId]); + const tokens = useMemo( + () => [...sameChainTokens, ...crossChainHoldings], + [sameChainTokens, crossChainHoldings], + ); + + // Disambiguate same-address-different-chain entries (e.g. USDC.e exists on + // multiple chains) in the dropdown by chainId:address. + const tokenKey = (t: EarnToken) => + `${t.chainId ?? vault.chainId}:${t.address.toLowerCase()}`; + const forcedToken = override?.fromToken ?? null; const forcedAmountRaw = override?.fromAmountRaw ?? null; @@ -225,6 +272,16 @@ export function DepositFlow({ const [selectedToken, setSelectedToken] = useState( forcedToken ?? firstToken ); + + // fromChainForQuote follows the picked token's chainId so a cross-chain + // pick (e.g. "USDC on Base" while the vault lives on Polygon) automatically + // routes through the Intent bridge. + const fromChainForQuote = + override?.fromChain ?? selectedToken?.chainId ?? vault.chainId; + + const supportedChain = SUPPORTED_CHAINS.find( + (c) => c.id === fromChainForQuote, + ); const [flowState, setFlowState] = useState("idle"); const [simResult, setSimResult] = useState(null); const [errorMsg, setErrorMsg] = useState(null); @@ -234,6 +291,27 @@ export function DepositFlow({ }); const [simulateFirst, setSimulateFirst] = useState(false); const [twoStepLabel, setTwoStepLabel] = useState(null); + // Cross-chain Composer 2-tx state (bridge fromToken on chain A → underlying + // on chain B, then deposit underlying into vault). Only meaningful when + // useIntents is OFF and fromChainForQuote !== vault.chainId. + const [crossChainState, setCrossChainState] = useState({ + phase: "idle", + }); + const destinationUnderlying = underlyingTokens[0] ?? null; + + function emitExecutionEvent(event: DepositExecutionEvent) { + onExecutionEvent?.(event); + } + + // When source chain ≠ vault chain we can route the funding leg through + // LI.FI Intents instead of Composer. Default to Intents because that's + // where the UX win lives (visible solver lifecycle + faster settlement). + // Users can flip back to Composer with the toggle below. + const isCrossChain = fromChainForQuote !== vault.chainId; + const [useIntents, setUseIntents] = useState(isCrossChain); + useEffect(() => { + setUseIntents(isCrossChain); + }, [isCrossChain]); // Reset state when vault/override changes so reopening the drawer for a // different vault doesn't leak stale state. @@ -286,7 +364,15 @@ export function DepositFlow({ toAddress: address ?? "", fromAmount: fromAmountRaw?.toString() ?? "0", underlyingSymbols, - enabled: isConnected && !!fromAmountRaw && fromAmountRaw.gt(0), + // Cross-chain uses the dedicated 2-tx flow (bridge → deposit), which + // manages its own quotes. Don't fire the single-tx quote that's + // guaranteed to 1001 — it just clutters the UI with a red error and + // produces a stale `quote` for the spender check + needsApproval logic. + enabled: + isConnected && + !!fromAmountRaw && + fromAmountRaw.gt(0) && + !(!useIntents && fromChainForQuote !== vault.chainId), }); // When Composer can't route fromToken → vault directly (1002), detect if a @@ -761,6 +847,11 @@ export function DepositFlow({ setTxHash(hash); onBroadcast?.(hash); + emitExecutionEvent({ + type: "tx-broadcast", + phase: "same-chain", + txHash: hash, + }); const receipt = await wagmiWaitForReceipt(wagmiConfig, { hash, @@ -774,15 +865,146 @@ export function DepositFlow({ setFlowState("success"); refetchBalance(); + emitExecutionEvent({ + type: "confirmed", + phase: "same-chain", + txHash: hash, + }); onConfirmed?.(); } catch (err: unknown) { const msg = formatTxError(err); setErrorMsg(msg); setFlowState("error"); + emitExecutionEvent({ + type: "failed", + phase: "same-chain", + message: msg, + }); onError?.(msg); } } + async function handleCrossChainExecute( + opts?: { resume?: { bridgeTxHash: string; destinationAmountRaw: string } }, + ) { + if (!address || !selectedToken || !fromAmountRaw || !destinationUnderlying) return; + + setErrorMsg(null); + setFlowState("executing"); + + let lastBroadcastHash: string | null = null; + + try { + await executeCrossChainComposerDeposit({ + wagmiConfig, + sourceChainId: fromChainForQuote, + sourceToken: selectedToken, + sourceAmountRaw: fromAmountRaw.toString(), + vault, + destinationUnderlying, + userAddress: address as Address, + onStateChange: (s) => { + setCrossChainState(s); + if ( + (s.phase === "bridging" || s.phase === "bridge-settled") && + s.bridgeTxHash && + s.bridgeTxHash !== lastBroadcastHash + ) { + lastBroadcastHash = s.bridgeTxHash; + setTxHash(s.bridgeTxHash); + onBroadcast?.(s.bridgeTxHash); + emitExecutionEvent({ + type: "tx-broadcast", + phase: "composer-bridge", + txHash: s.bridgeTxHash, + }); + } + if (s.phase === "bridging") { + emitExecutionEvent({ + type: "bridge-status", + phase: "composer-bridge", + status: s.bridgeStatus ?? "PENDING", + txHash: s.bridgeTxHash, + substatus: s.bridgeSubstatus, + }); + } + if (s.phase === "bridge-settled") { + emitExecutionEvent({ + type: "bridge-status", + phase: "composer-bridge", + status: "DONE", + txHash: s.bridgeTxHash, + }); + emitExecutionEvent({ + type: "delivered", + phase: "composer-bridge", + txHash: s.bridgeTxHash, + amountRaw: s.destinationAmountRaw, + }); + } + if ( + s.phase === "depositing" && + s.depositTxHash && + s.depositTxHash !== lastBroadcastHash + ) { + lastBroadcastHash = s.depositTxHash; + setTxHash(s.depositTxHash); + onBroadcast?.(s.depositTxHash); + emitExecutionEvent({ + type: "tx-broadcast", + phase: "composer-deposit", + txHash: s.depositTxHash, + }); + } + if (s.phase === "done") { + emitExecutionEvent({ + type: "confirmed", + phase: "composer-deposit", + txHash: s.depositTxHash, + }); + } + if (s.phase === "failed") { + emitExecutionEvent({ + type: "failed", + phase: s.failedAfterBridge ? "composer-deposit" : "composer-bridge", + message: s.message, + recoverable: s.failedAfterBridge, + txHash: s.failedAfterBridge ? undefined : s.bridgeTxHash, + }); + } + }, + switchChain: async (chainId) => { + // Read live, not from the render-captured `walletChain`. Without this + // the second-stage switch (back to vault chain after bridge step + // moved us to source chain) silently no-ops and the deposit prompts + // on the wrong chain. + const current = wagmiGetAccount(wagmiConfig).chainId; + if (current !== chainId) { + await switchChainAsync({ chainId }); + } + }, + resumeFromBridgeSettled: opts?.resume, + }); + + setFlowState("success"); + refetchBalance(); + onConfirmed?.(); + } catch (err: unknown) { + const msg = formatTxError(err); + setCrossChainState((prev) => { + const recoverable = prev.phase === "failed" && prev.failedAfterBridge; + if (!recoverable) { + setErrorMsg(msg); + setFlowState("error"); + onError?.(msg); + } else { + setFlowState("idle"); + } + return prev; + }); + } + } + async function handleTwoStepExecute() { if (!address || !selectedToken || !fromAmountRaw) return; const underlying = underlyingTokens[0]; @@ -852,6 +1074,11 @@ export function DepositFlow({ setTxHash(swapHash); onBroadcast?.(swapHash); + emitExecutionEvent({ + type: "tx-broadcast", + phase: "same-chain", + txHash: swapHash, + }); setTwoStepLabel("Confirming swap…"); const swapReceipt = await wagmiWaitForReceipt(wagmiConfig, { @@ -937,6 +1164,11 @@ export function DepositFlow({ }); setTxHash(depositHash); + emitExecutionEvent({ + type: "tx-broadcast", + phase: "same-chain", + txHash: depositHash, + }); setTwoStepLabel("Confirming deposit…"); const depositReceipt = await wagmiWaitForReceipt(wagmiConfig, { @@ -951,12 +1183,22 @@ export function DepositFlow({ setFlowState("success"); setTwoStepLabel(null); refetchBalance(); + emitExecutionEvent({ + type: "confirmed", + phase: "same-chain", + txHash: depositHash, + }); onConfirmed?.(); } catch (err: unknown) { const msg = formatTxError(err); setErrorMsg(msg); setFlowState("error"); setTwoStepLabel(null); + emitExecutionEvent({ + type: "failed", + phase: "same-chain", + message: msg, + }); onError?.(msg); } } @@ -1071,9 +1313,9 @@ export function DepositFlow({
{ setAmount(e.target.value); - setErrorMsg(null); - setFlowState("idle"); + resetRouteState(); }} disabled={isBusy} type="number" @@ -556,7 +1039,7 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF step="any" /> - {position.asset.symbol} + {inShareMode ? "shares" : position.asset.symbol}
{insufficientBalance && ( @@ -573,12 +1056,19 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF type="button" disabled={isBusy} onClick={() => { - const val = (positionTotal * pct) / 100; - const decimals = position.asset.decimals; - const display = decimals > 6 ? 6 : decimals; - setAmount(val.toFixed(display)); - setErrorMsg(null); - setFlowState("idle"); + // Prefer share-honest percentages — bps math against + // the on-chain share balance avoids float drift. + if (shareBalanceRaw) { + const bps = ethers.BigNumber.from(pct * 100); + const part = shareBalanceRaw.mul(bps).div(10_000); + setAmount(ethers.utils.formatUnits(part, shareDecimals)); + } else { + const val = ((positionTotal ?? 0) * pct) / 100; + const decimals = position.asset.decimals; + const display = decimals > 6 ? 6 : decimals; + setAmount(val.toFixed(display)); + } + resetRouteState(); }} className="flex-1 rounded border border-border/40 bg-muted/30 py-1 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground disabled:opacity-50" > @@ -589,9 +1079,8 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF type="button" disabled={isBusy} onClick={() => { - setAmount(position.balanceNative); - setErrorMsg(null); - setFlowState("idle"); + setAmount(shareBalanceForMax ?? position.balanceNative); + resetRouteState(); }} className="flex-1 rounded border border-border/40 bg-muted/30 py-1 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground disabled:opacity-50" > @@ -601,12 +1090,63 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF )} +
+
+ + + {routeRequired ? "Route after redeem" : "Same-chain redeem"} + +
+
+ + + +
+
+ {amount && fromAmountForQuote && (
{quoteLoading && (
- Fetching quote… + Fetching redeem quote…
)} {quoteError && ( @@ -641,7 +1181,7 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF
- You receive + {routeRequired ? "Redeem receives" : "You receive"} {toUsd != null @@ -770,6 +1310,76 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF
)} + {routeRequired && (redeemedAmountRaw || flowState === "redeemed") && ( +
+
+
+

Redeem confirmed

+

+ Route the actual redeemed balance delta to{" "} + {destinationToken.symbol} on {chainName(destinationChainId)}. +

+
+ {redeemedAmountRaw && ( + + {formatRawAmount(redeemedAmountRaw, underlyingToken.decimals)}{" "} + {underlyingToken.symbol} + + )} +
+ + {!redeemedAmountRaw ? ( +
+

+ The redeem transaction succeeded, but the post-redeem + balance delta could not be measured safely. +

+ +
+ ) : routeMode === "intent" ? ( + { + setRouteMode("composer"); + setFlowState("redeemed"); + setComposerRouteState({ phase: "idle" }); + }} + onKeepUnderlying={handleKeepUnderlying} + onRefunded={() => setFlowState("redeemed")} + /> + ) : ( + { + setRouteMode("intent"); + setComposerRouteState({ phase: "idle" }); + setFlowState("redeemed"); + }} + onKeepUnderlying={handleKeepUnderlying} + /> + )} +
+ )} + {errorMsg && (
@@ -777,6 +1387,7 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF
)} + {!(routeRequired && (redeemedAmountRaw || flowState === "redeemed")) && (
{!simResult?.success && (
@@ -795,18 +1406,20 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF {needsApproval ? ( ) : ( )}
+ )} )}
); } + +function mergeDestinationTokens(...groups: EarnToken[][]): EarnToken[] { + const seen = new Set(); + const merged: EarnToken[] = []; + for (const group of groups) { + for (const token of group) { + const key = destinationTokenKey(token); + if (seen.has(key)) continue; + seen.add(key); + merged.push(token); + } + } + return merged; +} + +function sameTokenAddress(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +function chainName(chainId: number): string { + return ( + SUPPORTED_CHAINS.find((c) => c.id === chainId)?.name ?? + CHAIN_REGISTRY.find((c) => c.id === chainId)?.name ?? + `chain ${chainId}` + ); +} + +function chainExplorerUrl(chainId: number): string | null { + return ( + SUPPORTED_CHAINS.find((c) => c.id === chainId)?.explorerUrl ?? + CHAIN_REGISTRY.find((c) => c.id === chainId)?.explorerUrl ?? + null + ); +} + +function rpcProviderForChain( + chainId: number, +): ethers.providers.StaticJsonRpcProvider | null { + const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId); + if (!chain) return null; + const resolution = networkConfigManager.resolveRpcUrl(chainId, chain.rpcUrl); + if (!resolution.url) return null; + return new ethers.providers.StaticJsonRpcProvider(resolution.url, chainId); +} + +async function readTokenBalanceOnChain( + tokenAddress: string, + owner: string, + chainId: number, +): Promise { + const provider = rpcProviderForChain(chainId); + if (!provider) { + throw new Error(`No RPC available for ${chainName(chainId)}`); + } + if (isNativeToken(tokenAddress)) { + return provider.getBalance(owner); + } + const token = new ethers.Contract(tokenAddress, ERC20_READ_ABI, provider); + return token.balanceOf(owner); +} + +async function readTokenDecimalsOnChain( + tokenAddress: string, + chainId: number, +): Promise { + if (isNativeToken(tokenAddress)) { + return ( + CHAIN_REGISTRY.find((c) => c.id === chainId)?.nativeCurrency?.decimals ?? + 18 + ); + } + const provider = rpcProviderForChain(chainId); + if (!provider) return null; + const token = new ethers.Contract(tokenAddress, ERC20_READ_ABI, provider); + const decimals = await token.decimals(); + return Number(decimals); +} + +function formatRawAmount(raw: string, decimals: number): string { + try { + const num = parseFloat(ethers.utils.formatUnits(raw, decimals)); + if (!Number.isFinite(num)) return raw; + if (num > 0 && num < 0.0001) return "<0.0001"; + return num.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 6, + }); + } catch { + return raw; + } +} + +function DestinationTokenSelectRow({ + token, + chainId, + ownerAddress, +}: { + token: EarnToken; + chainId: number; + ownerAddress: string | null; +}) { + const { data: rawBalance } = useTokenBalance({ + tokenAddress: token.address, + ownerAddress, + chainId, + }); + + let displayBalance: string | null = null; + if (rawBalance) { + const n = parseFloat(ethers.utils.formatUnits(rawBalance, token.decimals)); + if (Number.isFinite(n) && n > 0) { + displayBalance = + n < 0.0001 + ? "<0.0001" + : n < 1 + ? n.toPrecision(4) + : n.toLocaleString(undefined, { maximumFractionDigits: 4 }); + } + } + + return ( + + + + {token.symbol} + + on {chainName(chainId)} + + + {displayBalance && ( + {displayBalance} + )} + + ); +} + +function ComposerWithdrawRoutePanel({ + state, + sourceChainId, + destinationChainId, + sourceToken, + destinationToken, + onStart, + onTryIntent, + onKeepUnderlying, +}: { + state: WithdrawComposerRouteState; + sourceChainId: number; + destinationChainId: number; + sourceToken: EarnToken; + destinationToken: EarnToken; + onStart: () => void; + onTryIntent: () => void; + onKeepUnderlying: () => void; +}) { + const inFlight = + state.phase !== "idle" && + state.phase !== "done" && + state.phase !== "failed"; + const routeTxHash = "routeTxHash" in state ? state.routeTxHash : undefined; + const destinationTxHash = + state.phase === "done" ? state.destinationTxHash : undefined; + const sourceExplorer = chainExplorerUrl(sourceChainId); + const destinationExplorer = chainExplorerUrl(destinationChainId); + + return ( +
+
+ Composer route + + {sourceToken.symbol} on {chainName(sourceChainId)} →{" "} + {destinationToken.symbol} on {chainName(destinationChainId)} + +
+ + {state.phase !== "idle" && ( +
+
+ {state.phase === "done" ? ( + + ) : state.phase === "failed" ? ( + + ) : ( + + )} + + {composerRoutePhaseLabel(state)} + +
+ {state.phase === "composer-settling" && state.lifiStatus && ( +

+ LI.FI status: {state.lifiStatus} + {state.lifiSubstatus ? ` · ${state.lifiSubstatus}` : ""} +

+ )} + {routeTxHash && sourceExplorer && ( + + Source tx {shortAddress(routeTxHash)} + + )} + {destinationTxHash && destinationExplorer && destinationTxHash !== routeTxHash && ( + + Destination tx {shortAddress(destinationTxHash)} + + )} +
+ )} + + {state.phase === "failed" && ( +
+

+ + + {state.message} + {(state.lifiStatus || state.lifiSubstatus) && ( + + LI.FI status: {state.lifiStatus ?? "unknown"} + {state.lifiSubstatus ? ` · ${state.lifiSubstatus}` : ""} + + )} + {state.failedAfterBroadcast ? ( + + The route was broadcast. Review the LI.FI status and explorer + link before retrying manually. + + ) : ( + + The route was not broadcast; the redeemed underlying remains + in your wallet. + + )} + +

+ {!state.failedAfterBroadcast && ( +
+ + + +
+ )} +
+ )} + + {(state.phase === "idle" || state.phase === "done") && ( +
+ + {state.phase === "idle" && ( + + )} +
+ )} + + {inFlight && ( + + )} +
+ ); +} + +function composerRoutePhaseLabel(state: WithdrawComposerRouteState): string { + switch (state.phase) { + case "route-quoting": + return "Fetching route quote…"; + case "composer-quoted": + return "Route quoted"; + case "composer-approving": + return "Approving route…"; + case "composer-sending": + return "Confirm route in wallet…"; + case "composer-settling": + return "Waiting for LI.FI delivery…"; + case "done": + return "Route delivered"; + case "failed": + return "Route failed"; + case "idle": + return "Ready"; + } +} diff --git a/src/components/integrations/lifi-earn/WithdrawIntentRouteStep.tsx b/src/components/integrations/lifi-earn/WithdrawIntentRouteStep.tsx new file mode 100644 index 0000000..3a45ae5 --- /dev/null +++ b/src/components/integrations/lifi-earn/WithdrawIntentRouteStep.tsx @@ -0,0 +1,560 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAccount, useConfig, useSwitchChain } from "wagmi"; +import { + getWalletClient as getWagmiWalletClient, + waitForTransactionReceipt as wagmiWaitForReceipt, +} from "@wagmi/core"; +import { + encodeFunctionData, + type Address, + type Hex, +} from "viem"; +import { + ArrowRight, + ArrowsClockwise, + CheckCircle, + CircleNotch, + Sparkle, + XCircle, +} from "@phosphor-icons/react"; + +import { Button } from "../../ui/button"; +import ChainIcon from "../../icons/ChainIcon"; +import { TokenIcon } from "./TokenIcon"; +import { IntentStatusTimeline } from "./IntentStatusTimeline"; +import { + isDeliveredOrSettled, + readDestinationTxHash, + requestIntentQuote, + type IntentQuote, +} from "./intentsApi"; +import { useIntentOrderStatus } from "./useIntentOrderStatus"; +import { encodeEip7930EvmAddress } from "../../../lib/intents/eip7930"; +import { buildDeadlinePlan } from "../../../lib/intents/deadlines"; +import { nextOrderNonce } from "../../../lib/intents/nonce"; +import { + buildStandardOrder, + orderForAbi, + type StandardOrder, +} from "../../../lib/intents/standardOrder"; +import { + INPUT_SETTLER_ESCROW, + extractOpenOrderId, + inputSettlerEscrowAbi, +} from "../../../lib/intents/contracts"; +import { CHAIN_REGISTRY, SUPPORTED_CHAINS } from "../../../utils/chains"; +import type { EarnToken } from "./types"; +import { formatTxError, isNativeToken, safeApproveErc20 } from "./txUtils"; + +export type WithdrawIntentRouteStage = + | "idle" + | "quoting" + | "quoted" + | "approving" + | "signing" + | "open" + | "delivered" + | "failed" + | "refunding" + | "refunded"; + +interface WithdrawIntentRouteStepProps { + sourceChainId: number; + sourceToken: EarnToken; + sourceAmountRaw: string; + destinationChainId: number; + destinationToken: EarnToken; + recipient?: Address; + onStageChange?: (stage: WithdrawIntentRouteStage) => void; + onDelivered?: (details: { + openTxHash?: Hex; + destinationTxHash?: Hex; + orderId?: Hex; + }) => void; + onFallbackToComposer?: () => void; + onKeepUnderlying?: () => void; + onRefunded?: () => void; +} + +export function WithdrawIntentRouteStep({ + sourceChainId, + sourceToken, + sourceAmountRaw, + destinationChainId, + destinationToken, + recipient, + onStageChange, + onDelivered, + onFallbackToComposer, + onKeepUnderlying, + onRefunded, +}: WithdrawIntentRouteStepProps) { + const { address, isConnected, chain: walletChain } = useAccount(); + const config = useConfig(); + const { switchChainAsync } = useSwitchChain(); + + const [stage, setStage] = useState("idle"); + const [error, setError] = useState(null); + const [quote, setQuote] = useState(null); + const [order, setOrder] = useState(null); + const [openTxHash, setOpenTxHash] = useState(null); + const [orderId, setOrderId] = useState(null); + const deliveredNotifiedRef = useRef(false); + + const recipientAddr = (recipient ?? address) as Address | undefined; + + useEffect(() => { + setStage("idle"); + setError(null); + setQuote(null); + setOrder(null); + setOpenTxHash(null); + setOrderId(null); + deliveredNotifiedRef.current = false; + }, [ + sourceChainId, + sourceToken.address, + sourceAmountRaw, + destinationChainId, + destinationToken.address, + recipient, + ]); + + useEffect(() => { + onStageChange?.(stage); + }, [stage, onStageChange]); + + const explorerByChain = useMemo(() => { + const map = new Map(); + for (const c of SUPPORTED_CHAINS) { + if (c.explorerUrl) map.set(c.id, c.explorerUrl); + } + return map; + }, []); + + const { status: orderStatus, state: orderState } = useIntentOrderStatus({ + onChainOrderId: orderId ?? undefined, + enabled: isPostOpenStage(stage) || stage === "refunding", + }); + const deliveryConfirmed = isDeliveredOrSettled(orderState); + + useEffect(() => { + if (!deliveryConfirmed || deliveredNotifiedRef.current) return; + deliveredNotifiedRef.current = true; + setStage("delivered"); + onDelivered?.({ + openTxHash: openTxHash ?? undefined, + destinationTxHash: readDestinationTxHash(orderStatus), + orderId: orderId ?? undefined, + }); + }, [deliveryConfirmed, onDelivered, openTxHash, orderId, orderStatus]); + + async function handleQuote() { + if (!recipientAddr) return; + try { + setStage("quoting"); + setError(null); + + if (BigInt(sourceAmountRaw) <= 0n) { + throw new Error("No redeemed amount available to route"); + } + + const userEip = encodeEip7930EvmAddress(sourceChainId, recipientAddr); + const fromAssetEip = encodeEip7930EvmAddress( + sourceChainId, + sourceToken.address as Address, + ); + const toAssetEip = encodeEip7930EvmAddress( + destinationChainId, + destinationToken.address as Address, + ); + const receiverEip = encodeEip7930EvmAddress( + destinationChainId, + recipientAddr, + ); + + const res = await requestIntentQuote({ + user: userEip, + intent: { + intentType: "oif-swap", + inputs: [ + { user: userEip, asset: fromAssetEip, amount: sourceAmountRaw }, + ], + outputs: [ + { receiver: receiverEip, asset: toAssetEip, amount: null }, + ], + swapType: "exact-input", + }, + supportedTypes: ["oif-escrow-v0"], + }); + + const q = res.quotes?.[0]; + const previewAmount = q?.preview?.outputs?.[0]?.amount; + if (!q || !previewAmount) { + throw new Error("No intent quote available for this receive route"); + } + + const deadlines = buildDeadlinePlan({ + quoteValidUntilIso: q.validUntil ?? null, + }); + + const built = buildStandardOrder({ + user: recipientAddr, + nonce: nextOrderNonce(), + originChainId: sourceChainId, + inputToken: sourceToken.address as Address, + inputAmount: BigInt(sourceAmountRaw), + targetChainId: destinationChainId, + outputToken: destinationToken.address as Address, + outputAmount: BigInt(previewAmount), + recipient: recipientAddr, + expires: deadlines.expires, + fillDeadline: deadlines.fillDeadline, + context: (q.context as Hex | undefined) ?? "0x", + }); + + setQuote(q); + setOrder(built); + setStage("quoted"); + } catch (err) { + setError(formatTxError(err)); + setStage("failed"); + } + } + + async function handleOpen() { + if (!order || !recipientAddr) return; + try { + setError(null); + if (walletChain?.id !== sourceChainId) { + await switchChainAsync({ chainId: sourceChainId }); + } + const walletClient = await getWagmiWalletClient(config, { + chainId: sourceChainId, + }); + if (!walletClient) throw new Error("No wallet client for source chain"); + + setStage("approving"); + await safeApproveErc20({ + wagmiConfig: config, + walletClient, + token: sourceToken.address as Address, + spender: INPUT_SETTLER_ESCROW, + amount: BigInt(sourceAmountRaw), + owner: recipientAddr, + chainId: sourceChainId, + }); + + setStage("signing"); + const openData = encodeFunctionData({ + abi: inputSettlerEscrowAbi, + functionName: "open", + args: [orderForAbi(order)], + }); + const hash = await walletClient.sendTransaction({ + to: INPUT_SETTLER_ESCROW, + data: openData, + }); + const receipt = await wagmiWaitForReceipt(config, { + hash, + chainId: sourceChainId, + timeout: 120_000, + }); + if (receipt.status === "reverted") { + throw new Error("open() reverted on-chain"); + } + + setOpenTxHash(hash); + const decodedOrderId = extractOpenOrderId(receipt.logs); + if (!decodedOrderId) { + throw new Error( + "open() succeeded but Open(orderId) event could not be decoded", + ); + } + setOrderId(decodedOrderId); + setStage("open"); + } catch (err) { + setError(formatTxError(err)); + setStage("failed"); + } + } + + async function handleRefund() { + if (!order) return; + try { + setStage("refunding"); + if (walletChain?.id !== sourceChainId) { + await switchChainAsync({ chainId: sourceChainId }); + } + const walletClient = await getWagmiWalletClient(config, { + chainId: sourceChainId, + }); + if (!walletClient) throw new Error("No wallet client for source chain"); + const data = encodeFunctionData({ + abi: inputSettlerEscrowAbi, + functionName: "refund", + args: [orderForAbi(order)], + }); + const hash = await walletClient.sendTransaction({ + to: INPUT_SETTLER_ESCROW, + data, + }); + await wagmiWaitForReceipt(config, { + hash, + chainId: sourceChainId, + timeout: 120_000, + }); + setStage("refunded"); + onRefunded?.(); + } catch (err) { + setError(formatTxError(err)); + setStage("failed"); + } + } + + const previewOut = quote?.preview?.outputs?.[0]?.amount; + const previewOutDecimal = previewOut + ? formatRaw(previewOut, destinationToken.decimals) + : null; + + if (isNativeToken(sourceToken.address)) { + return ( +
+

+ LI.FI Intent routing requires an ERC-20 source after redeem. Keep the + underlying or try the Composer route. +

+
+ {onFallbackToComposer && ( + + )} + {onKeepUnderlying && ( + + )} +
+
+ ); + } + + return ( +
+
+ + + LI.FI Intent route + + + Delivery is final + +
+ +
+ + + + {formatRaw(sourceAmountRaw, sourceToken.decimals)}{" "} + {sourceToken.symbol ?? "token"} + + + + + + {previewOutDecimal ?? "—"} {destinationToken.symbol} + + + on {chainName(destinationChainId)} + +
+ + {error && ( +

+ + {error} +

+ )} + + {(isPostOpenStage(stage) || stage === "refunding") && order && ( + + )} + + {stage === "delivered" && ( +
+ + Funds delivered as {destinationToken.symbol} on{" "} + {chainName(destinationChainId)}. +
+ )} + + {(orderState === "Failed" || orderState === "Expired") && stage !== "refunded" && ( +

+ Intent {orderState.toLowerCase()}. Use the refund control once the + expiry window opens, then keep the underlying or try another route. +

+ )} + + {stage === "refunded" && ( +
+

Escrow refunded to the source chain.

+ {onKeepUnderlying && ( + + )} +
+ )} + + {(stage === "idle" || stage === "quoting") && ( + + )} + + {stage === "quoted" && ( + + )} + + {(stage === "approving" || stage === "signing") && ( + + )} + + {stage === "failed" && ( +
+ {onFallbackToComposer && ( + + )} + + {onKeepUnderlying && ( + + )} +
+ )} + + {stage === "refunding" && ( + + )} +
+ ); +} + +function isPostOpenStage(stage: WithdrawIntentRouteStage): boolean { + return stage === "open" || stage === "delivered" || stage === "refunded"; +} + +function chainName(id: number): string { + return ( + SUPPORTED_CHAINS.find((c) => c.id === id)?.name ?? + CHAIN_REGISTRY.find((c) => c.id === id)?.name ?? + `chain ${id}` + ); +} + +function formatRaw(raw: string, decimals: number): string { + try { + const big = BigInt(raw); + const whole = big / 10n ** BigInt(decimals); + const frac = big % 10n ** BigInt(decimals); + const fracStr = frac + .toString() + .padStart(decimals, "0") + .slice(0, 6) + .replace(/0+$/, ""); + return fracStr ? `${whole.toString()}.${fracStr}` : whole.toString(); + } catch { + return raw; + } +} diff --git a/src/components/integrations/lifi-earn/concierge/ExecutionQueue.tsx b/src/components/integrations/lifi-earn/concierge/ExecutionQueue.tsx index 4ceeefb..b03b506 100644 --- a/src/components/integrations/lifi-earn/concierge/ExecutionQueue.tsx +++ b/src/components/integrations/lifi-earn/concierge/ExecutionQueue.tsx @@ -3,7 +3,6 @@ import { CircleNotch, CheckCircle, XCircle, ArrowRight } from "@phosphor-icons/r import { Button } from "../../../../components/ui/button"; import { Card } from "../../../../components/ui/card"; import { DepositFlow } from "../DepositFlow"; -import { useCrossChainStatus } from "./hooks/useCrossChainStatus"; import { isCrossChain, type LegAction, type LegState } from "./executionMachine"; import type { Leg } from "./types"; @@ -16,14 +15,20 @@ export function ExecutionQueue({ state, dispatch }: ExecutionQueueProps) { if (state.legs.length === 0) return null; const current = state.currentIndex >= 0 ? state.legs[state.currentIndex] : null; - const allDone = state.legs.every( - (l) => l.status === "done" || l.status === "failed" - ); - // NEXT must wait for the current step to reach a terminal state — otherwise - // the forward-only reducer strands the in-flight step. - const canAdvance = - current !== null && - (current.status === "done" || current.status === "failed"); + // `allDone` must match the "leg is genuinely terminal" predicate so the + // queue doesn't auto-close while a recoverable-failed leg still has live + // user affordances. + const isLegTerminal = (l: Leg) => + l.status === "done" || + l.status === "refunded" || + (l.status === "failed" && !l.recoverable); + const allDone = state.legs.every(isLegTerminal); + // NEXT must wait for the current step to reach a terminal state. Recoverable + // failures (Intent expired with refund still available; bridged-but-deposit- + // failed) are NOT terminal from the user's perspective — they have an + // in-step affordance (refund / retry deposit). Refunded IS terminal: the + // user got their funds back, queue can advance. + const canAdvance = current !== null && isLegTerminal(current); const total = state.legs.length; @@ -87,11 +92,13 @@ export function ExecutionQueue({ state, dispatch }: ExecutionQueueProps) { className={`h-1.5 flex-1 rounded-full transition-colors ${ leg.status === "done" ? "bg-emerald-500" - : leg.status === "failed" - ? "bg-red-500" - : i === state.currentIndex - ? "bg-blue-500 animate-pulse" - : "bg-muted/40" + : leg.status === "refunded" + ? "bg-amber-500" + : leg.status === "failed" + ? "bg-red-500" + : i === state.currentIndex + ? "bg-amber-500 animate-pulse" + : "bg-muted/40" }`} /> ))} @@ -121,27 +128,12 @@ function LegCard({ dispatch: (action: LegAction) => void; }) { const crossChain = isCrossChain(leg); - - const { data: statusData } = useCrossChainStatus({ - txHash: leg.sourceTxHash, - fromChain: leg.source.asset.chainId, - toChain: leg.destination.chainId, - enabled: crossChain && leg.sourceTxHash !== null, - }); - - // INVALID means LI.FI can't track the source tx — treat as terminal failure - // so the step doesn't stay stuck in "bridging" forever. - useEffect(() => { - if (!statusData) return; - if (statusData.status === "DONE") { - dispatch({ type: "SET_BRIDGE_STATUS", id: leg.id, status: "DONE" }); - } else if ( - statusData.status === "FAILED" || - statusData.status === "INVALID" - ) { - dispatch({ type: "SET_BRIDGE_STATUS", id: leg.id, status: "FAILED" }); - } - }, [statusData, leg.id, dispatch]); + const progressDetails = legProgressDetails(leg); + const shouldRenderFlow = + isCurrent && + leg.status !== "done" && + leg.status !== "refunded" && + (leg.status !== "failed" || leg.recoverable); return ( {leg.destination.name ?? leg.destination.slug} {crossChain && ( - + cross-chain )}
- {leg.status} - {leg.bridgeStatus && ` · bridge: ${leg.bridgeStatus}`} - {statusData?.substatusMessage && ` · ${statusData.substatusMessage}`} + {progressDetails}
+ {(leg.sourceTxHash || + leg.intentOrderId || + leg.depositTxHash || + leg.destinationTxHash) && ( +
+ {leg.sourceTxHash && ( + source tx: {shortId(leg.sourceTxHash)} + )} + {leg.intentOrderId && ( + order: {shortId(leg.intentOrderId)} + )} + {leg.destinationTxHash && ( + destination tx: {shortId(leg.destinationTxHash)} + )} + {leg.depositTxHash && ( + deposit tx: {shortId(leg.depositTxHash)} + )} +
+ )} - {leg.errorMessage && ( + {leg.errorMessage && leg.status !== "refunded" && (
{leg.errorMessage} + {leg.recoverable && ( + + {recoverableHint(leg)} + + )} +
+ )} + + {leg.status === "refunded" && ( +
+ Intent refunded — funds returned to your wallet on the source chain. +
+ )} + + {leg.status === "failed" && !leg.recoverable && isCurrent && ( +
+ +
+ )} + + {leg.status === "failed" && leg.recoverable && isCurrent && ( +
+
)} - {isCurrent && leg.status !== "done" && leg.status !== "failed" && ( + {shouldRenderFlow && ( { - dispatch({ type: "SET_TX_HASH", id: leg.id, txHash }); - dispatch({ - type: "SET_STATUS", - id: leg.id, - status: crossChain ? "bridging" : "executing", - }); - }} onConfirmed={() => { if (!crossChain) { dispatch({ type: "SET_STATUS", id: leg.id, status: "done" }); @@ -200,6 +247,9 @@ function LegCard({ onError={(message) => { dispatch({ type: "SET_ERROR", id: leg.id, message }); }} + onExecutionEvent={(event) => { + dispatch({ type: "EXECUTION_EVENT", id: leg.id, event }); + }} /> )} @@ -208,7 +258,79 @@ function LegCard({ function StatusIcon({ status }: { status: Leg["status"] }) { if (status === "done") return ; + if (status === "refunded") return ; if (status === "failed") return ; if (status === "pending") return
; - return ; + return ; +} + +function legProgressDetails(leg: Leg): string { + const parts = [statusLabel(leg.status)]; + if (leg.executionMode) parts.push(modeLabel(leg.executionMode)); + if (leg.bridgeStatus) parts.push(`bridge: ${leg.bridgeStatus}`); + if (leg.intentStatus) parts.push(`intent: ${leg.intentStatus}`); + if (leg.recoverable) parts.push("recoverable"); + return parts.join(" · "); +} + +function recoverableHint(leg: Leg): string { + const intent = leg.intentStatus?.toLowerCase(); + if (intent === "expired") { + return "Intent expired before solver fill. Funds are still escrowed — use the refund button in the step."; + } + if (leg.bridgeStatus === "DONE" || leg.destinationTxHash) { + return "Funds were delivered on the destination chain. Retry the deposit step to finish."; + } + // By the time recoverableHint renders, leg.status is "failed". Key the + // "intent delivered but deposit failed" branch off intentStatus, which + // persists across the status→failed transition. + if (leg.intentOrderId && (intent === "delivered" || intent === "settled")) { + return "Intent delivered. Retry the deposit step to finish."; + } + return "This leg failed but funds may be recoverable — see the step for retry / refund options."; +} + +function statusLabel(status: Leg["status"]): string { + switch (status) { + case "pending": + return "Pending"; + case "quoting": + return "Quoting"; + case "ready": + return "Ready"; + case "approving": + return "Approving"; + case "executing": + return "Executing"; + case "bridging": + return "Bridging"; + case "intent-open": + return "Intent open"; + case "intent-delivered": + return "Intent delivered"; + case "depositing": + return "Depositing"; + case "done": + return "Done"; + case "refunded": + return "Refunded"; + case "failed": + return "Failed"; + } +} + +function modeLabel(mode: NonNullable): string { + switch (mode) { + case "composer-same": + return "Composer"; + case "composer-cross": + return "Composer bridge"; + case "intent": + return "Intent"; + } +} + +function shortId(value: string): string { + if (value.length <= 14) return value; + return `${value.slice(0, 6)}...${value.slice(-4)}`; } diff --git a/src/components/integrations/lifi-earn/concierge/FlowDiagram.tsx b/src/components/integrations/lifi-earn/concierge/FlowDiagram.tsx index 04edfca..728993c 100644 --- a/src/components/integrations/lifi-earn/concierge/FlowDiagram.tsx +++ b/src/components/integrations/lifi-earn/concierge/FlowDiagram.tsx @@ -180,12 +180,17 @@ function statusToBorder(status: LegStatus | "idle"): string { switch (status) { case "done": return "border-emerald-500/70"; + case "refunded": + return "border-amber-500/70"; case "failed": return "border-red-500/70"; case "quoting": case "approving": case "executing": case "bridging": + case "intent-open": + case "intent-delivered": + case "depositing": return "border-amber-500/70"; case "ready": return "border-blue-500/70"; @@ -200,12 +205,17 @@ function statusToEdgeColor(status: LegStatus | "idle"): string { switch (status) { case "done": return "#10b981"; + case "refunded": + return "#f59e0b"; case "failed": return "#ef4444"; case "quoting": case "approving": case "executing": case "bridging": + case "intent-open": + case "intent-delivered": + case "depositing": return "#f59e0b"; case "ready": return "#3b82f6"; @@ -221,7 +231,10 @@ function statusIsAnimating(status: LegStatus | "idle"): boolean { status === "quoting" || status === "approving" || status === "executing" || - status === "bridging" + status === "bridging" || + status === "intent-open" || + status === "intent-delivered" || + status === "depositing" ); } @@ -559,6 +572,13 @@ function rollupStatus(statuses: Array): LegStatus | "idle" { if (statuses.some(statusIsAnimating)) return "bridging"; if (statuses.some((s) => s === "ready")) return "ready"; if (statuses.some((s) => s === "pending")) return "pending"; - if (statuses.every((s) => s === "done")) return "done"; + // Treat "refunded" as terminal alongside "done" so a fully-resolved set of + // legs doesn't fall back to "idle" when at least one leg was refunded. If + // every leg is refunded we surface that; otherwise (mix of done + refunded) + // we treat the rollup as done — the user got funds back on every leg. + if (statuses.length > 0 && statuses.every((s) => s === "refunded")) { + return "refunded"; + } + if (statuses.every((s) => s === "done" || s === "refunded")) return "done"; return "idle"; } diff --git a/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx b/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx index 68aff04..dbcf408 100644 --- a/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx +++ b/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx @@ -281,8 +281,25 @@ export function IdleSweepPanel({ targetAddress }: IdleSweepPanelProps) { ]); const queueBuilt = legState.legs.length > 0; - const hasInFlightStep = legState.legs.some((l) => - ["quoting", "approving", "executing", "bridging", "ready"].includes(l.status) + // Include the post-Intent / post-Composer-bridge statuses introduced by the + // ExecutionQueue Intent-aware wiring — otherwise a destination change could + // rebuild the queue while a leg is still mid-flight (Intent escrow open, + // delivered-but-not-deposited, or actively depositing). Also guard + // recoverable failures so a queue rebuild can't erase the refund/retry + // affordance. + const hasInFlightStep = legState.legs.some( + (l) => + [ + "quoting", + "approving", + "executing", + "bridging", + "ready", + "intent-open", + "intent-delivered", + "depositing", + ].includes(l.status) || + (l.status === "failed" && l.recoverable), ); useEffect(() => { if (isReadOnly) return; diff --git a/src/components/integrations/lifi-earn/concierge/LlmErrorAlert.tsx b/src/components/integrations/lifi-earn/concierge/LlmErrorAlert.tsx index e900d99..f9447ce 100644 --- a/src/components/integrations/lifi-earn/concierge/LlmErrorAlert.tsx +++ b/src/components/integrations/lifi-earn/concierge/LlmErrorAlert.tsx @@ -70,7 +70,7 @@ function classify(rawError: string): Classification { category: "auth", title: "Recommender not authorized", description: - "The AI proxy rejected our request. This usually means the GEMINI_API_KEY isn't set on the server or the Origin allow-list is misconfigured. Rules-based picks are still safe to use.", + "The AI proxy rejected our request. This usually means the GEMINI_API_KEY isn't set on the server or the Origin allow-list is misconfigured. Rules-based picks are shown below as a fallback.", icon: , retryable: false, }; @@ -103,7 +103,7 @@ function classify(rawError: string): Classification { category: "schema", title: "Recommender returned something we couldn't parse", description: - "Gemini's response didn't match the shape we expect. We already retried once and fell back to rules-based picks — they're safe to use. Try again to see if a second call returns clean JSON.", + "Gemini's response didn't match the shape we expect. We already retried once and fell back to rules-based picks — rules-based picks are shown below. Try again to see if a second call returns clean JSON.", icon: , retryable: true, }; @@ -113,7 +113,7 @@ function classify(rawError: string): Classification { category: "unknown", title: "AI recommender unavailable", description: - "Something went wrong while fetching AI recommendations. Rules-based picks are still shown below and are safe to use.", + "Something went wrong while fetching AI recommendations. Rules-based picks are shown below as a fallback.", icon: , retryable: true, }; diff --git a/src/components/integrations/lifi-earn/concierge/VaultRecommendations.tsx b/src/components/integrations/lifi-earn/concierge/VaultRecommendations.tsx index 19fdb04..a2d1d1e 100644 --- a/src/components/integrations/lifi-earn/concierge/VaultRecommendations.tsx +++ b/src/components/integrations/lifi-earn/concierge/VaultRecommendations.tsx @@ -182,7 +182,9 @@ const SWAP_ALIAS_GROUPS: ReadonlyArray> = [ function needsSwap(sourceSymbol: string | null | undefined, vault: EarnVault): boolean { if (!sourceSymbol) return false; const src = sourceSymbol.toUpperCase(); - const underlyings = (vault.underlyingTokens ?? []).map((t) => t.symbol.toUpperCase()); + const underlyings = (vault.underlyingTokens ?? []) + .filter((t): t is typeof t & { symbol: string } => Boolean(t.symbol)) + .map((t) => t.symbol.toUpperCase()); if (underlyings.length === 0) return false; // Check direct match or alias match for (const u of underlyings) { diff --git a/src/components/integrations/lifi-earn/concierge/executionMachine.ts b/src/components/integrations/lifi-earn/concierge/executionMachine.ts index c54a1bf..c4ed62b 100644 --- a/src/components/integrations/lifi-earn/concierge/executionMachine.ts +++ b/src/components/integrations/lifi-earn/concierge/executionMachine.ts @@ -1,4 +1,10 @@ -import type { Leg, LegStatus, SelectedSource } from "./types"; +import type { + DepositExecutionEvent, + DepositExecutionPhase, + Leg, + LegStatus, + SelectedSource, +} from "./types"; import type { EarnVault } from "../types"; export type LegAction = @@ -12,6 +18,8 @@ export type LegAction = | { type: "SET_TX_HASH"; id: string; txHash: string } | { type: "SET_BRIDGE_STATUS"; id: string; status: "PENDING" | "DONE" | "FAILED" } | { type: "SET_ERROR"; id: string; message: string } + | { type: "EXECUTION_EVENT"; id: string; event: DepositExecutionEvent } + | { type: "SET_RECOVERABLE"; id: string; recoverable: boolean } | { type: "NEXT" } | { type: "RESET" }; @@ -31,30 +39,250 @@ function legIdFor(src: SelectedSource): string { return `${src.asset.chainId}:${src.asset.token.address.toLowerCase()}`; } +function buildLeg(source: SelectedSource, destination: EarnVault): Leg { + return { + id: legIdFor(source), + source, + destination, + status: "pending", + executionMode: null, + sourceTxHash: null, + bridgeStatus: null, + errorMessage: null, + recoverable: false, + }; +} + +function executionModeForPhase( + phase: DepositExecutionPhase, +): Leg["executionMode"] { + if (phase === "same-chain") return "composer-same"; + if (phase === "composer-bridge" || phase === "composer-deposit") { + return "composer-cross"; + } + return "intent"; +} + +function normalizeBridgeStatus(status: string): Leg["bridgeStatus"] { + const normalized = status.toUpperCase(); + if (normalized === "DONE" || normalized === "COMPLETED") return "DONE"; + if ( + normalized === "FAILED" || + normalized === "INVALID" || + normalized === "REFUNDED" || + normalized === "PARTIAL" + ) { + return "FAILED"; + } + return "PENDING"; +} + +function isTerminal(status: LegStatus): boolean { + return status === "done" || status === "failed" || status === "refunded"; +} + +function intentStatusToLegStatus(status: string, current: LegStatus): LegStatus { + const normalized = status.toLowerCase(); + // Refunded is a successful refund — funds are back in the user's wallet. + // It's terminal but distinct from "failed" so the UI can show a positive + // outcome and the queue can advance past it without showing retry UI. + if (normalized === "refunded") { + return "refunded"; + } + // Expired remains "failed + recoverable" — the user can still refund. + if (normalized === "failed" || normalized === "expired") { + return "failed"; + } + if (normalized === "delivered" || normalized === "settled") { + return current === "depositing" || current === "done" + ? current + : "intent-delivered"; + } + return isTerminal(current) || current === "depositing" + ? current + : "intent-open"; +} + +function applyExecutionEvent(leg: Leg, event: DepositExecutionEvent): Leg { + const executionMode = executionModeForPhase(event.phase); + + switch (event.type) { + case "tx-broadcast": { + if (event.phase === "composer-deposit" || event.phase === "intent-deposit") { + return { + ...leg, + executionMode, + status: "depositing", + depositTxHash: event.txHash, + errorMessage: null, + recoverable: false, + }; + } + if (event.phase === "composer-bridge") { + return { + ...leg, + executionMode, + status: "bridging", + sourceTxHash: event.txHash, + bridgeStatus: "PENDING", + errorMessage: null, + recoverable: false, + }; + } + if (event.phase === "intent-open") { + return { + ...leg, + executionMode, + status: "intent-open", + sourceTxHash: event.txHash, + errorMessage: null, + recoverable: false, + }; + } + return { + ...leg, + executionMode, + status: "executing", + sourceTxHash: event.txHash, + errorMessage: null, + recoverable: false, + }; + } + case "intent-opened": { + return { + ...leg, + executionMode, + status: "intent-open", + sourceTxHash: event.txHash, + intentOrderId: event.orderId, + intentStatus: "Open", + errorMessage: null, + recoverable: false, + }; + } + case "intent-status": { + const status = intentStatusToLegStatus(event.status, leg.status); + const normalized = event.status.toLowerCase(); + // Expired = refund button must stay reachable → recoverable. + // Refunded is its own terminal status now. + const recoverable = status === "failed" && normalized === "expired"; + return { + ...leg, + executionMode, + status, + intentOrderId: event.orderId ?? leg.intentOrderId, + intentStatus: event.status, + destinationTxHash: event.destinationTxHash ?? leg.destinationTxHash, + errorMessage: + status === "failed" + ? leg.errorMessage ?? `Intent ${event.status.toLowerCase()}` + : leg.errorMessage, + recoverable: status === "failed" ? recoverable : false, + }; + } + case "bridge-status": { + const bridgeStatus = normalizeBridgeStatus(event.status); + return { + ...leg, + executionMode, + sourceTxHash: event.txHash ?? leg.sourceTxHash, + bridgeStatus, + status: + bridgeStatus === "FAILED" + ? "failed" + : bridgeStatus === "DONE" + ? isTerminal(leg.status) + ? leg.status + : "depositing" + : isTerminal(leg.status) + ? leg.status + : "bridging", + errorMessage: + bridgeStatus === "FAILED" + ? leg.errorMessage ?? `Bridge ${event.status.toLowerCase()}` + : leg.errorMessage, + }; + } + case "delivered": { + if (event.phase === "composer-bridge") { + return { + ...leg, + executionMode, + bridgeStatus: "DONE", + destinationTxHash: event.destinationTxHash ?? leg.destinationTxHash, + status: isTerminal(leg.status) ? leg.status : "depositing", + errorMessage: null, + }; + } + return { + ...leg, + executionMode, + intentOrderId: event.orderId ?? leg.intentOrderId, + intentStatus: "Delivered", + destinationTxHash: event.destinationTxHash ?? leg.destinationTxHash, + status: isTerminal(leg.status) || leg.status === "depositing" + ? leg.status + : "intent-delivered", + errorMessage: null, + }; + } + case "confirmed": { + if (event.phase === "same-chain") { + return { + ...leg, + executionMode, + sourceTxHash: event.txHash ?? leg.sourceTxHash, + status: "done", + errorMessage: null, + recoverable: false, + }; + } + return { + ...leg, + executionMode, + depositTxHash: event.txHash ?? leg.depositTxHash, + status: "done", + errorMessage: null, + recoverable: false, + }; + } + case "failed": { + return { + ...leg, + executionMode, + status: "failed", + sourceTxHash: + event.phase === "same-chain" || + event.phase === "composer-bridge" || + event.phase === "intent-open" + ? event.txHash ?? leg.sourceTxHash + : leg.sourceTxHash, + depositTxHash: + event.phase === "composer-deposit" || event.phase === "intent-deposit" + ? event.txHash ?? leg.depositTxHash + : leg.depositTxHash, + intentOrderId: event.orderId ?? leg.intentOrderId, + bridgeStatus: + event.phase === "composer-bridge" ? "FAILED" : leg.bridgeStatus, + errorMessage: event.message, + recoverable: event.recoverable ?? false, + }; + } + } +} + export function legsReducer(state: LegState, action: LegAction): LegState { switch (action.type) { case "BUILD_QUEUE": { - const legs: Leg[] = action.sources.map((src) => ({ - id: legIdFor(src), - source: src, - destination: action.destination, - status: "pending", - sourceTxHash: null, - bridgeStatus: null, - errorMessage: null, - })); + const legs: Leg[] = action.sources.map((src) => + buildLeg(src, action.destination) + ); return { legs, currentIndex: -1, started: false }; } case "BUILD_QUEUE_PER_ASSET": { - const legs: Leg[] = action.legs.map(({ source, destination }) => ({ - id: legIdFor(source), - source, - destination, - status: "pending", - sourceTxHash: null, - bridgeStatus: null, - errorMessage: null, - })); + const legs: Leg[] = action.legs.map(({ source, destination }) => + buildLeg(source, destination) + ); return { legs, currentIndex: -1, started: false }; } case "START": { @@ -87,7 +315,7 @@ export function legsReducer(state: LegState, action: LegAction): LegState { bridgeStatus: action.status, status: action.status === "DONE" - ? "done" + ? "depositing" : action.status === "FAILED" ? "failed" : l.status, @@ -101,11 +329,32 @@ export function legsReducer(state: LegState, action: LegAction): LegState { ...state, legs: state.legs.map((l) => l.id === action.id - ? { ...l, status: "failed", errorMessage: action.message } + ? { + ...l, + status: "failed", + errorMessage: action.message, + recoverable: false, + } : l ), }; } + case "EXECUTION_EVENT": { + return { + ...state, + legs: state.legs.map((l) => + l.id === action.id ? applyExecutionEvent(l, action.event) : l + ), + }; + } + case "SET_RECOVERABLE": { + return { + ...state, + legs: state.legs.map((l) => + l.id === action.id ? { ...l, recoverable: action.recoverable } : l + ), + }; + } case "NEXT": { const nextIdx = state.legs.findIndex( (l, i) => diff --git a/src/components/integrations/lifi-earn/concierge/fallback.ts b/src/components/integrations/lifi-earn/concierge/fallback.ts index 9fe6f6b..69d308a 100644 --- a/src/components/integrations/lifi-earn/concierge/fallback.ts +++ b/src/components/integrations/lifi-earn/concierge/fallback.ts @@ -91,9 +91,9 @@ export function classifyRoute( vault: EarnVault, ): RouteType { const isSameChain = vault.chainId === asset.chainId; - const aliases = symbolAliases(asset.token.symbol); + const aliases = symbolAliases(asset.token.symbol ?? ""); const hasSymbolMatch = (vault.underlyingTokens ?? []).some((u) => - aliases.has(u.symbol.toUpperCase()) + u.symbol ? aliases.has(u.symbol.toUpperCase()) : false ); if (isSameChain && hasSymbolMatch) return "direct"; if (isSameChain && !hasSymbolMatch) return "swap"; @@ -116,7 +116,7 @@ export function candidatesForAsset( allVaults: EarnVault[] ): EarnVault[] { const tokenAddr = asset.token.address.toLowerCase(); - const aliases = symbolAliases(asset.token.symbol); + const aliases = symbolAliases(asset.token.symbol ?? ""); const sourceIsL2 = chainCostTier(asset.chainId) === "L2"; const direct: EarnVault[] = []; @@ -128,7 +128,11 @@ export function candidatesForAsset( if (!v.isTransactional) continue; const isSameChain = v.chainId === asset.chainId; - const symbols = (v.underlyingTokens ?? []).map((u) => u.symbol.toUpperCase()); + // Some upstream vaults ship underlyings with missing/undefined symbol — + // skip those entries rather than crashing on .toUpperCase(). + const symbols = (v.underlyingTokens ?? []) + .filter((u): u is typeof u & { symbol: string } => Boolean(u.symbol)) + .map((u) => u.symbol.toUpperCase()); const hasAliasMatch = symbols.some((s) => aliases.has(s)); const hasExactAddr = isSameChain && (v.underlyingTokens ?? []).some( (u) => u.address.toLowerCase() === tokenAddr @@ -237,7 +241,13 @@ export function pickByRules( safestPick: mkPick( safest, safest - ? `Highest TVL ($${formatCompactUsd(Number(safest.analytics.tvl.usd))}) above the safety floor.` + ? (() => { + const tvl = Number(safest.analytics.tvl.usd); + const aboveFloor = tvl >= minTvlForSafe; + return aboveFloor + ? `Highest TVL ($${formatCompactUsd(tvl)}) above the safety floor.` + : `Highest TVL available ($${formatCompactUsd(tvl)}) — below the configured safety floor.`; + })() : "no candidate meets TVL floor" ), alternatives: alternatives.map((v) => ({ diff --git a/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx b/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx index c7320c9..9d1cf09 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx +++ b/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx @@ -25,6 +25,15 @@ import { useIntentParser } from "./hooks/useIntentParser"; import { useVaultsByIntent } from "./hooks/useVaultsByIntent"; import { useIntentRecommendation, buildRecommendation } from "./hooks/useIntentRecommendation"; import type { ParsedIntent } from "./schema"; +import { + buildIntentLegPlan, + buildRoutesIndex, + type IntentLegSpec, +} from "./intentLegs"; +import { useIntentLegPipeline } from "./useIntentLegPipeline"; +import { RebalancePlanCard } from "./RebalancePlanCard"; +import { fetchIntentRoutes } from "../../intentsApi"; +import { Switch } from "../../../../ui/switch"; import type { EarnVault } from "../../types"; import type { IdleAsset, SelectedSource, VaultRecommendation } from "../types"; import { rankVaultsForIntent, type IntentVaultsResult } from "./hooks/useVaultsByIntent"; @@ -328,6 +337,7 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I if (!isMyAssetsMode) return []; const bySymbol = new Map(); for (const a of idleAssets) { + if (!a.token.symbol) continue; const sym = a.token.symbol.toUpperCase(); const existing = bySymbol.get(sym); if (!existing || (a.amountUsd ?? 0) > (existing.amountUsd ?? 0)) { @@ -510,7 +520,7 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I intent: perAssetIntents[idx], rankedVaults: vaultResult.ranked, walletAssets: idleAssets, - sourceTokenSymbol: asset.token.symbol.toUpperCase(), + sourceTokenSymbol: asset.token.symbol?.toUpperCase(), sourceChainId: asset.chainId, }; }); @@ -543,8 +553,26 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I // ── Execution pipeline ───────────────────────────────────────────── const [legState, legDispatch] = useReducer(legsReducer, initialLegState); + const [useIntentsPipeline, setUseIntentsPipeline] = useState(false); const isConsolidateMode = intent?.routing_mode === "consolidate"; + // ── LI.FI Intents pipeline (opt-in) ──────────────────────────────── + // Fetch the supported routes once so we can degrade unsupported legs + // gracefully in the rebalance plan. Empty / errored index falls back to + // "all routes plausible" rather than blocking the whole UI. + const { data: routesData } = useQuery({ + queryKey: ["lifi-intent-routes"], + queryFn: fetchIntentRoutes, + staleTime: 5 * 60_000, + enabled: useIntentsPipeline, + }); + const routesIndex = useMemo( + () => buildRoutesIndex(routesData?.routes), + [routesData], + ); + + const intentPipeline = useIntentLegPipeline(); + // For consolidate: use the global ranked vault list directly (already sorted // by objective in rankVaultsForIntent). No per-asset search needed — LI.FI // handles swaps from any held token into the vault's underlying asset. @@ -573,6 +601,62 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I [consolidateCandidates, selectedConsolidateSlug], ); + // Map per-asset recommendations back to vaults aligned with dedupedAssets. + const perAssetVaultByIndex = useMemo<(EarnVault | null)[]>(() => { + if (!isMyAssetsMode || isConsolidateMode) return []; + return dedupedAssets.map((asset) => { + const key = `${asset.chainId}:${asset.token.address.toLowerCase()}`; + const rec = recommendations.find( + (r) => `${r.forChainId}:${r.forTokenAddress.toLowerCase()}` === key, + ); + return rec?.bestPick?.vault ?? null; + }); + }, [isMyAssetsMode, isConsolidateMode, dedupedAssets, recommendations]); + + // Build the Intent leg plan when the toggle is on. Re-derives on every + // change to source assets / recommendations / routing mode. + const plannedIntentLegs = useMemo(() => { + if (!useIntentsPipeline || !intent || !isMyAssetsMode) return []; + return buildIntentLegPlan({ + intent, + sourceAssets: dedupedAssets, + perAssetVaults: perAssetVaultByIndex, + consolidateVault, + walletAddress: walletAddress ?? null, + routesIndex, + }); + }, [ + useIntentsPipeline, + intent, + isMyAssetsMode, + dedupedAssets, + perAssetVaultByIndex, + consolidateVault, + walletAddress, + routesIndex, + ]); + + // Reset the pipeline when the user changes execution mode (Composer ↔ + // Intents) or the routing mode. NOT keyed on `plannedIntentLegs.length`: + // a wallet-balance refetch or recommendation re-rank can shift that count + // mid-flight, and we don't want to nuke runs the user has already opened. + // + // Only protect rows that are on-chain or sequentially funded — `quoted` is + // pre-open (no wallet signature yet) so resetting it is harmless and lets + // the user retry from scratch when their intent changes. + const hasOnChainRuns = intentPipeline.runs.some( + (r) => + r.status === "approving" || + r.status === "signing" || + r.status === "open" || + r.status === "refunding", + ); + useEffect(() => { + if (hasOnChainRuns) return; + intentPipeline.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [intent?.routing_mode, useIntentsPipeline]); + const canExecute = useMemo(() => { if (!isMyAssetsMode || dedupedAssets.length === 0) return false; if (recommendations.length === 0) return false; @@ -1004,8 +1088,29 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I )} + {/* Execution engine toggle — Composer queue vs LI.FI Intents pipeline */} + {isMyAssetsMode && canExecute && isConnected && ( +
+ + + {useIntentsPipeline + ? "Per-leg solver settlement + refundable escrow" + : "Composer per-leg quote + deposit"} + +
+ )} + {/* Execute pipeline button — only for connected wallets, not read-only */} - {isMyAssetsMode && canExecute && !pipelineActive && isConnected && ( + {isMyAssetsMode && canExecute && !pipelineActive && isConnected && !useIntentsPipeline && (
); } diff --git a/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx b/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx new file mode 100644 index 0000000..f6b6933 --- /dev/null +++ b/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx @@ -0,0 +1,566 @@ +import { type ReactElement, useEffect, useMemo } from "react"; +import { + CircleNotch, + CheckCircle, + XCircle, + Warning, + Play, + ArrowsClockwise, +} from "@phosphor-icons/react"; +import { readContract as wagmiReadContract } from "@wagmi/core"; +import { parseAbi, type Address } from "viem"; +import { useConfig } from "wagmi"; +import { Button } from "../../../../ui/button"; +import ChainIcon from "../../../../icons/ChainIcon"; +import { IntentStatusTimeline } from "../../IntentStatusTimeline"; +import { useIntentOrderStatus } from "../../useIntentOrderStatus"; +import { isDeliveredOrSettled } from "../../intentsApi"; +import { SUPPORTED_CHAINS } from "../../../../../utils/chains"; +import { describeDegradeReason } from "./intentLegs"; +import type { IntentLegRun } from "./useIntentLegPipeline"; +import type { IntentLegSpec } from "./intentLegs"; +import type { EarnVault } from "../../types"; + +const erc20BalanceAbi = parseAbi([ + "function balanceOf(address owner) view returns (uint256)", +]); + +function formatRaw(raw: bigint, decimals: number): string { + const whole = raw / 10n ** BigInt(decimals); + const frac = raw % 10n ** BigInt(decimals); + const fracStr = frac + .toString() + .padStart(decimals, "0") + .slice(0, 6) + .replace(/0+$/, ""); + return fracStr ? `${whole.toString()}.${fracStr}` : whole.toString(); +} + +interface RebalancePlanCardProps { + plannedSpecs: IntentLegSpec[]; + runs: IntentLegRun[]; + routingMode: "per-asset" | "consolidate"; + isConnected: boolean; + onQuoteAll: () => void; + onOpenAll: () => void; + onRetry: (legId: string) => void; + onRefund: (legId: string) => void; + onDeposit: (legId: string) => void; + /** + * Fires when the leg's status poll first reports Delivered/Settled. Lets + * the pipeline hook stash the on-chain delivered amount on the run before + * the user clicks "Deposit". + */ + onMarkDelivered: (legId: string, deliveredAmount: bigint) => void; + onPickVault?: (vault: EarnVault) => void; +} + +export function RebalancePlanCard({ + plannedSpecs, + runs, + routingMode, + isConnected, + onQuoteAll, + onOpenAll, + onRetry, + onRefund, + onDeposit, + onMarkDelivered, + onPickVault, +}: RebalancePlanCardProps) { + // Show planned specs until the pipeline starts, then switch to live runs. + const rows = useMemo(() => { + if (runs.length > 0) return runs; + return plannedSpecs.map((spec) => ({ + spec, + status: spec.status === "degraded" ? "degraded" : "planned", + })); + }, [plannedSpecs, runs]); + + const executable = rows.filter((r) => r.status !== "degraded"); + const allQuoted = + executable.length > 0 && executable.every((r) => r.status === "quoted"); + const anyOpen = runs.some( + (r) => + r.status === "open" || + r.status === "deposit-quoting" || + r.status === "deposit-approving" || + r.status === "deposit-signing" || + r.status === "deposit-failed" || + r.status === "deposit-done", + ); + const anyQuoted = runs.some((r) => r.status === "quoted"); + // Sequential per-leg deposit prompts — disable deposit buttons on other + // legs while one is mid-flight (wallet UX). + const depositBusyLegId = runs.find( + (r) => + r.status === "deposit-quoting" || + r.status === "deposit-approving" || + r.status === "deposit-signing", + )?.spec.id; + + return ( +
+
+
+

+ Rebalance plan ({routingMode === "consolidate" ? "consolidate" : "per-asset"}) +

+

+ {executable.length} executable leg{executable.length === 1 ? "" : "s"} + {rows.length > executable.length && ( + <> · {rows.length - executable.length} skipped + )} +

+
+ r.status === "planned")} + allQuoted={allQuoted} + anyQuoted={anyQuoted} + anyOpen={anyOpen} + onQuoteAll={onQuoteAll} + onOpenAll={onOpenAll} + /> +
+ +
+ {rows.map((run) => ( + onRetry(run.spec.id)} + onRefund={() => onRefund(run.spec.id)} + onDeposit={() => onDeposit(run.spec.id)} + onMarkDelivered={onMarkDelivered} + onPickVault={onPickVault} + /> + ))} +
+
+ ); +} + +function PipelineControls({ + isConnected, + hasPlanned, + allQuoted, + anyQuoted, + anyOpen, + onQuoteAll, + onOpenAll, +}: { + isConnected: boolean; + hasPlanned: boolean; + allQuoted: boolean; + anyQuoted: boolean; + anyOpen: boolean; + onQuoteAll: () => void; + onOpenAll: () => void; +}) { + if (!isConnected) { + return ( + + Connect wallet to execute + + ); + } + if (anyOpen) { + return ( + + + Orders open — tracking + + ); + } + if (anyQuoted) { + return ( + + ); + } + return ( + + ); +} + +function LegRow({ + run, + depositBusy, + onRetry, + onRefund, + onDeposit, + onMarkDelivered, + onPickVault, +}: { + run: IntentLegRun; + depositBusy: boolean; + onRetry: () => void; + onRefund: () => void; + onDeposit: () => void; + onMarkDelivered: (legId: string, deliveredAmount: bigint) => void; + onPickVault?: (vault: EarnVault) => void; +}) { + const { spec } = run; + const sourceLabel = `${spec.source.amountDecimal} ${spec.source.symbol ?? "?"}`; + const destinationLabel = spec.destination.vault?.name + ? spec.destination.vault.name + : spec.destination.outputSymbol ?? "?"; + const config = useConfig(); + + const explorerByChain = useMemo(() => { + const map = new Map(); + for (const c of SUPPORTED_CHAINS) { + if (c.explorerUrl) map.set(c.id, c.explorerUrl); + } + return map; + }, []); + + // Share the React Query key with IntentStatusTimeline — duplicate hook + // calls with the same orderId hit the same cache entry, no duplicate poll. + const timelineActive = isTimelineActive(run.status); + const { state: orderState } = useIntentOrderStatus({ + onChainOrderId: timelineActive ? run.orderId : undefined, + enabled: timelineActive, + }); + const delivered = isDeliveredOrSettled(orderState); + + // Once delivered, snap the on-chain balance delta into the run so the + // deposit step can use the actual amount the solver delivered. + useEffect(() => { + if (!delivered || run.status !== "open") return; + if (run.deliveredAmount !== undefined && run.deliveredAmount > 0n) return; + let cancelled = false; + void (async () => { + try { + const post = (await wagmiReadContract(config, { + address: spec.destination.outputToken, + abi: erc20BalanceAbi, + functionName: "balanceOf", + args: [spec.destination.recipient], + chainId: spec.destination.chainId, + })) as bigint; + if (cancelled) return; + const pre = run.predeliveryBalance ?? 0n; + const delta = post > pre ? post - pre : post; + if (delta > 0n) onMarkDelivered(spec.id, delta); + } catch { + // Best-effort. The deposit handler will re-read balance on click. + } + })(); + return () => { + cancelled = true; + }; + }, [ + delivered, + run.status, + run.deliveredAmount, + run.predeliveryBalance, + spec.destination.outputToken, + spec.destination.recipient, + spec.destination.chainId, + spec.id, + config, + onMarkDelivered, + ]); + + return ( +
+
+
+ + {sourceLabel} +
+ +
+ + {destinationLabel} +
+
+ +
+
+ + {run.error && ( +

+ + {run.error} +

+ )} + + {run.status === "degraded" && spec.degradedReason && ( +

+ + {describeDegradeReason(spec.degradedReason)} +

+ )} + + {timelineActive && run.order && ( + + )} + + {delivered && run.status !== "deposit-done" && ( +
+

+ Delivered on {explorerLabel(spec.destination.chainId)}. + {run.deliveredAmount && spec.destination.outputSymbol && ( + + ({formatRaw(run.deliveredAmount, decimalsFor(run, spec))}{" "} + {spec.destination.outputSymbol}) + + )} +

+ {run.depositError && ( +

+ + {run.depositError} +

+ )} + +
+ )} + + {run.status === "deposit-done" && ( +

+ Deposit confirmed. + {run.depositTxHash && + explorerByChain.get(spec.destination.chainId) && ( + <> + {" "} + + View tx + + + )} +

+ )} + +
+ {run.status === "failed" && ( + + )} + {/* + Open vault drawer was previously shown at status === "open" — that + fired *before* the funds actually landed. Gate it on delivery so + the user can only navigate to the vault drawer once it would be + actionable (and only as an escape hatch from the auto-deposit). + */} + {onPickVault && + spec.destination.vault && + delivered && + run.status !== "deposit-done" && ( + + )} + {run.quote?.solver && ( + solver: {run.quote.solver} + )} +
+
+ ); +} + +function DepositButton({ + status, + disabled, + onClick, +}: { + status: IntentLegRun["status"]; + disabled: boolean; + onClick: () => void; +}) { + if (status === "deposit-quoting") { + return ( + + ); + } + if (status === "deposit-approving") { + return ( + + ); + } + if (status === "deposit-signing") { + return ( + + ); + } + return ( + + ); +} + +// The status pill renders during all of these — keep timeline visible the +// whole time so the user sees the order lifecycle through to settlement. +function isTimelineActive(status: IntentLegRun["status"]): boolean { + return ( + status === "open" || + status === "refunding" || + status === "deposit-quoting" || + status === "deposit-approving" || + status === "deposit-signing" || + status === "deposit-failed" || + status === "deposit-done" + ); +} + +function explorerLabel(chainId: number): string { + return SUPPORTED_CHAINS.find((c) => c.id === chainId)?.name ?? `chain ${chainId}`; +} + +function decimalsFor(run: IntentLegRun, spec: IntentLegSpec): number { + // The spec doesn't carry destination decimals — try to find them off the + // vault's underlyingTokens (matched by address), otherwise default to 18. + const addr = spec.destination.outputToken.toLowerCase(); + const tok = spec.destination.vault?.underlyingTokens?.find( + (t) => t.address.toLowerCase() === addr, + ); + void run; // run unused here; signature kept symmetric for future use. + return tok?.decimals ?? 18; +} + +function LegStatusPill({ status }: { status: IntentLegRun["status"] }) { + const config: Record = { + planned: { + label: "Planned", + cls: "border-border/40 bg-muted/20 text-muted-foreground", + }, + degraded: { + label: "Skipped", + cls: "border-yellow-500/40 bg-yellow-500/10 text-yellow-500", + }, + quoting: { + label: "Quoting", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + quoted: { + label: "Quoted", + cls: "border-sky-500/40 bg-sky-500/10 text-sky-400", + }, + approving: { + label: "Approving", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + signing: { + label: "Signing", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + open: { + label: "Opened", + cls: "border-emerald-500/40 bg-emerald-500/10 text-emerald-400", + icon: , + }, + "deposit-quoting": { + label: "Quoting deposit", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + "deposit-approving": { + label: "Approving", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + "deposit-signing": { + label: "Depositing", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + "deposit-done": { + label: "Deposited", + cls: "border-emerald-500/40 bg-emerald-500/10 text-emerald-400", + icon: , + }, + "deposit-failed": { + label: "Deposit failed", + cls: "border-destructive/40 bg-destructive/5 text-destructive", + icon: , + }, + refunding: { + label: "Refunding", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + refunded: { + label: "Refunded", + cls: "border-yellow-500/40 bg-yellow-500/10 text-yellow-500", + }, + failed: { + label: "Failed", + cls: "border-destructive/40 bg-destructive/5 text-destructive", + icon: , + }, + }; + const c = config[status]; + return ( + + {c.icon} + {c.label} + + ); +} + diff --git a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts index eb0f40a..f74272e 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts +++ b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts @@ -22,6 +22,7 @@ const ALIAS_GROUPS: ReadonlyArray> = [ function normalizeUnderlyingKey(vault: EarnVault): string { const symbols = (vault.underlyingTokens ?? []) + .filter((t): t is typeof t & { symbol: string } => Boolean(t.symbol)) .map((t) => { const upper = t.symbol.toUpperCase(); for (const group of ALIAS_GROUPS) { diff --git a/src/components/integrations/lifi-earn/concierge/intent/hooks/useVaultsByIntent.ts b/src/components/integrations/lifi-earn/concierge/intent/hooks/useVaultsByIntent.ts index c23e89d..98af894 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/hooks/useVaultsByIntent.ts +++ b/src/components/integrations/lifi-earn/concierge/intent/hooks/useVaultsByIntent.ts @@ -197,9 +197,9 @@ export function rankVaultsForIntent( continue; } if (targetAliases !== null) { - const symbols = (v.underlyingTokens ?? []).map((t) => - t.symbol.toUpperCase() - ); + const symbols = (v.underlyingTokens ?? []) + .filter((t): t is typeof t & { symbol: string } => Boolean(t.symbol)) + .map((t) => t.symbol.toUpperCase()); if (!symbols.some((s) => targetAliases.has(s))) { rejection.symbolMismatch++; continue; diff --git a/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts b/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts new file mode 100644 index 0000000..bb7e11a --- /dev/null +++ b/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts @@ -0,0 +1,216 @@ +import type { Address } from "viem"; +import type { EarnVault } from "../../types"; +import type { IdleAsset } from "../types"; +import type { ParsedIntent } from "./schema"; +import type { IntentRoute } from "../../intentsApi"; +import { isNativeToken } from "../../../../../utils/addressConstants"; + +// Legs that can't execute (no route, unsupported source, etc.) render as +// degraded rows rather than being silently dropped — the user needs to see +// why an asset was skipped. +export type LegDegradeReason = + | "non-evm-source" + | "native-source-unsupported" + | "wallet-not-connected" + | "source-not-routable" + | "no-target-vault" + | "missing-output-token" + | "amount-too-small"; + +export interface IntentLegSpec { + id: string; + mode: "per-asset" | "consolidate"; + source: { + chainId: number; + chainName: string; + token: Address | string; + symbol: string | undefined; + decimals: number; + amountRaw: string; + amountDecimal: string; + amountUsd: number | null; + }; + destination: { + vault: EarnVault; + chainId: number; + outputToken: Address; + outputSymbol: string | undefined; + recipient: Address; + }; + status: "planned" | "degraded"; + degradedReason?: LegDegradeReason; +} + +export interface RoutesIndex { + has: ( + fromChainId: number, + fromToken: string, + toChainId: number, + toToken: string, + ) => boolean; + isEmpty: boolean; +} + +// Distinguishes two states that the previous version conflated: +// - `undefined` → fetch hasn't completed / errored; we don't know coverage, +// so optimistically allow legs (caller may surface "coverage unknown"). +// - `[]` → the upstream genuinely reports no active routes; mark +// everything unroutable so users see honest "no route" reasons. +export function buildRoutesIndex(routes: IntentRoute[] | undefined): RoutesIndex { + if (routes === undefined) { + return { isEmpty: true, has: () => true }; + } + const set = new Set(); + for (const r of routes) { + if (!r.isActive) continue; + const key = routeKey( + Number(r.fromChain.chainId), + r.fromToken.address, + Number(r.toChain.chainId), + r.toToken.address, + ); + set.add(key); + } + return { + isEmpty: set.size === 0, + has: (fromChainId, fromToken, toChainId, toToken) => + set.has(routeKey(fromChainId, fromToken, toChainId, toToken)), + }; +} + +function routeKey( + fromChainId: number, + fromToken: string, + toChainId: number, + toToken: string, +): string { + return `${fromChainId}:${fromToken.toLowerCase()}>${toChainId}:${toToken.toLowerCase()}`; +} + +interface BuildPlanArgs { + intent: ParsedIntent; + sourceAssets: IdleAsset[]; + /** Per-asset best vaults, index-aligned with `sourceAssets`. */ + perAssetVaults?: (EarnVault | null)[]; + consolidateVault?: EarnVault | null; + walletAddress?: Address | null; + routesIndex?: RoutesIndex; +} + +const EVM_NON_EVM_TAG = /^solana|^sol|tron|tvm|svm/i; + +// LI.FI's SVM/TVM chain ids are far above any real EIP-155 chain (Solana +// mainnet is 1151111081099710), so a 9-digit ceiling is a safe heuristic. +function isLikelyEvmChainId(chainId: number): boolean { + return chainId > 0 && chainId < 1_000_000_000; +} + +export function buildIntentLegPlan(args: BuildPlanArgs): IntentLegSpec[] { + const { + intent, + sourceAssets, + perAssetVaults, + consolidateVault, + walletAddress, + routesIndex, + } = args; + + return sourceAssets.map((asset, idx) => { + const mode: IntentLegSpec["mode"] = + intent.routing_mode === "consolidate" ? "consolidate" : "per-asset"; + const targetVault = + mode === "consolidate" ? consolidateVault ?? null : perAssetVaults?.[idx] ?? null; + + const baseSource = { + chainId: asset.chainId, + chainName: asset.chainName, + token: asset.token.address, + symbol: asset.token.symbol, + decimals: asset.token.decimals, + amountRaw: asset.amountRaw, + amountDecimal: asset.amountDecimal, + amountUsd: asset.amountUsd, + }; + + const degrade = (reason: LegDegradeReason): IntentLegSpec => ({ + id: `${asset.chainId}:${asset.token.address.toLowerCase()}:${idx}`, + mode, + source: baseSource, + destination: { + // placeholder values — UI only reads these when status === 'planned'. + vault: targetVault as EarnVault, + chainId: targetVault?.chainId ?? 0, + outputToken: ("0x0000000000000000000000000000000000000000" as Address), + outputSymbol: targetVault?.underlyingTokens?.[0]?.symbol ?? "", + recipient: ("0x0000000000000000000000000000000000000000" as Address), + }, + status: "degraded", + degradedReason: reason, + }); + + if ( + !isLikelyEvmChainId(asset.chainId) || + EVM_NON_EVM_TAG.test(asset.chainName) + ) { + return degrade("non-evm-source"); + } + // OIF escrow expects ERC-20 transferFrom; native sources need wrapping. + if (isNativeToken(asset.token.address)) { + return degrade("native-source-unsupported"); + } + if (!walletAddress) return degrade("wallet-not-connected"); + if (!targetVault) return degrade("no-target-vault"); + + const outToken = targetVault.underlyingTokens?.[0]; + if (!outToken) return degrade("missing-output-token"); + + if ( + routesIndex && + !routesIndex.has( + asset.chainId, + asset.token.address, + targetVault.chainId, + outToken.address, + ) + ) { + return degrade("source-not-routable"); + } + + if (BigInt(asset.amountRaw || "0") === 0n) { + return degrade("amount-too-small"); + } + + return { + id: `${asset.chainId}:${asset.token.address.toLowerCase()}:${targetVault.slug}`, + mode, + source: baseSource, + destination: { + vault: targetVault, + chainId: targetVault.chainId, + outputToken: outToken.address as Address, + outputSymbol: outToken.symbol, + recipient: walletAddress as Address, + }, + status: "planned", + }; + }); +} + +export function describeDegradeReason(reason: LegDegradeReason): string { + switch (reason) { + case "non-evm-source": + return "Source is on a non-EVM chain — wagmi can't sign for it yet."; + case "native-source-unsupported": + return "Native tokens (ETH / native) aren't supported yet — wrap to WETH or pick an ERC-20."; + case "wallet-not-connected": + return "Connect a wallet to fund this leg."; + case "source-not-routable": + return "No active LI.FI Intent route for this token pair."; + case "no-target-vault": + return "No target vault selected for this asset."; + case "missing-output-token": + return "Target vault doesn't expose an underlying ERC-20."; + case "amount-too-small": + return "Amount is zero or below the dust threshold."; + } +} diff --git a/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts b/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts new file mode 100644 index 0000000..d50a775 --- /dev/null +++ b/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts @@ -0,0 +1,578 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + getWalletClient as getWagmiWalletClient, + readContract as wagmiReadContract, + waitForTransactionReceipt as wagmiWaitForReceipt, + switchChain as wagmiSwitchChain, + getAccount as wagmiGetAccount, +} from "@wagmi/core"; +import { + encodeFunctionData, + parseAbi, + type Address, + type Hex, +} from "viem"; +import { useConfig } from "wagmi"; +import { + requestIntentQuote, + type IntentQuote, +} from "../../intentsApi"; +import { fetchComposerQuote } from "../../earnApi"; +import { encodeEip7930EvmAddress } from "../../../../../lib/intents/eip7930"; +import { buildDeadlinePlan } from "../../../../../lib/intents/deadlines"; +import { nextOrderNonce } from "../../../../../lib/intents/nonce"; +import { + buildStandardOrder, + orderForAbi, + type StandardOrder, +} from "../../../../../lib/intents/standardOrder"; +import { + INPUT_SETTLER_ESCROW, + extractOpenOrderId, + inputSettlerEscrowAbi, +} from "../../../../../lib/intents/contracts"; +import { safeApproveErc20 } from "../../txUtils"; +import type { IntentLegSpec } from "./intentLegs"; + +// Quote requests fan out in parallel; on-chain open() runs sequentially — +// concurrent wallet prompts are unusable. +// +// `deposit-*` states cover the post-delivery same-chain Composer deposit +// that lands the underlying into the vault — Intents deliver an ERC-20 to +// the user's wallet, the deposit is a separate signature. +export type LegRunStatus = + | "planned" + | "degraded" + | "quoting" + | "quoted" + | "approving" + | "signing" + | "open" + | "deposit-quoting" + | "deposit-approving" + | "deposit-signing" + | "deposit-done" + | "deposit-failed" + | "failed" + | "refunding" + | "refunded"; + +export interface IntentLegRun { + spec: IntentLegSpec; + status: LegRunStatus; + quote?: IntentQuote; + order?: StandardOrder; + /** `Open(orderId)` event topic[1] from the open() receipt. */ + orderId?: Hex; + openTxHash?: Hex; + refundTxHash?: Hex; + /** On-chain destination underlying balance captured right before open(). */ + predeliveryBalance?: bigint; + /** Solver-delivered amount, measured as post-delivery balance delta. */ + deliveredAmount?: bigint; + depositTxHash?: Hex; + depositError?: string; + error?: string; +} + +const erc20Abi = parseAbi([ + "function allowance(address owner, address spender) view returns (uint256)", + "function approve(address spender, uint256 amount) returns (bool)", + "function balanceOf(address owner) view returns (uint256)", +]); + +interface UseIntentLegPipelineReturn { + runs: IntentLegRun[]; + quoteAll: (specs: IntentLegSpec[]) => Promise; + openAll: () => Promise; + retryLeg: (id: string) => Promise; + refundLeg: (id: string) => Promise; + depositLeg: (id: string) => Promise; + /** Lets RebalancePlanCard mark a leg as delivered once the timeline says so. */ + markLegDelivered: (id: string, deliveredAmount: bigint) => void; + reset: () => void; +} + +export function useIntentLegPipeline(): UseIntentLegPipelineReturn { + const config = useConfig(); + const [runs, setRuns] = useState([]); + + // Mirror state into a ref so async sequences (quoteAll / openAll) can read + // the latest snapshot without re-binding callbacks on every render. + const runsRef = useRef(runs); + useEffect(() => { + runsRef.current = runs; + }, [runs]); + + const patch = useCallback( + (id: string, patch: Partial) => + setRuns((prev) => + prev.map((r) => (r.spec.id === id ? { ...r, ...patch } : r)), + ), + [], + ); + + const quoteOne = useCallback( + async (run: IntentLegRun, walletAddress: Address): Promise => { + if (run.status === "degraded") return run; + const { spec } = run; + try { + const userEip7930 = encodeEip7930EvmAddress( + spec.source.chainId, + walletAddress, + ); + const fromAssetEip7930 = encodeEip7930EvmAddress( + spec.source.chainId, + spec.source.token as Address, + ); + const toAssetEip7930 = encodeEip7930EvmAddress( + spec.destination.chainId, + spec.destination.outputToken, + ); + const receiverEip7930 = encodeEip7930EvmAddress( + spec.destination.chainId, + spec.destination.recipient, + ); + + const quoteRes = await requestIntentQuote({ + user: userEip7930, + intent: { + intentType: "oif-swap", + inputs: [ + { + user: userEip7930, + asset: fromAssetEip7930, + amount: spec.source.amountRaw, + }, + ], + outputs: [ + { receiver: receiverEip7930, asset: toAssetEip7930, amount: null }, + ], + swapType: "exact-input", + }, + supportedTypes: ["oif-escrow-v0"], + }); + + const quote = quoteRes.quotes?.[0]; + if (!quote) { + return { ...run, status: "failed", error: "No quote returned" }; + } + + const previewAmount = quote.preview?.outputs?.[0]?.amount; + if (!previewAmount) { + return { + ...run, + status: "failed", + error: "Quote missing preview output amount", + }; + } + + const deadlines = buildDeadlinePlan({ + quoteValidUntilIso: quote.validUntil ?? null, + }); + + const order = buildStandardOrder({ + user: walletAddress, + nonce: nextOrderNonce(), + originChainId: spec.source.chainId, + inputToken: spec.source.token as Address, + inputAmount: BigInt(spec.source.amountRaw), + targetChainId: spec.destination.chainId, + outputToken: spec.destination.outputToken, + outputAmount: BigInt(previewAmount), + recipient: spec.destination.recipient, + expires: deadlines.expires, + fillDeadline: deadlines.fillDeadline, + context: (quote.context as Hex | undefined) ?? "0x", + }); + + return { ...run, status: "quoted", quote, order }; + } catch (err) { + return { + ...run, + status: "failed", + error: err instanceof Error ? err.message : String(err), + }; + } + }, + [], + ); + + const quoteAll = useCallback( + async (specs: IntentLegSpec[]) => { + const seeded: IntentLegRun[] = specs.map((spec) => ({ + spec, + status: spec.status === "degraded" ? "degraded" : "quoting", + })); + setRuns(seeded); + + const account = wagmiGetAccount(config); + const walletAddress = account.address as Address | undefined; + if (!walletAddress) { + setRuns( + seeded.map((r) => + r.status === "quoting" + ? { ...r, status: "failed", error: "Wallet not connected" } + : r, + ), + ); + return; + } + + const next = await Promise.all( + seeded.map((r) => + r.status === "quoting" + ? quoteOne(r, walletAddress) + : Promise.resolve(r), + ), + ); + setRuns(next); + }, + [config, quoteOne], + ); + + const openOne = useCallback( + async (run: IntentLegRun): Promise => { + if (!run.order) return run; + const chainId = run.spec.source.chainId; + + try { + const currentChain = wagmiGetAccount(config).chainId; + if (currentChain !== chainId) { + await wagmiSwitchChain(config, { chainId }); + } + + const walletClient = await getWagmiWalletClient(config, { chainId }); + if (!walletClient) throw new Error("No wallet client for source chain"); + const walletAddress = walletClient.account.address as Address; + + // Snapshot destination-chain balance of the underlying so the + // post-delivery deposit step can use the actual delta. CRITICAL: + // a failed read must HARD-FAIL — otherwise the post-fill delta + // calculation can't distinguish solver-delivered tokens from the + // user's pre-existing balance, and we'd deposit unrelated funds. + let predeliveryBalance: bigint; + try { + predeliveryBalance = (await wagmiReadContract(config, { + address: run.spec.destination.outputToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [run.spec.destination.recipient], + chainId: run.spec.destination.chainId, + })) as bigint; + } catch { + throw new Error( + "Couldn't read destination balance before opening the order — refusing to proceed (would risk depositing unrelated funds). Try again in a moment.", + ); + } + + const tokenAddr = run.spec.source.token as Address; + const amount = BigInt(run.spec.source.amountRaw); + + patch(run.spec.id, { status: "approving" }); + await safeApproveErc20({ + wagmiConfig: config, + walletClient, + token: tokenAddr, + spender: INPUT_SETTLER_ESCROW, + amount, + owner: walletAddress, + chainId, + }); + + patch(run.spec.id, { status: "signing" }); + const openData = encodeFunctionData({ + abi: inputSettlerEscrowAbi, + functionName: "open", + args: [orderForAbi(run.order)], + }); + const openHash = await walletClient.sendTransaction({ + to: INPUT_SETTLER_ESCROW, + data: openData, + }); + const receipt = await wagmiWaitForReceipt(config, { + hash: openHash, + chainId, + timeout: 120_000, + }); + if (receipt.status === "reverted") { + throw new Error("open() reverted on-chain"); + } + + const orderId = extractOpenOrderId(receipt.logs); + if (!orderId) { + // Without an orderId we can't poll status; fail loudly so the user + // sees the open tx landed but tracking is broken. + return { + ...run, + status: "failed", + openTxHash: openHash, + error: + "open() succeeded but Open(orderId) event could not be decoded — escrow ABI may have changed", + }; + } + + return { + ...run, + status: "open", + openTxHash: openHash, + orderId, + predeliveryBalance, + }; + } catch (err) { + return { + ...run, + status: "failed", + error: err instanceof Error ? err.message : String(err), + }; + } + }, + [config, patch], + ); + + const openAll = useCallback(async () => { + const ids = runs.filter((r) => r.status === "quoted").map((r) => r.spec.id); + for (const id of ids) { + const current = runsRef.current.find((r) => r.spec.id === id); + if (!current) continue; + const next = await openOne(current); + setRuns((prev) => + prev.map((r) => (r.spec.id === id ? next : r)), + ); + } + }, [openOne, runs]); + + const retryLeg = useCallback( + async (id: string) => { + const account = wagmiGetAccount(config); + const walletAddress = account.address as Address | undefined; + if (!walletAddress) return; + const current = runsRef.current.find((r) => r.spec.id === id); + if (!current) return; + patch(id, { status: "quoting", error: undefined }); + const next = await quoteOne( + { ...current, status: "quoting" }, + walletAddress, + ); + setRuns((prev) => prev.map((r) => (r.spec.id === id ? next : r))); + }, + [config, patch, quoteOne], + ); + + const refundLeg = useCallback( + async (id: string) => { + const current = runsRef.current.find((r) => r.spec.id === id); + if (!current?.order) return; + const chainId = current.spec.source.chainId; + try { + patch(id, { status: "refunding" }); + const currentChain = wagmiGetAccount(config).chainId; + if (currentChain !== chainId) { + await wagmiSwitchChain(config, { chainId }); + } + const walletClient = await getWagmiWalletClient(config, { chainId }); + if (!walletClient) throw new Error("No wallet client for source chain"); + const data = encodeFunctionData({ + abi: inputSettlerEscrowAbi, + functionName: "refund", + args: [orderForAbi(current.order)], + }); + const hash = await walletClient.sendTransaction({ + to: INPUT_SETTLER_ESCROW, + data, + }); + await wagmiWaitForReceipt(config, { + hash, + chainId, + timeout: 120_000, + }); + patch(id, { status: "refunded", refundTxHash: hash }); + } catch (err) { + patch(id, { + status: "failed", + error: err instanceof Error ? err.message : String(err), + }); + } + }, + [config, patch], + ); + + // Called by RebalancePlanCard once it observes Delivered/Settled on the + // status poll. We read the on-chain balance delta here (not the quote + // preview) because solver fill quality varies. + const markLegDelivered = useCallback( + (id: string, deliveredAmount: bigint) => { + setRuns((prev) => + prev.map((r) => + r.spec.id === id && r.status === "open" + ? { ...r, deliveredAmount } + : r, + ), + ); + }, + [], + ); + + // Cross-callback gate: prevent two clicks (or a stale React re-render + // letting two buttons stay enabled briefly) from kicking off two + // simultaneous wallet prompts for the same leg or two different legs. + const depositInFlight = useRef>(new Set()); + + const depositLeg = useCallback( + async (id: string) => { + if (depositInFlight.current.size > 0) return; + const current = runsRef.current.find((r) => r.spec.id === id); + if (!current) return; + if ( + current.status !== "open" && + current.status !== "deposit-failed" + ) { + return; + } + depositInFlight.current.add(id); + + const chainId = current.spec.destination.chainId; + const outputToken = current.spec.destination.outputToken; + const vault = current.spec.destination.vault; + const recipient = current.spec.destination.recipient; + + // Re-read the post-delivery balance now in case the user fired this + // before `markLegDelivered` landed. CRITICAL: never fall back to + // quote preview or `post` (without delta) — that risks depositing + // pre-existing balance the user holds on the destination chain. + let amount = current.deliveredAmount; + if (amount === undefined || amount === 0n) { + if (current.predeliveryBalance === undefined) { + patch(id, { + status: "deposit-failed", + depositError: + "Pre-delivery balance was never captured — open the vault drawer to deposit manually.", + }); + return; + } + try { + const post = (await wagmiReadContract(config, { + address: outputToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [recipient], + chainId, + })) as bigint; + const delta = post > current.predeliveryBalance + ? post - current.predeliveryBalance + : 0n; + if (delta === 0n) { + patch(id, { + status: "deposit-failed", + depositError: + "Solver fill not yet visible on-chain (RPC may be lagging). Retry in a moment.", + }); + return; + } + amount = delta; + } catch { + patch(id, { + status: "deposit-failed", + depositError: + "Couldn't read destination balance after delivery — retry the deposit step.", + }); + return; + } + } + + if (!amount || amount === 0n) { + patch(id, { + status: "deposit-failed", + depositError: + "Delivered amount not yet visible on-chain — wait and retry.", + }); + return; + } + + try { + patch(id, { status: "deposit-quoting", depositError: undefined }); + + const composer = await fetchComposerQuote({ + fromChain: chainId, + toChain: chainId, + fromToken: outputToken, + toToken: vault.address, + fromAddress: recipient, + toAddress: recipient, + fromAmount: amount.toString(), + underlyingSymbols: current.spec.destination.outputSymbol + ? [current.spec.destination.outputSymbol] + : undefined, + }); + + const currentChain = wagmiGetAccount(config).chainId; + if (currentChain !== chainId) { + await wagmiSwitchChain(config, { chainId }); + } + const walletClient = await getWagmiWalletClient(config, { chainId }); + if (!walletClient) { + throw new Error("No wallet client for destination chain"); + } + + const spender = composer.estimate.approvalAddress as Address; + patch(id, { status: "deposit-approving" }); + await safeApproveErc20({ + wagmiConfig: config, + walletClient, + token: outputToken, + spender, + amount, + owner: recipient, + chainId, + }); + + patch(id, { status: "deposit-signing" }); + const depositHash = await walletClient.sendTransaction({ + to: composer.transactionRequest.to as Address, + data: composer.transactionRequest.data as Hex, + value: composer.transactionRequest.value + ? BigInt(composer.transactionRequest.value) + : undefined, + gas: composer.transactionRequest.gasLimit + ? BigInt(composer.transactionRequest.gasLimit) + : undefined, + }); + const receipt = await wagmiWaitForReceipt(config, { + hash: depositHash, + chainId, + timeout: 120_000, + }); + if (receipt.status === "reverted") { + throw new Error("Deposit transaction reverted on-chain"); + } + patch(id, { + status: "deposit-done", + depositTxHash: depositHash, + deliveredAmount: amount, + }); + } catch (err) { + patch(id, { + status: "deposit-failed", + depositError: err instanceof Error ? err.message : String(err), + }); + } finally { + depositInFlight.current.delete(id); + } + }, + [config, patch], + ); + + const reset = useCallback(() => setRuns([]), []); + + return { + runs, + quoteAll, + openAll, + retryLeg, + refundLeg, + depositLeg, + markLegDelivered, + reset, + }; +} + diff --git a/src/components/integrations/lifi-earn/concierge/types.ts b/src/components/integrations/lifi-earn/concierge/types.ts index 7a1b7e9..c84c989 100644 --- a/src/components/integrations/lifi-earn/concierge/types.ts +++ b/src/components/integrations/lifi-earn/concierge/types.ts @@ -36,9 +36,15 @@ export interface Leg { source: SelectedSource; destination: EarnVault; status: LegStatus; + executionMode: "composer-same" | "composer-cross" | "intent" | null; sourceTxHash: string | null; + intentOrderId?: string; + intentStatus?: string; + depositTxHash?: string; + destinationTxHash?: string; bridgeStatus: "PENDING" | "DONE" | "FAILED" | null; errorMessage: string | null; + recoverable: boolean; } export type LegStatus = @@ -48,9 +54,68 @@ export type LegStatus = | "approving" | "executing" | "bridging" + | "intent-open" + | "intent-delivered" + | "depositing" | "done" + | "refunded" | "failed"; +export type DepositExecutionPhase = + | "same-chain" + | "composer-bridge" + | "composer-deposit" + | "intent-open" + | "intent-deposit"; + +export type DepositExecutionEvent = + | { + type: "tx-broadcast"; + phase: DepositExecutionPhase; + txHash: string; + } + | { + type: "intent-opened"; + phase: "intent-open"; + txHash: string; + orderId: string; + } + | { + type: "intent-status"; + phase: "intent-open"; + orderId?: string; + status: string; + destinationTxHash?: string; + } + | { + type: "bridge-status"; + phase: "composer-bridge"; + status: string; + txHash?: string; + substatus?: string; + } + | { + type: "delivered"; + phase: "composer-bridge" | "intent-open"; + txHash?: string; + orderId?: string; + amountRaw?: string; + destinationTxHash?: string; + } + | { + type: "confirmed"; + phase: "same-chain" | "composer-deposit" | "intent-deposit"; + txHash?: string; + } + | { + type: "failed"; + phase: DepositExecutionPhase; + message: string; + recoverable?: boolean; + txHash?: string; + orderId?: string; + }; + export interface ConciergeConfig { maxCandidatesPerAsset: number; minTvlForSafe: number; diff --git a/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts b/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts new file mode 100644 index 0000000..67e1bf6 --- /dev/null +++ b/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts @@ -0,0 +1,773 @@ +import { ethers } from "ethers"; +import type { Address } from "viem"; +import type { Config } from "@wagmi/core"; +import { + getWalletClient as getWagmiWalletClient, + waitForTransactionReceipt as wagmiWaitForReceipt, +} from "@wagmi/core"; + +import { + fetchComposerQuote, + fetchCrossChainStatus, +} from "./earnApi"; +import { networkConfigManager } from "../../../config/networkConfig"; +import { SUPPORTED_CHAINS } from "../../../utils/chains"; +import { isNativeToken } from "../../../utils/addressConstants"; +import { formatTxError } from "./txUtils"; +import type { EarnToken, EarnVault } from "./types"; + +/** + * Cross-chain Composer deposit — bridge fromToken on chain A to the vault's + * underlying on chain B, then deposit that underlying into the vault. Two + * distinct user-signed transactions chained behind LI.FI status polling. + * + * Built because Composer can route fromToken (chain A) -> underlying (chain B) + * in one tx but can't currently route fromToken (chain A) -> vault share + * (chain B) — so the UI's single-tx path fails for the cross-chain case. + * + * Sharp edges this code is paranoid about, derived from the same-chain + * `handleTwoStepExecute` audit: + * + * 1. **Bridge settlement, not source receipt.** The source tx receipt only + * proves funds left chain A; it does not prove the underlying landed on + * chain B. We poll `fetchCrossChainStatus` until `DONE` before reading the + * destination balance. Status `FAILED`/`INVALID` is terminal failure of + * the bridge leg. + * + * 2. **Balance delta, not total.** The user may already hold the underlying + * on chain B. We snapshot the balance before the bridge and deposit + * `postBalance - preBalance` (clamped to >=0). This avoids both + * under-depositing (toAmountMin leaves dust) and over-depositing pre- + * existing balance. + * + * 3. **USDT-style allowance reset.** Some ERC-20s revert when calling + * `approve(spender, n)` while a nonzero allowance is live (USDT being the + * canonical example). Before approving the deposit, if the current + * allowance is nonzero we call `approve(spender, 0)` and wait for that + * receipt before approving the real amount. + * + * 4. **Recoverable mid-flow failure.** If the bridge settles but the deposit + * tx fails, the user *still has their bridged funds*. The state machine + * surfaces `bridge-settled` with `bridgeTxHash` + `destinationAmountRaw` + * so the UI can offer a "Retry deposit" affordance. Callers can resume + * via the `resumeFromBridgeSettled` parameter — that path skips the + * bridge entirely and re-quotes the deposit against the on-chain balance + * delta the caller persisted. + */ + +export type CrossChainDepositPhase = + | "idle" + | "quoting-bridge" + | "approving-bridge" + | "signing-bridge" + | "bridging" + | "bridge-settled" + | "quoting-deposit" + | "approving-deposit" + | "signing-deposit" + | "depositing" + | "done" + | "failed"; + +/** + * Discriminated union — every phase carries the fields the UI legitimately + * needs at that point. Earlier fields persist into later phases (e.g. + * bridgeTxHash stays once it's set) so the timeline component can render a + * full history without each phase having to opt back in. + */ +export type CrossChainDepositState = + | { phase: "idle" } + | { phase: "quoting-bridge" } + | { + phase: "approving-bridge"; + bridgeApprovalSpender: string; + } + | { + phase: "signing-bridge"; + bridgeApprovalSpender?: string; + } + | { + phase: "bridging"; + bridgeTxHash: string; + bridgeStatus?: string; + bridgeSubstatus?: string; + } + | { + phase: "bridge-settled"; + bridgeTxHash: string; + destinationAmountRaw: string; + // If the destination amount is zero or unreadable, callers can still + // attempt the deposit step with the bridge-quoted toAmountMin as a + // fallback. We expose both so the UI can warn. + destinationToken: EarnToken; + } + | { + phase: "quoting-deposit"; + bridgeTxHash: string; + destinationAmountRaw: string; + } + | { + phase: "approving-deposit"; + bridgeTxHash: string; + destinationAmountRaw: string; + depositApprovalSpender: string; + } + | { + phase: "signing-deposit"; + bridgeTxHash: string; + destinationAmountRaw: string; + } + | { + phase: "depositing"; + bridgeTxHash: string; + depositTxHash: string; + destinationAmountRaw: string; + } + | { + phase: "done"; + bridgeTxHash: string; + depositTxHash: string; + } + | { + // `failedAfterBridge` discriminates a recoverable failure: the bridge + // settled, the user owns the underlying on the destination chain, and + // the UI can offer a "Retry deposit" affordance. Without this flag a + // failure is terminal (bridge never landed, or the user rejected). + phase: "failed"; + message: string; + failedAfterBridge: boolean; + bridgeTxHash?: string; + destinationAmountRaw?: string; + }; + +export interface ExecuteCrossChainComposerDepositArgs { + wagmiConfig: Config; + sourceChainId: number; + sourceToken: EarnToken; + sourceAmountRaw: string; + vault: EarnVault; + /** + * The ERC-20 the bridge will deliver to the user on the vault's chain. Must + * be one of the vault's underlying tokens — the function will then deposit + * it into the vault in step 2. + */ + destinationUnderlying: EarnToken; + userAddress: Address; + onStateChange: (state: CrossChainDepositState) => void; + /** + * Wraps `useSwitchChain().switchChainAsync` from the caller. We accept it as + * a closure (not a wagmi mutate fn) so this module stays React-free. + */ + switchChain: (chainId: number) => Promise; + /** + * If provided, skip the bridge leg entirely and retry the deposit step using + * this already-settled bridge as context. Used by the UI's "Retry deposit" + * affordance after a `failedAfterBridge` failure. The caller is responsible + * for persisting `bridgeTxHash` and `destinationAmountRaw` from the + * `bridge-settled` state. + */ + resumeFromBridgeSettled?: { + bridgeTxHash: string; + destinationAmountRaw: string; + }; +} + +const APPROVE_ABI = new ethers.utils.Interface([ + "function approve(address spender, uint256 amount) returns (bool)", +]); +const ERC20_READ_ABI = [ + "function allowance(address,address) view returns (uint256)", + "function balanceOf(address) view returns (uint256)", +]; + +/** Max poll duration for LI.FI bridge settlement before giving up. */ +const BRIDGE_POLL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (matches LI.FI's documented upper bound) +const BRIDGE_POLL_INTERVAL_MS = 4_000; +const RECEIPT_TIMEOUT_MS = 180_000; + +function chainRpcProvider(chainId: number): ethers.providers.JsonRpcProvider | null { + const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId); + if (!chain) return null; + const resolution = networkConfigManager.resolveRpcUrl(chainId, chain.rpcUrl); + if (!resolution.url) return null; + return new ethers.providers.StaticJsonRpcProvider(resolution.url, chainId); +} + +async function readAllowance( + tokenAddress: string, + owner: string, + spender: string, + chainId: number, +): Promise { + if (isNativeToken(tokenAddress)) return ethers.BigNumber.from(0); + const provider = chainRpcProvider(chainId); + if (!provider) return ethers.BigNumber.from(0); + const erc20 = new ethers.Contract(tokenAddress, ERC20_READ_ABI, provider); + return erc20.allowance(owner, spender); +} + +async function readBalance( + tokenAddress: string, + owner: string, + chainId: number, +): Promise { + const provider = chainRpcProvider(chainId); + if (!provider) return ethers.BigNumber.from(0); + if (isNativeToken(tokenAddress)) return provider.getBalance(owner); + const erc20 = new ethers.Contract(tokenAddress, ERC20_READ_ABI, provider); + return erc20.balanceOf(owner); +} + +/** + * Issue an `approve(spender, amount)` and wait for confirmation, resetting a + * nonzero allowance to 0 first when the token requires it. We always reset + * unconditionally when allowance is nonzero — the cost is one extra tx for + * non-USDT tokens, which is a fair price to avoid bricking USDT-style flows. + */ +async function approveWithReset(args: { + wagmiConfig: Config; + walletClient: NonNullable>>; + tokenAddress: string; + spender: string; + amount: ethers.BigNumber; + chainId: number; + currentAllowance: ethers.BigNumber; +}): Promise { + const { wagmiConfig, walletClient, tokenAddress, spender, amount, chainId, currentAllowance } = args; + if (currentAllowance.gte(amount)) return; + + if (currentAllowance.gt(0)) { + const resetData = APPROVE_ABI.encodeFunctionData("approve", [ + spender, + ethers.constants.Zero, + ]) as `0x${string}`; + const resetHash = await walletClient.sendTransaction({ + to: tokenAddress as `0x${string}`, + data: resetData, + // viem requires the chain object — we pass id only and rely on the + // wallet client already being scoped to chainId. + chain: { id: chainId } as any, + }); + const resetReceipt = await wagmiWaitForReceipt(wagmiConfig, { + hash: resetHash, + chainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (resetReceipt.status === "reverted") { + throw new Error("Allowance reset transaction reverted onchain"); + } + } + + const data = APPROVE_ABI.encodeFunctionData("approve", [ + spender, + ethers.constants.MaxUint256, + ]) as `0x${string}`; + const hash = await walletClient.sendTransaction({ + to: tokenAddress as `0x${string}`, + data, + chain: { id: chainId } as any, + }); + const receipt = await wagmiWaitForReceipt(wagmiConfig, { + hash, + chainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (receipt.status === "reverted") { + throw new Error("Approval transaction reverted onchain"); + } +} + +/** + * Outcome of bridge polling — `DONE` alone is not "tokens delivered to dest" + * (LI.FI uses DONE for COMPLETED, PARTIAL, and REFUNDED). We collapse the + * destination-arrived case into `COMPLETED`; everything else is failure. + */ +type BridgeOutcome = "COMPLETED" | "REFUNDED" | "PARTIAL" | "FAILED" | "INVALID"; + +async function waitForBridgeSettlement(args: { + txHash: string; + fromChain: number; + toChain: number; + onUpdate: (status: string, substatus?: string) => void; +}): Promise { + const deadline = Date.now() + BRIDGE_POLL_TIMEOUT_MS; + let consecutiveErrors = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (Date.now() > deadline) { + throw new Error("Bridge settlement timed out after 30 minutes"); + } + try { + const status = await fetchCrossChainStatus({ + txHash: args.txHash, + fromChain: args.fromChain, + toChain: args.toChain, + }); + consecutiveErrors = 0; + args.onUpdate(status.status, status.substatusMessage ?? status.substatus); + if (status.status === "DONE") { + // LI.FI substatus disambiguates: COMPLETED = tokens delivered, + // REFUNDED = bridge gave the funds back on origin, PARTIAL = some + // delivered but not the requested amount. Default to COMPLETED only + // when substatus is missing AND status is DONE — most well-behaved + // bridges set substatus. + const sub = (status.substatus ?? "").toUpperCase(); + if (sub === "REFUNDED") return "REFUNDED"; + if (sub === "PARTIAL") return "PARTIAL"; + return "COMPLETED"; + } + if (status.status === "FAILED") return "FAILED"; + if (status.status === "INVALID") return "INVALID"; + // NOT_FOUND / PENDING — keep polling + } catch (err) { + consecutiveErrors += 1; + if (consecutiveErrors >= 5) { + throw err; + } + } + await new Promise((r) => setTimeout(r, BRIDGE_POLL_INTERVAL_MS)); + } +} + +export async function executeCrossChainComposerDeposit( + args: ExecuteCrossChainComposerDepositArgs, +): Promise { + const { + wagmiConfig, + sourceChainId, + sourceToken, + sourceAmountRaw, + vault, + destinationUnderlying, + userAddress, + onStateChange, + switchChain, + resumeFromBridgeSettled, + } = args; + + // ── Sanity ──────────────────────────────────────────────────────────── + if (sourceChainId === vault.chainId) { + throw new Error( + "Cross-chain Composer deposit invoked for same-chain pair — use the single-tx flow instead", + ); + } + const isDestUnderlyingForVault = (vault.underlyingTokens ?? []).some( + (t) => t.address.toLowerCase() === destinationUnderlying.address.toLowerCase(), + ); + if (!isDestUnderlyingForVault) { + throw new Error( + "destinationUnderlying must be one of the vault's underlying tokens", + ); + } + + let bridgeTxHash: string | null = resumeFromBridgeSettled?.bridgeTxHash ?? null; + let destinationAmountRaw: string | null = + resumeFromBridgeSettled?.destinationAmountRaw ?? null; + + // Helper: build a "failed" state with the right recoverability flag. + // `failedAfterBridge` means "the bridge tx already landed" — funds may be + // on the destination chain. Only `bridgeTxHash` matters; we used to also + // require `destinationAmountRaw`, but post-bridge balance-read failures + // happen BEFORE we can assign that, and they're still recoverable (user + // can retry the deposit step once the RPC catches up). + const fail = (err: unknown): never => { + const msg = formatTxError(err); + onStateChange({ + phase: "failed", + message: msg, + failedAfterBridge: bridgeTxHash !== null, + bridgeTxHash: bridgeTxHash ?? undefined, + destinationAmountRaw: destinationAmountRaw ?? undefined, + }); + throw err instanceof Error ? err : new Error(msg); + }; + + // ───────────────────────────────────────────────────────────────────── + // STAGE 1: bridge (skip if resuming) + // ───────────────────────────────────────────────────────────────────── + if (!resumeFromBridgeSettled) { + // Snapshot the destination underlying balance BEFORE bridging so we can + // compute the delta after settlement. CRITICAL: a failed read must NOT + // default to 0 — the user could already hold destination-chain tokens + // from other sources, and `post - 0` would silently deposit those too. + let preBridgeDestBalance: ethers.BigNumber; + try { + preBridgeDestBalance = await readBalance( + destinationUnderlying.address, + userAddress, + vault.chainId, + ); + } catch (err) { + fail(new Error( + "Couldn't read destination balance before bridging — refusing to proceed (would risk depositing unrelated funds). Try again in a moment.", + )); + return; + } + + // ── Quote bridge: source -> destination underlying ────────────────── + onStateChange({ phase: "quoting-bridge" }); + let bridgeQuote; + try { + bridgeQuote = await fetchComposerQuote({ + fromChain: sourceChainId, + toChain: vault.chainId, + fromToken: sourceToken.address, + toToken: destinationUnderlying.address, + fromAddress: userAddress, + toAddress: userAddress, + fromAmount: sourceAmountRaw, + }); + } catch (err) { + fail(err); + return; + } + + // ── Switch chain & get wallet client for the source chain ────────── + try { + await switchChain(sourceChainId); + } catch (err) { + fail(err); + return; + } + + let sourceWalletClient; + try { + sourceWalletClient = await getWagmiWalletClient(wagmiConfig, { + chainId: sourceChainId, + }); + if (!sourceWalletClient) { + throw new Error("No wallet client available on source chain"); + } + } catch (err) { + fail(err); + return; + } + + // ── Approve sourceToken for bridge (with USDT-style reset) ───────── + if (!isNativeToken(sourceToken.address)) { + const spender = bridgeQuote.estimate.approvalAddress; + const sourceAmountBN = ethers.BigNumber.from(sourceAmountRaw); + let currentAllowance: ethers.BigNumber; + try { + currentAllowance = await readAllowance( + sourceToken.address, + userAddress, + spender, + sourceChainId, + ); + } catch { + currentAllowance = ethers.BigNumber.from(0); + } + if (currentAllowance.lt(sourceAmountBN)) { + onStateChange({ + phase: "approving-bridge", + bridgeApprovalSpender: spender, + }); + try { + await approveWithReset({ + wagmiConfig, + walletClient: sourceWalletClient, + tokenAddress: sourceToken.address, + spender, + amount: sourceAmountBN, + chainId: sourceChainId, + currentAllowance, + }); + } catch (err) { + fail(err); + return; + } + } + } + + // ── Send bridge tx ───────────────────────────────────────────────── + onStateChange({ phase: "signing-bridge" }); + let txHash: `0x${string}`; + try { + txHash = await sourceWalletClient.sendTransaction({ + to: bridgeQuote.transactionRequest.to as `0x${string}`, + data: bridgeQuote.transactionRequest.data as `0x${string}`, + value: bridgeQuote.transactionRequest.value + ? BigInt(bridgeQuote.transactionRequest.value) + : undefined, + gas: bridgeQuote.transactionRequest.gasLimit + ? BigInt(bridgeQuote.transactionRequest.gasLimit) + : undefined, + chain: { id: sourceChainId } as any, + }); + } catch (err) { + fail(err); + return; + } + + bridgeTxHash = txHash; + onStateChange({ + phase: "bridging", + bridgeTxHash: txHash, + }); + + // ── Wait for source receipt (proves tx mined, not bridge settled) ── + try { + const receipt = await wagmiWaitForReceipt(wagmiConfig, { + hash: txHash, + chainId: sourceChainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (receipt.status === "reverted") { + throw new Error("Bridge transaction reverted onchain"); + } + } catch (err) { + fail(err); + return; + } + + // ── Poll LI.FI status until terminal ──────────────────────────────── + let outcome: BridgeOutcome; + try { + outcome = await waitForBridgeSettlement({ + txHash, + fromChain: sourceChainId, + toChain: vault.chainId, + onUpdate: (status, substatus) => { + onStateChange({ + phase: "bridging", + bridgeTxHash: txHash, + bridgeStatus: status, + bridgeSubstatus: substatus, + }); + }, + }); + } catch (err) { + fail(err); + return; + } + if (outcome === "REFUNDED") { + fail(new Error("Bridge refunded — funds returned to the source chain. Deposit will not proceed.")); + return; + } + if (outcome === "PARTIAL") { + fail(new Error("Bridge delivered only a partial amount. We won't auto-deposit a partial fill; review on LI.FI and decide whether to deposit manually.")); + return; + } + if (outcome !== "COMPLETED") { + fail(new Error(`Bridge ${outcome.toLowerCase()} — funds may be stranded; check the source tx on LI.FI`)); + return; + } + + // ── Read destination balance DELTA ───────────────────────────────── + let postBridgeDestBalance: ethers.BigNumber; + try { + postBridgeDestBalance = await readBalance( + destinationUnderlying.address, + userAddress, + vault.chainId, + ); + } catch (err) { + // Bridge completed but we can't measure delivered amount. Recoverable: + // the user can retry from `bridge-settled` once the RPC catches up. + fail(new Error( + "Bridge settled but we couldn't read your destination balance. Your funds arrived; retry the deposit step.", + )); + return; + } + const delta = postBridgeDestBalance.sub(preBridgeDestBalance); + if (delta.lte(0)) { + fail(new Error( + "Bridge settled but destination balance hasn't increased yet (RPC lag). Wait a moment and retry the deposit step.", + )); + return; + } + destinationAmountRaw = delta.toString(); + + onStateChange({ + phase: "bridge-settled", + bridgeTxHash: txHash, + destinationAmountRaw, + destinationToken: destinationUnderlying, + }); + } else { + // Resuming after a post-bridge failure. Trust on-chain reality, not the + // stored destinationAmountRaw — the user may have spent / received more + // of the destination token, or the original delta may have been 0 due to + // RPC lag at first read. Re-read and treat the live balance as the + // depositable amount (capped at "what arrived" by reading the bridge + // status's `receiving.amount` when available, so we don't grab unrelated + // pre-existing balance). + let liveBalance: ethers.BigNumber; + try { + liveBalance = await readBalance( + destinationUnderlying.address, + userAddress, + vault.chainId, + ); + } catch (err) { + fail(new Error("Couldn't read destination balance for retry. Try again in a moment.")); + return; + } + if (liveBalance.lte(0)) { + fail(new Error("Destination balance is zero. Bridge may still be settling, or funds were already deposited.")); + return; + } + // Cap retry amount at the originally-bridged amount when known. If the + // stored value is "0" (the old broken case), fall back to the live + // balance — risky but only reachable from a recoverable-fail state the + // user explicitly retried. + let chosen = liveBalance; + try { + const original = ethers.BigNumber.from(destinationAmountRaw ?? "0"); + if (original.gt(0) && liveBalance.gte(original)) { + chosen = original; + } + } catch { + /* keep liveBalance */ + } + destinationAmountRaw = chosen.toString(); + + onStateChange({ + phase: "bridge-settled", + bridgeTxHash: bridgeTxHash!, + destinationAmountRaw, + destinationToken: destinationUnderlying, + }); + } + + // ───────────────────────────────────────────────────────────────────── + // STAGE 2: deposit underlying -> vault (same-chain on vault.chainId) + // ───────────────────────────────────────────────────────────────────── + // At this point bridgeTxHash and destinationAmountRaw are non-null. + const lockedBridgeHash = bridgeTxHash as string; + const lockedDestAmount = destinationAmountRaw as string; + + onStateChange({ + phase: "quoting-deposit", + bridgeTxHash: lockedBridgeHash, + destinationAmountRaw: lockedDestAmount, + }); + + let depositQuote; + try { + depositQuote = await fetchComposerQuote({ + fromChain: vault.chainId, + toChain: vault.chainId, + fromToken: destinationUnderlying.address, + toToken: vault.address, + fromAddress: userAddress, + toAddress: userAddress, + fromAmount: lockedDestAmount, + }); + } catch (err) { + fail(err); + return; + } + + try { + await switchChain(vault.chainId); + } catch (err) { + fail(err); + return; + } + + let destWalletClient; + try { + destWalletClient = await getWagmiWalletClient(wagmiConfig, { + chainId: vault.chainId, + }); + if (!destWalletClient) { + throw new Error("No wallet client available on destination chain"); + } + } catch (err) { + fail(err); + return; + } + + // ── Approve underlying for vault deposit (with USDT-style reset) ───── + if (!isNativeToken(destinationUnderlying.address)) { + const spender = depositQuote.estimate.approvalAddress; + const depositAmountBN = ethers.BigNumber.from(lockedDestAmount); + let currentAllowance: ethers.BigNumber; + try { + currentAllowance = await readAllowance( + destinationUnderlying.address, + userAddress, + spender, + vault.chainId, + ); + } catch { + currentAllowance = ethers.BigNumber.from(0); + } + if (currentAllowance.lt(depositAmountBN)) { + onStateChange({ + phase: "approving-deposit", + bridgeTxHash: lockedBridgeHash, + destinationAmountRaw: lockedDestAmount, + depositApprovalSpender: spender, + }); + try { + await approveWithReset({ + wagmiConfig, + walletClient: destWalletClient, + tokenAddress: destinationUnderlying.address, + spender, + amount: depositAmountBN, + chainId: vault.chainId, + currentAllowance, + }); + } catch (err) { + fail(err); + return; + } + } + } + + // ── Send deposit tx ─────────────────────────────────────────────────── + onStateChange({ + phase: "signing-deposit", + bridgeTxHash: lockedBridgeHash, + destinationAmountRaw: lockedDestAmount, + }); + let depositHash: `0x${string}`; + try { + depositHash = await destWalletClient.sendTransaction({ + to: depositQuote.transactionRequest.to as `0x${string}`, + data: depositQuote.transactionRequest.data as `0x${string}`, + value: depositQuote.transactionRequest.value + ? BigInt(depositQuote.transactionRequest.value) + : undefined, + gas: depositQuote.transactionRequest.gasLimit + ? BigInt(depositQuote.transactionRequest.gasLimit) + : undefined, + chain: { id: vault.chainId } as any, + }); + } catch (err) { + fail(err); + return; + } + + onStateChange({ + phase: "depositing", + bridgeTxHash: lockedBridgeHash, + depositTxHash: depositHash, + destinationAmountRaw: lockedDestAmount, + }); + + try { + const receipt = await wagmiWaitForReceipt(wagmiConfig, { + hash: depositHash, + chainId: vault.chainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (receipt.status === "reverted") { + throw new Error("Deposit transaction reverted onchain"); + } + } catch (err) { + fail(err); + return; + } + + onStateChange({ + phase: "done", + bridgeTxHash: lockedBridgeHash, + depositTxHash: depositHash, + }); +} diff --git a/src/components/integrations/lifi-earn/destinationTokenOptions.ts b/src/components/integrations/lifi-earn/destinationTokenOptions.ts new file mode 100644 index 0000000..e5ea5ba --- /dev/null +++ b/src/components/integrations/lifi-earn/destinationTokenOptions.ts @@ -0,0 +1,100 @@ +import { CHAIN_REGISTRY } from "../../../utils/chains"; +import type { EarnToken } from "./types"; + +/** + * Curated high-liquidity receive tokens per chain. Mirrored from + * DepositFlow's common-token picker so withdraw routing stays constrained to + * known Composer/Intent-friendly assets instead of arbitrary user input. + */ +export function getDestinationTokenOptions(chainId: number): EarnToken[] { + const native = (symbol: string, decimals = 18): EarnToken => ({ + address: "0x0000000000000000000000000000000000000000", + symbol, + decimals, + chainId, + }); + + const common: Record = { + 1: [ + native("ETH"), + { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", symbol: "USDC", decimals: 6, chainId: 1 }, + { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", symbol: "USDT", decimals: 6, chainId: 1 }, + { address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", symbol: "WETH", decimals: 18, chainId: 1 }, + ], + 137: [ + native("POL"), + { address: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", symbol: "WPOL", decimals: 18, chainId: 137 }, + { address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", symbol: "USDC", decimals: 6, chainId: 137 }, + { address: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", symbol: "USDT", decimals: 6, chainId: 137 }, + { address: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", symbol: "WETH", decimals: 18, chainId: 137 }, + ], + 42161: [ + native("ETH"), + { address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", symbol: "USDC", decimals: 6, chainId: 42161 }, + { address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", symbol: "USDT", decimals: 6, chainId: 42161 }, + { address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", symbol: "WETH", decimals: 18, chainId: 42161 }, + ], + 10: [ + native("ETH"), + { address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", symbol: "USDC", decimals: 6, chainId: 10 }, + { address: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", symbol: "USDT", decimals: 6, chainId: 10 }, + { address: "0x4200000000000000000000000000000000000006", symbol: "WETH", decimals: 18, chainId: 10 }, + ], + 8453: [ + native("ETH"), + { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", symbol: "USDC", decimals: 6, chainId: 8453 }, + { address: "0x4200000000000000000000000000000000000006", symbol: "WETH", decimals: 18, chainId: 8453 }, + ], + 56: [ + native("BNB"), + { address: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", symbol: "USDC", decimals: 18, chainId: 56 }, + { address: "0x55d398326f99059fF775485246999027B3197955", symbol: "USDT", decimals: 18, chainId: 56 }, + { address: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", symbol: "WBNB", decimals: 18, chainId: 56 }, + ], + 43114: [ + native("AVAX"), + { address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", symbol: "USDC", decimals: 6, chainId: 43114 }, + { address: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", symbol: "USDT", decimals: 6, chainId: 43114 }, + ], + 100: [ + native("xDAI", 18), + { address: "0x6A023CCd1ff6F2045C3309768eAD9E68F978f6e1", symbol: "WETH", decimals: 18, chainId: 100 }, + { address: "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", symbol: "USDC", decimals: 6, chainId: 100 }, + ], + }; + + const chainMeta = CHAIN_REGISTRY.find((c) => c.id === chainId); + const nativeSymbol = chainMeta?.nativeCurrency?.symbol ?? "ETH"; + const nativeDecimals = chainMeta?.nativeCurrency?.decimals ?? 18; + return common[chainId] ?? [native(nativeSymbol, nativeDecimals)]; +} + +export function destinationTokenKey(token: EarnToken): string { + return `${token.chainId ?? 0}:${token.address.toLowerCase()}`; +} + +export function pickDefaultDestinationToken(args: { + chainId: number; + sourceSymbol: string; + sameChainToken?: EarnToken; +}): EarnToken { + if (args.sameChainToken && args.sameChainToken.chainId === args.chainId) { + return args.sameChainToken; + } + + const options = getDestinationTokenOptions(args.chainId); + const source = args.sourceSymbol.toUpperCase(); + const ethLike = source === "ETH" || source === "WETH"; + if (ethLike) { + // Prefer WETH specifically — "startsWith W" picks up WPOL/WBNB/etc. on + // their native chains, which is the wrong asset for an ETH source. + const weth = options.find((t) => t.symbol?.toUpperCase() === "WETH"); + if (weth) return weth; + } + + return ( + options.find((t) => t.symbol?.toUpperCase() === source) ?? + options.find((t) => t.symbol?.toUpperCase() === "USDC") ?? + options[0] + ); +} diff --git a/src/components/integrations/lifi-earn/earnApi.ts b/src/components/integrations/lifi-earn/earnApi.ts index 5eb3196..c9c1429 100644 --- a/src/components/integrations/lifi-earn/earnApi.ts +++ b/src/components/integrations/lifi-earn/earnApi.ts @@ -96,19 +96,15 @@ function toComposerAddress(addr: string): string { return addr.trim().toLowerCase(); } -export async function fetchComposerQuote(params: { +function buildComposerQuoteUrl(params: { fromChain: number; toChain: number; fromToken: string; - // vault share address toToken: string; fromAddress: string; toAddress: string; - // smallest-unit decimal string fromAmount: string; - /** Vault's underlying token symbols — used for clearer error messages. */ - underlyingSymbols?: string[]; -}): Promise { +}): string { const url = new URL(`${window.location.origin}${COMPOSER_PROXY}/v1/quote`); url.searchParams.set("fromChain", String(params.fromChain)); url.searchParams.set("toChain", String(params.toChain)); @@ -121,8 +117,23 @@ export async function fetchComposerQuote(params: { // composer returns 1001 "None of the available routes could successfully // generate a tx". `hexkit` is our registered integrator in the LiFi portal. url.searchParams.set("integrator", "hexkit"); + return url.toString(); +} - const res = await fetch(url.toString(), { +export async function fetchComposerQuote(params: { + fromChain: number; + toChain: number; + fromToken: string; + // vault share address + toToken: string; + fromAddress: string; + toAddress: string; + // smallest-unit decimal string + fromAmount: string; + /** Vault's underlying token symbols — used for clearer error messages. */ + underlyingSymbols?: string[]; +}): Promise { + const res = await fetch(buildComposerQuoteUrl(params), { headers: proxyHeaders(), signal: AbortSignal.timeout(30000), }); @@ -133,17 +144,29 @@ export async function fetchComposerQuote(params: { try { const parsed = JSON.parse(body); if (parsed.code === 1002) { + // Composer code 1002 = no route found at all. Don't speculate on the + // cause (could be amount, liquidity, vendor outage, or a missing + // token mapping) — just point at the most common workaround. const syms = params.underlyingSymbols; const hint = syms?.length - ? ` Try depositing with ${syms.join("/")} directly — Composer can't always swap into this vault's underlying token in one step.` + ? ` Try depositing with ${syms.join("/")} directly — Composer can't always swap into this vault's underlying token.` : ""; + const isCrossChain = params.fromChain !== params.toChain; throw new Error( - `No route available for this deposit. The amount may be too small or there's no liquidity path.${hint}` + isCrossChain + ? `No bridge route available for this pair right now.${hint} Picking a vault on the same chain as your source token is the most reliable workaround.` + : `No route available for this deposit.${hint}` ); } if (parsed.code === 1001) { + // Composer 1001 = "no route generated a tx". Cross-chain success is + // non-monotonic and unstable hour-to-hour, so we don't speculate — + // we surface the failure fast and let the user adjust. + const isCrossChain = params.fromChain !== params.toChain; throw new Error( - "Route found but transaction couldn't be generated. Try a larger amount." + isCrossChain + ? "Cross-chain bridge couldn't price this route right now. Try a different amount, or pick a vault on the same chain as your source token." + : "Route exists but no available solver could quote it. Try adjusting the amount slightly." ); } if (parsed.message) { diff --git a/src/components/integrations/lifi-earn/hooks/useWithdrawQuote.ts b/src/components/integrations/lifi-earn/hooks/useWithdrawQuote.ts index 80db805..39ca0f8 100644 --- a/src/components/integrations/lifi-earn/hooks/useWithdrawQuote.ts +++ b/src/components/integrations/lifi-earn/hooks/useWithdrawQuote.ts @@ -14,6 +14,9 @@ interface UseWithdrawQuoteParams { } export function useWithdrawQuote(params: UseWithdrawQuoteParams) { + // Redeem quote only: vault share token -> underlying on the vault chain. + // Cross-chain withdraw routing starts after this tx confirms and the UI has + // measured the actual underlying balance delta. return useComposerQuote({ fromChain: params.chainId, toChain: params.chainId, diff --git a/src/components/integrations/lifi-earn/intentsApi.ts b/src/components/integrations/lifi-earn/intentsApi.ts new file mode 100644 index 0000000..82a1360 --- /dev/null +++ b/src/components/integrations/lifi-earn/intentsApi.ts @@ -0,0 +1,224 @@ +import type { Hex } from "viem"; + +// Integrator endpoints on order.li.fi are unauthenticated; the proxy exists +// for CORS parity + a server-side allowlist. Response shapes are a +// conservative superset — strict where we depend on a field, open elsewhere. +const INTENTS_PROXY = "/api/lifi-intents"; + +export type IntentSwapType = "exact-input" | "exact-output"; + +export interface IntentEndpoint { + /** EIP-7930 interoperable address (see lib/intents/eip7930). */ + user: Hex; + /** EIP-7930 interoperable address for the token. */ + asset: Hex; + /** Smallest-unit amount. Null on outputs for exact-input quotes. */ + amount: string | null; +} + +export interface IntentQuoteRequest { + user: Hex; + intent: { + intentType: "oif-swap"; + inputs: IntentEndpoint[]; + outputs: Array<{ receiver: Hex; asset: Hex; amount: string | null }>; + swapType: IntentSwapType; + }; + supportedTypes: Array<"oif-escrow-v0" | "oif-compact-v0">; +} + +export interface IntentQuotePreviewOutput { + amount: string; + [key: string]: unknown; +} + +export interface IntentQuote { + preview?: { + outputs?: IntentQuotePreviewOutput[]; + [key: string]: unknown; + }; + /** Pass straight into outputs[].context for auction/limit handling. */ + context?: Hex; + validUntil?: string; + solver?: string; + [key: string]: unknown; +} + +export interface IntentQuoteResponse { + quotes: IntentQuote[]; + [key: string]: unknown; +} + +// LI.FI surfaces tx hashes and solver under `meta.*`; older shapes (and our +// previous typing) put them at the top level. Readers fall back to either. +export interface IntentOrderStatus { + orderId?: Hex; + catalystOrderId?: string; + status?: string; + meta?: { + orderStatus?: string; + orderOpenedTxHash?: Hex; + orderSignedTxHash?: Hex; + orderDeliveredTxHash?: Hex; + orderSettledTxHash?: Hex; + solverAddress?: string; + [key: string]: unknown; + }; + originTxHash?: Hex; + destinationTxHash?: Hex; + solver?: string; + [key: string]: unknown; +} + +export function readOriginTxHash(s: IntentOrderStatus | null | undefined): Hex | undefined { + return s?.originTxHash ?? s?.meta?.orderOpenedTxHash; +} + +export function readDestinationTxHash( + s: IntentOrderStatus | null | undefined, +): Hex | undefined { + return ( + s?.destinationTxHash ?? + s?.meta?.orderDeliveredTxHash ?? + s?.meta?.orderSettledTxHash + ); +} + +export function readSolverAddress( + s: IntentOrderStatus | null | undefined, +): string | undefined { + return s?.solver ?? s?.meta?.solverAddress; +} + +async function postJson(path: string, body: unknown): Promise { + const res = await fetch(`${INTENTS_PROXY}/${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(`Intents ${path} failed: ${res.status} ${txt}`); + } + return res.json() as Promise; +} + +async function getJson(path: string, params?: Record): Promise { + const qs = params + ? `?${new URLSearchParams(params).toString()}` + : ""; + const url = `${INTENTS_PROXY}/${path}${qs}`; + const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(`Intents ${path} failed: ${res.status} ${txt}`); + } + return res.json() as Promise; +} + +export function requestIntentQuote( + body: IntentQuoteRequest, +): Promise { + return postJson("quote/request", body); +} + +export interface IntentOrderSubmitBody { + order: unknown; + signature?: Hex; + metadata?: { source?: string; [key: string]: unknown }; +} + +// /orders/submit is for gasless / sponsored flows (Permit2 + openFor, or +// Compact resource locks); normal escrow goes on-chain via open() and the +// order server picks it up from the Open event. +export function submitIntentOrder( + body: IntentOrderSubmitBody, +): Promise<{ orderId?: Hex; [key: string]: unknown }> { + return postJson("orders/submit", body); +} + +export function fetchIntentOrderStatus(params: { + onChainOrderId?: Hex; + catalystOrderId?: string; +}): Promise { + const query: Record = {}; + if (params.onChainOrderId) query.onChainOrderId = params.onChainOrderId; + if (params.catalystOrderId) query.catalystOrderId = params.catalystOrderId; + return getJson("orders/status", query); +} + +export interface IntentChain { + id: number; + chainId: string; + name: string; + chainType: "EVM" | "SVM" | "TVM" | string; +} + +export function fetchIntentChains(): Promise { + return getJson("chains/supported"); +} + +export interface IntentRoute { + fromChain: { chainId: string; chainType: string; name: string }; + toChain: { chainId: string; chainType: string; name: string }; + fromToken: { address: string; symbol: string | null; decimals: number }; + toToken: { address: string; symbol: string | null; decimals: number }; + isActive: boolean; + [key: string]: unknown; +} + +export interface IntentRoutesResponse { + routes: IntentRoute[]; +} + +export function fetchIntentRoutes(): Promise { + return getJson("routes"); +} + +// Canonical lifecycle from docs.li.fi/lifi-intents. Anything outside this +// set is treated as `Unknown` and surfaced verbatim. +export type CanonicalOrderState = + | "Submitted" + | "Open" + | "Signed" + | "Delivered" + | "Settled" + | "Expired" + | "Refunded" + | "Failed" + | "Unknown"; + +const KNOWN_STATES: Record = { + submitted: "Submitted", + open: "Open", + signed: "Signed", + delivered: "Delivered", + settled: "Settled", + expired: "Expired", + refunded: "Refunded", + failed: "Failed", +}; + +export function readOrderState(s: IntentOrderStatus | null | undefined): { + state: CanonicalOrderState; + rawLabel: string; +} { + const raw = (s?.meta?.orderStatus ?? s?.status ?? "").trim(); + if (!raw) return { state: "Unknown", rawLabel: "Pending" }; + const exact = KNOWN_STATES[raw.toLowerCase()]; + return { state: exact ?? "Unknown", rawLabel: raw }; +} + +export function isTerminalState(state: CanonicalOrderState): boolean { + return ( + state === "Settled" || + state === "Refunded" || + state === "Failed" || + state === "Expired" + ); +} + +export function isDeliveredOrSettled(state: CanonicalOrderState): boolean { + return state === "Delivered" || state === "Settled"; +} diff --git a/src/components/integrations/lifi-earn/txUtils.ts b/src/components/integrations/lifi-earn/txUtils.ts index fab3db7..1b8885f 100644 --- a/src/components/integrations/lifi-earn/txUtils.ts +++ b/src/components/integrations/lifi-earn/txUtils.ts @@ -65,3 +65,95 @@ export function formatTxError(err: unknown): string { } export { isNativeToken } from "../../../utils/addressConstants"; + +import { + readContract as wagmiReadContract, + waitForTransactionReceipt as wagmiWaitForReceipt, + type Config, +} from "@wagmi/core"; +import { + encodeFunctionData, + parseAbi, + type Address, + type Hex, +} from "viem"; + +const ERC20_APPROVE_ABI = parseAbi([ + "function allowance(address owner, address spender) view returns (uint256)", + "function approve(address spender, uint256 amount) returns (bool)", +]); + +/** + * Issue `approve(spender, amount)`, resetting a nonzero allowance to zero + * first when needed. USDT and a handful of other ERC-20s revert on + * `approve(spender, X)` when an existing nonzero `approve(spender, Y)` is + * already set — must `approve(spender, 0)` first. Always paying the extra tx + * when allowance is nonzero is safer than maintaining a token allowlist. + * + * No-op when current allowance >= amount. + */ +export async function safeApproveErc20(args: { + wagmiConfig: Config; + walletClient: { + sendTransaction: (tx: { to: Address; data: Hex }) => Promise; + }; + token: Address; + spender: Address; + amount: bigint; + owner: Address; + chainId: number; + timeoutMs?: number; +}): Promise { + const { + wagmiConfig, + walletClient, + token, + spender, + amount, + owner, + chainId, + timeoutMs = 120_000, + } = args; + + const current = (await wagmiReadContract(wagmiConfig, { + address: token, + abi: ERC20_APPROVE_ABI, + functionName: "allowance", + args: [owner, spender], + chainId, + })) as bigint; + + if (current >= amount) return; + + if (current > 0n) { + const resetData = encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [spender, 0n], + }); + const resetHash = await walletClient.sendTransaction({ + to: token, + data: resetData, + }); + await wagmiWaitForReceipt(wagmiConfig, { + hash: resetHash, + chainId, + timeout: timeoutMs, + }); + } + + const approveData = encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [spender, amount], + }); + const hash = await walletClient.sendTransaction({ + to: token, + data: approveData, + }); + await wagmiWaitForReceipt(wagmiConfig, { + hash, + chainId, + timeout: timeoutMs, + }); +} diff --git a/src/components/integrations/lifi-earn/types.ts b/src/components/integrations/lifi-earn/types.ts index c147b19..2a6402f 100644 --- a/src/components/integrations/lifi-earn/types.ts +++ b/src/components/integrations/lifi-earn/types.ts @@ -1,6 +1,9 @@ export interface EarnToken { address: string; - symbol: string; + // The upstream Earn API occasionally ships underlyings with a missing symbol; + // typed nullable so call sites have to handle it instead of crashing on + // `.toUpperCase()` (etc.). + symbol: string | undefined; name?: string; decimals: number; chainId?: number; diff --git a/src/components/integrations/lifi-earn/useIntentOrderStatus.ts b/src/components/integrations/lifi-earn/useIntentOrderStatus.ts new file mode 100644 index 0000000..ad85df3 --- /dev/null +++ b/src/components/integrations/lifi-earn/useIntentOrderStatus.ts @@ -0,0 +1,58 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Hex } from "viem"; +import { + fetchIntentOrderStatus, + isTerminalState, + readOrderState, + type CanonicalOrderState, + type IntentOrderStatus, +} from "./intentsApi"; + +// Shared between IntentStatusTimeline and IntentBridgeStep so React Query +// dedupes the poll across components. +export interface IntentOrderStatusResult { + status: IntentOrderStatus | null; + state: CanonicalOrderState; + rawLabel: string; + isLoading: boolean; +} + +export function useIntentOrderStatus(params: { + onChainOrderId?: Hex; + catalystOrderId?: string; + enabled?: boolean; +}): IntentOrderStatusResult { + const enabled = + (params.enabled ?? true) && + Boolean(params.onChainOrderId || params.catalystOrderId); + + const query = useQuery({ + queryKey: [ + "intent-order-status", + params.onChainOrderId ?? params.catalystOrderId ?? "", + ], + enabled, + queryFn: () => + fetchIntentOrderStatus({ + onChainOrderId: params.onChainOrderId, + catalystOrderId: params.catalystOrderId, + }), + refetchInterval: (q) => { + const data = q.state.data; + if (!data) return 3_000; + const { state } = readOrderState(data); + if (isTerminalState(state)) return false; + if (state === "Delivered") return 8_000; + return 3_000; + }, + refetchOnWindowFocus: true, + }); + + const { state, rawLabel } = readOrderState(query.data ?? null); + return { + status: query.data ?? null, + state, + rawLabel, + isLoading: query.isLoading, + }; +} diff --git a/src/components/integrations/lifi-earn/withdrawComposerRoute.ts b/src/components/integrations/lifi-earn/withdrawComposerRoute.ts new file mode 100644 index 0000000..fd52bb8 --- /dev/null +++ b/src/components/integrations/lifi-earn/withdrawComposerRoute.ts @@ -0,0 +1,317 @@ +import type { Config } from "@wagmi/core"; +import { + getWalletClient as getWagmiWalletClient, + waitForTransactionReceipt as wagmiWaitForReceipt, +} from "@wagmi/core"; +import type { Address, Hex } from "viem"; + +import { fetchComposerQuote, fetchCrossChainStatus } from "./earnApi"; +import type { EarnToken, LifiStatusResponse } from "./types"; +import { formatTxError, isNativeToken, safeApproveErc20 } from "./txUtils"; + +export type WithdrawComposerRoutePhase = + | "idle" + | "route-quoting" + | "composer-quoted" + | "composer-approving" + | "composer-sending" + | "composer-settling" + | "done" + | "failed"; + +export type WithdrawComposerRouteState = + | { phase: "idle" } + | { phase: "route-quoting" } + | { + phase: "composer-quoted"; + approvalSpender?: string; + } + | { + phase: "composer-approving"; + approvalSpender: string; + } + | { + phase: "composer-sending"; + approvalSpender?: string; + } + | { + phase: "composer-settling"; + routeTxHash: string; + lifiStatus?: string; + lifiSubstatus?: string; + } + | { + phase: "done"; + routeTxHash: string; + destinationTxHash?: string; + } + | { + phase: "failed"; + message: string; + failedAfterBroadcast: boolean; + routeTxHash?: string; + lifiStatus?: string; + lifiSubstatus?: string; + }; + +export interface ExecuteWithdrawComposerRouteArgs { + wagmiConfig: Config; + sourceChainId: number; + sourceToken: EarnToken; + sourceAmountRaw: string; + destinationChainId: number; + destinationToken: EarnToken; + userAddress: Address; + onStateChange: (state: WithdrawComposerRouteState) => void; + switchChain: (chainId: number) => Promise; +} + +const SETTLEMENT_TIMEOUT_MS = 30 * 60 * 1000; +const SETTLEMENT_POLL_INTERVAL_MS = 4_000; +const RECEIPT_TIMEOUT_MS = 180_000; + +type SettlementOutcome = "COMPLETED" | "REFUNDED" | "PARTIAL" | "FAILED" | "INVALID"; + +function readSubstatus(status: LifiStatusResponse): string | undefined { + return status.substatusMessage ?? status.substatus; +} + +function classifyDoneStatus(status: LifiStatusResponse): SettlementOutcome { + const sub = (status.substatus ?? "").toUpperCase(); + if (sub === "REFUNDED") return "REFUNDED"; + if (sub === "PARTIAL") return "PARTIAL"; + return "COMPLETED"; +} + +async function waitForComposerSettlement(args: { + txHash: string; + fromChain: number; + toChain: number; + onUpdate: (status: LifiStatusResponse) => void; +}): Promise<{ outcome: SettlementOutcome; status: LifiStatusResponse }> { + const deadline = Date.now() + SETTLEMENT_TIMEOUT_MS; + let consecutiveErrors = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (Date.now() > deadline) { + throw new Error("Route settlement timed out after 30 minutes"); + } + + try { + const status = await fetchCrossChainStatus({ + txHash: args.txHash, + fromChain: args.fromChain, + toChain: args.toChain, + }); + consecutiveErrors = 0; + args.onUpdate(status); + + if (status.status === "DONE") { + return { outcome: classifyDoneStatus(status), status }; + } + if (status.status === "FAILED") { + return { outcome: "FAILED", status }; + } + if (status.status === "INVALID") { + return { outcome: "INVALID", status }; + } + } catch (err) { + consecutiveErrors += 1; + if (consecutiveErrors >= 5) throw err; + } + + await new Promise((resolve) => + setTimeout(resolve, SETTLEMENT_POLL_INTERVAL_MS), + ); + } +} + +export async function executeWithdrawComposerRoute( + args: ExecuteWithdrawComposerRouteArgs, +): Promise { + const { + wagmiConfig, + sourceChainId, + sourceToken, + sourceAmountRaw, + destinationChainId, + destinationToken, + userAddress, + onStateChange, + switchChain, + } = args; + + let routeTxHash: string | null = null; + let lastStatus: LifiStatusResponse | null = null; + + const fail = (err: unknown): never => { + const message = formatTxError(err); + onStateChange({ + phase: "failed", + message, + failedAfterBroadcast: routeTxHash !== null, + routeTxHash: routeTxHash ?? undefined, + lifiStatus: lastStatus?.status, + lifiSubstatus: lastStatus ? readSubstatus(lastStatus) : undefined, + }); + throw err instanceof Error ? err : new Error(message); + }; + + let quote; + try { + onStateChange({ phase: "route-quoting" }); + quote = await fetchComposerQuote({ + fromChain: sourceChainId, + toChain: destinationChainId, + fromToken: sourceToken.address, + toToken: destinationToken.address, + fromAddress: userAddress, + toAddress: userAddress, + fromAmount: sourceAmountRaw, + }); + } catch (err) { + fail(err); + return; + } + + onStateChange({ + phase: "composer-quoted", + approvalSpender: quote.estimate.approvalAddress, + }); + + try { + await switchChain(sourceChainId); + } catch (err) { + fail(err); + return; + } + + let walletClient; + try { + walletClient = await getWagmiWalletClient(wagmiConfig, { + chainId: sourceChainId, + }); + if (!walletClient) { + throw new Error("No wallet client available on source chain"); + } + } catch (err) { + fail(err); + return; + } + + if (!isNativeToken(sourceToken.address)) { + try { + onStateChange({ + phase: "composer-approving", + approvalSpender: quote.estimate.approvalAddress, + }); + await safeApproveErc20({ + wagmiConfig, + walletClient, + token: sourceToken.address as Address, + spender: quote.estimate.approvalAddress as Address, + amount: BigInt(sourceAmountRaw), + owner: userAddress, + chainId: sourceChainId, + timeoutMs: RECEIPT_TIMEOUT_MS, + }); + } catch (err) { + fail(err); + return; + } + } + + onStateChange({ + phase: "composer-sending", + approvalSpender: isNativeToken(sourceToken.address) + ? undefined + : quote.estimate.approvalAddress, + }); + + try { + const hash = await walletClient.sendTransaction({ + to: quote.transactionRequest.to as Address, + data: quote.transactionRequest.data as Hex, + value: quote.transactionRequest.value + ? BigInt(quote.transactionRequest.value) + : undefined, + gas: quote.transactionRequest.gasLimit + ? BigInt(quote.transactionRequest.gasLimit) + : undefined, + chain: { id: sourceChainId } as any, + }); + routeTxHash = hash; + + onStateChange({ + phase: "composer-settling", + routeTxHash: hash, + }); + + const receipt = await wagmiWaitForReceipt(wagmiConfig, { + hash, + chainId: sourceChainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (receipt.status === "reverted") { + throw new Error("Route transaction reverted onchain"); + } + } catch (err) { + fail(err); + return; + } + + const lockedRouteTxHash = routeTxHash; + if (!lockedRouteTxHash) { + fail(new Error("Route transaction hash missing after broadcast")); + return; + } + + if (sourceChainId === destinationChainId) { + onStateChange({ + phase: "done", + routeTxHash: lockedRouteTxHash, + destinationTxHash: lockedRouteTxHash, + }); + return; + } + + let result: Awaited>; + try { + result = await waitForComposerSettlement({ + txHash: lockedRouteTxHash, + fromChain: sourceChainId, + toChain: destinationChainId, + onUpdate: (status) => { + lastStatus = status; + onStateChange({ + phase: "composer-settling", + routeTxHash: lockedRouteTxHash, + lifiStatus: status.status, + lifiSubstatus: readSubstatus(status), + }); + }, + }); + } catch (err) { + fail(err); + return; + } + + lastStatus = result.status; + if (result.outcome !== "COMPLETED") { + fail( + new Error( + `LI.FI route ${result.outcome.toLowerCase()}${ + readSubstatus(result.status) ? `: ${readSubstatus(result.status)}` : "" + }`, + ), + ); + return; + } + + onStateChange({ + phase: "done", + routeTxHash: lockedRouteTxHash, + destinationTxHash: result.status.receiving?.txHash, + }); +} diff --git a/src/lib/intents/addressBytes.ts b/src/lib/intents/addressBytes.ts new file mode 100644 index 0000000..9037fbb --- /dev/null +++ b/src/lib/intents/addressBytes.ts @@ -0,0 +1,12 @@ +import { getAddress, type Address, type Hex } from "viem"; + +// MandateOutput bytes32 fields are left-padded EVM addresses (not EIP-7930). +export function addressToBytes32(address: Address): Hex { + return `0x${"00".repeat(12)}${getAddress(address).slice(2).toLowerCase()}` as Hex; +} + +// StandardOrder.inputs[i][0] is the ERC-20 token address cast to uint256, +// upper 12 bytes zero. +export function tokenIdentifierForEscrow(address: Address): bigint { + return BigInt(getAddress(address)); +} diff --git a/src/lib/intents/contracts.ts b/src/lib/intents/contracts.ts new file mode 100644 index 0000000..8217023 --- /dev/null +++ b/src/lib/intents/contracts.ts @@ -0,0 +1,128 @@ +import { decodeEventLog, type Address, type Hex } from "viem"; + +// The deployed input settler is the LI.FI variant `InputSettlerEscrowLIFI` +// (verified via Sourcify, May 2026). Functions take the StandardOrder tuple +// directly, not pre-encoded bytes. +export const INPUT_SETTLER_ESCROW = + "0x000025c3226C00B2Cdc200005a1600509f4e00C0" as Address; + +export const INPUT_SETTLER_COMPACT = + "0x0000000000cd5f7fDEc90a03a31F79E5Fbc6A9Cf" as Address; + +export const OUTPUT_SETTLER_SIMPLE = + "0x0000000000eC36B683C2E6AC89e9A75989C22a2e" as Address; + +export const POLYMER_ORACLE = + "0x0000003E06000007A224AeE90052fA6bb46d43C9" as Address; + +// Canonical deterministic Permit2 deployment. +export const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as Address; + +const MANDATE_OUTPUT_COMPONENTS = [ + { name: "oracle", type: "bytes32" }, + { name: "settler", type: "bytes32" }, + { name: "chainId", type: "uint256" }, + { name: "token", type: "bytes32" }, + { name: "amount", type: "uint256" }, + { name: "recipient", type: "bytes32" }, + { name: "callbackData", type: "bytes" }, + { name: "context", type: "bytes" }, +] as const; + +const STANDARD_ORDER_COMPONENTS = [ + { name: "user", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "originChainId", type: "uint256" }, + { name: "expires", type: "uint32" }, + { name: "fillDeadline", type: "uint32" }, + { name: "inputOracle", type: "address" }, + { name: "inputs", type: "uint256[2][]" }, + { + name: "outputs", + type: "tuple[]", + components: MANDATE_OUTPUT_COMPONENTS, + }, +] as const; + +const STANDARD_ORDER_ARG = { + name: "order", + type: "tuple", + components: STANDARD_ORDER_COMPONENTS, +} as const; + +// Selectors and Open topic confirmed present in the deployed Base bytecode +// (May 2026): open=0x7515fd56, openFor=0x49927074, refund=0x48f49eaf, +// Open(bytes32,StandardOrder)=0x9ff74bd56d00785b881ef9fa3f03d7b598686a39a9bcff89a6008db588b18a7b. +export const inputSettlerEscrowAbi = [ + { + type: "function", + name: "open", + stateMutability: "nonpayable", + inputs: [STANDARD_ORDER_ARG], + outputs: [], + }, + { + type: "function", + name: "openFor", + stateMutability: "nonpayable", + inputs: [ + STANDARD_ORDER_ARG, + { name: "sponsor", type: "address" }, + { name: "signature", type: "bytes" }, + ], + outputs: [], + }, + { + type: "function", + name: "refund", + stateMutability: "nonpayable", + inputs: [STANDARD_ORDER_ARG], + outputs: [], + }, + { + type: "event", + name: "Open", + anonymous: false, + inputs: [ + { indexed: true, name: "orderId", type: "bytes32" }, + { indexed: false, name: "order", type: "tuple", components: STANDARD_ORDER_COMPONENTS }, + ], + }, + { + type: "event", + name: "Open", + anonymous: false, + inputs: [{ indexed: true, name: "orderId", type: "bytes32" }], + }, + { + type: "event", + name: "Refunded", + anonymous: false, + inputs: [{ indexed: true, name: "orderId", type: "bytes32" }], + }, +] as const; + +// Iterate logs by signature rather than index — the first log is usually the +// ERC-20 Transfer from approve()/transferFrom, not the escrow's Open event. +export function extractOpenOrderId( + logs: { address: string; topics: readonly Hex[]; data: Hex }[] | undefined, +): Hex | null { + if (!logs) return null; + for (const log of logs) { + if (log.address.toLowerCase() !== INPUT_SETTLER_ESCROW.toLowerCase()) continue; + try { + const decoded = decodeEventLog({ + abi: inputSettlerEscrowAbi, + data: log.data, + topics: log.topics as [Hex, ...Hex[]], + }); + if (decoded.eventName === "Open") { + const args = decoded.args as unknown as { orderId?: Hex }; + if (args.orderId) return args.orderId; + } + } catch { + // Topic didn't match any event in our ABI — keep scanning. + } + } + return null; +} diff --git a/src/lib/intents/deadlines.ts b/src/lib/intents/deadlines.ts new file mode 100644 index 0000000..1e61849 --- /dev/null +++ b/src/lib/intents/deadlines.ts @@ -0,0 +1,45 @@ +// fillDeadline = solver fill cutoff; expires = refund unlock on origin. +// Refund is only callable after `expires`; the gap is oracle settlement grace. +export interface DeadlinePlan { + nowSec: number; + quoteValidUntilSec: number | null; + fillDeadline: number; + expires: number; +} + +interface DeadlineInput { + quoteValidUntilIso?: string | null; + nowMs?: number; + maxFillTtlSec?: number; + refundGraceSec?: number; +} + +export function buildDeadlinePlan(args: DeadlineInput = {}): DeadlinePlan { + const nowSec = Math.floor((args.nowMs ?? Date.now()) / 1000); + + let quoteValidUntilSec: number | null = null; + if (args.quoteValidUntilIso) { + const parsed = Date.parse(args.quoteValidUntilIso); + if (Number.isFinite(parsed)) { + quoteValidUntilSec = Math.floor(parsed / 1000); + } + } + + const maxFillTtl = args.maxFillTtlSec ?? 15 * 60; + const grace = args.refundGraceSec ?? 30 * 60; + + const fillDeadline = quoteValidUntilSec + ? Math.min(quoteValidUntilSec, nowSec + maxFillTtl) + : nowSec + maxFillTtl; + + if (fillDeadline <= nowSec + 30) { + throw new Error("quote too close to expiry to safely open an order"); + } + + return { + nowSec, + quoteValidUntilSec, + fillDeadline, + expires: fillDeadline + grace, + }; +} diff --git a/src/lib/intents/eip7930.ts b/src/lib/intents/eip7930.ts new file mode 100644 index 0000000..f278324 --- /dev/null +++ b/src/lib/intents/eip7930.ts @@ -0,0 +1,27 @@ +import { getAddress, type Address, type Hex } from "viem"; + +// EIP-7930 chain-tagged address for EIP-155 chains: +// `version(2) | chainType(2) | chainRefLen(1) | chainRef(N) | addrLen(1) | addr(20)`. +// Example for Base: 0x0001|0000|02|2105|14|. https://eips.ethereum.org/EIPS/eip-7930 +const VERSION = "0001"; +const CHAIN_TYPE_EIP155 = "0000"; +const ADDR_LEN_EVM = "14"; + +function toMinimalBigEndianHex(n: number): string { + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`invalid chainId for EIP-7930: ${n}`); + } + let h = n.toString(16); + if (h.length % 2) h = `0${h}`; + return h; +} + +export function encodeEip7930EvmAddress( + chainId: number, + address: Address, +): Hex { + const chainRef = toMinimalBigEndianHex(chainId); + const chainRefLen = (chainRef.length / 2).toString(16).padStart(2, "0"); + const addr = getAddress(address).slice(2).toLowerCase(); + return `0x${VERSION}${CHAIN_TYPE_EIP155}${chainRefLen}${chainRef}${ADDR_LEN_EVM}${addr}` as Hex; +} diff --git a/src/lib/intents/nonce.ts b/src/lib/intents/nonce.ts new file mode 100644 index 0000000..00337d6 --- /dev/null +++ b/src/lib/intents/nonce.ts @@ -0,0 +1,15 @@ +// LI.FI Escrow needs nonce uniqueness per (user, originChainId), not +// monotonicity. Layout `(ts << 48) | (rand32 << 16) | counter16` keeps +// same-millisecond collisions across tabs at ~1/2^32. + +let counter = 0; + +const RAND_BITS = 32n; +const COUNTER_BITS = 16n; + +export function nextOrderNonce(): bigint { + counter = (counter + 1) & 0xffff; + const ts = BigInt(Date.now()); + const rand = BigInt(Math.floor(Math.random() * 0xffffffff)); + return (ts << (RAND_BITS + COUNTER_BITS)) | (rand << COUNTER_BITS) | BigInt(counter); +} diff --git a/src/lib/intents/standardOrder.ts b/src/lib/intents/standardOrder.ts new file mode 100644 index 0000000..79f1fc5 --- /dev/null +++ b/src/lib/intents/standardOrder.ts @@ -0,0 +1,95 @@ +import type { Address, Hex } from "viem"; +import { addressToBytes32, tokenIdentifierForEscrow } from "./addressBytes"; +import { OUTPUT_SETTLER_SIMPLE, POLYMER_ORACLE } from "./contracts"; + +export interface MandateOutput { + oracle: Hex; + settler: Hex; + chainId: bigint; + token: Hex; + amount: bigint; + recipient: Hex; + callbackData: Hex; + context: Hex; +} + +export interface StandardOrder { + user: Address; + nonce: bigint; + originChainId: bigint; + expires: number; + fillDeadline: number; + inputOracle: Address; + inputs: readonly (readonly [bigint, bigint])[]; + outputs: MandateOutput[]; +} + +interface BuildOrderInput { + user: Address; + nonce: bigint; + originChainId: number; + inputToken: Address; + inputAmount: bigint; + targetChainId: number; + outputToken: Address; + outputAmount: bigint; + recipient: Address; + expires: number; + fillDeadline: number; + context?: Hex; + callbackData?: Hex; +} + +// Shape required by viem's encodeFunctionData for the escrow's open/openFor +// /refund arguments — keeps the inputs as readonly tuples and re-spreads the +// outputs so the ABI encoder sees plain bigints/hex. +export function orderForAbi(order: StandardOrder) { + return { + user: order.user, + nonce: order.nonce, + originChainId: order.originChainId, + expires: order.expires, + fillDeadline: order.fillDeadline, + inputOracle: order.inputOracle, + inputs: order.inputs.map(([a, b]) => [a, b] as readonly [bigint, bigint]), + outputs: order.outputs.map((o) => ({ + oracle: o.oracle, + settler: o.settler, + chainId: o.chainId, + token: o.token, + amount: o.amount, + recipient: o.recipient, + callbackData: o.callbackData, + context: o.context, + })), + }; +} + +// Same-chain orders can let the OutputSettler act as its own oracle; cross-chain +// orders need an attestation bridge (Polymer here, per docs.li.fi/lifi-intents). +export function buildStandardOrder(args: BuildOrderInput): StandardOrder { + const crossChain = args.originChainId !== args.targetChainId; + const oracleAddr = crossChain ? POLYMER_ORACLE : OUTPUT_SETTLER_SIMPLE; + + return { + user: args.user, + nonce: args.nonce, + originChainId: BigInt(args.originChainId), + expires: args.expires, + fillDeadline: args.fillDeadline, + inputOracle: oracleAddr, + inputs: [[tokenIdentifierForEscrow(args.inputToken), args.inputAmount]], + outputs: [ + { + oracle: addressToBytes32(oracleAddr), + settler: addressToBytes32(OUTPUT_SETTLER_SIMPLE), + chainId: BigInt(args.targetChainId), + token: addressToBytes32(args.outputToken), + amount: args.outputAmount, + recipient: addressToBytes32(args.recipient), + callbackData: args.callbackData ?? "0x", + context: args.context ?? "0x", + }, + ], + }; +} diff --git a/vercel.json b/vercel.json index 533b97e..e73e03b 100644 --- a/vercel.json +++ b/vercel.json @@ -72,6 +72,10 @@ "source": "/api/lifi-composer/:path*", "destination": "/api/lifi-composer?path=:path*" }, + { + "source": "/api/lifi-intents/:path*", + "destination": "/api/lifi-intents?path=:path*" + }, { "source": "/:path((?!api/|assets/|.*\\..*).*)", "destination": "/index.html" diff --git a/vite.config.ts b/vite.config.ts index 7339f40..e0571f7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -329,6 +329,15 @@ export default defineConfig(({ mode }) => { "x-lifi-api-key": LIFI_API_KEY, }, }, + // LI.FI Intents (order.li.fi) — integrator endpoints are open, no API + // key required per docs.li.fi/lifi-intents/authentication. Proxy exists + // for CORS parity with the Vercel serverless fn used in prod. + "/api/lifi-intents": { + target: "https://order.li.fi", + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/api\/lifi-intents/, ""), + }, // Gemini is handled by llmProxyPlugin() below — not a static proxy // Proxy for Sourcify repo "/api/repo": { From f02669fb09cb2d4889169d117a9d0871fe2e1384 Mon Sep 17 00:00:00 2001 From: Timidan Date: Tue, 26 May 2026 21:22:31 +0100 Subject: [PATCH 2/7] fix(concierge): chunk per-chain multicall so ERC-20 balances don't silently drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chains with many underlyings (Base has ~300) blew past the public-RPC per-eth_call gas limit when `aggregate3` fanned out in a single request. viem's multicall returns ALL-failure (no throw) in that case, so every ERC-20 balance silently disappeared from the idle-asset list while native `getBalance` still worked — visible symptom was "ETH on Base shows $4 but my $200 of USDC doesn't appear." Cap each multicall at 50 sub-calls and fan the chunks out in parallel. Per-chunk catch returns failure shape so one bad chunk doesn't kill the rest. Outer withTimeout gets 2x the per-call budget to absorb the serialization cost of multiple round-trips on slow public RPCs. --- .../concierge/hooks/useIdleBalances.ts | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts b/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts index 3789da9..fe54d1a 100644 --- a/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts +++ b/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts @@ -174,19 +174,37 @@ async function scanSingleChain(args: { args: [address] as const, })); + // Multicall in chunks. Chains with many underlyings (Base has ~300) blow + // past public-RPC per-eth_call gas limits when aggregate3 fans out in a + // single request: the call returns ALL-failure rather than throwing, so + // every ERC-20 balance silently drops out (native getBalance still works, + // which is why ETH on Base would show but USDC wouldn't). Chunking caps + // each multicall at ~50 sub-calls and fans the chunks out in parallel. + const MULTICALL_CHUNK = 50; + async function multicallChunked(): Promise { + if (multicallCalls.length === 0) return []; + const chunks: (typeof multicallCalls)[] = []; + for (let i = 0; i < multicallCalls.length; i += MULTICALL_CHUNK) { + chunks.push(multicallCalls.slice(i, i + MULTICALL_CHUNK)); + } + const chunkResults = await Promise.all( + chunks.map((chunk) => + client + .multicall({ + contracts: chunk, + allowFailure: true, + multicallAddress: MULTICALL3_ADDRESS, + }) + .catch(() => chunk.map(() => ({ status: "failure" as const }))), + ), + ); + return chunkResults.flat(); + } + // Always fetch native balance — the user may hold native tokens on chains // where no vault explicitly lists the native sentinel as an underlying. const [erc20Results, nativeBalance] = await Promise.all([ - multicallCalls.length > 0 - ? withTimeout( - client.multicall({ - contracts: multicallCalls, - allowFailure: true, - multicallAddress: MULTICALL3_ADDRESS, - }), - timeoutMs - ) - : Promise.resolve([] as any[]), + withTimeout(multicallChunked(), timeoutMs * 2), withTimeout(client.getBalance({ address }), timeoutMs), ]); From 6b6139cc112a3af36745ea01c6ab39480a4cefe6 Mon Sep 17 00:00:00 2001 From: Timidan Date: Wed, 27 May 2026 01:11:53 +0100 Subject: [PATCH 3/7] fix(concierge): drop non-hex token addresses + chunk multicall for idle scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LI.FI Earn /vaults feed occasionally ships non-EVM identifiers in `underlyingTokens[].address` (seen in the wild on Base: `coingecko:universal-btc`). viem's multicall ABI-encodes each entry as `address`, and a single malformed entry corrupts the entire aggregate3 calldata. The RPC rejects with "invalid hex string", viem maps the whole batch to all-failure, and every legitimate balance silently disappears from the idle-asset list (e.g. USDC on Base — native ETH still shows up because it's a separate getBalance call). Pre-validate each address against /^0x[a-f0-9]{40}$/i before building the multicall, so one bad upstream entry no longer takes the whole chain offline. Also chunks the multicall into batches of 50 to keep the aggregate3 payload below the tighter eth_call gas budgets some RPCs enforce. vite.config.ts: ignore the edb submodule's 15GB Rust target/ dir (and other generated dirs) from the file watcher — they blow the Linux inotify per-process instance cap on startup. --- .../concierge/hooks/useIdleBalances.ts | 34 +++++++++++-------- vite.config.ts | 12 ++++++- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts b/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts index fe54d1a..a3f1e7b 100644 --- a/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts +++ b/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts @@ -164,7 +164,18 @@ async function scanSingleChain(args: { transport: http(rpcUrl), }); - const erc20s = tokens.filter((t) => !isNativeToken(t.address)); + // Filter to real 0x-prefixed 40-hex addresses. The upstream LI.FI Earn + // /vaults feed occasionally ships non-EVM identifiers in `underlyingTokens` + // (seen in the wild: `coingecko:universal-btc`). viem's multicall ABI-encodes + // each entry as `address`, and a single malformed entry corrupts the whole + // aggregate3 calldata — Alchemy rejects with "invalid hex string" and viem + // maps the entire batch to all-failure, silently dropping every legitimate + // balance (USDC on Base, BNB-chain ERC-20s, etc.). + const isHexAddress = (a: string | undefined) => + typeof a === "string" && /^0x[a-f0-9]{40}$/i.test(a); + const erc20s = tokens.filter( + (t) => !isNativeToken(t.address) && isHexAddress(t.address), + ); const nativeTokenMeta = tokens.find((t) => isNativeToken(t.address)); const multicallCalls = erc20s.map((tok) => ({ @@ -174,12 +185,9 @@ async function scanSingleChain(args: { args: [address] as const, })); - // Multicall in chunks. Chains with many underlyings (Base has ~300) blow - // past public-RPC per-eth_call gas limits when aggregate3 fans out in a - // single request: the call returns ALL-failure rather than throwing, so - // every ERC-20 balance silently drops out (native getBalance still works, - // which is why ETH on Base would show but USDC wouldn't). Chunking caps - // each multicall at ~50 sub-calls and fans the chunks out in parallel. + // Multicall in parallel chunks so a single oversize aggregate3 payload + // doesn't reach gas/size limits on tighter-budget RPCs. Errors bubble up + // to the outer chain-scan catch (which already logs and degrades cleanly). const MULTICALL_CHUNK = 50; async function multicallChunked(): Promise { if (multicallCalls.length === 0) return []; @@ -189,13 +197,11 @@ async function scanSingleChain(args: { } const chunkResults = await Promise.all( chunks.map((chunk) => - client - .multicall({ - contracts: chunk, - allowFailure: true, - multicallAddress: MULTICALL3_ADDRESS, - }) - .catch(() => chunk.map(() => ({ status: "failure" as const }))), + client.multicall({ + contracts: chunk, + allowFailure: true, + multicallAddress: MULTICALL3_ADDRESS, + }), ), ); return chunkResults.flat(); diff --git a/vite.config.ts b/vite.config.ts index e0571f7..afd06b2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -196,7 +196,17 @@ export default defineConfig(({ mode }) => { watch: { usePolling: false, interval: 100, - ignored: ["**/node_modules/**", "**/.git/**", "**/dist/**"], + ignored: [ + "**/node_modules/**", + "**/.git/**", + "**/dist/**", + // edb submodule has a 15GB Rust target/ dir; watching it instantly + // blows the inotify per-process instance cap on Linux. + "**/edb/**", + "**/starknet-sim/**", + "**/.claude/**", + "**/.vercel/**", + ], }, proxy: { // Proxy for EDB bridge (strips /api/edb prefix, forwards to bridge) From fa67871d4be41e108754e26e75fc83227397a1ba Mon Sep 17 00:00:00 2001 From: Timidan Date: Wed, 27 May 2026 09:07:17 +0100 Subject: [PATCH 4/7] feat(deposit): route-aware source picker for LI.FI Intent options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-chain holdings under "BRIDGE VIA LI.FI INTENT" now check the live LI.FI route registry before being offered as selectable. Sources without a routable solver path from origin to the vault's underlying appear in the group disabled, with an inline "· no Intent route" suffix. Radix's data-[disabled]:pointer-events-none disqualifies tooltip-on-disabled, so the reason is shown inline alongside the balance instead. A middle-dot separator is wrapped in aria-hidden so screen readers announce the suffix cleanly. A selection guard useEffect resets to the canonical DIRECT source (plus clears the amount + sim result) if a previously selected cross-chain token becomes known-unavailable after routes resolve — covers the race where the user picks during the loading window and the registry later reveals the route doesn't exist. Shares React Query's cache via queryKey: ["lifi-intent-routes"] (same key IntentPanel uses) so we don't double-fetch routes when both the concierge and the deposit drawer mount in the same session. buildRoutesIndex now treats both `undefined` (loading) AND `[]` (transient empty cache resolution) as optimistic — returns {isEmpty: true, has: () => true} for both. The earlier strict semantics disabled every cross-chain source for the ~200ms window between cache resolution and the populated fetch landing. The authoritative runtime gate remains IntentBridgeStep's "No quote available" panel, so optimistic-during-load is safe. --- .../integrations/lifi-earn/DepositFlow.tsx | 105 ++++++++++++++++-- .../lifi-earn/concierge/intent/intentLegs.ts | 15 ++- 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/src/components/integrations/lifi-earn/DepositFlow.tsx b/src/components/integrations/lifi-earn/DepositFlow.tsx index 05d717d..9b5378e 100644 --- a/src/components/integrations/lifi-earn/DepositFlow.tsx +++ b/src/components/integrations/lifi-earn/DepositFlow.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useRef, useEffect } from "react"; +import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"; import { useAccount, useConfig, useSwitchChain } from "wagmi"; import { getWalletClient as getWagmiWalletClient, @@ -39,6 +39,9 @@ import { executeCrossChainComposerDeposit, type CrossChainDepositState, } from "./crossChainComposerDeposit"; +import { fetchIntentRoutes } from "./intentsApi"; +import { buildRoutesIndex } from "./concierge/intent/intentLegs"; +import { useQuery } from "@tanstack/react-query"; import { getAccount as wagmiGetAccount } from "@wagmi/core"; import type { Address } from "viem"; import type { DepositExecutionEvent } from "./concierge/types"; @@ -251,6 +254,39 @@ export function DepositFlow({ return [...underlyingTokens, ...extras]; }, [underlyingTokens, vault.chainId]); + // LI.FI Intent routes — same queryKey as IntentPanel so React Query shares + // the cache automatically. While the request is pending, `buildRoutesIndex` + // returns an optimistic index (`has()` always true) so cross-chain sources + // don't false-negative during initial load. After resolution, sources whose + // (srcChain, srcAddr → vault.chainId, vault.underlyingTokens[0]) tuple is + // missing from the registry render as disabled in the picker. + const intentRoutesQuery = useQuery({ + queryKey: ["lifi-intent-routes"], + queryFn: fetchIntentRoutes, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); + const routesIndex = useMemo( + () => buildRoutesIndex(intentRoutesQuery.data?.routes), + [intentRoutesQuery.data], + ); + // `intentRoutesKnown` flags that the query has produced a response (even + // an empty one). Used by the selection guard so we don't reset the user's + // current pick until we've actually heard back from the routes endpoint. + // `routesIndex.isEmpty` is true during BOTH the initial `undefined` state + // and a resolved-but-empty `[]` response (per buildRoutesIndex's optimistic + // semantics), so it can't be used alone for guard sequencing. + const intentRoutesKnown = intentRoutesQuery.data?.routes !== undefined; + const destIntentUnderlying = vault.underlyingTokens?.[0]?.address; + const isIntentRouteAvailable = useCallback( + (t: EarnToken): boolean => { + if (!destIntentUnderlying) return false; + const srcChain = t.chainId ?? vault.chainId; + return routesIndex.has(srcChain, t.address, vault.chainId, destIntentUnderlying); + }, + [routesIndex, destIntentUnderlying, vault.chainId], + ); + const tokens = useMemo( () => [...sameChainTokens, ...crossChainHoldings], [sameChainTokens, crossChainHoldings], @@ -313,6 +349,34 @@ export function DepositFlow({ setUseIntents(isCrossChain); }, [isCrossChain]); + // Selection guard: while routes are loading, every cross-chain source looks + // available (optimistic). Once the registry resolves and reveals a missing + // route, bounce the user back to a safe DIRECT source so they don't sit on + // a disabled item with a quote that's about to fail. Skip when override is + // active (caller is forcing the token) and while we don't yet have route + // data (still in the optimistic-no-data state). + useEffect(() => { + if (override) return; + if (intentRoutesQuery.isLoading || intentRoutesQuery.isPending) return; + if (!intentRoutesKnown) return; + if (!selectedToken) return; + const srcChain = selectedToken.chainId ?? vault.chainId; + if (srcChain === vault.chainId) return; + if (isIntentRouteAvailable(selectedToken)) return; + setSelectedToken(firstToken); + setAmount(""); + setSimResult(null); + }, [ + intentRoutesQuery.isLoading, + intentRoutesQuery.isPending, + intentRoutesKnown, + isIntentRouteAvailable, + firstToken, + selectedToken, + vault.chainId, + override, + ]); + // Reset state when vault/override changes so reopening the drawer for a // different vault doesn't leak stale state. useEffect(() => { @@ -1362,15 +1426,24 @@ export function DepositFlow({
Bridge via LI.FI Intent
- {crossChainHoldings.map((t) => ( - - - - ))} + {crossChainHoldings.map((t) => { + const available = isIntentRouteAvailable(t); + return ( + + + + ); + })} )} @@ -2415,10 +2488,16 @@ function TokenSelectRow({ token, chainId, ownerAddress, + trailing, }: { token: EarnToken; chainId: number; ownerAddress: string | null; + /** Optional muted suffix rendered after the balance (e.g. "no Intent route" + * when the option is disabled). Radix `data-[disabled]:pointer-events-none` + * swallows hover events on disabled SelectItems, so tooltips no-op there — + * this is the inline alternative. */ + trailing?: string; }) { const { data: rawBalance } = useTokenBalance({ tokenAddress: token.address, @@ -2460,6 +2539,12 @@ function TokenSelectRow({ {displayBal && ( {displayBal} )} + {trailing && ( + + + {trailing} + + )} ); } diff --git a/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts b/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts index bb7e11a..0682dea 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts +++ b/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts @@ -51,13 +51,16 @@ export interface RoutesIndex { isEmpty: boolean; } -// Distinguishes two states that the previous version conflated: -// - `undefined` → fetch hasn't completed / errored; we don't know coverage, -// so optimistically allow legs (caller may surface "coverage unknown"). -// - `[]` → the upstream genuinely reports no active routes; mark -// everything unroutable so users see honest "no route" reasons. +// Treat both `undefined` (fetch hasn't resolved) AND `[]` (resolved empty +// — either a transient cache state or a genuinely empty upstream response) +// as "coverage unknown" and optimistically allow every leg. The earlier +// stricter "`[]` means definitely empty" semantics caused the deposit-flow +// picker to disable every cross-chain source for ~200ms during the brief +// window where React Query had cached an empty result before the populated +// fetch completed. The downstream "No quote available" panel in +// `IntentBridgeStep` is the authoritative runtime gate. export function buildRoutesIndex(routes: IntentRoute[] | undefined): RoutesIndex { - if (routes === undefined) { + if (routes === undefined || routes.length === 0) { return { isEmpty: true, has: () => true }; } const set = new Set(); From edb78a89b1acbde791976670531bf2ad76988e10 Mon Sep 17 00:00:00 2001 From: Timidan Date: Wed, 27 May 2026 10:08:23 +0100 Subject: [PATCH 5/7] fix(intents): allow missing-Origin same-origin GETs + check approval receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/lifi-intents.ts: when PROXY_SECRET is unset, the gate previously required an Origin header on every request and 403'd otherwise. Browsers omit Origin on many same-origin fetches (especially GETs), so production returned 403 on `/routes`, `/orders/status`, and `/chains/supported` for any client whose browser didn't attach Origin. Mirrors the lifi-composer proxy contract: allow missing Origin, reject only present but unapproved origins. src/components/integrations/lifi-earn/txUtils.ts: safeApproveErc20 was not inspecting receipt.status — wagmi's waitForTransactionReceipt resolves on reverted txs too, so a reverted reset-approve or final approve let the caller proceed assuming allowance was granted. The downstream open()/route execution would then fail with a confusing error far from the actual cause. Capture the receipt and throw on status === "reverted" for both legs. --- api/lifi-intents.ts | 11 +++++++---- src/components/integrations/lifi-earn/txUtils.ts | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/api/lifi-intents.ts b/api/lifi-intents.ts index 215d511..a6ffd77 100644 --- a/api/lifi-intents.ts +++ b/api/lifi-intents.ts @@ -113,10 +113,13 @@ export default async function handler( return res.status(403).json({ error: "Forbidden" }); } } else { - // Without PROXY_SECRET we require a known Origin — rejecting missing-Origin - // requests (curl, server-to-server) keeps the public endpoint scrape-resistant. - if (!allowedOrigin || !req.headers.origin) { - return res.status(403).json({ error: "Origin required" }); + // No PROXY_SECRET: allow same-origin requests (browser omits Origin on + // many same-origin fetches, especially GETs to `/routes`, `/orders/status`, + // `/chains/supported`) and requests with a matching Origin. Mirrors the + // lifi-composer proxy contract — see api/lifi-composer.ts. + const origin = req.headers.origin; + if (origin && !allowedOrigin) { + return res.status(403).json({ error: "Origin not allowed" }); } } diff --git a/src/components/integrations/lifi-earn/txUtils.ts b/src/components/integrations/lifi-earn/txUtils.ts index 1b8885f..d80cb3c 100644 --- a/src/components/integrations/lifi-earn/txUtils.ts +++ b/src/components/integrations/lifi-earn/txUtils.ts @@ -135,11 +135,18 @@ export async function safeApproveErc20(args: { to: token, data: resetData, }); - await wagmiWaitForReceipt(wagmiConfig, { + const resetReceipt = await wagmiWaitForReceipt(wagmiConfig, { hash: resetHash, chainId, timeout: timeoutMs, }); + // wagmi's waitForTransactionReceipt resolves even on reverted txs — without + // this check the helper would silently proceed and the next approve would + // hit a still-nonzero allowance (USDT pattern) or the caller would open() + // expecting an allowance that was never granted. + if (resetReceipt.status === "reverted") { + throw new Error(`approve(0) reverted: ${resetHash}`); + } } const approveData = encodeFunctionData({ @@ -151,9 +158,12 @@ export async function safeApproveErc20(args: { to: token, data: approveData, }); - await wagmiWaitForReceipt(wagmiConfig, { + const receipt = await wagmiWaitForReceipt(wagmiConfig, { hash, chainId, timeout: timeoutMs, }); + if (receipt.status === "reverted") { + throw new Error(`approve(${amount}) reverted: ${hash}`); + } } From 8bdaf1e09af0cb4cbba2f1c51b2f866ec8a509fc Mon Sep 17 00:00:00 2001 From: Timidan Date: Wed, 27 May 2026 10:54:08 +0100 Subject: [PATCH 6/7] chore(deposit): relabel cross-chain picker group as engine-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker section was named "Bridge via LI.FI Intent" but the actual cross-chain route is decided by the toggle below the picker — either LI.FI Intent (1 signature, solver auction) or LI.FI Composer 2-step (bridge tx + deposit tx). Promising one engine in the source-group label hides the alternative and confuses users when they expect a separate Composer entry. Rename to "Bridge via Composer or LI.FI Intent" so the group label matches what the underlying flow can actually pick. --- src/components/integrations/lifi-earn/DepositFlow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/integrations/lifi-earn/DepositFlow.tsx b/src/components/integrations/lifi-earn/DepositFlow.tsx index 9b5378e..37d87ae 100644 --- a/src/components/integrations/lifi-earn/DepositFlow.tsx +++ b/src/components/integrations/lifi-earn/DepositFlow.tsx @@ -1424,7 +1424,7 @@ export function DepositFlow({ {crossChainHoldings.length > 0 && ( <>
- Bridge via LI.FI Intent + Bridge via Composer or LI.FI Intent
{crossChainHoldings.map((t) => { const available = isIntentRouteAvailable(t); From 3cce1da879d0a095abd03af37e8820c5412e5d0d Mon Sep 17 00:00:00 2001 From: Timidan Date: Wed, 27 May 2026 22:32:00 +0100 Subject: [PATCH 7/7] fix(lifi-earn): guard refund receipts + balance-delta correctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IntentBridgeStep / WithdrawIntentRouteStep / useIntentLegPipeline: throw on receipt.status === "reverted" after refund() — wagmiWaitForReceipt resolves on reverted txs too, so a reverted refund was silently treated as success. - useIntentLegPipeline: wrap depositLeg body in outer try/finally so every early return (predeliveryBalance missing, delta zero, balance read fail) releases the in-flight lock instead of permanently blocking deposits. - crossChainComposerDeposit: readBalance now throws on missing provider (a silent BigNumber(0) caused post-pre deltas to include unrelated user funds); delta clamped via gt-guard; resume path requires a known originalBridged amount instead of falling back to entire live balance. - RebalancePlanCard: balance delta clamps to 0n when post <= pre instead of falling back to the entire post balance. --- .../lifi-earn/IntentBridgeStep.tsx | 8 +- .../lifi-earn/WithdrawIntentRouteStep.tsx | 8 +- .../concierge/intent/RebalancePlanCard.tsx | 6 +- .../concierge/intent/useIntentLegPipeline.ts | 233 ++++++++++-------- .../lifi-earn/crossChainComposerDeposit.ts | 69 +++--- 5 files changed, 183 insertions(+), 141 deletions(-) diff --git a/src/components/integrations/lifi-earn/IntentBridgeStep.tsx b/src/components/integrations/lifi-earn/IntentBridgeStep.tsx index 6c65a25..d167e05 100644 --- a/src/components/integrations/lifi-earn/IntentBridgeStep.tsx +++ b/src/components/integrations/lifi-earn/IntentBridgeStep.tsx @@ -380,11 +380,17 @@ export function IntentBridgeStep({ to: INPUT_SETTLER_ESCROW, data, }); - await wagmiWaitForReceipt(config, { + const receipt = await wagmiWaitForReceipt(config, { hash, chainId: sourceChainId, timeout: 120_000, }); + // wagmiWaitForReceipt resolves on reverted txs — without this check the + // UI would advance to "refunded" even though the funds are still + // escrowed on-chain. + if (receipt.status === "reverted") { + throw new Error(`refund() reverted: ${hash}`); + } setStage("refunded"); // ONLY emit the intent-status — the reducer maps "Refunded" to the // terminal-good "refunded" leg status. Emitting a separate `failed` diff --git a/src/components/integrations/lifi-earn/WithdrawIntentRouteStep.tsx b/src/components/integrations/lifi-earn/WithdrawIntentRouteStep.tsx index 3a45ae5..2593029 100644 --- a/src/components/integrations/lifi-earn/WithdrawIntentRouteStep.tsx +++ b/src/components/integrations/lifi-earn/WithdrawIntentRouteStep.tsx @@ -299,11 +299,17 @@ export function WithdrawIntentRouteStep({ to: INPUT_SETTLER_ESCROW, data, }); - await wagmiWaitForReceipt(config, { + const receipt = await wagmiWaitForReceipt(config, { hash, chainId: sourceChainId, timeout: 120_000, }); + // wagmiWaitForReceipt resolves on reverted txs — without this check the + // withdraw flow would offer "Keep underlying" while the escrow is still + // open. + if (receipt.status === "reverted") { + throw new Error(`refund() reverted: ${hash}`); + } setStage("refunded"); onRefunded?.(); } catch (err) { diff --git a/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx b/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx index f6b6933..6540a24 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx +++ b/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx @@ -258,7 +258,11 @@ function LegRow({ })) as bigint; if (cancelled) return; const pre = run.predeliveryBalance ?? 0n; - const delta = post > pre ? post - pre : post; + // CRITICAL: never fall back to `post` — that would deposit the user's + // entire destination balance, including unrelated pre-existing funds. + // If post <= pre, treat the delivery as not-yet-detected and let the + // deposit click re-read on its own. + const delta = post > pre ? post - pre : 0n; if (delta > 0n) onMarkDelivered(spec.id, delta); } catch { // Best-effort. The deposit handler will re-read balance on click. diff --git a/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts b/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts index d50a775..38160a8 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts +++ b/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts @@ -381,11 +381,17 @@ export function useIntentLegPipeline(): UseIntentLegPipelineReturn { to: INPUT_SETTLER_ESCROW, data, }); - await wagmiWaitForReceipt(config, { + const receipt = await wagmiWaitForReceipt(config, { hash, chainId, timeout: 120_000, }); + // wagmiWaitForReceipt resolves on reverted txs — without this check + // the rebalance plan would mark the leg refunded even though the + // escrow is still open. + if (receipt.status === "reverted") { + throw new Error(`refund() reverted: ${hash}`); + } patch(id, { status: "refunded", refundTxHash: hash }); } catch (err) { patch(id, { @@ -430,132 +436,141 @@ export function useIntentLegPipeline(): UseIntentLegPipelineReturn { return; } depositInFlight.current.add(id); - - const chainId = current.spec.destination.chainId; - const outputToken = current.spec.destination.outputToken; - const vault = current.spec.destination.vault; - const recipient = current.spec.destination.recipient; - - // Re-read the post-delivery balance now in case the user fired this - // before `markLegDelivered` landed. CRITICAL: never fall back to - // quote preview or `post` (without delta) — that risks depositing - // pre-existing balance the user holds on the destination chain. - let amount = current.deliveredAmount; - if (amount === undefined || amount === 0n) { - if (current.predeliveryBalance === undefined) { - patch(id, { - status: "deposit-failed", - depositError: - "Pre-delivery balance was never captured — open the vault drawer to deposit manually.", - }); - return; - } - try { - const post = (await wagmiReadContract(config, { - address: outputToken, - abi: erc20Abi, - functionName: "balanceOf", - args: [recipient], - chainId, - })) as bigint; - const delta = post > current.predeliveryBalance - ? post - current.predeliveryBalance - : 0n; - if (delta === 0n) { + // Outer try/finally covers EVERY exit path below — including the silent + // early returns during balance-delta validation (`predeliveryBalance` + // missing, zero delta, post-balance read failure, etc). Without it, + // those returns leave the lock set forever and depositInFlight.size>0 + // permanently blocks all future deposit attempts (the global gate at + // the top of this callback). + try { + const chainId = current.spec.destination.chainId; + const outputToken = current.spec.destination.outputToken; + const vault = current.spec.destination.vault; + const recipient = current.spec.destination.recipient; + + // Re-read the post-delivery balance now in case the user fired this + // before `markLegDelivered` landed. CRITICAL: never fall back to + // quote preview or `post` (without delta) — that risks depositing + // pre-existing balance the user holds on the destination chain. + let amount = current.deliveredAmount; + if (amount === undefined || amount === 0n) { + if (current.predeliveryBalance === undefined) { patch(id, { status: "deposit-failed", depositError: - "Solver fill not yet visible on-chain (RPC may be lagging). Retry in a moment.", + "Pre-delivery balance was never captured — open the vault drawer to deposit manually.", }); return; } - amount = delta; - } catch { + try { + const post = (await wagmiReadContract(config, { + address: outputToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [recipient], + chainId, + })) as bigint; + const delta = post > current.predeliveryBalance + ? post - current.predeliveryBalance + : 0n; + if (delta === 0n) { + patch(id, { + status: "deposit-failed", + depositError: + "Solver fill not yet visible on-chain (RPC may be lagging). Retry in a moment.", + }); + return; + } + amount = delta; + } catch { + patch(id, { + status: "deposit-failed", + depositError: + "Couldn't read destination balance after delivery — retry the deposit step.", + }); + return; + } + } + + if (!amount || amount === 0n) { patch(id, { status: "deposit-failed", depositError: - "Couldn't read destination balance after delivery — retry the deposit step.", + "Delivered amount not yet visible on-chain — wait and retry.", }); return; } - } - - if (!amount || amount === 0n) { - patch(id, { - status: "deposit-failed", - depositError: - "Delivered amount not yet visible on-chain — wait and retry.", - }); - return; - } - try { - patch(id, { status: "deposit-quoting", depositError: undefined }); - - const composer = await fetchComposerQuote({ - fromChain: chainId, - toChain: chainId, - fromToken: outputToken, - toToken: vault.address, - fromAddress: recipient, - toAddress: recipient, - fromAmount: amount.toString(), - underlyingSymbols: current.spec.destination.outputSymbol - ? [current.spec.destination.outputSymbol] - : undefined, - }); + try { + patch(id, { status: "deposit-quoting", depositError: undefined }); + + const composer = await fetchComposerQuote({ + fromChain: chainId, + toChain: chainId, + fromToken: outputToken, + toToken: vault.address, + fromAddress: recipient, + toAddress: recipient, + fromAmount: amount.toString(), + underlyingSymbols: current.spec.destination.outputSymbol + ? [current.spec.destination.outputSymbol] + : undefined, + }); - const currentChain = wagmiGetAccount(config).chainId; - if (currentChain !== chainId) { - await wagmiSwitchChain(config, { chainId }); - } - const walletClient = await getWagmiWalletClient(config, { chainId }); - if (!walletClient) { - throw new Error("No wallet client for destination chain"); - } + const currentChain = wagmiGetAccount(config).chainId; + if (currentChain !== chainId) { + await wagmiSwitchChain(config, { chainId }); + } + const walletClient = await getWagmiWalletClient(config, { chainId }); + if (!walletClient) { + throw new Error("No wallet client for destination chain"); + } - const spender = composer.estimate.approvalAddress as Address; - patch(id, { status: "deposit-approving" }); - await safeApproveErc20({ - wagmiConfig: config, - walletClient, - token: outputToken, - spender, - amount, - owner: recipient, - chainId, - }); + const spender = composer.estimate.approvalAddress as Address; + patch(id, { status: "deposit-approving" }); + await safeApproveErc20({ + wagmiConfig: config, + walletClient, + token: outputToken, + spender, + amount, + owner: recipient, + chainId, + }); - patch(id, { status: "deposit-signing" }); - const depositHash = await walletClient.sendTransaction({ - to: composer.transactionRequest.to as Address, - data: composer.transactionRequest.data as Hex, - value: composer.transactionRequest.value - ? BigInt(composer.transactionRequest.value) - : undefined, - gas: composer.transactionRequest.gasLimit - ? BigInt(composer.transactionRequest.gasLimit) - : undefined, - }); - const receipt = await wagmiWaitForReceipt(config, { - hash: depositHash, - chainId, - timeout: 120_000, - }); - if (receipt.status === "reverted") { - throw new Error("Deposit transaction reverted on-chain"); + patch(id, { status: "deposit-signing" }); + const depositHash = await walletClient.sendTransaction({ + to: composer.transactionRequest.to as Address, + data: composer.transactionRequest.data as Hex, + value: composer.transactionRequest.value + ? BigInt(composer.transactionRequest.value) + : undefined, + gas: composer.transactionRequest.gasLimit + ? BigInt(composer.transactionRequest.gasLimit) + : undefined, + }); + const receipt = await wagmiWaitForReceipt(config, { + hash: depositHash, + chainId, + timeout: 120_000, + }); + if (receipt.status === "reverted") { + throw new Error("Deposit transaction reverted on-chain"); + } + patch(id, { + status: "deposit-done", + depositTxHash: depositHash, + deliveredAmount: amount, + }); + } catch (err) { + patch(id, { + status: "deposit-failed", + depositError: err instanceof Error ? err.message : String(err), + }); } - patch(id, { - status: "deposit-done", - depositTxHash: depositHash, - deliveredAmount: amount, - }); - } catch (err) { - patch(id, { - status: "deposit-failed", - depositError: err instanceof Error ? err.message : String(err), - }); } finally { + // Outer finally: releases the global in-flight lock for every code + // path above (validation early returns AND the deposit try/catch). depositInFlight.current.delete(id); } }, diff --git a/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts b/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts index 67e1bf6..8f31805 100644 --- a/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts +++ b/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts @@ -95,10 +95,12 @@ export type CrossChainDepositState = | { phase: "bridge-settled"; bridgeTxHash: string; + // Always the on-chain delta (post − pre) snapshotted around the bridge + // tx, never the bridge-quote toAmountMin. If the post-bridge balance + // read fails, we surface "failed" with `failedAfterBridge` instead of + // optimistically emitting bridge-settled with a fabricated amount — + // depositing unrelated pre-existing destination funds is the bug. destinationAmountRaw: string; - // If the destination amount is zero or unreadable, callers can still - // attempt the deposit step with the bridge-quoted toAmountMin as a - // fallback. We expose both so the UI can warn. destinationToken: EarnToken; } | { @@ -212,7 +214,11 @@ async function readBalance( chainId: number, ): Promise { const provider = chainRpcProvider(chainId); - if (!provider) return ethers.BigNumber.from(0); + if (!provider) { + // CRITICAL: never return 0 silently. Callers compute `post - pre` deltas + // and a phantom-zero pre-balance would silently deposit unrelated funds. + throw new Error(`No RPC provider available for chain ${chainId}`); + } if (isNativeToken(tokenAddress)) return provider.getBalance(owner); const erc20 = new ethers.Contract(tokenAddress, ERC20_READ_ABI, provider); return erc20.balanceOf(owner); @@ -367,10 +373,12 @@ export async function executeCrossChainComposerDeposit( // Helper: build a "failed" state with the right recoverability flag. // `failedAfterBridge` means "the bridge tx already landed" — funds may be - // on the destination chain. Only `bridgeTxHash` matters; we used to also - // require `destinationAmountRaw`, but post-bridge balance-read failures - // happen BEFORE we can assign that, and they're still recoverable (user - // can retry the deposit step once the RPC catches up). + // on the destination chain. Recovery via the resume path additionally + // requires `destinationAmountRaw` to be set (see the else-branch below at + // ~L595): without a known bridged amount we refuse to deposit, since the + // live balance may include unrelated pre-existing funds. So the failure is + // surfaced with both fields when known, but `bridgeTxHash` alone is enough + // to mark the failure post-bridge for UI labeling purposes. const fail = (err: unknown): never => { const msg = formatTxError(err); onStateChange({ @@ -571,7 +579,12 @@ export async function executeCrossChainComposerDeposit( )); return; } - const delta = postBridgeDestBalance.sub(preBridgeDestBalance); + // Compare before subtracting: ethers BigNumber.sub on (post < pre) returns + // a negative value that `lte(0)` would catch, but defensive clamping makes + // the intent explicit and survives any version of the underlying math lib. + const delta = postBridgeDestBalance.gt(preBridgeDestBalance) + ? postBridgeDestBalance.sub(preBridgeDestBalance) + : ethers.BigNumber.from(0); if (delta.lte(0)) { fail(new Error( "Bridge settled but destination balance hasn't increased yet (RPC lag). Wait a moment and retry the deposit step.", @@ -587,13 +600,23 @@ export async function executeCrossChainComposerDeposit( destinationToken: destinationUnderlying, }); } else { - // Resuming after a post-bridge failure. Trust on-chain reality, not the - // stored destinationAmountRaw — the user may have spent / received more - // of the destination token, or the original delta may have been 0 due to - // RPC lag at first read. Re-read and treat the live balance as the - // depositable amount (capped at "what arrived" by reading the bridge - // status's `receiving.amount` when available, so we don't grab unrelated - // pre-existing balance). + // Resuming after a post-bridge failure. We must NEVER deposit the live + // balance directly — that would grab any unrelated destination-chain + // funds the user already held. Require a known bridged amount; cap at + // min(live, original) so a user who moved some funds out still resumes + // safely. + let originalBridged: ethers.BigNumber; + try { + originalBridged = ethers.BigNumber.from(destinationAmountRaw ?? "0"); + } catch { + originalBridged = ethers.BigNumber.from(0); + } + if (originalBridged.lte(0)) { + fail(new Error( + "Can't determine how much was originally bridged — refusing to deposit live balance which may include unrelated funds. Start a fresh deposit instead of resuming.", + )); + return; + } let liveBalance: ethers.BigNumber; try { liveBalance = await readBalance( @@ -609,19 +632,7 @@ export async function executeCrossChainComposerDeposit( fail(new Error("Destination balance is zero. Bridge may still be settling, or funds were already deposited.")); return; } - // Cap retry amount at the originally-bridged amount when known. If the - // stored value is "0" (the old broken case), fall back to the live - // balance — risky but only reachable from a recoverable-fail state the - // user explicitly retried. - let chosen = liveBalance; - try { - const original = ethers.BigNumber.from(destinationAmountRaw ?? "0"); - if (original.gt(0) && liveBalance.gte(original)) { - chosen = original; - } - } catch { - /* keep liveBalance */ - } + const chosen = liveBalance.gte(originalBridged) ? originalBridged : liveBalance; destinationAmountRaw = chosen.toString(); onStateChange({