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

Redeem confirmed

+

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

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

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

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

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

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

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

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

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

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

+ + {error} +

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

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

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

Escrow refunded to the source chain.

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

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

+

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

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

+ + {run.error} +

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

+ + {describeDegradeReason(spec.degradedReason)} +

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

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

+ {run.depositError && ( +

+ + {run.depositError} +

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

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

+ )} + +
+ {run.status === "failed" && ( + + )} + {/* + Open vault drawer was previously shown at status === "open" — that + fired *before* the funds actually landed. Gate it on delivery so + the user can only navigate to the vault drawer once it would be + actionable (and only as an escape hatch from the auto-deposit). + */} + {onPickVault && + spec.destination.vault && + delivered && + run.status !== "deposit-done" && ( + + )} + {run.quote?.solver && ( + solver: {run.quote.solver} + )} +
+
+ ); +} + +function DepositButton({ + status, + disabled, + onClick, +}: { + status: IntentLegRun["status"]; + disabled: boolean; + onClick: () => void; +}) { + if (status === "deposit-quoting") { + return ( + + ); + } + if (status === "deposit-approving") { + return ( + + ); + } + if (status === "deposit-signing") { + return ( + + ); + } + return ( + + ); +} + +// The status pill renders during all of these — keep timeline visible the +// whole time so the user sees the order lifecycle through to settlement. +function isTimelineActive(status: IntentLegRun["status"]): boolean { + return ( + status === "open" || + status === "refunding" || + status === "deposit-quoting" || + status === "deposit-approving" || + status === "deposit-signing" || + status === "deposit-failed" || + status === "deposit-done" + ); +} + +function explorerLabel(chainId: number): string { + return SUPPORTED_CHAINS.find((c) => c.id === chainId)?.name ?? `chain ${chainId}`; +} + +function decimalsFor(run: IntentLegRun, spec: IntentLegSpec): number { + // The spec doesn't carry destination decimals — try to find them off the + // vault's underlyingTokens (matched by address), otherwise default to 18. + const addr = spec.destination.outputToken.toLowerCase(); + const tok = spec.destination.vault?.underlyingTokens?.find( + (t) => t.address.toLowerCase() === addr, + ); + void run; // run unused here; signature kept symmetric for future use. + return tok?.decimals ?? 18; +} + +function LegStatusPill({ status }: { status: IntentLegRun["status"] }) { + const config: Record = { + planned: { + label: "Planned", + cls: "border-border/40 bg-muted/20 text-muted-foreground", + }, + degraded: { + label: "Skipped", + cls: "border-yellow-500/40 bg-yellow-500/10 text-yellow-500", + }, + quoting: { + label: "Quoting", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + quoted: { + label: "Quoted", + cls: "border-sky-500/40 bg-sky-500/10 text-sky-400", + }, + approving: { + label: "Approving", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + signing: { + label: "Signing", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + open: { + label: "Opened", + cls: "border-emerald-500/40 bg-emerald-500/10 text-emerald-400", + icon: , + }, + "deposit-quoting": { + label: "Quoting deposit", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + "deposit-approving": { + label: "Approving", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + "deposit-signing": { + label: "Depositing", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + "deposit-done": { + label: "Deposited", + cls: "border-emerald-500/40 bg-emerald-500/10 text-emerald-400", + icon: , + }, + "deposit-failed": { + label: "Deposit failed", + cls: "border-destructive/40 bg-destructive/5 text-destructive", + icon: , + }, + refunding: { + label: "Refunding", + cls: "border-primary/40 bg-primary/10 text-primary", + icon: , + }, + refunded: { + label: "Refunded", + cls: "border-yellow-500/40 bg-yellow-500/10 text-yellow-500", + }, + failed: { + label: "Failed", + cls: "border-destructive/40 bg-destructive/5 text-destructive", + icon: , + }, + }; + const c = config[status]; + return ( + + {c.icon} + {c.label} + + ); +} + diff --git a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts index eb0f40a..f74272e 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts +++ b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts @@ -22,6 +22,7 @@ const ALIAS_GROUPS: ReadonlyArray> = [ function normalizeUnderlyingKey(vault: EarnVault): string { const symbols = (vault.underlyingTokens ?? []) + .filter((t): t is typeof t & { symbol: string } => Boolean(t.symbol)) .map((t) => { const upper = t.symbol.toUpperCase(); for (const group of ALIAS_GROUPS) { diff --git a/src/components/integrations/lifi-earn/concierge/intent/hooks/useVaultsByIntent.ts b/src/components/integrations/lifi-earn/concierge/intent/hooks/useVaultsByIntent.ts index c23e89d..98af894 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/hooks/useVaultsByIntent.ts +++ b/src/components/integrations/lifi-earn/concierge/intent/hooks/useVaultsByIntent.ts @@ -197,9 +197,9 @@ export function rankVaultsForIntent( continue; } if (targetAliases !== null) { - const symbols = (v.underlyingTokens ?? []).map((t) => - t.symbol.toUpperCase() - ); + const symbols = (v.underlyingTokens ?? []) + .filter((t): t is typeof t & { symbol: string } => Boolean(t.symbol)) + .map((t) => t.symbol.toUpperCase()); if (!symbols.some((s) => targetAliases.has(s))) { rejection.symbolMismatch++; continue; diff --git a/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts b/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts new file mode 100644 index 0000000..bb7e11a --- /dev/null +++ b/src/components/integrations/lifi-earn/concierge/intent/intentLegs.ts @@ -0,0 +1,216 @@ +import type { Address } from "viem"; +import type { EarnVault } from "../../types"; +import type { IdleAsset } from "../types"; +import type { ParsedIntent } from "./schema"; +import type { IntentRoute } from "../../intentsApi"; +import { isNativeToken } from "../../../../../utils/addressConstants"; + +// Legs that can't execute (no route, unsupported source, etc.) render as +// degraded rows rather than being silently dropped — the user needs to see +// why an asset was skipped. +export type LegDegradeReason = + | "non-evm-source" + | "native-source-unsupported" + | "wallet-not-connected" + | "source-not-routable" + | "no-target-vault" + | "missing-output-token" + | "amount-too-small"; + +export interface IntentLegSpec { + id: string; + mode: "per-asset" | "consolidate"; + source: { + chainId: number; + chainName: string; + token: Address | string; + symbol: string | undefined; + decimals: number; + amountRaw: string; + amountDecimal: string; + amountUsd: number | null; + }; + destination: { + vault: EarnVault; + chainId: number; + outputToken: Address; + outputSymbol: string | undefined; + recipient: Address; + }; + status: "planned" | "degraded"; + degradedReason?: LegDegradeReason; +} + +export interface RoutesIndex { + has: ( + fromChainId: number, + fromToken: string, + toChainId: number, + toToken: string, + ) => boolean; + isEmpty: boolean; +} + +// Distinguishes two states that the previous version conflated: +// - `undefined` → fetch hasn't completed / errored; we don't know coverage, +// so optimistically allow legs (caller may surface "coverage unknown"). +// - `[]` → the upstream genuinely reports no active routes; mark +// everything unroutable so users see honest "no route" reasons. +export function buildRoutesIndex(routes: IntentRoute[] | undefined): RoutesIndex { + if (routes === undefined) { + return { isEmpty: true, has: () => true }; + } + const set = new Set(); + for (const r of routes) { + if (!r.isActive) continue; + const key = routeKey( + Number(r.fromChain.chainId), + r.fromToken.address, + Number(r.toChain.chainId), + r.toToken.address, + ); + set.add(key); + } + return { + isEmpty: set.size === 0, + has: (fromChainId, fromToken, toChainId, toToken) => + set.has(routeKey(fromChainId, fromToken, toChainId, toToken)), + }; +} + +function routeKey( + fromChainId: number, + fromToken: string, + toChainId: number, + toToken: string, +): string { + return `${fromChainId}:${fromToken.toLowerCase()}>${toChainId}:${toToken.toLowerCase()}`; +} + +interface BuildPlanArgs { + intent: ParsedIntent; + sourceAssets: IdleAsset[]; + /** Per-asset best vaults, index-aligned with `sourceAssets`. */ + perAssetVaults?: (EarnVault | null)[]; + consolidateVault?: EarnVault | null; + walletAddress?: Address | null; + routesIndex?: RoutesIndex; +} + +const EVM_NON_EVM_TAG = /^solana|^sol|tron|tvm|svm/i; + +// LI.FI's SVM/TVM chain ids are far above any real EIP-155 chain (Solana +// mainnet is 1151111081099710), so a 9-digit ceiling is a safe heuristic. +function isLikelyEvmChainId(chainId: number): boolean { + return chainId > 0 && chainId < 1_000_000_000; +} + +export function buildIntentLegPlan(args: BuildPlanArgs): IntentLegSpec[] { + const { + intent, + sourceAssets, + perAssetVaults, + consolidateVault, + walletAddress, + routesIndex, + } = args; + + return sourceAssets.map((asset, idx) => { + const mode: IntentLegSpec["mode"] = + intent.routing_mode === "consolidate" ? "consolidate" : "per-asset"; + const targetVault = + mode === "consolidate" ? consolidateVault ?? null : perAssetVaults?.[idx] ?? null; + + const baseSource = { + chainId: asset.chainId, + chainName: asset.chainName, + token: asset.token.address, + symbol: asset.token.symbol, + decimals: asset.token.decimals, + amountRaw: asset.amountRaw, + amountDecimal: asset.amountDecimal, + amountUsd: asset.amountUsd, + }; + + const degrade = (reason: LegDegradeReason): IntentLegSpec => ({ + id: `${asset.chainId}:${asset.token.address.toLowerCase()}:${idx}`, + mode, + source: baseSource, + destination: { + // placeholder values — UI only reads these when status === 'planned'. + vault: targetVault as EarnVault, + chainId: targetVault?.chainId ?? 0, + outputToken: ("0x0000000000000000000000000000000000000000" as Address), + outputSymbol: targetVault?.underlyingTokens?.[0]?.symbol ?? "", + recipient: ("0x0000000000000000000000000000000000000000" as Address), + }, + status: "degraded", + degradedReason: reason, + }); + + if ( + !isLikelyEvmChainId(asset.chainId) || + EVM_NON_EVM_TAG.test(asset.chainName) + ) { + return degrade("non-evm-source"); + } + // OIF escrow expects ERC-20 transferFrom; native sources need wrapping. + if (isNativeToken(asset.token.address)) { + return degrade("native-source-unsupported"); + } + if (!walletAddress) return degrade("wallet-not-connected"); + if (!targetVault) return degrade("no-target-vault"); + + const outToken = targetVault.underlyingTokens?.[0]; + if (!outToken) return degrade("missing-output-token"); + + if ( + routesIndex && + !routesIndex.has( + asset.chainId, + asset.token.address, + targetVault.chainId, + outToken.address, + ) + ) { + return degrade("source-not-routable"); + } + + if (BigInt(asset.amountRaw || "0") === 0n) { + return degrade("amount-too-small"); + } + + return { + id: `${asset.chainId}:${asset.token.address.toLowerCase()}:${targetVault.slug}`, + mode, + source: baseSource, + destination: { + vault: targetVault, + chainId: targetVault.chainId, + outputToken: outToken.address as Address, + outputSymbol: outToken.symbol, + recipient: walletAddress as Address, + }, + status: "planned", + }; + }); +} + +export function describeDegradeReason(reason: LegDegradeReason): string { + switch (reason) { + case "non-evm-source": + return "Source is on a non-EVM chain — wagmi can't sign for it yet."; + case "native-source-unsupported": + return "Native tokens (ETH / native) aren't supported yet — wrap to WETH or pick an ERC-20."; + case "wallet-not-connected": + return "Connect a wallet to fund this leg."; + case "source-not-routable": + return "No active LI.FI Intent route for this token pair."; + case "no-target-vault": + return "No target vault selected for this asset."; + case "missing-output-token": + return "Target vault doesn't expose an underlying ERC-20."; + case "amount-too-small": + return "Amount is zero or below the dust threshold."; + } +} diff --git a/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts b/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts new file mode 100644 index 0000000..d50a775 --- /dev/null +++ b/src/components/integrations/lifi-earn/concierge/intent/useIntentLegPipeline.ts @@ -0,0 +1,578 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + getWalletClient as getWagmiWalletClient, + readContract as wagmiReadContract, + waitForTransactionReceipt as wagmiWaitForReceipt, + switchChain as wagmiSwitchChain, + getAccount as wagmiGetAccount, +} from "@wagmi/core"; +import { + encodeFunctionData, + parseAbi, + type Address, + type Hex, +} from "viem"; +import { useConfig } from "wagmi"; +import { + requestIntentQuote, + type IntentQuote, +} from "../../intentsApi"; +import { fetchComposerQuote } from "../../earnApi"; +import { encodeEip7930EvmAddress } from "../../../../../lib/intents/eip7930"; +import { buildDeadlinePlan } from "../../../../../lib/intents/deadlines"; +import { nextOrderNonce } from "../../../../../lib/intents/nonce"; +import { + buildStandardOrder, + orderForAbi, + type StandardOrder, +} from "../../../../../lib/intents/standardOrder"; +import { + INPUT_SETTLER_ESCROW, + extractOpenOrderId, + inputSettlerEscrowAbi, +} from "../../../../../lib/intents/contracts"; +import { safeApproveErc20 } from "../../txUtils"; +import type { IntentLegSpec } from "./intentLegs"; + +// Quote requests fan out in parallel; on-chain open() runs sequentially — +// concurrent wallet prompts are unusable. +// +// `deposit-*` states cover the post-delivery same-chain Composer deposit +// that lands the underlying into the vault — Intents deliver an ERC-20 to +// the user's wallet, the deposit is a separate signature. +export type LegRunStatus = + | "planned" + | "degraded" + | "quoting" + | "quoted" + | "approving" + | "signing" + | "open" + | "deposit-quoting" + | "deposit-approving" + | "deposit-signing" + | "deposit-done" + | "deposit-failed" + | "failed" + | "refunding" + | "refunded"; + +export interface IntentLegRun { + spec: IntentLegSpec; + status: LegRunStatus; + quote?: IntentQuote; + order?: StandardOrder; + /** `Open(orderId)` event topic[1] from the open() receipt. */ + orderId?: Hex; + openTxHash?: Hex; + refundTxHash?: Hex; + /** On-chain destination underlying balance captured right before open(). */ + predeliveryBalance?: bigint; + /** Solver-delivered amount, measured as post-delivery balance delta. */ + deliveredAmount?: bigint; + depositTxHash?: Hex; + depositError?: string; + error?: string; +} + +const erc20Abi = parseAbi([ + "function allowance(address owner, address spender) view returns (uint256)", + "function approve(address spender, uint256 amount) returns (bool)", + "function balanceOf(address owner) view returns (uint256)", +]); + +interface UseIntentLegPipelineReturn { + runs: IntentLegRun[]; + quoteAll: (specs: IntentLegSpec[]) => Promise; + openAll: () => Promise; + retryLeg: (id: string) => Promise; + refundLeg: (id: string) => Promise; + depositLeg: (id: string) => Promise; + /** Lets RebalancePlanCard mark a leg as delivered once the timeline says so. */ + markLegDelivered: (id: string, deliveredAmount: bigint) => void; + reset: () => void; +} + +export function useIntentLegPipeline(): UseIntentLegPipelineReturn { + const config = useConfig(); + const [runs, setRuns] = useState([]); + + // Mirror state into a ref so async sequences (quoteAll / openAll) can read + // the latest snapshot without re-binding callbacks on every render. + const runsRef = useRef(runs); + useEffect(() => { + runsRef.current = runs; + }, [runs]); + + const patch = useCallback( + (id: string, patch: Partial) => + setRuns((prev) => + prev.map((r) => (r.spec.id === id ? { ...r, ...patch } : r)), + ), + [], + ); + + const quoteOne = useCallback( + async (run: IntentLegRun, walletAddress: Address): Promise => { + if (run.status === "degraded") return run; + const { spec } = run; + try { + const userEip7930 = encodeEip7930EvmAddress( + spec.source.chainId, + walletAddress, + ); + const fromAssetEip7930 = encodeEip7930EvmAddress( + spec.source.chainId, + spec.source.token as Address, + ); + const toAssetEip7930 = encodeEip7930EvmAddress( + spec.destination.chainId, + spec.destination.outputToken, + ); + const receiverEip7930 = encodeEip7930EvmAddress( + spec.destination.chainId, + spec.destination.recipient, + ); + + const quoteRes = await requestIntentQuote({ + user: userEip7930, + intent: { + intentType: "oif-swap", + inputs: [ + { + user: userEip7930, + asset: fromAssetEip7930, + amount: spec.source.amountRaw, + }, + ], + outputs: [ + { receiver: receiverEip7930, asset: toAssetEip7930, amount: null }, + ], + swapType: "exact-input", + }, + supportedTypes: ["oif-escrow-v0"], + }); + + const quote = quoteRes.quotes?.[0]; + if (!quote) { + return { ...run, status: "failed", error: "No quote returned" }; + } + + const previewAmount = quote.preview?.outputs?.[0]?.amount; + if (!previewAmount) { + return { + ...run, + status: "failed", + error: "Quote missing preview output amount", + }; + } + + const deadlines = buildDeadlinePlan({ + quoteValidUntilIso: quote.validUntil ?? null, + }); + + const order = buildStandardOrder({ + user: walletAddress, + nonce: nextOrderNonce(), + originChainId: spec.source.chainId, + inputToken: spec.source.token as Address, + inputAmount: BigInt(spec.source.amountRaw), + targetChainId: spec.destination.chainId, + outputToken: spec.destination.outputToken, + outputAmount: BigInt(previewAmount), + recipient: spec.destination.recipient, + expires: deadlines.expires, + fillDeadline: deadlines.fillDeadline, + context: (quote.context as Hex | undefined) ?? "0x", + }); + + return { ...run, status: "quoted", quote, order }; + } catch (err) { + return { + ...run, + status: "failed", + error: err instanceof Error ? err.message : String(err), + }; + } + }, + [], + ); + + const quoteAll = useCallback( + async (specs: IntentLegSpec[]) => { + const seeded: IntentLegRun[] = specs.map((spec) => ({ + spec, + status: spec.status === "degraded" ? "degraded" : "quoting", + })); + setRuns(seeded); + + const account = wagmiGetAccount(config); + const walletAddress = account.address as Address | undefined; + if (!walletAddress) { + setRuns( + seeded.map((r) => + r.status === "quoting" + ? { ...r, status: "failed", error: "Wallet not connected" } + : r, + ), + ); + return; + } + + const next = await Promise.all( + seeded.map((r) => + r.status === "quoting" + ? quoteOne(r, walletAddress) + : Promise.resolve(r), + ), + ); + setRuns(next); + }, + [config, quoteOne], + ); + + const openOne = useCallback( + async (run: IntentLegRun): Promise => { + if (!run.order) return run; + const chainId = run.spec.source.chainId; + + try { + const currentChain = wagmiGetAccount(config).chainId; + if (currentChain !== chainId) { + await wagmiSwitchChain(config, { chainId }); + } + + const walletClient = await getWagmiWalletClient(config, { chainId }); + if (!walletClient) throw new Error("No wallet client for source chain"); + const walletAddress = walletClient.account.address as Address; + + // Snapshot destination-chain balance of the underlying so the + // post-delivery deposit step can use the actual delta. CRITICAL: + // a failed read must HARD-FAIL — otherwise the post-fill delta + // calculation can't distinguish solver-delivered tokens from the + // user's pre-existing balance, and we'd deposit unrelated funds. + let predeliveryBalance: bigint; + try { + predeliveryBalance = (await wagmiReadContract(config, { + address: run.spec.destination.outputToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [run.spec.destination.recipient], + chainId: run.spec.destination.chainId, + })) as bigint; + } catch { + throw new Error( + "Couldn't read destination balance before opening the order — refusing to proceed (would risk depositing unrelated funds). Try again in a moment.", + ); + } + + const tokenAddr = run.spec.source.token as Address; + const amount = BigInt(run.spec.source.amountRaw); + + patch(run.spec.id, { status: "approving" }); + await safeApproveErc20({ + wagmiConfig: config, + walletClient, + token: tokenAddr, + spender: INPUT_SETTLER_ESCROW, + amount, + owner: walletAddress, + chainId, + }); + + patch(run.spec.id, { status: "signing" }); + const openData = encodeFunctionData({ + abi: inputSettlerEscrowAbi, + functionName: "open", + args: [orderForAbi(run.order)], + }); + const openHash = await walletClient.sendTransaction({ + to: INPUT_SETTLER_ESCROW, + data: openData, + }); + const receipt = await wagmiWaitForReceipt(config, { + hash: openHash, + chainId, + timeout: 120_000, + }); + if (receipt.status === "reverted") { + throw new Error("open() reverted on-chain"); + } + + const orderId = extractOpenOrderId(receipt.logs); + if (!orderId) { + // Without an orderId we can't poll status; fail loudly so the user + // sees the open tx landed but tracking is broken. + return { + ...run, + status: "failed", + openTxHash: openHash, + error: + "open() succeeded but Open(orderId) event could not be decoded — escrow ABI may have changed", + }; + } + + return { + ...run, + status: "open", + openTxHash: openHash, + orderId, + predeliveryBalance, + }; + } catch (err) { + return { + ...run, + status: "failed", + error: err instanceof Error ? err.message : String(err), + }; + } + }, + [config, patch], + ); + + const openAll = useCallback(async () => { + const ids = runs.filter((r) => r.status === "quoted").map((r) => r.spec.id); + for (const id of ids) { + const current = runsRef.current.find((r) => r.spec.id === id); + if (!current) continue; + const next = await openOne(current); + setRuns((prev) => + prev.map((r) => (r.spec.id === id ? next : r)), + ); + } + }, [openOne, runs]); + + const retryLeg = useCallback( + async (id: string) => { + const account = wagmiGetAccount(config); + const walletAddress = account.address as Address | undefined; + if (!walletAddress) return; + const current = runsRef.current.find((r) => r.spec.id === id); + if (!current) return; + patch(id, { status: "quoting", error: undefined }); + const next = await quoteOne( + { ...current, status: "quoting" }, + walletAddress, + ); + setRuns((prev) => prev.map((r) => (r.spec.id === id ? next : r))); + }, + [config, patch, quoteOne], + ); + + const refundLeg = useCallback( + async (id: string) => { + const current = runsRef.current.find((r) => r.spec.id === id); + if (!current?.order) return; + const chainId = current.spec.source.chainId; + try { + patch(id, { status: "refunding" }); + const currentChain = wagmiGetAccount(config).chainId; + if (currentChain !== chainId) { + await wagmiSwitchChain(config, { chainId }); + } + const walletClient = await getWagmiWalletClient(config, { chainId }); + if (!walletClient) throw new Error("No wallet client for source chain"); + const data = encodeFunctionData({ + abi: inputSettlerEscrowAbi, + functionName: "refund", + args: [orderForAbi(current.order)], + }); + const hash = await walletClient.sendTransaction({ + to: INPUT_SETTLER_ESCROW, + data, + }); + await wagmiWaitForReceipt(config, { + hash, + chainId, + timeout: 120_000, + }); + patch(id, { status: "refunded", refundTxHash: hash }); + } catch (err) { + patch(id, { + status: "failed", + error: err instanceof Error ? err.message : String(err), + }); + } + }, + [config, patch], + ); + + // Called by RebalancePlanCard once it observes Delivered/Settled on the + // status poll. We read the on-chain balance delta here (not the quote + // preview) because solver fill quality varies. + const markLegDelivered = useCallback( + (id: string, deliveredAmount: bigint) => { + setRuns((prev) => + prev.map((r) => + r.spec.id === id && r.status === "open" + ? { ...r, deliveredAmount } + : r, + ), + ); + }, + [], + ); + + // Cross-callback gate: prevent two clicks (or a stale React re-render + // letting two buttons stay enabled briefly) from kicking off two + // simultaneous wallet prompts for the same leg or two different legs. + const depositInFlight = useRef>(new Set()); + + const depositLeg = useCallback( + async (id: string) => { + if (depositInFlight.current.size > 0) return; + const current = runsRef.current.find((r) => r.spec.id === id); + if (!current) return; + if ( + current.status !== "open" && + current.status !== "deposit-failed" + ) { + return; + } + depositInFlight.current.add(id); + + const chainId = current.spec.destination.chainId; + const outputToken = current.spec.destination.outputToken; + const vault = current.spec.destination.vault; + const recipient = current.spec.destination.recipient; + + // Re-read the post-delivery balance now in case the user fired this + // before `markLegDelivered` landed. CRITICAL: never fall back to + // quote preview or `post` (without delta) — that risks depositing + // pre-existing balance the user holds on the destination chain. + let amount = current.deliveredAmount; + if (amount === undefined || amount === 0n) { + if (current.predeliveryBalance === undefined) { + patch(id, { + status: "deposit-failed", + depositError: + "Pre-delivery balance was never captured — open the vault drawer to deposit manually.", + }); + return; + } + try { + const post = (await wagmiReadContract(config, { + address: outputToken, + abi: erc20Abi, + functionName: "balanceOf", + args: [recipient], + chainId, + })) as bigint; + const delta = post > current.predeliveryBalance + ? post - current.predeliveryBalance + : 0n; + if (delta === 0n) { + patch(id, { + status: "deposit-failed", + depositError: + "Solver fill not yet visible on-chain (RPC may be lagging). Retry in a moment.", + }); + return; + } + amount = delta; + } catch { + patch(id, { + status: "deposit-failed", + depositError: + "Couldn't read destination balance after delivery — retry the deposit step.", + }); + return; + } + } + + if (!amount || amount === 0n) { + patch(id, { + status: "deposit-failed", + depositError: + "Delivered amount not yet visible on-chain — wait and retry.", + }); + return; + } + + try { + patch(id, { status: "deposit-quoting", depositError: undefined }); + + const composer = await fetchComposerQuote({ + fromChain: chainId, + toChain: chainId, + fromToken: outputToken, + toToken: vault.address, + fromAddress: recipient, + toAddress: recipient, + fromAmount: amount.toString(), + underlyingSymbols: current.spec.destination.outputSymbol + ? [current.spec.destination.outputSymbol] + : undefined, + }); + + const currentChain = wagmiGetAccount(config).chainId; + if (currentChain !== chainId) { + await wagmiSwitchChain(config, { chainId }); + } + const walletClient = await getWagmiWalletClient(config, { chainId }); + if (!walletClient) { + throw new Error("No wallet client for destination chain"); + } + + const spender = composer.estimate.approvalAddress as Address; + patch(id, { status: "deposit-approving" }); + await safeApproveErc20({ + wagmiConfig: config, + walletClient, + token: outputToken, + spender, + amount, + owner: recipient, + chainId, + }); + + patch(id, { status: "deposit-signing" }); + const depositHash = await walletClient.sendTransaction({ + to: composer.transactionRequest.to as Address, + data: composer.transactionRequest.data as Hex, + value: composer.transactionRequest.value + ? BigInt(composer.transactionRequest.value) + : undefined, + gas: composer.transactionRequest.gasLimit + ? BigInt(composer.transactionRequest.gasLimit) + : undefined, + }); + const receipt = await wagmiWaitForReceipt(config, { + hash: depositHash, + chainId, + timeout: 120_000, + }); + if (receipt.status === "reverted") { + throw new Error("Deposit transaction reverted on-chain"); + } + patch(id, { + status: "deposit-done", + depositTxHash: depositHash, + deliveredAmount: amount, + }); + } catch (err) { + patch(id, { + status: "deposit-failed", + depositError: err instanceof Error ? err.message : String(err), + }); + } finally { + depositInFlight.current.delete(id); + } + }, + [config, patch], + ); + + const reset = useCallback(() => setRuns([]), []); + + return { + runs, + quoteAll, + openAll, + retryLeg, + refundLeg, + depositLeg, + markLegDelivered, + reset, + }; +} + diff --git a/src/components/integrations/lifi-earn/concierge/types.ts b/src/components/integrations/lifi-earn/concierge/types.ts index 7a1b7e9..c84c989 100644 --- a/src/components/integrations/lifi-earn/concierge/types.ts +++ b/src/components/integrations/lifi-earn/concierge/types.ts @@ -36,9 +36,15 @@ export interface Leg { source: SelectedSource; destination: EarnVault; status: LegStatus; + executionMode: "composer-same" | "composer-cross" | "intent" | null; sourceTxHash: string | null; + intentOrderId?: string; + intentStatus?: string; + depositTxHash?: string; + destinationTxHash?: string; bridgeStatus: "PENDING" | "DONE" | "FAILED" | null; errorMessage: string | null; + recoverable: boolean; } export type LegStatus = @@ -48,9 +54,68 @@ export type LegStatus = | "approving" | "executing" | "bridging" + | "intent-open" + | "intent-delivered" + | "depositing" | "done" + | "refunded" | "failed"; +export type DepositExecutionPhase = + | "same-chain" + | "composer-bridge" + | "composer-deposit" + | "intent-open" + | "intent-deposit"; + +export type DepositExecutionEvent = + | { + type: "tx-broadcast"; + phase: DepositExecutionPhase; + txHash: string; + } + | { + type: "intent-opened"; + phase: "intent-open"; + txHash: string; + orderId: string; + } + | { + type: "intent-status"; + phase: "intent-open"; + orderId?: string; + status: string; + destinationTxHash?: string; + } + | { + type: "bridge-status"; + phase: "composer-bridge"; + status: string; + txHash?: string; + substatus?: string; + } + | { + type: "delivered"; + phase: "composer-bridge" | "intent-open"; + txHash?: string; + orderId?: string; + amountRaw?: string; + destinationTxHash?: string; + } + | { + type: "confirmed"; + phase: "same-chain" | "composer-deposit" | "intent-deposit"; + txHash?: string; + } + | { + type: "failed"; + phase: DepositExecutionPhase; + message: string; + recoverable?: boolean; + txHash?: string; + orderId?: string; + }; + export interface ConciergeConfig { maxCandidatesPerAsset: number; minTvlForSafe: number; diff --git a/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts b/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts new file mode 100644 index 0000000..67e1bf6 --- /dev/null +++ b/src/components/integrations/lifi-earn/crossChainComposerDeposit.ts @@ -0,0 +1,773 @@ +import { ethers } from "ethers"; +import type { Address } from "viem"; +import type { Config } from "@wagmi/core"; +import { + getWalletClient as getWagmiWalletClient, + waitForTransactionReceipt as wagmiWaitForReceipt, +} from "@wagmi/core"; + +import { + fetchComposerQuote, + fetchCrossChainStatus, +} from "./earnApi"; +import { networkConfigManager } from "../../../config/networkConfig"; +import { SUPPORTED_CHAINS } from "../../../utils/chains"; +import { isNativeToken } from "../../../utils/addressConstants"; +import { formatTxError } from "./txUtils"; +import type { EarnToken, EarnVault } from "./types"; + +/** + * Cross-chain Composer deposit — bridge fromToken on chain A to the vault's + * underlying on chain B, then deposit that underlying into the vault. Two + * distinct user-signed transactions chained behind LI.FI status polling. + * + * Built because Composer can route fromToken (chain A) -> underlying (chain B) + * in one tx but can't currently route fromToken (chain A) -> vault share + * (chain B) — so the UI's single-tx path fails for the cross-chain case. + * + * Sharp edges this code is paranoid about, derived from the same-chain + * `handleTwoStepExecute` audit: + * + * 1. **Bridge settlement, not source receipt.** The source tx receipt only + * proves funds left chain A; it does not prove the underlying landed on + * chain B. We poll `fetchCrossChainStatus` until `DONE` before reading the + * destination balance. Status `FAILED`/`INVALID` is terminal failure of + * the bridge leg. + * + * 2. **Balance delta, not total.** The user may already hold the underlying + * on chain B. We snapshot the balance before the bridge and deposit + * `postBalance - preBalance` (clamped to >=0). This avoids both + * under-depositing (toAmountMin leaves dust) and over-depositing pre- + * existing balance. + * + * 3. **USDT-style allowance reset.** Some ERC-20s revert when calling + * `approve(spender, n)` while a nonzero allowance is live (USDT being the + * canonical example). Before approving the deposit, if the current + * allowance is nonzero we call `approve(spender, 0)` and wait for that + * receipt before approving the real amount. + * + * 4. **Recoverable mid-flow failure.** If the bridge settles but the deposit + * tx fails, the user *still has their bridged funds*. The state machine + * surfaces `bridge-settled` with `bridgeTxHash` + `destinationAmountRaw` + * so the UI can offer a "Retry deposit" affordance. Callers can resume + * via the `resumeFromBridgeSettled` parameter — that path skips the + * bridge entirely and re-quotes the deposit against the on-chain balance + * delta the caller persisted. + */ + +export type CrossChainDepositPhase = + | "idle" + | "quoting-bridge" + | "approving-bridge" + | "signing-bridge" + | "bridging" + | "bridge-settled" + | "quoting-deposit" + | "approving-deposit" + | "signing-deposit" + | "depositing" + | "done" + | "failed"; + +/** + * Discriminated union — every phase carries the fields the UI legitimately + * needs at that point. Earlier fields persist into later phases (e.g. + * bridgeTxHash stays once it's set) so the timeline component can render a + * full history without each phase having to opt back in. + */ +export type CrossChainDepositState = + | { phase: "idle" } + | { phase: "quoting-bridge" } + | { + phase: "approving-bridge"; + bridgeApprovalSpender: string; + } + | { + phase: "signing-bridge"; + bridgeApprovalSpender?: string; + } + | { + phase: "bridging"; + bridgeTxHash: string; + bridgeStatus?: string; + bridgeSubstatus?: string; + } + | { + phase: "bridge-settled"; + bridgeTxHash: string; + destinationAmountRaw: string; + // If the destination amount is zero or unreadable, callers can still + // attempt the deposit step with the bridge-quoted toAmountMin as a + // fallback. We expose both so the UI can warn. + destinationToken: EarnToken; + } + | { + phase: "quoting-deposit"; + bridgeTxHash: string; + destinationAmountRaw: string; + } + | { + phase: "approving-deposit"; + bridgeTxHash: string; + destinationAmountRaw: string; + depositApprovalSpender: string; + } + | { + phase: "signing-deposit"; + bridgeTxHash: string; + destinationAmountRaw: string; + } + | { + phase: "depositing"; + bridgeTxHash: string; + depositTxHash: string; + destinationAmountRaw: string; + } + | { + phase: "done"; + bridgeTxHash: string; + depositTxHash: string; + } + | { + // `failedAfterBridge` discriminates a recoverable failure: the bridge + // settled, the user owns the underlying on the destination chain, and + // the UI can offer a "Retry deposit" affordance. Without this flag a + // failure is terminal (bridge never landed, or the user rejected). + phase: "failed"; + message: string; + failedAfterBridge: boolean; + bridgeTxHash?: string; + destinationAmountRaw?: string; + }; + +export interface ExecuteCrossChainComposerDepositArgs { + wagmiConfig: Config; + sourceChainId: number; + sourceToken: EarnToken; + sourceAmountRaw: string; + vault: EarnVault; + /** + * The ERC-20 the bridge will deliver to the user on the vault's chain. Must + * be one of the vault's underlying tokens — the function will then deposit + * it into the vault in step 2. + */ + destinationUnderlying: EarnToken; + userAddress: Address; + onStateChange: (state: CrossChainDepositState) => void; + /** + * Wraps `useSwitchChain().switchChainAsync` from the caller. We accept it as + * a closure (not a wagmi mutate fn) so this module stays React-free. + */ + switchChain: (chainId: number) => Promise; + /** + * If provided, skip the bridge leg entirely and retry the deposit step using + * this already-settled bridge as context. Used by the UI's "Retry deposit" + * affordance after a `failedAfterBridge` failure. The caller is responsible + * for persisting `bridgeTxHash` and `destinationAmountRaw` from the + * `bridge-settled` state. + */ + resumeFromBridgeSettled?: { + bridgeTxHash: string; + destinationAmountRaw: string; + }; +} + +const APPROVE_ABI = new ethers.utils.Interface([ + "function approve(address spender, uint256 amount) returns (bool)", +]); +const ERC20_READ_ABI = [ + "function allowance(address,address) view returns (uint256)", + "function balanceOf(address) view returns (uint256)", +]; + +/** Max poll duration for LI.FI bridge settlement before giving up. */ +const BRIDGE_POLL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (matches LI.FI's documented upper bound) +const BRIDGE_POLL_INTERVAL_MS = 4_000; +const RECEIPT_TIMEOUT_MS = 180_000; + +function chainRpcProvider(chainId: number): ethers.providers.JsonRpcProvider | null { + const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId); + if (!chain) return null; + const resolution = networkConfigManager.resolveRpcUrl(chainId, chain.rpcUrl); + if (!resolution.url) return null; + return new ethers.providers.StaticJsonRpcProvider(resolution.url, chainId); +} + +async function readAllowance( + tokenAddress: string, + owner: string, + spender: string, + chainId: number, +): Promise { + if (isNativeToken(tokenAddress)) return ethers.BigNumber.from(0); + const provider = chainRpcProvider(chainId); + if (!provider) return ethers.BigNumber.from(0); + const erc20 = new ethers.Contract(tokenAddress, ERC20_READ_ABI, provider); + return erc20.allowance(owner, spender); +} + +async function readBalance( + tokenAddress: string, + owner: string, + chainId: number, +): Promise { + const provider = chainRpcProvider(chainId); + if (!provider) return ethers.BigNumber.from(0); + if (isNativeToken(tokenAddress)) return provider.getBalance(owner); + const erc20 = new ethers.Contract(tokenAddress, ERC20_READ_ABI, provider); + return erc20.balanceOf(owner); +} + +/** + * Issue an `approve(spender, amount)` and wait for confirmation, resetting a + * nonzero allowance to 0 first when the token requires it. We always reset + * unconditionally when allowance is nonzero — the cost is one extra tx for + * non-USDT tokens, which is a fair price to avoid bricking USDT-style flows. + */ +async function approveWithReset(args: { + wagmiConfig: Config; + walletClient: NonNullable>>; + tokenAddress: string; + spender: string; + amount: ethers.BigNumber; + chainId: number; + currentAllowance: ethers.BigNumber; +}): Promise { + const { wagmiConfig, walletClient, tokenAddress, spender, amount, chainId, currentAllowance } = args; + if (currentAllowance.gte(amount)) return; + + if (currentAllowance.gt(0)) { + const resetData = APPROVE_ABI.encodeFunctionData("approve", [ + spender, + ethers.constants.Zero, + ]) as `0x${string}`; + const resetHash = await walletClient.sendTransaction({ + to: tokenAddress as `0x${string}`, + data: resetData, + // viem requires the chain object — we pass id only and rely on the + // wallet client already being scoped to chainId. + chain: { id: chainId } as any, + }); + const resetReceipt = await wagmiWaitForReceipt(wagmiConfig, { + hash: resetHash, + chainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (resetReceipt.status === "reverted") { + throw new Error("Allowance reset transaction reverted onchain"); + } + } + + const data = APPROVE_ABI.encodeFunctionData("approve", [ + spender, + ethers.constants.MaxUint256, + ]) as `0x${string}`; + const hash = await walletClient.sendTransaction({ + to: tokenAddress as `0x${string}`, + data, + chain: { id: chainId } as any, + }); + const receipt = await wagmiWaitForReceipt(wagmiConfig, { + hash, + chainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (receipt.status === "reverted") { + throw new Error("Approval transaction reverted onchain"); + } +} + +/** + * Outcome of bridge polling — `DONE` alone is not "tokens delivered to dest" + * (LI.FI uses DONE for COMPLETED, PARTIAL, and REFUNDED). We collapse the + * destination-arrived case into `COMPLETED`; everything else is failure. + */ +type BridgeOutcome = "COMPLETED" | "REFUNDED" | "PARTIAL" | "FAILED" | "INVALID"; + +async function waitForBridgeSettlement(args: { + txHash: string; + fromChain: number; + toChain: number; + onUpdate: (status: string, substatus?: string) => void; +}): Promise { + const deadline = Date.now() + BRIDGE_POLL_TIMEOUT_MS; + let consecutiveErrors = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (Date.now() > deadline) { + throw new Error("Bridge settlement timed out after 30 minutes"); + } + try { + const status = await fetchCrossChainStatus({ + txHash: args.txHash, + fromChain: args.fromChain, + toChain: args.toChain, + }); + consecutiveErrors = 0; + args.onUpdate(status.status, status.substatusMessage ?? status.substatus); + if (status.status === "DONE") { + // LI.FI substatus disambiguates: COMPLETED = tokens delivered, + // REFUNDED = bridge gave the funds back on origin, PARTIAL = some + // delivered but not the requested amount. Default to COMPLETED only + // when substatus is missing AND status is DONE — most well-behaved + // bridges set substatus. + const sub = (status.substatus ?? "").toUpperCase(); + if (sub === "REFUNDED") return "REFUNDED"; + if (sub === "PARTIAL") return "PARTIAL"; + return "COMPLETED"; + } + if (status.status === "FAILED") return "FAILED"; + if (status.status === "INVALID") return "INVALID"; + // NOT_FOUND / PENDING — keep polling + } catch (err) { + consecutiveErrors += 1; + if (consecutiveErrors >= 5) { + throw err; + } + } + await new Promise((r) => setTimeout(r, BRIDGE_POLL_INTERVAL_MS)); + } +} + +export async function executeCrossChainComposerDeposit( + args: ExecuteCrossChainComposerDepositArgs, +): Promise { + const { + wagmiConfig, + sourceChainId, + sourceToken, + sourceAmountRaw, + vault, + destinationUnderlying, + userAddress, + onStateChange, + switchChain, + resumeFromBridgeSettled, + } = args; + + // ── Sanity ──────────────────────────────────────────────────────────── + if (sourceChainId === vault.chainId) { + throw new Error( + "Cross-chain Composer deposit invoked for same-chain pair — use the single-tx flow instead", + ); + } + const isDestUnderlyingForVault = (vault.underlyingTokens ?? []).some( + (t) => t.address.toLowerCase() === destinationUnderlying.address.toLowerCase(), + ); + if (!isDestUnderlyingForVault) { + throw new Error( + "destinationUnderlying must be one of the vault's underlying tokens", + ); + } + + let bridgeTxHash: string | null = resumeFromBridgeSettled?.bridgeTxHash ?? null; + let destinationAmountRaw: string | null = + resumeFromBridgeSettled?.destinationAmountRaw ?? null; + + // Helper: build a "failed" state with the right recoverability flag. + // `failedAfterBridge` means "the bridge tx already landed" — funds may be + // on the destination chain. Only `bridgeTxHash` matters; we used to also + // require `destinationAmountRaw`, but post-bridge balance-read failures + // happen BEFORE we can assign that, and they're still recoverable (user + // can retry the deposit step once the RPC catches up). + const fail = (err: unknown): never => { + const msg = formatTxError(err); + onStateChange({ + phase: "failed", + message: msg, + failedAfterBridge: bridgeTxHash !== null, + bridgeTxHash: bridgeTxHash ?? undefined, + destinationAmountRaw: destinationAmountRaw ?? undefined, + }); + throw err instanceof Error ? err : new Error(msg); + }; + + // ───────────────────────────────────────────────────────────────────── + // STAGE 1: bridge (skip if resuming) + // ───────────────────────────────────────────────────────────────────── + if (!resumeFromBridgeSettled) { + // Snapshot the destination underlying balance BEFORE bridging so we can + // compute the delta after settlement. CRITICAL: a failed read must NOT + // default to 0 — the user could already hold destination-chain tokens + // from other sources, and `post - 0` would silently deposit those too. + let preBridgeDestBalance: ethers.BigNumber; + try { + preBridgeDestBalance = await readBalance( + destinationUnderlying.address, + userAddress, + vault.chainId, + ); + } catch (err) { + fail(new Error( + "Couldn't read destination balance before bridging — refusing to proceed (would risk depositing unrelated funds). Try again in a moment.", + )); + return; + } + + // ── Quote bridge: source -> destination underlying ────────────────── + onStateChange({ phase: "quoting-bridge" }); + let bridgeQuote; + try { + bridgeQuote = await fetchComposerQuote({ + fromChain: sourceChainId, + toChain: vault.chainId, + fromToken: sourceToken.address, + toToken: destinationUnderlying.address, + fromAddress: userAddress, + toAddress: userAddress, + fromAmount: sourceAmountRaw, + }); + } catch (err) { + fail(err); + return; + } + + // ── Switch chain & get wallet client for the source chain ────────── + try { + await switchChain(sourceChainId); + } catch (err) { + fail(err); + return; + } + + let sourceWalletClient; + try { + sourceWalletClient = await getWagmiWalletClient(wagmiConfig, { + chainId: sourceChainId, + }); + if (!sourceWalletClient) { + throw new Error("No wallet client available on source chain"); + } + } catch (err) { + fail(err); + return; + } + + // ── Approve sourceToken for bridge (with USDT-style reset) ───────── + if (!isNativeToken(sourceToken.address)) { + const spender = bridgeQuote.estimate.approvalAddress; + const sourceAmountBN = ethers.BigNumber.from(sourceAmountRaw); + let currentAllowance: ethers.BigNumber; + try { + currentAllowance = await readAllowance( + sourceToken.address, + userAddress, + spender, + sourceChainId, + ); + } catch { + currentAllowance = ethers.BigNumber.from(0); + } + if (currentAllowance.lt(sourceAmountBN)) { + onStateChange({ + phase: "approving-bridge", + bridgeApprovalSpender: spender, + }); + try { + await approveWithReset({ + wagmiConfig, + walletClient: sourceWalletClient, + tokenAddress: sourceToken.address, + spender, + amount: sourceAmountBN, + chainId: sourceChainId, + currentAllowance, + }); + } catch (err) { + fail(err); + return; + } + } + } + + // ── Send bridge tx ───────────────────────────────────────────────── + onStateChange({ phase: "signing-bridge" }); + let txHash: `0x${string}`; + try { + txHash = await sourceWalletClient.sendTransaction({ + to: bridgeQuote.transactionRequest.to as `0x${string}`, + data: bridgeQuote.transactionRequest.data as `0x${string}`, + value: bridgeQuote.transactionRequest.value + ? BigInt(bridgeQuote.transactionRequest.value) + : undefined, + gas: bridgeQuote.transactionRequest.gasLimit + ? BigInt(bridgeQuote.transactionRequest.gasLimit) + : undefined, + chain: { id: sourceChainId } as any, + }); + } catch (err) { + fail(err); + return; + } + + bridgeTxHash = txHash; + onStateChange({ + phase: "bridging", + bridgeTxHash: txHash, + }); + + // ── Wait for source receipt (proves tx mined, not bridge settled) ── + try { + const receipt = await wagmiWaitForReceipt(wagmiConfig, { + hash: txHash, + chainId: sourceChainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (receipt.status === "reverted") { + throw new Error("Bridge transaction reverted onchain"); + } + } catch (err) { + fail(err); + return; + } + + // ── Poll LI.FI status until terminal ──────────────────────────────── + let outcome: BridgeOutcome; + try { + outcome = await waitForBridgeSettlement({ + txHash, + fromChain: sourceChainId, + toChain: vault.chainId, + onUpdate: (status, substatus) => { + onStateChange({ + phase: "bridging", + bridgeTxHash: txHash, + bridgeStatus: status, + bridgeSubstatus: substatus, + }); + }, + }); + } catch (err) { + fail(err); + return; + } + if (outcome === "REFUNDED") { + fail(new Error("Bridge refunded — funds returned to the source chain. Deposit will not proceed.")); + return; + } + if (outcome === "PARTIAL") { + fail(new Error("Bridge delivered only a partial amount. We won't auto-deposit a partial fill; review on LI.FI and decide whether to deposit manually.")); + return; + } + if (outcome !== "COMPLETED") { + fail(new Error(`Bridge ${outcome.toLowerCase()} — funds may be stranded; check the source tx on LI.FI`)); + return; + } + + // ── Read destination balance DELTA ───────────────────────────────── + let postBridgeDestBalance: ethers.BigNumber; + try { + postBridgeDestBalance = await readBalance( + destinationUnderlying.address, + userAddress, + vault.chainId, + ); + } catch (err) { + // Bridge completed but we can't measure delivered amount. Recoverable: + // the user can retry from `bridge-settled` once the RPC catches up. + fail(new Error( + "Bridge settled but we couldn't read your destination balance. Your funds arrived; retry the deposit step.", + )); + return; + } + const delta = postBridgeDestBalance.sub(preBridgeDestBalance); + if (delta.lte(0)) { + fail(new Error( + "Bridge settled but destination balance hasn't increased yet (RPC lag). Wait a moment and retry the deposit step.", + )); + return; + } + destinationAmountRaw = delta.toString(); + + onStateChange({ + phase: "bridge-settled", + bridgeTxHash: txHash, + destinationAmountRaw, + destinationToken: destinationUnderlying, + }); + } else { + // Resuming after a post-bridge failure. Trust on-chain reality, not the + // stored destinationAmountRaw — the user may have spent / received more + // of the destination token, or the original delta may have been 0 due to + // RPC lag at first read. Re-read and treat the live balance as the + // depositable amount (capped at "what arrived" by reading the bridge + // status's `receiving.amount` when available, so we don't grab unrelated + // pre-existing balance). + let liveBalance: ethers.BigNumber; + try { + liveBalance = await readBalance( + destinationUnderlying.address, + userAddress, + vault.chainId, + ); + } catch (err) { + fail(new Error("Couldn't read destination balance for retry. Try again in a moment.")); + return; + } + if (liveBalance.lte(0)) { + fail(new Error("Destination balance is zero. Bridge may still be settling, or funds were already deposited.")); + return; + } + // Cap retry amount at the originally-bridged amount when known. If the + // stored value is "0" (the old broken case), fall back to the live + // balance — risky but only reachable from a recoverable-fail state the + // user explicitly retried. + let chosen = liveBalance; + try { + const original = ethers.BigNumber.from(destinationAmountRaw ?? "0"); + if (original.gt(0) && liveBalance.gte(original)) { + chosen = original; + } + } catch { + /* keep liveBalance */ + } + destinationAmountRaw = chosen.toString(); + + onStateChange({ + phase: "bridge-settled", + bridgeTxHash: bridgeTxHash!, + destinationAmountRaw, + destinationToken: destinationUnderlying, + }); + } + + // ───────────────────────────────────────────────────────────────────── + // STAGE 2: deposit underlying -> vault (same-chain on vault.chainId) + // ───────────────────────────────────────────────────────────────────── + // At this point bridgeTxHash and destinationAmountRaw are non-null. + const lockedBridgeHash = bridgeTxHash as string; + const lockedDestAmount = destinationAmountRaw as string; + + onStateChange({ + phase: "quoting-deposit", + bridgeTxHash: lockedBridgeHash, + destinationAmountRaw: lockedDestAmount, + }); + + let depositQuote; + try { + depositQuote = await fetchComposerQuote({ + fromChain: vault.chainId, + toChain: vault.chainId, + fromToken: destinationUnderlying.address, + toToken: vault.address, + fromAddress: userAddress, + toAddress: userAddress, + fromAmount: lockedDestAmount, + }); + } catch (err) { + fail(err); + return; + } + + try { + await switchChain(vault.chainId); + } catch (err) { + fail(err); + return; + } + + let destWalletClient; + try { + destWalletClient = await getWagmiWalletClient(wagmiConfig, { + chainId: vault.chainId, + }); + if (!destWalletClient) { + throw new Error("No wallet client available on destination chain"); + } + } catch (err) { + fail(err); + return; + } + + // ── Approve underlying for vault deposit (with USDT-style reset) ───── + if (!isNativeToken(destinationUnderlying.address)) { + const spender = depositQuote.estimate.approvalAddress; + const depositAmountBN = ethers.BigNumber.from(lockedDestAmount); + let currentAllowance: ethers.BigNumber; + try { + currentAllowance = await readAllowance( + destinationUnderlying.address, + userAddress, + spender, + vault.chainId, + ); + } catch { + currentAllowance = ethers.BigNumber.from(0); + } + if (currentAllowance.lt(depositAmountBN)) { + onStateChange({ + phase: "approving-deposit", + bridgeTxHash: lockedBridgeHash, + destinationAmountRaw: lockedDestAmount, + depositApprovalSpender: spender, + }); + try { + await approveWithReset({ + wagmiConfig, + walletClient: destWalletClient, + tokenAddress: destinationUnderlying.address, + spender, + amount: depositAmountBN, + chainId: vault.chainId, + currentAllowance, + }); + } catch (err) { + fail(err); + return; + } + } + } + + // ── Send deposit tx ─────────────────────────────────────────────────── + onStateChange({ + phase: "signing-deposit", + bridgeTxHash: lockedBridgeHash, + destinationAmountRaw: lockedDestAmount, + }); + let depositHash: `0x${string}`; + try { + depositHash = await destWalletClient.sendTransaction({ + to: depositQuote.transactionRequest.to as `0x${string}`, + data: depositQuote.transactionRequest.data as `0x${string}`, + value: depositQuote.transactionRequest.value + ? BigInt(depositQuote.transactionRequest.value) + : undefined, + gas: depositQuote.transactionRequest.gasLimit + ? BigInt(depositQuote.transactionRequest.gasLimit) + : undefined, + chain: { id: vault.chainId } as any, + }); + } catch (err) { + fail(err); + return; + } + + onStateChange({ + phase: "depositing", + bridgeTxHash: lockedBridgeHash, + depositTxHash: depositHash, + destinationAmountRaw: lockedDestAmount, + }); + + try { + const receipt = await wagmiWaitForReceipt(wagmiConfig, { + hash: depositHash, + chainId: vault.chainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (receipt.status === "reverted") { + throw new Error("Deposit transaction reverted onchain"); + } + } catch (err) { + fail(err); + return; + } + + onStateChange({ + phase: "done", + bridgeTxHash: lockedBridgeHash, + depositTxHash: depositHash, + }); +} diff --git a/src/components/integrations/lifi-earn/destinationTokenOptions.ts b/src/components/integrations/lifi-earn/destinationTokenOptions.ts new file mode 100644 index 0000000..e5ea5ba --- /dev/null +++ b/src/components/integrations/lifi-earn/destinationTokenOptions.ts @@ -0,0 +1,100 @@ +import { CHAIN_REGISTRY } from "../../../utils/chains"; +import type { EarnToken } from "./types"; + +/** + * Curated high-liquidity receive tokens per chain. Mirrored from + * DepositFlow's common-token picker so withdraw routing stays constrained to + * known Composer/Intent-friendly assets instead of arbitrary user input. + */ +export function getDestinationTokenOptions(chainId: number): EarnToken[] { + const native = (symbol: string, decimals = 18): EarnToken => ({ + address: "0x0000000000000000000000000000000000000000", + symbol, + decimals, + chainId, + }); + + const common: Record = { + 1: [ + native("ETH"), + { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", symbol: "USDC", decimals: 6, chainId: 1 }, + { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", symbol: "USDT", decimals: 6, chainId: 1 }, + { address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", symbol: "WETH", decimals: 18, chainId: 1 }, + ], + 137: [ + native("POL"), + { address: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", symbol: "WPOL", decimals: 18, chainId: 137 }, + { address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", symbol: "USDC", decimals: 6, chainId: 137 }, + { address: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", symbol: "USDT", decimals: 6, chainId: 137 }, + { address: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", symbol: "WETH", decimals: 18, chainId: 137 }, + ], + 42161: [ + native("ETH"), + { address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", symbol: "USDC", decimals: 6, chainId: 42161 }, + { address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", symbol: "USDT", decimals: 6, chainId: 42161 }, + { address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", symbol: "WETH", decimals: 18, chainId: 42161 }, + ], + 10: [ + native("ETH"), + { address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", symbol: "USDC", decimals: 6, chainId: 10 }, + { address: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", symbol: "USDT", decimals: 6, chainId: 10 }, + { address: "0x4200000000000000000000000000000000000006", symbol: "WETH", decimals: 18, chainId: 10 }, + ], + 8453: [ + native("ETH"), + { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", symbol: "USDC", decimals: 6, chainId: 8453 }, + { address: "0x4200000000000000000000000000000000000006", symbol: "WETH", decimals: 18, chainId: 8453 }, + ], + 56: [ + native("BNB"), + { address: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", symbol: "USDC", decimals: 18, chainId: 56 }, + { address: "0x55d398326f99059fF775485246999027B3197955", symbol: "USDT", decimals: 18, chainId: 56 }, + { address: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", symbol: "WBNB", decimals: 18, chainId: 56 }, + ], + 43114: [ + native("AVAX"), + { address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", symbol: "USDC", decimals: 6, chainId: 43114 }, + { address: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", symbol: "USDT", decimals: 6, chainId: 43114 }, + ], + 100: [ + native("xDAI", 18), + { address: "0x6A023CCd1ff6F2045C3309768eAD9E68F978f6e1", symbol: "WETH", decimals: 18, chainId: 100 }, + { address: "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", symbol: "USDC", decimals: 6, chainId: 100 }, + ], + }; + + const chainMeta = CHAIN_REGISTRY.find((c) => c.id === chainId); + const nativeSymbol = chainMeta?.nativeCurrency?.symbol ?? "ETH"; + const nativeDecimals = chainMeta?.nativeCurrency?.decimals ?? 18; + return common[chainId] ?? [native(nativeSymbol, nativeDecimals)]; +} + +export function destinationTokenKey(token: EarnToken): string { + return `${token.chainId ?? 0}:${token.address.toLowerCase()}`; +} + +export function pickDefaultDestinationToken(args: { + chainId: number; + sourceSymbol: string; + sameChainToken?: EarnToken; +}): EarnToken { + if (args.sameChainToken && args.sameChainToken.chainId === args.chainId) { + return args.sameChainToken; + } + + const options = getDestinationTokenOptions(args.chainId); + const source = args.sourceSymbol.toUpperCase(); + const ethLike = source === "ETH" || source === "WETH"; + if (ethLike) { + // Prefer WETH specifically — "startsWith W" picks up WPOL/WBNB/etc. on + // their native chains, which is the wrong asset for an ETH source. + const weth = options.find((t) => t.symbol?.toUpperCase() === "WETH"); + if (weth) return weth; + } + + return ( + options.find((t) => t.symbol?.toUpperCase() === source) ?? + options.find((t) => t.symbol?.toUpperCase() === "USDC") ?? + options[0] + ); +} diff --git a/src/components/integrations/lifi-earn/earnApi.ts b/src/components/integrations/lifi-earn/earnApi.ts index 5eb3196..c9c1429 100644 --- a/src/components/integrations/lifi-earn/earnApi.ts +++ b/src/components/integrations/lifi-earn/earnApi.ts @@ -96,19 +96,15 @@ function toComposerAddress(addr: string): string { return addr.trim().toLowerCase(); } -export async function fetchComposerQuote(params: { +function buildComposerQuoteUrl(params: { fromChain: number; toChain: number; fromToken: string; - // vault share address toToken: string; fromAddress: string; toAddress: string; - // smallest-unit decimal string fromAmount: string; - /** Vault's underlying token symbols — used for clearer error messages. */ - underlyingSymbols?: string[]; -}): Promise { +}): string { const url = new URL(`${window.location.origin}${COMPOSER_PROXY}/v1/quote`); url.searchParams.set("fromChain", String(params.fromChain)); url.searchParams.set("toChain", String(params.toChain)); @@ -121,8 +117,23 @@ export async function fetchComposerQuote(params: { // composer returns 1001 "None of the available routes could successfully // generate a tx". `hexkit` is our registered integrator in the LiFi portal. url.searchParams.set("integrator", "hexkit"); + return url.toString(); +} - const res = await fetch(url.toString(), { +export async function fetchComposerQuote(params: { + fromChain: number; + toChain: number; + fromToken: string; + // vault share address + toToken: string; + fromAddress: string; + toAddress: string; + // smallest-unit decimal string + fromAmount: string; + /** Vault's underlying token symbols — used for clearer error messages. */ + underlyingSymbols?: string[]; +}): Promise { + const res = await fetch(buildComposerQuoteUrl(params), { headers: proxyHeaders(), signal: AbortSignal.timeout(30000), }); @@ -133,17 +144,29 @@ export async function fetchComposerQuote(params: { try { const parsed = JSON.parse(body); if (parsed.code === 1002) { + // Composer code 1002 = no route found at all. Don't speculate on the + // cause (could be amount, liquidity, vendor outage, or a missing + // token mapping) — just point at the most common workaround. const syms = params.underlyingSymbols; const hint = syms?.length - ? ` Try depositing with ${syms.join("/")} directly — Composer can't always swap into this vault's underlying token in one step.` + ? ` Try depositing with ${syms.join("/")} directly — Composer can't always swap into this vault's underlying token.` : ""; + const isCrossChain = params.fromChain !== params.toChain; throw new Error( - `No route available for this deposit. The amount may be too small or there's no liquidity path.${hint}` + isCrossChain + ? `No bridge route available for this pair right now.${hint} Picking a vault on the same chain as your source token is the most reliable workaround.` + : `No route available for this deposit.${hint}` ); } if (parsed.code === 1001) { + // Composer 1001 = "no route generated a tx". Cross-chain success is + // non-monotonic and unstable hour-to-hour, so we don't speculate — + // we surface the failure fast and let the user adjust. + const isCrossChain = params.fromChain !== params.toChain; throw new Error( - "Route found but transaction couldn't be generated. Try a larger amount." + isCrossChain + ? "Cross-chain bridge couldn't price this route right now. Try a different amount, or pick a vault on the same chain as your source token." + : "Route exists but no available solver could quote it. Try adjusting the amount slightly." ); } if (parsed.message) { diff --git a/src/components/integrations/lifi-earn/hooks/useWithdrawQuote.ts b/src/components/integrations/lifi-earn/hooks/useWithdrawQuote.ts index 80db805..39ca0f8 100644 --- a/src/components/integrations/lifi-earn/hooks/useWithdrawQuote.ts +++ b/src/components/integrations/lifi-earn/hooks/useWithdrawQuote.ts @@ -14,6 +14,9 @@ interface UseWithdrawQuoteParams { } export function useWithdrawQuote(params: UseWithdrawQuoteParams) { + // Redeem quote only: vault share token -> underlying on the vault chain. + // Cross-chain withdraw routing starts after this tx confirms and the UI has + // measured the actual underlying balance delta. return useComposerQuote({ fromChain: params.chainId, toChain: params.chainId, diff --git a/src/components/integrations/lifi-earn/intentsApi.ts b/src/components/integrations/lifi-earn/intentsApi.ts new file mode 100644 index 0000000..82a1360 --- /dev/null +++ b/src/components/integrations/lifi-earn/intentsApi.ts @@ -0,0 +1,224 @@ +import type { Hex } from "viem"; + +// Integrator endpoints on order.li.fi are unauthenticated; the proxy exists +// for CORS parity + a server-side allowlist. Response shapes are a +// conservative superset — strict where we depend on a field, open elsewhere. +const INTENTS_PROXY = "/api/lifi-intents"; + +export type IntentSwapType = "exact-input" | "exact-output"; + +export interface IntentEndpoint { + /** EIP-7930 interoperable address (see lib/intents/eip7930). */ + user: Hex; + /** EIP-7930 interoperable address for the token. */ + asset: Hex; + /** Smallest-unit amount. Null on outputs for exact-input quotes. */ + amount: string | null; +} + +export interface IntentQuoteRequest { + user: Hex; + intent: { + intentType: "oif-swap"; + inputs: IntentEndpoint[]; + outputs: Array<{ receiver: Hex; asset: Hex; amount: string | null }>; + swapType: IntentSwapType; + }; + supportedTypes: Array<"oif-escrow-v0" | "oif-compact-v0">; +} + +export interface IntentQuotePreviewOutput { + amount: string; + [key: string]: unknown; +} + +export interface IntentQuote { + preview?: { + outputs?: IntentQuotePreviewOutput[]; + [key: string]: unknown; + }; + /** Pass straight into outputs[].context for auction/limit handling. */ + context?: Hex; + validUntil?: string; + solver?: string; + [key: string]: unknown; +} + +export interface IntentQuoteResponse { + quotes: IntentQuote[]; + [key: string]: unknown; +} + +// LI.FI surfaces tx hashes and solver under `meta.*`; older shapes (and our +// previous typing) put them at the top level. Readers fall back to either. +export interface IntentOrderStatus { + orderId?: Hex; + catalystOrderId?: string; + status?: string; + meta?: { + orderStatus?: string; + orderOpenedTxHash?: Hex; + orderSignedTxHash?: Hex; + orderDeliveredTxHash?: Hex; + orderSettledTxHash?: Hex; + solverAddress?: string; + [key: string]: unknown; + }; + originTxHash?: Hex; + destinationTxHash?: Hex; + solver?: string; + [key: string]: unknown; +} + +export function readOriginTxHash(s: IntentOrderStatus | null | undefined): Hex | undefined { + return s?.originTxHash ?? s?.meta?.orderOpenedTxHash; +} + +export function readDestinationTxHash( + s: IntentOrderStatus | null | undefined, +): Hex | undefined { + return ( + s?.destinationTxHash ?? + s?.meta?.orderDeliveredTxHash ?? + s?.meta?.orderSettledTxHash + ); +} + +export function readSolverAddress( + s: IntentOrderStatus | null | undefined, +): string | undefined { + return s?.solver ?? s?.meta?.solverAddress; +} + +async function postJson(path: string, body: unknown): Promise { + const res = await fetch(`${INTENTS_PROXY}/${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(`Intents ${path} failed: ${res.status} ${txt}`); + } + return res.json() as Promise; +} + +async function getJson(path: string, params?: Record): Promise { + const qs = params + ? `?${new URLSearchParams(params).toString()}` + : ""; + const url = `${INTENTS_PROXY}/${path}${qs}`; + const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(`Intents ${path} failed: ${res.status} ${txt}`); + } + return res.json() as Promise; +} + +export function requestIntentQuote( + body: IntentQuoteRequest, +): Promise { + return postJson("quote/request", body); +} + +export interface IntentOrderSubmitBody { + order: unknown; + signature?: Hex; + metadata?: { source?: string; [key: string]: unknown }; +} + +// /orders/submit is for gasless / sponsored flows (Permit2 + openFor, or +// Compact resource locks); normal escrow goes on-chain via open() and the +// order server picks it up from the Open event. +export function submitIntentOrder( + body: IntentOrderSubmitBody, +): Promise<{ orderId?: Hex; [key: string]: unknown }> { + return postJson("orders/submit", body); +} + +export function fetchIntentOrderStatus(params: { + onChainOrderId?: Hex; + catalystOrderId?: string; +}): Promise { + const query: Record = {}; + if (params.onChainOrderId) query.onChainOrderId = params.onChainOrderId; + if (params.catalystOrderId) query.catalystOrderId = params.catalystOrderId; + return getJson("orders/status", query); +} + +export interface IntentChain { + id: number; + chainId: string; + name: string; + chainType: "EVM" | "SVM" | "TVM" | string; +} + +export function fetchIntentChains(): Promise { + return getJson("chains/supported"); +} + +export interface IntentRoute { + fromChain: { chainId: string; chainType: string; name: string }; + toChain: { chainId: string; chainType: string; name: string }; + fromToken: { address: string; symbol: string | null; decimals: number }; + toToken: { address: string; symbol: string | null; decimals: number }; + isActive: boolean; + [key: string]: unknown; +} + +export interface IntentRoutesResponse { + routes: IntentRoute[]; +} + +export function fetchIntentRoutes(): Promise { + return getJson("routes"); +} + +// Canonical lifecycle from docs.li.fi/lifi-intents. Anything outside this +// set is treated as `Unknown` and surfaced verbatim. +export type CanonicalOrderState = + | "Submitted" + | "Open" + | "Signed" + | "Delivered" + | "Settled" + | "Expired" + | "Refunded" + | "Failed" + | "Unknown"; + +const KNOWN_STATES: Record = { + submitted: "Submitted", + open: "Open", + signed: "Signed", + delivered: "Delivered", + settled: "Settled", + expired: "Expired", + refunded: "Refunded", + failed: "Failed", +}; + +export function readOrderState(s: IntentOrderStatus | null | undefined): { + state: CanonicalOrderState; + rawLabel: string; +} { + const raw = (s?.meta?.orderStatus ?? s?.status ?? "").trim(); + if (!raw) return { state: "Unknown", rawLabel: "Pending" }; + const exact = KNOWN_STATES[raw.toLowerCase()]; + return { state: exact ?? "Unknown", rawLabel: raw }; +} + +export function isTerminalState(state: CanonicalOrderState): boolean { + return ( + state === "Settled" || + state === "Refunded" || + state === "Failed" || + state === "Expired" + ); +} + +export function isDeliveredOrSettled(state: CanonicalOrderState): boolean { + return state === "Delivered" || state === "Settled"; +} diff --git a/src/components/integrations/lifi-earn/txUtils.ts b/src/components/integrations/lifi-earn/txUtils.ts index fab3db7..1b8885f 100644 --- a/src/components/integrations/lifi-earn/txUtils.ts +++ b/src/components/integrations/lifi-earn/txUtils.ts @@ -65,3 +65,95 @@ export function formatTxError(err: unknown): string { } export { isNativeToken } from "../../../utils/addressConstants"; + +import { + readContract as wagmiReadContract, + waitForTransactionReceipt as wagmiWaitForReceipt, + type Config, +} from "@wagmi/core"; +import { + encodeFunctionData, + parseAbi, + type Address, + type Hex, +} from "viem"; + +const ERC20_APPROVE_ABI = parseAbi([ + "function allowance(address owner, address spender) view returns (uint256)", + "function approve(address spender, uint256 amount) returns (bool)", +]); + +/** + * Issue `approve(spender, amount)`, resetting a nonzero allowance to zero + * first when needed. USDT and a handful of other ERC-20s revert on + * `approve(spender, X)` when an existing nonzero `approve(spender, Y)` is + * already set — must `approve(spender, 0)` first. Always paying the extra tx + * when allowance is nonzero is safer than maintaining a token allowlist. + * + * No-op when current allowance >= amount. + */ +export async function safeApproveErc20(args: { + wagmiConfig: Config; + walletClient: { + sendTransaction: (tx: { to: Address; data: Hex }) => Promise; + }; + token: Address; + spender: Address; + amount: bigint; + owner: Address; + chainId: number; + timeoutMs?: number; +}): Promise { + const { + wagmiConfig, + walletClient, + token, + spender, + amount, + owner, + chainId, + timeoutMs = 120_000, + } = args; + + const current = (await wagmiReadContract(wagmiConfig, { + address: token, + abi: ERC20_APPROVE_ABI, + functionName: "allowance", + args: [owner, spender], + chainId, + })) as bigint; + + if (current >= amount) return; + + if (current > 0n) { + const resetData = encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [spender, 0n], + }); + const resetHash = await walletClient.sendTransaction({ + to: token, + data: resetData, + }); + await wagmiWaitForReceipt(wagmiConfig, { + hash: resetHash, + chainId, + timeout: timeoutMs, + }); + } + + const approveData = encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: "approve", + args: [spender, amount], + }); + const hash = await walletClient.sendTransaction({ + to: token, + data: approveData, + }); + await wagmiWaitForReceipt(wagmiConfig, { + hash, + chainId, + timeout: timeoutMs, + }); +} diff --git a/src/components/integrations/lifi-earn/types.ts b/src/components/integrations/lifi-earn/types.ts index c147b19..2a6402f 100644 --- a/src/components/integrations/lifi-earn/types.ts +++ b/src/components/integrations/lifi-earn/types.ts @@ -1,6 +1,9 @@ export interface EarnToken { address: string; - symbol: string; + // The upstream Earn API occasionally ships underlyings with a missing symbol; + // typed nullable so call sites have to handle it instead of crashing on + // `.toUpperCase()` (etc.). + symbol: string | undefined; name?: string; decimals: number; chainId?: number; diff --git a/src/components/integrations/lifi-earn/useIntentOrderStatus.ts b/src/components/integrations/lifi-earn/useIntentOrderStatus.ts new file mode 100644 index 0000000..ad85df3 --- /dev/null +++ b/src/components/integrations/lifi-earn/useIntentOrderStatus.ts @@ -0,0 +1,58 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Hex } from "viem"; +import { + fetchIntentOrderStatus, + isTerminalState, + readOrderState, + type CanonicalOrderState, + type IntentOrderStatus, +} from "./intentsApi"; + +// Shared between IntentStatusTimeline and IntentBridgeStep so React Query +// dedupes the poll across components. +export interface IntentOrderStatusResult { + status: IntentOrderStatus | null; + state: CanonicalOrderState; + rawLabel: string; + isLoading: boolean; +} + +export function useIntentOrderStatus(params: { + onChainOrderId?: Hex; + catalystOrderId?: string; + enabled?: boolean; +}): IntentOrderStatusResult { + const enabled = + (params.enabled ?? true) && + Boolean(params.onChainOrderId || params.catalystOrderId); + + const query = useQuery({ + queryKey: [ + "intent-order-status", + params.onChainOrderId ?? params.catalystOrderId ?? "", + ], + enabled, + queryFn: () => + fetchIntentOrderStatus({ + onChainOrderId: params.onChainOrderId, + catalystOrderId: params.catalystOrderId, + }), + refetchInterval: (q) => { + const data = q.state.data; + if (!data) return 3_000; + const { state } = readOrderState(data); + if (isTerminalState(state)) return false; + if (state === "Delivered") return 8_000; + return 3_000; + }, + refetchOnWindowFocus: true, + }); + + const { state, rawLabel } = readOrderState(query.data ?? null); + return { + status: query.data ?? null, + state, + rawLabel, + isLoading: query.isLoading, + }; +} diff --git a/src/components/integrations/lifi-earn/withdrawComposerRoute.ts b/src/components/integrations/lifi-earn/withdrawComposerRoute.ts new file mode 100644 index 0000000..fd52bb8 --- /dev/null +++ b/src/components/integrations/lifi-earn/withdrawComposerRoute.ts @@ -0,0 +1,317 @@ +import type { Config } from "@wagmi/core"; +import { + getWalletClient as getWagmiWalletClient, + waitForTransactionReceipt as wagmiWaitForReceipt, +} from "@wagmi/core"; +import type { Address, Hex } from "viem"; + +import { fetchComposerQuote, fetchCrossChainStatus } from "./earnApi"; +import type { EarnToken, LifiStatusResponse } from "./types"; +import { formatTxError, isNativeToken, safeApproveErc20 } from "./txUtils"; + +export type WithdrawComposerRoutePhase = + | "idle" + | "route-quoting" + | "composer-quoted" + | "composer-approving" + | "composer-sending" + | "composer-settling" + | "done" + | "failed"; + +export type WithdrawComposerRouteState = + | { phase: "idle" } + | { phase: "route-quoting" } + | { + phase: "composer-quoted"; + approvalSpender?: string; + } + | { + phase: "composer-approving"; + approvalSpender: string; + } + | { + phase: "composer-sending"; + approvalSpender?: string; + } + | { + phase: "composer-settling"; + routeTxHash: string; + lifiStatus?: string; + lifiSubstatus?: string; + } + | { + phase: "done"; + routeTxHash: string; + destinationTxHash?: string; + } + | { + phase: "failed"; + message: string; + failedAfterBroadcast: boolean; + routeTxHash?: string; + lifiStatus?: string; + lifiSubstatus?: string; + }; + +export interface ExecuteWithdrawComposerRouteArgs { + wagmiConfig: Config; + sourceChainId: number; + sourceToken: EarnToken; + sourceAmountRaw: string; + destinationChainId: number; + destinationToken: EarnToken; + userAddress: Address; + onStateChange: (state: WithdrawComposerRouteState) => void; + switchChain: (chainId: number) => Promise; +} + +const SETTLEMENT_TIMEOUT_MS = 30 * 60 * 1000; +const SETTLEMENT_POLL_INTERVAL_MS = 4_000; +const RECEIPT_TIMEOUT_MS = 180_000; + +type SettlementOutcome = "COMPLETED" | "REFUNDED" | "PARTIAL" | "FAILED" | "INVALID"; + +function readSubstatus(status: LifiStatusResponse): string | undefined { + return status.substatusMessage ?? status.substatus; +} + +function classifyDoneStatus(status: LifiStatusResponse): SettlementOutcome { + const sub = (status.substatus ?? "").toUpperCase(); + if (sub === "REFUNDED") return "REFUNDED"; + if (sub === "PARTIAL") return "PARTIAL"; + return "COMPLETED"; +} + +async function waitForComposerSettlement(args: { + txHash: string; + fromChain: number; + toChain: number; + onUpdate: (status: LifiStatusResponse) => void; +}): Promise<{ outcome: SettlementOutcome; status: LifiStatusResponse }> { + const deadline = Date.now() + SETTLEMENT_TIMEOUT_MS; + let consecutiveErrors = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (Date.now() > deadline) { + throw new Error("Route settlement timed out after 30 minutes"); + } + + try { + const status = await fetchCrossChainStatus({ + txHash: args.txHash, + fromChain: args.fromChain, + toChain: args.toChain, + }); + consecutiveErrors = 0; + args.onUpdate(status); + + if (status.status === "DONE") { + return { outcome: classifyDoneStatus(status), status }; + } + if (status.status === "FAILED") { + return { outcome: "FAILED", status }; + } + if (status.status === "INVALID") { + return { outcome: "INVALID", status }; + } + } catch (err) { + consecutiveErrors += 1; + if (consecutiveErrors >= 5) throw err; + } + + await new Promise((resolve) => + setTimeout(resolve, SETTLEMENT_POLL_INTERVAL_MS), + ); + } +} + +export async function executeWithdrawComposerRoute( + args: ExecuteWithdrawComposerRouteArgs, +): Promise { + const { + wagmiConfig, + sourceChainId, + sourceToken, + sourceAmountRaw, + destinationChainId, + destinationToken, + userAddress, + onStateChange, + switchChain, + } = args; + + let routeTxHash: string | null = null; + let lastStatus: LifiStatusResponse | null = null; + + const fail = (err: unknown): never => { + const message = formatTxError(err); + onStateChange({ + phase: "failed", + message, + failedAfterBroadcast: routeTxHash !== null, + routeTxHash: routeTxHash ?? undefined, + lifiStatus: lastStatus?.status, + lifiSubstatus: lastStatus ? readSubstatus(lastStatus) : undefined, + }); + throw err instanceof Error ? err : new Error(message); + }; + + let quote; + try { + onStateChange({ phase: "route-quoting" }); + quote = await fetchComposerQuote({ + fromChain: sourceChainId, + toChain: destinationChainId, + fromToken: sourceToken.address, + toToken: destinationToken.address, + fromAddress: userAddress, + toAddress: userAddress, + fromAmount: sourceAmountRaw, + }); + } catch (err) { + fail(err); + return; + } + + onStateChange({ + phase: "composer-quoted", + approvalSpender: quote.estimate.approvalAddress, + }); + + try { + await switchChain(sourceChainId); + } catch (err) { + fail(err); + return; + } + + let walletClient; + try { + walletClient = await getWagmiWalletClient(wagmiConfig, { + chainId: sourceChainId, + }); + if (!walletClient) { + throw new Error("No wallet client available on source chain"); + } + } catch (err) { + fail(err); + return; + } + + if (!isNativeToken(sourceToken.address)) { + try { + onStateChange({ + phase: "composer-approving", + approvalSpender: quote.estimate.approvalAddress, + }); + await safeApproveErc20({ + wagmiConfig, + walletClient, + token: sourceToken.address as Address, + spender: quote.estimate.approvalAddress as Address, + amount: BigInt(sourceAmountRaw), + owner: userAddress, + chainId: sourceChainId, + timeoutMs: RECEIPT_TIMEOUT_MS, + }); + } catch (err) { + fail(err); + return; + } + } + + onStateChange({ + phase: "composer-sending", + approvalSpender: isNativeToken(sourceToken.address) + ? undefined + : quote.estimate.approvalAddress, + }); + + try { + const hash = await walletClient.sendTransaction({ + to: quote.transactionRequest.to as Address, + data: quote.transactionRequest.data as Hex, + value: quote.transactionRequest.value + ? BigInt(quote.transactionRequest.value) + : undefined, + gas: quote.transactionRequest.gasLimit + ? BigInt(quote.transactionRequest.gasLimit) + : undefined, + chain: { id: sourceChainId } as any, + }); + routeTxHash = hash; + + onStateChange({ + phase: "composer-settling", + routeTxHash: hash, + }); + + const receipt = await wagmiWaitForReceipt(wagmiConfig, { + hash, + chainId: sourceChainId, + timeout: RECEIPT_TIMEOUT_MS, + }); + if (receipt.status === "reverted") { + throw new Error("Route transaction reverted onchain"); + } + } catch (err) { + fail(err); + return; + } + + const lockedRouteTxHash = routeTxHash; + if (!lockedRouteTxHash) { + fail(new Error("Route transaction hash missing after broadcast")); + return; + } + + if (sourceChainId === destinationChainId) { + onStateChange({ + phase: "done", + routeTxHash: lockedRouteTxHash, + destinationTxHash: lockedRouteTxHash, + }); + return; + } + + let result: Awaited>; + try { + result = await waitForComposerSettlement({ + txHash: lockedRouteTxHash, + fromChain: sourceChainId, + toChain: destinationChainId, + onUpdate: (status) => { + lastStatus = status; + onStateChange({ + phase: "composer-settling", + routeTxHash: lockedRouteTxHash, + lifiStatus: status.status, + lifiSubstatus: readSubstatus(status), + }); + }, + }); + } catch (err) { + fail(err); + return; + } + + lastStatus = result.status; + if (result.outcome !== "COMPLETED") { + fail( + new Error( + `LI.FI route ${result.outcome.toLowerCase()}${ + readSubstatus(result.status) ? `: ${readSubstatus(result.status)}` : "" + }`, + ), + ); + return; + } + + onStateChange({ + phase: "done", + routeTxHash: lockedRouteTxHash, + destinationTxHash: result.status.receiving?.txHash, + }); +} diff --git a/src/lib/intents/addressBytes.ts b/src/lib/intents/addressBytes.ts new file mode 100644 index 0000000..9037fbb --- /dev/null +++ b/src/lib/intents/addressBytes.ts @@ -0,0 +1,12 @@ +import { getAddress, type Address, type Hex } from "viem"; + +// MandateOutput bytes32 fields are left-padded EVM addresses (not EIP-7930). +export function addressToBytes32(address: Address): Hex { + return `0x${"00".repeat(12)}${getAddress(address).slice(2).toLowerCase()}` as Hex; +} + +// StandardOrder.inputs[i][0] is the ERC-20 token address cast to uint256, +// upper 12 bytes zero. +export function tokenIdentifierForEscrow(address: Address): bigint { + return BigInt(getAddress(address)); +} diff --git a/src/lib/intents/contracts.ts b/src/lib/intents/contracts.ts new file mode 100644 index 0000000..8217023 --- /dev/null +++ b/src/lib/intents/contracts.ts @@ -0,0 +1,128 @@ +import { decodeEventLog, type Address, type Hex } from "viem"; + +// The deployed input settler is the LI.FI variant `InputSettlerEscrowLIFI` +// (verified via Sourcify, May 2026). Functions take the StandardOrder tuple +// directly, not pre-encoded bytes. +export const INPUT_SETTLER_ESCROW = + "0x000025c3226C00B2Cdc200005a1600509f4e00C0" as Address; + +export const INPUT_SETTLER_COMPACT = + "0x0000000000cd5f7fDEc90a03a31F79E5Fbc6A9Cf" as Address; + +export const OUTPUT_SETTLER_SIMPLE = + "0x0000000000eC36B683C2E6AC89e9A75989C22a2e" as Address; + +export const POLYMER_ORACLE = + "0x0000003E06000007A224AeE90052fA6bb46d43C9" as Address; + +// Canonical deterministic Permit2 deployment. +export const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as Address; + +const MANDATE_OUTPUT_COMPONENTS = [ + { name: "oracle", type: "bytes32" }, + { name: "settler", type: "bytes32" }, + { name: "chainId", type: "uint256" }, + { name: "token", type: "bytes32" }, + { name: "amount", type: "uint256" }, + { name: "recipient", type: "bytes32" }, + { name: "callbackData", type: "bytes" }, + { name: "context", type: "bytes" }, +] as const; + +const STANDARD_ORDER_COMPONENTS = [ + { name: "user", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "originChainId", type: "uint256" }, + { name: "expires", type: "uint32" }, + { name: "fillDeadline", type: "uint32" }, + { name: "inputOracle", type: "address" }, + { name: "inputs", type: "uint256[2][]" }, + { + name: "outputs", + type: "tuple[]", + components: MANDATE_OUTPUT_COMPONENTS, + }, +] as const; + +const STANDARD_ORDER_ARG = { + name: "order", + type: "tuple", + components: STANDARD_ORDER_COMPONENTS, +} as const; + +// Selectors and Open topic confirmed present in the deployed Base bytecode +// (May 2026): open=0x7515fd56, openFor=0x49927074, refund=0x48f49eaf, +// Open(bytes32,StandardOrder)=0x9ff74bd56d00785b881ef9fa3f03d7b598686a39a9bcff89a6008db588b18a7b. +export const inputSettlerEscrowAbi = [ + { + type: "function", + name: "open", + stateMutability: "nonpayable", + inputs: [STANDARD_ORDER_ARG], + outputs: [], + }, + { + type: "function", + name: "openFor", + stateMutability: "nonpayable", + inputs: [ + STANDARD_ORDER_ARG, + { name: "sponsor", type: "address" }, + { name: "signature", type: "bytes" }, + ], + outputs: [], + }, + { + type: "function", + name: "refund", + stateMutability: "nonpayable", + inputs: [STANDARD_ORDER_ARG], + outputs: [], + }, + { + type: "event", + name: "Open", + anonymous: false, + inputs: [ + { indexed: true, name: "orderId", type: "bytes32" }, + { indexed: false, name: "order", type: "tuple", components: STANDARD_ORDER_COMPONENTS }, + ], + }, + { + type: "event", + name: "Open", + anonymous: false, + inputs: [{ indexed: true, name: "orderId", type: "bytes32" }], + }, + { + type: "event", + name: "Refunded", + anonymous: false, + inputs: [{ indexed: true, name: "orderId", type: "bytes32" }], + }, +] as const; + +// Iterate logs by signature rather than index — the first log is usually the +// ERC-20 Transfer from approve()/transferFrom, not the escrow's Open event. +export function extractOpenOrderId( + logs: { address: string; topics: readonly Hex[]; data: Hex }[] | undefined, +): Hex | null { + if (!logs) return null; + for (const log of logs) { + if (log.address.toLowerCase() !== INPUT_SETTLER_ESCROW.toLowerCase()) continue; + try { + const decoded = decodeEventLog({ + abi: inputSettlerEscrowAbi, + data: log.data, + topics: log.topics as [Hex, ...Hex[]], + }); + if (decoded.eventName === "Open") { + const args = decoded.args as unknown as { orderId?: Hex }; + if (args.orderId) return args.orderId; + } + } catch { + // Topic didn't match any event in our ABI — keep scanning. + } + } + return null; +} diff --git a/src/lib/intents/deadlines.ts b/src/lib/intents/deadlines.ts new file mode 100644 index 0000000..1e61849 --- /dev/null +++ b/src/lib/intents/deadlines.ts @@ -0,0 +1,45 @@ +// fillDeadline = solver fill cutoff; expires = refund unlock on origin. +// Refund is only callable after `expires`; the gap is oracle settlement grace. +export interface DeadlinePlan { + nowSec: number; + quoteValidUntilSec: number | null; + fillDeadline: number; + expires: number; +} + +interface DeadlineInput { + quoteValidUntilIso?: string | null; + nowMs?: number; + maxFillTtlSec?: number; + refundGraceSec?: number; +} + +export function buildDeadlinePlan(args: DeadlineInput = {}): DeadlinePlan { + const nowSec = Math.floor((args.nowMs ?? Date.now()) / 1000); + + let quoteValidUntilSec: number | null = null; + if (args.quoteValidUntilIso) { + const parsed = Date.parse(args.quoteValidUntilIso); + if (Number.isFinite(parsed)) { + quoteValidUntilSec = Math.floor(parsed / 1000); + } + } + + const maxFillTtl = args.maxFillTtlSec ?? 15 * 60; + const grace = args.refundGraceSec ?? 30 * 60; + + const fillDeadline = quoteValidUntilSec + ? Math.min(quoteValidUntilSec, nowSec + maxFillTtl) + : nowSec + maxFillTtl; + + if (fillDeadline <= nowSec + 30) { + throw new Error("quote too close to expiry to safely open an order"); + } + + return { + nowSec, + quoteValidUntilSec, + fillDeadline, + expires: fillDeadline + grace, + }; +} diff --git a/src/lib/intents/eip7930.ts b/src/lib/intents/eip7930.ts new file mode 100644 index 0000000..f278324 --- /dev/null +++ b/src/lib/intents/eip7930.ts @@ -0,0 +1,27 @@ +import { getAddress, type Address, type Hex } from "viem"; + +// EIP-7930 chain-tagged address for EIP-155 chains: +// `version(2) | chainType(2) | chainRefLen(1) | chainRef(N) | addrLen(1) | addr(20)`. +// Example for Base: 0x0001|0000|02|2105|14|. https://eips.ethereum.org/EIPS/eip-7930 +const VERSION = "0001"; +const CHAIN_TYPE_EIP155 = "0000"; +const ADDR_LEN_EVM = "14"; + +function toMinimalBigEndianHex(n: number): string { + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`invalid chainId for EIP-7930: ${n}`); + } + let h = n.toString(16); + if (h.length % 2) h = `0${h}`; + return h; +} + +export function encodeEip7930EvmAddress( + chainId: number, + address: Address, +): Hex { + const chainRef = toMinimalBigEndianHex(chainId); + const chainRefLen = (chainRef.length / 2).toString(16).padStart(2, "0"); + const addr = getAddress(address).slice(2).toLowerCase(); + return `0x${VERSION}${CHAIN_TYPE_EIP155}${chainRefLen}${chainRef}${ADDR_LEN_EVM}${addr}` as Hex; +} diff --git a/src/lib/intents/nonce.ts b/src/lib/intents/nonce.ts new file mode 100644 index 0000000..00337d6 --- /dev/null +++ b/src/lib/intents/nonce.ts @@ -0,0 +1,15 @@ +// LI.FI Escrow needs nonce uniqueness per (user, originChainId), not +// monotonicity. Layout `(ts << 48) | (rand32 << 16) | counter16` keeps +// same-millisecond collisions across tabs at ~1/2^32. + +let counter = 0; + +const RAND_BITS = 32n; +const COUNTER_BITS = 16n; + +export function nextOrderNonce(): bigint { + counter = (counter + 1) & 0xffff; + const ts = BigInt(Date.now()); + const rand = BigInt(Math.floor(Math.random() * 0xffffffff)); + return (ts << (RAND_BITS + COUNTER_BITS)) | (rand << COUNTER_BITS) | BigInt(counter); +} diff --git a/src/lib/intents/standardOrder.ts b/src/lib/intents/standardOrder.ts new file mode 100644 index 0000000..79f1fc5 --- /dev/null +++ b/src/lib/intents/standardOrder.ts @@ -0,0 +1,95 @@ +import type { Address, Hex } from "viem"; +import { addressToBytes32, tokenIdentifierForEscrow } from "./addressBytes"; +import { OUTPUT_SETTLER_SIMPLE, POLYMER_ORACLE } from "./contracts"; + +export interface MandateOutput { + oracle: Hex; + settler: Hex; + chainId: bigint; + token: Hex; + amount: bigint; + recipient: Hex; + callbackData: Hex; + context: Hex; +} + +export interface StandardOrder { + user: Address; + nonce: bigint; + originChainId: bigint; + expires: number; + fillDeadline: number; + inputOracle: Address; + inputs: readonly (readonly [bigint, bigint])[]; + outputs: MandateOutput[]; +} + +interface BuildOrderInput { + user: Address; + nonce: bigint; + originChainId: number; + inputToken: Address; + inputAmount: bigint; + targetChainId: number; + outputToken: Address; + outputAmount: bigint; + recipient: Address; + expires: number; + fillDeadline: number; + context?: Hex; + callbackData?: Hex; +} + +// Shape required by viem's encodeFunctionData for the escrow's open/openFor +// /refund arguments — keeps the inputs as readonly tuples and re-spreads the +// outputs so the ABI encoder sees plain bigints/hex. +export function orderForAbi(order: StandardOrder) { + return { + user: order.user, + nonce: order.nonce, + originChainId: order.originChainId, + expires: order.expires, + fillDeadline: order.fillDeadline, + inputOracle: order.inputOracle, + inputs: order.inputs.map(([a, b]) => [a, b] as readonly [bigint, bigint]), + outputs: order.outputs.map((o) => ({ + oracle: o.oracle, + settler: o.settler, + chainId: o.chainId, + token: o.token, + amount: o.amount, + recipient: o.recipient, + callbackData: o.callbackData, + context: o.context, + })), + }; +} + +// Same-chain orders can let the OutputSettler act as its own oracle; cross-chain +// orders need an attestation bridge (Polymer here, per docs.li.fi/lifi-intents). +export function buildStandardOrder(args: BuildOrderInput): StandardOrder { + const crossChain = args.originChainId !== args.targetChainId; + const oracleAddr = crossChain ? POLYMER_ORACLE : OUTPUT_SETTLER_SIMPLE; + + return { + user: args.user, + nonce: args.nonce, + originChainId: BigInt(args.originChainId), + expires: args.expires, + fillDeadline: args.fillDeadline, + inputOracle: oracleAddr, + inputs: [[tokenIdentifierForEscrow(args.inputToken), args.inputAmount]], + outputs: [ + { + oracle: addressToBytes32(oracleAddr), + settler: addressToBytes32(OUTPUT_SETTLER_SIMPLE), + chainId: BigInt(args.targetChainId), + token: addressToBytes32(args.outputToken), + amount: args.outputAmount, + recipient: addressToBytes32(args.recipient), + callbackData: args.callbackData ?? "0x", + context: args.context ?? "0x", + }, + ], + }; +} diff --git a/vercel.json b/vercel.json index 533b97e..e73e03b 100644 --- a/vercel.json +++ b/vercel.json @@ -72,6 +72,10 @@ "source": "/api/lifi-composer/:path*", "destination": "/api/lifi-composer?path=:path*" }, + { + "source": "/api/lifi-intents/:path*", + "destination": "/api/lifi-intents?path=:path*" + }, { "source": "/:path((?!api/|assets/|.*\\..*).*)", "destination": "/index.html" diff --git a/vite.config.ts b/vite.config.ts index 7339f40..e0571f7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -329,6 +329,15 @@ export default defineConfig(({ mode }) => { "x-lifi-api-key": LIFI_API_KEY, }, }, + // LI.FI Intents (order.li.fi) — integrator endpoints are open, no API + // key required per docs.li.fi/lifi-intents/authentication. Proxy exists + // for CORS parity with the Vercel serverless fn used in prod. + "/api/lifi-intents": { + target: "https://order.li.fi", + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/api\/lifi-intents/, ""), + }, // Gemini is handled by llmProxyPlugin() below — not a static proxy // Proxy for Sourcify repo "/api/repo": { From e27f0e79ee5d1d9647dd7cd3123b3921b9f8ff8d Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 21:10:45 +0100 Subject: [PATCH 02/18] feat: register Mezo Testnet (31611) and Mainnet (31612) chains - Add canonical contract addresses (MUSD, MEZO precompile, BTC ERC-20 surface, sMUSD, BorrowerOperations, TroveManager, SortedTroves, HintHelpers, PriceFeed, veMEZO, Voter, PoolFactory, Router, plus pool helpers) - Extend chain registry with both networks (native BTC, 18 decimals) - Extend CSP connect-src to allow Mezo public RPCs + Blockscout APIs - Document EDB_DEFAULT_BRIDGE_URL and EDB_MEZO_BRIDGE_URL env vars --- .env.example | 2 + data/mezoContracts.ts | 114 +++++++++++++++++++++++++++++++++++++++++ index.html | 2 +- src/chains/registry.ts | 22 +++++++- 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 data/mezoContracts.ts diff --git a/.env.example b/.env.example index 6a9964e..e2e2b27 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ VITE_WALLETCONNECT_PROJECT_ID=your-walletconnect-project-id # EDB Bridge # EDB_BRIDGE_URL — Full URL of the bridge server (e.g. https://your-droplet:5789) +# EDB_DEFAULT_BRIDGE_URL — Base/default bridge URL for non-Mezo chains (e.g. https://edb.hexkit.tech) +# EDB_MEZO_BRIDGE_URL — Mezo bridge URL (e.g. https://edb.hexkit.tech/mezo) # EDB_API_KEY — Secret key that the Vercel proxy injects into bridge requests # EDB_CORS_ALLOWED_ORIGINS — Comma-separated extra origins for the edb proxy (e.g. https://yourdomain.com) diff --git a/data/mezoContracts.ts b/data/mezoContracts.ts new file mode 100644 index 0000000..3d7a86e --- /dev/null +++ b/data/mezoContracts.ts @@ -0,0 +1,114 @@ +import type { Address } from "viem"; + +/** + * Mezo testnet contract registry (chain 31611). + * + * Addresses with `__DAY_0__` literal placeholders MUST be replaced before + * the relevant Mezo Lens flow can execute on-chain. The simulation + * infrastructure works even with placeholders — eth_simulateV1 will + * surface the resulting reverts as "leg would fail" warnings. + * + * Sources: + * - Mezo Docs: https://mezo.org/docs/users/resources/contracts-reference/ + * - Blockscout: https://api.explorer.test.mezo.org/api/v2/ + * - Day-0 smoke probe: scripts/mezo-day-0-smoke.sh + */ + +export const MEZO_TESTNET_CHAIN_ID = 31611 as const; + +const PLACEHOLDER: Address = "0x0000000000000000000000000000000000000000"; + +export const MEZO_CONTRACTS = { + // ── Tokens ────────────────────────────────────────────────────────────── + + /** Canonical MUSD (bound to BorrowerOperations). */ + MUSD: "0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503" as Address, + + /** Native BTC ERC-20 surface used by Mezo Pools; gas is still paid in BTC. */ + BTC: "0x7b7C000000000000000000000000000000000000" as Address, + + /** MEZO precompile — ERC-20 surface backed by the Cosmos SDK bank module. */ + MEZO: "0x7B7c000000000000000000000000000000000001" as Address, + + /** sMUSD savings vault — non-standard ERC-4626 interface; signature pulled at Day 0. */ + sMUSD: "0x6f461c68B2c5492C0F5CCEc5a264d692aA7A8e16" as Address, + + // ── CDP stack (Liquity fork — verified live on testnet) ───────────────── + + BorrowerOperations: "0xCdF7028ceAB81fA0C6971208e83fa7872994beE5" as Address, + TroveManager: "0xE47c80e8c23f6B4A1aE41c34837a0599D5D16bb0" as Address, + StabilityPool: "0x1CCA7E410eE41739792eA0A24e00349Dd247680e" as Address, + PriceFeed: "0x86bCF0841622a5dAC14A313a15f96A95421b9366" as Address, + HintHelpers: "0x4e4cBA3779d56386ED43631b4dCD6d8EacEcBCF6" as Address, + SortedTroves: "0x722E4D24FD6Ff8b0AC679450F3D91294607268fA" as Address, + + // ── Mezo Earn (Aerodrome ve(3,3) fork) ────────────────────────────────── + + /** veBTC — the base-weight ve token (locks BTC). Verified live. */ + veBTC: "0xB63fcCd03521Cf21907627bd7fA465C129479231" as Address, + + /** + * veMEZO — governance ERC-721 NFT (locks MEZO). + * Verified on Mezo testnet: `token()` returns the MEZO precompile, + * `symbol()` and `name()` both decode to "veMEZO". Blockscout token page: + * https://api.explorer.test.mezo.org/token/0xaCE816CA2bcc9b12C59799dcC5A959Fb9b98111b + */ + veMEZO: "0xaCE816CA2bcc9b12C59799dcC5A959Fb9b98111b" as Address, + + /** Voter — gauge directory + vote allocation. `Voter.ve` returns veBTC. */ + Voter: "0x72F8dd7F44fFa19E45955aa20A5486E8EB255738" as Address, + + // ── Mezo Pools (Velodrome fork) ───────────────────────────────────────── + + /** Pool factory — enumerable + token-pair lookup. */ + PoolFactory: "0x4947243CC818b627A5D06d14C4eCe7398A23Ce1A" as Address, + + /** Router — basic-pool Aerodrome-style router on Mezo testnet. */ + Router: "0x9a1ff7FE3a0F69959A3fBa1F1e5ee18e1A9CD7E9" as Address, + + /** MUSD/BTC vAMM pool — confirmed via factory lookup. */ + MUSD_BTC_Pool: "0xd16A5Df82120ED8D626a1a15232bFcE2366d6AA9" as Address, +} as const; + +/** + * Returns true if the given address is the zero-address sentinel (placeholder + * for Day-0 resolution). UI surfaces should detect this and show "coming + * after Day-0 smoke" copy instead of executing writes. + */ +export function isPlaceholderAddress(addr: Address): boolean { + return addr.toLowerCase() === PLACEHOLDER.toLowerCase(); +} + +export function isNativeBtcAddress(addr: Address): boolean { + const lower = addr.toLowerCase(); + return ( + lower === MEZO_CONTRACTS.BTC.toLowerCase() || + lower === PLACEHOLDER.toLowerCase() + ); +} + +export function toMezoPoolTokenAddress(addr: Address): Address { + return isNativeBtcAddress(addr) ? MEZO_CONTRACTS.BTC : addr; +} + +/** + * Non-canonical MUSD that other dApps may have deployed. We detect and warn + * if the user has a balance here — only `MEZO_CONTRACTS.MUSD` is supported. + */ +export const KNOWN_WRONG_MUSD = "0x637e22A1EBbca50EA2d34027c238317fD10003eB" as Address; + +// ── Useful constants ─────────────────────────────────────────────────────── + +export const MUSD_DECIMALS = 18 as const; +export const BTC_DECIMALS = 18 as const; // Mezo represents BTC at 18 decimals (1e18 wei = 1 BTC) +export const MEZO_DECIMALS = 18 as const; + +/** Liquity-style minimum net debt — borrower-side floor; gas comp adds 200. */ +export const MIN_NET_DEBT_MUSD = 1800n * 10n ** 18n; +export const MUSD_GAS_COMPENSATION = 200n * 10n ** 18n; + +/** Minimum total debt = MIN_NET_DEBT + MUSD_GAS_COMPENSATION. */ +export const MIN_TROVE_DEBT_MUSD = MIN_NET_DEBT_MUSD + MUSD_GAS_COMPENSATION; + +/** Minimum Collateral Ratio in basis points (110% = 11000). */ +export const MCR_BPS = 11000 as const; diff --git a/index.html b/index.html index 6746f42..ca52acf 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + diff --git a/src/chains/registry.ts b/src/chains/registry.ts index 7780501..d14942f 100644 --- a/src/chains/registry.ts +++ b/src/chains/registry.ts @@ -310,6 +310,22 @@ const EXPLORER_APIS: Record = { 421614: "https://arbitrum-sepolia.drpc.org", 11155420: "https://sepolia.optimism.io", 97: "https://bsc-testnet.drpc.org", + 31611: "https://rpc.test.mezo.org", + 31612: "https://mainnet.mezo.public.validationcloud.io", }; @@ -478,6 +496,7 @@ export const CHAIN_REGISTRY: Chain[] = [ makeChain(98866, "Plume", { name: "PLUME", symbol: "PLUME", decimals: 18 }), makeChain(167000, "Taiko", eth18()), makeChain(747474, "Katana", eth18()), + makeChain(31612, "Mezo", { name: "Bitcoin", symbol: "BTC", decimals: 18 }), // ── Testnets ── makeChain(11155111, "Ethereum Sepolia", { name: "Sepolia Ether", symbol: "ETH", decimals: 18 }), @@ -488,6 +507,7 @@ export const CHAIN_REGISTRY: Chain[] = [ makeChain(421614, "Arbitrum Sepolia", eth18()), makeChain(11155420, "Optimism Sepolia", eth18()), makeChain(97, "BNB Testnet", { name: "BNB", symbol: "tBNB", decimals: 18 }), + makeChain(31611, "Mezo Testnet", { name: "Bitcoin", symbol: "BTC", decimals: 18 }), ]; // ── Lookup helpers ─────────────────────────────────────────────────────────── @@ -497,7 +517,7 @@ const CHAIN_BY_ID = new Map(CHAIN_REGISTRY.map((c) => [c.id, c])); export const getChainById = (id: number): Chain | undefined => CHAIN_BY_ID.get(id); /** IDs of testnet chains */ -const TESTNET_IDS = new Set([11155111, 84532, 17000, 4202, 80002, 421614, 11155420, 97]); +const TESTNET_IDS = new Set([11155111, 84532, 17000, 4202, 80002, 421614, 11155420, 97, 31611]); export const isTestnet = (chainId: number): boolean => TESTNET_IDS.has(chainId); From 1b2148aadea9b21ad6978d763da1261f539a7c8a Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 21:11:08 +0100 Subject: [PATCH 03/18] feat: chain-aware EDB bridge routing The dev/Vercel proxy now inspects the chainId on every request (from the ?chainId query param or the JSON body) and forwards to a Mezo bridge for chain 31611/31612 vs the default bridge for everything else. Two new env vars let operators point each side at a different URL; without them, the proxy auto-derives the Mezo URL by appending /mezo to EDB_BRIDGE_URL. - New api/edbShared.ts module exports resolveEdbBridgeUrl(), extractChainIdFromRawJsonBody(), and ETHERSCAN key injection helpers - vite.config.ts dev proxy uses the shared helpers; the old static target is replaced with a per-request configure() that picks the bridge live - api/edb-proxy.ts (Vercel) mirrors the dev path so previews route the same way as production - vercel.json adds the /mezo Blockscout rewrite alongside the testnet one - DebugBridgeService and useDebugPrep pass the chainId through to the bridge calls --- api/edb-proxy.ts | 32 ++++- api/edbShared.ts | 125 ++++++++++++++++++ src/contexts/debug/useDebugPrep.ts | 5 +- src/services/DebugBridgeService.ts | 34 ++++- vercel.json | 16 +++ vite.config.ts | 205 ++++++++++++++++++++++++++--- 6 files changed, 384 insertions(+), 33 deletions(-) diff --git a/api/edb-proxy.ts b/api/edb-proxy.ts index cbcf8d9..f5883e9 100644 --- a/api/edb-proxy.ts +++ b/api/edb-proxy.ts @@ -1,5 +1,10 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { maybeInjectDefaultEtherscanKey } from "./edbShared.js"; +import { + appendEdbBridgeSubPath, + extractChainIdFromRawJsonBody, + maybeInjectDefaultEtherscanKey, + resolveEdbBridgeUrl, +} from "./edbShared.js"; export const config = { api: { bodyParser: false }, @@ -73,11 +78,14 @@ function getRawBody(req: VercelRequest): Promise { export default async function handler(req: VercelRequest, res: VercelResponse) { applyCors(req, res); - const bridgeUrl = process.env.EDB_BRIDGE_URL; + const configuredBridgeUrl = + process.env.EDB_BRIDGE_URL || + process.env.EDB_DEFAULT_BRIDGE_URL || + process.env.EDB_MEZO_BRIDGE_URL; const apiKey = process.env.EDB_API_KEY; const defaultEtherscanApiKey = process.env.ETHERSCAN_API_KEY; - if (!bridgeUrl) { + if (!configuredBridgeUrl) { return res.status(503).json({ error: "bridge_not_configured" }); } if (!apiKey) { @@ -124,8 +132,6 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } } - const target = `${bridgeUrl.replace(/\/+$/, "")}/${subPath}`; - // Build upstream headers (explicit allowlist — no client headers leak through) const upstreamHeaders: Record = { "X-API-Key": apiKey, @@ -142,6 +148,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { req.method !== "GET" && req.method !== "HEAD" ? await getRawBody(req) : undefined; + const queryChainId = Array.isArray(req.query?.chainId) + ? req.query.chainId[0] + : typeof req.query?.chainId === "string" + ? req.query.chainId + : undefined; + const parsedQueryChainId = queryChainId ? Number(queryChainId) : null; + const chainId = Number.isInteger(parsedQueryChainId) + ? parsedQueryChainId + : extractChainIdFromRawJsonBody(rawBody, req.headers["content-type"]); + const bridgeUrl = resolveEdbBridgeUrl(chainId, process.env, configuredBridgeUrl); + const target = appendEdbBridgeSubPath(bridgeUrl, subPath); const body = maybeInjectDefaultEtherscanKey( rawBody, req.headers["content-type"], @@ -152,14 +169,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { // Detect SSE path — use longer timeout, abort on client disconnect const isSSE = subPath.match(/debug\/prepare\/[^/]+\/events$/); const controller = new AbortController(); + let timer: ReturnType | null = null; if (isSSE) { // Abort upstream when client disconnects req.on("close", () => controller.abort()); } else { // Regular requests get a hard timeout - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - req.on("close", () => clearTimeout(timer)); + timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); } const upstream = await fetch(target, { @@ -169,6 +186,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { signal: controller.signal, redirect: "error", // never follow redirects — prevents key leaking to unexpected hosts }); + if (timer) clearTimeout(timer); // SSE streaming response const contentType = upstream.headers.get("content-type") || ""; diff --git a/api/edbShared.ts b/api/edbShared.ts index 931a887..8dffd9c 100644 --- a/api/edbShared.ts +++ b/api/edbShared.ts @@ -4,11 +4,56 @@ const BRIDGE_BOOTSTRAP_SUBPATHS = new Set([ "debug/start", ]); +const MEZO_CHAIN_IDS = new Set([31611, 31612]); + +export interface EdbBridgeEnv { + EDB_BRIDGE_URL?: string; + EDB_DEFAULT_BRIDGE_URL?: string; + EDB_MEZO_BRIDGE_URL?: string; +} + function normalizeEnvValue(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } +function stripTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +function withoutTrailingMezoPath(value: string): string { + try { + const url = new URL(value); + const parts = url.pathname.split("/").filter(Boolean); + if (parts[parts.length - 1]?.toLowerCase() === "mezo") { + parts.pop(); + url.pathname = parts.length > 0 ? `/${parts.join("/")}` : "/"; + } + url.search = ""; + url.hash = ""; + return stripTrailingSlash(url.toString()); + } catch { + return stripTrailingSlash(value.replace(/\/mezo\/?$/i, "")); + } +} + +function withTrailingMezoPath(value: string): string { + try { + const url = new URL(value); + const parts = url.pathname.split("/").filter(Boolean); + if (parts[parts.length - 1]?.toLowerCase() !== "mezo") { + parts.push("mezo"); + url.pathname = `/${parts.join("/")}`; + } + url.search = ""; + url.hash = ""; + return stripTrailingSlash(url.toString()); + } catch { + const stripped = stripTrailingSlash(value); + return /\/mezo$/i.test(stripped) ? stripped : `${stripped}/mezo`; + } +} + function isJsonContentType(contentType: string | string[] | undefined): boolean { if (Array.isArray(contentType)) { return contentType.some((value) => value.toLowerCase().includes("application/json")); @@ -16,6 +61,86 @@ function isJsonContentType(contentType: string | string[] | undefined): boolean return typeof contentType === "string" && contentType.toLowerCase().includes("application/json"); } +function coerceChainId(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : null; + } + return null; +} + +export function isMezoChainId(chainId: number | null | undefined): boolean { + return typeof chainId === "number" && MEZO_CHAIN_IDS.has(chainId); +} + +export function extractChainIdFromPayload(payload: unknown): number | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + + const obj = payload as Record; + const direct = coerceChainId(obj.chainId ?? obj.networkId); + if (direct !== null) return direct; + + const chain = obj.chain; + if (chain && typeof chain === "object" && !Array.isArray(chain)) { + const fromChain = coerceChainId((chain as Record).id); + if (fromChain !== null) return fromChain; + } + + const network = obj.network; + if (network && typeof network === "object" && !Array.isArray(network)) { + const fromNetwork = coerceChainId( + (network as Record).chainId ?? + (network as Record).id, + ); + if (fromNetwork !== null) return fromNetwork; + } + + return null; +} + +export function extractChainIdFromRawJsonBody( + body: Buffer | undefined, + contentType: string | string[] | undefined, +): number | null { + if (!body || !isJsonContentType(contentType)) return null; + try { + return extractChainIdFromPayload(JSON.parse(body.toString("utf8"))); + } catch { + return null; + } +} + +export function resolveEdbBridgeUrl( + chainId: number | null | undefined, + env: EdbBridgeEnv, + fallbackBridgeUrl: string, +): string { + const configuredBridge = + normalizeEnvValue(env.EDB_BRIDGE_URL) || fallbackBridgeUrl; + const defaultBridge = + normalizeEnvValue(env.EDB_DEFAULT_BRIDGE_URL) || + withoutTrailingMezoPath(configuredBridge); + const mezoBridge = + normalizeEnvValue(env.EDB_MEZO_BRIDGE_URL) || + withTrailingMezoPath(configuredBridge); + + return isMezoChainId(chainId) ? mezoBridge : defaultBridge; +} + +export function appendEdbBridgeSubPath( + bridgeUrl: string, + subPath: string, + search = "", +): string { + const cleanBridgeUrl = stripTrailingSlash(bridgeUrl); + const cleanSubPath = subPath.replace(/^\/+/, ""); + const path = cleanSubPath ? `/${cleanSubPath}` : ""; + return `${cleanBridgeUrl}${path}${search}`; +} + export function maybeInjectDefaultEtherscanKey( body: Buffer | undefined, contentType: string | string[] | undefined, diff --git a/src/contexts/debug/useDebugPrep.ts b/src/contexts/debug/useDebugPrep.ts index 463717b..b986260 100644 --- a/src/contexts/debug/useDebugPrep.ts +++ b/src/contexts/debug/useDebugPrep.ts @@ -97,6 +97,7 @@ export function useDebugPrep( const handleReady = (prepareId: string, data: PrepareReadyEvent) => { if (prepareIdRef.current !== prepareId) return; + debugBridgeService.rememberSessionChain(data.sessionId, params.chainId); setPrepState((prev) => ({ ...prev, @@ -200,7 +201,7 @@ export function useDebugPrep( if (prepareIdRef.current !== prepareId) return; try { - const status = await debugBridgeService.getPrepareStatus(prepareId); + const status = await debugBridgeService.getPrepareStatus(prepareId, params.chainId); applyPolledStatus(prepareId, status); if ( prepareIdRef.current !== prepareId || @@ -240,7 +241,7 @@ export function useDebugPrep( })); // Connect SSE for real-time progress - const es = debugBridgeService.connectPrepareEvents(prepareId); + const es = debugBridgeService.connectPrepareEvents(prepareId, params.chainId); eventSourceRef.current = es; es.addEventListener('stage', (event: MessageEvent) => { diff --git a/src/services/DebugBridgeService.ts b/src/services/DebugBridgeService.ts index 6efb0dc..4ba33f7 100644 --- a/src/services/DebugBridgeService.ts +++ b/src/services/DebugBridgeService.ts @@ -130,6 +130,7 @@ function serializeBreakpoint(breakpoint: GetBreakpointHitsRequest['breakpoints'] class DebugBridgeService { private storageValueCache = new Map(); + private sessionChainIds = new Map(); private putStorageCache(cacheKey: string, value: string): void { if (this.storageValueCache.has(cacheKey)) { @@ -155,14 +156,26 @@ class DebugBridgeService { } } + rememberSessionChain(sessionId: string, chainId: number): void { + if (sessionId && Number.isInteger(chainId)) { + this.sessionChainIds.set(sessionId, chainId); + } + } + /** * Make a raw RPC call to the debug session */ private async rpcCall(sessionId: string, method: string, params: unknown[] = []): Promise { + const chainId = this.sessionChainIds.get(sessionId); const response = await fetch(`${getBridgeUrl()}/debug/rpc`, { method: 'POST', headers: getBridgeHeaders(), - body: JSON.stringify({ sessionId, method, params }), + body: JSON.stringify({ + sessionId, + method, + params, + ...(chainId ? { chainId } : {}), + }), signal: AbortSignal.timeout(30_000), }); @@ -323,6 +336,7 @@ class DebugBridgeService { sourceFiles = data.sourceFiles || {}; if (!sessionId) return false; + this.rememberSessionChain(sessionId, request.chainId); const hasHookSnapshots = await this.sessionHasHookSnapshots(sessionId, snapshotCount); if (!hasHookSnapshots) { @@ -383,6 +397,7 @@ class DebugBridgeService { sourceFiles = simData.sourceFiles || {}; if (!sessionId) return false; + this.rememberSessionChain(sessionId, request.chainId); // Verify the /simulate session has hook snapshots (same as /debug/start path) const hasHookSnapshots = await this.sessionHasHookSnapshots(sessionId, snapshotCount); @@ -781,10 +796,14 @@ class DebugBridgeService { * End a debug session */ async endSession(request: EndDebugSessionRequest): Promise { + const chainId = this.sessionChainIds.get(request.sessionId); const response = await fetch(`${getBridgeUrl()}/debug/end`, { method: 'POST', headers: getBridgeHeaders(), - body: JSON.stringify(request), + body: JSON.stringify({ + ...request, + ...(chainId ? { chainId } : {}), + }), signal: AbortSignal.timeout(30_000), }); @@ -793,6 +812,7 @@ class DebugBridgeService { } this.clearStorageCacheForSession(request.sessionId); + this.sessionChainIds.delete(request.sessionId); return response.json(); } @@ -930,13 +950,15 @@ class DebugBridgeService { } /** Connect to SSE stream for debug preparation progress. */ - connectPrepareEvents(prepareId: string): EventSource { - return new EventSource(`${getBridgeUrl()}/debug/prepare/${prepareId}/events`); + connectPrepareEvents(prepareId: string, chainId?: number): EventSource { + const suffix = chainId ? `?chainId=${encodeURIComponent(String(chainId))}` : ''; + return new EventSource(`${getBridgeUrl()}/debug/prepare/${prepareId}/events${suffix}`); } /** Poll debug preparation status (fallback when SSE is unavailable). */ - async getPrepareStatus(prepareId: string): Promise { - const response = await fetch(`${getBridgeUrl()}/debug/prepare/${prepareId}`, { + async getPrepareStatus(prepareId: string, chainId?: number): Promise { + const suffix = chainId ? `?chainId=${encodeURIComponent(String(chainId))}` : ''; + const response = await fetch(`${getBridgeUrl()}/debug/prepare/${prepareId}${suffix}`, { headers: getBridgeHeaders(), signal: AbortSignal.timeout(30_000), }); diff --git a/vercel.json b/vercel.json index e73e03b..8dd4369 100644 --- a/vercel.json +++ b/vercel.json @@ -60,6 +60,22 @@ "source": "/api/gnosis-blockscout/:path*", "destination": "https://gnosis.blockscout.com/:path*" }, + { + "source": "/api/mezo-testnet-blockscout", + "destination": "https://api.explorer.test.mezo.org/api" + }, + { + "source": "/api/mezo-testnet-blockscout/:path*", + "destination": "https://api.explorer.test.mezo.org/api/:path*" + }, + { + "source": "/api/mezo-blockscout", + "destination": "https://api.explorer.mezo.org/api" + }, + { + "source": "/api/mezo-blockscout/:path*", + "destination": "https://api.explorer.mezo.org/api/:path*" + }, { "source": "/api/repo/:path*", "destination": "https://repo.sourcify.dev/:path*" diff --git a/vite.config.ts b/vite.config.ts index e0571f7..718a750 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,12 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import path from "path"; import { handleEtherscanLookup } from "./api/explorer/etherscanShared"; +import { + appendEdbBridgeSubPath, + extractChainIdFromRawJsonBody, + maybeInjectDefaultEtherscanKey, + resolveEdbBridgeUrl, +} from "./api/edbShared"; /** * Injects the EDB bridge origin into the CSP connect-src at build time. @@ -96,6 +102,161 @@ function devExplorerProxy(): Plugin { }; } +function edbBridgeProxyPlugin(envObj: Record): Plugin { + const MAX_BODY_BYTES = 50 * 1024 * 1024; + const FETCH_TIMEOUT_MS = 120_000; + const FALLBACK_BRIDGE_URL = "http://127.0.0.1:5789"; + + const readRawBody = (req: any): Promise => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + + req.on("data", (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > MAX_BODY_BYTES) { + req.destroy(new Error("body_too_large")); + reject(new Error("body_too_large")); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); + + return { + name: "edb-bridge-proxy", + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const requestUrl = new URL(req.url || "/", "http://localhost"); + if ( + requestUrl.pathname !== "/api/edb" && + !requestUrl.pathname.startsWith("/api/edb/") + ) { + next(); + return; + } + + if (req.method === "OPTIONS") { + res.statusCode = 204; + res.setHeader("cache-control", "no-store"); + res.end(); + return; + } + + const subPath = requestUrl.pathname + .replace(/^\/api\/edb\/?/, "") + .replace(/^\/+/, ""); + + try { + const rawBody = + req.method !== "GET" && req.method !== "HEAD" + ? await readRawBody(req) + : undefined; + const queryChainId = requestUrl.searchParams.get("chainId"); + const parsedQueryChainId = queryChainId ? Number(queryChainId) : null; + const chainId = Number.isInteger(parsedQueryChainId) + ? parsedQueryChainId + : extractChainIdFromRawJsonBody(rawBody, req.headers["content-type"]); + const bridgeUrl = resolveEdbBridgeUrl( + chainId, + envObj, + envObj.EDB_BRIDGE_URL || FALLBACK_BRIDGE_URL, + ); + const target = appendEdbBridgeSubPath( + bridgeUrl, + subPath, + requestUrl.search, + ); + const body = maybeInjectDefaultEtherscanKey( + rawBody, + req.headers["content-type"], + subPath, + envObj.ETHERSCAN_API_KEY, + ); + + const upstreamHeaders: Record = {}; + const apiKey = envObj.EDB_API_KEY || ""; + if (apiKey) upstreamHeaders["X-API-Key"] = apiKey; + const contentType = req.headers["content-type"]; + if (typeof contentType === "string") { + upstreamHeaders["Content-Type"] = contentType; + } + const accept = req.headers.accept; + if (typeof accept === "string") upstreamHeaders.Accept = accept; + const acceptEncoding = req.headers["accept-encoding"]; + if (typeof acceptEncoding === "string") { + upstreamHeaders["Accept-Encoding"] = acceptEncoding; + } + + const controller = new AbortController(); + const isSSE = /debug\/prepare\/[^/]+\/events$/.test(subPath); + let timer: ReturnType | null = null; + if (isSSE) { + req.on("close", () => controller.abort()); + } else { + timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + } + + const upstream = await fetch(target, { + method: req.method || "GET", + headers: upstreamHeaders, + body: + req.method === "GET" || req.method === "HEAD" + ? undefined + : body, + signal: controller.signal, + redirect: "error", + }); + + if (timer) clearTimeout(timer); + + res.statusCode = upstream.status; + upstream.headers.forEach((value, key) => { + const lowerKey = key.toLowerCase(); + if (lowerKey !== "content-encoding" && lowerKey !== "content-length") { + res.setHeader(key, value); + } + }); + + if ( + upstream.headers.get("content-type")?.includes("text/event-stream") && + upstream.body + ) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + res.write(decoder.decode(value, { stream: true })); + } + } finally { + reader.cancel().catch(() => {}); + res.end(); + } + return; + } + + const responseBody = Buffer.from(await upstream.arrayBuffer()); + res.end(responseBody); + } catch (err) { + const isAbort = err instanceof Error && err.name === "AbortError"; + res.statusCode = isAbort ? 504 : 502; + res.setHeader("cache-control", "no-store"); + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ error: isAbort ? "bridge_timeout" : "bridge_unreachable" })); + } + }); + }, + }; +} + // ── Gemini AI Studio LLM proxy (dev server) ──────────────────────────────── function llmProxyPlugin(envObj: Record): Plugin { @@ -151,6 +312,7 @@ export default defineConfig(({ mode }) => { tailwindcss(), injectBridgeCsp(), devExplorerProxy(), + edbBridgeProxyPlugin(env), llmProxyPlugin(env), ], esbuild: { @@ -183,7 +345,7 @@ export default defineConfig(({ mode }) => { }, resolve: { alias: { - buffer: "buffer", + buffer: "buffer/", "@": path.resolve(__dirname, "./src"), }, }, @@ -196,25 +358,18 @@ export default defineConfig(({ mode }) => { watch: { usePolling: false, interval: 100, - ignored: ["**/node_modules/**", "**/.git/**", "**/dist/**"], + atomic: false, + ignored: [ + "**/node_modules/**", + "**/.git/**", + "**/dist/**", + "**/target/**", + "**/edb/**", + "**/starknet-sim/**", + "**/*.tmp.*", + ], }, proxy: { - // Proxy for EDB bridge (strips /api/edb prefix, forwards to bridge) - // Reads EDB_BRIDGE_URL from .env; falls back to localhost for local bridge dev. - // Injects X-API-Key server-side so the browser never sees the secret — - // mirrors api/edb-proxy.ts behavior for local dev. - "/api/edb": { - target: env.EDB_BRIDGE_URL || "http://127.0.0.1:5789", - changeOrigin: true, - secure: true, - rewrite: (path) => path.replace(/^\/api\/edb/, ""), - configure: (proxy) => { - const apiKey = env.EDB_API_KEY || ""; - proxy.on("proxyReq", (proxyReq) => { - if (apiKey) proxyReq.setHeader("X-API-Key", apiKey); - }); - }, - }, // Proxy for Sourcify Repository API (must be BEFORE the general /api/sourcify) // repo.sourcify.dev now 307-redirects to sourcify.dev/server/repository, // so target the new location directly to avoid redirect/CORS issues. @@ -309,6 +464,20 @@ export default defineConfig(({ mode }) => { rewrite: (path) => path.replace(/^\/api\/gnosis-blockscout/, "/api"), }, + "/api/mezo-testnet-blockscout": { + target: "https://api.explorer.test.mezo.org", + changeOrigin: true, + secure: true, + rewrite: (path) => + path.replace(/^\/api\/mezo-testnet-blockscout/, "/api"), + }, + "/api/mezo-blockscout": { + target: "https://api.explorer.mezo.org", + changeOrigin: true, + secure: true, + rewrite: (path) => + path.replace(/^\/api\/mezo-blockscout/, "/api"), + }, // Proxy for LI.FI Earn Data API (API key now mandatory — same key as Composer) "/api/lifi-earn": { target: "https://earn.li.fi", From 632f87c0a8e70b4809d7ad150a27b41e7332219f Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 21:11:56 +0100 Subject: [PATCH 04/18] feat: add Mezo Lens integration (Stack, Borrow, Save, Lock, Swap, Liquidity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end integration for Mezo Testnet that lets a user preview every leg of a Mezo position before signing. Six tabs share a common workbench: input + atomic-bundle eth_simulateV1 preview + step-by-step pipeline. Highlights: - sim/ — eth_simulateV1 client with per-leg gas budgeting (5M for openTrove, 2M for veMEZO.createLock and Router.addLiquidity, 1M+ default split for the rest); bundle builders for each tab; viewcall encoders/decoders; unified SimulationResult shape with trove / swap / liquidity / veMEZO outcome fields - pipeline/ — wagmi-backed execution after sim succeeds; per-leg status timeline with retry; legHandlers translate MezoLegSpec to wagmi writes - preview/ — DepositReceiveCards / DecodedLegList / TokensMovedPanel; PreviewPanel composes them and renders the atomic-flow summary - tabs/ — six tabs, each fronting one bundle builder. Common UX: AssetInput with `intent="deposit"|"receive"`, pool / slippage controls, pre-sim ICR validation, post-sim "you'll deposit / you'll receive" cards - abi/ — minimal ABI fragments for MUSD, sMUSD, BorrowerOperations, TroveManager, PriceFeed, HintHelpers, SortedTroves, VotingEscrow, Voter, Gauge, Pool, Router (4-field Aerodrome routes), PoolFactory - hooks/ — useFindPool, useReserves, usePriceFeed via wagmi - components/ — AssetInput, AssetIcon, TabBar, MezoTopBar, WorkbenchBody, FlowRibbon, SectionEyebrow, Term (glossary popovers) - ChainGate gates the whole page on wallet + chain 31611 - copy.ts holds every user-facing string (no jargon) - MezoLensPage routes between tabs and renders PositionsSidebar + HonestyFooter alongside --- .../integrations/mezo/ChainGate.tsx | 64 ++ .../integrations/mezo/HonestyFooter.tsx | 19 + .../integrations/mezo/MezoLensPage.tsx | 64 ++ .../integrations/mezo/PositionsSidebar.tsx | 165 +++++ src/components/integrations/mezo/TabBar.tsx | 98 +++ src/components/integrations/mezo/abi/index.ts | 578 ++++++++++++++++++ .../mezo/components/AssetIcon.tsx | 195 ++++++ .../mezo/components/AssetInput.tsx | 162 +++++ .../mezo/components/FlowRibbon.tsx | 82 +++ .../mezo/components/MezoTopBar.tsx | 48 ++ .../mezo/components/SectionEyebrow.tsx | 42 ++ .../mezo/components/SideRailNav.tsx | 206 +++++++ .../integrations/mezo/components/Term.tsx | 47 ++ .../mezo/components/WorkbenchBody.tsx | 69 +++ src/components/integrations/mezo/constants.ts | 16 + src/components/integrations/mezo/copy.ts | 91 +++ src/components/integrations/mezo/glossary.ts | 120 ++++ .../integrations/mezo/hooks/useFindPool.ts | 176 ++++++ .../integrations/mezo/hooks/usePriceFeed.ts | 21 + .../integrations/mezo/hooks/useReserves.ts | 1 + src/components/integrations/mezo/index.ts | 23 + .../mezo/pipeline/MezoLegTimeline.tsx | 81 +++ .../integrations/mezo/pipeline/legHandlers.ts | 196 ++++++ .../integrations/mezo/pipeline/mezoLegs.ts | 126 ++++ .../mezo/pipeline/useMezoLegPipeline.ts | 105 ++++ .../mezo/preview/DecodedLegList.tsx | 95 +++ .../mezo/preview/DepositReceiveCards.tsx | 257 ++++++++ .../mezo/preview/PreviewPanel.tsx | 160 +++++ .../integrations/mezo/sim/buildCalls.ts | 217 +++++++ .../integrations/mezo/sim/bundles/borrow.ts | 40 ++ .../mezo/sim/bundles/liquidity.ts | 123 ++++ .../integrations/mezo/sim/bundles/lock.ts | 40 ++ .../integrations/mezo/sim/bundles/save.ts | 39 ++ .../integrations/mezo/sim/bundles/stack.ts | 80 +++ .../integrations/mezo/sim/bundles/swap.ts | 97 +++ .../integrations/mezo/sim/decodeResults.ts | 414 +++++++++++++ .../integrations/mezo/sim/ethSimulateV1.ts | 143 +++++ src/components/integrations/mezo/sim/types.ts | 183 ++++++ .../mezo/sim/useMezoBundleSimulation.ts | 414 +++++++++++++ src/components/integrations/mezo/sim/views.ts | 183 ++++++ .../integrations/mezo/tabs/BorrowTab.tsx | 236 +++++++ .../integrations/mezo/tabs/LiquidityTab.tsx | 495 +++++++++++++++ .../integrations/mezo/tabs/LockTab.tsx | 287 +++++++++ .../integrations/mezo/tabs/SaveTab.tsx | 190 ++++++ .../integrations/mezo/tabs/StackTab.tsx | 438 +++++++++++++ .../integrations/mezo/tabs/SwapTab.tsx | 460 ++++++++++++++ 46 files changed, 7386 insertions(+) create mode 100644 src/components/integrations/mezo/ChainGate.tsx create mode 100644 src/components/integrations/mezo/HonestyFooter.tsx create mode 100644 src/components/integrations/mezo/MezoLensPage.tsx create mode 100644 src/components/integrations/mezo/PositionsSidebar.tsx create mode 100644 src/components/integrations/mezo/TabBar.tsx create mode 100644 src/components/integrations/mezo/abi/index.ts create mode 100644 src/components/integrations/mezo/components/AssetIcon.tsx create mode 100644 src/components/integrations/mezo/components/AssetInput.tsx create mode 100644 src/components/integrations/mezo/components/FlowRibbon.tsx create mode 100644 src/components/integrations/mezo/components/MezoTopBar.tsx create mode 100644 src/components/integrations/mezo/components/SectionEyebrow.tsx create mode 100644 src/components/integrations/mezo/components/SideRailNav.tsx create mode 100644 src/components/integrations/mezo/components/Term.tsx create mode 100644 src/components/integrations/mezo/components/WorkbenchBody.tsx create mode 100644 src/components/integrations/mezo/constants.ts create mode 100644 src/components/integrations/mezo/copy.ts create mode 100644 src/components/integrations/mezo/glossary.ts create mode 100644 src/components/integrations/mezo/hooks/useFindPool.ts create mode 100644 src/components/integrations/mezo/hooks/usePriceFeed.ts create mode 100644 src/components/integrations/mezo/hooks/useReserves.ts create mode 100644 src/components/integrations/mezo/index.ts create mode 100644 src/components/integrations/mezo/pipeline/MezoLegTimeline.tsx create mode 100644 src/components/integrations/mezo/pipeline/legHandlers.ts create mode 100644 src/components/integrations/mezo/pipeline/mezoLegs.ts create mode 100644 src/components/integrations/mezo/pipeline/useMezoLegPipeline.ts create mode 100644 src/components/integrations/mezo/preview/DecodedLegList.tsx create mode 100644 src/components/integrations/mezo/preview/DepositReceiveCards.tsx create mode 100644 src/components/integrations/mezo/preview/PreviewPanel.tsx create mode 100644 src/components/integrations/mezo/sim/buildCalls.ts create mode 100644 src/components/integrations/mezo/sim/bundles/borrow.ts create mode 100644 src/components/integrations/mezo/sim/bundles/liquidity.ts create mode 100644 src/components/integrations/mezo/sim/bundles/lock.ts create mode 100644 src/components/integrations/mezo/sim/bundles/save.ts create mode 100644 src/components/integrations/mezo/sim/bundles/stack.ts create mode 100644 src/components/integrations/mezo/sim/bundles/swap.ts create mode 100644 src/components/integrations/mezo/sim/decodeResults.ts create mode 100644 src/components/integrations/mezo/sim/ethSimulateV1.ts create mode 100644 src/components/integrations/mezo/sim/types.ts create mode 100644 src/components/integrations/mezo/sim/useMezoBundleSimulation.ts create mode 100644 src/components/integrations/mezo/sim/views.ts create mode 100644 src/components/integrations/mezo/tabs/BorrowTab.tsx create mode 100644 src/components/integrations/mezo/tabs/LiquidityTab.tsx create mode 100644 src/components/integrations/mezo/tabs/LockTab.tsx create mode 100644 src/components/integrations/mezo/tabs/SaveTab.tsx create mode 100644 src/components/integrations/mezo/tabs/StackTab.tsx create mode 100644 src/components/integrations/mezo/tabs/SwapTab.tsx diff --git a/src/components/integrations/mezo/ChainGate.tsx b/src/components/integrations/mezo/ChainGate.tsx new file mode 100644 index 0000000..67e92b2 --- /dev/null +++ b/src/components/integrations/mezo/ChainGate.tsx @@ -0,0 +1,64 @@ +import { useAccount, useSwitchChain } from "wagmi"; +import { Button } from "@/components/ui/button"; +import { Wallet, WarningCircle } from "@phosphor-icons/react"; +import { MEZO_TESTNET_CHAIN_ID, MEZO_FAUCET_URL } from "./constants"; +import { MEZO_LENS_COPY } from "./copy"; + +interface ChainGateProps { + children: React.ReactNode; +} + +export function ChainGate({ children }: ChainGateProps) { + const { isConnected, chainId } = useAccount(); + const { switchChain, isPending: isSwitching } = useSwitchChain(); + + if (!isConnected) { + return ( +
+
+ + + +

+ {MEZO_LENS_COPY.emptyStateConnectWallet} +

+
+
+ ); + } + + if (chainId !== MEZO_TESTNET_CHAIN_ID) { + return ( +
+
+ + + +

+ {MEZO_LENS_COPY.emptyStateWrongChain} +

+
+ + + {MEZO_LENS_COPY.openFaucetCta} ↗ + +
+
+
+ ); + } + + return <>{children}; +} diff --git a/src/components/integrations/mezo/HonestyFooter.tsx b/src/components/integrations/mezo/HonestyFooter.tsx new file mode 100644 index 0000000..d825f4f --- /dev/null +++ b/src/components/integrations/mezo/HonestyFooter.tsx @@ -0,0 +1,19 @@ +import { Eye } from "@phosphor-icons/react"; +import { MEZO_LENS_COPY } from "./copy"; + +export function HonestyFooter() { + return ( +
+ +

+ + Honesty · + {" "} + {MEZO_LENS_COPY.honestyFooter} +

+
+ ); +} diff --git a/src/components/integrations/mezo/MezoLensPage.tsx b/src/components/integrations/mezo/MezoLensPage.tsx new file mode 100644 index 0000000..725d37e --- /dev/null +++ b/src/components/integrations/mezo/MezoLensPage.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { ChainGate } from "./ChainGate"; +import { type MezoTabId } from "./TabBar"; +import { StackTab } from "./tabs/StackTab"; +import { SwapTab } from "./tabs/SwapTab"; +import { LiquidityTab } from "./tabs/LiquidityTab"; +import { LockTab } from "./tabs/LockTab"; +import { SaveTab } from "./tabs/SaveTab"; +import { BorrowTab } from "./tabs/BorrowTab"; +import { MEZO_LENS_COPY } from "./copy"; +import { MezoTopBar } from "./components/MezoTopBar"; +import { SideRailNav } from "./components/SideRailNav"; + +function TabBody({ tabId }: { tabId: MezoTabId }) { + if (tabId === "stack") return ; + if (tabId === "borrow") return ; + if (tabId === "swap") return ; + if (tabId === "save") return ; + if (tabId === "liquidity") return ; + if (tabId === "lock") return ; + return null; +} + +export default function MezoLensPage() { + const [activeTab, setActiveTab] = useState("stack"); + + return ( +
+
+
+ +
+

+ {MEZO_LENS_COPY.pageTitle} +

+

+ {MEZO_LENS_COPY.pageSubtitle} +

+
+
+
+ +
+ + + +
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/src/components/integrations/mezo/PositionsSidebar.tsx b/src/components/integrations/mezo/PositionsSidebar.tsx new file mode 100644 index 0000000..4ec71b0 --- /dev/null +++ b/src/components/integrations/mezo/PositionsSidebar.tsx @@ -0,0 +1,165 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Warning } from "@phosphor-icons/react"; +import { useAccount, useBalance, useReadContract } from "wagmi"; +import { formatUnits, type Address } from "viem"; +import { + KNOWN_WRONG_MUSD, + MEZO_CONTRACTS, +} from "../../../../data/mezoContracts"; +import { MEZO_ABIS } from "./abi"; +import { MEZO_TESTNET_CHAIN_ID } from "./constants"; +import { MEZO_LENS_COPY } from "./copy"; +import { SectionEyebrow } from "./components/SectionEyebrow"; + +function fmt(value: bigint | undefined, decimals = 18, precision = 4): string { + if (value === undefined) return "—"; + const n = Number(formatUnits(value, decimals)); + if (n === 0) return "0.00"; + return n.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: precision, + }); +} + +const TOKEN_ACCENT: Record = { + BTC: "bg-amber-400/80", + MUSD: "bg-emerald-400/80", + sMUSD: "bg-emerald-300/80", + MEZO: "bg-pink-400/80", +}; + +export function PositionsSidebar() { + const { address, isConnected, chainId } = useAccount(); + const onMezo = isConnected && chainId === MEZO_TESTNET_CHAIN_ID; + + const btc = useBalance({ + address: onMezo ? (address as Address) : undefined, + chainId: MEZO_TESTNET_CHAIN_ID, + query: { enabled: onMezo }, + }); + + const canonicalMusd = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address as Address] : undefined, + query: { enabled: onMezo }, + }); + + const wrongMusd = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: KNOWN_WRONG_MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address as Address] : undefined, + query: { enabled: onMezo, refetchInterval: 30_000 }, + }); + const wrongMusdBalance = (wrongMusd.data as bigint | undefined) ?? 0n; + const hasWrongMusd = wrongMusdBalance > 0n; + + const walletStatus = onMezo ? "live" : "off"; + + const btcVal = btc.data?.value; + const musdVal = canonicalMusd.data as bigint | undefined; + + return ( + + ); +} + +function TokenRow({ + symbol, + value, + precision = 4, + placeholder, +}: { + symbol: string; + value: bigint | undefined; + precision?: number; + placeholder?: boolean; +}) { + const isEmpty = placeholder || value === undefined; + return ( +
+
+ + + {symbol} + +
+
+ {isEmpty ? "—" : fmt(value, 18, precision)} +
+
+ ); +} diff --git a/src/components/integrations/mezo/TabBar.tsx b/src/components/integrations/mezo/TabBar.tsx new file mode 100644 index 0000000..f4642fc --- /dev/null +++ b/src/components/integrations/mezo/TabBar.tsx @@ -0,0 +1,98 @@ +import type { Icon } from "@phosphor-icons/react"; +import { + Stack as StackIcon, + Vault as VaultIcon, + ArrowsLeftRight as SwapIcon, + PiggyBank as SaveIcon, + CirclesThreePlus as LiquidityIcon, + Lock as LockIcon, +} from "@phosphor-icons/react"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { MEZO_LENS_COPY } from "./copy"; +import type { GlossaryKey } from "./glossary"; + +interface MezoTabSpec { + id: "stack" | "borrow" | "swap" | "save" | "liquidity" | "lock"; + label: string; + icon: Icon; + glossaryKey: GlossaryKey; +} + +export const MEZO_TABS: readonly MezoTabSpec[] = [ + { + id: "stack", + label: MEZO_LENS_COPY.tabs.stack.label, + icon: StackIcon, + glossaryKey: "stack", + }, + { + id: "borrow", + label: MEZO_LENS_COPY.tabs.borrow.label, + icon: VaultIcon, + glossaryKey: "borrow", + }, + { + id: "swap", + label: MEZO_LENS_COPY.tabs.swap.label, + icon: SwapIcon, + glossaryKey: "swap", + }, + { + id: "save", + label: MEZO_LENS_COPY.tabs.save.label, + icon: SaveIcon, + glossaryKey: "save", + }, + { + id: "liquidity", + label: MEZO_LENS_COPY.tabs.liquidity.label, + icon: LiquidityIcon, + glossaryKey: "liquidity", + }, + { + id: "lock", + label: MEZO_LENS_COPY.tabs.lock.label, + icon: LockIcon, + glossaryKey: "lock", + }, +] as const; + +export type MezoTabId = (typeof MEZO_TABS)[number]["id"]; + +interface TabBarProps { + active: MezoTabId; + onChange: (id: MezoTabId) => void; +} + +/** + * Legacy horizontal segmented-pill tab bar — kept for environments outside the + * Workbench shell (the Workbench layout uses SideRailNav instead). + */ +export function TabBar({ active, onChange }: TabBarProps) { + return ( + onChange(v as MezoTabId)} + className="w-full" + > + + {MEZO_TABS.map((tab) => { + const TabIcon = tab.icon; + return ( + + + {tab.label} + + ); + })} + + + ); +} diff --git a/src/components/integrations/mezo/abi/index.ts b/src/components/integrations/mezo/abi/index.ts new file mode 100644 index 0000000..75266ab --- /dev/null +++ b/src/components/integrations/mezo/abi/index.ts @@ -0,0 +1,578 @@ +/** + * Minimal ABI fragments covering every selector Mezo Lens encodes. + * + * Signatures sourced from: + * - MUSD source: github.com/mezo-org/musd/solidity/contracts/ + * - Tigris source: github.com/mezo-org/tigris/solidity/contracts/ (archived) + * - Mezo docs: mezo.org/docs/developers/musd/ + * - Day-0 smoke verification (scripts/mezo-day-0-smoke.sh) + */ + +const ERC20_ABI = [ + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ name: "owner", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "allowance", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "approve", + stateMutability: "nonpayable", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "function", + name: "transfer", + stateMutability: "nonpayable", + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + type: "function", + name: "totalSupply", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "symbol", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "string" }], + }, + { + type: "function", + name: "decimals", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint8" }], + }, +] as const; + +const BORROWER_OPERATIONS_ABI = [ + { + type: "function", + name: "openTrove", + stateMutability: "payable", + inputs: [ + { name: "_debtAmount", type: "uint256" }, + { name: "_upperHint", type: "address" }, + { name: "_lowerHint", type: "address" }, + ], + outputs: [], + }, + { + type: "function", + name: "adjustTrove", + stateMutability: "payable", + inputs: [ + { name: "_collWithdrawal", type: "uint256" }, + { name: "_debtChange", type: "uint256" }, + { name: "_isDebtIncrease", type: "bool" }, + { name: "_upperHint", type: "address" }, + { name: "_lowerHint", type: "address" }, + ], + outputs: [], + }, + { + type: "function", + name: "repayMUSD", + stateMutability: "nonpayable", + inputs: [ + { name: "_amount", type: "uint256" }, + { name: "_upperHint", type: "address" }, + { name: "_lowerHint", type: "address" }, + ], + outputs: [], + }, + { + type: "function", + name: "closeTrove", + stateMutability: "nonpayable", + inputs: [], + outputs: [], + }, +] as const; + +// Troves struct order matches MUSD source. +const TROVE_MANAGER_ABI = [ + { + type: "function", + name: "Troves", + stateMutability: "view", + inputs: [{ name: "_borrower", type: "address" }], + outputs: [ + { name: "coll", type: "uint256" }, + { name: "principal", type: "uint256" }, + { name: "interestOwed", type: "uint256" }, + { name: "stake", type: "uint256" }, + { name: "status", type: "uint8" }, + { name: "interestRate", type: "uint256" }, + { name: "lastInterestUpdateTime", type: "uint256" }, + { name: "maxBorrowingCapacity", type: "uint256" }, + { name: "arrayIndex", type: "uint256" }, + ], + }, + { + type: "function", + name: "getCurrentICR", + stateMutability: "view", + inputs: [ + { name: "_borrower", type: "address" }, + { name: "_price", type: "uint256" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "getTroveOwnersCount", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "getTCR", + stateMutability: "view", + inputs: [{ name: "_price", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +// fetchPrice is declared `nonpayable` on-chain (it updates a cached price) +// but `eth_call` returns the current value without persisting. Declared as +// `view` here so wagmi's `useReadContract` treats it as a read — the +// selector is identical either way. +const PRICE_FEED_ABI = [ + { + type: "function", + name: "fetchPrice", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +const HINT_HELPERS_ABI = [ + { + type: "function", + name: "getApproxHint", + stateMutability: "view", + inputs: [ + { name: "_CR", type: "uint256" }, + { name: "_numTrials", type: "uint256" }, + { name: "_inputRandomSeed", type: "uint256" }, + ], + outputs: [ + { name: "hintAddress", type: "address" }, + { name: "diff", type: "uint256" }, + { name: "latestRandomSeed", type: "uint256" }, + ], + }, +] as const; + +const SORTED_TROVES_ABI = [ + { + type: "function", + name: "findInsertPosition", + stateMutability: "view", + inputs: [ + { name: "_ICR", type: "uint256" }, + { name: "_prevId", type: "address" }, + { name: "_nextId", type: "address" }, + ], + outputs: [ + { name: "", type: "address" }, + { name: "", type: "address" }, + ], + }, + { + type: "function", + name: "getSize", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "getFirst", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "address" }], + }, + { + type: "function", + name: "getLast", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "address" }], + }, +] as const; + +// sMUSD has a non-standard interface; Day-0 smoke confirms the deposit +// signature. Fallback assumes a minimal `deposit(uint256)` shape. +const SMUSD_ABI = [ + ...ERC20_ABI, + { + type: "function", + name: "deposit", + stateMutability: "nonpayable", + inputs: [{ name: "amount", type: "uint256" }], + outputs: [], + }, + { + type: "function", + name: "withdraw", + stateMutability: "nonpayable", + inputs: [{ name: "amount", type: "uint256" }], + outputs: [], + }, +] as const; + +// veMEZO uses Tigris camelCase signatures (createLock, not Curve's create_lock). +const VOTING_ESCROW_ABI = [ + { + type: "function", + name: "createLock", + stateMutability: "nonpayable", + inputs: [ + { name: "value", type: "uint256" }, + { name: "lockDuration", type: "uint256" }, + ], + outputs: [{ name: "tokenId", type: "uint256" }], + }, + { + type: "function", + name: "increaseAmount", + stateMutability: "nonpayable", + inputs: [ + { name: "tokenId", type: "uint256" }, + { name: "value", type: "uint256" }, + ], + outputs: [], + }, + { + type: "function", + name: "increaseUnlockTime", + stateMutability: "nonpayable", + inputs: [ + { name: "tokenId", type: "uint256" }, + { name: "lockDuration", type: "uint256" }, + ], + outputs: [], + }, + { + type: "function", + name: "locked", + stateMutability: "view", + inputs: [{ name: "tokenId", type: "uint256" }], + outputs: [ + { + name: "", + type: "tuple", + components: [ + { name: "amount", type: "int128" }, + { name: "end", type: "uint256" }, + ], + }, + ], + }, + { + type: "function", + name: "balanceOfNFT", + stateMutability: "view", + inputs: [{ name: "tokenId", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ name: "owner", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "tokenOfOwnerByIndex", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "index", type: "uint256" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +const VOTER_ABI = [ + { + type: "function", + name: "gauges", + stateMutability: "view", + inputs: [{ name: "pool", type: "address" }], + outputs: [{ name: "", type: "address" }], + }, + { + type: "function", + name: "ve", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "address" }], + }, +] as const; + +const GAUGE_ABI = [ + { + type: "function", + name: "deposit", + stateMutability: "nonpayable", + inputs: [{ name: "amount", type: "uint256" }], + outputs: [], + }, + { + type: "function", + name: "withdraw", + stateMutability: "nonpayable", + inputs: [{ name: "amount", type: "uint256" }], + outputs: [], + }, + { + type: "function", + name: "getReward", + stateMutability: "nonpayable", + inputs: [{ name: "account", type: "address" }], + outputs: [], + }, + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "earned", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + type: "function", + name: "rewardToken", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "address" }], + }, + { + type: "function", + name: "rewardRate", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +// Mezo Pool — Velodrome fork. +const MEZO_POOL_ABI = [ + ...ERC20_ABI, + { + type: "function", + name: "getReserves", + stateMutability: "view", + inputs: [], + outputs: [ + { name: "_reserve0", type: "uint256" }, + { name: "_reserve1", type: "uint256" }, + { name: "_blockTimestampLast", type: "uint256" }, + ], + }, + { + type: "function", + name: "token0", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "address" }], + }, + { + type: "function", + name: "token1", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "address" }], + }, + { + type: "function", + name: "stable", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "bool" }], + }, +] as const; + +const POOL_FACTORY_ABI = [ + { + type: "function", + name: "getPool", + stateMutability: "view", + inputs: [ + { name: "tokenA", type: "address" }, + { name: "tokenB", type: "address" }, + { name: "stable", type: "bool" }, + ], + outputs: [{ name: "pool", type: "address" }], + }, +] as const; + +// Router — Velodrome fork. +const ROUTER_ROUTE_COMPONENTS = [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "stable", type: "bool" }, + { name: "factory", type: "address" }, +] as const; + +const ROUTER_ABI = [ + { + type: "function", + name: "getAmountsOut", + stateMutability: "view", + inputs: [ + { name: "amountIn", type: "uint256" }, + { + name: "routes", + type: "tuple[]", + components: ROUTER_ROUTE_COMPONENTS, + }, + ], + outputs: [{ name: "amounts", type: "uint256[]" }], + }, + { + type: "function", + name: "swapExactTokensForTokens", + stateMutability: "nonpayable", + inputs: [ + { name: "amountIn", type: "uint256" }, + { name: "amountOutMin", type: "uint256" }, + { + name: "routes", + type: "tuple[]", + components: ROUTER_ROUTE_COMPONENTS, + }, + { name: "to", type: "address" }, + { name: "deadline", type: "uint256" }, + ], + outputs: [{ name: "amounts", type: "uint256[]" }], + }, + { + type: "function", + name: "swapExactETHForTokens", + stateMutability: "payable", + inputs: [ + { name: "amountOutMin", type: "uint256" }, + { + name: "routes", + type: "tuple[]", + components: ROUTER_ROUTE_COMPONENTS, + }, + { name: "to", type: "address" }, + { name: "deadline", type: "uint256" }, + ], + outputs: [{ name: "amounts", type: "uint256[]" }], + }, + { + type: "function", + name: "swapExactTokensForETH", + stateMutability: "nonpayable", + inputs: [ + { name: "amountIn", type: "uint256" }, + { name: "amountOutMin", type: "uint256" }, + { + name: "routes", + type: "tuple[]", + components: ROUTER_ROUTE_COMPONENTS, + }, + { name: "to", type: "address" }, + { name: "deadline", type: "uint256" }, + ], + outputs: [{ name: "amounts", type: "uint256[]" }], + }, + { + type: "function", + name: "addLiquidity", + stateMutability: "nonpayable", + inputs: [ + { name: "tokenA", type: "address" }, + { name: "tokenB", type: "address" }, + { name: "stable", type: "bool" }, + { name: "amountADesired", type: "uint256" }, + { name: "amountBDesired", type: "uint256" }, + { name: "amountAMin", type: "uint256" }, + { name: "amountBMin", type: "uint256" }, + { name: "to", type: "address" }, + { name: "deadline", type: "uint256" }, + ], + outputs: [ + { name: "amountA", type: "uint256" }, + { name: "amountB", type: "uint256" }, + { name: "liquidity", type: "uint256" }, + ], + }, + { + type: "function", + name: "addLiquidityETH", + stateMutability: "payable", + inputs: [ + { name: "token", type: "address" }, + { name: "stable", type: "bool" }, + { name: "amountTokenDesired", type: "uint256" }, + { name: "amountTokenMin", type: "uint256" }, + { name: "amountETHMin", type: "uint256" }, + { name: "to", type: "address" }, + { name: "deadline", type: "uint256" }, + ], + outputs: [ + { name: "amountToken", type: "uint256" }, + { name: "amountETH", type: "uint256" }, + { name: "liquidity", type: "uint256" }, + ], + }, +] as const; + +export const MEZO_ABIS = { + MUSD: ERC20_ABI, + MEZO: ERC20_ABI, + sMUSD: SMUSD_ABI, + BorrowerOperations: BORROWER_OPERATIONS_ABI, + TroveManager: TROVE_MANAGER_ABI, + PriceFeed: PRICE_FEED_ABI, + HintHelpers: HINT_HELPERS_ABI, + SortedTroves: SORTED_TROVES_ABI, + VotingEscrow: VOTING_ESCROW_ABI, + Voter: VOTER_ABI, + Gauge: GAUGE_ABI, + MezoPool: MEZO_POOL_ABI, + PoolFactory: POOL_FACTORY_ABI, + Router: ROUTER_ABI, +} as const; + +export type MezoContractName = keyof typeof MEZO_ABIS; diff --git a/src/components/integrations/mezo/components/AssetIcon.tsx b/src/components/integrations/mezo/components/AssetIcon.tsx new file mode 100644 index 0000000..0cb0c5d --- /dev/null +++ b/src/components/integrations/mezo/components/AssetIcon.tsx @@ -0,0 +1,195 @@ +import type { ReactNode } from "react"; +import { CurrencyBtc } from "@phosphor-icons/react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { MEZO_GLOSSARY, type GlossaryKey } from "../glossary"; + +export type AssetSymbol = "BTC" | "MUSD" | "sMUSD" | "MEZO" | "veMEZO"; + +const SYMBOL_TO_GLOSSARY: Record = { + BTC: "btc", + MUSD: "musd", + sMUSD: "smusd", + MEZO: "mezo", + veMEZO: "vemezo", +}; + +const sizeClass = { + sm: "h-4 w-4", + md: "h-5 w-5", + lg: "h-7 w-7", + xl: "h-9 w-9", +} as const; + +interface AssetIconProps { + symbol: AssetSymbol; + size?: keyof typeof sizeClass; + showLabel?: boolean; + noTooltip?: boolean; + className?: string; +} + +export function AssetIcon({ + symbol, + size = "md", + showLabel, + noTooltip, + className, +}: AssetIconProps) { + const glyph = ( + + {renderGlyph(symbol)} + + ); + + const node = showLabel ? ( + + {glyph} + {symbol} + + ) : ( + glyph + ); + + if (noTooltip) return node; + + const entry = MEZO_GLOSSARY[SYMBOL_TO_GLOSSARY[symbol]]; + return ( + + + + {node} + + + +
+ {entry.title} +
+
+ {entry.body} +
+
+
+ ); +} + +function renderGlyph(symbol: AssetSymbol): ReactNode { + switch (symbol) { + case "BTC": + return ( + + ); + case "MUSD": + return ; + case "sMUSD": + return ; + case "MEZO": + return ; + case "veMEZO": + return ; + } +} + +function MusdGlyph() { + return ( + + + + $ + + + ); +} + +function SmusdGlyph() { + return ( + + + + $ + + + + s + + + ); +} + +function MezoGlyph() { + return ( + + + + + ); +} + +function VeMezoGlyph() { + return ( + + + + + + ); +} diff --git a/src/components/integrations/mezo/components/AssetInput.tsx b/src/components/integrations/mezo/components/AssetInput.tsx new file mode 100644 index 0000000..72e492e --- /dev/null +++ b/src/components/integrations/mezo/components/AssetInput.tsx @@ -0,0 +1,162 @@ +import { useId, useMemo } from "react"; +import { formatUnits } from "viem"; +import { cn } from "@/lib/utils"; +import { AssetIcon, type AssetSymbol } from "./AssetIcon"; + +export type { AssetSymbol }; + +interface AssetInputProps { + label: string; + symbol: AssetSymbol; + value: string; + onChange: (next: string) => void; + step?: string; + helper?: string; + balance?: bigint; + balanceDecimals?: number; + usdValue?: number; + disabled?: boolean; + invalid?: boolean; + className?: string; + /** + * `deposit` (default) warns when value > balance. `receive` (e.g. MUSD + * borrow) suppresses the warning but still shows the wallet balance. + */ + intent?: "deposit" | "receive"; +} + +function formatBalance(value: bigint, decimals: number, precision = 4): string { + const n = Number(formatUnits(value, decimals)); + if (!Number.isFinite(n)) return "0"; + if (n === 0) return "0"; + if (n < 0.0001) return n.toExponential(2); + return n.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: precision, + }); +} + +function formatUsd(n: number): string { + if (!Number.isFinite(n)) return "—"; + if (n === 0) return "$0.00"; + if (Math.abs(n) < 0.01) return "< $0.01"; + return n.toLocaleString(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }); +} + +export function AssetInput({ + label, + symbol, + value, + onChange, + step = "0.001", + helper, + balance, + balanceDecimals = 18, + usdValue, + disabled, + invalid, + className, + intent = "deposit", +}: AssetInputProps) { + const id = useId(); + const hasBalance = balance !== undefined && balance > 0n; + + const handleMax = () => { + if (balance === undefined) return; + onChange(formatUnits(balance, balanceDecimals)); + }; + + const exceedsBalance = useMemo(() => { + if (intent !== "deposit") return false; + if (balance === undefined) return false; + if (!value || !value.trim()) return false; + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return false; + const balanceFloat = Number(formatUnits(balance, balanceDecimals)); + // 0.1% tolerance — avoids tripping the warning when the on-chain + // balance is a few wei under the displayed rounded value. + return n > balanceFloat * 1.001; + }, [intent, value, balance, balanceDecimals]); + + const isInvalid = invalid || exceedsBalance; + + return ( +
+
+ + {balance !== undefined && ( + + )} +
+ +
+ onChange(e.target.value)} + disabled={disabled} + placeholder="0.00" + className="min-w-0 flex-1 bg-transparent font-mono text-2xl font-light tabular-nums tracking-tight text-zinc-50 outline-none placeholder:text-zinc-700 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> +
+ + + {symbol} + +
+
+ +
+ {exceedsBalance ? ( +

+ Exceeds balance · simulation runs with override but tx will revert +

+ ) : helper ? ( +

{helper}

+ ) : ( + + )} + {usdValue !== undefined && ( + + ≈ {formatUsd(usdValue)} + + )} +
+
+ ); +} diff --git a/src/components/integrations/mezo/components/FlowRibbon.tsx b/src/components/integrations/mezo/components/FlowRibbon.tsx new file mode 100644 index 0000000..10f464c --- /dev/null +++ b/src/components/integrations/mezo/components/FlowRibbon.tsx @@ -0,0 +1,82 @@ +import { Fragment } from "react"; +import { cn } from "@/lib/utils"; +import type { AssetSymbol } from "./AssetInput"; + +export interface FlowStep { + symbol: AssetSymbol | "veMEZO"; + label?: string; + muted?: boolean; +} + +interface FlowRibbonProps { + steps: FlowStep[]; + caption?: string; + className?: string; +} + +const SYMBOL_ACCENT: Record = { + BTC: "bg-amber-400/80", + MUSD: "bg-emerald-400/80", + sMUSD: "bg-emerald-300/80", + MEZO: "bg-pink-400/80", + veMEZO: "bg-violet-400/80", +}; + +export function FlowRibbon({ steps, caption, className }: FlowRibbonProps) { + return ( +
+ {steps.map((step, i) => ( + + + + {step.symbol} + {step.label && ( + + {step.label} + + )} + + {i < steps.length - 1 && ( + + + + )} + + ))} + {caption && ( + + {caption} + + )} +
+ ); +} diff --git a/src/components/integrations/mezo/components/MezoTopBar.tsx b/src/components/integrations/mezo/components/MezoTopBar.tsx new file mode 100644 index 0000000..25b6dc2 --- /dev/null +++ b/src/components/integrations/mezo/components/MezoTopBar.tsx @@ -0,0 +1,48 @@ +import { useBlockNumber } from "wagmi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import { usePriceFeed } from "../hooks/usePriceFeed"; + +export function MezoTopBar() { + const block = useBlockNumber({ + chainId: MEZO_TESTNET_CHAIN_ID, + watch: true, + }); + const priceFeed = usePriceFeed(); + const btcUsd = priceFeed.data + ? Number(priceFeed.data as bigint) / 1e18 + : undefined; + + return ( +
+
+ + + MEZO TESTNET + + · + chain 31611 + {block.data !== undefined && ( + <> + · + + block{" "} + + {block.data.toString()} + + + + )} +
+
+ {btcUsd !== undefined && ( + + BTC{" "} + + ${btcUsd.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + + )} +
+
+ ); +} diff --git a/src/components/integrations/mezo/components/SectionEyebrow.tsx b/src/components/integrations/mezo/components/SectionEyebrow.tsx new file mode 100644 index 0000000..fe5bc26 --- /dev/null +++ b/src/components/integrations/mezo/components/SectionEyebrow.tsx @@ -0,0 +1,42 @@ +import { cn } from "@/lib/utils"; + +type EyebrowStatus = "live" | "empty" | "warning" | "off"; + +interface SectionEyebrowProps { + label: string; + status?: EyebrowStatus; + suffix?: React.ReactNode; + className?: string; +} + +const STATUS_DOT: Record = { + live: "bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.12)]", + empty: "bg-zinc-600", + warning: "bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.14)]", + off: "bg-zinc-700", +}; + +export function SectionEyebrow({ + label, + status = "empty", + suffix, + className, +}: SectionEyebrowProps) { + return ( +
+ + + {label} + + {suffix} +
+ ); +} diff --git a/src/components/integrations/mezo/components/SideRailNav.tsx b/src/components/integrations/mezo/components/SideRailNav.tsx new file mode 100644 index 0000000..ad6ec3a --- /dev/null +++ b/src/components/integrations/mezo/components/SideRailNav.tsx @@ -0,0 +1,206 @@ +import { useAccount, useBalance, useReadContract } from "wagmi"; +import { formatUnits, type Address } from "viem"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { MEZO_TABS, type MezoTabId } from "../TabBar"; +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import { MEZO_GLOSSARY, type GlossaryKey } from "../glossary"; + +interface SideRailNavProps { + active: MezoTabId; + onChange: (id: MezoTabId) => void; +} + +function fmt(value: bigint | undefined, decimals = 18, precision = 4): string { + if (value === undefined) return "—"; + const n = Number(formatUnits(value, decimals)); + if (n === 0) return "0.00"; + return n.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: precision, + }); +} + +export function SideRailNav({ active, onChange }: SideRailNavProps) { + const { address, isConnected, chainId } = useAccount(); + const onMezo = isConnected && chainId === MEZO_TESTNET_CHAIN_ID; + + // 6-second background refetch keeps the wallet rail fresh even when txs + // land outside Mezo Lens (e.g. user using testnet.mezo.org in another tab). + // Txs sent through useMezoLegPipeline invalidate the cache on confirm, so + // intra-Lens updates land within ~1 block (≈ 2s) regardless. + const refetchInterval = onMezo ? 6_000 : false; + + const btc = useBalance({ + address: onMezo ? (address as Address) : undefined, + chainId: MEZO_TESTNET_CHAIN_ID, + query: { enabled: onMezo, refetchInterval }, + }); + const musd = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address as Address] : undefined, + query: { enabled: onMezo, refetchInterval }, + }); + const sMusd = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.sMUSD, + abi: MEZO_ABIS.sMUSD, + functionName: "balanceOf", + args: address ? [address as Address] : undefined, + query: { enabled: onMezo, refetchInterval }, + }); + const mezo = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MEZO, + abi: MEZO_ABIS.MEZO, + functionName: "balanceOf", + args: address ? [address as Address] : undefined, + query: { enabled: onMezo, refetchInterval }, + }); + + return ( + + ); +} + +function TokenRow({ + symbol, + glossaryKey, + value, + precision = 4, + muted, +}: { + symbol: string; + glossaryKey: GlossaryKey; + value: bigint | undefined; + precision?: number; + muted?: boolean; +}) { + const isEmpty = muted || value === undefined; + const entry = MEZO_GLOSSARY[glossaryKey]; + return ( + + +
+ {symbol} + + {isEmpty ? "—" : fmt(value, 18, precision)} + +
+
+ +
+ {entry.title} +
+
+ {entry.body} +
+
+
+ ); +} diff --git a/src/components/integrations/mezo/components/Term.tsx b/src/components/integrations/mezo/components/Term.tsx new file mode 100644 index 0000000..358465b --- /dev/null +++ b/src/components/integrations/mezo/components/Term.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { MEZO_GLOSSARY, type GlossaryKey } from "../glossary"; + +interface TermProps { + k: GlossaryKey; + children?: ReactNode; + className?: string; +} + +/** Inline term with a dotted-underline tooltip pulled from MEZO_GLOSSARY. */ +export function Term({ k, children, className }: TermProps) { + const entry = MEZO_GLOSSARY[k]; + if (!entry) return <>{children}; + return ( + + + + {children ?? entry.title} + + + +
+ {entry.title} +
+
+ {entry.body} +
+
+
+ ); +} diff --git a/src/components/integrations/mezo/components/WorkbenchBody.tsx b/src/components/integrations/mezo/components/WorkbenchBody.tsx new file mode 100644 index 0000000..20b0e66 --- /dev/null +++ b/src/components/integrations/mezo/components/WorkbenchBody.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +type WidthVariant = "full" | "narrow"; + +interface WorkbenchBodyProps { + composerHeader?: ReactNode; + composer: ReactNode; + outcome: ReactNode; + actions?: ReactNode; + trailing?: ReactNode; + /** + * `narrow` centers content at `max-w-xl` (single-column swap tabs). + * `full` (default) spans the workbench width (2x2 input grids). + */ + width?: WidthVariant; + className?: string; +} + +const WIDTH_CLASS: Record = { + full: "", + narrow: "mx-auto w-full max-w-xl", +}; + +export function WorkbenchBody({ + composerHeader, + composer, + outcome, + actions, + trailing, + width = "full", + className, +}: WorkbenchBodyProps) { + const inner = WIDTH_CLASS[width]; + + return ( +
+
+
{composerHeader}
+
+ {composer} +
+
+ +
+
{outcome}
+
+ + {trailing && ( +
+
{trailing}
+
+ )} + + {actions && ( +
+
+ {actions} +
+
+ )} +
+ ); +} diff --git a/src/components/integrations/mezo/constants.ts b/src/components/integrations/mezo/constants.ts new file mode 100644 index 0000000..18a31ba --- /dev/null +++ b/src/components/integrations/mezo/constants.ts @@ -0,0 +1,16 @@ +export const MEZO_TESTNET_CHAIN_ID = 31611 as const; + +export const MEZO_RPC_URL = "https://rpc.test.mezo.org" as const; + +export const MEZO_FAUCET_URL = "https://faucet.test.mezo.org/" as const; + +export const MEZO_BLOCKSCOUT_UI = "https://explorer.test.mezo.org" as const; + +export const MEZO_BLOCKSCOUT_API = "https://api.explorer.test.mezo.org/api/v2" as const; + +/** + * Minimum BTC to open a trove at min net debt (1,800) + gas comp (200) under + * MCR 110%, computed against the Day-0 BTC price ($77,365.83). The faucet + * drips 0.05 BTC per claim — one Starter Stack with healthy margin. + */ +export const MEZO_MIN_BTC_FOR_TROVE = 0.028 as const; diff --git a/src/components/integrations/mezo/copy.ts b/src/components/integrations/mezo/copy.ts new file mode 100644 index 0000000..278ff51 --- /dev/null +++ b/src/components/integrations/mezo/copy.ts @@ -0,0 +1,91 @@ +/** + * User-facing copy for Mezo Lens. Keep builder jargon out — no + * "eth_simulateV1", "state override", "ABI", "decoder", etc. + */ +export const MEZO_LENS_COPY = { + pageTitle: "Mezo Lens", + pageSubtitle: + "Put your BTC, MUSD, and MEZO to work on Mezo — preview every step before you sign.", + integrationPillLabel: "Mezo Lens", + integrationPillDescription: + "Borrow, save, lock — six purpose-built Mezo actions with full position preview.", + + // Empty / error states + emptyStateConnectWallet: "Connect your wallet to see your Mezo positions.", + emptyStateWrongChain: "Switch to Mezo Testnet to continue.", + emptyStateInsufficientBtc: + "You need at least 0.028 BTC for a minimum trove. Claim from the faucet.", + emptyStateRpcUnreachable: "Mezo testnet is unreachable. Retry in a moment.", + + // CTAs + switchToMezoCta: "Switch to Mezo Testnet", + openFaucetCta: "Open faucet", + buildStackCta: "Build Stack", + previewCta: "Preview", + executeCta: "Execute", + retryCta: "Retry", + resetCta: "Reset", + + // Tabs + tabs: { + stack: { + label: "Stack", + title: "Composed Stack", + subtitle: "Borrow MUSD against BTC, save it, lock MEZO — one composed flow.", + }, + borrow: { + label: "Borrow", + title: "Trove", + subtitle: "Open, adjust, repay, or close your CDP against BTC collateral.", + }, + swap: { + label: "Swap", + title: "Swap", + subtitle: + "Swap BTC, MUSD, and MEZO through a single Mezo Router pool with quoted output before signing.", + }, + save: { + label: "Save", + title: "MUSD Savings", + subtitle: "Earn yield on MUSD via sMUSD. Optional gauge stake for emissions.", + }, + liquidity: { + label: "Liquidity", + title: "Liquidity", + subtitle: + "Provide paired assets to a Mezo pool and preview reserves plus LP-token balance changes before signing.", + }, + lock: { + label: "Lock", + title: "Lock MEZO", + subtitle: "Lock MEZO into veMEZO as a governance position.", + }, + }, + + // PositionsSidebar + positionsSidebar: { + walletHeader: "Wallet", + troveHeader: "Trove", + troveEmpty: "No trove yet", + troveLiquidationPrefix: "Liquidates @ $", + savingsHeader: "MUSD Savings", + savingsEmpty: "No sMUSD yet", + veMezoHeader: "veMEZO", + veMezoEmpty: "No active lock", + }, + + // Honesty footer + honestyFooter: + "Mezo Lens reads what the chain says. Testnet gauge emissions report rewardRate=0 — we display that directly. Canonical MUSD only (0x1189…3eB); duplicate 0x637e22A1… is detected and warned.", + + // Warnings + warnings: { + canonicalMusdOnly: "Canonical MUSD only — duplicate 0x637e22A1… detected and ignored.", + dormantEmissions: + "Testnet gauge emissions are dormant (rewardRate=0). The honest yield shown is direct sMUSD only.", + icrTooLow: "Collateral ratio below safety margin (150%).", + icrAtLiquidationRisk: "Collateral ratio close to liquidation threshold.", + belowMinDebt: "Debt below MIN_NET_DEBT (1,800 MUSD).", + lockTooShort: "Lock duration shorter than 4 weeks — minimum voting power.", + }, +} as const; diff --git a/src/components/integrations/mezo/glossary.ts b/src/components/integrations/mezo/glossary.ts new file mode 100644 index 0000000..78096f8 --- /dev/null +++ b/src/components/integrations/mezo/glossary.ts @@ -0,0 +1,120 @@ +/** + * Tooltip definitions for and SideRailNav. 1–3 sentences each; + * audience is a DeFi user who doesn't know Liquity vocab. + */ +export const MEZO_GLOSSARY = { + stack: { + title: "Composed Stack", + body: + "One atomic flow that opens a BTC-backed trove, mints MUSD, parks part of it in sMUSD for yield, and locks MEZO for veMEZO voting power — every step previewed before you sign.", + }, + borrow: { + title: "Borrow (Trove)", + body: + "Open a Liquity-style CDP — deposit BTC, mint MUSD against it. Liquidates if collateral ratio drops below 110%.", + }, + swap: { + title: "Swap", + body: + "Trade any Mezo Pools pair, or redeem MUSD for BTC at face value. Ships in v2.", + }, + save: { + title: "Save (sMUSD)", + body: + "Deposit MUSD into the sMUSD vault for direct yield. Gauge-staked emissions arrive in v2.", + }, + liquidity: { + title: "Liquidity + Stake", + body: + "Provide liquidity to any Mezo pool, optionally stake the LP for emissions. Ships in v2.", + }, + lock: { + title: "Lock (veMEZO)", + body: + "Lock MEZO into veMEZO as a non-transferable governance NFT. Voting power decays linearly toward zero at unlock.", + }, + + btc: { + title: "BTC (native)", + body: + "Bitcoin, native gas/collateral asset on Mezo testnet. Faucet drips ≈ 0.05 BTC per claim.", + }, + musd: { + title: "MUSD", + body: + "Mezo's collateral-backed stablecoin minted against BTC via openTrove. Canonical address: 0x1189…3eB.", + }, + smusd: { + title: "sMUSD", + body: + "Yield-bearing wrapper for MUSD — deposit MUSD, receive sMUSD that accrues protocol fees.", + }, + mezo: { + title: "MEZO", + body: + "Mezo governance token. Lock it as veMEZO to gain voting power over emissions and pool weights.", + }, + vemezo: { + title: "veMEZO", + body: + "Non-transferable governance NFT minted by locking MEZO. Voting weight = lockedAmount × (duration / maxDuration), decays linearly.", + }, + + trove: { + title: "Trove", + body: + "Liquity-style CDP — your BTC-collateralized debt position. Each user has at most one trove per market.", + }, + icr: { + title: "ICR — Individual Collateral Ratio", + body: + "Trove collateral value ÷ debt, expressed as a percentage. Falls below 110% and your trove gets liquidated.", + }, + ltv: { + title: "LTV — Loan-to-Value", + body: + "Debt ÷ collateral value, expressed as a percentage. Inverse of ICR — higher LTV means more risk.", + }, + liquidation: { + title: "Liquidation price", + body: + "BTC/USD level at which your trove's ICR drops to 110% and the protocol seizes collateral to repay your debt. If BTC trades below this, you lose collateral.", + }, + troveDebt: { + title: "Total trove debt", + body: + "MUSD you owe the protocol — net borrow plus 200 MUSD gas compensation (refunded on clean close). Liquidation seizes collateral to repay this.", + }, + gasComp: { + title: "Gas compensation", + body: + "200 MUSD is added to your debt as a liquidation incentive. Refunded if you close the trove cleanly.", + }, + minDebt: { + title: "Minimum net debt", + body: + "Mezo enforces a 1,800 MUSD floor on borrowable amounts. Total mint = 1,800 net + 200 gas compensation = 2,000 minimum.", + }, + gauge: { + title: "Gauge", + body: + "Per-pool emissions distributor. veMEZO holders vote on weights to direct MEZO emissions. Currently dormant on testnet (rewardRate=0).", + }, + voteWeight: { + title: "Vote weight", + body: + "lockedMEZO × (lockDuration / maxLockDuration). Decays linearly with time, hits zero at unlock.", + }, + preview: { + title: "Bundle preview", + body: + "Mezo Lens calls eth_simulateV1 with your wallet's state, returning exactly what would happen if you signed. No on-chain side effects.", + }, + atomicBundle: { + title: "Atomic bundle", + body: + "All legs simulate together with shared state — later legs see the effects of earlier ones, just like a real Multicall transaction.", + }, +} as const; + +export type GlossaryKey = keyof typeof MEZO_GLOSSARY; diff --git a/src/components/integrations/mezo/hooks/useFindPool.ts b/src/components/integrations/mezo/hooks/useFindPool.ts new file mode 100644 index 0000000..2932149 --- /dev/null +++ b/src/components/integrations/mezo/hooks/useFindPool.ts @@ -0,0 +1,176 @@ +import type { Address } from "viem"; +import { useReadContract } from "wagmi"; +import { MEZO_ABIS } from "../abi"; +import { + MEZO_CONTRACTS, + toMezoPoolTokenAddress, +} from "../../../../../data/mezoContracts"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; + +const ZERO_ADDR = "0x0000000000000000000000000000000000000000" as Address; + +/** + * Resolve the Mezo Pools pair address. When `stable` is omitted, both + * variants are read so the UI can pick a default; when passed, `address` + * follows that exact pool shape. + */ +export function useFindPool( + tokenA?: Address, + tokenB?: Address, + stable?: boolean, +) { + const poolTokenA = tokenA ? toMezoPoolTokenAddress(tokenA) : undefined; + const poolTokenB = tokenB ? toMezoPoolTokenAddress(tokenB) : undefined; + const enabled = canReadPair(poolTokenA, poolTokenB); + const volatilePoolRead = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.PoolFactory, + abi: MEZO_ABIS.PoolFactory, + functionName: "getPool", + args: + enabled && poolTokenA && poolTokenB + ? [poolTokenA, poolTokenB, false] + : undefined, + query: { enabled }, + }); + const stablePoolRead = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.PoolFactory, + abi: MEZO_ABIS.PoolFactory, + functionName: "getPool", + args: + enabled && poolTokenA && poolTokenB + ? [poolTokenA, poolTokenB, true] + : undefined, + query: { enabled }, + }); + + const volatilePool = normalizePoolAddress(volatilePoolRead.data); + const stablePool = normalizePoolAddress(stablePoolRead.data); + const defaultStable = !volatilePool && !!stablePool; + const defaultPool = volatilePool ?? stablePool; + const exactPool = + stable === undefined ? defaultPool : stable ? stablePool : volatilePool; + + return { + address: exactPool, + pool: exactPool, + defaultPool, + stablePool, + volatilePool, + defaultStable, + hasPool: !!exactPool, + volatilePoolRead, + stablePoolRead, + isLoading: volatilePoolRead.isLoading || stablePoolRead.isLoading, + error: volatilePoolRead.error ?? stablePoolRead.error, + }; +} + +export function useReserves( + tokenA?: Address, + tokenB?: Address, + stable?: boolean, +) { + const poolTokenA = tokenA ? toMezoPoolTokenAddress(tokenA) : undefined; + const poolTokenB = tokenB ? toMezoPoolTokenAddress(tokenB) : undefined; + const poolEnabled = + canReadPair(poolTokenA, poolTokenB) && stable !== undefined; + const poolRead = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.PoolFactory, + abi: MEZO_ABIS.PoolFactory, + functionName: "getPool", + args: + poolEnabled && poolTokenA && poolTokenB && stable !== undefined + ? [poolTokenA, poolTokenB, stable] + : undefined, + query: { enabled: poolEnabled }, + }); + const pool = normalizePoolAddress(poolRead.data); + const hasPool = !!pool; + + const reservesRead = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: pool ?? ZERO_ADDR, + abi: MEZO_ABIS.MezoPool, + functionName: "getReserves", + query: { enabled: hasPool }, + }); + const token0Read = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: pool ?? ZERO_ADDR, + abi: MEZO_ABIS.MezoPool, + functionName: "token0", + query: { enabled: hasPool }, + }); + const token1Read = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: pool ?? ZERO_ADDR, + abi: MEZO_ABIS.MezoPool, + functionName: "token1", + query: { enabled: hasPool }, + }); + + const reserves = reservesRead.data as + | readonly [bigint, bigint, bigint] + | undefined; + const token0 = normalizeAddress(token0Read.data); + const token1 = normalizeAddress(token1Read.data); + const token0IsA = + poolTokenA && token0 ? sameAddress(token0, poolTokenA) : undefined; + const reserveA = + reserves && token0IsA !== undefined + ? token0IsA + ? reserves[0] + : reserves[1] + : undefined; + const reserveB = + reserves && token0IsA !== undefined + ? token0IsA + ? reserves[1] + : reserves[0] + : undefined; + + return { + pool, + hasPool, + token0, + token1, + reserves, + reserveA, + reserveB, + blockTimestampLast: reserves?.[2], + poolRead, + reservesRead, + token0Read, + token1Read, + isLoading: + poolRead.isLoading || + reservesRead.isLoading || + token0Read.isLoading || + token1Read.isLoading, + error: + poolRead.error ?? + reservesRead.error ?? + token0Read.error ?? + token1Read.error, + }; +} + +function canReadPair(tokenA?: Address, tokenB?: Address): boolean { + return !!(tokenA && tokenB && !sameAddress(tokenA, tokenB)); +} + +function normalizePoolAddress(value: unknown): Address | undefined { + const addr = normalizeAddress(value); + return addr && !sameAddress(addr, ZERO_ADDR) ? addr : undefined; +} + +function normalizeAddress(value: unknown): Address | undefined { + return typeof value === "string" ? (value as Address) : undefined; +} + +function sameAddress(a: Address, b: Address): boolean { + return a.toLowerCase() === b.toLowerCase(); +} diff --git a/src/components/integrations/mezo/hooks/usePriceFeed.ts b/src/components/integrations/mezo/hooks/usePriceFeed.ts new file mode 100644 index 0000000..a1d27a7 --- /dev/null +++ b/src/components/integrations/mezo/hooks/usePriceFeed.ts @@ -0,0 +1,21 @@ +import { useReadContract } from "wagmi"; +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; + +/** + * Live BTC/USD from Mezo's PriceFeed as a 1e18 bigint. `fetchPrice()` is + * the supported entry point — Liquity's `lastGoodPrice()` reverts here. + */ +export function usePriceFeed() { + return useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.PriceFeed, + abi: MEZO_ABIS.PriceFeed, + functionName: "fetchPrice", + query: { + refetchInterval: 15_000, + staleTime: 10_000, + }, + }); +} diff --git a/src/components/integrations/mezo/hooks/useReserves.ts b/src/components/integrations/mezo/hooks/useReserves.ts new file mode 100644 index 0000000..389e7f3 --- /dev/null +++ b/src/components/integrations/mezo/hooks/useReserves.ts @@ -0,0 +1 @@ +export { useReserves } from "./useFindPool"; diff --git a/src/components/integrations/mezo/index.ts b/src/components/integrations/mezo/index.ts new file mode 100644 index 0000000..6b540a6 --- /dev/null +++ b/src/components/integrations/mezo/index.ts @@ -0,0 +1,23 @@ +export { default as MezoLensPage } from "./MezoLensPage"; +export { TabBar, MEZO_TABS, type MezoTabId } from "./TabBar"; +export { ChainGate } from "./ChainGate"; +export { PositionsSidebar } from "./PositionsSidebar"; +export { HonestyFooter } from "./HonestyFooter"; +export { MEZO_LENS_COPY } from "./copy"; +export { useFindPool, useReserves } from "./hooks/useFindPool"; +export { + buildSwapBundle, + type SwapBundleParams, +} from "./sim/bundles/swap"; +export { + buildLiquidityBundle, + type LiquidityBundleParams, +} from "./sim/bundles/liquidity"; +export { + MEZO_TESTNET_CHAIN_ID, + MEZO_RPC_URL, + MEZO_FAUCET_URL, + MEZO_BLOCKSCOUT_UI, + MEZO_BLOCKSCOUT_API, + MEZO_MIN_BTC_FOR_TROVE, +} from "./constants"; diff --git a/src/components/integrations/mezo/pipeline/MezoLegTimeline.tsx b/src/components/integrations/mezo/pipeline/MezoLegTimeline.tsx new file mode 100644 index 0000000..e757661 --- /dev/null +++ b/src/components/integrations/mezo/pipeline/MezoLegTimeline.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from "react"; +import { CircleNotch, CheckCircle, XCircle, Clock } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import { MEZO_BLOCKSCOUT_UI } from "../constants"; +import type { LegRun, LegStatus } from "./mezoLegs"; + +const statusIcon: Record = { + planned: , + ready: , + signing: , + confirming: , + confirmed: , + failed: , + rejected: , +}; + +const statusLabel: Record = { + planned: "Planned", + ready: "Ready", + signing: "Signing…", + confirming: "Confirming on Mezo Testnet…", + confirmed: "Confirmed", + failed: "Failed", + rejected: "Rejected", +}; + +interface MezoLegTimelineProps { + runs: LegRun[]; + onRetry: (id: string) => void; +} + +export function MezoLegTimeline({ runs, onRetry }: MezoLegTimelineProps) { + if (runs.length === 0) return null; + + return ( +
    + {runs.map((run) => ( +
  1. +
    {statusIcon[run.status]}
    +
    +
    {run.decodedSummary}
    +
    + {statusLabel[run.status]} +
    + {run.txHash && ( + + {shortHash(run.txHash)} ↗ + + )} + {run.error && ( +
    + {run.error} +
    + )} +
    + {(run.status === "failed" || run.status === "rejected") && ( + + )} +
  2. + ))} +
+ ); +} + +function shortHash(hash: string): string { + return `${hash.slice(0, 10)}…${hash.slice(-6)}`; +} diff --git a/src/components/integrations/mezo/pipeline/legHandlers.ts b/src/components/integrations/mezo/pipeline/legHandlers.ts new file mode 100644 index 0000000..fa7361c --- /dev/null +++ b/src/components/integrations/mezo/pipeline/legHandlers.ts @@ -0,0 +1,196 @@ +import { writeContract, type Config } from "@wagmi/core"; +import type { Address, Hex } from "viem"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import type { MezoLegSpec } from "./mezoLegs"; + +/** + * Dispatch a single write leg via wagmi `writeContract`. Throws on v2 + * variants. + */ +export async function executeLeg( + config: Config, + account: Address, + leg: MezoLegSpec, +): Promise { + switch (leg.type) { + case "openTrove": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.BorrowerOperations, + abi: MEZO_ABIS.BorrowerOperations, + functionName: "openTrove", + args: [leg.debtAmount, leg.upperHint, leg.lowerHint], + value: leg.collateralWei, + account, + }); + + case "troveAdjust": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.BorrowerOperations, + abi: MEZO_ABIS.BorrowerOperations, + functionName: "adjustTrove", + args: [ + leg.collWithdrawal, + leg.debtChange, + leg.isDebtIncrease, + leg.upperHint, + leg.lowerHint, + ], + value: leg.collDeposit > 0n ? leg.collDeposit : 0n, + account, + }); + + case "approveErc20": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: leg.token, + abi: MEZO_ABIS.MUSD, + functionName: "approve", + args: [leg.spender, leg.amount], + account, + }); + + case "sMusdDeposit": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.sMUSD, + abi: MEZO_ABIS.sMUSD, + functionName: "deposit", + args: [leg.amount], + account, + }); + + case "gaugeDeposit": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: leg.gauge, + abi: MEZO_ABIS.Gauge, + functionName: "deposit", + args: [leg.amount], + account, + }); + + case "veMezoCreateLock": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.veMEZO, + abi: MEZO_ABIS.VotingEscrow, + functionName: "createLock", + args: [leg.amount, leg.lockDuration], + account, + }); + + case "veMezoIncreaseAmount": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.veMEZO, + abi: MEZO_ABIS.VotingEscrow, + functionName: "increaseAmount", + args: [leg.tokenId, leg.amount], + account, + }); + + case "veMezoIncreaseUnlockTime": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.veMEZO, + abi: MEZO_ABIS.VotingEscrow, + functionName: "increaseUnlockTime", + args: [leg.tokenId, leg.lockDuration], + account, + }); + + case "routerSwap": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.Router, + abi: MEZO_ABIS.Router, + functionName: "swapExactTokensForTokens", + args: [ + leg.amountIn, + leg.amountOutMin, + leg.routes, + leg.to, + leg.deadline, + ], + account, + }); + + case "routerSwapEthIn": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.Router, + abi: MEZO_ABIS.Router, + functionName: "swapExactETHForTokens", + args: [leg.amountOutMin, leg.routes, leg.to, leg.deadline], + value: leg.amountIn, + account, + }); + + case "routerSwapEthOut": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.Router, + abi: MEZO_ABIS.Router, + functionName: "swapExactTokensForETH", + args: [ + leg.amountIn, + leg.amountOutMin, + leg.routes, + leg.to, + leg.deadline, + ], + account, + }); + + case "routerAddLiquidity": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.Router, + abi: MEZO_ABIS.Router, + functionName: "addLiquidity", + args: [ + leg.tokenA, + leg.tokenB, + leg.stable, + leg.amountADesired, + leg.amountBDesired, + leg.amountAMin, + leg.amountBMin, + leg.to, + leg.deadline, + ], + account, + }); + + case "routerAddLiquidityEth": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.Router, + abi: MEZO_ABIS.Router, + functionName: "addLiquidityETH", + args: [ + leg.token, + leg.stable, + leg.amountTokenDesired, + leg.amountTokenMin, + leg.amountEthMin, + leg.to, + leg.deadline, + ], + value: leg.amountEthDesired, + account, + }); + + case "repayMUSD": + case "closeTrove": + case "sMusdWithdraw": + case "gaugeWithdraw": + case "gaugeClaim": + case "redeemCollateral": + throw new Error(`executeLeg: ${leg.type} is a v2 leg, not implemented`); + } +} diff --git a/src/components/integrations/mezo/pipeline/mezoLegs.ts b/src/components/integrations/mezo/pipeline/mezoLegs.ts new file mode 100644 index 0000000..a6833ca --- /dev/null +++ b/src/components/integrations/mezo/pipeline/mezoLegs.ts @@ -0,0 +1,126 @@ +import type { Address } from "viem"; + +export interface MezoRouterRoute { + from: Address; + to: Address; + stable: boolean; + factory: Address; +} + +/** + * Every write Mezo Lens can perform. v1 variants have handlers; v2 + * variants exist for forward-typed dispatch (throw at encode/execute time). + */ +export type MezoLegSpec = + | { + type: "openTrove"; + debtAmount: bigint; + collateralWei: bigint; + upperHint: Address; + lowerHint: Address; + } + | { + type: "troveAdjust"; + collDeposit: bigint; + collWithdrawal: bigint; + debtChange: bigint; + isDebtIncrease: boolean; + upperHint: Address; + lowerHint: Address; + } + | { type: "repayMUSD"; amount: bigint; upperHint: Address; lowerHint: Address } + | { type: "closeTrove" } + | { + type: "approveErc20"; + token: Address; + spender: Address; + amount: bigint; + tokenLabel: string; + } + | { type: "sMusdDeposit"; amount: bigint } + | { type: "sMusdWithdraw"; amount: bigint } + | { type: "gaugeDeposit"; gauge: Address; amount: bigint; gaugeLabel: string } + | { type: "gaugeWithdraw"; gauge: Address; amount: bigint } + | { type: "gaugeClaim"; gauge: Address } + | { + type: "routerSwap"; + amountIn: bigint; + amountOutMin: bigint; + routes: readonly MezoRouterRoute[]; + to: Address; + deadline: bigint; + } + | { + type: "routerSwapEthIn"; + amountIn: bigint; + amountOutMin: bigint; + routes: readonly MezoRouterRoute[]; + to: Address; + deadline: bigint; + } + | { + type: "routerSwapEthOut"; + amountIn: bigint; + amountOutMin: bigint; + routes: readonly MezoRouterRoute[]; + to: Address; + deadline: bigint; + } + | { + type: "routerAddLiquidity"; + tokenA: Address; + tokenB: Address; + stable: boolean; + amountADesired: bigint; + amountBDesired: bigint; + amountAMin: bigint; + amountBMin: bigint; + to: Address; + deadline: bigint; + } + | { + type: "routerAddLiquidityEth"; + token: Address; + stable: boolean; + amountTokenDesired: bigint; + amountEthDesired: bigint; + amountTokenMin: bigint; + amountEthMin: bigint; + to: Address; + deadline: bigint; + } + | { + type: "redeemCollateral"; + musdAmount: bigint; + firstRedemptionHint: Address; + upperPartialRedemptionHint: Address; + lowerPartialRedemptionHint: Address; + partialRedemptionHintNICR: bigint; + maxIterations: bigint; + maxFeePercentage: bigint; + } + | { type: "veMezoCreateLock"; amount: bigint; lockDuration: bigint } + | { type: "veMezoIncreaseAmount"; tokenId: bigint; amount: bigint } + | { type: "veMezoIncreaseUnlockTime"; tokenId: bigint; lockDuration: bigint }; + +/** + * planned → ready → signing → confirming → confirmed. + * Failure → `failed`; rejection → `rejected`; both retry to `ready`. + */ +export type LegStatus = + | "planned" + | "ready" + | "signing" + | "confirming" + | "confirmed" + | "failed" + | "rejected"; + +export interface LegRun { + id: string; + spec: MezoLegSpec; + status: LegStatus; + txHash?: `0x${string}`; + error?: string; + decodedSummary: string; +} diff --git a/src/components/integrations/mezo/pipeline/useMezoLegPipeline.ts b/src/components/integrations/mezo/pipeline/useMezoLegPipeline.ts new file mode 100644 index 0000000..b814cde --- /dev/null +++ b/src/components/integrations/mezo/pipeline/useMezoLegPipeline.ts @@ -0,0 +1,105 @@ +import { useCallback, useRef, useState } from "react"; +import { useAccount, useConfig } from "wagmi"; +import { useQueryClient } from "@tanstack/react-query"; +import { waitForTransactionReceipt as wagmiWaitForReceipt } from "@wagmi/core"; +import { executeLeg } from "./legHandlers"; +import type { LegRun, LegStatus, MezoLegSpec } from "./mezoLegs"; + +function makeRunId(): string { + return Math.random().toString(36).slice(2, 10); +} + +export function useMezoLegPipeline() { + const config = useConfig(); + const { address } = useAccount(); + const queryClient = useQueryClient(); + const [runs, setRuns] = useState([]); + const runsRef = useRef(runs); + runsRef.current = runs; + + const updateLeg = useCallback((id: string, patch: Partial) => { + setRuns((prev) => { + const next = prev.map((r) => (r.id === id ? { ...r, ...patch } : r)); + runsRef.current = next; + return next; + }); + }, []); + + const start = useCallback( + (legs: MezoLegSpec[], summaries: string[]): LegRun[] => { + const newRuns: LegRun[] = legs.map((spec, i) => ({ + id: makeRunId(), + spec, + status: "ready" as LegStatus, + decodedSummary: summaries[i] ?? "", + })); + setRuns(newRuns); + runsRef.current = newRuns; + return newRuns; + }, + [], + ); + + const runOne = useCallback( + async (run: LegRun) => { + if (!address) throw new Error("wallet not connected"); + try { + updateLeg(run.id, { status: "signing" }); + const txHash = await executeLeg(config, address, run.spec); + updateLeg(run.id, { status: "confirming", txHash }); + await wagmiWaitForReceipt(config, { hash: txHash }); + updateLeg(run.id, { status: "confirmed" }); + // Tx landed — bust wagmi's read cache so wallet balances, trove state, + // veMEZO views, and pool reserves all refresh on the next tick. + // Broad invalidation is fine here: the surface area is small and an + // extra round-trip per balance is cheaper than missed updates. + queryClient.invalidateQueries(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const isReject = /user (rejected|denied)/i.test(msg); + updateLeg(run.id, { + status: isReject ? "rejected" : "failed", + error: msg, + }); + throw err; + } + }, + [address, config, queryClient, updateLeg], + ); + + const executeAll = useCallback(async () => { + const queue = runsRef.current.slice(); + for (const run of queue) { + try { + await runOne(run); + } catch { + // Stop on first failure; user can retry the specific leg. + return; + } + } + }, [runOne]); + + + const retry = useCallback( + async (id: string) => { + const run = runsRef.current.find((r) => r.id === id); + if (!run) return; + if (run.status !== "failed" && run.status !== "rejected") return; + updateLeg(id, { status: "ready", error: undefined }); + const fresh: LegRun = { ...run, status: "ready", error: undefined }; + try { + await runOne(fresh); + } catch { + // Error already surfaced in state. + } + }, + [runOne, updateLeg], + ); + + const reset = useCallback(() => { + setRuns([]); + runsRef.current = []; + }, []); + + return { runs, start, executeAll, retry, reset }; +} diff --git a/src/components/integrations/mezo/preview/DecodedLegList.tsx b/src/components/integrations/mezo/preview/DecodedLegList.tsx new file mode 100644 index 0000000..53fc973 --- /dev/null +++ b/src/components/integrations/mezo/preview/DecodedLegList.tsx @@ -0,0 +1,95 @@ +import { CheckCircle, XCircle } from "@phosphor-icons/react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { DecodedLeg } from "../sim/types"; + +interface DecodedLegListProps { + legs: DecodedLeg[]; +} + +/** Horizontal execution-plan stepper — chip per leg, details in tooltip. */ +export function DecodedLegList({ legs }: DecodedLegListProps) { + return ( +
    + {legs.map((leg, i) => { + const ok = leg.status === "success"; + const short = shortLabel(leg.decodedSummary); + return ( +
  1. + + +
    + {ok ? ( + + ) : ( + + )} + + {i + 1} + + {short} +
    +
    + +
    + Step {i + 1} · {ok ? "ok" : "reverts"} +
    +
    + {leg.decodedSummary} +
    + {leg.revertReason && ( +
    + {leg.revertReason} +
    + )} +
    + gas {leg.gasUsed.toLocaleString()} +
    +
    +
    + {i < legs.length - 1 && ( + + → + + )} +
  2. + ); + })} +
+ ); +} + +function shortLabel(summary: string): string { + const lower = summary.toLowerCase(); + if (lower.startsWith("open trove")) return "Open trove"; + if (lower.startsWith("approve")) { + const tok = summary.match(/Approve\s+([A-Za-z]+)/)?.[1] ?? "Approve"; + return `Approve ${tok}`; + } + if (lower.startsWith("deposit")) { + const tok = summary.match(/into\s+([A-Za-z]+)/)?.[1]; + return tok ? `→ ${tok}` : "Deposit"; + } + if (lower.startsWith("lock")) return "Lock"; + if (lower.startsWith("withdraw")) return "Withdraw"; + // fallback: first three words + return summary.split(/\s+/).slice(0, 3).join(" "); +} diff --git a/src/components/integrations/mezo/preview/DepositReceiveCards.tsx b/src/components/integrations/mezo/preview/DepositReceiveCards.tsx new file mode 100644 index 0000000..2d9808d --- /dev/null +++ b/src/components/integrations/mezo/preview/DepositReceiveCards.tsx @@ -0,0 +1,257 @@ +import { formatUnits, type Address } from "viem"; +import { ArrowRight, Vault } from "@phosphor-icons/react"; +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { AssetIcon, type AssetSymbol } from "../components/AssetIcon"; +import type { DecodedLeg, SimLog } from "../sim/types"; + +/** + * "You deposit / You receive" cards. Net deltas come from ERC-20 Transfer + * logs; native BTC delta is passed by the caller because payable calls + * don't emit ERC-20 Transfer logs for native moves. + */ + +const TRANSFER_TOPIC = + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + +interface WatchedToken { + address: Address; + symbol: string; + decimals: number; + usdPerUnit?: number; +} + +interface DepositReceiveCardsProps { + legs: DecodedLeg[]; + userAddress: Address | undefined; + btcDeltaWei?: bigint; + btcUsdPrice?: number; + extraReceives?: ExtraReceive[]; +} + +export interface ExtraReceive { + label: string; + detail?: string; +} + +interface TokenLine { + symbol: string; + amount: bigint; + decimals: number; + usd?: number; +} + +const watched = (musdPrice = 1, sMusdPrice = 1, mezoPrice?: number): WatchedToken[] => [ + { address: MEZO_CONTRACTS.MUSD, symbol: "MUSD", decimals: 18, usdPerUnit: musdPrice }, + { address: MEZO_CONTRACTS.sMUSD, symbol: "sMUSD", decimals: 18, usdPerUnit: sMusdPrice }, + { address: MEZO_CONTRACTS.MEZO, symbol: "MEZO", decimals: 18, usdPerUnit: mezoPrice }, +]; + +export function DepositReceiveCards({ + legs, + userAddress, + btcDeltaWei, + btcUsdPrice, + extraReceives, +}: DepositReceiveCardsProps) { + const tokens = watched(); + const { deposits, receives } = aggregate( + legs, + userAddress, + tokens, + btcDeltaWei, + btcUsdPrice, + ); + + if (deposits.length === 0 && receives.length === 0 && !extraReceives?.length) { + return null; + } + + return ( +
+ +
+ + +
+ +
+ ); +} + +interface SideCardProps { + label: string; + tone: "in" | "out"; + lines: TokenLine[]; + extras?: ExtraReceive[]; +} + +function SideCard({ label, tone, lines, extras }: SideCardProps) { + const valueColor = tone === "out" ? "text-red-300" : "text-emerald-300"; + return ( +
+
+ {label} +
+ {lines.length === 0 && (!extras || extras.length === 0) && ( +
+ )} + {lines.map((line) => { + const amount = Number(formatUnits(line.amount, line.decimals)); + const iconSymbol = toAssetSymbol(line.symbol); + return ( +
+
+ {iconSymbol ? ( + + ) : null} + + {tone === "out" ? "−" : "+"} + {formatAmount(amount)} + + + {line.symbol} + +
+ {line.usd !== undefined && ( + + ≈ ${formatAmount(amount * line.usd)} + + )} +
+ ); + })} + {extras?.map((extra, i) => ( +
+ +
+
+ {extra.label} +
+ {extra.detail && ( +
+ {extra.detail} +
+ )} +
+
+ ))} +
+ ); +} + +function toAssetSymbol(s: string): AssetSymbol | null { + switch (s) { + case "BTC": + case "MUSD": + case "sMUSD": + case "MEZO": + case "veMEZO": + return s; + default: + return null; + } +} + +function aggregate( + legs: DecodedLeg[], + user: Address | undefined, + tokens: WatchedToken[], + btcDeltaWei: bigint | undefined, + btcUsdPrice: number | undefined, +): { deposits: TokenLine[]; receives: TokenLine[] } { + if (!user) return { deposits: [], receives: [] }; + const userLower = user.toLowerCase(); + + const byToken = new Map< + string, + { amount: bigint; symbol: string; decimals: number; usd?: number } + >(); + + for (const leg of legs) { + for (const log of leg.logs) { + const t = parseTransfer(log); + if (!t) continue; + const meta = tokens.find( + (tk) => tk.address.toLowerCase() === log.address.toLowerCase(), + ); + if (!meta) continue; + const existing = + byToken.get(meta.symbol) ?? + { + amount: 0n, + symbol: meta.symbol, + decimals: meta.decimals, + usd: meta.usdPerUnit, + }; + if (t.from.toLowerCase() === userLower) existing.amount -= t.amount; + if (t.to.toLowerCase() === userLower) existing.amount += t.amount; + byToken.set(meta.symbol, existing); + } + } + + const deposits: TokenLine[] = []; + const receives: TokenLine[] = []; + + if (btcDeltaWei !== undefined && btcDeltaWei !== 0n) { + const line: TokenLine = { + symbol: "BTC", + amount: btcDeltaWei < 0n ? -btcDeltaWei : btcDeltaWei, + decimals: 18, + usd: btcUsdPrice, + }; + if (btcDeltaWei < 0n) deposits.push(line); + else receives.push(line); + } + + for (const entry of byToken.values()) { + if (entry.amount === 0n) continue; + const line: TokenLine = { + symbol: entry.symbol, + amount: entry.amount < 0n ? -entry.amount : entry.amount, + decimals: entry.decimals, + usd: entry.usd, + }; + if (entry.amount < 0n) deposits.push(line); + else receives.push(line); + } + + const order = ["BTC", "MUSD", "sMUSD", "MEZO"]; + const sortByOrder = (a: TokenLine, b: TokenLine) => + order.indexOf(a.symbol) - order.indexOf(b.symbol); + deposits.sort(sortByOrder); + receives.sort(sortByOrder); + + return { deposits, receives }; +} + +function parseTransfer( + log: SimLog, +): { from: string; to: string; amount: bigint } | null { + if (log.topics[0]?.toLowerCase() !== TRANSFER_TOPIC) return null; + if (log.topics.length < 3) return null; + const from = `0x${log.topics[1].slice(26)}`; + const to = `0x${log.topics[2].slice(26)}`; + try { + return { from, to, amount: BigInt(log.data) }; + } catch { + return null; + } +} + +function formatAmount(n: number): string { + if (Math.abs(n) >= 10000) return n.toFixed(0); + if (Math.abs(n) >= 1) return n.toFixed(2); + return n.toFixed(4); +} diff --git a/src/components/integrations/mezo/preview/PreviewPanel.tsx b/src/components/integrations/mezo/preview/PreviewPanel.tsx new file mode 100644 index 0000000..9949f79 --- /dev/null +++ b/src/components/integrations/mezo/preview/PreviewPanel.tsx @@ -0,0 +1,160 @@ +import type { Address } from "viem"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { CircleNotch, Warning, Info } from "@phosphor-icons/react"; +import { DecodedLegList } from "./DecodedLegList"; +import { + DepositReceiveCards, + type ExtraReceive, +} from "./DepositReceiveCards"; +import type { SimulationResult } from "../sim/types"; +import { Term } from "../components/Term"; +import type { GlossaryKey } from "../glossary"; + +interface PreviewPanelProps { + isLoading: boolean; + error: Error | null; + result: SimulationResult | undefined; + userAddress?: Address; + btcDeltaWei?: bigint; + btcUsdPrice?: number; + extraReceives?: ExtraReceive[]; +} + +export function PreviewPanel({ + isLoading, + error, + result, + userAddress, + btcDeltaWei, + btcUsdPrice, + extraReceives, +}: PreviewPanelProps) { + if (isLoading && !result) { + return ( +
+ + Simulating bundle on Mezo testnet… +
+ ); + } + + if (error) { + return ( + + + + Simulation failed: {error.message} + + + ); + } + + if (!result) { + return ( +
+ Adjust inputs to preview outcome +
+ ); + } + + return ( +
+ + + {result.outcome.trove && ( +
+
+ Resulting trove +
+
+ + + +
+
+ )} + +
+
+ Execution plan · {result.legs.length}{" "} + {result.legs.length === 1 ? "step" : "steps"} +
+ +
+ + {result.warnings.length > 0 && ( +
+ {result.warnings.map((w, i) => { + const tone = + w.severity === "caution" + ? "border-red-500/30 bg-red-950/30 text-red-100/85" + : w.severity === "warning" + ? "border-amber-500/25 bg-amber-500/[0.04] text-amber-100/85" + : "border-white/[0.06] bg-zinc-950/40 text-zinc-300"; + return ( +
+ {w.severity === "info" ? ( + + ) : ( + + )} + {w.text} +
+ ); + })} +
+ )} +
+ ); +} + +function Stat({ + label, + labelKey, + value, +}: { + label: string; + labelKey?: GlossaryKey; + value: string; +}) { + return ( +
+
+ {labelKey ? {label} : label} +
+
+ {value} +
+
+ ); +} diff --git a/src/components/integrations/mezo/sim/buildCalls.ts b/src/components/integrations/mezo/sim/buildCalls.ts new file mode 100644 index 0000000..e463bfa --- /dev/null +++ b/src/components/integrations/mezo/sim/buildCalls.ts @@ -0,0 +1,217 @@ +import { encodeFunctionData, type Address, type Hex } from "viem"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import type { SimCall } from "./ethSimulateV1"; +import type { MezoLegSpec } from "../pipeline/mezoLegs"; + +/** + * Encode a write-leg spec as an `eth_simulateV1` SimCall. Throws on v2 + * variants that have no v1 implementation. + */ +export function encodeWrite(account: Address, leg: MezoLegSpec): SimCall { + switch (leg.type) { + case "openTrove": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.BorrowerOperations, + functionName: "openTrove", + args: [leg.debtAmount, leg.upperHint, leg.lowerHint], + }); + // openTrove walks SortedTroves from upperHint to find the insertion + // point. With ~200 existing troves on testnet a real-fork walk can + // consume ~4M gas — well above the simulateBundle default split. + return { + from: account, + to: MEZO_CONTRACTS.BorrowerOperations, + input, + value: bigintToHex(leg.collateralWei), + gas: "0x4c4b40" as `0x${string}`, // 5,000,000 + }; + } + + case "troveAdjust": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.BorrowerOperations, + functionName: "adjustTrove", + args: [ + leg.collWithdrawal, + leg.debtChange, + leg.isDebtIncrease, + leg.upperHint, + leg.lowerHint, + ], + }); + return { + from: account, + to: MEZO_CONTRACTS.BorrowerOperations, + input, + value: leg.collDeposit > 0n ? bigintToHex(leg.collDeposit) : undefined, + }; + } + + case "approveErc20": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.MUSD, + functionName: "approve", + args: [leg.spender, leg.amount], + }); + return { from: account, to: leg.token, input }; + } + + case "sMusdDeposit": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.sMUSD, + functionName: "deposit", + args: [leg.amount], + }); + return { from: account, to: MEZO_CONTRACTS.sMUSD, input }; + } + + case "gaugeDeposit": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.Gauge, + functionName: "deposit", + args: [leg.amount], + }); + return { from: account, to: leg.gauge, input }; + } + + case "veMezoCreateLock": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.VotingEscrow, + functionName: "createLock", + args: [leg.amount, leg.lockDuration], + }); + // createLock mints an NFT, writes locked-amount + checkpoint storage, + // and pulls MEZO via safeTransferFrom — real-fork usage is ~1M gas. + return { + from: account, + to: MEZO_CONTRACTS.veMEZO, + input, + gas: "0x1e8480" as `0x${string}`, // 2,000,000 + }; + } + + case "veMezoIncreaseAmount": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.VotingEscrow, + functionName: "increaseAmount", + args: [leg.tokenId, leg.amount], + }); + return { from: account, to: MEZO_CONTRACTS.veMEZO, input }; + } + + case "veMezoIncreaseUnlockTime": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.VotingEscrow, + functionName: "increaseUnlockTime", + args: [leg.tokenId, leg.lockDuration], + }); + return { from: account, to: MEZO_CONTRACTS.veMEZO, input }; + } + + case "routerSwap": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.Router, + functionName: "swapExactTokensForTokens", + args: [ + leg.amountIn, + leg.amountOutMin, + leg.routes, + leg.to, + leg.deadline, + ], + }); + return { from: account, to: MEZO_CONTRACTS.Router, input }; + } + + case "routerSwapEthIn": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.Router, + functionName: "swapExactETHForTokens", + args: [leg.amountOutMin, leg.routes, leg.to, leg.deadline], + }); + return { + from: account, + to: MEZO_CONTRACTS.Router, + input, + value: bigintToHex(leg.amountIn), + }; + } + + case "routerSwapEthOut": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.Router, + functionName: "swapExactTokensForETH", + args: [ + leg.amountIn, + leg.amountOutMin, + leg.routes, + leg.to, + leg.deadline, + ], + }); + return { from: account, to: MEZO_CONTRACTS.Router, input }; + } + + case "routerAddLiquidity": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.Router, + functionName: "addLiquidity", + args: [ + leg.tokenA, + leg.tokenB, + leg.stable, + leg.amountADesired, + leg.amountBDesired, + leg.amountAMin, + leg.amountBMin, + leg.to, + leg.deadline, + ], + }); + // addLiquidity mints LP, transfers both tokens, updates reserves — + // real-fork usage is ~1.5M; the default bundle split (≈500k once + // openTrove reserves its 5M) isn't enough. + return { + from: account, + to: MEZO_CONTRACTS.Router, + input, + gas: "0x1e8480" as `0x${string}`, // 2,000,000 + }; + } + + case "routerAddLiquidityEth": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.Router, + functionName: "addLiquidityETH", + args: [ + leg.token, + leg.stable, + leg.amountTokenDesired, + leg.amountTokenMin, + leg.amountEthMin, + leg.to, + leg.deadline, + ], + }); + return { + from: account, + to: MEZO_CONTRACTS.Router, + input, + value: bigintToHex(leg.amountEthDesired), + }; + } + + case "repayMUSD": + case "closeTrove": + case "sMusdWithdraw": + case "gaugeWithdraw": + case "gaugeClaim": + case "redeemCollateral": + throw new Error(`encodeWrite: ${leg.type} is a v2 leg, not implemented`); + } +} + +function bigintToHex(value: bigint): Hex { + return `0x${value.toString(16)}` as Hex; +} diff --git a/src/components/integrations/mezo/sim/bundles/borrow.ts b/src/components/integrations/mezo/sim/bundles/borrow.ts new file mode 100644 index 0000000..b2726d6 --- /dev/null +++ b/src/components/integrations/mezo/sim/bundles/borrow.ts @@ -0,0 +1,40 @@ +import type { Address } from "viem"; +import type { MezoLegSpec } from "../../pipeline/mezoLegs"; +import type { ViewCallSpec } from "../types"; + +const ZERO_ADDR = "0x0000000000000000000000000000000000000000" as Address; + +export interface BorrowOpenParams { + account: Address; + collateralBtcWei: bigint; + debtMusd: bigint; + troveInsertHint?: Address; +} + +/** + * Standalone openTrove bundle. Views read MUSD balance, trove state, and + * price feed for the resulting-trove panel. + */ +export function buildBorrowOpenBundle(params: BorrowOpenParams): { + legs: MezoLegSpec[]; + views: ViewCallSpec[]; +} { + const hint = params.troveInsertHint ?? ZERO_ADDR; + const legs: MezoLegSpec[] = [ + { + type: "openTrove", + debtAmount: params.debtMusd, + collateralWei: params.collateralBtcWei, + upperHint: hint, + lowerHint: hint, + }, + ]; + + const views: ViewCallSpec[] = [ + { kind: "priceFeedFetch" }, + { kind: "musdBalanceOf", account: params.account }, + { kind: "troveDebtCollateral", account: params.account }, + ]; + + return { legs, views }; +} diff --git a/src/components/integrations/mezo/sim/bundles/liquidity.ts b/src/components/integrations/mezo/sim/bundles/liquidity.ts new file mode 100644 index 0000000..25286ea --- /dev/null +++ b/src/components/integrations/mezo/sim/bundles/liquidity.ts @@ -0,0 +1,123 @@ +import type { Address } from "viem"; +import { + MEZO_CONTRACTS, + toMezoPoolTokenAddress, +} from "../../../../../../data/mezoContracts"; +import type { MezoLegSpec } from "../../pipeline/mezoLegs"; +import type { ViewCallSpec } from "../types"; + +export interface LiquidityBundleParams { + account: Address; + tokenA: Address; + tokenB: Address; + stable: boolean; + amountADesired: bigint; + amountBDesired: bigint; + amountAMin: bigint; + amountBMin: bigint; + deadlineSec: bigint; +} + +export function buildLiquidityBundle(p: LiquidityBundleParams): { + legs: MezoLegSpec[]; + views: ViewCallSpec[]; +} { + const tokenA = toMezoPoolTokenAddress(p.tokenA); + const tokenB = toMezoPoolTokenAddress(p.tokenB); + + if (sameAddress(tokenA, tokenB)) { + throw new Error("buildLiquidityBundle: tokenA and tokenB must differ"); + } + + // Mezo's Router has no `addLiquidityETH` — BTC on Mezo is the ERC-20 + // surface at 0x7b7C…0000, so we always approve+addLiquidity. Same + // pattern as the swap builder. + const legs: MezoLegSpec[] = [ + { + type: "approveErc20", + token: p.tokenA, + spender: MEZO_CONTRACTS.Router, + amount: p.amountADesired, + tokenLabel: tokenLabel(p.tokenA), + }, + { + type: "approveErc20", + token: p.tokenB, + spender: MEZO_CONTRACTS.Router, + amount: p.amountBDesired, + tokenLabel: tokenLabel(p.tokenB), + }, + { + type: "routerAddLiquidity", + tokenA: p.tokenA, + tokenB: p.tokenB, + stable: p.stable, + amountADesired: p.amountADesired, + amountBDesired: p.amountBDesired, + amountAMin: p.amountAMin, + amountBMin: p.amountBMin, + to: p.account, + deadline: p.deadlineSec, + }, + ]; + + const views: ViewCallSpec[] = [ + { + kind: "poolReservesForPair", + tokenA, + tokenB, + stable: p.stable, + position: "before", + }, + { + kind: "lpBalanceOfForPair", + tokenA, + tokenB, + stable: p.stable, + account: p.account, + position: "before", + }, + { + kind: "lpTotalSupplyForPair", + tokenA, + tokenB, + stable: p.stable, + position: "before", + }, + { + kind: "poolReservesForPair", + tokenA, + tokenB, + stable: p.stable, + position: "after", + }, + { + kind: "lpBalanceOfForPair", + tokenA, + tokenB, + stable: p.stable, + account: p.account, + position: "after", + }, + { + kind: "lpTotalSupplyForPair", + tokenA, + tokenB, + stable: p.stable, + position: "after", + }, + ]; + + return { legs, views }; +} + +function sameAddress(a: Address, b: Address): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +function tokenLabel(token: Address): string { + if (sameAddress(token, MEZO_CONTRACTS.BTC)) return "BTC"; + if (sameAddress(token, MEZO_CONTRACTS.MUSD)) return "MUSD"; + if (sameAddress(token, MEZO_CONTRACTS.MEZO)) return "MEZO"; + return "token"; +} diff --git a/src/components/integrations/mezo/sim/bundles/lock.ts b/src/components/integrations/mezo/sim/bundles/lock.ts new file mode 100644 index 0000000..78e7a9a --- /dev/null +++ b/src/components/integrations/mezo/sim/bundles/lock.ts @@ -0,0 +1,40 @@ +import type { Address } from "viem"; +import { MEZO_CONTRACTS } from "../../../../../../data/mezoContracts"; +import type { MezoLegSpec } from "../../pipeline/mezoLegs"; +import type { ViewCallSpec } from "../types"; + +export interface LockParams { + account: Address; + mezoLockAmount: bigint; + lockDurationSeconds: bigint; +} + +/** + * Lock bundle: approve MEZO → veMezo.createLock. View reads MEZO balance + * after to confirm the spend. + */ +export function buildLockBundle(params: LockParams): { + legs: MezoLegSpec[]; + views: ViewCallSpec[]; +} { + const legs: MezoLegSpec[] = [ + { + type: "approveErc20", + token: MEZO_CONTRACTS.MEZO, + spender: MEZO_CONTRACTS.veMEZO, + amount: params.mezoLockAmount, + tokenLabel: "MEZO", + }, + { + type: "veMezoCreateLock", + amount: params.mezoLockAmount, + lockDuration: params.lockDurationSeconds, + }, + ]; + + const views: ViewCallSpec[] = [ + { kind: "mezoBalanceOf", account: params.account }, + ]; + + return { legs, views }; +} diff --git a/src/components/integrations/mezo/sim/bundles/save.ts b/src/components/integrations/mezo/sim/bundles/save.ts new file mode 100644 index 0000000..83818b7 --- /dev/null +++ b/src/components/integrations/mezo/sim/bundles/save.ts @@ -0,0 +1,39 @@ +import type { Address } from "viem"; +import { MEZO_CONTRACTS } from "../../../../../../data/mezoContracts"; +import type { MezoLegSpec } from "../../pipeline/mezoLegs"; +import type { ViewCallSpec } from "../types"; + +export interface SaveParams { + account: Address; + musdDepositAmount: bigint; +} + +/** + * Direct yield deposit: approve MUSD → sMUSD.deposit. Views read MUSD + + * sMUSD balances to show the conversion. + */ +export function buildSaveBundle(params: SaveParams): { + legs: MezoLegSpec[]; + views: ViewCallSpec[]; +} { + const legs: MezoLegSpec[] = [ + { + type: "approveErc20", + token: MEZO_CONTRACTS.MUSD, + spender: MEZO_CONTRACTS.sMUSD, + amount: params.musdDepositAmount, + tokenLabel: "MUSD", + }, + { + type: "sMusdDeposit", + amount: params.musdDepositAmount, + }, + ]; + + const views: ViewCallSpec[] = [ + { kind: "musdBalanceOf", account: params.account }, + { kind: "sMusdBalanceOf", account: params.account }, + ]; + + return { legs, views }; +} diff --git a/src/components/integrations/mezo/sim/bundles/stack.ts b/src/components/integrations/mezo/sim/bundles/stack.ts new file mode 100644 index 0000000..cd23c22 --- /dev/null +++ b/src/components/integrations/mezo/sim/bundles/stack.ts @@ -0,0 +1,80 @@ +import type { Address } from "viem"; +import { MEZO_CONTRACTS } from "../../../../../../data/mezoContracts"; +import type { MezoLegSpec } from "../../pipeline/mezoLegs"; +import type { ViewCallSpec } from "../types"; + +const ZERO_ADDR = "0x0000000000000000000000000000000000000000" as Address; + +export interface StackParams { + account: Address; + collateralBtcWei: bigint; + debtMusd: bigint; + sMusdDepositAmount: bigint; + mezoLockAmount: bigint; + lockDurationSeconds: bigint; + /** + * SortedTroves hint for the openTrove insertion. With 100s of existing + * troves on testnet, zero-address hints make the contract revert with no + * data. Pass any existing trove (head/tail) and the contract walks the + * list correctly. + */ + troveInsertHint?: Address; +} + +/** + * Starter Stack: openTrove → approve MUSD → sMUSD.deposit → approve MEZO → + * veMezo.createLock. Views read post-state balances, trove, and ICR. + */ +export function buildStackBundle(params: StackParams): { + legs: MezoLegSpec[]; + views: ViewCallSpec[]; + priceFeedViewIdx: number; +} { + const hint = params.troveInsertHint ?? ZERO_ADDR; + const legs: MezoLegSpec[] = [ + { + type: "openTrove", + debtAmount: params.debtMusd, + collateralWei: params.collateralBtcWei, + upperHint: hint, + lowerHint: hint, + }, + { + type: "approveErc20", + token: MEZO_CONTRACTS.MUSD, + spender: MEZO_CONTRACTS.sMUSD, + amount: params.sMusdDepositAmount, + tokenLabel: "MUSD", + }, + { + type: "sMusdDeposit", + amount: params.sMusdDepositAmount, + }, + { + type: "approveErc20", + token: MEZO_CONTRACTS.MEZO, + spender: MEZO_CONTRACTS.veMEZO, + amount: params.mezoLockAmount, + tokenLabel: "MEZO", + }, + { + type: "veMezoCreateLock", + amount: params.mezoLockAmount, + lockDuration: params.lockDurationSeconds, + }, + ]; + + const views: ViewCallSpec[] = [ + { kind: "priceFeedFetch" }, + { kind: "musdBalanceOf", account: params.account }, + { kind: "sMusdBalanceOf", account: params.account }, + { kind: "mezoBalanceOf", account: params.account }, + { kind: "troveDebtCollateral", account: params.account }, + ]; + + return { + legs, + views, + priceFeedViewIdx: 0, + }; +} diff --git a/src/components/integrations/mezo/sim/bundles/swap.ts b/src/components/integrations/mezo/sim/bundles/swap.ts new file mode 100644 index 0000000..6c69c54 --- /dev/null +++ b/src/components/integrations/mezo/sim/bundles/swap.ts @@ -0,0 +1,97 @@ +import type { Address } from "viem"; +import { + MEZO_CONTRACTS, + toMezoPoolTokenAddress, +} from "../../../../../../data/mezoContracts"; +import type { + MezoLegSpec, + MezoRouterRoute, +} from "../../pipeline/mezoLegs"; +import type { ViewCallSpec } from "../types"; + +export interface SwapBundleParams { + account: Address; + tokenIn: Address; + tokenOut: Address; + amountIn: bigint; + amountOutMin: bigint; + stable: boolean; + deadlineSec: bigint; +} + +export function buildSwapBundle(p: SwapBundleParams): { + legs: MezoLegSpec[]; + views: ViewCallSpec[]; +} { + const tokenIn = toMezoPoolTokenAddress(p.tokenIn); + const tokenOut = toMezoPoolTokenAddress(p.tokenOut); + + if (sameAddress(tokenIn, tokenOut)) { + throw new Error("buildSwapBundle: tokenIn and tokenOut must differ"); + } + + const route: MezoRouterRoute = { + from: tokenIn, + to: tokenOut, + stable: p.stable, + factory: MEZO_CONTRACTS.PoolFactory, + }; + const routes = [route] as const; + + // Mezo's Router only exposes `swapExactTokensForTokens` — there is no + // ETH-native variant. BTC on Mezo is an ERC-20 surface (0x7b7C…0000) + // bound to native, so we always approve+swap as if it were a normal token. + const legs: MezoLegSpec[] = [ + { + type: "approveErc20", + token: tokenIn, + spender: MEZO_CONTRACTS.Router, + amount: p.amountIn, + tokenLabel: tokenLabel(tokenIn), + }, + { + type: "routerSwap", + amountIn: p.amountIn, + amountOutMin: p.amountOutMin, + routes, + to: p.account, + deadline: p.deadlineSec, + }, + ]; + + const views: ViewCallSpec[] = [ + { + kind: "routerGetAmountsOut", + amountIn: p.amountIn, + routes, + position: "before", + }, + { + kind: "erc20BalanceOf", + token: tokenOut, + account: p.account, + tokenLabel: tokenLabel(tokenOut), + position: "before", + }, + { + kind: "erc20BalanceOf", + token: tokenOut, + account: p.account, + tokenLabel: tokenLabel(tokenOut), + position: "after", + }, + ]; + + return { legs, views }; +} + +function sameAddress(a: Address, b: Address): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +function tokenLabel(token: Address): string { + if (sameAddress(token, MEZO_CONTRACTS.BTC)) return "BTC"; + if (sameAddress(token, MEZO_CONTRACTS.MUSD)) return "MUSD"; + if (sameAddress(token, MEZO_CONTRACTS.MEZO)) return "MEZO"; + return "token"; +} diff --git a/src/components/integrations/mezo/sim/decodeResults.ts b/src/components/integrations/mezo/sim/decodeResults.ts new file mode 100644 index 0000000..6d91df9 --- /dev/null +++ b/src/components/integrations/mezo/sim/decodeResults.ts @@ -0,0 +1,414 @@ +import { + decodeErrorResult, + decodeFunctionResult, + formatUnits, + type Hex, +} from "viem"; +import { MEZO_ABIS } from "../abi"; +import type { SimulatedBlock } from "./ethSimulateV1"; +import type { + DecodedLeg, + DecodedView, + SimLog, + ViewCallSpec, +} from "./types"; +import type { MezoLegSpec } from "../pipeline/mezoLegs"; + +const TRANSFER_TOPIC = + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + +export function decodeBundle( + result: SimulatedBlock, + legs: MezoLegSpec[], + views: ViewCallSpec[], +): { legs: DecodedLeg[]; views: DecodedView[] } { + const decodedLegs: DecodedLeg[] = []; + const decodedViews: DecodedView[] = []; + const beforeViews = views.filter((v) => viewPosition(v) === "before"); + const afterViews = views.filter((v) => viewPosition(v) === "after"); + + for (let i = 0; i < legs.length; i++) { + const leg = legs[i]; + const call = result.calls[beforeViews.length + i]; + if (!call) throw new Error(`missing simulation result for leg ${i}`); + + const status: "success" | "reverted" = + call.status === "0x1" ? "success" : "reverted"; + + decodedLegs.push({ + spec: leg, + status, + gasUsed: BigInt(call.gasUsed), + returnData: call.returnData, + logs: call.logs, + revertReason: + status === "reverted" ? decodeRevertReason(call.returnData) : undefined, + decodedSummary: summarizeLeg(leg), + }); + } + + for (let i = 0; i < views.length; i++) { + const view = views[i]; + const viewCallIdx = + viewPosition(view) === "before" + ? beforeViews.indexOf(view) + : beforeViews.length + legs.length + afterViews.indexOf(view); + const call = result.calls[viewCallIdx]; + if (!call) throw new Error(`missing simulation result for view ${i}`); + + let decoded: unknown = call.returnData; + try { + decoded = decodeViewByKind(view, call.returnData); + } catch { + // Leave raw; UI surfaces as "decode error" for that view tile. + } + decodedViews.push({ spec: view, returnData: call.returnData, decoded }); + } + + return { legs: decodedLegs, views: decodedViews }; +} + +function viewPosition(view: ViewCallSpec): "before" | "after" { + return view.position ?? "after"; +} + +/** + * Standard EVM revert reason format: + * selector (4 bytes) + offset (32) + length (32) + bytes (variable) + * selector for Error(string) = 0x08c379a0 + * selector for Panic(uint256) = 0x4e487b71 + * + * For custom errors we walk every ABI in MEZO_ABIS + a built-in OZ v5 ERC20 + * error set; viem's `decodeErrorResult` finds the matching item by selector + * and decodes the args. + */ +function decodeRevertReason(returnData: Hex): string | undefined { + if (returnData === "0x" || returnData === "0x0" || returnData.length < 10) { + return "execution reverted (no reason)"; + } + + // 1. Standard Error(string) + if (returnData.toLowerCase().startsWith("0x08c379a0")) { + try { + const lengthHex = returnData.slice(74, 138); + const length = parseInt(lengthHex, 16); + const dataHex = returnData.slice(138, 138 + length * 2); + const bytes = new Uint8Array( + dataHex.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || [], + ); + return new TextDecoder().decode(bytes); + } catch { + return "execution reverted (decode failed)"; + } + } + + // 2. Panic(uint256) + if (returnData.toLowerCase().startsWith("0x4e487b71")) { + const codeHex = returnData.slice(10).padStart(64, "0"); + const code = parseInt(codeHex, 16); + return `Panic(0x${code.toString(16)}) — ${PANIC_CODES[code] ?? "unknown panic"}`; + } + + // 3. Custom errors — try every ABI we know about. + for (const abi of ERROR_ABIS) { + try { + const decoded = decodeErrorResult({ abi, data: returnData }); + const args = decoded.args ?? []; + const formatted = args.map((a) => formatArg(a)).join(", "); + return `${decoded.errorName}(${formatted})`; + } catch { + // Selector not in this ABI; keep trying. + } + } + + return `execution reverted (selector: ${returnData.slice(0, 10)})`; +} + +/** + * OpenZeppelin v5 ERC20 custom errors — sMUSD vault wraps an OZ v5 MUSD + * token, so reverts on the savings vault commonly surface as these. Adding + * them to the decoder turns "(selector: 0xe450d38c)" into + * "ERC20InsufficientBalance(sender=0x…, balance=0, needed=100e18)". + */ +const OZ_V5_ERC20_ERRORS_ABI = [ + { + type: "error", + name: "ERC20InsufficientBalance", + inputs: [ + { name: "sender", type: "address" }, + { name: "balance", type: "uint256" }, + { name: "needed", type: "uint256" }, + ], + }, + { + type: "error", + name: "ERC20InsufficientAllowance", + inputs: [ + { name: "spender", type: "address" }, + { name: "allowance", type: "uint256" }, + { name: "needed", type: "uint256" }, + ], + }, + { type: "error", name: "ERC20InvalidSender", inputs: [{ name: "sender", type: "address" }] }, + { type: "error", name: "ERC20InvalidReceiver", inputs: [{ name: "receiver", type: "address" }] }, + { type: "error", name: "ERC20InvalidApprover", inputs: [{ name: "approver", type: "address" }] }, + { type: "error", name: "ERC20InvalidSpender", inputs: [{ name: "spender", type: "address" }] }, + // ERC-4626 vault errors (OZ v5) + { + type: "error", + name: "ERC4626ExceededMaxDeposit", + inputs: [ + { name: "receiver", type: "address" }, + { name: "assets", type: "uint256" }, + { name: "max", type: "uint256" }, + ], + }, + { + type: "error", + name: "ERC4626ExceededMaxMint", + inputs: [ + { name: "receiver", type: "address" }, + { name: "shares", type: "uint256" }, + { name: "max", type: "uint256" }, + ], + }, + { + type: "error", + name: "ERC4626ExceededMaxWithdraw", + inputs: [ + { name: "owner", type: "address" }, + { name: "assets", type: "uint256" }, + { name: "max", type: "uint256" }, + ], + }, + { + type: "error", + name: "ERC4626ExceededMaxRedeem", + inputs: [ + { name: "owner", type: "address" }, + { name: "shares", type: "uint256" }, + { name: "max", type: "uint256" }, + ], + }, +] as const; + +/** Tried in order — first match wins. */ +const ERROR_ABIS = [ + OZ_V5_ERC20_ERRORS_ABI, + ...Object.values(MEZO_ABIS), +]; + +/** EIP standard panic codes — relevant subset. */ +const PANIC_CODES: Record = { + 0x01: "assertion failed", + 0x11: "arithmetic overflow/underflow", + 0x12: "division by zero", + 0x21: "invalid enum", + 0x22: "storage slice access out-of-bounds", + 0x31: "pop on empty array", + 0x32: "array index out of bounds", + 0x41: "out-of-memory allocation", + 0x51: "uninitialised function pointer", +}; + +/** Format a decoded error arg for inline display. */ +function formatArg(arg: unknown): string { + if (typeof arg === "bigint") { + // Heuristic: large bigints likely represent 18-decimals; show both raw and scaled. + if (arg > 10n ** 15n) { + const scaled = Number(formatUnits(arg, 18)); + const display = + scaled >= 0.0001 ? scaled.toFixed(4).replace(/\.?0+$/, "") : arg.toString(); + return display; + } + return arg.toString(); + } + if (typeof arg === "string") { + // Shorten addresses + if (arg.startsWith("0x") && arg.length === 42) { + return `${arg.slice(0, 6)}…${arg.slice(-4)}`; + } + return arg; + } + if (typeof arg === "boolean") return String(arg); + if (Array.isArray(arg)) return `[${arg.map(formatArg).join(", ")}]`; + return JSON.stringify(arg); +} + +function summarizeLeg(leg: MezoLegSpec): string { + switch (leg.type) { + case "openTrove": + return `Open trove · ${formatBn(leg.collateralWei)} BTC collateral · borrow ${formatBn(leg.debtAmount)} MUSD`; + case "troveAdjust": { + const dir = leg.isDebtIncrease ? "Borrow more" : "Repay"; + return `Adjust trove · ${dir} ${formatBn(leg.debtChange)} MUSD`; + } + case "repayMUSD": + return `Repay ${formatBn(leg.amount)} MUSD`; + case "closeTrove": + return `Close trove`; + case "approveErc20": + return `Approve ${leg.tokenLabel} → spender`; + case "sMusdDeposit": + return `Deposit ${formatBn(leg.amount)} MUSD into sMUSD savings`; + case "sMusdWithdraw": + return `Withdraw ${formatBn(leg.amount)} MUSD from sMUSD`; + case "gaugeDeposit": + return `Stake into ${leg.gaugeLabel} gauge`; + case "gaugeWithdraw": + return `Unstake from gauge`; + case "gaugeClaim": + return `Claim gauge rewards`; + case "routerSwap": + return `Swap ${formatBn(leg.amountIn)} (min out ${formatBn(leg.amountOutMin)})`; + case "routerSwapEthIn": + return `Swap ${formatBn(leg.amountIn)} BTC (min out ${formatBn(leg.amountOutMin)})`; + case "routerSwapEthOut": + return `Swap ${formatBn(leg.amountIn)} (min ${formatBn(leg.amountOutMin)} BTC out)`; + case "routerAddLiquidity": + return `Add liquidity to ${leg.stable ? "stable" : "volatile"} pool`; + case "routerAddLiquidityEth": + return `Add BTC liquidity to ${leg.stable ? "stable" : "volatile"} pool`; + case "redeemCollateral": + return `Redeem ${formatBn(leg.musdAmount)} MUSD for BTC`; + case "veMezoCreateLock": { + const days = Number(leg.lockDuration) / 86400; + return `Lock ${formatBn(leg.amount)} MEZO for ${days.toFixed(0)}d into veMEZO`; + } + case "veMezoIncreaseAmount": + return `Top up veMEZO lock · +${formatBn(leg.amount)} MEZO`; + case "veMezoIncreaseUnlockTime": { + const days = Number(leg.lockDuration) / 86400; + return `Extend veMEZO unlock by ${days.toFixed(0)}d`; + } + } +} + +function formatBn(value: bigint, decimals = 18, precision = 4): string { + const n = Number(formatUnits(value, decimals)); + if (n >= 1) return n.toFixed(2); + return n.toFixed(precision); +} + +function decodeViewByKind(view: ViewCallSpec, data: Hex): unknown { + switch (view.kind) { + case "musdBalanceOf": + case "sMusdBalanceOf": + case "mezoBalanceOf": + case "erc20BalanceOf": + return decodeFunctionResult({ + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + data, + }); + case "routerGetAmountsOut": + return decodeFunctionResult({ + abi: MEZO_ABIS.Router, + functionName: "getAmountsOut", + data, + }); + case "poolFactoryGetPool": + return decodeFunctionResult({ + abi: MEZO_ABIS.PoolFactory, + functionName: "getPool", + data, + }); + case "lpBalanceOf": + return decodeFunctionResult({ + abi: MEZO_ABIS.MezoPool, + functionName: "balanceOf", + data, + }); + case "lpTotalSupply": + return decodeFunctionResult({ + abi: MEZO_ABIS.MezoPool, + functionName: "totalSupply", + data, + }); + case "gaugeBalanceOf": + return decodeFunctionResult({ + abi: MEZO_ABIS.Gauge, + functionName: "balanceOf", + data, + }); + case "veMezoBalanceOfNFTLiteral": + return decodeFunctionResult({ + abi: MEZO_ABIS.VotingEscrow, + functionName: "balanceOfNFT", + data, + }); + case "veMezoLockedLiteral": + return decodeFunctionResult({ + abi: MEZO_ABIS.VotingEscrow, + functionName: "locked", + data, + }); + case "priceFeedFetch": + return decodeFunctionResult({ + abi: MEZO_ABIS.PriceFeed, + functionName: "fetchPrice", + data, + }); + case "currentIcr": + return decodeFunctionResult({ + abi: MEZO_ABIS.TroveManager, + functionName: "getCurrentICR", + data, + }); + case "troveDebtCollateral": + return decodeFunctionResult({ + abi: MEZO_ABIS.TroveManager, + functionName: "Troves", + data, + }); + case "poolReserves": + return decodeFunctionResult({ + abi: MEZO_ABIS.MezoPool, + functionName: "getReserves", + data, + }); + case "poolReservesForPair": + case "lpBalanceOfForPair": + case "lpTotalSupplyForPair": + return data; + case "veMezoBalanceOfNFTFromPreviousLeg": + case "veMezoLockedFromPreviousLeg": + // Shouldn't be reachable post-resolution; if it is, return raw. + return data; + } +} + +export interface WatchedToken { + address: string; + symbol: string; + decimals: number; +} + +export interface AssetMovement { + token: string; + from: string; + to: string; + amount: bigint; +} + +export function extractAssetMovements( + logs: SimLog[], + watchedTokens: WatchedToken[], +): AssetMovement[] { + const movements: AssetMovement[] = []; + for (const log of logs) { + if (log.topics[0]?.toLowerCase() !== TRANSFER_TOPIC) continue; + const tokenMeta = watchedTokens.find( + (t) => t.address.toLowerCase() === log.address.toLowerCase(), + ); + if (!tokenMeta) continue; + if (log.topics.length < 3) continue; + movements.push({ + token: tokenMeta.symbol, + from: `0x${log.topics[1].slice(26)}`, + to: `0x${log.topics[2].slice(26)}`, + amount: BigInt(log.data), + }); + } + return movements; +} diff --git a/src/components/integrations/mezo/sim/ethSimulateV1.ts b/src/components/integrations/mezo/sim/ethSimulateV1.ts new file mode 100644 index 0000000..66e2d84 --- /dev/null +++ b/src/components/integrations/mezo/sim/ethSimulateV1.ts @@ -0,0 +1,143 @@ +import type { Address, Hex } from "viem"; +import type { SimLog } from "./types"; + +/** + * Raw JSON-RPC client for `eth_simulateV1` against Mezo testnet (Mezod + * 11.0.0-rc0). The method is not yet finalized in execution-apis; schema + * may drift across Mezod versions — the contract test at + * `__tests__/ethSimulateV1.contract.test.ts` pins the current shape. + */ + +export interface SimCall { + from: Address; + to: Address; + input?: Hex; + value?: Hex; + gas?: Hex; + nonce?: Hex; +} + +export interface StateOverrideEntry { + balance?: Hex; + code?: Hex; + state?: Record; + stateDiff?: Record; + nonce?: Hex; +} + +export type StateOverrides = Record; + +export interface BlockStateCall { + stateOverrides?: StateOverrides; + calls: SimCall[]; +} + +export interface SimulateV1Options { + /** Whether the node should synthesize Transfer logs for native moves. */ + traceTransfers?: boolean; + /** Skip nonce/baseFee/intrinsic-gas validation — appropriate for preview UX. */ + validation?: boolean; +} + +export interface SimulatedCall { + status: "0x1" | "0x0"; + returnData: Hex; + gasUsed: Hex; + logs: SimLog[]; + error?: { message: string }; +} + +export interface SimulatedBlock { + calls: SimulatedCall[]; +} + +/** + * Generous balance override (10,000 BTC in wei) — high enough for any Mezo + * Lens bundle yet small enough to avoid Mezod's internal integer-overflow + * path that triggers on 2^256-1. We deliberately don't use MAX_UINT256 + * here because Mezo's node rejects it with "rpc error: integer overflow". + */ +const GENEROUS_BALANCE_HEX = ("0x" + + (10000n * 10n ** 18n).toString(16)) as Hex; + +export function maxBalanceOverride(addr: Address): StateOverrides { + return { [addr.toLowerCase()]: { balance: GENEROUS_BALANCE_HEX } }; +} + +/** + * Mezo's eth_simulateV1 enforces intrinsic gas even with validation=false + * (passing 0 fails with "intrinsic gas too low") AND enforces a per-bundle + * block gas limit of ~10M. Legs that need more (e.g. openTrove walking + * SortedTroves with 200+ entries needs ~4M) set their own `gas` field via + * the encoder. The remaining budget is split across calls that didn't set + * one explicitly. + */ +const BUNDLE_GAS_BUDGET = 9_500_000n; +const MIN_CALL_GAS = 100_000n; + +export async function simulateBundle( + rpcUrl: string, + blockStateCall: BlockStateCall, + options: SimulateV1Options = {}, +): Promise { + let reservedGas = 0n; + let callsNeedingDefault = 0n; + for (const call of blockStateCall.calls) { + if (call.gas) { + reservedGas += BigInt(call.gas); + } else { + callsNeedingDefault += 1n; + } + } + const remainingBudget = + BUNDLE_GAS_BUDGET > reservedGas ? BUNDLE_GAS_BUDGET - reservedGas : 0n; + const share = + callsNeedingDefault > 0n ? remainingBudget / callsNeedingDefault : 0n; + const perCallDefault = share < MIN_CALL_GAS ? MIN_CALL_GAS : share; + const defaultGas = `0x${perCallDefault.toString(16)}` as Hex; + const callsWithGas: SimCall[] = blockStateCall.calls.map((call) => + call.gas ? call : { ...call, gas: defaultGas }, + ); + + const body = { + jsonrpc: "2.0", + id: 1, + method: "eth_simulateV1", + params: [ + { + blockStateCalls: [{ ...blockStateCall, calls: callsWithGas }], + traceTransfers: options.traceTransfers ?? true, + validation: options.validation ?? false, + }, + "latest", + ], + }; + + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + throw new Error(`eth_simulateV1 HTTP ${res.status} ${res.statusText}`); + } + + const json = (await res.json()) as { + error?: { code: number; message: string }; + result?: SimulatedBlock[]; + }; + + if (json.error) { + throw new Error( + `eth_simulateV1 RPC ${json.error.code}: ${json.error.message}`, + ); + } + + const block = json.result?.[0]; + if (!block) { + throw new Error("eth_simulateV1: empty result"); + } + + return block; +} diff --git a/src/components/integrations/mezo/sim/types.ts b/src/components/integrations/mezo/sim/types.ts new file mode 100644 index 0000000..c737769 --- /dev/null +++ b/src/components/integrations/mezo/sim/types.ts @@ -0,0 +1,183 @@ +import type { Address, Hex } from "viem"; +import type { MezoLegSpec, MezoRouterRoute } from "../pipeline/mezoLegs"; + +/** + * Simulation types for the `eth_simulateV1` bundle pipeline (Mezo chain + * 31611). The RPC executes a multi-leg bundle in one round-trip and returns + * per-call { status, gasUsed, returnData, logs } plus synthetic Transfer + * logs for native BTC moves when traceTransfers=true. + */ + +/** + * View calls appended to a bundle to read end-state. "FromPreviousLeg" + * variants depend on a prior leg's return data (e.g., tokenId from + * veMezo.createLock) and are resolved to literals by the runner before + * encoding. + */ +export type ViewCallPosition = "before" | "after"; + +export type ViewCallSpec = + | { kind: "musdBalanceOf"; account: Address; position?: ViewCallPosition } + | { kind: "sMusdBalanceOf"; account: Address; position?: ViewCallPosition } + | { kind: "mezoBalanceOf"; account: Address; position?: ViewCallPosition } + | { + kind: "erc20BalanceOf"; + token: Address; + account: Address; + tokenLabel?: string; + position?: ViewCallPosition; + } + | { kind: "troveDebtCollateral"; account: Address; position?: ViewCallPosition } + | { + kind: "currentIcr"; + account: Address; + priceWei: bigint; + position?: ViewCallPosition; + } + | { kind: "priceFeedFetch"; position?: ViewCallPosition } + | { kind: "veMezoBalanceOfNFTLiteral"; tokenId: bigint; position?: ViewCallPosition } + | { kind: "veMezoLockedLiteral"; tokenId: bigint; position?: ViewCallPosition } + | { + kind: "veMezoBalanceOfNFTFromPreviousLeg"; + legIdx: number; + position?: ViewCallPosition; + } + | { + kind: "veMezoLockedFromPreviousLeg"; + legIdx: number; + position?: ViewCallPosition; + } + | { + kind: "routerGetAmountsOut"; + amountIn: bigint; + routes: readonly MezoRouterRoute[]; + position?: ViewCallPosition; + } + | { + kind: "poolFactoryGetPool"; + tokenA: Address; + tokenB: Address; + stable: boolean; + position?: ViewCallPosition; + } + | { + kind: "poolReservesForPair"; + tokenA: Address; + tokenB: Address; + stable: boolean; + position?: ViewCallPosition; + } + | { + kind: "lpBalanceOfForPair"; + tokenA: Address; + tokenB: Address; + stable: boolean; + account: Address; + position?: ViewCallPosition; + } + | { + kind: "lpTotalSupplyForPair"; + tokenA: Address; + tokenB: Address; + stable: boolean; + position?: ViewCallPosition; + } + | { kind: "lpBalanceOf"; lp: Address; account: Address; position?: ViewCallPosition } + | { kind: "lpTotalSupply"; lp: Address; position?: ViewCallPosition } + | { + kind: "gaugeBalanceOf"; + gauge: Address; + account: Address; + position?: ViewCallPosition; + } + | { kind: "poolReserves"; pool: Address; position?: ViewCallPosition }; + +export interface SimLog { + address: Address; + topics: Hex[]; + data: Hex; +} + +export interface DecodedLeg { + spec: MezoLegSpec; + status: "success" | "reverted"; + gasUsed: bigint; + returnData: Hex; + logs: SimLog[]; + revertReason?: string; + decodedSummary: string; +} + +export interface DecodedView { + spec: ViewCallSpec; + returnData: Hex; + decoded: unknown; +} + +export interface SimulationBalances { + btc: { before: bigint; after: bigint }; + musd: { before: bigint; after: bigint }; + sMusd: { before: bigint; after: bigint }; + mezo: { before: bigint; after: bigint }; +} + +export interface SimulationTrove { + debt: bigint; + collateral: bigint; + icrBps: number; + liquidationPriceUsd: number; +} + +export interface SimulationVeMezo { + tokenId: bigint; + votingPower: bigint; + lockEnd: bigint; +} + +export interface SimulationSwap { + amountOut?: bigint; + amountOutMin: bigint; + outputBalanceBefore?: bigint; + outputBalanceAfter?: bigint; + outputDelta?: bigint; + priceImpactBps?: number; +} + +export interface SimulationLiquidity { + lpTokensReceived?: bigint; + poolShareBps?: number; + lpBalanceBefore?: bigint; + lpBalanceAfter?: bigint; + lpTotalSupplyBefore?: bigint; + lpTotalSupplyAfter?: bigint; + reserve0Before?: bigint; + reserve1Before?: bigint; + reserve0After?: bigint; + reserve1After?: bigint; +} + +export interface SimulationOutcome { + balances: SimulationBalances; + trove?: SimulationTrove | null; + veMezo?: SimulationVeMezo | null; + swap?: SimulationSwap | null; + liquidity?: SimulationLiquidity | null; +} + +export interface SimulationWarning { + severity: "info" | "warning" | "caution"; + text: string; +} + +export interface SimulationRequest { + legs: MezoLegSpec[]; + views: ViewCallSpec[]; + beforeBalances: SimulationBalances; +} + +export interface SimulationResult { + legs: DecodedLeg[]; + views: DecodedView[]; + outcome: SimulationOutcome; + warnings: SimulationWarning[]; +} diff --git a/src/components/integrations/mezo/sim/useMezoBundleSimulation.ts b/src/components/integrations/mezo/sim/useMezoBundleSimulation.ts new file mode 100644 index 0000000..09eaf69 --- /dev/null +++ b/src/components/integrations/mezo/sim/useMezoBundleSimulation.ts @@ -0,0 +1,414 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAccount } from "wagmi"; +import { decodeFunctionResult, type Address } from "viem"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_RPC_URL } from "../constants"; +import { + simulateBundle, + maxBalanceOverride, + type StateOverrides, +} from "./ethSimulateV1"; +import { encodeWrite } from "./buildCalls"; +import { encodeView } from "./views"; +import { decodeBundle } from "./decodeResults"; +import type { + SimulationRequest, + SimulationResult, + SimulationOutcome, + ViewCallSpec, +} from "./types"; +import type { MezoLegSpec } from "../pipeline/mezoLegs"; + +export function useMezoBundleSimulation( + request: SimulationRequest | null, + options: { enabled?: boolean } = {}, +) { + const { address } = useAccount(); + + return useQuery({ + queryKey: ["mezo-sim", address, request ? serializeRequest(request) : null], + enabled: !!(address && request && (options.enabled ?? true)), + staleTime: 4_000, + retry: 1, + queryFn: async () => { + if (!address || !request) throw new Error("missing inputs"); + return await runBundleSimulation(address, request); + }, + }); +} + +async function runBundleSimulation( + account: Address, + request: SimulationRequest, +): Promise { + const overrides: StateOverrides = { + ...maxBalanceOverride(account), + }; + + // "FromPreviousLeg" views need a two-pass simulator (encode writes, read + // tokenIds from returnData, re-encode views). Not yet wired in v1. + const nonPoolResolvedViews: ViewCallSpec[] = request.views.map((v) => { + if ( + v.kind === "veMezoBalanceOfNFTFromPreviousLeg" || + v.kind === "veMezoLockedFromPreviousLeg" + ) { + throw new Error( + "useMezoBundleSimulation: FromPreviousLeg views require a two-pass simulator; not yet implemented in v1", + ); + } + return v; + }); + const literalViews = await resolvePoolViews(account, nonPoolResolvedViews); + const beforeViews = literalViews.filter((v) => viewPosition(v) === "before"); + const afterViews = literalViews.filter((v) => viewPosition(v) === "after"); + + const result = await simulateBundle(MEZO_RPC_URL, { + stateOverrides: overrides, + calls: [ + ...beforeViews.map((view) => encodeView(account, view)), + ...request.legs.map((leg) => encodeWrite(account, leg)), + ...afterViews.map((view) => encodeView(account, view)), + ], + }); + + const decoded = decodeBundle(result, request.legs, literalViews); + + const outcome = buildOutcome(request, decoded); + const warnings = buildWarnings(decoded, outcome); + + return { + legs: decoded.legs, + views: decoded.views, + outcome, + warnings, + }; +} + +const ZERO_ADDR = "0x0000000000000000000000000000000000000000" as Address; + +function viewPosition(view: ViewCallSpec): "before" | "after" { + return view.position ?? "after"; +} + +async function resolvePoolViews( + account: Address, + views: ViewCallSpec[], +): Promise { + const poolCache = new Map(); + + const resolvePool = async ( + tokenA: Address, + tokenB: Address, + stable: boolean, + ): Promise
=> { + const key = `${tokenA.toLowerCase()}:${tokenB.toLowerCase()}:${stable}`; + const cached = poolCache.get(key); + if (cached) return cached; + + const poolView: ViewCallSpec = { + kind: "poolFactoryGetPool", + tokenA, + tokenB, + stable, + }; + const result = await simulateBundle( + MEZO_RPC_URL, + { + calls: [encodeView(account, poolView)], + }, + { traceTransfers: false }, + ); + const call = result.calls[0]; + if (!call || call.status !== "0x1") { + throw new Error("PoolFactory.getPool simulation failed"); + } + const pool = decodeFunctionResult({ + abi: MEZO_ABIS.PoolFactory, + functionName: "getPool", + data: call.returnData, + }) as Address; + poolCache.set(key, pool); + return pool; + }; + + return Promise.all( + views.map(async (view): Promise => { + if (view.kind === "poolReservesForPair") { + const pool = await resolvePool(view.tokenA, view.tokenB, view.stable); + if (isZeroAddress(pool)) { + return { + kind: "poolFactoryGetPool", + tokenA: view.tokenA, + tokenB: view.tokenB, + stable: view.stable, + position: view.position, + }; + } + return { kind: "poolReserves", pool, position: view.position }; + } + + if (view.kind === "lpBalanceOfForPair") { + const pool = await resolvePool(view.tokenA, view.tokenB, view.stable); + if (isZeroAddress(pool)) { + return { + kind: "poolFactoryGetPool", + tokenA: view.tokenA, + tokenB: view.tokenB, + stable: view.stable, + position: view.position, + }; + } + return { + kind: "lpBalanceOf", + lp: pool, + account: view.account, + position: view.position, + }; + } + + if (view.kind === "lpTotalSupplyForPair") { + const pool = await resolvePool(view.tokenA, view.tokenB, view.stable); + if (isZeroAddress(pool)) { + return { + kind: "poolFactoryGetPool", + tokenA: view.tokenA, + tokenB: view.tokenB, + stable: view.stable, + position: view.position, + }; + } + return { + kind: "lpTotalSupply", + lp: pool, + position: view.position, + }; + } + + return view; + }), + ); +} + +function isZeroAddress(addr: Address): boolean { + return addr.toLowerCase() === ZERO_ADDR; +} + +function buildOutcome( + request: SimulationRequest, + decoded: ReturnType, +): SimulationOutcome { + const findView = (kind: string, position?: "before" | "after") => + decoded.views.find( + (v) => + v.spec.kind === kind && + (position === undefined || viewPosition(v.spec) === position), + ); + + const musdAfter = + (findView("musdBalanceOf")?.decoded as bigint | undefined) ?? + request.beforeBalances.musd.after; + const sMusdAfter = + (findView("sMusdBalanceOf")?.decoded as bigint | undefined) ?? + request.beforeBalances.sMusd.after; + const mezoAfter = + (findView("mezoBalanceOf")?.decoded as bigint | undefined) ?? + request.beforeBalances.mezo.after; + + // BTC: not readable via eth_call; derive from native value transfers in + // the openTrove / troveAdjust legs. + let btcAfter = request.beforeBalances.btc.before; + for (const leg of decoded.legs) { + if (leg.spec.type === "openTrove") { + btcAfter -= leg.spec.collateralWei; + } else if (leg.spec.type === "troveAdjust") { + btcAfter -= leg.spec.collDeposit; + btcAfter += leg.spec.collWithdrawal; + } + } + + let trove: SimulationOutcome["trove"] = null; + const troveView = findView("troveDebtCollateral"); + if (troveView && troveView.decoded) { + try { + const raw = troveView.decoded as readonly [ + bigint, + bigint, + bigint, + bigint, + number, + bigint, + bigint, + bigint, + bigint, + ]; + const [coll, principal, interestOwed, , status] = raw; + const debt = principal + interestOwed; + if (status === 1) { + const priceView = findView("priceFeedFetch"); + const price = + (priceView?.decoded as bigint | undefined) ?? 77365n * 10n ** 18n; + const icrBps = Number((coll * price * 10000n) / (debt * 10n ** 18n)); + const liquidationPriceUsd = + (Number(debt) * 1.1) / 1e18 / (Number(coll) / 1e18); + trove = { debt, collateral: coll, icrBps, liquidationPriceUsd }; + } + } catch { + trove = null; + } + } + + const swapLeg = decoded.legs.map((l) => l.spec).find(isSwapLegSpec); + let swap: SimulationOutcome["swap"] = null; + if (swapLeg) { + const quoteAmounts = findView("routerGetAmountsOut", "before")?.decoded as + | readonly bigint[] + | undefined; + const quotedOut = + quoteAmounts && quoteAmounts.length > 0 + ? quoteAmounts[quoteAmounts.length - 1] + : undefined; + const outputBalanceBefore = findView( + "erc20BalanceOf", + "before", + )?.decoded as bigint | undefined; + const outputBalanceAfter = findView("erc20BalanceOf", "after")?.decoded as + | bigint + | undefined; + const outputDelta = + outputBalanceBefore !== undefined && outputBalanceAfter !== undefined + ? outputBalanceAfter - outputBalanceBefore + : undefined; + swap = { + amountOut: quotedOut ?? outputDelta, + amountOutMin: swapLeg.amountOutMin, + outputBalanceBefore, + outputBalanceAfter, + outputDelta, + }; + } + + const liquidityLeg = decoded.legs + .map((l) => l.spec) + .find(isLiquidityLegSpec); + let liquidity: SimulationOutcome["liquidity"] = null; + if (liquidityLeg) { + const lpBalanceBefore = findView("lpBalanceOf", "before")?.decoded as + | bigint + | undefined; + const lpBalanceAfter = findView("lpBalanceOf", "after")?.decoded as + | bigint + | undefined; + const lpTotalSupplyBefore = findView( + "lpTotalSupply", + "before", + )?.decoded as bigint | undefined; + const lpTotalSupplyAfter = findView("lpTotalSupply", "after")?.decoded as + | bigint + | undefined; + const reservesBefore = findView("poolReserves", "before")?.decoded as + | readonly [bigint, bigint, bigint] + | undefined; + const reservesAfter = findView("poolReserves", "after")?.decoded as + | readonly [bigint, bigint, bigint] + | undefined; + const lpTokensReceived = + lpBalanceBefore !== undefined && lpBalanceAfter !== undefined + ? lpBalanceAfter - lpBalanceBefore + : undefined; + const poolShareBps = + lpTokensReceived !== undefined && + lpTotalSupplyAfter !== undefined && + lpTotalSupplyAfter > 0n + ? Number((lpTokensReceived * 10000n) / lpTotalSupplyAfter) + : undefined; + + liquidity = { + lpTokensReceived, + poolShareBps, + lpBalanceBefore, + lpBalanceAfter, + lpTotalSupplyBefore, + lpTotalSupplyAfter, + reserve0Before: reservesBefore?.[0], + reserve1Before: reservesBefore?.[1], + reserve0After: reservesAfter?.[0], + reserve1After: reservesAfter?.[1], + }; + } + + return { + balances: { + btc: { before: request.beforeBalances.btc.before, after: btcAfter }, + musd: { before: request.beforeBalances.musd.before, after: musdAfter }, + sMusd: { + before: request.beforeBalances.sMusd.before, + after: sMusdAfter, + }, + mezo: { before: request.beforeBalances.mezo.before, after: mezoAfter }, + }, + trove, + veMezo: null, + swap, + liquidity, + }; +} + +function buildWarnings( + decoded: ReturnType, + outcome: SimulationOutcome, +): SimulationResult["warnings"] { + const warnings: SimulationResult["warnings"] = []; + + // Per-step revert reasons are surfaced inline in DecodedLegList; no + // duplicate banner here. + + if (outcome.trove) { + if (outcome.trove.icrBps < 13000) { + warnings.push({ + severity: outcome.trove.icrBps < 11500 ? "caution" : "warning", + text: `Collateral ratio ${(outcome.trove.icrBps / 100).toFixed(0)}% — close to liquidation threshold (110%).`, + }); + } + } + + return warnings; +} + +function isSwapLegSpec( + spec: MezoLegSpec, +): spec is Extract< + MezoLegSpec, + { type: "routerSwap" | "routerSwapEthIn" | "routerSwapEthOut" } +> { + return ( + spec.type === "routerSwap" || + spec.type === "routerSwapEthIn" || + spec.type === "routerSwapEthOut" + ); +} + +function isLiquidityLegSpec( + spec: MezoLegSpec, +): spec is Extract< + MezoLegSpec, + { type: "routerAddLiquidity" | "routerAddLiquidityEth" } +> { + return ( + spec.type === "routerAddLiquidity" || + spec.type === "routerAddLiquidityEth" + ); +} + +function serializeRequest(req: SimulationRequest): string { + return JSON.stringify(req, (_k, v) => + typeof v === "bigint" ? `0x${v.toString(16)}` : v, + ); +} + +export function decodeBalanceOfView(returnData: `0x${string}`): bigint { + return decodeFunctionResult({ + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + data: returnData, + }) as bigint; +} diff --git a/src/components/integrations/mezo/sim/views.ts b/src/components/integrations/mezo/sim/views.ts new file mode 100644 index 0000000..a2ccf76 --- /dev/null +++ b/src/components/integrations/mezo/sim/views.ts @@ -0,0 +1,183 @@ +import { encodeFunctionData, type Address } from "viem"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import type { SimCall } from "./ethSimulateV1"; +import type { ViewCallSpec } from "./types"; + +export type { ViewCallSpec } from "./types"; + +/** + * Encode a view spec as a SimCall. Previous-leg variants must be resolved + * to literals by the runner first (see useMezoBundleSimulation.ts). + */ +export function encodeView(account: Address, v: ViewCallSpec): SimCall { + switch (v.kind) { + case "musdBalanceOf": + case "erc20BalanceOf": + return { + from: account, + to: v.kind === "erc20BalanceOf" ? v.token : MEZO_CONTRACTS.MUSD, + input: encodeFunctionData({ + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: [v.account], + }), + }; + + case "sMusdBalanceOf": + return { + from: account, + to: MEZO_CONTRACTS.sMUSD, + input: encodeFunctionData({ + abi: MEZO_ABIS.sMUSD, + functionName: "balanceOf", + args: [v.account], + }), + }; + + case "mezoBalanceOf": + return { + from: account, + to: MEZO_CONTRACTS.MEZO, + input: encodeFunctionData({ + abi: MEZO_ABIS.MEZO, + functionName: "balanceOf", + args: [v.account], + }), + }; + + case "troveDebtCollateral": + return { + from: account, + to: MEZO_CONTRACTS.TroveManager, + input: encodeFunctionData({ + abi: MEZO_ABIS.TroveManager, + functionName: "Troves", + args: [v.account], + }), + }; + + case "currentIcr": + // getCurrentICR(address, uint256 price) — caller passes live + // PriceFeed.fetchPrice() into v.priceWei. + return { + from: account, + to: MEZO_CONTRACTS.TroveManager, + input: encodeFunctionData({ + abi: MEZO_ABIS.TroveManager, + functionName: "getCurrentICR", + args: [v.account, v.priceWei], + }), + }; + + case "priceFeedFetch": + return { + from: account, + to: MEZO_CONTRACTS.PriceFeed, + input: encodeFunctionData({ + abi: MEZO_ABIS.PriceFeed, + functionName: "fetchPrice", + args: [], + }), + }; + + case "routerGetAmountsOut": + return { + from: account, + to: MEZO_CONTRACTS.Router, + input: encodeFunctionData({ + abi: MEZO_ABIS.Router, + functionName: "getAmountsOut", + args: [v.amountIn, v.routes], + }), + }; + + case "poolFactoryGetPool": + return { + from: account, + to: MEZO_CONTRACTS.PoolFactory, + input: encodeFunctionData({ + abi: MEZO_ABIS.PoolFactory, + functionName: "getPool", + args: [v.tokenA, v.tokenB, v.stable], + }), + }; + + case "veMezoBalanceOfNFTLiteral": + return { + from: account, + to: MEZO_CONTRACTS.veMEZO, + input: encodeFunctionData({ + abi: MEZO_ABIS.VotingEscrow, + functionName: "balanceOfNFT", + args: [v.tokenId], + }), + }; + + case "veMezoLockedLiteral": + return { + from: account, + to: MEZO_CONTRACTS.veMEZO, + input: encodeFunctionData({ + abi: MEZO_ABIS.VotingEscrow, + functionName: "locked", + args: [v.tokenId], + }), + }; + + case "veMezoBalanceOfNFTFromPreviousLeg": + case "veMezoLockedFromPreviousLeg": + throw new Error( + `encodeView: unresolved previous-leg reference (${v.kind})`, + ); + + case "lpBalanceOf": + return { + from: account, + to: v.lp, + input: encodeFunctionData({ + abi: MEZO_ABIS.MezoPool, + functionName: "balanceOf", + args: [v.account], + }), + }; + + case "lpTotalSupply": + return { + from: account, + to: v.lp, + input: encodeFunctionData({ + abi: MEZO_ABIS.MezoPool, + functionName: "totalSupply", + args: [], + }), + }; + + case "gaugeBalanceOf": + return { + from: account, + to: v.gauge, + input: encodeFunctionData({ + abi: MEZO_ABIS.Gauge, + functionName: "balanceOf", + args: [v.account], + }), + }; + + case "poolReserves": + return { + from: account, + to: v.pool, + input: encodeFunctionData({ + abi: MEZO_ABIS.MezoPool, + functionName: "getReserves", + args: [], + }), + }; + + case "poolReservesForPair": + case "lpBalanceOfForPair": + case "lpTotalSupplyForPair": + throw new Error(`encodeView: unresolved pool reference (${v.kind})`); + } +} diff --git a/src/components/integrations/mezo/tabs/BorrowTab.tsx b/src/components/integrations/mezo/tabs/BorrowTab.tsx new file mode 100644 index 0000000..7e27825 --- /dev/null +++ b/src/components/integrations/mezo/tabs/BorrowTab.tsx @@ -0,0 +1,236 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useBalance, useReadContract } from "wagmi"; +import { parseUnits, type Address } from "viem"; +import { Button } from "@/components/ui/button"; +import { ArrowsClockwise, Vault } from "@phosphor-icons/react"; + +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import { MEZO_LENS_COPY } from "../copy"; + +import { buildBorrowOpenBundle } from "../sim/bundles/borrow"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import type { SimulationRequest, SimulationBalances } from "../sim/types"; + +import { PreviewPanel } from "../preview/PreviewPanel"; +import type { ExtraReceive } from "../preview/DepositReceiveCards"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; +import { usePriceFeed } from "../hooks/usePriceFeed"; + +import { AssetInput } from "../components/AssetInput"; +import { AssetIcon } from "../components/AssetIcon"; +import { WorkbenchBody } from "../components/WorkbenchBody"; +import { Term } from "../components/Term"; + +export function BorrowTab() { + const { address } = useAccount(); + + const btc = useBalance({ + address, + chainId: MEZO_TESTNET_CHAIN_ID, + query: { enabled: !!address }, + }); + const musdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + const sortedTrovesHead = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.SortedTroves, + abi: MEZO_ABIS.SortedTroves, + functionName: "getFirst", + }); + const troveInsertHint = sortedTrovesHead.data as Address | undefined; + + const [btcInput, setBtcInput] = useState("0.05"); + const [musdInput, setMusdInput] = useState("2000"); + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: btc.data?.value ?? 0n, after: btc.data?.value ?? 0n }, + musd: { + before: (musdBalance.data as bigint | undefined) ?? 0n, + after: (musdBalance.data as bigint | undefined) ?? 0n, + }, + sMusd: { before: 0n, after: 0n }, + mezo: { before: 0n, after: 0n }, + }), + [btc.data?.value, musdBalance.data], + ); + + const params = useMemo(() => { + if (!address) return null; + try { + return { + account: address as Address, + collateralBtcWei: parseUnits(btcInput || "0", 18), + debtMusd: parseUnits(musdInput || "0", 18), + troveInsertHint, + }; + } catch { + return null; + } + }, [address, btcInput, musdInput, troveInsertHint]); + + const bundle = useMemo( + () => (params ? buildBorrowOpenBundle(params) : null), + [params], + ); + + const request: SimulationRequest | null = useMemo(() => { + if (!bundle) return null; + return { legs: bundle.legs, views: bundle.views, beforeBalances }; + }, [bundle, beforeBalances]); + + const [debouncedRequest, setDebouncedRequest] = useState( + null, + ); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest); + const pipeline = useMezoLegPipeline(); + + const onOpenTrove = async () => { + if (!bundle || !sim.data) return; + const summaries = sim.data.legs.map((l) => l.decodedSummary); + pipeline.start(bundle.legs, summaries); + await pipeline.executeAll(); + }; + + const priceFeed = usePriceFeed(); + const btcUsdPrice = priceFeed.data + ? Number(priceFeed.data as bigint) / 1e18 + : undefined; + const btcUsdValue = useMemo(() => { + if (!btcUsdPrice) return undefined; + const n = Number(btcInput); + return Number.isFinite(n) ? n * btcUsdPrice : undefined; + }, [btcInput, btcUsdPrice]); + const musdUsdValue = useMemo(() => { + const n = Number(musdInput); + return Number.isFinite(n) ? n : undefined; + }, [musdInput]); + + const extraReceives: ExtraReceive[] = useMemo(() => { + const out: ExtraReceive[] = []; + if (sim.data?.outcome.trove) { + const t = sim.data.outcome.trove; + out.push({ + label: `Trove opened · ${(t.icrBps / 100).toFixed(0)}% ICR`, + detail: `Liquidation @ $${t.liquidationPriceUsd.toFixed(0)} BTC`, + }); + } + return out; + }, [sim.data]); + + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + return ( + +

+ {MEZO_LENS_COPY.tabs.borrow.title} +

+
+ + + + · + + liquidates if ICR < 110% + +
+
+ } + composer={ +
+ + +
+ } + outcome={ + + } + trailing={ + pipeline.runs.length > 0 ? ( + + ) : undefined + } + actions={ + <> + {pipeline.runs.length > 0 && ( + + )} + + + + } + /> + ); +} diff --git a/src/components/integrations/mezo/tabs/LiquidityTab.tsx b/src/components/integrations/mezo/tabs/LiquidityTab.tsx new file mode 100644 index 0000000..8dec403 --- /dev/null +++ b/src/components/integrations/mezo/tabs/LiquidityTab.tsx @@ -0,0 +1,495 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useBalance, useReadContract } from "wagmi"; +import { formatUnits, parseUnits, type Address } from "viem"; +import { Button } from "@/components/ui/button"; +import { + ArrowsClockwise, + CirclesThreePlus, + Lightning, + Plus, +} from "@phosphor-icons/react"; + +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import { MEZO_LENS_COPY } from "../copy"; + +import { buildLiquidityBundle } from "../sim/bundles/liquidity"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import { useFindPool } from "../hooks/useFindPool"; +import { useReserves } from "../hooks/useReserves"; +import type { SimulationRequest, SimulationBalances } from "../sim/types"; + +import { PreviewPanel } from "../preview/PreviewPanel"; +import type { ExtraReceive } from "../preview/DepositReceiveCards"; +import { usePriceFeed } from "../hooks/usePriceFeed"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; + +import { AssetInput } from "../components/AssetInput"; +import { AssetIcon, type AssetSymbol } from "../components/AssetIcon"; +import { WorkbenchBody } from "../components/WorkbenchBody"; + +type LpToken = Extract; + +const TOKEN_ORDER: LpToken[] = ["BTC", "MUSD", "MEZO"]; + +const TOKEN_ADDRESS: Record = { + BTC: MEZO_CONTRACTS.BTC, + MUSD: MEZO_CONTRACTS.MUSD, + MEZO: MEZO_CONTRACTS.MEZO, +}; + +const SLIPPAGE_PRESETS = [0.1, 0.5, 1.0]; +const DEFAULT_DEADLINE_MIN = 20; + +function trimDecimals(s: string, maxDp: number): string { + if (!s.includes(".")) return s; + const [whole, frac] = s.split("."); + const truncated = frac.slice(0, maxDp).replace(/0+$/, ""); + return truncated.length === 0 ? whole : `${whole}.${truncated}`; +} + +export function LiquidityTab() { + const { address } = useAccount(); + + const [tokenA, setTokenA] = useState("BTC"); + const [tokenB, setTokenB] = useState("MUSD"); + const [amountA, setAmountA] = useState("0.01"); + const [amountB, setAmountB] = useState(""); + const [lastEdited, setLastEdited] = useState<"A" | "B">("A"); + const [slippagePct, setSlippagePct] = useState(0.5); + const [stable, setStable] = useState(false); + + const btc = useBalance({ + address, + chainId: MEZO_TESTNET_CHAIN_ID, + query: { enabled: !!address }, + }); + const musdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + const mezoBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MEZO, + abi: MEZO_ABIS.MEZO, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + + const balanceOf = (sym: LpToken): bigint | undefined => + sym === "BTC" + ? btc.data?.value + : sym === "MUSD" + ? (musdBalance.data as bigint | undefined) + : (mezoBalance.data as bigint | undefined); + + const pool = useFindPool(TOKEN_ADDRESS[tokenA], TOKEN_ADDRESS[tokenB], stable); + const reserves = useReserves( + TOKEN_ADDRESS[tokenA], + TOKEN_ADDRESS[tokenB], + stable, + ); + + const poolMissing = + !pool.isLoading && + (pool.address === undefined || + pool.address.toLowerCase() === + "0x0000000000000000000000000000000000000000"); + + // Auto-balance the un-edited side from current reserves ratio. + useEffect(() => { + if (!reserves.reserveA || !reserves.reserveB) return; + if (reserves.reserveA === 0n || reserves.reserveB === 0n) return; + try { + if (lastEdited === "A" && amountA) { + const a = parseUnits(amountA || "0", 18); + if (a === 0n) { + setAmountB(""); + return; + } + const b = (a * reserves.reserveB) / reserves.reserveA; + setAmountB(trimDecimals(formatUnits(b, 18), 6)); + } else if (lastEdited === "B" && amountB) { + const b = parseUnits(amountB || "0", 18); + if (b === 0n) { + setAmountA(""); + return; + } + const a = (b * reserves.reserveA) / reserves.reserveB; + setAmountA(trimDecimals(formatUnits(a, 18), 6)); + } + } catch { + // Swallow parse errors — input is mid-edit. + } + }, [amountA, amountB, lastEdited, reserves.reserveA, reserves.reserveB]); + + const params = useMemo(() => { + if (!address) return null; + if (tokenA === tokenB) return null; + try { + const aWei = parseUnits(amountA || "0", 18); + const bWei = parseUnits(amountB || "0", 18); + if (aWei === 0n || bWei === 0n) return null; + const slipBps = BigInt(Math.round(slippagePct * 100)); + const aMin = (aWei * (10_000n - slipBps)) / 10_000n; + const bMin = (bWei * (10_000n - slipBps)) / 10_000n; + return { + account: address as Address, + tokenA: TOKEN_ADDRESS[tokenA], + tokenB: TOKEN_ADDRESS[tokenB], + stable, + amountADesired: aWei, + amountBDesired: bWei, + amountAMin: aMin, + amountBMin: bMin, + deadlineSec: BigInt( + Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_MIN * 60, + ), + }; + } catch { + return null; + } + }, [address, tokenA, tokenB, amountA, amountB, stable, slippagePct]); + + const bundle = useMemo( + () => (params ? buildLiquidityBundle(params) : null), + [params], + ); + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: btc.data?.value ?? 0n, after: btc.data?.value ?? 0n }, + musd: { + before: (musdBalance.data as bigint | undefined) ?? 0n, + after: (musdBalance.data as bigint | undefined) ?? 0n, + }, + sMusd: { before: 0n, after: 0n }, + mezo: { + before: (mezoBalance.data as bigint | undefined) ?? 0n, + after: (mezoBalance.data as bigint | undefined) ?? 0n, + }, + }), + [btc.data?.value, musdBalance.data, mezoBalance.data], + ); + + const request: SimulationRequest | null = useMemo(() => { + if (!bundle) return null; + return { legs: bundle.legs, views: bundle.views, beforeBalances }; + }, [bundle, beforeBalances]); + + const [debouncedRequest, setDebouncedRequest] = useState< + SimulationRequest | null + >(null); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest, { + enabled: !poolMissing, + }); + + const pipeline = useMezoLegPipeline(); + + const onAddLiquidity = async () => { + if (!bundle || !sim.data) return; + const summaries = sim.data.legs.map((l) => l.decodedSummary); + pipeline.start(bundle.legs, summaries); + await pipeline.executeAll(); + }; + + const lpReceived = sim.data?.outcome.liquidity?.lpTokensReceived; + const poolShareBps = sim.data?.outcome.liquidity?.poolShareBps; + + // Native BTC moves don't emit ERC-20 Transfer logs — plumb explicitly so + // YOU'LL DEPOSIT shows the BTC leg when it's part of the pair. + const priceFeed = usePriceFeed(); + const btcUsdPrice = priceFeed.data + ? Number(priceFeed.data as bigint) / 1e18 + : undefined; + const btcDeltaWei = useMemo(() => { + if (!params) return undefined; + if (tokenA === "BTC") return -params.amountADesired; + if (tokenB === "BTC") return -params.amountBDesired; + return undefined; + }, [params, tokenA, tokenB]); + + const formattedLp = + lpReceived !== undefined ? trimDecimals(formatUnits(lpReceived, 18), 6) : "—"; + const formattedShare = + poolShareBps !== undefined ? (poolShareBps / 100).toFixed(4) : "—"; + + const extraReceives: ExtraReceive[] = useMemo(() => { + if (lpReceived === undefined || lpReceived === 0n) return []; + const share = + poolShareBps !== undefined && poolShareBps > 0 + ? `${(poolShareBps / 100).toFixed(4)}% of the ${stable ? "stable" : "volatile"} pool` + : `${stable ? "stable" : "volatile"} ${tokenA}/${tokenB} pool`; + return [ + { + label: `${formattedLp} LP tokens`, + detail: share, + }, + ]; + }, [lpReceived, poolShareBps, formattedLp, stable, tokenA, tokenB]); + + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + return ( + +
+

+ {MEZO_LENS_COPY.tabs.liquidity.title} +

+
+ + + + + · + {stable ? "stable pool" : "volatile pool"} +
+
+
+ + Mezo Pools +
+ + } + composer={ +
+
+
+ TOKEN A +
+
+ { + setLastEdited("A"); + setAmountA(v); + }} + step="0.001" + balance={balanceOf(tokenA)} + /> + { + if (t === tokenB) setTokenB(tokenA); + setTokenA(t); + }} + /> +
+
+ +
+ + + +
+ +
+
+ TOKEN B +
+
+ { + setLastEdited("B"); + setAmountB(v); + }} + step="0.001" + balance={balanceOf(tokenB)} + /> + { + if (t === tokenA) setTokenA(tokenB); + setTokenB(t); + }} + /> +
+
+ +
+
+ + LP tokens + + + {sim.isFetching ? "…" : formattedLp} + +
+
+ + Pool share + + + {sim.isFetching ? "…" : `${formattedShare}%`} + +
+
+ +
+
+ + Slippage + + {SLIPPAGE_PRESETS.map((preset) => { + const active = preset === slippagePct; + return ( + + ); + })} +
+
+ + Pool + + + +
+
+ + {poolMissing && ( +

+ No {stable ? "stable" : "volatile"} pool exists for {tokenA}/ + {tokenB} on Mezo. You'd be creating a new pool — the first + depositor sets the price. +

+ )} +
+ } + outcome={ + + } + trailing={ + pipeline.runs.length > 0 ? ( + + ) : undefined + } + actions={ + <> + {pipeline.runs.length > 0 && ( + + )} + + + + } + /> + ); +} + +function TokenPicker({ + value, + onChange, +}: { + value: LpToken; + onChange: (next: LpToken) => void; +}) { + return ( +
+ {TOKEN_ORDER.map((t) => { + const active = t === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/integrations/mezo/tabs/LockTab.tsx b/src/components/integrations/mezo/tabs/LockTab.tsx new file mode 100644 index 0000000..8babddf --- /dev/null +++ b/src/components/integrations/mezo/tabs/LockTab.tsx @@ -0,0 +1,287 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useReadContract } from "wagmi"; +import { parseUnits, type Address } from "viem"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ArrowsClockwise, Lock, Warning } from "@phosphor-icons/react"; + +import { MEZO_CONTRACTS, isPlaceholderAddress } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import { MEZO_LENS_COPY } from "../copy"; + +import { buildLockBundle } from "../sim/bundles/lock"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import type { SimulationRequest, SimulationBalances } from "../sim/types"; + +import { PreviewPanel } from "../preview/PreviewPanel"; +import type { ExtraReceive } from "../preview/DepositReceiveCards"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; + +import { AssetInput } from "../components/AssetInput"; +import { AssetIcon } from "../components/AssetIcon"; +import { WorkbenchBody } from "../components/WorkbenchBody"; +import { Term } from "../components/Term"; + +function formatVeMezo(n: number): string { + if (!Number.isFinite(n)) return "—"; + if (n === 0) return "0"; + if (n >= 100) return n.toFixed(0); + if (n >= 10) return n.toFixed(1); + if (n >= 1) return n.toFixed(2); + if (n >= 0.01) return n.toFixed(3); + return n.toExponential(1); +} + +const DURATION_PRESETS = [ + { label: "14d", seconds: 14n * 24n * 60n * 60n, weight: 0.08 }, + { label: "30d", seconds: 30n * 24n * 60n * 60n, weight: 0.16 }, + { label: "180d", seconds: 180n * 24n * 60n * 60n, weight: 0.5 }, + { label: "365d", seconds: 365n * 24n * 60n * 60n, weight: 1.0 }, +]; + +export function LockTab() { + const { address } = useAccount(); + const veMezoPlaceholder = isPlaceholderAddress(MEZO_CONTRACTS.veMEZO); + + const mezoBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MEZO, + abi: MEZO_ABIS.MEZO, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + + const [mezoInput, setMezoInput] = useState("50"); + const [durationIdx, setDurationIdx] = useState(0); + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: 0n, after: 0n }, + musd: { before: 0n, after: 0n }, + sMusd: { before: 0n, after: 0n }, + mezo: { + before: (mezoBalance.data as bigint | undefined) ?? 0n, + after: (mezoBalance.data as bigint | undefined) ?? 0n, + }, + }), + [mezoBalance.data], + ); + + const params = useMemo(() => { + if (!address) return null; + try { + return { + account: address as Address, + mezoLockAmount: parseUnits(mezoInput || "0", 18), + lockDurationSeconds: DURATION_PRESETS[durationIdx].seconds, + }; + } catch { + return null; + } + }, [address, mezoInput, durationIdx]); + + const bundle = useMemo(() => (params ? buildLockBundle(params) : null), [ + params, + ]); + + const request: SimulationRequest | null = useMemo(() => { + if (!bundle) return null; + return { legs: bundle.legs, views: bundle.views, beforeBalances }; + }, [bundle, beforeBalances]); + + const [debouncedRequest, setDebouncedRequest] = useState( + null, + ); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest, { + enabled: !veMezoPlaceholder, + }); + + const pipeline = useMezoLegPipeline(); + + const onLock = async () => { + if (!bundle || !sim.data) return; + const summaries = sim.data.legs.map((l) => l.decodedSummary); + pipeline.start(bundle.legs, summaries); + await pipeline.executeAll(); + }; + + const extraReceives: ExtraReceive[] = useMemo(() => { + if (!params) return []; + const days = Number(params.lockDurationSeconds) / 86400; + const label = + days >= 365 + ? `${(days / 365).toFixed(0)} year` + : days >= 30 + ? `${(days / 30).toFixed(0)} month` + : `${days.toFixed(0)} day`; + return [ + { + label: "veMEZO governance NFT", + detail: `${label} lock · voting power decays linearly`, + }, + ]; + }, [params]); + + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + const currentPreset = DURATION_PRESETS[durationIdx]; + const projectedVotingPower = useMemo(() => { + const n = Number(mezoInput); + if (!Number.isFinite(n)) return undefined; + return n * currentPreset.weight; + }, [mezoInput, currentPreset]); + + return ( + +
+

+ {MEZO_LENS_COPY.tabs.lock.title} +

+
+ + + + · + + voting power decays linearly + +
+
+ {veMezoPlaceholder && ( + + + + veMEZO unresolved · run{" "} + + scripts/mezo-day-0-smoke.sh + + + + )} + + } + composer={ +
+ +
+
+ + Lock duration + + {projectedVotingPower !== undefined && ( + + ≈ {projectedVotingPower.toFixed(2)}{" "} + vote weight + + )} +
+
+ {DURATION_PRESETS.map((preset, i) => { + const active = i === durationIdx; + const mezoIn = Number(mezoInput); + const projected = + Number.isFinite(mezoIn) && mezoIn > 0 + ? mezoIn * preset.weight + : undefined; + return ( + + ); + })} +
+
+
+ } + outcome={ + + } + trailing={ + pipeline.runs.length > 0 ? ( + + ) : undefined + } + actions={ + <> + {pipeline.runs.length > 0 && ( + + )} + + + + } + /> + ); +} diff --git a/src/components/integrations/mezo/tabs/SaveTab.tsx b/src/components/integrations/mezo/tabs/SaveTab.tsx new file mode 100644 index 0000000..18502a2 --- /dev/null +++ b/src/components/integrations/mezo/tabs/SaveTab.tsx @@ -0,0 +1,190 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useReadContract } from "wagmi"; +import { parseUnits, type Address } from "viem"; +import { Button } from "@/components/ui/button"; +import { ArrowsClockwise, PiggyBank } from "@phosphor-icons/react"; + +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import { MEZO_LENS_COPY } from "../copy"; + +import { buildSaveBundle } from "../sim/bundles/save"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import type { SimulationRequest, SimulationBalances } from "../sim/types"; + +import { PreviewPanel } from "../preview/PreviewPanel"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; + +import { AssetInput } from "../components/AssetInput"; +import { AssetIcon } from "../components/AssetIcon"; +import { WorkbenchBody } from "../components/WorkbenchBody"; +import { Term } from "../components/Term"; + +export function SaveTab() { + const { address } = useAccount(); + + const musdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + const sMusdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.sMUSD, + abi: MEZO_ABIS.sMUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + + const [musdInput, setMusdInput] = useState("100"); + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: 0n, after: 0n }, + musd: { + before: (musdBalance.data as bigint | undefined) ?? 0n, + after: (musdBalance.data as bigint | undefined) ?? 0n, + }, + sMusd: { + before: (sMusdBalance.data as bigint | undefined) ?? 0n, + after: (sMusdBalance.data as bigint | undefined) ?? 0n, + }, + mezo: { before: 0n, after: 0n }, + }), + [musdBalance.data, sMusdBalance.data], + ); + + const params = useMemo(() => { + if (!address) return null; + try { + return { + account: address as Address, + musdDepositAmount: parseUnits(musdInput || "0", 18), + }; + } catch { + return null; + } + }, [address, musdInput]); + + const bundle = useMemo(() => (params ? buildSaveBundle(params) : null), [params]); + + const request: SimulationRequest | null = useMemo(() => { + if (!bundle) return null; + return { legs: bundle.legs, views: bundle.views, beforeBalances }; + }, [bundle, beforeBalances]); + + const [debouncedRequest, setDebouncedRequest] = useState( + null, + ); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest); + const pipeline = useMezoLegPipeline(); + + const onSave = async () => { + if (!bundle || !sim.data) return; + const summaries = sim.data.legs.map((l) => l.decodedSummary); + pipeline.start(bundle.legs, summaries); + await pipeline.executeAll(); + }; + + const musdUsdValue = useMemo(() => { + const n = Number(musdInput); + return Number.isFinite(n) ? n : undefined; + }, [musdInput]); + + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + return ( + +

+ {MEZO_LENS_COPY.tabs.save.title} +

+
+ + + + · + + direct yield · gauge stake in v2 + +
+ + } + composer={ + + } + outcome={ + + } + trailing={ + pipeline.runs.length > 0 ? ( + + ) : undefined + } + actions={ + <> + {pipeline.runs.length > 0 && ( + + )} + + + + } + /> + ); +} diff --git a/src/components/integrations/mezo/tabs/StackTab.tsx b/src/components/integrations/mezo/tabs/StackTab.tsx new file mode 100644 index 0000000..acb9959 --- /dev/null +++ b/src/components/integrations/mezo/tabs/StackTab.tsx @@ -0,0 +1,438 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useBalance, useReadContract } from "wagmi"; +import { parseUnits, type Address } from "viem"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ArrowsClockwise, Lightning, Warning } from "@phosphor-icons/react"; + +import { MEZO_CONTRACTS, isPlaceholderAddress } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import { MEZO_LENS_COPY } from "../copy"; + +import { buildStackBundle } from "../sim/bundles/stack"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import type { SimulationRequest, SimulationBalances } from "../sim/types"; + +import { PreviewPanel } from "../preview/PreviewPanel"; +import type { ExtraReceive } from "../preview/DepositReceiveCards"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; +import { usePriceFeed } from "../hooks/usePriceFeed"; + +import { AssetInput } from "../components/AssetInput"; +import { AssetIcon } from "../components/AssetIcon"; +import { WorkbenchBody } from "../components/WorkbenchBody"; +import { Term } from "../components/Term"; + +// Mezo's VotingEscrow rounds the unlock time down to the previous week +// boundary, so a strict 7-day duration can round to "this week" and fail +// the future-time check. Two weeks guarantees we land in the next week. +const ONE_WEEK_SECONDS = 14n * 24n * 60n * 60n; + +export function StackTab() { + const { address } = useAccount(); + + const btc = useBalance({ + address, + chainId: MEZO_TESTNET_CHAIN_ID, + query: { enabled: !!address }, + }); + const musdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + const sMusdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.sMUSD, + abi: MEZO_ABIS.sMUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + const mezoBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MEZO, + abi: MEZO_ABIS.MEZO, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + + const [btcInput, setBtcInput] = useState("0.05"); + const [musdInput, setMusdInput] = useState("2000"); + const [sMusdInput, setSMusdInput] = useState("1500"); + const [mezoInput, setMezoInput] = useState("50"); + + const veMezoPlaceholder = isPlaceholderAddress(MEZO_CONTRACTS.veMEZO); + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: btc.data?.value ?? 0n, after: btc.data?.value ?? 0n }, + musd: { + before: (musdBalance.data as bigint | undefined) ?? 0n, + after: (musdBalance.data as bigint | undefined) ?? 0n, + }, + sMusd: { + before: (sMusdBalance.data as bigint | undefined) ?? 0n, + after: (sMusdBalance.data as bigint | undefined) ?? 0n, + }, + mezo: { + before: (mezoBalance.data as bigint | undefined) ?? 0n, + after: (mezoBalance.data as bigint | undefined) ?? 0n, + }, + }), + [btc.data?.value, musdBalance.data, sMusdBalance.data, mezoBalance.data], + ); + + // SortedTroves has 100s of existing troves on Mezo; openTrove with + // zero-address hints reverts (no data). Read head() and pass it as both + // hints so the contract can walk the linked list to our insert spot. + const sortedTrovesHead = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.SortedTroves, + abi: MEZO_ABIS.SortedTroves, + functionName: "getFirst", + }); + const troveInsertHint = sortedTrovesHead.data as Address | undefined; + + const params = useMemo(() => { + if (!address) return null; + try { + return { + account: address as Address, + collateralBtcWei: parseUnits(btcInput || "0", 18), + debtMusd: parseUnits(musdInput || "0", 18), + sMusdDepositAmount: parseUnits(sMusdInput || "0", 18), + mezoLockAmount: parseUnits(mezoInput || "0", 18), + lockDurationSeconds: ONE_WEEK_SECONDS, + troveInsertHint, + }; + } catch { + return null; + } + }, [address, btcInput, musdInput, sMusdInput, mezoInput, troveInsertHint]); + + const bundle = useMemo(() => (params ? buildStackBundle(params) : null), [ + params, + ]); + + const request: SimulationRequest | null = useMemo(() => { + if (!bundle) return null; + return { legs: bundle.legs, views: bundle.views, beforeBalances }; + }, [bundle, beforeBalances]); + + const [debouncedRequest, setDebouncedRequest] = useState( + null, + ); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest, { + enabled: !veMezoPlaceholder, + }); + + const pipeline = useMezoLegPipeline(); + + const onBuildStack = async () => { + if (!bundle || !sim.data) return; + const summaries = sim.data.legs.map((l) => l.decodedSummary); + pipeline.start(bundle.legs, summaries); + await pipeline.executeAll(); + }; + + const priceFeed = usePriceFeed(); + const btcUsdPrice = priceFeed.data + ? Number(priceFeed.data as bigint) / 1e18 + : undefined; + const btcUsdValue = useMemo(() => { + if (!btcUsdPrice) return undefined; + const n = Number(btcInput); + return Number.isFinite(n) ? n * btcUsdPrice : undefined; + }, [btcInput, btcUsdPrice]); + const musdUsdValue = useMemo(() => { + const n = Number(musdInput); + return Number.isFinite(n) ? n : undefined; + }, [musdInput]); + const sMusdUsdValue = useMemo(() => { + const n = Number(sMusdInput); + return Number.isFinite(n) ? n : undefined; + }, [sMusdInput]); + + // Pre-simulation ICR + minimum-debt validation so the user sees + // BorrowerOps violations before the bundle reverts. + const troveCheck = useMemo(() => { + const collBtc = Number(btcInput); + const borrow = Number(musdInput); + if (!Number.isFinite(collBtc) || !Number.isFinite(borrow)) return null; + if (borrow <= 0) { + return { + kind: "below-min" as const, + message: "MUSD borrow must be at least 2,000 (1,800 net + 200 gas comp)", + }; + } + if (borrow < 2000) { + return { + kind: "below-min" as const, + message: `Below minimum trove debt (2,000 MUSD). You typed ${borrow.toLocaleString()}.`, + }; + } + if (!btcUsdPrice) return null; + // Mezo gross debt = borrow × 1.01 issuance fee + 200 MUSD gas comp + const grossDebt = borrow * 1.01 + 200; + const collateralUsd = collBtc * btcUsdPrice; + const icr = grossDebt > 0 ? (collateralUsd / grossDebt) * 100 : 0; + const minBtc = (grossDebt * 1.1) / btcUsdPrice; + if (icr < 110) { + return { + kind: "icr-violation" as const, + icr, + grossDebt, + minBtc, + message: `ICR ${icr.toFixed(1)}% < 110% min · need ≥ ${minBtc.toFixed(4)} BTC for ${borrow.toLocaleString()} MUSD borrow`, + }; + } + return { + kind: "ok" as const, + icr, + grossDebt, + minBtc, + }; + }, [btcInput, musdInput, btcUsdPrice]); + + const extraReceives: ExtraReceive[] = useMemo(() => { + const out: ExtraReceive[] = []; + if (sim.data?.outcome.trove) { + const t = sim.data.outcome.trove; + out.push({ + label: `Trove opened · ${(t.icrBps / 100).toFixed(0)}% ICR`, + detail: `Liquidation @ $${t.liquidationPriceUsd.toFixed(0)} BTC`, + }); + } + if (params?.mezoLockAmount && params.mezoLockAmount > 0n) { + out.push({ + label: "veMEZO governance NFT", + detail: "1-week lock · voting power decays linearly", + }); + } + return out; + }, [sim.data, params]); + + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + return ( + +
+

+ {MEZO_LENS_COPY.tabs.stack.title} +

+
+ + + + + + + + + · + atomic +
+
+ {veMezoPlaceholder && ( + + + + veMEZO unresolved · run{" "} + + scripts/mezo-day-0-smoke.sh + + + + )} + + } + composer={ +
+ + + + + {troveCheck && troveCheck.kind !== "ok" && ( +
+ + + + {troveCheck.message} + + +
+ )} + {troveCheck && troveCheck.kind === "ok" && ( +
+
+ + Projected ICR + + = 150 + ? "text-emerald-300" + : troveCheck.icr >= 130 + ? "text-zinc-100" + : "text-amber-300" + }`} + > + {troveCheck.icr.toFixed(1)}% + +
+
+ + Gross debt + + + {troveCheck.grossDebt.toLocaleString(undefined, { + maximumFractionDigits: 0, + })}{" "} + MUSD + +
+
+ + Min collateral + + + {troveCheck.minBtc.toFixed(4)} BTC + +
+
+ )} +
+ } + outcome={ + + } + trailing={ + pipeline.runs.length > 0 ? ( + + ) : undefined + } + actions={ + <> + {pipeline.runs.length > 0 && ( + + )} + + + + } + /> + ); +} diff --git a/src/components/integrations/mezo/tabs/SwapTab.tsx b/src/components/integrations/mezo/tabs/SwapTab.tsx new file mode 100644 index 0000000..6f09a0c --- /dev/null +++ b/src/components/integrations/mezo/tabs/SwapTab.tsx @@ -0,0 +1,460 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useBalance, useReadContract } from "wagmi"; +import { formatUnits, parseUnits, type Address } from "viem"; +import { Button } from "@/components/ui/button"; +import { + ArrowsClockwise, + ArrowsDownUp, + ArrowsLeftRight, + Lightning, +} from "@phosphor-icons/react"; + +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import { MEZO_LENS_COPY } from "../copy"; + +import { buildSwapBundle } from "../sim/bundles/swap"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import { useFindPool } from "../hooks/useFindPool"; +import type { SimulationRequest, SimulationBalances } from "../sim/types"; + +import { PreviewPanel } from "../preview/PreviewPanel"; +import { usePriceFeed } from "../hooks/usePriceFeed"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; + +import { AssetInput } from "../components/AssetInput"; +import { AssetIcon, type AssetSymbol } from "../components/AssetIcon"; +import { WorkbenchBody } from "../components/WorkbenchBody"; + +type SwapToken = Extract; + +const TOKEN_ORDER: SwapToken[] = ["BTC", "MUSD", "MEZO"]; + +const TOKEN_ADDRESS: Record = { + BTC: MEZO_CONTRACTS.BTC, + MUSD: MEZO_CONTRACTS.MUSD, + MEZO: MEZO_CONTRACTS.MEZO, +}; + +const SLIPPAGE_PRESETS = [0.1, 0.5, 1.0]; + +const DEFAULT_DEADLINE_MIN = 20; + +/** Trim a decimal string to at most `maxDp` fractional digits, drop trailing zeros. */ +function trimDecimals(s: string, maxDp: number): string { + if (!s.includes(".")) return s; + const [whole, frac] = s.split("."); + const truncated = frac.slice(0, maxDp).replace(/0+$/, ""); + return truncated.length === 0 ? whole : `${whole}.${truncated}`; +} + +export function SwapTab() { + const { address } = useAccount(); + + const [tokenIn, setTokenIn] = useState("BTC"); + const [tokenOut, setTokenOut] = useState("MUSD"); + const [amountIn, setAmountIn] = useState("0.01"); + const [slippagePct, setSlippagePct] = useState(0.5); + const [stable, setStable] = useState(false); + + const flip = () => { + setTokenIn(tokenOut); + setTokenOut(tokenIn); + setAmountIn("0"); + }; + + const btc = useBalance({ + address, + chainId: MEZO_TESTNET_CHAIN_ID, + query: { enabled: !!address }, + }); + const musdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + const mezoBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MEZO, + abi: MEZO_ABIS.MEZO, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + + const balanceOf = (sym: SwapToken): bigint | undefined => + sym === "BTC" + ? btc.data?.value + : sym === "MUSD" + ? (musdBalance.data as bigint | undefined) + : (mezoBalance.data as bigint | undefined); + + const pool = useFindPool( + TOKEN_ADDRESS[tokenIn], + TOKEN_ADDRESS[tokenOut], + stable, + ); + + const poolMissing = + !pool.isLoading && + (pool.address === undefined || + pool.address.toLowerCase() === + "0x0000000000000000000000000000000000000000"); + + const params = useMemo(() => { + if (!address) return null; + if (tokenIn === tokenOut) return null; + try { + const amountInWei = parseUnits(amountIn || "0", 18); + if (amountInWei === 0n) return null; + return { + account: address as Address, + tokenIn: TOKEN_ADDRESS[tokenIn], + tokenOut: TOKEN_ADDRESS[tokenOut], + amountIn: amountInWei, + amountOutMin: 0n, + stable, + slippageBps: BigInt(Math.round(slippagePct * 100)), + deadlineSec: BigInt( + Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_MIN * 60, + ), + }; + } catch { + return null; + } + }, [address, tokenIn, tokenOut, amountIn, slippagePct, stable]); + + const bundle = useMemo( + () => (params ? buildSwapBundle(params) : null), + [params], + ); + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: btc.data?.value ?? 0n, after: btc.data?.value ?? 0n }, + musd: { + before: (musdBalance.data as bigint | undefined) ?? 0n, + after: (musdBalance.data as bigint | undefined) ?? 0n, + }, + sMusd: { before: 0n, after: 0n }, + mezo: { + before: (mezoBalance.data as bigint | undefined) ?? 0n, + after: (mezoBalance.data as bigint | undefined) ?? 0n, + }, + }), + [btc.data?.value, musdBalance.data, mezoBalance.data], + ); + + const request: SimulationRequest | null = useMemo(() => { + if (!bundle) return null; + return { legs: bundle.legs, views: bundle.views, beforeBalances }; + }, [bundle, beforeBalances]); + + const [debouncedRequest, setDebouncedRequest] = useState< + SimulationRequest | null + >(null); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest, { + enabled: !poolMissing, + }); + + const pipeline = useMezoLegPipeline(); + + const onSwap = async () => { + if (!bundle || !sim.data) return; + // Sim ran with amountOutMin=0 so the quote always lands. For real + // signing, clamp using the live quote × (1 − slippage). Bail if we + // somehow don't have a quote — never sign with 0 min-out. + const quote = sim.data.outcome.swap?.amountOut; + if (quote === undefined || quote === 0n) return; + const slipBps = BigInt(Math.round(slippagePct * 100)); + const minOutForSigning = (quote * (10_000n - slipBps)) / 10_000n; + const legsForSigning = bundle.legs.map((leg) => + leg.type === "routerSwap" + ? { ...leg, amountOutMin: minOutForSigning } + : leg, + ); + const summaries = sim.data.legs.map((l) => l.decodedSummary); + pipeline.start(legsForSigning, summaries); + await pipeline.executeAll(); + }; + + const quotedOut = sim.data?.outcome.swap?.amountOut; + const minOut = sim.data?.outcome.swap?.amountOutMin; + const priceImpactBps = sim.data?.outcome.swap?.priceImpactBps; + + const formattedQuotedOut = + quotedOut !== undefined ? trimDecimals(formatUnits(quotedOut, 18), 6) : "—"; + const formattedMinOut = + minOut !== undefined ? trimDecimals(formatUnits(minOut, 18), 6) : "—"; + + // Native BTC deltas don't emit ERC-20 Transfer logs, so the DepositReceive + // aggregator can't see them. Surface them explicitly: negative when BTC is + // the input side (we send), positive when it's the output (we receive). + const priceFeed = usePriceFeed(); + const btcUsdPrice = priceFeed.data + ? Number(priceFeed.data as bigint) / 1e18 + : undefined; + const btcDeltaWei = useMemo(() => { + if (tokenIn === "BTC" && params) return -params.amountIn; + if (tokenOut === "BTC" && quotedOut !== undefined) return quotedOut; + return undefined; + }, [tokenIn, tokenOut, params, quotedOut]); + + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + return ( + +
+

+ {MEZO_LENS_COPY.tabs.swap.title} +

+
+ + + + · + {stable ? "stable pool" : "volatile pool"} +
+
+
+ + Mezo Router +
+ + } + composer={ +
+
+
+ SELL +
+
+ + { + if (t === tokenOut) setTokenOut(tokenIn); + setTokenIn(t); + }} + /> +
+
+ +
+ +
+ +
+
+ BUY (ESTIMATED) +
+
+
+
+ + {sim.isFetching ? "…" : formattedQuotedOut} + +
+
+

+ Min received @ {slippagePct}% slip: {formattedMinOut} +

+ {priceImpactBps !== undefined && ( + 300 + ? "text-red-400" + : priceImpactBps > 100 + ? "text-amber-300" + : "text-zinc-500" + }`} + > + impact {(priceImpactBps / 100).toFixed(2)}% + + )} +
+
+ { + if (t === tokenIn) setTokenIn(tokenOut); + setTokenOut(t); + }} + /> +
+
+ +
+
+ + Slippage + + {SLIPPAGE_PRESETS.map((preset) => { + const active = preset === slippagePct; + return ( + + ); + })} +
+
+ + Pool + + + +
+
+ + {poolMissing && ( +

+ No {stable ? "stable" : "volatile"} pool exists for {tokenIn}/ + {tokenOut} on Mezo. Try the other pool type or a different pair. +

+ )} +
+ } + outcome={ + + } + trailing={ + pipeline.runs.length > 0 ? ( + + ) : undefined + } + actions={ + <> + {pipeline.runs.length > 0 && ( + + )} + + + + } + /> + ); +} + +function TokenPicker({ + value, + onChange, +}: { + value: SwapToken; + onChange: (next: SwapToken) => void; +}) { + return ( +
+ {TOKEN_ORDER.map((t) => { + const active = t === value; + return ( + + ); + })} +
+ ); +} From 1bb6c4ec46261b6038024687023547987e2c4630 Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 21:12:38 +0100 Subject: [PATCH 05/18] feat: chain-aware simulation trace and Blockscout source provenance The simulation-results renderer now adapts to the replayed chain instead of hardcoding Ethereum assumptions, and the Contracts panel honors the provider that actually returned the source. - edbTraceConverter accepts optional nativeSymbol / nativeName so native movements render as BTC on Mezo (chain id 31611/31612) instead of ETH - Event dedup key now folds in the call-frame id + emission index so legitimate identical logs from a loop (bulk Transfer airdrops, etc.) don't collapse to a single row; same fix mirrored in the engine renderer - Native token movements collapse to net-per-address rows so a multi-hop value-passing tx surfaces one debit + one credit instead of every intermediate frame - Contracts panel displays a "BLOCKSCOUT" / "SOURCIFY" badge based on the explicit sourceProvider tag the engine attaches to each artifact, with bridge compaction preserving the label end-to-end - Token metadata cache seeds MEZO, BTC, MUSD, sMUSD so a synthetic MEZO precompile Transfer renders as "MEZO" instead of "MEZOCaller" - Bridge response normalization now plumbs sourceProvider through artifactFetching, responseParsing, bridgeSimulation, and the trace compactor scripts --- scripts/artifact-compactor.mjs | 8 + scripts/simulator-bridge.mjs | 3 + scripts/trace-processing.mjs | 2 + src/components/ExecutionStackTrace.tsx | 33 +++- .../simulation-results/ContractsTab.tsx | 21 ++- .../simulation-results/EventsTab.tsx | 148 +++++++++++++----- .../simulation-results/SummaryTab.tsx | 47 +++--- .../simulation-results/eventDecoder.ts | 2 + .../useSimulationPageState.ts | 91 +++++++---- src/utils/edbTraceConverter.ts | 139 ++++++++++++++-- src/utils/resolver/sources/blockscout.ts | 37 ++++- src/utils/simulationArtifacts.ts | 11 +- src/utils/tokenMovements.ts | 6 + .../artifactFetching.ts | 2 + .../bridgeSimulation.ts | 1 + .../transaction-simulation/responseParsing.ts | 23 ++- 16 files changed, 452 insertions(+), 122 deletions(-) diff --git a/scripts/artifact-compactor.mjs b/scripts/artifact-compactor.mjs index 953f00a..a834e87 100644 --- a/scripts/artifact-compactor.mjs +++ b/scripts/artifact-compactor.mjs @@ -297,6 +297,14 @@ export function buildCompactArtifactMap(artifacts, opcodeLines) { const compactOutputContracts = buildCompactOutputContracts(artifact); const compactArtifact = {}; + const sourceProvider = artifact.sourceProvider || artifact.source; + if ( + sourceProvider === "sourcify" || + sourceProvider === "etherscan" || + sourceProvider === "blockscout" + ) { + compactArtifact.sourceProvider = sourceProvider; + } if (compactSources) { compactArtifact.input = { sources: compactSources }; } diff --git a/scripts/simulator-bridge.mjs b/scripts/simulator-bridge.mjs index 0188ba0..2c3b49f 100644 --- a/scripts/simulator-bridge.mjs +++ b/scripts/simulator-bridge.mjs @@ -362,7 +362,10 @@ const server = http.createServer(async (req, res) => { null; const storageLayout = extractStorageLayoutFromArtifact(artifact, cName); + const sourceProvider = + artifact.sourceProvider || artifact.source || null; rawTrace.artifacts[addr] = { + ...(sourceProvider ? { sourceProvider } : {}), ...(artifact.meta ? { meta: artifact.meta } : {}), ...(storageLayout ? { storageLayout } : {}), }; diff --git a/scripts/trace-processing.mjs b/scripts/trace-processing.mjs index c13a915..f8bbd73 100644 --- a/scripts/trace-processing.mjs +++ b/scripts/trace-processing.mjs @@ -422,7 +422,9 @@ export function parseSimulationResult(raw) { const meta = artifact.meta || null; const cName = meta?.ContractName || meta?.Name || null; const storageLayout = extractStorageLayoutFromArtifact(artifact, cName); + const sourceProvider = artifact.sourceProvider || artifact.source || null; rawTrace.artifacts[addr] = { + ...(sourceProvider ? { sourceProvider } : {}), ...(meta ? { meta } : {}), ...(storageLayout ? { storageLayout } : {}), }; diff --git a/src/components/ExecutionStackTrace.tsx b/src/components/ExecutionStackTrace.tsx index f5aec7f..26c6b50 100644 --- a/src/components/ExecutionStackTrace.tsx +++ b/src/components/ExecutionStackTrace.tsx @@ -13,6 +13,31 @@ import { } from "./ui/accordion"; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "./ui/table"; import { extractTokenMovements } from "../utils/tokenMovements"; +import { CurrencyBtc, CurrencyEth, Coin } from "@phosphor-icons/react"; +import { getChainById } from "../chains/registry"; + +/** + * Pick a native-asset glyph for a given chain. Mezo (testnet 31611, mainnet + * 31612) is BTC; EVM L1/L2s default to ETH; anything unknown falls back to a + * generic coin icon. + */ +function NativeAssetIcon({ + chainId, + symbol, +}: { + chainId?: number; + symbol?: string | null; +}) { + const resolvedSymbol = + symbol ?? (chainId ? getChainById(chainId)?.nativeCurrency.symbol : undefined); + if (resolvedSymbol === "BTC") { + return ; + } + if (resolvedSymbol === "ETH") { + return ; + } + return ; +} // Re-export types for backward compatibility export type { TraceRow, TraceFilters, DecodedLogData } from "./execution-trace"; @@ -252,7 +277,13 @@ const ExecutionStackTrace: React.FC = (props) => { {change.address ? `${change.address.slice(0, 10)}\u2026${change.address.slice(-8)}` : "\u2014"} - {change.symbol || "Unknown"} + + + {change.symbol || "Unknown"} + {change.amount || change.rawAmount || "0"} diff --git a/src/components/simulation-results/ContractsTab.tsx b/src/components/simulation-results/ContractsTab.tsx index 07982d4..b88fb79 100644 --- a/src/components/simulation-results/ContractsTab.tsx +++ b/src/components/simulation-results/ContractsTab.tsx @@ -32,6 +32,9 @@ const explorerBase: Record = { 100: { etherscan: 'https://gnosisscan.io', etherscanName: 'Gnosisscan', blockscout: 'https://gnosis.blockscout.com', blockscoutName: 'Blockscout' }, }; +const isSourceProvider = (value: unknown): value is Exclude => + value === 'sourcify' || value === 'etherscan' || value === 'blockscout'; + export const ContractsTab: React.FC = ({ result, contractContext }) => { const navigate = useNavigate(); @@ -80,11 +83,18 @@ export const ContractsTab: React.FC = ({ result, contractCont const hasOpcodeLines = !!(traceOpcodeLines[addr] || traceOpcodeLines[normalized]); if (hasOpcodeLines || (artifact && (artifact.sourceProvider || artifact.meta || (artifact.sources && typeof artifact.sources === 'object' && Object.keys(artifact.sources).length > 0) || (artifact.input?.sources && typeof artifact.input.sources === 'object' && Object.keys(artifact.input.sources).length > 0)))) { - // Determine source provider - if (artifact?.sourceProvider && (artifact.sourceProvider === 'sourcify' || artifact.sourceProvider === 'etherscan' || artifact.sourceProvider === 'blockscout')) { + if (isSourceProvider(artifact?.sourceProvider)) { return { verified: true, sourceProvider: artifact.sourceProvider }; } + if (isSourceProvider(artifact?.source)) { + return { verified: true, sourceProvider: artifact.source }; + } if (artifact?.meta) { + const metaProvider = + artifact.meta.SourceProvider || artifact.meta.sourceProvider || artifact.meta.source; + if (isSourceProvider(metaProvider)) { + return { verified: true, sourceProvider: metaProvider }; + } if (artifact.meta.CompilerVersion || artifact.meta.SwarmSource !== undefined) { return { verified: true, sourceProvider: 'etherscan' }; } @@ -109,10 +119,13 @@ export const ContractsTab: React.FC = ({ result, contractCont // Patch contracts: re-derive verification from trace artifacts when saved data is stale const simulationContracts = rawSimulationContracts.map(c => { - if (c.verified) return c; const derived = deriveVerificationFromArtifacts(c.address); if (derived.verified) { - return { ...c, verified: true, sourceProvider: derived.sourceProvider }; + return { + ...c, + verified: true, + sourceProvider: derived.sourceProvider ?? c.sourceProvider, + }; } return c; }); diff --git a/src/components/simulation-results/EventsTab.tsx b/src/components/simulation-results/EventsTab.tsx index e4e2ef3..7d806c0 100644 --- a/src/components/simulation-results/EventsTab.tsx +++ b/src/components/simulation-results/EventsTab.tsx @@ -36,6 +36,48 @@ export const EventsTab: React.FC = ({ const n = Number(value); return Number.isFinite(n) ? n : undefined; }; + const normalizeTopic = (topic: unknown): string => { + const hex = String(topic ?? "").replace(/^0x/, ""); + return "0x" + hex.padStart(64, "0"); + }; + const rawDataFromMemory = (row: any): string => { + if (!row?.logInfo || !Array.isArray(row?.memory)) return "0x"; + const off = Number(BigInt(row.logInfo.offset || 0)); + const len = Number(BigInt(row.logInfo.size || 0)); + const start = Math.max(0, off); + const end = Math.min(row.memory.length, start + len); + if (end <= start) return "0x"; + return ( + "0x" + + row.memory + .slice(start, end) + .map((b: any) => { + const n = Number(b) & 0xff; + return n.toString(16).padStart(2, "0"); + }) + .join("") + ); + }; + const eventTopics = (event: any): string[] => { + const topics = + event?.data?.topics || + event?.topics || + event?.logInfo?.topics || + []; + return Array.isArray(topics) ? topics.map(normalizeTopic) : []; + }; + const eventData = (event: any): string => + event?.data?.data || + (typeof event?.data === "string" ? event.data : undefined) || + event?.rawData || + "0x"; + const eventKey = (event: any, fallbackIndex: number): string => { + const address = firstAddress(event?.address, event?.data?.address, event?.logInfo?.address) || ""; + const topics = eventTopics(event).join(","); + const data = eventData(event); + if (!address && !topics && !data) return `event-${fallbackIndex}`; + return `${address.toLowerCase()}|${topics}|${data}`; + }; const resolveTraceId = (row: any): number | undefined => { if (typeof row?.traceId === "number") return row.traceId; if (Array.isArray(row?.frame_id) && row.frame_id.length > 0) { @@ -99,33 +141,33 @@ export const EventsTab: React.FC = ({ }); } - // Extract events from decoded trace rows (LOG opcodes with decodedLog) + // Collect all ABIs for fallback decoding + const allAbis: any[] = []; + if (contractContext?.abi) { + allAbis.push(contractContext.abi); + } + if (contractContext?.diamondFacets) { + contractContext.diamondFacets.forEach((facet: any) => { + if (facet.abi) { + allAbis.push(facet.abi); + } + }); + } + + // Extract events from decoded trace rows. Even without ABI/source metadata, + // LOG rows still carry topics and sometimes data; surface those as raw events. const traceEvents: any[] = []; if (decodedTrace?.rows) { decodedTrace.rows.forEach((row: any) => { - if (row.name?.startsWith("LOG") && row.decodedLog) { - const rawTopics = (row.logInfo?.topics || []).map((t: any) => { - const hex = String(t).replace(/^0x/, ""); - return "0x" + hex.padStart(64, "0"); - }); - - let rawData = ""; - if (row.logInfo && row.memory) { - const memArr = Array.isArray(row.memory) ? row.memory : []; - const off = Number(BigInt(row.logInfo.offset || 0)); - const len = Number(BigInt(row.logInfo.size || 0)); - const start = Math.max(0, off); - const end = Math.min(memArr.length, start + len); - const slice = memArr.slice(start, end); - rawData = - "0x" + - slice - .map((b: any) => { - const n = Number(b) & 0xff; - return n.toString(16).padStart(2, "0"); - }) - .join(""); - } + if (row.name?.startsWith("LOG") && row.logInfo) { + const rawTopics = (row.logInfo?.topics || []).map(normalizeTopic); + const rawData = rawDataFromMemory(row); + const decoded = + row.decodedLog || + decodeRawEvent( + { topics: rawTopics, rawData, logInfo: row.logInfo }, + allAbis, + ); const traceId = resolveTraceId(row); const frameMeta = traceId !== undefined ? traceMetaByTraceId.get(traceId) : undefined; @@ -153,12 +195,16 @@ export const EventsTab: React.FC = ({ : frameMeta?.targetContractName || frameMeta?.codeContractName); traceEvents.push({ - eventName: row.decodedLog.name || "Event", - eventArgs: row.decodedLog.args, + eventName: + decoded?.name || + row.eventFallback?.name || + (rawTopics[0] ? "Anonymous Event" : "Event"), + eventArgs: decoded?.args || row.decodedLog?.args, + eventSignature: decoded?.signature, address: eventAddress, logInfo: row.logInfo, contractName: eventContractName, - source: row.decodedLog.source, + source: row.decodedLog?.source || (decoded ? "abi" : "raw-log"), topics: rawTopics, rawData: rawData, }); @@ -166,21 +212,41 @@ export const EventsTab: React.FC = ({ }); } - const sourceEvents = - traceEvents.length > 0 ? traceEvents : artifacts?.events || []; + const rawTraceEvents = Array.isArray(decodedTrace?.rawEvents) + ? decodedTrace.rawEvents.map((event: any) => { + const normalizedAddress = event?.address?.toLowerCase(); + const displayContractName = + event?.contractName || + (normalizedAddress ? contractNameByAddress.get(normalizedAddress) : undefined) || + (normalizedAddress && proxyAddress && normalizedAddress === proxyAddress + ? contractContext?.name + : undefined); - // Collect all ABIs for fallback decoding - const allAbis: any[] = []; - if (contractContext?.abi) { - allAbis.push(contractContext.abi); - } - if (contractContext?.diamondFacets) { - contractContext.diamondFacets.forEach((facet: any) => { - if (facet.abi) { - allAbis.push(facet.abi); - } - }); - } + return { + ...event, + address: event?.address, + contractName: displayContractName, + topics: eventTopics(event), + rawData: eventData(event), + data: { + ...(event && typeof event === "object" ? event : {}), + topics: eventTopics(event), + data: eventData(event), + }, + }; + }) + : []; + + const sourceEvents = [ + ...traceEvents, + ...rawTraceEvents, + ...(Array.isArray(artifacts?.events) ? artifacts.events : []), + ].filter((event, index, allEvents) => { + const key = eventKey(event, index); + return allEvents.findIndex((candidate, candidateIndex) => + eventKey(candidate, candidateIndex) === key + ) === index; + }); // Process events const processedEvents = sourceEvents.map((event: any, index: number) => { diff --git a/src/components/simulation-results/SummaryTab.tsx b/src/components/simulation-results/SummaryTab.tsx index 5d0acc4..463951e 100644 --- a/src/components/simulation-results/SummaryTab.tsx +++ b/src/components/simulation-results/SummaryTab.tsx @@ -1,4 +1,5 @@ import React, { Suspense } from "react"; +import { Info } from "@phosphor-icons/react"; import type { SimulationResult } from "../../types/transaction"; import type { TraceRow, TraceFilters } from "../ExecutionStackTrace"; import LoadingSpinner from "../shared/LoadingSpinner"; @@ -55,28 +56,36 @@ export const SummaryTab: React.FC = ({ }) => { return ( <> - {/* Warnings from EDB */} + {/* Simulator notes (methodology disclosures from EDB local replay) */} {result?.warnings && result.warnings.length > 0 && ( -
- Warning: Simulation Warnings: -
    - {result.warnings.map((warning: any, i: number) => ( -
  • - {typeof warning === "string" - ? warning.length > 200 - ? warning.slice(0, 200) + "\u2026" +
    +
    + + Simulator notes +
    +
      + {result.warnings.map((warning: any, i: number) => { + const text = + typeof warning === "string" + ? warning.length > 240 + ? warning.slice(0, 240) + "\u2026" : warning - : JSON.stringify(warning)} - - ))} + : JSON.stringify(warning); + return ( +
    • + + {text} +
    • + ); + })}
    -
+ )} {/* Stack Trace - Rich Error Display */} {errorMessage && ( diff --git a/src/components/simulation-results/eventDecoder.ts b/src/components/simulation-results/eventDecoder.ts index d5dbec1..f07f69d 100644 --- a/src/components/simulation-results/eventDecoder.ts +++ b/src/components/simulation-results/eventDecoder.ts @@ -67,6 +67,8 @@ export function decodeRawEvent( } catch { // Not JSON, use as-is } + } else if (typeof event.data === 'string') { + rawData = event.data; } if (!rawTopics && event.topics) { diff --git a/src/components/simulation-results/useSimulationPageState.ts b/src/components/simulation-results/useSimulationPageState.ts index eeda6ff..39ecbfe 100644 --- a/src/components/simulation-results/useSimulationPageState.ts +++ b/src/components/simulation-results/useSimulationPageState.ts @@ -233,9 +233,63 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { const events = useMemo(() => artifacts?.events ?? [], [artifacts?.events]); const storageDiffs = useMemo(() => artifacts?.storageDiffs ?? [], [artifacts?.storageDiffs]); + // ---- Persist decoded trace ---- + const persistDecodedTrace = useCallback( + async (decoded: any, simulationId: string) => { + const hasJumpRows = decoded?.rows?.some((r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall); + const jumpRowCount = decoded?.rows?.filter((r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall).length ?? 0; + + try { + const existingTrace = await traceVaultService.loadDecodedTrace(simulationId, { includeHeavy: false }); + const existingJumpCount = existingTrace?.rows?.filter((r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall).length ?? 0; + + if (existingJumpCount > 0 && jumpRowCount === 0) return; + + const saved = await traceVaultService.saveDecodedTrace(simulationId, decoded); + const rowsToStore = saved?.lite?.rows ?? decoded.rows; + const { simulationHistoryService } = await import("../../services/SimulationHistoryService"); + await simulationHistoryService.updateSimulationDecodedRows(simulationId, rowsToStore, { + maxRetries: 6, delayMs: 150, + }); + } catch (err) { + console.error("[SimulationResults] Failed to persist trace:", err); + } + }, + [] + ); + + const { decodedTrace, isDecoding: isTraceDecoding } = useDecodedTrace({ + result, id, contextDecodedTraceRows, contractContext, + traceMeta: decodedTraceMeta, onDecoded: persistDecodedTrace, + decodeMode: "lite", + }); + + const eventLookupCandidates = useMemo(() => { + const candidates: any[] = [...events]; + + if (Array.isArray(decodedTrace?.rawEvents)) { + candidates.push(...decodedTrace.rawEvents); + } + + if (Array.isArray(decodedTrace?.rows)) { + decodedTrace.rows.forEach((row: any) => { + if (!row?.name?.startsWith("LOG") || !row?.logInfo?.topics?.length) { + return; + } + candidates.push({ + topics: row.logInfo.topics, + logInfo: row.logInfo, + rawData: "0x", + }); + }); + } + + return candidates; + }, [decodedTrace?.rawEvents, decodedTrace?.rows, events]); + // ---- Event signature lookup ---- useEffect(() => { - if (activeTab !== 'events' || events.length === 0) return; + if (activeTab !== 'events' || eventLookupCandidates.length === 0) return; const lookupUnknownEvents = async () => { const cachedSignatures = getCachedSignatures('event'); @@ -247,7 +301,7 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { } const unknownTopics: string[] = []; - events.forEach((event: any) => { + eventLookupCandidates.forEach((event: any) => { if (event.name && event.name !== 'Anonymous Event') return; let topic0: string | null = null; if (event.data?.topics?.[0]) topic0 = String(event.data.topics[0]); @@ -294,38 +348,7 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { }; lookupUnknownEvents(); - }, [activeTab, events, contractContext, lookedUpEventNames]); - - // ---- Persist decoded trace ---- - const persistDecodedTrace = useCallback( - async (decoded: any, simulationId: string) => { - const hasJumpRows = decoded?.rows?.some((r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall); - const jumpRowCount = decoded?.rows?.filter((r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall).length ?? 0; - - try { - const existingTrace = await traceVaultService.loadDecodedTrace(simulationId, { includeHeavy: false }); - const existingJumpCount = existingTrace?.rows?.filter((r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall).length ?? 0; - - if (existingJumpCount > 0 && jumpRowCount === 0) return; - - const saved = await traceVaultService.saveDecodedTrace(simulationId, decoded); - const rowsToStore = saved?.lite?.rows ?? decoded.rows; - const { simulationHistoryService } = await import("../../services/SimulationHistoryService"); - await simulationHistoryService.updateSimulationDecodedRows(simulationId, rowsToStore, { - maxRetries: 6, delayMs: 150, - }); - } catch (err) { - console.error("[SimulationResults] Failed to persist trace:", err); - } - }, - [] - ); - - const { decodedTrace, isDecoding: isTraceDecoding } = useDecodedTrace({ - result, id, contextDecodedTraceRows, contractContext, - traceMeta: decodedTraceMeta, onDecoded: persistDecodedTrace, - decodeMode: "lite", - }); + }, [activeTab, eventLookupCandidates, contractContext, lookedUpEventNames]); const buildReplayDebugPrepRequest = useCallback(() => { const resultWithExtras = result as (typeof result & SimulationResultExtras) | null; diff --git a/src/utils/edbTraceConverter.ts b/src/utils/edbTraceConverter.ts index 03c77ff..d293c15 100644 --- a/src/utils/edbTraceConverter.ts +++ b/src/utils/edbTraceConverter.ts @@ -209,13 +209,23 @@ export const buildOpcodeTraceFromTraceLiteRows = ( // ---- EDB trace -> artifacts ----------------------------------------- +export interface ConvertEdbTraceOptions { + /** Native asset symbol for this chain (e.g. "BTC" on Mezo). Defaults to "ETH". */ + nativeSymbol?: string; + /** Native asset full name (e.g. "Bitcoin" on Mezo). Defaults to "Ether". */ + nativeName?: string; +} + export const convertEdbTraceToArtifacts = ( traceEntries: any[], + options: ConvertEdbTraceOptions = {}, ): { callTree: SimulationCallNode[]; events: SimulationEventEntry[]; assetChanges: SimulationAssetChangeEntry[]; } => { + const nativeSymbol = options.nativeSymbol || "ETH"; + const nativeName = options.nativeName || "Ether"; type InternalNode = SimulationCallNode & { __internalId: number; __children?: InternalNode[]; @@ -224,9 +234,18 @@ export const convertEdbTraceToArtifacts = ( const nodes = new Map(); const childrenBucket = new Map(); const events: SimulationEventEntry[] = []; + const seenEventKeys = new Set(); const assetChanges: SimulationAssetChangeEntry[] = []; + const nativeDeltas = new Map< + string, + { + address: string; + delta: bigint; + counterparties: Set; + } + >(); - const recordEthTransfer = ( + const recordNativeDelta = ( address: string | undefined, value: ethers.BigNumber, direction: "in" | "out", @@ -235,17 +254,75 @@ export const convertEdbTraceToArtifacts = ( if (!address || value.isZero()) { return; } - const formatted = ethers.utils.formatEther(value); - const prefix = direction === "in" ? "+" : "-"; - assetChanges.push({ - address, - symbol: "ETH", - name: "Ether", - amount: `${prefix}${formatted}`, - rawAmount: value.toString(), - direction, - counterparty, - }); + const normalized = normalizeTraceAddress(address) ?? address; + const key = normalized.toLowerCase(); + const current = + nativeDeltas.get(key) ?? + { + address: normalized, + delta: 0n, + counterparties: new Set(), + }; + const amount = BigInt(value.toString()); + current.delta += direction === "in" ? amount : -amount; + if (counterparty) { + current.counterparties.add(counterparty); + } + nativeDeltas.set(key, current); + }; + + const eventTopics = (evt: any): string[] => { + const topics = evt?.topics ?? evt?.data?.topics ?? evt?.logInfo?.topics ?? []; + return Array.isArray(topics) + ? topics.map((topic) => { + const hex = String(topic ?? "").replace(/^0x/i, ""); + return `0x${hex.padStart(64, "0")}`.toLowerCase(); + }) + : []; + }; + + const eventData = (evt: any): string => { + const data = + evt?.data?.data ?? + (typeof evt?.data === "string" ? evt.data : undefined) ?? + evt?.rawData ?? + "0x"; + return String(data).toLowerCase(); + }; + + const eventIdentity = ( + evt: any, + address: string | undefined, + frameId: number | undefined, + fallbackIndex: number, + ): string => { + const txHash = evt?.transactionHash ?? evt?.txHash ?? evt?.transaction_hash; + const logIndex = evt?.logIndex ?? evt?.log_index; + const blockNumber = evt?.blockNumber ?? evt?.block_number; + if (txHash !== undefined && logIndex !== undefined) { + return [ + "indexed", + String(blockNumber ?? ""), + String(txHash).toLowerCase(), + String(logIndex), + ].join("|"); + } + // Always include the emitting call-frame id and the event's position + // within that frame. Without these two coordinates, identical Transfers + // from a bulk-transfer loop collapse to one row. + const topics = eventTopics(evt).join(","); + const data = eventData(evt); + if (!address && !topics && data === "0x") { + return `fallback|${frameId ?? "?"}|${fallbackIndex}`; + } + return [ + "content", + String(frameId ?? "?"), + String(fallbackIndex), + (address ?? "").toLowerCase(), + topics, + data, + ].join("|"); }; traceEntries.forEach((entryRaw: any) => { @@ -325,12 +402,21 @@ export const convertEdbTraceToArtifacts = ( const entryEvents = ensureArray( entryRaw.events ?? entryRaw.logs ?? entryRaw.event_logs, ); - entryEvents.forEach((evt: any) => { + entryEvents.forEach((evt: any, eventIndex: number) => { + const eventAddress = normalizeTraceAddress( + evt?.address ?? evt?.data?.address ?? evt?.logInfo?.address ?? node.to, + ); + const key = eventIdentity(evt, eventAddress, id, eventIndex); + if (seenEventKeys.has(key)) { + return; + } + seenEventKeys.add(key); events.push({ name: evt?.name ?? evt?.event, signature: evt?.signature, - address: evt?.address ?? node.to, + address: eventAddress, decoded: evt?.args ?? evt?.decoded, + topics: eventTopics(evt), data: evt, }); }); @@ -339,9 +425,30 @@ export const convertEdbTraceToArtifacts = ( entryRaw.value ?? entryRaw.transfer_value ?? entryRaw.amount; const valueBigNumber = parseTraceValue(transferValue); if (valueBigNumber && !valueBigNumber.isZero()) { - recordEthTransfer(fromAddress, valueBigNumber, "out", toAddress); - recordEthTransfer(toAddress, valueBigNumber, "in", fromAddress); + recordNativeDelta(fromAddress, valueBigNumber, "out", toAddress); + recordNativeDelta(toAddress, valueBigNumber, "in", fromAddress); + } + }); + + nativeDeltas.forEach(({ address, delta, counterparties }) => { + if (delta === 0n) { + return; } + const direction = delta > 0n ? "in" : "out"; + const absolute = delta > 0n ? delta : -delta; + const rawAmount = absolute.toString(); + const formatted = ethers.utils.formatEther(rawAmount); + const prefix = direction === "in" ? "+" : "-"; + assetChanges.push({ + address, + symbol: nativeSymbol, + name: nativeName, + amount: `${prefix}${formatted}`, + rawAmount, + direction, + counterparty: + counterparties.size === 1 ? Array.from(counterparties)[0] : undefined, + }); }); traceEntries.forEach((entryRaw: any) => { diff --git a/src/utils/resolver/sources/blockscout.ts b/src/utils/resolver/sources/blockscout.ts index d1692b7..aaff9dc 100644 --- a/src/utils/resolver/sources/blockscout.ts +++ b/src/utils/resolver/sources/blockscout.ts @@ -37,10 +37,43 @@ const CHAIN_PROXIES: Record = { 1135: '/api/lisk-blockscout', 4202: '/api/lisk-sepolia-blockscout', 8453: '/api/blockscout', // Base mainnet uses default blockscout proxy + 31611: '/api/mezo-testnet-blockscout', + 31612: '/api/mezo-blockscout', }; const getProxy = (chainId: number): string => CHAIN_PROXIES[chainId] || '/api/blockscout'; +const normalizeBase = (base: string): string => base.replace(/\/+$/, ''); + +const buildV2SmartContractUrl = (base: string, address: string): string => { + const cleanBase = normalizeBase(base); + + if (/\/api\/v2$/i.test(cleanBase)) { + return `${cleanBase}/smart-contracts/${address}`; + } + + if (/\/api$/i.test(cleanBase) || cleanBase.startsWith('/api/')) { + return `${cleanBase}/v2/smart-contracts/${address}`; + } + + return `${cleanBase}/api/v2/smart-contracts/${address}`; +}; + +const buildV1SourceUrl = (base: string, address: string): string => { + const cleanBase = normalizeBase(base); + let apiBase: string; + + if (/\/api\/v2$/i.test(cleanBase)) { + apiBase = cleanBase.replace(/\/v2$/i, ''); + } else if (/\/api$/i.test(cleanBase) || cleanBase.startsWith('/api/')) { + apiBase = cleanBase; + } else { + apiBase = `${cleanBase}/api`; + } + + return `${apiBase}?module=contract&action=getsourcecode&address=${address}`; +}; + const extractContractName = (data: unknown): string | null => { if (!data || typeof data !== 'object') return null; @@ -227,7 +260,7 @@ export async function fetchBlockscout( return { success: false, error: 'Aborted' }; } - const v2Url = `${base.replace(/\/$/, '')}/v2/smart-contracts/${normalizedAddress}`; + const v2Url = buildV2SmartContractUrl(base, normalizedAddress); try { const response = await fetch(v2Url, { @@ -258,7 +291,7 @@ export async function fetchBlockscout( lastError = error instanceof Error ? error.message : String(error); } - const v1Url = `${base.replace(/\/$/, '')}?module=contract&action=getsourcecode&address=${normalizedAddress}`; + const v1Url = buildV1SourceUrl(base, normalizedAddress); try { const response = await fetch(v1Url, { diff --git a/src/utils/simulationArtifacts.ts b/src/utils/simulationArtifacts.ts index 6227cfe..582b583 100644 --- a/src/utils/simulationArtifacts.ts +++ b/src/utils/simulationArtifacts.ts @@ -38,6 +38,7 @@ export { } from "./edbTraceConverter"; import { convertEdbTraceToArtifacts, normalizeAssetChangeEntry, buildOpcodeTraceFromTraceLiteRows } from "./edbTraceConverter"; +import { getChainById } from "../chains/registry"; // ---- shared utilities ------------------------------------------------- @@ -155,6 +156,12 @@ export const extractSimulationArtifacts = ( const rawTrace = result.rawTrace; const traceLiteRows = ensureArray((result as any)?.traceLite?.rows); + const chainId = result.chainId; + const chain = typeof chainId === "number" ? getChainById(chainId) : undefined; + const convertOptions = { + nativeSymbol: chain?.nativeCurrency.symbol, + nativeName: chain?.nativeCurrency.name, + }; if (rawTrace === null || rawTrace === undefined) { if (traceLiteRows.length > 0) { artifacts.opcodeTrace = buildOpcodeTraceFromTraceLiteRows(traceLiteRows); @@ -176,7 +183,7 @@ export const extractSimulationArtifacts = ( artifacts.rawPayload = null; } } - const converted = convertEdbTraceToArtifacts(rawTrace); + const converted = convertEdbTraceToArtifacts(rawTrace, convertOptions); artifacts.callTree = converted.callTree; artifacts.events = converted.events; artifacts.assetChanges = converted.assetChanges; @@ -209,7 +216,7 @@ export const extractSimulationArtifacts = ( } if (innerTraceEntries.length > 0) { - const converted = convertEdbTraceToArtifacts(innerTraceEntries); + const converted = convertEdbTraceToArtifacts(innerTraceEntries, convertOptions); artifacts.callTree = converted.callTree; artifacts.events = converted.events; artifacts.assetChanges = converted.assetChanges; diff --git a/src/utils/tokenMovements.ts b/src/utils/tokenMovements.ts index 6e36db1..fcd468f 100644 --- a/src/utils/tokenMovements.ts +++ b/src/utils/tokenMovements.ts @@ -392,6 +392,12 @@ setTokenMetadataCache("0xaf88d065e77c8cC2239327C5EDb3A432268e5831", { symbol: "U setTokenMetadataCache("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", { symbol: "USDT", name: "Tether USD", decimals: 6 }); setTokenMetadataCache("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", { symbol: "WETH", name: "Wrapped Ether", decimals: 18 }); +// Mezo (Testnet 31611 + Mainnet 31612) +setTokenMetadataCache("0x7B7c000000000000000000000000000000000001", { symbol: "MEZO", name: "Mezo", decimals: 18 }); +setTokenMetadataCache("0x7b7C000000000000000000000000000000000000", { symbol: "BTC", name: "Bitcoin", decimals: 18 }); +setTokenMetadataCache("0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503", { symbol: "MUSD", name: "Mezo USD", decimals: 18 }); +setTokenMetadataCache("0x6f461c68B2c5492C0F5CCEc5a264d692aA7A8e16", { symbol: "sMUSD", name: "Savings MUSD", decimals: 18 }); + /** * Parse a log event to detect token transfers */ diff --git a/src/utils/transaction-simulation/artifactFetching.ts b/src/utils/transaction-simulation/artifactFetching.ts index 6fae563..4657138 100644 --- a/src/utils/transaction-simulation/artifactFetching.ts +++ b/src/utils/transaction-simulation/artifactFetching.ts @@ -21,6 +21,8 @@ export const BLOCKSCOUT_INSTANCES: Record = { 137: 'https://polygon.blockscout.com', 100: 'https://gnosis.blockscout.com', 56: 'https://bsc.blockscout.com', + 31611: 'https://api.explorer.test.mezo.org', + 31612: 'https://api.explorer.mezo.org', }; export const artifactCache = new Map(); diff --git a/src/utils/transaction-simulation/bridgeSimulation.ts b/src/utils/transaction-simulation/bridgeSimulation.ts index 8131a57..9de14e1 100644 --- a/src/utils/transaction-simulation/bridgeSimulation.ts +++ b/src/utils/transaction-simulation/bridgeSimulation.ts @@ -566,6 +566,7 @@ export const trySimulatorBridge = async ( : null; artifactsInline[addr] = { + ...(artifact.sourceProvider ? { sourceProvider: artifact.sourceProvider } : {}), input: { language: "Solidity", sources: sourcesObj, diff --git a/src/utils/transaction-simulation/responseParsing.ts b/src/utils/transaction-simulation/responseParsing.ts index 2955a9d..3de416b 100644 --- a/src/utils/transaction-simulation/responseParsing.ts +++ b/src/utils/transaction-simulation/responseParsing.ts @@ -25,6 +25,9 @@ const SELECTOR_LOOKUP_TIMEOUT_MS = 3000; // Sentinel used to distinguish a timeout result from a genuine null result. const TIMEOUT_SENTINEL = Symbol('timeout'); +const isSourceProvider = (value: unknown): value is 'sourcify' | 'etherscan' | 'blockscout' => + value === 'sourcify' || value === 'etherscan' || value === 'blockscout'; + async function resolveErrorSelectorName(selector: string): Promise { const normalized = selector.toLowerCase(); if (errorSelectorNameCache.has(normalized)) { @@ -97,16 +100,22 @@ export const buildContractsFromTrace = (rawTrace: any): SimulationContract[] => const getSourceProvider = (addr: string): 'sourcify' | 'etherscan' | 'blockscout' | null => { const artifact = artifacts[addr] || artifacts[addr.toLowerCase()]; // Check direct sourceProvider field first (most reliable — set during artifact fetching) - if (artifact?.sourceProvider && - (artifact.sourceProvider === 'sourcify' || artifact.sourceProvider === 'etherscan' || artifact.sourceProvider === 'blockscout')) { + if (isSourceProvider(artifact?.sourceProvider)) { return artifact.sourceProvider; } + if (isSourceProvider(artifact?.source)) { + return artifact.source; + } if (!artifact?.meta) { if (opcodeLinesAddresses.has(addr.toLowerCase())) { return 'sourcify'; } return null; } + const metaProvider = artifact.meta.SourceProvider || artifact.meta.sourceProvider || artifact.meta.source; + if (isSourceProvider(metaProvider)) { + return metaProvider; + } // Infer from meta field naming conventions if (artifact.meta.CompilerVersion || artifact.meta.SwarmSource !== undefined) { return 'etherscan'; @@ -228,7 +237,15 @@ export const prewarmCacheFromTrace = (rawTrace: any, chainId: number | null): vo } catch { /* ignore parse errors */ } let source: 'sourcify' | 'etherscan' | 'blockscout' = 'sourcify'; - if (artifact.meta.CompilerVersion || artifact.meta.SwarmSource !== undefined) { + const explicitSource = + artifact.sourceProvider || + artifact.source || + artifact.meta.SourceProvider || + artifact.meta.sourceProvider || + artifact.meta.source; + if (isSourceProvider(explicitSource)) { + source = explicitSource; + } else if (artifact.meta.CompilerVersion || artifact.meta.SwarmSource !== undefined) { source = 'etherscan'; } else if (artifact.meta.compiler_version) { source = 'blockscout'; From d5407dd76016a45a0e23bfbeb8cc8e40f5c92cd6 Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 21:12:57 +0100 Subject: [PATCH 06/18] =?UTF-8?q?feat:=20bump=20SimulationHistory=20IDB=20?= =?UTF-8?q?schema=20v2=20=E2=86=92=20v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idempotent onupgradeneeded migration ensures the meta + simulations stores have every needed index (timestamp, status, networkId, contractAddress, from, to, functionName) on both fresh installs and upgrades from v1/v2 without dropping existing entries. - Adds version-change handler so a second tab triggers a clean reopen - Refines SimulationHistoryPage retry path so a stale DB connection no longer wedges the "Failed to load" state --- src/components/SimulationHistoryPage.tsx | 10 +++- src/services/SimulationHistoryService.ts | 69 +++++++++++++----------- 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/components/SimulationHistoryPage.tsx b/src/components/SimulationHistoryPage.tsx index 25df900..773f8f3 100644 --- a/src/components/SimulationHistoryPage.tsx +++ b/src/components/SimulationHistoryPage.tsx @@ -154,8 +154,14 @@ const SimulationHistoryPage: React.FC = () => { // Use lightweight=true to avoid loading full result/contractContext into memory const sims = await simulationHistoryService.getSimulations(filter, true); setSimulations(sims); - } catch { - setError('Failed to load simulation history'); + } catch (err) { + const detail = + err instanceof DOMException + ? `${err.name}: ${err.message}` + : err instanceof Error + ? err.message + : String(err || 'Unknown IndexedDB error'); + setError(`Failed to load simulation history (${detail})`); } finally { setLoading(false); } diff --git a/src/services/SimulationHistoryService.ts b/src/services/SimulationHistoryService.ts index 3ebe96b..d7109f2 100644 --- a/src/services/SimulationHistoryService.ts +++ b/src/services/SimulationHistoryService.ts @@ -209,7 +209,7 @@ function shouldKeepExistingTraceRows( } const DB_NAME = 'web3-toolkit-simulations'; -const DB_VERSION = 2; +const DB_VERSION = 3; const STORE_NAME = 'simulations'; const META_STORE_NAME = 'simulations-meta'; const MAX_SIMULATIONS = 100; // Keep last 100 simulations @@ -234,6 +234,10 @@ class SimulationHistoryService { reject(request.error); }; + request.onblocked = () => { + reject(new Error('IndexedDB upgrade blocked by another open HexKit tab')); + }; + request.onsuccess = () => { this.db = request.result; // Handle version change from another tab @@ -249,31 +253,34 @@ class SimulationHistoryService { request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; - const oldVersion = event.oldVersion; - - // V1: Create simulations store - if (oldVersion < 1) { - const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); - store.createIndex('timestamp', 'timestamp', { unique: false }); - store.createIndex('status', 'status', { unique: false }); - store.createIndex('networkId', 'networkId', { unique: false }); - store.createIndex('contractAddress', 'contractAddress', { unique: false }); - store.createIndex('from', 'from', { unique: false }); - store.createIndex('to', 'to', { unique: false }); - store.createIndex('functionName', 'functionName', { unique: false }); - } + const tx = (event.target as IDBOpenDBRequest).transaction; + + const ensureIndex = ( + store: IDBObjectStore, + name: string, + keyPath: string, + ) => { + if (!store.indexNames.contains(name)) { + store.createIndex(name, keyPath, { unique: false }); + } + }; - // V2: Create simulations-meta store for fast lightweight queries - if (oldVersion < 2) { - const metaStore = db.createObjectStore(META_STORE_NAME, { keyPath: 'id' }); - metaStore.createIndex('timestamp', 'timestamp', { unique: false }); - metaStore.createIndex('status', 'status', { unique: false }); - metaStore.createIndex('networkId', 'networkId', { unique: false }); - metaStore.createIndex('contractAddress', 'contractAddress', { unique: false }); - metaStore.createIndex('from', 'from', { unique: false }); - metaStore.createIndex('to', 'to', { unique: false }); - metaStore.createIndex('functionName', 'functionName', { unique: false }); - } + const ensureSimulationStore = (storeName: string) => { + const store = db.objectStoreNames.contains(storeName) + ? tx!.objectStore(storeName) + : db.createObjectStore(storeName, { keyPath: 'id' }); + + ensureIndex(store, 'timestamp', 'timestamp'); + ensureIndex(store, 'status', 'status'); + ensureIndex(store, 'networkId', 'networkId'); + ensureIndex(store, 'contractAddress', 'contractAddress'); + ensureIndex(store, 'from', 'from'); + ensureIndex(store, 'to', 'to'); + ensureIndex(store, 'functionName', 'functionName'); + }; + + ensureSimulationStore(STORE_NAME); + ensureSimulationStore(META_STORE_NAME); }; }).then(() => this.migrateMetaStore()); @@ -561,9 +568,9 @@ class SimulationHistoryService { filtered = sims.filter(sim => { if (filter.status && sim.status !== filter.status) return false; if (filter.networkId && sim.networkId !== filter.networkId) return false; - if (filter.contractAddress && sim.contractAddress.toLowerCase() !== filter.contractAddress.toLowerCase()) return false; - if (filter.from && sim.from.toLowerCase() !== filter.from.toLowerCase()) return false; - if (filter.to && sim.to.toLowerCase() !== filter.to.toLowerCase()) return false; + if (filter.contractAddress && String(sim.contractAddress || '').toLowerCase() !== filter.contractAddress.toLowerCase()) return false; + if (filter.from && String(sim.from || '').toLowerCase() !== filter.from.toLowerCase()) return false; + if (filter.to && String(sim.to || '').toLowerCase() !== filter.to.toLowerCase()) return false; if (filter.functionName && sim.functionName !== filter.functionName) return false; if (filter.fromTimestamp && sim.timestamp < filter.fromTimestamp) return false; if (filter.toTimestamp && sim.timestamp > filter.toTimestamp) return false; @@ -610,9 +617,9 @@ class SimulationHistoryService { if (filter) { if (filter.status && sim.status !== filter.status) include = false; if (filter.networkId && sim.networkId !== filter.networkId) include = false; - if (filter.contractAddress && sim.contractAddress.toLowerCase() !== filter.contractAddress.toLowerCase()) include = false; - if (filter.from && sim.from.toLowerCase() !== filter.from.toLowerCase()) include = false; - if (filter.to && sim.to.toLowerCase() !== filter.to.toLowerCase()) include = false; + if (filter.contractAddress && String(sim.contractAddress || '').toLowerCase() !== filter.contractAddress.toLowerCase()) include = false; + if (filter.from && String(sim.from || '').toLowerCase() !== filter.from.toLowerCase()) include = false; + if (filter.to && String(sim.to || '').toLowerCase() !== filter.to.toLowerCase()) include = false; if (filter.functionName && sim.functionName !== filter.functionName) include = false; if (filter.fromTimestamp && sim.timestamp < filter.fromTimestamp) include = false; if (filter.toTimestamp && sim.timestamp > filter.toTimestamp) include = false; From 05a79816bd877d986c1c66eac014e2455e58e69b Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 21:13:35 +0100 Subject: [PATCH 07/18] chore: tighten LI.FI Earn types and remove unused branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive-by cleanup encountered while building Mezo Lens — the touched files are unrelated paths that benefited from minor type tightening and removal of dead conditional branches. Behavior unchanged. --- .../integrations/lifi-earn/WithdrawFlow.tsx | 11 +++++------ .../lifi-earn/concierge/ExecutionQueue.tsx | 11 +++-------- .../lifi-earn/concierge/executionMachine.ts | 10 ++++------ .../concierge/intent/RebalancePlanCard.tsx | 13 ++++++------- 4 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/components/integrations/lifi-earn/WithdrawFlow.tsx b/src/components/integrations/lifi-earn/WithdrawFlow.tsx index d4fbee4..504b029 100644 --- a/src/components/integrations/lifi-earn/WithdrawFlow.tsx +++ b/src/components/integrations/lifi-earn/WithdrawFlow.tsx @@ -225,12 +225,11 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF // Composer's redeem flow uses `fromToken = vault.share`, so it expects // `fromAmount` in SHARE-token decimals. We parse with shareDecimals to - // match that wire format. Note: the UI label still shows the underlying - // symbol (position.asset.symbol) because Earn surfaces position size in - // underlying terms; for vaults where 1 share ≈ 1 underlying (most stables) - // this is harmless, but for appreciated-share vaults the user's number - // will be interpreted as shares-not-underlying. Honest follow-up: convert - // via the live exchange rate, or relabel the input "shares". + // match that wire format. The UI label still shows the underlying symbol + // (position.asset.symbol) because Earn surfaces position size in + // underlying terms; for vaults where 1 share ≈ 1 underlying (most + // stables) this is harmless, but for appreciated-share vaults the user's + // number is interpreted as shares-not-underlying. const fromAmountForQuote = useMemo(() => { if (!amount) return null; try { diff --git a/src/components/integrations/lifi-earn/concierge/ExecutionQueue.tsx b/src/components/integrations/lifi-earn/concierge/ExecutionQueue.tsx index b03b506..204fa17 100644 --- a/src/components/integrations/lifi-earn/concierge/ExecutionQueue.tsx +++ b/src/components/integrations/lifi-earn/concierge/ExecutionQueue.tsx @@ -15,19 +15,14 @@ export function ExecutionQueue({ state, dispatch }: ExecutionQueueProps) { if (state.legs.length === 0) return null; const current = state.currentIndex >= 0 ? state.legs[state.currentIndex] : null; - // `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. + // Recoverable failures (Intent expired with refund still available; + // bridged-but-deposit-failed) keep their in-step affordances, so the queue + // must not treat them as terminal. Refunded is terminal-good. 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; diff --git a/src/components/integrations/lifi-earn/concierge/executionMachine.ts b/src/components/integrations/lifi-earn/concierge/executionMachine.ts index c4ed62b..12fed5f 100644 --- a/src/components/integrations/lifi-earn/concierge/executionMachine.ts +++ b/src/components/integrations/lifi-earn/concierge/executionMachine.ts @@ -83,13 +83,12 @@ function isTerminal(status: LegStatus): boolean { 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. + // Refunded is its own terminal status (positive outcome, no retry UI). if (normalized === "refunded") { return "refunded"; } - // Expired remains "failed + recoverable" — the user can still refund. + // Expired still maps to failed — recoverability is assigned downstream + // so the refund button stays reachable. if (normalized === "failed" || normalized === "expired") { return "failed"; } @@ -163,8 +162,7 @@ function applyExecutionEvent(leg: Leg, event: DepositExecutionEvent): Leg { 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. + // Only Expired stays recoverable so the refund button remains reachable. const recoverable = status === "failed" && normalized === "expired"; return { ...leg, diff --git a/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx b/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx index f6b6933..f40d2aa 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx +++ b/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx @@ -330,7 +330,7 @@ function LegRow({ Delivered on {explorerLabel(spec.destination.chainId)}. {run.deliveredAmount && spec.destination.outputSymbol && ( - ({formatRaw(run.deliveredAmount, decimalsFor(run, spec))}{" "} + ({formatRaw(run.deliveredAmount, decimalsFor(spec))}{" "} {spec.destination.outputSymbol}) )} @@ -450,8 +450,8 @@ function DepositButton({ ); } -// The status pill renders during all of these — keep timeline visible the -// whole time so the user sees the order lifecycle through to settlement. +// Keep the timeline visible across deposit phases so the user sees the +// order lifecycle through to settlement. function isTimelineActive(status: IntentLegRun["status"]): boolean { return ( status === "open" || @@ -468,14 +468,13 @@ 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. +function decimalsFor(spec: IntentLegSpec): number { + // The spec doesn't carry destination decimals — match by address against + // the vault's underlyingTokens, 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; } From dac6ec8004172ff42fbc12a670ad3e9fce617ca6 Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 21:17:42 +0100 Subject: [PATCH 08/18] feat: surface Mezo Lens in navigation, search, and docs - Add Mezo Lens entries to top nav, mobile drawer, integrations hub, and universal search so the new flow is discoverable - Route meta tags for /integrations/mezo (title, description, og) - Mezo logo + wordmark assets - public/mezo-lab/ standalone preview page used during prototyping - README documents the new integration - Drop a trailing-newline-only .gitignore tweak --- .gitignore | 1 - README.md | 24 + public/logos/mezo-wordmark.svg | 14 + public/logos/mezo.svg | 1 + public/mezo-lab/index.html | 783 ++++++++++++++++++ src/components/MobileDrawer.tsx | 1 + src/components/Navigation.tsx | 1 + .../integrations/IntegrationsHub.tsx | 4 + src/components/shared/RouteMetaTags.tsx | 5 + src/hooks/useUniversalSearch.ts | 1 + 10 files changed, 834 insertions(+), 1 deletion(-) create mode 100644 public/logos/mezo-wordmark.svg create mode 100644 public/logos/mezo.svg create mode 100644 public/mezo-lab/index.html diff --git a/.gitignore b/.gitignore index 7b8edfe..f621572 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Service account keys gen-lang-client-*.json - # Logs logs *.log diff --git a/README.md b/README.md index becc606..4947aad 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,30 @@ A full yield management layer powered by the LI.FI Earn API: - **Deposit / Withdraw Flows** -- Deposit into and withdraw from vaults directly through LI.FI's Composer API, which handles cross-chain swaps and bridging automatically. - **Vault Simulator** -- Forecast projected returns for any vault over a configurable time horizon before committing capital. +#### Mezo Lens + +DeFi on Mezo testnet (chain 31611): borrow MUSD against BTC, save it, lock MEZO for governance. + +- **Six action tabs**: Stack (composite onboarding flow), Borrow (Liquity-style CDP), Swap (v2 placeholder), Save (sMUSD deposit), Liquidity (v2 placeholder), Lock (veMEZO governance). +- **Bundle simulation before sign**: every parameter change triggers an `eth_simulateV1` round-trip against Mezo's RPC. The whole multi-leg sequence (up to 5 writes + appended view calls) executes server-side and returns end-state balances, ICR, liquidation price, and decoded leg outcomes. State chains across calls. +- **Testnet gauge emissions** report `rewardRate=0` and are displayed as such. +- **Canonical-MUSD guard**: Mezo testnet has two MUSD ERC-20 deployments. The docs `0x118917a4…` is the one bound to BorrowerOperations; another `0x637e22A1…` exists separately. The sidebar warns if you hold balance on the wrong one. +- **Shared dev tooling**: once Mezo is in the chain registry, the simulator, decoder, ABI fetcher, and storage-layout reader work against Mezo contracts. + +Demo path: + +1. Visit https://faucet.test.mezo.org/ for 0.05 BTC + 100 MEZO testnet drip. +2. Open `/integrations/mezo` and connect; the page prompts for the chain switch. +3. Stack tab: tweak collateral / debt / save / lock sliders. The "Before → After" panel updates from simulated state on each change. +4. Build Stack executes all 5 legs sequentially with Blockscout tx links per leg. + +`scripts/mezo-day-0-smoke.sh` runs the full write sequence (openTrove → MUSD.approve → sMUSD.deposit → MEZO.approve → VotingEscrow.createLock) against a throwaway wallet and emits testnet tx hashes for every leg. + +Integrations: + +- **MUSD**: open trove (mint canonical MUSD), `sMUSD.deposit` (savings vault), MUSD/BTC pool reads. +- **MEZO**: MEZO precompile reads, `VotingEscrow.createLock` to mint a veMEZO governance NFT. + #### Yield Concierge (AI-powered) An AI assistant that translates natural language yield goals into actionable vault recommendations: diff --git a/public/logos/mezo-wordmark.svg b/public/logos/mezo-wordmark.svg new file mode 100644 index 0000000..4f1d50d --- /dev/null +++ b/public/logos/mezo-wordmark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/logos/mezo.svg b/public/logos/mezo.svg new file mode 100644 index 0000000..da5a49a --- /dev/null +++ b/public/logos/mezo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/mezo-lab/index.html b/public/mezo-lab/index.html new file mode 100644 index 0000000..d056a65 --- /dev/null +++ b/public/mezo-lab/index.html @@ -0,0 +1,783 @@ + + + + + +Mezo Lens · Layout Lab + + + + + + + + + +
+
+
+
+ M +
+
+
+ + Mezo Lens · Layout Lab +
+
Three structural directions · pick one
+
+
+ +
+
+ +
+ + +
+
+
+
Direction A
+

Split-screen · Build ↔ Outcome

+

Composer on the left, live outcome dashboard on the right. Tabs as segmented pills. + Health gauge, projected receives, risk meter visible at all times — you never tab away to see your result.

+
+ recommended · least churn +
+ + +
+
+ + + + + + +
+ + +
+ +
+ +
+
+
+
+
+
+ + Starter Stack · 4 legs +
+

Sculpt your stack

+

Borrow MUSD against BTC, save it, lock MEZO — one atomic composed flow.

+
+ +
+ + +
+ + BTC deposit + + + + MUSD borrow + + + + sMUSD save + + + + veMEZO lock + + composed · atomic +
+ + +
+ + +
+
+
BTC Collateral
+
Balance 0.0500 ·
+
+
+
+
0.05
+
≈ $5,210.50
+
+
+ + BTC +
+
+ +
+ + +
+
+
MUSD Borrow · 38% LTV
+
Min 2,000
+
+
+
+
2,000
+
≈ $2,000.00
+
+
+ + MUSD +
+
+ +
+ +
+ +
+
+
→ sMUSD save
+ 3.21% APR +
+
1,500
+
75% of borrowed
+
+ +
+
+
MEZO lock
+
+ + + + +
+
+
50
+
vote weight ≈ 2.00
+
+
+
+
+ + +
+
4 legs · atomic · simulated 412ms ago
+
+ + +
+
+
+ + +
+ + +
+
+
+ + Trove Health +
+ SAFE +
+
+
+
+
+
+
312%
+
ICR
+
+
+
+
+
Liquidation @$33,250
+
LTV38.4%
+
Net deposit+$5,210
+
Net borrow+$2,000
+
+
+
+ + +
+
+
You receive
+ after 4 legs +
+
+
+ +
+
+1,500 sMUSD +3.21% APR
+
Direct yield · no gauge stake in v1
+
+
+
+ +
+
veMEZO NFT vote weight 2.00
+
7-day lock · decays linearly
+
+
+
+ +
+
Trove opened 312% ICR
+
Liquidates @ $33,250 BTC
+
+
+
+
+ + +
+ ! +
+
Risk · veMEZO unresolved
+
Day-0 smoke required before the lock leg can simulate. Run scripts/mezo-day-0-smoke.sh
+
+
+
+
+
+ +
+ + +
+
+
+
Direction B
+

Position-first · the dashboard IS your position

+

No "form" framing. The page shows your current/projected position by default — health gauge, composition donut, activity feed. Tabs become a Mode toggle inside one panel for adjusting.

+
+ most novel · biggest change +
+ + +
+
+
Health · ICR
+
312%
+
SAFE · +14 from target
+
+
+
Net position
+
$4,140
+
$5,210 col − $1,070 debt
+
+
+
sMUSD APR
+
3.21%
+
direct · gauge dormant
+
+
+
veMEZO voting
+
2.00
+
7d · decays linearly
+
+
+ +
+ + +
+
+
+
+ + Your Mezo Position +
+

Composition

+

After 4 legs · BTC collateral, MUSD debt, sMUSD savings, veMEZO lock.

+
+ sim · 412ms ago +
+ +
+ +
+
+
+
+
Total locked
+
$4,140
+
4 assets
+
+
+
+
+
+ BTC collateral + $5,210 +
+
+ MUSD debt + −$1,070 +
+
+ sMUSD savings + $1,500 +
+
+ veMEZO lock + $500 +
+
+
+
+ + +
+
+
Projected legs
+ atomic · 1 tx +
+
    +
  1. + +
    Deposit 0.05 BTC as collateral
    +
    openTrove · ICR 312%
    +
  2. +
  3. + +
    Mint 2,000 MUSD
    +
    net 1,800 + 200 gas comp
    +
  4. +
  5. + +
    Stake 1,500 MUSD → sMUSD
    +
    direct yield · 3.21% APR
    +
  6. +
  7. + +
    Lock 50 MEZO → veMEZO NFT
    +
    7-day lock · weight 2.00
    +
  8. +
+
+ + +
+
+
Adjust position
+
+ + + + + + +
+
+
+ + + + +
+
+
sim · 412ms ago · 4 legs · atomic
+
+ + +
+
+
+
+
+ +
+ + +
+
+
+
Direction C
+

Workbench · dense 3-pane

+

Side-rail nav for actions, center for the composer, right pane locked to live outcome. Bottom is a leg execution scrubber. Densest, most technical — closer to a trader terminal than a yield UI.

+
+ densest · pro feel +
+ +
+ +
+
+ MEZO TESTNET + · + chain 31611 + · + block 2,481,902 +
+
+ BTC$104,210 + sMUSD APR3.21% + Troves1,820 +
+
+ +
+ + + + +
+
+
+
+ STACK · COMPOSER +
+

Starter Stack

+
+
+ + + + +
+
+ +
+ + + + +
+ +
+
Lock duration
+
+ + + + +
+
+
+ + +
+
+
+ + Live outcome +
+
+
+
+
+
+
312%
+
ICR
+
+
+
+
+
Liq @$33,250
+
LTV38%
+
Net+$4,140
+
+
+
+
+
Receives
+ + + +
+
+
+ + +
+
+ LEGS + + + + + + + +
+
+ + +
+
+
+
+ +
+ mezo lens · layout lab · /mezo-lab +
+
+ + + + + diff --git a/src/components/MobileDrawer.tsx b/src/components/MobileDrawer.tsx index 8fe1c58..c72f99c 100644 --- a/src/components/MobileDrawer.tsx +++ b/src/components/MobileDrawer.tsx @@ -51,6 +51,7 @@ const TOOLS = [ icon: Stack, subTabs: [ { id: "lifi-earn", label: "LI.FI Earn", paramKey: "route" }, + { id: "mezo", label: "Mezo Lens", paramKey: "route" }, ], }, ]; diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index df5439a..3cfcbe0 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -69,6 +69,7 @@ const TOOLS: ToolDef[] = [ shortLabel: "Integrate", subTabs: [ { id: "lifi-earn", label: "LI.FI Earn", shortLabel: "LI.FI", paramKey: "route", icon: }, + { id: "mezo", label: "Mezo Lens", shortLabel: "Mezo", paramKey: "route", icon: }, ], }, ]; diff --git a/src/components/integrations/IntegrationsHub.tsx b/src/components/integrations/IntegrationsHub.tsx index b243c0a..7b42902 100644 --- a/src/components/integrations/IntegrationsHub.tsx +++ b/src/components/integrations/IntegrationsHub.tsx @@ -8,6 +8,9 @@ const LifiEarnPage = React.lazy( const SparkleShowcase = React.lazy( () => import("./lifi-earn/SparkleShowcase") ); +const MezoLensPage = React.lazy( + () => import("./mezo/MezoLensPage") +); const IntegrationsHub: React.FC = () => { const { pathname } = useLocation(); @@ -22,6 +25,7 @@ const IntegrationsHub: React.FC = () => { }> {segment === "sparkle-test" && } {segment === "lifi-earn" && } + {segment === "mezo" && } ); }; diff --git a/src/components/shared/RouteMetaTags.tsx b/src/components/shared/RouteMetaTags.tsx index 5cb460b..42fb547 100644 --- a/src/components/shared/RouteMetaTags.tsx +++ b/src/components/shared/RouteMetaTags.tsx @@ -38,6 +38,11 @@ const ROUTE_META: Record = { description: "Browse DeFi yield vaults, simulate deposits, and manage positions across protocols. Powered by LI.FI Earn.", }, + "/integrations/mezo": { + title: "Hexkit - Mezo Lens — Bitcoin-native DeFi on Mezo", + description: + "Put your BTC, MUSD, and MEZO to work on Mezo — borrow, save, lock. Preview every leg before you sign.", + }, }; /** diff --git a/src/hooks/useUniversalSearch.ts b/src/hooks/useUniversalSearch.ts index 0294739..de39d4a 100644 --- a/src/hooks/useUniversalSearch.ts +++ b/src/hooks/useUniversalSearch.ts @@ -132,6 +132,7 @@ const pages: PageDefinition[] = [ { id: 'page-history', name: 'Simulation History', description: 'View past simulation results', icon: 'RotateCcw', route: '/simulations', keywords: ['history', 'past', 'previous'] }, { id: 'page-integrations', name: 'Integrations', description: 'Protocol integrations with yield vaults', icon: 'Layers', route: '/integrations', keywords: ['yield', 'earn', 'lifi', 'vault', 'defi'] }, { id: 'page-lifi-earn', name: 'LI.FI Earn', description: 'Browse yield vaults and deposit', icon: 'Layers', route: '/integrations/lifi-earn', keywords: ['yield', 'earn', 'lifi', 'vault', 'apy', 'tvl'] }, + { id: 'page-mezo-lens', name: 'Mezo Lens', description: 'Bitcoin-native DeFi on Mezo: borrow, save, lock', icon: 'Layers', route: '/integrations/mezo', keywords: ['mezo', 'bitcoin', 'btc', 'musd', 'borrow', 'lock', 'vault', 'trove', 'liquity'] }, ]; export function useUniversalSearch(): UseUniversalSearchReturn { From d685a4a3823ba64543ebbb0a9b66b6c7af7f305e Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 21:18:59 +0100 Subject: [PATCH 09/18] chore: bump edb submodule to feat/mezo Pulls in chain metadata for Mezo Testnet + Mainnet, the stateful MEZO precompile inspector (balanceOf delta tracking + allowance defaults), source-provider tagging on artifacts, event dedup, and synthetic precompile log emission. See edb commit f280d32. --- edb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/edb b/edb index c5b32ca..f280d32 160000 --- a/edb +++ b/edb @@ -1 +1 @@ -Subproject commit c5b32ca53e7c2146e647830af486b6481aa37548 +Subproject commit f280d329bba31aebf9b18c6b174dd4d271e0d600 From e7d9554da1a37c7bc90256d1879bafca84a7c0a3 Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 21:40:51 +0100 Subject: [PATCH 10/18] fix: drop unused router ETH-variant legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mezo's Router only exposes swapExactTokensForTokens and addLiquidity — there is no ETH-native variant because BTC on Mezo is itself an ERC-20 surface (0x7b7C…0000). The swap and liquidity bundles already always use the standard token-token entrypoints; the routerSwapEthIn / routerSwapEthOut / routerAddLiquidityEth leg types were dead and broke the strict wagmi v2 typing on Vercel. Removes those leg types from MezoLegSpec and their orphan branches in buildCalls.ts, legHandlers.ts, decodeResults.ts, and the swap/liquidity type-guards. Casts the routerSwap routes arg to `never` so wagmi's strict `as const` ABI inference stops widening to the runtime tuple. --- .../integrations/mezo/pipeline/legHandlers.ts | 51 ++----------------- .../integrations/mezo/pipeline/mezoLegs.ts | 27 ---------- .../integrations/mezo/sim/buildCalls.ts | 51 ------------------- .../integrations/mezo/sim/decodeResults.ts | 6 --- .../mezo/sim/useMezoBundleSimulation.ts | 21 ++------ 5 files changed, 8 insertions(+), 148 deletions(-) diff --git a/src/components/integrations/mezo/pipeline/legHandlers.ts b/src/components/integrations/mezo/pipeline/legHandlers.ts index fa7361c..c921bfe 100644 --- a/src/components/integrations/mezo/pipeline/legHandlers.ts +++ b/src/components/integrations/mezo/pipeline/legHandlers.ts @@ -109,37 +109,13 @@ export async function executeLeg( address: MEZO_CONTRACTS.Router, abi: MEZO_ABIS.Router, functionName: "swapExactTokensForTokens", + // wagmi infers a strict tuple shape from the ABI's `as const`; + // our runtime route shape matches but TS can't prove it through + // the discriminated-union indirection. args: [ leg.amountIn, leg.amountOutMin, - leg.routes, - leg.to, - leg.deadline, - ], - account, - }); - - case "routerSwapEthIn": - return writeContract(config, { - chainId: MEZO_TESTNET_CHAIN_ID, - address: MEZO_CONTRACTS.Router, - abi: MEZO_ABIS.Router, - functionName: "swapExactETHForTokens", - args: [leg.amountOutMin, leg.routes, leg.to, leg.deadline], - value: leg.amountIn, - account, - }); - - case "routerSwapEthOut": - return writeContract(config, { - chainId: MEZO_TESTNET_CHAIN_ID, - address: MEZO_CONTRACTS.Router, - abi: MEZO_ABIS.Router, - functionName: "swapExactTokensForETH", - args: [ - leg.amountIn, - leg.amountOutMin, - leg.routes, + leg.routes as never, leg.to, leg.deadline, ], @@ -166,25 +142,6 @@ export async function executeLeg( account, }); - case "routerAddLiquidityEth": - return writeContract(config, { - chainId: MEZO_TESTNET_CHAIN_ID, - address: MEZO_CONTRACTS.Router, - abi: MEZO_ABIS.Router, - functionName: "addLiquidityETH", - args: [ - leg.token, - leg.stable, - leg.amountTokenDesired, - leg.amountTokenMin, - leg.amountEthMin, - leg.to, - leg.deadline, - ], - value: leg.amountEthDesired, - account, - }); - case "repayMUSD": case "closeTrove": case "sMusdWithdraw": diff --git a/src/components/integrations/mezo/pipeline/mezoLegs.ts b/src/components/integrations/mezo/pipeline/mezoLegs.ts index a6833ca..4d00dbc 100644 --- a/src/components/integrations/mezo/pipeline/mezoLegs.ts +++ b/src/components/integrations/mezo/pipeline/mezoLegs.ts @@ -50,22 +50,6 @@ export type MezoLegSpec = to: Address; deadline: bigint; } - | { - type: "routerSwapEthIn"; - amountIn: bigint; - amountOutMin: bigint; - routes: readonly MezoRouterRoute[]; - to: Address; - deadline: bigint; - } - | { - type: "routerSwapEthOut"; - amountIn: bigint; - amountOutMin: bigint; - routes: readonly MezoRouterRoute[]; - to: Address; - deadline: bigint; - } | { type: "routerAddLiquidity"; tokenA: Address; @@ -78,17 +62,6 @@ export type MezoLegSpec = to: Address; deadline: bigint; } - | { - type: "routerAddLiquidityEth"; - token: Address; - stable: boolean; - amountTokenDesired: bigint; - amountEthDesired: bigint; - amountTokenMin: bigint; - amountEthMin: bigint; - to: Address; - deadline: bigint; - } | { type: "redeemCollateral"; musdAmount: bigint; diff --git a/src/components/integrations/mezo/sim/buildCalls.ts b/src/components/integrations/mezo/sim/buildCalls.ts index e463bfa..6671fc8 100644 --- a/src/components/integrations/mezo/sim/buildCalls.ts +++ b/src/components/integrations/mezo/sim/buildCalls.ts @@ -124,35 +124,6 @@ export function encodeWrite(account: Address, leg: MezoLegSpec): SimCall { return { from: account, to: MEZO_CONTRACTS.Router, input }; } - case "routerSwapEthIn": { - const input = encodeFunctionData({ - abi: MEZO_ABIS.Router, - functionName: "swapExactETHForTokens", - args: [leg.amountOutMin, leg.routes, leg.to, leg.deadline], - }); - return { - from: account, - to: MEZO_CONTRACTS.Router, - input, - value: bigintToHex(leg.amountIn), - }; - } - - case "routerSwapEthOut": { - const input = encodeFunctionData({ - abi: MEZO_ABIS.Router, - functionName: "swapExactTokensForETH", - args: [ - leg.amountIn, - leg.amountOutMin, - leg.routes, - leg.to, - leg.deadline, - ], - }); - return { from: account, to: MEZO_CONTRACTS.Router, input }; - } - case "routerAddLiquidity": { const input = encodeFunctionData({ abi: MEZO_ABIS.Router, @@ -180,28 +151,6 @@ export function encodeWrite(account: Address, leg: MezoLegSpec): SimCall { }; } - case "routerAddLiquidityEth": { - const input = encodeFunctionData({ - abi: MEZO_ABIS.Router, - functionName: "addLiquidityETH", - args: [ - leg.token, - leg.stable, - leg.amountTokenDesired, - leg.amountTokenMin, - leg.amountEthMin, - leg.to, - leg.deadline, - ], - }); - return { - from: account, - to: MEZO_CONTRACTS.Router, - input, - value: bigintToHex(leg.amountEthDesired), - }; - } - case "repayMUSD": case "closeTrove": case "sMusdWithdraw": diff --git a/src/components/integrations/mezo/sim/decodeResults.ts b/src/components/integrations/mezo/sim/decodeResults.ts index 6d91df9..d18d810 100644 --- a/src/components/integrations/mezo/sim/decodeResults.ts +++ b/src/components/integrations/mezo/sim/decodeResults.ts @@ -261,14 +261,8 @@ function summarizeLeg(leg: MezoLegSpec): string { return `Claim gauge rewards`; case "routerSwap": return `Swap ${formatBn(leg.amountIn)} (min out ${formatBn(leg.amountOutMin)})`; - case "routerSwapEthIn": - return `Swap ${formatBn(leg.amountIn)} BTC (min out ${formatBn(leg.amountOutMin)})`; - case "routerSwapEthOut": - return `Swap ${formatBn(leg.amountIn)} (min ${formatBn(leg.amountOutMin)} BTC out)`; case "routerAddLiquidity": return `Add liquidity to ${leg.stable ? "stable" : "volatile"} pool`; - case "routerAddLiquidityEth": - return `Add BTC liquidity to ${leg.stable ? "stable" : "volatile"} pool`; case "redeemCollateral": return `Redeem ${formatBn(leg.musdAmount)} MUSD for BTC`; case "veMezoCreateLock": { diff --git a/src/components/integrations/mezo/sim/useMezoBundleSimulation.ts b/src/components/integrations/mezo/sim/useMezoBundleSimulation.ts index 09eaf69..f2eaf06 100644 --- a/src/components/integrations/mezo/sim/useMezoBundleSimulation.ts +++ b/src/components/integrations/mezo/sim/useMezoBundleSimulation.ts @@ -376,27 +376,14 @@ function buildWarnings( function isSwapLegSpec( spec: MezoLegSpec, -): spec is Extract< - MezoLegSpec, - { type: "routerSwap" | "routerSwapEthIn" | "routerSwapEthOut" } -> { - return ( - spec.type === "routerSwap" || - spec.type === "routerSwapEthIn" || - spec.type === "routerSwapEthOut" - ); +): spec is Extract { + return spec.type === "routerSwap"; } function isLiquidityLegSpec( spec: MezoLegSpec, -): spec is Extract< - MezoLegSpec, - { type: "routerAddLiquidity" | "routerAddLiquidityEth" } -> { - return ( - spec.type === "routerAddLiquidity" || - spec.type === "routerAddLiquidityEth" - ); +): spec is Extract { + return spec.type === "routerAddLiquidity"; } function serializeRequest(req: SimulationRequest): string { From db143052fc7c6f6bb027dcaaea34afcd5d30455c Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 22:37:11 +0100 Subject: [PATCH 11/18] fix: native asset label in simulation Summary header The SimulationResultsPage Summary header (Value + Tx Fee fields) was rendering "ETH" for every replay regardless of chain. A Mezo Mainnet tx with 0.000001 BTC value showed "0.000001 ETH" + "0 ETH" because formatEth + calculateTxFee hardcoded the suffix and computeGasValues seeded txFee = "0 ETH" literal. - formatters.formatEth(wei, symbol = "ETH") + calculateTxFee(..., symbol = "ETH") now accept the native symbol with a back-compat default so existing call sites keep working. - gasHelpers.computeGasValues(..., nativeSymbol = "ETH") threads it into the txFee placeholder. - SimulationResultsPage resolves nativeSymbol via getChainById( result.chainId ?? contractContext.networkId)?.nativeCurrency.symbol and forwards it to both computeGasValues and TransactionSummary. - TransactionSummary adds an optional nativeSymbol prop and uses it in the Value field rendering. NATIVE TOKEN CHANGE table was already chain-aware via the trace converter; this just brings the Summary header into line. --- src/components/SimulationResultsPage.tsx | 10 ++++++++-- .../simulation-results/TransactionSummary.tsx | 5 ++++- src/components/simulation-results/formatters.ts | 14 +++++++++----- src/components/simulation-results/gasHelpers.ts | 5 +++-- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/components/SimulationResultsPage.tsx b/src/components/SimulationResultsPage.tsx index 0880d73..c36c15a 100644 --- a/src/components/SimulationResultsPage.tsx +++ b/src/components/SimulationResultsPage.tsx @@ -9,6 +9,7 @@ import "../styles/SimulationResultsPage.css"; import type { SimulationResultsPageProps, SimulatorTab } from "./simulation-results/types"; import { useSimulationPageState } from "./simulation-results/useSimulationPageState"; import { resolveFunctionName, computeGasValues, resolveReturnData } from "./simulation-results/gasHelpers"; +import { getChainById } from "../chains/registry"; import type { ContractContextExtras } from "./simulation-results/useSimulationPageState"; import { ResultsHeader } from "./simulation-results/ResultsHeader"; import { TransactionSummary } from "./simulation-results/TransactionSummary"; @@ -107,8 +108,12 @@ const SimulationResultsPage: React.FC = (props) => { const rawInput = result.data || rootCall?.input || "0x"; const functionName = resolveFunctionName(result, rootCall, decodedTrace, rawInput, contractContext); + const chainIdForChain = result.chainId ?? contractContext?.networkId; + const nativeSymbol = + (typeof chainIdForChain === "number" ? getChainById(chainIdForChain) : undefined) + ?.nativeCurrency.symbol ?? "ETH"; const { gasUsed, gasLimit, gasPrice, nonce, txFee, txType } = computeGasValues( - result, decodedTrace, rawInput, contractContext + result, decodedTrace, rawInput, contractContext, nativeSymbol ); const returnData = resolveReturnData(decodedTrace, artifacts, rootCall, rawInput); const errorMessage = result.error || result.revertReason || null; @@ -155,7 +160,8 @@ const SimulationResultsPage: React.FC = (props) => { gasPrice={gasPrice} txType={txType} nonce={nonce} - chainId={contractContext?.networkId || 1} + chainId={chainIdForChain || 1} + nativeSymbol={nativeSymbol} formatAddressWithName={formatAddressWithName} normalizeValue={normalizeValue} highlightedValue={highlightedValue} diff --git a/src/components/simulation-results/TransactionSummary.tsx b/src/components/simulation-results/TransactionSummary.tsx index 18bb144..38460ea 100644 --- a/src/components/simulation-results/TransactionSummary.tsx +++ b/src/components/simulation-results/TransactionSummary.tsx @@ -26,6 +26,8 @@ interface TransactionSummaryProps { nonce: string; /** Chain ID for native token USD pricing (defaults to 1 / Ethereum) */ chainId?: number; + /** Native asset symbol from the chain registry; defaults to "ETH". */ + nativeSymbol?: string; formatAddressWithName: (address: string) => { display: string; hasName: boolean }; normalizeValue: (value: string | undefined | null) => string | null; highlightedValue: string | null; @@ -51,6 +53,7 @@ export const TransactionSummary: React.FC = ({ txType, nonce, chainId = 1, + nativeSymbol = "ETH", formatAddressWithName, normalizeValue, highlightedValue, @@ -175,7 +178,7 @@ export const TransactionSummary: React.FC = ({
Value - {formatEth(value)} + {formatEth(value, nativeSymbol)} {value && value !== "\u2014" && ( {formatUsd(value)} )} diff --git a/src/components/simulation-results/formatters.ts b/src/components/simulation-results/formatters.ts index 0982b05..98b38bf 100644 --- a/src/components/simulation-results/formatters.ts +++ b/src/components/simulation-results/formatters.ts @@ -87,13 +87,13 @@ export const formatGwei = (weiValue?: string | null) => { } }; -export const formatEth = (weiValue?: string | null) => { +export const formatEth = (weiValue?: string | null, symbol = "ETH") => { if (!weiValue) return "\u2014"; try { const wei = BigInt(weiValue); - const isSmall = wei < 10n ** 14n; // < 0.0001 ETH + const isSmall = wei < 10n ** 14n; // < 0.0001 of the native asset const displayDecimals = isSmall ? 6 : 4; - return `${formatBigIntUnits(wei, 18, displayDecimals)} ETH`; + return `${formatBigIntUnits(wei, 18, displayDecimals)} ${symbol}`; } catch { return "\u2014"; } @@ -118,13 +118,17 @@ export const calculateIntrinsicGas = (calldata?: string | null): number => { return INTRINSIC_BASE + calldataGas; }; -export const calculateTxFee = (gasUsed?: string | null, gasPrice?: string | null) => { +export const calculateTxFee = ( + gasUsed?: string | null, + gasPrice?: string | null, + symbol = "ETH", +) => { if (!gasUsed || !gasPrice) return "\u2014"; try { const gas = BigInt(gasUsed); const price = BigInt(gasPrice); const feeInWei = gas * price; - return formatEth(feeInWei.toString()); + return formatEth(feeInWei.toString(), symbol); } catch { return "\u2014"; } diff --git a/src/components/simulation-results/gasHelpers.ts b/src/components/simulation-results/gasHelpers.ts index a20427c..24453fb 100644 --- a/src/components/simulation-results/gasHelpers.ts +++ b/src/components/simulation-results/gasHelpers.ts @@ -119,7 +119,8 @@ export function computeGasValues( result: any, decodedTrace: any, rawInput: string, - contractContext: any + contractContext: any, + nativeSymbol = "ETH", ) { const edbExecutionGas = decodedTrace?.callMeta?.gas_used ?? decodedTrace?.callMeta?.gasUsed; @@ -178,7 +179,7 @@ export function computeGasValues( const gasLimit = `${gasLimitNum.toLocaleString()} (${gasPercentage}%)`; const gasPrice = result.effectiveGasPrice || result.gasPrice || "\u2014"; const nonce = result.nonce !== null && result.nonce !== undefined ? String(result.nonce) : "\u2014"; - const txFee = "0 ETH"; + const txFee = `0 ${nativeSymbol}`; const txType = formatTxType(result.type); return { gasUsed, gasLimit, gasPrice, nonce, txFee, txType }; From baf8f871e9d8cdc49deca64eeee71e367f1665e2 Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 23:30:22 +0100 Subject: [PATCH 12/18] feat: source Mezo token icons from CoinGecko CDN Wire MEZO, BTC, and MUSD icons through CoinGecko so the trace ASSET column and Mezo Lens AssetIcon stop falling back to a generic dot. Covers both the Mezo precompile/surface addresses and the Ethereum NTT bridge twins. sMUSD has no public CDN entry so it keeps a bundled SVG. --- public/logos/smusd.svg | 1 + src/components/TokenMovementsPanel.tsx | 22 +++++-- .../mezo/components/AssetIcon.tsx | 66 +++++++++++++++++-- src/utils/tokenMovements.ts | 47 +++++++++++++ 4 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 public/logos/smusd.svg diff --git a/public/logos/smusd.svg b/public/logos/smusd.svg new file mode 100644 index 0000000..f885cd6 --- /dev/null +++ b/public/logos/smusd.svg @@ -0,0 +1 @@ +$s diff --git a/src/components/TokenMovementsPanel.tsx b/src/components/TokenMovementsPanel.tsx index a8745d1..f8bd3a5 100644 --- a/src/components/TokenMovementsPanel.tsx +++ b/src/components/TokenMovementsPanel.tsx @@ -6,7 +6,7 @@ import { groupByTokenType, fetchTokenPrices, fetchTokenMetadata, - getTokenIconUrl, + getTokenIconUrls, type TokenType, type BalanceChange, type TokenMovement, @@ -448,8 +448,14 @@ const TokenMovementRow: React.FC = ({ ); }; - // Get token icon URL - const iconUrl = getTokenIconUrl(change.tokenAddress, chainId); + // Token icon URL cascade (Mezo ecosystem: CoinGecko → local SVG → fallback) + const iconUrls = useMemo( + () => getTokenIconUrls(change.tokenAddress, chainId), + [change.tokenAddress, chainId], + ); + const [iconIdx, setIconIdx] = useState(0); + useEffect(() => { setIconIdx(0); setIconError(false); }, [change.tokenAddress, chainId]); + const iconUrl = iconIdx < iconUrls.length ? iconUrls[iconIdx] : null; // Calculate USD value const usdValue = useMemo(() => { @@ -477,14 +483,20 @@ const TokenMovementRow: React.FC = ({ - {!iconError ? ( + {iconUrl && !iconError ? ( setIconError(true)} + onError={() => { + if (iconIdx + 1 < iconUrls.length) { + setIconIdx(iconIdx + 1); + } else { + setIconError(true); + } + }} loading="lazy" /> ) : ( diff --git a/src/components/integrations/mezo/components/AssetIcon.tsx b/src/components/integrations/mezo/components/AssetIcon.tsx index 0cb0c5d..cdbebfe 100644 --- a/src/components/integrations/mezo/components/AssetIcon.tsx +++ b/src/components/integrations/mezo/components/AssetIcon.tsx @@ -1,11 +1,11 @@ -import type { ReactNode } from "react"; -import { CurrencyBtc } from "@phosphor-icons/react"; +import { useMemo, useState, useEffect, type ReactNode } from "react"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { getTokenIconUrls } from "@/utils/tokenMovements"; import { MEZO_GLOSSARY, type GlossaryKey } from "../glossary"; export type AssetSymbol = "BTC" | "MUSD" | "sMUSD" | "MEZO" | "veMEZO"; @@ -18,6 +18,16 @@ const SYMBOL_TO_GLOSSARY: Record = { veMEZO: "vemezo", }; +// Canonical Mezo Mainnet addresses for icon lookup. veMEZO is a non-ERC20 +// position so it has no address; we render a styled glyph for it. +const SYMBOL_TO_ADDRESS: Record = { + BTC: "0x7b7C000000000000000000000000000000000000", + MUSD: "0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503", + sMUSD: "0x6f461c68B2c5492C0F5CCEc5a264d692aA7A8e16", + MEZO: "0x7B7c000000000000000000000000000000000001", + veMEZO: null, +}; + const sizeClass = { sm: "h-4 w-4", md: "h-5 w-5", @@ -49,7 +59,7 @@ export function AssetIcon({ )} aria-hidden > - {renderGlyph(symbol)} + ); @@ -92,12 +102,35 @@ export function AssetIcon({ ); } -function renderGlyph(symbol: AssetSymbol): ReactNode { +function SymbolIcon({ symbol }: { symbol: AssetSymbol }): ReactNode { + const address = SYMBOL_TO_ADDRESS[symbol]; + + const urls = useMemo( + () => (address ? getTokenIconUrls(address, 31612) : []), + [address], + ); + const [srcIdx, setSrcIdx] = useState(0); + useEffect(() => { setSrcIdx(0); }, [address]); + + if (!address || srcIdx >= urls.length) { + return renderGlyphFallback(symbol); + } + + return ( + setSrcIdx((i) => i + 1)} + /> + ); +} + +function renderGlyphFallback(symbol: AssetSymbol): ReactNode { switch (symbol) { case "BTC": - return ( - - ); + return ; case "MUSD": return ; case "sMUSD": @@ -109,6 +142,25 @@ function renderGlyph(symbol: AssetSymbol): ReactNode { } } +function BtcGlyph() { + return ( + + + + ₿ + + + ); +} + function MusdGlyph() { return ( diff --git a/src/utils/tokenMovements.ts b/src/utils/tokenMovements.ts index fcd468f..ff3cba2 100644 --- a/src/utils/tokenMovements.ts +++ b/src/utils/tokenMovements.ts @@ -84,6 +84,40 @@ const CHAIN_ID_TO_ZAPPER: Record = { 1101: "polygon-zkevm", }; +// Mezo ecosystem icons. Zapper/1inch/Trust Wallet don't index chain 31611/31612, +// and cross-chain NTT bridge traces reference Ethereum/Base twins that 1inch +// also misses. Point at CoinGecko's CDN where possible, fall back to a bundled +// SVG only where no CDN entry exists. +// Keyed by address (lower-case); covers both the Mezo and Ethereum/Base sides. +const MEZO_TOKEN_ICONS: Record = { + // MEZO precompile (CoinGecko id "mezo") + "0x7b7c000000000000000000000000000000000001": { + cdn: "https://coin-images.coingecko.com/coins/images/71716/large/KnBgdkXh_400x400_%281%29.jpg", + local: "/logos/mezo.svg", + }, + // MEZO on Ethereum/Base (NTT bridge twin) + "0x8e4cbbcc33db6c0a18561fde1f6ba35906d4848b": { + cdn: "https://coin-images.coingecko.com/coins/images/71716/large/KnBgdkXh_400x400_%281%29.jpg", + local: "/logos/mezo.svg", + }, + // BTC ERC-20 surface (use canonical bitcoin image) + "0x7b7c000000000000000000000000000000000000": { + cdn: "https://coin-images.coingecko.com/coins/images/1/large/bitcoin.png", + }, + // Canonical MUSD on Mezo (CoinGecko id "mezo-usd") + "0x118917a40faf1cd7a13db0ef56c86de7973ac503": { + cdn: "https://coin-images.coingecko.com/coins/images/66938/large/37163_%281%29.png", + }, + // MUSD on Ethereum (NTT bridge twin) + "0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186": { + cdn: "https://coin-images.coingecko.com/coins/images/66938/large/37163_%281%29.png", + }, + // sMUSD savings vault — not indexed by any public CDN + "0x6f461c68b2c5492c0f5ccec5a264d692aa7a8e16": { + local: "/logos/smusd.svg", + }, +}; + /** * Get token icon URL * Uses 1inch for Ethereum (supports lowercase), Trust Wallet for other chains @@ -93,6 +127,12 @@ const CHAIN_ID_TO_ZAPPER: Record = { export function getTokenIconUrl(tokenAddress: string, chainId: number = 1): string { const addr = tokenAddress.toLowerCase(); + const mezo = MEZO_TOKEN_ICONS[addr]; + if (mezo) { + const url = mezo.cdn ?? mezo.local; + if (url) return url; + } + // For Ethereum mainnet, use 1inch direct URL (avoids redirect) if (chainId === 1) { return `https://tokens-data.1inch.io/images/${addr}.png`; @@ -122,6 +162,13 @@ export function getTokenIconUrls(tokenAddress: string, chainId: number = 1): str const addr = tokenAddress.toLowerCase(); const urls: string[] = []; + const mezo = MEZO_TOKEN_ICONS[addr]; + if (mezo) { + if (mezo.cdn) urls.push(mezo.cdn); + if (mezo.local) urls.push(mezo.local); + if (urls.length) return urls; + } + // 1. Zapper (good coverage of DeFi/vault tokens) const zapperChain = CHAIN_ID_TO_ZAPPER[chainId]; if (zapperChain) { From 13d1b4524cf31a0606d4d792789b7b27bb4cd7bb Mon Sep 17 00:00:00 2001 From: Timidan Date: Sun, 24 May 2026 23:38:47 +0100 Subject: [PATCH 13/18] feat: support veBTC and Mezo-bridged USDC/USDT in icons veBTC is the base ve-token of Mezo's Aerodrome v2 fork; add it as an AssetSymbol with a local orange glyph + glossary entry. Bridged USDC/USDT are indexed by CoinGecko, so route them through the same CDN map as MUSD/MEZO. Seed metadata so traces surface the correct symbol without an RPC roundtrip. --- .../mezo/components/AssetIcon.tsx | 41 +++++++++++++++++-- .../mezo/components/FlowRibbon.tsx | 1 + src/components/integrations/mezo/glossary.ts | 5 +++ src/utils/tokenMovements.ts | 12 ++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/components/integrations/mezo/components/AssetIcon.tsx b/src/components/integrations/mezo/components/AssetIcon.tsx index cdbebfe..fe96a67 100644 --- a/src/components/integrations/mezo/components/AssetIcon.tsx +++ b/src/components/integrations/mezo/components/AssetIcon.tsx @@ -8,7 +8,7 @@ import { cn } from "@/lib/utils"; import { getTokenIconUrls } from "@/utils/tokenMovements"; import { MEZO_GLOSSARY, type GlossaryKey } from "../glossary"; -export type AssetSymbol = "BTC" | "MUSD" | "sMUSD" | "MEZO" | "veMEZO"; +export type AssetSymbol = "BTC" | "MUSD" | "sMUSD" | "MEZO" | "veMEZO" | "veBTC"; const SYMBOL_TO_GLOSSARY: Record = { BTC: "btc", @@ -16,16 +16,19 @@ const SYMBOL_TO_GLOSSARY: Record = { sMUSD: "smusd", MEZO: "mezo", veMEZO: "vemezo", + veBTC: "vebtc", }; -// Canonical Mezo Mainnet addresses for icon lookup. veMEZO is a non-ERC20 -// position so it has no address; we render a styled glyph for it. +// Canonical Mezo Mainnet addresses for icon lookup. veMEZO/veBTC are +// non-transferable NFT positions with no public CDN coverage, so they +// render styled local glyphs. const SYMBOL_TO_ADDRESS: Record = { BTC: "0x7b7C000000000000000000000000000000000000", MUSD: "0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503", sMUSD: "0x6f461c68B2c5492C0F5CCEc5a264d692aA7A8e16", MEZO: "0x7B7c000000000000000000000000000000000001", veMEZO: null, + veBTC: null, }; const sizeClass = { @@ -139,6 +142,8 @@ function renderGlyphFallback(symbol: AssetSymbol): ReactNode { return ; case "veMEZO": return ; + case "veBTC": + return ; } } @@ -245,3 +250,33 @@ function VeMezoGlyph() { ); } + +function VeBtcGlyph() { + return ( + + + + + + ₿ + + + ); +} diff --git a/src/components/integrations/mezo/components/FlowRibbon.tsx b/src/components/integrations/mezo/components/FlowRibbon.tsx index 10f464c..0d65d2e 100644 --- a/src/components/integrations/mezo/components/FlowRibbon.tsx +++ b/src/components/integrations/mezo/components/FlowRibbon.tsx @@ -20,6 +20,7 @@ const SYMBOL_ACCENT: Record = { sMUSD: "bg-emerald-300/80", MEZO: "bg-pink-400/80", veMEZO: "bg-violet-400/80", + veBTC: "bg-amber-300/70", }; export function FlowRibbon({ steps, caption, className }: FlowRibbonProps) { diff --git a/src/components/integrations/mezo/glossary.ts b/src/components/integrations/mezo/glossary.ts index 78096f8..1165857 100644 --- a/src/components/integrations/mezo/glossary.ts +++ b/src/components/integrations/mezo/glossary.ts @@ -59,6 +59,11 @@ export const MEZO_GLOSSARY = { body: "Non-transferable governance NFT minted by locking MEZO. Voting weight = lockedAmount × (duration / maxDuration), decays linearly.", }, + vebtc: { + title: "veBTC", + body: + "Base ve-token of Mezo's Aerodrome v2 fork. Locks BTC into a non-transferable NFT used by the Voter to set baseline pool weights before MEZO emissions are layered on top.", + }, trove: { title: "Trove", diff --git a/src/utils/tokenMovements.ts b/src/utils/tokenMovements.ts index ff3cba2..4d37fdd 100644 --- a/src/utils/tokenMovements.ts +++ b/src/utils/tokenMovements.ts @@ -116,6 +116,14 @@ const MEZO_TOKEN_ICONS: Record = { "0x6f461c68b2c5492c0f5ccec5a264d692aa7a8e16": { local: "/logos/smusd.svg", }, + // Mezo Bridged USDC (CoinGecko id "mezo-bridged-usdc-mezo") + "0x04671c72aab5ac02a03c1098314b1bb6b560c197": { + cdn: "https://coin-images.coingecko.com/coins/images/68245/large/usdc.jpg", + }, + // Mezo Bridged USDT (CoinGecko id "mezo-bridged-usdt-mezo") + "0xeb5a5d39de4ea42c2aa6a57eca2894376683bb8e": { + cdn: "https://coin-images.coingecko.com/coins/images/68246/large/usdt.jpg", + }, }; /** @@ -444,6 +452,10 @@ setTokenMetadataCache("0x7B7c000000000000000000000000000000000001", { symbol: "M setTokenMetadataCache("0x7b7C000000000000000000000000000000000000", { symbol: "BTC", name: "Bitcoin", decimals: 18 }); setTokenMetadataCache("0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503", { symbol: "MUSD", name: "Mezo USD", decimals: 18 }); setTokenMetadataCache("0x6f461c68B2c5492C0F5CCEc5a264d692aA7A8e16", { symbol: "sMUSD", name: "Savings MUSD", decimals: 18 }); +setTokenMetadataCache("0x04671c72aab5aC02a03C1098314b1bB6B560C197", { symbol: "mUSDC", name: "Mezo Bridged USDC", decimals: 6 }); +setTokenMetadataCache("0xeb5a5d39DE4Ea42c2Aa6A57Eca2894376683bb8E", { symbol: "mUSDT", name: "Mezo Bridged USDT", decimals: 6 }); +setTokenMetadataCache("0xB63fcCd03521Cf21907627bd7fA465C129479231", { symbol: "veBTC", name: "Mezo Vote-Escrowed BTC", decimals: 18 }); +setTokenMetadataCache("0xaCE816CA2bcc9b12C59799dcC5A959Fb9b98111b", { symbol: "veMEZO", name: "Mezo Vote-Escrowed MEZO", decimals: 18 }); /** * Parse a log event to detect token transfers From fb57b9d136c756b418e52afcf488783e50fb1315 Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 25 May 2026 00:52:35 +0100 Subject: [PATCH 14/18] fix: surface live trove + veMEZO state, skip openTrove on resume Three Stack-tab fixes that surface during recording: 1. Side-rail Trove and veMEZO sections were placeholder text. Wire them to TroveManager.Troves and veMEZO.locked so they reflect the wallet's actual on-chain position with 6s refetch. 2. If the user already has an active trove (status === 1), the Stack bundle now omits the openTrove leg instead of replaying it. Saves the user from a guaranteed revert when they re-run Build Stack. 3. Pipeline executeAll now skips legs already in "confirmed" state so clicking Build Stack again resumes from the first non-confirmed step. Pair with a 90s timeout on the wagmi receipt wait so a stuck poll can't halt the whole stack indefinitely. --- .../mezo/components/SideRailNav.tsx | 71 ++++++++++++++++++- .../mezo/pipeline/useMezoLegPipeline.ts | 7 +- .../integrations/mezo/sim/bundles/stack.ts | 17 +++-- .../integrations/mezo/tabs/StackTab.tsx | 36 +++++++++- 4 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/components/integrations/mezo/components/SideRailNav.tsx b/src/components/integrations/mezo/components/SideRailNav.tsx index ad6ec3a..51b6907 100644 --- a/src/components/integrations/mezo/components/SideRailNav.tsx +++ b/src/components/integrations/mezo/components/SideRailNav.tsx @@ -67,6 +67,55 @@ export function SideRailNav({ active, onChange }: SideRailNavProps) { query: { enabled: onMezo, refetchInterval }, }); + const trove = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.TroveManager, + abi: MEZO_ABIS.TroveManager, + functionName: "Troves", + args: address ? [address as Address] : undefined, + query: { enabled: onMezo, refetchInterval }, + }); + + // veMEZO is an ERC-721 — read NFT count, then lock data for the first token. + const veMezoCount = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.veMEZO, + abi: MEZO_ABIS.VotingEscrow, + functionName: "balanceOf", + args: address ? [address as Address] : undefined, + query: { enabled: onMezo, refetchInterval }, + }); + const veMezoTokenId = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.veMEZO, + abi: MEZO_ABIS.VotingEscrow, + functionName: "tokenOfOwnerByIndex", + args: address ? [address as Address, 0n] : undefined, + query: { + enabled: onMezo && (veMezoCount.data as bigint | undefined) !== undefined && (veMezoCount.data as bigint) > 0n, + refetchInterval, + }, + }); + const veMezoLock = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.veMEZO, + abi: MEZO_ABIS.VotingEscrow, + functionName: "locked", + args: veMezoTokenId.data !== undefined ? [veMezoTokenId.data as bigint] : undefined, + query: { enabled: onMezo && veMezoTokenId.data !== undefined, refetchInterval }, + }); + + const troveData = trove.data as + | readonly [bigint, bigint, bigint, bigint, number, bigint, bigint, bigint, bigint] + | undefined; + const troveActive = troveData ? troveData[4] === 1 : false; // status === 1 (Active) + const troveColl = troveData?.[0]; + const trovePrincipal = troveData?.[1]; + + const lockData = veMezoLock.data as { amount: bigint; end: bigint } | undefined; + const lockAmount = lockData?.amount; + const lockEnd = lockData?.end; + return ( ); } diff --git a/src/components/integrations/mezo/pipeline/useMezoLegPipeline.ts b/src/components/integrations/mezo/pipeline/useMezoLegPipeline.ts index b814cde..65f48ce 100644 --- a/src/components/integrations/mezo/pipeline/useMezoLegPipeline.ts +++ b/src/components/integrations/mezo/pipeline/useMezoLegPipeline.ts @@ -47,7 +47,9 @@ export function useMezoLegPipeline() { updateLeg(run.id, { status: "signing" }); const txHash = await executeLeg(config, address, run.spec); updateLeg(run.id, { status: "confirming", txHash }); - await wagmiWaitForReceipt(config, { hash: txHash }); + // 90s timeout — Mezo testnet blocks ~2s, so this is generous; without + // a ceiling a stuck receipt poll would halt the whole stack. + await wagmiWaitForReceipt(config, { hash: txHash, timeout: 90_000 }); updateLeg(run.id, { status: "confirmed" }); // Tx landed — bust wagmi's read cache so wallet balances, trove state, // veMEZO views, and pool reserves all refresh on the next tick. @@ -70,6 +72,9 @@ export function useMezoLegPipeline() { const executeAll = useCallback(async () => { const queue = runsRef.current.slice(); for (const run of queue) { + // Skip already-confirmed legs so a second Build Stack click resumes + // from the first non-confirmed step instead of replaying the bundle. + if (run.status === "confirmed") continue; try { await runOne(run); } catch { diff --git a/src/components/integrations/mezo/sim/bundles/stack.ts b/src/components/integrations/mezo/sim/bundles/stack.ts index cd23c22..5ca4b5e 100644 --- a/src/components/integrations/mezo/sim/bundles/stack.ts +++ b/src/components/integrations/mezo/sim/bundles/stack.ts @@ -19,6 +19,12 @@ export interface StackParams { * list correctly. */ troveInsertHint?: Address; + /** + * Skip the openTrove leg when the user already has an active trove. + * The remaining legs (sMUSD deposit, veMEZO lock) execute against + * existing balances instead of freshly minted MUSD/MEZO. + */ + skipOpenTrove?: boolean; } /** @@ -31,14 +37,17 @@ export function buildStackBundle(params: StackParams): { priceFeedViewIdx: number; } { const hint = params.troveInsertHint ?? ZERO_ADDR; - const legs: MezoLegSpec[] = [ - { + const legs: MezoLegSpec[] = []; + if (!params.skipOpenTrove) { + legs.push({ type: "openTrove", debtAmount: params.debtMusd, collateralWei: params.collateralBtcWei, upperHint: hint, lowerHint: hint, - }, + }); + } + legs.push( { type: "approveErc20", token: MEZO_CONTRACTS.MUSD, @@ -62,7 +71,7 @@ export function buildStackBundle(params: StackParams): { amount: params.mezoLockAmount, lockDuration: params.lockDurationSeconds, }, - ]; + ); const views: ViewCallSpec[] = [ { kind: "priceFeedFetch" }, diff --git a/src/components/integrations/mezo/tabs/StackTab.tsx b/src/components/integrations/mezo/tabs/StackTab.tsx index acb9959..9d494ae 100644 --- a/src/components/integrations/mezo/tabs/StackTab.tsx +++ b/src/components/integrations/mezo/tabs/StackTab.tsx @@ -100,6 +100,24 @@ export function StackTab() { }); const troveInsertHint = sortedTrovesHead.data as Address | undefined; + // Detect whether the user already has an active trove. If so, the bundle + // skips openTrove so re-running Build Stack doesn't error out trying to + // re-open. status === 1 means Active in the Liquity fork. + const existingTrove = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.TroveManager, + abi: MEZO_ABIS.TroveManager, + functionName: "Troves", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + const troveActive = useMemo(() => { + const data = existingTrove.data as + | readonly [bigint, bigint, bigint, bigint, number, bigint, bigint, bigint, bigint] + | undefined; + return data ? data[4] === 1 : false; + }, [existingTrove.data]); + const params = useMemo(() => { if (!address) return null; try { @@ -111,11 +129,12 @@ export function StackTab() { mezoLockAmount: parseUnits(mezoInput || "0", 18), lockDurationSeconds: ONE_WEEK_SECONDS, troveInsertHint, + skipOpenTrove: troveActive, }; } catch { return null; } - }, [address, btcInput, musdInput, sMusdInput, mezoInput, troveInsertHint]); + }, [address, btcInput, musdInput, sMusdInput, mezoInput, troveInsertHint, troveActive]); const bundle = useMemo(() => (params ? buildStackBundle(params) : null), [ params, @@ -143,7 +162,14 @@ export function StackTab() { const onBuildStack = async () => { if (!bundle || !sim.data) return; const summaries = sim.data.legs.map((l) => l.decodedSummary); - pipeline.start(bundle.legs, summaries); + // If a prior run already started (possibly with confirmed legs), resume + // instead of clobbering progress with a fresh start. The user can hit + // Reset to force a clean rebuild. + const hasExistingRun = pipeline.runs.length > 0; + const hasConfirmedLeg = pipeline.runs.some((r) => r.status === "confirmed"); + if (!hasExistingRun || !hasConfirmedLeg) { + pipeline.start(bundle.legs, summaries); + } await pipeline.executeAll(); }; @@ -259,6 +285,12 @@ export function StackTab() { )} + {troveActive && ( +
+ Trove active + — openTrove leg skipped; stack continues from sMUSD deposit. +
+ )}
} composer={ From c0d4294a6f85da801ed3e1ad4ddc0a3c3098a6bd Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 25 May 2026 01:02:59 +0100 Subject: [PATCH 15/18] feat: clickable trove panel opens Manage Trove dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Side-rail Trove row is now a button that opens a Manage Trove dialog with two modes: - Adjust — single signed adjustTrove call combining BTC delta (+ add / − withdraw) and MUSD delta (+ borrow / − repay). When repaying, the bundle prepends a MUSD approve so BorrowerOperations can pull the repayment. - Close — approve current debt + closeTrove. Burns the trove, refunds the 200 MUSD gas comp, returns all collateral. Blocks the button when wallet MUSD is short of the debt. Both flows reuse useMezoBundleSimulation for the live preview and useMezoLegPipeline for execution, matching the rest of Mezo Lens. Implement the previously-stubbed repayMUSD and closeTrove handlers. --- .../mezo/components/ManageTroveDialog.tsx | 287 ++++++++++++++++++ .../mezo/components/SideRailNav.tsx | 21 +- .../integrations/mezo/pipeline/legHandlers.ts | 18 ++ .../integrations/mezo/sim/bundles/borrow.ts | 91 ++++++ 4 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 src/components/integrations/mezo/components/ManageTroveDialog.tsx diff --git a/src/components/integrations/mezo/components/ManageTroveDialog.tsx b/src/components/integrations/mezo/components/ManageTroveDialog.tsx new file mode 100644 index 0000000..1007823 --- /dev/null +++ b/src/components/integrations/mezo/components/ManageTroveDialog.tsx @@ -0,0 +1,287 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useReadContract } from "wagmi"; +import { formatUnits, parseUnits, type Address } from "viem"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import { + buildBorrowAdjustBundle, + buildBorrowCloseBundle, +} from "../sim/bundles/borrow"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import type { SimulationBalances, SimulationRequest } from "../sim/types"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; +import { AssetInput } from "./AssetInput"; +import { AssetIcon } from "./AssetIcon"; + +interface ManageTroveDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + collateralBtc: bigint | undefined; + debtMusd: bigint | undefined; +} + +type Mode = "adjust" | "close"; + +export function ManageTroveDialog({ + open, + onOpenChange, + collateralBtc, + debtMusd, +}: ManageTroveDialogProps) { + const { address } = useAccount(); + const [mode, setMode] = useState("adjust"); + + const musdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address && open }, + }); + + const sortedTrovesHead = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.SortedTroves, + abi: MEZO_ABIS.SortedTroves, + functionName: "getFirst", + query: { enabled: open }, + }); + const troveInsertHint = sortedTrovesHead.data as Address | undefined; + + // Adjust mode inputs. Positive collDelta = add coll; negative = withdraw. + // Positive debtDelta = borrow more; negative = repay. + const [collDelta, setCollDelta] = useState("0"); + const [debtDelta, setDebtDelta] = useState("0"); + + useEffect(() => { + if (!open) { + setCollDelta("0"); + setDebtDelta("0"); + setMode("adjust"); + } + }, [open]); + + const collDeltaWei = useMemo(() => { + try { + return parseUnits(collDelta || "0", 18); + } catch { + return 0n; + } + }, [collDelta]); + const debtDeltaWei = useMemo(() => { + try { + return parseUnits(debtDelta || "0", 18); + } catch { + return 0n; + } + }, [debtDelta]); + + const adjustParams = useMemo(() => { + if (!address) return null; + const collDeposit = collDeltaWei > 0n ? collDeltaWei : 0n; + const collWithdrawal = collDeltaWei < 0n ? -collDeltaWei : 0n; + const debtChange = debtDeltaWei < 0n ? -debtDeltaWei : debtDeltaWei; + const isDebtIncrease = debtDeltaWei >= 0n; + return { + account: address as Address, + collDeposit, + collWithdrawal, + debtChange, + isDebtIncrease, + troveInsertHint, + }; + }, [address, collDeltaWei, debtDeltaWei, troveInsertHint]); + + const closeParams = useMemo(() => { + if (!address || debtMusd === undefined) return null; + return { account: address as Address, debtMusd }; + }, [address, debtMusd]); + + const bundle = useMemo(() => { + if (mode === "close" && closeParams) return buildBorrowCloseBundle(closeParams); + if (mode === "adjust" && adjustParams) return buildBorrowAdjustBundle(adjustParams); + return null; + }, [mode, adjustParams, closeParams]); + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: 0n, after: 0n }, + musd: { + before: (musdBalance.data as bigint | undefined) ?? 0n, + after: (musdBalance.data as bigint | undefined) ?? 0n, + }, + sMusd: { before: 0n, after: 0n }, + mezo: { before: 0n, after: 0n }, + }), + [musdBalance.data], + ); + + const request: SimulationRequest | null = useMemo(() => { + if (!bundle) return null; + // Skip sim when adjust mode has no real change requested. + if (mode === "adjust" && collDeltaWei === 0n && debtDeltaWei === 0n) return null; + return { legs: bundle.legs, views: bundle.views, beforeBalances }; + }, [bundle, mode, collDeltaWei, debtDeltaWei, beforeBalances]); + + const [debouncedRequest, setDebouncedRequest] = useState(null); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest, { enabled: open }); + + const pipeline = useMezoLegPipeline(); + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + const onExecute = async () => { + if (!bundle || !sim.data) return; + const summaries = sim.data.legs.map((l) => l.decodedSummary); + const hasConfirmedLeg = pipeline.runs.some((r) => r.status === "confirmed"); + if (pipeline.runs.length === 0 || !hasConfirmedLeg) { + pipeline.start(bundle.legs, summaries); + } + await pipeline.executeAll(); + }; + + const collText = collateralBtc !== undefined + ? Number(formatUnits(collateralBtc, 18)).toLocaleString(undefined, { + minimumFractionDigits: 4, + maximumFractionDigits: 6, + }) + : "—"; + const debtText = debtMusd !== undefined + ? Number(formatUnits(debtMusd, 18)).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : "—"; + + const musdBalanceValue = (musdBalance.data as bigint | undefined) ?? 0n; + const repayingMore = mode === "adjust" && debtDeltaWei < 0n && -debtDeltaWei > musdBalanceValue; + const closeShort = mode === "close" && debtMusd !== undefined && musdBalanceValue < debtMusd; + + return ( + + + + Manage Trove + + +
+
+
Collateral
+
{collText} BTC
+
+
+
Debt
+
{debtText} MUSD
+
+
+ + setMode(v as Mode)} className="mt-1"> + + Adjust + Close + + + + {mode === "adjust" && ( +
+ + + {repayingMore && ( +
+ Repay amount exceeds wallet MUSD balance. +
+ )} +
+ )} + + {mode === "close" && ( +
+ Burns the trove, repays {debtText} MUSD from your wallet, returns all collateral + ({collText} BTC) plus the 200 MUSD gas-comp refund. + {closeShort && ( +
+ Wallet has {Number(formatUnits(musdBalanceValue, 18)).toFixed(2)} MUSD — + short by {Number(formatUnits((debtMusd ?? 0n) - musdBalanceValue, 18)).toFixed(2)} MUSD. +
+ )} +
+ )} + + {/* Sim preview */} + {sim.isFetching && ( +
Simulating…
+ )} + {sim.error && ( +
+ Simulation error: {sim.error.message} +
+ )} + {sim.data && ( +
+ Simulation passed — {sim.data.legs.length} leg{sim.data.legs.length === 1 ? "" : "s"} ready. +
+ )} + + {pipeline.runs.length > 0 && ( + + )} + +
+
+ + + +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/integrations/mezo/components/SideRailNav.tsx b/src/components/integrations/mezo/components/SideRailNav.tsx index 51b6907..2bfc148 100644 --- a/src/components/integrations/mezo/components/SideRailNav.tsx +++ b/src/components/integrations/mezo/components/SideRailNav.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useAccount, useBalance, useReadContract } from "wagmi"; import { formatUnits, type Address } from "viem"; import { @@ -11,6 +12,7 @@ import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; import { MEZO_ABIS } from "../abi"; import { MEZO_TESTNET_CHAIN_ID } from "../constants"; import { MEZO_GLOSSARY, type GlossaryKey } from "../glossary"; +import { ManageTroveDialog } from "./ManageTroveDialog"; interface SideRailNavProps { active: MezoTabId; @@ -116,6 +118,8 @@ export function SideRailNav({ active, onChange }: SideRailNavProps) { const lockAmount = lockData?.amount; const lockEnd = lockData?.end; + const [manageOpen, setManageOpen] = useState(false); + return ( ); } diff --git a/src/components/integrations/mezo/pipeline/legHandlers.ts b/src/components/integrations/mezo/pipeline/legHandlers.ts index c921bfe..41ac5c0 100644 --- a/src/components/integrations/mezo/pipeline/legHandlers.ts +++ b/src/components/integrations/mezo/pipeline/legHandlers.ts @@ -143,7 +143,25 @@ export async function executeLeg( }); case "repayMUSD": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.BorrowerOperations, + abi: MEZO_ABIS.BorrowerOperations, + functionName: "repayMUSD", + args: [leg.amount, leg.upperHint, leg.lowerHint], + account, + }); + case "closeTrove": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.BorrowerOperations, + abi: MEZO_ABIS.BorrowerOperations, + functionName: "closeTrove", + args: [], + account, + }); + case "sMusdWithdraw": case "gaugeWithdraw": case "gaugeClaim": diff --git a/src/components/integrations/mezo/sim/bundles/borrow.ts b/src/components/integrations/mezo/sim/bundles/borrow.ts index b2726d6..09d4c87 100644 --- a/src/components/integrations/mezo/sim/bundles/borrow.ts +++ b/src/components/integrations/mezo/sim/bundles/borrow.ts @@ -1,4 +1,5 @@ import type { Address } from "viem"; +import { MEZO_CONTRACTS } from "../../../../../../data/mezoContracts"; import type { MezoLegSpec } from "../../pipeline/mezoLegs"; import type { ViewCallSpec } from "../types"; @@ -38,3 +39,93 @@ export function buildBorrowOpenBundle(params: BorrowOpenParams): { return { legs, views }; } + +export interface BorrowAdjustParams { + account: Address; + /** BTC to deposit as additional collateral (0 = no add). */ + collDeposit: bigint; + /** BTC to withdraw from collateral (0 = no withdraw). */ + collWithdrawal: bigint; + /** Magnitude of debt change. Use isDebtIncrease to indicate direction. */ + debtChange: bigint; + /** true = borrow more MUSD, false = repay MUSD. */ + isDebtIncrease: boolean; + troveInsertHint?: Address; +} + +/** + * adjustTrove bundle covering both directions in one call. When repaying + * (isDebtIncrease=false && debtChange>0) we prepend a MUSD approve so the + * contract can pull the repayment. + */ +export function buildBorrowAdjustBundle(params: BorrowAdjustParams): { + legs: MezoLegSpec[]; + views: ViewCallSpec[]; +} { + const hint = params.troveInsertHint ?? ZERO_ADDR; + const legs: MezoLegSpec[] = []; + + const repaying = !params.isDebtIncrease && params.debtChange > 0n; + if (repaying) { + legs.push({ + type: "approveErc20", + token: MEZO_CONTRACTS.MUSD, + spender: MEZO_CONTRACTS.BorrowerOperations, + amount: params.debtChange, + tokenLabel: "MUSD", + }); + } + + legs.push({ + type: "troveAdjust", + collDeposit: params.collDeposit, + collWithdrawal: params.collWithdrawal, + debtChange: params.debtChange, + isDebtIncrease: params.isDebtIncrease, + upperHint: hint, + lowerHint: hint, + }); + + const views: ViewCallSpec[] = [ + { kind: "priceFeedFetch" }, + { kind: "musdBalanceOf", account: params.account }, + { kind: "troveDebtCollateral", account: params.account }, + ]; + + return { legs, views }; +} + +export interface BorrowCloseParams { + account: Address; + /** Current trove debt; required to size the MUSD approve. */ + debtMusd: bigint; +} + +/** + * closeTrove bundle. Approves MUSD up to the current debt so + * BorrowerOperations can pull the repayment, then calls closeTrove which + * burns the trove, refunds the 200 MUSD gas comp, and returns the BTC. + */ +export function buildBorrowCloseBundle(params: BorrowCloseParams): { + legs: MezoLegSpec[]; + views: ViewCallSpec[]; +} { + const legs: MezoLegSpec[] = [ + { + type: "approveErc20", + token: MEZO_CONTRACTS.MUSD, + spender: MEZO_CONTRACTS.BorrowerOperations, + amount: params.debtMusd, + tokenLabel: "MUSD", + }, + { type: "closeTrove" }, + ]; + + const views: ViewCallSpec[] = [ + { kind: "priceFeedFetch" }, + { kind: "musdBalanceOf", account: params.account }, + { kind: "troveDebtCollateral", account: params.account }, + ]; + + return { legs, views }; +} From 256e7929e67de3692ee90556efb57b7aad47b18b Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 25 May 2026 01:07:48 +0100 Subject: [PATCH 16/18] fix: encode repayMUSD + closeTrove for eth_simulateV1 Execution handlers were wired in the previous commit but the simulation-side encoder still threw "v2 leg not implemented", so the Manage Trove preview failed before the user could sign. Encode both as plain BorrowerOperations calls; the trove dialog now runs through the sim path cleanly. --- .../integrations/mezo/sim/buildCalls.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/integrations/mezo/sim/buildCalls.ts b/src/components/integrations/mezo/sim/buildCalls.ts index 6671fc8..0aa6188 100644 --- a/src/components/integrations/mezo/sim/buildCalls.ts +++ b/src/components/integrations/mezo/sim/buildCalls.ts @@ -151,8 +151,24 @@ export function encodeWrite(account: Address, leg: MezoLegSpec): SimCall { }; } - case "repayMUSD": - case "closeTrove": + case "repayMUSD": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.BorrowerOperations, + functionName: "repayMUSD", + args: [leg.amount, leg.upperHint, leg.lowerHint], + }); + return { from: account, to: MEZO_CONTRACTS.BorrowerOperations, input }; + } + + case "closeTrove": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.BorrowerOperations, + functionName: "closeTrove", + args: [], + }); + return { from: account, to: MEZO_CONTRACTS.BorrowerOperations, input }; + } + case "sMusdWithdraw": case "gaugeWithdraw": case "gaugeClaim": From e9270044fe0cc79d50783c3092a81f610623dcf1 Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 25 May 2026 01:12:38 +0100 Subject: [PATCH 17/18] fix: close-trove pulls debt minus gas comp, not full debt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liquity-fork closeTrove pulls (debt - GAS_COMPENSATION) from the user's wallet. The 200 MUSD gas comp lives in the protocol's Gas Pool throughout the trove's life and is burned automatically on clean close — never the borrower's cost. The earlier approve sized to the full debt and the dialog warning said the user was "short by" the gas comp amount, both wrong. --- .../mezo/components/ManageTroveDialog.tsx | 20 ++++++++++++++----- .../integrations/mezo/sim/bundles/borrow.ts | 18 ++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/components/integrations/mezo/components/ManageTroveDialog.tsx b/src/components/integrations/mezo/components/ManageTroveDialog.tsx index 1007823..e8abc59 100644 --- a/src/components/integrations/mezo/components/ManageTroveDialog.tsx +++ b/src/components/integrations/mezo/components/ManageTroveDialog.tsx @@ -11,7 +11,10 @@ import { import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { + MEZO_CONTRACTS, + MUSD_GAS_COMPENSATION, +} from "../../../../../data/mezoContracts"; import { MEZO_ABIS } from "../abi"; import { MEZO_TESTNET_CHAIN_ID } from "../constants"; import { @@ -174,7 +177,13 @@ export function ManageTroveDialog({ const musdBalanceValue = (musdBalance.data as bigint | undefined) ?? 0n; const repayingMore = mode === "adjust" && debtDeltaWei < 0n && -debtDeltaWei > musdBalanceValue; - const closeShort = mode === "close" && debtMusd !== undefined && musdBalanceValue < debtMusd; + // Liquity model: closeTrove pulls (debt - gas comp) from the user. The 200 + // MUSD gas comp lives in the protocol's Gas Pool and is burned automatically. + const closeRepayAmount = + debtMusd !== undefined && debtMusd > MUSD_GAS_COMPENSATION + ? debtMusd - MUSD_GAS_COMPENSATION + : 0n; + const closeShort = mode === "close" && musdBalanceValue < closeRepayAmount; return ( @@ -232,12 +241,13 @@ export function ManageTroveDialog({ {mode === "close" && (
- Burns the trove, repays {debtText} MUSD from your wallet, returns all collateral - ({collText} BTC) plus the 200 MUSD gas-comp refund. + Repays {Number(formatUnits(closeRepayAmount, 18)).toFixed(2)} MUSD from your wallet + ({debtText} debt − 200 gas comp held by the protocol) and returns {collText} BTC + collateral. The 200 MUSD in the Gas Pool is burned on clean close — never your cost. {closeShort && (
Wallet has {Number(formatUnits(musdBalanceValue, 18)).toFixed(2)} MUSD — - short by {Number(formatUnits((debtMusd ?? 0n) - musdBalanceValue, 18)).toFixed(2)} MUSD. + short by {Number(formatUnits(closeRepayAmount - musdBalanceValue, 18)).toFixed(2)} MUSD.
)}
diff --git a/src/components/integrations/mezo/sim/bundles/borrow.ts b/src/components/integrations/mezo/sim/bundles/borrow.ts index 09d4c87..9f22b68 100644 --- a/src/components/integrations/mezo/sim/bundles/borrow.ts +++ b/src/components/integrations/mezo/sim/bundles/borrow.ts @@ -1,5 +1,8 @@ import type { Address } from "viem"; -import { MEZO_CONTRACTS } from "../../../../../../data/mezoContracts"; +import { + MEZO_CONTRACTS, + MUSD_GAS_COMPENSATION, +} from "../../../../../../data/mezoContracts"; import type { MezoLegSpec } from "../../pipeline/mezoLegs"; import type { ViewCallSpec } from "../types"; @@ -102,20 +105,25 @@ export interface BorrowCloseParams { } /** - * closeTrove bundle. Approves MUSD up to the current debt so - * BorrowerOperations can pull the repayment, then calls closeTrove which - * burns the trove, refunds the 200 MUSD gas comp, and returns the BTC. + * closeTrove bundle. BorrowerOperations only pulls `debt - GAS_COMP` from + * the user (the 200 MUSD gas comp lives in the protocol's Gas Pool and is + * burned automatically on clean close — never the user's cost). So we + * approve exactly the pull amount, then call closeTrove. */ export function buildBorrowCloseBundle(params: BorrowCloseParams): { legs: MezoLegSpec[]; views: ViewCallSpec[]; } { + const repayAmount = + params.debtMusd > MUSD_GAS_COMPENSATION + ? params.debtMusd - MUSD_GAS_COMPENSATION + : 0n; const legs: MezoLegSpec[] = [ { type: "approveErc20", token: MEZO_CONTRACTS.MUSD, spender: MEZO_CONTRACTS.BorrowerOperations, - amount: params.debtMusd, + amount: repayAmount, tokenLabel: "MUSD", }, { type: "closeTrove" }, From 71556f85bc866e23330f4cbe5b66e63d895b68ea Mon Sep 17 00:00:00 2001 From: Timidan Date: Mon, 25 May 2026 01:45:43 +0100 Subject: [PATCH 18/18] feat: manage-position dialogs for Lock, Savings, and Liquidity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Side-rail now surfaces every position (Trove, veMEZO, sMUSD savings, MUSD/BTC LP) and each row opens a dedicated Manage dialog. When a position is empty, the row shows a "Deposit →" / "Add →" prompt so the user can open one without leaving the rail. - ManageLockDialog: top up MEZO into the existing veMEZO position (approve + increaseAmount) or extend the unlock time. Extend passes a relative duration from now (Aerodrome VotingEscrow semantics), not an absolute timestamp. - ManageSavingsDialog: deposit MUSD into sMUSD or withdraw MUSD from the vault (the vault burns the matching sMUSD shares). - ManageLiquidityDialog: add MUSD/BTC liquidity or remove LP shares. Carries a testnet banner — slippage min is 0 and the dialog should not ship to mainnet without slippage controls. Supporting work: - Wire sMusdWithdraw + routerRemoveLiquidity (sim encoder + exec handler + decoded summary + Router ABI entry). - Shared BalanceDeltaPreview surfaces concrete asset deltas (wallet BTC / MUSD / sMUSD / MEZO + trove debt/coll + LP + veMEZO lock) from the simulation outcome. - Close-trove approve sized off principal+interestOwed (total debt) minus the 200 MUSD gas comp — the protocol's Gas Pool burns the comp, the user only pays what they actually borrowed. - Each dialog resets the pipeline on open and fingerprints legs; changing inputs after a partial run can't resume against stale specs. - Implement repayMUSD and closeTrove on both the sim and execution sides (were previously v2-stubs). --- src/components/integrations/mezo/abi/index.ts | 19 + .../mezo/components/BalanceDeltaPreview.tsx | 159 ++++++++ .../mezo/components/ManageLiquidityDialog.tsx | 343 ++++++++++++++++++ .../mezo/components/ManageLockDialog.tsx | 293 +++++++++++++++ .../mezo/components/ManageSavingsDialog.tsx | 243 +++++++++++++ .../mezo/components/ManageTroveDialog.tsx | 34 +- .../mezo/components/SideRailNav.tsx | 110 +++++- .../integrations/mezo/pipeline/legHandlers.ts | 28 ++ .../integrations/mezo/pipeline/mezoLegs.ts | 11 + .../integrations/mezo/sim/buildCalls.ts | 33 +- .../integrations/mezo/sim/decodeResults.ts | 2 + 11 files changed, 1265 insertions(+), 10 deletions(-) create mode 100644 src/components/integrations/mezo/components/BalanceDeltaPreview.tsx create mode 100644 src/components/integrations/mezo/components/ManageLiquidityDialog.tsx create mode 100644 src/components/integrations/mezo/components/ManageLockDialog.tsx create mode 100644 src/components/integrations/mezo/components/ManageSavingsDialog.tsx diff --git a/src/components/integrations/mezo/abi/index.ts b/src/components/integrations/mezo/abi/index.ts index 75266ab..e45fc8c 100644 --- a/src/components/integrations/mezo/abi/index.ts +++ b/src/components/integrations/mezo/abi/index.ts @@ -556,6 +556,25 @@ const ROUTER_ABI = [ { name: "liquidity", type: "uint256" }, ], }, + { + type: "function", + name: "removeLiquidity", + stateMutability: "nonpayable", + inputs: [ + { name: "tokenA", type: "address" }, + { name: "tokenB", type: "address" }, + { name: "stable", type: "bool" }, + { name: "liquidity", type: "uint256" }, + { name: "amountAMin", type: "uint256" }, + { name: "amountBMin", type: "uint256" }, + { name: "to", type: "address" }, + { name: "deadline", type: "uint256" }, + ], + outputs: [ + { name: "amountA", type: "uint256" }, + { name: "amountB", type: "uint256" }, + ], + }, ] as const; export const MEZO_ABIS = { diff --git a/src/components/integrations/mezo/components/BalanceDeltaPreview.tsx b/src/components/integrations/mezo/components/BalanceDeltaPreview.tsx new file mode 100644 index 0000000..26b3dd8 --- /dev/null +++ b/src/components/integrations/mezo/components/BalanceDeltaPreview.tsx @@ -0,0 +1,159 @@ +import { formatUnits } from "viem"; +import { AssetIcon, type AssetSymbol } from "./AssetIcon"; +import type { + SimulationBalances, + SimulationLiquidity, + SimulationSwap, + SimulationTrove, + SimulationVeMezo, +} from "../sim/types"; + +export interface BalanceDeltaPreviewProps { + balances: SimulationBalances; + troveBefore?: { debt: bigint; coll: bigint } | null; + troveAfter?: SimulationTrove | null; + veMezoAfter?: SimulationVeMezo | null; + swap?: SimulationSwap | null; + liquidity?: SimulationLiquidity | null; + legsCount: number; + /** Extra rows: precomputed deltas the caller wants surfaced (e.g. LP). */ + extra?: { symbol: AssetSymbol; label: string; delta: bigint; precision?: number }[]; +} + +export function BalanceDeltaPreview({ + balances, + troveBefore, + troveAfter, + veMezoAfter, + swap, + liquidity, + legsCount, + extra, +}: BalanceDeltaPreviewProps) { + const rows: { symbol: AssetSymbol; label: string; delta: bigint; precision?: number }[] = []; + const push = ( + symbol: AssetSymbol, + label: string, + before: bigint, + after: bigint, + precision = 4, + ) => { + const delta = after - before; + if (delta !== 0n) rows.push({ symbol, label, delta, precision }); + }; + push("BTC", "Wallet BTC", balances.btc.before, balances.btc.after, 6); + push("MUSD", "Wallet MUSD", balances.musd.before, balances.musd.after, 2); + push("sMUSD", "sMUSD savings", balances.sMusd.before, balances.sMusd.after, 2); + push("MEZO", "MEZO", balances.mezo.before, balances.mezo.after, 2); + + for (const e of extra ?? []) rows.push(e); + + const troveDebtDelta = + troveBefore && troveAfter ? troveAfter.debt - troveBefore.debt : null; + const troveCollDelta = + troveBefore && troveAfter ? troveAfter.collateral - troveBefore.coll : null; + const troveClosed = troveBefore && troveAfter === null; + + const swapOut = swap?.outputDelta; + const lpDelta = + liquidity?.lpBalanceBefore !== undefined && liquidity?.lpBalanceAfter !== undefined + ? liquidity.lpBalanceAfter - liquidity.lpBalanceBefore + : null; + + const hasAny = + rows.length > 0 || + troveClosed || + (troveDebtDelta !== null && troveDebtDelta !== 0n) || + (troveCollDelta !== null && troveCollDelta !== 0n) || + swapOut !== undefined || + (lpDelta !== null && lpDelta !== 0n) || + veMezoAfter != null; + + return ( +
+
+ Simulation passed + + {legsCount} leg{legsCount === 1 ? "" : "s"} + +
+ {!hasAny &&
No balance changes.
} +
    + {rows.map((r, i) => ( + + ))} + {troveClosed && ( +
  • + Trove + closed +
  • + )} + {troveDebtDelta !== null && troveDebtDelta !== 0n && ( + + )} + {troveCollDelta !== null && troveCollDelta !== 0n && ( + + )} + {swapOut !== undefined && swapOut !== 0n && ( + + )} + {lpDelta !== null && lpDelta !== 0n && ( + + )} + {veMezoAfter && veMezoAfter.tokenId > 0n && ( +
  • + + + veMEZO lock + + + token #{veMezoAfter.tokenId.toString()} + {veMezoAfter.lockEnd > 0n && ( + + · unlocks {new Date(Number(veMezoAfter.lockEnd) * 1000).toLocaleDateString()} + + )} + +
  • + )} +
+
+ ); +} + +function DeltaRow({ + symbol, + label, + delta, + precision, +}: { + symbol: AssetSymbol; + label: string; + delta: bigint; + precision: number; +}) { + const positive = delta > 0n; + const abs = delta < 0n ? -delta : delta; + const formatted = Number(formatUnits(abs, 18)).toLocaleString(undefined, { + minimumFractionDigits: Math.min(precision, 2), + maximumFractionDigits: precision, + }); + return ( +
  • + + + {label} + + + {positive ? "+" : "−"} + {formatted} {symbol} + +
  • + ); +} diff --git a/src/components/integrations/mezo/components/ManageLiquidityDialog.tsx b/src/components/integrations/mezo/components/ManageLiquidityDialog.tsx new file mode 100644 index 0000000..aad653a --- /dev/null +++ b/src/components/integrations/mezo/components/ManageLiquidityDialog.tsx @@ -0,0 +1,343 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useReadContract } from "wagmi"; +import { formatUnits, parseUnits, type Address } from "viem"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import type { MezoLegSpec } from "../pipeline/mezoLegs"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import type { SimulationBalances, SimulationRequest } from "../sim/types"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; +import { AssetInput } from "./AssetInput"; +import { AssetIcon } from "./AssetIcon"; +import { BalanceDeltaPreview } from "./BalanceDeltaPreview"; + +interface ManageLiquidityDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** LP token balance the user holds in the MUSD/BTC pool. */ + lpBalance: bigint | undefined; +} + +type Mode = "add" | "remove"; + +const DEADLINE_BUFFER = 20n * 60n; // 20 min + +export function ManageLiquidityDialog({ + open, + onOpenChange, + lpBalance, +}: ManageLiquidityDialogProps) { + const { address } = useAccount(); + const [mode, setMode] = useState("add"); + + const musdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address && open }, + }); + const btcBalanceErc20 = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.BTC, + abi: MEZO_ABIS.MUSD, // ERC-20 surface + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address && open }, + }); + + const [musdAmount, setMusdAmount] = useState("0"); + const [btcAmount, setBtcAmount] = useState("0"); + const [lpAmount, setLpAmount] = useState("0"); + + useEffect(() => { + if (!open) { + setMusdAmount("0"); + setBtcAmount("0"); + setLpAmount("0"); + setMode("add"); + } + }, [open]); + + const musdWei = useMemo(() => { + try { return parseUnits(musdAmount || "0", 18); } catch { return 0n; } + }, [musdAmount]); + const btcWei = useMemo(() => { + try { return parseUnits(btcAmount || "0", 18); } catch { return 0n; } + }, [btcAmount]); + const lpWei = useMemo(() => { + try { return parseUnits(lpAmount || "0", 18); } catch { return 0n; } + }, [lpAmount]); + + const deadline = useMemo( + () => BigInt(Math.floor(Date.now() / 1000)) + DEADLINE_BUFFER, + [musdWei, btcWei, lpWei, mode], + ); + + const legs: MezoLegSpec[] = useMemo(() => { + if (!address) return []; + if (mode === "add") { + if (musdWei <= 0n || btcWei <= 0n) return []; + return [ + { + type: "approveErc20", + token: MEZO_CONTRACTS.MUSD, + spender: MEZO_CONTRACTS.Router, + amount: musdWei, + tokenLabel: "MUSD", + }, + { + type: "approveErc20", + token: MEZO_CONTRACTS.BTC, + spender: MEZO_CONTRACTS.Router, + amount: btcWei, + tokenLabel: "BTC", + }, + { + type: "routerAddLiquidity", + tokenA: MEZO_CONTRACTS.MUSD, + tokenB: MEZO_CONTRACTS.BTC, + stable: false, + amountADesired: musdWei, + amountBDesired: btcWei, + amountAMin: 0n, + amountBMin: 0n, + to: address as Address, + deadline, + }, + ]; + } + // remove + if (lpWei <= 0n) return []; + return [ + { + type: "approveErc20", + token: MEZO_CONTRACTS.MUSD_BTC_Pool, + spender: MEZO_CONTRACTS.Router, + amount: lpWei, + tokenLabel: "LP", + }, + { + type: "routerRemoveLiquidity", + tokenA: MEZO_CONTRACTS.MUSD, + tokenB: MEZO_CONTRACTS.BTC, + stable: false, + liquidity: lpWei, + amountAMin: 0n, + amountBMin: 0n, + to: address as Address, + deadline, + }, + ]; + }, [mode, musdWei, btcWei, lpWei, address, deadline]); + + const musdBalanceValue = (musdBalance.data as bigint | undefined) ?? 0n; + const btcBalanceValue = (btcBalanceErc20.data as bigint | undefined) ?? 0n; + const lpBalanceValue = lpBalance ?? 0n; + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: btcBalanceValue, after: btcBalanceValue }, + musd: { before: musdBalanceValue, after: musdBalanceValue }, + sMusd: { before: 0n, after: 0n }, + mezo: { before: 0n, after: 0n }, + }), + [btcBalanceValue, musdBalanceValue], + ); + + const request: SimulationRequest | null = useMemo(() => { + if (legs.length === 0 || !address) return null; + return { + legs, + views: [ + { kind: "musdBalanceOf", account: address as Address }, + { kind: "lpBalanceOfForPair", tokenA: MEZO_CONTRACTS.MUSD, tokenB: MEZO_CONTRACTS.BTC, stable: false, account: address as Address }, + ], + beforeBalances, + }; + }, [legs, address, beforeBalances]); + + const [debouncedRequest, setDebouncedRequest] = useState(null); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest, { enabled: open }); + + const pipeline = useMezoLegPipeline(); + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + useEffect(() => { + if (open) pipeline.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const legsFingerprint = useMemo( + () => JSON.stringify(legs, bigintReplacer), + [legs], + ); + + const onExecute = async () => { + if (!sim.data || legs.length === 0) return; + const summaries = sim.data.legs.map((l) => l.decodedSummary); + const existingFp = pipeline.runs.length + ? JSON.stringify(pipeline.runs.map((r) => r.spec), bigintReplacer) + : ""; + const fpMatches = existingFp === legsFingerprint; + const hasConfirmedLeg = pipeline.runs.some((r) => r.status === "confirmed"); + if (!fpMatches || pipeline.runs.length === 0 || !hasConfirmedLeg) { + pipeline.start(legs, summaries); + } + await pipeline.executeAll(); + }; + + const addExceedsMusd = mode === "add" && musdWei > musdBalanceValue; + const addExceedsBtc = mode === "add" && btcWei > btcBalanceValue; + const removeExceedsLp = mode === "remove" && lpWei > lpBalanceValue; + + return ( + + + + Manage MUSD/BTC liquidity + + +
    +
    +
    MUSD
    +
    + {Number(formatUnits(musdBalanceValue, 18)).toFixed(2)} +
    +
    +
    +
    BTC
    +
    + {Number(formatUnits(btcBalanceValue, 18)).toFixed(6)} +
    +
    +
    +
    LP shares
    +
    + {Number(formatUnits(lpBalanceValue, 18)).toFixed(6)} +
    +
    +
    + + setMode(v as Mode)} className="mt-1"> + + Add + Remove + + + +
    + Testnet · slippage min set to 0 — not safe for mainnet liquidity. +
    + + {mode === "add" && ( +
    + + + {addExceedsMusd && ( +
    MUSD amount exceeds wallet balance.
    + )} + {addExceedsBtc && ( +
    BTC amount exceeds wallet balance.
    + )} +
    + )} + + {mode === "remove" && ( +
    + + {removeExceedsLp && ( +
    LP amount exceeds your position.
    + )} +
    + )} + + {sim.isFetching && ( +
    Simulating…
    + )} + {sim.error && ( +
    + Simulation error: {sim.error.message} +
    + )} + {sim.data && ( + + )} + + {pipeline.runs.length > 0 && ( + + )} + +
    +
    + + + + +
    +
    + + +
    +
    +
    +
    + ); +} + +function bigintReplacer(_key: string, value: unknown) { + return typeof value === "bigint" ? value.toString() : value; +} diff --git a/src/components/integrations/mezo/components/ManageLockDialog.tsx b/src/components/integrations/mezo/components/ManageLockDialog.tsx new file mode 100644 index 0000000..06f39d3 --- /dev/null +++ b/src/components/integrations/mezo/components/ManageLockDialog.tsx @@ -0,0 +1,293 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useReadContract } from "wagmi"; +import { formatUnits, parseUnits, type Address } from "viem"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import type { MezoLegSpec } from "../pipeline/mezoLegs"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import type { SimulationBalances, SimulationRequest } from "../sim/types"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; +import { AssetInput } from "./AssetInput"; +import { AssetIcon } from "./AssetIcon"; +import { BalanceDeltaPreview } from "./BalanceDeltaPreview"; + +interface ManageLockDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tokenId: bigint | undefined; + lockedAmount: bigint | undefined; + lockEnd: bigint | undefined; +} + +type Mode = "topup" | "extend"; + +const SECONDS_PER_DAY = 86_400n; +const SEVEN_DAYS = 7n * SECONDS_PER_DAY; + +export function ManageLockDialog({ + open, + onOpenChange, + tokenId, + lockedAmount, + lockEnd, +}: ManageLockDialogProps) { + const { address } = useAccount(); + const [mode, setMode] = useState("topup"); + + const mezoBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MEZO, + abi: MEZO_ABIS.MEZO, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address && open }, + }); + + const [topupAmount, setTopupAmount] = useState("0"); + const [extendDays, setExtendDays] = useState("7"); + + useEffect(() => { + if (!open) { + setTopupAmount("0"); + setExtendDays("7"); + setMode("topup"); + } + }, [open]); + + const topupWei = useMemo(() => { + try { + return parseUnits(topupAmount || "0", 18); + } catch { + return 0n; + } + }, [topupAmount]); + + const extendSeconds = useMemo(() => { + const days = Number(extendDays || "0"); + if (!Number.isFinite(days) || days <= 0) return 0n; + return BigInt(Math.floor(days)) * SECONDS_PER_DAY; + }, [extendDays]); + + // Build legs based on mode + const legs: MezoLegSpec[] = useMemo(() => { + if (!tokenId || tokenId === 0n) return []; + if (mode === "topup") { + if (topupWei <= 0n) return []; + return [ + { + type: "approveErc20", + token: MEZO_CONTRACTS.MEZO, + spender: MEZO_CONTRACTS.veMEZO, + amount: topupWei, + tokenLabel: "MEZO", + }, + { type: "veMezoIncreaseAmount", tokenId, amount: topupWei }, + ]; + } + // extend — Aerodrome-style VotingEscrow expects a relative lock duration + // from `block.timestamp`. To extend the current end by `extendSeconds`, + // we send `(currentLockEnd + extendSeconds) - nowSeconds`. + if (extendSeconds < SEVEN_DAYS) return []; + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + const targetEnd = (lockEnd ?? nowSeconds) + extendSeconds; + if (targetEnd <= nowSeconds) return []; + const relativeDuration = targetEnd - nowSeconds; + return [ + { + type: "veMezoIncreaseUnlockTime", + tokenId, + lockDuration: relativeDuration, + }, + ]; + }, [mode, tokenId, topupWei, extendSeconds, lockEnd]); + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: 0n, after: 0n }, + musd: { before: 0n, after: 0n }, + sMusd: { before: 0n, after: 0n }, + mezo: { + before: (mezoBalance.data as bigint | undefined) ?? 0n, + after: (mezoBalance.data as bigint | undefined) ?? 0n, + }, + }), + [mezoBalance.data], + ); + + const request: SimulationRequest | null = useMemo(() => { + if (legs.length === 0 || !address) return null; + const views: SimulationRequest["views"] = [ + { kind: "mezoBalanceOf", account: address as Address }, + ]; + if (tokenId && tokenId > 0n) { + views.push({ kind: "veMezoLockedLiteral", tokenId }); + views.push({ kind: "veMezoBalanceOfNFTLiteral", tokenId }); + } + return { legs, views, beforeBalances }; + }, [legs, address, beforeBalances, tokenId]); + + const [debouncedRequest, setDebouncedRequest] = useState(null); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest, { enabled: open }); + + const pipeline = useMezoLegPipeline(); + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + useEffect(() => { + if (open) pipeline.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const legsFingerprint = useMemo( + () => JSON.stringify(legs, bigintReplacer), + [legs], + ); + + const onExecute = async () => { + if (!sim.data || legs.length === 0) return; + const summaries = sim.data.legs.map((l) => l.decodedSummary); + const existingFp = pipeline.runs.length + ? JSON.stringify(pipeline.runs.map((r) => r.spec), bigintReplacer) + : ""; + const fpMatches = existingFp === legsFingerprint; + const hasConfirmedLeg = pipeline.runs.some((r) => r.status === "confirmed"); + if (!fpMatches || pipeline.runs.length === 0 || !hasConfirmedLeg) { + pipeline.start(legs, summaries); + } + await pipeline.executeAll(); + }; + + const mezoBalanceValue = (mezoBalance.data as bigint | undefined) ?? 0n; + const topupExceeds = mode === "topup" && topupWei > mezoBalanceValue; + + const lockedText = lockedAmount !== undefined + ? Number(formatUnits(lockedAmount, 18)).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 4, + }) + : "—"; + const unlockText = lockEnd && lockEnd > 0n + ? new Date(Number(lockEnd) * 1000).toLocaleDateString() + : "—"; + + return ( + + + + Manage veMEZO lock + + +
    +
    +
    Locked MEZO
    +
    {lockedText}
    +
    +
    +
    Unlocks
    +
    {unlockText}
    +
    +
    + + setMode(v as Mode)} className="mt-1"> + + Top up + Extend lock + + + + {mode === "topup" && ( +
    + + {topupExceeds && ( +
    + Top-up exceeds wallet MEZO balance. +
    + )} +
    + )} + + {mode === "extend" && ( +
    + +
    + )} + + {sim.isFetching && ( +
    Simulating…
    + )} + {sim.error && ( +
    + Simulation error: {sim.error.message} +
    + )} + {sim.data && ( + + )} + + {pipeline.runs.length > 0 && ( + + )} + +
    +
    + + + +
    +
    + + +
    +
    +
    +
    + ); +} + +function bigintReplacer(_key: string, value: unknown) { + return typeof value === "bigint" ? value.toString() : value; +} diff --git a/src/components/integrations/mezo/components/ManageSavingsDialog.tsx b/src/components/integrations/mezo/components/ManageSavingsDialog.tsx new file mode 100644 index 0000000..c3dce44 --- /dev/null +++ b/src/components/integrations/mezo/components/ManageSavingsDialog.tsx @@ -0,0 +1,243 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAccount, useReadContract } from "wagmi"; +import { formatUnits, parseUnits, type Address } from "viem"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { MEZO_CONTRACTS } from "../../../../../data/mezoContracts"; +import { MEZO_ABIS } from "../abi"; +import { MEZO_TESTNET_CHAIN_ID } from "../constants"; +import type { MezoLegSpec } from "../pipeline/mezoLegs"; +import { useMezoBundleSimulation } from "../sim/useMezoBundleSimulation"; +import type { SimulationBalances, SimulationRequest } from "../sim/types"; +import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; +import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; +import { AssetInput } from "./AssetInput"; +import { AssetIcon } from "./AssetIcon"; +import { BalanceDeltaPreview } from "./BalanceDeltaPreview"; + +interface ManageSavingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + sMusdBalance: bigint | undefined; +} + +type Mode = "deposit" | "withdraw"; + +export function ManageSavingsDialog({ + open, + onOpenChange, + sMusdBalance, +}: ManageSavingsDialogProps) { + const { address } = useAccount(); + const [mode, setMode] = useState("deposit"); + + const musdBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address && open }, + }); + + const [amount, setAmount] = useState("0"); + useEffect(() => { + if (!open) { + setAmount("0"); + setMode("deposit"); + } + }, [open]); + + const amountWei = useMemo(() => { + try { + return parseUnits(amount || "0", 18); + } catch { + return 0n; + } + }, [amount]); + + const legs: MezoLegSpec[] = useMemo(() => { + if (amountWei <= 0n) return []; + if (mode === "deposit") { + return [ + { + type: "approveErc20", + token: MEZO_CONTRACTS.MUSD, + spender: MEZO_CONTRACTS.sMUSD, + amount: amountWei, + tokenLabel: "MUSD", + }, + { type: "sMusdDeposit", amount: amountWei }, + ]; + } + return [{ type: "sMusdWithdraw", amount: amountWei }]; + }, [mode, amountWei]); + + const musdBalanceValue = (musdBalance.data as bigint | undefined) ?? 0n; + const sMusdBalanceValue = sMusdBalance ?? 0n; + + const beforeBalances: SimulationBalances = useMemo( + () => ({ + btc: { before: 0n, after: 0n }, + musd: { before: musdBalanceValue, after: musdBalanceValue }, + sMusd: { before: sMusdBalanceValue, after: sMusdBalanceValue }, + mezo: { before: 0n, after: 0n }, + }), + [musdBalanceValue, sMusdBalanceValue], + ); + + const request: SimulationRequest | null = useMemo(() => { + if (legs.length === 0 || !address) return null; + return { + legs, + views: [ + { kind: "musdBalanceOf", account: address as Address }, + { kind: "sMusdBalanceOf", account: address as Address }, + ], + beforeBalances, + }; + }, [legs, address, beforeBalances]); + + const [debouncedRequest, setDebouncedRequest] = useState(null); + useEffect(() => { + const t = setTimeout(() => setDebouncedRequest(request), 350); + return () => clearTimeout(t); + }, [request]); + + const sim = useMezoBundleSimulation(debouncedRequest, { enabled: open }); + + const pipeline = useMezoLegPipeline(); + const isExecuting = pipeline.runs.some( + (r) => r.status === "signing" || r.status === "confirming", + ); + + useEffect(() => { + if (open) pipeline.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const legsFingerprint = useMemo( + () => JSON.stringify(legs, bigintReplacer), + [legs], + ); + + const onExecute = async () => { + if (!sim.data || legs.length === 0) return; + const summaries = sim.data.legs.map((l) => l.decodedSummary); + const existingFp = pipeline.runs.length + ? JSON.stringify(pipeline.runs.map((r) => r.spec), bigintReplacer) + : ""; + const fpMatches = existingFp === legsFingerprint; + const hasConfirmedLeg = pipeline.runs.some((r) => r.status === "confirmed"); + if (!fpMatches || pipeline.runs.length === 0 || !hasConfirmedLeg) { + pipeline.start(legs, summaries); + } + await pipeline.executeAll(); + }; + + const depositExceeds = mode === "deposit" && amountWei > musdBalanceValue; + const withdrawExceeds = mode === "withdraw" && amountWei > sMusdBalanceValue; + + return ( + + + + Manage sMUSD savings + + +
    +
    +
    Wallet MUSD
    +
    + {Number(formatUnits(musdBalanceValue, 18)).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
    +
    +
    +
    sMUSD balance
    +
    + {Number(formatUnits(sMusdBalanceValue, 18)).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
    +
    +
    + + setMode(v as Mode)} className="mt-1"> + + Deposit + Withdraw + + + +
    + + {depositExceeds && ( +
    Deposit exceeds wallet MUSD balance.
    + )} + {withdrawExceeds && ( +
    Withdraw exceeds sMUSD balance.
    + )} +
    + + {sim.isFetching && ( +
    Simulating…
    + )} + {sim.error && ( +
    + Simulation error: {sim.error.message} +
    + )} + {sim.data && ( + + )} + + {pipeline.runs.length > 0 && ( + + )} + +
    +
    + + + +
    +
    + + +
    +
    +
    +
    + ); +} + +function bigintReplacer(_key: string, value: unknown) { + return typeof value === "bigint" ? value.toString() : value; +} diff --git a/src/components/integrations/mezo/components/ManageTroveDialog.tsx b/src/components/integrations/mezo/components/ManageTroveDialog.tsx index e8abc59..562bc7e 100644 --- a/src/components/integrations/mezo/components/ManageTroveDialog.tsx +++ b/src/components/integrations/mezo/components/ManageTroveDialog.tsx @@ -27,6 +27,7 @@ import { useMezoLegPipeline } from "../pipeline/useMezoLegPipeline"; import { MezoLegTimeline } from "../pipeline/MezoLegTimeline"; import { AssetInput } from "./AssetInput"; import { AssetIcon } from "./AssetIcon"; +import { BalanceDeltaPreview } from "./BalanceDeltaPreview"; interface ManageTroveDialogProps { open: boolean; @@ -152,11 +153,29 @@ export function ManageTroveDialog({ (r) => r.status === "signing" || r.status === "confirming", ); + // Reset the pipeline on every fresh open so a stale set of `runs` from a + // prior session can't be auto-resumed against the current legs. + useEffect(() => { + if (open) pipeline.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Fingerprint the current bundle legs. If the user changes inputs after a + // partial run, the legs no longer match — reset before executing. + const legsFingerprint = useMemo( + () => (bundle ? JSON.stringify(bundle.legs, bigintReplacer) : ""), + [bundle], + ); + const onExecute = async () => { if (!bundle || !sim.data) return; const summaries = sim.data.legs.map((l) => l.decodedSummary); + const existingFp = pipeline.runs.length + ? JSON.stringify(pipeline.runs.map((r) => r.spec), bigintReplacer) + : ""; + const fpMatches = existingFp === legsFingerprint; const hasConfirmedLeg = pipeline.runs.some((r) => r.status === "confirmed"); - if (pipeline.runs.length === 0 || !hasConfirmedLeg) { + if (!fpMatches || pipeline.runs.length === 0 || !hasConfirmedLeg) { pipeline.start(bundle.legs, summaries); } await pipeline.executeAll(); @@ -263,9 +282,12 @@ export function ManageTroveDialog({ )} {sim.data && ( -
    - Simulation passed — {sim.data.legs.length} leg{sim.data.legs.length === 1 ? "" : "s"} ready. -
    + )} {pipeline.runs.length > 0 && ( @@ -295,3 +317,7 @@ export function ManageTroveDialog({
    ); } + +function bigintReplacer(_key: string, value: unknown) { + return typeof value === "bigint" ? value.toString() : value; +} diff --git a/src/components/integrations/mezo/components/SideRailNav.tsx b/src/components/integrations/mezo/components/SideRailNav.tsx index 2bfc148..f89da88 100644 --- a/src/components/integrations/mezo/components/SideRailNav.tsx +++ b/src/components/integrations/mezo/components/SideRailNav.tsx @@ -13,6 +13,9 @@ import { MEZO_ABIS } from "../abi"; import { MEZO_TESTNET_CHAIN_ID } from "../constants"; import { MEZO_GLOSSARY, type GlossaryKey } from "../glossary"; import { ManageTroveDialog } from "./ManageTroveDialog"; +import { ManageLockDialog } from "./ManageLockDialog"; +import { ManageSavingsDialog } from "./ManageSavingsDialog"; +import { ManageLiquidityDialog } from "./ManageLiquidityDialog"; interface SideRailNavProps { active: MezoTabId; @@ -113,12 +116,38 @@ export function SideRailNav({ active, onChange }: SideRailNavProps) { const troveActive = troveData ? troveData[4] === 1 : false; // status === 1 (Active) const troveColl = troveData?.[0]; const trovePrincipal = troveData?.[1]; + const troveInterestOwed = troveData?.[2]; + // Liquity-fork Trove debt = principal + interestOwed. closeTrove pulls + // (totalDebt - gasComp) from the wallet, so the dialog must size the + // approve off the total, not just the principal. + const troveTotalDebt = + trovePrincipal !== undefined && troveInterestOwed !== undefined + ? trovePrincipal + troveInterestOwed + : trovePrincipal; const lockData = veMezoLock.data as { amount: bigint; end: bigint } | undefined; const lockAmount = lockData?.amount; const lockEnd = lockData?.end; const [manageOpen, setManageOpen] = useState(false); + const [lockOpen, setLockOpen] = useState(false); + const [savingsOpen, setSavingsOpen] = useState(false); + const [liquidityOpen, setLiquidityOpen] = useState(false); + + // LP balance for MUSD/BTC pool + const lpBalance = useReadContract({ + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.MUSD_BTC_Pool, + abi: MEZO_ABIS.MUSD, + functionName: "balanceOf", + args: address ? [address as Address] : undefined, + query: { enabled: onMezo, refetchInterval }, + }); + const lpBalanceValue = lpBalance.data as bigint | undefined; + const hasLp = lpBalanceValue !== undefined && lpBalanceValue > 0n; + const sMusdBalanceValue = sMusd.data as bigint | undefined; + const hasSavings = sMusdBalanceValue !== undefined && sMusdBalanceValue > 0n; + const hasLock = lockAmount !== undefined && lockAmount > 0n; return ( ); diff --git a/src/components/integrations/mezo/pipeline/legHandlers.ts b/src/components/integrations/mezo/pipeline/legHandlers.ts index 41ac5c0..71513c1 100644 --- a/src/components/integrations/mezo/pipeline/legHandlers.ts +++ b/src/components/integrations/mezo/pipeline/legHandlers.ts @@ -142,6 +142,25 @@ export async function executeLeg( account, }); + case "routerRemoveLiquidity": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.Router, + abi: MEZO_ABIS.Router, + functionName: "removeLiquidity", + args: [ + leg.tokenA, + leg.tokenB, + leg.stable, + leg.liquidity, + leg.amountAMin, + leg.amountBMin, + leg.to, + leg.deadline, + ], + account, + }); + case "repayMUSD": return writeContract(config, { chainId: MEZO_TESTNET_CHAIN_ID, @@ -163,6 +182,15 @@ export async function executeLeg( }); case "sMusdWithdraw": + return writeContract(config, { + chainId: MEZO_TESTNET_CHAIN_ID, + address: MEZO_CONTRACTS.sMUSD, + abi: MEZO_ABIS.sMUSD, + functionName: "withdraw", + args: [leg.amount], + account, + }); + case "gaugeWithdraw": case "gaugeClaim": case "redeemCollateral": diff --git a/src/components/integrations/mezo/pipeline/mezoLegs.ts b/src/components/integrations/mezo/pipeline/mezoLegs.ts index 4d00dbc..42a9b91 100644 --- a/src/components/integrations/mezo/pipeline/mezoLegs.ts +++ b/src/components/integrations/mezo/pipeline/mezoLegs.ts @@ -62,6 +62,17 @@ export type MezoLegSpec = to: Address; deadline: bigint; } + | { + type: "routerRemoveLiquidity"; + tokenA: Address; + tokenB: Address; + stable: boolean; + liquidity: bigint; + amountAMin: bigint; + amountBMin: bigint; + to: Address; + deadline: bigint; + } | { type: "redeemCollateral"; musdAmount: bigint; diff --git a/src/components/integrations/mezo/sim/buildCalls.ts b/src/components/integrations/mezo/sim/buildCalls.ts index 0aa6188..82a1d11 100644 --- a/src/components/integrations/mezo/sim/buildCalls.ts +++ b/src/components/integrations/mezo/sim/buildCalls.ts @@ -66,6 +66,15 @@ export function encodeWrite(account: Address, leg: MezoLegSpec): SimCall { return { from: account, to: MEZO_CONTRACTS.sMUSD, input }; } + case "sMusdWithdraw": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.sMUSD, + functionName: "withdraw", + args: [leg.amount], + }); + return { from: account, to: MEZO_CONTRACTS.sMUSD, input }; + } + case "gaugeDeposit": { const input = encodeFunctionData({ abi: MEZO_ABIS.Gauge, @@ -151,6 +160,29 @@ export function encodeWrite(account: Address, leg: MezoLegSpec): SimCall { }; } + case "routerRemoveLiquidity": { + const input = encodeFunctionData({ + abi: MEZO_ABIS.Router, + functionName: "removeLiquidity", + args: [ + leg.tokenA, + leg.tokenB, + leg.stable, + leg.liquidity, + leg.amountAMin, + leg.amountBMin, + leg.to, + leg.deadline, + ], + }); + return { + from: account, + to: MEZO_CONTRACTS.Router, + input, + gas: "0x1e8480" as `0x${string}`, // 2,000,000 + }; + } + case "repayMUSD": { const input = encodeFunctionData({ abi: MEZO_ABIS.BorrowerOperations, @@ -169,7 +201,6 @@ export function encodeWrite(account: Address, leg: MezoLegSpec): SimCall { return { from: account, to: MEZO_CONTRACTS.BorrowerOperations, input }; } - case "sMusdWithdraw": case "gaugeWithdraw": case "gaugeClaim": case "redeemCollateral": diff --git a/src/components/integrations/mezo/sim/decodeResults.ts b/src/components/integrations/mezo/sim/decodeResults.ts index d18d810..a06e9bd 100644 --- a/src/components/integrations/mezo/sim/decodeResults.ts +++ b/src/components/integrations/mezo/sim/decodeResults.ts @@ -263,6 +263,8 @@ function summarizeLeg(leg: MezoLegSpec): string { return `Swap ${formatBn(leg.amountIn)} (min out ${formatBn(leg.amountOutMin)})`; case "routerAddLiquidity": return `Add liquidity to ${leg.stable ? "stable" : "volatile"} pool`; + case "routerRemoveLiquidity": + return `Remove ${formatBn(leg.liquidity)} LP from ${leg.stable ? "stable" : "volatile"} pool`; case "redeemCollateral": return `Redeem ${formatBn(leg.musdAmount)} MUSD for BTC`; case "veMezoCreateLock": {