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..a6ffd77 --- /dev/null +++ b/api/lifi-intents.ts @@ -0,0 +1,194 @@ +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 { + // 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" }); + } + } + + 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..37d87ae 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, @@ -33,6 +33,18 @@ 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 { 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"; import type { EarnToken, EarnVault } from "./types"; import { formatTxError, shortAddress, isNativeToken } from "./txUtils"; import EdbBadge from "../../EdbBadge"; @@ -175,6 +187,7 @@ interface DepositFlowProps { onBroadcast?: (txHash: string) => void; onConfirmed?: () => void; onError?: (message: string) => void; + onExecutionEvent?: (event: DepositExecutionEvent) => void; } @@ -184,15 +197,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 +212,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 +254,49 @@ 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], + ); + + // 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 +308,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 +327,55 @@ 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]); + + // 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. @@ -286,7 +428,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 +911,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 +929,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 +1138,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 +1228,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 +1247,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 +1377,9 @@ export function DepositFlow({
@@ -1239,7 +1570,70 @@ export function DepositFlow({ apy={vault.analytics.apy.total} /> - {amount && fromAmountRaw && fromAmountRaw.gt(0) && ( + {isCrossChain && fromAmountRaw && fromAmountRaw.gt(0) && ( +
+
+ + + {useIntents ? "Solver settlement + live lifecycle" : "Composer 2-step (bridge + deposit)"} + +
+ {useIntents && ( + setUseIntents(false)} + /> + )} + {!useIntents && crossChainState.phase !== "idle" && ( + { + // Resume needs only the bridge tx hash — the executor + // re-reads live destination balance to recompute the + // depositable delta. destinationAmountRaw can be + // undefined if the post-bridge balance read failed + // before we could assign it; passing "" lets the resume + // code fall through to the live re-read. + if ( + crossChainState.phase === "failed" && + crossChainState.failedAfterBridge && + crossChainState.bridgeTxHash + ) { + handleCrossChainExecute({ + resume: { + bridgeTxHash: crossChainState.bridgeTxHash, + destinationAmountRaw: + crossChainState.destinationAmountRaw ?? "0", + }, + }); + } + }} + /> + )} +
+ )} + + {amount && fromAmountRaw && fromAmountRaw.gt(0) && !useIntents && (
{quoteLoading && (
@@ -1247,7 +1641,7 @@ export function DepositFlow({ Fetching quote…
)} - {quoteError && !isTwoStepEligible && ( + {quoteError && !isTwoStepEligible && !isCrossChain && (
{(quoteErrorObj as Error)?.message ?? "Failed to fetch quote"} @@ -1337,7 +1731,7 @@ export function DepositFlow({
)} - {simResult && ( + {!useIntents && simResult && ( )} + {!useIntents && ( {flowState === "error" && errorMsg && ( )} + )} + {!useIntents && ( {flowState === "success" && txHash && ( )} + )} - {needsApproval && quote?.estimate?.approvalAddress && ( + {!useIntents && !isCrossChain && needsApproval && quote?.estimate?.approvalAddress && ( )} + {!useIntents && (
{!needsApproval && !isTwoStepEligible && !simResult?.success && (
@@ -1564,6 +1963,72 @@ export function DepositFlow({ )} {(() => { + // Cross-chain Composer 2-tx mode — bridge fromToken→underlying on + // vault chain, then deposit underlying into vault. Active when + // the user is on Composer (not Intents) AND source≠vault chain. + if (isCrossChain && destinationUnderlying) { + const inFlight = + crossChainState.phase !== "idle" && + crossChainState.phase !== "done" && + crossChainState.phase !== "failed"; + const isFailed = crossChainState.phase === "failed"; + const failedAfterBridge = + isFailed && crossChainState.failedAfterBridge; + const isDone = crossChainState.phase === "done"; + const disabled = + inFlight || + isDone || + insufficientBalance || + !fromAmountRaw || + fromAmountRaw.isZero(); + let ctaKey: string; + let ctaLabel: React.ReactNode; + if (inFlight) { + ctaKey = `xchain-${crossChainState.phase}`; + ctaLabel = ( + <> + + {phaseLabel(crossChainState.phase)} + + ); + } else if (isDone) { + ctaKey = "xchain-done"; + ctaLabel = "Deposit complete"; + } else if (failedAfterBridge) { + // Retry CTA lives in the timeline; keep the main button quiet. + ctaKey = "xchain-recoverable"; + ctaLabel = "See timeline to retry deposit"; + } else if (insufficientBalance) { + ctaKey = "xchain-insufficient"; + ctaLabel = "Insufficient balance"; + } else { + ctaKey = "xchain-start"; + ctaLabel = "Bridge + Deposit (2 steps)"; + } + return ( + + ); + } + // Two-step mode: the CTA is always "Deposit (2 steps)" if (isTwoStepEligible) { const twoStepBusy = @@ -1682,6 +2147,7 @@ export function DepositFlow({ ); })()}
+ )} )}
@@ -1766,7 +2232,7 @@ function SpenderCheckPanel({ return ( - Spender verified + Spender matches quote path ); case "already": @@ -1814,15 +2280,224 @@ function SpenderCheckPanel({ ); } +function phaseLabel(phase: CrossChainDepositState["phase"]): string { + switch (phase) { + case "quoting-bridge": return "Fetching bridge quote…"; + case "approving-bridge": return "Approving bridge…"; + case "signing-bridge": return "Confirm in wallet…"; + case "bridging": return "Bridging…"; + case "bridge-settled": return "Bridge settled"; + case "quoting-deposit": return "Fetching deposit quote…"; + case "approving-deposit": return "Approving deposit…"; + case "signing-deposit": return "Confirm in wallet…"; + case "depositing": return "Depositing…"; + case "done": return "Done"; + case "failed": return "Failed"; + case "idle": return ""; + } +} + +type TimelineStepStatus = "pending" | "active" | "done" | "failed"; + +interface TimelineStep { + key: string; + label: string; + status: TimelineStepStatus; + detail?: string; + txHash?: string; + chainId?: number; +} + +function buildTimelineSteps(args: { + state: CrossChainDepositState; + sourceChainId: number; + destinationChainId: number; + sourceToken: EarnToken | null; + destinationToken: EarnToken | null; + vaultLabel: string; +}): TimelineStep[] { + const { state, sourceChainId, destinationChainId, sourceToken, destinationToken, vaultLabel } = args; + const phase = state.phase; + const bridgeTxHash = "bridgeTxHash" in state ? state.bridgeTxHash : undefined; + const depositTxHash = + "depositTxHash" in state ? (state as { depositTxHash?: string }).depositTxHash : undefined; + const bridgeStatus = + "bridgeStatus" in state ? (state as { bridgeStatus?: string }).bridgeStatus : undefined; + const bridgeSubstatus = + "bridgeSubstatus" in state ? (state as { bridgeSubstatus?: string }).bridgeSubstatus : undefined; + + const bridgePhases = new Set(["quoting-bridge", "approving-bridge", "signing-bridge"]); + const settlePhases = new Set(["bridging"]); + const settledPhases = new Set([ + "bridge-settled", "quoting-deposit", "approving-deposit", + "signing-deposit", "depositing", "done", + ]); + const depositPhases = new Set(["quoting-deposit", "approving-deposit", "signing-deposit"]); + const depositingPhases = new Set(["depositing"]); + + const failedAfterBridge = + phase === "failed" && (state as { failedAfterBridge?: boolean }).failedAfterBridge; + + function stepStatus(opts: { active: boolean; isPast: boolean; failedHere: boolean }): TimelineStepStatus { + if (opts.failedHere) return "failed"; + if (opts.isPast) return "done"; + if (opts.active) return "active"; + return "pending"; + } + + const bridgeSubmitActive = bridgePhases.has(phase); + const bridgeSubmitDone = settlePhases.has(phase) || settledPhases.has(phase); + const bridgeSubmitFailed = phase === "failed" && !failedAfterBridge && bridgeTxHash === undefined; + const settleActive = settlePhases.has(phase); + const settleDone = settledPhases.has(phase); + const settleFailed = phase === "failed" && !failedAfterBridge && bridgeTxHash !== undefined; + const depositSubmitActive = depositPhases.has(phase); + const depositSubmitDone = depositingPhases.has(phase) || phase === "done"; + const depositSubmitFailed = phase === "failed" && failedAfterBridge === true && !depositTxHash; + const depositConfirmActive = depositingPhases.has(phase); + const depositConfirmDone = phase === "done"; + const depositConfirmFailed = phase === "failed" && failedAfterBridge === true && depositTxHash !== undefined; + + const sourceChainName = + CHAIN_REGISTRY.find((c) => c.id === sourceChainId)?.name ?? `chain ${sourceChainId}`; + const destChainName = + CHAIN_REGISTRY.find((c) => c.id === destinationChainId)?.name ?? `chain ${destinationChainId}`; + + return [ + { + key: "bridge-submit", + label: `Bridge ${sourceToken?.symbol ?? "asset"} → ${destinationToken?.symbol ?? "asset"}`, + detail: `${sourceChainName} → ${destChainName}`, + status: stepStatus({ active: bridgeSubmitActive, isPast: bridgeSubmitDone, failedHere: bridgeSubmitFailed }), + txHash: bridgeTxHash, + chainId: sourceChainId, + }, + { + key: "bridge-settle", + label: "Bridge settlement", + detail: bridgeSubstatus ?? (bridgeStatus ? `LI.FI status: ${bridgeStatus}` : "Waiting for delivery on destination chain"), + status: stepStatus({ active: settleActive, isPast: settleDone, failedHere: settleFailed }), + }, + { + key: "deposit-submit", + label: `Deposit ${destinationToken?.symbol ?? "asset"} → ${vaultLabel}`, + detail: destChainName, + status: stepStatus({ active: depositSubmitActive, isPast: depositSubmitDone, failedHere: depositSubmitFailed }), + }, + { + key: "deposit-confirm", + label: "Deposit confirmed", + status: stepStatus({ active: depositConfirmActive, isPast: depositConfirmDone, failedHere: depositConfirmFailed }), + txHash: depositTxHash, + chainId: destinationChainId, + }, + ]; +} + +interface CrossChainTimelineProps { + state: CrossChainDepositState; + sourceChainId: number; + destinationChainId: number; + sourceToken: EarnToken | null; + destinationToken: EarnToken | null; + vaultLabel: string; + onRetryDeposit: () => void; +} + +function CrossChainTimeline({ + state, sourceChainId, destinationChainId, + sourceToken, destinationToken, vaultLabel, onRetryDeposit, +}: CrossChainTimelineProps) { + const steps = buildTimelineSteps({ + state, sourceChainId, destinationChainId, sourceToken, destinationToken, vaultLabel, + }); + // Retry is reachable whenever the bridge tx landed — destinationAmountRaw + // may be missing if post-bridge balance read failed; the executor's resume + // path re-reads it live. + const showRetry = + state.phase === "failed" && + state.failedAfterBridge === true && + !!state.bridgeTxHash; + + return ( +
+
Cross-chain deposit
+
    + {steps.map((s) => ( +
  1. + +
    +
    + {s.label} +
    + {s.detail && ( +
    {s.detail}
    + )} + {s.txHash && (() => { + const explorer = CHAIN_REGISTRY.find((c) => c.id === s.chainId)?.explorerUrl; + if (!explorer) return null; + return ( + + {shortAddress(s.txHash)} + + ); + })()} +
    +
  2. + ))} +
+ {state.phase === "failed" && ( +
+
+ + + {state.message} + {showRetry && ( + + Your bridged funds are safe on the destination chain. Retry the deposit step to finish. + + )} + +
+ {showRetry && ( + + )} +
+ )} +
+ ); +} + +function TimelineDot({ status }: { status: TimelineStepStatus }) { + if (status === "done") return ; + if (status === "failed") return ; + if (status === "active") return ; + return ; +} + /** Token row inside the deposit-with dropdown — icon + symbol + balance. */ 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, @@ -1840,6 +2515,14 @@ function TokenSelectRow({ } } + // Show the chain label inline — without it, "USDC on Base" and "USDC on + // Ethereum" are indistinguishable in the dropdown and users can pick the + // wrong chain by accident. + const chainLabel = + SUPPORTED_CHAINS.find((c) => c.id === chainId)?.name ?? + CHAIN_REGISTRY.find((c) => c.id === chainId)?.name ?? + `chain ${chainId}`; + return ( - {token.symbol} + + {token.symbol} + + on {chainLabel} + + {displayBal && ( {displayBal} )} + {trailing && ( + + + {trailing} + + )} ); } diff --git a/src/components/integrations/lifi-earn/IntentBridgeStep.tsx b/src/components/integrations/lifi-earn/IntentBridgeStep.tsx new file mode 100644 index 0000000..6c65a25 --- /dev/null +++ b/src/components/integrations/lifi-earn/IntentBridgeStep.tsx @@ -0,0 +1,885 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAccount, useConfig, useSwitchChain } from "wagmi"; +import { + getWalletClient as getWagmiWalletClient, + readContract as wagmiReadContract, + waitForTransactionReceipt as wagmiWaitForReceipt, +} from "@wagmi/core"; +import { + encodeFunctionData, + parseAbi, + type Address, + type Hex, +} from "viem"; +import { + ArrowRight, + ArrowsClockwise, + CircleNotch, + Sparkle, + XCircle, +} from "@phosphor-icons/react"; +import { Button } from "../../ui/button"; +import ChainIcon from "../../icons/ChainIcon"; +import { + requestIntentQuote, + isDeliveredOrSettled, + readDestinationTxHash, + type IntentQuote, +} from "./intentsApi"; +import { fetchComposerQuote } from "./earnApi"; +import { IntentStatusTimeline } from "./IntentStatusTimeline"; +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 { SUPPORTED_CHAINS } from "../../../utils/chains"; +import type { DepositExecutionEvent } from "./concierge/types"; +import type { EarnToken, EarnVault } from "./types"; +import { formatTxError, isNativeToken, safeApproveErc20 } from "./txUtils"; + +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)", +]); + +type Stage = + | "idle" + | "quoting" + | "quoted" + | "approving" + | "signing" + | "open" + // Post-delivery deposit phase — once the solver fills on destination we + // need a second (same-chain) tx to land the underlying into the vault. + | "deposit-quoting" + | "deposit-approving" + | "deposit-signing" + | "deposit-done" + | "deposit-failed" + | "failed" + | "refunding" + | "refunded"; + +interface IntentBridgeStepProps { + sourceChainId: number; + sourceToken: EarnToken; + sourceAmountRaw: string; + vault: EarnVault; + /** Where destination tokens land. Defaults to the connected wallet. */ + recipient?: Address; + /** Fires when the user closes the panel after delivery (NOT a deposit confirmation). */ + onDismiss?: () => void; + onExecutionEvent?: (event: DepositExecutionEvent) => void; + /** + * Fires when the user wants to fall back to the Composer flow — typically + * after Intents reports "no quote available". The parent should flip its + * Intents toggle off. + */ + onFallbackToComposer?: () => void; +} + +export function IntentBridgeStep({ + sourceChainId, + sourceToken, + sourceAmountRaw, + vault, + recipient, + onDismiss, + onExecutionEvent, + onFallbackToComposer, +}: IntentBridgeStepProps) { + const { address, isConnected, chain: walletChain } = useAccount(); + const config = useConfig(); + const { switchChainAsync } = useSwitchChain(); + + const outputToken = vault.underlyingTokens?.[0]; + 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); + // Pre-open balance of the destination underlying so we can compute the + // actual delivered amount (solver fills can drift vs. quote preview). + const [predeliveryBalance, setPredeliveryBalance] = useState(null); + const [deliveredAmount, setDeliveredAmount] = useState(null); + const [depositTxHash, setDepositTxHash] = useState(null); + const [depositError, setDepositError] = useState(null); + const lastIntentStatusEventRef = useRef(null); + const lastDeliveredEventRef = useRef(null); + + // Reset state on source/destination/recipient changes so we don't reuse a + // stale quote, signed order, or orderId from a prior flow. + useEffect(() => { + setStage("idle"); + setError(null); + setQuote(null); + setOrder(null); + setOpenTxHash(null); + setOrderId(null); + setPredeliveryBalance(null); + setDeliveredAmount(null); + setDepositTxHash(null); + setDepositError(null); + lastIntentStatusEventRef.current = null; + lastDeliveredEventRef.current = null; + }, [sourceChainId, sourceToken.address, sourceAmountRaw, vault.address, recipient]); + + 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 recipientAddr = (recipient ?? address) as Address | undefined; + + // Shared with IntentStatusTimeline via React Query dedupe (same queryKey). + // Keep polling through the deposit phase too — the order can transition + // from Delivered → Settled while the user signs the deposit tx, and we + // want the timeline to reflect that. + const { + status: orderStatus, + state: orderState, + rawLabel: orderStatusLabel, + } = useIntentOrderStatus({ + onChainOrderId: orderId ?? undefined, + enabled: isPostOpenStage(stage) || stage === "refunding", + }); + const deliveryConfirmed = isDeliveredOrSettled(orderState); + + useEffect(() => { + if (!onExecutionEvent) return; + // CRITICAL: once stage === "refunded" we've locally fired the + // terminal-good event from handleRefund. Any later poll cycle that + // returns a stale/cached "Expired" or "Failed" status would, if + // forwarded, get reduced to leg.status === "failed" and overwrite the + // refunded state. Stop emitting once locally terminal. + if (stage === "refunded" || stage === "deposit-done") return; + if (!isPostOpenStage(stage) && stage !== "refunding") return; + const destinationTxHash = readDestinationTxHash(orderStatus); + const key = `${orderId ?? ""}:${orderStatusLabel}:${destinationTxHash ?? ""}`; + if (lastIntentStatusEventRef.current === key) return; + lastIntentStatusEventRef.current = key; + onExecutionEvent({ + type: "intent-status", + phase: "intent-open", + orderId: orderId ?? undefined, + status: orderStatusLabel, + destinationTxHash, + }); + }, [onExecutionEvent, orderId, orderStatus, orderStatusLabel, stage]); + + useEffect(() => { + if (!onExecutionEvent || !deliveryConfirmed) return; + const destinationTxHash = readDestinationTxHash(orderStatus); + const amountRaw = deliveredAmount?.toString(); + const key = `${orderId ?? ""}:${destinationTxHash ?? ""}:${amountRaw ?? ""}`; + if (lastDeliveredEventRef.current === key) return; + lastDeliveredEventRef.current = key; + onExecutionEvent({ + type: "delivered", + phase: "intent-open", + orderId: orderId ?? undefined, + amountRaw, + destinationTxHash, + }); + }, [deliveryConfirmed, deliveredAmount, onExecutionEvent, orderId, orderStatus]); + + async function handleQuote() { + if (!recipientAddr || !outputToken) return; + try { + setStage("quoting"); + setError(null); + + const userEip = encodeEip7930EvmAddress(sourceChainId, recipientAddr); + const fromAssetEip = encodeEip7930EvmAddress( + sourceChainId, + sourceToken.address as Address, + ); + const toAssetEip = encodeEip7930EvmAddress( + vault.chainId, + outputToken.address as Address, + ); + const receiverEip = encodeEip7930EvmAddress(vault.chainId, 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 quote available for this 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: vault.chainId, + outputToken: outputToken.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 || !outputToken) return; + try { + 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"); + + // Snapshot the destination underlying balance BEFORE we open the order. + // CRITICAL: a failed pre-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 preSnapshot: bigint; + try { + preSnapshot = (await wagmiReadContract(config, { + address: outputToken.address as Address, + abi: erc20Abi, + functionName: "balanceOf", + args: [recipientAddr], + chainId: vault.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.", + ); + } + setPredeliveryBalance(preSnapshot); + + const tokenAddr = sourceToken.address as Address; + const amount = BigInt(sourceAmountRaw); + + setStage("approving"); + await safeApproveErc20({ + wagmiConfig: config, + walletClient, + token: tokenAddr, + spender: INPUT_SETTLER_ESCROW, + amount, + 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, + }); + onExecutionEvent?.({ + type: "tx-broadcast", + phase: "intent-open", + txHash: hash, + }); + 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) { + // Without an orderId we can't poll status; fail loudly instead of + // dead-ending at "Opened". + throw new Error( + "open() succeeded but Open(orderId) event could not be decoded — escrow ABI may have changed", + ); + } + setOrderId(decodedOrderId); + setStage("open"); + onExecutionEvent?.({ + type: "intent-opened", + phase: "intent-open", + txHash: hash, + orderId: decodedOrderId, + }); + } catch (err) { + const message = formatTxError(err); + setError(message); + setStage("failed"); + onExecutionEvent?.({ + type: "failed", + phase: "intent-open", + message, + }); + } + } + + 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"); + // ONLY emit the intent-status — the reducer maps "Refunded" to the + // terminal-good "refunded" leg status. Emitting a separate `failed` + // event right after would overwrite that with `failed` (the reducer + // runs in event order), which defeats the whole point of having a + // refunded terminal state. + onExecutionEvent?.({ + type: "intent-status", + phase: "intent-open", + orderId: orderId ?? undefined, + status: "Refunded", + }); + } catch (err) { + setError(formatTxError(err)); + setStage("failed"); + } + } + + // Once delivery is confirmed, read the destination balance delta — the + // actual amount the solver delivered is what we want to deposit, not the + // quote preview (solver fill quality varies). Falls back to the quote + // preview if we never got a pre-snapshot. + useEffect(() => { + if (!deliveryConfirmed || !recipientAddr || !outputToken) return; + if (deliveredAmount !== null) return; + if (stage !== "open") return; + let cancelled = false; + void (async () => { + // If we couldn't take a pre-snapshot, we have no safe way to compute + // the delivered amount — refuse rather than fall back to the quote + // preview (which would risk depositing pre-existing funds). + if (predeliveryBalance === null) { + if (cancelled) return; + setDepositError( + "Pre-delivery balance unknown — refusing to auto-compute delivered amount. Open vault drawer manually to deposit.", + ); + return; + } + try { + const post = (await wagmiReadContract(config, { + address: outputToken.address as Address, + abi: erc20Abi, + functionName: "balanceOf", + args: [recipientAddr], + chainId: vault.chainId, + })) as bigint; + if (cancelled) return; + const delta = post > predeliveryBalance ? post - predeliveryBalance : 0n; + if (delta > 0n) { + setDeliveredAmount(delta); + } else { + // RPC lag: post equals pre. Don't fall back to quote preview — + // wait for the user to manually retry deposit (which re-reads). + setDepositError( + "Solver fill not yet visible on-chain (RPC may be lagging). Retry the deposit step in a moment.", + ); + } + } catch { + if (cancelled) return; + setDepositError( + "Couldn't read destination balance after delivery — retry the deposit step.", + ); + } + })(); + return () => { + cancelled = true; + }; + }, [ + deliveryConfirmed, + recipientAddr, + outputToken, + deliveredAmount, + stage, + predeliveryBalance, + quote, + vault.chainId, + config, + ]); + + async function handleDeposit() { + if (!recipientAddr || !outputToken) return; + + // If the auto-computed deliveredAmount is missing (RPC lag at delivery + // time), re-attempt the balance read here. Don't fall back to the quote + // preview — that would risk depositing pre-existing funds. + let amountToDeposit = deliveredAmount; + if ((amountToDeposit === null || amountToDeposit === 0n) && predeliveryBalance !== null) { + try { + const post = (await wagmiReadContract(config, { + address: outputToken.address as Address, + abi: erc20Abi, + functionName: "balanceOf", + args: [recipientAddr], + chainId: vault.chainId, + })) as bigint; + const delta = post > predeliveryBalance ? post - predeliveryBalance : 0n; + if (delta > 0n) { + amountToDeposit = delta; + setDeliveredAmount(delta); + setDepositError(null); + } + } catch { + // fall through to the error path below + } + } + + if (amountToDeposit === null || amountToDeposit === 0n) { + const message = + "Delivered amount still not visible on-chain. Wait a moment and try again."; + setDepositError(message); + setStage("deposit-failed"); + onExecutionEvent?.({ + type: "failed", + phase: "intent-deposit", + message, + recoverable: true, + orderId: orderId ?? undefined, + }); + return; + } + try { + setDepositError(null); + setStage("deposit-quoting"); + + const fromAmount = amountToDeposit.toString(); + const composer = await fetchComposerQuote({ + fromChain: vault.chainId, + toChain: vault.chainId, + fromToken: outputToken.address, + toToken: vault.address, + fromAddress: recipientAddr, + toAddress: recipientAddr, + fromAmount, + underlyingSymbols: outputToken.symbol ? [outputToken.symbol] : undefined, + }); + + // Switch to the vault chain for the second tx — the user has been on + // the source chain since open(). + if (walletChain?.id !== vault.chainId) { + await switchChainAsync({ chainId: vault.chainId }); + } + const walletClient = await getWagmiWalletClient(config, { + chainId: vault.chainId, + }); + if (!walletClient) throw new Error("No wallet client for vault chain"); + + const spender = composer.estimate.approvalAddress as Address; + const needed = BigInt(fromAmount); + setStage("deposit-approving"); + await safeApproveErc20({ + wagmiConfig: config, + walletClient, + token: outputToken.address as Address, + spender, + amount: needed, + owner: recipientAddr, + chainId: vault.chainId, + }); + + setStage("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, + }); + onExecutionEvent?.({ + type: "tx-broadcast", + phase: "intent-deposit", + txHash: depositHash, + }); + const receipt = await wagmiWaitForReceipt(config, { + hash: depositHash, + chainId: vault.chainId, + timeout: 120_000, + }); + if (receipt.status === "reverted") { + throw new Error("Deposit transaction reverted on-chain"); + } + setDepositTxHash(depositHash); + setStage("deposit-done"); + onExecutionEvent?.({ + type: "confirmed", + phase: "intent-deposit", + txHash: depositHash, + }); + } catch (err) { + const message = formatTxError(err); + setDepositError(message); + setStage("deposit-failed"); + onExecutionEvent?.({ + type: "failed", + phase: "intent-deposit", + message, + recoverable: true, + orderId: orderId ?? undefined, + }); + } + } + + if (!outputToken) { + return ( +
+ Intent bridge unavailable — vault has no underlying ERC-20. +
+ ); + } + + if (isNativeToken(sourceToken.address)) { + // OIF Escrow uses ERC-20 transferFrom; native sources revert on approve(). + return ( +
+ LI.FI Intent bridging requires an ERC-20 source. Pick a wrapped or + stablecoin balance (e.g. WETH / USDC) — native tokens aren't supported + yet on the escrow path. +
+ ); + } + + const previewOut = quote?.preview?.outputs?.[0]?.amount; + const previewOutDecimal = previewOut + ? formatRaw(previewOut, outputToken.decimals) + : null; + + return ( +
+
+ + + LI.FI Intent bridge + + + Solver settles to {outputToken.symbol} on {chainName(vault.chainId)} + +
+ +
+ + + {formatRaw(sourceAmountRaw, sourceToken.decimals)} {sourceToken.symbol ?? "?"} + + + on {chainName(sourceChainId)} + + + + + {previewOutDecimal ?? "—"} {outputToken.symbol} + + + on {chainName(vault.chainId)} + +
+ + {error && ( +

+ + {error} +

+ )} + + {isPostOpenStage(stage) && order && ( + <> + + + {!deliveryConfirmed && stage === "open" && ( +

+ Funds are escrowed on origin until the solver fills on + destination — usually under a minute. +

+ )} + + {deliveryConfirmed && ( +
+

+ Funds delivered on {chainName(vault.chainId)}. +

+ {stage !== "deposit-done" && ( +

+ Complete the deposit on {chainName(vault.chainId)} — + underlying → vault shares is a separate signature. +

+ )} + + {depositError && ( +

+ + {depositError} +

+ )} + + {stage === "deposit-done" ? ( +
+

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

+ {onDismiss && ( +
+ +
+ )} +
+ ) : stage === "deposit-quoting" ? ( + + ) : stage === "deposit-approving" ? ( + + ) : stage === "deposit-signing" ? ( + + ) : ( + + )} + + {stage !== "deposit-done" && onDismiss && ( +
+ +
+ )} +
+ )} + + )} + + {(stage === "idle" || stage === "quoting") && ( + + )} + + {stage === "failed" && ( +
+ {onFallbackToComposer && ( + + )} + +
+ )} + + {stage === "quoted" && ( + + )} + + {(stage === "approving" || stage === "signing") && ( + + )} + + {stage === "refunding" && ( + + )} + + {stage === "refunded" && ( +

+ Escrow refunded. +

+ )} +
+ ); +} + +// Any stage where the source-chain order has been opened and we're either +// awaiting delivery or running the post-delivery deposit. Used both to +// render the timeline + delivery panel and to keep status polling active. +function isPostOpenStage(stage: Stage): boolean { + return ( + stage === "open" || + stage === "deposit-quoting" || + stage === "deposit-approving" || + stage === "deposit-signing" || + stage === "deposit-done" || + stage === "deposit-failed" + ); +} + +function chainName(id: number): string { + return SUPPORTED_CHAINS.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/IntentStatusTimeline.tsx b/src/components/integrations/lifi-earn/IntentStatusTimeline.tsx new file mode 100644 index 0000000..9bbd903 --- /dev/null +++ b/src/components/integrations/lifi-earn/IntentStatusTimeline.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from "react"; +import { CircleNotch, CheckCircle, XCircle, Warning } from "@phosphor-icons/react"; +import type { Hex } from "viem"; +import { useIntentOrderStatus } from "./useIntentOrderStatus"; +import { + readDestinationTxHash, + readSolverAddress, + type CanonicalOrderState, +} from "./intentsApi"; + +type StepKey = "open" | "signed" | "delivered" | "settled"; +type StepStatus = "waiting" | "active" | "done" | "failed"; + +interface Step { + key: StepKey; + label: string; + hint: string; +} + +const STEPS: Step[] = [ + { key: "open", label: "Opened", hint: "Tokens escrowed on origin" }, + { key: "signed", label: "Signed", hint: "Solver picked up the order" }, + { key: "delivered", label: "Delivered", hint: "Output delivered on destination" }, + { key: "settled", label: "Settled", hint: "Proof verified, escrow released" }, +]; + +function reduceSteps( + state: CanonicalOrderState, + nowSec: number, + fillDeadlineSec: number, +): Record { + switch (state) { + case "Settled": + return { open: "done", signed: "done", delivered: "done", settled: "done" }; + case "Delivered": + return { open: "done", signed: "done", delivered: "done", settled: "active" }; + case "Signed": + return { open: "done", signed: "done", delivered: "active", settled: "waiting" }; + case "Refunded": + case "Failed": + case "Expired": + return { open: "done", signed: "failed", delivered: "waiting", settled: "waiting" }; + case "Submitted": + case "Open": + case "Unknown": + default: + // Past fill deadline without a Signed pickup → flag as missed. + if (nowSec > fillDeadlineSec) { + return { open: "done", signed: "failed", delivered: "waiting", settled: "waiting" }; + } + return { open: "done", signed: "active", delivered: "waiting", settled: "waiting" }; + } +} + +interface IntentStatusTimelineProps { + onChainOrderId?: Hex; + catalystOrderId?: string; + /** Unix seconds. Fill must happen before this. */ + fillDeadline: number; + /** Unix seconds. Refund unlocks after this. */ + expires: number; + openTxHash?: Hex; + originExplorerUrl?: string; + destinationExplorerUrl?: string; + onRefund?: () => Promise | void; + refundDisabled?: boolean; + refundPending?: boolean; +} + +export function IntentStatusTimeline({ + onChainOrderId, + catalystOrderId, + fillDeadline, + expires, + openTxHash, + originExplorerUrl, + destinationExplorerUrl, + onRefund, + refundDisabled = false, + refundPending = false, +}: IntentStatusTimelineProps) { + const { status, state, rawLabel } = useIntentOrderStatus({ + onChainOrderId, + catalystOrderId, + }); + + const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); + useEffect(() => { + const id = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000); + return () => clearInterval(id); + }, []); + + const steps = useMemo( + () => reduceSteps(state, nowSec, fillDeadline), + [state, nowSec, fillDeadline], + ); + + const fillSecondsLeft = Math.max(0, fillDeadline - nowSec); + const expireSecondsLeft = Math.max(0, expires - nowSec); + const canRefund = nowSec >= expires; + + return ( +
+
+ {rawLabel} + + {canRefund + ? "Refund window open" + : fillSecondsLeft > 0 + ? `${formatSeconds(fillSecondsLeft)} to fill` + : `${formatSeconds(expireSecondsLeft)} until refund`} + +
+ +
    + {STEPS.map((s) => ( + + ))} +
+ + {(() => { + const destTx = readDestinationTxHash(status); + const solver = readSolverAddress(status); + if (!openTxHash && !destTx && !solver) return null; + return ( +
+ {openTxHash && originExplorerUrl && ( + + Origin tx ↗ + + )} + {destTx && destinationExplorerUrl && ( + + Destination tx ↗ + + )} + {solver && ( + solver: {solver} + )} +
+ ); + })()} + + {onRefund && ( +
+ + + Refund becomes callable after expiry; anyone can trigger it. + + +
+ )} +
+ ); +} + +function StepPill({ step, status }: { step: Step; status: StepStatus }) { + const icon = + status === "done" ? ( + + ) : status === "failed" ? ( + + ) : status === "active" ? ( + + ) : ( + + ); + + const tone = + status === "done" + ? "border-emerald-500/30 bg-emerald-500/5" + : status === "failed" + ? "border-destructive/40 bg-destructive/5" + : status === "active" + ? "border-primary/30 bg-primary/5" + : "border-border/30 bg-muted/10"; + + return ( +
  • + + {icon} + {step.label} + + {step.hint} +
  • + ); +} + +function formatSeconds(s: number): string { + if (s <= 0) return "0s"; + const m = Math.floor(s / 60); + const sec = s % 60; + if (m === 0) return `${sec}s`; + if (m < 60) return `${m}m ${sec.toString().padStart(2, "0")}s`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; +} diff --git a/src/components/integrations/lifi-earn/TokenIcon.tsx b/src/components/integrations/lifi-earn/TokenIcon.tsx index bf53f93..5294ce1 100644 --- a/src/components/integrations/lifi-earn/TokenIcon.tsx +++ b/src/components/integrations/lifi-earn/TokenIcon.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react"; import { getTokenIconUrls } from "../../../utils/tokenMovements"; interface TokenIconProps { - token: { address: string; symbol: string; logoURI?: string }; + token: { address: string; symbol: string | undefined; logoURI?: string }; chainId: number; className?: string; } @@ -15,10 +15,11 @@ export function TokenIcon({ token, chainId, className }: TokenIconProps) { }, [token.address, token.logoURI, chainId]); const [srcIndex, setSrcIndex] = useState(0); + const fallbackInitial = (token.symbol ?? "?").charAt(0).toUpperCase(); const fallbackSvg = useMemo( () => - `data:image/svg+xml,${encodeURIComponent(token.symbol.charAt(0).toUpperCase())}`, - [token.symbol], + `data:image/svg+xml,${encodeURIComponent(fallbackInitial)}`, + [fallbackInitial], ); const currentSrc = srcIndex < urls.length ? urls[srcIndex] : fallbackSvg; diff --git a/src/components/integrations/lifi-earn/VaultList.tsx b/src/components/integrations/lifi-earn/VaultList.tsx index fba6118..e78d7ee 100644 --- a/src/components/integrations/lifi-earn/VaultList.tsx +++ b/src/components/integrations/lifi-earn/VaultList.tsx @@ -1054,8 +1054,9 @@ export function VaultCard({

    This vault has very high APY with{" "} - low TVL. This pattern may indicate - elevated risk — including unsustainable yields or potential rug pulls. Proceed with caution. + low TVL. That pattern is often + associated with short-lived yields or insufficiently-vetted vaults. We can't assess + contract risk on your behalf — review the protocol yourself before depositing.

    - {flowState === "success" ? ( + {flowState === "done" ? (
    - Withdrawal confirmed + {routeOutcome === "intent" + ? "Withdrawal delivered" + : routeOutcome === "composer" + ? "Withdrawal routed" + : routeOutcome === "kept-underlying" + ? "Underlying kept in wallet" + : "Withdrawal confirmed"}
    - {txHash && explorerUrl && ( + {redeemTxHash && explorerUrl && ( - View on explorer + View redeem tx + + )} + {routeTxHash && routeOutcome !== "redeemed-only" && ( + + View route tx )}
    ) : ( <> + {shareBalanceLoad === "failed" && !isNativeToken(vault.address) && ( +
    + +
    +

    + Couldn’t read your on-chain vault share balance. Withdraw is + blocked — without it we can’t safely size the redeem on + appreciated-share vaults. +

    + +
    +
    + )}
    - - {balanceDisplay && ( + + {(inShareMode || balanceDisplay) && (
    Available: <> - {balanceDisplay} {position.asset.symbol} + {inShareMode + ? `${shareBalanceDisplay ?? "0"} shares` + : `${balanceDisplay} ${position.asset.symbol}`} + {balanceDisplay && inShareMode && ( + + ≈ {balanceDisplay} {position.asset.symbol} + + )}
    { 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/hooks/useIdleBalances.ts b/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts index 3789da9..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,19 +185,32 @@ async function scanSingleChain(args: { args: [address] as const, })); + // 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 []; + 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, + }), + ), + ); + 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), ]); 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..0682dea --- /dev/null +++ b/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts @@ -0,0 +1,219 @@ +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; +} + +// 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 || routes.length === 0) { + 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..d80cb3c 100644 --- a/src/components/integrations/lifi-earn/txUtils.ts +++ b/src/components/integrations/lifi-earn/txUtils.ts @@ -65,3 +65,105 @@ 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, + }); + 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({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [spender, amount], + }); + const hash = await walletClient.sendTransaction({ + to: token, + data: approveData, + }); + const receipt = await wagmiWaitForReceipt(wagmiConfig, { + hash, + chainId, + timeout: timeoutMs, + }); + if (receipt.status === "reverted") { + throw new Error(`approve(${amount}) reverted: ${hash}`); + } +} 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..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) @@ -329,6 +339,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": {