Wallet Agent Chat
An AI wallet assistant that answers with transaction cards.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/wallet-agent-chat.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-merge viemAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/color.tsexport type Vec3 = [r: number, g: number, b: number]; /** * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels. * Anything else falls back to black, so a typo never crashes a shader. */ export function hexToRgb(hex: string): Vec3 { let value = hex.trim().replace(/^#/, ""); if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join(""); if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0]; const n = Number.parseInt(value.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function hexToVec3(hex: string): Vec3 { const [r, g, b] = hexToRgb(hex); return [r / 255, g / 255, b / 255]; } /** * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result * instead of calling this every frame. Browser only; unreadable values fall back to black. */ export function cssColorToRgb(color: string, element: Element): Vec3 { const value = color.trim(); if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value); // Children of a canvas still get computed styles, but a regular parent is the safer host. const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element; const probe = document.createElement("span"); probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden"; probe.style.color = value; host.appendChild(probe); const computed = getComputedStyle(probe).color; probe.remove(); // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes. const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!ctx) return [0, 0, 0]; ctx.fillStyle = "#000"; ctx.fillStyle = computed; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return [r, g, b]; } /** * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme. * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe. */ export function observeTheme(onChange: () => void): () => void { const observer = new MutationObserver(onChange); const options = { attributes: true, attributeFilter: ["class", "data-theme"] }; observer.observe(document.documentElement, options); if (document.body) observer.observe(document.body, options); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); scheme.addEventListener("change", onChange); return () => { observer.disconnect(); scheme.removeEventListener("change", onChange); }; } /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function cssColorToVec3(color: string, element: Element): Vec3 { const [r, g, b] = cssColorToRgb(color, element); return [r / 255, g / 255, b / 255]; } const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */ export function rgbToOklch([r, g, b]: Vec3): Vec3 { const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255)); const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; return [L, Math.hypot(A, B), Math.atan2(B, A)]; } /** OKLCH back to 0–255 sRGB, clamped into gamut. */ export function oklchToRgb([L, C, h]: Vec3): Vec3 { const A = C * Math.cos(h); const B = C * Math.sin(h); const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3; const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3; const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3; const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]; return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3; } /** * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep * their chroma instead of turning muddy the way an RGB mix does. */ export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 { const [L1, C1, h1] = rgbToOklch(a); const [L2, C2, h2] = rgbToOklch(b); // A near-gray end has no meaningful hue: borrow the other end's. const ha = C1 < 0.02 ? h2 : h1; const hb = C2 < 0.02 ? h1 : h2; let dh = hb - ha; if (dh > Math.PI) dh -= Math.PI * 2; if (dh < -Math.PI) dh += Math.PI * 2; return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]); }lib/markets/avatar.tsximport { cn } from "@/lib/utils"; /** A generated avatar: a gradient from a hash of the name, with initials. No images needed. */ export function MarketAvatar({ name, className }: { name: string; className?: string }) { let hash = 0; for (let i = 0; i < name.length; i++) hash = (Math.imul(hash, 31) + name.charCodeAt(i)) | 0; const hue = Math.abs(hash) % 360; const initials = name .replace(/^@/, "") .split(/[\s._-]+/) .map((part) => part[0]) .join("") .slice(0, 2) .toUpperCase(); return ( <span aria-hidden="true" className={cn("grid size-7 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-white ring-2 ring-background", className)} style={{ background: `linear-gradient(135deg, hsl(${hue} 70% 55%), hsl(${(hue + 40) % 360} 70% 40%))` }} > {initials} </span> ); } /** A small "Simulated" badge for demos that use mock data. */ export function SimulatedBadge({ className }: { className?: string }) { return ( <span className={cn("inline-flex h-5 items-center gap-1 rounded-full border border-warning/30 bg-warning/10 px-2 text-[11px] font-medium text-[color-mix(in_oklab,var(--warning)_75%,var(--foreground))]", className)}> <span aria-hidden="true" className="size-1.5 rounded-full bg-warning" /> Simulated </span> ); }lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }lib/usage/wallet-agent.tsimport { useMemo } from "react"; import { getAddress } from "viem"; import { formatAmount, formatUsd, parseAmount, toUsd, USD_DECIMALS } from "@/lib/web3/format"; import type { Token } from "@/lib/web3/types"; /** * A scripted wallet agent for prototypes and demos. It parses three simple intents with regular * expressions and returns the steps, the reply text and a transaction card to review. It never * talks to a wallet, an RPC or a model, and nothing it returns is signed. All amounts are bigint. */ /** A token the agent can move: its balance in token units and its USD price (USD_DECIMALS). */ export interface AgentToken extends Token { balance: bigint; price: bigint; } /** A saved contact, or a spender for approvals. */ export interface AgentContact { name: string; address: string; } interface CardBase { id: string; /** Network fee in USD with USD_DECIMALS. */ networkFeeUsd: bigint; } export interface SwapCard extends CardBase { kind: "swap"; from: AgentToken; to: AgentToken; amountIn: bigint; amountOut: bigint; /** Hops by symbol, e.g. ["ARB", "ETH", "USDC"]. */ route: string[]; feeBps: number; priceImpactBps: number; } export interface SendCard extends CardBase { kind: "send"; token: AgentToken; amount: bigint; to: AgentContact; } export interface ApproveCard extends CardBase { kind: "approve"; token: AgentToken; spender: AgentContact; /** Allowance in token units, or null for an unlimited allowance. */ amount: bigint | null; } export type AgentActionCard = SwapCard | SendCard | ApproveCard; export interface AgentReply { /** Tool steps shown one by one, e.g. "Checking balance". */ steps: string[]; /** The reply text, streamed in after the steps. */ text: string; /** A transaction to review, if the request made one. */ card?: AgentActionCard; /** Follow-up prompts offered as chips. */ suggestions?: string[]; } export interface WalletAgent { /** Answers a prompt with a scripted reply. */ respond: (prompt: string) => AgentReply; } const FEE_BPS = 30; const usd = (dollars: string) => parseAmount(dollars, USD_DECIMALS) ?? BigInt(0); const NETWORK_FEE = { swap: usd("4.18"), send: usd("1.12"), approve: usd("0.86") }; /** A deterministic, checksummed placeholder address for a name (not a real account). */ export function placeholderAddress(seed: string) { let hex = ""; for (let round = 0; hex.length < 40; round++) { let hash = 0x811c9dc5; for (const char of `${round}:${seed}`) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193); hash ^= hash >>> 15; hex += (hash >>> 0).toString(16).padStart(8, "0"); } return getAddress(`0x${hex.slice(0, 40)}`); } /** * A quote from USD prices: a 0.3% fee plus price impact that grows with size (1 bp per $1,000, * capped at 25%). `jitterBps` nudges the output to simulate a refreshed quote. */ export function quoteSwap(from: AgentToken, to: AgentToken, amountIn: bigint, jitterBps = 0) { const usdIn = toUsd(amountIn, from.decimals, from.price); const thousand = BigInt(1000) * BigInt(10) ** BigInt(USD_DECIMALS); const impact = usdIn / thousand > BigInt(2500) ? 2500 : Number(usdIn / thousand); const usdOut = (usdIn * BigInt(10_000 - FEE_BPS - impact + jitterBps)) / BigInt(10_000); const amountOut = to.price > BigInt(0) ? (usdOut * BigInt(10) ** BigInt(to.decimals)) / to.price : BigInt(0); const stable = (t: Token) => ["USDC", "USDT", "DAI"].includes(t.symbol); const direct = from.symbol === "ETH" || to.symbol === "ETH" || (stable(from) && stable(to)); return { amountOut, priceImpactBps: impact, route: direct ? [from.symbol, to.symbol] : [from.symbol, "ETH", to.symbol] }; } const amountText = (token: AgentToken, value: bigint) => `${formatAmount(value, token.decimals)} ${token.symbol}`; const usdText = (token: AgentToken, value: bigint) => formatUsd(toUsd(value, token.decimals, token.price)); let counter = 0; const nextId = () => `card-${Date.now().toString(36)}-${(counter++).toString(36)}`; const SWAP = /^(?:swap|trade|convert|exchange)\s+([\d.,]+|all|max)\s+([a-z0-9]+)\s+(?:to|for|into)\s+([a-z0-9]+)\b/i; const SEND = /^(?:send|pay|transfer)\s+([\d.,]+)\s+([a-z0-9]+)\s+to\s+(.+?)\.?$/i; const APPROVE = /^(?:approve|allow)\s+(unlimited|[\d.,]+)\s+([a-z0-9]+)\s+(?:for|to)\s+(.+?)\.?$/i; /** Builds a scripted agent over the given tokens and contacts. */ export function createMockWalletAgent(tokens: AgentToken[], contacts: AgentContact[]): WalletAgent { const find = (symbol: string) => tokens.find((t) => t.symbol.toLowerCase() === symbol.toLowerCase()); const help: AgentReply = { steps: ["Reading your request"], text: "I can prepare three kinds of transactions: a swap (\"swap 0.5 ETH to USDC\"), a payment to a saved contact (\"send 120 USDC to Maya\") or a token allowance (\"approve 500 USDC for Tidepool Router\"). Each one comes back as a card you review; nothing is signed until you approve it.", suggestions: ["Swap 0.5 ETH to USDC", `Send 50 USDC to ${contacts[0]?.name ?? "Maya"}`, "Approve 500 USDC for Tidepool Router"], }; const unknownToken = (symbol: string): AgentReply => ({ steps: ["Reading your request", "Looking up tokens"], text: `I couldn't find ${symbol.toUpperCase()} in this wallet. You hold ${tokens .filter((t) => t.balance > BigInt(0)) .slice(0, 5) .map((t) => t.symbol) .join(", ")}.`, suggestions: tokens.slice(0, 2).length === 2 ? [`Swap 10 ${tokens[1].symbol} to ${tokens[0].symbol}`] : [], }); return { respond(prompt) { const text = prompt.trim(); const swap = text.match(SWAP); if (swap) { const from = find(swap[2]); const to = find(swap[3]); if (!from) return unknownToken(swap[2]); if (!to) return unknownToken(swap[3]); if (from.symbol === to.symbol) return { steps: ["Reading your request"], text: `${from.symbol} to ${to.symbol} is the same token, so there is nothing to swap.`, suggestions: [`Swap 0.1 ${from.symbol} to ${tokens.find((t) => t !== from)?.symbol ?? "USDC"}`] }; const amountIn = /^(all|max)$/i.test(swap[1]) ? from.balance : (parseAmount(swap[1], from.decimals) ?? BigInt(0)); if (amountIn <= BigInt(0)) return help; if (amountIn > from.balance) { return { steps: ["Checking balance"], text: `You have ${amountText(from, from.balance)}, which is less than the ${amountText(from, amountIn)} you asked to swap. I can prepare a swap for your full balance instead.`, suggestions: [`Swap max ${from.symbol} to ${to.symbol}`], }; } const quote = quoteSwap(from, to, amountIn); return { steps: ["Checking balance", "Fetching quote", "Simulating transaction"], text: `Here's the best route I found: ${amountText(from, amountIn)} (${usdText(from, amountIn)}) for about ${amountText(to, quote.amountOut)}${quote.route.length > 2 ? ` through ${quote.route[1]}` : ""}. The quote refreshes every 15 seconds while you review it.`, card: { kind: "swap", id: nextId(), from, to, amountIn, amountOut: quote.amountOut, route: quote.route, feeBps: FEE_BPS, priceImpactBps: quote.priceImpactBps, networkFeeUsd: NETWORK_FEE.swap }, suggestions: [`Send 25 ${to.symbol} to ${contacts[0]?.name ?? "Maya"}`, `Swap 0.1 ${to.symbol === "ETH" ? "USDC" : "ETH"} to ${to.symbol === "ETH" ? "ETH" : to.symbol}`], }; } const send = text.match(SEND); if (send) { const token = find(send[2]); if (!token) return unknownToken(send[2]); const name = send[3].trim(); const contact = contacts.find((c) => c.name.toLowerCase() === name.toLowerCase()); if (!contact) { return { steps: ["Reading your request", "Searching contacts"], text: `I don't have a saved contact named ${name}. To keep payments safe I only send to saved contacts: ${contacts.map((c) => c.name).join(", ")}.`, suggestions: contacts.slice(0, 2).map((c) => `Send ${send[1]} ${token.symbol} to ${c.name}`), }; } const amount = parseAmount(send[1], token.decimals) ?? BigInt(0); if (amount <= BigInt(0)) return help; if (amount > token.balance) { return { steps: ["Checking balance"], text: `You have ${amountText(token, token.balance)}, so I can't send ${amountText(token, amount)}.`, suggestions: [`Swap 0.5 ETH to ${token.symbol}`] }; } return { steps: ["Checking balance", "Resolving contact", "Simulating transaction"], text: `Ready to send ${amountText(token, amount)} (${usdText(token, amount)}) to ${contact.name}. Check the address on the card before you approve.`, card: { kind: "send", id: nextId(), token, amount, to: contact, networkFeeUsd: NETWORK_FEE.send }, suggestions: [`Send 10 ${token.symbol} to ${contacts.find((c) => c !== contact)?.name ?? contact.name}`, "Swap 0.25 ETH to USDC"], }; } const approve = text.match(APPROVE); if (approve) { const token = find(approve[2]); if (!token) return unknownToken(approve[2]); const name = approve[3].trim(); const spender = contacts.find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? { name, address: placeholderAddress(`spender:${name.toLowerCase()}`) }; const unlimited = /^unlimited$/i.test(approve[1]); const amount = unlimited ? null : (parseAmount(approve[1], token.decimals) ?? BigInt(0)); return { steps: ["Reading your request", "Checking current allowance", "Simulating transaction"], text: unlimited ? `This lets ${spender.name} spend all of your ${token.symbol}, now and later. I'd suggest a cap instead; you can set one on the card.` : `This lets ${spender.name} spend up to ${amountText(token, amount ?? BigInt(0))}. You can lower or revoke it at any time.`, card: { kind: "approve", id: nextId(), token, spender, amount, networkFeeUsd: NETWORK_FEE.approve }, suggestions: [`Swap 100 ${token.symbol} to ETH`], }; } return help; }, }; } /** `createMockWalletAgent` as a hook, rebuilt only when the tokens or contacts change. */ export function useMockWalletAgent({ tokens, contacts }: { tokens: AgentToken[]; contacts: AgentContact[] }) { return useMemo(() => createMockWalletAgent(tokens, contacts), [tokens, contacts]); }lib/web3/format.tsimport { formatUnits, getAddress, isAddress, parseUnits } from "viem"; /** * Formatting for on-chain values. Amounts are always `bigint` plus `decimals`: nothing here * converts a balance to a float, so large or precise values never lose digits. */ /** Decimals used for USD values and prices across the web3 components (like Chainlink feeds). */ export const USD_DECIMALS = 8; const COMPACT_UNITS = [ { suffix: "T", exponent: 12 }, { suffix: "B", exponent: 9 }, { suffix: "M", exponent: 6 }, { suffix: "K", exponent: 3 }, ] as const; function groupThousands(integer: string) { return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ","); } function compact(value: bigint, decimals: number): string | null { const base = BigInt(10) ** BigInt(decimals); for (const { suffix, exponent } of COMPACT_UNITS) { const unit = base * BigInt(10) ** BigInt(exponent); if (value < unit) continue; const tenths = (value * BigInt(10)) / unit; const whole = tenths / BigInt(10); const fraction = tenths % BigInt(10); return `${groupThousands(whole.toString())}${fraction && whole < BigInt(100) ? `.${fraction}` : ""}${suffix}`; } return null; } export interface FormatAmountOptions { /** Most fraction digits shown; extra digits are cut, never rounded up. @default 4 */ maxFractionDigits?: number; /** Fraction digits always shown (padded with zeros). @default 0 */ minFractionDigits?: number; /** Abbreviate values of 1,000 and up: 1.2K, 3.4M, 5B. @default false */ compact?: boolean; /** Prefix positive values with "+". @default false */ signed?: boolean; } /** * Formats a token amount: `formatAmount(parseUnits("1.23456789", 18), 18)` → "1.2345". * Digits past `maxFractionDigits` are truncated so a balance is never overstated, and a tiny * non-zero amount shows as "<0.0001" instead of "0". */ export function formatAmount(value: bigint, decimals: number, options: FormatAmountOptions = {}) { const { maxFractionDigits = 4, minFractionDigits = 0, compact: useCompact = false, signed = false } = options; const negative = value < BigInt(0); const abs = negative ? -value : value; const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : ""; const short = useCompact ? compact(abs, decimals) : null; if (short) return sign + short; const [integer, fraction = ""] = formatUnits(abs, decimals).split("."); let digits = fraction.slice(0, maxFractionDigits).replace(/0+$/, ""); if (digits.length < minFractionDigits) digits = digits.padEnd(minFractionDigits, "0"); if (abs > BigInt(0) && integer === "0" && !/[1-9]/.test(digits)) { return `${sign}<0.${"0".repeat(Math.max(0, maxFractionDigits - 1))}1`; } return `${sign}${groupThousands(integer)}${digits ? `.${digits}` : ""}`; } /** USD value of a token amount, as a bigint with `USD_DECIMALS` decimals. `price` uses the same decimals. */ export function toUsd(amount: bigint, tokenDecimals: number, price: bigint) { return (amount * price) / BigInt(10) ** BigInt(tokenDecimals); } /** Formats a USD bigint (default `USD_DECIMALS`): "$1,234.56", or "$1.2M" with `compact`. */ export function formatUsd(value: bigint, decimals = USD_DECIMALS, options: Omit<FormatAmountOptions, "maxFractionDigits"> = {}) { const { compact: useCompact = false, signed = false } = options; const negative = value < BigInt(0); const abs = negative ? -value : value; const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : ""; // Under a cent, keep up to 6 significant fraction digits so token prices like $0.000042 stay readable. const tiny = abs > BigInt(0) && abs < BigInt(10) ** BigInt(Math.max(0, decimals - 2)); const body = formatAmount(abs, decimals, { compact: useCompact, maxFractionDigits: tiny ? Math.min(decimals, 8) : 2, minFractionDigits: tiny ? 0 : 2, }); return `${sign}$${body}`; } /** Formats a ratio as a percentage: `formatPercent(0.0123)` → "1.23%". */ export function formatPercent(ratio: number, options: { fractionDigits?: number; signed?: boolean } = {}) { const { fractionDigits = 2, signed = false } = options; if (!Number.isFinite(ratio)) return "—"; const text = `${(Math.abs(ratio) * 100).toFixed(fractionDigits)}%`; if (ratio < 0) return `-${text}`; return signed && ratio > 0 ? `+${text}` : text; } /** Formats basis points (1 bp = 0.01%): `formatBps(BigInt(50))` → "0.5%". */ export function formatBps(bps: bigint | number) { const value = BigInt(bps); const abs = value < BigInt(0) ? -value : value; const fraction = abs % BigInt(100); return `${value < BigInt(0) ? "-" : ""}${abs / BigInt(100)}${fraction ? `.${fraction.toString().padStart(2, "0").replace(/0$/, "")}` : ""}%`; } /** Compact display for plain counts and non-token numbers: 1200 → "1.2K". */ export function compactNumber(value: number | bigint) { return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value); } /** EIP-55 checksummed address, or the input unchanged when it isn't a valid address. */ export function checksumAddress(address: string) { return isAddress(address, { strict: false }) ? getAddress(address) : address; } /** "0x1234…abcd": checksummed, with `start` characters (including 0x) and `end` characters kept. */ export function truncateAddress(address: string, { start = 6, end = 4 }: { start?: number; end?: number } = {}) { const value = checksumAddress(address); if (value.length <= start + end + 1) return value; return `${value.slice(0, start)}…${value.slice(-end)}`; } /** * Parses user input into a bigint amount. Accepts digits with one "." (commas are ignored), * cuts fraction digits past `decimals`, and returns null for anything that isn't a number. */ export function parseAmount(input: string, decimals: number): bigint | null { const text = input.replace(/,/g, "").trim(); if (!/^\d*\.?\d*$/.test(text) || text === "" || text === ".") return null; const [integer, fraction = ""] = text.split("."); return parseUnits(`${integer || "0"}.${fraction.slice(0, decimals) || "0"}`, decimals); } /** A percentage of an amount, in whole percent: `percentOf(balance, 25)`. Rounds down. */ export function percentOf(amount: bigint, percent: number) { return (amount * BigInt(Math.round(percent * 100))) / BigInt(10_000); }lib/web3/icons.tsx"use client"; import { useId, type ComponentProps } from "react"; import { hexToRgb } from "@/lib/color"; import { cn } from "@/lib/utils"; /** * Generated avatars for tokens, chains and accounts. No third-party logos are bundled: * pass `iconUrl` (tokens, chains) or `src` (accounts) to show your own artwork. */ const PALETTE = ["#6366F1", "#2563EB", "#0EA5E9", "#14B8A6", "#16A34A", "#F59E0B", "#EF4444", "#EC4899", "#8B5CF6", "#64748B"]; function hashString(value: string) { let hash = 0x811c9dc5; for (const char of value) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193); return hash >>> 0; } /** Text color with enough contrast on a fill (relative luminance threshold). */ function inkFor(fill: string) { const [r, g, b] = hexToRgb(fill).map((c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? "#09090B" : "#FFFFFF"; } function Monogram({ text, fill, size, shape, className }: { text: string; fill: string; size: number; shape: "circle" | "squircle"; className?: string }) { const letters = text.slice(0, 4); const fontSize = letters.length <= 2 ? 13 : letters.length === 3 ? 10 : 8.5; return ( <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0", className)}> {shape === "circle" ? <circle cx="16" cy="16" r="16" fill={fill} /> : <rect width="32" height="32" rx="9" fill={fill} />} <text x="16" y="16" dy="0.35em" textAnchor="middle" fontSize={fontSize} fontWeight={700} fill={inkFor(fill)} style={{ fontFamily: "inherit", letterSpacing: letters.length > 2 ? "-0.02em" : undefined }} > {letters} </text> </svg> ); } function ImageIcon({ src, size, rounded, className }: { src: string; size: number; rounded: string; className?: string }) { // eslint-disable-next-line @next/next/no-img-element -- consumers pass arbitrary logo URLs return <img src={src} alt="" width={size} height={size} className={cn("shrink-0 object-cover", rounded, className)} />; } export interface TokenIconProps { token: { symbol: string; iconUrl?: string; color?: string }; /** Pixel size. @default 24 */ size?: number; className?: string; } /** A token's logo, or a colored circle with its symbol. Decorative: pair it with visible text. */ export function TokenIcon({ token, size = 24, className }: TokenIconProps) { if (token.iconUrl) return <ImageIcon src={token.iconUrl} size={size} rounded="rounded-full" className={className} />; const fill = token.color ?? PALETTE[hashString(token.symbol) % PALETTE.length]; return <Monogram text={token.symbol} fill={fill} size={size} shape="circle" className={className} />; } export interface ChainIconProps { chain: { name: string; shortName?: string; iconUrl?: string; color?: string }; /** Pixel size. @default 20 */ size?: number; className?: string; } /** A chain's logo, or a rounded square with its initial. Decorative: pair it with visible text. */ export function ChainIcon({ chain, size = 20, className }: ChainIconProps) { if (chain.iconUrl) return <ImageIcon src={chain.iconUrl} size={size} rounded="rounded-[28%]" className={className} />; const fill = chain.color ?? PALETTE[hashString(chain.name) % PALETTE.length]; return <Monogram text={(chain.shortName ?? chain.name).charAt(0)} fill={fill} size={size} shape="squircle" className={className} />; } export interface WalletIconProps { wallet: { name: string; iconUrl?: string; color?: string }; /** Pixel size. @default 32 */ size?: number; className?: string; } /** A wallet's logo, or a rounded square with its initial. Decorative: pair it with visible text. */ export function WalletIcon({ wallet, size = 32, className }: WalletIconProps) { if (wallet.iconUrl) return <ImageIcon src={wallet.iconUrl} size={size} rounded="rounded-[28%]" className={className} />; const fill = wallet.color ?? PALETTE[hashString(wallet.name) % PALETTE.length]; return <Monogram text={wallet.name.charAt(0)} fill={fill} size={size} shape="squircle" className={className} />; } export interface AddressAvatarProps extends Omit<ComponentProps<"svg">, "children"> { address: string; /** Pixel size. @default 24 */ size?: number; } /** A deterministic gradient avatar for an address: same address, same avatar, on every device. */ export function AddressAvatar({ address, size = 24, className, ...props }: AddressAvatarProps) { const id = useId(); const hex = address.toLowerCase().replace(/^0x/, "").padEnd(40, "0"); const byte = (i: number) => Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) || 0; const hue = Math.round((byte(0) / 255) * 360); const hue2 = (hue + 40 + Math.round((byte(1) / 255) * 120)) % 360; const angle = Math.round((byte(2) / 255) * 360); return ( <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0 rounded-full", className)} {...props}> <defs> <linearGradient id={`${id}g`} gradientTransform={`rotate(${angle} 0.5 0.5)`}> <stop offset="0" stopColor={`hsl(${hue} 85% 64%)`} /> <stop offset="1" stopColor={`hsl(${hue2} 80% 52%)`} /> </linearGradient> <clipPath id={`${id}c`}> <circle cx="16" cy="16" r="16" /> </clipPath> </defs> <g clipPath={`url(#${id}c)`}> <rect width="32" height="32" fill={`url(#${id}g)`} /> <circle cx={6 + (byte(3) % 20)} cy={6 + (byte(4) % 20)} r={6 + (byte(5) % 6)} fill="#fff" opacity="0.32" /> <circle cx={6 + (byte(6) % 20)} cy={6 + (byte(7) % 20)} r={3 + (byte(8) % 5)} fill="#fff" opacity="0.22" /> </g> </svg> ); }lib/web3/rolling-value.tsx"use client"; import { useEffect, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; const REEL = { type: "spring", stiffness: 160, damping: 22, mass: 0.9 } as const; const MASK = "linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent)"; function Reel({ digit, reduceMotion }: { digit: number; reduceMotion: boolean }) { return ( <span className="relative inline-block [clip-path:inset(0)]" style={{ maskImage: MASK, WebkitMaskImage: MASK }}> <span className="invisible">0</span> <motion.span className="absolute inset-x-0 top-0 flex flex-col items-center" initial={false} animate={{ y: `${-digit * 10}%` }} transition={reduceMotion ? { duration: 0 } : REEL} > {DIGITS.map((n) => ( <span key={n}>{n}</span> ))} </motion.span> </span> ); } export interface RollingValueProps { /** Pre-formatted text, e.g. "$1,234.56" or "2.4815". Digits roll; everything else stays put. */ value: string; /** * The raw value behind the text (a bigint amount or a number). When it changes, the text * flashes green for an increase and red for a decrease. */ trend?: bigint | number; /** Flash on increases and decreases. @default true */ flash?: boolean; className?: string; } type Flash = { direction: "up" | "down"; key: number }; /** * Rolling digits for pre-formatted strings, so bigint amounts animate without ever becoming * floats. Uses tabular figures; screen readers get the plain text. */ export function RollingValue({ value, trend, flash = true, className }: RollingValueProps) { const reduceMotion = useReducedMotion() ?? false; const [previous, setPrevious] = useState(trend); const [pulse, setPulse] = useState<Flash | null>(null); if (trend !== previous) { setPrevious(trend); if (flash && trend !== undefined && previous !== undefined) { setPulse({ direction: trend > previous ? "up" : "down", key: (pulse?.key ?? 0) + 1 }); } } useEffect(() => { if (!pulse) return; const timer = window.setTimeout(() => setPulse(null), 900); return () => window.clearTimeout(timer); }, [pulse]); // Key characters by their distance from the end, so a given place value keeps its reel. const chars = [...value]; return ( <span className={cn( "relative inline-flex tabular-nums transition-colors duration-300", pulse?.direction === "up" && "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]", pulse?.direction === "down" && "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]", className, )} > <span className="sr-only">{value}</span> <AnimatePresence> {pulse && !reduceMotion ? ( <motion.span key={pulse.key} aria-hidden="true" className={cn( "pointer-events-none absolute -inset-x-1 inset-y-0 rounded-[4px]", pulse.direction === "up" ? "bg-success/12" : "bg-destructive/12", )} initial={{ opacity: 1 }} animate={{ opacity: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.9, ease: "easeOut" }} /> ) : null} </AnimatePresence> <span aria-hidden="true" className="relative inline-flex select-none"> {chars.map((char, index) => { const key = chars.length - index; return /\d/.test(char) ? <Reel key={key} digit={Number(char)} reduceMotion={reduceMotion} /> : <span key={`${key}${char}`}>{char}</span>; })} </span> </span> ); }lib/web3/types.ts/** Shapes the web3 components accept. Your own token and chain objects only need these fields. */ export interface Token { symbol: string; name?: string; /** Token decimals: 18 for ETH, 6 for USDC. Amounts are bigint in these units. */ decimals: number; address?: string; /** Logo URL. Without one, a generated avatar (colored circle and symbol) is shown. */ iconUrl?: string; /** Avatar color for the generated icon. Picked from the symbol when omitted. */ color?: string; /** USD price as a bigint with USD_DECIMALS (8) decimals. */ price?: bigint; } export interface Chain { id: number; name: string; shortName?: string; iconUrl?: string; color?: string; /** Block explorer base URL without a trailing slash, e.g. https://etherscan.io */ explorerUrl?: string; nativeSymbol?: string; } export interface WalletOption { id: string; name: string; /** Logo URL. Without one, a generated squircle with the wallet's initial is shown. */ iconUrl?: string; color?: string; /** Shows a "Recent" badge. */ recent?: boolean; /** The wallet is detected in this browser. */ installed?: boolean; /** Connects by scanning a QR code with a phone instead of a browser extension. */ qr?: boolean; /** Short helper line under the name. */ description?: string; } /** Lifecycle of anything the user signs: every state gets its own visual. */ export type TxStatus = "idle" | "awaiting-signature" | "pending" | "confirmed" | "failed"; /** Explorer URL for an address or transaction, or undefined without an explorer. */ export function explorerLink(explorerUrl: string | undefined, kind: "address" | "tx", value: string) { return explorerUrl ? `${explorerUrl.replace(/\/$/, "")}/${kind}/${value}` : undefined; }Add the theme tokens
This component uses --destructive-text, --link, --success, and --warning, which shadcn does not define by default. Add them to your global CSS (--destructive-text and --link fall back to your --destructive and --primary unless you set them):
app/globals.css:root { --success: #15803d; --warning: #b45309; } .dark { --success: #4ade80; --warning: #fbbf24; } @theme inline { --color-destructive-text: var(--destructive-text, var(--destructive)); --color-link: var(--link, var(--primary)); --color-success: var(--success); --color-warning: var(--warning); }Copy the source
components/ui/wallet-agent-chat/wallet-agent-chat.tsx"use client"; import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react"; import { ArrowDown, ArrowRight, Cpu, ExternalLink, RotateCcw, ShieldAlert } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease, spring, stagger } from "@/lib/motion"; import { AddressPill } from "@/components/ui/address-pill/address-pill"; import { AgentSteps, type AgentStep } from "@/components/ui/agent-steps/agent-steps"; import { AIPromptInput } from "@/components/ui/ai-prompt-input/ai-prompt-input"; import { StreamingText, useTextStream } from "@/components/ui/streaming-text/streaming-text"; import { SimulatedBadge } from "@/lib/markets/avatar"; import { AddressAvatar, TokenIcon } from "@/lib/web3/icons"; import { formatAmount, formatBps, formatUsd, toUsd, truncateAddress } from "@/lib/web3/format"; import { RollingValue } from "@/lib/web3/rolling-value"; import { explorerLink } from "@/lib/web3/types"; import { quoteSwap, useMockWalletAgent, type AgentActionCard, type AgentContact, type AgentReply, type AgentToken, type WalletAgent, } from "@/lib/usage/wallet-agent"; export interface StarterPrompt { /** Card title, e.g. "Swap tokens". */ title: string; /** The prompt sent when the card is picked. */ prompt: string; } export interface WalletAgentChatProps { /** Name shown in the header. @default "Atlas" */ agentName?: string; /** The connected address. */ address: string; /** Total wallet value in USD as a bigint with 8 decimals (USD_DECIMALS). Confirmed actions roll it down. */ balanceUsd: bigint; /** Saved contacts the agent may pay. */ contacts: AgentContact[]; /** Tokens in the wallet with balances and USD prices. */ tokens: AgentToken[]; /** Up to three prompt cards for the empty state. */ starterPrompts?: StarterPrompt[]; /** The agent that answers prompts. @default a scripted agent from useMockWalletAgent */ agent?: WalletAgent; /** * Called when a card is approved. Return a promise to hold the card at "Awaiting signature" until it * settles; resolve with a transaction hash, or reject to show the failed state. */ onApprove?: (card: AgentActionCard) => Promise<string | void> | void; /** Called when a card is cancelled before signing. */ onReject?: (card: AgentActionCard) => void; /** Block explorer base URL for the confirmed link, e.g. "https://etherscan.io". */ explorerUrl?: string; /** Show the "Simulated" badge. @default true */ simulated?: boolean; /** Classes for the chat. */ className?: string; } type CardStatus = "review" | "awaiting-signature" | "pending" | "confirmed" | "failed" | "cancelled"; type Message = { id: string; role: "user"; text: string } | { id: string; role: "agent"; reply: AgentReply }; const STEP_MS = 650; const PENDING_MS = 2400; const QUOTE_SECONDS = 15; const usdOf = (token: AgentToken, amount: bigint) => toUsd(amount, token.decimals, token.price); const amountText = (token: AgentToken, amount: bigint) => `${formatAmount(amount, token.decimals)} ${token.symbol}`; /** What a confirmed card takes off the wallet's USD total. */ function costOf(card: AgentActionCard) { if (card.kind === "send") return usdOf(card.token, card.amount) + card.networkFeeUsd; if (card.kind === "swap") return usdOf(card.from, card.amountIn) - usdOf(card.to, card.amountOut) + card.networkFeeUsd; return card.networkFeeUsd; } function fakeHash(seed: string) { let out = ""; for (let round = 0; out.length < 64; round++) { let hash = 0x811c9dc5; for (const char of `${round}:${seed}`) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193); hash ^= hash >>> 13; out += (hash >>> 0).toString(16).padStart(8, "0"); } return `0x${out.slice(0, 64)}`; } const button = "inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-full px-4 text-[13px] font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"; function Row({ label, children, strong }: { label: string; children: React.ReactNode; strong?: boolean }) { return ( <div className="flex items-baseline justify-between gap-3 text-[12px]"> <dt className="text-muted-foreground">{label}</dt> <dd className={cn("text-right font-mono tabular-nums", strong ? "font-semibold text-foreground" : "text-foreground")}>{children}</dd> </div> ); } /** A small ring that empties over the quote's lifetime. */ function QuoteRing({ seconds }: { seconds: number }) { const r = 7; const c = 2 * Math.PI * r; return ( <svg viewBox="0 0 18 18" className="size-4 -rotate-90" aria-hidden="true"> <circle cx="9" cy="9" r={r} fill="none" stroke="var(--border)" strokeWidth="2" /> <circle cx="9" cy="9" r={r} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - seconds / QUOTE_SECONDS)} className="transition-[stroke-dashoffset] duration-1000 ease-linear" /> </svg> ); } function ActionCard({ card: initial, onApprove, onReject, onStatus, explorerUrl, }: { card: AgentActionCard; onApprove?: WalletAgentChatProps["onApprove"]; onReject?: WalletAgentChatProps["onReject"]; onStatus: (card: AgentActionCard, status: CardStatus) => void; explorerUrl?: string; }) { const reduceMotion = useReducedMotion(); const [card, setCard] = useState(initial); const [status, setStatus] = useState<CardStatus>("review"); const [hash, setHash] = useState(""); const [error, setError] = useState(""); const [seconds, setSeconds] = useState(QUOTE_SECONDS); const [progress, setProgress] = useState(0); const [scope, animate] = useAnimate<HTMLElement>(); const left = useRef(QUOTE_SECONDS); const live = useRef(true); useEffect(() => { live.current = true; return () => { live.current = false; }; }, []); const update = (next: CardStatus, current = card) => { setStatus(next); onStatus(current, next); }; // The swap quote counts down while under review and re-rolls when it runs out. useEffect(() => { if (card.kind !== "swap" || status !== "review") return; const timer = window.setInterval(() => { left.current -= 1; if (left.current <= 0) { left.current = QUOTE_SECONDS; // A refreshed quote lands within ±0.2% of the last one. const jitter = Math.round((Math.random() - 0.5) * 40); setCard((c) => { if (c.kind !== "swap") return c; const quote = quoteSwap(c.from, c.to, c.amountIn, jitter); return { ...c, amountOut: quote.amountOut, priceImpactBps: quote.priceImpactBps }; }); } setSeconds(left.current); }, 1000); return () => window.clearInterval(timer); }, [card.kind, status]); // Pending fills a progress ring, then confirms. useEffect(() => { if (status !== "pending") return; const started = performance.now(); let frame = 0; const tick = () => { const p = Math.min(1, (performance.now() - started) / PENDING_MS); setProgress(p); if (p < 1) frame = requestAnimationFrame(tick); else if (live.current) update("confirmed"); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once per pending phase }, [status]); const approve = async () => { const signed = card; setError(""); update("awaiting-signature", signed); try { const started = Date.now(); const result = await onApprove?.(signed); // A simulated wallet takes a moment to "sign". const wait = Math.max(0, 1100 - (Date.now() - started)); await new Promise((resolve) => window.setTimeout(resolve, wait)); if (!live.current) return; setHash(typeof result === "string" ? result : fakeHash(signed.id)); setProgress(0); update("pending", signed); } catch (reason) { if (!live.current) return; setError(reason instanceof Error ? reason.message : "The transaction was not signed."); update("failed", signed); if (!reduceMotion && scope.current) animate(scope.current, { x: [0, -8, 7, -5, 3, 0] }, { duration: 0.4 }); } }; const cancel = () => { update("cancelled"); onReject?.(card); }; const title = card.kind === "swap" ? "Swap" : card.kind === "send" ? "Send" : "Token approval"; const verb = card.kind === "swap" ? "Confirm swap" : card.kind === "send" ? `Send ${card.token.symbol}` : "Approve"; const signing = status === "awaiting-signature"; const done = status === "confirmed"; const href = hash ? explorerLink(explorerUrl, "tx", hash) : undefined; const ring = 2 * Math.PI * 9; return ( <motion.article ref={scope} aria-label={`${title} review`} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.98 }} animate={reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }} transition={reduceMotion ? { duration: duration.base } : spring.smooth} className={cn( "mt-3 w-full max-w-sm rounded-2xl border bg-background p-4 shadow-sm transition-colors", status === "failed" ? "border-destructive/40" : done ? "border-success/40" : "border-border", status === "cancelled" && "opacity-60", )} > <header className="flex items-center justify-between gap-2"> <p className="text-[13px] font-semibold text-foreground">{title}</p> <span className={cn( "inline-flex h-6 items-center gap-1.5 rounded-full px-2 text-[11px] font-medium", status === "review" && "bg-surface-2 text-foreground", signing && "bg-primary/10 text-link", status === "pending" && "bg-warning/12 text-[color-mix(in_oklab,var(--warning)_65%,var(--foreground))]", done && "bg-success/12 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]", status === "failed" && "bg-destructive/10 text-destructive-text", status === "cancelled" && "bg-surface-2 text-muted-foreground", )} > {status === "review" ? "Review" : signing ? "Awaiting signature" : status === "pending" ? "Pending" : done ? "Confirmed" : status === "failed" ? "Failed" : "Cancelled"} </span> </header> {/* Body */} {card.kind === "swap" ? ( <div className="mt-3 space-y-1.5"> <div className="flex items-center gap-2.5 rounded-xl bg-surface p-2.5"> <TokenIcon token={card.from} size={28} /> <div className="min-w-0 flex-1"> <p className="text-[11px] text-muted-foreground">You pay</p> <p className="font-mono text-[15px] font-semibold text-foreground tabular-nums">{amountText(card.from, card.amountIn)}</p> </div> <span className="font-mono text-[12px] text-muted-foreground tabular-nums">{formatUsd(usdOf(card.from, card.amountIn))}</span> </div> <div className="relative -my-3 flex justify-center"> <span className="z-10 grid size-7 place-items-center rounded-full border border-border bg-background text-muted-foreground"> <ArrowDown className="size-3.5" aria-hidden="true" /> </span> </div> <div className="flex items-center gap-2.5 rounded-xl bg-surface p-2.5"> <TokenIcon token={card.to} size={28} /> <div className="min-w-0 flex-1"> <p className="text-[11px] text-muted-foreground">You receive about</p> <p className="font-mono text-[15px] font-semibold text-foreground"> <RollingValue value={formatAmount(card.amountOut, card.to.decimals)} trend={card.amountOut} /> {card.to.symbol} </p> </div> <span className="font-mono text-[12px] text-muted-foreground tabular-nums">{formatUsd(usdOf(card.to, card.amountOut))}</span> </div> </div> ) : card.kind === "send" ? ( <div className="mt-3 space-y-2"> <div className="flex items-center gap-2.5 rounded-xl bg-surface p-2.5"> <AddressAvatar address={card.to.address} size={32} /> <div className="min-w-0 flex-1"> <p className="text-[13px] font-medium text-foreground">{card.to.name}</p> <p className="font-mono text-[11px] text-muted-foreground" title={card.to.address}> {truncateAddress(card.to.address)} </p> </div> <span className="text-[11px] text-muted-foreground">Saved contact</span> </div> <div className="flex items-center gap-2.5 px-1"> <TokenIcon token={card.token} size={24} /> <p className="flex-1 font-mono text-[15px] font-semibold text-foreground tabular-nums">{amountText(card.token, card.amount)}</p> <span className="font-mono text-[12px] text-muted-foreground tabular-nums">{formatUsd(usdOf(card.token, card.amount))}</span> </div> </div> ) : ( <div className="mt-3 space-y-2"> <div className="flex items-center gap-2.5 rounded-xl bg-surface p-2.5"> <TokenIcon token={card.token} size={28} /> <div className="min-w-0 flex-1"> <p className="text-[11px] text-muted-foreground">Allow spending up to</p> <p className="font-mono text-[15px] font-semibold text-foreground tabular-nums">{card.amount === null ? `Unlimited ${card.token.symbol}` : amountText(card.token, card.amount)}</p> </div> </div> <div className="flex items-center gap-2.5 px-1 text-[12px]"> <span className="text-muted-foreground">Spender</span> <span className="ml-auto font-medium text-foreground">{card.spender.name}</span> <span className="font-mono text-muted-foreground" title={card.spender.address}> {truncateAddress(card.spender.address)} </span> </div> {card.amount === null ? ( <div role="alert" className="flex items-start gap-2 rounded-xl border border-warning/35 bg-warning/10 p-2.5 text-[12px] text-[color-mix(in_oklab,var(--warning)_60%,var(--foreground))]"> <ShieldAlert className="mt-px size-4 shrink-0" aria-hidden="true" /> <div className="flex-1"> <p className="font-medium">Unlimited allowance</p> <p className="mt-0.5 opacity-90">{card.spender.name} could move every {card.token.symbol} you hold, now or later.</p> {status === "review" ? ( <button type="button" onClick={() => setCard({ ...card, amount: card.token.balance })} className="mt-1.5 cursor-pointer rounded font-semibold underline underline-offset-2 outline-none focus-visible:ring-2 focus-visible:ring-ring" > Cap at your balance ({amountText(card.token, card.token.balance)}) </button> ) : null} </div> </div> ) : null} </div> )} <dl className="mt-3 space-y-1 border-t border-border pt-3"> {card.kind === "swap" ? ( <> <Row label="Rate"> 1 {card.from.symbol} ≈ {formatAmount(card.amountIn > BigInt(0) ? (card.amountOut * BigInt(10) ** BigInt(card.from.decimals)) / card.amountIn : BigInt(0), card.to.decimals)} {card.to.symbol} </Row> <Row label="Route">{card.route.join(" → ")}</Row> <Row label="Fees"> {formatBps(card.feeBps)} · impact{" "} <span className={cn(card.priceImpactBps >= 300 && "text-destructive-text")}>{formatBps(card.priceImpactBps)}</span> </Row> </> ) : null} <Row label="Network fee">{formatUsd(card.networkFeeUsd)}</Row> {card.kind === "send" ? <Row label="Total" strong>{formatUsd(usdOf(card.token, card.amount) + card.networkFeeUsd)}</Row> : null} </dl> {card.kind === "swap" && status === "review" ? ( <p className="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground"> <QuoteRing seconds={seconds} /> Quote refreshes in <span className="font-mono tabular-nums">{seconds}s</span> </p> ) : null} {/* Lifecycle */} <div className="mt-3"> <AnimatePresence mode="popLayout" initial={false}> {status === "review" || status === "awaiting-signature" ? ( <motion.div key="actions" className="flex gap-2" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: duration.quick }}> <button type="button" onClick={cancel} className={cn(button, "border border-border text-foreground hover:bg-surface-2")}> Cancel </button> <button type="button" onClick={approve} disabled={signing} aria-busy={signing} className={cn(button, "flex-1 bg-foreground text-background")}> {signing ? ( <> <span aria-hidden="true" className="size-3.5 animate-spin rounded-full border-2 border-background/30 border-t-background motion-reduce:animate-none" /> Check your wallet… </> ) : ( verb )} </button> </motion.div> ) : status === "pending" ? ( <motion.div key="pending" className="flex items-center gap-2.5 text-[12px] text-muted-foreground" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}> <svg viewBox="0 0 22 22" className="size-5 -rotate-90" role="progressbar" aria-label="Confirming" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)}> <circle cx="11" cy="11" r="9" fill="none" stroke="var(--border)" strokeWidth="2.5" /> <circle cx="11" cy="11" r="9" fill="none" stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeDasharray={ring} strokeDashoffset={ring * (1 - progress)} /> </svg> Waiting for confirmation… <span className="ml-auto font-mono">{truncateAddress(hash, { start: 8, end: 4 })}</span> </motion.div> ) : done ? ( <motion.div key="done" className="flex items-center gap-2 text-[12px]" initial={{ opacity: 0 }} animate={{ opacity: 1 }}> <svg viewBox="0 0 22 22" className="size-5" aria-hidden="true"> <circle cx="11" cy="11" r="10" fill="color-mix(in oklab, var(--success) 14%, transparent)" /> <motion.path d="M6.5 11.5l3 3 6-6.5" fill="none" stroke="color-mix(in oklab, var(--success) 80%, var(--foreground))" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: reduceMotion ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: duration.slow, ease: ease.out }} /> </svg> <span className="font-medium text-foreground">Confirmed</span> {href ? ( <a href={href} target="_blank" rel="noreferrer" className="ml-auto inline-flex items-center gap-1 rounded font-medium text-link outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"> View on explorer <ExternalLink className="size-3" aria-hidden="true" /> </a> ) : ( <span className="ml-auto font-mono text-muted-foreground">{truncateAddress(hash, { start: 8, end: 4 })}</span> )} </motion.div> ) : status === "failed" ? ( <motion.div key="failed" className="flex items-center gap-2" initial={{ opacity: 0 }} animate={{ opacity: 1 }}> <p className="flex-1 text-[12px] text-destructive-text">{error}</p> <button type="button" onClick={cancel} className={cn(button, "h-8 border border-border px-3 text-foreground hover:bg-surface-2")}> Dismiss </button> <button type="button" onClick={approve} className={cn(button, "h-8 bg-foreground px-3 text-background")}> <RotateCcw className="size-3.5" aria-hidden="true" /> Retry </button> </motion.div> ) : ( <motion.p key="cancelled" className="text-[12px] text-muted-foreground" initial={{ opacity: 0 }} animate={{ opacity: 1 }}> Cancelled. Nothing was signed. </motion.p> )} </AnimatePresence> </div> </motion.article> ); } function AgentMessage({ reply, instant, onSettled, onStatus, onApprove, onReject, explorerUrl, }: { reply: AgentReply; instant: boolean; onSettled: () => void; onStatus: (card: AgentActionCard, status: CardStatus) => void; onApprove?: WalletAgentChatProps["onApprove"]; onReject?: WalletAgentChatProps["onReject"]; explorerUrl?: string; }) { const [done, setDone] = useState(0); const stepsDone = instant || done >= reply.steps.length; const stream = useTextStream(reply.text, { enabled: stepsDone && !instant, wordsPerSecond: 40 }); const text = instant ? reply.text : stream.text; const finished = stepsDone && text.length >= reply.text.length; const settled = useRef(false); useEffect(() => { if (stepsDone) return; const timer = window.setTimeout(() => setDone((d) => d + 1), STEP_MS); return () => window.clearTimeout(timer); }, [done, stepsDone]); useEffect(() => { if (finished && !settled.current) { settled.current = true; onSettled(); } }, [finished, onSettled]); const steps: AgentStep[] = reply.steps.map((title, i) => ({ id: `${i}`, title, status: stepsDone || i < done ? "done" : i === done ? "running" : "pending" })); return ( <div className="flex gap-2.5"> <span aria-hidden="true" className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-full bg-foreground text-background"> <Cpu className="size-3.5" /> </span> <div className="min-w-0 flex-1"> <AgentSteps steps={steps} aria-label="Agent steps" className="[&_li]:pb-2.5" /> {stepsDone ? <StreamingText text={text} streaming={!finished} className="mt-2 text-[14px] leading-6 text-foreground" /> : null} {finished && reply.card ? <ActionCard card={reply.card} onApprove={onApprove} onReject={onReject} onStatus={onStatus} explorerUrl={explorerUrl} /> : null} </div> </div> ); } /** * An AI wallet assistant that answers with transaction cards the user reviews and approves in place. * Each reply resolves its tool steps, streams its text, then slides in a swap, send or approval card * that tracks Review → Awaiting signature → Pending → Confirmed or Failed. Nothing is signed until the * user approves, and cancel is always available before signing. */ export function WalletAgentChat({ agentName = "Atlas", address, balanceUsd, contacts, tokens, starterPrompts = [], agent, onApprove, onReject, explorerUrl, simulated = true, className, }: WalletAgentChatProps) { const id = useId(); const reduceMotion = useReducedMotion(); const fallback = useMockWalletAgent({ tokens, contacts }); const responder = agent ?? fallback; const [messages, setMessages] = useState<Message[]>([]); const [busy, setBusy] = useState(false); const [instant, setInstant] = useState<Record<string, boolean>>({}); const [spent, setSpent] = useState(BigInt(0)); const [announce, setAnnounce] = useState(""); const scroller = useRef<HTMLDivElement>(null); const list = useRef<HTMLDivElement>(null); const counter = useRef(0); // Keep the newest content in view as the conversation grows. useLayoutEffect(() => { const el = scroller.current; const inner = list.current; if (!el || !inner) return; const observer = new ResizeObserver(() => el.scrollTo({ top: el.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" })); observer.observe(inner); return () => observer.disconnect(); }, [reduceMotion]); const send = (prompt: string) => { const text = prompt.trim(); if (!text || busy) return; const n = counter.current++; const reply = responder.respond(text); setMessages((m) => [...m, { id: `u${n}`, role: "user", text }, { id: `a${n}`, role: "agent", reply }]); setBusy(true); }; const lastAgent = [...messages].reverse().find((m): m is Extract<Message, { role: "agent" }> => m.role === "agent"); const settle = (message: Extract<Message, { role: "agent" }>) => () => { setBusy(false); setAnnounce(`${agentName}: ${message.reply.text}${message.reply.card ? " A card is ready to review." : ""}`); }; const onStatus = (card: AgentActionCard, status: CardStatus) => { const label = card.kind === "swap" ? "Swap" : card.kind === "send" ? "Payment" : "Approval"; if (status === "confirmed") setSpent((s) => s + costOf(card)); const words: Partial<Record<CardStatus, string>> = { "awaiting-signature": "awaiting signature", pending: "pending", confirmed: "confirmed", failed: "failed", cancelled: "cancelled" }; if (words[status]) setAnnounce(`${label} ${words[status]}`); }; const balance = balanceUsd - spent > BigInt(0) ? balanceUsd - spent : BigInt(0); return ( <section aria-labelledby={`${id}-title`} className={cn("flex h-[38rem] w-full max-w-xl flex-col overflow-hidden rounded-2xl border border-border bg-background", className)}> <header className="flex flex-wrap items-center gap-3 border-b border-border px-4 py-3"> <span aria-hidden="true" className="grid size-9 shrink-0 place-items-center rounded-full bg-[conic-gradient(from_140deg,#6366f1,#22d3ee,#a855f7,#6366f1)] text-white"> <Cpu className="size-4" /> </span> <div className="min-w-0"> <h3 id={`${id}-title`} className="flex items-center gap-2 text-sm font-semibold text-foreground"> {agentName} {simulated ? <SimulatedBadge /> : null} </h3> <p className="text-[12px] text-muted-foreground">Wallet agent</p> </div> <div className="ml-auto flex items-center gap-2"> <p className="text-right"> <span className="block text-[11px] text-muted-foreground">Balance</span> <span className="font-mono text-[13px] font-semibold text-foreground"> <RollingValue value={formatUsd(balance)} trend={balance} /> </span> </p> <AddressPill address={address} size="sm" explorerUrl={explorerUrl} /> </div> </header> <div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-4 py-4" role="region" aria-label="Conversation"> <div ref={list}> {messages.length === 0 ? ( <div className="flex flex-col items-center py-6 text-center"> <p className="text-lg font-semibold tracking-tight text-foreground">What should we move today?</p> <p className="mt-1 max-w-sm text-[13px] text-muted-foreground">I prepare every transaction as a card you can review. Nothing is signed until you approve it.</p> <motion.ul className="mt-5 grid w-full gap-2 sm:grid-cols-3" initial="hidden" animate="visible" variants={{ hidden: {}, visible: { transition: { staggerChildren: stagger.base } } }} > {starterPrompts.slice(0, 3).map((s) => ( <motion.li key={s.title} variants={reduceMotion ? { hidden: { opacity: 0 }, visible: { opacity: 1 } } : { hidden: { opacity: 0, y: 8 }, visible: { opacity: 1, y: 0, transition: { duration: duration.base, ease: ease.out } } }}> <button type="button" onClick={() => send(s.prompt)} className="group flex h-full w-full cursor-pointer flex-col items-start gap-1 rounded-xl border border-border p-3 text-left outline-none transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring" > <span className="flex w-full items-center justify-between text-[13px] font-medium text-foreground"> {s.title} <ArrowRight className="size-3.5 text-muted-foreground transition-transform group-hover:translate-x-0.5" aria-hidden="true" /> </span> <span className="text-[12px] text-muted-foreground">{s.prompt}</span> </button> </motion.li> ))} </motion.ul> </div> ) : ( <ul className="space-y-5"> {messages.map((m) => m.role === "user" ? ( <motion.li key={m.id} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: duration.base, ease: ease.out }} className="flex justify-end" > <p className="max-w-[80%] rounded-2xl rounded-br-md bg-foreground px-3.5 py-2 text-[14px] text-background"> <span className="sr-only">You: </span> {m.text} </p> </motion.li> ) : ( <li key={m.id}> <span className="sr-only">{agentName}: </span> <AgentMessage reply={m.reply} instant={!!instant[m.id]} onSettled={settle(m)} onStatus={onStatus} onApprove={onApprove} onReject={onReject} explorerUrl={explorerUrl} /> </li> ), )} </ul> )} </div> </div> <div className="border-t border-border p-3"> <AnimatePresence initial={false}> {!busy && lastAgent?.reply.suggestions?.length ? ( <motion.div key={lastAgent.id} role="group" aria-label="Suggestions" className="mb-2 flex gap-1.5 overflow-x-auto pb-0.5" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: duration.instant } }} transition={{ duration: duration.base, ease: ease.out }} > {lastAgent.reply.suggestions.map((s) => ( <button key={s} type="button" onClick={() => send(s)} className="h-8 shrink-0 cursor-pointer rounded-full border border-border bg-background px-3 text-[12px] font-medium text-foreground outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring" > {s} </button> ))} </motion.div> ) : null} </AnimatePresence> <AIPromptInput placeholder="Swap, send or approve…" allowAttachments={false} maxRows={4} loading={busy} onStop={() => { if (lastAgent) setInstant((s) => ({ ...s, [lastAgent.id]: true })); }} onSubmit={(value) => send(value)} aria-label={`Message ${agentName}`} /> </div> <p aria-live="polite" className="sr-only"> {announce} </p> </section> ); }
Usage
import { WalletAgentChat } from "@/components/ui/wallet-agent-chat/wallet-agent-chat";
<WalletAgentChat
agentName="Atlas"
address={account.address}
balanceUsd={totalUsd} // bigint, 8 decimals
tokens={tokens} // { symbol, decimals, price, balance: bigint }[]
contacts={[{ name: "Maya", address: "0x…" }]}
starterPrompts={[{ title: "Swap tokens", prompt: "Swap 0.5 ETH to USDC" }]}
onApprove={async (card) => {
// Hand the card to your own wallet flow here. Resolve with a tx hash, or throw to show Failed.
}}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
agentName | string | "Atlas" | Name shown in the header. |
address* | string | – | The connected address. |
balanceUsd* | bigint | – | Total wallet value in USD as a bigint with 8 decimals (USD_DECIMALS). Confirmed actions roll it down. |
contacts* | AgentContact[] | – | Saved contacts the agent may pay. |
tokens* | AgentToken[] | – | Tokens in the wallet with balances and USD prices. |
starterPrompts | StarterPrompt[] | – | Up to three prompt cards for the empty state. |
agent | WalletAgent | a scripted agent from useMockWalletAgent | The agent that answers prompts. |
onApprove | (card: AgentActionCard) => Promise<string | void> | void | – | Called when a card is approved. Return a promise to hold the card at "Awaiting signature" until it settles; resolve with a transaction hash, or reject to show the failed state. |
onReject | (card: AgentActionCard) => void | – | Called when a card is cancelled before signing. |
explorerUrl | string | – | Block explorer base URL for the confirmed link, e.g. "https://etherscan.io". |
simulated | boolean | true | Show the "Simulated" badge. |
className | string | – | Classes for the chat. |
- Simulated by default: replies come from useMockWalletAgent in src/lib/usage/wallet-agent.ts, which parses "swap X A to B", "send X A to name" and "approve X A for spender" with regular expressions. Pass `agent` to plug in your own.
- Nothing is signed without an explicit click on the card's approve button, and Cancel is available until the signature resolves. Payments only go to saved contacts.
- All token amounts are bigint with their decimals; USD values use 8 decimals. Swap quotes refresh every 15 seconds while under review, and confirmed actions roll the header balance down.
Accessibility
- The conversation is a labelled region. Each reply is announced politely once it finishes, along with each card's status changes (awaiting signature, pending, confirmed, failed, cancelled).
- Every action card is an article with its amounts, fees and spender in text; the unlimited-allowance warning is an alert with a keyboard-reachable "Cap at your balance" action.
- The pending ring is a progressbar with its value. Reduced motion keeps every state but drops the slide-in, shake and check draw.