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/.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/.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/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/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/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/edb b/edb index 1ba2fca..f280d32 160000 --- a/edb +++ b/edb @@ -1 +1 @@ -Subproject commit 1ba2fcaca73cee96bf10107b7b0f98ab1ceab1a4 +Subproject commit f280d329bba31aebf9b18c6b174dd4d271e0d600 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/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/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/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/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/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); 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/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/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/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/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/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/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 +1038,7 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF step="any" /> - {position.asset.symbol} + {inShareMode ? "shares" : position.asset.symbol}
{insufficientBalance && ( @@ -573,12 +1055,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 +1078,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 +1089,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 +1180,7 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF
- You receive + {routeRequired ? "Redeem receives" : "You receive"} {toUsd != null @@ -770,6 +1309,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 +1386,7 @@ export function WithdrawFlow({ position, vault, onComplete, onClose }: WithdrawF
)} + {!(routeRequired && (redeemedAmountRaw || flowState === "redeemed")) && (
{!simResult?.success && (
@@ -795,18 +1405,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..204fa17 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,15 @@ 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"); + // 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); + const canAdvance = current !== null && isLegTerminal(current); const total = state.legs.length; @@ -87,11 +87,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 +123,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 +242,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 +253,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..12fed5f 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,248 @@ 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 its own terminal status (positive outcome, no retry UI). + if (normalized === "refunded") { + return "refunded"; + } + // Expired still maps to failed — recoverability is assigned downstream + // so the refund button stays reachable. + 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(); + // Only Expired stays recoverable so the refund button remains reachable. + 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 +313,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 +327,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..f40d2aa --- /dev/null +++ b/src/components/integrations/lifi-earn/concierge/intent/RebalancePlanCard.tsx @@ -0,0 +1,565 @@ +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(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 ( + + ); +} + +// 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" || + 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(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, + ); + 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/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..e45fc8c --- /dev/null +++ b/src/components/integrations/mezo/abi/index.ts @@ -0,0 +1,597 @@ +/** + * 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" }, + ], + }, + { + 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 = { + 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..fe96a67 --- /dev/null +++ b/src/components/integrations/mezo/components/AssetIcon.tsx @@ -0,0 +1,282 @@ +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" | "veBTC"; + +const SYMBOL_TO_GLOSSARY: Record = { + BTC: "btc", + MUSD: "musd", + sMUSD: "smusd", + MEZO: "mezo", + veMEZO: "vemezo", + veBTC: "vebtc", +}; + +// 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 = { + 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 = ( + + + + ); + + 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 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 ; + case "MUSD": + return ; + case "sMUSD": + return ; + case "MEZO": + return ; + case "veMEZO": + return ; + case "veBTC": + return ; + } +} + +function BtcGlyph() { + return ( + + + + ₿ + + + ); +} + +function MusdGlyph() { + return ( + + + + $ + + + ); +} + +function SmusdGlyph() { + return ( + + + + $ + + + + s + + + ); +} + +function MezoGlyph() { + return ( + + + + + ); +} + +function VeMezoGlyph() { + return ( + + + + + + ); +} + +function VeBtcGlyph() { + 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/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/FlowRibbon.tsx b/src/components/integrations/mezo/components/FlowRibbon.tsx new file mode 100644 index 0000000..0d65d2e --- /dev/null +++ b/src/components/integrations/mezo/components/FlowRibbon.tsx @@ -0,0 +1,83 @@ +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", + veBTC: "bg-amber-300/70", +}; + +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/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 new file mode 100644 index 0000000..562bc7e --- /dev/null +++ b/src/components/integrations/mezo/components/ManageTroveDialog.tsx @@ -0,0 +1,323 @@ +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, + MUSD_GAS_COMPENSATION, +} 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"; +import { BalanceDeltaPreview } from "./BalanceDeltaPreview"; + +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", + ); + + // 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 (!fpMatches || 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; + // 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 ( + + + + 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" && ( +
    + 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(closeRepayAmount - musdBalanceValue, 18)).toFixed(2)} MUSD. +
    + )} +
    + )} + + {/* Sim preview */} + {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/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..f89da88 --- /dev/null +++ b/src/components/integrations/mezo/components/SideRailNav.tsx @@ -0,0 +1,390 @@ +import { useState } from "react"; +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"; +import { ManageTroveDialog } from "./ManageTroveDialog"; +import { ManageLockDialog } from "./ManageLockDialog"; +import { ManageSavingsDialog } from "./ManageSavingsDialog"; +import { ManageLiquidityDialog } from "./ManageLiquidityDialog"; + +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 }, + }); + + 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 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 ( + + ); +} + +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..1165857 --- /dev/null +++ b/src/components/integrations/mezo/glossary.ts @@ -0,0 +1,125 @@ +/** + * 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.", + }, + 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", + 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..71513c1 --- /dev/null +++ b/src/components/integrations/mezo/pipeline/legHandlers.ts @@ -0,0 +1,199 @@ +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", + // 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 as never, + 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 "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, + 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": + 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": + 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..42a9b91 --- /dev/null +++ b/src/components/integrations/mezo/pipeline/mezoLegs.ts @@ -0,0 +1,110 @@ +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: "routerAddLiquidity"; + tokenA: Address; + tokenB: Address; + stable: boolean; + amountADesired: bigint; + amountBDesired: bigint; + amountAMin: bigint; + amountBMin: bigint; + 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; + 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..65f48ce --- /dev/null +++ b/src/components/integrations/mezo/pipeline/useMezoLegPipeline.ts @@ -0,0 +1,110 @@ +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 }); + // 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. + // 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) { + // 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 { + // 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..82a1d11 --- /dev/null +++ b/src/components/integrations/mezo/sim/buildCalls.ts @@ -0,0 +1,213 @@ +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 "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, + 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 "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 "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, + 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 "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..9f22b68 --- /dev/null +++ b/src/components/integrations/mezo/sim/bundles/borrow.ts @@ -0,0 +1,139 @@ +import type { Address } from "viem"; +import { + MEZO_CONTRACTS, + MUSD_GAS_COMPENSATION, +} from "../../../../../../data/mezoContracts"; +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 }; +} + +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. 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: repayAmount, + tokenLabel: "MUSD", + }, + { type: "closeTrove" }, + ]; + + 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..5ca4b5e --- /dev/null +++ b/src/components/integrations/mezo/sim/bundles/stack.ts @@ -0,0 +1,89 @@ +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; + /** + * 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; +} + +/** + * 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[] = []; + 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, + 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..a06e9bd --- /dev/null +++ b/src/components/integrations/mezo/sim/decodeResults.ts @@ -0,0 +1,410 @@ +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 "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": { + 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..f2eaf06 --- /dev/null +++ b/src/components/integrations/mezo/sim/useMezoBundleSimulation.ts @@ -0,0 +1,401 @@ +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 { + return spec.type === "routerSwap"; +} + +function isLiquidityLegSpec( + spec: MezoLegSpec, +): spec is Extract { + return spec.type === "routerAddLiquidity"; +} + +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..9d494ae --- /dev/null +++ b/src/components/integrations/mezo/tabs/StackTab.tsx @@ -0,0 +1,470 @@ +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; + + // 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 { + 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, + skipOpenTrove: troveActive, + }; + } catch { + return null; + } + }, [address, btcInput, musdInput, sMusdInput, mezoInput, troveInsertHint, troveActive]); + + 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); + // 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(); + }; + + 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 + + + + )} + {troveActive && ( +
    + Trove active + — openTrove leg skipped; stack continues from sMUSD deposit. +
    + )} + + } + 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 ( + + ); + })} +
    + ); +} 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/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/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/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/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 }; 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/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/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 { 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/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/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; 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..4d37fdd 100644 --- a/src/utils/tokenMovements.ts +++ b/src/utils/tokenMovements.ts @@ -84,6 +84,48 @@ 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", + }, + // 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", + }, +}; + /** * Get token icon URL * Uses 1inch for Ethereum (supports lowercase), Trust Wallet for other chains @@ -93,6 +135,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 +170,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) { @@ -392,6 +447,16 @@ 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 }); +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 */ 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'; diff --git a/vercel.json b/vercel.json index 533b97e..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*" @@ -72,6 +88,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..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", @@ -329,6 +498,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": {