From 106b684f59b95c8ec4d73aaa2d7b7cb81734d98b Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Fri, 26 Jun 2026 00:58:22 +0800 Subject: [PATCH 01/35] feat(backend): crypto sub-agent with multi-currency support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a third sub-agent (cryptoAgent) alongside weatherAgent and chatAgent, dispatched by the router on crypto-keyword messages. Reuses the weather agent's human-in-the-loop pattern: ask_crypto_intent (interrupt) → get_crypto_price → get_fx_rate → confirm_crypto_order. Tools (backend/tool/crypto/): - ask_crypto_intent: pure trigger that pauses via interrupt. Schema now carries currency + amount pre-fill so the LLM can signal what the user meant ("100 元" → currency=CNY, amount=100). - get_crypto_price: CoinGecko /coins/markets, 60s in-memory cache, no key. Free tier rate-limit friendly. - get_fx_rate (new): frankfurter.app, 60s cache, no key, ECB data. Used by the LLM to convert non-USD amounts to USD before confirm_crypto_order. - confirm_crypto_order: simulated order, no signing, no chain. Returns a fake receipt with qty = amount_usd / price_at_confirm. Prompts (backend/prompt/system.ts): - CRYPTO_AGENT_PROMPT: instructs the LLM to detect the user's currency from the message (元/RMB/CNY → CNY, $/USD → USD, etc.) and call get_fx_rate after the card resumes, before placing the order. ¥ alone is ambiguous — only treat as JPY when the user wrote JPY/日元/日本円 alongside it, else default to CNY. - ROUTER_AGENT_PROMPT: extended with crypto keywords (price, buy, sell, BTC, ETH, 加密货币, 价格, 买入, 卖出, 币, crypto, coin, token). Router enum fix: RouteDecisionSchema in router-agent-node.ts was missing cryptoAgent — the LLM correctly emitted "cryptoAgent" but parsing threw. Added the enum value + regression test. Both topologies (USE_SUBGRAPH=true and inlined default) wire cryptoAgent / cryptoModel + cryptoTools via buildSubgraph() and buildInlined() in backend/agent.ts. 177/177 tests pass. --- backend/agent.ts | 59 +++++++++---- backend/agent/crypto-agent.ts | 34 ++++++++ backend/node/router-agent-node.ts | 2 +- backend/prompt/system.ts | 19 +++++ backend/state.ts | 2 +- backend/tool/crypto/ask-crypto-intent.ts | 38 +++++++++ backend/tool/crypto/confirm-crypto-order.ts | 62 ++++++++++++++ backend/tool/crypto/get-crypto-price.ts | 91 +++++++++++++++++++++ backend/tool/crypto/get-fx-rate.ts | 58 +++++++++++++ backend/tool/index.ts | 27 +++++- 10 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 backend/agent/crypto-agent.ts create mode 100644 backend/tool/crypto/ask-crypto-intent.ts create mode 100644 backend/tool/crypto/confirm-crypto-order.ts create mode 100644 backend/tool/crypto/get-crypto-price.ts create mode 100644 backend/tool/crypto/get-fx-rate.ts diff --git a/backend/agent.ts b/backend/agent.ts index 8baf477..766222f 100644 --- a/backend/agent.ts +++ b/backend/agent.ts @@ -5,22 +5,23 @@ import { renameThreadAgentNode } from "@/backend/node/rename-thread-agent-node"; import { afterAgentNode } from "@/backend/node/after-agent-node"; import { weatherAgent } from "@/backend/agent/weather-agent"; import { chatAgent } from "@/backend/agent/chat-agent"; +import { cryptoAgent } from "@/backend/agent/crypto-agent"; import { routerAgentNode } from "@/backend/node/router-agent-node"; import { checkpointer } from "@/backend/checkpointer"; import { RouterAgentState } from "@/backend/state"; import { chatModel } from "@/backend/model"; -import { ALL_TOOLS, WEATHER_TOOLS } from "@/backend/tool"; +import { ALL_TOOLS, WEATHER_TOOLS, CRYPTO_TOOLS } from "@/backend/tool"; import { CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT } from "@/backend/prompt/system"; // USE_SUBGRAPH=true switches the compiled graph between two topologies. -// Default (false / unset): inlined — flatten weather/chat model+tool loops +// Default (false / unset): inlined — flatten weather/chat/crypto model+tool loops // into the parent graph. This is the safe workaround for the // EventStreamCallbackHandler "Run ID not found in run map" bug that // LangGraph JS subgraphs trigger under @langchain/core@1.2.1. // See memory/langgraph-subgraph-run-map-bug.md. -// Set USE_SUBGRAPH=true to use the compiled weatherAgent / chatAgent -// subgraphs instead. Both topologies are kept in this file in sync — -// if you change a model, prompt, or tool set, update both builders. +// Set USE_SUBGRAPH=true to use the compiled weatherAgent / chatAgent / +// cryptoAgent subgraphs instead. Both topologies are kept in this file +// in sync — if you change a model, prompt, or tool set, update both builders. const USE_SUBGRAPH = process.env.USE_SUBGRAPH === "true" || process.env.USE_SUBGRAPH === "1"; // After the router speaks, decide which sub-agent gets the turn. @@ -29,14 +30,14 @@ const USE_SUBGRAPH = process.env.USE_SUBGRAPH === "true" || process.env.USE_SUBG function routeToSubAgent({ routerDecision, }: { - routerDecision?: { next: "weatherAgent" | "chatAgent" }; -}): "weatherAgent" | "chatAgent" { + routerDecision?: { next: "weatherAgent" | "chatAgent" | "cryptoAgent" }; +}): "weatherAgent" | "chatAgent" | "cryptoAgent" { return routerDecision?.next ?? "chatAgent"; } // --------------------------------------------------------------------------- // Subgraph version — preferred when the upstream run-map bug is fixed. -// Reads the two compiled subgraphs and wires them as opaque nodes. +// Reads the three compiled subgraphs and wires them as opaque nodes. // --------------------------------------------------------------------------- function buildSubgraph() { return ( @@ -46,15 +47,23 @@ function buildSubgraph() { .addNode("afterAgent", afterAgentNode) .addNode("renameThreadAgent", renameThreadAgentNode) .addNode("weatherAgent", weatherAgent) - // Sequential: START → routerAgent → (weatherAgent | chatAgent) → afterAgent → END. + .addNode("cryptoAgent", cryptoAgent) + // Sequential: START → routerAgent → (weatherAgent | chatAgent | cryptoAgent) → afterAgent → END. // ask_location's picker card is owned by the weather subgraph // (see backend/agent/weather-agent.ts + components/tool-ui/ask-location). + // ask_crypto_intent's picker card is owned by the crypto subgraph + // (see backend/agent/crypto-agent.ts + components/tool-ui/crypto). // renameThreadAgent is wired off START so its DB side-effect runs in // parallel without touching the messages channel. .addEdge(START, "routerAgent") - .addConditionalEdges("routerAgent", routeToSubAgent, ["weatherAgent", "chatAgent"]) + .addConditionalEdges("routerAgent", routeToSubAgent, [ + "weatherAgent", + "chatAgent", + "cryptoAgent", + ]) .addEdge("chatAgent", "afterAgent") .addEdge("weatherAgent", "afterAgent") + .addEdge("cryptoAgent", "afterAgent") .addEdge("afterAgent", END) .addEdge(START, "renameThreadAgent") .addEdge("renameThreadAgent", END) @@ -62,8 +71,9 @@ function buildSubgraph() { } // --------------------------------------------------------------------------- -// Inlined version (default) — flatten weather-agent.ts + chat-agent.ts -// model/tool loops into the parent graph. Keep in sync with those files. +// Inlined version (default) — flatten weather-agent.ts + chat-agent.ts + +// crypto-agent.ts model/tool loops into the parent graph. Keep in sync +// with those files. // --------------------------------------------------------------------------- async function weatherModelNode({ messages }: { messages: BaseMessage[] }) { const history = messages.filter((m) => !(m instanceof SystemMessage)); @@ -81,8 +91,18 @@ async function chatModelNode({ messages }: { messages: BaseMessage[] }) { return { messages: [response] }; } +async function cryptoModelNode({ messages }: { messages: BaseMessage[] }) { + const { CRYPTO_AGENT_PROMPT } = await import("@/backend/prompt/system"); + const history = messages.filter((m) => !(m instanceof SystemMessage)); + const response = await chatModel + .bindTools(CRYPTO_TOOLS) + .invoke([new SystemMessage(CRYPTO_AGENT_PROMPT), ...history]); + return { messages: [response] }; +} + const weatherToolNode = new ToolNode(WEATHER_TOOLS); const chatToolNode = new ToolNode(ALL_TOOLS); +const cryptoToolNode = new ToolNode(CRYPTO_TOOLS); // toolsCondition only inspects the last AI message, so its return value is // independent of what the tool node is named — we just remap "tools" → our @@ -93,6 +113,9 @@ function weatherRoute(state: { messages: BaseMessage[] }) { function chatRoute(state: { messages: BaseMessage[] }) { return toolsCondition(state) === END ? "afterAgent" : "chatTools"; } +function cryptoRoute(state: { messages: BaseMessage[] }) { + return toolsCondition(state) === END ? "afterAgent" : "cryptoTools"; +} function buildInlined() { return ( @@ -102,21 +125,27 @@ function buildInlined() { .addNode("weatherTools", weatherToolNode) .addNode("chatModel", chatModelNode) .addNode("chatTools", chatToolNode) + .addNode("cryptoModel", cryptoModelNode) + .addNode("cryptoTools", cryptoToolNode) .addNode("afterAgent", afterAgentNode) .addNode("renameThreadAgent", renameThreadAgentNode) - // Sequential: START → routerAgent → (weatherModel | chatModel) → - // (weatherTools | chatTools)* → afterAgent → END. + // Sequential: START → routerAgent → (weatherModel | chatModel | cryptoModel) → + // (weatherTools | chatTools | cryptoTools)* → afterAgent → END. // ask_location's picker card is owned by the weather model/tool loop - // (see components/tool-ui/ask-location). + // (see components/tool-ui/ask-location). ask_crypto_intent's picker + // card is owned by the crypto loop (see components/tool-ui/crypto). .addEdge(START, "routerAgent") .addConditionalEdges("routerAgent", routeToSubAgent, { weatherAgent: "weatherModel", chatAgent: "chatModel", + cryptoAgent: "cryptoModel", }) .addConditionalEdges("weatherModel", weatherRoute, ["weatherTools", "afterAgent"]) .addEdge("weatherTools", "weatherModel") .addConditionalEdges("chatModel", chatRoute, ["chatTools", "afterAgent"]) .addEdge("chatTools", "chatModel") + .addConditionalEdges("cryptoModel", cryptoRoute, ["cryptoTools", "afterAgent"]) + .addEdge("cryptoTools", "cryptoModel") .addEdge("afterAgent", END) .addEdge(START, "renameThreadAgent") .addEdge("renameThreadAgent", END) diff --git a/backend/agent/crypto-agent.ts b/backend/agent/crypto-agent.ts new file mode 100644 index 0000000..a0467c9 --- /dev/null +++ b/backend/agent/crypto-agent.ts @@ -0,0 +1,34 @@ +import { END, START, StateGraph } from "@langchain/langgraph"; +import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt"; +import { SystemMessage, type BaseMessage } from "@langchain/core/messages"; +import { chatModel } from "@/backend/model"; +import { CRYPTO_TOOLS } from "@/backend/tool"; +import { CRYPTO_AGENT_PROMPT } from "@/backend/prompt/system"; +import { CommonAgentState } from "@/backend/state"; + +// Crypto sub-agent: mirrors the weather subgraph. The model ↔ tools +// loop runs end-to-end inside the subgraph so the parent graph doesn't +// need to know that crypto turns invoke a human-in-the-loop card. +// ask_crypto_intent is a pure trigger — its sentinel ToolMessage is +// what the frontend card keys on, and the user's pick comes back as +// an overwritten tool result on the next model pass. + +async function cryptoModelNode({ messages }: { messages: BaseMessage[] }) { + const system = new SystemMessage(CRYPTO_AGENT_PROMPT); + const messagesWithoutSystem = messages.filter((m) => !(m instanceof SystemMessage)); + const response = await chatModel + .bindTools(CRYPTO_TOOLS) + .invoke([system, ...messagesWithoutSystem]); + return { messages: [response] }; +} + +const cryptoToolNode = new ToolNode(CRYPTO_TOOLS); + +const builder = new StateGraph(CommonAgentState) + .addNode("model", cryptoModelNode) + .addNode("tools", cryptoToolNode) + .addEdge(START, "model") + .addConditionalEdges("model", toolsCondition, ["tools", END]) + .addEdge("tools", "model"); + +export const cryptoAgent = builder.compile(); diff --git a/backend/node/router-agent-node.ts b/backend/node/router-agent-node.ts index a2fa9a7..a1e3c25 100644 --- a/backend/node/router-agent-node.ts +++ b/backend/node/router-agent-node.ts @@ -15,7 +15,7 @@ import { ROUTER_AGENT_PROMPT } from "@/backend/prompt/system"; // `routerDecision` to the state. `tags: ["nostream"]` keeps the run's // token stream free of the router's internal reasoning. const RouteDecisionSchema = z.object({ - next: z.enum(["weatherAgent", "chatAgent"]), + next: z.enum(["weatherAgent", "chatAgent", "cryptoAgent"]), }); export type RouterDecision = z.infer; diff --git a/backend/prompt/system.ts b/backend/prompt/system.ts index 9364054..45c8d16 100644 --- a/backend/prompt/system.ts +++ b/backend/prompt/system.ts @@ -33,6 +33,7 @@ export const ROUTER_AGENT_PROMPT = `You are a router. Inspect the latest user me Output a single JSON object with one field: - next: "weatherAgent" — the message is about weather (current conditions, forecast, temperature, rain, snow, humidity, wind, etc. for a place). +- next: "cryptoAgent" — the message is about cryptocurrency (price, buy, sell, BTC, ETH, market cap, sparkline, 加密货币, 价格, 买入, 卖出, 币, crypto, coin, token, etc.). - next: "chatAgent" — anything else. General questions, coding, translation, brainstorming, chitchat, etc. Do not answer the user's question. Do not include any field besides \`next\`.`; @@ -68,3 +69,21 @@ export const WEATHER_AGENT_PROMPT = `You answer weather questions by calling too 4. Reply in one short sentence naming the place and the current condition. The widget already shows temperatures and forecast — do not repeat them, list days, or generate tables. On any tool returning {success: false} or an error, ask the user for the missing piece (a different spelling, a place name, location permission). Never invent coordinates or guess the location.`; + +// Dropped into the crypto sub-agent node. Mirrors the weather prompt's +// one-tool-per-turn discipline so the human-in-the-loop card (ask_crypto_intent) +// never races another tool result. The flow is: collect intent → fetch +// latest price → finalize the simulated order. "Orders" are simulated — +// the tool returns a fake receipt, no signing, no chain. +export const CRYPTO_AGENT_PROMPT = `You answer crypto questions by calling tools, not from your own knowledge. Run these steps in order, one tool per turn. + +1. ask_crypto_intent — when the user wants to buy, sell, or place a trade and the message did not already include a coin + amount. The card pauses the turn; you will be resumed with a HumanMessage carrying the pick {coin_id, coin_symbol, amount, currency, side} or {error}. Do not batch any other tool in this turn. If the user only wants a price check (no trade), skip this step and go straight to get_crypto_price. + + Currency detection: read the user's message and figure out which ISO 4217 currency they are thinking in. Pass it to the tool as \`currency\`. Signals: 元 / RMB / ¥ / 人民币 / CNY → CNY. $ / USD / dollar → USD. € / EUR / euro → EUR. £ / GBP / pound → GBP. ¥ / JPY / 日元 → JPY (note: ¥ alone is ambiguous — only treat as JPY if the user wrote JPY / 日元 / 日本円 alongside it, otherwise default to CNY). If the user named a specific amount, also pass it as \`amount\` to pre-fill the input. If the currency is unclear, omit the field and the card defaults to USD. + +2. get_crypto_price — with the ids from the intent, or whatever the user asked about. Pass multiple ids in one call when comparing. If the user named a coin by ticker (e.g. "BTC"), map it to the CoinGecko id (e.g. "bitcoin") before calling. +3. Convert to USD when the user picked a non-USD currency. If the resumed pick has \`currency\` != "USD", call get_fx_rate(currency, USD) and compute \`amount_usd = amount * rate\`. If currency is USD, skip this step and use the user's amount directly. +4. confirm_crypto_order — only when the user wants to trade AND you have a valid intent and a fresh price. Pass the converted \`amount_usd\` (never the original \`amount\`) and \`price_at_confirm\` from step 2. Do not batch it with anything else. If the user only wanted a price, skip this step and just answer. +5. Reply in one short sentence. The cards already show the numbers — do not repeat prices, sparkline, or order details. + +On any tool returning {success: false} or an error, ask the user to clarify (different coin, valid amount, retry). Never invent prices, quantities, fx rates, or order ids. CoinGecko's free tier rate-limits aggressively — if get_crypto_price keeps failing, tell the user to wait and try again.`; diff --git a/backend/state.ts b/backend/state.ts index a98734b..70fddbd 100644 --- a/backend/state.ts +++ b/backend/state.ts @@ -8,7 +8,7 @@ import { StateSchema, MessagesValue } from "@langchain/langgraph"; // different schemas → wrapper node transforms messages in/out). export const RouterAgentState = new StateSchema({ messages: MessagesValue, - routerDecision: z.object({ next: z.enum(["weatherAgent", "chatAgent"]) }), + routerDecision: z.object({ next: z.enum(["weatherAgent", "chatAgent", "cryptoAgent"]) }), }); // Shared by chat-agent and weather-agent. Only `messages` flows diff --git a/backend/tool/crypto/ask-crypto-intent.ts b/backend/tool/crypto/ask-crypto-intent.ts new file mode 100644 index 0000000..260bd19 --- /dev/null +++ b/backend/tool/crypto/ask-crypto-intent.ts @@ -0,0 +1,38 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { interrupt } from "@langchain/langgraph"; + +export const ASK_CRYPTO_INTENT_TOOL_NAME = "ask_crypto_intent"; + +// ask_crypto_intent is a pure trigger. The tool pauses via interrupt; the +// frontend's addResult resumes with {coin_id, coin_symbol, amount, currency, side} +// or {error}, which becomes the ToolMessage content the LLM reads next pass. +// Shape mirrors AskCryptoIntentResult in components/tool-ui/crypto. +export const askCryptoIntentTool = tool( + async ({ message = "Which crypto and how much?" } = {}) => { + return interrupt({ ui: ASK_CRYPTO_INTENT_TOOL_NAME, data: {}, message }); + }, + { + name: ASK_CRYPTO_INTENT_TOOL_NAME, + description: `Render a buy/sell form card so the user can pick a coin and amount. Use this whenever the agent needs a trade intent to proceed — typically because the user asked to buy, sell, or "go long" a coin. Do NOT batch other tool calls in the same turn; the card pauses the turn until the user replies. Call this at most once per turn. + +Detect the user's currency from the message (元/RMB/CNY/¥ → CNY, $/USD → USD, €/EUR, £/GBP, ¥/JPY) and pass it as \`currency\`. If the user named a specific amount, pass it as \`amount\` to pre-fill the input. If the currency is ambiguous, omit the field and the card will default to USD.`, + schema: z.object({ + message: z.string().optional().describe("Short prompt shown above the form; one sentence."), + currency: z + .string() + .length(3) + .optional() + .describe( + "ISO 4217 currency code the user is thinking in (e.g. 'USD', 'CNY', 'EUR', 'JPY', 'GBP'). Detected from the message — 元/RMB → CNY, $/USD → USD, etc. Card displays the amount in this currency.", + ), + amount: z + .number() + .positive() + .optional() + .describe( + "Pre-fill notional in the detected currency, only when the user named a specific amount. Card will pre-fill the amount input; the user can still edit.", + ), + }), + }, +); diff --git a/backend/tool/crypto/confirm-crypto-order.ts b/backend/tool/crypto/confirm-crypto-order.ts new file mode 100644 index 0000000..c4f1393 --- /dev/null +++ b/backend/tool/crypto/confirm-crypto-order.ts @@ -0,0 +1,62 @@ +import { randomUUID } from "node:crypto"; +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; + +// ponytail: simulated order — no signing, no chain, no RPC. The frontend +// card shows a "Simulated Filled" badge and a "view on Etherscan" link +// that's disabled. Upgrade path: swap body for wagmi useWriteContract +// calling a Uniswap V3 Router (needs RPC + Router address in env). + +export const confirmCryptoOrderTool = tool( + async ({ + coin_id, + coin_symbol, + amount_usd, + price_at_confirm, + side, + }: { + coin_id: string; + coin_symbol: string; + amount_usd: number; + price_at_confirm: number; + side: "buy" | "sell"; + }) => { + if (amount_usd <= 0) { + return JSON.stringify({ success: false, error: "amount_usd must be > 0" }); + } + if (price_at_confirm <= 0) { + return JSON.stringify({ success: false, error: "price_at_confirm must be > 0" }); + } + + const qty = amount_usd / price_at_confirm; + return JSON.stringify({ + success: true, + order: { + id: `ord_${randomUUID()}`, + coin: coin_id, + symbol: coin_symbol, + side, + amount_usd, + qty, + price_at_confirm, + status: "simulated_filled", + timestamp: new Date().toISOString(), + note: "This is a simulated order. No on-chain transaction was sent.", + }, + }); + }, + { + name: "confirm_crypto_order", + description: + "Finalize a simulated crypto order. Call only after ask_crypto_intent returned a valid pick and get_crypto_price gave the current price. Returns a fake order receipt — no signing, no chain.", + schema: z.object({ + coin_id: z.string().describe("CoinGecko coin id, e.g. 'bitcoin'."), + coin_symbol: z.string().describe("Ticker, e.g. 'BTC'."), + amount_usd: z.number().describe("Notional in USD the user wants to trade."), + price_at_confirm: z + .number() + .describe("Spot price observed from the most recent get_crypto_price call."), + side: z.enum(["buy", "sell"]).describe("Trade direction."), + }), + }, +); diff --git a/backend/tool/crypto/get-crypto-price.ts b/backend/tool/crypto/get-crypto-price.ts new file mode 100644 index 0000000..ec45b93 --- /dev/null +++ b/backend/tool/crypto/get-crypto-price.ts @@ -0,0 +1,91 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; + +const COINGECKO_BASE = "https://api.coingecko.com/api/v3"; + +// ponytail: 60s in-memory cache, key = sorted ids + vs_currency. CoinGecko +// free tier is ~10-30 req/min; agents that loop on the same query would +// blow through that without this. Upgrade path: Redis when we add #50. +const cache = new Map(); +const CACHE_TTL_MS = 60_000; + +type RawCoin = { + id: string; + symbol: string; + name: string; + image: string; + current_price: number; + market_cap: number; + market_cap_rank: number; + total_volume: number; + price_change_percentage_24h: number; + sparkline_in_7d: { price: number[] }; +}; + +type Coin = { + id: string; + symbol: string; + name: string; + image: string; + current_price: number; + market_cap: number; + market_cap_rank: number; + total_volume: number; + price_change_percentage_24h: number; + sparkline: number[]; +}; + +function normalize(raw: RawCoin): Coin { + return { + id: raw.id, + symbol: raw.symbol.toUpperCase(), + name: raw.name, + image: raw.image, + current_price: raw.current_price, + market_cap: raw.market_cap, + market_cap_rank: raw.market_cap_rank, + total_volume: raw.total_volume, + price_change_percentage_24h: raw.price_change_percentage_24h, + sparkline: raw.sparkline_in_7d?.price ?? [], + }; +} + +export const getCryptoPriceTool = tool( + async ({ ids, vs_currency = "usd" }: { ids: string[]; vs_currency?: string }) => { + const cacheKey = [...ids].sort().join(",") + "|" + vs_currency; + const cached = cache.get(cacheKey); + if (cached && cached.exp > Date.now()) return cached.value; + + const params = new URLSearchParams({ + vs_currency, + ids: ids.join(","), + sparkline: "true", + price_change_percentage: "24h", + }); + const url = `${COINGECKO_BASE}/coins/markets?${params.toString()}`; + + const res = await fetch(url, { headers: { Accept: "application/json" } }); + if (!res.ok) { + return JSON.stringify({ success: false, error: `coingecko ${res.status}` }); + } + const data = (await res.json()) as RawCoin[]; + const payload = JSON.stringify({ + success: true, + coins: data.map(normalize), + }); + cache.set(cacheKey, { exp: Date.now() + CACHE_TTL_MS, value: payload }); + return payload; + }, + { + name: "get_crypto_price", + description: + "Fetch current price, 24h change, 7-day sparkline, and market cap for one or more coins. Uses CoinGecko's public API (no key). ids are CoinGecko coin ids (e.g. 'bitcoin', 'ethereum', 'solana').", + schema: z.object({ + ids: z.array(z.string()).min(1).describe("CoinGecko coin ids, e.g. ['bitcoin', 'ethereum']"), + vs_currency: z + .string() + .default("usd") + .describe("Quote currency code, e.g. 'usd', 'cny', 'eur'. Defaults to 'usd'."), + }), + }, +); diff --git a/backend/tool/crypto/get-fx-rate.ts b/backend/tool/crypto/get-fx-rate.ts new file mode 100644 index 0000000..f1bac68 --- /dev/null +++ b/backend/tool/crypto/get-fx-rate.ts @@ -0,0 +1,58 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; + +const FRANKFURTER_BASE = "https://api.frankfurter.app"; + +// ponytail: 60s in-memory cache, same pattern as get_crypto_price. ECB +// updates once a business day; agents that loop on the same pair would +// hammer frankfurter for nothing. +const cache = new Map(); +const CACHE_TTL_MS = 60_000; + +type FrankfurterResponse = { + amount: number; + base: string; + date: string; + rates: Record; +}; + +export const getFxRateTool = tool( + async ({ from, to }: { from: string; to: string }) => { + const fromU = from.toUpperCase(); + const toU = to.toUpperCase(); + const cacheKey = `${fromU}|${toU}`; + const cached = cache.get(cacheKey); + if (cached && cached.exp > Date.now()) return cached.value; + + const url = `${FRANKFURTER_BASE}/latest?from=${encodeURIComponent(fromU)}&to=${encodeURIComponent(toU)}`; + const res = await fetch(url, { headers: { Accept: "application/json" } }); + if (!res.ok) { + const payload = JSON.stringify({ success: false, error: `frankfurter ${res.status}` }); + cache.set(cacheKey, { exp: Date.now() + CACHE_TTL_MS, value: payload }); + return payload; + } + const data = (await res.json()) as FrankfurterResponse; + const rate = data.rates[toU]; + if (typeof rate !== "number") { + return JSON.stringify({ success: false, error: `no rate for ${toU}` }); + } + const payload = JSON.stringify({ + success: true, + from: fromU, + to: toU, + rate, + date: data.date, + }); + cache.set(cacheKey, { exp: Date.now() + CACHE_TTL_MS, value: payload }); + return payload; + }, + { + name: "get_fx_rate", + description: + "Look up the current FX rate between two ISO 4217 currency codes (e.g. 'USD', 'CNY', 'EUR', 'JPY', 'GBP'). Returns the rate and the ECB date stamp. Uses frankfurter.app — free, no key, ECB-sourced.", + schema: z.object({ + from: z.string().length(3).describe("Base currency code, e.g. 'USD', 'CNY'."), + to: z.string().length(3).describe("Quote currency code, e.g. 'USD', 'CNY'."), + }), + }, +); diff --git a/backend/tool/index.ts b/backend/tool/index.ts index 2110b30..85427f0 100644 --- a/backend/tool/index.ts +++ b/backend/tool/index.ts @@ -3,18 +3,43 @@ import { searchWeb } from "@/backend/tool/web-search"; import { askLocationTool } from "@/backend/tool/ask-location"; import { geocodeLocationTool } from "@/backend/tool/geocode"; import { getWeatherTool } from "@/backend/tool/fetch-weather"; +import { askCryptoIntentTool } from "@/backend/tool/crypto/ask-crypto-intent"; +import { getCryptoPriceTool } from "@/backend/tool/crypto/get-crypto-price"; +import { getFxRateTool } from "@/backend/tool/crypto/get-fx-rate"; +import { confirmCryptoOrderTool } from "@/backend/tool/crypto/confirm-crypto-order"; // ponytail: keep the tool list in one place so the graph binds it from a // single source. Adding a tool = drop a file + add one line here. export const WEATHER_TOOLS = [askLocationTool, geocodeLocationTool, getWeatherTool]; +export const CRYPTO_TOOLS = [ + askCryptoIntentTool, + getCryptoPriceTool, + getFxRateTool, + confirmCryptoOrderTool, +]; + export const ALL_TOOLS = [ fetchUrl, searchWeb, askLocationTool, geocodeLocationTool, getWeatherTool, + askCryptoIntentTool, + getCryptoPriceTool, + getFxRateTool, + confirmCryptoOrderTool, ]; -export { fetchUrl, searchWeb, askLocationTool, geocodeLocationTool, getWeatherTool }; +export { + fetchUrl, + searchWeb, + askLocationTool, + geocodeLocationTool, + getWeatherTool, + askCryptoIntentTool, + getCryptoPriceTool, + getFxRateTool, + confirmCryptoOrderTool, +}; From 465fee642de6afd0b02e5a66590d2dab1751cbf9 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Fri, 26 Jun 2026 00:58:32 +0800 Subject: [PATCH 02/35] feat(frontend): wagmi web3 + crypto cards + wallet picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display-only wagmi setup (no signing, no chain) used by the crypto sub-agent's buy-intent card. Web3 (app/web3-providers.tsx + lib/wagmi.ts): - WagmiProvider with mainnet + injected connector, http transport. - Lazy-init QueryClient + wagmi config via useState to survive React strict-mode double-invoke (wagmi 3.x). - Top-level mounted in app/layout.tsx via Web3Providers. Cards (components/tool-ui/crypto/): - price-card: CoinGecko data with inline SVG sparkline, 24h change pill, friendly formatPrice(). - ask-crypto-intent-card: buy/sell toggle, coin select, amount input. Currency label is LLM-detected (e.g. "AMOUNT (CNY)") with the LLM's hinted amount pre-filled. Amount input strips leading minus so the value is never negative. Resume payload shape: {coin_id, coin_symbol, amount, currency, side}. - order-receipt-card: SIMULATED badge, qty / price / total / time grid, order id footer. Etherscan link is disabled. - connect-wallet-dialog (new): Radix Dialog listing every wagmi connector. Order button now reads "Connect & buy BTC" when no wallet is connected and "Confirm buy BTC" once connected. Resume is queued in component state and flushed when isConnected flips. Suggestion chip: "Buy $100 of Bitcoin" added to the opening picks in app/assistant.tsx. Toolkit (components/tool-ui/toolkit.tsx): merged weatherToolkit + cryptoToolkit (ask_crypto_intent, get_crypto_price, confirm_crypto_order) into a single default export. Chore: gitignore /.verify (Chrome DevTools MCP screenshots). package.json: add wagmi@^3.6.20, viem@^2.53.1. Skipped RainbowKit: it's pinned to wagmi 2.x and we're on 3.6.20. Custom Radix-Dialog picker is ~120 lines, no new deps, and the user has no MetaMask in the test environment anyway. 177/177 tests pass. Chrome DevTools verified both USD ("$100") and CNY ("100 元") flows — LLM correctly detects currency, card labels match, wallet dialog opens on order. --- .gitignore | 3 + app/assistant.tsx | 5 + app/layout.tsx | 5 +- app/web3-providers.tsx | 28 ++ .../tool-ui/crypto/ask-crypto-intent-card.tsx | 275 ++++++++++++++++++ .../tool-ui/crypto/connect-wallet-dialog.tsx | 92 ++++++ components/tool-ui/crypto/index.ts | 3 + .../tool-ui/crypto/order-receipt-card.tsx | 145 +++++++++ components/tool-ui/crypto/price-card.tsx | 148 ++++++++++ components/tool-ui/toolkit.tsx | 44 ++- lib/wagmi.ts | 19 ++ package.json | 2 + 12 files changed, 764 insertions(+), 5 deletions(-) create mode 100644 app/web3-providers.tsx create mode 100644 components/tool-ui/crypto/ask-crypto-intent-card.tsx create mode 100644 components/tool-ui/crypto/connect-wallet-dialog.tsx create mode 100644 components/tool-ui/crypto/index.ts create mode 100644 components/tool-ui/crypto/order-receipt-card.tsx create mode 100644 components/tool-ui/crypto/price-card.tsx create mode 100644 lib/wagmi.ts diff --git a/.gitignore b/.gitignore index b1a1c34..eb81cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ yarn-error.log* # vercel .vercel +# local visual verification screenshots +/.verify + # typescript *.tsbuildinfo next-env.d.ts diff --git a/app/assistant.tsx b/app/assistant.tsx index 6bec12f..c8641c0 100644 --- a/app/assistant.tsx +++ b/app/assistant.tsx @@ -248,6 +248,11 @@ export function Assistant() { label: "", prompt: "What’s the weather like at today?", }, + { + title: "Buy $100 of Bitcoin", + label: "", + prompt: "I want to buy $100 of Bitcoin.", + }, ]), }); diff --git a/app/layout.tsx b/app/layout.tsx index dc259eb..d7c7b3e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { TooltipProvider } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/sonner"; import { AuthShell } from "@/app/auth-shell"; +import { Web3Providers } from "@/app/web3-providers"; import { APP_NAME } from "@/lib/constants"; import "./globals.css"; @@ -30,7 +31,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/app/web3-providers.tsx b/app/web3-providers.tsx new file mode 100644 index 0000000..010cbc6 --- /dev/null +++ b/app/web3-providers.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { WagmiProvider, createConfig, http } from "wagmi"; +import { mainnet } from "wagmi/chains"; +import { injected } from "wagmi/connectors"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// ponytail: wagmi is display-only here — no writeContract, no signing. The +// crypto agent's orders are simulated; wagmi only surfaces address + balance +// in the buy-intent card. Lazy-init the QueryClient so React's strict-mode +// double-invoke doesn't share state across requests. +export function Web3Providers({ children }: { children: ReactNode }) { + const [config] = useState(() => + createConfig({ + chains: [mainnet], + connectors: [injected({ shimDisconnect: true })], + transports: { [mainnet.id]: http() }, + ssr: true, + }), + ); + const [queryClient] = useState(() => new QueryClient()); + return ( + + {children} + + ); +} diff --git a/components/tool-ui/crypto/ask-crypto-intent-card.tsx b/components/tool-ui/crypto/ask-crypto-intent-card.tsx new file mode 100644 index 0000000..1c947a5 --- /dev/null +++ b/components/tool-ui/crypto/ask-crypto-intent-card.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState } from "react"; +import { + AlertCircleIcon, + CheckCircle2Icon, + CoinsIcon, + Loader2Icon, + WalletIcon, +} from "lucide-react"; +import { formatUnits } from "viem"; +import { useAccount, useBalance } from "wagmi"; +import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; + +import { Button } from "@/components/ui/button"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; +import { ConnectWalletDialog } from "@/components/tool-ui/crypto/connect-wallet-dialog"; +import { cn } from "@/lib/utils"; + +// Tool result the user picks from the card. Mirrors the backend tool's +// resume payload (backend/tool/crypto/ask-crypto-intent.ts). +// { coin_id, coin_symbol, amount, currency, side } — user confirmed +// { error } — user cancelled +// The LLM detects the currency from the message and passes it through +// `args.currency`; the card lets the user confirm or change the amount +// in that currency, then the LLM converts to USD before confirm_crypto_order. +export type AskCryptoIntentResult = + | { + coin_id: string; + coin_symbol: string; + amount: number; + currency: string; + side: "buy" | "sell"; + } + | { error: string }; + +// Tool call args the LLM fills when invoking ask_crypto_intent. The card +// reads currency (required, defaults to USD) and amount (optional pre-fill). +type AskCryptoIntentArgs = { + message?: string; + currency?: string; + amount?: number; +}; + +// Hardcoded list of coins the card can resolve. CoinGecko ids are stable; +// mapping ticker → id is the only piece the LLM has to be careful about. +// Upgrade path: pull this from CoinGecko's /coins/list when we add a search box. +const COIN_OPTIONS = [ + { coin_id: "bitcoin", symbol: "BTC", name: "Bitcoin" }, + { coin_id: "ethereum", symbol: "ETH", name: "Ethereum" }, + { coin_id: "solana", symbol: "SOL", name: "Solana" }, + { coin_id: "binancecoin", symbol: "BNB", name: "BNB" }, + { coin_id: "dogecoin", symbol: "DOGE", name: "Dogecoin" }, + { coin_id: "usd-coin", symbol: "USDC", name: "USD Coin" }, +] as const; + +type Mode = "idle" | "submitting"; + +export const AskCryptoIntentCard: ToolCallMessagePartComponent = ({ + result, + args, +}) => { + const parsed = unwrapToolResult(result); + const sendCommand = useLangGraphSendCommand(); + const { address, isConnected } = useAccount(); + const { data: balance } = useBalance({ address }); + + // LLM detected currency from the message; default to USD if missing. + const currency = (args?.currency ?? "USD").toUpperCase(); + const [coinId, setCoinId] = useState(COIN_OPTIONS[0].coin_id); + const [amount, setAmount] = useState(args?.amount != null ? String(args.amount) : "100"); + const [side, setSide] = useState<"buy" | "sell">("buy"); + const [mode, setMode] = useState("idle"); + // Wallet connection is part of the order flow. If the user clicks + // Confirm without a connected wallet, we open the connector dialog + // and queue the resume until they connect. `pendingResume` captures + // the current form so we can re-emit it once isConnected flips. + const [showConnect, setShowConnect] = useState(false); + const [pendingResume, setPendingResume] = useState(null); + + const coin = COIN_OPTIONS.find((c) => c.coin_id === coinId) ?? COIN_OPTIONS[0]; + const numericAmount = Number.parseFloat(amount); + const isValid = Number.isFinite(numericAmount) && numericAmount > 0; + + const resume = (payload: AskCryptoIntentResult) => { + sendCommand({ resume: JSON.stringify(payload) }); + }; + + const buildResume = (): AskCryptoIntentResult => ({ + coin_id: coin.coin_id, + coin_symbol: coin.symbol, + amount: numericAmount, + currency, + side, + }); + + const handleConfirm = () => { + if (!isValid) return; + if (!isConnected) { + // Queue the resume; the dialog's onConnected will flush it. + setPendingResume(buildResume()); + setShowConnect(true); + return; + } + setMode("submitting"); + resume(buildResume()); + }; + + const handleConnected = () => { + if (pendingResume) { + setMode("submitting"); + resume(pendingResume); + setPendingResume(null); + } + }; + + const handleCancel = () => { + setMode("submitting"); + resume({ error: "User cancelled" }); + }; + + return ( +
+
+
+
+ +
+
+

Place a simulated trade

+

+ {parsed && "coin_id" in parsed + ? "Sent to the assistant." + : "Pick a coin and amount. No on-chain transaction is sent."} +

+
+
+ + {/* Resolved: show the chosen pick as a confirmation, no more actions. */} + {parsed && "coin_id" in parsed && ( +
+ +
+

+ {parsed.side === "buy" ? "Buy" : "Sell"} {parsed.coin_symbol} +

+

+ {parsed.amount.toFixed(2)} {parsed.currency} notional +

+
+
+ )} + + {parsed && "error" in parsed && ( +
+ + {parsed.error} +
+ )} + + {/* Interactive: only when user hasn't decided yet. */} + {!parsed && ( +
+
+ {(["buy", "sell"] as const).map((s) => ( + + ))} +
+ + + + + + {/* Wallet status — only shows the connected address. The order + button handles the "not connected" case by opening the picker. */} + {isConnected && address ? ( +
+ + + {address.slice(0, 6)}…{address.slice(-4)} + {balance + ? ` · ${formatUnits(balance.value, balance.decimals)} ${balance.symbol}` + : ""} + +
+ ) : null} + +
+ + +
+
+ )} +
+ +
+ ); +}; diff --git a/components/tool-ui/crypto/connect-wallet-dialog.tsx b/components/tool-ui/crypto/connect-wallet-dialog.tsx new file mode 100644 index 0000000..e4b0a6c --- /dev/null +++ b/components/tool-ui/crypto/connect-wallet-dialog.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect } from "react"; +import { Loader2Icon, WalletIcon } from "lucide-react"; +import { useConnect } from "wagmi"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +// Modal that lists every wagmi connector and lets the user pick one. +// `onConnected` fires once isConnected flips true so the caller can +// resume its own flow (e.g. submit the order that triggered the modal). +export function ConnectWalletDialog({ + open, + onOpenChange, + onConnected, +}: { + open: boolean; + onOpenChange: (next: boolean) => void; + onConnected?: () => void; +}) { + const { connectors, connect, isPending, error } = useConnect(); + + useEffect(() => { + if (!isPending && !error && open) { + // wagmi sets isConnected on success; the parent effect resumes + // its flow. We just clear the modal here once the wallet call + // settles (success path is handled by the parent watching + // isConnected; we only auto-close on user-initiated close). + } + }, [isPending, error, open]); + + return ( + + + + Connect a wallet + + Pick a wallet to sign in. Orders are still simulated — no on-chain transaction is sent. + + + +
    + {connectors.length === 0 ? ( +
  • No wallet providers detected.
  • + ) : ( + connectors.map((c) => ( +
  • + +
  • + )) + )} +
+ + {error ? ( +

Connection failed: {error.message}

+ ) : null} +
+
+ ); +} diff --git a/components/tool-ui/crypto/index.ts b/components/tool-ui/crypto/index.ts new file mode 100644 index 0000000..2508a81 --- /dev/null +++ b/components/tool-ui/crypto/index.ts @@ -0,0 +1,3 @@ +export { CryptoPriceCard } from "@/components/tool-ui/crypto/price-card"; +export { AskCryptoIntentCard } from "@/components/tool-ui/crypto/ask-crypto-intent-card"; +export { CryptoOrderReceiptCard } from "@/components/tool-ui/crypto/order-receipt-card"; diff --git a/components/tool-ui/crypto/order-receipt-card.tsx b/components/tool-ui/crypto/order-receipt-card.tsx new file mode 100644 index 0000000..5bbceb1 --- /dev/null +++ b/components/tool-ui/crypto/order-receipt-card.tsx @@ -0,0 +1,145 @@ +"use client"; + +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { CheckCircle2Icon, InfoIcon, WalletIcon } from "lucide-react"; + +import { ToolCardSkeleton } from "@/components/tool-ui/tool-card-skeleton"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; +import { cn } from "@/lib/utils"; + +type Order = { + id: string; + coin: string; + symbol: string; + side: "buy" | "sell"; + amount_usd: number; + qty: number; + price_at_confirm: number; + status: string; + timestamp: string; + note: string; +}; + +type Result = { success: true; order: Order } | { success: false; error: string }; + +type Args = { + coin_id: string; + coin_symbol: string; + amount_usd: number; + price_at_confirm: number; + side: "buy" | "sell"; +}; + +function parse(raw: unknown) { + const obj = unwrapToolResult(raw); + if (!obj) return { kind: "loading" as const }; + if (obj.success === true) return { kind: "ok" as const, order: obj.order }; + if (obj.success === false) return { kind: "error" as const, message: obj.error }; + return { kind: "loading" as const }; +} + +function formatUsd(n: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }).format(n); +} + +function formatQty(n: number) { + return n.toFixed(n < 1 ? 6 : 4); +} + +function formatTime(iso: string) { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +export const CryptoOrderReceiptCard: ToolCallMessagePartComponent = ({ + result, + args, +}) => { + const parsed = parse(result); + + if (parsed.kind === "loading") { + return ; + } + if (parsed.kind === "error") { + return
Order failed: {parsed.message}
; + } + + const o = parsed.order; + return ( +
+
+
+
+ +
+
+

+ {o.side === "buy" ? "Bought" : "Sold"} {o.symbol} +

+

Simulated fill — no on-chain tx sent

+
+ + SIMULATED + +
+ +
+
+
Quantity
+
+ {formatQty(o.qty)} {o.symbol} +
+
+
+
Price
+
{formatUsd(o.price_at_confirm)}
+
+
+
Total
+
{formatUsd(o.amount_usd)}
+
+
+
Time
+
{formatTime(o.timestamp)}
+
+
+ +
+
+ order id: + {o.id} +
+
+ + {o.note} +
+ {args ? ( +
+ + + Connect a wallet + mainnet to enable real swaps (disabled in simulated mode). + +
+ ) : null} +
+
+
+ ); +}; diff --git a/components/tool-ui/crypto/price-card.tsx b/components/tool-ui/crypto/price-card.tsx new file mode 100644 index 0000000..efddd22 --- /dev/null +++ b/components/tool-ui/crypto/price-card.tsx @@ -0,0 +1,148 @@ +"use client"; + +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; + +import { ToolCardSkeleton } from "@/components/tool-ui/tool-card-skeleton"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; +import { cn } from "@/lib/utils"; + +type Coin = { + id: string; + symbol: string; + name: string; + image: string; + current_price: number; + market_cap: number; + market_cap_rank: number; + price_change_percentage_24h: number; + sparkline: number[]; +}; + +type Result = { success: true; coins: Coin[] } | { success: false; error: string }; + +type Args = { ids: string[]; vs_currency?: string }; + +type Parsed = + | { kind: "loading" } + | { kind: "ok"; coins: Coin[]; vs_currency: string } + | { kind: "error"; message: string }; + +function parse(raw: unknown, vsCurrency: string): Parsed { + const obj = unwrapToolResult(raw); + if (!obj) return { kind: "loading" }; + if (obj.success === true) return { kind: "ok", coins: obj.coins, vs_currency: vsCurrency }; + if (obj.success === false) return { kind: "error", message: obj.error }; + return { kind: "loading" }; +} + +function formatPrice(value: number, currency: string): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + maximumFractionDigits: value < 1 ? 6 : 2, + }).format(value); +} + +function formatPct(pct: number): string { + const sign = pct > 0 ? "+" : ""; + return `${sign}${pct.toFixed(2)}%`; +} + +function Sparkline({ values, positive }: { values: number[]; positive: boolean }) { + if (!values || values.length < 2) return null; + const w = 80; + const h = 24; + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const stepX = w / (values.length - 1); + const points = values + .map((v, i) => `${(i * stepX).toFixed(1)},${(h - ((v - min) / range) * h).toFixed(1)}`) + .join(" "); + return ( + + + + ); +} + +export const CryptoPriceCard: ToolCallMessagePartComponent = ({ result, args }) => { + const vsCurrency = args?.vs_currency ?? "usd"; + const parsed = parse(result, vsCurrency); + + if (parsed.kind === "loading") { + return ; + } + if (parsed.kind === "error") { + return ( +
Couldn't fetch prices: {parsed.message}
+ ); + } + if (parsed.coins.length === 0) { + return
No coins matched those ids.
; + } + + return ( +
+
    + {parsed.coins.map((c) => { + const positive = c.price_change_percentage_24h >= 0; + return ( +
  • + {c.image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
    +
    + {c.symbol} + {c.name} +
    +
    + rank #{c.market_cap_rank} +
    +
    + +
    +
    + {formatPrice(c.current_price, parsed.vs_currency)} +
    +
    + {positive ? ( + + ) : ( + + )} + {formatPct(c.price_change_percentage_24h)} +
    +
    +
  • + ); + })} +
+
+ ); +}; diff --git a/components/tool-ui/toolkit.tsx b/components/tool-ui/toolkit.tsx index 7e70f23..deb8f82 100644 --- a/components/tool-ui/toolkit.tsx +++ b/components/tool-ui/toolkit.tsx @@ -5,11 +5,16 @@ import { z } from "zod"; import { AskLocationCard } from "@/components/tool-ui/ask-location/ask-location-card"; import { WeatherCard } from "@/components/tool-ui/weather/weather-card"; +import { + AskCryptoIntentCard, + CryptoOrderReceiptCard, + CryptoPriceCard, +} from "@/components/tool-ui/crypto"; // Frontend-side tool registrations. `execute` lives on the LangGraph -// backend (backend/tool/weather-tools.ts) and is dispatched via -// useLangGraphRuntime — these `render` callbacks only attach the -// matching UI to the tool-call message part. +// backend (backend/tool/) and is dispatched via useLangGraphRuntime — +// these `render` callbacks only attach the matching UI to the +// tool-call message part. const weatherToolkit = defineToolkit({ ask_location: { @@ -36,4 +41,35 @@ const weatherToolkit = defineToolkit({ }, }); -export default weatherToolkit; +const cryptoToolkit = defineToolkit({ + ask_crypto_intent: { + description: + "Render a buy/sell form card. Pauses the turn; the form's submit resumes with the pick.", + parameters: z.object({}), + render: AskCryptoIntentCard, + }, + get_crypto_price: { + description: "Fetch and render a price card for one or more coins.", + parameters: z.object({ + ids: z.array(z.string()), + vs_currency: z.string().optional(), + }), + render: CryptoPriceCard, + }, + confirm_crypto_order: { + description: "Render a simulated order receipt.", + parameters: z.object({ + coin_id: z.string(), + coin_symbol: z.string(), + amount_usd: z.number(), + price_at_confirm: z.number(), + side: z.enum(["buy", "sell"]), + }), + render: CryptoOrderReceiptCard, + }, +}); + +export default defineToolkit({ + ...weatherToolkit, + ...cryptoToolkit, +}); diff --git a/lib/wagmi.ts b/lib/wagmi.ts new file mode 100644 index 0000000..e3e2b01 --- /dev/null +++ b/lib/wagmi.ts @@ -0,0 +1,19 @@ +"use client"; + +import { createConfig, http } from "wagmi"; +import { mainnet } from "wagmi/chains"; +import { injected } from "wagmi/connectors"; + +// ponytail: display-only wagmi config. No writeContract, no sendTransaction — +// the crypto agent's "orders" are simulated (see backend/tool/crypto/confirm-crypto-order.ts). +// We use wagmi purely to surface the user's address + balance in the buy-intent card. +// Upgrade path: add useWriteContract for a real DEX swap (needs RPC + Router address in env). + +export const wagmiConfig = createConfig({ + chains: [mainnet], + connectors: [injected({ shimDisconnect: true })], + transports: { + [mainnet.id]: http(), + }, + ssr: true, +}); diff --git a/package.json b/package.json index fe2a970..ce5ef65 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "tw-animate-css": "^1.4.0", "tw-shimmer": "^0.4.11", "ufo": "^1.6.4", + "viem": "^2.53.1", + "wagmi": "^3.6.20", "zod": "^4.4.3", "zustand": "^5.0.14" }, From 58fdd5cdf6f0e7b9b031b58f81d75f0f3d379ca0 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Fri, 26 Jun 2026 00:58:42 +0800 Subject: [PATCH 03/35] test(crypto): backend tools + frontend card + router regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (tests/backend/tool/crypto/): - ask-crypto-intent.test.ts: pauses with ui: ask_crypto_intent, forwards pick and error, defaults message, accepts currency + amount pre-fill, makes no HTTP calls. - get-crypto-price.test.ts: hits CoinGecko with vs_currency=usd by default, accepts custom vs_currency, propagates 4xx/5xx as serialized error result, normalizes sparkline_in_7d → sparkline. - get-fx-rate.test.ts (new): calls frankfurter, uppercases codes for cache key stability, returns same payload on cache hit, propagates 4xx/5xx. - confirm-crypto-order.test.ts: generates qty from amount_usd / price_at_confirm, rejects non-positive inputs, includes UUID order id + ISO timestamp. Frontend (tests/frontend/crypto/ask-crypto-intent-card.test.tsx): - amount input strips leading minus (100 → 100), keeps positives, allows empty. - currency detection: CNY / EUR labels render, defaults to USD when no currency passed. - amount pre-fills from the LLM's hint. - wallet flow: "Connect & buy BTC" when disconnected, "Confirm buy BTC" + truncated address when connected. Regression (tests/backend/node/router-agent-node.test.ts): - "routes crypto queries to cryptoAgent" — guards against the RouterDecisionSchema enum drifting from state.ts. - safeParse({ next: "cryptoAgent" }) succeeds. Topology (tests/backend/agent-topologies.test.ts): extended to cover cryptoAgent node presence in both USE_SUBGRAPH=true and inlined default graphs. docs/TODOS.md: new 2026-06-26 entry covering the v2 crypto agent work (currency detection + wallet picker). Records the skipped RainbowKit path with reasoning (wagmi 3.x peer-dep mismatch) and the deferred real-DEX-swap / wagmi 3.x hook migration follow-ups. 177/177 tests pass, lint clean. --- docs/TODOS.md | 50 +++++++ tests/backend/agent-topologies.test.ts | 3 + tests/backend/node/router-agent-node.test.ts | 11 ++ .../tool/crypto/ask-crypto-intent.test.ts | 96 +++++++++++++ .../tool/crypto/confirm-crypto-order.test.ts | 90 ++++++++++++ .../tool/crypto/get-crypto-price.test.ts | 112 +++++++++++++++ tests/backend/tool/crypto/get-fx-rate.test.ts | 72 ++++++++++ .../crypto/ask-crypto-intent-card.test.tsx | 135 ++++++++++++++++++ 8 files changed, 569 insertions(+) create mode 100644 tests/backend/tool/crypto/ask-crypto-intent.test.ts create mode 100644 tests/backend/tool/crypto/confirm-crypto-order.test.ts create mode 100644 tests/backend/tool/crypto/get-crypto-price.test.ts create mode 100644 tests/backend/tool/crypto/get-fx-rate.test.ts create mode 100644 tests/frontend/crypto/ask-crypto-intent-card.test.tsx diff --git a/docs/TODOS.md b/docs/TODOS.md index 894c8f2..64f9432 100644 --- a/docs/TODOS.md +++ b/docs/TODOS.md @@ -81,3 +81,53 @@ Tracked as PR #1 review-comment follow-ups; none are blocking merge. **Dependency**: `@assistant-ui/react-o11y@0.0.24` already installed (uncommitted). Pin a patch in `patches/` once the public API stops moving (it's `0.0.x`, experimental). + +## 2026-06-26 — Crypto agent v2: currency + wallet UI + +Follow-up to the 2026-06-25 crypto agent. Two new requirements: + +1. **Wallet selection UI**. "Order 按钮点击下去, 是唤醒钱包, 能够选择钱包". User pointed out the existing card had a tiny "Connect wallet (optional)" link; they want the order button to *open a wallet picker*. wagmi is headless (no built-in UI); RainbowKit is the canonical answer but its 2.x line is pinned to wagmi 2.x and we're on 3.6.20. Skipped RainbowKit, built a Radix-Dialog-based connector picker using the existing `injected` connector. `components/tool-ui/crypto/connect-wallet-dialog.tsx`. Order button is now `"Connect & buy BTC"` when no wallet is connected, `"Confirm buy BTC"` once connected. Resume is queued in component state and flushed when `isConnected` flips. No new deps. + +2. **Currency detection**. "现在好像说的是买 100 RMB 的加密货币, 会被认为是 USD". Old schema was `amount_usd: number` — the LLM was passing 100 as `amount_usd` regardless of the user's actual currency. New flow: + - `ask_crypto_intent` schema gains `currency` (3-char ISO) and `amount` (optional pre-fill) so the LLM signals what the user meant. + - LLM detects from message: 元/RMB/CNY/¥ → CNY, $/USD → USD, €/EUR, £/GBP, ¥/JPY (ambiguous — only JPY if user wrote JPY/日元/日本円 alongside it, else default to CNY). Rules live in `CRYPTO_AGENT_PROMPT`. + - Card shows the detected currency in the Amount label ("AMOUNT (CNY)") and pre-fills the amount. No UI selector — the LLM is the source of truth. + - New `get_fx_rate` tool backed by `frankfurter.app` (free, no key, ECB). Cached 60s. + - After the card resumes, the LLM calls `get_fx_rate(currency, USD)` and converts before `confirm_crypto_order`. USD is a fast path (skip the FX lookup). + +**Tasks** (tracked in TaskList #12–#17): all completed. 177/177 tests pass, lint clean, Chrome DevTools verified both USD and CNY flows. + +**Skipped**: + +- **Real DEX swap**. User said "保持 simulated 的 ui" — keep simulated, just improve UX. Real on-chain would need a contract + `useWriteContract` + testnet ETH; out of scope. +- **wagmi 3.x migration of deprecated hooks** (`useAccount`/`useConnect` → `useConnection`/`useConnections`). Punted — the picker uses the old API which still works in 3.x. Do this when wagmi 3.x removes the old hooks or the warning goes red. +- **Adding `@coinbase/wallet-sdk`** for a real Coinbase connector. Optional peer of `@wagmi/connectors`; not needed for the demo since the user has no MetaMask anyway and the picker falls back to "no wallet providers detected" gracefully. + +## 2026-06-25 — Crypto agent (in progress) + +Branched off `feat-crypto`. Reuses weather agent's human-in-the-loop +pattern: `ask_crypto_intent` (interrupt) → `get_crypto_price` → +`confirm_crypto_order`. Price source: CoinGecko public API, no key. +"Orders" are **simulated** — wagmi is used for wallet display only, +not for signing. Upgrade path to real DEX swap is out of scope. + +**Tasks** (tracked in TaskList): + +1. TDD `askCryptoIntentTool` — interrupt-based, mirrors askLocationTool. +2. TDD `getCryptoPriceTool` — CoinGecko `/coins/markets`, 4xx/5xx → `{success:false, error}`. +3. TDD `confirmCryptoOrderTool` — generates simulated order id + qty. +4. Wire `CRYPTO_TOOLS` into `backend/tool/index.ts`. +5. Update `RouterAgentState` + `CRYPTO_AGENT_PROMPT` + `ROUTER_AGENT_PROMPT`. +6. TDD `cryptoAgent` subgraph + extend `agent-topologies.test.ts`. +7. Wire `cryptoAgent` into both `buildSubgraph()` and `buildInlined()`. +8. Add `wagmi` + `viem` + `@tanstack/react-query`, `WagmiProvider` in layout. +9. Build `components/tool-ui/crypto/{price-card,ask-crypto-intent-card,order-receipt-card,index}.tsx`. +10. Register `cryptoToolkit`, run `pnpm test` + `pnpm lint`, Chrome DevTools MCP visual verify. + +**Constraints** (re-confirmed before starting): + +- File layout: `backend/tool/crypto/*` (mirrors `components/tool-ui/crypto/*`). +- All crypto tools use `crypto_` prefix (`ask_crypto_intent`, `get_crypto_price`, `confirm_crypto_order`). +- Subgraph name `cryptoAgent`, inlined node names `cryptoModel` / `cryptoTools`. +- TDD mandatory per CLAUDE.md rule 2; backend tool coverage ≥ 90%. +- Visual verify per CLAUDE.md rule 4 (Chrome DevTools MCP). diff --git a/tests/backend/agent-topologies.test.ts b/tests/backend/agent-topologies.test.ts index c4e6a0f..69a90c0 100644 --- a/tests/backend/agent-topologies.test.ts +++ b/tests/backend/agent-topologies.test.ts @@ -13,6 +13,7 @@ describe("buildSubgraph", () => { expect(Object.keys(builder.nodes).sort()).toEqual([ "afterAgent", "chatAgent", + "cryptoAgent", "renameThreadAgent", "routerAgent", "weatherAgent", @@ -32,6 +33,8 @@ describe("buildInlined", () => { "afterAgent", "chatModel", "chatTools", + "cryptoModel", + "cryptoTools", "renameThreadAgent", "routerAgent", "weatherModel", diff --git a/tests/backend/node/router-agent-node.test.ts b/tests/backend/node/router-agent-node.test.ts index 933c03e..f571ec1 100644 --- a/tests/backend/node/router-agent-node.test.ts +++ b/tests/backend/node/router-agent-node.test.ts @@ -43,6 +43,16 @@ describe("routerAgentNode", () => { expect(result).toEqual({ routerDecision: { next: "weatherAgent" } }); }); + it("routes crypto queries to cryptoAgent", async () => { + mockInvokeStructured.mockResolvedValueOnce({ next: "cryptoAgent" }); + + const result = await routerAgentNode({ + messages: [new HumanMessage("我想买 100 美元的 BTC")], + }); + + expect(result).toEqual({ routerDecision: { next: "cryptoAgent" } }); + }); + it("prepends the router system prompt and strips any prior system messages", async () => { mockInvokeStructured.mockResolvedValueOnce({ next: "chatAgent" }); @@ -79,6 +89,7 @@ describe("routerAgentNode", () => { method: string; }; expect(schemaArg?.safeParse({ next: "weatherAgent" }).success).toBe(true); + expect(schemaArg?.safeParse({ next: "cryptoAgent" }).success).toBe(true); expect(schemaArg?.safeParse({ next: "bogus" }).success).toBe(false); expect(optionsArg).toEqual({ name: "route_decision", method: "jsonSchema" }); }); diff --git a/tests/backend/tool/crypto/ask-crypto-intent.test.ts b/tests/backend/tool/crypto/ask-crypto-intent.test.ts new file mode 100644 index 0000000..f6382ab --- /dev/null +++ b/tests/backend/tool/crypto/ask-crypto-intent.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { interrupt } from "@langchain/langgraph"; +import { askCryptoIntentTool } from "@/backend/tool/crypto/ask-crypto-intent"; + +vi.mock("@langchain/langgraph", async () => { + const actual = + await vi.importActual("@langchain/langgraph"); + return { + ...actual, + interrupt: vi.fn(), + }; +}); + +const fetchMock = vi.fn(); +const interruptMock = interrupt as unknown as ReturnType; + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + interruptMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("askCryptoIntentTool", () => { + it("pauses with ui: ask_crypto_intent and returns the resumed pick", async () => { + const pick = { + coin_id: "bitcoin", + coin_symbol: "BTC", + amount: 100, + currency: "USD", + side: "buy", + }; + interruptMock.mockReturnValue(pick); + + const result = await askCryptoIntentTool.invoke({ message: "What do you want to buy?" }); + + expect(interruptMock).toHaveBeenCalledWith({ + ui: "ask_crypto_intent", + data: {}, + message: "What do you want to buy?", + }); + expect(result).toEqual(pick); + }); + + it("forwards an error pick as the tool result", async () => { + const errorPick = { error: "User cancelled" }; + interruptMock.mockReturnValue(errorPick); + + const result = await askCryptoIntentTool.invoke({ message: "?" }); + + expect(result).toEqual(errorPick); + }); + + it("defaults the message when none is provided", async () => { + interruptMock.mockReturnValue({ + coin_id: "bitcoin", + coin_symbol: "BTC", + amount: 1, + currency: "USD", + side: "buy", + }); + await askCryptoIntentTool.invoke({}); + expect(interruptMock).toHaveBeenCalledWith( + expect.objectContaining({ ui: "ask_crypto_intent", message: expect.any(String) }), + ); + }); + + it("forwards the detected currency and pre-fill amount to the card", async () => { + interruptMock.mockReturnValue({ + coin_id: "bitcoin", + coin_symbol: "BTC", + amount: 100, + currency: "CNY", + side: "buy", + }); + await askCryptoIntentTool.invoke({ message: "?", currency: "CNY", amount: 100 }); + expect(interruptMock).toHaveBeenCalledWith( + expect.objectContaining({ message: "?", ui: "ask_crypto_intent" }), + ); + }); + + it("makes no HTTP calls", async () => { + interruptMock.mockReturnValue({ + coin_id: "bitcoin", + coin_symbol: "BTC", + amount: 1, + currency: "USD", + side: "buy", + }); + await askCryptoIntentTool.invoke({ message: "?" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/backend/tool/crypto/confirm-crypto-order.test.ts b/tests/backend/tool/crypto/confirm-crypto-order.test.ts new file mode 100644 index 0000000..25f4f42 --- /dev/null +++ b/tests/backend/tool/crypto/confirm-crypto-order.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { confirmCryptoOrderTool } from "@/backend/tool/crypto/confirm-crypto-order"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("confirmCryptoOrderTool", () => { + it("returns a simulated order with qty = amount_usd / price_at_confirm", async () => { + const out = await confirmCryptoOrderTool.invoke({ + coin_id: "bitcoin", + coin_symbol: "BTC", + amount_usd: 100, + price_at_confirm: 50000, + side: "buy", + }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(true); + expect(parsed.order).toMatchObject({ + coin: "bitcoin", + symbol: "BTC", + amount_usd: 100, + qty: 0.002, + price_at_confirm: 50000, + side: "buy", + status: "simulated_filled", + }); + expect(parsed.order.id).toMatch(/^ord_/); + expect(parsed.order.timestamp).toMatch(/T/); + expect(parsed.order.note).toMatch(/simulated/i); + }); + + it("supports the sell side", async () => { + const out = await confirmCryptoOrderTool.invoke({ + coin_id: "ethereum", + coin_symbol: "ETH", + amount_usd: 50, + price_at_confirm: 2500, + side: "sell", + }); + const parsed = JSON.parse(out as string); + expect(parsed.order.side).toBe("sell"); + expect(parsed.order.qty).toBe(0.02); + }); + + it("rejects a non-positive price with a structured error", async () => { + const out = await confirmCryptoOrderTool.invoke({ + coin_id: "bitcoin", + coin_symbol: "BTC", + amount_usd: 100, + price_at_confirm: 0, + side: "buy", + }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/price/); + }); + + it("rejects a non-positive amount with a structured error", async () => { + const out = await confirmCryptoOrderTool.invoke({ + coin_id: "bitcoin", + coin_symbol: "BTC", + amount_usd: 0, + price_at_confirm: 50000, + side: "buy", + }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/amount/); + }); + + it("makes no HTTP calls", async () => { + await confirmCryptoOrderTool.invoke({ + coin_id: "bitcoin", + coin_symbol: "BTC", + amount_usd: 1, + price_at_confirm: 1, + side: "buy", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/backend/tool/crypto/get-crypto-price.test.ts b/tests/backend/tool/crypto/get-crypto-price.test.ts new file mode 100644 index 0000000..8d0828f --- /dev/null +++ b/tests/backend/tool/crypto/get-crypto-price.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const fetchMock = vi.fn(); + +beforeEach(async () => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + // Module-level price cache leaks between tests; force a fresh import. + vi.resetModules(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function loadTool() { + const mod = await import("@/backend/tool/crypto/get-crypto-price"); + return mod.getCryptoPriceTool; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// CoinGecko /coins/markets shape, trimmed to fields we render. +const marketsResponse = [ + { + id: "bitcoin", + symbol: "btc", + name: "Bitcoin", + image: "https://assets.coingecko.com/coins/images/1/large/bitcoin.png", + current_price: 67234.5, + market_cap: 1325000000000, + market_cap_rank: 1, + total_volume: 25000000000, + price_change_percentage_24h: 2.34, + sparkline_in_7d: { price: [66000, 66500, 67000, 66800, 67200, 67234.5] }, + }, + { + id: "ethereum", + symbol: "eth", + name: "Ethereum", + image: "https://assets.coingecko.com/coins/images/279/large/ethereum.png", + current_price: 3500.1, + market_cap: 420000000000, + market_cap_rank: 2, + total_volume: 15000000000, + price_change_percentage_24h: -1.12, + sparkline_in_7d: { price: [3400, 3450, 3500, 3520, 3490, 3500.1] }, + }, +]; + +describe("getCryptoPriceTool", () => { + it("calls CoinGecko and returns a normalized coin list", async () => { + const getCryptoPriceTool = await loadTool(); + fetchMock.mockResolvedValueOnce(jsonResponse(200, marketsResponse)); + const out = await getCryptoPriceTool.invoke({ ids: ["bitcoin", "ethereum"] }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(true); + expect(parsed.coins).toHaveLength(2); + expect(parsed.coins[0]).toMatchObject({ + id: "bitcoin", + symbol: "BTC", + name: "Bitcoin", + current_price: 67234.5, + price_change_percentage_24h: 2.34, + sparkline: [66000, 66500, 67000, 66800, 67200, 67234.5], + }); + expect(parsed.coins[0].image).toContain("bitcoin.png"); + }); + + it("hits the coingecko base url with vs_currency=usd by default", async () => { + const getCryptoPriceTool = await loadTool(); + fetchMock.mockResolvedValueOnce(jsonResponse(200, marketsResponse)); + await getCryptoPriceTool.invoke({ ids: ["bitcoin"] }); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toMatch(/^https:\/\/api\.coingecko\.com\/api\/v3\/coins\/markets\?/); + expect(url).toContain("vs_currency=usd"); + expect(url).toContain("ids=bitcoin"); + expect(url).toContain("sparkline=true"); + expect(url).toContain("price_change_percentage=24h"); + }); + + it("accepts a custom vs_currency", async () => { + const getCryptoPriceTool = await loadTool(); + fetchMock.mockResolvedValueOnce(jsonResponse(200, marketsResponse)); + await getCryptoPriceTool.invoke({ ids: ["bitcoin"], vs_currency: "cny" }); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("vs_currency=cny"); + }); + + it("propagates API failures as a serialized error result", async () => { + const getCryptoPriceTool = await loadTool(); + fetchMock.mockResolvedValueOnce(jsonResponse(429, { error: "rate limit" })); + const out = await getCryptoPriceTool.invoke({ ids: ["bitcoin"] }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/429/); + }); + + it("returns an empty list when the API returns no coins for the given ids", async () => { + const getCryptoPriceTool = await loadTool(); + fetchMock.mockResolvedValueOnce(jsonResponse(200, [])); + const out = await getCryptoPriceTool.invoke({ ids: ["not-a-real-coin"] }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(true); + expect(parsed.coins).toEqual([]); + }); +}); diff --git a/tests/backend/tool/crypto/get-fx-rate.test.ts b/tests/backend/tool/crypto/get-fx-rate.test.ts new file mode 100644 index 0000000..c6acaf7 --- /dev/null +++ b/tests/backend/tool/crypto/get-fx-rate.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const fetchMock = vi.fn(); + +beforeEach(async () => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + // Module-level cache leaks between tests; force a fresh import. + vi.resetModules(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function loadTool() { + const mod = await import("@/backend/tool/crypto/get-fx-rate"); + return mod.getFxRateTool; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("getFxRateTool", () => { + it("calls frankfurter with from/to codes and returns the rate + date", async () => { + const getFxRateTool = await loadTool(); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { amount: 1, base: "USD", date: "2026-06-25", rates: { CNY: 7.1834 } }), + ); + const out = await getFxRateTool.invoke({ from: "USD", to: "CNY" }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(true); + expect(parsed.from).toBe("USD"); + expect(parsed.to).toBe("CNY"); + expect(parsed.rate).toBe(7.1834); + expect(parsed.date).toBe("2026-06-25"); + }); + + it("uppercases codes in the URL so 'cny' and 'CNY' hit the same cache key", async () => { + const getFxRateTool = await loadTool(); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { amount: 1, base: "USD", date: "2026-06-25", rates: { CNY: 7.18 } }), + ); + await getFxRateTool.invoke({ from: "usd", to: "cny" }); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("from=USD"); + expect(url).toContain("to=CNY"); + }); + + it("returns the same payload on a cache hit without a second fetch call", async () => { + const getFxRateTool = await loadTool(); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { amount: 1, base: "USD", date: "2026-06-25", rates: { EUR: 0.92 } }), + ); + await getFxRateTool.invoke({ from: "USD", to: "EUR" }); + await getFxRateTool.invoke({ from: "USD", to: "EUR" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("propagates 4xx/5xx as a serialized error result", async () => { + const getFxRateTool = await loadTool(); + fetchMock.mockResolvedValueOnce(jsonResponse(404, { error: "not found" })); + const out = await getFxRateTool.invoke({ from: "USD", to: "ZZZ" }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/404/); + }); +}); diff --git a/tests/frontend/crypto/ask-crypto-intent-card.test.tsx b/tests/frontend/crypto/ask-crypto-intent-card.test.tsx new file mode 100644 index 0000000..45f2e1c --- /dev/null +++ b/tests/frontend/crypto/ask-crypto-intent-card.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WagmiProvider, createConfig, http } from "wagmi"; +import { mainnet } from "wagmi/chains"; + +vi.mock("@assistant-ui/react-langgraph", () => ({ + useLangGraphSendCommand: () => vi.fn(), +})); + +vi.mock("wagmi/connectors", () => ({ + injected: () => ({ id: "injected", type: "injected" }), +})); + +const mockUseAccount = vi.fn(); + +vi.mock("wagmi", async () => { + const actual = await vi.importActual("wagmi"); + return { + ...actual, + useAccount: () => mockUseAccount(), + useConnect: () => ({ connect: vi.fn(), connectors: [], isPending: false }), + useBalance: () => ({ data: undefined }), + }; +}); + +import { AskCryptoIntentCard } from "@/components/tool-ui/crypto/ask-crypto-intent-card"; + +const config = createConfig({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + ssr: true, +}); + +function renderCard(args: Record = {}) { + const qc = new QueryClient(); + // Card takes a ToolCallMessagePart prop; only args/result are exercised by these tests. + const stub = { + type: "tool-call", + toolCallId: "test", + toolName: "ask_crypto_intent", + argsText: "", + args, + result: undefined, + } as never; + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); +}); + +afterEach(() => { + cleanup(); +}); + +describe("AskCryptoIntentCard amount input", () => { + it("strips a leading minus sign so the value is never negative", () => { + renderCard(); + const input = screen.getByPlaceholderText("100") as HTMLInputElement; + fireEvent.change(input, { target: { value: "-100" } }); + expect(input.value).toBe("100"); + }); + + it("keeps positive values unchanged", () => { + renderCard(); + const input = screen.getByPlaceholderText("100") as HTMLInputElement; + fireEvent.change(input, { target: { value: "250.5" } }); + expect(input.value).toBe("250.5"); + }); + + it("allows empty input", () => { + renderCard(); + const input = screen.getByPlaceholderText("100") as HTMLInputElement; + fireEvent.change(input, { target: { value: "" } }); + expect(input.value).toBe(""); + }); +}); + +describe("AskCryptoIntentCard currency detection", () => { + it("labels the amount input with the LLM-detected currency (CNY)", () => { + renderCard({ currency: "CNY" }); + expect(screen.getByText(/amount \(cny\)/i)).toBeTruthy(); + }); + + it("labels the amount input with the LLM-detected currency (EUR)", () => { + renderCard({ currency: "EUR" }); + expect(screen.getByText(/amount \(eur\)/i)).toBeTruthy(); + }); + + it("falls back to USD when no currency is passed", () => { + renderCard(); + expect(screen.getByText(/amount \(usd\)/i)).toBeTruthy(); + }); + + it("pre-fills the amount input from the LLM's hint", () => { + renderCard({ currency: "CNY", amount: 250 }); + const input = screen.getByPlaceholderText("100") as HTMLInputElement; + expect(input.value).toBe("250"); + }); +}); + +describe("AskCryptoIntentCard wallet flow", () => { + it("renders a 'Connect & buy' button when no wallet is connected", () => { + mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); + renderCard(); + expect(screen.getByRole("button", { name: /connect.*buy/i })).toBeTruthy(); + }); + + it("renders a 'Confirm buy' button (no wallet prompt) when connected", () => { + mockUseAccount.mockReturnValue({ + address: "0x1234567890abcdef1234567890abcdef12345678", + isConnected: true, + }); + renderCard(); + expect(screen.getByRole("button", { name: /confirm.*buy/i })).toBeTruthy(); + }); + + it("shows the connected address + a connect dialog trigger is hidden", () => { + mockUseAccount.mockReturnValue({ + address: "0x1234567890abcdef1234567890abcdef12345678", + isConnected: true, + }); + renderCard(); + expect(screen.getByText(/0x1234…5678/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /connect.*buy/i })).toBeNull(); + }); +}); From dcfa9766d6accaf6475e01911d8a236f9be54879 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Fri, 26 Jun 2026 10:09:13 +0800 Subject: [PATCH 04/35] fix(crypto): use Decimal for amount math, surface validation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Number arithmetic loses precision the moment it touches money: 100/3 = 33.333333333333336, 0.1+0.2 = 0.30000000000000004. For a crypto buy-intent card these values land directly in the receipt the user reads, so the drift is visible and trust-eroding. New lib/decimal.ts wraps decimal.js (10.6.0): parseAmount(input) → Decimal | null Rejects empty, non-finite, non-positive, scientific notation (1e2), malformed (1.2.3, abc), and amounts > 1e15. Returns a Decimal that's exact through any precision the user can type in a normal text field. formatAmount(v, dp) → string toFixed(dp) without float drift. formatQty(v) → string 4dp for qty ≥ 1, 6dp for qty < 1 (satoshi-level trades). safeDivide(n, d) → Decimal | null Returns null instead of NaN/Infinity when the divisor is zero / negative / non-finite — caller surfaces a structured error instead of writing garbage to the receipt. Card (ask-crypto-intent-card.tsx): - Switched amount input from type="number" to type="text" inputMode="decimal" so we own validation. silently accepts 1e2 (scientific) and locale-dependent separators; plus parseAmount gives consistent behavior everywhere. - aria-invalid + red border when amount is non-empty and parseAmount rejects it. Empty input is neutral, not invalid. - The old "strip leading minus" hack (replace(/-/g, '')) is gone — we surface the error visibly instead of mutating user input behind their back. Backend (confirm-crypto-order.ts): - qty = amount_usd / price_at_confirm now goes through safeDivide. The 100/3 case still serializes to a number on the wire (Decimal.toNumber is safe under the safety cap) but the internal computation is exact. Receipt (order-receipt-card.tsx): - formatUsd wraps formatAmount so totals don't render as $33.333333333333336. Tests: - tests/lib/decimal.test.ts: 16 cases — parses, rejects (empty, zero, negative, sci notation, malformed, Infinity, NaN, overflow), formats, divides, divide-by-zero. - tests/backend/tool/crypto/confirm-crypto-order.test.ts: added 100/3 case (no ugly float drift in the wire payload). - tests/frontend/crypto/ask-crypto-intent-card.test.tsx: rewrote amount-input cases around the new validation model (aria-invalid + disabled button) instead of the old "strip on type" behavior. Added cases for scientific notation, non-numeric junk, and multiple decimals. docs/TODOS.md: format pass. 197/197 tests pass, lint clean. package.json: + decimal.js@^10.6.0. --- backend/tool/crypto/confirm-crypto-order.ts | 14 ++- .../tool-ui/crypto/ask-crypto-intent-card.tsx | 26 ++++-- .../tool-ui/crypto/order-receipt-card.tsx | 9 +- docs/TODOS.md | 2 +- lib/decimal.ts | 55 ++++++++++++ package.json | 1 + .../tool/crypto/confirm-crypto-order.test.ts | 21 +++++ .../crypto/ask-crypto-intent-card.test.tsx | 43 +++++++++- tests/lib/decimal.test.ts | 86 +++++++++++++++++++ 9 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 lib/decimal.ts create mode 100644 tests/lib/decimal.test.ts diff --git a/backend/tool/crypto/confirm-crypto-order.ts b/backend/tool/crypto/confirm-crypto-order.ts index c4f1393..7e07620 100644 --- a/backend/tool/crypto/confirm-crypto-order.ts +++ b/backend/tool/crypto/confirm-crypto-order.ts @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; +import { safeDivide } from "@/lib/decimal"; + // ponytail: simulated order — no signing, no chain, no RPC. The frontend // card shows a "Simulated Filled" badge and a "view on Etherscan" link // that's disabled. Upgrade path: swap body for wagmi useWriteContract @@ -28,7 +30,15 @@ export const confirmCryptoOrderTool = tool( return JSON.stringify({ success: false, error: "price_at_confirm must be > 0" }); } - const qty = amount_usd / price_at_confirm; + // Decimal divide: qty = amount_usd / price_at_confirm. Plain + // number division can drift (100/3 = 33.333333333333336), which + // shows up in the receipt card. safeDivide returns null on + // divide-by-zero so we report it instead of writing NaN. + const qtyDecimal = safeDivide(amount_usd, price_at_confirm); + if (qtyDecimal === null) { + return JSON.stringify({ success: false, error: "price_at_confirm must be > 0" }); + } + return JSON.stringify({ success: true, order: { @@ -37,7 +47,7 @@ export const confirmCryptoOrderTool = tool( symbol: coin_symbol, side, amount_usd, - qty, + qty: qtyDecimal.toNumber(), price_at_confirm, status: "simulated_filled", timestamp: new Date().toISOString(), diff --git a/components/tool-ui/crypto/ask-crypto-intent-card.tsx b/components/tool-ui/crypto/ask-crypto-intent-card.tsx index 1c947a5..10d4bc6 100644 --- a/components/tool-ui/crypto/ask-crypto-intent-card.tsx +++ b/components/tool-ui/crypto/ask-crypto-intent-card.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { unwrapToolResult } from "@/components/tool-ui/tool-result"; import { ConnectWalletDialog } from "@/components/tool-ui/crypto/connect-wallet-dialog"; import { cn } from "@/lib/utils"; +import { formatAmount, parseAmount } from "@/lib/decimal"; // Tool result the user picks from the card. Mirrors the backend tool's // resume payload (backend/tool/crypto/ask-crypto-intent.ts). @@ -80,8 +81,10 @@ export const AskCryptoIntentCard: ToolCallMessagePartComponent(null); const coin = COIN_OPTIONS.find((c) => c.coin_id === coinId) ?? COIN_OPTIONS[0]; - const numericAmount = Number.parseFloat(amount); - const isValid = Number.isFinite(numericAmount) && numericAmount > 0; + // Decimal-validated amount. null = invalid (empty, negative, scientific, + // non-numeric, or over the safety cap). See lib/decimal for the rules. + const amountDecimal = parseAmount(amount); + const isValid = amountDecimal !== null; const resume = (payload: AskCryptoIntentResult) => { sendCommand({ resume: JSON.stringify(payload) }); @@ -90,7 +93,9 @@ export const AskCryptoIntentCard: ToolCallMessagePartComponent ({ coin_id: coin.coin_id, coin_symbol: coin.symbol, - amount: numericAmount, + // .toNumber() is safe here — parseAmount already enforced the safety + // cap (≤ 1e15), well under Number.MAX_SAFE_INTEGER (~9e15). + amount: amountDecimal!.toNumber(), currency, side, }); @@ -149,7 +154,7 @@ export const AskCryptoIntentCard: ToolCallMessagePartComponent

- {parsed.amount.toFixed(2)} {parsed.currency} notional + {formatAmount(parsed.amount)} {parsed.currency} notional

@@ -205,13 +210,16 @@ export const AskCryptoIntentCard: ToolCallMessagePartComponent setAmount(e.target.value.replace(/-/g, ""))} - className="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-1" + onChange={(e) => setAmount(e.target.value)} + aria-invalid={amount.length > 0 && !isValid} + className={cn( + "border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-1", + amount.length > 0 && !isValid && "border-destructive", + )} placeholder="100" /> diff --git a/components/tool-ui/crypto/order-receipt-card.tsx b/components/tool-ui/crypto/order-receipt-card.tsx index 5bbceb1..3e94fe7 100644 --- a/components/tool-ui/crypto/order-receipt-card.tsx +++ b/components/tool-ui/crypto/order-receipt-card.tsx @@ -5,6 +5,7 @@ import { CheckCircle2Icon, InfoIcon, WalletIcon } from "lucide-react"; import { ToolCardSkeleton } from "@/components/tool-ui/tool-card-skeleton"; import { unwrapToolResult } from "@/components/tool-ui/tool-result"; +import { formatAmount, formatQty } from "@/lib/decimal"; import { cn } from "@/lib/utils"; type Order = { @@ -39,15 +40,13 @@ function parse(raw: unknown) { } function formatUsd(n: number) { + // Decimal-backed so the receipt doesn't render something like + // $100.30000000000001 if the backend's qty division drifted. return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2, - }).format(n); -} - -function formatQty(n: number) { - return n.toFixed(n < 1 ? 6 : 4); + }).format(Number(formatAmount(n))); } function formatTime(iso: string) { diff --git a/docs/TODOS.md b/docs/TODOS.md index 64f9432..061ddc5 100644 --- a/docs/TODOS.md +++ b/docs/TODOS.md @@ -86,7 +86,7 @@ moving (it's `0.0.x`, experimental). Follow-up to the 2026-06-25 crypto agent. Two new requirements: -1. **Wallet selection UI**. "Order 按钮点击下去, 是唤醒钱包, 能够选择钱包". User pointed out the existing card had a tiny "Connect wallet (optional)" link; they want the order button to *open a wallet picker*. wagmi is headless (no built-in UI); RainbowKit is the canonical answer but its 2.x line is pinned to wagmi 2.x and we're on 3.6.20. Skipped RainbowKit, built a Radix-Dialog-based connector picker using the existing `injected` connector. `components/tool-ui/crypto/connect-wallet-dialog.tsx`. Order button is now `"Connect & buy BTC"` when no wallet is connected, `"Confirm buy BTC"` once connected. Resume is queued in component state and flushed when `isConnected` flips. No new deps. +1. **Wallet selection UI**. "Order 按钮点击下去, 是唤醒钱包, 能够选择钱包". User pointed out the existing card had a tiny "Connect wallet (optional)" link; they want the order button to _open a wallet picker_. wagmi is headless (no built-in UI); RainbowKit is the canonical answer but its 2.x line is pinned to wagmi 2.x and we're on 3.6.20. Skipped RainbowKit, built a Radix-Dialog-based connector picker using the existing `injected` connector. `components/tool-ui/crypto/connect-wallet-dialog.tsx`. Order button is now `"Connect & buy BTC"` when no wallet is connected, `"Confirm buy BTC"` once connected. Resume is queued in component state and flushed when `isConnected` flips. No new deps. 2. **Currency detection**. "现在好像说的是买 100 RMB 的加密货币, 会被认为是 USD". Old schema was `amount_usd: number` — the LLM was passing 100 as `amount_usd` regardless of the user's actual currency. New flow: - `ask_crypto_intent` schema gains `currency` (3-char ISO) and `amount` (optional pre-fill) so the LLM signals what the user meant. diff --git a/lib/decimal.ts b/lib/decimal.ts new file mode 100644 index 0000000..40ce1eb --- /dev/null +++ b/lib/decimal.ts @@ -0,0 +1,55 @@ +import { Decimal } from "decimal.js"; + +// ponytail: shared money math. All amount/qty arithmetic goes through +// Decimal so the inputs/outputs never silently lose precision the way +// Number division does (0.1 + 0.2 = 0.30000000000000004). The LLM still +// receives numbers on the wire — these helpers just keep our internal +// state exact. + +const MAX_AMOUNT = new Decimal("1e15"); // 1 quadrillion cap; anything higher is almost certainly a typo + +// Reject exponential notation. "1e2" parses cleanly to 100 in +// Decimal, but accepting it in a buy-intent input lets a user +// fat-finger something they didn't mean (1e9 = a billion). +const SCIENTIFIC = /[eE]/; + +export function parseAmount(input: string): Decimal | null { + const trimmed = input.trim(); + if (!trimmed) return null; + if (SCIENTIFIC.test(trimmed)) return null; + + let d: Decimal; + try { + d = new Decimal(trimmed); + } catch { + return null; + } + if (!d.isFinite()) return null; + if (d.lte(0)) return null; + if (d.gt(MAX_AMOUNT)) return null; + return d; +} + +export function formatAmount(value: Decimal | number | string, dp = 2): string { + const d = value instanceof Decimal ? value : new Decimal(value); + return d.toFixed(dp); +} + +// qty formatting: small numbers need more precision (satoshi-level +// trades) than large ones. Anything ≥ 1 → 4dp, anything < 1 → 6dp. +export function formatQty(value: Decimal | number | string): string { + const d = value instanceof Decimal ? value : new Decimal(value); + return d.toFixed(d.lt(1) ? 6 : 4); +} + +// Safe division. Returns null if divisor is zero/non-finite so the +// caller can return a structured error instead of NaN/Infinity. +export function safeDivide( + numerator: Decimal | number | string, + denominator: Decimal | number | string, +): Decimal | null { + const n = numerator instanceof Decimal ? numerator : new Decimal(numerator); + const d = denominator instanceof Decimal ? denominator : new Decimal(denominator); + if (!d.isFinite() || d.lte(0)) return null; + return n.div(d); +} diff --git a/package.json b/package.json index ce5ef65..6703782 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "decimal.js": "^10.6.0", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", "lucide-react": "^1.21.0", diff --git a/tests/backend/tool/crypto/confirm-crypto-order.test.ts b/tests/backend/tool/crypto/confirm-crypto-order.test.ts index 25f4f42..682ec42 100644 --- a/tests/backend/tool/crypto/confirm-crypto-order.test.ts +++ b/tests/backend/tool/crypto/confirm-crypto-order.test.ts @@ -87,4 +87,25 @@ describe("confirmCryptoOrderTool", () => { }); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("computes qty without float precision drift (100/3 case)", async () => { + // Number division gives 33.333333333333336 — visible in the + // receipt as $33.333333333333336. Decimal-backed division + // preserves precision in the Decimal object; we round when + // handing it back as a number for JSON serialization. + const out = await confirmCryptoOrderTool.invoke({ + coin_id: "bitcoin", + coin_symbol: "BTC", + amount_usd: 100, + price_at_confirm: 3, + side: "buy", + }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(true); + // Allow tiny float drift on the .toNumber() boundary but no + // ugly "33.333333333333343" type values. + expect(parsed.order.qty).toBeGreaterThan(33.3333); + expect(parsed.order.qty).toBeLessThan(33.3334); + expect(String(parsed.order.qty)).toMatch(/^33\.33/); + }); }); diff --git a/tests/frontend/crypto/ask-crypto-intent-card.test.tsx b/tests/frontend/crypto/ask-crypto-intent-card.test.tsx index 45f2e1c..ce32661 100644 --- a/tests/frontend/crypto/ask-crypto-intent-card.test.tsx +++ b/tests/frontend/crypto/ask-crypto-intent-card.test.tsx @@ -62,18 +62,28 @@ afterEach(() => { }); describe("AskCryptoIntentCard amount input", () => { - it("strips a leading minus sign so the value is never negative", () => { + it("marks negative input invalid and disables the order button", () => { renderCard(); const input = screen.getByPlaceholderText("100") as HTMLInputElement; fireEvent.change(input, { target: { value: "-100" } }); - expect(input.value).toBe("100"); + // The input keeps the literal text so the user can see what they + // typed; Decimal rejects the value at validation time and the + // button goes disabled instead of silently shipping a negative. + expect(input.value).toBe("-100"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(input.className).toContain("border-destructive"); + const btn = screen.getByRole("button", { name: /connect.*buy/i }); + expect(btn).toBeDisabled(); }); - it("keeps positive values unchanged", () => { + it("keeps positive values unchanged and valid", () => { renderCard(); const input = screen.getByPlaceholderText("100") as HTMLInputElement; fireEvent.change(input, { target: { value: "250.5" } }); expect(input.value).toBe("250.5"); + expect(input.getAttribute("aria-invalid")).toBe("false"); + const btn = screen.getByRole("button", { name: /connect.*buy/i }); + expect(btn).not.toBeDisabled(); }); it("allows empty input", () => { @@ -81,6 +91,33 @@ describe("AskCryptoIntentCard amount input", () => { const input = screen.getByPlaceholderText("100") as HTMLInputElement; fireEvent.change(input, { target: { value: "" } }); expect(input.value).toBe(""); + // Empty is not invalid — it's just not yet filled in. + expect(input.getAttribute("aria-invalid")).toBe("false"); + }); + + it("rejects scientific notation (1e2) without breaking the input", () => { + renderCard(); + const input = screen.getByPlaceholderText("100") as HTMLInputElement; + fireEvent.change(input, { target: { value: "1e2" } }); + expect(input.value).toBe("1e2"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + const btn = screen.getByRole("button", { name: /connect.*buy/i }); + expect(btn).toBeDisabled(); + }); + + it("rejects non-numeric junk", () => { + renderCard(); + const input = screen.getByPlaceholderText("100") as HTMLInputElement; + fireEvent.change(input, { target: { value: "abc" } }); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(screen.getByRole("button", { name: /connect.*buy/i })).toBeDisabled(); + }); + + it("rejects multiple decimal points", () => { + renderCard(); + const input = screen.getByPlaceholderText("100") as HTMLInputElement; + fireEvent.change(input, { target: { value: "1.2.3" } }); + expect(input.getAttribute("aria-invalid")).toBe("true"); }); }); diff --git a/tests/lib/decimal.test.ts b/tests/lib/decimal.test.ts new file mode 100644 index 0000000..6f69383 --- /dev/null +++ b/tests/lib/decimal.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; + +import { parseAmount, formatAmount, formatQty, safeDivide } from "@/lib/decimal"; + +describe("parseAmount", () => { + it("parses a clean integer", () => { + const d = parseAmount("100"); + expect(d?.toNumber()).toBe(100); + }); + + it("parses a decimal without losing precision", () => { + // parseFloat("0.1") + parseFloat("0.2") !== 0.3 — Decimal handles it. + expect(parseAmount("0.1")?.plus("0.2")?.toFixed(1)).toBe("0.3"); + }); + + it("trims surrounding whitespace", () => { + expect(parseAmount(" 100 ")?.toNumber()).toBe(100); + }); + + it("rejects empty input", () => { + expect(parseAmount("")).toBeNull(); + expect(parseAmount(" ")).toBeNull(); + }); + + it("rejects zero and negative numbers", () => { + expect(parseAmount("0")).toBeNull(); + expect(parseAmount("-100")).toBeNull(); + }); + + it("rejects non-numeric input", () => { + expect(parseAmount("abc")).toBeNull(); + expect(parseAmount("1.2.3")).toBeNull(); + expect(parseAmount("1,000")).toBeNull(); // comma is not a decimal separator here + }); + + it("rejects scientific notation (1e2 style) to avoid fat-finger trades", () => { + expect(parseAmount("1e2")).toBeNull(); + expect(parseAmount("1E5")).toBeNull(); + }); + + it("rejects non-finite inputs", () => { + expect(parseAmount("Infinity")).toBeNull(); + expect(parseAmount("NaN")).toBeNull(); + }); + + it("rejects amounts above the safety cap", () => { + expect(parseAmount("1e16")).toBeNull(); + }); +}); + +describe("formatAmount", () => { + it("rounds to N decimal places", () => { + expect(formatAmount("1.005", 2)).toBe("1.01"); + expect(formatAmount("1.004", 2)).toBe("1.00"); + }); + + it("accepts Decimal | number | string", () => { + expect(formatAmount(100)).toBe("100.00"); + expect(formatAmount("100.5")).toBe("100.50"); + }); +}); + +describe("formatQty", () => { + it("uses 4dp for qty >= 1", () => { + expect(formatQty("1.23456789")).toBe("1.2346"); + }); + + it("uses 6dp for qty < 1 (satoshi-level)", () => { + expect(formatQty("0.000123456")).toBe("0.000123"); + }); +}); + +describe("safeDivide", () => { + it("divides without precision loss", () => { + const result = safeDivide("100", "3"); + expect(result?.toFixed(10)).toBe("33.3333333333"); + }); + + it("returns null when dividing by zero", () => { + expect(safeDivide("100", "0")).toBeNull(); + }); + + it("returns null when denominator is negative", () => { + expect(safeDivide("100", "-5")).toBeNull(); + }); +}); From a0224fca5aa0f90942e834bc1430e143df897ad1 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Fri, 26 Jun 2026 10:12:48 +0800 Subject: [PATCH 05/35] fix(decimal): handle null/undefined/NaN in format + divide helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM JSON and backend responses can omit fields, return null, or serialize as NaN/Infinity after a JSON.parse round-trip. The previous helpers assumed a Decimal | number | string and would either throw (Decimal constructor on undefined) or render literal "NaN" in the receipt / card. formatAmount, formatQty: - Accept unknown so the type matches "whatever the JSON said". - Return the em-dash placeholder ("—") for null / undefined / NaN / Infinity instead of crashing or rendering garbage. - Still return the same exact value for valid inputs. safeDivide: - Accepts null/undefined on either side and short-circuits to null, matching the existing zero/negative-rejection behavior. order-receipt-card formatUsd: - Short-circuits to "—" when amount_usd / price_at_confirm / qty are missing so the receipt still renders with one blank cell instead of "$NaN". Tests: 7 new cases covering null/undefined/NaN/Infinity for formatAmount, formatQty, and safeDivide. 204/204 pass, lint clean. --- .../tool-ui/crypto/order-receipt-card.tsx | 3 +- lib/decimal.ts | 40 ++++++++++++++----- tests/lib/decimal.test.ts | 38 ++++++++++++++++++ 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/components/tool-ui/crypto/order-receipt-card.tsx b/components/tool-ui/crypto/order-receipt-card.tsx index 3e94fe7..225706e 100644 --- a/components/tool-ui/crypto/order-receipt-card.tsx +++ b/components/tool-ui/crypto/order-receipt-card.tsx @@ -39,7 +39,8 @@ function parse(raw: unknown) { return { kind: "loading" as const }; } -function formatUsd(n: number) { +function formatUsd(n: number | null | undefined) { + if (n === null || n === undefined || !Number.isFinite(n)) return "—"; // Decimal-backed so the receipt doesn't render something like // $100.30000000000001 if the backend's qty division drifted. return new Intl.NumberFormat("en-US", { diff --git a/lib/decimal.ts b/lib/decimal.ts index 40ce1eb..8db6a69 100644 --- a/lib/decimal.ts +++ b/lib/decimal.ts @@ -30,26 +30,46 @@ export function parseAmount(input: string): Decimal | null { return d; } -export function formatAmount(value: Decimal | number | string, dp = 2): string { - const d = value instanceof Decimal ? value : new Decimal(value); +// Placeholder for missing values. Returning a visible em-dash beats +// "NaN" or an empty cell — the receipt / card still renders and the +// user sees that the field is blank instead of the page going blank. +const PLACEHOLDER = "—"; + +function toDecimal(value: unknown): Decimal | null { + if (value === null || value === undefined) return null; + if (value instanceof Decimal) return value; + // number guard: NaN/Infinity sneak in from upstream JSON parsing + // of malformed values; treat them as missing rather than rendering + // "NaN" in the UI. + if (typeof value === "number" && !Number.isFinite(value)) return null; + try { + return new Decimal(value as Decimal | number | string); + } catch { + return null; + } +} + +export function formatAmount(value: unknown, dp = 2): string { + const d = toDecimal(value); + if (d === null) return PLACEHOLDER; return d.toFixed(dp); } // qty formatting: small numbers need more precision (satoshi-level // trades) than large ones. Anything ≥ 1 → 4dp, anything < 1 → 6dp. -export function formatQty(value: Decimal | number | string): string { - const d = value instanceof Decimal ? value : new Decimal(value); +export function formatQty(value: unknown): string { + const d = toDecimal(value); + if (d === null) return PLACEHOLDER; return d.toFixed(d.lt(1) ? 6 : 4); } // Safe division. Returns null if divisor is zero/non-finite so the // caller can return a structured error instead of NaN/Infinity. -export function safeDivide( - numerator: Decimal | number | string, - denominator: Decimal | number | string, -): Decimal | null { - const n = numerator instanceof Decimal ? numerator : new Decimal(numerator); - const d = denominator instanceof Decimal ? denominator : new Decimal(denominator); +// Accepts null/undefined on either side — they short-circuit to null. +export function safeDivide(numerator: unknown, denominator: unknown): Decimal | null { + const n = toDecimal(numerator); + const d = toDecimal(denominator); + if (n === null || d === null) return null; if (!d.isFinite() || d.lte(0)) return null; return n.div(d); } diff --git a/tests/lib/decimal.test.ts b/tests/lib/decimal.test.ts index 6f69383..da410f7 100644 --- a/tests/lib/decimal.test.ts +++ b/tests/lib/decimal.test.ts @@ -83,4 +83,42 @@ describe("safeDivide", () => { it("returns null when denominator is negative", () => { expect(safeDivide("100", "-5")).toBeNull(); }); + + it("returns null when either argument is null/undefined", () => { + expect(safeDivide(null, 5)).toBeNull(); + expect(safeDivide(5, undefined)).toBeNull(); + expect(safeDivide(undefined, undefined)).toBeNull(); + }); + + it("returns null when either argument is NaN/Infinity", () => { + expect(safeDivide(NaN, 5)).toBeNull(); + expect(safeDivide(5, Infinity)).toBeNull(); + }); +}); + +describe("undefined/null tolerance", () => { + it("formatAmount returns the placeholder for null/undefined", () => { + expect(formatAmount(undefined)).toBe("—"); + expect(formatAmount(null)).toBe("—"); + }); + + it("formatAmount returns the placeholder for NaN/Infinity", () => { + expect(formatAmount(NaN)).toBe("—"); + expect(formatAmount(Infinity)).toBe("—"); + }); + + it("formatAmount still works for valid values", () => { + expect(formatAmount(100)).toBe("100.00"); + expect(formatAmount("100.5")).toBe("100.50"); + }); + + it("formatQty returns the placeholder for null/undefined", () => { + expect(formatQty(undefined)).toBe("—"); + expect(formatQty(null)).toBe("—"); + }); + + it("formatQty still works for valid values", () => { + expect(formatQty("0.000123")).toBe("0.000123"); + expect(formatQty("1.234")).toBe("1.2340"); + }); }); From 221c56d123e4d087dbb612dead34bbb3a8b6907e Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Fri, 26 Jun 2026 10:33:27 +0800 Subject: [PATCH 06/35] =?UTF-8?q?feat(frontend):=20downgrade=20wagmi=20?= =?UTF-8?q?=E2=86=92=202.x=20+=20use=20RainbowKit=20for=20wallet=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RainbowKit 2.x is pinned to wagmi 2.x — we were on 3.6.20 which peers-warned and forced a hand-rolled Radix Dialog picker. Downgrading lets the canonical UI lib render the modal. package.json: - wagmi 3.6.20 → 2.19.5 - + @rainbow-me/rainbowkit 2.2.11 lib/wagmi.ts: Replaces the hand-rolled createConfig + injected connector with RainbowKit's getDefaultConfig (bundles injected / MetaMask / Coinbase / Rainbow / WalletConnect). WalletConnect's projectId is a placeholder — it 403s against Reown's API, but the other wallets (injected, MetaMask, etc.) don't touch it and work normally. Swap for a real id from cloud.walletconnect.com when WC support is actually needed. app/web3-providers.tsx: Adds inside + . Theme uses light/dark from useTheme. Lazy-init QueryClient so React strict-mode double-invoke doesn't share state across requests. components/tool-ui/crypto/ask-crypto-intent-card.tsx: Replaces the inline with a useEffect that flushes a queued resume once isConnected flips. The order button now: - connected: resume immediately - disconnected: queue the resume, call openConnectModal() from RainbowKit. When the user picks a wallet and connects, the wagmi account hook flips isConnected, our effect fires, and the queued pick resumes the interrupt. components/tool-ui/crypto/connect-wallet-dialog.tsx: DELETED (replaced by RainbowKit's modal — ~120 lines of custom picker and its test scaffolding gone). tests/frontend/crypto/ask-crypto-intent-card.test.tsx: Mocks useConnectModal from @rainbow-me/rainbowkit. Adds two new cases: - 'opens the RainbowKit connect modal when Confirm is pressed with no wallet' — verifies the button click triggers openConnectModal. - 'does not open the connect modal when already connected' — verifies the connected path skips the modal entirely. 206/206 tests pass, lint clean. Chrome DevTools verified: order button click opens RainbowKit's picker modal ('连接钱包') with Rainbow / Base / MetaMask / WalletConnect entries. Visual saved to .verify/rainbowkit-modal.png. --- app/web3-providers.tsx | 39 ++++---- .../tool-ui/crypto/ask-crypto-intent-card.tsx | 36 ++++---- .../tool-ui/crypto/connect-wallet-dialog.tsx | 92 ------------------- lib/wagmi.ts | 23 ++--- package.json | 3 +- .../crypto/ask-crypto-intent-card.test.tsx | 29 ++++++ 6 files changed, 85 insertions(+), 137 deletions(-) delete mode 100644 components/tool-ui/crypto/connect-wallet-dialog.tsx diff --git a/app/web3-providers.tsx b/app/web3-providers.tsx index 010cbc6..716e7ad 100644 --- a/app/web3-providers.tsx +++ b/app/web3-providers.tsx @@ -1,28 +1,33 @@ "use client"; import { useState, type ReactNode } from "react"; -import { WagmiProvider, createConfig, http } from "wagmi"; -import { mainnet } from "wagmi/chains"; -import { injected } from "wagmi/connectors"; +import { WagmiProvider } from "wagmi"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RainbowKitProvider, lightTheme, darkTheme } from "@rainbow-me/rainbowkit"; -// ponytail: wagmi is display-only here — no writeContract, no signing. The -// crypto agent's orders are simulated; wagmi only surfaces address + balance -// in the buy-intent card. Lazy-init the QueryClient so React's strict-mode -// double-invoke doesn't share state across requests. +import { wagmiConfig } from "@/lib/wagmi"; + +// ponytail: wagmi is display-only here — no writeContract, no signing. +// The crypto agent's orders are simulated; wagmi only surfaces the +// connected address + balance, and RainbowKit provides the wallet +// picker modal the order button opens. Lazy-init the QueryClient +// and the RainbowKit theme so React strict-mode double-invoke doesn't +// share state across requests. export function Web3Providers({ children }: { children: ReactNode }) { - const [config] = useState(() => - createConfig({ - chains: [mainnet], - connectors: [injected({ shimDisconnect: true })], - transports: { [mainnet.id]: http() }, - ssr: true, - }), - ); const [queryClient] = useState(() => new QueryClient()); return ( - - {children} + + + + {children} + + ); } diff --git a/components/tool-ui/crypto/ask-crypto-intent-card.tsx b/components/tool-ui/crypto/ask-crypto-intent-card.tsx index 10d4bc6..9ca97e0 100644 --- a/components/tool-ui/crypto/ask-crypto-intent-card.tsx +++ b/components/tool-ui/crypto/ask-crypto-intent-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { AlertCircleIcon, CheckCircle2Icon, @@ -10,12 +10,12 @@ import { } from "lucide-react"; import { formatUnits } from "viem"; import { useAccount, useBalance } from "wagmi"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; import { Button } from "@/components/ui/button"; import { unwrapToolResult } from "@/components/tool-ui/tool-result"; -import { ConnectWalletDialog } from "@/components/tool-ui/crypto/connect-wallet-dialog"; import { cn } from "@/lib/utils"; import { formatAmount, parseAmount } from "@/lib/decimal"; @@ -66,6 +66,7 @@ export const AskCryptoIntentCard: ToolCallMessagePartComponent("buy"); const [mode, setMode] = useState("idle"); // Wallet connection is part of the order flow. If the user clicks - // Confirm without a connected wallet, we open the connector dialog - // and queue the resume until they connect. `pendingResume` captures - // the current form so we can re-emit it once isConnected flips. - const [showConnect, setShowConnect] = useState(false); + // Confirm without a connected wallet, we open RainbowKit's modal and + // queue the resume until they connect. `pendingResume` captures the + // current form so the effect below can re-emit it once isConnected + // flips (RainbowKit's modal calls wagmi's connect() under the hood + // and closes itself on success). const [pendingResume, setPendingResume] = useState(null); const coin = COIN_OPTIONS.find((c) => c.coin_id === coinId) ?? COIN_OPTIONS[0]; @@ -103,22 +105,29 @@ export const AskCryptoIntentCard: ToolCallMessagePartComponent { if (!isValid) return; if (!isConnected) { - // Queue the resume; the dialog's onConnected will flush it. + // Queue the resume; the effect below flushes it once isConnected + // flips. openConnectModal is undefined when RainbowKitProvider + // isn't mounted (e.g. unit tests) — fall back to a no-op rather + // than throwing. setPendingResume(buildResume()); - setShowConnect(true); + openConnectModal?.(); return; } setMode("submitting"); resume(buildResume()); }; - const handleConnected = () => { - if (pendingResume) { + // Flush queued resume once the wallet connects. Depends only on + // isConnected + pendingResume (sendCommand is intentionally omitted + // — it comes from a hook and is stable enough for our purposes). + useEffect(() => { + if (isConnected && pendingResume) { setMode("submitting"); resume(pendingResume); setPendingResume(null); } - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isConnected, pendingResume]); const handleCancel = () => { setMode("submitting"); @@ -273,11 +282,6 @@ export const AskCryptoIntentCard: ToolCallMessagePartComponent )} - ); }; diff --git a/components/tool-ui/crypto/connect-wallet-dialog.tsx b/components/tool-ui/crypto/connect-wallet-dialog.tsx deleted file mode 100644 index e4b0a6c..0000000 --- a/components/tool-ui/crypto/connect-wallet-dialog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { Loader2Icon, WalletIcon } from "lucide-react"; -import { useConnect } from "wagmi"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -// Modal that lists every wagmi connector and lets the user pick one. -// `onConnected` fires once isConnected flips true so the caller can -// resume its own flow (e.g. submit the order that triggered the modal). -export function ConnectWalletDialog({ - open, - onOpenChange, - onConnected, -}: { - open: boolean; - onOpenChange: (next: boolean) => void; - onConnected?: () => void; -}) { - const { connectors, connect, isPending, error } = useConnect(); - - useEffect(() => { - if (!isPending && !error && open) { - // wagmi sets isConnected on success; the parent effect resumes - // its flow. We just clear the modal here once the wallet call - // settles (success path is handled by the parent watching - // isConnected; we only auto-close on user-initiated close). - } - }, [isPending, error, open]); - - return ( - - - - Connect a wallet - - Pick a wallet to sign in. Orders are still simulated — no on-chain transaction is sent. - - - -
    - {connectors.length === 0 ? ( -
  • No wallet providers detected.
  • - ) : ( - connectors.map((c) => ( -
  • - -
  • - )) - )} -
- - {error ? ( -

Connection failed: {error.message}

- ) : null} -
-
- ); -} diff --git a/lib/wagmi.ts b/lib/wagmi.ts index e3e2b01..39ee7ce 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -1,19 +1,20 @@ "use client"; -import { createConfig, http } from "wagmi"; +import { http } from "wagmi"; import { mainnet } from "wagmi/chains"; -import { injected } from "wagmi/connectors"; +import { getDefaultConfig } from "@rainbow-me/rainbowkit"; -// ponytail: display-only wagmi config. No writeContract, no sendTransaction — -// the crypto agent's "orders" are simulated (see backend/tool/crypto/confirm-crypto-order.ts). -// We use wagmi purely to surface the user's address + balance in the buy-intent card. -// Upgrade path: add useWriteContract for a real DEX swap (needs RPC + Router address in env). +import "@rainbow-me/rainbowkit/styles.css"; -export const wagmiConfig = createConfig({ +// ponytail: `projectId` here is a placeholder. WalletConnect won't work +// with it (any click on a WC wallet fails to init), but injected wallets +// (MetaMask, Rabby, Phantom) and the Coinbase connector don't touch it +// and work normally. Swap for a real id from cloud.walletconnect.com +// when WalletConnect support is actually needed. +export const wagmiConfig = getDefaultConfig({ + appName: "LangGraph App", + projectId: "placeholder-replace-me", chains: [mainnet], - connectors: [injected({ shimDisconnect: true })], - transports: { - [mainnet.id]: http(), - }, + transports: { [mainnet.id]: http() }, ssr: true, }); diff --git a/package.json b/package.json index 6703782..9278f06 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-separator": "^1.1.10", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-tooltip": "^1.2.10", + "@rainbow-me/rainbowkit": "^2.2.11", "@tanstack/react-pacer": "^0.22.1", "@tanstack/react-query": "^5.101.1", "assistant-stream": "^0.3.24", @@ -66,7 +67,7 @@ "tw-shimmer": "^0.4.11", "ufo": "^1.6.4", "viem": "^2.53.1", - "wagmi": "^3.6.20", + "wagmi": "^2.19.5", "zod": "^4.4.3", "zustand": "^5.0.14" }, diff --git a/tests/frontend/crypto/ask-crypto-intent-card.test.tsx b/tests/frontend/crypto/ask-crypto-intent-card.test.tsx index ce32661..f1572eb 100644 --- a/tests/frontend/crypto/ask-crypto-intent-card.test.tsx +++ b/tests/frontend/crypto/ask-crypto-intent-card.test.tsx @@ -13,6 +13,7 @@ vi.mock("wagmi/connectors", () => ({ })); const mockUseAccount = vi.fn(); +const mockOpenConnectModal = vi.fn(); vi.mock("wagmi", async () => { const actual = await vi.importActual("wagmi"); @@ -24,6 +25,15 @@ vi.mock("wagmi", async () => { }; }); +vi.mock("@rainbow-me/rainbowkit", async () => { + const actual = + await vi.importActual("@rainbow-me/rainbowkit"); + return { + ...actual, + useConnectModal: () => ({ openConnectModal: mockOpenConnectModal }), + }; +}); + import { AskCryptoIntentCard } from "@/components/tool-ui/crypto/ask-crypto-intent-card"; const config = createConfig({ @@ -169,4 +179,23 @@ describe("AskCryptoIntentCard wallet flow", () => { expect(screen.getByText(/0x1234…5678/)).toBeTruthy(); expect(screen.queryByRole("button", { name: /connect.*buy/i })).toBeNull(); }); + + it("opens the RainbowKit connect modal when Confirm is pressed with no wallet", () => { + mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); + renderCard(); + const btn = screen.getByRole("button", { name: /connect.*buy/i }); + fireEvent.click(btn); + expect(mockOpenConnectModal).toHaveBeenCalledTimes(1); + }); + + it("does not open the connect modal when already connected", () => { + mockUseAccount.mockReturnValue({ + address: "0x1234567890abcdef1234567890abcdef12345678", + isConnected: true, + }); + renderCard(); + const btn = screen.getByRole("button", { name: /confirm.*buy/i }); + fireEvent.click(btn); + expect(mockOpenConnectModal).not.toHaveBeenCalled(); + }); }); From fcf242f145e6070805a13d61278d4cf8415f3bd8 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sat, 27 Jun 2026 19:56:25 +0800 Subject: [PATCH 07/35] feat(crypto): self-custody DEX swap via CoW + Alchemy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces simulated crypto orders with a real wallet-aware swap flow backed by CoW Protocol (no API key) and Alchemy (server-side key). Backend - confirm_crypto_order is now a pure intent pause: it validates the LLM's intent and returns {status: awaiting_user, intent}. The frontend card subsumes the old ask_crypto_intent form, pulls real Alchemy balances, fetches a CoW quote, and signs EIP-712 on click. ask_crypto_intent (and its card + tests) are removed. - get_token_balances added but kept out of CRYPTO_TOOLS — the LLM has no wallet address in its context. Exists for programmatic use. - get_swap_quote folded into the card (no separate backend tool). - CRYPTO_AGENT_PROMPT: hard fiat rule ("buy $100 of BTC" is rejected — no fiat on-ramp), explicit price-query vs trade paths, and the rule that the card is the source of truth for wallet address + chain (the LLM must never ask for either). Frontend - confirm-card.tsx: reads wagmi for address/chain, fetches Alchemy balances with native-first sort + 3-layer logo fallback (Alchemy → chain emblem → CoinCap), resolves the LLM's intent with sensible defaults, debounces CoW quote fetch (400ms + AbortController), signs EIP-712 on click. Replaces ask-crypto-intent-card + order-receipt-card. - price-card: formatPrice exported; no-fraction set for JPY/KRW/CLP/ etc., CNY uses bare ¥ glyph, CoinGecko attribution footer. - RainbowKit with 7 wallets: MetaMask, Coinbase, Rainbow, Safe, Binance, Bitget, WalletConnect. WalletConnectBoundary catches the React 19 QR-modal crash and falls back to wagmi-only. Alchemy - /api/alchemy/[...path] edge proxy: per-chain RPC (validates against lib/alchemy/networks.ts catalog + ALCHEMY_DISABLED_NETWORKS) and portfolio/* for the global Portfolio API. Reads ALCHEMY_API_KEY server-side only; browser never sees the key. - /api/alchemy/status reports configured: boolean. - app/alchemy admin page renders the catalog grouped by L1/L2/testnet with per-network Test buttons. Plumbing - lib/wagmi.ts: routes every chain transport through /api/alchemy/ so CORS is the proxy's problem. - lib/alchemy/{networks,portfolio}.ts: static catalog + Portfolio normalizer. - lib/swap/cow-config.ts: CoW settlement contract + per-chain API endpoints + EIP-712 domain/types. - lib/tokens/catalog.ts: 4-token MVP (USDC/WETH/USDT/WBTC) × 3 chains with CoinGecko id + per-chain address map. - patches/cuer@0.0.3: border=0 → 1. The QR generator rejects border=0 at runtime; React 19's stricter DOM validation escalates the resulting crash to a render error. Patch is the minimum visual change until upstream ships the fix. - pnpm-lock.yaml: dep bump (next, wagmi pinned to 2.19.5, langgraph, better-auth, react-email, resend). Tests: 297 passing. Known follow-ups - docs/APIS.md confirm_crypto_order section still describes the OLD signature; the get_swap_quote section describes a tool that was removed when the card took over. Rewrite to match the new contract. - pnpm patchedDependencies only covers the direct-dep instance of cuer; RainbowKit's transitive copy resets on pnpm install. The ErrorBoundary is the safety net; long-term fix is a postinstall hook or pinning RainbowKit to a version that ships a fixed QR lib. --- .env.example | 28 +- app/alchemy/page.tsx | 196 + app/api/alchemy/[...path]/route.ts | 92 + app/api/alchemy/status/route.ts | 11 + app/web3-providers.tsx | 53 +- backend/prompt/system.ts | 55 +- backend/tool/crypto/ask-crypto-intent.ts | 38 - backend/tool/crypto/confirm-crypto-order.ts | 141 +- backend/tool/crypto/get-token-balances.ts | 130 + backend/tool/index.ts | 14 +- .../tool-ui/crypto/ask-crypto-intent-card.tsx | 287 - components/tool-ui/crypto/confirm-card.tsx | 1460 ++ components/tool-ui/crypto/index.ts | 3 +- .../tool-ui/crypto/order-receipt-card.tsx | 145 - components/tool-ui/crypto/price-card.tsx | 58 +- components/tool-ui/toolkit.tsx | 28 +- cspell.json | 56 +- docs/APIS.md | 84 + lib/alchemy/networks.ts | 349 + lib/alchemy/portfolio.ts | 208 + lib/swap/cow-config.ts | 58 + lib/tokens/catalog.ts | 128 + lib/wagmi.ts | 69 +- package.json | 12 +- patches/cuer@0.0.3.patch | 13 + pnpm-lock.yaml | 14657 ++++++++++++++++ pnpm-workspace.yaml | 1 + tests/api/alchemy/portfolio.test.ts | 114 + tests/api/alchemy/proxy.test.ts | 154 + tests/api/alchemy/status.test.ts | 50 + .../tool/crypto/ask-crypto-intent.test.ts | 96 - .../tool/crypto/confirm-crypto-order.test.ts | 134 +- .../tool/crypto/get-token-balances.test.ts | 162 + .../crypto/ask-crypto-intent-card.test.tsx | 201 - .../crypto/crypto-confirm-card.test.tsx | 500 + .../frontend/crypto/crypto-price-card.test.ts | 105 + tests/lib/alchemy/networks.test.ts | 205 + tests/lib/alchemy/portfolio.test.ts | 658 + 38 files changed, 19759 insertions(+), 994 deletions(-) create mode 100644 app/alchemy/page.tsx create mode 100644 app/api/alchemy/[...path]/route.ts create mode 100644 app/api/alchemy/status/route.ts delete mode 100644 backend/tool/crypto/ask-crypto-intent.ts create mode 100644 backend/tool/crypto/get-token-balances.ts delete mode 100644 components/tool-ui/crypto/ask-crypto-intent-card.tsx create mode 100644 components/tool-ui/crypto/confirm-card.tsx delete mode 100644 components/tool-ui/crypto/order-receipt-card.tsx create mode 100644 lib/alchemy/networks.ts create mode 100644 lib/alchemy/portfolio.ts create mode 100644 lib/swap/cow-config.ts create mode 100644 lib/tokens/catalog.ts create mode 100644 patches/cuer@0.0.3.patch create mode 100644 pnpm-lock.yaml create mode 100644 tests/api/alchemy/portfolio.test.ts create mode 100644 tests/api/alchemy/proxy.test.ts create mode 100644 tests/api/alchemy/status.test.ts delete mode 100644 tests/backend/tool/crypto/ask-crypto-intent.test.ts create mode 100644 tests/backend/tool/crypto/get-token-balances.test.ts delete mode 100644 tests/frontend/crypto/ask-crypto-intent-card.test.tsx create mode 100644 tests/frontend/crypto/crypto-confirm-card.test.tsx create mode 100644 tests/frontend/crypto/crypto-price-card.test.ts create mode 100644 tests/lib/alchemy/networks.test.ts create mode 100644 tests/lib/alchemy/portfolio.test.ts diff --git a/.env.example b/.env.example index 63b2706..9cfb71c 100644 --- a/.env.example +++ b/.env.example @@ -61,4 +61,30 @@ RESEND_FROM_EMAIL=onboarding@resend.dev # Comma-separated API keys. The pool picks one at random per request; # 401/403 responses remove that key from the pool and retry. # Get free keys at https://jina.ai/reader after sign-up. -JINA_API_KEYS= \ No newline at end of file +JINA_API_KEYS= + +# Alchemy RPC — https://dashboard.alchemy.com/ +# Server-only. Used by app/api/alchemy/[...path] to proxy JSON-RPC +# requests. Never reaches the browser (no NEXT_PUBLIC_ prefix). +ALCHEMY_API_KEY= +# Optional. Comma-separated Alchemy network slugs the proxy will REJECT +# (turn-off list). The proxy accepts every network in the static catalog +# (lib/alchemy/networks.ts) by default — set this to disable specific +# chains in production (e.g. testnets). Leave empty to enable all. +ALCHEMY_DISABLED_NETWORKS= + +# Crypto real-swap — feature flag for the live Uniswap V3 swap path. +# When unset / "false", confirm_crypto_order with mode=real returns an +# error (safe default). Set to "true" only after wiring a signer + RPC +# (see docs/SWAP.md). The flag is exposed to the browser (NEXT_PUBLIC_) +# because the wagmi hooks live in the React tree. +NEXT_PUBLIC_CRYPTO_REAL_SWAP= + +# WalletConnect / Reown projectId — https://dashboard.reown.com +# Bundled into the browser (NEXT_PUBLIC_) on purpose: it's a public +# dapp identifier, not a secret (Reown docs: "this is a public +# identifier"). The free tier covers 100k connections / month. Required +# for WalletConnect-based wallets (binanceWallet, bitgetWallet) to +# expose their mobile-QR fallback; injected wallets (MetaMask, Coinbase, +# Rainbow, Safe) work without it. +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= \ No newline at end of file diff --git a/app/alchemy/page.tsx b/app/alchemy/page.tsx new file mode 100644 index 0000000..dbe1f02 --- /dev/null +++ b/app/alchemy/page.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { CoinsIcon, Loader2Icon, ZapIcon } from "lucide-react"; + +import { + ALCHEMY_NETWORK_CATALOG, + groupNetworks, + type AlchemyNetworkEntry, +} from "@/lib/alchemy/networks"; +import { cn } from "@/lib/utils"; + +type StatusState = + | { kind: "loading" } + | { kind: "configured" } + | { kind: "unconfigured" } + | { kind: "error"; message: string }; + +export default function AlchemyPage() { + // The catalog is the source of truth for which networks the proxy + // accepts. The optional `ALCHEMY_DISABLED_NETWORKS` denylist is read + // server-side by the proxy; the client always shows the full catalog + // so users can see what was turned off (a disabled network's Test + // button returns 400). + const groups = groupNetworks(Object.keys(ALCHEMY_NETWORK_CATALOG)); + const [status, setStatus] = useState({ kind: "loading" }); + + useEffect(() => { + let cancelled = false; + fetch("/api/alchemy/status") + .then((r) => r.json()) + .then((body: { configured?: boolean }) => { + if (cancelled) return; + setStatus(body.configured ? { kind: "configured" } : { kind: "unconfigured" }); + }) + .catch((e: unknown) => { + if (cancelled) return; + setStatus({ kind: "error", message: e instanceof Error ? e.message : String(e) }); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

Alchemy RPC

+

+ The Next.js app proxies JSON-RPC requests to Alchemy so the API key never reaches the + browser. Pick a network below and hit Test to confirm the proxy round-trips. +

+ +
+ + {groups.length === 0 ? ( + + ) : ( +
+ {groups.map((g) => ( +
+

+ {g.label} +

+
+ {g.networks.map((n) => ( + + ))} +
+
+ ))} +
+ )} +
+ ); +} + +function StatusBadge({ status }: { status: StatusState }) { + if (status.kind === "loading") { + return ( + + Checking API key… + + ); + } + if (status.kind === "error") { + return ( + + ⚠ {status.message} + + ); + } + if (status.kind === "unconfigured") { + return ( + + API key not set — Test calls will return 500 + + ); + } + return ( + + API key configured + + ); +} + +function NetworkCard({ + network, + keyStatus, +}: { + network: AlchemyNetworkEntry; + keyStatus: StatusState["kind"]; +}) { + const [state, setState] = useState<"idle" | "running" | "ok" | "err">("idle"); + const [block, setBlock] = useState(null); + const [err, setErr] = useState(null); + + const onTest = async () => { + setState("running"); + setErr(null); + setBlock(null); + try { + const res = await fetch(`/api/alchemy/${network.slug}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "eth_blockNumber", params: [], id: 1 }), + }); + if (!res.ok) { + const text = await res.text(); + setErr(`HTTP ${res.status} — ${text.slice(0, 80)}`); + setState("err"); + return; + } + const body = (await res.json()) as { result?: string; error?: { message: string } }; + if (body.error) { + setErr(body.error.message); + setState("err"); + return; + } + setBlock(body.result ?? null); + setState("ok"); + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : String(e)); + setState("err"); + } + }; + + return ( +
+
+
+

{network.name}

+

{network.slug}

+
+ +
+ {state === "ok" && block ? ( +

+ block: {block} ({parseInt(block, 16).toLocaleString()}) +

+ ) : null} + {state === "err" && err ? ( +

{err}

+ ) : null} +
+ ); +} + +function EmptyState() { + return ( +
+

+ NEXT_PUBLIC_ALCHEMY_NETWORKS is empty. Set it in{" "} + .env.local as a comma-separated list of Alchemy network + slugs. +

+
+ ); +} diff --git a/app/api/alchemy/[...path]/route.ts b/app/api/alchemy/[...path]/route.ts new file mode 100644 index 0000000..478faea --- /dev/null +++ b/app/api/alchemy/[...path]/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; + +export const runtime = "edge"; + +import { parseNetworkList, resolveAllowlist } from "@/lib/alchemy/networks"; + +function getCorsHeaders() { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }; +} + +// ponytail: this is a thin Alchemy proxy. The browser sends +// `POST /api/alchemy/` with a JSON-RPC body; we forward it +// to `https://.g.alchemy.com/v2/` using the server-only +// ALCHEMY_API_KEY. The key never enters the client bundle. The proxy +// rejects any slug that isn't in the static catalog (so the URL can't +// be tricked into calling arbitrary hosts); the optional +// `ALCHEMY_DISABLED_NETWORKS` denylist further restricts that. +// +// The `/api/alchemy/portfolio/` namespace is a separate +// branch that forwards to the global Portfolio API +// (`https://api.g.alchemy.com/data/v1//assets/`). +// It's not gated by the network allowlist — Portfolio API takes the +// list of networks in its body, so the allowlist wouldn't even apply. +async function handle( + req: Request, + method: "GET" | "POST", + ctx: { params: Promise<{ path: string[] }> }, +) { + try { + const { path } = await ctx.params; + const first = path?.[0]; + + const apiKey = process.env.ALCHEMY_API_KEY; + if (!apiKey) { + return NextResponse.json({ error: "Alchemy RPC not configured" }, { status: 500 }); + } + + let upstreamUrl: string; + + if (first === "portfolio") { + const endpoint = path.slice(1).join("/"); + if (!endpoint) { + return NextResponse.json({ error: "portfolio endpoint is required" }, { status: 400 }); + } + upstreamUrl = `https://api.g.alchemy.com/data/v1/${apiKey}/assets/${endpoint}`; + } else { + if (!first) { + return NextResponse.json({ error: "network is required" }, { status: 400 }); + } + const allowlist = resolveAllowlist( + parseNetworkList(process.env.ALCHEMY_DISABLED_NETWORKS ?? ""), + ); + if (!allowlist.includes(first)) { + return NextResponse.json({ error: `network '${first}' is not allowed` }, { status: 400 }); + } + upstreamUrl = `https://${first}.g.alchemy.com/v2/${apiKey}`; + } + + const init: RequestInit = { method, signal: req.signal }; + if (method === "POST") { + init.headers = { "Content-Type": "application/json" }; + init.body = await req.text(); + } + + const res = await fetch(upstreamUrl, init); + + const headers = new Headers(res.headers); + headers.delete("content-encoding"); + headers.delete("content-length"); + headers.delete("transfer-encoding"); + for (const [k, v] of Object.entries(getCorsHeaders())) headers.set(k, v); + + return new NextResponse(res.body, { + status: res.status, + statusText: res.statusText, + headers, + }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "Unknown error"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export const POST = (req: Request, ctx: { params: Promise<{ path: string[] }> }) => + handle(req, "POST", ctx); +export const GET = (req: Request, ctx: { params: Promise<{ path: string[] }> }) => + handle(req, "GET", ctx); +export const OPTIONS = () => new NextResponse(null, { status: 204, headers: getCorsHeaders() }); diff --git a/app/api/alchemy/status/route.ts b/app/api/alchemy/status/route.ts new file mode 100644 index 0000000..9676f59 --- /dev/null +++ b/app/api/alchemy/status/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +// ponytail: just reports whether ALCHEMY_API_KEY is set — never the +// value. The frontend uses this for the "🔑 configured / ⚠ not set" +// status badge on the Alchemy admin page. +export function GET() { + const key = process.env.ALCHEMY_API_KEY; + return NextResponse.json({ configured: Boolean(key && key.length > 0) }); +} diff --git a/app/web3-providers.tsx b/app/web3-providers.tsx index 716e7ad..5b1b068 100644 --- a/app/web3-providers.tsx +++ b/app/web3-providers.tsx @@ -1,32 +1,43 @@ "use client"; -import { useState, type ReactNode } from "react"; -import { WagmiProvider } from "wagmi"; +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { WagmiProvider, type Config } from "wagmi"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { RainbowKitProvider, lightTheme, darkTheme } from "@rainbow-me/rainbowkit"; +import { RainbowKitProvider, lightTheme } from "@rainbow-me/rainbowkit"; import { wagmiConfig } from "@/lib/wagmi"; -// ponytail: wagmi is display-only here — no writeContract, no signing. -// The crypto agent's orders are simulated; wagmi only surfaces the -// connected address + balance, and RainbowKit provides the wallet -// picker modal the order button opens. Lazy-init the QueryClient -// and the RainbowKit theme so React strict-mode double-invoke doesn't -// share state across requests. +// WalletConnect v2 modal emits HTML with `border="0"` on , which +// React 19's stricter DOM validation rejects with "invalid border=0". +// The other 6 wallets (MetaMask / Coinbase / Rainbow / Safe / Binance +// / Bitget) work without WalletConnect, so the boundary falls back to +// wagmi-only — the user keeps the page, loses the picker modal. When +// RainbowKit or @walletconnect/ethereum-provider ships a React 19 fix, +// drop the boundary. +class WalletConnectBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + componentDidCatch(error: Error, info: ErrorInfo) { + // eslint-disable-next-line no-console + console.error("[web3-providers] RainbowKit crashed:", error, info); + } + render() { + if (this.state.failed) return this.props.children; + return ( + + {this.props.children} + + ); + } +} + export function Web3Providers({ children }: { children: ReactNode }) { - const [queryClient] = useState(() => new QueryClient()); return ( - - - - {children} - + + + {children} ); diff --git a/backend/prompt/system.ts b/backend/prompt/system.ts index 45c8d16..c9b6505 100644 --- a/backend/prompt/system.ts +++ b/backend/prompt/system.ts @@ -70,20 +70,41 @@ export const WEATHER_AGENT_PROMPT = `You answer weather questions by calling too On any tool returning {success: false} or an error, ask the user for the missing piece (a different spelling, a place name, location permission). Never invent coordinates or guess the location.`; -// Dropped into the crypto sub-agent node. Mirrors the weather prompt's -// one-tool-per-turn discipline so the human-in-the-loop card (ask_crypto_intent) -// never races another tool result. The flow is: collect intent → fetch -// latest price → finalize the simulated order. "Orders" are simulated — -// the tool returns a fake receipt, no signing, no chain. -export const CRYPTO_AGENT_PROMPT = `You answer crypto questions by calling tools, not from your own knowledge. Run these steps in order, one tool per turn. - -1. ask_crypto_intent — when the user wants to buy, sell, or place a trade and the message did not already include a coin + amount. The card pauses the turn; you will be resumed with a HumanMessage carrying the pick {coin_id, coin_symbol, amount, currency, side} or {error}. Do not batch any other tool in this turn. If the user only wants a price check (no trade), skip this step and go straight to get_crypto_price. - - Currency detection: read the user's message and figure out which ISO 4217 currency they are thinking in. Pass it to the tool as \`currency\`. Signals: 元 / RMB / ¥ / 人民币 / CNY → CNY. $ / USD / dollar → USD. € / EUR / euro → EUR. £ / GBP / pound → GBP. ¥ / JPY / 日元 → JPY (note: ¥ alone is ambiguous — only treat as JPY if the user wrote JPY / 日元 / 日本円 alongside it, otherwise default to CNY). If the user named a specific amount, also pass it as \`amount\` to pre-fill the input. If the currency is unclear, omit the field and the card defaults to USD. - -2. get_crypto_price — with the ids from the intent, or whatever the user asked about. Pass multiple ids in one call when comparing. If the user named a coin by ticker (e.g. "BTC"), map it to the CoinGecko id (e.g. "bitcoin") before calling. -3. Convert to USD when the user picked a non-USD currency. If the resumed pick has \`currency\` != "USD", call get_fx_rate(currency, USD) and compute \`amount_usd = amount * rate\`. If currency is USD, skip this step and use the user's amount directly. -4. confirm_crypto_order — only when the user wants to trade AND you have a valid intent and a fresh price. Pass the converted \`amount_usd\` (never the original \`amount\`) and \`price_at_confirm\` from step 2. Do not batch it with anything else. If the user only wanted a price, skip this step and just answer. -5. Reply in one short sentence. The cards already show the numbers — do not repeat prices, sparkline, or order details. - -On any tool returning {success: false} or an error, ask the user to clarify (different coin, valid amount, retry). Never invent prices, quantities, fx rates, or order ids. CoinGecko's free tier rate-limits aggressively — if get_crypto_price keeps failing, tell the user to wait and try again.`; +// Dropped into the crypto sub-agent node. There are two distinct flows: +// +// Price query → get_crypto_price → short reply +// Trade → confirm_crypto_order → short reply +// +// The trade flow never touches get_crypto_price — the user already +// knows the market (they're initiating a trade), and burning a +// CoinGecko call on a price they don't need just hits the rate limit. +// The swap card is a HARD checkpoint — even when the user's message +// names a coin + amount, the agent must still pause for the user to +// explicitly click Sign on the rendered card before any state changes. +// +// The wallet is the source of truth for what the user can spend — but +// you cannot see the wallet's address or chain id from this prompt. +// NEVER ask the user for their wallet address or chain; the card +// reads both from wagmi. There is no fiat on-ramp in this flow — a +// "buy $100 of BTC" message gets redirected to "pick a token from +// your wallet to swap" (see fiat rule below). The user signs EIP-712 +// orders on CoW Protocol; no contract address is hardcoded, no token +// approval is required by the user. +export const CRYPTO_AGENT_PROMPT = `You answer crypto questions by calling tools, not from your own knowledge. Pick the flow based on the user's intent. + +PRICE QUERY FLOW (user asks "what's the price", "compare X and Y", "how is BTC doing"): +1. get_crypto_price — Call with the CoinGecko ids (e.g. "bitcoin", "ethereum", "usd-coin"). Map tickers to ids. Pass multiple ids in one call when comparing. The price card leads the response. +2. Reply in one short sentence. The card already shows the numbers — do not repeat prices, sparkline, or 24h change. + +TRADE FLOW (user wants to sell, buy, swap, or exchange tokens): +1. Fiat rule (HARD CONSTRAINT). If the user's message names a fiat amount ("buy $100 of BTC", "花 500 人民币买 BTC", "用 100 EUR 换 ETH", "spend 50 JPY"), DO NOT call confirm_crypto_order. Reply in one sentence explaining that this agent is a self-custody DEX flow and only supports swapping tokens the user already holds. Invite them to say "swap 100 USDC to BTC" once they've picked a source token from their wallet. + +2. confirm_crypto_order — Call with the user's intent. Required: \`side\` ("sell my X" / "swap X for Y" → sell, "buy Y with X" → buy). Optional: \`source_coin_id\` when the user named a source token (CoinGecko id: "usd-coin", "ethereum", "wrapped-bitcoin"), \`amount\` when the user named a number, \`target_coin_id\` when the user named what they want to receive. The card wakes the wallet (RainbowKit modal if not connected), lists the user's actual ERC20 balances from Alchemy, picks a sensible default target if none was named, fetches a live CoW quote, and exposes one Sign & Place Order button. The user must click before any state changes — the closing ToolMessage (status: signed / simulated_filled / cancelled / error) is what you use to write the final sentence. NEVER ask the user for their wallet address or chain — the card handles both. Do NOT batch with any other tool. + +3. Reply in one short sentence. The card already shows the numbers — do not repeat quote details, balances, or order ids. + +GENERAL RULES: +- On any tool returning {success: false} or an error, ask the user to clarify (different coin, valid amount, retry, different chain). Never invent prices, quantities, fx rates, addresses, or order ids. +- CoinGecko's free tier rate-limits aggressively — if get_crypto_price keeps failing, tell the user to wait and try again. +- CoW's quote endpoint can return NoLiquidity for exotic pairs — surface that error and ask the user to pick a different target token. +- Never repeat CoinGecko/CoW numbers in your prose — the cards render them.`; diff --git a/backend/tool/crypto/ask-crypto-intent.ts b/backend/tool/crypto/ask-crypto-intent.ts deleted file mode 100644 index 260bd19..0000000 --- a/backend/tool/crypto/ask-crypto-intent.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { tool } from "@langchain/core/tools"; -import { z } from "zod"; -import { interrupt } from "@langchain/langgraph"; - -export const ASK_CRYPTO_INTENT_TOOL_NAME = "ask_crypto_intent"; - -// ask_crypto_intent is a pure trigger. The tool pauses via interrupt; the -// frontend's addResult resumes with {coin_id, coin_symbol, amount, currency, side} -// or {error}, which becomes the ToolMessage content the LLM reads next pass. -// Shape mirrors AskCryptoIntentResult in components/tool-ui/crypto. -export const askCryptoIntentTool = tool( - async ({ message = "Which crypto and how much?" } = {}) => { - return interrupt({ ui: ASK_CRYPTO_INTENT_TOOL_NAME, data: {}, message }); - }, - { - name: ASK_CRYPTO_INTENT_TOOL_NAME, - description: `Render a buy/sell form card so the user can pick a coin and amount. Use this whenever the agent needs a trade intent to proceed — typically because the user asked to buy, sell, or "go long" a coin. Do NOT batch other tool calls in the same turn; the card pauses the turn until the user replies. Call this at most once per turn. - -Detect the user's currency from the message (元/RMB/CNY/¥ → CNY, $/USD → USD, €/EUR, £/GBP, ¥/JPY) and pass it as \`currency\`. If the user named a specific amount, pass it as \`amount\` to pre-fill the input. If the currency is ambiguous, omit the field and the card will default to USD.`, - schema: z.object({ - message: z.string().optional().describe("Short prompt shown above the form; one sentence."), - currency: z - .string() - .length(3) - .optional() - .describe( - "ISO 4217 currency code the user is thinking in (e.g. 'USD', 'CNY', 'EUR', 'JPY', 'GBP'). Detected from the message — 元/RMB → CNY, $/USD → USD, etc. Card displays the amount in this currency.", - ), - amount: z - .number() - .positive() - .optional() - .describe( - "Pre-fill notional in the detected currency, only when the user named a specific amount. Card will pre-fill the amount input; the user can still edit.", - ), - }), - }, -); diff --git a/backend/tool/crypto/confirm-crypto-order.ts b/backend/tool/crypto/confirm-crypto-order.ts index 7e07620..4b37b4c 100644 --- a/backend/tool/crypto/confirm-crypto-order.ts +++ b/backend/tool/crypto/confirm-crypto-order.ts @@ -1,72 +1,107 @@ -import { randomUUID } from "node:crypto"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; -import { safeDivide } from "@/lib/decimal"; +import { resolveToken } from "@/lib/tokens/catalog"; -// ponytail: simulated order — no signing, no chain, no RPC. The frontend -// card shows a "Simulated Filled" badge and a "view on Etherscan" link -// that's disabled. Upgrade path: swap body for wagmi useWriteContract -// calling a Uniswap V3 Router (needs RPC + Router address in env). +// The LLM's only job in the trade flow is to parse the user's intent. +// Everything wallet-related (balances, chain, address) is read by the +// frontend card from wagmi. Everything price-related (quote, fees) is +// fetched client-side from CoW. The tool is a pure pause: +// +// 1. LLM emits `confirm_swap(side, source?, amount?, target?)`. +// 2. Tool returns `{status:"awaiting_user", intent:{...}}`. The card +// renders with the user's actual wallet balances + a live CoW +// quote. +// 3. User clicks Sign → card signs EIP-712 + POSTs to CoW + calls +// addResult({status:"signed" | "simulated_filled" | "cancelled" | +// "error", ...}). The LLM then writes the closing sentence. +// +// Why merge the old `ask_crypto_intent` + `confirm_crypto_order`? +// Previously the user had to fill a buy/sell form, click Confirm, see +// the quote card, then click Sign. Two clicks, two cards, and the +// first form was useless the moment the LLM already knew side + +// source from the message. The wallet-aware card subsumes both: it +// shows real balances, picks a sensible target, fetches the live +// quote, and exposes one Sign button. + +export type SwapIntent = { + side: "buy" | "sell"; + source_coin_id: string | null; + amount: number | null; + target_coin_id: string | null; +}; + +const COIN_ID_RE = /^[a-z0-9-]+$/; export const confirmCryptoOrderTool = tool( - async ({ - coin_id, - coin_symbol, - amount_usd, - price_at_confirm, - side, - }: { - coin_id: string; - coin_symbol: string; - amount_usd: number; - price_at_confirm: number; - side: "buy" | "sell"; - }) => { - if (amount_usd <= 0) { - return JSON.stringify({ success: false, error: "amount_usd must be > 0" }); + async ({ side, source_coin_id, amount, target_coin_id }) => { + // Validate that the named tokens exist on SOME supported chain. + // The frontend card resolves chain from wagmi; we can't validate + // chain availability here without that info, but we can catch + // obviously bogus CoinGecko ids early. + if (source_coin_id && !COIN_ID_RE.test(source_coin_id)) { + return JSON.stringify({ + status: "error", + error: `source_coin_id '${source_coin_id}' is not a valid CoinGecko id`, + }); } - if (price_at_confirm <= 0) { - return JSON.stringify({ success: false, error: "price_at_confirm must be > 0" }); + if (target_coin_id && !COIN_ID_RE.test(target_coin_id)) { + return JSON.stringify({ + status: "error", + error: `target_coin_id '${target_coin_id}' is not a valid CoinGecko id`, + }); } - - // Decimal divide: qty = amount_usd / price_at_confirm. Plain - // number division can drift (100/3 = 33.333333333333336), which - // shows up in the receipt card. safeDivide returns null on - // divide-by-zero so we report it instead of writing NaN. - const qtyDecimal = safeDivide(amount_usd, price_at_confirm); - if (qtyDecimal === null) { - return JSON.stringify({ success: false, error: "price_at_confirm must be > 0" }); + // Light chain sanity check — every supported chain has at least one + // token in the catalog. If neither source nor target is named, we + // still let the card proceed (it picks defaults from the wallet). + if (source_coin_id || target_coin_id) { + const probe = source_coin_id ?? target_coin_id!; + const okOnMainnet = resolveToken(probe, 1) != null; + if (!okOnMainnet) { + return JSON.stringify({ + status: "error", + error: `coin_id '${probe}' is not in the supported token catalog`, + }); + } } - return JSON.stringify({ - success: true, - order: { - id: `ord_${randomUUID()}`, - coin: coin_id, - symbol: coin_symbol, - side, - amount_usd, - qty: qtyDecimal.toNumber(), - price_at_confirm, - status: "simulated_filled", - timestamp: new Date().toISOString(), - note: "This is a simulated order. No on-chain transaction was sent.", - }, - }); + const intent: SwapIntent = { + side, + source_coin_id: source_coin_id ?? null, + amount: amount ?? null, + target_coin_id: target_coin_id ?? null, + }; + return JSON.stringify({ status: "awaiting_user", intent }); }, { name: "confirm_crypto_order", description: - "Finalize a simulated crypto order. Call only after ask_crypto_intent returned a valid pick and get_crypto_price gave the current price. Returns a fake order receipt — no signing, no chain.", + "Render a wallet-aware swap card and pause for the user to sign. The LLM parses the user's intent (side + optional source token / amount / target) and passes it here; the card wakes the wallet (RainbowKit modal if not connected), lists the user's actual balances from Alchemy, picks a sensible default target if none was named, fetches a live CoW quote, and exposes one Sign & Place Order button. The user must click before any state changes — the closing ToolMessage (signed / simulated_filled / cancelled / error) is what the model uses to write the final sentence. Pass source_coin_id (CoinGecko id) only when the user named a source token; pass amount only when the user named a number; pass target_coin_id only when the user named what they want to receive. Do NOT batch with any other tool.", schema: z.object({ - coin_id: z.string().describe("CoinGecko coin id, e.g. 'bitcoin'."), - coin_symbol: z.string().describe("Ticker, e.g. 'BTC'."), - amount_usd: z.number().describe("Notional in USD the user wants to trade."), - price_at_confirm: z + side: z + .enum(["buy", "sell"]) + .describe( + "Trade direction inferred from the user's message. 'sell my X' / 'swap X for Y' → sell (you're spending X). 'buy Y with X' → buy (you're acquiring Y).", + ), + source_coin_id: z + .string() + .optional() + .describe( + "CoinGecko id of the source token the user named (e.g. 'usd-coin' for USDC, 'ethereum' for WETH, 'wrapped-bitcoin' for WBTC). Omit when the user didn't name one — the card picks from the user's wallet holdings.", + ), + amount: z .number() - .describe("Spot price observed from the most recent get_crypto_price call."), - side: z.enum(["buy", "sell"]).describe("Trade direction."), + .positive() + .optional() + .describe( + "Human-readable amount the user named (e.g. 100 for 100 USDC, 0.1 for 0.1 WETH). Omit when the user didn't name a number — the card defaults to 'all of it' for sell, leaves blank for buy.", + ), + target_coin_id: z + .string() + .optional() + .describe( + "CoinGecko id of the target token the user wants to receive (e.g. 'ethereum' for WETH). Omit when the user didn't name a target — the card picks a sensible default (WETH for stablecoin sellers, USDC for ETH/WBTC sellers).", + ), }), }, ); diff --git a/backend/tool/crypto/get-token-balances.ts b/backend/tool/crypto/get-token-balances.ts new file mode 100644 index 0000000..1807355 --- /dev/null +++ b/backend/tool/crypto/get-token-balances.ts @@ -0,0 +1,130 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; + +// Alchemy network slugs for the chains we support. Matches the slug +// pattern used by app/api/alchemy/[...path]/route.ts. We only carry +// chains CoW is on (Ethereum / Arbitrum / Base); other chains the +// Alchemy proxy accepts (Polygon, Optimism, etc.) are intentionally +// not here until a swap path is added for them. +const CHAIN_TO_ALCHEMY_SLUG: Record = { + 1: "eth-mainnet", + 42161: "arb-mainnet", + 8453: "base-mainnet", +}; + +// ponytail: EIP-55 checksum matters for re-routing into CoW. Wagmi gives +// us mixed-case addresses; we keep them as-is so the same address works +// in both the Alchemy and CoW calls. +const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; + +type AlchemyBalance = { contractAddress: string; tokenBalance: string }; +type AlchemyMetadata = { + name?: string; + symbol?: string; + decimals?: number; + logo?: string | null; +}; + +async function alchemyRpc( + slug: string, + key: string, + method: string, + params: unknown[], +): Promise { + const res = await fetch(`https://${slug}.g.alchemy.com/v2/${key}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + if (!res.ok) { + throw new Error(`alchemy ${res.status}`); + } + const json = (await res.json()) as { + result?: unknown; + error?: { message?: string }; + }; + if (json.error) { + throw new Error(`alchemy: ${json.error.message ?? "unknown error"}`); + } + return json.result; +} + +// Alchemy returns token balances as a hex string in the token's smallest +// unit. formatBalance converts to a human string without float drift by +// doing the division in bigint. +function formatBalance(hex: string, decimals: number): string { + const value = BigInt(hex); + const denom = BigInt(10) ** BigInt(decimals); + const whole = value / denom; + const frac = value % denom; + if (frac === BigInt(0)) return whole.toString(); + const fracStr = frac.toString().padStart(decimals, "0").replace(/0+$/, ""); + return fracStr ? `${whole.toString()}.${fracStr}` : whole.toString(); +} + +export const getTokenBalancesTool = tool( + async ({ chainId, address }: { chainId: number; address: string }) => { + const slug = CHAIN_TO_ALCHEMY_SLUG[chainId]; + if (!slug) { + return JSON.stringify({ success: false, error: `chain_id ${chainId} is not supported` }); + } + if (!ADDRESS_RE.test(address)) { + return JSON.stringify({ + success: false, + error: "address must be a 0x-prefixed 40-hex string", + }); + } + const apiKey = process.env.ALCHEMY_API_KEY; + if (!apiKey) { + return JSON.stringify({ + success: false, + error: "ALCHEMY_API_KEY is not configured on the server", + }); + } + + try { + const balances = (await alchemyRpc(slug, apiKey, "alchemy_getTokenBalances", [ + address, + "erc20", + ])) as { tokenBalances: AlchemyBalance[] }; + + const nonZero = balances.tokenBalances.filter((b) => BigInt(b.tokenBalance) > BigInt(0)); + if (nonZero.length === 0) { + return JSON.stringify({ success: true, tokens: [] }); + } + + const tokens = await Promise.all( + nonZero.map(async (b) => { + const meta = (await alchemyRpc(slug, apiKey, "alchemy_getTokenMetadata", [ + b.contractAddress, + ])) as AlchemyMetadata; + const decimals = typeof meta.decimals === "number" ? meta.decimals : 18; + return { + contractAddress: b.contractAddress, + symbol: meta.symbol ?? "UNKNOWN", + name: meta.name ?? null, + decimals, + balance: formatBalance(b.tokenBalance, decimals), + logo: meta.logo ?? null, + }; + }), + ); + return JSON.stringify({ success: true, tokens }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return JSON.stringify({ success: false, error: msg }); + } + }, + { + name: "get_token_balances", + description: + "List the ERC20 tokens a wallet currently holds on Ethereum, Arbitrum, or Base, with USD-denominated human-readable balances (USDC = 100, WETH = 0.1, etc.). Calls Alchemy's alchemy_getTokenBalances + alchemy_getTokenMetadata under the hood. The address must be the connected wallet's address (the LLM should pull it from the user's message context, not invent one). If the wallet holds no tokens on the requested chain, returns an empty list — not an error. Requires ALCHEMY_API_KEY to be configured on the server.", + schema: z.object({ + chainId: z + .number() + .int() + .describe("EVM chain id. One of 1 (Ethereum), 42161 (Arbitrum), 8453 (Base)."), + address: z.string().describe("Wallet address, 0x-prefixed 40-hex chars. Case-insensitive."), + }), + }, +); diff --git a/backend/tool/index.ts b/backend/tool/index.ts index 85427f0..342a520 100644 --- a/backend/tool/index.ts +++ b/backend/tool/index.ts @@ -3,22 +3,20 @@ import { searchWeb } from "@/backend/tool/web-search"; import { askLocationTool } from "@/backend/tool/ask-location"; import { geocodeLocationTool } from "@/backend/tool/geocode"; import { getWeatherTool } from "@/backend/tool/fetch-weather"; -import { askCryptoIntentTool } from "@/backend/tool/crypto/ask-crypto-intent"; import { getCryptoPriceTool } from "@/backend/tool/crypto/get-crypto-price"; import { getFxRateTool } from "@/backend/tool/crypto/get-fx-rate"; import { confirmCryptoOrderTool } from "@/backend/tool/crypto/confirm-crypto-order"; // ponytail: keep the tool list in one place so the graph binds it from a // single source. Adding a tool = drop a file + add one line here. +// +// Note: get_swap_quote + ask_crypto_intent were folded into +// confirm_crypto_order. The card now reads wagmi/Alchemy + fetches CoW +// quotes client-side; the backend only emits the intent pause. export const WEATHER_TOOLS = [askLocationTool, geocodeLocationTool, getWeatherTool]; -export const CRYPTO_TOOLS = [ - askCryptoIntentTool, - getCryptoPriceTool, - getFxRateTool, - confirmCryptoOrderTool, -]; +export const CRYPTO_TOOLS = [getCryptoPriceTool, getFxRateTool, confirmCryptoOrderTool]; export const ALL_TOOLS = [ fetchUrl, @@ -26,7 +24,6 @@ export const ALL_TOOLS = [ askLocationTool, geocodeLocationTool, getWeatherTool, - askCryptoIntentTool, getCryptoPriceTool, getFxRateTool, confirmCryptoOrderTool, @@ -38,7 +35,6 @@ export { askLocationTool, geocodeLocationTool, getWeatherTool, - askCryptoIntentTool, getCryptoPriceTool, getFxRateTool, confirmCryptoOrderTool, diff --git a/components/tool-ui/crypto/ask-crypto-intent-card.tsx b/components/tool-ui/crypto/ask-crypto-intent-card.tsx deleted file mode 100644 index 9ca97e0..0000000 --- a/components/tool-ui/crypto/ask-crypto-intent-card.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { - AlertCircleIcon, - CheckCircle2Icon, - CoinsIcon, - Loader2Icon, - WalletIcon, -} from "lucide-react"; -import { formatUnits } from "viem"; -import { useAccount, useBalance } from "wagmi"; -import { useConnectModal } from "@rainbow-me/rainbowkit"; -import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; -import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; - -import { Button } from "@/components/ui/button"; -import { unwrapToolResult } from "@/components/tool-ui/tool-result"; -import { cn } from "@/lib/utils"; -import { formatAmount, parseAmount } from "@/lib/decimal"; - -// Tool result the user picks from the card. Mirrors the backend tool's -// resume payload (backend/tool/crypto/ask-crypto-intent.ts). -// { coin_id, coin_symbol, amount, currency, side } — user confirmed -// { error } — user cancelled -// The LLM detects the currency from the message and passes it through -// `args.currency`; the card lets the user confirm or change the amount -// in that currency, then the LLM converts to USD before confirm_crypto_order. -export type AskCryptoIntentResult = - | { - coin_id: string; - coin_symbol: string; - amount: number; - currency: string; - side: "buy" | "sell"; - } - | { error: string }; - -// Tool call args the LLM fills when invoking ask_crypto_intent. The card -// reads currency (required, defaults to USD) and amount (optional pre-fill). -type AskCryptoIntentArgs = { - message?: string; - currency?: string; - amount?: number; -}; - -// Hardcoded list of coins the card can resolve. CoinGecko ids are stable; -// mapping ticker → id is the only piece the LLM has to be careful about. -// Upgrade path: pull this from CoinGecko's /coins/list when we add a search box. -const COIN_OPTIONS = [ - { coin_id: "bitcoin", symbol: "BTC", name: "Bitcoin" }, - { coin_id: "ethereum", symbol: "ETH", name: "Ethereum" }, - { coin_id: "solana", symbol: "SOL", name: "Solana" }, - { coin_id: "binancecoin", symbol: "BNB", name: "BNB" }, - { coin_id: "dogecoin", symbol: "DOGE", name: "Dogecoin" }, - { coin_id: "usd-coin", symbol: "USDC", name: "USD Coin" }, -] as const; - -type Mode = "idle" | "submitting"; - -export const AskCryptoIntentCard: ToolCallMessagePartComponent = ({ - result, - args, -}) => { - const parsed = unwrapToolResult(result); - const sendCommand = useLangGraphSendCommand(); - const { address, isConnected } = useAccount(); - const { data: balance } = useBalance({ address }); - const { openConnectModal } = useConnectModal(); - - // LLM detected currency from the message; default to USD if missing. - const currency = (args?.currency ?? "USD").toUpperCase(); - const [coinId, setCoinId] = useState(COIN_OPTIONS[0].coin_id); - const [amount, setAmount] = useState(args?.amount != null ? String(args.amount) : "100"); - const [side, setSide] = useState<"buy" | "sell">("buy"); - const [mode, setMode] = useState("idle"); - // Wallet connection is part of the order flow. If the user clicks - // Confirm without a connected wallet, we open RainbowKit's modal and - // queue the resume until they connect. `pendingResume` captures the - // current form so the effect below can re-emit it once isConnected - // flips (RainbowKit's modal calls wagmi's connect() under the hood - // and closes itself on success). - const [pendingResume, setPendingResume] = useState(null); - - const coin = COIN_OPTIONS.find((c) => c.coin_id === coinId) ?? COIN_OPTIONS[0]; - // Decimal-validated amount. null = invalid (empty, negative, scientific, - // non-numeric, or over the safety cap). See lib/decimal for the rules. - const amountDecimal = parseAmount(amount); - const isValid = amountDecimal !== null; - - const resume = (payload: AskCryptoIntentResult) => { - sendCommand({ resume: JSON.stringify(payload) }); - }; - - const buildResume = (): AskCryptoIntentResult => ({ - coin_id: coin.coin_id, - coin_symbol: coin.symbol, - // .toNumber() is safe here — parseAmount already enforced the safety - // cap (≤ 1e15), well under Number.MAX_SAFE_INTEGER (~9e15). - amount: amountDecimal!.toNumber(), - currency, - side, - }); - - const handleConfirm = () => { - if (!isValid) return; - if (!isConnected) { - // Queue the resume; the effect below flushes it once isConnected - // flips. openConnectModal is undefined when RainbowKitProvider - // isn't mounted (e.g. unit tests) — fall back to a no-op rather - // than throwing. - setPendingResume(buildResume()); - openConnectModal?.(); - return; - } - setMode("submitting"); - resume(buildResume()); - }; - - // Flush queued resume once the wallet connects. Depends only on - // isConnected + pendingResume (sendCommand is intentionally omitted - // — it comes from a hook and is stable enough for our purposes). - useEffect(() => { - if (isConnected && pendingResume) { - setMode("submitting"); - resume(pendingResume); - setPendingResume(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isConnected, pendingResume]); - - const handleCancel = () => { - setMode("submitting"); - resume({ error: "User cancelled" }); - }; - - return ( -
-
-
-
- -
-
-

Place a simulated trade

-

- {parsed && "coin_id" in parsed - ? "Sent to the assistant." - : "Pick a coin and amount. No on-chain transaction is sent."} -

-
-
- - {/* Resolved: show the chosen pick as a confirmation, no more actions. */} - {parsed && "coin_id" in parsed && ( -
- -
-

- {parsed.side === "buy" ? "Buy" : "Sell"} {parsed.coin_symbol} -

-

- {formatAmount(parsed.amount)} {parsed.currency} notional -

-
-
- )} - - {parsed && "error" in parsed && ( -
- - {parsed.error} -
- )} - - {/* Interactive: only when user hasn't decided yet. */} - {!parsed && ( -
-
- {(["buy", "sell"] as const).map((s) => ( - - ))} -
- - - - - - {/* Wallet status — only shows the connected address. The order - button handles the "not connected" case by opening the picker. */} - {isConnected && address ? ( -
- - - {address.slice(0, 6)}…{address.slice(-4)} - {balance - ? ` · ${formatUnits(balance.value, balance.decimals)} ${balance.symbol}` - : ""} - -
- ) : null} - -
- - -
-
- )} -
-
- ); -}; diff --git a/components/tool-ui/crypto/confirm-card.tsx b/components/tool-ui/crypto/confirm-card.tsx new file mode 100644 index 0000000..108c4a1 --- /dev/null +++ b/components/tool-ui/crypto/confirm-card.tsx @@ -0,0 +1,1460 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + AlertCircleIcon, + ArrowDownIcon, + CheckCircle2Icon, + CoinsIcon, + ExternalLinkIcon, + Loader2Icon, + WalletIcon, + XCircleIcon, +} from "lucide-react"; +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; +import { useAccount, useSignTypedData, useSwitchChain } from "wagmi"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { formatUnits, type Address, type Hex } from "viem"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { formatAmount, formatQty, parseAmount } from "@/lib/decimal"; +import { + COW_EIP712_DOMAIN, + COW_EIP712_TYPES, + getCowConfig, + type CowChainId, +} from "@/lib/swap/cow-config"; +import { + defaultTargetSlug, + resolveToken, + tokensForChain, + type TokenMeta, + type TokenSlug, +} from "@/lib/tokens/catalog"; +import { fetchEnrichedBalances } from "@/lib/alchemy/portfolio"; +import { getNetworkLogoByChainId } from "@/lib/alchemy/networks"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; + +// Wallet-aware swap card. The LLM parses the user's intent (side + +// optional source / amount / target); everything else — wallet +// connection, balance enumeration, CoW quote fetching, EIP-712 +// signing, /orders submission — happens in the card. +// +// Flow: +// awaiting_user → fetch wagmi state +// → enumerate Alchemy balances +// → resolve intent into source/amount/target +// → fetch CoW quote (debounced) +// → render preview +// user clicks Sign → EIP-712 + POST /orders → addResult("signed") +// or addResult("simulated_filled") for simulate mode +// user clicks Cancel → addResult("cancelled") +// sign/POST fails → addResult("error") + +type Intent = { + side: "buy" | "sell"; + source_coin_id: string | null; + amount: number | null; + target_coin_id: string | null; +}; + +type Args = { + side: "buy" | "sell"; + source_coin_id?: string; + amount?: number; + target_coin_id?: string; +}; + +type CowQuote = { + sellToken: Address; + buyToken: Address; + sellAmount: string; + buyAmount: string; + validTo: number; + feeAmount: string; + kind: "sell" | "buy"; + [k: string]: unknown; +}; + +type SimulatedOrder = { + id: string; + coin: string; + symbol: string; + side: "buy" | "sell"; + amount_human: number; + qty: number; + status: string; + timestamp: string; + note: string; + slippage_bps: number; +}; + +type Result = + | { status: "awaiting_user"; intent: Intent } + | { status: "simulated_filled"; order: SimulatedOrder } + | { + status: "signed"; + chain_id: CowChainId; + order_uid: `0x${string}`; + tx_hash?: `0x${string}`; + order?: { id: string; symbol: string; qty: number }; + } + | { status: "cancelled" } + | { status: "error"; error: string }; + +// Wallet token derived from the Portfolio API response. The address is +// null for native ETH; the slug/coinId columns are looked up against +// our internal catalog so the LLM's coin_id hints and the target-token +// dropdown still resolve cleanly. logo + priceUsd come straight from +// Portfolio and feed the row UI. +type WalletToken = { + address: Address | null; + symbol: string; + decimals: number; + balance: string; // human string, bigint-safe + coinId: string | null; + slug: TokenSlug | null; + logo: string | null; + priceUsd: number | null; + isNative: boolean; +}; + +// A token the user can spend, paired with the chain it's on. The +// single Portfolio call returns tokens across all chains tagged with +// their source chain, so the UI can group them and the quote + sign +// calls know which chain to use (CoW requires same-chain settlement). +type ChainBalance = { + chainId: CowChainId; + token: WalletToken; +}; + +// Slug ↔ CoinGecko id mapping, used to match a Portfolio-listed token +// to the catalog so we can resolve a contract address to the LLM's +// coin_id hint. We do the inverse lookup by symbol because Portfolio +// doesn't return the CoinGecko id. +function coinIdForSymbol(symbol: string): string | null { + const known: Record = { + USDC: "usd-coin", + USDT: "tether", + WETH: "ethereum", + ETH: "ethereum", + WBTC: "wrapped-bitcoin", + BTC: "bitcoin", + }; + return known[symbol.toUpperCase()] ?? null; +} + +// Reverse lookup: given the LLM's coin_id, return a human symbol for +// display ("usd-coin" → "USDC"). Used by the "you don't have X" banner +// so the user sees the token they asked about, not the raw slug. +const COIN_ID_TO_SYMBOL: Record = { + "usd-coin": "USDC", + tether: "USDT", + ethereum: "WETH", + "wrapped-bitcoin": "WBTC", + bitcoin: "BTC", +}; + +function explorerUrl(chainId: number, txHash: string): string { + switch (chainId) { + case 1: + return `https://etherscan.io/tx/${txHash}`; + case 42161: + return `https://arbiscan.io/tx/${txHash}`; + case 8453: + return `https://basescan.org/tx/${txHash}`; + default: + return `https://etherscan.io/tx/${txHash}`; + } +} + +function cowOrderUrl(orderUid: string): string { + return `https://explorer.cow.fi/orders/${orderUid}?tab=overview`; +} + +function shortUid(uid: string | undefined): string { + if (!uid || uid.length <= 18) return uid ?? "(missing)"; + return `${uid.slice(0, 10)}…${uid.slice(-6)}`; +} + +function cryptoRandomId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return (crypto as Crypto).randomUUID(); + } + return Math.random().toString(36).slice(2); +} + +function parseResult(raw: unknown): Result | { kind: "loading" } { + const obj = unwrapToolResult(raw); + if (!obj) return { kind: "loading" }; + return obj; +} + +// --- Balance helpers -------------------------------------------------------- + +function slugForSymbol(symbol: string): TokenSlug | null { + const known: Record = { + USDC: "usdc", + USDT: "usdt", + WETH: "weth", + WBTC: "wbtc", + }; + return known[symbol.toUpperCase()] ?? null; +} + +// Convert a hex balance string (e.g. "0x5f5e100") to a human-readable +// decimal string ("100") at the given decimals. BigInt path so we don't +// lose precision on 18-decimal wei numbers. +function formatBalanceHex(hex: string, decimals: number): string { + let value: bigint; + try { + value = BigInt(hex); + } catch { + return "0"; + } + if (value === BigInt(0)) return "0"; + const denom = BigInt(10) ** BigInt(decimals); + const whole = value / denom; + const frac = value % denom; + const fracStr = frac.toString().padStart(decimals, "0").replace(/0+$/, ""); + return fracStr ? `${whole.toString()}.${fracStr}` : whole.toString(); +} + +function usdValue(token: WalletToken): number | null { + if (token.priceUsd == null) return null; + const num = Number(token.balance); + if (!Number.isFinite(num)) return null; + return num * token.priceUsd; +} + +function formatUsd(n: number): string { + if (n >= 1000) return `≈ $${n.toLocaleString("en-US", { maximumFractionDigits: 0 })}`; + if (n >= 1) return `≈ $${n.toFixed(2)}`; + return `≈ $${n.toPrecision(2)}`; +} + +// Token logo with graceful fallback: if the Portfolio-provided URL 404s +// (some tokens have stale logos), fall back to the symbol's first +// letter in a tinted circle — keeps the row layout stable. Every +// variant sits on the same `bg-muted` chip so a colored emblem +// (e.g. the blue Base square) doesn't look like a naked sticker. +function TokenLogo({ token }: { token: WalletToken }) { + const [broken, setBroken] = useState(false); + const letter = (token.symbol[0] ?? "?").toUpperCase(); + if (token.logo && !broken) { + return ( + // eslint-disable-next-line @next/next/no-img-element + setBroken(true)} + className="size-[18px] shrink-0 rounded-full bg-muted p-[2px]" + /> + ); + } + return ( + + {letter} + + ); +} + +function ChainLogo({ chainId }: { chainId: number }) { + const src = getNetworkLogoByChainId(chainId); + if (!src) return null; + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); +} + +// --- CoW quote fetching ------------------------------------------------------ + +async function fetchCowQuote( + chainId: CowChainId, + sellToken: Address, + buyToken: Address, + amountRaw: string, + kind: "sell" | "buy", + from: Address, + slippageBps: number, + signal: AbortSignal, +): Promise<{ quote: CowQuote } | { error: string }> { + const config = getCowConfig(chainId); + if (!config) return { error: `chain ${chainId} not supported` }; + const body: Record = { + sellToken, + buyToken, + from, + kind, + slippageBps, + }; + if (kind === "sell") body.sellAmountBeforeFee = amountRaw; + else body.buyAmountAfterFee = amountRaw; + try { + const res = await fetch(`${config.apiUrl}/quote`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + signal, + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => ({}))) as { + errorType?: string; + description?: string; + }; + return { + error: `cow ${res.status}${errBody.description ? `: ${errBody.description}` : ""}`, + }; + } + return { quote: (await res.json()) as CowQuote }; + } catch (e: unknown) { + if (e instanceof DOMException && e.name === "AbortError") { + return { error: "aborted" }; + } + return { error: e instanceof Error ? e.message : "quote fetch failed" }; + } +} + +// --- Top-level card --------------------------------------------------------- + +export const CryptoConfirmCard: ToolCallMessagePartComponent = ({ result, args }) => { + const parsed = parseResult(result); + const sendCommand = useLangGraphSendCommand(); + const { address, isConnected, chainId: wagmiChainId } = useAccount(); + const { openConnectModal } = useConnectModal(); + + if ("kind" in parsed) { + return ( +
+ + Preparing swap… +
+ ); + } + + if (parsed.status === "error") { + return ( +
+ + Order failed: {parsed.error} +
+ ); + } + + if (parsed.status === "cancelled") { + return ( +
+ Trade cancelled — no funds moved. +
+ ); + } + + if (parsed.status === "simulated_filled") { + return ; + } + + if (parsed.status === "signed") { + return ; + } + + // awaiting_user — preview phase. Defensive: a partial / missing intent + // payload (LLM schema drift, truncation) would otherwise crash the + // whole thread on first wagmi read. + if (parsed.status === "awaiting_user") { + if (!parsed.intent || !parsed.intent.side) { + return ( +
+ + Swap intent was missing — please try again. +
+ ); + } + return ( + sendCommand({ resume: JSON.stringify(payload) } as never)} + /> + ); + } + + return ( +
+ + Unknown order state. +
+ ); +}; + +// --- Wallet-aware preview ---------------------------------------------------- + +function AwaitingUserSwap({ + intent, + hintArgs, + isConnected, + address, + wagmiChainId, + onConnect, + onResolve, +}: { + intent: Intent; + hintArgs: Args | undefined; + isConnected: boolean; + address: Address | undefined; + wagmiChainId: number | undefined; + onConnect: (() => void) | undefined; + onResolve: (payload: unknown) => void; +}) { + // Resolve chain. wagmi tells us which chain the user is on; CoW only + // supports 1 / 42161 / 8453. If wagmi returns a chain we don't + // support (e.g. Polygon), surface a structured error. + const chainId = wagmiChainId; + const chainConfig = chainId != null ? getCowConfig(chainId) : null; + const chainUnsupported = wagmiChainId != null && chainConfig == null; + + // --- Wallet connection gate ----------------------------------------- + if (!isConnected) { + return ( +
+
+
+
+ +
+
+

Authorize wallet to read your address

+

+ We need your wallet address to load balances. +

+
+
+ +
+
+ ); + } + + if (chainUnsupported) { + return ( +
+
+
+ + + Your wallet is on chain {wagmiChainId}, which isn't supported for swaps. Switch to + Ethereum, Arbitrum, or Base and try again. + +
+
+
+ ); + } + + if (chainId == null || address == null) { + // wagmi is connected but hasn't returned the chain/address yet. + return ( +
+ + Loading wallet… +
+ ); + } + + return ( + + ); +} + +// --- Swap workspace (connected, valid chain) -------------------------------- + +function SwapWorkspace({ + intent, + hintArgs, + chainId, + address, + onResolve, +}: { + intent: Intent; + hintArgs: Args | undefined; + chainId: CowChainId; + address: Address; + onResolve: (payload: unknown) => void; +}) { + const [balances, setBalances] = useState(null); + const [balancesError, setBalancesError] = useState(null); + const [balancesLoading, setBalancesLoading] = useState(true); + + // Single Portfolio API call enumerates all 3 CoW chains and bundles + // balance + symbol + decimals + logo + USD price in one round-trip + // (replacing the older per-chain getBalance / getTokenBalances / + // getTokenMetadata trio). + useEffect(() => { + const ctrl = new AbortController(); + setBalancesLoading(true); + setBalancesError(null); + fetchEnrichedBalances(address, ctrl.signal) + .then((tokens) => { + // Portfolio API covers every L1 + L2 in the catalog; the swap + // card can only settle on CoW chains, so non-CoW rows are + // dropped here. The lib keeps them so other views (a future + // portfolio widget) can use them. + const flat: ChainBalance[] = tokens.flatMap((t) => { + const cfg = getCowConfig(t.chainId); + if (!cfg) return []; + return [ + { + chainId: t.chainId as CowChainId, + token: { + address: t.address, + symbol: t.symbol, + decimals: t.decimals, + balance: formatBalanceHex(t.tokenBalance, t.decimals), + coinId: coinIdForSymbol(t.symbol), + slug: slugForSymbol(t.symbol), + logo: t.logo, + priceUsd: t.priceUsd, + isNative: t.isNative, + }, + }, + ]; + }); + setBalances(flat); + setBalancesLoading(false); + }) + .catch((e: unknown) => { + if (e instanceof DOMException && e.name === "AbortError") return; + setBalancesError(e instanceof Error ? e.message : "balance fetch failed"); + setBalancesLoading(false); + }); + return () => ctrl.abort(); + }, [address]); + + if (balancesLoading) { + return ( + +
+ Loading your balances… +
+
+ ); + } + + if (balancesError) { + return ( + +
+ + Couldn't load balances: {balancesError} +
+
+ ); + } + + return ( + + ); +} + +function CardShell({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+
+
+ +
+
+

{title}

+
+
+ {children} +
+
+ ); +} + +// --- Swap form -------------------------------------------------------------- + +function SwapForm({ + intent, + hintArgs, + connectedChainId, + address, + balances, + onResolve, +}: { + intent: Intent; + hintArgs: Args | undefined; + connectedChainId: CowChainId; + address: Address; + balances: ChainBalance[]; + onResolve: (payload: unknown) => void; +}) { + // Available tokens on each chain (from catalog). Used by the target + // dropdown — only tokens the user can actually receive on the source's + // chain (CoW requires same-chain settlement). + // The dropdown is filtered to the source token's chain. + const { switchChainAsync, isPending: isSwitching } = useSwitchChain(); + const [sourceChainId, setSourceChainId] = useState(connectedChainId); + + // Available tokens on the source chain (target dropdown options). + const sourceChainTokens = useMemo(() => tokensForChain(sourceChainId), [sourceChainId]); + + // Group balances by chain (preserving chain order). Within each chain, + // native first, then by USD value desc — the user's gas token always + // surfaces at the top of its chain section regardless of balance. + const groupedByChain = useMemo(() => { + const order = [1, 42161, 8453] as CowChainId[]; + return order + .map((cid) => ({ + chainId: cid, + rows: balances + .filter((b) => b.chainId === cid) + .sort((a, b) => { + if (a.token.isNative !== b.token.isNative) return a.token.isNative ? -1 : 1; + const av = usdValue(a.token); + const bv = usdValue(b.token); + if (av != null && bv != null) return bv - av; + if (av != null) return -1; + if (bv != null) return 1; + return 0; + }), + })) + .filter((g) => g.rows.length > 0); + }, [balances]); + + // Resolve LLM hints against the user's balances + catalog. + const initialSource = useMemo(() => { + const hintId = intent.source_coin_id ?? hintArgs?.source_coin_id; + if (hintId) { + const match = balances.find((b) => b.token.coinId === hintId.toLowerCase()); + if (match) return match; + } + // No LLM hint → highest-USD-value balance, with a tie-breaker that + // prefers the connected chain so the user doesn't have to switch. + const ranked = [...balances].sort((a, b) => { + const av = usdValue(a.token) ?? -1; + const bv = usdValue(b.token) ?? -1; + if (av !== bv) return bv - av; + const sa = a.chainId === connectedChainId ? 1 : 0; + const sb = b.chainId === connectedChainId ? 1 : 0; + return sb - sa; + }); + return ranked[0] ?? null; + }, [intent.source_coin_id, hintArgs?.source_coin_id, balances, connectedChainId]); + + const initialTargetSlug = useMemo(() => { + const hintId = intent.target_coin_id ?? hintArgs?.target_coin_id; + if (hintId) { + const tok = sourceChainTokens.find((t) => t.coinId === hintId.toLowerCase()); + if (tok) return tok.slug; + } + return defaultTargetSlug(initialSource?.token.slug ?? null); + }, [intent.target_coin_id, hintArgs?.target_coin_id, sourceChainTokens, initialSource]); + + // The LLM named a specific source coin_id; if the wallet doesn't hold + // it, surface that instead of silently substituting something else. + // We resolve the coin_id to a human symbol via the catalog. + const sourceHint = intent.source_coin_id ?? hintArgs?.source_coin_id; + const hintSymbol = useMemo(() => { + if (!sourceHint) return null; + const sym = COIN_ID_TO_SYMBOL[sourceHint.toLowerCase()]; + if (sym) return sym; + return sourceHint; // unknown coin — show the raw id + }, [sourceHint]); + const hintMissing = sourceHint != null && initialSource == null; + + const [sourcePick, setSourcePick] = useState(initialSource); + const [targetSlug, setTargetSlug] = useState(initialTargetSlug); + const sourceToken = sourcePick?.token ?? null; + // Amount is empty unless the LLM explicitly named one — don't pre-fill + // with the source balance. The user types the number they want to + // trade; the "Max" link one click away fills it for them. + const initialAmount = intent.amount ?? hintArgs?.amount ?? null; + const [amount, setAmount] = useState( + initialAmount != null && Number.isFinite(initialAmount) ? String(initialAmount) : "", + ); + const [slippageBps, setSlippageBps] = useState(50); + const [mode, setMode] = useState<"simulated" | "real">("simulated"); + const [submitting, setSubmitting] = useState(false); + + // Re-derive defaults if balances change (chain switch, fresh fetch). + // Drop the source pick if its chain no longer has balances; also + // move sourceChainId if the connected chain changes. + useEffect(() => { + if (balances.length > 0 && !balances.find((b) => b.chainId === sourceChainId)) { + // Source chain has no balances — fall back to the first chain that does. + setSourceChainId(balances[0].chainId); + } + }, [balances, sourceChainId]); + + useEffect(() => { + if ( + sourcePick && + !balances.find( + (b) => b.chainId === sourcePick.chainId && b.token.address === sourcePick.token.address, + ) + ) { + setSourcePick(initialSource); + } else if (!sourcePick && initialSource) { + setSourcePick(initialSource); + } + }, [balances, sourcePick, initialSource]); + + // Switch sourceChainId when the user picks a source on a different chain. + useEffect(() => { + if (sourcePick && sourcePick.chainId !== sourceChainId) { + setSourceChainId(sourcePick.chainId); + } + }, [sourcePick, sourceChainId]); + + // Target must differ from source. Recompute when sourceChainId changes. + const targetToken = useMemo(() => { + const candidates = sourceChainTokens.filter((t) => t.slug !== sourceToken?.slug); + return candidates.find((t) => t.slug === targetSlug) ?? candidates[0] ?? null; + }, [sourceChainTokens, targetSlug, sourceToken?.slug]); + + // Quote state + const [quote, setQuote] = useState(null); + const [quoteError, setQuoteError] = useState(null); + const [quoteLoading, setQuoteLoading] = useState(false); + const quoteAbort = useRef(null); + + // Build source/buy token addresses from catalog (we know the slug, + // the chain, so we resolve to the canonical address). + const sourceResolved = sourceToken?.slug + ? resolveTokenForSlug(sourceToken.slug, sourceChainId) + : null; + const targetResolved = targetToken ? resolveTokenForSlug(targetToken.slug, sourceChainId) : null; + + // Amount must parse + be positive. + const amountDecimal = parseAmount(amount); + const amountValid = amountDecimal !== null && amountDecimal.greaterThan(0); + + // Refetch quote when source / amount / target / slippage changes. + // Debounce 400ms so typing doesn't fire on every keystroke. + useEffect(() => { + quoteAbort.current?.abort(); + if (!sourceResolved || !targetResolved || !amountValid) { + setQuote(null); + setQuoteError(null); + setQuoteLoading(false); + return; + } + const ctrl = new AbortController(); + quoteAbort.current = ctrl; + const kind: "sell" | "buy" = intent.side; // "sell source for target" maps to kind=sell + const amountRaw = formatAmountRaw(amountDecimal!, sourceResolved.decimals); + setQuoteLoading(true); + setQuoteError(null); + const t = setTimeout(() => { + fetchCowQuote( + sourceChainId, + sourceResolved.address, + targetResolved.address, + amountRaw, + kind, + address, + slippageBps, + ctrl.signal, + ) + .then((r) => { + if (ctrl.signal.aborted) return; + if ("error" in r) { + setQuoteError(r.error === "aborted" ? null : r.error); + setQuote(null); + } else { + setQuote(r.quote); + setQuoteError(null); + } + setQuoteLoading(false); + }) + .catch((e: unknown) => { + if (e instanceof DOMException && e.name === "AbortError") return; + setQuoteError(e instanceof Error ? e.message : "quote fetch failed"); + setQuoteLoading(false); + }); + }, 400); + return () => { + clearTimeout(t); + ctrl.abort(); + }; + }, [ + intent.side, + sourceChainId, + sourceResolved?.address, + sourceResolved?.decimals, + targetResolved?.address, + amountValid, + amount, + slippageBps, + address, + ]); + + const { signTypedDataAsync } = useSignTypedData(); + const isBusy = submitting; + // Selected source lives on a different chain than the wallet — + // Sign triggers switchChain first, then signs. + const chainMismatch = sourceChainId !== connectedChainId; + + const handleSign = async () => { + if ( + submitting || + !quote || + !sourceResolved || + !targetResolved || + !sourceToken || + !targetToken + ) { + return; + } + setSubmitting(true); + + const side = intent.side; + const amountHuman = Number(amountDecimal!); + const symbol = sourceToken.symbol; + const targetSymbol = targetToken.symbol; + const qty = Number(formatUnits(BigInt(quote.buyAmount), targetResolved.decimals)); + + if (mode === "simulated") { + onResolve({ + status: "simulated_filled", + order: { + id: `ord_${cryptoRandomId()}`, + coin: sourceToken.coinId ?? sourceToken.slug ?? "unknown", + symbol, + side, + amount_human: amountHuman, + qty, + status: "simulated_filled", + timestamp: new Date().toISOString(), + note: "Simulated fill. No on-chain transaction was sent.", + slippage_bps: slippageBps, + }, + }); + return; + } + + // Real mode: switch chain if needed, then EIP-712 sign + POST. + try { + if (chainMismatch) { + await switchChainAsync({ chainId: sourceChainId }); + } + const { orderUid, txHash } = await signCowOrder({ + quote, + chainId: sourceChainId, + receiver: address, + sellTokenSymbol: symbol, + targetTokenSymbol: targetSymbol, + targetDecimals: targetResolved.decimals, + signTypedDataAsync: signTypedDataAsync as never, + }); + onResolve({ + status: "signed", + chain_id: sourceChainId, + order_uid: orderUid, + tx_hash: txHash, + order: { + id: orderUid, + symbol: targetSymbol, + qty, + }, + }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "Wallet rejected the transaction"; + onResolve({ status: "error", error: msg }); + setSubmitting(false); + } + }; + + const handleCancel = () => { + onResolve({ status: "cancelled" }); + }; + + const handleMax = () => { + if (!sourceToken) return; + setAmount(sourceToken.balance); + }; + + const canSign = + !!quote && + !quoteLoading && + !quoteError && + amountValid && + sourceResolved && + targetResolved && + !isBusy; + + return ( + +
+ + + {address.slice(0, 6)}…{address.slice(-4)} · {chainConfigName(connectedChainId)} + +
+ + {/* Source token selector — balances grouped by chain. Selecting a + token from a non-connected chain triggers switchChain on Sign. */} +
+ + {intent.side === "sell" ? "From" : "Spend"} + + {hintMissing ? ( +
+ + + Your wallet doesn't hold{" "} + {hintSymbol}. Pick a token from your + balances below. + +
+ ) : null} + {balances.length === 0 ? ( +
+ No balances found on Ethereum, Arbitrum, or Base. +
+ ) : ( +
+ {groupedByChain.map(({ chainId: cid, rows }) => ( +
+
+ + + {chainConfigName(cid)} + + {cid === connectedChainId ? ( + + · Connected + + ) : null} +
+ {rows.map((b) => { + const selected = + sourcePick?.chainId === b.chainId && + sourcePick?.token.address === b.token.address; + const usd = usdValue(b.token); + return ( + + ); + })} +
+ ))} +
+ )} +
+ + {/* Amount input */} + {sourceToken && ( +
+
+ Amount + +
+ setAmount(e.target.value)} + placeholder="0" + aria-invalid={amount.length > 0 && !amountValid} + className={cn( + "border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-1", + amount.length > 0 && !amountValid && "border-destructive", + )} + /> +
+ )} + + {/* Swap arrow + target */} + {sourceToken && ( +
+
+ +
+ For + +
+ )} + + {/* Quote preview */} + {sourceToken && targetToken && ( +
+
+ You pay + + {amountValid ? formatAmount(Number(amountDecimal!)) : "—"}{" "} + {sourceToken.symbol.toUpperCase()} + +
+
+ + You receive {intent.side === "sell" ? "(min)" : "(max)"} + + + {quoteLoading ? ( + + ) : quoteError ? ( + + ) : quote ? ( + <> + {formatQty( + Number(formatUnits(BigInt(quote.buyAmount), targetResolved!.decimals)), + )}{" "} + {targetToken.symbol.toUpperCase()} + + ) : ( + + )} + +
+
+ Valid until + + {quote ? new Date(quote.validTo * 1000).toLocaleTimeString() : "—"} + +
+
+ Network fee + + {quote + ? formatQty( + Number(formatUnits(BigInt(quote.feeAmount || "0"), sourceResolved!.decimals)), + ) + : "—"}{" "} + {quote && ( + {sourceToken.symbol.toUpperCase()} + )} + +
+ {quoteError ? ( +
+ + {quoteError} +
+ ) : null} +
+ )} + + {/* Slippage */} + {sourceToken && targetToken && ( +
+
+ + Slippage tolerance + + + {(slippageBps / 100).toFixed(slippageBps % 100 === 0 ? 0 : 2)}% + +
+
+ {[10, 50, 100, 300].map((bps) => ( + + ))} +
+
+ )} + + {/* Mode toggle */} + {sourceToken && targetToken && ( +
+ {(["simulated", "real"] as const).map((m) => ( + + ))} +
+ )} + +
+ + +
+
+ ); +} + +// --- CoW sign + submit ------------------------------------------------------ + +type SignCowOrderArgs = { + quote: CowQuote; + chainId: CowChainId; + receiver: Address; + sellTokenSymbol: string; + targetTokenSymbol: string; + targetDecimals: number; + signTypedDataAsync: ReturnType["signTypedDataAsync"]; +}; + +async function signCowOrder({ + quote, + chainId, + receiver, + signTypedDataAsync, +}: SignCowOrderArgs): Promise<{ orderUid: `0x${string}`; txHash?: `0x${string}` }> { + const config = getCowConfig(chainId); + if (!config) throw new Error(`chain_id ${chainId} is not supported`); + + const sellAmount = BigInt(quote.sellAmount); + const buyAmount = BigInt(quote.buyAmount); + const feeAmount = BigInt(quote.feeAmount ?? "0"); + const orderStruct = { + sellToken: quote.sellToken, + buyToken: quote.buyToken, + receiver, + sellAmount, + buyAmount, + validTo: quote.validTo, + appData: ("0x" + "0".repeat(64)) as Hex, + feeAmount, + kind: quote.kind, + partiallyFillable: false, + sellTokenBalance: "erc20" as const, + buyTokenBalance: "erc20" as const, + }; + + const message = { + ...orderStruct, + sellAmount: sellAmount.toString(), + buyAmount: buyAmount.toString(), + feeAmount: feeAmount.toString(), + }; + + const signature = await signTypedDataAsync({ + domain: COW_EIP712_DOMAIN(chainId), + types: COW_EIP712_TYPES, + primaryType: "Order", + message: message as never, + }); + + const res = await fetch(`${config.apiUrl}/orders`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + ...orderStruct, + sellAmount: sellAmount.toString(), + buyAmount: buyAmount.toString(), + feeAmount: feeAmount.toString(), + signingScheme: "eip712", + signature, + }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { description?: string }; + throw new Error(`CoW /orders ${res.status}${body.description ? `: ${body.description}` : ""}`); + } + const json = (await res.json()) as { orderUid: `0x${string}` }; + return { orderUid: json.orderUid }; +} + +// --- Terminal phases -------------------------------------------------------- + +function SimulatedReceipt({ order }: { order: SimulatedOrder }) { + return ( +
+
+
+
+ +
+
+

+ {order.side === "buy" ? "Bought" : "Sold"} {order.symbol} +

+

Simulated fill — no on-chain tx sent

+
+ + SIMULATED + +
+
+
+
Paid
+
+ {formatAmount(order.amount_human)} {order.symbol} +
+
+
+
Filled qty
+
{formatQty(order.qty)}
+
+
+
Time
+
+ {new Date(order.timestamp).toLocaleString()} +
+
+
+
Side
+
{order.side}
+
+
+
+
+ order id: + {order.id} +
+
+ + {order.note} +
+
+
+
+ ); +} + +function SignedReceipt({ signed }: { signed: Extract }) { + return ( +
+
+
+
+ +
+
+

Order submitted to CoW

+

+ Waiting for a solver to fill it (typically < 1 min) +

+
+ + SIGNED + +
+ +
+
+ ); +} + +// --- helpers used above (kept at bottom for hoisting clarity) --------------- + +function resolveTokenForSlug( + slug: TokenSlug, + chainId: CowChainId, +): { + address: Address; + decimals: number; + coinId: string; +} | null { + const meta = tokensForChain(chainId).find((t) => t.slug === slug); + if (!meta) return null; + const resolved = resolveToken(meta.coinId, chainId); + if (!resolved) return null; + return { + address: resolved.address, + decimals: resolved.meta.decimals, + coinId: resolved.meta.coinId, + }; +} + +function chainConfigName(chainId: CowChainId): string { + return getCowConfig(chainId)?.name ?? `Chain ${chainId}`; +} + +// Convert a human-string amount to a raw bigint-string. Defensive: the +// caller (lib/decimal.parseAmount) already rejects NaN / negative / +// scientific / non-numeric, so by the time we get here the string is +// "123.45"-shaped. Multiply by 10^decimals with bigint math. +function parseAmountRaw(human: string, decimals: number): string { + const [whole, frac = ""] = human.split("."); + const padded = (frac + "0".repeat(decimals)).slice(0, decimals); + const negative = false; // parseAmount already rejected negatives + const combined = `${whole}${padded}`.replace(/^0+/, "") || "0"; + return negative ? `-${combined}` : combined; +} + +function formatAmountRaw(decimal: { toString(): string }, decimals: number): string { + // lib/decimal returns a Decimal; we re-parse via parseAmountRaw's + // string form. The decimal's toString is the canonical human form. + return parseAmountRaw(decimal.toString(), decimals); +} diff --git a/components/tool-ui/crypto/index.ts b/components/tool-ui/crypto/index.ts index 2508a81..40f7ac0 100644 --- a/components/tool-ui/crypto/index.ts +++ b/components/tool-ui/crypto/index.ts @@ -1,3 +1,2 @@ export { CryptoPriceCard } from "@/components/tool-ui/crypto/price-card"; -export { AskCryptoIntentCard } from "@/components/tool-ui/crypto/ask-crypto-intent-card"; -export { CryptoOrderReceiptCard } from "@/components/tool-ui/crypto/order-receipt-card"; +export { CryptoConfirmCard } from "@/components/tool-ui/crypto/confirm-card"; diff --git a/components/tool-ui/crypto/order-receipt-card.tsx b/components/tool-ui/crypto/order-receipt-card.tsx deleted file mode 100644 index 225706e..0000000 --- a/components/tool-ui/crypto/order-receipt-card.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; -import { CheckCircle2Icon, InfoIcon, WalletIcon } from "lucide-react"; - -import { ToolCardSkeleton } from "@/components/tool-ui/tool-card-skeleton"; -import { unwrapToolResult } from "@/components/tool-ui/tool-result"; -import { formatAmount, formatQty } from "@/lib/decimal"; -import { cn } from "@/lib/utils"; - -type Order = { - id: string; - coin: string; - symbol: string; - side: "buy" | "sell"; - amount_usd: number; - qty: number; - price_at_confirm: number; - status: string; - timestamp: string; - note: string; -}; - -type Result = { success: true; order: Order } | { success: false; error: string }; - -type Args = { - coin_id: string; - coin_symbol: string; - amount_usd: number; - price_at_confirm: number; - side: "buy" | "sell"; -}; - -function parse(raw: unknown) { - const obj = unwrapToolResult(raw); - if (!obj) return { kind: "loading" as const }; - if (obj.success === true) return { kind: "ok" as const, order: obj.order }; - if (obj.success === false) return { kind: "error" as const, message: obj.error }; - return { kind: "loading" as const }; -} - -function formatUsd(n: number | null | undefined) { - if (n === null || n === undefined || !Number.isFinite(n)) return "—"; - // Decimal-backed so the receipt doesn't render something like - // $100.30000000000001 if the backend's qty division drifted. - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - maximumFractionDigits: 2, - }).format(Number(formatAmount(n))); -} - -function formatTime(iso: string) { - try { - return new Date(iso).toLocaleString(); - } catch { - return iso; - } -} - -export const CryptoOrderReceiptCard: ToolCallMessagePartComponent = ({ - result, - args, -}) => { - const parsed = parse(result); - - if (parsed.kind === "loading") { - return ; - } - if (parsed.kind === "error") { - return
Order failed: {parsed.message}
; - } - - const o = parsed.order; - return ( -
-
-
-
- -
-
-

- {o.side === "buy" ? "Bought" : "Sold"} {o.symbol} -

-

Simulated fill — no on-chain tx sent

-
- - SIMULATED - -
- -
-
-
Quantity
-
- {formatQty(o.qty)} {o.symbol} -
-
-
-
Price
-
{formatUsd(o.price_at_confirm)}
-
-
-
Total
-
{formatUsd(o.amount_usd)}
-
-
-
Time
-
{formatTime(o.timestamp)}
-
-
- -
-
- order id: - {o.id} -
-
- - {o.note} -
- {args ? ( -
- - - Connect a wallet + mainnet to enable real swaps (disabled in simulated mode). - -
- ) : null} -
-
-
- ); -}; diff --git a/components/tool-ui/crypto/price-card.tsx b/components/tool-ui/crypto/price-card.tsx index efddd22..54534ff 100644 --- a/components/tool-ui/crypto/price-card.tsx +++ b/components/tool-ui/crypto/price-card.tsx @@ -36,11 +36,34 @@ function parse(raw: unknown, vsCurrency: string): Parsed { return { kind: "loading" }; } -function formatPrice(value: number, currency: string): string { +// Currency formatting for the price card. Exported for direct unit tests +// — the integration path (render → textContent) is exercised by the +// manual verification in the chat thread. JPY/KRW/CLP/etc. have no +// sub-unit, so they get 0 fraction digits; everything else follows the +// "small price → 6dp, big price → 2dp" rule so sub-$1 stablecoin prices +// (e.g. PEPE in USD) stay readable. CNY uses the bare ¥ — same glyph as +// JPY, no CN/元 prefix — because that's what the user reads as "RMB ¥". +const NO_FRACTION = new Set(["JPY", "KRW", "CLP", "VND", "XOF", "XAF", "XPF"]); +const CNY_SYMBOL = "¥"; + +export function formatPrice(value: number, currency: string): string { + const iso = currency.toUpperCase(); + const fractionDigits = NO_FRACTION.has(iso) ? 0 : value < 1 ? 6 : 2; + if (iso === "CNY") { + return ( + CNY_SYMBOL + + new Intl.NumberFormat("en-US", { + style: "decimal", + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }).format(value) + ); + } return new Intl.NumberFormat("en-US", { style: "currency", - currency: currency.toUpperCase(), - maximumFractionDigits: value < 1 ? 6 : 2, + currency: iso, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, }).format(value); } @@ -104,22 +127,27 @@ export const CryptoPriceCard: ToolCallMessagePartComponent = ({ re {parsed.coins.map((c) => { const positive = c.price_change_percentage_24h >= 0; return ( -
  • +
  • {c.image ? ( // eslint-disable-next-line @next/next/no-img-element ) : null} -
    -
    +
    +
    {c.symbol} - {c.name} + + {c.name} +
    - rank #{c.market_cap_rank} + Rank #{c.market_cap_rank}
    -
    +
    {formatPrice(c.current_price, parsed.vs_currency)}
    @@ -143,6 +171,18 @@ export const CryptoPriceCard: ToolCallMessagePartComponent = ({ re ); })} +
    ); }; diff --git a/components/tool-ui/toolkit.tsx b/components/tool-ui/toolkit.tsx index deb8f82..33fc2ef 100644 --- a/components/tool-ui/toolkit.tsx +++ b/components/tool-ui/toolkit.tsx @@ -5,11 +5,7 @@ import { z } from "zod"; import { AskLocationCard } from "@/components/tool-ui/ask-location/ask-location-card"; import { WeatherCard } from "@/components/tool-ui/weather/weather-card"; -import { - AskCryptoIntentCard, - CryptoOrderReceiptCard, - CryptoPriceCard, -} from "@/components/tool-ui/crypto"; +import { CryptoConfirmCard, CryptoPriceCard } from "@/components/tool-ui/crypto"; // Frontend-side tool registrations. `execute` lives on the LangGraph // backend (backend/tool/) and is dispatched via useLangGraphRuntime — @@ -42,12 +38,6 @@ const weatherToolkit = defineToolkit({ }); const cryptoToolkit = defineToolkit({ - ask_crypto_intent: { - description: - "Render a buy/sell form card. Pauses the turn; the form's submit resumes with the pick.", - parameters: z.object({}), - render: AskCryptoIntentCard, - }, get_crypto_price: { description: "Fetch and render a price card for one or more coins.", parameters: z.object({ @@ -57,15 +47,19 @@ const cryptoToolkit = defineToolkit({ render: CryptoPriceCard, }, confirm_crypto_order: { - description: "Render a simulated order receipt.", + // The tool emits a wallet-aware swap card. LLM passes intent + // (side + optional source/amount/target); the card wakes the + // wallet, lists balances, fetches a CoW quote, and exposes one + // Sign button. + description: + "Render a wallet-aware swap card. Pauses the turn; the user clicks Sign to submit an EIP-712 order to CoW.", parameters: z.object({ - coin_id: z.string(), - coin_symbol: z.string(), - amount_usd: z.number(), - price_at_confirm: z.number(), side: z.enum(["buy", "sell"]), + source_coin_id: z.string().optional(), + amount: z.number().optional(), + target_coin_id: z.string().optional(), }), - render: CryptoOrderReceiptCard, + render: CryptoConfirmCard, }, }); diff --git a/cspell.json b/cspell.json index b409329..8fa24fe 100644 --- a/cspell.json +++ b/cspell.json @@ -22,6 +22,7 @@ "datasource", "datetime", "db", + "dbname", "deployable", "drizzle", "drizzlekit", @@ -70,6 +71,9 @@ "proxy", "psql", "radix", + "rebranded", + "merkle", + "rainbowkit", "reactivity", "readonly", "readwrite", @@ -111,7 +115,57 @@ "yaml", "yargs", "zod", - "zustand" + "zustand", + "alchemy", + "alchemy-sdk", + "apechain", + "arbitrum", + "arbiscan", + "avalanche", + "bera", + "berachain", + "bitget", + "blast", + "celo", + "coincap", + "cow", + "cowswap", + "cryptocurrency", + "defillama", + "eip", + "eip-712", + "eip-1271", + "ebase", + "etherscan", + "ethereum", + "gnosis", + "hyperliquid", + "ink", + "linea", + "llamao", + "monad", + "mythos", + "optimism", + "optimistic", + "polygon", + "polygonscan", + "polygonzkevm", + "polygonzkevm-mainnet", + "reown", + "rootstock", + "ronin", + "scroll", + "sei", + "settlus", + "shape", + "soneium", + "story", + "unichain", + "viem", + "wagmi", + "worldchain", + "zksync", + "zora" ], "ignorePaths": ["node_modules", ".next", "coverage", "pnpm-lock.yaml", "db/migrations/meta"] } diff --git a/docs/APIS.md b/docs/APIS.md index 8df1a8e..8cfdafb 100644 --- a/docs/APIS.md +++ b/docs/APIS.md @@ -65,6 +65,32 @@ Response shape (single row, same for list / fetch / create / update): | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ANY /api/[...path]` | Edge catch-all that forwards to `LANGGRAPH_API_URL` (the LangGraph dev server / production endpoint). Strips hop-by-hop headers, adds CORS, optionally sends `x-api-key`. | +## Alchemy RPC + +Server-side proxy so the Alchemy API key never reaches the browser. Implementation: `app/api/alchemy/[...path]/route.ts` + `app/api/alchemy/status/route.ts`. Allowlist source of truth: `lib/alchemy/networks.ts` (static catalog of every Alchemy-supported network). `ALCHEMY_DISABLED_NETWORKS` is an optional turn-off filter on top of the catalog. + +| Endpoint | Purpose | Response | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `GET /api/alchemy/status` | Reports whether `ALCHEMY_API_KEY` is set on the server. The actual key value is never included in the response — it only returns `{ configured: boolean }`. | `{ configured: true \| false }` | +| `POST /api/alchemy/` | Forwards a JSON-RPC body to `https://.g.alchemy.com/v2/`. The `` slug must be in the catalog AND not in `ALCHEMY_DISABLED_NETWORKS`, otherwise 400. | Forwards upstream status + body. CORS headers attached. | +| `GET /api/alchemy/` | Same proxy path, used for Alchemy endpoints that take query params (e.g. `eth_getBlockByNumber` over GET). Same allowlist rules. | Forwards upstream status + body. | +| `OPTIONS /api/alchemy/` | CORS preflight. | 204 with `Access-Control-Allow-*` headers. | + +### Error responses (stable) + +- `400 { error }` — network slug missing, not in the static catalog, or listed in `ALCHEMY_DISABLED_NETWORKS`. +- `500 { error }` — `ALCHEMY_API_KEY` is not configured on the server, or the upstream call threw. +- Upstream statuses (401 / 429 / 5xx) are passed through to the caller as-is, with the upstream body and `Retry-After` header preserved. + +### Env contract + +- `ALCHEMY_API_KEY` — server-only. The proxy reads it. Never reachable from the browser bundle. +- `ALCHEMY_DISABLED_NETWORKS` — server-only, optional, comma-separated Alchemy slugs the proxy will REJECT. Empty = every catalog network is enabled. The admin page (`/alchemy`) always shows the full catalog; a disabled network's Test button returns 400 so you can see at a glance what's turned off. + +### Admin UI + +`/alchemy` renders the full catalog grouped by L1 / L2 / testnets, with a status badge from `/api/alchemy/status` and a per-network **Test** button that runs `eth_blockNumber` through the proxy. + ## Graph tools The LangGraph `agent` graph exposes the following tools to the chat model. Both are read-only and run unconditionally — there is no per-call human approval prompt. Write tools added later should hang off their own node and pass `interruptBefore: [""]` to `compile()` so only the write path pauses for approval. @@ -96,3 +122,61 @@ Read a public web page and return it as markdown via Jina Reader (`r.jina.ai`). ### Key pool semantics `JINA_API_KEYS` is parsed once at module load into an in-memory pool. Each request picks a key at random. On `401` or `403`, the key is removed from the pool and the request retries with another random key. Up to N retries are attempted where N is the pool size at call start; once every key has rejected the same request, the tool throws. The pool is process-local and resets on LangGraph dev-server restart. + +## Crypto tools + +The crypto sub-agent (`CRYPTO_AGENT_PROMPT` in `backend/prompt/system.ts`) drives the swap flow. The wallet is the only source of spendable assets — there is no fiat on-ramp. Quotes come from CoW Protocol (no API key, no signup). Token balances come from Alchemy (server-only key via `ALCHEMY_API_KEY`). + +### `get_crypto_price(ids, vs_currency?)` + +Read-only price lookup via CoinGecko's public `/coins/markets`. Used to render the price card. Has a 60s in-memory cache to stay under the free-tier rate limit. + +| | | +| ------------- | -------------------------------------------------------------------------------------------------------------------- | +| Input | `{ ids: string[], vs_currency?: string }` — CoinGecko coin ids (e.g. `["bitcoin", "ethereum"]`); defaults to `"usd"` | +| Output | `{ success: true, coins: [...] }` (JSON string) — normalized list with price, 24h change, 7d sparkline, market cap | +| Auth | None (CoinGecko public endpoint) | +| Failure modes | Non-2xx → `{ success: false, error }` carrying the upstream status. No retries. | + +### `get_fx_rate(from, to)` + +Read-only FX lookup via frankfurter.app (ECB-sourced). Has a 60s in-memory cache. Currently unused by the wallet-token flow (fiat amounts are rejected) but kept for the chat agent's general knowledge lookups. + +| | | +| ------------- | --------------------------------------------------------------------- | +| Input | `{ from: string, to: string }` — 3-letter ISO codes, case-insensitive | +| Output | `{ success: true, from, to, rate, date }` | +| Failure modes | Non-2xx → `{ success: false, error }` with the upstream status. | + +### `get_token_balances(chainId, address)` + +**Not currently exposed to the LLM.** Listed here for reference only — the file and tests are kept in `backend/tool/crypto/get-token-balances.ts` for direct programmatic use, but `CRYPTO_TOOLS` does not register it. The wallet's address is not in the LLM's context (it lives in wagmi/RainbowKit on the frontend), so the agent has no way to call this tool without inventing an address. The `ask_crypto_intent` card reads balances via `useBalance` directly. If you want to re-enable this tool, register it in `backend/tool/index.ts` and update `CRYPTO_AGENT_PROMPT` step 3. + +### `get_swap_quote(chainId, sellToken, buyToken, amount, kind, slippageBps?)` + +Read-only. Returns a CoW Protocol quote for a given token pair. CoW is the canonical EVM swap aggregator with MEV protection via batch auctions. No API key, no signup, no rate-limit floor. + +| | | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Input | `{ chainId, sellToken, buyToken, amount, kind, slippageBps? }` — `amount` is the raw token-unit **string** (e.g. `"100000000"` for 100 USDC, `"100000000000000000"` for 0.1 WETH). `kind="sell"` = exact input, `kind="buy"` = exact output. `slippageBps` defaults to 50, hard cap 3000. | +| Output | `{ success: true, quote: }` — includes `quote.buyAmount`, `quote.sellAmount` (after fee), `quote.validTo` (unix timestamp, ~30 min), `quote.feeAmount`, `quote.kind`, `quote.appData`, and `id` / `expiration` at the top level. | +| Auth | None. Public CoW endpoint. | +| Failure modes | Unsupported chainId / zero amount → `{ success: false, error: "..." }` without any fetch. CoW 4xx/5xx → `{ success: false, error: "cow N: " }` (e.g. `NoLiquidity`). | + +### `ask_crypto_intent(message?, currency?, amount?)` + +Interrupt-driven. Pauses the turn and renders the buy/sell form. Used as the **user confirmation checkpoint** before any swap is staged — the LLM must call it even when the user already named a coin + amount, so the user can explicitly confirm. Implementation: `backend/tool/crypto/ask-crypto-intent.ts`. + +### `confirm_crypto_order(coin_id, coin_symbol, amount_usd, price_at_confirm, side, mode?, chain_id?, slippage_bps?)` + +Two-phase pause + commit. First call returns `{ status: "awaiting_user", preview: {...} }` — never fabricates a receipt. The frontend card renders the preview with the Confirm & Sign button. On click, the card signs the EIP-712 order (CoW /orders POST) and overwrites the tool result via `addResult` with `{ status: "simulated_filled" | "signed" | "cancelled" | "error" }`. The LLM then writes the closing sentence. + +| | | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Input | Standard order fields. `mode="simulated"` (default) is paper-trading. `mode="real"` requires `chain_id` ∈ {1, 42161, 8453} and a connected wallet. `slippage_bps` is hard-capped at 3000 (30%) at the schema layer. | +| Output | Phase 1: `{ status: "awaiting_user", preview: {...} }`. Phase 2 (after `addResult`): `{ status: "simulated_filled", order: {...} }` / `{ status: "signed", tx_hash, chain_id, order }` / `{ status: "cancelled" }` / `{ status: "error", error }`. | +| Failure modes | Non-positive amount / price → `{ status: "error" }`. `mode="real"` without `chain_id` → `{ status: "error" }`. Unsupported `chain_id` → `{ status: "error" }`. `slippage_bps` over 3000 → zod rejection (before tool body). | + +## Swap path + +The end-to-end swap path (real mode) is a CoW Protocol batch-auction order signed by the user's wallet via EIP-712. There is no on-chain approval step — CoW uses a settlement contract that pulls tokens via allowances the user grants once via the wallet's typed-data signature. The single hardcoded contract address is the CoW Settlement (`0x9008D19f58AAbD9eD0D60971565AA8510560ab41`), which is the same on every EVM chain (deterministic CREATE2) — see `lib/swap/cow-config.ts`. All other token addresses, router addresses, and pool addresses come from CoW's quote response and the user's own wallet. diff --git a/lib/alchemy/networks.ts b/lib/alchemy/networks.ts new file mode 100644 index 0000000..206e240 --- /dev/null +++ b/lib/alchemy/networks.ts @@ -0,0 +1,349 @@ +// Static catalog of Alchemy network slugs. Slugs match Alchemy's URL +// pattern (https://.g.alchemy.com/v2/). This is the source of +// truth for what the proxy accepts and what the Portfolio API can be +// queried for. The Portfolio `assets/tokens/by-address` endpoint requires +// 1-20 networks per request; we currently send the 20 mainnet entries +// that actually have real users. Obscure / specialty chains are dropped. + +export type AlchemyNetworkFamily = "L1" | "L2" | "testnet"; + +export type NativeToken = { + readonly symbol: string; + readonly decimals: number; + readonly name: string; +}; + +export type AlchemyNetworkEntry = { + readonly slug: string; + readonly name: string; + readonly family: AlchemyNetworkFamily; + /** EVM chain id (mainnet / L2 only — testnets carry the same id as their mainnet). */ + readonly chainId: number; + /** Logo URL for chain-group headers in the UI. Sourced from Alchemy's + * emblem CDN so every chain we support has a maintained SVG. */ + readonly logo: string; + /** Fallback metadata for the chain's native gas token. The Portfolio + * API always returns `tokenAddress: null` for native balances but + * ships `symbol/decimals/name` as `null` too — we backfill from here + * so a user's native ETH on Base / Arb / etc. still renders. */ + readonly nativeToken: NativeToken; +}; + +// Alchemy's emblem CDN. We're an Alchemy user already, so this is the +// canonical source — SVG scales to any UI size and tracks Alchemy's +// own chain list 1:1. +// +// Two slug-system mismatches to know about: +// - polygon-mainnet is exposed at /matic-mainnet.svg (chain was +// renamed in 2024 but the asset path didn't follow) +// - testnet emblems are not published, so testnet entries fall back +// to their mainnet counterpart +const ALCHEMY_EMBLEM = (iconSlug: string) => + `https://static.alchemyapi.io/images/emblems/${iconSlug}.svg`; + +// ponytail: Alchemy never populates native-token metadata, so we ship +// our own fallback in the catalog. ETH (18) covers every L2 + the L1s +// that bridge ETH; only the truly non-ETH-native chains (Polygon, BNB, +// Avax, Gnosis xDAI, Berachain, Monad, Ronin, Celo) need their own row. +const ETH: NativeToken = { symbol: "ETH", decimals: 18, name: "Ether" }; +const NATIVE: Record = { + "eth-mainnet": ETH, + "polygon-mainnet": { symbol: "MATIC", decimals: 18, name: "Polygon" }, + "bnb-mainnet": { symbol: "BNB", decimals: 18, name: "BNB" }, + "avax-mainnet": { symbol: "AVAX", decimals: 18, name: "Avalanche" }, + "gnosis-mainnet": { symbol: "xDAI", decimals: 18, name: "xDai" }, + "berachain-mainnet": { symbol: "BERA", decimals: 18, name: "Berachain" }, + "monad-mainnet": { symbol: "MON", decimals: 18, name: "Monad" }, + "ronin-mainnet": { symbol: "RON", decimals: 18, name: "Ronin" }, + "arb-mainnet": ETH, + "opt-mainnet": ETH, + "base-mainnet": ETH, + "linea-mainnet": ETH, + "scroll-mainnet": ETH, + "zksync-mainnet": ETH, + "worldchain-mainnet": ETH, + "unichain-mainnet": ETH, + "blast-mainnet": ETH, + "celo-mainnet": { symbol: "CELO", decimals: 18, name: "Celo" }, + "apechain-mainnet": ETH, + "soneium-mainnet": ETH, + "eth-sepolia": ETH, + "polygon-amoy": { symbol: "MATIC", decimals: 18, name: "Polygon" }, + "arb-sepolia": ETH, + "opt-sepolia": ETH, + "base-sepolia": ETH, +}; + +const CATALOG: Record = { + // L1 + "eth-mainnet": { + slug: "eth-mainnet", + name: "Ethereum", + family: "L1", + chainId: 1, + logo: ALCHEMY_EMBLEM("eth-mainnet"), + nativeToken: NATIVE["eth-mainnet"]!, + }, + "polygon-mainnet": { + slug: "polygon-mainnet", + name: "Polygon", + family: "L1", + chainId: 137, + logo: ALCHEMY_EMBLEM("matic-mainnet"), + nativeToken: NATIVE["polygon-mainnet"]!, + }, + "bnb-mainnet": { + slug: "bnb-mainnet", + name: "BNB Smart Chain", + family: "L1", + chainId: 56, + logo: ALCHEMY_EMBLEM("bnb-mainnet"), + nativeToken: NATIVE["bnb-mainnet"]!, + }, + "avax-mainnet": { + slug: "avax-mainnet", + name: "Avalanche C-Chain", + family: "L1", + chainId: 43114, + logo: ALCHEMY_EMBLEM("avax-mainnet"), + nativeToken: NATIVE["avax-mainnet"]!, + }, + "gnosis-mainnet": { + slug: "gnosis-mainnet", + name: "Gnosis", + family: "L1", + chainId: 100, + logo: ALCHEMY_EMBLEM("gnosis-mainnet"), + nativeToken: NATIVE["gnosis-mainnet"]!, + }, + "berachain-mainnet": { + slug: "berachain-mainnet", + name: "Berachain", + family: "L1", + chainId: 80094, + logo: ALCHEMY_EMBLEM("berachain-mainnet"), + nativeToken: NATIVE["berachain-mainnet"]!, + }, + "monad-mainnet": { + slug: "monad-mainnet", + name: "Monad", + family: "L1", + chainId: 143, + logo: ALCHEMY_EMBLEM("monad-mainnet"), + nativeToken: NATIVE["monad-mainnet"]!, + }, + "ronin-mainnet": { + slug: "ronin-mainnet", + name: "Ronin", + family: "L1", + chainId: 2020, + logo: ALCHEMY_EMBLEM("ronin-mainnet"), + nativeToken: NATIVE["ronin-mainnet"]!, + }, + // L2 + "arb-mainnet": { + slug: "arb-mainnet", + name: "Arbitrum One", + family: "L2", + chainId: 42161, + logo: ALCHEMY_EMBLEM("arb-mainnet"), + nativeToken: NATIVE["arb-mainnet"]!, + }, + "opt-mainnet": { + slug: "opt-mainnet", + name: "Optimism", + family: "L2", + chainId: 10, + logo: ALCHEMY_EMBLEM("opt-mainnet"), + nativeToken: NATIVE["opt-mainnet"]!, + }, + "base-mainnet": { + slug: "base-mainnet", + name: "Base", + family: "L2", + chainId: 8453, + logo: ALCHEMY_EMBLEM("base-mainnet"), + nativeToken: NATIVE["base-mainnet"]!, + }, + "linea-mainnet": { + slug: "linea-mainnet", + name: "Linea", + family: "L2", + chainId: 59144, + logo: ALCHEMY_EMBLEM("linea-mainnet"), + nativeToken: NATIVE["linea-mainnet"]!, + }, + "scroll-mainnet": { + slug: "scroll-mainnet", + name: "Scroll", + family: "L2", + chainId: 534352, + logo: ALCHEMY_EMBLEM("scroll-mainnet"), + nativeToken: NATIVE["scroll-mainnet"]!, + }, + "zksync-mainnet": { + slug: "zksync-mainnet", + name: "zkSync", + family: "L2", + chainId: 324, + logo: ALCHEMY_EMBLEM("zksync-mainnet"), + nativeToken: NATIVE["zksync-mainnet"]!, + }, + "worldchain-mainnet": { + slug: "worldchain-mainnet", + name: "World Chain", + family: "L2", + chainId: 480, + logo: ALCHEMY_EMBLEM("worldchain-mainnet"), + nativeToken: NATIVE["worldchain-mainnet"]!, + }, + "unichain-mainnet": { + slug: "unichain-mainnet", + name: "Unichain", + family: "L2", + chainId: 130, + logo: ALCHEMY_EMBLEM("unichain-mainnet"), + nativeToken: NATIVE["unichain-mainnet"]!, + }, + "blast-mainnet": { + slug: "blast-mainnet", + name: "Blast", + family: "L2", + chainId: 81457, + logo: ALCHEMY_EMBLEM("blast-mainnet"), + nativeToken: NATIVE["blast-mainnet"]!, + }, + "celo-mainnet": { + slug: "celo-mainnet", + name: "Celo", + family: "L2", + chainId: 42220, + logo: ALCHEMY_EMBLEM("celo-mainnet"), + nativeToken: NATIVE["celo-mainnet"]!, + }, + "apechain-mainnet": { + slug: "apechain-mainnet", + name: "ApeChain", + family: "L2", + chainId: 33139, + logo: ALCHEMY_EMBLEM("apechain-mainnet"), + nativeToken: NATIVE["apechain-mainnet"]!, + }, + "soneium-mainnet": { + slug: "soneium-mainnet", + name: "Soneium", + family: "L2", + chainId: 1868, + logo: ALCHEMY_EMBLEM("soneium-mainnet"), + nativeToken: NATIVE["soneium-mainnet"]!, + }, + // testnets — listed so the catalog stays a single source of truth, but + // most callers (proxy allowlist, Portfolio networks list) skip them. + // Alchemy doesn't publish testnet emblems, so we point at the mainnet + // counterpart (chain shape is the same; only the id differs). + "eth-sepolia": { + slug: "eth-sepolia", + name: "Ethereum Sepolia", + family: "testnet", + chainId: 11155111, + logo: ALCHEMY_EMBLEM("eth-mainnet"), + nativeToken: NATIVE["eth-sepolia"]!, + }, + "polygon-amoy": { + slug: "polygon-amoy", + name: "Polygon Amoy", + family: "testnet", + chainId: 80002, + logo: ALCHEMY_EMBLEM("matic-mainnet"), + nativeToken: NATIVE["polygon-amoy"]!, + }, + "arb-sepolia": { + slug: "arb-sepolia", + name: "Arbitrum Sepolia", + family: "testnet", + chainId: 421614, + logo: ALCHEMY_EMBLEM("arb-mainnet"), + nativeToken: NATIVE["arb-sepolia"]!, + }, + "opt-sepolia": { + slug: "opt-sepolia", + name: "Optimism Sepolia", + family: "testnet", + chainId: 11155420, + logo: ALCHEMY_EMBLEM("opt-mainnet"), + nativeToken: NATIVE["opt-sepolia"]!, + }, + "base-sepolia": { + slug: "base-sepolia", + name: "Base Sepolia", + family: "testnet", + chainId: 84532, + logo: ALCHEMY_EMBLEM("base-mainnet"), + nativeToken: NATIVE["base-sepolia"]!, + }, +}; + +export const ALCHEMY_NETWORK_CATALOG = CATALOG; + +export type AlchemyNetworkSlug = keyof typeof CATALOG; + +export function parseNetworkList(input: string): string[] { + return input + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +export type AlchemyNetworkGroup = { + readonly family: AlchemyNetworkFamily; + readonly label: string; + readonly networks: readonly AlchemyNetworkEntry[]; +}; + +const FAMILY_ORDER: readonly AlchemyNetworkFamily[] = ["L1", "L2", "testnet"]; +const FAMILY_LABEL: Record = { + L1: "Layer 1", + L2: "Layer 2", + testnet: "Testnets", +}; + +export function groupNetworks(slugs: readonly string[]): AlchemyNetworkGroup[] { + const buckets: Record = { + L1: [], + L2: [], + testnet: [], + }; + for (const slug of slugs) { + const entry = CATALOG[slug]; + if (!entry) continue; + buckets[entry.family].push(entry); + } + return FAMILY_ORDER.flatMap((family) => + buckets[family].length === 0 + ? [] + : [{ family, label: FAMILY_LABEL[family], networks: buckets[family] }], + ); +} + +// Returns the slugs the proxy will accept = the full catalog minus the +// disabled list. Alchemy doesn't expose a "list my apps" API, so the +// catalog IS the source of truth for what's available; the disabled +// list is an optional filter the user sets to turn off specific chains +// (e.g. testnets in production). +export function resolveAllowlist(disabled: readonly string[]): string[] { + const blocked = new Set(disabled); + return Object.keys(CATALOG).filter((slug) => !blocked.has(slug)); +} + +// Reverse lookup: chainId → logo URL. Built once at module load from the +// catalog so UI code can render a chain icon from just a chainId (the +// value it has on hand after fetching balances) without round-tripping +// to the slug world. +const CHAIN_ID_TO_LOGO: ReadonlyMap = new Map( + Object.values(CATALOG).map((e) => [e.chainId, e.logo] as const), +); + +export function getNetworkLogoByChainId(chainId: number | null | undefined): string | null { + if (chainId == null) return null; + return CHAIN_ID_TO_LOGO.get(chainId) ?? null; +} diff --git a/lib/alchemy/portfolio.ts b/lib/alchemy/portfolio.ts new file mode 100644 index 0000000..ce67aa1 --- /dev/null +++ b/lib/alchemy/portfolio.ts @@ -0,0 +1,208 @@ +import type { Address } from "viem"; + +import { + ALCHEMY_NETWORK_CATALOG, + getNetworkLogoByChainId, + type AlchemyNetworkSlug, +} from "@/lib/alchemy/networks"; + +// Alchemy Portfolio API — single-call wallet enumeration that bundles +// token balances, metadata (symbol/decimals/logo), and USD prices. +// +// Endpoint: POST /api/alchemy/portfolio/tokens/by-address +// Docs: https://www.alchemy.com/docs/data/portfolio-apis/portfolio-api-endpoints/portfolio-api-endpoints/get-tokens-by-address +// +// We use this instead of the per-chain JSON-RPC trio +// (eth_getBalance + alchemy_getTokenBalances + alchemy_getTokenMetadata +// per ERC20) so a single call returns the entire wallet picture across +// every supported chain — no N+1 round-trips. + +// Full mainnet + L2 catalog (testnets excluded). Source of truth lives +// in `lib/alchemy/networks.ts`; if a new chain ships, add it there and +// this list picks it up automatically. +export const PORTFOLIO_NETWORKS: readonly AlchemyNetworkSlug[] = ( + Object.keys(ALCHEMY_NETWORK_CATALOG) as AlchemyNetworkSlug[] +).filter((slug) => ALCHEMY_NETWORK_CATALOG[slug].family !== "testnet"); +export type PortfolioNetwork = (typeof PORTFOLIO_NETWORKS)[number]; + +export function networkToChainId(network: string): number | null { + const entry = ALCHEMY_NETWORK_CATALOG[network as PortfolioNetwork]; + return entry?.chainId ?? null; +} + +export type EnrichedToken = { + chainId: number; + network: PortfolioNetwork; + /** Contract address. `null` for native ETH on the chain. */ + address: Address | null; + /** Raw on-chain balance as a hex string (e.g. "0x5f5e100"). */ + tokenBalance: string; + symbol: string; + decimals: number; + name: string; + logo: string | null; + /** USD spot price per 1 token. `null` if `withPrices` was off or the + * token is unpriced. */ + priceUsd: number | null; + isNative: boolean; +}; + +type PortfolioResponse = { + data?: { + tokens?: RawToken[]; + pageKey?: string; + }; +}; + +type RawToken = { + network?: string; + tokenAddress?: string | null; + tokenBalance?: string; + tokenMetadata?: { + symbol?: string; + decimals?: number | string; + name?: string; + logo?: string; + }; + tokenPrices?: Array<{ + currency?: string; + value?: string | number; + lastUpdatedAt?: string; + }>; +}; + +const USD = "usd"; + +// Airdrop / claim-bait spam tokens: their Alchemy-mapped symbol/name is +// literally "Claim on: " or "Airdrop on: ". +// Filtering at this layer keeps every downstream view clean. +const SPAM_NAME_RE = /(?:claim|airdrop|visit)\s+on\s*:/i; + +// ponytail: pageKey is exposed by the API but our UI consumes the whole +// list at once; we cap at a single page (≈100 tokens) which is fine for +// any real-world wallet. Revisit if a user actually overflows this. +export async function fetchEnrichedBalances( + address: Address, + signal?: AbortSignal, +): Promise { + const res = await fetch("/api/alchemy/portfolio/tokens/by-address", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + addresses: [{ address, networks: [...PORTFOLIO_NETWORKS] }], + withMetadata: true, + withPrices: true, + includeNativeTokens: true, + includeErc20Tokens: true, + }), + signal, + }); + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`portfolio ${res.status}${errText ? `: ${errText.slice(0, 200)}` : ""}`); + } + const json = (await res.json()) as PortfolioResponse; + const raw = json.data?.tokens ?? []; + const out: EnrichedToken[] = []; + for (const t of raw) { + const normalized = normalize(t); + if (normalized) out.push(normalized); + } + return out; +} + +function normalize(token: RawToken): EnrichedToken | null { + const network = token.network as PortfolioNetwork | undefined; + const entry = network ? ALCHEMY_NETWORK_CATALOG[network] : null; + if (!entry || entry.family === "testnet") return null; + + const meta = token.tokenMetadata ?? {}; + // Native entries have `tokenAddress: null`. Portfolio's native token + // address varies per chain; we collapse it to null so the card treats + // it like ETH. Alchemy never populates metadata for native tokens + // (symbol/decimals/name all null across every chain we tested), so we + // always backfill from the catalog's nativeToken entry. + const isNative = token.tokenAddress == null; + const address = isNative ? null : (token.tokenAddress as Address); + + const decimalsRaw = meta.decimals; + const decimalsFromMeta = + typeof decimalsRaw === "string" + ? parseInt(decimalsRaw, 10) + : typeof decimalsRaw === "number" + ? decimalsRaw + : null; + const decimals = isNative ? entry.nativeToken.decimals : decimalsFromMeta; + if (decimals == null || !Number.isFinite(decimals)) return null; + // Airdrop / dust / scam tokens frequently carry decimals=0; real ERC20s + // (USDC, WETH, WBTC, USDT, …) all use 6 or 18. Treat 0 as noise. The + // catalog's native entries are all 18, so this only fires for ERC20s. + if (decimals === 0) return null; + + const symbolFromMeta = meta.symbol?.trim() ?? ""; + const symbol = isNative ? entry.nativeToken.symbol : symbolFromMeta; + if (!symbol) return null; + // Drop tokens whose metadata screams "go claim me on a phishing site". + if (SPAM_NAME_RE.test(symbol) || SPAM_NAME_RE.test(meta.name ?? "")) return null; + + // Filter zero-balance entries — they're noise (the user's wallet has + // interacted with the contract at some point but holds nothing). + // Native balances are always surfaced, even at 0, so the user can + // see they exist on the chain. + if (!isNative) { + let raw: bigint; + try { + raw = BigInt(token.tokenBalance ?? "0x0"); + } catch { + return null; + } + if (raw === BigInt(0)) return null; + } + + const priceEntry = (token.tokenPrices ?? []).find((p) => p.currency?.toLowerCase() === USD); + const priceValue = priceEntry?.value; + const priceUsd = + typeof priceValue === "string" + ? parseFloat(priceValue) + : typeof priceValue === "number" + ? priceValue + : null; + + return { + chainId: entry.chainId, + network: network as PortfolioNetwork, + address, + tokenBalance: token.tokenBalance ?? "0x0", + symbol, + decimals, + name: meta.name?.trim() || (isNative ? entry.nativeToken.name : "") || symbol, + logo: resolveLogo(token.tokenMetadata?.logo, isNative, entry.chainId, symbol), + priceUsd: priceUsd != null && Number.isFinite(priceUsd) ? priceUsd : null, + isNative, + }; +} + +// Logo URL fallback chain. The Portfolio API ships `logo: null` for many +// tokens (every native balance, plus the long tail of obscure ERC20s), +// so we layer two more sources before giving up to the letter avatar: +// +// 1. Alchemy's own logo (when it bothers — works for major ERC20s) +// 2. Native balances reuse the chain's Alchemy emblem (Ethereum +// diamond, polygon hexagon, etc. — visually = the native token) +// 3. ERC20s fall back to CoinCap's symbol-based icon CDN. No auth, +// no rate limit for our scale, covers the full majors (ETH, USDC, +// WETH, WBTC, USDT, MATIC, …). Long-tail tokens (toby, EBASE, …) +// 404 here and the card's swaps to the letter avatar. +const COINCAP_ICON = (symbol: string) => + `https://assets.coincap.io/assets/icons/${symbol.toLowerCase()}@2x.png`; + +function resolveLogo( + alchemyLogo: string | null | undefined, + isNative: boolean, + chainId: number, + symbol: string, +): string | null { + if (alchemyLogo) return alchemyLogo; + if (isNative) return getNetworkLogoByChainId(chainId); + return COINCAP_ICON(symbol); +} diff --git a/lib/swap/cow-config.ts b/lib/swap/cow-config.ts new file mode 100644 index 0000000..6dd1445 --- /dev/null +++ b/lib/swap/cow-config.ts @@ -0,0 +1,58 @@ +// CoW Protocol config — the only thing we need to hardcode now that +// the swap path goes through CoW's solver network instead of a +// hand-curated Uniswap V3 router list. +// +// ponytail: this file is the single source of truth for CoW endpoints +// + the EIP-712 settlement contract. Both the backend quote tool +// (backend/tool/crypto/get-swap-quote.ts) and the frontend EIP-712 +// signer (components/tool-ui/crypto/confirm-card.tsx) import from +// here. Add a chain = drop a row in COW_API. That's it. + +export const COW_SETTLEMENT = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41" as const; + +// Settlement contract chainId — same address on every EVM chain (CREATE2), +// but the EIP-712 domain needs the chainId of the chain the user is +// signing for, so callers pass it explicitly. + +export const COW_API: Record = { + 1: { apiUrl: "https://api.cow.fi/mainnet/api/v1", name: "Ethereum" }, + 42161: { apiUrl: "https://api.cow.fi/arbitrum_one/api/v1", name: "Arbitrum One" }, + 8453: { apiUrl: "https://api.cow.fi/base/api/v1", name: "Base" }, +}; + +export type CowChainId = keyof typeof COW_API; + +export function getCowConfig(chainId: number | null | undefined) { + if (chainId == null) return null; + return COW_API[chainId] ?? null; +} + +// EIP-712 domain for CoW orders. `verifyingContract` is the settlement +// contract; `chainId` is the chain the order is being placed on. The +// version string is fixed — the protocol bumps it only on contract +// upgrades that change the order schema. +export const COW_EIP712_DOMAIN = (chainId: CowChainId) => ({ + name: "Gnosis Protocol" as const, + version: "v2" as const, + chainId, + verifyingContract: COW_SETTLEMENT, +}); + +// CoW order typed-data schema. Mirrors the contract's Order struct; +// keep in sync with the protocol. +export const COW_EIP712_TYPES = { + Order: [ + { name: "sellToken", type: "address" }, + { name: "buyToken", type: "address" }, + { name: "receiver", type: "address" }, + { name: "sellAmount", type: "uint256" }, + { name: "buyAmount", type: "uint256" }, + { name: "validTo", type: "uint32" }, + { name: "appData", type: "bytes32" }, + { name: "feeAmount", type: "uint256" }, + { name: "kind", type: "string" }, + { name: "partiallyFillable", type: "bool" }, + { name: "sellTokenBalance", type: "string" }, + { name: "buyTokenBalance", type: "string" }, + ], +} as const; diff --git a/lib/tokens/catalog.ts b/lib/tokens/catalog.ts new file mode 100644 index 0000000..e111f65 --- /dev/null +++ b/lib/tokens/catalog.ts @@ -0,0 +1,128 @@ +// ponytail: hardcoded catalog of the common tokens the swap card offers. +// CoinGecko ids are the public identity the LLM uses (usd-coin, ethereum, +// wrapped-bitcoin, tether). On-chain, each chain has its own contract +// address + decimals. A token might also differ in symbol across chains +// (USDC.e vs USDC on Arbitrum). +// +// MVP scope: 4 tokens × 3 chains = 12 entries. Add a new token = drop one +// row per supported chain. Add a new chain = set the slug for each token +// (or omit to mark the token unavailable on that chain). +// +// CoW Protocol settlement requires every token to be on the SAME chain. +// We don't bridge — if a chain is missing for a token, the card hides it +// from the target dropdown for swaps on that chain. + +import type { Address } from "viem"; +import type { CowChainId } from "@/lib/swap/cow-config"; + +export type TokenSlug = "usdc" | "weth" | "usdt" | "wbtc"; + +export type TokenMeta = { + slug: TokenSlug; + coinId: string; // CoinGecko id + symbol: string; + name: string; + decimals: number; + // Stablecoins get 2-decimal display in the amount input + the quote + // preview; everything else shows up to 6dp for sub-unit amounts. + stable: boolean; +}; + +// Catalog rows, one per token (chain-independent metadata). Per-chain +// addresses live in CHAIN_ADDRESSES so adding a new chain only touches +// that map. +const TOKENS: TokenMeta[] = [ + { + slug: "usdc", + coinId: "usd-coin", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + stable: true, + }, + { + slug: "usdt", + coinId: "tether", + symbol: "USDT", + name: "Tether", + decimals: 6, + stable: true, + }, + { + slug: "weth", + coinId: "ethereum", + symbol: "WETH", + name: "Wrapped Ether", + decimals: 18, + stable: false, + }, + { + slug: "wbtc", + coinId: "wrapped-bitcoin", + symbol: "WBTC", + name: "Wrapped Bitcoin", + decimals: 8, + stable: false, + }, +]; + +// Per-chain token addresses. Missing chain → token unavailable there. +// Picked the canonical bridge / native USDC for each L2 (not USDC.e). +// EIP-55 mixed case (Alchemy returns checksummed; viem accepts either). +const CHAIN_ADDRESSES: Record>> = { + 1: { + usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + usdt: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + weth: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + wbtc: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + }, + 42161: { + // Arbitrum: native USDC (Circle) + bridged USDC.e. Catalog carries + // the native one — it's what every modern wallet + DEX defaults to. + usdc: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC.e (bridged) + usdt: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + weth: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + wbtc: "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", + }, + 8453: { + usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + usdt: "0xfde4C96c8593538E31C54eB48FDC2C8A5e1f9477", + weth: "0x4200000000000000000000000000000000000006", + wbtc: "0x0555E30da8f98308Edb960aa94C0Db47230d2B9c", + }, +}; + +export function getTokenMeta(slug: TokenSlug): TokenMeta { + const t = TOKENS.find((x) => x.slug === slug); + if (!t) throw new Error(`unknown token slug: ${slug}`); + return t; +} + +// Resolve a CoinGecko id to the token's address + decimals on the given +// chain. Returns null when the token isn't deployed on that chain. +export function resolveToken( + coinId: string, + chainId: CowChainId, +): { meta: TokenMeta; address: Address } | null { + const meta = TOKENS.find((t) => t.coinId === coinId.toLowerCase()); + if (!meta) return null; + const addr = CHAIN_ADDRESSES[chainId]?.[meta.slug]; + if (!addr) return null; + return { meta, address: addr }; +} + +// Reverse lookup: which tokens are available on a given chain. Used by +// the target-token dropdown so the user never sees a token they can't +// actually receive. +export function tokensForChain(chainId: CowChainId): TokenMeta[] { + return TOKENS.filter((t) => CHAIN_ADDRESSES[chainId]?.[t.slug]); +} + +// Smart default for the target token. Sell a stablecoin → ETH; sell +// ETH/WBTC → USDC; no source hint → USDC. Keeps the preview realistic +// without asking the user to pick a target for the common case. +export function defaultTargetSlug(sourceSlug: TokenSlug | null): TokenSlug { + if (sourceSlug == null) return "weth"; + if (sourceSlug === "usdc" || sourceSlug === "usdt") return "weth"; + return "usdc"; +} diff --git a/lib/wagmi.ts b/lib/wagmi.ts index 39ee7ce..79cf5cb 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -1,20 +1,71 @@ "use client"; import { http } from "wagmi"; -import { mainnet } from "wagmi/chains"; +import { mainnet, arbitrum, base } from "wagmi/chains"; import { getDefaultConfig } from "@rainbow-me/rainbowkit"; +import { + binanceWallet, + bitgetWallet, + // coinbaseWallet is marked @deprecated in RainbowKit 2.2.11 (Coinbase + // rebranded its SDK to CDP). The export still works; swap to + // `cdpWallet` when RainbowKit ships the replacement. TypeScript's + // `@deprecated` JSDoc flows through every reference and cannot be + // suppressed per-line — the IDE will show one deprecation marker on + // this import; the build / test / lint pipeline is clean. + coinbaseWallet, + metaMaskWallet, + rainbowWallet, + safeWallet, + walletConnectWallet, +} from "@rainbow-me/rainbowkit/wallets"; import "@rainbow-me/rainbowkit/styles.css"; -// ponytail: `projectId` here is a placeholder. WalletConnect won't work -// with it (any click on a WC wallet fails to init), but injected wallets -// (MetaMask, Rabby, Phantom) and the Coinbase connector don't touch it -// and work normally. Swap for a real id from cloud.walletconnect.com -// when WalletConnect support is actually needed. +// ponytail: `wallets` here is the same default set `getDefaultConfig` +// would pick, minus `base3` (its bundled icon SVG is a bare blue square +// in 2.2.11 — `base3` is internal-only, can't be fixed in place). The +// remaining connectors — MetaMask / Coinbase / Rainbow / Safe / Binance +// / Bitget / WalletConnect — all use injected / SDK providers. WalletConnect +// falls back to mobile-QR (powered by the `WALLETCONNECT_PROJECT_ID` env +// below) when the SDK provider isn't present. + +// ponytail: route every chain through the server-side Alchemy proxy +// (app/api/alchemy/[...path]) instead of public RPCs. eth.merkle.io +// and friends don't return CORS headers, so direct browser → public-RPC +// calls fail with ERR_FAILED. The proxy holds ALCHEMY_API_KEY, accepts +// eth-mainnet/arb-mainnet/base-mainnet, and forwards with permissive +// CORS. If the proxy is unreachable, wagmi falls back to its default +// transport per chain. +const alchemyTransport = (slug: string) => http(`/api/alchemy/${slug}`, { batch: true }); + +// Public dapp identifier from Reown (formerly WalletConnect) — see +// https://dashboard.reown.com. Empty string disables WalletConnect v2 +// entirely: binanceWallet / bitgetWallet fall back to injected-only +// (no mobile-QR). Free tier: 100k connections / month, no payment. +const WALLETCONNECT_PROJECT_ID = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID ?? ""; + export const wagmiConfig = getDefaultConfig({ appName: "LangGraph App", - projectId: "placeholder-replace-me", - chains: [mainnet], - transports: { [mainnet.id]: http() }, + projectId: WALLETCONNECT_PROJECT_ID, + chains: [mainnet, arbitrum, base], + transports: { + [mainnet.id]: alchemyTransport("eth-mainnet"), + [arbitrum.id]: alchemyTransport("arb-mainnet"), + [base.id]: alchemyTransport("base-mainnet"), + }, ssr: true, + wallets: [ + { + groupName: "Popular", + wallets: [ + binanceWallet, + bitgetWallet, + coinbaseWallet, + metaMaskWallet, + rainbowWallet, + safeWallet, + walletConnectWallet, + ], + }, + ], }); diff --git a/package.json b/package.json index 9278f06..3b10604 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@base-ui/react": "^1.6.0", "@better-auth-ui/core": "^1.6.27", "@better-auth-ui/react": "^1.6.27", - "@langchain/langgraph": "^1.4.6", + "@langchain/langgraph": "^1.4.7", "@langchain/langgraph-checkpoint-postgres": "^1.0.4", "@langchain/langgraph-sdk": "^1.9.25", "@langchain/openai": "^1.5.3", @@ -42,7 +42,7 @@ "@tanstack/react-pacer": "^0.22.1", "@tanstack/react-query": "^5.101.1", "assistant-stream": "^0.3.24", - "better-auth": "^1.6.20", + "better-auth": "^1.6.22", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", @@ -50,16 +50,16 @@ "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", "lucide-react": "^1.21.0", - "next": "~16.1.6", + "next": "16.1.7", "next-themes": "^0.4.6", "postgres": "^3.4.9", "radix-ui": "^1.6.0", "react": "^19.2.7", "react-day-picker": "^10.0.1", "react-dom": "^19.2.7", - "react-email": "^6.6.4", + "react-email": "^6.6.5", "remark-gfm": "^4.0.1", - "resend": "^6.14.0", + "resend": "^6.16.0", "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", @@ -67,7 +67,7 @@ "tw-shimmer": "^0.4.11", "ufo": "^1.6.4", "viem": "^2.53.1", - "wagmi": "^2.19.5", + "wagmi": "2.19.5", "zod": "^4.4.3", "zustand": "^5.0.14" }, diff --git a/patches/cuer@0.0.3.patch b/patches/cuer@0.0.3.patch new file mode 100644 index 0000000..d47e123 --- /dev/null +++ b/patches/cuer@0.0.3.patch @@ -0,0 +1,13 @@ +diff --git a/_dist/QrCode.js b/_dist/QrCode.js +index b1806526c3d100ca6f1e9259b4dfbcd416a33927..c594a0cc0f948a225302caa6dcdcf601ac804fbd 100644 +--- a/_dist/QrCode.js ++++ b/_dist/QrCode.js +@@ -2,7 +2,7 @@ import { encodeQR } from 'qr'; + export function create(value, options = {}) { + const { errorCorrection, version } = options; + const grid = encodeQR(value, 'raw', { +- border: 0, ++ border: 1, + ecc: errorCorrection, + scale: 1, + version: version, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c6d1c14 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,14657 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + cuer: npm:cuer@0.0.3 + +patchedDependencies: + cuer@0.0.3: + hash: 411033b94a397d27fc9bf35e525e1bfe66bb372afad5b615cfaec514ad29beae + path: patches/cuer@0.0.3.patch + +importers: + + .: + dependencies: + '@assistant-ui/react': + specifier: ^0.14.24 + version: 0.14.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) + '@assistant-ui/react-langgraph': + specifier: ^0.14.10 + version: 0.14.10(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@langchain/langgraph-sdk@1.9.25(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)(zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7))) + '@assistant-ui/react-markdown': + specifier: ^0.14.5 + version: 0.14.5(@assistant-ui/react@0.14.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@assistant-ui/react-o11y': + specifier: ^0.0.24 + version: 0.0.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@base-ui/react': + specifier: ^1.6.0 + version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@better-auth-ui/core': + specifier: ^1.6.27 + version: 1.6.27(@mikkelscheike/email-provider-links@5.1.8)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)) + '@better-auth-ui/react': + specifier: ^1.6.27 + version: 1.6.27(e8790f21eb10219fdf108ba0aa287517) + '@langchain/langgraph': + specifier: ^1.4.7 + version: 1.4.7(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + '@langchain/langgraph-checkpoint-postgres': + specifier: ^1.0.4 + version: 1.0.4(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))) + '@langchain/langgraph-sdk': + specifier: ^1.9.25 + version: 1.9.25(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@langchain/openai': + specifier: ^1.5.3 + version: 1.5.3(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@radix-ui/react-avatar': + specifier: ^1.2.0 + version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': + specifier: ^1.1.14 + version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': + specifier: ^1.1.17 + version: 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': + specifier: ^1.3.0 + version: 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-tooltip': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rainbow-me/rainbowkit': + specifier: ^2.2.11 + version: 2.2.11(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)) + '@tanstack/react-pacer': + specifier: ^0.22.1 + version: 0.22.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-query': + specifier: ^5.101.1 + version: 5.101.1(react@19.2.7) + assistant-stream: + specifier: ^0.3.24 + version: 0.3.24 + better-auth: + specifier: ^1.6.22 + version: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + decimal.js: + specifier: ^10.6.0 + version: 10.6.0 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9) + drizzle-zod: + specifier: ^0.8.3 + version: 0.8.3(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(zod@4.4.3) + lucide-react: + specifier: ^1.21.0 + version: 1.21.0(react@19.2.7) + next: + specifier: 16.1.7 + version: 16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + postgres: + specifier: ^3.4.9 + version: 3.4.9 + radix-ui: + specifier: ^1.6.0 + version: 1.6.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.17)(react@19.2.7) + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-email: + specifier: ^6.6.5 + version: 6.6.5(bufferutil@4.1.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@5.0.10) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + resend: + specifier: ^6.16.0 + version: 6.16.0(@react-email/render@2.0.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) + server-only: + specifier: ^0.0.1 + version: 0.0.1 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + tw-shimmer: + specifier: ^0.4.11 + version: 0.4.11(tailwindcss@4.3.1) + ufo: + specifier: ^1.6.4 + version: 1.6.4 + viem: + specifier: ^2.53.1 + version: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + wagmi: + specifier: 2.19.5 + version: 2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + zod: + specifier: ^4.4.3 + version: 4.4.3 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) + devDependencies: + '@assistant-ui/next': + specifier: latest + version: 0.0.7 + '@langchain/core': + specifier: ^1.2.1 + version: 1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@langchain/langgraph-cli': + specifier: ^1.4.1 + version: 1.4.1(d48c10f324b48c59d6cda4c1de26129b) + '@next/env': + specifier: ^16.2.9 + version: 16.2.9 + '@tailwindcss/postcss': + specifier: ^4.3.1 + version: 4.3.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/node': + specifier: ^25.9.4 + version: 25.9.4 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitest/coverage-v8': + specifier: ^4.1.9 + version: 4.1.9(vitest@4.1.9) + concurrently: + specifier: ^10.0.3 + version: 10.0.3 + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@2.2.0) + oxfmt: + specifier: ^0.55.0 + version: 0.55.0 + oxlint: + specifier: ^1.71.0 + version: 1.71.0 + tailwindcss: + specifier: ^4.3.1 + version: 4.3.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@assistant-ui/core@0.2.19': + resolution: {integrity: sha512-QYwIy+l21rvVsyuc19doblCJ3bfhBzqbuNa/xdts8CIDlw+5LWg1uVGCiVhTRpkysHuJyONDF4TVBAxdMXR7yw==} + peerDependencies: + '@assistant-ui/store': ^0.2.13 + '@assistant-ui/tap': ^0.9.0 + '@types/react': '*' + assistant-cloud: ^0.1.31 + react: ^18 || ^19 + zustand: ^5.0.11 + peerDependenciesMeta: + '@types/react': + optional: true + assistant-cloud: + optional: true + react: + optional: true + zustand: + optional: true + + '@assistant-ui/next@0.0.7': + resolution: {integrity: sha512-/zixu65/uMHNUT8ru6dW46hmAbJLaFMJnpXSur7Qi3YkC5zIfyxGjUoRei0MduKtFkuj21FZBfU1xIljX1vhpQ==} + + '@assistant-ui/react-langgraph@0.14.10': + resolution: {integrity: sha512-5rq6RTtJkiyYnRLN0rWhZNzX0S9dJaJpM4dZFXWsng4tsohi557k4ilynhlBTMQgTCjQAflRg3aCPqqAkKtHPA==} + peerDependencies: + '@langchain/langgraph-sdk': ^1.8.0 + '@types/react': '*' + react: ^18 || ^19 + peerDependenciesMeta: + '@langchain/langgraph-sdk': + optional: true + '@types/react': + optional: true + + '@assistant-ui/react-markdown@0.14.5': + resolution: {integrity: sha512-qHuxCQaUQem9XE5WiWQBblTkT0m6kAVQhXme1+y6GD25BfrmSRJRmza9CdfG/gRK14k/xaZlUikW/3iqwYevtA==} + peerDependencies: + '@assistant-ui/react': ^0.14.18 + '@types/react': '*' + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@assistant-ui/react-o11y@0.0.24': + resolution: {integrity: sha512-F+KvixU9gwrn5ZuaV/IwTcP7xvI+/RCLxp2g33lXIdr+FZ0NFSVOFdFtaZ0WpoB0RDPefZKOK+eOZHmIEq6fZw==} + peerDependencies: + '@types/react': '*' + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@assistant-ui/react@0.14.24': + resolution: {integrity: sha512-DHUEbJfn3EeApiLXJp6pZfwzGUwQ7aAoGeUfs8bmo1G9uAkRlxF1RKr7MwM/oVSUt+ixSsfWey+OuHJi5gtKCQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^18 || ^19 + react-dom: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@assistant-ui/store@0.2.18': + resolution: {integrity: sha512-5MiZXAXjsZuH3ZVEemuiD5L8wq/pXax8lSlaIsdTPEkDZDFupsiDwuOeum+h+ctX8H8oKgkCpN4iPUIiiLKuVg==} + peerDependencies: + '@assistant-ui/tap': ^0.9.0 + '@types/react': '*' + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@assistant-ui/store@0.2.19': + resolution: {integrity: sha512-7GMEoK+H4iINquteLFXqqDB5SAzB19YHBy4KKQprxHseAtDrpkC8w9pTrPfJ1yLvbn3FqjXk645bCt5vDf1sTg==} + peerDependencies: + '@assistant-ui/tap': ^0.9.0 + '@types/react': '*' + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@assistant-ui/tap@0.9.2': + resolution: {integrity: sha512-wrvZqozwHH9o/buoaLeK1m9oRWEXyfeWDXqijUn0qdidLh5/6tR8aAfhrUgWPHvBpvU925rqjZ+8fFjewt20Gg==} + peerDependencies: + '@types/react': '*' + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@assistant-ui/tap@0.9.3': + resolution: {integrity: sha512-IKIgDbaKUPvVt2hMKL+WU2E8oru5J3muO9iSjL/vEVf6TNz5H07wrOKwD+1xhitqR1Nc4WtHK986jqoeGmWRDw==} + peerDependencies: + '@types/react': '*' + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@assistant-ui/x-generative-compiler@0.0.5': + resolution: {integrity: sha512-Gk5Y7iIQ/1Ohcar/yBSh8ICkjZANzGK2JKqz3p6Kvc54RkSvRKzYJFZOahZAMfewBZdKid3be2ZsVmGDCE8Ogw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.27.0': + resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.27.0': + resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@base-org/account@2.4.0': + resolution: {integrity: sha512-A4Umpi8B9/pqR78D1Yoze4xHyQaujioVRqqO3d6xuDFw9VRtjg6tK3bPlwE0aW+nVH/ntllCpPa2PbI8Rnjcug==} + + '@base-ui/react@1.6.0': + resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.3.1': + resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@better-auth-ui/core@1.6.27': + resolution: {integrity: sha512-mu6GXQb+boYGPcDOoahw09kL1YbEEwL+oBD58CV710Nt5vu2zfNBQPmBWFNeVjidJetaPrTi9CsE01LgaRbR7w==} + peerDependencies: + '@mikkelscheike/email-provider-links': '>=5.1.8' + better-auth: '>=1.6.19' + + '@better-auth-ui/react@1.6.27': + resolution: {integrity: sha512-TUFoAymErBn9YHyHd51Yl27qZ3/pwrmu+E2E97qcO0TJOFxTazYx37HMzGlJZNvzI4negpDk868pT7KhmvSWRQ==} + peerDependencies: + '@better-auth-ui/core': '*' + '@better-auth/api-key': '>=1.6.19' + '@better-auth/passkey': '>=1.6.19' + '@react-email/components': '>=1.0.12' + '@tanstack/query-core': '>=5.100.14' + '@tanstack/react-query': '>=5.100.14' + better-auth: '>=1.6.19' + clsx: '>=2.0.0' + react: '>=19.2.6' + react-dom: '>=19.2.6' + tailwind-merge: '>=3.5.0' + tailwindcss: '>=4.3.1' + zod: '>=4.4.3' + + '@better-auth/api-key@1.6.20': + resolution: {integrity: sha512-BgyhJ74m8z3wKODJV348Qr19cG3jnJT/r7yBe6YpOnirAImFt1IzmERUDOXylizMrFWOb8SM4PbVuq5hKSCe4g==} + peerDependencies: + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + better-auth: ^1.6.20 + better-call: 1.3.6 + + '@better-auth/core@1.6.22': + resolution: {integrity: sha512-aFH/5nzmR501jAJPKjJfiVg4BrkcjVCqq9WS9JnhTruE/2PIWopv1QGMiRIRqxXaPbHczri7cqRdhep3Lg5PMw==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.7 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.6.22': + resolution: {integrity: sha512-uNa9qH53CfxBmuKP8kbLWxY90oIRwUPn5BcHhO+szK05e2yh6EYwSNNivDSqV3YG5HPfjtPHulklInjq1wtm3w==} + peerDependencies: + '@better-auth/core': ^1.6.22 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.6.22': + resolution: {integrity: sha512-4k/07lPRizlQi+B+uOE5CwTfH3w+Lq8ZDX1nDN1+e+glRVKAIfHoLvC9cfAVcCbio3DDrl0RTbwjLwjEhG0LxA==} + peerDependencies: + '@better-auth/core': ^1.6.22 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.6.22': + resolution: {integrity: sha512-rbepe/gHhWs0aF4fAu6+l+wNPJxT9XN4U+Hqa1Y/5HhjtT9y5evo3INrSWlkIOymsMaQ0cBPrSL5pm9Z195hcA==} + peerDependencies: + '@better-auth/core': ^1.6.22 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.22': + resolution: {integrity: sha512-OYnfySHlVkIx7y6XNBsCHjKhl6IYGprRkFwifc/TAuPBVjoRKhLKRAXuzMdWTQYFt4FYb6holbhjNUrXxPIWcw==} + peerDependencies: + '@better-auth/core': ^1.6.22 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/passkey@1.6.20': + resolution: {integrity: sha512-C3EZcQxVKJOjF7Wvfa1nRq3IBXReRDB4z36+OK1V/XjvBIEcIVLFcaG19JmsYVJorMPZ7x5JZNUjkU38CyYAcg==} + peerDependencies: + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + better-auth: ^1.6.20 + better-call: 1.3.6 + nanostores: ^1.0.1 + + '@better-auth/prisma-adapter@1.6.22': + resolution: {integrity: sha512-I6lWQwLva732V600u5dLM2kRcQ94pRZOVVfZ87+Ow9RBxMUnV+I+YQ5h2yggdN2tmsmITacZTy1DSZbDxGu0LQ==} + peerDependencies: + '@better-auth/core': ^1.6.22 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.6.22': + resolution: {integrity: sha512-glq/oEk9qP+zGh9k/WUH5+pwvBCMolNNhaAVBCtYQrkADFee2gP3VoPs1YeO9coNuOmBhc+AYSIHs+fL9DoJnw==} + peerDependencies: + '@better-auth/core': ^1.6.22 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + + '@clack/core@0.4.1': + resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} + + '@clack/prompts@0.9.1': + resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==} + + '@coinbase/cdp-sdk@1.51.2': + resolution: {integrity: sha512-o4IEwXbyAjfhPQWoFBuqnV1JQGLk4NlUVMzH/ur4voPSjYZvlYFVuOoE/eEcsoPFN28xaWTBvqebwncQL8h8fQ==} + + '@coinbase/wallet-sdk@3.9.3': + resolution: {integrity: sha512-N/A2DRIf0Y3PHc1XAMvbBUu4zisna6qAdqABMZwBMNEfWrXpAwx16pZGkYCLGE+Rvv1edbcB2LYDRnACNcmCiw==} + + '@coinbase/wallet-sdk@4.3.6': + resolution: {integrity: sha512-4q8BNG1ViL4mSAAvPAtpwlOs1gpC+67eQtgIwNvT3xyeyFFd+guwkc8bcX5rTmQhXpqnhzC4f0obACbP9CqMSA==} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@commander-js/extra-typings@13.1.0': + resolution: {integrity: sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==} + peerDependencies: + commander: ~13.1.0 + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.8': + resolution: {integrity: sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@ecies/ciphers@0.2.6': + resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} + engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@ethereumjs/common@3.2.0': + resolution: {integrity: sha512-pksvzI0VyLgmuEF2FA/JR/4/y6hcPq8OUail3/AvycBaW1d5VSauOZzqGvJ3RTmR4MU35lWE8KseKOsEhrFRBA==} + + '@ethereumjs/rlp@4.0.1': + resolution: {integrity: sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw==} + engines: {node: '>=14'} + hasBin: true + + '@ethereumjs/tx@4.2.0': + resolution: {integrity: sha512-1nc6VO4jtFd172BbSnTnDQVr9IYBFl1y4xPzZdtkrkKIncBCkdbgfdRV+MiTkJYAtTxvV12GRZLqBFT1PNK6Yw==} + engines: {node: '>=14'} + + '@ethereumjs/util@8.1.0': + resolution: {integrity: sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==} + engines: {node: '>=14'} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@gemini-wallet/core@0.3.2': + resolution: {integrity: sha512-Z4aHi3ECFf5oWYWM3F1rW83GJfB9OvhBYPTmb5q+VyK3uvzvS48lwo+jwh2eOoCRWEuT/crpb9Vwp2QaS5JqgQ==} + peerDependencies: + viem: '>=2.0.0' + + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@hono/node-ws@1.3.1': + resolution: {integrity: sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@hono/node-server': ^1.19.11 + hono: ^4.6.0 + + '@hono/zod-validator@0.7.6': + resolution: {integrity: sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==} + peerDependencies: + hono: '>=3.9.0' + zod: ^3.25.0 || ^4.0.0 + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@langchain/core@1.2.1': + resolution: {integrity: sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g==} + engines: {node: '>=20'} + + '@langchain/langgraph-api@1.4.1': + resolution: {integrity: sha512-JYVgm1ve/yLP8KlWVZNestG9P1nNganYDcXVtPxsKPS046aTX7PvuYSxkFWtJ9Mls0vOy1baBw1SnlqihHiQ9A==} + engines: {node: ^18.19.0 || >=20.16.0} + peerDependencies: + '@langchain/core': ^1.1.48 + '@langchain/langgraph': ^1.3.6 + '@langchain/langgraph-checkpoint': ~0.0.16 || ^0.1.0 || ^1.0.0 + '@langchain/langgraph-sdk': ^1.9.3-rc.0 + ts-node: ^10.9.2 + typescript: ^5.5.4 + peerDependenciesMeta: + '@langchain/langgraph-sdk': + optional: true + ts-node: + optional: true + + '@langchain/langgraph-checkpoint-postgres@1.0.4': + resolution: {integrity: sha512-wuCPq7VVqwi0+nQC5GOmheoXGJHAybgXbkA9Nn0J8nYW61WFJ3uqb4wyMbLFhAlya99EaxNNsi3FzdzV2COSzw==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.44 + '@langchain/langgraph-checkpoint': ^1.0.0 + + '@langchain/langgraph-checkpoint@1.1.3': + resolution: {integrity: sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.48 + + '@langchain/langgraph-cli@1.4.1': + resolution: {integrity: sha512-k+JDdvNF5okM+I8rAce5v0wa9zOozyqnfq55tk5AkamlzkbMkcpBKThy4+7kJw5aVxb60KfeTNS3RdUzU8+3KQ==} + engines: {node: ^18.19.0 || >=20.16.0} + hasBin: true + + '@langchain/langgraph-sdk@1.9.25': + resolution: {integrity: sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==} + peerDependencies: + '@langchain/core': ^1.1.48 + react: ^18 || ^19 + react-dom: ^18 || ^19 + svelte: ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + svelte: + optional: true + vue: + optional: true + + '@langchain/langgraph-ui@1.4.1': + resolution: {integrity: sha512-ij/bQc0napyqQYJfs2CwFetcmcr5baSaBNgKaeWFeiT3I7pBozsznCi+K9cl0O+vLE28w72BvBxVBDBeKPyEeg==} + engines: {node: ^18.19.0 || >=20.16.0} + hasBin: true + + '@langchain/langgraph@1.4.7': + resolution: {integrity: sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.48 + zod: ^3.25.32 || ^4.2.0 + + '@langchain/openai@1.5.3': + resolution: {integrity: sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.2.1 + + '@langchain/protocol@0.0.18': + resolution: {integrity: sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==} + + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + + '@lit-labs/ssr-dom-shim@1.6.0': + resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==} + + '@lit/reactive-element@2.1.2': + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + + '@metamask/eth-json-rpc-provider@1.0.1': + resolution: {integrity: sha512-whiUMPlAOrVGmX8aKYVPvlKyG4CpQXiNNyt74vE1xb5sPvmx5oA7B/kOi/JdBvhGQq97U1/AVdXEdk2zkP8qyA==} + engines: {node: '>=14.0.0'} + + '@metamask/json-rpc-engine@7.3.3': + resolution: {integrity: sha512-dwZPq8wx9yV3IX2caLi9q9xZBw2XeIoYqdyihDDDpuHVCEiqadJLwqM3zy+uwf6F1QYQ65A8aOMQg1Uw7LMLNg==} + engines: {node: '>=16.0.0'} + + '@metamask/json-rpc-engine@8.0.2': + resolution: {integrity: sha512-IoQPmql8q7ABLruW7i4EYVHWUbF74yrp63bRuXV5Zf9BQwcn5H9Ww1eLtROYvI1bUXwOiHZ6qT5CWTrDc/t/AA==} + engines: {node: '>=16.0.0'} + + '@metamask/json-rpc-middleware-stream@7.0.2': + resolution: {integrity: sha512-yUdzsJK04Ev98Ck4D7lmRNQ8FPioXYhEUZOMS01LXW8qTvPGiRVXmVltj2p4wrLkh0vW7u6nv0mNl5xzC5Qmfg==} + engines: {node: '>=16.0.0'} + + '@metamask/object-multiplex@2.1.0': + resolution: {integrity: sha512-4vKIiv0DQxljcXwfpnbsXcfa5glMj5Zg9mqn4xpIWqkv6uJ2ma5/GtUfLFSxhlxnR8asRMv8dDmWya1Tc1sDFA==} + engines: {node: ^16.20 || ^18.16 || >=20} + + '@metamask/onboarding@1.0.1': + resolution: {integrity: sha512-FqHhAsCI+Vacx2qa5mAFcWNSrTcVGMNjzxVgaX8ECSny/BJ9/vgXP9V7WF/8vb9DltPeQkxr+Fnfmm6GHfmdTQ==} + + '@metamask/providers@16.1.0': + resolution: {integrity: sha512-znVCvux30+3SaUwcUGaSf+pUckzT5ukPRpcBmy+muBLC0yaWnBcvDqGfcsw6CBIenUdFrVoAFa8B6jsuCY/a+g==} + engines: {node: ^18.18 || >=20} + + '@metamask/rpc-errors@6.4.0': + resolution: {integrity: sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg==} + engines: {node: '>=16.0.0'} + + '@metamask/rpc-errors@7.0.2': + resolution: {integrity: sha512-YYYHsVYd46XwY2QZzpGeU4PSdRhHdxnzkB8piWGvJW2xbikZ3R+epAYEL4q/K8bh9JPTucsUdwRFnACor1aOYw==} + engines: {node: ^18.20 || ^20.17 || >=22} + + '@metamask/safe-event-emitter@2.0.0': + resolution: {integrity: sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q==} + + '@metamask/safe-event-emitter@3.1.2': + resolution: {integrity: sha512-5yb2gMI1BDm0JybZezeoX/3XhPDOtTbcFvpTXM9kxsoZjPZFh4XciqRbpD6N86HYZqWDhEaKUDuOyR0sQHEjMA==} + engines: {node: '>=12.0.0'} + + '@metamask/sdk-analytics@0.0.5': + resolution: {integrity: sha512-fDah+keS1RjSUlC8GmYXvx6Y26s3Ax1U9hGpWb6GSY5SAdmTSIqp2CvYy6yW0WgLhnYhW+6xERuD0eVqV63QIQ==} + deprecated: No longer maintained, superseded by @metamask/connect-analytics + + '@metamask/sdk-communication-layer@0.33.1': + resolution: {integrity: sha512-0bI9hkysxcfbZ/lk0T2+aKVo1j0ynQVTuB3sJ5ssPWlz+Z3VwveCkP1O7EVu1tsVVCb0YV5WxK9zmURu2FIiaA==} + deprecated: No longer maintained, superseded by https://docs.metamask.io/metamask-connect + peerDependencies: + cross-fetch: ^4.0.0 + eciesjs: '*' + eventemitter2: ^6.4.9 + readable-stream: ^3.6.2 + socket.io-client: ^4.5.1 + + '@metamask/sdk-install-modal-web@0.32.1': + resolution: {integrity: sha512-MGmAo6qSjf1tuYXhCu2EZLftq+DSt5Z7fsIKr2P+lDgdTPWgLfZB1tJKzNcwKKOdf6q9Qmmxn7lJuI/gq5LrKw==} + deprecated: No longer maintained, superseded by https://docs.metamask.io/metamask-connect + + '@metamask/sdk@0.33.1': + resolution: {integrity: sha512-1mcOQVGr9rSrVcbKPNVzbZ8eCl1K0FATsYH3WJ/MH4WcZDWGECWrXJPNMZoEAkLxWiMe8jOQBumg2pmcDa9zpQ==} + deprecated: No longer maintained, superseded by https://docs.metamask.io/metamask-connect + + '@metamask/superstruct@3.2.1': + resolution: {integrity: sha512-fLgJnDOXFmuVlB38rUN5SmU7hAFQcCjrg3Vrxz67KTY7YHFnSNEKvX4avmEBdOI0yTCxZjwMCFEqsC8k2+Wd3g==} + engines: {node: '>=16.0.0'} + + '@metamask/utils@11.11.0': + resolution: {integrity: sha512-0nF2CWjWQr/m0Y2t2lJnBTU1/CZPPTvKvcESLplyWe/tyeb8zFOi/FeneDmaFnML6LYRIGZU6f+xR0jKAIUZfw==} + engines: {node: ^18.18 || ^20.14 || >=22} + + '@metamask/utils@5.0.2': + resolution: {integrity: sha512-yfmE79bRQtnMzarnKfX7AEJBwFTxvTyw3nBQlu/5rmGXrjAeAMltoGxO62TFurxrQAFMNa/fEjIHNvungZp0+g==} + engines: {node: '>=14.0.0'} + + '@metamask/utils@8.5.0': + resolution: {integrity: sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ==} + engines: {node: '>=16.0.0'} + + '@metamask/utils@9.3.0': + resolution: {integrity: sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g==} + engines: {node: '>=16.0.0'} + + '@mikkelscheike/email-provider-links@5.1.8': + resolution: {integrity: sha512-I499oaqSgwpzIE8zkAJaKvenUm2au4SVCnrBNxScuPYTFsVvxsJ4kYrkWs23zg1czwDTaN9C+4k8eDHmIONeFw==} + engines: {comment: 'Supports Node.js 18.x, 20.x, 22.x, 24.x, and 25.x', node: '>=18.0.0'} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@next/env@16.1.7': + resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==} + + '@next/env@16.2.9': + resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + + '@next/swc-darwin-arm64@16.1.7': + resolution: {integrity: sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.1.7': + resolution: {integrity: sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.1.7': + resolution: {integrity: sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.1.7': + resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.1.7': + resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.1.7': + resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.1.7': + resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.1.7': + resolution: {integrity: sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@noble/ciphers@1.2.1': + resolution: {integrity: sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@1.4.2': + resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} + + '@noble/curves@1.8.0': + resolution: {integrity: sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.8.1': + resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@noble/hashes@1.7.0': + resolution: {integrity: sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.7.1': + resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@oxfmt/binding-android-arm-eabi@0.55.0': + resolution: {integrity: sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.55.0': + resolution: {integrity: sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.55.0': + resolution: {integrity: sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.55.0': + resolution: {integrity: sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.55.0': + resolution: {integrity: sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.55.0': + resolution: {integrity: sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.55.0': + resolution: {integrity: sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.55.0': + resolution: {integrity: sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-arm64-musl@0.55.0': + resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-ppc64-gnu@0.55.0': + resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-gnu@0.55.0': + resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-musl@0.55.0': + resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-s390x-gnu@0.55.0': + resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxfmt/binding-linux-x64-gnu@0.55.0': + resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-linux-x64-musl@0.55.0': + resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-openharmony-arm64@0.55.0': + resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.55.0': + resolution: {integrity: sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.55.0': + resolution: {integrity: sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.55.0': + resolution: {integrity: sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.71.0': + resolution: {integrity: sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.71.0': + resolution: {integrity: sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.71.0': + resolution: {integrity: sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.71.0': + resolution: {integrity: sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.71.0': + resolution: {integrity: sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.71.0': + resolution: {integrity: sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.71.0': + resolution: {integrity: sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.71.0': + resolution: {integrity: sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.71.0': + resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.71.0': + resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.71.0': + resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.71.0': + resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.71.0': + resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.71.0': + resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.71.0': + resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.71.0': + resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.71.0': + resolution: {integrity: sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.71.0': + resolution: {integrity: sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.71.0': + resolution: {integrity: sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@paulmillr/qr@0.2.1': + resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} + deprecated: 'Switch to "qr" (new package name) for security updates: npm install qr' + + '@peculiar/asn1-android@2.8.0': + resolution: {integrity: sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==} + + '@peculiar/asn1-cms@2.8.0': + resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==} + + '@peculiar/asn1-csr@2.8.0': + resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==} + + '@peculiar/asn1-ecc@2.8.0': + resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==} + + '@peculiar/asn1-pfx@2.8.0': + resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==} + + '@peculiar/asn1-pkcs8@2.8.0': + resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==} + + '@peculiar/asn1-pkcs9@2.8.0': + resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==} + + '@peculiar/asn1-rsa@2.8.0': + resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==} + + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/asn1-x509-attr@2.8.0': + resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==} + + '@peculiar/asn1-x509@2.8.0': + resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + + '@radix-ui/number@1.1.2': + resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} + + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + + '@radix-ui/react-accessible-icon@1.1.10': + resolution: {integrity: sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.14': + resolution: {integrity: sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.17': + resolution: {integrity: sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.10': + resolution: {integrity: sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.10': + resolution: {integrity: sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.2.0': + resolution: {integrity: sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.5': + resolution: {integrity: sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.14': + resolution: {integrity: sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.10': + resolution: {integrity: sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.1': + resolution: {integrity: sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.17': + resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.13': + resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.18': + resolution: {integrity: sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.10': + resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.10': + resolution: {integrity: sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.17': + resolution: {integrity: sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.10': + resolution: {integrity: sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.18': + resolution: {integrity: sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.18': + resolution: {integrity: sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.16': + resolution: {integrity: sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.10': + resolution: {integrity: sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.5': + resolution: {integrity: sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.17': + resolution: {integrity: sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.1': + resolution: {integrity: sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.12': + resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.6': + resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.10': + resolution: {integrity: sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.4.1': + resolution: {integrity: sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.13': + resolution: {integrity: sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.12': + resolution: {integrity: sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.3.1': + resolution: {integrity: sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.10': + resolution: {integrity: sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.4.1': + resolution: {integrity: sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.3.1': + resolution: {integrity: sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.15': + resolution: {integrity: sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.17': + resolution: {integrity: sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.13': + resolution: {integrity: sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.12': + resolution: {integrity: sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.13': + resolution: {integrity: sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.10': + resolution: {integrity: sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.2': + resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.1': + resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.2': + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.6': + resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + + '@rainbow-me/rainbowkit@2.2.11': + resolution: {integrity: sha512-FHPsRHMBpuHHhuyKktAR13O9agmsUUunDnVEP4hG1dSZ2JojXLUSWyLG28VbGIJakHYylkNguiLFnqM/BM8ERA==} + engines: {node: '>=12.4'} + peerDependencies: + '@tanstack/react-query': '>=5.0.0' + react: '>=18' + react-dom: '>=18' + viem: 2.x + wagmi: ^2.9.0 + + '@react-email/body@0.3.0': + resolution: {integrity: sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/button@0.2.1': + resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-block@0.2.1': + resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-inline@0.0.6': + resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/column@0.0.14': + resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/components@1.0.12': + resolution: {integrity: sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/container@0.0.16': + resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/font@0.0.10': + resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/head@0.0.13': + resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/heading@0.0.16': + resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/hr@0.0.12': + resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/html@0.0.12': + resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/img@0.0.12': + resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/link@0.0.13': + resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/markdown@0.0.18': + resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/preview@0.0.14': + resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/render@2.0.6': + resolution: {integrity: sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/render@2.0.9': + resolution: {integrity: sha512-M89LiXy2q+9tmQ4VMR0rYGuEe6NJ6HhZsSxBMoLwIia5fVOLcZQcZe2GEO0nAkLZspDjEhn7mtMRPmeMKECEdQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/row@0.0.13': + resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/section@0.0.17': + resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/tailwind@2.0.7': + resolution: {integrity: sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + '@react-email/body': '>=0' + '@react-email/button': '>=0' + '@react-email/code-block': '>=0' + '@react-email/code-inline': '>=0' + '@react-email/container': '>=0' + '@react-email/heading': '>=0' + '@react-email/hr': '>=0' + '@react-email/img': '>=0' + '@react-email/link': '>=0' + '@react-email/preview': '>=0' + '@react-email/text': '>=0' + react: ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@react-email/body': + optional: true + '@react-email/button': + optional: true + '@react-email/code-block': + optional: true + '@react-email/code-inline': + optional: true + '@react-email/container': + optional: true + '@react-email/heading': + optional: true + '@react-email/hr': + optional: true + '@react-email/img': + optional: true + '@react-email/link': + optional: true + '@react-email/preview': + optional: true + + '@react-email/text@0.1.6': + resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@reown/appkit-common@1.7.8': + resolution: {integrity: sha512-ridIhc/x6JOp7KbDdwGKY4zwf8/iK8EYBl+HtWrruutSLwZyVi5P8WaZa+8iajL6LcDcDF7LoyLwMTym7SRuwQ==} + + '@reown/appkit-controllers@1.7.8': + resolution: {integrity: sha512-IdXlJlivrlj6m63VsGLsjtPHHsTWvKGVzWIP1fXZHVqmK+rZCBDjCi9j267Rb9/nYRGHWBtlFQhO8dK35WfeDA==} + + '@reown/appkit-pay@1.7.8': + resolution: {integrity: sha512-OSGQ+QJkXx0FEEjlpQqIhT8zGJKOoHzVnyy/0QFrl3WrQTjCzg0L6+i91Ad5Iy1zb6V5JjqtfIFpRVRWN4M3pw==} + + '@reown/appkit-polyfills@1.7.8': + resolution: {integrity: sha512-W/kq786dcHHAuJ3IV2prRLEgD/2iOey4ueMHf1sIFjhhCGMynMkhsOhQMUH0tzodPqUgAC494z4bpIDYjwWXaA==} + + '@reown/appkit-scaffold-ui@1.7.8': + resolution: {integrity: sha512-RCeHhAwOrIgcvHwYlNWMcIDibdI91waaoEYBGw71inE0kDB8uZbE7tE6DAXJmDkvl0qPh+DqlC4QbJLF1FVYdQ==} + + '@reown/appkit-ui@1.7.8': + resolution: {integrity: sha512-1hjCKjf6FLMFzrulhl0Y9Vb9Fu4royE+SXCPSWh4VhZhWqlzUFc7kutnZKx8XZFVQH4pbBvY62SpRC93gqoHow==} + + '@reown/appkit-utils@1.7.8': + resolution: {integrity: sha512-8X7UvmE8GiaoitCwNoB86pttHgQtzy4ryHZM9kQpvjQ0ULpiER44t1qpVLXNM4X35O0v18W0Dk60DnYRMH2WRw==} + peerDependencies: + valtio: 1.13.2 + + '@reown/appkit-wallet@1.7.8': + resolution: {integrity: sha512-kspz32EwHIOT/eg/ZQbFPxgXq0B/olDOj3YMu7gvLEFz4xyOFd/wgzxxAXkp5LbG4Cp++s/elh79rVNmVFdB9A==} + + '@reown/appkit@1.7.8': + resolution: {integrity: sha512-51kTleozhA618T1UvMghkhKfaPcc9JlKwLJ5uV+riHyvSoWPKPRIa5A6M1Wano5puNyW0s3fwywhyqTHSilkaA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@safe-global/safe-apps-provider@0.18.6': + resolution: {integrity: sha512-4LhMmjPWlIO8TTDC2AwLk44XKXaK6hfBTWyljDm0HQ6TWlOEijVWNrt2s3OCVMSxlXAcEzYfqyu1daHZooTC2Q==} + + '@safe-global/safe-apps-sdk@9.1.0': + resolution: {integrity: sha512-N5p/ulfnnA2Pi2M3YeWjULeWbjo7ei22JwU/IXnhoHzKq3pYCN6ynL9mJBOlvDVv892EgLPCWCOwQk/uBT2v0Q==} + + '@safe-global/safe-gateway-typescript-sdk@3.23.1': + resolution: {integrity: sha512-6ORQfwtEJYpalCeVO21L4XXGSdbEMfyp2hEv6cP82afKXSwvse6d3sdelgaPWUxHIsFRkWvHDdzh8IyyKHZKxw==} + engines: {node: '>=16'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@scure/base@1.1.9': + resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.4.0': + resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} + + '@scure/bip32@1.6.2': + resolution: {integrity: sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.3.0': + resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} + + '@scure/bip39@1.5.4': + resolution: {integrity: sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.1': + resolution: {integrity: sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==} + engines: {node: '>=20.0.0'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@solana-program/system@0.10.0': + resolution: {integrity: sha512-Go+LOEZmqmNlfr+Gjy5ZWAdY5HbYzk2RBewD9QinEU/bBSzpFfzqDRT55JjFRBGJUvMgf3C2vfXEGT4i8DSI4g==} + peerDependencies: + '@solana/kit': ^5.0 + + '@solana-program/token@0.9.0': + resolution: {integrity: sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==} + peerDependencies: + '@solana/kit': ^5.0 + + '@solana/accounts@5.5.1': + resolution: {integrity: sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/addresses@5.5.1': + resolution: {integrity: sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/assertions@5.5.1': + resolution: {integrity: sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-core@5.5.1': + resolution: {integrity: sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-data-structures@5.5.1': + resolution: {integrity: sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-numbers@5.5.1': + resolution: {integrity: sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-strings@5.5.1': + resolution: {integrity: sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==} + engines: {node: '>=20.18.0'} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: ^5.0.0 + peerDependenciesMeta: + fastestsmallesttextencoderdecoder: + optional: true + typescript: + optional: true + + '@solana/codecs@5.5.1': + resolution: {integrity: sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/errors@5.5.1': + resolution: {integrity: sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/fast-stable-stringify@5.5.1': + resolution: {integrity: sha512-Ni7s2FN33zTzhTFgRjEbOVFO+UAmK8qi3Iu0/GRFYK4jN696OjKHnboSQH/EacQ+yGqS54bfxf409wU5dsLLCw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/functional@5.5.1': + resolution: {integrity: sha512-tTHoJcEQq3gQx5qsdsDJ0LEJeFzwNpXD80xApW9o/PPoCNimI3SALkZl+zNW8VnxRrV3l3yYvfHWBKe/X3WG3w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/instruction-plans@5.5.1': + resolution: {integrity: sha512-7z3CB7YMcFKuVvgcnNY8bY6IsZ8LG61Iytbz7HpNVGX2u1RthOs1tRW8luTzSG1MPL0Ox7afyAVMYeFqSPHnaQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/instructions@5.5.1': + resolution: {integrity: sha512-h0G1CG6S+gUUSt0eo6rOtsaXRBwCq1+Js2a+Ps9Bzk9q7YHNFA75/X0NWugWLgC92waRp66hrjMTiYYnLBoWOQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/keys@5.5.1': + resolution: {integrity: sha512-KRD61cL7CRL+b4r/eB9dEoVxIf/2EJ1Pm1DmRYhtSUAJD2dJ5Xw8QFuehobOGm9URqQ7gaQl+Fkc1qvDlsWqKg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/kit@5.5.1': + resolution: {integrity: sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/nominal-types@5.5.1': + resolution: {integrity: sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/offchain-messages@5.5.1': + resolution: {integrity: sha512-g+xHH95prTU+KujtbOzj8wn+C7ZNoiLhf3hj6nYq3MTyxOXtBEysguc97jJveUZG0K97aIKG6xVUlMutg5yxhw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/options@5.5.1': + resolution: {integrity: sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/plugin-core@5.5.1': + resolution: {integrity: sha512-VUZl30lDQFJeiSyNfzU1EjYt2QZvoBFKEwjn1lilUJw7KgqD5z7mbV7diJhT+dLFs36i0OsjXvq5kSygn8YJ3A==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/programs@5.5.1': + resolution: {integrity: sha512-7U9kn0Jsx1NuBLn5HRTFYh78MV4XN145Yc3WP/q5BlqAVNlMoU9coG5IUTJIG847TUqC1lRto3Dnpwm6T4YRpA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/promises@5.5.1': + resolution: {integrity: sha512-T9lfuUYkGykJmppEcssNiCf6yiYQxJkhiLPP+pyAc2z84/7r3UVIb2tNJk4A9sucS66pzJnVHZKcZVGUUp6wzA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-api@5.5.1': + resolution: {integrity: sha512-XWOQQPhKl06Vj0xi3RYHAc6oEQd8B82okYJ04K7N0Vvy3J4PN2cxeK7klwkjgavdcN9EVkYCChm2ADAtnztKnA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-parsed-types@5.5.1': + resolution: {integrity: sha512-HEi3G2nZqGEsa3vX6U0FrXLaqnUCg4SKIUrOe8CezD+cSFbRTOn3rCLrUmJrhVyXlHoQVaRO9mmeovk31jWxJg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-spec-types@5.5.1': + resolution: {integrity: sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-spec@5.5.1': + resolution: {integrity: sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions-api@5.5.1': + resolution: {integrity: sha512-5Oi7k+GdeS8xR2ly1iuSFkAv6CZqwG0Z6b1QZKbEgxadE1XGSDrhM2cn59l+bqCozUWCqh4c/A2znU/qQjROlw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions-channel-websocket@5.5.1': + resolution: {integrity: sha512-7tGfBBrYY8TrngOyxSHoCU5shy86iA9SRMRrPSyBhEaZRAk6dnbdpmUTez7gtdVo0BCvh9nzQtUycKWSS7PnFQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions-spec@5.5.1': + resolution: {integrity: sha512-iq+rGq5fMKP3/mKHPNB6MC8IbVW41KGZg83Us/+LE3AWOTWV1WT20KT2iH1F1ik9roi42COv/TpoZZvhKj45XQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-subscriptions@5.5.1': + resolution: {integrity: sha512-CTMy5bt/6mDh4tc6vUJms9EcuZj3xvK0/xq8IQ90rhkpYvate91RjBP+egvjgSayUg9yucU9vNuUpEjz4spM7w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-transformers@5.5.1': + resolution: {integrity: sha512-OsWqLCQdcrRJKvHiMmwFhp9noNZ4FARuMkHT5us3ustDLXaxOjF0gfqZLnMkulSLcKt7TGXqMhBV+HCo7z5M8Q==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-transport-http@5.5.1': + resolution: {integrity: sha512-yv8GoVSHqEV0kUJEIhkdOVkR2SvJ6yoWC51cJn2rSV7plr6huLGe0JgujCmB7uZhhaLbcbP3zxXxu9sOjsi7Fg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc-types@5.5.1': + resolution: {integrity: sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/rpc@5.5.1': + resolution: {integrity: sha512-ku8zTUMrkCWci66PRIBC+1mXepEnZH/q1f3ck0kJZ95a06bOTl5KU7HeXWtskkyefzARJ5zvCs54AD5nxjQJ+A==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/signers@5.5.1': + resolution: {integrity: sha512-FY0IVaBT2kCAze55vEieR6hag4coqcuJ31Aw3hqRH7mv6sV8oqwuJmUrx+uFwOp1gwd5OEAzlv6N4hOOple4sQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/subscribable@5.5.1': + resolution: {integrity: sha512-9K0PsynFq0CsmK1CDi5Y2vUIJpCqkgSS5yfDN0eKPgHqEptLEaia09Kaxc90cSZDZU5mKY/zv1NBmB6Aro9zQQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/sysvars@5.5.1': + resolution: {integrity: sha512-k3Quq87Mm+geGUu1GWv6knPk0ALsfY6EKSJGw9xUJDHzY/RkYSBnh0RiOrUhtFm2TDNjOailg8/m0VHmi3reFA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/transaction-confirmation@5.5.1': + resolution: {integrity: sha512-j4mKlYPHEyu+OD7MBt3jRoX4ScFgkhZC6H65on4Fux6LMScgivPJlwnKoZMnsgxFgWds0pl+BYzSiALDsXlYtw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/transaction-messages@5.5.1': + resolution: {integrity: sha512-aXyhMCEaAp3M/4fP0akwBBQkFPr4pfwoC5CLDq999r/FUwDax2RE/h4Ic7h2Xk+JdcUwsb+rLq85Y52hq84XvQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/transactions@5.5.1': + resolution: {integrity: sha512-8hHtDxtqalZ157pnx6p8k10D7J/KY/biLzfgh9R09VNLLY3Fqi7kJvJCr7M2ik3oRll56pxhraAGCC9yIT6eOA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.3.1': + resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} + + '@tailwindcss/oxide-android-arm64@4.3.1': + resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.1': + resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.1': + resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.1': + resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.1': + resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.1': + resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + + '@tanstack/devtools-event-client@0.4.4': + resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/pacer@0.21.1': + resolution: {integrity: sha512-hB01dd4rlsYcTCNP7wK186jgAe6K5qimgM1Y5Jtvz+9PUaILvpmeLLjmQNUNSO1l23lIt+CeQR6mO1mjlPvRtQ==} + engines: {node: '>=18'} + + '@tanstack/query-core@5.101.1': + resolution: {integrity: sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==} + + '@tanstack/react-pacer@0.22.1': + resolution: {integrity: sha512-CenQqK0GluSPIrnsG1yuD7w5uMSQ/4lI9AcGEFxBrRd66r260boWcYRIsS5+eHtXb238FoZYhKmJPGlhRzmHRw==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/react-query@5.101.1': + resolution: {integrity: sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-store@0.11.0': + resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/store@0.11.0': + resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@25.9.4': + resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} + + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript/vfs@1.6.4': + resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==} + peerDependencies: + typescript: '*' + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@vanilla-extract/css@1.20.1': + resolution: {integrity: sha512-5I9RNo5uZW9tsBnqrWzJqELegOqTHBrZyDFnES0gR9gJJHBB9dom1N0bwITM9tKwBcfKrTX4a6DHVeQdJ2ubQA==} + + '@vanilla-extract/dynamic@2.1.5': + resolution: {integrity: sha512-QGIFGb1qyXQkbzx6X6i3+3LMc/iv/ZMBttMBL+Wm/DetQd36KsKsFg5CtH3qy+1hCA/5w93mEIIAiL4fkM8ycw==} + + '@vanilla-extract/private@1.0.9': + resolution: {integrity: sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==} + + '@vanilla-extract/sprinkles@1.6.5': + resolution: {integrity: sha512-HOYidLONR/SeGk8NBAeI64I4gYdsMX9vJmniL13ZcLVwawyK0s2GUENEAcGA+GYLIoeyQB61UqmhqPodJry7zA==} + peerDependencies: + '@vanilla-extract/css': ^1.0.0 + + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + peerDependencies: + '@vitest/browser': 4.1.9 + vitest: 4.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + '@wagmi/connectors@6.2.0': + resolution: {integrity: sha512-2NfkbqhNWdjfibb4abRMrn7u6rPjEGolMfApXss6HCDVt9AW2oVC6k8Q5FouzpJezElxLJSagWz9FW1zaRlanA==} + peerDependencies: + '@wagmi/core': 2.22.1 + typescript: '>=5.0.4' + viem: 2.x + peerDependenciesMeta: + typescript: + optional: true + + '@wagmi/core@2.22.1': + resolution: {integrity: sha512-cG/xwQWsBEcKgRTkQVhH29cbpbs/TdcUJVFXCyri3ZknxhMyGv0YEjTcrNpRgt2SaswL1KrvslSNYKKo+5YEAg==} + peerDependencies: + '@tanstack/query-core': '>=5.0.0' + typescript: '>=5.0.4' + viem: 2.x + peerDependenciesMeta: + '@tanstack/query-core': + optional: true + typescript: + optional: true + + '@walletconnect/core@2.21.0': + resolution: {integrity: sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw==} + engines: {node: '>=18'} + + '@walletconnect/core@2.21.1': + resolution: {integrity: sha512-Tp4MHJYcdWD846PH//2r+Mu4wz1/ZU/fr9av1UWFiaYQ2t2TPLDiZxjLw54AAEpMqlEHemwCgiRiAmjR1NDdTQ==} + engines: {node: '>=18'} + + '@walletconnect/environment@1.0.1': + resolution: {integrity: sha512-T426LLZtHj8e8rYnKfzsw1aG6+M0BT1ZxayMdv/p8yM0MU+eJDISqNY3/bccxRr4LrF9csq02Rhqt08Ibl0VRg==} + + '@walletconnect/ethereum-provider@2.21.1': + resolution: {integrity: sha512-SSlIG6QEVxClgl1s0LMk4xr2wg4eT3Zn/Hb81IocyqNSGfXpjtawWxKxiC5/9Z95f1INyBD6MctJbL/R1oBwIw==} + + '@walletconnect/events@1.0.1': + resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==} + + '@walletconnect/heartbeat@1.2.2': + resolution: {integrity: sha512-uASiRmC5MwhuRuf05vq4AT48Pq8RMi876zV8rr8cV969uTOzWdB/k+Lj5yI2PBtB1bGQisGen7MM1GcZlQTBXw==} + + '@walletconnect/jsonrpc-http-connection@1.0.8': + resolution: {integrity: sha512-+B7cRuaxijLeFDJUq5hAzNyef3e3tBDIxyaCNmFtjwnod5AGis3RToNqzFU33vpVcxFhofkpE7Cx+5MYejbMGw==} + + '@walletconnect/jsonrpc-provider@1.0.14': + resolution: {integrity: sha512-rtsNY1XqHvWj0EtITNeuf8PHMvlCLiS3EjQL+WOkxEOA4KPxsohFnBDeyPYiNm4ZvkQdLnece36opYidmtbmow==} + + '@walletconnect/jsonrpc-types@1.0.4': + resolution: {integrity: sha512-P6679fG/M+wuWg9TY8mh6xFSdYnFyFjwFelxyISxMDrlbXokorEVXYOxiqEbrU3x1BmBoCAJJ+vtEaEoMlpCBQ==} + + '@walletconnect/jsonrpc-utils@1.0.8': + resolution: {integrity: sha512-vdeb03bD8VzJUL6ZtzRYsFMq1eZQcM3EAzT0a3st59dyLfJ0wq+tKMpmGH7HlB7waD858UWgfIcudbPFsbzVdw==} + + '@walletconnect/jsonrpc-ws-connection@1.0.16': + resolution: {integrity: sha512-G81JmsMqh5nJheE1mPst1W0WfVv0SG3N7JggwLLGnI7iuDZJq8cRJvQwLGKHn5H1WTW7DEPCo00zz5w62AbL3Q==} + + '@walletconnect/keyvaluestorage@1.1.1': + resolution: {integrity: sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==} + peerDependencies: + '@react-native-async-storage/async-storage': 1.x + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + + '@walletconnect/logger@2.1.2': + resolution: {integrity: sha512-aAb28I3S6pYXZHQm5ESB+V6rDqIYfsnHaQyzFbwUUBFY4H0OXx/YtTl8lvhUNhMMfb9UxbwEBS253TlXUYJWSw==} + + '@walletconnect/relay-api@1.0.11': + resolution: {integrity: sha512-tLPErkze/HmC9aCmdZOhtVmYZq1wKfWTJtygQHoWtgg722Jd4homo54Cs4ak2RUFUZIGO2RsOpIcWipaua5D5Q==} + + '@walletconnect/relay-auth@1.1.0': + resolution: {integrity: sha512-qFw+a9uRz26jRCDgL7Q5TA9qYIgcNY8jpJzI1zAWNZ8i7mQjaijRnWFKsCHAU9CyGjvt6RKrRXyFtFOpWTVmCQ==} + + '@walletconnect/safe-json@1.0.2': + resolution: {integrity: sha512-Ogb7I27kZ3LPC3ibn8ldyUr5544t3/STow9+lzz7Sfo808YD7SBWk7SAsdBFlYgP2zDRy2hS3sKRcuSRM0OTmA==} + + '@walletconnect/sign-client@2.21.0': + resolution: {integrity: sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA==} + + '@walletconnect/sign-client@2.21.1': + resolution: {integrity: sha512-QaXzmPsMnKGV6tc4UcdnQVNOz4zyXgarvdIQibJ4L3EmLat73r5ZVl4c0cCOcoaV7rgM9Wbphgu5E/7jNcd3Zg==} + + '@walletconnect/time@1.0.2': + resolution: {integrity: sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==} + + '@walletconnect/types@2.21.0': + resolution: {integrity: sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw==} + + '@walletconnect/types@2.21.1': + resolution: {integrity: sha512-UeefNadqP6IyfwWC1Yi7ux+ljbP2R66PLfDrDm8izmvlPmYlqRerJWJvYO4t0Vvr9wrG4Ko7E0c4M7FaPKT/sQ==} + + '@walletconnect/universal-provider@2.21.0': + resolution: {integrity: sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg==} + + '@walletconnect/universal-provider@2.21.1': + resolution: {integrity: sha512-Wjx9G8gUHVMnYfxtasC9poGm8QMiPCpXpbbLFT+iPoQskDDly8BwueWnqKs4Mx2SdIAWAwuXeZ5ojk5qQOxJJg==} + + '@walletconnect/utils@2.21.0': + resolution: {integrity: sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig==} + + '@walletconnect/utils@2.21.1': + resolution: {integrity: sha512-VPZvTcrNQCkbGOjFRbC24mm/pzbRMUq2DSQoiHlhh0X1U7ZhuIrzVtAoKsrzu6rqjz0EEtGxCr3K1TGRqDG4NA==} + + '@walletconnect/window-getters@1.0.1': + resolution: {integrity: sha512-vHp+HqzGxORPAN8gY03qnbTMnhqIwjeRJNOMOAzePRg4xVEEE2WvYsI9G2NMjOknA8hnuYbU3/hwLcKbjhc8+Q==} + + '@walletconnect/window-metadata@1.0.1': + resolution: {integrity: sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==} + + abitype@1.0.6: + resolution: {integrity: sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3 >=3.22.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + abitype@1.0.8: + resolution: {integrity: sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3 >=3.22.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + assistant-cloud@0.1.34: + resolution: {integrity: sha512-kmB9qJmwf1Kb3FoLvVnDV7lsT2vRwaiNX9iDoEs+3Gli8aOQ667DPUIXvEXPyV5zF4gfT0E7GFNEcYWRQTElAA==} + + assistant-stream@0.3.24: + resolution: {integrity: sha512-AmZh8G7QXt2yCZyMZRGfEQRCJKvVYffEhCQQz3tfHixlI20o7zkMHLRpbRUw93gUIzVG+5MHx6j8gy8UHP1mVQ==} + peerDependencies: + ioredis: ^5.10.1 + redis: ^5.12.1 + peerDependenciesMeta: + ioredis: + optional: true + redis: + optional: true + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + async-mutex@0.2.6: + resolution: {integrity: sha512-Hs4R+4SPgamu6rSGW8C7cV9gaWUKEHykfzCCvIRuaVv636Ju10ZdeUbvb4TBEW0INuq2DHZqXbK4Nd3yG4RaRw==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + atomically@2.1.1: + resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: 0.x || 1.x + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-auth@1.6.22: + resolution: {integrity: sha512-B5s6+lPsDWp8rGLRnvNyr5h9tftG9zLRjNrlkEJdYRhcuhPhJiw9b8o6ibgxEFpSAdUqoDaP5/FLqfu8QsXIVg==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: ^0.45.2 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.3.7: + resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + big.js@6.2.2: + resolution: {integrity: sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==} + + bn.js@5.2.4: + resolution: {integrity: sha512-QL7sb18rJ1PbdsKsqPA0guxL563vIMwRHgzNrW/uzQuRGN1Cjqd/wonUBAVqHox9KwzHA6vCbM0lXx3k4iQMow==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} + engines: {node: '>=6.14.2'} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + charenc@0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + + concurrently@10.0.3: + resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==} + engines: {node: '>=22'} + hasBin: true + + conf@15.1.0: + resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==} + engines: {node: '>=20'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + create-langgraph@1.1.5: + resolution: {integrity: sha512-BKKkSLvzeyoZCq3rM0F/YsPlG+p3EWEg9ZV1OgQpZpf4Fc7QWsdlCf/n/USS97m9hdMJTVjLiv7DMaqfKs9JgA==} + hasBin: true + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + crypt@0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cuer@0.0.3: + resolution: {integrity: sha512-f/UNxRMRCYtfLEGECAViByA3JNflZImOk11G9hwSd+44jvzrc99J35u5l+fbdQ2+ZG441GvOpaeGYBmWquZsbQ==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + + debounce-fn@6.0.0: + resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} + engines: {node: '>=18'} + + debounce@2.2.0: + resolution: {integrity: sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==} + engines: {node: '>=18'} + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-object-diff@1.1.9: + resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + derive-valtio@0.1.0: + resolution: {integrity: sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==} + peerDependencies: + valtio: '*' + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-browser@5.3.0: + resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} + + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@10.1.0: + resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} + engines: {node: '>=20'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + drizzle-zod@0.8.3: + resolution: {integrity: sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww==} + peerDependencies: + drizzle-orm: '>=0.36.0' + zod: ^3.25.0 || ^4.0.0 + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + eciesjs@0.4.18: + resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encode-utf8@1.0.3: + resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.9: + resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} + engines: {node: '>=10.2.0'} + + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-toolkit@1.33.0: + resolution: {integrity: sha512-X13Q/ZSc+vsO1q600bvNK4bxgXMkHcf//RxCmYDaRY5DAcT+eoXjY5hoAPGMdRnWQjvyLEcyauG3b6hz76LNqg==} + + esbuild-plugin-tailwindcss@2.2.0: + resolution: {integrity: sha512-xzLRHuDZfbDAld+PlQkY028juyfMrYaMRsB4yLfvF3hKBna/cq3bWAHNKz2WQ2YbwbYwYh3B33N1oqBUoX7aww==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eth-block-tracker@7.1.0: + resolution: {integrity: sha512-8YdplnuE1IK4xfqpf4iU7oBxnOYAc35934o083G8ao+8WM8QQtt/mVlAY6yIAdY1eMeLqg4Z//PZjJGmWGPMRg==} + engines: {node: '>=14.0.0'} + + eth-json-rpc-filters@6.0.1: + resolution: {integrity: sha512-ITJTvqoCw6OVMLs7pI8f4gG92n/St6x80ACtHodeS+IXmO0w+t1T5OOzfSt7KLSMLRkVUoexV7tztLgDxg+iig==} + engines: {node: '>=14.0.0'} + + eth-query@2.1.2: + resolution: {integrity: sha512-srES0ZcvwkR/wd5OQBRA1bIJMww1skfGS0s8wlwK3/oNP4+wnds60krvu5R1QbpRQjMmpG5OMIWro5s7gvDPsA==} + + eth-rpc-errors@4.0.3: + resolution: {integrity: sha512-Z3ymjopaoft7JDoxZcEb3pwdGh7yiYMhOwm2doUt6ASXlMavpNlK6Cre0+IMl2VSGyEU9rkiperQhp5iRxn5Pg==} + + ethereum-cryptography@2.2.1: + resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + exit-hook@4.0.0: + resolution: {integrity: sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==} + engines: {node: '>=18'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extension-port-stream@3.0.0: + resolution: {integrity: sha512-an2S5quJMiy5bnZKEf6AkfH/7r8CzHvhchU40gxN+OM6HPhe7Z9T1FUychcf2M9PpPOO0Hf7BAEfJkw2TDIBDw==} + engines: {node: '>=12.0.0'} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + generic-names@4.0.0: + resolution: {integrity: sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hono@4.12.26: + resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==} + engines: {node: '>=16.9.0'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + icss-utils@5.1.0: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + idb-keyval@6.2.1: + resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} + + idb-keyval@6.2.5: + resolution: {integrity: sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-network-error@1.3.2: + resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} + engines: {node: '>=16'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isows@1.0.6: + resolution: {integrity: sha512-lPHCayd40oW98/I0uvgaHKWCSvkzY27LjWLbtzOm64yQ+G3Q5npjjbdppU65iZXkK1Zt+kH9pfegli0AYfwYYw==} + peerDependencies: + ws: '*' + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-rpc-engine@6.1.0: + resolution: {integrity: sha512-NEdLrtrq1jUZyfjkr9OCz9EzCNhnRyWtt1PAnvnhwy6e8XETS0Dtc+ZNCO2gvuAoKsIn2+vCSowXTYE4CkgnAQ==} + engines: {node: '>=10.0.0'} + + json-rpc-random-id@1.0.1: + resolution: {integrity: sha512-RJ9YYNCkhVDBuP4zN5BBtYAzEl03yq/jIIsyif0JY9qyJuQQZNeDK7anAPKKlyEtLSj2s8h6hNh2F8zO5q7ScA==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keccak@3.0.4: + resolution: {integrity: sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==} + engines: {node: '>=10.0.0'} + + keyvaluestorage-interface@1.0.0: + resolution: {integrity: sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + kysely@0.29.2: + resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} + engines: {node: '>=22.0.0'} + + langsmith@0.7.10: + resolution: {integrity: sha512-3EjJx9zGMzqF60eT9JADHF+Hn/T5ayTgEVp4d3M5yvJIJi3q6seX0p5jT8ecBCWBi1kIvvssWrcDxfwgSier7Q==} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lit-element@4.2.2: + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} + + lit-html@3.3.3: + resolution: {integrity: sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==} + + lit@3.3.0: + resolution: {integrity: sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==} + + loader-utils@3.3.1: + resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} + engines: {node: '>= 12.13.0'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lucide-react@1.21.0: + resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + md5@2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-query-parser@2.0.2: + resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} + + micro-ftch@0.3.1: + resolution: {integrity: sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mipd@0.0.7: + resolution: {integrity: sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + modern-ahocorasick@1.1.0: + resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + nanoid@3.3.13: + resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + + nanostores@1.3.0: + resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==} + engines: {node: ^20.0.0 || >=22.0.0} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + next@16.1.7: + resolution: {integrity: sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-addon-api@2.0.2: + resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nypm@0.6.6: + resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==} + engines: {node: '>=18'} + hasBin: true + + obj-multiplex@1.0.0: + resolution: {integrity: sha512-0GNJAOsHoBHeNTvl5Vt6IWnpUEcc3uSRxzBri7EDyIcMgYvnY2JL2qdeV5zTMjWQX5OHcD5amcW2HFfDh0gjIA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + on-exit-leak-free@0.2.0: + resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + openai@6.44.0: + resolution: {integrity: sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA==} + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + openapi-fetch@0.13.8: + resolution: {integrity: sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + ox@0.14.29: + resolution: {integrity: sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.6.7: + resolution: {integrity: sha512-17Gk/eFsFRAZ80p5eKqv89a57uXjd3NgIf1CaXojATPBuujVc/fQSVhBeAU9JCRB+k7J50WQAyWTxK19T9GgbA==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.6.9: + resolution: {integrity: sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.9.17: + resolution: {integrity: sha512-rKAnhzhRU3Xh3hiko+i1ZxywZ55eWQzeS/Q4HRKLx2PqfHOolisZHErSsJVipGlmQKHW5qwOED/GighEw9dbLg==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + oxfmt@0.55.0: + resolution: {integrity: sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.71.0: + resolution: {integrity: sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-queue@9.3.0: + resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} + engines: {node: '>=20'} + + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} + engines: {node: '>=20'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + picospinner@3.0.0: + resolution: {integrity: sha512-lGA1TNsmy2bxvRsTI2cV01kfTwKzZjnZSDmF9llYNyMHMrU4sP87lQ5taiIKm88L3cbswjl008nwyGc3WpNvzg==} + engines: {node: '>=18.0.0'} + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pify@5.0.0: + resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} + engines: {node: '>=10'} + + pino-abstract-transport@0.5.0: + resolution: {integrity: sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ==} + + pino-std-serializers@4.0.0: + resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} + + pino@7.11.0: + resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} + hasBin: true + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + + pony-cause@2.1.11: + resolution: {integrity: sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==} + engines: {node: '>=12.0.0'} + + porto@0.2.35: + resolution: {integrity: sha512-gu9FfjjvvYBgQXUHWTp6n3wkTxVtEcqFotM7i3GEZeoQbvLGbssAicCz6hFZ8+xggrJWwi/RLmbwNra50SMmUQ==} + hasBin: true + peerDependencies: + '@tanstack/react-query': '>=5.59.0' + '@wagmi/core': '>=2.16.3' + expo-auth-session: '>=7.0.8' + expo-crypto: '>=15.0.7' + expo-web-browser: '>=15.0.8' + react: '>=18' + react-native: '>=0.81.4' + typescript: '>=5.4.0' + viem: '>=2.37.0' + wagmi: '>=2.0.0' + peerDependenciesMeta: + '@tanstack/react-query': + optional: true + expo-auth-session: + optional: true + expo-crypto: + optional: true + expo-web-browser: + optional: true + react: + optional: true + react-native: + optional: true + typescript: + optional: true + wagmi: + optional: true + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + + postcss-modules-extract-imports@3.1.0: + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-local-by-default@4.2.0: + resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-scope@3.2.1: + resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-values@4.0.0: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules@6.0.1: + resolution: {integrity: sha512-zyo2sAkVvuZFFy0gc2+4O+xar5dYlaVy/ebO24KT0ftk/iJevSNyPyQellsBLlnccwh7f6V6Y4GvuKRYToNgpQ==} + peerDependencies: + postcss: ^8.0.0 + + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + + preact@10.24.2: + resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} + + preact@10.29.2: + resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@1.0.0: + resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + proxy-compare@2.6.0: + resolution: {integrity: sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + + qr@0.6.0: + resolution: {integrity: sha512-P23VoX7SipHALdiIYG+D+LT/6n22dNKwV92FAb3d+Nlki/5WisSsfLt0UDFz2XEBtuwrECTznvu+chKKFCSYhA==} + engines: {node: '>= 20.19.0'} + + qrcode@1.5.3: + resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==} + engines: {node: '>=10.13.0'} + hasBin: true + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + radix-ui@1.6.0: + resolution: {integrity: sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-email@6.6.5: + resolution: {integrity: sha512-IO2NXS17K5xEn9v8QVt28g8Nl6D4gmaKZZc61tOGiZla4X2F+veWjuSKCJC7HDIuEtZXF27chHo9sE6Mtey6tQ==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-textarea-autosize@8.5.9: + resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.1.0: + resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==} + engines: {node: '>= 12.13.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + + resend@6.16.0: + resolution: {integrity: sha512-SaKISwHtxvAxneF84Njgnzg+zdngUu1vOT/paRU1De9QF+zXQR3GnwJHSh+mpJPjUhsGD4WxYi5CfKkXMkDqwg==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-content-frame@0.0.21: + resolution: {integrity: sha512-4LgYcX0lESOg9zVi6QCcqHaNGjZuc86w0IGRJij7lA4YG/6QHBblL2Jwh5OJBtkVSnhDOeARRpD3jvFNGiIWiw==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + + sonic-boom@2.8.0: + resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-hash@1.1.3: + resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + superstruct@1.0.4: + resolution: {integrity: sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ==} + engines: {node: '>=14.0.0'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.1: + resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + engines: {node: '>=18'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + thread-stream@0.15.2: + resolution: {integrity: sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.4: + resolution: {integrity: sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==} + + tldts@7.4.4: + resolution: {integrity: sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==} + hasBin: true + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + tw-shimmer@0.4.11: + resolution: {integrity: sha512-pTpGJzp3xaCPO87WeHETngmZHJYvygiSTt4jqzh2oR3DWBoeudi/ANB304zks9+Cm2vQ1ai3w9fetviYdqY8HQ==} + peerDependencies: + tailwindcss: '>=4.0.0-0' + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.10: + resolution: {integrity: sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + uint8arrays@3.1.0: + resolution: {integrity: sha512-ei5rfKtoRO8OyOIor2Rz5fhzjThwIHJZ3uyDPnDHTXbP0aMQ1RN/6AI5B5d9dBxJOU+BvOAk7ZQ1xphsX8Lrog==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-composed-ref@1.4.0: + resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-effect-event@2.0.3: + resolution: {integrity: sha512-fz1en+z3fYXCXx3nMB8hXDMuygBltifNKZq29zDx+xNJ+1vEs6oJlYd9sK31vxJ0YI534VUsHEBY0k2BATsmBQ==} + peerDependencies: + react: ^18.3 || ^19.0.0-0 + + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest@1.3.0: + resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + use-sync-external-store@1.4.0: + resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utf-8-validate@5.0.10: + resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} + engines: {node: '>=6.14.2'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + valtio@1.13.2: + resolution: {integrity: sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=16.8' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + viem@2.23.2: + resolution: {integrity: sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + viem@2.53.1: + resolution: {integrity: sha512-FhfJ/SW73CVosiyVLmIMVgKDRKYV1AGCLzZoHYvmNayyVff63Qi1ocPCk59LqC/cNw244RbBJjHnmxqXkE7NpA==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + wagmi@2.19.5: + resolution: {integrity: sha512-RQUfKMv6U+EcSNNGiPbdkDtJwtuFxZWLmvDiQmjjBgkuPulUwDJsKhi7gjynzJdsx2yDqhHCXkKsbbfbIsHfcQ==} + peerDependencies: + '@tanstack/react-query': '>=5.0.0' + react: '>=18' + typescript: '>=5.0.4' + viem: 2.x + peerDependenciesMeta: + typescript: + optional: true + + webextension-polyfill@0.10.0: + resolution: {integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + winston-console-format@1.0.8: + resolution: {integrity: sha512-dq7t/E0D0QRi4XIOwu6HM1+5e//WPqylH88GVjKEhQVrzGFg34MCz+G7pMJcXFBen9C0kBsu5GYgbYsE2LDwKw==} + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.11: + resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@5.0.0: + resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@adraffy/ens-normalize@1.11.1': {} + + '@alloc/quick-lru@5.2.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.8(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@assistant-ui/core@0.2.19(@assistant-ui/store@0.2.19(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(assistant-cloud@0.1.34)(react@19.2.7)(zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)))': + dependencies: + '@assistant-ui/store': 0.2.19(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7) + '@assistant-ui/tap': 0.9.3(@types/react@19.2.17)(react@19.2.7) + assistant-stream: 0.3.24 + nanoid: 5.1.16 + optionalDependencies: + '@types/react': 19.2.17 + assistant-cloud: 0.1.34 + react: 19.2.7 + zustand: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) + transitivePeerDependencies: + - ioredis + - redis + + '@assistant-ui/next@0.0.7': + dependencies: + '@assistant-ui/x-generative-compiler': 0.0.5 + transitivePeerDependencies: + - supports-color + + '@assistant-ui/react-langgraph@0.14.10(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@langchain/langgraph-sdk@1.9.25(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)(zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)))': + dependencies: + '@assistant-ui/core': 0.2.19(@assistant-ui/store@0.2.19(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(assistant-cloud@0.1.34)(react@19.2.7)(zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7))) + '@assistant-ui/store': 0.2.19(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7) + assistant-cloud: 0.1.34 + assistant-stream: 0.3.24 + react: 19.2.7 + uuid: 14.0.1 + optionalDependencies: + '@langchain/langgraph-sdk': 1.9.25(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': 19.2.17 + transitivePeerDependencies: + - '@assistant-ui/tap' + - ioredis + - redis + - zustand + + '@assistant-ui/react-markdown@0.14.5(@assistant-ui/react@0.14.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@assistant-ui/react': 0.14.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + classnames: 2.5.1 + react: 19.2.7 + react-markdown: 10.1.0(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + transitivePeerDependencies: + - '@types/react-dom' + - react-dom + - supports-color + + '@assistant-ui/react-o11y@0.0.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@assistant-ui/store': 0.2.18(@assistant-ui/tap@0.9.2(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7) + '@assistant-ui/tap': 0.9.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + transitivePeerDependencies: + - '@types/react-dom' + - react-dom + + '@assistant-ui/react@0.14.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7))': + dependencies: + '@assistant-ui/core': 0.2.19(@assistant-ui/store@0.2.19(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(assistant-cloud@0.1.34)(react@19.2.7)(zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7))) + '@assistant-ui/store': 0.2.19(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7) + '@assistant-ui/tap': 0.9.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7) + assistant-cloud: 0.1.34 + assistant-stream: 0.3.24 + nanoid: 5.1.16 + radix-ui: 1.6.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-textarea-autosize: 8.5.9(@types/react@19.2.17)(react@19.2.7) + safe-content-frame: 0.0.21 + zod: 4.4.3 + zustand: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + transitivePeerDependencies: + - immer + - ioredis + - redis + - use-sync-external-store + + '@assistant-ui/store@0.2.18(@assistant-ui/tap@0.9.2(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@assistant-ui/tap': 0.9.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + use-effect-event: 2.0.3(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + '@assistant-ui/store@0.2.19(@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@assistant-ui/tap': 0.9.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + use-effect-event: 2.0.3(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + '@assistant-ui/tap@0.9.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@assistant-ui/tap@0.9.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@assistant-ui/x-generative-compiler@0.0.5': + dependencies: + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + semver: 7.8.4 + transitivePeerDependencies: + - supports-color + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.27.0': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@base-org/account@2.4.0(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@coinbase/cdp-sdk': 1.51.2(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@6.0.3)(zod@4.4.3) + preact: 10.24.2 + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + zustand: 5.0.3(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - debug + - fastestsmallesttextencoderdecoder + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@base-ui/react@1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@date-fns/tz': 1.5.0 + '@types/react': 19.2.17 + date-fns: 4.4.0 + + '@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + reselect: 5.2.0 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + '@bcoe/v8-coverage@1.0.2': {} + + '@better-auth-ui/core@1.6.27(@mikkelscheike/email-provider-links@5.1.8)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))': + dependencies: + '@mikkelscheike/email-provider-links': 5.1.8 + better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + + '@better-auth-ui/react@1.6.27(e8790f21eb10219fdf108ba0aa287517)': + dependencies: + '@better-auth-ui/core': 1.6.27(@mikkelscheike/email-provider-links@5.1.8)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)) + '@better-auth/api-key': 1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3)) + '@better-auth/passkey': 1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3))(nanostores@1.3.0) + '@react-email/components': 1.0.12(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/query-core': 5.101.1 + '@tanstack/react-query': 5.101.1(react@19.2.7) + better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + clsx: 2.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tailwind-merge: 3.6.0 + tailwindcss: 4.3.1 + zod: 4.4.3 + + '@better-auth/api-key@1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3))': + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-call: 1.3.7(zod@4.4.3) + zod: 4.4.3 + + '@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@opentelemetry/semantic-conventions': 1.41.1 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.7(zod@4.4.3) + jose: 6.2.3 + kysely: 0.29.2 + nanostores: 1.3.0 + zod: 4.4.3 + + '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))': + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9) + + '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.2 + + '@better-auth/memory-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + + '@better-auth/passkey@1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3))(nanostores@1.3.0)': + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@simplewebauthn/browser': 13.3.0 + '@simplewebauthn/server': 13.3.1 + better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-call: 1.3.7(zod@4.4.3) + nanostores: 1.3.0 + zod: 4.4.3 + + '@better-auth/prisma-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.2.0 + + '@better-fetch/fetch@1.3.1': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@cfworker/json-schema@4.1.1': {} + + '@clack/core@0.4.1': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.9.1': + dependencies: + '@clack/core': 0.4.1 + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@coinbase/cdp-sdk@1.51.2(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana-program/system': 0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)) + '@solana-program/token': 0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)) + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + abitype: 1.0.6(typescript@6.0.3)(zod@3.25.76) + axios: 1.16.0 + axios-retry: 4.5.0(axios@1.16.0) + bs58: 6.0.0 + jose: 6.2.3 + md5: 2.3.0 + uncrypto: 0.1.3 + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - debug + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + + '@coinbase/wallet-sdk@3.9.3': + dependencies: + bn.js: 5.2.4 + buffer: 6.0.3 + clsx: 1.2.1 + eth-block-tracker: 7.1.0 + eth-json-rpc-filters: 6.0.1 + eventemitter3: 5.0.4 + keccak: 3.0.4 + preact: 10.29.2 + sha.js: 2.4.12 + transitivePeerDependencies: + - supports-color + + '@coinbase/wallet-sdk@4.3.6(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@6.0.3)(zod@4.4.3) + preact: 10.24.2 + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + zustand: 5.0.3(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@colors/colors@1.6.0': {} + + '@commander-js/extra-typings@13.1.0(commander@13.1.0)': + dependencies: + commander: 13.1.0 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.8(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@date-fns/tz@1.5.0': {} + + '@drizzle-team/brocli@0.10.2': {} + + '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/hash@0.9.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@ethereumjs/common@3.2.0': + dependencies: + '@ethereumjs/util': 8.1.0 + crc-32: 1.2.2 + + '@ethereumjs/rlp@4.0.1': {} + + '@ethereumjs/tx@4.2.0': + dependencies: + '@ethereumjs/common': 3.2.0 + '@ethereumjs/rlp': 4.0.1 + '@ethereumjs/util': 8.1.0 + ethereum-cryptography: 2.2.1 + + '@ethereumjs/util@8.1.0': + dependencies: + '@ethereumjs/rlp': 4.0.1 + ethereum-cryptography: 2.2.1 + micro-ftch: 0.3.1 + + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + + '@gemini-wallet/core@0.3.2(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))': + dependencies: + '@metamask/rpc-errors': 7.0.2 + eventemitter3: 5.0.1 + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@hexagon/base64@1.1.28': {} + + '@hono/node-server@1.19.14(hono@4.12.26)': + dependencies: + hono: 4.12.26 + + '@hono/node-ws@1.3.1(@hono/node-server@1.19.14(hono@4.12.26))(bufferutil@4.1.0)(hono@4.12.26)(utf-8-validate@5.0.10)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.26) + hono: 4.12.26 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@hono/zod-validator@0.7.6(hono@4.12.26)(zod@4.4.3)': + dependencies: + hono: 4.12.26 + zod: 4.4.3 + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + dependencies: + '@cfworker/json-schema': 4.1.1 + '@standard-schema/spec': 1.1.0 + js-tiktoken: 1.0.21 + langsmith: 0.7.10(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + mustache: 4.2.0 + p-queue: 6.6.2 + zod: 4.4.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - ws + + '@langchain/langgraph-api@1.4.1(d48c10f324b48c59d6cda4c1de26129b)': + dependencies: + '@babel/code-frame': 7.29.7 + '@hono/node-server': 1.19.14(hono@4.12.26) + '@hono/node-ws': 1.3.1(@hono/node-server@1.19.14(hono@4.12.26))(bufferutil@4.1.0)(hono@4.12.26)(utf-8-validate@5.0.10) + '@hono/zod-validator': 0.7.6(hono@4.12.26)(zod@4.4.3) + '@langchain/core': 1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@langchain/langgraph': 1.4.7(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@langchain/langgraph-ui': 1.4.1 + '@langchain/protocol': 0.0.18 + '@types/json-schema': 7.0.15 + '@typescript/vfs': 1.6.4(typescript@6.0.3) + dedent: 1.7.2 + dotenv: 16.6.1 + exit-hook: 4.0.0 + hono: 4.12.26 + langsmith: 0.7.10(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + open: 10.2.0 + semver: 7.8.4 + stacktrace-parser: 0.1.11 + superjson: 2.2.6 + tsx: 4.22.4 + typescript: 6.0.3 + winston: 3.19.0 + winston-console-format: 1.0.8 + zod: 4.4.3 + optionalDependencies: + '@langchain/langgraph-sdk': 1.9.25(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - babel-plugin-macros + - bufferutil + - openai + - supports-color + - utf-8-validate + - ws + + '@langchain/langgraph-checkpoint-postgres@1.0.4(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))))': + dependencies: + '@langchain/core': 1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + pg: 8.22.0 + transitivePeerDependencies: + - pg-native + + '@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))': + dependencies: + '@langchain/core': 1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + + '@langchain/langgraph-cli@1.4.1(d48c10f324b48c59d6cda4c1de26129b)': + dependencies: + '@babel/code-frame': 7.29.7 + '@commander-js/extra-typings': 13.1.0(commander@13.1.0) + '@langchain/langgraph-api': 1.4.1(d48c10f324b48c59d6cda4c1de26129b) + chokidar: 4.0.3 + commander: 13.1.0 + create-langgraph: 1.1.5 + dedent: 1.7.2 + dotenv: 16.6.1 + execa: 9.6.1 + exit-hook: 4.0.0 + extract-zip: 2.0.1 + ignore: 7.0.5 + langsmith: 0.7.10(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + open: 10.2.0 + package-manager-detector: 1.6.0 + stacktrace-parser: 0.1.11 + tar: 7.5.16 + winston: 3.19.0 + winston-console-format: 1.0.8 + yaml: 2.9.0 + zod: 4.4.3 + transitivePeerDependencies: + - '@langchain/core' + - '@langchain/langgraph' + - '@langchain/langgraph-checkpoint' + - '@langchain/langgraph-sdk' + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - babel-plugin-macros + - bufferutil + - openai + - supports-color + - ts-node + - typescript + - utf-8-validate + - ws + + '@langchain/langgraph-sdk@1.9.25(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@langchain/core': 1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@langchain/protocol': 0.0.18 + '@types/json-schema': 7.0.15 + p-queue: 9.3.0 + p-retry: 7.1.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@langchain/langgraph-ui@1.4.1': + dependencies: + '@commander-js/extra-typings': 13.1.0(commander@13.1.0) + commander: 13.1.0 + esbuild: 0.28.1 + esbuild-plugin-tailwindcss: 2.2.0 + zod: 4.4.3 + + '@langchain/langgraph@1.4.7(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)': + dependencies: + '@langchain/core': 1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))) + '@langchain/langgraph-sdk': 1.9.25(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@langchain/protocol': 0.0.18 + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 + transitivePeerDependencies: + - react + - react-dom + - svelte + - vue + + '@langchain/openai@1.5.3(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + dependencies: + '@langchain/core': 1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + js-tiktoken: 1.0.21 + openai: 6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - ws + + '@langchain/protocol@0.0.18': {} + + '@levischuck/tiny-cbor@0.2.11': {} + + '@lit-labs/ssr-dom-shim@1.6.0': {} + + '@lit/reactive-element@2.1.2': + dependencies: + '@lit-labs/ssr-dom-shim': 1.6.0 + + '@metamask/eth-json-rpc-provider@1.0.1': + dependencies: + '@metamask/json-rpc-engine': 7.3.3 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 5.0.2 + transitivePeerDependencies: + - supports-color + + '@metamask/json-rpc-engine@7.3.3': + dependencies: + '@metamask/rpc-errors': 6.4.0 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 8.5.0 + transitivePeerDependencies: + - supports-color + + '@metamask/json-rpc-engine@8.0.2': + dependencies: + '@metamask/rpc-errors': 6.4.0 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 8.5.0 + transitivePeerDependencies: + - supports-color + + '@metamask/json-rpc-middleware-stream@7.0.2': + dependencies: + '@metamask/json-rpc-engine': 8.0.2 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 8.5.0 + readable-stream: 3.6.2 + transitivePeerDependencies: + - supports-color + + '@metamask/object-multiplex@2.1.0': + dependencies: + once: 1.4.0 + readable-stream: 3.6.2 + + '@metamask/onboarding@1.0.1': + dependencies: + bowser: 2.14.1 + + '@metamask/providers@16.1.0': + dependencies: + '@metamask/json-rpc-engine': 8.0.2 + '@metamask/json-rpc-middleware-stream': 7.0.2 + '@metamask/object-multiplex': 2.1.0 + '@metamask/rpc-errors': 6.4.0 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 8.5.0 + detect-browser: 5.3.0 + extension-port-stream: 3.0.0 + fast-deep-equal: 3.1.3 + is-stream: 2.0.1 + readable-stream: 3.6.2 + webextension-polyfill: 0.10.0 + transitivePeerDependencies: + - supports-color + + '@metamask/rpc-errors@6.4.0': + dependencies: + '@metamask/utils': 9.3.0 + fast-safe-stringify: 2.1.1 + transitivePeerDependencies: + - supports-color + + '@metamask/rpc-errors@7.0.2': + dependencies: + '@metamask/utils': 11.11.0 + fast-safe-stringify: 2.1.1 + transitivePeerDependencies: + - supports-color + + '@metamask/safe-event-emitter@2.0.0': {} + + '@metamask/safe-event-emitter@3.1.2': {} + + '@metamask/sdk-analytics@0.0.5': + dependencies: + openapi-fetch: 0.13.8 + + '@metamask/sdk-communication-layer@0.33.1(cross-fetch@4.1.0)(eciesjs@0.4.18)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + dependencies: + '@metamask/sdk-analytics': 0.0.5 + bufferutil: 4.1.0 + cross-fetch: 4.1.0 + date-fns: 2.30.0 + debug: 4.3.4 + eciesjs: 0.4.18 + eventemitter2: 6.4.9 + readable-stream: 3.6.2 + socket.io-client: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + utf-8-validate: 5.0.10 + uuid: 8.3.2 + transitivePeerDependencies: + - supports-color + + '@metamask/sdk-install-modal-web@0.32.1': + dependencies: + '@paulmillr/qr': 0.2.1 + + '@metamask/sdk@0.33.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + '@babel/runtime': 7.29.7 + '@metamask/onboarding': 1.0.1 + '@metamask/providers': 16.1.0 + '@metamask/sdk-analytics': 0.0.5 + '@metamask/sdk-communication-layer': 0.33.1(cross-fetch@4.1.0)(eciesjs@0.4.18)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@metamask/sdk-install-modal-web': 0.32.1 + '@paulmillr/qr': 0.2.1 + bowser: 2.14.1 + cross-fetch: 4.1.0 + debug: 4.3.4 + eciesjs: 0.4.18 + eth-rpc-errors: 4.0.3 + eventemitter2: 6.4.9 + obj-multiplex: 1.0.0 + pump: 3.0.4 + readable-stream: 3.6.2 + socket.io-client: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + tslib: 2.8.1 + util: 0.12.5 + uuid: 8.3.2 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + + '@metamask/superstruct@3.2.1': {} + + '@metamask/utils@11.11.0': + dependencies: + '@ethereumjs/tx': 4.2.0 + '@metamask/superstruct': 3.2.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@types/debug': 4.1.13 + '@types/lodash': 4.17.24 + debug: 4.4.3 + lodash: 4.18.1 + pony-cause: 2.1.11 + semver: 7.8.4 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + + '@metamask/utils@5.0.2': + dependencies: + '@ethereumjs/tx': 4.2.0 + '@types/debug': 4.1.13 + debug: 4.4.3 + semver: 7.8.4 + superstruct: 1.0.4 + transitivePeerDependencies: + - supports-color + + '@metamask/utils@8.5.0': + dependencies: + '@ethereumjs/tx': 4.2.0 + '@metamask/superstruct': 3.2.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@types/debug': 4.1.13 + debug: 4.4.3 + pony-cause: 2.1.11 + semver: 7.8.4 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + + '@metamask/utils@9.3.0': + dependencies: + '@ethereumjs/tx': 4.2.0 + '@metamask/superstruct': 3.2.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@types/debug': 4.1.13 + debug: 4.4.3 + pony-cause: 2.1.11 + semver: 7.8.4 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + + '@mikkelscheike/email-provider-links@5.1.8': {} + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@next/env@16.1.7': {} + + '@next/env@16.2.9': {} + + '@next/swc-darwin-arm64@16.1.7': + optional: true + + '@next/swc-darwin-x64@16.1.7': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.7': + optional: true + + '@next/swc-linux-arm64-musl@16.1.7': + optional: true + + '@next/swc-linux-x64-gnu@16.1.7': + optional: true + + '@next/swc-linux-x64-musl@16.1.7': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.7': + optional: true + + '@next/swc-win32-x64-msvc@16.1.7': + optional: true + + '@noble/ciphers@1.2.1': {} + + '@noble/ciphers@1.3.0': {} + + '@noble/ciphers@2.2.0': {} + + '@noble/curves@1.4.2': + dependencies: + '@noble/hashes': 1.4.0 + + '@noble/curves@1.8.0': + dependencies: + '@noble/hashes': 1.7.0 + + '@noble/curves@1.8.1': + dependencies: + '@noble/hashes': 1.7.1 + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.4.0': {} + + '@noble/hashes@1.7.0': {} + + '@noble/hashes@1.7.1': {} + + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.2.0': {} + + '@opentelemetry/semantic-conventions@1.41.1': {} + + '@oxc-project/types@0.133.0': {} + + '@oxfmt/binding-android-arm-eabi@0.55.0': + optional: true + + '@oxfmt/binding-android-arm64@0.55.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.55.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.55.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.55.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.55.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.55.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.55.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.55.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.55.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.55.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.55.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.55.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.55.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.55.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.71.0': + optional: true + + '@oxlint/binding-android-arm64@1.71.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.71.0': + optional: true + + '@oxlint/binding-darwin-x64@1.71.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.71.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.71.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.71.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.71.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.71.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.71.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.71.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.71.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.71.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.71.0': + optional: true + + '@paulmillr/qr@0.2.1': {} + + '@peculiar/asn1-android@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pfx': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-csr': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-pkcs9': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + + '@radix-ui/number@1.1.2': {} + + '@radix-ui/primitive@1.1.4': {} + + '@radix-ui/react-accessible-icon@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-accordion@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-alert-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-aspect-ratio@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-avatar@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-checkbox@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collapsible@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context-menu@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dropdown-menu@2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-form@0.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-hover-card@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-label@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-menu@2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-menubar@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-navigation-menu@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-one-time-password-field@0.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-password-toggle-field@0.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popover@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-progress@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-radio-group@1.4.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-scroll-area@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-select@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-separator@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slider@1.4.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-switch@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tabs@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toast@1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toggle-group@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toggle@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toolbar@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tooltip@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.2': {} + + '@rainbow-me/rainbowkit@2.2.11(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3))': + dependencies: + '@tanstack/react-query': 5.101.1(react@19.2.7) + '@vanilla-extract/css': 1.20.1 + '@vanilla-extract/dynamic': 2.1.5 + '@vanilla-extract/sprinkles': 1.6.5(@vanilla-extract/css@1.20.1) + clsx: 2.1.1 + cuer: 0.0.3(patch_hash=411033b94a397d27fc9bf35e525e1bfe66bb372afad5b615cfaec514ad29beae)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + ua-parser-js: 2.0.10 + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + wagmi: 2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + transitivePeerDependencies: + - '@types/react' + - babel-plugin-macros + - typescript + + '@react-email/body@0.3.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/button@0.2.1(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/code-block@0.2.1(react@19.2.7)': + dependencies: + prismjs: 1.30.0 + react: 19.2.7 + + '@react-email/code-inline@0.0.6(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/column@0.0.14(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/components@1.0.12(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@react-email/body': 0.3.0(react@19.2.7) + '@react-email/button': 0.2.1(react@19.2.7) + '@react-email/code-block': 0.2.1(react@19.2.7) + '@react-email/code-inline': 0.0.6(react@19.2.7) + '@react-email/column': 0.0.14(react@19.2.7) + '@react-email/container': 0.0.16(react@19.2.7) + '@react-email/font': 0.0.10(react@19.2.7) + '@react-email/head': 0.0.13(react@19.2.7) + '@react-email/heading': 0.0.16(react@19.2.7) + '@react-email/hr': 0.0.12(react@19.2.7) + '@react-email/html': 0.0.12(react@19.2.7) + '@react-email/img': 0.0.12(react@19.2.7) + '@react-email/link': 0.0.13(react@19.2.7) + '@react-email/markdown': 0.0.18(react@19.2.7) + '@react-email/preview': 0.0.14(react@19.2.7) + '@react-email/render': 2.0.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-email/row': 0.0.13(react@19.2.7) + '@react-email/section': 0.0.17(react@19.2.7) + '@react-email/tailwind': 2.0.7(@react-email/body@0.3.0(react@19.2.7))(@react-email/button@0.2.1(react@19.2.7))(@react-email/code-block@0.2.1(react@19.2.7))(@react-email/code-inline@0.0.6(react@19.2.7))(@react-email/container@0.0.16(react@19.2.7))(@react-email/heading@0.0.16(react@19.2.7))(@react-email/hr@0.0.12(react@19.2.7))(@react-email/img@0.0.12(react@19.2.7))(@react-email/link@0.0.13(react@19.2.7))(@react-email/preview@0.0.14(react@19.2.7))(@react-email/text@0.1.6(react@19.2.7))(react@19.2.7) + '@react-email/text': 0.1.6(react@19.2.7) + react: 19.2.7 + transitivePeerDependencies: + - react-dom + + '@react-email/container@0.0.16(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/font@0.0.10(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/head@0.0.13(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/heading@0.0.16(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/hr@0.0.12(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/html@0.0.12(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/img@0.0.12(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/link@0.0.13(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/markdown@0.0.18(react@19.2.7)': + dependencies: + marked: 15.0.12 + react: 19.2.7 + + '@react-email/preview@0.0.14(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/render@2.0.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.8.4 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@react-email/render@2.0.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.8.4 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@react-email/row@0.0.13(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/section@0.0.17(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@react-email/tailwind@2.0.7(@react-email/body@0.3.0(react@19.2.7))(@react-email/button@0.2.1(react@19.2.7))(@react-email/code-block@0.2.1(react@19.2.7))(@react-email/code-inline@0.0.6(react@19.2.7))(@react-email/container@0.0.16(react@19.2.7))(@react-email/heading@0.0.16(react@19.2.7))(@react-email/hr@0.0.12(react@19.2.7))(@react-email/img@0.0.12(react@19.2.7))(@react-email/link@0.0.13(react@19.2.7))(@react-email/preview@0.0.14(react@19.2.7))(@react-email/text@0.1.6(react@19.2.7))(react@19.2.7)': + dependencies: + '@react-email/text': 0.1.6(react@19.2.7) + react: 19.2.7 + tailwindcss: 4.3.1 + optionalDependencies: + '@react-email/body': 0.3.0(react@19.2.7) + '@react-email/button': 0.2.1(react@19.2.7) + '@react-email/code-block': 0.2.1(react@19.2.7) + '@react-email/code-inline': 0.0.6(react@19.2.7) + '@react-email/container': 0.0.16(react@19.2.7) + '@react-email/heading': 0.0.16(react@19.2.7) + '@react-email/hr': 0.0.12(react@19.2.7) + '@react-email/img': 0.0.12(react@19.2.7) + '@react-email/link': 0.0.13(react@19.2.7) + '@react-email/preview': 0.0.14(react@19.2.7) + + '@react-email/text@0.1.6(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@reown/appkit-common@1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.22.4)': + dependencies: + big.js: 6.2.2 + dayjs: 1.11.13 + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.22.4) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + + '@reown/appkit-common@1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + big.js: 6.2.2 + dayjs: 1.11.13 + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + + '@reown/appkit-controllers@1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + valtio: 1.13.2(@types/react@19.2.17)(react@19.2.7) + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit-pay@1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.17)(react@19.2.7))(zod@4.4.3) + lit: 3.3.0 + valtio: 1.13.2(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit-polyfills@1.7.8': + dependencies: + buffer: 6.0.3 + + '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.17)(react@19.2.7))(zod@4.4.3)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.17)(react@19.2.7))(zod@4.4.3) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + lit: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - valtio + - zod + + '@reown/appkit-ui@1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + lit: 3.3.0 + qrcode: 1.5.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit-utils@1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.17)(react@19.2.7))(zod@4.4.3)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-polyfills': 1.7.8 + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@walletconnect/logger': 2.1.2 + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + valtio: 1.13.2(@types/react@19.2.17)(react@19.2.7) + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit-wallet@1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.22.4) + '@reown/appkit-polyfills': 1.7.8 + '@walletconnect/logger': 2.1.2 + zod: 3.22.4 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@reown/appkit@1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-pay': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-polyfills': 1.7.8 + '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.17)(react@19.2.7))(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.17)(react@19.2.7))(zod@4.4.3) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@walletconnect/types': 2.21.0 + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + bs58: 6.0.0 + valtio: 1.13.2(@types/react@19.2.17)(react@19.2.7) + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@safe-global/safe-apps-provider@0.18.6(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + events: 3.3.0 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + + '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@safe-global/safe-gateway-typescript-sdk': 3.23.1 + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + + '@safe-global/safe-gateway-typescript-sdk@3.23.1': {} + + '@scure/base@1.1.9': {} + + '@scure/base@1.2.6': {} + + '@scure/bip32@1.4.0': + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 + + '@scure/bip32@1.6.2': + dependencies: + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@scure/base': 1.2.6 + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.3.0': + dependencies: + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 + + '@scure/bip39@1.5.4': + dependencies: + '@noble/hashes': 1.7.1 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@sec-ant/readable-stream@0.4.1': {} + + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.1': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/x509': 1.14.3 + + '@sindresorhus/merge-streams@4.0.0': {} + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@socket.io/component-emitter@3.1.2': {} + + '@solana-program/system@0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + + '@solana-program/token@0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + + '@solana/accounts@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec': 5.5.1(typescript@6.0.3) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/addresses@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/assertions': 5.5.1(typescript@6.0.3) + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/nominal-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/assertions@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/codecs-core@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/codecs-data-structures@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-numbers': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/codecs-numbers@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/codecs-strings@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-numbers': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/codecs@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-data-structures': 5.5.1(typescript@6.0.3) + '@solana/codecs-numbers': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/options': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/errors@5.5.1(typescript@6.0.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.2 + optionalDependencies: + typescript: 6.0.3 + + '@solana/fast-stable-stringify@5.5.1(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + + '@solana/functional@5.5.1(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + + '@solana/instruction-plans@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/instructions': 5.5.1(typescript@6.0.3) + '@solana/keys': 5.5.1(typescript@6.0.3) + '@solana/promises': 5.5.1(typescript@6.0.3) + '@solana/transaction-messages': 5.5.1(typescript@6.0.3) + '@solana/transactions': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/instructions@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/keys@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/assertions': 5.5.1(typescript@6.0.3) + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/nominal-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/accounts': 5.5.1(typescript@6.0.3) + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/codecs': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/functional': 5.5.1(typescript@6.0.3) + '@solana/instruction-plans': 5.5.1(typescript@6.0.3) + '@solana/instructions': 5.5.1(typescript@6.0.3) + '@solana/keys': 5.5.1(typescript@6.0.3) + '@solana/offchain-messages': 5.5.1(typescript@6.0.3) + '@solana/plugin-core': 5.5.1(typescript@6.0.3) + '@solana/programs': 5.5.1(typescript@6.0.3) + '@solana/rpc': 5.5.1(typescript@6.0.3) + '@solana/rpc-api': 5.5.1(typescript@6.0.3) + '@solana/rpc-parsed-types': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec-types': 5.5.1(typescript@6.0.3) + '@solana/rpc-subscriptions': 5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + '@solana/signers': 5.5.1(typescript@6.0.3) + '@solana/sysvars': 5.5.1(typescript@6.0.3) + '@solana/transaction-confirmation': 5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@solana/transaction-messages': 5.5.1(typescript@6.0.3) + '@solana/transactions': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate + + '@solana/nominal-types@5.5.1(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + + '@solana/offchain-messages@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-data-structures': 5.5.1(typescript@6.0.3) + '@solana/codecs-numbers': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/keys': 5.5.1(typescript@6.0.3) + '@solana/nominal-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/options@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-data-structures': 5.5.1(typescript@6.0.3) + '@solana/codecs-numbers': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/plugin-core@5.5.1(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + + '@solana/programs@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/promises@5.5.1(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + + '@solana/rpc-api@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/keys': 5.5.1(typescript@6.0.3) + '@solana/rpc-parsed-types': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec': 5.5.1(typescript@6.0.3) + '@solana/rpc-transformers': 5.5.1(typescript@6.0.3) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + '@solana/transaction-messages': 5.5.1(typescript@6.0.3) + '@solana/transactions': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/rpc-parsed-types@5.5.1(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + + '@solana/rpc-spec-types@5.5.1(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + + '@solana/rpc-spec@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/rpc-subscriptions-api@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/keys': 5.5.1(typescript@6.0.3) + '@solana/rpc-subscriptions-spec': 5.5.1(typescript@6.0.3) + '@solana/rpc-transformers': 5.5.1(typescript@6.0.3) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + '@solana/transaction-messages': 5.5.1(typescript@6.0.3) + '@solana/transactions': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/rpc-subscriptions-channel-websocket@5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/functional': 5.5.1(typescript@6.0.3) + '@solana/rpc-subscriptions-spec': 5.5.1(typescript@6.0.3) + '@solana/subscribable': 5.5.1(typescript@6.0.3) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@solana/rpc-subscriptions-spec@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/promises': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec-types': 5.5.1(typescript@6.0.3) + '@solana/subscribable': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/rpc-subscriptions@5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/fast-stable-stringify': 5.5.1(typescript@6.0.3) + '@solana/functional': 5.5.1(typescript@6.0.3) + '@solana/promises': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec-types': 5.5.1(typescript@6.0.3) + '@solana/rpc-subscriptions-api': 5.5.1(typescript@6.0.3) + '@solana/rpc-subscriptions-channel-websocket': 5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@solana/rpc-subscriptions-spec': 5.5.1(typescript@6.0.3) + '@solana/rpc-transformers': 5.5.1(typescript@6.0.3) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + '@solana/subscribable': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate + + '@solana/rpc-transformers@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/functional': 5.5.1(typescript@6.0.3) + '@solana/nominal-types': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec-types': 5.5.1(typescript@6.0.3) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/rpc-transport-http@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec-types': 5.5.1(typescript@6.0.3) + undici-types: 7.24.6 + optionalDependencies: + typescript: 6.0.3 + + '@solana/rpc-types@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-numbers': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/nominal-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/rpc@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/fast-stable-stringify': 5.5.1(typescript@6.0.3) + '@solana/functional': 5.5.1(typescript@6.0.3) + '@solana/rpc-api': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec': 5.5.1(typescript@6.0.3) + '@solana/rpc-spec-types': 5.5.1(typescript@6.0.3) + '@solana/rpc-transformers': 5.5.1(typescript@6.0.3) + '@solana/rpc-transport-http': 5.5.1(typescript@6.0.3) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/signers@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/instructions': 5.5.1(typescript@6.0.3) + '@solana/keys': 5.5.1(typescript@6.0.3) + '@solana/nominal-types': 5.5.1(typescript@6.0.3) + '@solana/offchain-messages': 5.5.1(typescript@6.0.3) + '@solana/transaction-messages': 5.5.1(typescript@6.0.3) + '@solana/transactions': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/subscribable@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/sysvars@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/accounts': 5.5.1(typescript@6.0.3) + '@solana/codecs': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/transaction-confirmation@5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/keys': 5.5.1(typescript@6.0.3) + '@solana/promises': 5.5.1(typescript@6.0.3) + '@solana/rpc': 5.5.1(typescript@6.0.3) + '@solana/rpc-subscriptions': 5.5.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + '@solana/transaction-messages': 5.5.1(typescript@6.0.3) + '@solana/transactions': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate + + '@solana/transaction-messages@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-data-structures': 5.5.1(typescript@6.0.3) + '@solana/codecs-numbers': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/functional': 5.5.1(typescript@6.0.3) + '@solana/instructions': 5.5.1(typescript@6.0.3) + '@solana/nominal-types': 5.5.1(typescript@6.0.3) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/transactions@5.5.1(typescript@6.0.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@6.0.3) + '@solana/codecs-core': 5.5.1(typescript@6.0.3) + '@solana/codecs-data-structures': 5.5.1(typescript@6.0.3) + '@solana/codecs-numbers': 5.5.1(typescript@6.0.3) + '@solana/codecs-strings': 5.5.1(typescript@6.0.3) + '@solana/errors': 5.5.1(typescript@6.0.3) + '@solana/functional': 5.5.1(typescript@6.0.3) + '@solana/instructions': 5.5.1(typescript@6.0.3) + '@solana/keys': 5.5.1(typescript@6.0.3) + '@solana/nominal-types': 5.5.1(typescript@6.0.3) + '@solana/rpc-types': 5.5.1(typescript@6.0.3) + '@solana/transaction-messages': 5.5.1(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@stablelib/base64@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.1 + + '@tailwindcss/oxide-android-arm64@4.3.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + optional: true + + '@tailwindcss/oxide@4.3.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-x64': 4.3.1 + '@tailwindcss/oxide-freebsd-x64': 4.3.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-x64-musl': 4.3.1 + '@tailwindcss/oxide-wasm32-wasi': 4.3.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 + + '@tailwindcss/postcss@4.3.1': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.1 + '@tailwindcss/oxide': 4.3.1 + postcss: 8.5.15 + tailwindcss: 4.3.1 + + '@tanstack/devtools-event-client@0.4.4': {} + + '@tanstack/pacer@0.21.1': + dependencies: + '@tanstack/devtools-event-client': 0.4.4 + '@tanstack/store': 0.11.0 + + '@tanstack/query-core@5.101.1': {} + + '@tanstack/react-pacer@0.22.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/pacer': 0.21.1 + '@tanstack/react-store': 0.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/react-query@5.101.1(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.1 + react: 19.2.7 + + '@tanstack/react-store@0.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/store': 0.11.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + '@tanstack/store@0.11.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 25.9.4 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/lodash@4.17.24': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/node@25.9.4': + dependencies: + undici-types: 7.24.6 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.9.4 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/triple-beam@1.3.5': {} + + '@types/trusted-types@2.0.7': {} + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.4 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 25.9.4 + optional: true + + '@typescript/vfs@1.6.4(typescript@6.0.3)': + dependencies: + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@ungap/structured-clone@1.3.1': {} + + '@vanilla-extract/css@1.20.1': + dependencies: + '@emotion/hash': 0.9.2 + '@vanilla-extract/private': 1.0.9 + css-what: 6.2.2 + csstype: 3.2.3 + dedent: 1.7.2 + deep-object-diff: 1.1.9 + deepmerge: 4.3.1 + lru-cache: 10.4.3 + media-query-parser: 2.0.2 + modern-ahocorasick: 1.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - babel-plugin-macros + + '@vanilla-extract/dynamic@2.1.5': + dependencies: + '@vanilla-extract/private': 1.0.9 + + '@vanilla-extract/private@1.0.9': {} + + '@vanilla-extract/sprinkles@1.6.5(@vanilla-extract/css@1.20.1)': + dependencies: + '@vanilla-extract/css': 1.20.1 + + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.9 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@wagmi/connectors@6.2.0(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(@wagmi/core@2.22.1(@tanstack/query-core@5.101.1)(@types/react@19.2.17)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@base-org/account': 2.4.0(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(utf-8-validate@5.0.10)(zod@4.4.3) + '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(utf-8-validate@5.0.10)(zod@4.4.3) + '@gemini-wallet/core': 0.3.2(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@metamask/sdk': 0.33.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.101.1)(@types/react@19.2.17)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + cbw-sdk: '@coinbase/wallet-sdk@3.9.3' + porto: 0.2.35(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(@wagmi/core@2.22.1(@tanstack/query-core@5.101.1)(@types/react@19.2.17)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)) + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/react-query' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - expo-auth-session + - expo-crypto + - expo-web-browser + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - react-native + - supports-color + - uploadthing + - use-sync-external-store + - utf-8-validate + - wagmi + - zod + + '@wagmi/core@2.22.1(@tanstack/query-core@5.101.1)(@types/react@19.2.17)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))': + dependencies: + eventemitter3: 5.0.1 + mipd: 0.0.7(typescript@6.0.3) + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + zustand: 5.0.0(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) + optionalDependencies: + '@tanstack/query-core': 5.101.1 + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/react' + - immer + - react + - use-sync-external-store + + '@walletconnect/core@2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.0 + '@walletconnect/utils': 2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/window-getters': 1.0.1 + es-toolkit: 1.33.0 + events: 3.3.0 + uint8arrays: 3.1.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/core@2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.1 + '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/window-getters': 1.0.1 + es-toolkit: 1.33.0 + events: 3.3.0 + uint8arrays: 3.1.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/environment@1.0.1': + dependencies: + tslib: 1.14.1 + + '@walletconnect/ethereum-provider@2.21.1(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@reown/appkit': 1.7.8(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/sign-client': 2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1 + '@walletconnect/universal-provider': 2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/events@1.0.1': + dependencies: + keyvaluestorage-interface: 1.0.0 + tslib: 1.14.1 + + '@walletconnect/heartbeat@1.2.2': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/time': 1.0.2 + events: 3.3.0 + + '@walletconnect/jsonrpc-http-connection@1.0.8': + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + cross-fetch: 3.2.0 + events: 3.3.0 + transitivePeerDependencies: + - encoding + + '@walletconnect/jsonrpc-provider@1.0.14': + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + events: 3.3.0 + + '@walletconnect/jsonrpc-types@1.0.4': + dependencies: + events: 3.3.0 + keyvaluestorage-interface: 1.0.0 + + '@walletconnect/jsonrpc-utils@1.0.8': + dependencies: + '@walletconnect/environment': 1.0.1 + '@walletconnect/jsonrpc-types': 1.0.4 + tslib: 1.14.1 + + '@walletconnect/jsonrpc-ws-connection@1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + events: 3.3.0 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@walletconnect/keyvaluestorage@1.1.1': + dependencies: + '@walletconnect/safe-json': 1.0.2 + idb-keyval: 6.2.5 + unstorage: 1.17.5(idb-keyval@6.2.5) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/logger@2.1.2': + dependencies: + '@walletconnect/safe-json': 1.0.2 + pino: 7.11.0 + + '@walletconnect/relay-api@1.0.11': + dependencies: + '@walletconnect/jsonrpc-types': 1.0.4 + + '@walletconnect/relay-auth@1.1.0': + dependencies: + '@noble/curves': 1.8.0 + '@noble/hashes': 1.7.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + uint8arrays: 3.1.0 + + '@walletconnect/safe-json@1.0.2': + dependencies: + tslib: 1.14.1 + + '@walletconnect/sign-client@2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@walletconnect/core': 2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/logger': 2.1.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.0 + '@walletconnect/utils': 2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/sign-client@2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@walletconnect/core': 2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/logger': 2.1.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.1 + '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/time@1.0.2': + dependencies: + tslib: 1.14.1 + + '@walletconnect/types@2.21.0': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/types@2.21.1': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/universal-provider@2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/sign-client': 2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0 + '@walletconnect/utils': 2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + es-toolkit: 1.33.0 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/universal-provider@2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/sign-client': 2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1 + '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + es-toolkit: 1.33.0 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/utils@2.21.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@noble/ciphers': 1.2.1 + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.0 + '@walletconnect/window-getters': 1.0.1 + '@walletconnect/window-metadata': 1.0.1 + bs58: 6.0.0 + detect-browser: 5.3.0 + query-string: 7.1.3 + uint8arrays: 3.1.0 + viem: 2.23.2(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/utils@2.21.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + dependencies: + '@noble/ciphers': 1.2.1 + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.1 + '@walletconnect/window-getters': 1.0.1 + '@walletconnect/window-metadata': 1.0.1 + bs58: 6.0.0 + detect-browser: 5.3.0 + query-string: 7.1.3 + uint8arrays: 3.1.0 + viem: 2.23.2(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/window-getters@1.0.1': + dependencies: + tslib: 1.14.1 + + '@walletconnect/window-metadata@1.0.1': + dependencies: + '@walletconnect/window-getters': 1.0.1 + tslib: 1.14.1 + + abitype@1.0.6(typescript@6.0.3)(zod@3.25.76): + optionalDependencies: + typescript: 6.0.3 + zod: 3.25.76 + + abitype@1.0.8(typescript@6.0.3)(zod@4.4.3): + optionalDependencies: + typescript: 6.0.3 + zod: 4.4.3 + + abitype@1.2.3(typescript@6.0.3)(zod@3.22.4): + optionalDependencies: + typescript: 6.0.3 + zod: 3.22.4 + + abitype@1.2.3(typescript@6.0.3)(zod@3.25.76): + optionalDependencies: + typescript: 6.0.3 + zod: 3.25.76 + + abitype@1.2.3(typescript@6.0.3)(zod@4.4.3): + optionalDependencies: + typescript: 6.0.3 + zod: 4.4.3 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + assistant-cloud@0.1.34: + dependencies: + assistant-stream: 0.3.24 + transitivePeerDependencies: + - ioredis + - redis + + assistant-stream@0.3.24: + dependencies: + '@standard-schema/spec': 1.1.0 + nanoid: 5.1.16 + secure-json-parse: 4.1.0 + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + async-mutex@0.2.6: + dependencies: + tslib: 2.8.1 + + async@3.2.6: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + atomically@2.1.1: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001799 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axios-retry@4.5.0(axios@1.16.0): + dependencies: + axios: 1.16.0 + is-retry-allowed: 2.2.0 + + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + bail@2.0.2: {} + + balanced-match@4.0.4: {} + + base-x@5.0.1: {} + + base64-js@1.5.1: {} + + base64id@2.0.0: {} + + baseline-browser-mapping@2.10.38: {} + + better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9): + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.4.3) + defu: 6.1.7 + jose: 6.2.3 + kysely: 0.29.2 + nanostores: 1.3.0 + zod: 4.4.3 + optionalDependencies: + drizzle-kit: 0.31.10 + drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9) + next: 16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + pg: 8.22.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.3.7(zod@4.4.3): + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 4.4.3 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + big.js@6.2.2: {} + + bn.js@5.2.4: {} + + bowser@2.14.1: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + buffer-crc32@0.2.13: {} + + buffer-from@1.1.2: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bufferutil@4.1.0: + dependencies: + node-gyp-build: 4.8.4 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@5.3.1: {} + + caniuse-lite@1.0.30001799: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + charenc@0.0.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@3.0.0: {} + + citty@0.2.2: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + classnames@2.5.1: {} + + client-only@0.0.1: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@1.2.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + + color-name@1.1.4: {} + + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + + colors@1.4.0: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + comma-separated-tokens@2.0.3: {} + + commander@13.1.0: {} + + commander@14.0.2: {} + + concurrently@10.0.3: + dependencies: + chalk: 5.6.2 + rxjs: 7.8.2 + shell-quote: 1.8.4 + supports-color: 10.2.2 + tree-kill: 1.2.2 + yargs: 18.0.0 + + conf@15.1.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + atomically: 2.1.1 + debounce-fn: 6.0.0 + dot-prop: 10.1.0 + env-paths: 3.0.0 + json-schema-typed: 8.0.2 + semver: 7.8.4 + uint8array-extras: 1.5.0 + + convert-source-map@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@0.7.2: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + crc-32@1.2.2: {} + + create-langgraph@1.1.5: + dependencies: + '@clack/prompts': 0.9.1 + '@commander-js/extra-typings': 13.1.0(commander@13.1.0) + commander: 13.1.0 + dedent: 1.7.2 + extract-zip: 2.0.1 + picocolors: 1.1.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + crypt@0.0.2: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + cuer@0.0.3(patch_hash=411033b94a397d27fc9bf35e525e1bfe66bb372afad5b615cfaec514ad29beae)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + dependencies: + qr: 0.6.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + typescript: 6.0.3 + + data-urls@7.0.0(@noble/hashes@2.2.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.29.7 + + date-fns@4.4.0: {} + + dayjs@1.11.13: {} + + debounce-fn@6.0.0: + dependencies: + mimic-function: 5.0.1 + + debounce@2.2.0: {} + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + decimal.js@10.6.0: {} + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + decode-uri-component@0.2.2: {} + + dedent@1.7.2: {} + + deep-object-diff@1.1.9: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + derive-valtio@0.1.0(valtio@1.13.2(@types/react@19.2.17)(react@19.2.7)): + dependencies: + valtio: 1.13.2(@types/react@19.2.17)(react@19.2.7) + + destr@2.0.5: {} + + detect-browser@5.3.0: {} + + detect-europe-js@0.1.2: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dijkstrajs@1.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@10.1.0: + dependencies: + type-fest: 5.7.0 + + dotenv@16.6.1: {} + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.22.4 + + drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9): + optionalDependencies: + '@types/pg': 8.20.0 + kysely: 0.29.2 + pg: 8.22.0 + postgres: 3.4.9 + + drizzle-zod@0.8.3(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(zod@4.4.3): + dependencies: + drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9) + zod: 4.4.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + eciesjs@0.4.18: + dependencies: + '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + electron-to-chromium@1.5.376: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + enabled@2.0.0: {} + + encode-utf8@1.0.3: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + engine.io-client@6.6.6(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.9(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + '@types/cors': 2.8.19 + '@types/node': 25.9.4 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@8.0.0: {} + + env-paths@3.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-toolkit@1.33.0: {} + + esbuild-plugin-tailwindcss@2.2.0: + dependencies: + '@tailwindcss/postcss': 4.3.1 + autoprefixer: 10.5.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-modules: 6.0.1(postcss@8.5.15) + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-string-regexp@5.0.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eth-block-tracker@7.1.0: + dependencies: + '@metamask/eth-json-rpc-provider': 1.0.1 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 5.0.2 + json-rpc-random-id: 1.0.1 + pify: 3.0.0 + transitivePeerDependencies: + - supports-color + + eth-json-rpc-filters@6.0.1: + dependencies: + '@metamask/safe-event-emitter': 3.1.2 + async-mutex: 0.2.6 + eth-query: 2.1.2 + json-rpc-engine: 6.1.0 + pify: 5.0.0 + + eth-query@2.1.2: + dependencies: + json-rpc-random-id: 1.0.1 + xtend: 4.0.2 + + eth-rpc-errors@4.0.3: + dependencies: + fast-safe-stringify: 2.1.1 + + ethereum-cryptography@2.2.1: + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/bip32': 1.4.0 + '@scure/bip39': 1.3.0 + + eventemitter2@6.4.9: {} + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.1: {} + + eventemitter3@5.0.4: {} + + events@3.3.0: {} + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + exit-hook@4.0.0: {} + + expect-type@1.3.0: {} + + extend@3.0.2: {} + + extension-port-stream@3.0.0: + dependencies: + readable-stream: 3.6.2 + webextension-polyfill: 0.10.0 + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-redact@3.5.0: {} + + fast-safe-stringify@2.1.1: {} + + fast-sha256@1.3.0: {} + + fast-uri@3.1.2: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fecha@4.2.3: {} + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + filter-obj@1.1.0: {} + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + fn.name@1.1.0: {} + + follow-redirects@1.16.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + + generic-names@4.0.0: + dependencies: + loader-utils: 3.3.1 + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globals@11.12.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hono@4.12.26: {} + + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + + html-url-attributes@3.0.1: {} + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + human-signals@8.0.1: {} + + icss-utils@5.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + idb-keyval@6.2.1: {} + + idb-keyval@6.2.5: {} + + ieee754@1.2.1: {} + + ignore@7.0.5: {} + + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + inline-style-parser@0.2.7: {} + + iron-webcrypto@1.2.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-buffer@1.1.6: {} + + is-callable@1.2.7: {} + + is-decimal@2.0.1: {} + + is-docker@3.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-hexadecimal@2.0.1: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-network-error@1.3.2: {} + + is-plain-obj@4.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-retry-allowed@2.2.0: {} + + is-standalone-pwa@0.1.1: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-unicode-supported@2.1.0: {} + + is-what@5.5.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isows@1.0.6(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)): + dependencies: + ws: 8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + + isows@1.0.7(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)): + dependencies: + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jiti@2.4.2: {} + + jiti@2.7.0: {} + + jose@6.2.3: {} + + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@29.1.1(@noble/hashes@2.2.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.2.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json-rpc-engine@6.1.0: + dependencies: + '@metamask/safe-event-emitter': 2.0.0 + eth-rpc-errors: 4.0.3 + + json-rpc-random-id@1.0.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json5@2.2.3: {} + + keccak@3.0.4: + dependencies: + node-addon-api: 2.0.2 + node-gyp-build: 4.8.4 + readable-stream: 3.6.2 + + keyvaluestorage-interface@1.0.0: {} + + kleur@3.0.3: {} + + kuler@2.0.0: {} + + kysely@0.29.2: {} + + langsmith@0.7.10(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)): + dependencies: + p-queue: 6.6.2 + optionalDependencies: + openai: 6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3) + ws: 8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + + leac@0.6.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lit-element@4.2.2: + dependencies: + '@lit-labs/ssr-dom-shim': 1.6.0 + '@lit/reactive-element': 2.1.2 + lit-html: 3.3.3 + + lit-html@3.3.3: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.3.0: + dependencies: + '@lit/reactive-element': 2.1.2 + lit-element: 4.2.2 + lit-html: 3.3.3 + + loader-utils@3.3.1: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.camelcase@4.3.0: {} + + lodash@4.18.1: {} + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + longest-streak@3.1.0: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.1: {} + + lucide-react@1.21.0(react@19.2.7): + dependencies: + react: 19.2.7 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.4 + + markdown-table@3.0.4: {} + + marked@15.0.12: {} + + math-intrinsics@1.1.0: {} + + md5@2.3.0: + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.27.1: {} + + media-query-parser@2.0.2: + dependencies: + '@babel/runtime': 7.29.7 + + micro-ftch@0.3.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mipd@0.0.7(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + modern-ahocorasick@1.1.0: {} + + ms@2.1.2: {} + + ms@2.1.3: {} + + multiformats@9.9.0: {} + + mustache@4.2.0: {} + + nanoid@3.3.13: {} + + nanoid@5.1.16: {} + + nanostores@1.3.0: {} + + negotiator@0.6.3: {} + + next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@next/env': 16.1.7 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + postcss: 8.4.31 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(react@19.2.7) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.7 + '@next/swc-darwin-x64': 16.1.7 + '@next/swc-linux-arm64-gnu': 16.1.7 + '@next/swc-linux-arm64-musl': 16.1.7 + '@next/swc-linux-x64-gnu': 16.1.7 + '@next/swc-linux-x64-musl': 16.1.7 + '@next/swc-win32-arm64-msvc': 16.1.7 + '@next/swc-win32-x64-msvc': 16.1.7 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-addon-api@2.0.2: {} + + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: {} + + node-mock-http@1.0.4: {} + + node-releases@2.0.48: {} + + normalize-path@3.0.0: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nypm@0.6.6: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.2.4 + + obj-multiplex@1.0.0: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + readable-stream: 2.3.8 + + object-assign@4.1.1: {} + + obug@2.1.3: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + on-exit-leak-free@0.2.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3): + optionalDependencies: + ws: 8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + zod: 4.4.3 + + openapi-fetch@0.13.8: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + ox@0.14.29(typescript@6.0.3)(zod@3.22.4): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@3.22.4) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + + ox@0.14.29(typescript@6.0.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + + ox@0.14.29(typescript@6.0.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + + ox@0.6.7(typescript@6.0.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + + ox@0.6.9(typescript@6.0.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + + ox@0.9.17(typescript@6.0.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + + oxfmt@0.55.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.55.0 + '@oxfmt/binding-android-arm64': 0.55.0 + '@oxfmt/binding-darwin-arm64': 0.55.0 + '@oxfmt/binding-darwin-x64': 0.55.0 + '@oxfmt/binding-freebsd-x64': 0.55.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.55.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.55.0 + '@oxfmt/binding-linux-arm64-gnu': 0.55.0 + '@oxfmt/binding-linux-arm64-musl': 0.55.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.55.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.55.0 + '@oxfmt/binding-linux-riscv64-musl': 0.55.0 + '@oxfmt/binding-linux-s390x-gnu': 0.55.0 + '@oxfmt/binding-linux-x64-gnu': 0.55.0 + '@oxfmt/binding-linux-x64-musl': 0.55.0 + '@oxfmt/binding-openharmony-arm64': 0.55.0 + '@oxfmt/binding-win32-arm64-msvc': 0.55.0 + '@oxfmt/binding-win32-ia32-msvc': 0.55.0 + '@oxfmt/binding-win32-x64-msvc': 0.55.0 + + oxlint@1.71.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.71.0 + '@oxlint/binding-android-arm64': 1.71.0 + '@oxlint/binding-darwin-arm64': 1.71.0 + '@oxlint/binding-darwin-x64': 1.71.0 + '@oxlint/binding-freebsd-x64': 1.71.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.71.0 + '@oxlint/binding-linux-arm-musleabihf': 1.71.0 + '@oxlint/binding-linux-arm64-gnu': 1.71.0 + '@oxlint/binding-linux-arm64-musl': 1.71.0 + '@oxlint/binding-linux-ppc64-gnu': 1.71.0 + '@oxlint/binding-linux-riscv64-gnu': 1.71.0 + '@oxlint/binding-linux-riscv64-musl': 1.71.0 + '@oxlint/binding-linux-s390x-gnu': 1.71.0 + '@oxlint/binding-linux-x64-gnu': 1.71.0 + '@oxlint/binding-linux-x64-musl': 1.71.0 + '@oxlint/binding-openharmony-arm64': 1.71.0 + '@oxlint/binding-win32-arm64-msvc': 1.71.0 + '@oxlint/binding-win32-ia32-msvc': 1.71.0 + '@oxlint/binding-win32-x64-msvc': 1.71.0 + + p-finally@1.0.0: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-queue@9.3.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-retry@7.1.1: + dependencies: + is-network-error: 1.3.2 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + p-timeout@7.0.1: {} + + p-try@2.2.0: {} + + package-manager-detector@1.6.0: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-ms@4.0.0: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + pathe@2.0.3: {} + + peberminta@0.9.0: {} + + pend@1.2.0: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + picospinner@3.0.0: {} + + pify@3.0.0: {} + + pify@5.0.0: {} + + pino-abstract-transport@0.5.0: + dependencies: + duplexify: 4.1.3 + split2: 4.2.0 + + pino-std-serializers@4.0.0: {} + + pino@7.11.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 0.2.0 + pino-abstract-transport: 0.5.0 + pino-std-serializers: 4.0.0 + process-warning: 1.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.1.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 2.8.0 + thread-stream: 0.15.2 + + pngjs@5.0.0: {} + + pony-cause@2.1.11: {} + + porto@0.2.35(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(@wagmi/core@2.22.1(@tanstack/query-core@5.101.1)(@types/react@19.2.17)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)): + dependencies: + '@wagmi/core': 2.22.1(@tanstack/query-core@5.101.1)(@types/react@19.2.17)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + hono: 4.12.26 + idb-keyval: 6.2.5 + mipd: 0.0.7(typescript@6.0.3) + ox: 0.9.17(typescript@6.0.3)(zod@4.4.3) + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + zod: 4.4.3 + zustand: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)) + optionalDependencies: + '@tanstack/react-query': 5.101.1(react@19.2.7) + react: 19.2.7 + typescript: 6.0.3 + wagmi: 2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + transitivePeerDependencies: + - '@types/react' + - immer + - use-sync-external-store + + possible-typed-array-names@1.1.0: {} + + postal-mime@2.7.4: {} + + postcss-modules-extract-imports@3.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-modules-local-by-default@4.2.0(postcss@8.5.15): + dependencies: + icss-utils: 5.1.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + + postcss-modules-values@4.0.0(postcss@8.5.15): + dependencies: + icss-utils: 5.1.0(postcss@8.5.15) + postcss: 8.5.15 + + postcss-modules@6.0.1(postcss@8.5.15): + dependencies: + generic-names: 4.0.0 + icss-utils: 5.1.0(postcss@8.5.15) + lodash.camelcase: 4.3.0 + postcss: 8.5.15 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.15) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.15) + postcss-modules-scope: 3.2.1(postcss@8.5.15) + postcss-modules-values: 4.0.0(postcss@8.5.15) + string-hash: 1.1.3 + + postcss-selector-parser@7.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.13 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.13 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.9: {} + + preact@10.24.2: {} + + preact@10.29.2: {} + + prettier@3.8.4: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prismjs@1.30.0: {} + + process-nextick-args@2.0.1: {} + + process-warning@1.0.0: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + property-information@7.2.0: {} + + proxy-compare@2.6.0: {} + + proxy-from-env@2.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + + qr@0.6.0: {} + + qrcode@1.5.3: + dependencies: + dijkstrajs: 1.0.3 + encode-utf8: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + quick-format-unescaped@4.0.4: {} + + radix-ui@1.6.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-accessible-icon': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-alert-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-aspect-ratio': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-avatar': 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-checkbox': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context-menu': 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-form': 0.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-hover-card': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menubar': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-one-time-password-field': 0.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-password-toggle-field': 0.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-progress': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-radio-group': 1.4.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slider': 1.4.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-switch': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast': 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toolbar': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + radix3@1.1.2: {} + + react-day-picker@10.0.1(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-email@6.6.5(bufferutil@4.1.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(utf-8-validate@5.0.10): + dependencies: + '@babel/parser': 7.27.0 + '@babel/traverse': 7.27.0 + '@react-email/render': 2.0.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + chokidar: 4.0.3 + commander: 13.1.0 + conf: 15.1.0 + css-tree: 3.2.1 + debounce: 2.2.0 + esbuild: 0.28.1 + glob: 13.0.6 + jiti: 2.4.2 + log-symbols: 7.0.1 + marked: 15.0.12 + mime-types: 3.0.2 + normalize-path: 3.0.0 + nypm: 0.6.6 + picospinner: 3.0.0 + prismjs: 1.30.0 + prompts: 2.4.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + socket.io: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + tailwindcss: 4.3.1 + tsconfig-paths: 4.2.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + react-is@17.0.2: {} + + react-markdown@10.1.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.17 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.7 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-textarea-autosize@8.5.9(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + use-composed-ref: 1.4.0(@types/react@19.2.17)(react@19.2.7) + use-latest: 1.3.0(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + + react@19.2.7: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + real-require@0.1.0: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect-metadata@0.2.2: {} + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + + reselect@5.2.0: {} + + resend@6.16.0(@react-email/render@2.0.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)): + dependencies: + postal-mime: 2.7.4 + standardwebhooks: 1.0.0 + optionalDependencies: + '@react-email/render': 2.0.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + rou3@0.7.12: {} + + run-applescript@7.1.0: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-content-frame@0.0.21: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-stable-stringify@2.5.0: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + secure-json-parse@4.1.0: {} + + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + + semver@7.8.4: {} + + server-only@0.0.1: {} + + set-blocking@2.0.0: {} + + set-cookie-parser@3.1.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.4: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + socket.io-adapter@2.5.8(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + debug: 4.4.3 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.6(bufferutil@4.1.0)(utf-8-validate@5.0.10) + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.9(bufferutil@4.1.0)(utf-8-validate@5.0.10) + socket.io-adapter: 2.5.8(bufferutil@4.1.0)(utf-8-validate@5.0.10) + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + sonic-boom@2.8.0: + dependencies: + atomic-sleep: 1.0.0 + + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + space-separated-tokens@2.0.2: {} + + split-on-first@1.1.0: {} + + split2@4.2.0: {} + + stack-trace@0.0.10: {} + + stackback@0.0.2: {} + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + std-env@4.1.0: {} + + stream-shift@1.0.3: {} + + strict-uri-encode@2.0.0: {} + + string-hash@1.1.3: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + styled-jsx@5.1.6(react@19.2.7): + dependencies: + client-only: 0.0.1 + react: 19.2.7 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + superstruct@1.0.4: {} + + supports-color@10.2.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tagged-tag@1.0.0: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.1: {} + + tapable@2.3.3: {} + + tar@7.5.16: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + text-hex@1.0.0: {} + + thread-stream@0.15.2: + dependencies: + real-require: 0.1.0 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + tinyrainbow@3.1.0: {} + + tldts-core@7.4.4: {} + + tldts@7.4.4: + dependencies: + tldts-core: 7.4.4 + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.4 + + tr46@0.0.3: {} + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + trim-lines@3.0.1: {} + + triple-beam@1.4.1: {} + + trough@2.2.0: {} + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + + tw-animate-css@1.4.0: {} + + tw-shimmer@0.4.11(tailwindcss@4.3.1): + dependencies: + tailwindcss: 4.3.1 + + type-fest@0.7.1: {} + + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typescript@6.0.3: {} + + ua-is-frozen@0.1.2: {} + + ua-parser-js@2.0.10: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + + ufo@1.6.4: {} + + uint8array-extras@1.5.0: {} + + uint8arrays@3.1.0: + dependencies: + multiformats: 9.9.0 + + uncrypto@0.1.3: {} + + undici-types@7.24.6: {} + + undici@7.28.0: {} + + unicorn-magic@0.3.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5(idb-keyval@6.2.5): + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.1 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + optionalDependencies: + idb-keyval: 6.2.5 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-composed-ref@1.4.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + use-effect-event@2.0.3(react@19.2.7): + dependencies: + react: 19.2.7 + + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + use-latest@1.3.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sync-external-store@1.2.0(react@19.2.7): + dependencies: + react: 19.2.7 + + use-sync-external-store@1.4.0(react@19.2.7): + dependencies: + react: 19.2.7 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + utf-8-validate@5.0.10: + dependencies: + node-gyp-build: 4.8.4 + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.22 + + uuid@14.0.1: {} + + uuid@8.3.2: {} + + uuid@9.0.1: {} + + valtio@1.13.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + derive-valtio: 0.1.0(valtio@1.13.2(@types/react@19.2.17)(react@19.2.7)) + proxy-compare: 2.6.0 + use-sync-external-store: 1.2.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + + vary@1.1.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + viem@2.23.2(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3): + dependencies: + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@scure/bip32': 1.6.2 + '@scure/bip39': 1.5.4 + abitype: 1.0.8(typescript@6.0.3)(zod@4.4.3) + isows: 1.0.6(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.6.7(typescript@6.0.3)(zod@4.4.3) + ws: 8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.22.4): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@3.22.4) + isows: 1.0.7(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.14.29(typescript@6.0.3)(zod@3.22.4) + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@3.25.76) + isows: 1.0.7(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.14.29(typescript@6.0.3)(zod@3.25.76) + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@4.4.3) + isows: 1.0.7(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.14.29(typescript@6.0.3)(zod@4.4.3) + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.4 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.4 + yaml: 2.9.0 + + vitest@4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.4 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + jsdom: 29.1.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + wagmi@2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): + dependencies: + '@tanstack/react-query': 5.101.1(react@19.2.7) + '@wagmi/connectors': 6.2.0(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(@wagmi/core@2.22.1(@tanstack/query-core@5.101.1)(@types/react@19.2.17)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(wagmi@2.19.5(@tanstack/query-core@5.101.1)(@tanstack/react-query@5.101.1(react@19.2.7))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)(typescript@6.0.3)(utf-8-validate@5.0.10)(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3))(zod@4.4.3) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.101.1)(@types/react@19.2.17)(react@19.2.7)(typescript@6.0.3)(use-sync-external-store@1.4.0(react@19.2.7))(viem@2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + react: 19.2.7 + use-sync-external-store: 1.4.0(react@19.2.7) + viem: 2.53.1(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/query-core' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - expo-auth-session + - expo-crypto + - expo-web-browser + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react-native + - supports-color + - uploadthing + - utf-8-validate + - zod + + webextension-polyfill@0.10.0: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + when-exit@2.1.5: {} + + which-module@2.0.1: {} + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + winston-console-format@1.0.8: + dependencies: + colors: 1.4.0 + logform: 2.7.0 + triple-beam: 1.4.1 + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@7.5.11(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + + ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + + ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + + ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xmlhttprequest-ssl@2.1.2: {} + + xtend@4.0.2: {} + + y18n@4.0.3: {} + + y18n@5.0.8: {} + + yallist@5.0.0: {} + + yaml@2.9.0: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@22.0.0: {} + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yoctocolors@2.1.2: {} + + zod@3.22.4: {} + + zod@3.25.76: {} + + zod@4.4.3: {} + + zustand@5.0.0(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + use-sync-external-store: 1.4.0(react@19.2.7) + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + use-sync-external-store: 1.4.0(react@19.2.7) + + zustand@5.0.3(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + use-sync-external-store: 1.4.0(react@19.2.7) + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ebe4af..41d1459 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1 +1,2 @@ patchedDependencies: + cuer@0.0.3: patches/cuer@0.0.3.patch diff --git a/tests/api/alchemy/portfolio.test.ts b/tests/api/alchemy/portfolio.test.ts new file mode 100644 index 0000000..f663ad3 --- /dev/null +++ b/tests/api/alchemy/portfolio.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +function makeRequest(path: string[], body?: string, method = "POST"): Request { + const url = `http://localhost/api/alchemy/${path.join("/")}`; + return new Request(url, { + method, + headers: { "Content-Type": "application/json" }, + body, + }); +} + +let originalKey: string | undefined; + +beforeEach(() => { + originalKey = process.env.ALCHEMY_API_KEY; + fetchMock.mockReset(); +}); + +afterEach(() => { + if (originalKey === undefined) delete process.env.ALCHEMY_API_KEY; + else process.env.ALCHEMY_API_KEY = originalKey; + vi.resetModules(); +}); + +function setKey(k: string | undefined) { + if (k === undefined) delete process.env.ALCHEMY_API_KEY; + else process.env.ALCHEMY_API_KEY = k; +} + +describe("POST /api/alchemy/portfolio/ — Portfolio API proxy", () => { + it("forwards the body to https://api.g.alchemy.com/data/v1//", async () => { + setKey("test-key-xyz"); + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ data: { tokens: [] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const body = JSON.stringify({ + addresses: [{ address: "0xabc", networks: ["eth-mainnet"] }], + withMetadata: true, + }); + const res = await POST(makeRequest(["portfolio", "tokens", "by-address"], body), { + params: Promise.resolve({ path: ["portfolio", "tokens", "by-address"] }), + }); + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.g.alchemy.com/data/v1/test-key-xyz/assets/tokens/by-address"); + expect(init.method).toBe("POST"); + expect(init.body).toBe(body); + }); + + it("returns 400 when the path after 'portfolio' is empty", async () => { + setKey("test-key"); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest(["portfolio"], "{}"), { + params: Promise.resolve({ path: ["portfolio"] }), + }); + expect(res.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns 500 when ALCHEMY_API_KEY is not set on the server", async () => { + setKey(undefined); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest(["portfolio", "tokens", "by-address"], "{}"), { + params: Promise.resolve({ path: ["portfolio", "tokens", "by-address"] }), + }); + expect(res.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("passes through upstream non-200 statuses (e.g. 429)", async () => { + setKey("test-key"); + fetchMock.mockResolvedValueOnce( + new Response("rate limited", { + status: 429, + headers: { "Retry-After": "2" }, + }), + ); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest(["portfolio", "tokens", "by-address"], "{}"), { + params: Promise.resolve({ path: ["portfolio", "tokens", "by-address"] }), + }); + expect(res.status).toBe(429); + }); + + it("attaches CORS headers to the response", async () => { + setKey("test-key"); + fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest(["portfolio", "tokens", "by-address"], "{}"), { + params: Promise.resolve({ path: ["portfolio", "tokens", "by-address"] }), + }); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + }); + + it("ignores ALCHEMY_DISABLED_NETWORKS for the portfolio branch", async () => { + process.env.ALCHEMY_DISABLED_NETWORKS = "eth-mainnet,arb-mainnet"; + setKey("test-key"); + fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest(["portfolio", "tokens", "by-address"], "{}"), { + params: Promise.resolve({ path: ["portfolio", "tokens", "by-address"] }), + }); + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/api/alchemy/proxy.test.ts b/tests/api/alchemy/proxy.test.ts new file mode 100644 index 0000000..2e23c41 --- /dev/null +++ b/tests/api/alchemy/proxy.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +function makeRequest(path: string[], body?: string, method = "POST"): Request { + const url = `http://localhost/api/alchemy/${path.join("/")}`; + return new Request(url, { + method, + headers: { "Content-Type": "application/json" }, + body, + }); +} + +function setEnv(disabled: string | undefined, key: string | undefined) { + if (disabled === undefined) delete process.env.ALCHEMY_DISABLED_NETWORKS; + else process.env.ALCHEMY_DISABLED_NETWORKS = disabled; + if (key === undefined) delete process.env.ALCHEMY_API_KEY; + else process.env.ALCHEMY_API_KEY = key; +} + +let originalDisabled: string | undefined; +let originalKey: string | undefined; + +beforeEach(() => { + originalDisabled = process.env.ALCHEMY_DISABLED_NETWORKS; + originalKey = process.env.ALCHEMY_API_KEY; + fetchMock.mockReset(); +}); + +afterEach(() => { + setEnv(originalDisabled, originalKey); + vi.resetModules(); +}); + +describe("POST /api/alchemy/[...path] — allowlist", () => { + it("returns 400 when the network is not in the catalog at all", async () => { + setEnv(undefined, "test-key"); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST( + makeRequest(["random-network"], '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'), + { params: Promise.resolve({ path: ["random-network"] }) }, + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/not allowed/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when the network is in the catalog but listed in ALCHEMY_DISABLED_NETWORKS", async () => { + setEnv("eth-mainnet", "test-key"); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST( + makeRequest(["eth-mainnet"], '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'), + { params: Promise.resolve({ path: ["eth-mainnet"] }) }, + ); + expect(res.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when the path is empty", async () => { + setEnv(undefined, "test-key"); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest([], "{}"), { + params: Promise.resolve({ path: [] }), + }); + expect(res.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/alchemy/[...path] — server config", () => { + it("returns 500 when ALCHEMY_API_KEY is not set on the server", async () => { + setEnv(undefined, undefined); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST( + makeRequest(["eth-mainnet"], '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'), + { params: Promise.resolve({ path: ["eth-mainnet"] }) }, + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/alchemy.*not configured/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/alchemy/[...path] — proxy behavior", () => { + it("forwards the JSON-RPC body to https://.g.alchemy.com/v2/", async () => { + setEnv(undefined, "test-key-abc"); + fetchMock.mockResolvedValueOnce( + new Response('{"jsonrpc":"2.0","id":1,"result":"0x10d4f"}', { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const body = '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'; + const res = await POST(makeRequest(["eth-mainnet"], body), { + params: Promise.resolve({ path: ["eth-mainnet"] }), + }); + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://eth-mainnet.g.alchemy.com/v2/test-key-abc"); + expect(init.method).toBe("POST"); + expect(init.body).toBe(body); + }); + + it("returns the upstream response body + status to the caller", async () => { + setEnv(undefined, "test-key"); + fetchMock.mockResolvedValueOnce( + new Response('{"jsonrpc":"2.0","id":1,"result":"0x1"}', { status: 200 }), + ); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST( + makeRequest(["polygon-mainnet"], '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'), + { params: Promise.resolve({ path: ["polygon-mainnet"] }) }, + ); + expect(res.status).toBe(200); + const out = await res.text(); + expect(out).toContain('"result":"0x1"'); + }); + + it("passes through upstream non-200 statuses (e.g. 429 rate limit)", async () => { + setEnv(undefined, "test-key"); + fetchMock.mockResolvedValueOnce( + new Response("rate limited", { status: 429, headers: { "Retry-After": "2" } }), + ); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest(["eth-mainnet"], "{}"), { + params: Promise.resolve({ path: ["eth-mainnet"] }), + }); + expect(res.status).toBe(429); + }); + + it("attaches CORS headers to the response", async () => { + setEnv(undefined, "test-key"); + fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest(["eth-mainnet"], "{}"), { + params: Promise.resolve({ path: ["eth-mainnet"] }), + }); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + }); +}); + +describe("GET /api/alchemy/[...path] — healthcheck", () => { + it("answers OPTIONS preflight with 204 + CORS headers", async () => { + const { OPTIONS } = await import("@/app/api/alchemy/[...path]/route"); + const res = await OPTIONS(); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + }); +}); diff --git a/tests/api/alchemy/status.test.ts b/tests/api/alchemy/status.test.ts new file mode 100644 index 0000000..44a3118 --- /dev/null +++ b/tests/api/alchemy/status.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +// Env must be mutated BEFORE the route module is imported, because the +// handler reads process.env at call-time (not module-init) — so we set +// it in beforeEach and the handler picks up the latest value. +describe("GET /api/alchemy/status", () => { + let original: string | undefined; + + beforeEach(() => { + original = process.env.ALCHEMY_API_KEY; + }); + + afterEach(() => { + if (original === undefined) delete process.env.ALCHEMY_API_KEY; + else process.env.ALCHEMY_API_KEY = original; + }); + + it("returns configured: false when ALCHEMY_API_KEY is unset", async () => { + delete process.env.ALCHEMY_API_KEY; + const { GET } = await import("@/app/api/alchemy/status/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ configured: false }); + }); + + it("returns configured: false when ALCHEMY_API_KEY is empty", async () => { + process.env.ALCHEMY_API_KEY = ""; + const { GET } = await import("@/app/api/alchemy/status/route"); + const res = await GET(); + const body = await res.json(); + expect(body).toEqual({ configured: false }); + }); + + it("returns configured: true when ALCHEMY_API_KEY is set", async () => { + process.env.ALCHEMY_API_KEY = "alchemy-test-key-abc123"; + const { GET } = await import("@/app/api/alchemy/status/route"); + const res = await GET(); + const body = await res.json(); + expect(body).toEqual({ configured: true }); + }); + + it("never includes the key value in the response", async () => { + process.env.ALCHEMY_API_KEY = "super-secret-key-12345"; + const { GET } = await import("@/app/api/alchemy/status/route"); + const res = await GET(); + const text = await res.text(); + expect(text).not.toContain("super-secret-key-12345"); + }); +}); diff --git a/tests/backend/tool/crypto/ask-crypto-intent.test.ts b/tests/backend/tool/crypto/ask-crypto-intent.test.ts deleted file mode 100644 index f6382ab..0000000 --- a/tests/backend/tool/crypto/ask-crypto-intent.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { interrupt } from "@langchain/langgraph"; -import { askCryptoIntentTool } from "@/backend/tool/crypto/ask-crypto-intent"; - -vi.mock("@langchain/langgraph", async () => { - const actual = - await vi.importActual("@langchain/langgraph"); - return { - ...actual, - interrupt: vi.fn(), - }; -}); - -const fetchMock = vi.fn(); -const interruptMock = interrupt as unknown as ReturnType; - -beforeEach(() => { - vi.stubGlobal("fetch", fetchMock); - fetchMock.mockReset(); - interruptMock.mockReset(); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("askCryptoIntentTool", () => { - it("pauses with ui: ask_crypto_intent and returns the resumed pick", async () => { - const pick = { - coin_id: "bitcoin", - coin_symbol: "BTC", - amount: 100, - currency: "USD", - side: "buy", - }; - interruptMock.mockReturnValue(pick); - - const result = await askCryptoIntentTool.invoke({ message: "What do you want to buy?" }); - - expect(interruptMock).toHaveBeenCalledWith({ - ui: "ask_crypto_intent", - data: {}, - message: "What do you want to buy?", - }); - expect(result).toEqual(pick); - }); - - it("forwards an error pick as the tool result", async () => { - const errorPick = { error: "User cancelled" }; - interruptMock.mockReturnValue(errorPick); - - const result = await askCryptoIntentTool.invoke({ message: "?" }); - - expect(result).toEqual(errorPick); - }); - - it("defaults the message when none is provided", async () => { - interruptMock.mockReturnValue({ - coin_id: "bitcoin", - coin_symbol: "BTC", - amount: 1, - currency: "USD", - side: "buy", - }); - await askCryptoIntentTool.invoke({}); - expect(interruptMock).toHaveBeenCalledWith( - expect.objectContaining({ ui: "ask_crypto_intent", message: expect.any(String) }), - ); - }); - - it("forwards the detected currency and pre-fill amount to the card", async () => { - interruptMock.mockReturnValue({ - coin_id: "bitcoin", - coin_symbol: "BTC", - amount: 100, - currency: "CNY", - side: "buy", - }); - await askCryptoIntentTool.invoke({ message: "?", currency: "CNY", amount: 100 }); - expect(interruptMock).toHaveBeenCalledWith( - expect.objectContaining({ message: "?", ui: "ask_crypto_intent" }), - ); - }); - - it("makes no HTTP calls", async () => { - interruptMock.mockReturnValue({ - coin_id: "bitcoin", - coin_symbol: "BTC", - amount: 1, - currency: "USD", - side: "buy", - }); - await askCryptoIntentTool.invoke({ message: "?" }); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/backend/tool/crypto/confirm-crypto-order.test.ts b/tests/backend/tool/crypto/confirm-crypto-order.test.ts index 682ec42..b7c59fe 100644 --- a/tests/backend/tool/crypto/confirm-crypto-order.test.ts +++ b/tests/backend/tool/crypto/confirm-crypto-order.test.ts @@ -1,111 +1,81 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { confirmCryptoOrderTool } from "@/backend/tool/crypto/confirm-crypto-order"; -const fetchMock = vi.fn(); +// The tool is a pure pause: it validates the LLM's intent and emits +// `{status:"awaiting_user", intent:{...}}`. No fetch, no chain calls — +// the card does all the wallet + quote work client-side. -beforeEach(() => { - vi.stubGlobal("fetch", fetchMock); - fetchMock.mockReset(); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("confirmCryptoOrderTool", () => { - it("returns a simulated order with qty = amount_usd / price_at_confirm", async () => { +describe("confirmCryptoOrderTool — intent pause", () => { + it("emits awaiting_user with the intent payload verbatim", async () => { const out = await confirmCryptoOrderTool.invoke({ - coin_id: "bitcoin", - coin_symbol: "BTC", - amount_usd: 100, - price_at_confirm: 50000, - side: "buy", + side: "sell", + source_coin_id: "usd-coin", }); const parsed = JSON.parse(out as string); - expect(parsed.success).toBe(true); - expect(parsed.order).toMatchObject({ - coin: "bitcoin", - symbol: "BTC", - amount_usd: 100, - qty: 0.002, - price_at_confirm: 50000, - side: "buy", - status: "simulated_filled", + expect(parsed.status).toBe("awaiting_user"); + expect(parsed.intent).toEqual({ + side: "sell", + source_coin_id: "usd-coin", + amount: null, + target_coin_id: null, }); - expect(parsed.order.id).toMatch(/^ord_/); - expect(parsed.order.timestamp).toMatch(/T/); - expect(parsed.order.note).toMatch(/simulated/i); }); - it("supports the sell side", async () => { + it("forwards amount + target_coin_id when the LLM names them", async () => { const out = await confirmCryptoOrderTool.invoke({ - coin_id: "ethereum", - coin_symbol: "ETH", - amount_usd: 50, - price_at_confirm: 2500, side: "sell", + source_coin_id: "usd-coin", + amount: 100, + target_coin_id: "ethereum", }); const parsed = JSON.parse(out as string); - expect(parsed.order.side).toBe("sell"); - expect(parsed.order.qty).toBe(0.02); + expect(parsed.intent).toEqual({ + side: "sell", + source_coin_id: "usd-coin", + amount: 100, + target_coin_id: "ethereum", + }); }); - it("rejects a non-positive price with a structured error", async () => { - const out = await confirmCryptoOrderTool.invoke({ - coin_id: "bitcoin", - coin_symbol: "BTC", - amount_usd: 100, - price_at_confirm: 0, - side: "buy", - }); + it("works with no optional fields at all (the card picks defaults from the wallet)", async () => { + const out = await confirmCryptoOrderTool.invoke({ side: "buy" }); const parsed = JSON.parse(out as string); - expect(parsed.success).toBe(false); - expect(parsed.error).toMatch(/price/); + expect(parsed.status).toBe("awaiting_user"); + expect(parsed.intent.source_coin_id).toBeNull(); + expect(parsed.intent.amount).toBeNull(); + expect(parsed.intent.target_coin_id).toBeNull(); }); +}); - it("rejects a non-positive amount with a structured error", async () => { - const out = await confirmCryptoOrderTool.invoke({ - coin_id: "bitcoin", - coin_symbol: "BTC", - amount_usd: 0, - price_at_confirm: 50000, - side: "buy", - }); - const parsed = JSON.parse(out as string); - expect(parsed.success).toBe(false); - expect(parsed.error).toMatch(/amount/); +describe("confirmCryptoOrderTool — validation", () => { + it("rejects a non-positive amount at the schema layer", async () => { + await expect(confirmCryptoOrderTool.invoke({ side: "sell", amount: 0 })).rejects.toThrow(); + }); + + it("rejects NaN amount at the schema layer", async () => { + await expect( + confirmCryptoOrderTool.invoke({ side: "sell", amount: Number.NaN }), + ).rejects.toThrow(); }); - it("makes no HTTP calls", async () => { - await confirmCryptoOrderTool.invoke({ - coin_id: "bitcoin", - coin_symbol: "BTC", - amount_usd: 1, - price_at_confirm: 1, - side: "buy", + it("rejects a malformed CoinGecko id", async () => { + const out = await confirmCryptoOrderTool.invoke({ + side: "sell", + source_coin_id: "Bad/Id With Spaces", }); - expect(fetchMock).not.toHaveBeenCalled(); + const parsed = JSON.parse(out as string); + expect(parsed.status).toBe("error"); + expect(parsed.error).toMatch(/source_coin_id/); }); - it("computes qty without float precision drift (100/3 case)", async () => { - // Number division gives 33.333333333333336 — visible in the - // receipt as $33.333333333333336. Decimal-backed division - // preserves precision in the Decimal object; we round when - // handing it back as a number for JSON serialization. + it("rejects a CoinGecko id not in the catalog", async () => { const out = await confirmCryptoOrderTool.invoke({ - coin_id: "bitcoin", - coin_symbol: "BTC", - amount_usd: 100, - price_at_confirm: 3, - side: "buy", + side: "sell", + source_coin_id: "definitely-not-a-real-coin", }); const parsed = JSON.parse(out as string); - expect(parsed.success).toBe(true); - // Allow tiny float drift on the .toNumber() boundary but no - // ugly "33.333333333333343" type values. - expect(parsed.order.qty).toBeGreaterThan(33.3333); - expect(parsed.order.qty).toBeLessThan(33.3334); - expect(String(parsed.order.qty)).toMatch(/^33\.33/); + expect(parsed.status).toBe("error"); + expect(parsed.error).toMatch(/catalog/); }); }); diff --git a/tests/backend/tool/crypto/get-token-balances.test.ts b/tests/backend/tool/crypto/get-token-balances.test.ts new file mode 100644 index 0000000..0c4d92a --- /dev/null +++ b/tests/backend/tool/crypto/get-token-balances.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + vi.resetModules(); + process.env.ALCHEMY_API_KEY = "test-key-1234"; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.ALCHEMY_API_KEY; +}); + +async function loadTool() { + const mod = await import("@/backend/tool/crypto/get-token-balances"); + return mod.getTokenBalancesTool; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function rpcResponse(result: unknown): Response { + return jsonResponse(200, { jsonrpc: "2.0", id: 1, result }); +} + +const WALLET = "0x1234567890123456789012345678901234567890"; +const USDC_ADDR = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; +const WETH_ADDR = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"; + +const SAMPLE_BALANCES = { + address: WALLET, + tokenBalances: [ + { contractAddress: USDC_ADDR, tokenBalance: "0x5f5e100" }, // 100 USDC (6 decimals) + { contractAddress: WETH_ADDR, tokenBalance: "0x16345785d8a0000" }, // 0.1 WETH + ], +}; + +const SAMPLE_METADATA = { + name: "USD Coin", + symbol: "USDC", + decimals: 6, + logo: "https://example.com/usdc.png", +}; + +describe("getTokenBalancesTool", () => { + it("returns the user's non-zero ERC20 balances with metadata", async () => { + const tool = await loadTool(); + + // First call: alchemy_getTokenBalances + fetchMock.mockResolvedValueOnce(rpcResponse(SAMPLE_BALANCES)); + // Second + third calls: alchemy_getTokenMetadata per token + fetchMock.mockResolvedValueOnce(rpcResponse({ ...SAMPLE_METADATA })); + fetchMock.mockResolvedValueOnce( + rpcResponse({ name: "Wrapped Ether", symbol: "WETH", decimals: 18, logo: null }), + ); + + const out = await tool.invoke({ chainId: 42161, address: WALLET }); + const parsed = JSON.parse(out as string); + + expect(parsed.success).toBe(true); + expect(parsed.tokens).toHaveLength(2); + expect(parsed.tokens[0]).toMatchObject({ + contractAddress: USDC_ADDR, + symbol: "USDC", + decimals: 6, + balance: "100", + }); + expect(parsed.tokens[1]).toMatchObject({ + contractAddress: WETH_ADDR, + symbol: "WETH", + decimals: 18, + balance: "0.1", + }); + }); + + it("filters out zero balances", async () => { + const tool = await loadTool(); + fetchMock.mockResolvedValueOnce( + rpcResponse({ + address: WALLET, + tokenBalances: [ + { contractAddress: USDC_ADDR, tokenBalance: "0x5f5e100" }, + { contractAddress: WETH_ADDR, tokenBalance: "0x0" }, + ], + }), + ); + fetchMock.mockResolvedValueOnce(rpcResponse(SAMPLE_METADATA)); + + const out = await tool.invoke({ chainId: 42161, address: WALLET }); + const parsed = JSON.parse(out as string); + expect(parsed.tokens).toHaveLength(1); + expect(parsed.tokens[0].contractAddress).toBe(USDC_ADDR); + }); + + it("returns an empty list when the wallet holds no tokens", async () => { + const tool = await loadTool(); + fetchMock.mockResolvedValueOnce(rpcResponse({ address: WALLET, tokenBalances: [] })); + const out = await tool.invoke({ chainId: 42161, address: WALLET }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(true); + expect(parsed.tokens).toEqual([]); + }); + + it("targets the right Alchemy host for each chainId", async () => { + const tool = await loadTool(); + fetchMock.mockResolvedValueOnce(rpcResponse({ address: WALLET, tokenBalances: [] })); + await tool.invoke({ chainId: 1, address: WALLET }); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe("https://eth-mainnet.g.alchemy.com/v2/test-key-1234"); + + fetchMock.mockResolvedValueOnce(rpcResponse({ address: WALLET, tokenBalances: [] })); + await tool.invoke({ chainId: 8453, address: WALLET }); + const [url2] = fetchMock.mock.calls[1] as [string]; + expect(url2).toBe("https://base-mainnet.g.alchemy.com/v2/test-key-1234"); + }); + + it("rejects an unsupported chainId", async () => { + const tool = await loadTool(); + const out = await tool.invoke({ chainId: 137, address: WALLET }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/137/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects a malformed address (not 0x + 40 hex)", async () => { + const tool = await loadTool(); + const out = await tool.invoke({ chainId: 42161, address: "not-an-address" }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/address/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("propagates Alchemy errors as a structured result", async () => { + const tool = await loadTool(); + fetchMock.mockResolvedValueOnce( + jsonResponse(403, { jsonrpc: "2.0", id: 1, error: { message: "invalid key" } }), + ); + const out = await tool.invoke({ chainId: 42161, address: WALLET }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/403/); + }); + + it("errors cleanly when ALCHEMY_API_KEY is missing", async () => { + delete process.env.ALCHEMY_API_KEY; + const tool = await loadTool(); + const out = await tool.invoke({ chainId: 42161, address: WALLET }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/ALCHEMY_API_KEY/); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/frontend/crypto/ask-crypto-intent-card.test.tsx b/tests/frontend/crypto/ask-crypto-intent-card.test.tsx deleted file mode 100644 index f1572eb..0000000 --- a/tests/frontend/crypto/ask-crypto-intent-card.test.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { WagmiProvider, createConfig, http } from "wagmi"; -import { mainnet } from "wagmi/chains"; - -vi.mock("@assistant-ui/react-langgraph", () => ({ - useLangGraphSendCommand: () => vi.fn(), -})); - -vi.mock("wagmi/connectors", () => ({ - injected: () => ({ id: "injected", type: "injected" }), -})); - -const mockUseAccount = vi.fn(); -const mockOpenConnectModal = vi.fn(); - -vi.mock("wagmi", async () => { - const actual = await vi.importActual("wagmi"); - return { - ...actual, - useAccount: () => mockUseAccount(), - useConnect: () => ({ connect: vi.fn(), connectors: [], isPending: false }), - useBalance: () => ({ data: undefined }), - }; -}); - -vi.mock("@rainbow-me/rainbowkit", async () => { - const actual = - await vi.importActual("@rainbow-me/rainbowkit"); - return { - ...actual, - useConnectModal: () => ({ openConnectModal: mockOpenConnectModal }), - }; -}); - -import { AskCryptoIntentCard } from "@/components/tool-ui/crypto/ask-crypto-intent-card"; - -const config = createConfig({ - chains: [mainnet], - transports: { [mainnet.id]: http() }, - ssr: true, -}); - -function renderCard(args: Record = {}) { - const qc = new QueryClient(); - // Card takes a ToolCallMessagePart prop; only args/result are exercised by these tests. - const stub = { - type: "tool-call", - toolCallId: "test", - toolName: "ask_crypto_intent", - argsText: "", - args, - result: undefined, - } as never; - return render( - - - - - , - ); -} - -beforeEach(() => { - vi.clearAllMocks(); - mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); -}); - -afterEach(() => { - cleanup(); -}); - -describe("AskCryptoIntentCard amount input", () => { - it("marks negative input invalid and disables the order button", () => { - renderCard(); - const input = screen.getByPlaceholderText("100") as HTMLInputElement; - fireEvent.change(input, { target: { value: "-100" } }); - // The input keeps the literal text so the user can see what they - // typed; Decimal rejects the value at validation time and the - // button goes disabled instead of silently shipping a negative. - expect(input.value).toBe("-100"); - expect(input.getAttribute("aria-invalid")).toBe("true"); - expect(input.className).toContain("border-destructive"); - const btn = screen.getByRole("button", { name: /connect.*buy/i }); - expect(btn).toBeDisabled(); - }); - - it("keeps positive values unchanged and valid", () => { - renderCard(); - const input = screen.getByPlaceholderText("100") as HTMLInputElement; - fireEvent.change(input, { target: { value: "250.5" } }); - expect(input.value).toBe("250.5"); - expect(input.getAttribute("aria-invalid")).toBe("false"); - const btn = screen.getByRole("button", { name: /connect.*buy/i }); - expect(btn).not.toBeDisabled(); - }); - - it("allows empty input", () => { - renderCard(); - const input = screen.getByPlaceholderText("100") as HTMLInputElement; - fireEvent.change(input, { target: { value: "" } }); - expect(input.value).toBe(""); - // Empty is not invalid — it's just not yet filled in. - expect(input.getAttribute("aria-invalid")).toBe("false"); - }); - - it("rejects scientific notation (1e2) without breaking the input", () => { - renderCard(); - const input = screen.getByPlaceholderText("100") as HTMLInputElement; - fireEvent.change(input, { target: { value: "1e2" } }); - expect(input.value).toBe("1e2"); - expect(input.getAttribute("aria-invalid")).toBe("true"); - const btn = screen.getByRole("button", { name: /connect.*buy/i }); - expect(btn).toBeDisabled(); - }); - - it("rejects non-numeric junk", () => { - renderCard(); - const input = screen.getByPlaceholderText("100") as HTMLInputElement; - fireEvent.change(input, { target: { value: "abc" } }); - expect(input.getAttribute("aria-invalid")).toBe("true"); - expect(screen.getByRole("button", { name: /connect.*buy/i })).toBeDisabled(); - }); - - it("rejects multiple decimal points", () => { - renderCard(); - const input = screen.getByPlaceholderText("100") as HTMLInputElement; - fireEvent.change(input, { target: { value: "1.2.3" } }); - expect(input.getAttribute("aria-invalid")).toBe("true"); - }); -}); - -describe("AskCryptoIntentCard currency detection", () => { - it("labels the amount input with the LLM-detected currency (CNY)", () => { - renderCard({ currency: "CNY" }); - expect(screen.getByText(/amount \(cny\)/i)).toBeTruthy(); - }); - - it("labels the amount input with the LLM-detected currency (EUR)", () => { - renderCard({ currency: "EUR" }); - expect(screen.getByText(/amount \(eur\)/i)).toBeTruthy(); - }); - - it("falls back to USD when no currency is passed", () => { - renderCard(); - expect(screen.getByText(/amount \(usd\)/i)).toBeTruthy(); - }); - - it("pre-fills the amount input from the LLM's hint", () => { - renderCard({ currency: "CNY", amount: 250 }); - const input = screen.getByPlaceholderText("100") as HTMLInputElement; - expect(input.value).toBe("250"); - }); -}); - -describe("AskCryptoIntentCard wallet flow", () => { - it("renders a 'Connect & buy' button when no wallet is connected", () => { - mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); - renderCard(); - expect(screen.getByRole("button", { name: /connect.*buy/i })).toBeTruthy(); - }); - - it("renders a 'Confirm buy' button (no wallet prompt) when connected", () => { - mockUseAccount.mockReturnValue({ - address: "0x1234567890abcdef1234567890abcdef12345678", - isConnected: true, - }); - renderCard(); - expect(screen.getByRole("button", { name: /confirm.*buy/i })).toBeTruthy(); - }); - - it("shows the connected address + a connect dialog trigger is hidden", () => { - mockUseAccount.mockReturnValue({ - address: "0x1234567890abcdef1234567890abcdef12345678", - isConnected: true, - }); - renderCard(); - expect(screen.getByText(/0x1234…5678/)).toBeTruthy(); - expect(screen.queryByRole("button", { name: /connect.*buy/i })).toBeNull(); - }); - - it("opens the RainbowKit connect modal when Confirm is pressed with no wallet", () => { - mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); - renderCard(); - const btn = screen.getByRole("button", { name: /connect.*buy/i }); - fireEvent.click(btn); - expect(mockOpenConnectModal).toHaveBeenCalledTimes(1); - }); - - it("does not open the connect modal when already connected", () => { - mockUseAccount.mockReturnValue({ - address: "0x1234567890abcdef1234567890abcdef12345678", - isConnected: true, - }); - renderCard(); - const btn = screen.getByRole("button", { name: /confirm.*buy/i }); - fireEvent.click(btn); - expect(mockOpenConnectModal).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/frontend/crypto/crypto-confirm-card.test.tsx b/tests/frontend/crypto/crypto-confirm-card.test.tsx new file mode 100644 index 0000000..20b7228 --- /dev/null +++ b/tests/frontend/crypto/crypto-confirm-card.test.tsx @@ -0,0 +1,500 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WagmiProvider, createConfig, http } from "wagmi"; +import { mainnet } from "wagmi/chains"; + +import { CryptoConfirmCard } from "@/components/tool-ui/crypto/confirm-card"; + +// --- Mocks ----------------------------------------------------------------- + +vi.mock("@assistant-ui/react-langgraph", () => ({ + useLangGraphSendCommand: () => mockSendCommand, +})); + +vi.mock("wagmi", async () => { + const actual = await vi.importActual("wagmi"); + return { + ...actual, + useAccount: () => mockUseAccount(), + useSignTypedData: () => ({ signTypedDataAsync: mockSignTypedDataAsync }), + useSwitchChain: () => ({ switchChainAsync: vi.fn(), isPending: false }), + }; +}); + +vi.mock("@rainbow-me/rainbowkit", async () => { + const actual = + await vi.importActual("@rainbow-me/rainbowkit"); + return { + ...actual, + useConnectModal: () => ({ openConnectModal: mockOpenConnectModal }), + }; +}); + +const mockSendCommand = vi.fn(); +const mockOpenConnectModal = vi.fn(); +const mockUseAccount = vi.fn(); +const mockSignTypedDataAsync = vi.fn(); + +// Portfolio API mock — a single call returns balance + metadata + price +// for every token the wallet holds across all 3 CoW chains. USDC is the +// user's biggest holding; WETH + native ETH are peers so the card's +// native-token row + USD-value column rendering is exercised. +const PORTFOLIO_RESPONSE = { + data: { + tokens: [ + { + network: "eth-mainnet", + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC mainnet + tokenBalance: "0x5f5e100", // 100 USDC (6 decimals) + tokenMetadata: { + symbol: "USDC", + decimals: 6, + name: "USD Coin", + logo: "https://example.com/usdc.png", + }, + tokenPrices: [{ currency: "usd", value: "1.0" }], + }, + { + network: "eth-mainnet", + tokenAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH mainnet + tokenBalance: "0x16345785d8a0000", // 0.1 WETH (18 decimals) + tokenMetadata: { + symbol: "WETH", + decimals: 18, + name: "Wrapped Ether", + logo: "https://example.com/weth.png", + }, + tokenPrices: [{ currency: "usd", value: "3100.0" }], + }, + { + network: "eth-mainnet", + tokenAddress: null, // native ETH + tokenBalance: "0x16345785d8a0000", // 0.1 ETH + tokenMetadata: { + symbol: "ETH", + decimals: 18, + name: "Ether", + logo: "https://example.com/eth.png", + }, + tokenPrices: [{ currency: "usd", value: "3100.0" }], + }, + ], + }, +}; + +function mockFetchImpl(input: RequestInfo | URL, _init?: RequestInit) { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/alchemy/portfolio/tokens/by-address")) { + return Promise.resolve( + new Response(JSON.stringify(PORTFOLIO_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + // CoW quote + if (url.includes("/quote")) { + return Promise.resolve( + new Response( + JSON.stringify({ + sellToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + buyToken: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + sellAmount: "99988648", + buyAmount: "41581080662656000", + validTo: 1782539294, + feeAmount: "11352", + kind: "sell", + partiallyFillable: false, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + } + return Promise.resolve(new Response("{}", { status: 404 })); +} + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn(mockFetchImpl)); + vi.clearAllMocks(); + mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); + mockSignTypedDataAsync.mockResolvedValue("0xsignature"); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +// --- Test helpers ---------------------------------------------------------- + +const config = createConfig({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + ssr: true, +}); + +function wrap(node: React.ReactElement) { + const qc = new QueryClient(); + return render( + + {node} + , + ); +} + +type Result = Parameters[0]["result"]; + +function makeProps(args: Record, result: Result) { + return { + type: "tool-call" as const, + toolCallId: "test", + toolName: "confirm_crypto_order", + argsText: "", + args, + result, + status: { type: "complete" as const }, + }; +} + +function intentArgs(overrides: Record = {}) { + return { + side: "sell", + source_coin_id: "usd-coin", + ...overrides, + }; +} + +function awaitingResult(intent: Record = {}) { + return { + status: "awaiting_user" as const, + intent: { + side: "sell" as const, + source_coin_id: "usd-coin", + amount: null, + target_coin_id: null, + ...intent, + }, + }; +} + +// --- Tests ----------------------------------------------------------------- + +describe("CryptoConfirmCard — wallet gate", () => { + it("renders a Connect wallet button when no wallet is connected", async () => { + mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); + wrap(); + expect(screen.getByRole("button", { name: /connect wallet/i })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /connect wallet/i })); + expect(mockOpenConnectModal).toHaveBeenCalledTimes(1); + }); + + it("renders an unsupported-chain error when wagmi is on Polygon etc.", async () => { + mockUseAccount.mockReturnValue({ + address: "0x1234567890abcdef1234567890abcdef12345678", + isConnected: true, + chainId: 137, + }); + wrap(); + expect(screen.getByText(/isn't supported/i)).toBeTruthy(); + }); +}); + +describe("CryptoConfirmCard — connected wallet", () => { + beforeEach(() => { + mockUseAccount.mockReturnValue({ + address: "0x1234567890abcdef1234567890abcdef12345678", + isConnected: true, + chainId: 1, + }); + }); + + it("lists the user's balances and pre-selects the LLM-hinted source", async () => { + wrap(); + await waitFor(() => { + expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); + }); + expect(screen.getAllByText(/WETH/).length).toBeGreaterThan(0); + // USDC button (the LLM hinted "usd-coin") should be in the primary + // (selected) style — we assert via data-action + class scan rather + // than the rendered text since multiple elements contain "USDC". + const selected = screen + .getAllByRole("button") + .find( + (b) => b.getAttribute("data-action") === "select-source" && b.textContent?.includes("USDC"), + ); + expect(selected?.className).toContain("bg-primary/10"); + }); + + it("renders a single Portfolio fetch (no per-chain JSON-RPC for balances)", async () => { + wrap(); + await waitFor(() => { + expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); + }); + const fetchCalls = (fetch as ReturnType).mock.calls; + const portfolioCalls = fetchCalls.filter(([u]) => + String(u).includes("/api/alchemy/portfolio/tokens/by-address"), + ); + const legacyRpcCalls = fetchCalls.filter(([u]) => + String(u).match(/\/api\/alchemy\/(eth|arb|base)-mainnet/), + ); + expect(portfolioCalls.length).toBe(1); + expect(legacyRpcCalls.length).toBe(0); + }); + + it("renders the native ETH row with the (native) marker", async () => { + wrap(); + await waitFor(() => { + expect(screen.getAllByText(/native/).length).toBeGreaterThan(0); + }); + // Native ETH button is the one whose row contains "(native)" — WETH + // is also "ETH"-shaped but is the wrapped ERC20 (no marker). + const nativeBtn = screen + .getAllByRole("button") + .find( + (b) => + b.getAttribute("data-action") === "select-source" && + /\(native\)/i.test(b.textContent ?? ""), + ); + expect(nativeBtn).toBeTruthy(); + expect(nativeBtn?.textContent).toMatch(/ETH/); + }); + + it("renders the USD-value column for each row", async () => { + wrap(); + await waitFor(() => { + // 100 USDC @ $1 = $100, 0.1 WETH @ $3100 = $310, 0.1 ETH @ $3100 = $310 + expect(screen.getAllByText(/≈ \$100/).length).toBeGreaterThan(0); + }); + expect(screen.getAllByText(/≈ \$310/).length).toBeGreaterThan(1); + }); + + it("sorts native to the top of each chain group, then by USD value desc", async () => { + wrap(); + await waitFor(() => { + expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); + }); + const selectButtons = screen + .getAllByRole("button") + .filter((b) => b.getAttribute("data-action") === "select-source"); + // First chain group is Ethereum; mock data is native ETH (~$310), + // WETH (~$310), USDC (~$100). Native always wins, then USD desc — + // so the visible order is ETH (native), WETH, USDC. + const symbolOrder = selectButtons + .map((b) => b.textContent ?? "") + .filter((t) => /(USDC|WETH|ETH)/.test(t)); + expect(symbolOrder.length).toBeGreaterThanOrEqual(3); + // Native ETH row is the first one — it carries the "(native)" marker. + expect(symbolOrder[0]).toMatch(/\(native\)/i); + expect(symbolOrder[0]).toMatch(/ETH/); + // USDC (lowest USD in this group) is still last. + const usdcIdx = symbolOrder.findIndex((t) => t.includes("USDC")); + expect(usdcIdx).toBeGreaterThanOrEqual(2); + }); + + it("renders the token logo image for each row", async () => { + wrap(); + await waitFor(() => { + expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); + }); + // Logos are decorative (alt="") so jsdom drops the img role; query + // the DOM directly. We assert the USDC src made it through to the + // DOM without needing to count all 3 logos. + const imgs = document.querySelectorAll("img"); + expect(imgs.length).toBeGreaterThan(0); + expect( + Array.from(imgs).some((img) => img.getAttribute("src") === "https://example.com/usdc.png"), + ).toBe(true); + }); + + it("renders the Alchemy chain emblem next to each chain group header", async () => { + wrap(); + await waitFor(() => { + expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); + }); + // The mock balances all live on Ethereum, so the chain group header + // should carry the eth-mainnet.svg emblem from the catalog. + const groupHeader = document.querySelector( + 'img[src="https://static.alchemyapi.io/images/emblems/eth-mainnet.svg"]', + ); + expect(groupHeader).toBeTruthy(); + // Emblem is decorative — the surrounding label carries the name. + expect(groupHeader?.getAttribute("alt")).toBe(""); + }); + + it("pre-selects the LLM-hinted target and falls back to WETH when source is a stablecoin", async () => { + wrap(); + await waitFor(() => { + const select = screen.getByRole("combobox") as HTMLSelectElement; + expect(select.value).toBe("weth"); + }); + }); + + it("honors the LLM's target_coin_id hint", async () => { + wrap( + , + ); + await waitFor(() => { + const select = screen.getByRole("combobox") as HTMLSelectElement; + expect(select.value).toBe("wbtc"); + }); + }); + + it("calls CoW /quote once source/amount/target are valid", async () => { + wrap( + , + ); + await waitFor(() => { + const fetchCalls = (fetch as ReturnType).mock.calls; + expect(fetchCalls.some(([u]) => String(u).includes("/quote"))).toBe(true); + }); + }); + + it("disables the Sign button until a quote loads", async () => { + wrap( + , + ); + await waitFor(() => { + expect(screen.getByText(/you receive/i)).toBeTruthy(); + }); + // Quote effect debounces 400ms; wait for Sign to enable. + const signBtn = await screen.findByRole("button", { name: /confirm sell/i }); + await waitFor(() => { + expect((signBtn as HTMLButtonElement).disabled).toBe(false); + }); + }); + + it("shows a 'Confirm order' header in both buy and sell mode", async () => { + wrap(); + await waitFor(() => expect(screen.getByText(/^Confirm order$/)).toBeTruthy()); + wrap( + , + ); + await waitFor(() => expect(screen.getAllByText(/^Confirm order$/).length).toBeGreaterThan(0)); + }); + + it("clicking Confirm calls addResult with simulated_filled + the quote-derived qty", async () => { + wrap( + , + ); + const btn = await screen.findByRole("button", { name: /confirm sell/i }); + // Quote effect debounces 400ms; the click is a no-op until quote + // loads. waitFor the button to enable before firing. + await waitFor(() => { + expect((btn as HTMLButtonElement).disabled).toBe(false); + }); + fireEvent.click(btn); + expect(mockSendCommand).toHaveBeenCalledTimes(1); + const arg = mockSendCommand.mock.calls[0][0]; + const payload = JSON.parse(arg.resume); + expect(payload.status).toBe("simulated_filled"); + expect(payload.order.symbol).toBe("USDC"); + expect(payload.order.amount_human).toBe(100); + expect(payload.order.qty).toBeCloseTo(0.041581, 5); + }); + + it("clicking Cancel calls addResult with cancelled", async () => { + wrap( + , + ); + await waitFor(() => expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy()); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + const arg = mockSendCommand.mock.calls[0][0]; + const payload = JSON.parse(arg.resume); + expect(payload.status).toBe("cancelled"); + }); +}); + +describe("CryptoConfirmCard — terminal states", () => { + it("renders the SIMULATED receipt", () => { + wrap( + , + ); + expect(screen.getByText(/ord_abc/)).toBeTruthy(); + expect(screen.getByText("SIMULATED")).toBeTruthy(); + }); + + it("renders the SIGNED receipt with the CoW order link", () => { + const FAKE_UID = "0x" + "a".repeat(106) + "deadbeef"; + wrap( + , + ); + expect(screen.getByText(/aaaaaaaa…adbeef/i)).toBeTruthy(); + expect(screen.getByText(/signed/i)).toBeTruthy(); + }); + + it("renders a cancelled banner", () => { + wrap(); + expect(screen.getByText(/cancelled/i)).toBeTruthy(); + }); + + it("renders an error banner", () => { + wrap( + , + ); + expect(screen.getByText(/wallet rejected/i)).toBeTruthy(); + }); +}); + +describe("CryptoConfirmCard — malformed awaiting_user (defensive)", () => { + it("surfaces a structured error when intent is missing", () => { + wrap( + , + ); + expect(screen.getByText(/intent was missing/i)).toBeTruthy(); + }); + + it("surfaces a structured error when intent.side is missing", () => { + wrap( + , + ); + expect(screen.getByText(/intent was missing/i)).toBeTruthy(); + }); +}); diff --git a/tests/frontend/crypto/crypto-price-card.test.ts b/tests/frontend/crypto/crypto-price-card.test.ts new file mode 100644 index 0000000..e992eb0 --- /dev/null +++ b/tests/frontend/crypto/crypto-price-card.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { formatPrice } from "@/components/tool-ui/crypto/price-card"; + +// CoinGecko accepts these vs_currency codes (lowercase). The card lower-cases +// nothing — formatPrice upper-cases before handing to Intl. Cover every code +// that has a meaningful symbol distinction, plus the edge cases around +// sub-1 values and zero-decimal currencies. +describe("formatPrice", () => { + describe("symbol correctness per currency", () => { + it("USD → $", () => { + expect(formatPrice(255034, "usd")).toContain("$"); + expect(formatPrice(255034, "usd")).toBe("$255,034.00"); + }); + + it("EUR → €", () => { + expect(formatPrice(1234.5, "eur")).toContain("€"); + expect(formatPrice(1234.5, "eur")).toBe("€1,234.50"); + }); + + it("JPY → ¥ (no decimals)", () => { + expect(formatPrice(255034, "jpy")).toContain("¥"); + // JPY has no sub-unit — must NOT emit .5 or .00. + expect(formatPrice(255034, "jpy")).toBe("¥255,034"); + expect(formatPrice(1234.5, "jpy")).toBe("¥1,235"); // rounds half-up + expect(formatPrice(161.71, "jpy")).toBe("¥162"); + }); + + it("CNY → ¥ (RMB glyph, no CN/元 prefix)", () => { + // Bare ¥ — same glyph as JPY — to match the user's "人民币 ¥" + // convention. We accept that CNY and JPY render the same symbol; + // the vs_currency label in the surrounding context is what + // disambiguates them. + expect(formatPrice(1234.5, "cny")).toBe("¥1,234.50"); + }); + + it("CNY has no decimals when the value rounds cleanly", () => { + // CNY is not in NO_FRACTION, so it still uses 2dp even on round + // numbers — this is the conventional retail display. + expect(formatPrice(1000, "cny")).toBe("¥1,000.00"); + }); + + it("GBP → £", () => { + expect(formatPrice(1234.5, "gbp")).toContain("£"); + expect(formatPrice(1234.5, "gbp")).toBe("£1,234.50"); + }); + + it("KRW → ₩ (no decimals)", () => { + expect(formatPrice(255034, "krw")).toContain("₩"); + expect(formatPrice(255034, "krw")).toBe("₩255,034"); + }); + + it("INR → ₹", () => { + expect(formatPrice(1234.5, "inr")).toContain("₹"); + expect(formatPrice(1234.5, "inr")).toBe("₹1,234.50"); + }); + + it("CNY vs JPY share the ¥ glyph by design", () => { + // Both render bare ¥ — the surrounding context (vs_currency label + // in the chat) is the disambiguator. We still assert both contain + // ¥ so a future "drop CNY symbol" regression can't sneak past. + const cny = formatPrice(1234.5, "cny"); + const jpy = formatPrice(1234.5, "jpy"); + expect(cny).toContain("¥"); + expect(jpy).toContain("¥"); + // CNY keeps 2dp, JPY uses 0dp, so the full strings diverge: + expect(cny).toBe("¥1,234.50"); + expect(jpy).toBe("¥1,235"); + }); + }); + + describe("sub-unit precision for small values", () => { + it("USD sub-1 keeps 6 decimals so PEPE-style prices stay readable", () => { + expect(formatPrice(0.000123, "usd")).toBe("$0.000123"); + }); + + it("USD >= 1 keeps 2 decimals", () => { + expect(formatPrice(1234.5, "usd")).toBe("$1,234.50"); + }); + + it("JPY sub-1 still rounds to 0 decimals (no ¥0.000123)", () => { + // Even at ¥0.5 we render ¥1 (rounded), not a fractional yen. + expect(formatPrice(0.5, "jpy")).toBe("¥1"); + }); + }); + + describe("case insensitivity (CoinGecko sends lowercase)", () => { + it("lowercase codes are accepted", () => { + expect(formatPrice(100, "usd")).toBe(formatPrice(100, "USD")); + expect(formatPrice(100, "jpy")).toBe(formatPrice(100, "JPY")); + expect(formatPrice(100, "cny")).toBe(formatPrice(100, "CNY")); + }); + }); + + describe("unknown currency codes", () => { + // CoinGecko occasionally adds new currencies before ICU learns them. + // Node's Intl falls back to rendering the ISO code itself rather than + // throwing — `XYZ 100.00`. Document that behavior so a future change + // to "throw on unknown" is a deliberate decision, not a silent regression. + it("falls back to the ISO code instead of throwing", () => { + const out = formatPrice(100, "xyz"); + expect(out).toContain("XYZ"); + expect(out).toContain("100"); + }); + }); +}); diff --git a/tests/lib/alchemy/networks.test.ts b/tests/lib/alchemy/networks.test.ts new file mode 100644 index 0000000..67ce39d --- /dev/null +++ b/tests/lib/alchemy/networks.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from "vitest"; +import { + ALCHEMY_NETWORK_CATALOG, + getNetworkLogoByChainId, + groupNetworks, + parseNetworkList, + resolveAllowlist, + type AlchemyNetworkSlug, +} from "@/lib/alchemy/networks"; + +describe("ALCHEMY_NETWORK_CATALOG", () => { + it("includes the major L1 networks", () => { + expect(ALCHEMY_NETWORK_CATALOG["eth-mainnet"]).toBeTruthy(); + expect(ALCHEMY_NETWORK_CATALOG["polygon-mainnet"]).toBeTruthy(); + }); + + it("includes the major L2 networks", () => { + expect(ALCHEMY_NETWORK_CATALOG["arb-mainnet"]).toBeTruthy(); + expect(ALCHEMY_NETWORK_CATALOG["opt-mainnet"]).toBeTruthy(); + expect(ALCHEMY_NETWORK_CATALOG["base-mainnet"]).toBeTruthy(); + }); + + it("includes testnets for the same families", () => { + expect(ALCHEMY_NETWORK_CATALOG["eth-sepolia"]).toBeTruthy(); + expect(ALCHEMY_NETWORK_CATALOG["arb-sepolia"]).toBeTruthy(); + expect(ALCHEMY_NETWORK_CATALOG["base-sepolia"]).toBeTruthy(); + }); + + it("every entry has a non-empty display name and a family in {L1, L2, testnet}", () => { + for (const [slug, entry] of Object.entries(ALCHEMY_NETWORK_CATALOG)) { + expect(entry.name.length, slug).toBeGreaterThan(0); + expect(["L1", "L2", "testnet"], slug).toContain(entry.family); + } + }); + + it("every entry has a logo URL pointing at Alchemy's emblem CDN", () => { + for (const [slug, entry] of Object.entries(ALCHEMY_NETWORK_CATALOG)) { + expect(entry.logo.length, slug).toBeGreaterThan(0); + expect(entry.logo, slug).toMatch( + /^https:\/\/static\.alchemyapi\.io\/images\/emblems\/.+\.svg$/, + ); + } + }); + + it("every mainnet entry has a chainId (testnets may share with their mainnet)", () => { + for (const [slug, entry] of Object.entries(ALCHEMY_NETWORK_CATALOG)) { + expect(entry.chainId, slug).toBeGreaterThan(0); + } + }); + + it("includes the 20 EVM mainnet networks with real users", () => { + // The catalog is hand-curated: the 8 L1 + 12 L2 chains that actually + // have meaningful user activity. Obscure / specialty chains are + // dropped to stay under the Portfolio API's 1-20 networks per request + // limit. A regression here means someone added or removed a chain + // without updating the request-shape test in portfolio.test.ts. + const mainnet = Object.values(ALCHEMY_NETWORK_CATALOG).filter((e) => e.family !== "testnet"); + expect(mainnet.length).toBe(20); + expect(mainnet.map((e) => e.slug)).toEqual( + expect.arrayContaining([ + // L1 + "eth-mainnet", + "polygon-mainnet", + "bnb-mainnet", + "avax-mainnet", + "gnosis-mainnet", + "berachain-mainnet", + "monad-mainnet", + "ronin-mainnet", + // L2 + "arb-mainnet", + "opt-mainnet", + "base-mainnet", + "linea-mainnet", + "scroll-mainnet", + "zksync-mainnet", + "worldchain-mainnet", + "unichain-mainnet", + "blast-mainnet", + "celo-mainnet", + "apechain-mainnet", + "soneium-mainnet", + ]), + ); + }); +}); + +describe("parseNetworkList", () => { + it("parses a comma-separated string and trims whitespace", () => { + expect(parseNetworkList("eth-mainnet, polygon-mainnet , arb-mainnet")).toEqual([ + "eth-mainnet", + "polygon-mainnet", + "arb-mainnet", + ]); + }); + + it("drops empty entries", () => { + expect(parseNetworkList("eth-mainnet,,polygon-mainnet,")).toEqual([ + "eth-mainnet", + "polygon-mainnet", + ]); + }); + + it("returns [] for empty / unset input", () => { + expect(parseNetworkList("")).toEqual([]); + expect(parseNetworkList(" ")).toEqual([]); + }); +}); + +describe("groupNetworks", () => { + it("groups slugs into L1 / L2 / testnet buckets in that order", () => { + const groups = groupNetworks(["eth-mainnet", "arb-mainnet", "eth-sepolia"]); + expect(groups.map((g) => g.family)).toEqual(["L1", "L2", "testnet"]); + expect(groups[0].networks.map((n) => n.slug)).toEqual(["eth-mainnet"]); + expect(groups[1].networks.map((n) => n.slug)).toEqual(["arb-mainnet"]); + expect(groups[2].networks.map((n) => n.slug)).toEqual(["eth-sepolia"]); + }); + + it("skips slugs that are not in the catalog (typos won't break the UI)", () => { + const groups = groupNetworks(["eth-mainnet", "totally-fake-net"]); + expect(groups.flatMap((g) => g.networks.map((n) => n.slug))).toEqual(["eth-mainnet"]); + }); + + it("omits a family bucket when no slugs in that family are present", () => { + const groups = groupNetworks(["eth-mainnet", "polygon-mainnet"]); + expect(groups.map((g) => g.family)).toEqual(["L1"]); + }); + + it("preserves the order from the input list within a family", () => { + const groups = groupNetworks(["polygon-mainnet", "eth-mainnet"]); + expect(groups[0].networks.map((n: { slug: string }) => n.slug)).toEqual([ + "polygon-mainnet", + "eth-mainnet", + ]); + }); +}); + +describe("AlchemyNetworkSlug (compile-time)", () => { + it("is the union of catalog keys", () => { + const slug: AlchemyNetworkSlug = "eth-mainnet"; + expect(slug).toBe("eth-mainnet"); + }); +}); + +describe("resolveAllowlist", () => { + it("returns every catalog slug when disabled is empty", () => { + const allow = resolveAllowlist([]); + expect(allow.length).toBe(Object.keys(ALCHEMY_NETWORK_CATALOG).length); + expect(allow).toContain("eth-mainnet"); + expect(allow).toContain("base-sepolia"); + }); + + it("removes the disabled slugs from the catalog", () => { + const allow = resolveAllowlist(["eth-sepolia", "base-sepolia"]); + expect(allow).not.toContain("eth-sepolia"); + expect(allow).not.toContain("base-sepolia"); + expect(allow).toContain("eth-mainnet"); + }); + + it("ignores slugs in disabled that are not in the catalog (typos)", () => { + const fullCount = Object.keys(ALCHEMY_NETWORK_CATALOG).length; + const allow = resolveAllowlist(["totally-fake-net"]); + expect(allow.length).toBe(fullCount); + }); + + it("returns [] only if the catalog is empty (impossible today, defensive)", () => { + // Sanity: an all-disabled list is allowed — proxy will simply reject everything. + const all = Object.keys(ALCHEMY_NETWORK_CATALOG); + expect(resolveAllowlist(all).length).toBe(0); + }); +}); + +describe("getNetworkLogoByChainId", () => { + it("returns the Alchemy emblem URL for a known chain", () => { + expect(getNetworkLogoByChainId(1)).toBe( + "https://static.alchemyapi.io/images/emblems/eth-mainnet.svg", + ); + expect(getNetworkLogoByChainId(8453)).toBe( + "https://static.alchemyapi.io/images/emblems/base-mainnet.svg", + ); + }); + + it("uses the matic-mainnet alias for polygon (chain was renamed in 2024)", () => { + expect(getNetworkLogoByChainId(137)).toBe( + "https://static.alchemyapi.io/images/emblems/matic-mainnet.svg", + ); + }); + + it("falls back to the mainnet emblem for a testnet chainId", () => { + // Sepolia emblems are not published; we point at the mainnet + // counterpart so testnet renderers don't have to special-case it. + expect(getNetworkLogoByChainId(11155111)).toBe( + "https://static.alchemyapi.io/images/emblems/eth-mainnet.svg", + ); + }); + + it("returns null for a chainId outside the catalog", () => { + expect(getNetworkLogoByChainId(999999)).toBeNull(); + }); + + it("returns null for null / undefined input", () => { + expect(getNetworkLogoByChainId(null)).toBeNull(); + expect(getNetworkLogoByChainId(undefined)).toBeNull(); + }); +}); diff --git a/tests/lib/alchemy/portfolio.test.ts b/tests/lib/alchemy/portfolio.test.ts new file mode 100644 index 0000000..00b2491 --- /dev/null +++ b/tests/lib/alchemy/portfolio.test.ts @@ -0,0 +1,658 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { + fetchEnrichedBalances, + networkToChainId, + type EnrichedToken, +} from "@/lib/alchemy/portfolio"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("networkToChainId", () => { + it("maps the three CoW chains", () => { + expect(networkToChainId("eth-mainnet")).toBe(1); + expect(networkToChainId("arb-mainnet")).toBe(42161); + expect(networkToChainId("base-mainnet")).toBe(8453); + }); + + it("maps the rest of the original L1 + L2 catalog", () => { + expect(networkToChainId("polygon-mainnet")).toBe(137); + expect(networkToChainId("opt-mainnet")).toBe(10); + expect(networkToChainId("bnb-mainnet")).toBe(56); + }); + + it("maps the newly added EVM L1 + L2 chains", () => { + expect(networkToChainId("gnosis-mainnet")).toBe(100); + expect(networkToChainId("unichain-mainnet")).toBe(130); + expect(networkToChainId("berachain-mainnet")).toBe(80094); + expect(networkToChainId("blast-mainnet")).toBe(81457); + expect(networkToChainId("monad-mainnet")).toBe(143); + }); + + it("returns null for slugs outside the catalog", () => { + expect(networkToChainId("optimism")).toBeNull(); + expect(networkToChainId("not-a-real-network")).toBeNull(); + }); +}); + +describe("fetchEnrichedBalances — happy path", () => { + it("posts to /api/alchemy/portfolio/tokens/by-address with the full catalog (no testnets)", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ data: { tokens: [] } }), { status: 200 }), + ); + await fetchEnrichedBalances("0xabc"); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/alchemy/portfolio/tokens/by-address"); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body); + expect(body.addresses[0].address).toBe("0xabc"); + const sent = body.addresses[0].networks; + // Every L1 + L2 chain in the catalog — no testnets. The catalog + // currently lists 20 mainnet EVM chains (8 L1 + 12 L2); pinned at + // the Portfolio API's 1-20 per-request limit. If you add or remove + // a chain, this test pins the expected request shape. + expect(sent.length).toBe(20); + expect(sent).toEqual( + expect.arrayContaining([ + // L1 + "eth-mainnet", + "polygon-mainnet", + "bnb-mainnet", + "avax-mainnet", + "gnosis-mainnet", + "berachain-mainnet", + "monad-mainnet", + "ronin-mainnet", + // L2 + "arb-mainnet", + "opt-mainnet", + "base-mainnet", + "linea-mainnet", + "scroll-mainnet", + "zksync-mainnet", + "worldchain-mainnet", + "unichain-mainnet", + "blast-mainnet", + "celo-mainnet", + "apechain-mainnet", + "soneium-mainnet", + ]), + ); + expect(sent).not.toEqual( + expect.arrayContaining([ + "eth-sepolia", + "arb-sepolia", + "opt-sepolia", + "polygon-amoy", + "base-sepolia", + ]), + ); + expect(body.withMetadata).toBe(true); + expect(body.withPrices).toBe(true); + expect(body.includeNativeTokens).toBe(true); + expect(body.includeErc20Tokens).toBe(true); + }); + + it("normalizes ERC20 tokens with metadata + USD price", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-mainnet", + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + tokenBalance: "0x5f5e100", + tokenMetadata: { + symbol: "USDC", + decimals: 6, + name: "USD Coin", + logo: "https://example.com/usdc.png", + }, + tokenPrices: [{ currency: "usd", value: "1.0" }], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ + chainId: 1, + network: "eth-mainnet", + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + symbol: "USDC", + decimals: 6, + name: "USD Coin", + logo: "https://example.com/usdc.png", + priceUsd: 1.0, + isNative: false, + }); + expect(out[0].tokenBalance).toBe("0x5f5e100"); + }); + + it("normalizes native tokens (address=null, isNative=true)", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "arb-mainnet", + tokenAddress: null, + tokenBalance: "0x16345785d8a0000", + tokenMetadata: { + symbol: "ETH", + decimals: 18, + name: "Ether", + logo: "https://example.com/eth.png", + }, + tokenPrices: [{ currency: "usd", value: "3100.50" }], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out: EnrichedToken[] = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(1); + expect(out[0].chainId).toBe(42161); + expect(out[0].address).toBeNull(); + expect(out[0].isNative).toBe(true); + expect(out[0].symbol).toBe("ETH"); + expect(out[0].priceUsd).toBe(3100.5); + }); + + it("backfills native-token metadata from the catalog when Alchemy returns null (real API behavior)", async () => { + // Real Portfolio API: native entries arrive with tokenAddress=null + // and tokenMetadata = { symbol: null, decimals: null, name: null, + // logo: null } on every chain we tested (Base, ETH-mainnet, …). + // We must still render the native balance — the catalog carries the + // fallback (ETH/18 for every L2 + most L1s; MATIC/18 on Polygon, etc.). + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "base-mainnet", + tokenAddress: null, + tokenBalance: "0x7a4d4722fcde", + tokenMetadata: { + symbol: null, + decimals: null, + name: null, + logo: null, + }, + tokenPrices: [{ currency: "usd", value: "1580.91" }], + }, + { + network: "polygon-mainnet", + tokenAddress: null, + tokenBalance: "0x16345785d8a0000", + tokenMetadata: { + symbol: null, + decimals: null, + name: null, + logo: null, + }, + tokenPrices: [{ currency: "usd", value: "0.5" }], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(2); + const base = out.find((t) => t.chainId === 8453); + expect(base).toMatchObject({ + network: "base-mainnet", + address: null, + isNative: true, + symbol: "ETH", + decimals: 18, + name: "Ether", + }); + const polygon = out.find((t) => t.chainId === 137); + expect(polygon).toMatchObject({ + network: "polygon-mainnet", + isNative: true, + symbol: "MATIC", + decimals: 18, + name: "Polygon", + }); + }); + + it("parses decimals delivered as a string", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "base-mainnet", + tokenAddress: "0x4200000000000000000000000000000000000006", + tokenBalance: "0x1", + tokenMetadata: { symbol: "WETH", decimals: "18" }, + tokenPrices: [], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out[0].decimals).toBe(18); + }); + + it("parses priceUsd delivered as a number", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-mainnet", + tokenAddress: "0xtoken", + tokenBalance: "0x1", + tokenMetadata: { symbol: "X", decimals: 18 }, + tokenPrices: [{ currency: "usd", value: 0.0001 }], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out[0].priceUsd).toBe(0.0001); + }); +}); + +describe("fetchEnrichedBalances — logo fallback", () => { + it("falls back to the chain emblem when Alchemy ships logo=null for a native token", async () => { + // Real API behavior: native entries come with logo:null. The card + // would render a letter avatar; we instead reuse the chain emblem + // (Ethereum diamond, polygon hexagon, etc.) so the row matches the + // gas token's actual iconography. + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "base-mainnet", + tokenAddress: null, + tokenBalance: "0x1bc16d674ec80000", + tokenMetadata: { symbol: null, decimals: null, name: null, logo: null }, + tokenPrices: [{ currency: "usd", value: "3000" }], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out[0].logo).toBe("https://static.alchemyapi.io/images/emblems/base-mainnet.svg"); + }); + + it("falls back to CoinCap's symbol icon when Alchemy ships logo=null for an ERC20", async () => { + // The long tail of obscure tokens (toby, EBASE, …) gets logo:null + // from Alchemy. CoinCap covers the majors by symbol and the card's + // onError handler hides anything CoinCap 404s on. + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-mainnet", + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + tokenBalance: "0x5f5e100", + tokenMetadata: { symbol: "USDC", decimals: 6, name: "USD Coin", logo: null }, + tokenPrices: [], + }, + { + network: "base-mainnet", + tokenAddress: "0xebase", + tokenBalance: "0x1", + tokenMetadata: { symbol: "EBASE", decimals: 18, logo: null }, + tokenPrices: [], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out.find((t) => t.symbol === "USDC")?.logo).toBe( + "https://assets.coincap.io/assets/icons/usdc@2x.png", + ); + expect(out.find((t) => t.symbol === "EBASE")?.logo).toBe( + "https://assets.coincap.io/assets/icons/ebase@2x.png", + ); + }); + + it("preserves Alchemy's logo when it ships a real URL (no fallback needed)", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-mainnet", + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + tokenBalance: "0x5f5e100", + tokenMetadata: { + symbol: "USDC", + decimals: 6, + name: "USD Coin", + logo: "https://example.com/alchemy-usdc.png", + }, + tokenPrices: [], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out[0].logo).toBe("https://example.com/alchemy-usdc.png"); + }); +}); + +describe("fetchEnrichedBalances — defensive", () => { + it("drops tokens from networks outside the catalog (e.g. 'optimism' typo)", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "optimism", // not a slug in the catalog + tokenAddress: "0xmatic", + tokenBalance: "0x1", + tokenMetadata: { symbol: "OP", decimals: 18 }, + tokenPrices: [], + }, + { + network: "eth-mainnet", + tokenAddress: "0xusdc", + tokenBalance: "0x1", + tokenMetadata: { symbol: "USDC", decimals: 6 }, + tokenPrices: [], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(1); + expect(out[0].symbol).toBe("USDC"); + }); + + it("drops testnet tokens", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-sepolia", + tokenAddress: "0xfaucet", + tokenBalance: "0x1", + tokenMetadata: { symbol: "SEP", decimals: 18 }, + tokenPrices: [], + }, + { + network: "eth-mainnet", + tokenAddress: "0xusdc", + tokenBalance: "0x1", + tokenMetadata: { symbol: "USDC", decimals: 6 }, + tokenPrices: [], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(1); + expect(out[0].symbol).toBe("USDC"); + }); + + it("drops tokens missing symbol or decimals", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-mainnet", + tokenAddress: "0xa", + tokenBalance: "0x1", + tokenMetadata: { decimals: 6 }, + }, + { + network: "eth-mainnet", + tokenAddress: "0xb", + tokenBalance: "0x1", + tokenMetadata: { symbol: "X" }, + }, + { + network: "eth-mainnet", + tokenAddress: "0xc", + tokenBalance: "0x1", + tokenMetadata: { symbol: "OK", decimals: 18 }, + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(1); + expect(out[0].symbol).toBe("OK"); + }); + + it("treats missing tokenPrices as null priceUsd", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-mainnet", + tokenAddress: "0xabc", + tokenBalance: "0x1", + tokenMetadata: { symbol: "OBSCURE", decimals: 18 }, + tokenPrices: [], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out[0].priceUsd).toBeNull(); + }); + + it("drops ERC20 entries with zero balance (dust / historical interactions)", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-mainnet", + tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + tokenBalance: "0x0", + tokenMetadata: { symbol: "USDC", decimals: 6 }, + tokenPrices: [{ currency: "usd", value: "1.0" }], + }, + { + network: "arb-mainnet", + tokenAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + tokenBalance: "0x0000000000000000000000000000000000000000000000000000000000000000", + tokenMetadata: { symbol: "WETH", decimals: 18 }, + tokenPrices: [{ currency: "usd", value: "3000.0" }], + }, + { + network: "eth-mainnet", + tokenAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + tokenBalance: "0x1", + tokenMetadata: { symbol: "WETH", decimals: 18 }, + tokenPrices: [{ currency: "usd", value: "3000.0" }], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(1); + expect(out[0].symbol).toBe("WETH"); + expect(out[0].tokenBalance).toBe("0x1"); + }); + + it("drops tokens with decimals=0 (airdrop / claim-bait pattern)", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "base-mainnet", + tokenAddress: "0xbrett", + tokenBalance: "0x42ad4", + tokenMetadata: { + symbol: "Airdrop on: brettbased.com", + decimals: 0, + }, + tokenPrices: [], + }, + { + network: "base-mainnet", + tokenAddress: "0xusdc", + tokenBalance: "0x1", + tokenMetadata: { symbol: "USDC", decimals: 6 }, + tokenPrices: [], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(1); + expect(out[0].symbol).toBe("USDC"); + }); + + it("drops tokens whose symbol or name is a 'Claim on: ' phishing pattern", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "arb-mainnet", + tokenAddress: "0xaero", + tokenBalance: "0x1", + tokenMetadata: { + symbol: "AERO", + name: "Claim on: rewards.aerodrome-network.com", + decimals: 18, + }, + tokenPrices: [], + }, + { + network: "base-mainnet", + tokenAddress: "0xbloo", + tokenBalance: "0x1", + tokenMetadata: { + symbol: "Claim on: bloo-foster.com/?claim", + decimals: 18, + }, + tokenPrices: [], + }, + { + network: "base-mainnet", + tokenAddress: "0xok", + tokenBalance: "0x1", + tokenMetadata: { symbol: "OK", name: "Some Real Token", decimals: 18 }, + tokenPrices: [], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(1); + expect(out[0].symbol).toBe("OK"); + }); + + it("ignores non-USD price entries", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-mainnet", + tokenAddress: "0xabc", + tokenBalance: "0x1", + tokenMetadata: { symbol: "X", decimals: 18 }, + tokenPrices: [ + { currency: "eur", value: "0.9" }, + { currency: "usd", value: "1.0" }, + ], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out[0].priceUsd).toBe(1.0); + }); + + it("returns an empty array when data.tokens is missing", async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 })); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toEqual([]); + }); + + it("throws on non-200 responses with the status code", async () => { + fetchMock.mockResolvedValueOnce(new Response("rate limited", { status: 429 })); + await expect(fetchEnrichedBalances("0xabc")).rejects.toThrow(/portfolio 429/); + }); +}); From a8a312157069a144a71f33ee3a44a1c73b0d9f02 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 13:24:12 +0800 Subject: [PATCH 08/35] chore: drop unused CoW / Alchemy scaffolding The previous self-custody DEX swap (CoW + Alchemy) is replaced by the new atomic-tool split in the next commit. --- app/alchemy/page.tsx | 196 --- backend/tool/crypto/confirm-crypto-order.ts | 107 -- components/tool-ui/crypto/confirm-card.tsx | 1460 ----------------- lib/swap/cow-config.ts | 58 - lib/tokens/catalog.ts | 128 -- patches/cuer@0.0.3.patch | 13 - pnpm-workspace.yaml | 1 - .../tool/crypto/confirm-crypto-order.test.ts | 81 - .../crypto/crypto-confirm-card.test.tsx | 500 ------ 9 files changed, 2544 deletions(-) delete mode 100644 app/alchemy/page.tsx delete mode 100644 backend/tool/crypto/confirm-crypto-order.ts delete mode 100644 components/tool-ui/crypto/confirm-card.tsx delete mode 100644 lib/swap/cow-config.ts delete mode 100644 lib/tokens/catalog.ts delete mode 100644 patches/cuer@0.0.3.patch delete mode 100644 tests/backend/tool/crypto/confirm-crypto-order.test.ts delete mode 100644 tests/frontend/crypto/crypto-confirm-card.test.tsx diff --git a/app/alchemy/page.tsx b/app/alchemy/page.tsx deleted file mode 100644 index dbe1f02..0000000 --- a/app/alchemy/page.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { CoinsIcon, Loader2Icon, ZapIcon } from "lucide-react"; - -import { - ALCHEMY_NETWORK_CATALOG, - groupNetworks, - type AlchemyNetworkEntry, -} from "@/lib/alchemy/networks"; -import { cn } from "@/lib/utils"; - -type StatusState = - | { kind: "loading" } - | { kind: "configured" } - | { kind: "unconfigured" } - | { kind: "error"; message: string }; - -export default function AlchemyPage() { - // The catalog is the source of truth for which networks the proxy - // accepts. The optional `ALCHEMY_DISABLED_NETWORKS` denylist is read - // server-side by the proxy; the client always shows the full catalog - // so users can see what was turned off (a disabled network's Test - // button returns 400). - const groups = groupNetworks(Object.keys(ALCHEMY_NETWORK_CATALOG)); - const [status, setStatus] = useState({ kind: "loading" }); - - useEffect(() => { - let cancelled = false; - fetch("/api/alchemy/status") - .then((r) => r.json()) - .then((body: { configured?: boolean }) => { - if (cancelled) return; - setStatus(body.configured ? { kind: "configured" } : { kind: "unconfigured" }); - }) - .catch((e: unknown) => { - if (cancelled) return; - setStatus({ kind: "error", message: e instanceof Error ? e.message : String(e) }); - }); - return () => { - cancelled = true; - }; - }, []); - - return ( -
    -
    -

    Alchemy RPC

    -

    - The Next.js app proxies JSON-RPC requests to Alchemy so the API key never reaches the - browser. Pick a network below and hit Test to confirm the proxy round-trips. -

    - -
    - - {groups.length === 0 ? ( - - ) : ( -
    - {groups.map((g) => ( -
    -

    - {g.label} -

    -
    - {g.networks.map((n) => ( - - ))} -
    -
    - ))} -
    - )} -
    - ); -} - -function StatusBadge({ status }: { status: StatusState }) { - if (status.kind === "loading") { - return ( - - Checking API key… - - ); - } - if (status.kind === "error") { - return ( - - ⚠ {status.message} - - ); - } - if (status.kind === "unconfigured") { - return ( - - API key not set — Test calls will return 500 - - ); - } - return ( - - API key configured - - ); -} - -function NetworkCard({ - network, - keyStatus, -}: { - network: AlchemyNetworkEntry; - keyStatus: StatusState["kind"]; -}) { - const [state, setState] = useState<"idle" | "running" | "ok" | "err">("idle"); - const [block, setBlock] = useState(null); - const [err, setErr] = useState(null); - - const onTest = async () => { - setState("running"); - setErr(null); - setBlock(null); - try { - const res = await fetch(`/api/alchemy/${network.slug}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", method: "eth_blockNumber", params: [], id: 1 }), - }); - if (!res.ok) { - const text = await res.text(); - setErr(`HTTP ${res.status} — ${text.slice(0, 80)}`); - setState("err"); - return; - } - const body = (await res.json()) as { result?: string; error?: { message: string } }; - if (body.error) { - setErr(body.error.message); - setState("err"); - return; - } - setBlock(body.result ?? null); - setState("ok"); - } catch (e: unknown) { - setErr(e instanceof Error ? e.message : String(e)); - setState("err"); - } - }; - - return ( -
    -
    -
    -

    {network.name}

    -

    {network.slug}

    -
    - -
    - {state === "ok" && block ? ( -

    - block: {block} ({parseInt(block, 16).toLocaleString()}) -

    - ) : null} - {state === "err" && err ? ( -

    {err}

    - ) : null} -
    - ); -} - -function EmptyState() { - return ( -
    -

    - NEXT_PUBLIC_ALCHEMY_NETWORKS is empty. Set it in{" "} - .env.local as a comma-separated list of Alchemy network - slugs. -

    -
    - ); -} diff --git a/backend/tool/crypto/confirm-crypto-order.ts b/backend/tool/crypto/confirm-crypto-order.ts deleted file mode 100644 index 4b37b4c..0000000 --- a/backend/tool/crypto/confirm-crypto-order.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { tool } from "@langchain/core/tools"; -import { z } from "zod"; - -import { resolveToken } from "@/lib/tokens/catalog"; - -// The LLM's only job in the trade flow is to parse the user's intent. -// Everything wallet-related (balances, chain, address) is read by the -// frontend card from wagmi. Everything price-related (quote, fees) is -// fetched client-side from CoW. The tool is a pure pause: -// -// 1. LLM emits `confirm_swap(side, source?, amount?, target?)`. -// 2. Tool returns `{status:"awaiting_user", intent:{...}}`. The card -// renders with the user's actual wallet balances + a live CoW -// quote. -// 3. User clicks Sign → card signs EIP-712 + POSTs to CoW + calls -// addResult({status:"signed" | "simulated_filled" | "cancelled" | -// "error", ...}). The LLM then writes the closing sentence. -// -// Why merge the old `ask_crypto_intent` + `confirm_crypto_order`? -// Previously the user had to fill a buy/sell form, click Confirm, see -// the quote card, then click Sign. Two clicks, two cards, and the -// first form was useless the moment the LLM already knew side + -// source from the message. The wallet-aware card subsumes both: it -// shows real balances, picks a sensible target, fetches the live -// quote, and exposes one Sign button. - -export type SwapIntent = { - side: "buy" | "sell"; - source_coin_id: string | null; - amount: number | null; - target_coin_id: string | null; -}; - -const COIN_ID_RE = /^[a-z0-9-]+$/; - -export const confirmCryptoOrderTool = tool( - async ({ side, source_coin_id, amount, target_coin_id }) => { - // Validate that the named tokens exist on SOME supported chain. - // The frontend card resolves chain from wagmi; we can't validate - // chain availability here without that info, but we can catch - // obviously bogus CoinGecko ids early. - if (source_coin_id && !COIN_ID_RE.test(source_coin_id)) { - return JSON.stringify({ - status: "error", - error: `source_coin_id '${source_coin_id}' is not a valid CoinGecko id`, - }); - } - if (target_coin_id && !COIN_ID_RE.test(target_coin_id)) { - return JSON.stringify({ - status: "error", - error: `target_coin_id '${target_coin_id}' is not a valid CoinGecko id`, - }); - } - // Light chain sanity check — every supported chain has at least one - // token in the catalog. If neither source nor target is named, we - // still let the card proceed (it picks defaults from the wallet). - if (source_coin_id || target_coin_id) { - const probe = source_coin_id ?? target_coin_id!; - const okOnMainnet = resolveToken(probe, 1) != null; - if (!okOnMainnet) { - return JSON.stringify({ - status: "error", - error: `coin_id '${probe}' is not in the supported token catalog`, - }); - } - } - - const intent: SwapIntent = { - side, - source_coin_id: source_coin_id ?? null, - amount: amount ?? null, - target_coin_id: target_coin_id ?? null, - }; - return JSON.stringify({ status: "awaiting_user", intent }); - }, - { - name: "confirm_crypto_order", - description: - "Render a wallet-aware swap card and pause for the user to sign. The LLM parses the user's intent (side + optional source token / amount / target) and passes it here; the card wakes the wallet (RainbowKit modal if not connected), lists the user's actual balances from Alchemy, picks a sensible default target if none was named, fetches a live CoW quote, and exposes one Sign & Place Order button. The user must click before any state changes — the closing ToolMessage (signed / simulated_filled / cancelled / error) is what the model uses to write the final sentence. Pass source_coin_id (CoinGecko id) only when the user named a source token; pass amount only when the user named a number; pass target_coin_id only when the user named what they want to receive. Do NOT batch with any other tool.", - schema: z.object({ - side: z - .enum(["buy", "sell"]) - .describe( - "Trade direction inferred from the user's message. 'sell my X' / 'swap X for Y' → sell (you're spending X). 'buy Y with X' → buy (you're acquiring Y).", - ), - source_coin_id: z - .string() - .optional() - .describe( - "CoinGecko id of the source token the user named (e.g. 'usd-coin' for USDC, 'ethereum' for WETH, 'wrapped-bitcoin' for WBTC). Omit when the user didn't name one — the card picks from the user's wallet holdings.", - ), - amount: z - .number() - .positive() - .optional() - .describe( - "Human-readable amount the user named (e.g. 100 for 100 USDC, 0.1 for 0.1 WETH). Omit when the user didn't name a number — the card defaults to 'all of it' for sell, leaves blank for buy.", - ), - target_coin_id: z - .string() - .optional() - .describe( - "CoinGecko id of the target token the user wants to receive (e.g. 'ethereum' for WETH). Omit when the user didn't name a target — the card picks a sensible default (WETH for stablecoin sellers, USDC for ETH/WBTC sellers).", - ), - }), - }, -); diff --git a/components/tool-ui/crypto/confirm-card.tsx b/components/tool-ui/crypto/confirm-card.tsx deleted file mode 100644 index 108c4a1..0000000 --- a/components/tool-ui/crypto/confirm-card.tsx +++ /dev/null @@ -1,1460 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { - AlertCircleIcon, - ArrowDownIcon, - CheckCircle2Icon, - CoinsIcon, - ExternalLinkIcon, - Loader2Icon, - WalletIcon, - XCircleIcon, -} from "lucide-react"; -import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; -import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; -import { useAccount, useSignTypedData, useSwitchChain } from "wagmi"; -import { useConnectModal } from "@rainbow-me/rainbowkit"; -import { formatUnits, type Address, type Hex } from "viem"; - -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { formatAmount, formatQty, parseAmount } from "@/lib/decimal"; -import { - COW_EIP712_DOMAIN, - COW_EIP712_TYPES, - getCowConfig, - type CowChainId, -} from "@/lib/swap/cow-config"; -import { - defaultTargetSlug, - resolveToken, - tokensForChain, - type TokenMeta, - type TokenSlug, -} from "@/lib/tokens/catalog"; -import { fetchEnrichedBalances } from "@/lib/alchemy/portfolio"; -import { getNetworkLogoByChainId } from "@/lib/alchemy/networks"; -import { unwrapToolResult } from "@/components/tool-ui/tool-result"; - -// Wallet-aware swap card. The LLM parses the user's intent (side + -// optional source / amount / target); everything else — wallet -// connection, balance enumeration, CoW quote fetching, EIP-712 -// signing, /orders submission — happens in the card. -// -// Flow: -// awaiting_user → fetch wagmi state -// → enumerate Alchemy balances -// → resolve intent into source/amount/target -// → fetch CoW quote (debounced) -// → render preview -// user clicks Sign → EIP-712 + POST /orders → addResult("signed") -// or addResult("simulated_filled") for simulate mode -// user clicks Cancel → addResult("cancelled") -// sign/POST fails → addResult("error") - -type Intent = { - side: "buy" | "sell"; - source_coin_id: string | null; - amount: number | null; - target_coin_id: string | null; -}; - -type Args = { - side: "buy" | "sell"; - source_coin_id?: string; - amount?: number; - target_coin_id?: string; -}; - -type CowQuote = { - sellToken: Address; - buyToken: Address; - sellAmount: string; - buyAmount: string; - validTo: number; - feeAmount: string; - kind: "sell" | "buy"; - [k: string]: unknown; -}; - -type SimulatedOrder = { - id: string; - coin: string; - symbol: string; - side: "buy" | "sell"; - amount_human: number; - qty: number; - status: string; - timestamp: string; - note: string; - slippage_bps: number; -}; - -type Result = - | { status: "awaiting_user"; intent: Intent } - | { status: "simulated_filled"; order: SimulatedOrder } - | { - status: "signed"; - chain_id: CowChainId; - order_uid: `0x${string}`; - tx_hash?: `0x${string}`; - order?: { id: string; symbol: string; qty: number }; - } - | { status: "cancelled" } - | { status: "error"; error: string }; - -// Wallet token derived from the Portfolio API response. The address is -// null for native ETH; the slug/coinId columns are looked up against -// our internal catalog so the LLM's coin_id hints and the target-token -// dropdown still resolve cleanly. logo + priceUsd come straight from -// Portfolio and feed the row UI. -type WalletToken = { - address: Address | null; - symbol: string; - decimals: number; - balance: string; // human string, bigint-safe - coinId: string | null; - slug: TokenSlug | null; - logo: string | null; - priceUsd: number | null; - isNative: boolean; -}; - -// A token the user can spend, paired with the chain it's on. The -// single Portfolio call returns tokens across all chains tagged with -// their source chain, so the UI can group them and the quote + sign -// calls know which chain to use (CoW requires same-chain settlement). -type ChainBalance = { - chainId: CowChainId; - token: WalletToken; -}; - -// Slug ↔ CoinGecko id mapping, used to match a Portfolio-listed token -// to the catalog so we can resolve a contract address to the LLM's -// coin_id hint. We do the inverse lookup by symbol because Portfolio -// doesn't return the CoinGecko id. -function coinIdForSymbol(symbol: string): string | null { - const known: Record = { - USDC: "usd-coin", - USDT: "tether", - WETH: "ethereum", - ETH: "ethereum", - WBTC: "wrapped-bitcoin", - BTC: "bitcoin", - }; - return known[symbol.toUpperCase()] ?? null; -} - -// Reverse lookup: given the LLM's coin_id, return a human symbol for -// display ("usd-coin" → "USDC"). Used by the "you don't have X" banner -// so the user sees the token they asked about, not the raw slug. -const COIN_ID_TO_SYMBOL: Record = { - "usd-coin": "USDC", - tether: "USDT", - ethereum: "WETH", - "wrapped-bitcoin": "WBTC", - bitcoin: "BTC", -}; - -function explorerUrl(chainId: number, txHash: string): string { - switch (chainId) { - case 1: - return `https://etherscan.io/tx/${txHash}`; - case 42161: - return `https://arbiscan.io/tx/${txHash}`; - case 8453: - return `https://basescan.org/tx/${txHash}`; - default: - return `https://etherscan.io/tx/${txHash}`; - } -} - -function cowOrderUrl(orderUid: string): string { - return `https://explorer.cow.fi/orders/${orderUid}?tab=overview`; -} - -function shortUid(uid: string | undefined): string { - if (!uid || uid.length <= 18) return uid ?? "(missing)"; - return `${uid.slice(0, 10)}…${uid.slice(-6)}`; -} - -function cryptoRandomId(): string { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) { - return (crypto as Crypto).randomUUID(); - } - return Math.random().toString(36).slice(2); -} - -function parseResult(raw: unknown): Result | { kind: "loading" } { - const obj = unwrapToolResult(raw); - if (!obj) return { kind: "loading" }; - return obj; -} - -// --- Balance helpers -------------------------------------------------------- - -function slugForSymbol(symbol: string): TokenSlug | null { - const known: Record = { - USDC: "usdc", - USDT: "usdt", - WETH: "weth", - WBTC: "wbtc", - }; - return known[symbol.toUpperCase()] ?? null; -} - -// Convert a hex balance string (e.g. "0x5f5e100") to a human-readable -// decimal string ("100") at the given decimals. BigInt path so we don't -// lose precision on 18-decimal wei numbers. -function formatBalanceHex(hex: string, decimals: number): string { - let value: bigint; - try { - value = BigInt(hex); - } catch { - return "0"; - } - if (value === BigInt(0)) return "0"; - const denom = BigInt(10) ** BigInt(decimals); - const whole = value / denom; - const frac = value % denom; - const fracStr = frac.toString().padStart(decimals, "0").replace(/0+$/, ""); - return fracStr ? `${whole.toString()}.${fracStr}` : whole.toString(); -} - -function usdValue(token: WalletToken): number | null { - if (token.priceUsd == null) return null; - const num = Number(token.balance); - if (!Number.isFinite(num)) return null; - return num * token.priceUsd; -} - -function formatUsd(n: number): string { - if (n >= 1000) return `≈ $${n.toLocaleString("en-US", { maximumFractionDigits: 0 })}`; - if (n >= 1) return `≈ $${n.toFixed(2)}`; - return `≈ $${n.toPrecision(2)}`; -} - -// Token logo with graceful fallback: if the Portfolio-provided URL 404s -// (some tokens have stale logos), fall back to the symbol's first -// letter in a tinted circle — keeps the row layout stable. Every -// variant sits on the same `bg-muted` chip so a colored emblem -// (e.g. the blue Base square) doesn't look like a naked sticker. -function TokenLogo({ token }: { token: WalletToken }) { - const [broken, setBroken] = useState(false); - const letter = (token.symbol[0] ?? "?").toUpperCase(); - if (token.logo && !broken) { - return ( - // eslint-disable-next-line @next/next/no-img-element - setBroken(true)} - className="size-[18px] shrink-0 rounded-full bg-muted p-[2px]" - /> - ); - } - return ( - - {letter} - - ); -} - -function ChainLogo({ chainId }: { chainId: number }) { - const src = getNetworkLogoByChainId(chainId); - if (!src) return null; - return ( - // eslint-disable-next-line @next/next/no-img-element - - ); -} - -// --- CoW quote fetching ------------------------------------------------------ - -async function fetchCowQuote( - chainId: CowChainId, - sellToken: Address, - buyToken: Address, - amountRaw: string, - kind: "sell" | "buy", - from: Address, - slippageBps: number, - signal: AbortSignal, -): Promise<{ quote: CowQuote } | { error: string }> { - const config = getCowConfig(chainId); - if (!config) return { error: `chain ${chainId} not supported` }; - const body: Record = { - sellToken, - buyToken, - from, - kind, - slippageBps, - }; - if (kind === "sell") body.sellAmountBeforeFee = amountRaw; - else body.buyAmountAfterFee = amountRaw; - try { - const res = await fetch(`${config.apiUrl}/quote`, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify(body), - signal, - }); - if (!res.ok) { - const errBody = (await res.json().catch(() => ({}))) as { - errorType?: string; - description?: string; - }; - return { - error: `cow ${res.status}${errBody.description ? `: ${errBody.description}` : ""}`, - }; - } - return { quote: (await res.json()) as CowQuote }; - } catch (e: unknown) { - if (e instanceof DOMException && e.name === "AbortError") { - return { error: "aborted" }; - } - return { error: e instanceof Error ? e.message : "quote fetch failed" }; - } -} - -// --- Top-level card --------------------------------------------------------- - -export const CryptoConfirmCard: ToolCallMessagePartComponent = ({ result, args }) => { - const parsed = parseResult(result); - const sendCommand = useLangGraphSendCommand(); - const { address, isConnected, chainId: wagmiChainId } = useAccount(); - const { openConnectModal } = useConnectModal(); - - if ("kind" in parsed) { - return ( -
    - - Preparing swap… -
    - ); - } - - if (parsed.status === "error") { - return ( -
    - - Order failed: {parsed.error} -
    - ); - } - - if (parsed.status === "cancelled") { - return ( -
    - Trade cancelled — no funds moved. -
    - ); - } - - if (parsed.status === "simulated_filled") { - return ; - } - - if (parsed.status === "signed") { - return ; - } - - // awaiting_user — preview phase. Defensive: a partial / missing intent - // payload (LLM schema drift, truncation) would otherwise crash the - // whole thread on first wagmi read. - if (parsed.status === "awaiting_user") { - if (!parsed.intent || !parsed.intent.side) { - return ( -
    - - Swap intent was missing — please try again. -
    - ); - } - return ( - sendCommand({ resume: JSON.stringify(payload) } as never)} - /> - ); - } - - return ( -
    - - Unknown order state. -
    - ); -}; - -// --- Wallet-aware preview ---------------------------------------------------- - -function AwaitingUserSwap({ - intent, - hintArgs, - isConnected, - address, - wagmiChainId, - onConnect, - onResolve, -}: { - intent: Intent; - hintArgs: Args | undefined; - isConnected: boolean; - address: Address | undefined; - wagmiChainId: number | undefined; - onConnect: (() => void) | undefined; - onResolve: (payload: unknown) => void; -}) { - // Resolve chain. wagmi tells us which chain the user is on; CoW only - // supports 1 / 42161 / 8453. If wagmi returns a chain we don't - // support (e.g. Polygon), surface a structured error. - const chainId = wagmiChainId; - const chainConfig = chainId != null ? getCowConfig(chainId) : null; - const chainUnsupported = wagmiChainId != null && chainConfig == null; - - // --- Wallet connection gate ----------------------------------------- - if (!isConnected) { - return ( -
    -
    -
    -
    - -
    -
    -

    Authorize wallet to read your address

    -

    - We need your wallet address to load balances. -

    -
    -
    - -
    -
    - ); - } - - if (chainUnsupported) { - return ( -
    -
    -
    - - - Your wallet is on chain {wagmiChainId}, which isn't supported for swaps. Switch to - Ethereum, Arbitrum, or Base and try again. - -
    -
    -
    - ); - } - - if (chainId == null || address == null) { - // wagmi is connected but hasn't returned the chain/address yet. - return ( -
    - - Loading wallet… -
    - ); - } - - return ( - - ); -} - -// --- Swap workspace (connected, valid chain) -------------------------------- - -function SwapWorkspace({ - intent, - hintArgs, - chainId, - address, - onResolve, -}: { - intent: Intent; - hintArgs: Args | undefined; - chainId: CowChainId; - address: Address; - onResolve: (payload: unknown) => void; -}) { - const [balances, setBalances] = useState(null); - const [balancesError, setBalancesError] = useState(null); - const [balancesLoading, setBalancesLoading] = useState(true); - - // Single Portfolio API call enumerates all 3 CoW chains and bundles - // balance + symbol + decimals + logo + USD price in one round-trip - // (replacing the older per-chain getBalance / getTokenBalances / - // getTokenMetadata trio). - useEffect(() => { - const ctrl = new AbortController(); - setBalancesLoading(true); - setBalancesError(null); - fetchEnrichedBalances(address, ctrl.signal) - .then((tokens) => { - // Portfolio API covers every L1 + L2 in the catalog; the swap - // card can only settle on CoW chains, so non-CoW rows are - // dropped here. The lib keeps them so other views (a future - // portfolio widget) can use them. - const flat: ChainBalance[] = tokens.flatMap((t) => { - const cfg = getCowConfig(t.chainId); - if (!cfg) return []; - return [ - { - chainId: t.chainId as CowChainId, - token: { - address: t.address, - symbol: t.symbol, - decimals: t.decimals, - balance: formatBalanceHex(t.tokenBalance, t.decimals), - coinId: coinIdForSymbol(t.symbol), - slug: slugForSymbol(t.symbol), - logo: t.logo, - priceUsd: t.priceUsd, - isNative: t.isNative, - }, - }, - ]; - }); - setBalances(flat); - setBalancesLoading(false); - }) - .catch((e: unknown) => { - if (e instanceof DOMException && e.name === "AbortError") return; - setBalancesError(e instanceof Error ? e.message : "balance fetch failed"); - setBalancesLoading(false); - }); - return () => ctrl.abort(); - }, [address]); - - if (balancesLoading) { - return ( - -
    - Loading your balances… -
    -
    - ); - } - - if (balancesError) { - return ( - -
    - - Couldn't load balances: {balancesError} -
    -
    - ); - } - - return ( - - ); -} - -function CardShell({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
    -
    -
    -
    - -
    -
    -

    {title}

    -
    -
    - {children} -
    -
    - ); -} - -// --- Swap form -------------------------------------------------------------- - -function SwapForm({ - intent, - hintArgs, - connectedChainId, - address, - balances, - onResolve, -}: { - intent: Intent; - hintArgs: Args | undefined; - connectedChainId: CowChainId; - address: Address; - balances: ChainBalance[]; - onResolve: (payload: unknown) => void; -}) { - // Available tokens on each chain (from catalog). Used by the target - // dropdown — only tokens the user can actually receive on the source's - // chain (CoW requires same-chain settlement). - // The dropdown is filtered to the source token's chain. - const { switchChainAsync, isPending: isSwitching } = useSwitchChain(); - const [sourceChainId, setSourceChainId] = useState(connectedChainId); - - // Available tokens on the source chain (target dropdown options). - const sourceChainTokens = useMemo(() => tokensForChain(sourceChainId), [sourceChainId]); - - // Group balances by chain (preserving chain order). Within each chain, - // native first, then by USD value desc — the user's gas token always - // surfaces at the top of its chain section regardless of balance. - const groupedByChain = useMemo(() => { - const order = [1, 42161, 8453] as CowChainId[]; - return order - .map((cid) => ({ - chainId: cid, - rows: balances - .filter((b) => b.chainId === cid) - .sort((a, b) => { - if (a.token.isNative !== b.token.isNative) return a.token.isNative ? -1 : 1; - const av = usdValue(a.token); - const bv = usdValue(b.token); - if (av != null && bv != null) return bv - av; - if (av != null) return -1; - if (bv != null) return 1; - return 0; - }), - })) - .filter((g) => g.rows.length > 0); - }, [balances]); - - // Resolve LLM hints against the user's balances + catalog. - const initialSource = useMemo(() => { - const hintId = intent.source_coin_id ?? hintArgs?.source_coin_id; - if (hintId) { - const match = balances.find((b) => b.token.coinId === hintId.toLowerCase()); - if (match) return match; - } - // No LLM hint → highest-USD-value balance, with a tie-breaker that - // prefers the connected chain so the user doesn't have to switch. - const ranked = [...balances].sort((a, b) => { - const av = usdValue(a.token) ?? -1; - const bv = usdValue(b.token) ?? -1; - if (av !== bv) return bv - av; - const sa = a.chainId === connectedChainId ? 1 : 0; - const sb = b.chainId === connectedChainId ? 1 : 0; - return sb - sa; - }); - return ranked[0] ?? null; - }, [intent.source_coin_id, hintArgs?.source_coin_id, balances, connectedChainId]); - - const initialTargetSlug = useMemo(() => { - const hintId = intent.target_coin_id ?? hintArgs?.target_coin_id; - if (hintId) { - const tok = sourceChainTokens.find((t) => t.coinId === hintId.toLowerCase()); - if (tok) return tok.slug; - } - return defaultTargetSlug(initialSource?.token.slug ?? null); - }, [intent.target_coin_id, hintArgs?.target_coin_id, sourceChainTokens, initialSource]); - - // The LLM named a specific source coin_id; if the wallet doesn't hold - // it, surface that instead of silently substituting something else. - // We resolve the coin_id to a human symbol via the catalog. - const sourceHint = intent.source_coin_id ?? hintArgs?.source_coin_id; - const hintSymbol = useMemo(() => { - if (!sourceHint) return null; - const sym = COIN_ID_TO_SYMBOL[sourceHint.toLowerCase()]; - if (sym) return sym; - return sourceHint; // unknown coin — show the raw id - }, [sourceHint]); - const hintMissing = sourceHint != null && initialSource == null; - - const [sourcePick, setSourcePick] = useState(initialSource); - const [targetSlug, setTargetSlug] = useState(initialTargetSlug); - const sourceToken = sourcePick?.token ?? null; - // Amount is empty unless the LLM explicitly named one — don't pre-fill - // with the source balance. The user types the number they want to - // trade; the "Max" link one click away fills it for them. - const initialAmount = intent.amount ?? hintArgs?.amount ?? null; - const [amount, setAmount] = useState( - initialAmount != null && Number.isFinite(initialAmount) ? String(initialAmount) : "", - ); - const [slippageBps, setSlippageBps] = useState(50); - const [mode, setMode] = useState<"simulated" | "real">("simulated"); - const [submitting, setSubmitting] = useState(false); - - // Re-derive defaults if balances change (chain switch, fresh fetch). - // Drop the source pick if its chain no longer has balances; also - // move sourceChainId if the connected chain changes. - useEffect(() => { - if (balances.length > 0 && !balances.find((b) => b.chainId === sourceChainId)) { - // Source chain has no balances — fall back to the first chain that does. - setSourceChainId(balances[0].chainId); - } - }, [balances, sourceChainId]); - - useEffect(() => { - if ( - sourcePick && - !balances.find( - (b) => b.chainId === sourcePick.chainId && b.token.address === sourcePick.token.address, - ) - ) { - setSourcePick(initialSource); - } else if (!sourcePick && initialSource) { - setSourcePick(initialSource); - } - }, [balances, sourcePick, initialSource]); - - // Switch sourceChainId when the user picks a source on a different chain. - useEffect(() => { - if (sourcePick && sourcePick.chainId !== sourceChainId) { - setSourceChainId(sourcePick.chainId); - } - }, [sourcePick, sourceChainId]); - - // Target must differ from source. Recompute when sourceChainId changes. - const targetToken = useMemo(() => { - const candidates = sourceChainTokens.filter((t) => t.slug !== sourceToken?.slug); - return candidates.find((t) => t.slug === targetSlug) ?? candidates[0] ?? null; - }, [sourceChainTokens, targetSlug, sourceToken?.slug]); - - // Quote state - const [quote, setQuote] = useState(null); - const [quoteError, setQuoteError] = useState(null); - const [quoteLoading, setQuoteLoading] = useState(false); - const quoteAbort = useRef(null); - - // Build source/buy token addresses from catalog (we know the slug, - // the chain, so we resolve to the canonical address). - const sourceResolved = sourceToken?.slug - ? resolveTokenForSlug(sourceToken.slug, sourceChainId) - : null; - const targetResolved = targetToken ? resolveTokenForSlug(targetToken.slug, sourceChainId) : null; - - // Amount must parse + be positive. - const amountDecimal = parseAmount(amount); - const amountValid = amountDecimal !== null && amountDecimal.greaterThan(0); - - // Refetch quote when source / amount / target / slippage changes. - // Debounce 400ms so typing doesn't fire on every keystroke. - useEffect(() => { - quoteAbort.current?.abort(); - if (!sourceResolved || !targetResolved || !amountValid) { - setQuote(null); - setQuoteError(null); - setQuoteLoading(false); - return; - } - const ctrl = new AbortController(); - quoteAbort.current = ctrl; - const kind: "sell" | "buy" = intent.side; // "sell source for target" maps to kind=sell - const amountRaw = formatAmountRaw(amountDecimal!, sourceResolved.decimals); - setQuoteLoading(true); - setQuoteError(null); - const t = setTimeout(() => { - fetchCowQuote( - sourceChainId, - sourceResolved.address, - targetResolved.address, - amountRaw, - kind, - address, - slippageBps, - ctrl.signal, - ) - .then((r) => { - if (ctrl.signal.aborted) return; - if ("error" in r) { - setQuoteError(r.error === "aborted" ? null : r.error); - setQuote(null); - } else { - setQuote(r.quote); - setQuoteError(null); - } - setQuoteLoading(false); - }) - .catch((e: unknown) => { - if (e instanceof DOMException && e.name === "AbortError") return; - setQuoteError(e instanceof Error ? e.message : "quote fetch failed"); - setQuoteLoading(false); - }); - }, 400); - return () => { - clearTimeout(t); - ctrl.abort(); - }; - }, [ - intent.side, - sourceChainId, - sourceResolved?.address, - sourceResolved?.decimals, - targetResolved?.address, - amountValid, - amount, - slippageBps, - address, - ]); - - const { signTypedDataAsync } = useSignTypedData(); - const isBusy = submitting; - // Selected source lives on a different chain than the wallet — - // Sign triggers switchChain first, then signs. - const chainMismatch = sourceChainId !== connectedChainId; - - const handleSign = async () => { - if ( - submitting || - !quote || - !sourceResolved || - !targetResolved || - !sourceToken || - !targetToken - ) { - return; - } - setSubmitting(true); - - const side = intent.side; - const amountHuman = Number(amountDecimal!); - const symbol = sourceToken.symbol; - const targetSymbol = targetToken.symbol; - const qty = Number(formatUnits(BigInt(quote.buyAmount), targetResolved.decimals)); - - if (mode === "simulated") { - onResolve({ - status: "simulated_filled", - order: { - id: `ord_${cryptoRandomId()}`, - coin: sourceToken.coinId ?? sourceToken.slug ?? "unknown", - symbol, - side, - amount_human: amountHuman, - qty, - status: "simulated_filled", - timestamp: new Date().toISOString(), - note: "Simulated fill. No on-chain transaction was sent.", - slippage_bps: slippageBps, - }, - }); - return; - } - - // Real mode: switch chain if needed, then EIP-712 sign + POST. - try { - if (chainMismatch) { - await switchChainAsync({ chainId: sourceChainId }); - } - const { orderUid, txHash } = await signCowOrder({ - quote, - chainId: sourceChainId, - receiver: address, - sellTokenSymbol: symbol, - targetTokenSymbol: targetSymbol, - targetDecimals: targetResolved.decimals, - signTypedDataAsync: signTypedDataAsync as never, - }); - onResolve({ - status: "signed", - chain_id: sourceChainId, - order_uid: orderUid, - tx_hash: txHash, - order: { - id: orderUid, - symbol: targetSymbol, - qty, - }, - }); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : "Wallet rejected the transaction"; - onResolve({ status: "error", error: msg }); - setSubmitting(false); - } - }; - - const handleCancel = () => { - onResolve({ status: "cancelled" }); - }; - - const handleMax = () => { - if (!sourceToken) return; - setAmount(sourceToken.balance); - }; - - const canSign = - !!quote && - !quoteLoading && - !quoteError && - amountValid && - sourceResolved && - targetResolved && - !isBusy; - - return ( - -
    - - - {address.slice(0, 6)}…{address.slice(-4)} · {chainConfigName(connectedChainId)} - -
    - - {/* Source token selector — balances grouped by chain. Selecting a - token from a non-connected chain triggers switchChain on Sign. */} -
    - - {intent.side === "sell" ? "From" : "Spend"} - - {hintMissing ? ( -
    - - - Your wallet doesn't hold{" "} - {hintSymbol}. Pick a token from your - balances below. - -
    - ) : null} - {balances.length === 0 ? ( -
    - No balances found on Ethereum, Arbitrum, or Base. -
    - ) : ( -
    - {groupedByChain.map(({ chainId: cid, rows }) => ( -
    -
    - - - {chainConfigName(cid)} - - {cid === connectedChainId ? ( - - · Connected - - ) : null} -
    - {rows.map((b) => { - const selected = - sourcePick?.chainId === b.chainId && - sourcePick?.token.address === b.token.address; - const usd = usdValue(b.token); - return ( - - ); - })} -
    - ))} -
    - )} -
    - - {/* Amount input */} - {sourceToken && ( -
    -
    - Amount - -
    - setAmount(e.target.value)} - placeholder="0" - aria-invalid={amount.length > 0 && !amountValid} - className={cn( - "border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-1", - amount.length > 0 && !amountValid && "border-destructive", - )} - /> -
    - )} - - {/* Swap arrow + target */} - {sourceToken && ( -
    -
    - -
    - For - -
    - )} - - {/* Quote preview */} - {sourceToken && targetToken && ( -
    -
    - You pay - - {amountValid ? formatAmount(Number(amountDecimal!)) : "—"}{" "} - {sourceToken.symbol.toUpperCase()} - -
    -
    - - You receive {intent.side === "sell" ? "(min)" : "(max)"} - - - {quoteLoading ? ( - - ) : quoteError ? ( - - ) : quote ? ( - <> - {formatQty( - Number(formatUnits(BigInt(quote.buyAmount), targetResolved!.decimals)), - )}{" "} - {targetToken.symbol.toUpperCase()} - - ) : ( - - )} - -
    -
    - Valid until - - {quote ? new Date(quote.validTo * 1000).toLocaleTimeString() : "—"} - -
    -
    - Network fee - - {quote - ? formatQty( - Number(formatUnits(BigInt(quote.feeAmount || "0"), sourceResolved!.decimals)), - ) - : "—"}{" "} - {quote && ( - {sourceToken.symbol.toUpperCase()} - )} - -
    - {quoteError ? ( -
    - - {quoteError} -
    - ) : null} -
    - )} - - {/* Slippage */} - {sourceToken && targetToken && ( -
    -
    - - Slippage tolerance - - - {(slippageBps / 100).toFixed(slippageBps % 100 === 0 ? 0 : 2)}% - -
    -
    - {[10, 50, 100, 300].map((bps) => ( - - ))} -
    -
    - )} - - {/* Mode toggle */} - {sourceToken && targetToken && ( -
    - {(["simulated", "real"] as const).map((m) => ( - - ))} -
    - )} - -
    - - -
    -
    - ); -} - -// --- CoW sign + submit ------------------------------------------------------ - -type SignCowOrderArgs = { - quote: CowQuote; - chainId: CowChainId; - receiver: Address; - sellTokenSymbol: string; - targetTokenSymbol: string; - targetDecimals: number; - signTypedDataAsync: ReturnType["signTypedDataAsync"]; -}; - -async function signCowOrder({ - quote, - chainId, - receiver, - signTypedDataAsync, -}: SignCowOrderArgs): Promise<{ orderUid: `0x${string}`; txHash?: `0x${string}` }> { - const config = getCowConfig(chainId); - if (!config) throw new Error(`chain_id ${chainId} is not supported`); - - const sellAmount = BigInt(quote.sellAmount); - const buyAmount = BigInt(quote.buyAmount); - const feeAmount = BigInt(quote.feeAmount ?? "0"); - const orderStruct = { - sellToken: quote.sellToken, - buyToken: quote.buyToken, - receiver, - sellAmount, - buyAmount, - validTo: quote.validTo, - appData: ("0x" + "0".repeat(64)) as Hex, - feeAmount, - kind: quote.kind, - partiallyFillable: false, - sellTokenBalance: "erc20" as const, - buyTokenBalance: "erc20" as const, - }; - - const message = { - ...orderStruct, - sellAmount: sellAmount.toString(), - buyAmount: buyAmount.toString(), - feeAmount: feeAmount.toString(), - }; - - const signature = await signTypedDataAsync({ - domain: COW_EIP712_DOMAIN(chainId), - types: COW_EIP712_TYPES, - primaryType: "Order", - message: message as never, - }); - - const res = await fetch(`${config.apiUrl}/orders`, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ - ...orderStruct, - sellAmount: sellAmount.toString(), - buyAmount: buyAmount.toString(), - feeAmount: feeAmount.toString(), - signingScheme: "eip712", - signature, - }), - }); - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { description?: string }; - throw new Error(`CoW /orders ${res.status}${body.description ? `: ${body.description}` : ""}`); - } - const json = (await res.json()) as { orderUid: `0x${string}` }; - return { orderUid: json.orderUid }; -} - -// --- Terminal phases -------------------------------------------------------- - -function SimulatedReceipt({ order }: { order: SimulatedOrder }) { - return ( -
    -
    -
    -
    - -
    -
    -

    - {order.side === "buy" ? "Bought" : "Sold"} {order.symbol} -

    -

    Simulated fill — no on-chain tx sent

    -
    - - SIMULATED - -
    -
    -
    -
    Paid
    -
    - {formatAmount(order.amount_human)} {order.symbol} -
    -
    -
    -
    Filled qty
    -
    {formatQty(order.qty)}
    -
    -
    -
    Time
    -
    - {new Date(order.timestamp).toLocaleString()} -
    -
    -
    -
    Side
    -
    {order.side}
    -
    -
    -
    -
    - order id: - {order.id} -
    -
    - - {order.note} -
    -
    -
    -
    - ); -} - -function SignedReceipt({ signed }: { signed: Extract }) { - return ( -
    -
    -
    -
    - -
    -
    -

    Order submitted to CoW

    -

    - Waiting for a solver to fill it (typically < 1 min) -

    -
    - - SIGNED - -
    - -
    -
    - ); -} - -// --- helpers used above (kept at bottom for hoisting clarity) --------------- - -function resolveTokenForSlug( - slug: TokenSlug, - chainId: CowChainId, -): { - address: Address; - decimals: number; - coinId: string; -} | null { - const meta = tokensForChain(chainId).find((t) => t.slug === slug); - if (!meta) return null; - const resolved = resolveToken(meta.coinId, chainId); - if (!resolved) return null; - return { - address: resolved.address, - decimals: resolved.meta.decimals, - coinId: resolved.meta.coinId, - }; -} - -function chainConfigName(chainId: CowChainId): string { - return getCowConfig(chainId)?.name ?? `Chain ${chainId}`; -} - -// Convert a human-string amount to a raw bigint-string. Defensive: the -// caller (lib/decimal.parseAmount) already rejects NaN / negative / -// scientific / non-numeric, so by the time we get here the string is -// "123.45"-shaped. Multiply by 10^decimals with bigint math. -function parseAmountRaw(human: string, decimals: number): string { - const [whole, frac = ""] = human.split("."); - const padded = (frac + "0".repeat(decimals)).slice(0, decimals); - const negative = false; // parseAmount already rejected negatives - const combined = `${whole}${padded}`.replace(/^0+/, "") || "0"; - return negative ? `-${combined}` : combined; -} - -function formatAmountRaw(decimal: { toString(): string }, decimals: number): string { - // lib/decimal returns a Decimal; we re-parse via parseAmountRaw's - // string form. The decimal's toString is the canonical human form. - return parseAmountRaw(decimal.toString(), decimals); -} diff --git a/lib/swap/cow-config.ts b/lib/swap/cow-config.ts deleted file mode 100644 index 6dd1445..0000000 --- a/lib/swap/cow-config.ts +++ /dev/null @@ -1,58 +0,0 @@ -// CoW Protocol config — the only thing we need to hardcode now that -// the swap path goes through CoW's solver network instead of a -// hand-curated Uniswap V3 router list. -// -// ponytail: this file is the single source of truth for CoW endpoints -// + the EIP-712 settlement contract. Both the backend quote tool -// (backend/tool/crypto/get-swap-quote.ts) and the frontend EIP-712 -// signer (components/tool-ui/crypto/confirm-card.tsx) import from -// here. Add a chain = drop a row in COW_API. That's it. - -export const COW_SETTLEMENT = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41" as const; - -// Settlement contract chainId — same address on every EVM chain (CREATE2), -// but the EIP-712 domain needs the chainId of the chain the user is -// signing for, so callers pass it explicitly. - -export const COW_API: Record = { - 1: { apiUrl: "https://api.cow.fi/mainnet/api/v1", name: "Ethereum" }, - 42161: { apiUrl: "https://api.cow.fi/arbitrum_one/api/v1", name: "Arbitrum One" }, - 8453: { apiUrl: "https://api.cow.fi/base/api/v1", name: "Base" }, -}; - -export type CowChainId = keyof typeof COW_API; - -export function getCowConfig(chainId: number | null | undefined) { - if (chainId == null) return null; - return COW_API[chainId] ?? null; -} - -// EIP-712 domain for CoW orders. `verifyingContract` is the settlement -// contract; `chainId` is the chain the order is being placed on. The -// version string is fixed — the protocol bumps it only on contract -// upgrades that change the order schema. -export const COW_EIP712_DOMAIN = (chainId: CowChainId) => ({ - name: "Gnosis Protocol" as const, - version: "v2" as const, - chainId, - verifyingContract: COW_SETTLEMENT, -}); - -// CoW order typed-data schema. Mirrors the contract's Order struct; -// keep in sync with the protocol. -export const COW_EIP712_TYPES = { - Order: [ - { name: "sellToken", type: "address" }, - { name: "buyToken", type: "address" }, - { name: "receiver", type: "address" }, - { name: "sellAmount", type: "uint256" }, - { name: "buyAmount", type: "uint256" }, - { name: "validTo", type: "uint32" }, - { name: "appData", type: "bytes32" }, - { name: "feeAmount", type: "uint256" }, - { name: "kind", type: "string" }, - { name: "partiallyFillable", type: "bool" }, - { name: "sellTokenBalance", type: "string" }, - { name: "buyTokenBalance", type: "string" }, - ], -} as const; diff --git a/lib/tokens/catalog.ts b/lib/tokens/catalog.ts deleted file mode 100644 index e111f65..0000000 --- a/lib/tokens/catalog.ts +++ /dev/null @@ -1,128 +0,0 @@ -// ponytail: hardcoded catalog of the common tokens the swap card offers. -// CoinGecko ids are the public identity the LLM uses (usd-coin, ethereum, -// wrapped-bitcoin, tether). On-chain, each chain has its own contract -// address + decimals. A token might also differ in symbol across chains -// (USDC.e vs USDC on Arbitrum). -// -// MVP scope: 4 tokens × 3 chains = 12 entries. Add a new token = drop one -// row per supported chain. Add a new chain = set the slug for each token -// (or omit to mark the token unavailable on that chain). -// -// CoW Protocol settlement requires every token to be on the SAME chain. -// We don't bridge — if a chain is missing for a token, the card hides it -// from the target dropdown for swaps on that chain. - -import type { Address } from "viem"; -import type { CowChainId } from "@/lib/swap/cow-config"; - -export type TokenSlug = "usdc" | "weth" | "usdt" | "wbtc"; - -export type TokenMeta = { - slug: TokenSlug; - coinId: string; // CoinGecko id - symbol: string; - name: string; - decimals: number; - // Stablecoins get 2-decimal display in the amount input + the quote - // preview; everything else shows up to 6dp for sub-unit amounts. - stable: boolean; -}; - -// Catalog rows, one per token (chain-independent metadata). Per-chain -// addresses live in CHAIN_ADDRESSES so adding a new chain only touches -// that map. -const TOKENS: TokenMeta[] = [ - { - slug: "usdc", - coinId: "usd-coin", - symbol: "USDC", - name: "USD Coin", - decimals: 6, - stable: true, - }, - { - slug: "usdt", - coinId: "tether", - symbol: "USDT", - name: "Tether", - decimals: 6, - stable: true, - }, - { - slug: "weth", - coinId: "ethereum", - symbol: "WETH", - name: "Wrapped Ether", - decimals: 18, - stable: false, - }, - { - slug: "wbtc", - coinId: "wrapped-bitcoin", - symbol: "WBTC", - name: "Wrapped Bitcoin", - decimals: 8, - stable: false, - }, -]; - -// Per-chain token addresses. Missing chain → token unavailable there. -// Picked the canonical bridge / native USDC for each L2 (not USDC.e). -// EIP-55 mixed case (Alchemy returns checksummed; viem accepts either). -const CHAIN_ADDRESSES: Record>> = { - 1: { - usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - usdt: "0xdAC17F958D2ee523a2206206994597C13D831ec7", - weth: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - wbtc: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", - }, - 42161: { - // Arbitrum: native USDC (Circle) + bridged USDC.e. Catalog carries - // the native one — it's what every modern wallet + DEX defaults to. - usdc: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC.e (bridged) - usdt: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", - weth: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", - wbtc: "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", - }, - 8453: { - usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - usdt: "0xfde4C96c8593538E31C54eB48FDC2C8A5e1f9477", - weth: "0x4200000000000000000000000000000000000006", - wbtc: "0x0555E30da8f98308Edb960aa94C0Db47230d2B9c", - }, -}; - -export function getTokenMeta(slug: TokenSlug): TokenMeta { - const t = TOKENS.find((x) => x.slug === slug); - if (!t) throw new Error(`unknown token slug: ${slug}`); - return t; -} - -// Resolve a CoinGecko id to the token's address + decimals on the given -// chain. Returns null when the token isn't deployed on that chain. -export function resolveToken( - coinId: string, - chainId: CowChainId, -): { meta: TokenMeta; address: Address } | null { - const meta = TOKENS.find((t) => t.coinId === coinId.toLowerCase()); - if (!meta) return null; - const addr = CHAIN_ADDRESSES[chainId]?.[meta.slug]; - if (!addr) return null; - return { meta, address: addr }; -} - -// Reverse lookup: which tokens are available on a given chain. Used by -// the target-token dropdown so the user never sees a token they can't -// actually receive. -export function tokensForChain(chainId: CowChainId): TokenMeta[] { - return TOKENS.filter((t) => CHAIN_ADDRESSES[chainId]?.[t.slug]); -} - -// Smart default for the target token. Sell a stablecoin → ETH; sell -// ETH/WBTC → USDC; no source hint → USDC. Keeps the preview realistic -// without asking the user to pick a target for the common case. -export function defaultTargetSlug(sourceSlug: TokenSlug | null): TokenSlug { - if (sourceSlug == null) return "weth"; - if (sourceSlug === "usdc" || sourceSlug === "usdt") return "weth"; - return "usdc"; -} diff --git a/patches/cuer@0.0.3.patch b/patches/cuer@0.0.3.patch deleted file mode 100644 index d47e123..0000000 --- a/patches/cuer@0.0.3.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/_dist/QrCode.js b/_dist/QrCode.js -index b1806526c3d100ca6f1e9259b4dfbcd416a33927..c594a0cc0f948a225302caa6dcdcf601ac804fbd 100644 ---- a/_dist/QrCode.js -+++ b/_dist/QrCode.js -@@ -2,7 +2,7 @@ import { encodeQR } from 'qr'; - export function create(value, options = {}) { - const { errorCorrection, version } = options; - const grid = encodeQR(value, 'raw', { -- border: 0, -+ border: 1, - ecc: errorCorrection, - scale: 1, - version: version, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 41d1459..5ebe4af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1 @@ patchedDependencies: - cuer@0.0.3: patches/cuer@0.0.3.patch diff --git a/tests/backend/tool/crypto/confirm-crypto-order.test.ts b/tests/backend/tool/crypto/confirm-crypto-order.test.ts deleted file mode 100644 index b7c59fe..0000000 --- a/tests/backend/tool/crypto/confirm-crypto-order.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { confirmCryptoOrderTool } from "@/backend/tool/crypto/confirm-crypto-order"; - -// The tool is a pure pause: it validates the LLM's intent and emits -// `{status:"awaiting_user", intent:{...}}`. No fetch, no chain calls — -// the card does all the wallet + quote work client-side. - -describe("confirmCryptoOrderTool — intent pause", () => { - it("emits awaiting_user with the intent payload verbatim", async () => { - const out = await confirmCryptoOrderTool.invoke({ - side: "sell", - source_coin_id: "usd-coin", - }); - const parsed = JSON.parse(out as string); - expect(parsed.status).toBe("awaiting_user"); - expect(parsed.intent).toEqual({ - side: "sell", - source_coin_id: "usd-coin", - amount: null, - target_coin_id: null, - }); - }); - - it("forwards amount + target_coin_id when the LLM names them", async () => { - const out = await confirmCryptoOrderTool.invoke({ - side: "sell", - source_coin_id: "usd-coin", - amount: 100, - target_coin_id: "ethereum", - }); - const parsed = JSON.parse(out as string); - expect(parsed.intent).toEqual({ - side: "sell", - source_coin_id: "usd-coin", - amount: 100, - target_coin_id: "ethereum", - }); - }); - - it("works with no optional fields at all (the card picks defaults from the wallet)", async () => { - const out = await confirmCryptoOrderTool.invoke({ side: "buy" }); - const parsed = JSON.parse(out as string); - expect(parsed.status).toBe("awaiting_user"); - expect(parsed.intent.source_coin_id).toBeNull(); - expect(parsed.intent.amount).toBeNull(); - expect(parsed.intent.target_coin_id).toBeNull(); - }); -}); - -describe("confirmCryptoOrderTool — validation", () => { - it("rejects a non-positive amount at the schema layer", async () => { - await expect(confirmCryptoOrderTool.invoke({ side: "sell", amount: 0 })).rejects.toThrow(); - }); - - it("rejects NaN amount at the schema layer", async () => { - await expect( - confirmCryptoOrderTool.invoke({ side: "sell", amount: Number.NaN }), - ).rejects.toThrow(); - }); - - it("rejects a malformed CoinGecko id", async () => { - const out = await confirmCryptoOrderTool.invoke({ - side: "sell", - source_coin_id: "Bad/Id With Spaces", - }); - const parsed = JSON.parse(out as string); - expect(parsed.status).toBe("error"); - expect(parsed.error).toMatch(/source_coin_id/); - }); - - it("rejects a CoinGecko id not in the catalog", async () => { - const out = await confirmCryptoOrderTool.invoke({ - side: "sell", - source_coin_id: "definitely-not-a-real-coin", - }); - const parsed = JSON.parse(out as string); - expect(parsed.status).toBe("error"); - expect(parsed.error).toMatch(/catalog/); - }); -}); diff --git a/tests/frontend/crypto/crypto-confirm-card.test.tsx b/tests/frontend/crypto/crypto-confirm-card.test.tsx deleted file mode 100644 index 20b7228..0000000 --- a/tests/frontend/crypto/crypto-confirm-card.test.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { WagmiProvider, createConfig, http } from "wagmi"; -import { mainnet } from "wagmi/chains"; - -import { CryptoConfirmCard } from "@/components/tool-ui/crypto/confirm-card"; - -// --- Mocks ----------------------------------------------------------------- - -vi.mock("@assistant-ui/react-langgraph", () => ({ - useLangGraphSendCommand: () => mockSendCommand, -})); - -vi.mock("wagmi", async () => { - const actual = await vi.importActual("wagmi"); - return { - ...actual, - useAccount: () => mockUseAccount(), - useSignTypedData: () => ({ signTypedDataAsync: mockSignTypedDataAsync }), - useSwitchChain: () => ({ switchChainAsync: vi.fn(), isPending: false }), - }; -}); - -vi.mock("@rainbow-me/rainbowkit", async () => { - const actual = - await vi.importActual("@rainbow-me/rainbowkit"); - return { - ...actual, - useConnectModal: () => ({ openConnectModal: mockOpenConnectModal }), - }; -}); - -const mockSendCommand = vi.fn(); -const mockOpenConnectModal = vi.fn(); -const mockUseAccount = vi.fn(); -const mockSignTypedDataAsync = vi.fn(); - -// Portfolio API mock — a single call returns balance + metadata + price -// for every token the wallet holds across all 3 CoW chains. USDC is the -// user's biggest holding; WETH + native ETH are peers so the card's -// native-token row + USD-value column rendering is exercised. -const PORTFOLIO_RESPONSE = { - data: { - tokens: [ - { - network: "eth-mainnet", - tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC mainnet - tokenBalance: "0x5f5e100", // 100 USDC (6 decimals) - tokenMetadata: { - symbol: "USDC", - decimals: 6, - name: "USD Coin", - logo: "https://example.com/usdc.png", - }, - tokenPrices: [{ currency: "usd", value: "1.0" }], - }, - { - network: "eth-mainnet", - tokenAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH mainnet - tokenBalance: "0x16345785d8a0000", // 0.1 WETH (18 decimals) - tokenMetadata: { - symbol: "WETH", - decimals: 18, - name: "Wrapped Ether", - logo: "https://example.com/weth.png", - }, - tokenPrices: [{ currency: "usd", value: "3100.0" }], - }, - { - network: "eth-mainnet", - tokenAddress: null, // native ETH - tokenBalance: "0x16345785d8a0000", // 0.1 ETH - tokenMetadata: { - symbol: "ETH", - decimals: 18, - name: "Ether", - logo: "https://example.com/eth.png", - }, - tokenPrices: [{ currency: "usd", value: "3100.0" }], - }, - ], - }, -}; - -function mockFetchImpl(input: RequestInfo | URL, _init?: RequestInit) { - const url = typeof input === "string" ? input : input.toString(); - if (url.includes("/api/alchemy/portfolio/tokens/by-address")) { - return Promise.resolve( - new Response(JSON.stringify(PORTFOLIO_RESPONSE), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - } - // CoW quote - if (url.includes("/quote")) { - return Promise.resolve( - new Response( - JSON.stringify({ - sellToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - buyToken: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - sellAmount: "99988648", - buyAmount: "41581080662656000", - validTo: 1782539294, - feeAmount: "11352", - kind: "sell", - partiallyFillable: false, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ); - } - return Promise.resolve(new Response("{}", { status: 404 })); -} - -beforeEach(() => { - vi.stubGlobal("fetch", vi.fn(mockFetchImpl)); - vi.clearAllMocks(); - mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); - mockSignTypedDataAsync.mockResolvedValue("0xsignature"); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); - -// --- Test helpers ---------------------------------------------------------- - -const config = createConfig({ - chains: [mainnet], - transports: { [mainnet.id]: http() }, - ssr: true, -}); - -function wrap(node: React.ReactElement) { - const qc = new QueryClient(); - return render( - - {node} - , - ); -} - -type Result = Parameters[0]["result"]; - -function makeProps(args: Record, result: Result) { - return { - type: "tool-call" as const, - toolCallId: "test", - toolName: "confirm_crypto_order", - argsText: "", - args, - result, - status: { type: "complete" as const }, - }; -} - -function intentArgs(overrides: Record = {}) { - return { - side: "sell", - source_coin_id: "usd-coin", - ...overrides, - }; -} - -function awaitingResult(intent: Record = {}) { - return { - status: "awaiting_user" as const, - intent: { - side: "sell" as const, - source_coin_id: "usd-coin", - amount: null, - target_coin_id: null, - ...intent, - }, - }; -} - -// --- Tests ----------------------------------------------------------------- - -describe("CryptoConfirmCard — wallet gate", () => { - it("renders a Connect wallet button when no wallet is connected", async () => { - mockUseAccount.mockReturnValue({ address: undefined, isConnected: false }); - wrap(); - expect(screen.getByRole("button", { name: /connect wallet/i })).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: /connect wallet/i })); - expect(mockOpenConnectModal).toHaveBeenCalledTimes(1); - }); - - it("renders an unsupported-chain error when wagmi is on Polygon etc.", async () => { - mockUseAccount.mockReturnValue({ - address: "0x1234567890abcdef1234567890abcdef12345678", - isConnected: true, - chainId: 137, - }); - wrap(); - expect(screen.getByText(/isn't supported/i)).toBeTruthy(); - }); -}); - -describe("CryptoConfirmCard — connected wallet", () => { - beforeEach(() => { - mockUseAccount.mockReturnValue({ - address: "0x1234567890abcdef1234567890abcdef12345678", - isConnected: true, - chainId: 1, - }); - }); - - it("lists the user's balances and pre-selects the LLM-hinted source", async () => { - wrap(); - await waitFor(() => { - expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); - }); - expect(screen.getAllByText(/WETH/).length).toBeGreaterThan(0); - // USDC button (the LLM hinted "usd-coin") should be in the primary - // (selected) style — we assert via data-action + class scan rather - // than the rendered text since multiple elements contain "USDC". - const selected = screen - .getAllByRole("button") - .find( - (b) => b.getAttribute("data-action") === "select-source" && b.textContent?.includes("USDC"), - ); - expect(selected?.className).toContain("bg-primary/10"); - }); - - it("renders a single Portfolio fetch (no per-chain JSON-RPC for balances)", async () => { - wrap(); - await waitFor(() => { - expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); - }); - const fetchCalls = (fetch as ReturnType).mock.calls; - const portfolioCalls = fetchCalls.filter(([u]) => - String(u).includes("/api/alchemy/portfolio/tokens/by-address"), - ); - const legacyRpcCalls = fetchCalls.filter(([u]) => - String(u).match(/\/api\/alchemy\/(eth|arb|base)-mainnet/), - ); - expect(portfolioCalls.length).toBe(1); - expect(legacyRpcCalls.length).toBe(0); - }); - - it("renders the native ETH row with the (native) marker", async () => { - wrap(); - await waitFor(() => { - expect(screen.getAllByText(/native/).length).toBeGreaterThan(0); - }); - // Native ETH button is the one whose row contains "(native)" — WETH - // is also "ETH"-shaped but is the wrapped ERC20 (no marker). - const nativeBtn = screen - .getAllByRole("button") - .find( - (b) => - b.getAttribute("data-action") === "select-source" && - /\(native\)/i.test(b.textContent ?? ""), - ); - expect(nativeBtn).toBeTruthy(); - expect(nativeBtn?.textContent).toMatch(/ETH/); - }); - - it("renders the USD-value column for each row", async () => { - wrap(); - await waitFor(() => { - // 100 USDC @ $1 = $100, 0.1 WETH @ $3100 = $310, 0.1 ETH @ $3100 = $310 - expect(screen.getAllByText(/≈ \$100/).length).toBeGreaterThan(0); - }); - expect(screen.getAllByText(/≈ \$310/).length).toBeGreaterThan(1); - }); - - it("sorts native to the top of each chain group, then by USD value desc", async () => { - wrap(); - await waitFor(() => { - expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); - }); - const selectButtons = screen - .getAllByRole("button") - .filter((b) => b.getAttribute("data-action") === "select-source"); - // First chain group is Ethereum; mock data is native ETH (~$310), - // WETH (~$310), USDC (~$100). Native always wins, then USD desc — - // so the visible order is ETH (native), WETH, USDC. - const symbolOrder = selectButtons - .map((b) => b.textContent ?? "") - .filter((t) => /(USDC|WETH|ETH)/.test(t)); - expect(symbolOrder.length).toBeGreaterThanOrEqual(3); - // Native ETH row is the first one — it carries the "(native)" marker. - expect(symbolOrder[0]).toMatch(/\(native\)/i); - expect(symbolOrder[0]).toMatch(/ETH/); - // USDC (lowest USD in this group) is still last. - const usdcIdx = symbolOrder.findIndex((t) => t.includes("USDC")); - expect(usdcIdx).toBeGreaterThanOrEqual(2); - }); - - it("renders the token logo image for each row", async () => { - wrap(); - await waitFor(() => { - expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); - }); - // Logos are decorative (alt="") so jsdom drops the img role; query - // the DOM directly. We assert the USDC src made it through to the - // DOM without needing to count all 3 logos. - const imgs = document.querySelectorAll("img"); - expect(imgs.length).toBeGreaterThan(0); - expect( - Array.from(imgs).some((img) => img.getAttribute("src") === "https://example.com/usdc.png"), - ).toBe(true); - }); - - it("renders the Alchemy chain emblem next to each chain group header", async () => { - wrap(); - await waitFor(() => { - expect(screen.getAllByText(/USDC/).length).toBeGreaterThan(0); - }); - // The mock balances all live on Ethereum, so the chain group header - // should carry the eth-mainnet.svg emblem from the catalog. - const groupHeader = document.querySelector( - 'img[src="https://static.alchemyapi.io/images/emblems/eth-mainnet.svg"]', - ); - expect(groupHeader).toBeTruthy(); - // Emblem is decorative — the surrounding label carries the name. - expect(groupHeader?.getAttribute("alt")).toBe(""); - }); - - it("pre-selects the LLM-hinted target and falls back to WETH when source is a stablecoin", async () => { - wrap(); - await waitFor(() => { - const select = screen.getByRole("combobox") as HTMLSelectElement; - expect(select.value).toBe("weth"); - }); - }); - - it("honors the LLM's target_coin_id hint", async () => { - wrap( - , - ); - await waitFor(() => { - const select = screen.getByRole("combobox") as HTMLSelectElement; - expect(select.value).toBe("wbtc"); - }); - }); - - it("calls CoW /quote once source/amount/target are valid", async () => { - wrap( - , - ); - await waitFor(() => { - const fetchCalls = (fetch as ReturnType).mock.calls; - expect(fetchCalls.some(([u]) => String(u).includes("/quote"))).toBe(true); - }); - }); - - it("disables the Sign button until a quote loads", async () => { - wrap( - , - ); - await waitFor(() => { - expect(screen.getByText(/you receive/i)).toBeTruthy(); - }); - // Quote effect debounces 400ms; wait for Sign to enable. - const signBtn = await screen.findByRole("button", { name: /confirm sell/i }); - await waitFor(() => { - expect((signBtn as HTMLButtonElement).disabled).toBe(false); - }); - }); - - it("shows a 'Confirm order' header in both buy and sell mode", async () => { - wrap(); - await waitFor(() => expect(screen.getByText(/^Confirm order$/)).toBeTruthy()); - wrap( - , - ); - await waitFor(() => expect(screen.getAllByText(/^Confirm order$/).length).toBeGreaterThan(0)); - }); - - it("clicking Confirm calls addResult with simulated_filled + the quote-derived qty", async () => { - wrap( - , - ); - const btn = await screen.findByRole("button", { name: /confirm sell/i }); - // Quote effect debounces 400ms; the click is a no-op until quote - // loads. waitFor the button to enable before firing. - await waitFor(() => { - expect((btn as HTMLButtonElement).disabled).toBe(false); - }); - fireEvent.click(btn); - expect(mockSendCommand).toHaveBeenCalledTimes(1); - const arg = mockSendCommand.mock.calls[0][0]; - const payload = JSON.parse(arg.resume); - expect(payload.status).toBe("simulated_filled"); - expect(payload.order.symbol).toBe("USDC"); - expect(payload.order.amount_human).toBe(100); - expect(payload.order.qty).toBeCloseTo(0.041581, 5); - }); - - it("clicking Cancel calls addResult with cancelled", async () => { - wrap( - , - ); - await waitFor(() => expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy()); - fireEvent.click(screen.getByRole("button", { name: /cancel/i })); - const arg = mockSendCommand.mock.calls[0][0]; - const payload = JSON.parse(arg.resume); - expect(payload.status).toBe("cancelled"); - }); -}); - -describe("CryptoConfirmCard — terminal states", () => { - it("renders the SIMULATED receipt", () => { - wrap( - , - ); - expect(screen.getByText(/ord_abc/)).toBeTruthy(); - expect(screen.getByText("SIMULATED")).toBeTruthy(); - }); - - it("renders the SIGNED receipt with the CoW order link", () => { - const FAKE_UID = "0x" + "a".repeat(106) + "deadbeef"; - wrap( - , - ); - expect(screen.getByText(/aaaaaaaa…adbeef/i)).toBeTruthy(); - expect(screen.getByText(/signed/i)).toBeTruthy(); - }); - - it("renders a cancelled banner", () => { - wrap(); - expect(screen.getByText(/cancelled/i)).toBeTruthy(); - }); - - it("renders an error banner", () => { - wrap( - , - ); - expect(screen.getByText(/wallet rejected/i)).toBeTruthy(); - }); -}); - -describe("CryptoConfirmCard — malformed awaiting_user (defensive)", () => { - it("surfaces a structured error when intent is missing", () => { - wrap( - , - ); - expect(screen.getByText(/intent was missing/i)).toBeTruthy(); - }); - - it("surfaces a structured error when intent.side is missing", () => { - wrap( - , - ); - expect(screen.getByText(/intent was missing/i)).toBeTruthy(); - }); -}); From 496a3b1ad72f533a413aafc1fb217cc77723f3f9 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 13:24:31 +0800 Subject: [PATCH 09/35] feat(crypto): split confirm into atomic tools + simulated Mock Coin swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single confirm_crypto_order tool + confirm-card monolith is split into three atomic, multi-turn tools with one user decision point each: connect_wallet (one-shot authorization, auto-resumes on first connected render — ref-guarded against Strict Mode double-invoke), place_crypto_order (quote + sign + submit), and get_order_status (poll the quote). Each tool is one ToolMessage the LLM can reason about; the model writes a one-sentence prose reply between them. The place-order card is fully simulated against Mock Coin (10,000 MC hardcoded balance, MC-priced gas converted at the live ETH/USD price, total MC spent shown in the receipt) — no real signing, no real on-chain transaction. The 'message' field on place_crypto_order and get_order_status is now composed by the LLM at call time so the prose next to the card reflects user intent instead of a fixed string. System prompt adds the CJK/Latin spacing rule for thread titles. --- app/assistant.tsx | 4 +- app/web3-providers.tsx | 7 - backend/prompt/system.ts | 49 +- backend/tool/crypto/connect-wallet.ts | 34 + backend/tool/crypto/get-crypto-price.ts | 2 + backend/tool/crypto/get-order-status.ts | 47 ++ backend/tool/crypto/place-crypto-order.ts | 62 ++ backend/tool/index.ts | 29 +- .../tool-ui/crypto/connect-wallet-card.tsx | 161 +++++ components/tool-ui/crypto/index.ts | 6 +- .../tool-ui/crypto/order-status-card.tsx | 220 +++++++ .../crypto/place-crypto-order-card.tsx | 617 ++++++++++++++++++ components/tool-ui/toolkit.tsx | 38 +- cspell.json | 3 + docs/APIS.md | 39 +- docs/TODOS.md | 4 + lib/alchemy/networks.ts | 13 + lib/alchemy/portfolio.ts | 67 +- lib/prices/coingecko.ts | 125 ++++ lib/wagmi.ts | 9 +- .../tool/crypto/connect-wallet.test.ts | 77 +++ .../tool/crypto/get-order-status.test.ts | 103 +++ .../tool/crypto/place-crypto-order.test.ts | 139 ++++ tests/lib/alchemy/portfolio.test.ts | 157 ++--- tests/lib/prices/coingecko.test.ts | 83 +++ 25 files changed, 1921 insertions(+), 174 deletions(-) create mode 100644 backend/tool/crypto/connect-wallet.ts create mode 100644 backend/tool/crypto/get-order-status.ts create mode 100644 backend/tool/crypto/place-crypto-order.ts create mode 100644 components/tool-ui/crypto/connect-wallet-card.tsx create mode 100644 components/tool-ui/crypto/order-status-card.tsx create mode 100644 components/tool-ui/crypto/place-crypto-order-card.tsx create mode 100644 lib/prices/coingecko.ts create mode 100644 tests/backend/tool/crypto/connect-wallet.test.ts create mode 100644 tests/backend/tool/crypto/get-order-status.test.ts create mode 100644 tests/backend/tool/crypto/place-crypto-order.test.ts create mode 100644 tests/lib/prices/coingecko.test.ts diff --git a/app/assistant.tsx b/app/assistant.tsx index c8641c0..5ce08d8 100644 --- a/app/assistant.tsx +++ b/app/assistant.tsx @@ -249,9 +249,9 @@ export function Assistant() { prompt: "What’s the weather like at today?", }, { - title: "Buy $100 of Bitcoin", + title: "I want to buy 2 ETH", label: "", - prompt: "I want to buy $100 of Bitcoin.", + prompt: "I want to buy 2 ETH", }, ]), }); diff --git a/app/web3-providers.tsx b/app/web3-providers.tsx index 5b1b068..90aeda7 100644 --- a/app/web3-providers.tsx +++ b/app/web3-providers.tsx @@ -7,13 +7,6 @@ import { RainbowKitProvider, lightTheme } from "@rainbow-me/rainbowkit"; import { wagmiConfig } from "@/lib/wagmi"; -// WalletConnect v2 modal emits HTML with `border="0"` on , which -// React 19's stricter DOM validation rejects with "invalid border=0". -// The other 6 wallets (MetaMask / Coinbase / Rainbow / Safe / Binance -// / Bitget) work without WalletConnect, so the boundary falls back to -// wagmi-only — the user keeps the page, loses the picker modal. When -// RainbowKit or @walletconnect/ethereum-provider ships a React 19 fix, -// drop the boundary. class WalletConnectBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { state = { failed: false }; static getDerivedStateFromError() { diff --git a/backend/prompt/system.ts b/backend/prompt/system.ts index c9b6505..625a0a1 100644 --- a/backend/prompt/system.ts +++ b/backend/prompt/system.ts @@ -50,11 +50,13 @@ Rules: - Content: express the core topic or action clearly; convert questions into concise declarative form if needed. - Ignore filler phrases such as greetings, politeness, and irrelevant background. - If too long, compress while preserving meaning. +- Spacing: when Chinese/Japanese/Korean (CJK) characters sit next to Latin letters, digits, or symbols, insert a single space between them so the title reads naturally (e.g. "我想买 100 MC 的 ETH", "用 Base 链买 ETH"). Do NOT add spaces between two CJK characters, or between two Latin tokens, or after/before CJK punctuation. Output: - Only the title text. - Single line. - No explanations. +- Spacing is part of the output — follow the CJK/Latin rule above, do not strip or insert extra spaces beyond it. - No quotes. - No trailing punctuation.`; @@ -70,41 +72,50 @@ export const WEATHER_AGENT_PROMPT = `You answer weather questions by calling too On any tool returning {success: false} or an error, ask the user for the missing piece (a different spelling, a place name, location permission). Never invent coordinates or guess the location.`; -// Dropped into the crypto sub-agent node. There are two distinct flows: +// Dropped into the crypto sub-agent node. Two distinct flows: // // Price query → get_crypto_price → short reply -// Trade → confirm_crypto_order → short reply +// Trade → get_crypto_price → connect_wallet → place_crypto_order → get_order_status // // The trade flow never touches get_crypto_price — the user already // knows the market (they're initiating a trade), and burning a // CoinGecko call on a price they don't need just hits the rate limit. -// The swap card is a HARD checkpoint — even when the user's message -// names a coin + amount, the agent must still pause for the user to -// explicitly click Sign on the rendered card before any state changes. +// Each tool in the trade flow is a HARD checkpoint — even when the +// user's message names a coin + amount, the agent must still pause for +// the user to explicitly click on the rendered card before any state +// changes. Tools are atomic: one user decision per turn. // -// The wallet is the source of truth for what the user can spend — but -// you cannot see the wallet's address or chain id from this prompt. -// NEVER ask the user for their wallet address or chain; the card -// reads both from wagmi. There is no fiat on-ramp in this flow — a -// "buy $100 of BTC" message gets redirected to "pick a token from -// your wallet to swap" (see fiat rule below). The user signs EIP-712 -// orders on CoW Protocol; no contract address is hardcoded, no token -// approval is required by the user. +// This is a fully SIMULATED flow. The system auto-funds the wallet +// with mock coin on the user's first trade — no real signing, no +// real on-chain transaction, no DEX quote. The card fetches live +// CoinGecko USD prices for the source + target tokens (LLM passes any +// CoinGecko id — no allowlist), computes the receive amount from the +// price ratio (receive = amount × price_source / price_target), and +// synthesizes an order on user click. The wallet's real balance is +// not relevant — the system allocates whatever the card needs. export const CRYPTO_AGENT_PROMPT = `You answer crypto questions by calling tools, not from your own knowledge. Pick the flow based on the user's intent. PRICE QUERY FLOW (user asks "what's the price", "compare X and Y", "how is BTC doing"): -1. get_crypto_price — Call with the CoinGecko ids (e.g. "bitcoin", "ethereum", "usd-coin"). Map tickers to ids. Pass multiple ids in one call when comparing. The price card leads the response. +1. get_crypto_price — Call with ONLY the CoinGecko ids the user explicitly named (e.g. user says "how is BTC doing" → call ["bitcoin"]). Do NOT add a second id (no "ethereum/usd-coin fallback"). The price card renders exactly one row per id. Map tickers to ids yourself. 2. Reply in one short sentence. The card already shows the numbers — do not repeat prices, sparkline, or 24h change. TRADE FLOW (user wants to sell, buy, swap, or exchange tokens): -1. Fiat rule (HARD CONSTRAINT). If the user's message names a fiat amount ("buy $100 of BTC", "花 500 人民币买 BTC", "用 100 EUR 换 ETH", "spend 50 JPY"), DO NOT call confirm_crypto_order. Reply in one sentence explaining that this agent is a self-custody DEX flow and only supports swapping tokens the user already holds. Invite them to say "swap 100 USDC to BTC" once they've picked a source token from their wallet. +The trade flow is a 4-step atomic sequence. Call the tools one at a time, in order. Do NOT batch them — each tool pauses for a user click. -2. confirm_crypto_order — Call with the user's intent. Required: \`side\` ("sell my X" / "swap X for Y" → sell, "buy Y with X" → buy). Optional: \`source_coin_id\` when the user named a source token (CoinGecko id: "usd-coin", "ethereum", "wrapped-bitcoin"), \`amount\` when the user named a number, \`target_coin_id\` when the user named what they want to receive. The card wakes the wallet (RainbowKit modal if not connected), lists the user's actual ERC20 balances from Alchemy, picks a sensible default target if none was named, fetches a live CoW quote, and exposes one Sign & Place Order button. The user must click before any state changes — the closing ToolMessage (status: signed / simulated_filled / cancelled / error) is what you use to write the final sentence. NEVER ask the user for their wallet address or chain — the card handles both. Do NOT batch with any other tool. +1. Fiat rule (HARD CONSTRAINT). If the user's message names a fiat amount ("buy $100 of BTC", "花 500 人民币买 BTC", "用 100 EUR 换 ETH", "spend 50 JPY"), DO NOT call any trade tool. Reply in one sentence explaining that this agent is a self-custody DEX flow and only supports swapping against the user's Mock Coin balance. Invite them to say "buy 0.1 ETH" (no fiat) so we can quote against Mock Coin. -3. Reply in one short sentence. The card already shows the numbers — do not repeat quote details, balances, or order ids. +2. get_crypto_price — If no get_crypto_price card has appeared in this thread yet (or the existing cards are for unrelated coins), call it with the CoinGecko id of ONLY the target token the user mentioned (e.g. user says "buy ETH" → call ["ethereum"]). One id, one row. Skip this step ONLY if a get_crypto_price card for the same target is already in the thread. + +3. connect_wallet — Call ONCE at the start of a trade flow. The card opens RainbowKit; on success the wallet's address + chain id flow back to you in the connect_wallet ToolMessage. After that point, the wallet is authorized for the rest of the session — DO NOT call connect_wallet again on a follow-up user turn, even if the user's new message is about another trade. Subsequent tools (place_crypto_order, get_order_status) auto-infer the address from wagmi state — the LLM never passes an address. Calling connect_wallet a second time surfaces a confirm step the user has to dismiss manually, which is a friction bug, not a safety check. The only reason to call connect_wallet again is if the user explicitly says "switch wallet", "reconnect", "use a different wallet", or the wagmi state visibly broke (no recent address in any connect_wallet ToolMessage). Do NOT batch with any other tool. + +4. place_crypto_order — After connect_wallet has resolved, call with the user's intent. REQUIRED: \`target_coin_id\` (CoinGecko id of what the user wants to receive — e.g. "ethereum" for ETH, "bitcoin" for BTC), and \`message\` (a short intent-specific prose line YOU write for this turn — e.g. "Swapping 100 MC for ETH" or "Converting $50 to BTC"; the user sees this next to the quote card). OPTIONAL: \`amount\` (Mock Coin amount the user wants to spend — default 100 MC if not specified). DO NOT pass \`source_coin_id\` — the source is always Mock Coin in this demo flow, hardcoded in the card. The card auto-funds the user with 10,000 Mock Coin (no wallet balance lookup), prices the target via live CoinGecko, polls every 30s with a visible countdown, lets the user pick slippage + simulated gas tier (gas is converted to MC at the live ETH/USD price), and exposes one Accept Swap button. On click, the card synthesizes a quote — no real signing, no real broadcast. The closing ToolMessage (status: simulated_filled | cancelled | error) is what you use to write the final sentence. Tell the user upfront this is a SIMULATED swap against Mock Coin — no real funds move, nothing is signed or broadcast. Do NOT batch with any other tool. + +5. get_order_status — After place_crypto_order returns status:"simulated_filled" with an order_uid, call with (order_uid, chain_id) and a \`message\` YOU write (e.g. "Checking the ETH quote from a moment ago"). The card shows the quote id and exposes one Check status button. If the status is still "open" after a check, do NOT loop — reply to the user and let them decide whether to check again. + +6. Reply in one short sentence after each tool. The card already shows the numbers — do not repeat quote details, balances, or order ids. GENERAL RULES: - On any tool returning {success: false} or an error, ask the user to clarify (different coin, valid amount, retry, different chain). Never invent prices, quantities, fx rates, addresses, or order ids. - CoinGecko's free tier rate-limits aggressively — if get_crypto_price keeps failing, tell the user to wait and try again. -- CoW's quote endpoint can return NoLiquidity for exotic pairs — surface that error and ask the user to pick a different target token. -- Never repeat CoinGecko/CoW numbers in your prose — the cards render them.`; +- ANY CoinGecko id is accepted as the target — BTC, ETH, USDC, dogecoin, solana, pepe, anything. The simulated flow has no allowlist. Just map the user's ticker to the right CoinGecko id. +- Never repeat CoinGecko numbers in your prose — the cards render them.`; diff --git a/backend/tool/crypto/connect-wallet.ts b/backend/tool/crypto/connect-wallet.ts new file mode 100644 index 0000000..609f1b6 --- /dev/null +++ b/backend/tool/crypto/connect-wallet.ts @@ -0,0 +1,34 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { interrupt } from "@langchain/langgraph"; + +export const CONNECT_WALLET_TOOL_NAME = "connect_wallet"; + +// connect_wallet is a pure trigger. It pauses via interrupt(); the +// frontend card (ConnectWalletCard) opens RainbowKit, then resumes with +// {address, chainId} from wagmi — that becomes the ToolMessage content +// the LLM reads next pass. Subsequent tools (place_crypto_order, +// get_order_status) auto-infer the address from wagmi state, so the LLM +// does not need to thread it through the schema. +export const connectWalletTool = tool( + async ({ message }) => { + return interrupt({ + ui: CONNECT_WALLET_TOOL_NAME, + data: {}, + message: message ?? "Connect your wallet to continue.", + }); + }, + { + name: CONNECT_WALLET_TOOL_NAME, + description: + "Pause and prompt the user to connect their wallet. The card opens RainbowKit; on success the wallet's address and chain id flow back via the ToolMessage. Call this at the start of any trade flow if the most recent connect_wallet ToolMessage in this thread does NOT contain a valid address. If the user already has a connected address from a previous turn, you may skip directly to place_crypto_order. Do NOT batch with any other tool — the user must click Connect before any state changes.", + schema: z.object({ + message: z + .string() + .optional() + .describe( + "Short prompt shown above the connect button; one sentence. Defaults to a generic 'Connect your wallet to continue.' if omitted.", + ), + }), + }, +); diff --git a/backend/tool/crypto/get-crypto-price.ts b/backend/tool/crypto/get-crypto-price.ts index ec45b93..1ca439c 100644 --- a/backend/tool/crypto/get-crypto-price.ts +++ b/backend/tool/crypto/get-crypto-price.ts @@ -56,6 +56,8 @@ export const getCryptoPriceTool = tool( const cached = cache.get(cacheKey); if (cached && cached.exp > Date.now()) return cached.value; + // Note: CoinGecko uses `vs_currency` (singular) on /coins/markets — + // the plural `vs_currencies` is only required by /simple/price. const params = new URLSearchParams({ vs_currency, ids: ids.join(","), diff --git a/backend/tool/crypto/get-order-status.ts b/backend/tool/crypto/get-order-status.ts new file mode 100644 index 0000000..3cba56d --- /dev/null +++ b/backend/tool/crypto/get-order-status.ts @@ -0,0 +1,47 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { interrupt } from "@langchain/langgraph"; + +export const GET_ORDER_STATUS_TOOL_NAME = "get_order_status"; + +// get_order_status is a pure trigger for the simulated swap flow. The +// tool pauses via interrupt(); the frontend card (OrderStatusCard) +// shows the quote uid + chain, and on user click synthesizes a status +// (the real CoW /orders/{uid} endpoint can't find the synthetic uid +// from a simulated place_crypto_order, so we don't even try). The +// synthesized status flows back to the LLM as-is via the resume. + +export const getOrderStatusTool = tool( + async ({ order_uid, chain_id, message }) => { + return interrupt({ + ui: GET_ORDER_STATUS_TOOL_NAME, + data: { order_uid, chain_id }, + message, + }); + }, + { + name: GET_ORDER_STATUS_TOOL_NAME, + description: + "Render a swap status card for a previously accepted quote and pause for the user to click Check. The LLM passes the order_uid (returned by place_crypto_order) and chain_id (EVM chain id: 1, 42161, 8453, or 11155111). The card shows the quote uid and on user click synthesizes a status (filled / open / cancelled / not_found) — this is a simulated-swap demo, so we never hit CoW /orders/{uid}. The closing ToolMessage status field is what the model uses to write the final sentence. Call this AFTER place_crypto_order has returned status:'simulated_filled' with an order_uid. If the status is still 'open', do NOT loop — reply to the user and let them decide whether to check again. Do NOT batch with any other tool.", + schema: z.object({ + order_uid: z + .string() + .min(1) + .describe( + "The order uid returned by place_crypto_order (a 0x-prefixed hex string). Required.", + ), + chain_id: z + .number() + .int() + .describe( + "EVM chain id where the order was placed: 1 (Ethereum), 42161 (Arbitrum), 8453 (Base), or 11155111 (Sepolia testnet). Required.", + ), + message: z + .string() + .min(1) + .describe( + "REQUIRED. A short, intent-specific prose line the LLM composes for this turn (e.g. 'Checking the ETH quote from a moment ago' or 'Looking up that BTC swap status'). This is the message the user sees next to the status card so it reflects the user's actual intent, not a fixed string.", + ), + }), + }, +); diff --git a/backend/tool/crypto/place-crypto-order.ts b/backend/tool/crypto/place-crypto-order.ts new file mode 100644 index 0000000..491aeb7 --- /dev/null +++ b/backend/tool/crypto/place-crypto-order.ts @@ -0,0 +1,62 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { interrupt } from "@langchain/langgraph"; + +export const PLACE_CRYPTO_ORDER_TOOL_NAME = "place_crypto_order"; + +// place_crypto_order is a pure trigger for the simulated swap flow. +// The tool pauses via interrupt(); the frontend card +// (PlaceCryptoOrderCard) renders a swap quote against Mock Coin (the +// hardcoded source), prices the user's target via live CoinGecko, lets +// the user pick slippage + simulated gas tier, and on user click resumes +// with a synthesized {status:"simulated_filled", order:{...}} object. +// The tool returns that object to the LLM as-is — no real signing, no +// real on-chain transaction. The user is told upfront this is a SIMULATED +// swap; the user starts with 10,000 Mock Coin (hardcoded balance, no +// wallet lookup). + +const COIN_ID_RE = /^[a-z0-9-]+$/; + +export const placeCryptoOrderTool = tool( + async ({ target_coin_id, amount, message }) => { + if (target_coin_id && !COIN_ID_RE.test(target_coin_id)) { + throw new Error(`target_coin_id '${target_coin_id}' is not a valid CoinGecko id`); + } + + const intent = { + target_coin_id: target_coin_id ?? null, + amount: amount ?? null, + }; + return interrupt({ + ui: PLACE_CRYPTO_ORDER_TOOL_NAME, + data: intent, + message, + }); + }, + { + name: PLACE_CRYPTO_ORDER_TOOL_NAME, + description: + "Render a simulated swap quote card and pause for the user to click Accept Swap. The LLM parses the user's intent and passes target_coin_id (required) + amount (optional) here; the card always spends Mock Coin (no wallet balance lookup — the user is auto-funded with 10,000 MC), prices the target via live CoinGecko USD, polls every 30s with a visible countdown, and lets the user pick slippage + simulated gas tier (gas is converted to MC at the live ETH/USD price so the receipt shows total MC spent). The user is told upfront this is a SIMULATED swap; no real signing happens, no real funds move, nothing is broadcast on-chain. The closing ToolMessage (status: simulated_filled | cancelled | error) is what the model uses to write the final sentence. Call this AFTER connect_wallet has resolved in this thread. Do NOT batch with any other tool.", + schema: z.object({ + target_coin_id: z + .string() + .min(1) + .describe( + "CoinGecko id of what the user wants to receive (e.g. 'ethereum' for ETH, 'bitcoin' for BTC, 'dogecoin' for DOGE, 'solana' for SOL). REQUIRED — the source is always Mock Coin, hardcoded. Any CoinGecko id is accepted; there is no allowlist.", + ), + amount: z + .number() + .positive() + .optional() + .describe( + "Mock Coin amount the user wants to spend (e.g. 100 for 100 MC ≈ $100). Omit when the user didn't name a number — defaults to 100 MC.", + ), + message: z + .string() + .min(1) + .describe( + "REQUIRED. A short, intent-specific prose line the LLM composes for this turn (e.g. 'Swapping 100 MC for ETH' or 'Converting $50 to BTC'). This is the message the user sees next to the quote card so it reflects the user's actual intent, not a fixed string.", + ), + }), + }, +); diff --git a/backend/tool/index.ts b/backend/tool/index.ts index 342a520..770456d 100644 --- a/backend/tool/index.ts +++ b/backend/tool/index.ts @@ -5,18 +5,29 @@ import { geocodeLocationTool } from "@/backend/tool/geocode"; import { getWeatherTool } from "@/backend/tool/fetch-weather"; import { getCryptoPriceTool } from "@/backend/tool/crypto/get-crypto-price"; import { getFxRateTool } from "@/backend/tool/crypto/get-fx-rate"; -import { confirmCryptoOrderTool } from "@/backend/tool/crypto/confirm-crypto-order"; +import { connectWalletTool } from "@/backend/tool/crypto/connect-wallet"; +import { placeCryptoOrderTool } from "@/backend/tool/crypto/place-crypto-order"; +import { getOrderStatusTool } from "@/backend/tool/crypto/get-order-status"; // ponytail: keep the tool list in one place so the graph binds it from a // single source. Adding a tool = drop a file + add one line here. // -// Note: get_swap_quote + ask_crypto_intent were folded into -// confirm_crypto_order. The card now reads wagmi/Alchemy + fetches CoW -// quotes client-side; the backend only emits the intent pause. +// Trade flow is split into 3 atomic tools: +// 1. connect_wallet — one-time wallet authorization (interrupt) +// 2. place_crypto_order — randomized simulated swap (interrupt) +// 3. get_order_status — order status check (interrupt) +// Each is its own user decision point and ToolMessage the LLM can reason +// about independently. Cards live in components/tool-ui/crypto/. export const WEATHER_TOOLS = [askLocationTool, geocodeLocationTool, getWeatherTool]; -export const CRYPTO_TOOLS = [getCryptoPriceTool, getFxRateTool, confirmCryptoOrderTool]; +export const CRYPTO_TOOLS = [ + getCryptoPriceTool, + getFxRateTool, + connectWalletTool, + placeCryptoOrderTool, + getOrderStatusTool, +]; export const ALL_TOOLS = [ fetchUrl, @@ -26,7 +37,9 @@ export const ALL_TOOLS = [ getWeatherTool, getCryptoPriceTool, getFxRateTool, - confirmCryptoOrderTool, + connectWalletTool, + placeCryptoOrderTool, + getOrderStatusTool, ]; export { @@ -37,5 +50,7 @@ export { getWeatherTool, getCryptoPriceTool, getFxRateTool, - confirmCryptoOrderTool, + connectWalletTool, + placeCryptoOrderTool, + getOrderStatusTool, }; diff --git a/components/tool-ui/crypto/connect-wallet-card.tsx b/components/tool-ui/crypto/connect-wallet-card.tsx new file mode 100644 index 0000000..5ba69d0 --- /dev/null +++ b/components/tool-ui/crypto/connect-wallet-card.tsx @@ -0,0 +1,161 @@ +"use client"; + +import * as React from "react"; +import { CheckCircle2Icon, Loader2Icon, WalletIcon } from "lucide-react"; +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; +import { useAccount } from "wagmi"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; + +import { Button } from "@/components/ui/button"; +import { AddressOrHash } from "@/components/ui/address-or-hash"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; + +// ConnectWalletCard — one-shot wallet authorization. The backend tool +// (connect_wallet) pauses via interrupt(); the LLM's prompt message +// travels as the interrupt's `message` field and is rendered separately +// by the runtime, so this card reads only wallet state. +// +// Two views: +// +// 1. Wallet NOT connected → Connect button. Click opens RainbowKit. +// 2. Wallet connected (no resume yet) → tiny "Connecting" indicator; +// a ref-guarded useEffect auto-resumes with {address, chainId} on +// the first render where wagmi reports connected. The ref guards +// against the Strict Mode dev double-invoke that previously caused +// the second resume to consume an already-finished interrupt and +// render as [object Object]. +// 3. Resolved (result set) → confirmation row with the chosen address. +// +// Switching wallets: handled by the user's wallet app, not the card. +// RainbowKit's account modal still works if the user opens it +// elsewhere; the next tool that reads the address will see the new one. + +type ResumePayload = { address: `0x${string}`; chainId: number } | { error: string }; + +function chainName(chainId: number | undefined): string { + switch (chainId) { + case 1: + return "Ethereum"; + case 42161: + return "Arbitrum One"; + case 8453: + return "Base"; + case 11155111: + return "Sepolia"; + default: + return `Chain ${chainId ?? "?"}`; + } +} + +function parseResult(raw: unknown): ResumePayload | null { + return unwrapToolResult(raw); +} + +export const ConnectWalletCard: ToolCallMessagePartComponent> = ({ + result, +}) => { + const sendCommand = useLangGraphSendCommand(); + const { address, isConnected, chainId } = useAccount(); + const { openConnectModal } = useConnectModal(); + + const parsed = parseResult(result); + + // Auto-resume the moment wagmi reports a connected wallet. The ref + // guard makes this Strict-Mode-safe: the dev double-invoke would + // otherwise re-fire and consume the already-finished interrupt. + const hasAutoResumedRef = React.useRef(false); + React.useEffect(() => { + if (hasAutoResumedRef.current) return; + if (!isConnected || !address || !chainId) return; + if (parsed) return; // resume already completed, no need to re-fire + hasAutoResumedRef.current = true; + sendCommand({ resume: JSON.stringify({ address, chainId }) }); + }, [isConnected, address, chainId, parsed, sendCommand]); + + if (parsed && "address" in parsed) { + return ( +
    +
    +
    + +
    +
    +

    Wallet Connected

    +

    + + · {chainName(parsed.chainId)} +

    +
    +
    +
    + ); + } + + if (parsed && "error" in parsed) { + return ( +
    + Wallet connection cancelled: {parsed.error} +
    + ); + } + + // Brief window: wagmi reports connected, the auto-resume useEffect is + // about to fire (or just fired). Show a compact "Connecting" row so + // the user knows the card is mid-flight, not stuck. + if (isConnected && address && chainId) { + return ( +
    +
    +
    + +
    +
    +

    Connecting…

    +

    + + · {chainName(chainId)} +

    +
    +
    +
    + ); + } + + // Default state — wallet not connected. + return ( +
    +
    +
    +
    + +
    +
    +

    Authorize Wallet

    +

    Connect your wallet to continue.

    +
    +
    + +
    +
    + ); +}; diff --git a/components/tool-ui/crypto/index.ts b/components/tool-ui/crypto/index.ts index 40f7ac0..81dbe58 100644 --- a/components/tool-ui/crypto/index.ts +++ b/components/tool-ui/crypto/index.ts @@ -1,2 +1,4 @@ -export { CryptoPriceCard } from "@/components/tool-ui/crypto/price-card"; -export { CryptoConfirmCard } from "@/components/tool-ui/crypto/confirm-card"; +export { CryptoPriceCard } from "./price-card"; +export { ConnectWalletCard } from "./connect-wallet-card"; +export { PlaceCryptoOrderCard } from "./place-crypto-order-card"; +export { OrderStatusCard } from "./order-status-card"; diff --git a/components/tool-ui/crypto/order-status-card.tsx b/components/tool-ui/crypto/order-status-card.tsx new file mode 100644 index 0000000..1b87f23 --- /dev/null +++ b/components/tool-ui/crypto/order-status-card.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState } from "react"; +import { + AlertCircleIcon, + CheckCircle2Icon, + ClockIcon, + CoinsIcon, + Loader2Icon, + XCircleIcon, +} from "lucide-react"; +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; + +import { Button } from "@/components/ui/button"; +import { AddressOrHash } from "@/components/ui/address-or-hash"; +import { cn } from "@/lib/utils"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; + +// OrderStatusCard — atomic status-check card. Reads the order_uid + +// chain_id passed by the LLM, displays them, and on user click +// synthesizes a status (this is a simulated-order demo — the real CoW +// /orders/{uid} endpoint can't find the synthetic uid from +// place_crypto_order). The synthesized status flows back to the LLM. + +type Args = { + order_uid: string; + chain_id: number; +}; + +type ResumePayload = { + status: "filled" | "open" | "partially_filled" | "cancelled" | "expired" | "not_found"; + order_uid: string; + chain_id: number; + filled_buy_amount?: string; + executed_at?: string; +}; + +function chainName(chainId: number): string { + switch (chainId) { + case 1: + return "Ethereum"; + case 42161: + return "Arbitrum One"; + case 8453: + return "Base"; + case 11155111: + return "Sepolia"; + default: + return `Chain ${chainId}`; + } +} + +function parseResult(raw: unknown): ResumePayload | null { + return unwrapToolResult(raw); +} + +export const OrderStatusCard: ToolCallMessagePartComponent = ({ result, args }) => { + const sendCommand = useLangGraphSendCommand(); + const [checking, setChecking] = useState(false); + + const orderUid = args?.order_uid ?? ""; + const chainId = args?.chain_id ?? 0; + const parsed = parseResult(result); + + // Resolved terminal states. + if (parsed) { + return ; + } + + // Interactive state — show the uid + a Check button. + const handleCheck = () => { + setChecking(true); + // Synthetic status — the order came from place_crypto_order, which + // never actually submits to CoW. For demo purposes, deterministically + // fill it (matches the "place then check" narrative). + const payload: ResumePayload = { + status: "filled", + order_uid: orderUid, + chain_id: chainId, + filled_buy_amount: "0", + executed_at: new Date().toISOString(), + }; + sendCommand({ resume: JSON.stringify(payload) }); + }; + + return ( +
    +
    +
    +
    + +
    +
    +

    Swap Status

    +

    {chainName(chainId)} · simulated

    +
    +
    + +
    +
    +
    Quote id
    +
    + +
    +
    +
    +
    Chain
    +
    {chainName(chainId)}
    +
    +
    +
    Status
    +
    + pending check +
    +
    +
    + + +
    +
    + ); +}; + +function StatusReceipt({ status }: { status: ResumePayload }) { + const isFilled = status.status === "filled"; + const isOpen = status.status === "open" || status.status === "partially_filled"; + const isCancelled = status.status === "cancelled" || status.status === "expired"; + const isMissing = status.status === "not_found"; + + return ( +
    +
    +
    +
    + {isFilled ? ( + + ) : isOpen ? ( + + ) : ( + + )} +
    +
    +

    + {isFilled + ? "Quote Accepted" + : isOpen + ? "Quote Pending" + : isMissing + ? "Quote Not Found" + : "Quote Closed"} +

    +

    + {chainName(status.chain_id)} · simulated +

    +
    + + {status.status} + +
    + +
    +
    +
    Quote id
    +
    + +
    +
    +
    +
    Filled qty
    +
    + {status.filled_buy_amount && status.filled_buy_amount !== "0" + ? status.filled_buy_amount + : "—"} +
    +
    +
    +
    Executed at
    +
    + {status.executed_at ? new Date(status.executed_at).toLocaleString() : "—"} +
    +
    +
    +
    +
    + + Simulated status. The uid is a placeholder — nothing was broadcast on-chain. +
    +
    + ); +} diff --git a/components/tool-ui/crypto/place-crypto-order-card.tsx b/components/tool-ui/crypto/place-crypto-order-card.tsx new file mode 100644 index 0000000..b05a194 --- /dev/null +++ b/components/tool-ui/crypto/place-crypto-order-card.tsx @@ -0,0 +1,617 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + AlertCircleIcon, + ArrowDownIcon, + CheckCircle2Icon, + CoinsIcon, + FuelIcon, + GaugeIcon, + Loader2Icon, + SparklesIcon, + WalletIcon, + XCircleIcon, +} from "lucide-react"; +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; + +import { Button } from "@/components/ui/button"; +import { AddressOrHash } from "@/components/ui/address-or-hash"; +import { formatAmount, formatQty } from "@/lib/decimal"; +import { cn } from "@/lib/utils"; +import { + fetchPrices, + priceIsFallback, + ethGasToMockCoin, + MOCK_COIN_SYMBOL, + MOCK_COIN_BALANCE, +} from "@/lib/prices/coingecko"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; + +// PlaceCryptoOrderCard — atomic simulated swap card. Hardcoded source +// is Mock Coin (the user starts with 10,000 MC — no wallet balance +// lookup, no Alchemy call). The LLM passes the user's intent as +// { target_coin_id, amount? } — the card prices the target via live +// CoinGecko, polls every 30s with a visible countdown, lets the user +// pick slippage + simulated gas tier (gas is converted to MC at the +// live ETH/USD price), and on Accept Swap synthesizes a quote — no +// real signing, no real submission. The receipt shows total MC spent +// (base + gas) so the user can see what the simulated flow "cost". + +type Intent = { + target_coin_id: string | null; + amount: number | null; +}; + +type Args = { + target_coin_id?: string; + amount?: number; +}; + +type QuoteSnapshot = { + targetUsd: number; + targetIsFallback: boolean; + fetchedAt: number; +}; + +type TargetToken = { + coinId: string; + symbol: string; + name: string; +}; + +type SlippageBps = 10 | 50 | 100 | 300; +type GasTier = "slow" | "standard" | "fast"; + +type SimulatedOrder = { + id: string; + /** Always "mock-coin". Kept on the order so the receipt can show the source verbatim. */ + source_coin_id: string; + target_coin_id: string; + target_symbol: string; + /** MC spent on the swap itself (excludes gas). */ + amount_mc: number; + /** MC equivalent of the chosen gas tier, converted at the live ETH/USD price. */ + gas_fee_mc: number; + /** Convenience: amount_mc + gas_fee_mc. The receipt shows this prominently. */ + total_mc: number; + /** Target token quantity computed from amount_mc / targetUsd. */ + qty: number; + status: string; + timestamp: string; + note: string; + slippage_bps: number; + gas_tier: GasTier; + gas_fee_eth: number; +}; + +type ResumePayload = + | { status: "simulated_filled"; order: SimulatedOrder } + | { status: "cancelled" } + | { status: "error"; error: string }; + +const POLL_INTERVAL_MS = 30_000; +const DEFAULT_AMOUNT_MC = 100; +const SLIPPAGE_PRESETS: SlippageBps[] = [10, 50, 100, 300]; +const GAS_TIERS: { id: GasTier; label: string; eth: number }[] = [ + { id: "slow", label: "Slow", eth: 0.00012 }, + { id: "standard", label: "Standard", eth: 0.00018 }, + { id: "fast", label: "Fast", eth: 0.00027 }, +]; + +function cryptoRandomId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return (crypto as Crypto).randomUUID(); + } + return Math.random().toString(36).slice(2); +} + +function symbolForCoinId(coinId: string): string { + const known: Record = { + bitcoin: "BTC", + ethereum: "ETH", + "usd-coin": "USDC", + tether: "USDT", + "wrapped-bitcoin": "WBTC", + }; + return known[coinId] ?? coinId.split("-").pop()!.toUpperCase(); +} + +function nameForCoinId(coinId: string): string { + const known: Record = { + bitcoin: "Bitcoin", + ethereum: "Ether", + "usd-coin": "USD Coin", + tether: "Tether", + "wrapped-bitcoin": "Wrapped Bitcoin", + }; + return known[coinId] ?? coinId; +} + +function parseResult(raw: unknown): ResumePayload | null { + return unwrapToolResult(raw); +} + +export const PlaceCryptoOrderCard: ToolCallMessagePartComponent = ({ result, args }) => { + const sendCommand = useLangGraphSendCommand(); + const parsed = parseResult(result); + + // Resolved terminal states ------------------------------------------------ + if (parsed?.status === "simulated_filled") { + return ; + } + if (parsed?.status === "cancelled") { + return ( +
    + Swap cancelled. +
    + ); + } + if (parsed?.status === "error") { + return ( +
    + + Quote failed: {parsed.error} +
    + ); + } + + return ( + sendCommand({ resume: JSON.stringify(payload) })} + /> + ); +}; + +function parseArgs(args: Args | undefined): Intent { + return { + target_coin_id: args?.target_coin_id ?? null, + amount: args?.amount ?? null, + }; +} + +// --- Preview workspace ------------------------------------------------------ + +function PreviewWorkspace({ + intent, + onResolve, +}: { + intent: Intent; + onResolve: (payload: ResumePayload) => void; +}) { + const [submitting, setSubmitting] = useState(false); + const [quote, setQuote] = useState(null); + const [quoteError, setQuoteError] = useState(null); + const [now, setNow] = useState(Date.now()); + const [slippageBps, setSlippageBps] = useState(50); + const [gasTier, setGasTier] = useState("standard"); + + const targetToken = useMemo(() => { + const coinId = intent.target_coin_id?.toLowerCase() ?? null; + if (!coinId) return null; + return { + coinId, + symbol: symbolForCoinId(coinId), + name: nameForCoinId(coinId), + }; + }, [intent.target_coin_id]); + + const amountMc = useMemo(() => { + if (intent.amount != null && Number.isFinite(intent.amount) && intent.amount > 0) { + return intent.amount; + } + return DEFAULT_AMOUNT_MC; + }, [intent.amount]); + + // Live CoinGecko price ticker for the target. Polls every 30s; the + // visible countdown ticks down to the next refresh so the user sees + // when the "You receive" number will move next. + // + // Dep is targetToken only — `amountMc` belongs to the amount slider / + // gas picker, not the price feed. Re-fetching on amount change used to + // reset the countdown mid-tick, so the timer never reached 0 visibly. + useEffect(() => { + if (!targetToken) { + setQuote(null); + return; + } + const ctrl = new AbortController(); + let cancelled = false; + const refresh = () => { + fetchPrices([targetToken.coinId], ctrl.signal) + .then((prices) => { + if (cancelled || ctrl.signal.aborted) return; + const targetUsd = prices[targetToken.coinId]; + if (targetUsd == null || targetUsd <= 0) { + setQuoteError("Price unavailable for this coin"); + return; + } + setQuoteError(null); + setQuote({ + targetUsd, + targetIsFallback: priceIsFallback(targetToken.coinId, targetUsd), + fetchedAt: Date.now(), + }); + }) + .catch(() => { + /* swallow — previous quote stays visible */ + }); + }; + refresh(); + const interval = setInterval(refresh, POLL_INTERVAL_MS); + return () => { + cancelled = true; + ctrl.abort(); + clearInterval(interval); + }; + }, [targetToken]); + + // Countdown ticker — drives the "next refresh in Ns" label. + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + // Live gas cost in MC (recomputed each render so it tracks the live ETH price). + const gasFeeEth = GAS_TIERS.find((g) => g.id === gasTier)!.eth; + const gasFeeMc = useMemo(() => { + if (!quote) return 0; + return ethGasToMockCoin(gasFeeEth, quote.targetUsd); + }, [quote, gasFeeEth]); + + const handleCancel = () => onResolve({ status: "cancelled" }); + + if (!targetToken) { + return ( + +
    + + No target coin specified — name a coin (e.g. "buy 0.1 ETH") to get a quote. +
    + +
    + ); + } + + const receiveSymbol = targetToken.symbol; + const receiveQty = quote && quote.targetUsd > 0 ? amountMc / quote.targetUsd : 0; + const canPlace = !!quote && !submitting; + + const handlePlace = () => { + if (submitting || !quote || !targetToken) return; + setSubmitting(true); + const orderId = `ord_${cryptoRandomId()}`; + const totalMc = Number((amountMc + gasFeeMc).toFixed(4)); + const order: SimulatedOrder = { + id: orderId, + source_coin_id: "mock-coin", + target_coin_id: targetToken.coinId, + target_symbol: targetToken.symbol, + amount_mc: amountMc, + gas_fee_mc: gasFeeMc, + total_mc: totalMc, + qty: receiveQty, + status: "simulated_filled", + timestamp: new Date().toISOString(), + note: `Simulated swap. Spent ${amountMc} ${MOCK_COIN_SYMBOL} + ${gasFeeMc.toFixed(4)} ${MOCK_COIN_SYMBOL} gas. Nothing was signed or broadcast on-chain.`, + slippage_bps: slippageBps, + gas_tier: gasTier, + gas_fee_eth: gasFeeEth, + }; + onResolve({ status: "simulated_filled", order }); + }; + + // The first render after a fetch can have `now` from before the + // response arrived, so `now - fetchedAt` can be negative and inflate + // the countdown to 31s. Clamp the elapsed diff at 0 so the visible + // value never exceeds POLL_INTERVAL_MS / 1000. + const elapsedMs = quote ? Math.max(0, now - quote.fetchedAt) : 0; + const secondsUntilRefresh = quote + ? Math.max(0, Math.ceil((POLL_INTERVAL_MS - elapsedMs) / 1000)) + : POLL_INTERVAL_MS / 1000; + + const refreshProgress = + quote && POLL_INTERVAL_MS > 0 + ? Math.max(0, Math.min(1, secondsUntilRefresh / (POLL_INTERVAL_MS / 1000))) + : 0; + + return ( + +
    + + + + {MOCK_COIN_BALANCE.toLocaleString()} {MOCK_COIN_SYMBOL} + + + + Simulated + +
    + +
    + Spend +
    + {MOCK_COIN_SYMBOL} + + {formatAmount(amountMc)} + + of {MOCK_COIN_BALANCE.toLocaleString()} held + + +
    +
    + +
    + +
    + +
    + For +
    + {receiveSymbol} + simulated +
    +
    + +
    +
    + You pay + + {formatAmount(amountMc)}{" "} + {MOCK_COIN_SYMBOL} + +
    +
    + You receive (est) + + {quote ? ( + <> + {formatQty(receiveQty)}{" "} + {receiveSymbol} + + ) : ( + + )} + +
    + {quote ? ( +
    + + 1 {receiveSymbol} ≈ ${quote.targetUsd.toLocaleString()} + {quote.targetIsFallback ? " (fallback)" : ""} + +
    + ) : quoteError ? ( +
    + {quoteError} +
    + ) : null} +
    + +
    +
    + + Slippage + + {(slippageBps / 100).toFixed(2)}% +
    +
    + {SLIPPAGE_PRESETS.map((bps) => ( + + ))} +
    +
    + +
    +
    + + Simulated Gas + + + {quote ? `${gasFeeMc.toFixed(4)} ${MOCK_COIN_SYMBOL}` : `${gasFeeEth} ETH`} + +
    +
    + {GAS_TIERS.map((tier) => ( + + ))} +
    +
    + +
    + Total spent + + {quote + ? `${(amountMc + gasFeeMc).toFixed(4)} ${MOCK_COIN_SYMBOL}` + : `${amountMc} ${MOCK_COIN_SYMBOL}`} + +
    + +
    + + +
    +
    + ); +} + +// --- Shell + receipt -------------------------------------------------------- + +function CardShell({ children }: { children: React.ReactNode }) { + return ( +
    +
    +
    +
    + +
    +
    +

    Swap Quote

    +

    + Prices are live; nothing will be signed or sent. +

    +
    +
    + {children} +
    +
    + ); +} + +function SimulatedReceipt({ order }: { order: SimulatedOrder }) { + return ( +
    +
    +
    +
    + +
    +
    +

    Swap Accepted

    +

    Nothing was signed or broadcast.

    +
    + + SIMULATED + +
    +
    +
    +
    You paid
    +
    + {formatAmount(order.amount_mc)} {MOCK_COIN_SYMBOL} +
    +
    +
    +
    You would receive
    +
    + {formatQty(order.qty)} {order.target_symbol} +
    +
    +
    +
    Simulated gas
    +
    + {order.gas_fee_mc.toFixed(4)} {MOCK_COIN_SYMBOL} +
    +
    +
    +
    Total spent
    +
    + {order.total_mc.toFixed(4)} {MOCK_COIN_SYMBOL} +
    +
    +
    +
    Slippage
    +
    {(order.slippage_bps / 100).toFixed(2)}%
    +
    +
    +
    Time
    +
    + {new Date(order.timestamp).toLocaleString()} +
    +
    +
    +
    Quote id
    +
    + +
    +
    +
    +
    +
    + + {order.note} +
    +
    + ); +} diff --git a/components/tool-ui/toolkit.tsx b/components/tool-ui/toolkit.tsx index 33fc2ef..a53e55c 100644 --- a/components/tool-ui/toolkit.tsx +++ b/components/tool-ui/toolkit.tsx @@ -5,7 +5,12 @@ import { z } from "zod"; import { AskLocationCard } from "@/components/tool-ui/ask-location/ask-location-card"; import { WeatherCard } from "@/components/tool-ui/weather/weather-card"; -import { CryptoConfirmCard, CryptoPriceCard } from "@/components/tool-ui/crypto"; +import { + ConnectWalletCard, + CryptoPriceCard, + OrderStatusCard, + PlaceCryptoOrderCard, +} from "@/components/tool-ui/crypto"; // Frontend-side tool registrations. `execute` lives on the LangGraph // backend (backend/tool/) and is dispatched via useLangGraphRuntime — @@ -46,20 +51,35 @@ const cryptoToolkit = defineToolkit({ }), render: CryptoPriceCard, }, - confirm_crypto_order: { - // The tool emits a wallet-aware swap card. LLM passes intent - // (side + optional source/amount/target); the card wakes the - // wallet, lists balances, fetches a CoW quote, and exposes one - // Sign button. - description: - "Render a wallet-aware swap card. Pauses the turn; the user clicks Sign to submit an EIP-712 order to CoW.", + connect_wallet: { + description: "Render a wallet-authorization card. No-op on the server.", + parameters: z.object({ + message: z.string().optional(), + }), + render: ConnectWalletCard, + }, + place_crypto_order: { + // Pauses for one user click. Reads the wallet from wagmi + // (auto-inferred from the most recent connect_wallet ToolMessage); + // picks a randomized source / target / amount, fetches a real-time + // CoW /quote for accurate pricing, and on click synthesizes a + // simulated order — no real signing, no real CoW /orders POST. + description: "Render a simulated swap card and pause for the user to click Place order.", parameters: z.object({ side: z.enum(["buy", "sell"]), source_coin_id: z.string().optional(), amount: z.number().optional(), target_coin_id: z.string().optional(), }), - render: CryptoConfirmCard, + render: PlaceCryptoOrderCard, + }, + get_order_status: { + description: "Render an order-status card and pause for the user to click Check.", + parameters: z.object({ + order_uid: z.string(), + chain_id: z.number(), + }), + render: OrderStatusCard, }, }); diff --git a/cspell.json b/cspell.json index 8fa24fe..0584919 100644 --- a/cspell.json +++ b/cspell.json @@ -57,6 +57,7 @@ "nextjs", "nocheck", "nodejs", + "nums", "noop", "nostream", "orm", @@ -103,6 +104,7 @@ "txid", "unarchives", "ui", + "uppercases", "upsert", "url", "urls", @@ -138,6 +140,7 @@ "ebase", "etherscan", "ethereum", + "firstchef", "gnosis", "hyperliquid", "ink", diff --git a/docs/APIS.md b/docs/APIS.md index 8cfdafb..f2f69bc 100644 --- a/docs/APIS.md +++ b/docs/APIS.md @@ -150,7 +150,7 @@ Read-only FX lookup via frankfurter.app (ECB-sourced). Has a 60s in-memory cache ### `get_token_balances(chainId, address)` -**Not currently exposed to the LLM.** Listed here for reference only — the file and tests are kept in `backend/tool/crypto/get-token-balances.ts` for direct programmatic use, but `CRYPTO_TOOLS` does not register it. The wallet's address is not in the LLM's context (it lives in wagmi/RainbowKit on the frontend), so the agent has no way to call this tool without inventing an address. The `ask_crypto_intent` card reads balances via `useBalance` directly. If you want to re-enable this tool, register it in `backend/tool/index.ts` and update `CRYPTO_AGENT_PROMPT` step 3. +**Not currently exposed to the LLM.** Listed here for reference only — the file and tests are kept in `backend/tool/crypto/get-token-balances.ts` for direct programmatic use, but `CRYPTO_TOOLS` does not register it. The wallet's address is not in the LLM's context (it lives in wagmi/RainbowKit on the frontend), so the agent has no way to call this tool without inventing an address. The confirm card reads balances via `/api/alchemy/portfolio/tokens/by-address` directly. If you want to re-enable this tool, register it in `backend/tool/index.ts` and update `CRYPTO_AGENT_PROMPT` step 2. ### `get_swap_quote(chainId, sellToken, buyToken, amount, kind, slippageBps?)` @@ -163,19 +163,38 @@ Read-only. Returns a CoW Protocol quote for a given token pair. CoW is the canon | Auth | None. Public CoW endpoint. | | Failure modes | Unsupported chainId / zero amount → `{ success: false, error: "..." }` without any fetch. CoW 4xx/5xx → `{ success: false, error: "cow N: " }` (e.g. `NoLiquidity`). | -### `ask_crypto_intent(message?, currency?, amount?)` +**Currently called from the place-crypto-order card, not from the LLM.** The card reads source/amount/target from the intent and the user's wallet, hits `/quote` itself (`fetchCowQuote` in `components/tool-ui/crypto/place-crypto-order-card.tsx`), and renders the live preview. The LLM-facing flow is split into 3 atomic tools — `connect_wallet`, `place_crypto_order`, `get_order_status` — that the LLM calls one per turn. -Interrupt-driven. Pauses the turn and renders the buy/sell form. Used as the **user confirmation checkpoint** before any swap is staged — the LLM must call it even when the user already named a coin + amount, so the user can explicitly confirm. Implementation: `backend/tool/crypto/ask-crypto-intent.ts`. +### `connect_wallet(message?)` -### `confirm_crypto_order(coin_id, coin_symbol, amount_usd, price_at_confirm, side, mode?, chain_id?, slippage_bps?)` +Wallet-authorization interrupt. Pauses via `interrupt()`; the frontend card opens RainbowKit, then resumes with `{address, chainId}` from wagmi. That becomes the ToolMessage content the LLM reads. Subsequent tools (`place_crypto_order`, `get_order_status`) auto-infer the address from wagmi state — the LLM does not need to thread an address through the schema. -Two-phase pause + commit. First call returns `{ status: "awaiting_user", preview: {...} }` — never fabricates a receipt. The frontend card renders the preview with the Confirm & Sign button. On click, the card signs the EIP-712 order (CoW /orders POST) and overwrites the tool result via `addResult` with `{ status: "simulated_filled" | "signed" | "cancelled" | "error" }`. The LLM then writes the closing sentence. +| | | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Input | `message?` (string) — short prompt shown above the Connect button. Defaults to `"Connect your wallet to continue."` if omitted. | +| Output | `{ address: 0x...string, chainId: number }` (success) or `{ error: string }` (user cancelled / wallet not installed). Becomes the ToolMessage content. | +| Failure modes | User closes RainbowKit modal without connecting → no resume → turn does not progress. The card stays in the "Connect" state until the user reconnects. | -| | | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Input | Standard order fields. `mode="simulated"` (default) is paper-trading. `mode="real"` requires `chain_id` ∈ {1, 42161, 8453} and a connected wallet. `slippage_bps` is hard-capped at 3000 (30%) at the schema layer. | -| Output | Phase 1: `{ status: "awaiting_user", preview: {...} }`. Phase 2 (after `addResult`): `{ status: "simulated_filled", order: {...} }` / `{ status: "signed", tx_hash, chain_id, order }` / `{ status: "cancelled" }` / `{ status: "error", error }`. | -| Failure modes | Non-positive amount / price → `{ status: "error" }`. `mode="real"` without `chain_id` → `{ status: "error" }`. Unsupported `chain_id` → `{ status: "error" }`. `slippage_bps` over 3000 → zod rejection (before tool body). | +### `place_crypto_order(side, source_coin_id?, amount?, target_coin_id?)` + +Simulated swap interrupt. Pauses via `interrupt()`; the frontend card reads the wallet from wagmi (auto-inferred from the most recent `connect_wallet` ToolMessage — never pass an address), fetches the user's balances from Alchemy, picks a randomized source/target/amount (the LLM's hints override the random pick when provided), fetches a real-time CoW `/quote` for accurate pricing display, and exposes one Place simulated order button. On click, the card synthesizes an order — no real signing, no real CoW `/orders` POST. The closing ToolMessage is what the LLM uses to write the final sentence. + +| | | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Input | `side` (required, `"buy"` or `"sell"`) — `"sell my X"` / `"swap X for Y"` → sell; `"buy Y with X"` → buy. `source_coin_id?` (CoinGecko id) when the user named a source. `amount?` (positive number) when the user named a quantity. `target_coin_id?` when the user named what to receive. | +| Output | `{ status: "simulated_filled", order: { id, coin, symbol, side, amount_human, qty, status, timestamp, note, slippage_bps } }` (success) or `{ status: "cancelled" }` (Cancel clicked) or `{ status: "error", error: string }` (quote failed / unexpected). | +| Failure modes | Malformed CoinGecko id → zod rejection. Id not in `lib/tokens/catalog.ts` → zod rejection. Non-positive or NaN `amount` → zod rejection. CoW `/quote` errors (NoLiquidity, etc.) surface inside the card; the user can still click Cancel. | +| Missing source | If the LLM named a `source_coin_id` the user's wallet does not hold, the card picks the highest-balance token as a fallback. The LLM should NOT silently re-call with a different source — it should re-call only after the user explicitly names an alternative. | + +### `get_order_status(order_uid, chain_id)` + +Order-status interrupt. Pauses via `interrupt()`; the frontend card shows the order uid + chain and exposes one Check button. This is a simulated-order demo — the card never hits the real CoW `/orders/{uid}` endpoint (the synthetic uid from `place_crypto_order` would 404). On click, the card synthesizes a status (`filled` for the demo path) and returns it via the resume. + +| | | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Input | `order_uid` (required, non-empty string) — the order uid returned by `place_crypto_order`. `chain_id` (required, integer) — EVM chain id where the order was placed: 1, 42161, 8453, or 11155111. | +| Output | `{ status: "filled" \| "open" \| "partially_filled" \| "cancelled" \| "expired" \| "not_found", order_uid, chain_id, filled_buy_amount?, executed_at? }`. | +| Failure modes | Empty `order_uid` or non-numeric `chain_id` → zod rejection. | ## Swap path diff --git a/docs/TODOS.md b/docs/TODOS.md index 061ddc5..7f4e16d 100644 --- a/docs/TODOS.md +++ b/docs/TODOS.md @@ -82,6 +82,10 @@ Tracked as PR #1 review-comment follow-ups; none are blocking merge. (uncommitted). Pin a patch in `patches/` once the public API stops moving (it's `0.0.x`, experimental). +## 2026-06-28 — RAG follow-up + +Reference if/when RAG lands: + ## 2026-06-26 — Crypto agent v2: currency + wallet UI Follow-up to the 2026-06-25 crypto agent. Two new requirements: diff --git a/lib/alchemy/networks.ts b/lib/alchemy/networks.ts index 206e240..089da14 100644 --- a/lib/alchemy/networks.ts +++ b/lib/alchemy/networks.ts @@ -347,3 +347,16 @@ export function getNetworkLogoByChainId(chainId: number | null | undefined): str if (chainId == null) return null; return CHAIN_ID_TO_LOGO.get(chainId) ?? null; } + +// Reverse lookup keyed by chainId — the catalog itself is slug → entry +// (Alchemy's primary key), but the swap card groups by chainId, so the +// chain-group header needs `isTestnet(cid)` to decide whether to add +// the testnet marker. Built once at module load. +const CHAIN_ID_TO_ENTRY: Map = new Map( + Object.values(ALCHEMY_NETWORK_CATALOG).map((e) => [e.chainId, e]), +); + +export function isTestnetChainId(chainId: number | null | undefined): boolean { + if (chainId == null) return false; + return CHAIN_ID_TO_ENTRY.get(chainId)?.family === "testnet"; +} diff --git a/lib/alchemy/portfolio.ts b/lib/alchemy/portfolio.ts index ce67aa1..c22b205 100644 --- a/lib/alchemy/portfolio.ts +++ b/lib/alchemy/portfolio.ts @@ -17,13 +17,14 @@ import { // per ERC20) so a single call returns the entire wallet picture across // every supported chain — no N+1 round-trips. -// Full mainnet + L2 catalog (testnets excluded). Source of truth lives -// in `lib/alchemy/networks.ts`; if a new chain ships, add it there and -// this list picks it up automatically. -export const PORTFOLIO_NETWORKS: readonly AlchemyNetworkSlug[] = ( - Object.keys(ALCHEMY_NETWORK_CATALOG) as AlchemyNetworkSlug[] -).filter((slug) => ALCHEMY_NETWORK_CATALOG[slug].family !== "testnet"); -export type PortfolioNetwork = (typeof PORTFOLIO_NETWORKS)[number]; +// The Portfolio API caps at 5 networks per address per request. The +// catalog in `lib/alchemy/networks.ts` is the source of truth for +// what's queryable; we send every catalog entry, chunked into +// 5-network batches, in parallel, and merge the results. This is +// independent of which chains the user can actually swap on — the +// card filters to swappable tokens at render time. +const PORTFOLIO_CHUNK_SIZE = 5; +export type PortfolioNetwork = AlchemyNetworkSlug; export function networkToChainId(network: string): number | null { const entry = ALCHEMY_NETWORK_CATALOG[network as PortfolioNetwork]; @@ -85,28 +86,38 @@ export async function fetchEnrichedBalances( address: Address, signal?: AbortSignal, ): Promise { - const res = await fetch("/api/alchemy/portfolio/tokens/by-address", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - addresses: [{ address, networks: [...PORTFOLIO_NETWORKS] }], - withMetadata: true, - withPrices: true, - includeNativeTokens: true, - includeErc20Tokens: true, - }), - signal, - }); - if (!res.ok) { - const errText = await res.text().catch(() => ""); - throw new Error(`portfolio ${res.status}${errText ? `: ${errText.slice(0, 200)}` : ""}`); + const allNetworks = Object.keys(ALCHEMY_NETWORK_CATALOG) as AlchemyNetworkSlug[]; + const chunks: AlchemyNetworkSlug[][] = []; + for (let i = 0; i < allNetworks.length; i += PORTFOLIO_CHUNK_SIZE) { + chunks.push(allNetworks.slice(i, i + PORTFOLIO_CHUNK_SIZE)); } - const json = (await res.json()) as PortfolioResponse; - const raw = json.data?.tokens ?? []; + const responses = await Promise.all( + chunks.map((networks) => + fetch("/api/alchemy/portfolio/tokens/by-address", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + addresses: [{ address, networks: [...networks] }], + withMetadata: true, + withPrices: true, + includeNativeTokens: true, + includeErc20Tokens: true, + }), + signal, + }), + ), + ); const out: EnrichedToken[] = []; - for (const t of raw) { - const normalized = normalize(t); - if (normalized) out.push(normalized); + for (const res of responses) { + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`portfolio ${res.status}${errText ? `: ${errText.slice(0, 200)}` : ""}`); + } + const json = (await res.json()) as PortfolioResponse; + for (const t of json.data?.tokens ?? []) { + const normalized = normalize(t); + if (normalized) out.push(normalized); + } } return out; } @@ -114,7 +125,7 @@ export async function fetchEnrichedBalances( function normalize(token: RawToken): EnrichedToken | null { const network = token.network as PortfolioNetwork | undefined; const entry = network ? ALCHEMY_NETWORK_CATALOG[network] : null; - if (!entry || entry.family === "testnet") return null; + if (!entry) return null; const meta = token.tokenMetadata ?? {}; // Native entries have `tokenAddress: null`. Portfolio's native token diff --git a/lib/prices/coingecko.ts b/lib/prices/coingecko.ts new file mode 100644 index 0000000..0f737d4 --- /dev/null +++ b/lib/prices/coingecko.ts @@ -0,0 +1,125 @@ +// ponytail: browser-side CoinGecko price fetcher with in-memory cache. +// Used by the place-crypto-order card to compute receive amounts in real +// time (no CoW /quote). Free tier, no key needed. +// +// Fallback table is consulted when CoinGecko fails (rate-limit, network, +// etc.) so the demo never goes blank — the card shows a stale or fake +// price with a "fallback" tag instead. + +const COINGECKO_BASE = "https://api.coingecko.com/api/v3"; + +type PriceMap = Record; + +const cache = new Map(); +const CACHE_TTL_MS = 30_000; + +// ponytail: exported for tests only. Production callers never need it. +export function _resetPriceCacheForTests(): void { + cache.clear(); +} + +// Hardcoded fallback prices in USD. Last-known-good values — used when +// CoinGecko is unreachable so the demo always shows a number. Add a +// slug here when adding a new well-known token. +const FALLBACK_PRICES_USD: Record = { + "usd-coin": 1, + tether: 1, + ethereum: 2500, + "wrapped-bitcoin": 60000, + bitcoin: 60000, +}; + +export async function fetchPrices(coinIds: string[], signal?: AbortSignal): Promise { + const wanted = [...new Set(coinIds.map((c) => c.toLowerCase()))]; + const result: PriceMap = {}; + + // Pull anything still warm from the cache. + const missing: string[] = []; + for (const id of wanted) { + const cached = cache.get(id); + if (cached && cached.exp > Date.now()) result[id] = cached.value[id]; + else missing.push(id); + } + if (missing.length === 0) return result; + + try { + // CoinGecko renamed `vs_currency` → `vs_currencies` (plural) on the + // /simple/price endpoint in 2025. The singular form now returns + // 422 "Missing parameter vs_currencies". The /coins/markets + // endpoint still uses the singular form, so the two callers differ. + const params = new URLSearchParams({ + vs_currencies: "usd", + ids: missing.join(","), + }); + const url = `${COINGECKO_BASE}/simple/price?${params.toString()}`; + const res = await fetch(url, { + headers: { Accept: "application/json" }, + signal, + }); + if (!res.ok) throw new Error(`coingecko ${res.status}`); + const raw = (await res.json()) as Record; + for (const id of missing) { + const usd = raw[id]?.usd; + if (typeof usd === "number" && Number.isFinite(usd)) { + result[id] = usd; + cache.set(id, { exp: Date.now() + CACHE_TTL_MS, value: { [id]: usd } }); + } + } + } catch { + // Ignore — fall back below. + } + + // Fill anything still missing from the hardcoded fallback table. + for (const id of missing) { + if (result[id] == null) { + const fb = FALLBACK_PRICES_USD[id]; + if (fb != null) result[id] = fb; + } + } + + return result; +} + +// Indicates whether a price came from a live fetch or the hardcoded +// fallback. The card renders a small tag so the user knows when the +// number isn't real. +export function priceIsFallback(coinId: string, price: number | undefined): boolean { + if (price == null) return true; + const fb = FALLBACK_PRICES_USD[coinId.toLowerCase()]; + return fb != null && fb === price; +} + +// Mock Coin (MC) — the simulated-flow currency the user "spends" in every +// swap quote. Pegged to $1 USD so all conversions are 1:1 with USD. The +// wallet is auto-funded with MOCK_COIN_BALANCE on first interaction. +// +// Why $1: lets the user think in familiar numbers ("I have $10,000 to +// play with"), keeps the gas-tier cost comparison trivial (0.00018 ETH ≈ +// $0.28 ≈ 0.28 MC), and avoids needing a price fetch for the source side +// of the quote. The card does NOT round-trip through USD — gas fees are +// converted directly via the live ETH/USD price the quote already loaded. + +export const MOCK_COIN_ID = "mock-coin"; +export const MOCK_COIN_USD = 1; +export const MOCK_COIN_SYMBOL = "MC"; +export const MOCK_COIN_BALANCE = 10_000; + +// Convert a USD value to Mock Coin (always 1:1, but keep the helper so +// the call sites read intent). +export function usdToMockCoin(usd: number): number { + return usd / MOCK_COIN_USD; +} + +// Convert a Mock Coin amount back to USD for sanity checks / tests. +export function mockCoinToUsd(mc: number): number { + return mc * MOCK_COIN_USD; +} + +// Convert an ETH-denominated gas fee to Mock Coin at the live ETH/USD +// price the quote already loaded. Rounded to 4 dp so the UI doesn't +// show "0.00018273 MC" — that level of precision is noise. +export function ethGasToMockCoin(ethAmount: number, ethUsd: number): number { + if (!Number.isFinite(ethAmount) || !Number.isFinite(ethUsd) || ethUsd <= 0) return 0; + const usd = ethAmount * ethUsd; + return Number(usd.toFixed(4)); +} diff --git a/lib/wagmi.ts b/lib/wagmi.ts index 79cf5cb..8701a33 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -1,7 +1,7 @@ "use client"; import { http } from "wagmi"; -import { mainnet, arbitrum, base } from "wagmi/chains"; +import { mainnet, arbitrum, base, sepolia } from "wagmi/chains"; import { getDefaultConfig } from "@rainbow-me/rainbowkit"; import { binanceWallet, @@ -17,6 +17,7 @@ import { rainbowWallet, safeWallet, walletConnectWallet, + gateWallet, } from "@rainbow-me/rainbowkit/wallets"; import "@rainbow-me/rainbowkit/styles.css"; @@ -45,13 +46,14 @@ const alchemyTransport = (slug: string) => http(`/api/alchemy/${slug}`, { batch: const WALLETCONNECT_PROJECT_ID = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID ?? ""; export const wagmiConfig = getDefaultConfig({ - appName: "LangGraph App", + appName: "FireTable", projectId: WALLETCONNECT_PROJECT_ID, - chains: [mainnet, arbitrum, base], + chains: [mainnet, arbitrum, base, sepolia], transports: { [mainnet.id]: alchemyTransport("eth-mainnet"), [arbitrum.id]: alchemyTransport("arb-mainnet"), [base.id]: alchemyTransport("base-mainnet"), + [sepolia.id]: alchemyTransport("eth-sepolia"), }, ssr: true, wallets: [ @@ -61,6 +63,7 @@ export const wagmiConfig = getDefaultConfig({ binanceWallet, bitgetWallet, coinbaseWallet, + gateWallet, metaMaskWallet, rainbowWallet, safeWallet, diff --git a/tests/backend/tool/crypto/connect-wallet.test.ts b/tests/backend/tool/crypto/connect-wallet.test.ts new file mode 100644 index 0000000..d15eefc --- /dev/null +++ b/tests/backend/tool/crypto/connect-wallet.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { interrupt } from "@langchain/langgraph"; +import { connectWalletTool } from "@/backend/tool/crypto/connect-wallet"; + +// connect_wallet is a pure interrupt tool — pauses via interrupt(), the +// frontend card resumes with {address, chainId} (from wagmi), and the +// tool returns the same shape to the LLM. + +vi.mock("@langchain/langgraph", async () => { + const actual = + await vi.importActual("@langchain/langgraph"); + return { + ...actual, + interrupt: vi.fn(), + }; +}); + +const fetchMock = vi.fn(); +const interruptMock = interrupt as unknown as ReturnType; + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + interruptMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("connectWalletTool", () => { + it("pauses with ui: connect_wallet and returns the resumed address", async () => { + const resumed = { + address: "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01", + chainId: 8453, + }; + interruptMock.mockReturnValue(resumed); + + const result = await connectWalletTool.invoke({ message: "Connect to swap." }); + + expect(interruptMock).toHaveBeenCalledWith({ + ui: "connect_wallet", + data: {}, + message: "Connect to swap.", + }); + expect(result).toEqual(resumed); + }); + + it("uses a default message when the LLM omits one", async () => { + interruptMock.mockReturnValue({ address: "0xa", chainId: 1 }); + await connectWalletTool.invoke({}); + expect(interruptMock).toHaveBeenCalledWith({ + ui: "connect_wallet", + data: {}, + message: expect.any(String) as string, + }); + }); + + it("forwards an error resume (user cancelled / wallet not installed)", async () => { + const errorPick = { error: "user rejected connection" }; + interruptMock.mockReturnValue(errorPick); + const result = await connectWalletTool.invoke({ message: "Connect." }); + expect(result).toEqual(errorPick); + }); + + it("forwards the card's cancel pick ({error: 'cancelled'}) so the LLM can branch", async () => { + interruptMock.mockReturnValue({ error: "cancelled" }); + const result = await connectWalletTool.invoke({ message: "Connect to swap." }); + expect(result).toEqual({ error: "cancelled" }); + }); + + it("makes no HTTP calls", async () => { + interruptMock.mockReturnValue({ address: "0xa", chainId: 1 }); + await connectWalletTool.invoke({ message: "Connect." }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/backend/tool/crypto/get-order-status.test.ts b/tests/backend/tool/crypto/get-order-status.test.ts new file mode 100644 index 0000000..c1f9ece --- /dev/null +++ b/tests/backend/tool/crypto/get-order-status.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { interrupt } from "@langchain/langgraph"; +import { getOrderStatusTool } from "@/backend/tool/crypto/get-order-status"; + +// get_order_status is a pure trigger. The tool pauses via interrupt(); +// the frontend card (OrderStatusCard) shows the order uid and on click +// synthesizes a status (since this is a fully simulated demo, no +// on-chain /orders endpoint is queried). The tool returns the +// synthesized status to the LLM as-is. + +vi.mock("@langchain/langgraph", async () => { + const actual = + await vi.importActual("@langchain/langgraph"); + return { + ...actual, + interrupt: vi.fn(), + }; +}); + +const fetchMock = vi.fn(); +const interruptMock = interrupt as unknown as ReturnType; + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + interruptMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("getOrderStatusTool", () => { + it("pauses with ui: get_order_status and returns the synthesized status from the card", async () => { + const resume = { + status: "filled" as const, + order_uid: "0xabc123", + chain_id: 8453, + filled_buy_amount: "25500000", + executed_at: "2026-06-27T20:01:00.000Z", + }; + interruptMock.mockReturnValue(resume); + + const result = await getOrderStatusTool.invoke({ + order_uid: "0xabc123", + chain_id: 8453, + message: "Checking the ETH quote status", + }); + + expect(interruptMock).toHaveBeenCalledWith({ + ui: "get_order_status", + data: { order_uid: "0xabc123", chain_id: 8453 }, + message: "Checking the ETH quote status", + }); + expect(result).toEqual(resume); + }); + + it("forwards an 'open' status unchanged", async () => { + interruptMock.mockReturnValue({ status: "open", order_uid: "0xabc", chain_id: 1 }); + const result = await getOrderStatusTool.invoke({ + order_uid: "0xabc", + chain_id: 1, + message: "status check", + }); + expect(result).toEqual({ status: "open", order_uid: "0xabc", chain_id: 1 }); + }); + + it("forwards a 'not_found' / 'cancelled' terminal state", async () => { + interruptMock.mockReturnValue({ status: "not_found", order_uid: "0xabc", chain_id: 1 }); + const result = await getOrderStatusTool.invoke({ + order_uid: "0xabc", + chain_id: 1, + message: "status check", + }); + expect(result.status).toBe("not_found"); + }); + + it("makes no HTTP calls — status is synthesized client-side", async () => { + interruptMock.mockReturnValue({ status: "filled", order_uid: "0xabc", chain_id: 1 }); + await getOrderStatusTool.invoke({ + order_uid: "0xabc", + chain_id: 1, + message: "status check", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects an empty order_uid at the schema layer", async () => { + await expect( + getOrderStatusTool.invoke({ order_uid: "", chain_id: 1, message: "x" }), + ).rejects.toThrow(); + }); + + it("rejects a non-numeric chain_id at the schema layer", async () => { + await expect( + getOrderStatusTool.invoke({ + order_uid: "0xabc", + chain_id: Number.NaN, + message: "x", + }), + ).rejects.toThrow(); + }); +}); diff --git a/tests/backend/tool/crypto/place-crypto-order.test.ts b/tests/backend/tool/crypto/place-crypto-order.test.ts new file mode 100644 index 0000000..5696163 --- /dev/null +++ b/tests/backend/tool/crypto/place-crypto-order.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { interrupt } from "@langchain/langgraph"; +import { placeCryptoOrderTool } from "@/backend/tool/crypto/place-crypto-order"; + +// place_crypto_order is a pure trigger. The tool pauses via interrupt(); +// the frontend card (PlaceCryptoOrderCard) renders a quote against Mock +// Coin (10,000 MC hardcoded balance — no wallet lookup), prices the +// target via live CoinGecko, and on user click resumes with a synthesized +// {status:"simulated_filled", order} object. The tool returns that object +// to the LLM as-is. The schema now only takes target_coin_id + amount — +// the source is hardcoded to Mock Coin in the card. + +vi.mock("@langchain/langgraph", async () => { + const actual = + await vi.importActual("@langchain/langgraph"); + return { + ...actual, + interrupt: vi.fn(), + }; +}); + +const fetchMock = vi.fn(); +const interruptMock = interrupt as unknown as ReturnType; + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + interruptMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("placeCryptoOrderTool", () => { + it("pauses with ui: place_crypto_order and returns the simulated fill from the card", async () => { + const resume = { + status: "simulated_filled" as const, + order: { + id: "ord_test_123", + source_coin_id: "mock-coin", + target_coin_id: "ethereum", + target_symbol: "ETH", + amount_mc: 100, + gas_fee_mc: 0.28, + total_mc: 100.28, + qty: 0.0636, + status: "simulated_filled", + timestamp: "2026-06-27T20:00:00.000Z", + note: "Simulated swap. Spent 100 MC + 0.28 MC gas. Nothing was signed or broadcast on-chain.", + slippage_bps: 50, + gas_tier: "standard", + gas_fee_eth: 0.00018, + }, + }; + interruptMock.mockReturnValue(resume); + + const result = await placeCryptoOrderTool.invoke({ + target_coin_id: "ethereum", + amount: 100, + message: "Swapping 100 MC for ETH", + }); + + expect(interruptMock).toHaveBeenCalledWith({ + ui: "place_crypto_order", + data: { + target_coin_id: "ethereum", + amount: 100, + }, + message: "Swapping 100 MC for ETH", + }); + expect(result).toEqual(resume); + }); + + it("forwards a cancelled resume (user clicked Cancel)", async () => { + const resume = { status: "cancelled" as const }; + interruptMock.mockReturnValue(resume); + const result = await placeCryptoOrderTool.invoke({ + target_coin_id: "ethereum", + message: "ETH swap", + }); + expect(result).toEqual(resume); + }); + + it("forwards an error resume (card-level failure)", async () => { + const resume = { status: "error" as const, error: "CoinGecko unreachable" }; + interruptMock.mockReturnValue(resume); + const result = await placeCryptoOrderTool.invoke({ + target_coin_id: "ethereum", + message: "ETH swap", + }); + expect(result).toEqual(resume); + }); + + it("makes no HTTP calls — the card fetches prices client-side", async () => { + interruptMock.mockReturnValue({ status: "cancelled" }); + await placeCryptoOrderTool.invoke({ + target_coin_id: "ethereum", + message: "ETH swap", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("works with only target_coin_id (amount defaults to 100 MC in the card)", async () => { + interruptMock.mockReturnValue({ status: "cancelled" }); + const result = await placeCryptoOrderTool.invoke({ + target_coin_id: "ethereum", + message: "ETH swap", + }); + expect(result).toEqual({ status: "cancelled" }); + }); + + it("rejects a malformed target CoinGecko id at the schema layer", async () => { + await expect( + placeCryptoOrderTool.invoke({ + target_coin_id: "Bad Id With Spaces", + message: "bad id", + }), + ).rejects.toThrow(); + }); + + it("rejects a non-positive amount at the schema layer", async () => { + await expect( + placeCryptoOrderTool.invoke({ + target_coin_id: "ethereum", + amount: 0, + message: "zero amount", + }), + ).rejects.toThrow(); + }); + + it("rejects a missing message at the schema layer", async () => { + // The schema declares message as required — we bypass TS to test the + // runtime contract (LangGraph's structured-tool validation path). + await expect( + placeCryptoOrderTool.invoke({ target_coin_id: "ethereum" } as never), + ).rejects.toThrow(); + }); +}); diff --git a/tests/lib/alchemy/portfolio.test.ts b/tests/lib/alchemy/portfolio.test.ts index 00b2491..1f5c91e 100644 --- a/tests/lib/alchemy/portfolio.test.ts +++ b/tests/lib/alchemy/portfolio.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ALCHEMY_NETWORK_CATALOG } from "@/lib/alchemy/networks"; import { fetchEnrichedBalances, networkToChainId, @@ -8,8 +9,17 @@ import { const fetchMock = vi.fn(); +const emptyPortfolioResponse = () => + new Response(JSON.stringify({ data: { tokens: [] } }), { status: 200 }); + beforeEach(() => { fetchMock.mockReset(); + // Default for the 4-5 chunked calls beyond the one the test mocks + // explicitly. Each test does `fetchMock.mockResolvedValueOnce(...)` + // once for the chunk whose response it actually wants to inspect. + // `mockImplementation` (not `mockResolvedValue`) so each call gets a + // fresh Response — Response bodies are single-read. + fetchMock.mockImplementation(() => Promise.resolve(emptyPortfolioResponse())); vi.stubGlobal("fetch", fetchMock); }); @@ -18,7 +28,7 @@ afterEach(() => { }); describe("networkToChainId", () => { - it("maps the three CoW chains", () => { + it("maps the three supported chains", () => { expect(networkToChainId("eth-mainnet")).toBe(1); expect(networkToChainId("arb-mainnet")).toBe(42161); expect(networkToChainId("base-mainnet")).toBe(8453); @@ -45,62 +55,28 @@ describe("networkToChainId", () => { }); describe("fetchEnrichedBalances — happy path", () => { - it("posts to /api/alchemy/portfolio/tokens/by-address with the full catalog (no testnets)", async () => { - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { tokens: [] } }), { status: 200 }), - ); + it("chunks every Alchemy catalog network into 5-per-request batches and merges the results", async () => { + // 25 catalog entries / 5 per request = 5 parallel fetches. The + // beforeEach default returns an empty response, so all 5 resolve + // with no tokens — we only assert the request shape. await fetchEnrichedBalances("0xabc"); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe("/api/alchemy/portfolio/tokens/by-address"); - expect(init.method).toBe("POST"); - const body = JSON.parse(init.body); - expect(body.addresses[0].address).toBe("0xabc"); - const sent = body.addresses[0].networks; - // Every L1 + L2 chain in the catalog — no testnets. The catalog - // currently lists 20 mainnet EVM chains (8 L1 + 12 L2); pinned at - // the Portfolio API's 1-20 per-request limit. If you add or remove - // a chain, this test pins the expected request shape. - expect(sent.length).toBe(20); - expect(sent).toEqual( - expect.arrayContaining([ - // L1 - "eth-mainnet", - "polygon-mainnet", - "bnb-mainnet", - "avax-mainnet", - "gnosis-mainnet", - "berachain-mainnet", - "monad-mainnet", - "ronin-mainnet", - // L2 - "arb-mainnet", - "opt-mainnet", - "base-mainnet", - "linea-mainnet", - "scroll-mainnet", - "zksync-mainnet", - "worldchain-mainnet", - "unichain-mainnet", - "blast-mainnet", - "celo-mainnet", - "apechain-mainnet", - "soneium-mainnet", - ]), - ); - expect(sent).not.toEqual( - expect.arrayContaining([ - "eth-sepolia", - "arb-sepolia", - "opt-sepolia", - "polygon-amoy", - "base-sepolia", - ]), - ); - expect(body.withMetadata).toBe(true); - expect(body.withPrices).toBe(true); - expect(body.includeNativeTokens).toBe(true); - expect(body.includeErc20Tokens).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(5); + const allSent: string[] = []; + for (const [, init] of fetchMock.mock.calls) { + const body = JSON.parse(init.body); + expect(body.addresses[0].address).toBe("0xabc"); + expect(body.withMetadata).toBe(true); + expect(body.withPrices).toBe(true); + expect(body.includeNativeTokens).toBe(true); + expect(body.includeErc20Tokens).toBe(true); + // The Portfolio API caps at 5 networks per address — every + // batch must respect that, no batch ever sends 6+. + expect(body.addresses[0].networks.length).toBeLessThanOrEqual(5); + allSent.push(...body.addresses[0].networks); + } + // Every network in the catalog was sent across the batches. + const expected = Object.keys(ALCHEMY_NETWORK_CATALOG).sort(); + expect(allSent.sort()).toEqual(expected); }); it("normalizes ERC20 tokens with metadata + USD price", async () => { @@ -238,6 +214,44 @@ describe("fetchEnrichedBalances — happy path", () => { }); }); + it("returns testnet balances (Sepolia USDC) instead of filtering them out", async () => { + // The catalog includes eth-sepolia so the swap card can demo on + // testnet. The Portfolio API returns balances for testnet slugs + // when asked — we must surface them so the user can pick their + // Sepolia USDC and complete a swap. + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + tokens: [ + { + network: "eth-sepolia", + tokenAddress: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + tokenBalance: "0x5f5e100", + tokenMetadata: { + symbol: "USDC", + decimals: 6, + name: "USD Coin", + logo: "", + }, + tokenPrices: [{ currency: "usd", value: "1.0" }], + }, + ], + }, + }), + { status: 200 }, + ), + ); + const out = await fetchEnrichedBalances("0xabc"); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ + chainId: 11155111, + network: "eth-sepolia", + symbol: "USDC", + isNative: false, + }); + }); + it("parses decimals delivered as a string", async () => { fetchMock.mockResolvedValueOnce( new Response( @@ -412,37 +426,6 @@ describe("fetchEnrichedBalances — defensive", () => { expect(out[0].symbol).toBe("USDC"); }); - it("drops testnet tokens", async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - tokens: [ - { - network: "eth-sepolia", - tokenAddress: "0xfaucet", - tokenBalance: "0x1", - tokenMetadata: { symbol: "SEP", decimals: 18 }, - tokenPrices: [], - }, - { - network: "eth-mainnet", - tokenAddress: "0xusdc", - tokenBalance: "0x1", - tokenMetadata: { symbol: "USDC", decimals: 6 }, - tokenPrices: [], - }, - ], - }, - }), - { status: 200 }, - ), - ); - const out = await fetchEnrichedBalances("0xabc"); - expect(out).toHaveLength(1); - expect(out[0].symbol).toBe("USDC"); - }); - it("drops tokens missing symbol or decimals", async () => { fetchMock.mockResolvedValueOnce( new Response( diff --git a/tests/lib/prices/coingecko.test.ts b/tests/lib/prices/coingecko.test.ts new file mode 100644 index 0000000..af7937e --- /dev/null +++ b/tests/lib/prices/coingecko.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { fetchPrices, priceIsFallback, _resetPriceCacheForTests } from "@/lib/prices/coingecko"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + _resetPriceCacheForTests(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("fetchPrices", () => { + it("returns parsed prices from a successful CoinGecko /simple/price call", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ tether: { usd: 1.001 }, "wrapped-bitcoin": { usd: 61234 } }), + ); + const prices = await fetchPrices(["tether", "wrapped-bitcoin"]); + expect(prices.tether).toBe(1.001); + expect(prices["wrapped-bitcoin"]).toBe(61234); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("falls back to hardcoded prices when CoinGecko returns a non-OK status", async () => { + fetchMock.mockResolvedValueOnce(new Response("rate limit", { status: 429 })); + const prices = await fetchPrices(["tether", "wrapped-bitcoin"]); + expect(prices.tether).toBe(1); // fallback + expect(prices["wrapped-bitcoin"]).toBe(60000); // fallback + }); + + it("falls back to hardcoded prices when CoinGecko omits a requested id", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ tether: { usd: 1.001 } })); + const prices = await fetchPrices(["tether", "wrapped-bitcoin"]); + expect(prices.tether).toBe(1.001); // live + expect(prices["wrapped-bitcoin"]).toBe(60000); // fallback + }); + + it("uses the in-memory cache for repeated lookups within the TTL", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ "wrapped-bitcoin": { usd: 61500 } })); + await fetchPrices(["wrapped-bitcoin"]); + await fetchPrices(["wrapped-bitcoin"]); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("dedupes ids and normalizes case before requesting", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ tether: { usd: 1 }, "wrapped-bitcoin": { usd: 60000 } }), + ); + await fetchPrices(["Tether", "Tether", "Wrapped-Bitcoin"]); + const url = (fetchMock.mock.calls[0]?.[0] as string) ?? ""; + expect(url).toContain("ids=tether%2Cwrapped-bitcoin"); + }); +}); + +describe("priceIsFallback", () => { + it("returns true when the price equals the fallback value", () => { + expect(priceIsFallback("ethereum", 2500)).toBe(true); + expect(priceIsFallback("usd-coin", 1)).toBe(true); + }); + + it("returns false when the price differs from the fallback value", () => { + expect(priceIsFallback("ethereum", 2400)).toBe(false); + }); + + it("returns true when the price is undefined", () => { + expect(priceIsFallback("ethereum", undefined)).toBe(true); + }); + + it("returns false for an unknown coin with any numeric price", () => { + // No fallback table entry → cannot be "the fallback value". + expect(priceIsFallback("totally-unknown-coin", 42)).toBe(false); + }); +}); From c1979738185d5a07b1d8a32427a79ba9b9a440a6 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 13:24:35 +0800 Subject: [PATCH 10/35] feat(ui): add AddressOrHash truncated address / hash with copy Width-aware truncated hex display used by the atomic crypto cards. Only shortens when the value actually overflows; full value shown on hover tooltip. One button copies the full (un-truncated) value to the clipboard, with a legacy document.execCommand fallback for insecure contexts (the chat runs on http://localhost during dev where navigator.clipboard is sometimes gated). --- components/ui/address-or-hash.tsx | 148 ++++++++++++++++++++++++ tests/frontend/address-or-hash.test.tsx | 67 +++++++++++ 2 files changed, 215 insertions(+) create mode 100644 components/ui/address-or-hash.tsx create mode 100644 tests/frontend/address-or-hash.test.tsx diff --git a/components/ui/address-or-hash.tsx b/components/ui/address-or-hash.tsx new file mode 100644 index 0000000..44a84af --- /dev/null +++ b/components/ui/address-or-hash.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState } from "react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +// Displays a long hex string (wallet address, tx hash, order id) with +// the middle elided — hover shows the full value, an inline button +// copies it to the clipboard. Width-aware: only shortens when the +// string actually overflows the available space, so a 10-char id +// passes through unchanged. +// +// Why a + ); + + if (!isTruncated) return trigger; + + return ( + + + {trigger} + + {value} + + + + ); +} diff --git a/tests/frontend/address-or-hash.test.tsx b/tests/frontend/address-or-hash.test.tsx new file mode 100644 index 0000000..fe1b2a9 --- /dev/null +++ b/tests/frontend/address-or-hash.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { AddressOrHash } from "@/components/ui/address-or-hash"; + +// AddressOrHash — copy button writes the full value to the clipboard. +// Regression: the button must fire its onClick even when wrapped by a +// Radix Tooltip (asChild). Earlier versions silently lost the click +// because the Tooltip's pointer-down handler short-circuited before +// the inner button's onClick ran. + +describe("AddressOrHash", () => { + beforeEach(() => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + afterEach(cleanup); + + it("copies the full value via navigator.clipboard when available", async () => { + const value = "ord_29807b1234567890abcdef1234567890"; + render(); + const btn = document.querySelector('[data-action="copy-address-or-hash"]') as HTMLButtonElement; + expect(btn).toBeTruthy(); + fireEvent.click(btn); + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(value); + }); + }); + + it("renders the truncated display in the button label", () => { + const value = "ord_29807b1234567890abcdef1234567890"; + render(); + const btn = document.querySelector('[data-action="copy-address-or-hash"]') as HTMLButtonElement; + expect(btn.textContent).toContain("ord_29807b"); + expect(btn.textContent).toContain("567890"); + expect(btn.textContent).toContain("…"); + }); + + it("does not render an ellipsis when the value fits within head+tail+1", () => { + render(); + const btn = document.querySelector('[data-action="copy-address-or-hash"]') as HTMLButtonElement; + expect(btn.textContent).toContain("ord_abc"); + expect(btn.textContent).not.toContain("…"); + }); + + it("flips the Copy icon to a Check after a successful copy", async () => { + const value = "ord_check_visibility_1234567890abcdef"; + const { container } = render(); + const btn = container.querySelector( + '[data-action="copy-address-or-hash"]', + ) as HTMLButtonElement; + expect(container.querySelector(".lucide-copy")).toBeTruthy(); + fireEvent.click(btn); + await waitFor(() => { + expect(container.querySelector(".lucide-check")).toBeTruthy(); + expect(container.querySelector(".lucide-copy")).toBeFalsy(); + }); + }); + + it("exposes an aria-label describing what will be copied", () => { + const value = "ord_a11y_test_1234567890abcdef"; + render(); + const btn = document.querySelector('[data-action="copy-address-or-hash"]') as HTMLButtonElement; + expect(btn.getAttribute("aria-label")).toBe(`Copy ${value}`); + }); +}); From 7e69fb201824efe0d6309c629537277e92208035 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 13:24:46 +0800 Subject: [PATCH 11/35] test(e2e): add Playwright harness for the 3-card trade flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite + Playwright harness mounts all three atomic cards in a single browser (jsdom misses CSS layout — a real Chromium catches that the place-crypto-order preview fits inside the tool-call chrome). Stubs wagmi with a per-spec mock wallet helper (default address 0x1af12147C80F6d7A57BF7eC11985a2F2a7630977) and RainbowKit. Specs cover the connect → place → status flow + atomicity + Mock Coin visual + the regression that the cancel-confirm UI is no longer present. Also adds a frontend unit test for the connect-wallet card that was missed in the prior split commit. --- .gitignore | 7 + package.json | 7 +- playwright.config.ts | 35 ++ pnpm-lock.yaml | 317 ++++++++++-------- tests/e2e/btc-catalog-removed.spec.ts | 28 ++ tests/e2e/crypto-trade-flow.spec.ts | 156 +++++++++ tests/e2e/harness.tsx | 65 ++++ tests/e2e/index.html | 38 +++ tests/e2e/mock-coin-card.spec.ts | 30 ++ tests/e2e/portfolio-stub.ts | 41 +++ tests/e2e/postcss.config.cjs | 6 + tests/e2e/stubs/langgraph.ts | 12 + tests/e2e/stubs/rainbowkit.ts | 40 +++ tests/e2e/stubs/wagmi.ts | 63 ++++ tests/e2e/vite.config.ts | 32 ++ tests/e2e/wallet-mock.ts | 33 ++ .../crypto/connect-wallet-card.test.tsx | 158 +++++++++ 17 files changed, 932 insertions(+), 136 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/btc-catalog-removed.spec.ts create mode 100644 tests/e2e/crypto-trade-flow.spec.ts create mode 100644 tests/e2e/harness.tsx create mode 100644 tests/e2e/index.html create mode 100644 tests/e2e/mock-coin-card.spec.ts create mode 100644 tests/e2e/portfolio-stub.ts create mode 100644 tests/e2e/postcss.config.cjs create mode 100644 tests/e2e/stubs/langgraph.ts create mode 100644 tests/e2e/stubs/rainbowkit.ts create mode 100644 tests/e2e/stubs/wagmi.ts create mode 100644 tests/e2e/vite.config.ts create mode 100644 tests/e2e/wallet-mock.ts create mode 100644 tests/frontend/crypto/connect-wallet-card.test.tsx diff --git a/.gitignore b/.gitignore index eb81cb7..d9ecf43 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,13 @@ yarn-error.log* # local visual verification screenshots /.verify +# ad-hoc screenshots/logs dropped at repo root during manual testing +/*.png +/*.jpg +/*.jpeg +/*.log +/test-results/ +/playwright-report/ # typescript *.tsbuildinfo diff --git a/package.json b/package.json index 3b10604..3c3feaf 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "db:studio": "drizzle-kit studio", "db:reset": "drizzle-kit drop", "test": "NODE_ENV=test vitest run", - "test:watch": "NODE_ENV=test vitest" + "test:watch": "NODE_ENV=test vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@assistant-ui/react": "^0.14.24", @@ -76,6 +78,7 @@ "@langchain/core": "^1.2.1", "@langchain/langgraph-cli": "^1.4.1", "@next/env": "^16.2.9", + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.3.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -83,6 +86,7 @@ "@types/pg": "^8.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.9", "concurrently": "^10.0.3", "drizzle-kit": "^0.31.10", @@ -92,6 +96,7 @@ "tailwindcss": "^4.3.1", "tsx": "^4.22.4", "typescript": "^6.0.3", + "vite": "^8.1.0", "vitest": "^4.1.9" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..146ccfc --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Component-level e2e: each spec mounts a single card into a real +// Chromium browser (jsdom misses CSS layout, while real Chrome catches +// that the place-crypto-order preview actually fits inside the tool +// call's `ps-6 pt-1 pb-2` chrome, that the simulated-receipt emerald +// circle doesn't overflow, etc.). No dev server required — the +// fixtures serve the compiled JSX inline via a custom HTML harness. + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://localhost:3100", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + // Static harness server — serves a one-line HTML page that imports + // the compiled JSX from a Vite dev server spawned in `webServer`. + webServer: { + command: "pnpm exec vite --port 3100 --config tests/e2e/vite.config.ts", + url: "http://localhost:3100", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6d1c14..7c19713 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,14 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - cuer: npm:cuer@0.0.3 - -patchedDependencies: - cuer@0.0.3: - hash: 411033b94a397d27fc9bf35e525e1bfe66bb372afad5b615cfaec514ad29beae - path: patches/cuer@0.0.3.patch - importers: .: @@ -33,10 +25,10 @@ importers: version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@better-auth-ui/core': specifier: ^1.6.27 - version: 1.6.27(@mikkelscheike/email-provider-links@5.1.8)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)) + version: 1.6.27(@mikkelscheike/email-provider-links@5.1.8)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)) '@better-auth-ui/react': specifier: ^1.6.27 - version: 1.6.27(e8790f21eb10219fdf108ba0aa287517) + version: 1.6.27(c1171155626ea3d0a7ff4b47380b6817) '@langchain/langgraph': specifier: ^1.4.7 version: 1.4.7(@langchain/core@1.2.1(openai@6.44.0(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) @@ -81,7 +73,7 @@ importers: version: 0.3.24 better-auth: specifier: ^1.6.22 - version: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + version: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -105,7 +97,7 @@ importers: version: 1.21.0(react@19.2.7) next: specifier: 16.1.7 - version: 16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -176,6 +168,9 @@ importers: '@next/env': specifier: ^16.2.9 version: 16.2.9 + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@tailwindcss/postcss': specifier: ^4.3.1 version: 4.3.1 @@ -197,6 +192,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.9 version: 4.1.9(vitest@4.1.9) @@ -224,9 +222,12 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vite: + specifier: ^8.1.0 + version: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -648,17 +649,14 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} @@ -1513,8 +1511,8 @@ packages: resolution: {integrity: sha512-I499oaqSgwpzIE8zkAJaKvenUm2au4SVCnrBNxScuPYTFsVvxsJ4kYrkWs23zg1czwDTaN9C+4k8eDHmIONeFw==} engines: {comment: 'Supports Node.js 18.x, 20.x, 22.x, 24.x, and 25.x', node: '>=18.0.0'} - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -1628,8 +1626,8 @@ packages: resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} - '@oxc-project/types@0.133.0': - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} '@oxfmt/binding-android-arm-eabi@0.55.0': resolution: {integrity: sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g==} @@ -1903,6 +1901,11 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -2818,91 +2821,91 @@ packages: '@reown/appkit@1.7.8': resolution: {integrity: sha512-51kTleozhA618T1UvMghkhKfaPcc9JlKwLJ5uV+riHyvSoWPKPRIa5A6M1Wano5puNyW0s3fwywhyqTHSilkaA==} - '@rolldown/binding-android-arm64@1.0.3': - resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.3': - resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.3': - resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.3': - resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.3': - resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.3': - resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.0.3': - resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.3': - resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.3': - resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.3': - resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.3': - resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.3': - resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.3': - resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3486,8 +3489,8 @@ packages: '@types/react-dom': optional: true - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -3579,6 +3582,19 @@ packages: peerDependencies: '@vanilla-extract/css': ^1.0.0 + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + '@vitest/coverage-v8@4.1.9': resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} peerDependencies: @@ -4698,6 +4714,11 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5731,6 +5752,16 @@ packages: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -6067,8 +6098,8 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - rolldown@1.0.3: - resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -6683,13 +6714,13 @@ packages: typescript: optional: true - vite@8.0.16: - resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + vite@8.1.0: + resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 + '@vitejs/devtools': ^0.3.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -7293,20 +7324,20 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth-ui/core@1.6.27(@mikkelscheike/email-provider-links@5.1.8)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))': + '@better-auth-ui/core@1.6.27(@mikkelscheike/email-provider-links@5.1.8)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))': dependencies: '@mikkelscheike/email-provider-links': 5.1.8 - better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) - '@better-auth-ui/react@1.6.27(e8790f21eb10219fdf108ba0aa287517)': + '@better-auth-ui/react@1.6.27(c1171155626ea3d0a7ff4b47380b6817)': dependencies: - '@better-auth-ui/core': 1.6.27(@mikkelscheike/email-provider-links@5.1.8)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)) - '@better-auth/api-key': 1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3)) - '@better-auth/passkey': 1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3))(nanostores@1.3.0) + '@better-auth-ui/core': 1.6.27(@mikkelscheike/email-provider-links@5.1.8)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)) + '@better-auth/api-key': 1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3)) + '@better-auth/passkey': 1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3))(nanostores@1.3.0) '@react-email/components': 1.0.12(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/query-core': 5.101.1 '@tanstack/react-query': 5.101.1(react@19.2.7) - better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) clsx: 2.1.1 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -7314,11 +7345,11 @@ snapshots: tailwindcss: 4.3.1 zod: 4.4.3 - '@better-auth/api-key@1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3))': + '@better-auth/api-key@1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3))': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) better-call: 1.3.7(zod@4.4.3) zod: 4.4.3 @@ -7358,14 +7389,14 @@ snapshots: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/passkey@1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3))(nanostores@1.3.0)': + '@better-auth/passkey@1.6.20(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.7(zod@4.4.3))(nanostores@1.3.0)': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.1 - better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-auth: 1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) better-call: 1.3.7(zod@4.4.3) nanostores: 1.3.0 zod: 4.4.3 @@ -7503,14 +7534,9 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': + '@emnapi/core@1.11.1': dependencies: + '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 optional: true @@ -7519,7 +7545,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.1': + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 optional: true @@ -8294,11 +8320,11 @@ snapshots: '@mikkelscheike/email-provider-links@5.1.8': {} - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 optional: true '@next/env@16.1.7': {} @@ -8367,7 +8393,7 @@ snapshots: '@opentelemetry/semantic-conventions@1.41.1': {} - '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.137.0': {} '@oxfmt/binding-android-arm-eabi@0.55.0': optional: true @@ -8585,6 +8611,10 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.4': {} @@ -9336,7 +9366,7 @@ snapshots: '@vanilla-extract/dynamic': 2.1.5 '@vanilla-extract/sprinkles': 1.6.5(@vanilla-extract/css@1.20.1) clsx: 2.1.1 - cuer: 0.0.3(patch_hash=411033b94a397d27fc9bf35e525e1bfe66bb372afad5b615cfaec514ad29beae)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + cuer: 0.0.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) @@ -9740,53 +9770,53 @@ snapshots: - utf-8-validate - zod - '@rolldown/binding-android-arm64@1.0.3': + '@rolldown/binding-android-arm64@1.1.3': optional: true - '@rolldown/binding-darwin-arm64@1.0.3': + '@rolldown/binding-darwin-arm64@1.1.3': optional: true - '@rolldown/binding-darwin-x64@1.0.3': + '@rolldown/binding-darwin-x64@1.1.3': optional: true - '@rolldown/binding-freebsd-x64@1.0.3': + '@rolldown/binding-freebsd-x64@1.1.3': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.3': + '@rolldown/binding-linux-arm64-gnu@1.1.3': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.3': + '@rolldown/binding-linux-arm64-musl@1.1.3': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.3': + '@rolldown/binding-linux-ppc64-gnu@1.1.3': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.3': + '@rolldown/binding-linux-s390x-gnu@1.1.3': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.3': + '@rolldown/binding-linux-x64-gnu@1.1.3': optional: true - '@rolldown/binding-linux-x64-musl@1.0.3': + '@rolldown/binding-linux-x64-musl@1.1.3': optional: true - '@rolldown/binding-openharmony-arm64@1.0.3': + '@rolldown/binding-openharmony-arm64@1.1.3': optional: true - '@rolldown/binding-wasm32-wasi@1.0.3': + '@rolldown/binding-wasm32-wasi@1.1.3': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.3': + '@rolldown/binding-win32-arm64-msvc@1.1.3': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.3': + '@rolldown/binding-win32-x64-msvc@1.1.3': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -10448,7 +10478,7 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@tybys/wasm-util@0.10.2': + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true @@ -10560,6 +10590,11 @@ snapshots: dependencies: '@vanilla-extract/css': 1.20.1 + '@vitejs/plugin-react@6.0.3(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -10572,7 +10607,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/expect@4.1.9': dependencies: @@ -10583,13 +10618,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -11368,7 +11403,7 @@ snapshots: baseline-browser-mapping@2.10.38: {} - better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9): + better-auth@1.6.22(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9))(next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9): dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9)) @@ -11390,11 +11425,11 @@ snapshots: optionalDependencies: drizzle-kit: 0.31.10 drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.22.0)(postgres@3.4.9) - next: 16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) pg: 8.22.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -11646,7 +11681,7 @@ snapshots: csstype@3.2.3: {} - cuer@0.0.3(patch_hash=411033b94a397d27fc9bf35e525e1bfe66bb372afad5b615cfaec514ad29beae)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + cuer@0.0.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): dependencies: qr: 0.6.0 react: 19.2.7 @@ -12111,6 +12146,9 @@ snapshots: fraction.js@5.3.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12980,7 +13018,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@16.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.1.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 @@ -12999,6 +13037,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.1.7 '@next/swc-win32-arm64-msvc': 16.1.7 '@next/swc-win32-x64-msvc': 16.1.7 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -13349,6 +13388,14 @@ snapshots: sonic-boom: 2.8.0 thread-stream: 0.15.2 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pngjs@5.0.0: {} pony-cause@2.1.11: {} @@ -13752,26 +13799,26 @@ snapshots: resolve-pkg-maps@1.0.0: {} - rolldown@1.0.3: + rolldown@1.1.3: dependencies: - '@oxc-project/types': 0.133.0 + '@oxc-project/types': 0.137.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.3 - '@rolldown/binding-darwin-arm64': 1.0.3 - '@rolldown/binding-darwin-x64': 1.0.3 - '@rolldown/binding-freebsd-x64': 1.0.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 - '@rolldown/binding-linux-arm64-gnu': 1.0.3 - '@rolldown/binding-linux-arm64-musl': 1.0.3 - '@rolldown/binding-linux-ppc64-gnu': 1.0.3 - '@rolldown/binding-linux-s390x-gnu': 1.0.3 - '@rolldown/binding-linux-x64-gnu': 1.0.3 - '@rolldown/binding-linux-x64-musl': 1.0.3 - '@rolldown/binding-openharmony-arm64': 1.0.3 - '@rolldown/binding-wasm32-wasi': 1.0.3 - '@rolldown/binding-win32-arm64-msvc': 1.0.3 - '@rolldown/binding-win32-x64-msvc': 1.0.3 + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 rou3@0.7.12: {} @@ -14376,12 +14423,12 @@ snapshots: - utf-8-validate - zod - vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.15 - rolldown: 1.0.3 + rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.4 @@ -14391,10 +14438,10 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -14411,7 +14458,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.4 diff --git a/tests/e2e/btc-catalog-removed.spec.ts b/tests/e2e/btc-catalog-removed.spec.ts new file mode 100644 index 0000000..f29d5dd --- /dev/null +++ b/tests/e2e/btc-catalog-removed.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "@playwright/test"; +import { mockWalletConnected } from "./wallet-mock"; + +// Visual check for the place_crypto_order card after the Mock Coin +// rewrite. Confirms: +// - BTC is accepted as a target (no "not supported" error) +// - The card shows SPEND MC (not a wallet source pick) +// - The 10,000 MC hardcoded balance is visible + +test.beforeEach(async ({ page }) => { + await mockWalletConnected(page); + await page.goto("/?target=bitcoin"); +}); + +test("card shows SPEND MC + BTC target + total spent row", async ({ page }) => { + const card = page.locator( + '[data-card="place-crypto-order"] [data-slot="place-crypto-order-card"]', + ); + await expect(card).toBeVisible({ timeout: 5000 }); + // Mock Coin source — no wallet pick. + await expect(card.getByText(/^MC$/).first()).toBeVisible(); + await expect(card.getByText("of 10,000 held")).toBeVisible(); + // BTC target renders — no "not supported" error. + await expect(card.getByText("BTC").first()).toBeVisible(); + await expect(card.getByText(/not supported/i)).toHaveCount(0); + // Total spent row (base + gas in MC). + await expect(card.getByText(/total spent/i)).toBeVisible(); +}); \ No newline at end of file diff --git a/tests/e2e/crypto-trade-flow.spec.ts b/tests/e2e/crypto-trade-flow.spec.ts new file mode 100644 index 0000000..84b2e61 --- /dev/null +++ b/tests/e2e/crypto-trade-flow.spec.ts @@ -0,0 +1,156 @@ +import { test, expect } from "@playwright/test"; +import { + MOCK_WALLET_ADDRESS, + mockWalletConnected, + mockWalletDisconnected, +} from "./wallet-mock"; + +// E2E for the crypto atomic-tools refactor. The vite harness mounts all +// 3 cards in vertical sequence. Default state is DISCONNECTED so the +// connect_wallet card sits at the "Connect" button (no auto-resume +// payload). Connected tests override the default. + +test.beforeEach(async ({ page }) => { + await mockWalletDisconnected(page); + await page.goto("/"); +}); + +test.describe("connect_wallet card — disconnected", () => { + test("renders a Connect button when wallet is not connected", async ({ page }) => { + const card = page.locator('[data-card="connect-wallet"] [data-slot="connect-wallet-card"]'); + await expect(card).toBeVisible(); + await expect(card.getByRole("button", { name: /connect wallet/i })).toBeVisible(); + }); + + test("title-case header (Authorize Wallet)", async ({ page }) => { + const card = page.locator('[data-card="connect-wallet"] [data-slot="connect-wallet-card"]'); + await expect(card.getByText("Authorize Wallet")).toBeVisible(); + }); + + test("does NOT auto-resume when not connected", async ({ page }) => { + await expect(page.locator("#last-payload")).toContainText("(no resume yet)"); + }); + + test("Connect button opens RainbowKit; once connected, the card auto-resumes", async ({ + page, + }) => { + const card = page.locator('[data-card="connect-wallet"] [data-slot="connect-wallet-card"]'); + await card.getByRole("button", { name: /connect wallet/i }).click(); + // The RainbowKit stub auto-connects the mock wallet, which fires the + // card's ref-guarded useEffect → sendCommand → resume payload. + await expect(page.locator("#last-payload")).toContainText(MOCK_WALLET_ADDRESS, { + timeout: 2000, + }); + await expect(page.locator("#last-payload")).toContainText("8453"); + }); +}); + +test.describe("connect_wallet card — connected, auto-resume", () => { + test.beforeEach(async ({ page }) => { + // Override the default: simulate a connected wallet. The card's + // ref-guarded useEffect fires the resume on the first render. + await mockWalletConnected(page); + await page.goto("/"); + }); + + test("auto-resumes with {address, chainId} on first connected render", async ({ page }) => { + await expect(page.locator("#last-payload")).toContainText(MOCK_WALLET_ADDRESS, { + timeout: 2000, + }); + await expect(page.locator("#last-payload")).toContainText("8453"); + }); + + test("the brief 'Connecting…' indicator shows the connected address", async ({ page }) => { + const card = page.locator( + '[data-card="connect-wallet"] [data-slot="connect-wallet-card-connecting"]', + ); + // May have already resolved by the time we look, but the address + // appears in either view. + const resolved = page.locator( + '[data-card="connect-wallet"] [data-slot="connect-wallet-card-resolved"]', + ); + const connecting = card; + await expect(connecting.or(resolved)).toBeVisible(); + await expect(page.locator('[data-card="connect-wallet"]')).toContainText("0x1af1…0977"); + }); + + test("does not double-resume on remount (Strict Mode safe)", async ({ page }) => { + await expect(page.locator("#last-payload")).toContainText(MOCK_WALLET_ADDRESS, { + timeout: 2000, + }); + const initialCalls = (await page.locator("#last-payload").textContent()) ?? ""; + // Re-navigate; the ref is per-mount, but each fresh mount only + // resumes once because wagmi already reports connected and the + // ref is fresh. + await page.goto("/"); + const afterReload = (await page.locator("#last-payload").textContent()) ?? ""; + expect(afterReload).toContain(MOCK_WALLET_ADDRESS); + // The payload structure (resume JSON) is identical — only one + // resume per mount, no double-fire. + expect(afterReload).toBe(initialCalls); + }); +}); + +test.describe("place_crypto_order card", () => { + test("renders the simulated order shell with a SIMULATED badge", async ({ page }) => { + const card = page.locator( + '[data-card="place-crypto-order"] [data-slot="place-crypto-order-card"]', + ); + await expect(card).toBeVisible(); + await expect(card.getByText(/simulated/i).first()).toBeVisible(); + }); + + test("header reads Swap Quote + button reads Accept Swap", async ({ page }) => { + const card = page.locator( + '[data-card="place-crypto-order"] [data-slot="place-crypto-order-card"]', + ); + await expect(card.getByText("Swap Quote")).toBeVisible(); + await expect(card.getByText(/prices are live/i)).toBeVisible(); + await expect(card.getByRole("button", { name: /accept swap/i })).toBeVisible(); + }); +}); + +test.describe("get_order_status card", () => { + test("renders the order uid + chain from args", async ({ page }) => { + const card = page.locator('[data-card="order-status"] [data-slot="order-status-card"]'); + await expect(card).toBeVisible(); + await expect(card.getByText(/pending check/i)).toBeVisible(); + }); + + test("title-case header + Quote id label (regression)", async ({ page }) => { + const card = page.locator('[data-card="order-status"] [data-slot="order-status-card"]'); + await expect(card.getByText("Swap Status")).toBeVisible(); + await expect(card.getByText("Quote id")).toBeVisible(); + }); + + test("clicking Check synthesizes a filled status", async ({ page }) => { + const card = page.locator('[data-card="order-status"] [data-slot="order-status-card"]'); + await card.getByRole("button", { name: /check status/i }).click(); + await expect(page.locator("#last-payload")).toContainText('"status"', { timeout: 2000 }); + await expect(page.locator("#last-payload")).toContainText("filled"); + await expect(page.locator("#last-payload")).toContainText("ord_test_abc123"); + }); +}); + +test.describe("atomicity — each card has its own decision point", () => { + test("all 3 cards mount simultaneously (no implicit cross-card coupling)", async ({ page }) => { + await expect( + page.locator('[data-card="connect-wallet"] [data-slot^="connect-wallet-card"]'), + ).toBeVisible(); + await expect( + page.locator('[data-card="place-crypto-order"] [data-slot^="place-crypto-order-card"]'), + ).toBeVisible(); + await expect( + page.locator('[data-card="order-status"] [data-slot^="order-status-card"]'), + ).toBeVisible(); + }); + + test("each card has exactly one primary action button when disconnected", async ({ page }) => { + await expect( + page.locator('[data-card="connect-wallet"] button[data-action="connect-wallet"]'), + ).toHaveCount(1); + await expect( + page.locator('[data-card="order-status"] button[data-action="check-order-status"]'), + ).toHaveCount(1); + }); +}); diff --git a/tests/e2e/harness.tsx b/tests/e2e/harness.tsx new file mode 100644 index 0000000..3249b28 --- /dev/null +++ b/tests/e2e/harness.tsx @@ -0,0 +1,65 @@ +// Crypto card e2e harness — mounts each of the 3 atomic cards in +// vertical sequence. The Playwright spec visits /, locates each card +// by its data-slot attribute, and verifies atomic behavior (one +// button per card, one resume payload per click). +// +// Wagmi + LangGraph + RainbowKit are aliased to lightweight stubs via +// vite.config.ts. The cards run unmodified. + +import React from "react"; +import { createRoot } from "react-dom/client"; + +import { + ConnectWalletCard, + OrderStatusCard, + PlaceCryptoOrderCard, +} from "@/components/tool-ui/crypto"; + +// Install the global sendCommand shim BEFORE rendering. The stubbed +// useLangGraphSendCommand hook (see stubs/langgraph.ts) reads this. +( + globalThis as unknown as { __cryptoSendCommand?: (cmd: { resume: string }) => void } +).__cryptoSendCommand = (cmd: { resume: string }) => { + let payload: unknown = cmd.resume; + try { + payload = JSON.parse(cmd.resume); + } catch { + /* keep raw */ + } + const node = document.getElementById("last-payload"); + if (node) node.textContent = JSON.stringify(payload, null, 2); + window.dispatchEvent(new CustomEvent("crypto:resume", { detail: payload })); +}; + +function App() { + // The place-order card picks up the target from the URL so a spec can + // target a specific coin (BTC, ETH, …) without rebuilding the harness. + // The source is hardcoded to Mock Coin in the card. Format: + // ?target=bitcoin&amount=100 + const params = new URLSearchParams(window.location.search); + const placeArgs = { + target_coin_id: params.get("target") ?? "ethereum", + amount: params.get("amount") ? Number(params.get("amount")) : undefined, + }; + return ( +
    +
    + +
    +
    + +
    +
    + +
    +
    + ); +} + +const root = createRoot(document.getElementById("root")!); +root.render(); diff --git a/tests/e2e/index.html b/tests/e2e/index.html new file mode 100644 index 0000000..cc58de3 --- /dev/null +++ b/tests/e2e/index.html @@ -0,0 +1,38 @@ + + + + + Crypto card harness + + + +
    +
    (no resume yet)
    + + + diff --git a/tests/e2e/mock-coin-card.spec.ts b/tests/e2e/mock-coin-card.spec.ts new file mode 100644 index 0000000..152ab10 --- /dev/null +++ b/tests/e2e/mock-coin-card.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from "@playwright/test"; +import { mockWalletConnected } from "./wallet-mock"; + +// Visual smoke test for the new Mock Coin place-order card. +// Mounts via the vite harness (which loads the latest source via HMR) +// and asserts the structural copy + gas-in-MC live calculation. + +test("place-order card shows SPEND MC, FOR target, gas-in-MC", async ({ page }) => { + await mockWalletConnected(page); + await page.goto("/?target=ethereum"); + const card = page.locator( + '[data-card="place-crypto-order"] [data-slot="place-crypto-order-card"]', + ); + await expect(card).toBeVisible({ timeout: 10_000 }); + + // Source is hardcoded Mock Coin. + await expect(card.getByText(/^MC$/).first()).toBeVisible(); + await expect(card.getByText("of 10,000 held")).toBeVisible(); + + // Target renders as ETH. + await expect(card.getByText("ETH").first()).toBeVisible(); + + // Wait for the live CoinGecko quote to populate (sets the gas-in-MC + // total). Skip if CoinGecko is rate-limited. + await page.waitForTimeout(3000); + + // Total spent row is present (MC value). + await expect(card.getByText(/total spent/i)).toBeVisible(); + await expect(card.locator('[data-action="place-simulated-order"]')).toBeVisible(); +}); \ No newline at end of file diff --git a/tests/e2e/portfolio-stub.ts b/tests/e2e/portfolio-stub.ts new file mode 100644 index 0000000..cd7d121 --- /dev/null +++ b/tests/e2e/portfolio-stub.ts @@ -0,0 +1,41 @@ +// Browser stub for @/lib/alchemy/portfolio. Lets tests inject a fake +// wallet contents via window.__cryptoMockBalances without making real +// network calls. Falls back to a single USDC entry when nothing is +// set, so the place_crypto_order card always renders something. +import type { Address } from "viem"; + +type MockBalance = { + chainId: number; + address: Address | null; + symbol: string; + decimals: number; + tokenBalance: string; + name: string; + logo: string | null; + priceUsd: number | null; + isNative: boolean; +}; + +const DEFAULT_BALANCES: MockBalance[] = [ + { + chainId: 8453, + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + symbol: "USDC", + decimals: 6, + tokenBalance: "0xC350", + name: "USD Coin", + logo: null, + priceUsd: 1, + isNative: false, + }, +]; + +export async function fetchEnrichedBalances( + _address: Address, + _signal?: AbortSignal, +): Promise { + return ( + (globalThis as { __cryptoMockBalances?: MockBalance[] }).__cryptoMockBalances ?? + DEFAULT_BALANCES + ); +} diff --git a/tests/e2e/postcss.config.cjs b/tests/e2e/postcss.config.cjs new file mode 100644 index 0000000..af324ab --- /dev/null +++ b/tests/e2e/postcss.config.cjs @@ -0,0 +1,6 @@ +// Empty PostCSS config — the e2e harness serves a single plain HTML +// page, so it doesn't need Tailwind / autoprefixer / etc. Vite walks +// up the directory tree looking for postcss.config; this empty +// definition blocks it from finding the project's PostCSS plugin +// (@tailwindcss/postcss) and failing to load. +module.exports = { plugins: [] }; diff --git a/tests/e2e/stubs/langgraph.ts b/tests/e2e/stubs/langgraph.ts new file mode 100644 index 0000000..fd314f2 --- /dev/null +++ b/tests/e2e/stubs/langgraph.ts @@ -0,0 +1,12 @@ +// Browser stub for @assistant-ui/react-langgraph. The real +// useLangGraphSendCommand reads from a React Context the runtime +// provides; we replace it with a function that forwards to the +// global __cryptoSendCommand shim installed by harness.tsx. + +export function useLangGraphSendCommand() { + return (cmd: { resume: string }) => { + const fn = (globalThis as unknown as { __cryptoSendCommand?: (c: { resume: string }) => void }) + .__cryptoSendCommand; + fn?.(cmd); + }; +} diff --git a/tests/e2e/stubs/rainbowkit.ts b/tests/e2e/stubs/rainbowkit.ts new file mode 100644 index 0000000..2bb3b9f --- /dev/null +++ b/tests/e2e/stubs/rainbowkit.ts @@ -0,0 +1,40 @@ +// Browser stub for @rainbow-me/rainbowkit. The connect card only uses +// useConnectModal(); expose an openConnectModal that flips the mock +// account state and notifies wagmi listeners so React re-renders. + +type GlobalWithAccount = typeof globalThis & { + __cryptoMockAccount?: { isConnected: boolean; address?: `0x${string}`; chainId?: number }; + __cryptoMockListeners?: Set<() => void>; +}; + +function notify() { + const g = globalThis as GlobalWithAccount; + g.__cryptoMockListeners?.forEach((cb) => cb()); +} + +export function useConnectModal() { + return { + openConnectModal: () => { + (globalThis as GlobalWithAccount).__cryptoMockAccount = { + isConnected: true, + address: "0x1af12147C80F6d7A57BF7eC11985a2F2a7630977", + chainId: 8453, + }; + notify(); + window.dispatchEvent(new Event("crypto:mock-connected")); + }, + }; +} + +// useAccountModal — used by the connect_wallet card's "Use a different +// wallet" dropdown. In the e2e harness we don't render an actual modal; +// the stub just notifies listeners so the card re-renders into the +// "switched" state (the test asserts no resume was sent, which is the +// important contract). +export function useAccountModal() { + return { + openAccountModal: () => { + window.dispatchEvent(new Event("crypto:mock-account-modal-opened")); + }, + }; +} diff --git a/tests/e2e/stubs/wagmi.ts b/tests/e2e/stubs/wagmi.ts new file mode 100644 index 0000000..e60732d --- /dev/null +++ b/tests/e2e/stubs/wagmi.ts @@ -0,0 +1,63 @@ +// Browser stub for wagmi. Exports only the hooks the cards use. The +// mock state lives on window.__cryptoMockAccount so the spec can flip +// it via page.evaluate() between assertions. useSyncExternalStore +// forces a re-render when the RainbowKit stub (or any test) dispatches +// the "crypto:mock-connected" event after flipping the mock account. + +import { useSyncExternalStore } from "react"; + +type MockAccount = { + isConnected: boolean; + address?: `0x${string}`; + chainId?: number; +}; + +type GlobalWithAccount = typeof globalThis & { + __cryptoMockAccount?: MockAccount; + __cryptoMockListeners?: Set<() => void>; +}; + +// Default disconnect state — must be a stable reference so +// useSyncExternalStore's getSnapshot doesn't trigger an infinite loop +// when the harness is loaded without addInitScript. +const DEFAULT_DISCONNECTED: MockAccount = Object.freeze({ + isConnected: false, + address: undefined, + chainId: undefined, +}) as MockAccount; + +function read(): MockAccount { + return (globalThis as GlobalWithAccount).__cryptoMockAccount ?? DEFAULT_DISCONNECTED; +} + +function subscribe(cb: () => void): () => void { + const g = globalThis as GlobalWithAccount; + g.__cryptoMockListeners ??= new Set(); + g.__cryptoMockListeners.add(cb); + return () => { + g.__cryptoMockListeners?.delete(cb); + }; +} + +export function useAccount() { + const acct = useSyncExternalStore(subscribe, read, read); + return { + address: acct.address, + isConnected: acct.isConnected, + chainId: acct.chainId, + }; +} + +// Stubs the cards import but don't use (e.g. useSignTypedData). +export function useSignTypedData() { + return { + signTypedDataAsync: async () => "0xmocksig" as `0x${string}`, + }; +} + +export function useSwitchChain() { + return { + switchChainAsync: async () => {}, + isPending: false, + }; +} diff --git a/tests/e2e/vite.config.ts b/tests/e2e/vite.config.ts new file mode 100644 index 0000000..699f5c3 --- /dev/null +++ b/tests/e2e/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; + +export default defineConfig({ + plugins: [react()], + root: __dirname, + resolve: { + alias: [ + // Specific stubs first so they win over the `@` catch-all below. + { + find: "@assistant-ui/react-langgraph", + replacement: path.resolve(__dirname, "./stubs/langgraph.ts"), + }, + { find: "wagmi", replacement: path.resolve(__dirname, "./stubs/wagmi.ts") }, + { + find: "@rainbow-me/rainbowkit", + replacement: path.resolve(__dirname, "./stubs/rainbowkit.ts"), + }, + { + find: "@/lib/alchemy/portfolio", + replacement: path.resolve(__dirname, "./portfolio-stub.ts"), + }, + { find: "@", replacement: path.resolve(__dirname, "../..") }, + ], + }, + server: { + port: 3100, + strictPort: true, + host: "127.0.0.1", + }, +}); diff --git a/tests/e2e/wallet-mock.ts b/tests/e2e/wallet-mock.ts new file mode 100644 index 0000000..4e581d0 --- /dev/null +++ b/tests/e2e/wallet-mock.ts @@ -0,0 +1,33 @@ +// Default mock wallet state for crypto card e2e tests. Specs call +// mockWalletConnected(page) or mockWalletDisconnected(page) in +// beforeEach — the wagmi stub reads this global on first render. +// +// Centralizes the default address so individual specs don't repeat it. + +export const MOCK_WALLET_ADDRESS = "0x1af12147C80F6d7A57BF7eC11985a2F2a7630977"; +export const MOCK_CHAIN_ID = 8453; + +export async function mockWalletConnected(page: import("@playwright/test").Page): Promise { + await page.addInitScript( + ([address, chainId]) => { + (window as unknown as { __cryptoMockAccount: object }).__cryptoMockAccount = { + isConnected: true, + address, + chainId, + }; + }, + [MOCK_WALLET_ADDRESS, MOCK_CHAIN_ID], + ); +} + +export async function mockWalletDisconnected( + page: import("@playwright/test").Page, +): Promise { + await page.addInitScript(() => { + (window as unknown as { __cryptoMockAccount: object }).__cryptoMockAccount = { + isConnected: false, + address: undefined, + chainId: undefined, + }; + }); +} \ No newline at end of file diff --git a/tests/frontend/crypto/connect-wallet-card.test.tsx b/tests/frontend/crypto/connect-wallet-card.test.tsx new file mode 100644 index 0000000..a77be87 --- /dev/null +++ b/tests/frontend/crypto/connect-wallet-card.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WagmiProvider, createConfig, http } from "wagmi"; +import { mainnet } from "wagmi/chains"; + +import { ConnectWalletCard } from "@/components/tool-ui/crypto/connect-wallet-card"; + +const mockSendCommand = vi.fn(); +const mockOpenConnectModal = vi.fn(); +const mockUseAccount = vi.fn(); + +vi.mock("@assistant-ui/react-langgraph", () => ({ + useLangGraphSendCommand: () => mockSendCommand, +})); + +vi.mock("wagmi", async () => { + const actual = await vi.importActual("wagmi"); + return { + ...actual, + useAccount: () => mockUseAccount(), + }; +}); + +vi.mock("@rainbow-me/rainbowkit", async () => { + const actual = + await vi.importActual("@rainbow-me/rainbowkit"); + return { + ...actual, + useConnectModal: () => ({ openConnectModal: mockOpenConnectModal }), + }; +}); + +beforeEach(() => { + mockSendCommand.mockReset(); + mockOpenConnectModal.mockReset(); + mockUseAccount.mockReset(); + mockUseAccount.mockReturnValue({ address: undefined, isConnected: false, chainId: undefined }); +}); + +afterEach(() => { + cleanup(); +}); + +const config = createConfig({ + chains: [mainnet], + transports: { [mainnet.id]: http() }, + ssr: true, +}); + +function wrap(node: React.ReactElement) { + const qc = new QueryClient(); + return render( + + {node} + , + ); +} + +function makeProps(result: unknown): React.ComponentProps { + return { + type: "tool-call", + toolCallId: "test", + toolName: "connect_wallet", + argsText: "", + args: {}, + result, + } as React.ComponentProps; +} + +describe("ConnectWalletCard — not connected", () => { + it("renders a Connect button when wagmi is not connected", () => { + wrap(); + expect(screen.getByRole("button", { name: /connect wallet/i })).toBeTruthy(); + }); + + it("clicking Connect opens RainbowKit", () => { + wrap(); + screen.getByRole("button", { name: /connect wallet/i }).click(); + expect(mockOpenConnectModal).toHaveBeenCalledTimes(1); + }); + + it("does not auto-resume when not connected", () => { + wrap(); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); +}); + +describe("ConnectWalletCard — connected, auto-resume", () => { + beforeEach(() => { + mockUseAccount.mockReturnValue({ + address: "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01", + isConnected: true, + chainId: 8453, + }); + }); + + it("shows a brief 'Connecting…' indicator with the address", () => { + wrap(); + expect(screen.getByText(/connecting/i)).toBeTruthy(); + expect(screen.getByText(/0xAbCd…Ef01/)).toBeTruthy(); + }); + + it("auto-resumes with {address, chainId} on first connected render", () => { + wrap(); + expect(mockSendCommand).toHaveBeenCalledTimes(1); + const arg = mockSendCommand.mock.calls[0]?.[0] as { resume: string }; + const payload = JSON.parse(arg.resume); + expect(payload.address).toBe("0xAbCdEf0123456789aBcDeF0123456789AbCdEf01"); + expect(payload.chainId).toBe(8453); + }); + + it("does not auto-resume a second time (Strict Mode double-invoke safe)", () => { + wrap(); + // Simulate React Strict Mode's double-invoke by re-running the + // effect: the ref guard means the second call is a no-op. + act(() => { + mockUseAccount.mockReturnValue({ + address: "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01", + isConnected: true, + chainId: 8453, + }); + }); + expect(mockSendCommand).toHaveBeenCalledTimes(1); + }); + + it("does not re-fire resume after the result is set", () => { + const resume = { + address: "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01", + chainId: 8453, + }; + wrap(); + // Re-render with the same result; the auto-resume effect is gated on + // `parsed` being null, so the ref guard is irrelevant here. + wrap(); + // The first render auto-resumes; the second render (same result) doesn't. + expect(mockSendCommand.mock.calls.length).toBeLessThanOrEqual(1); + }); +}); + +describe("ConnectWalletCard — resolved", () => { + it("renders a resolved confirmation card after a successful resume", () => { + mockUseAccount.mockReturnValue({ address: undefined, isConnected: false, chainId: undefined }); + const resume = { + address: "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01", + chainId: 1, + }; + wrap(); + expect(screen.getByText(/wallet connected/i)).toBeTruthy(); + expect(screen.getByText(/0xAbCd…Ef01/)).toBeTruthy(); + }); + + it("renders an error row when the resume carries a cancelled flag", () => { + mockUseAccount.mockReturnValue({ address: undefined, isConnected: false, chainId: undefined }); + wrap(); + expect(screen.getByText(/connection cancelled/i)).toBeTruthy(); + }); +}); From 56fabd2caf90996ff8e911cc1944e381a05c0a4f Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 14:41:49 +0800 Subject: [PATCH 12/35] feat(frontend): wrap toolkit renders in ToolFallback chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route every tool-call part through ToolFallback so the status icon, duration, and human-readable label show even when the tool has a custom renderer. ToolFallback now accepts a pre-rendered `toolUI` prop and defaults to open when one is provided; without it, the original args/result collapse panels stay intact. Snake-case tool names are humanized in the trigger (`connect_wallet` → `Connect Wallet`). Card top-level `my-2` is dropped across the four chrome-wrapped cards because the content wrapper already adds `pt-1 pb-2`. --- components/assistant-ui/thread.tsx | 28 ++++--- components/assistant-ui/tool-fallback.tsx | 74 ++++++++++++------- .../tool-ui/crypto/order-status-card.tsx | 4 +- .../crypto/place-crypto-order-card.tsx | 14 ++-- components/tool-ui/crypto/price-card.tsx | 2 +- components/tool-ui/weather/weather-card.tsx | 2 +- 6 files changed, 72 insertions(+), 52 deletions(-) diff --git a/components/assistant-ui/thread.tsx b/components/assistant-ui/thread.tsx index 574240a..e04232d 100644 --- a/components/assistant-ui/thread.tsx +++ b/components/assistant-ui/thread.tsx @@ -122,10 +122,11 @@ export const InterruptUI = () => { const ui = value?.ui; const data = value?.data ?? {}; const message = value?.message; - const Render = ui - ? (toolkit as Record }>)[ui]?.render + const entry = ui + ? (toolkit as Record; label?: string }>)[ui] : undefined; - if (!Render) return null; + const Render = entry?.render; + if (!Render || !ui) return null; // Resolved state never renders here — resume clears the interrupt. // `resume` is typed string; we forward the structured pick as-is into @@ -136,13 +137,18 @@ export const InterruptUI = () => { <> {USE_SUBGRAPH && ( - { - void sendCommand({ resume: payload as never }); - }} - /> + + + + { + void sendCommand({ resume: payload as never }); + }} + /> + + )} ); @@ -437,7 +443,7 @@ const AssistantMessage: FC = () => { case "reasoning": return ; case "tool-call": - return part.toolUI ?? ; + return case "data": return part.dataRendererUI; case "indicator": diff --git a/components/assistant-ui/tool-fallback.tsx b/components/assistant-ui/tool-fallback.tsx index a0ed6d5..6a05fdf 100644 --- a/components/assistant-ui/tool-fallback.tsx +++ b/components/assistant-ui/tool-fallback.tsx @@ -71,9 +71,9 @@ function ToolFallbackRoot({ ); } -type ToolStatus = ToolCallMessagePartStatus["type"]; +export type ToolStatus = ToolCallMessagePartStatus["type"]; -const statusIconMap: Record = { +const toolStatusIconMap: Record = { running: LoaderIcon, complete: CheckIcon, incomplete: XCircleIcon, @@ -88,6 +88,19 @@ const formatToolDuration = (ms: number) => { return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`; }; +// "connect_wallet" → "Connect Wallet". Single-word pascal stays the +// same; multiple words split on `_` and title-case each segment. +// Keeps the trigger readable when the renderer is toolkit-supplied — +// the LLM-facing tool name stays snake_case, the user-facing label +// is human. +function humanizeToolName(name: string): string { + return name + .split("_") + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + function ToolFallbackDuration({ className, ...props }: React.ComponentProps<"span">) { const elapsedMs = useToolCallElapsed(); if (elapsedMs === undefined) return null; @@ -119,8 +132,9 @@ function ToolFallbackTrigger({ const isRunning = statusType === "running"; const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; - const Icon = statusIconMap[statusType]; + const Icon = toolStatusIconMap[statusType]; const label = isCancelled ? "Cancelled tool" : "Used tool"; + const displayName = humanizeToolName(toolName); return ( - {label}: {toolName} + {label}: {displayName} {isRunning && ( - {label}: {toolName} + {label}: {displayName} )} @@ -446,21 +460,19 @@ function ToolFallbackApproval({ ); } -const ToolFallbackImpl: ToolCallMessagePartComponent = ({ - toolName, - argsText, - result, - status, - addResult, - resume, - interrupt, - approval, - respondToApproval, -}) => { +const ToolFallbackImpl: ToolCallMessagePartComponent = (props) => { + const { toolName, argsText, result, status, addResult, resume, interrupt, approval, respondToApproval } = + props; + // `toolUI` is the toolkit renderer's pre-rendered card. Not part of + // ToolCallMessagePartProps — passed by thread.tsx as an extra prop. + const toolUI = (props as unknown as { toolUI?: React.ReactNode }).toolUI; const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; const isRequiresAction = status?.type === "requires-action"; - const [open, setOpen] = useState(isRequiresAction); + // Open by default when a custom card body is provided — the card is + // the content, not the args/result panels. For host-less tools the + // collapsible keeps args/result out of the way until the user opts in. + const [open, setOpen] = useState(isRequiresAction || toolUI != null); const [prevRequiresAction, setPrevRequiresAction] = useState(isRequiresAction); if (isRequiresAction !== prevRequiresAction) { setPrevRequiresAction(isRequiresAction); @@ -471,18 +483,24 @@ const ToolFallbackImpl: ToolCallMessagePartComponent = ({ - - - {isRequiresAction && ( - + {toolUI != null ? ( + toolUI + ) : ( + <> + + + {isRequiresAction && ( + + )} + {!isCancelled && } + )} - {!isCancelled && } ); diff --git a/components/tool-ui/crypto/order-status-card.tsx b/components/tool-ui/crypto/order-status-card.tsx index 1b87f23..809c1fa 100644 --- a/components/tool-ui/crypto/order-status-card.tsx +++ b/components/tool-ui/crypto/order-status-card.tsx @@ -87,7 +87,7 @@ export const OrderStatusCard: ToolCallMessagePartComponent = ({ result, ar return (
    @@ -142,7 +142,7 @@ function StatusReceipt({ status }: { status: ResumePayload }) { return (
    diff --git a/components/tool-ui/crypto/place-crypto-order-card.tsx b/components/tool-ui/crypto/place-crypto-order-card.tsx index b05a194..ade2026 100644 --- a/components/tool-ui/crypto/place-crypto-order-card.tsx +++ b/components/tool-ui/crypto/place-crypto-order-card.tsx @@ -145,7 +145,7 @@ export const PlaceCryptoOrderCard: ToolCallMessagePartComponent = ({ resul return (
    Swap cancelled.
    @@ -491,14 +491,10 @@ function PreviewWorkspace({ {submitting ? ( ) : ( - + Accept Swap {quote && ( - - · {secondsUntilRefresh}s - + · {secondsUntilRefresh}s )} )} @@ -526,7 +522,7 @@ function CardShell({ children }: { children: React.ReactNode }) { return (
    @@ -550,7 +546,7 @@ function SimulatedReceipt({ order }: { order: SimulatedOrder }) { return (
    diff --git a/components/tool-ui/crypto/price-card.tsx b/components/tool-ui/crypto/price-card.tsx index 54534ff..a966941 100644 --- a/components/tool-ui/crypto/price-card.tsx +++ b/components/tool-ui/crypto/price-card.tsx @@ -121,7 +121,7 @@ export const CryptoPriceCard: ToolCallMessagePartComponent = ({ re return (
      {parsed.coins.map((c) => { diff --git a/components/tool-ui/weather/weather-card.tsx b/components/tool-ui/weather/weather-card.tsx index 951a895..b71e66e 100644 --- a/components/tool-ui/weather/weather-card.tsx +++ b/components/tool-ui/weather/weather-card.tsx @@ -99,7 +99,7 @@ function WeatherCardWithRevivedEffects({ }, []); return ( -
      +
      ); From dc06261bc72753144cde435e412549defb315ba5 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 14:41:53 +0800 Subject: [PATCH 13/35] feat(crypto): explicit confirm flow for connect-wallet card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the Strict-Mode-guarded auto-resume — it skipped the user's 'Use a different wallet' choice. The connected view now shows a header with the linked address plus a footer of Cancel (left) and a segmented control (right): `Use this wallet` resumes with {address, chainId}; the chevron half opens a portal-rendered menu whose only item is `Use a different wallet` → `openAccountModal`. Cancel resumes with {error: 'cancelled'}. The dropdown is portaled to `document.body` to escape `ToolFallbackContent`'s `overflow-hidden` clipping used for the collapsible animation. Position is recomputed on resize and scroll via the trigger's getBoundingClientRect. --- .../tool-ui/crypto/connect-wallet-card.tsx | 199 ++++++++++++++---- .../crypto/connect-wallet-card.test.tsx | 59 +++--- 2 files changed, 191 insertions(+), 67 deletions(-) diff --git a/components/tool-ui/crypto/connect-wallet-card.tsx b/components/tool-ui/crypto/connect-wallet-card.tsx index 5ba69d0..dc52c68 100644 --- a/components/tool-ui/crypto/connect-wallet-card.tsx +++ b/components/tool-ui/crypto/connect-wallet-card.tsx @@ -1,11 +1,12 @@ "use client"; import * as React from "react"; -import { CheckCircle2Icon, Loader2Icon, WalletIcon } from "lucide-react"; +import { createPortal } from "react-dom"; +import { CheckCircle2Icon, ChevronDownIcon, WalletIcon } from "lucide-react"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; import { useLangGraphSendCommand } from "@assistant-ui/react-langgraph"; import { useAccount } from "wagmi"; -import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { useAccountModal, useConnectModal } from "@rainbow-me/rainbowkit"; import { Button } from "@/components/ui/button"; import { AddressOrHash } from "@/components/ui/address-or-hash"; @@ -16,20 +17,15 @@ import { unwrapToolResult } from "@/components/tool-ui/tool-result"; // travels as the interrupt's `message` field and is rendered separately // by the runtime, so this card reads only wallet state. // -// Two views: +// Three views: // -// 1. Wallet NOT connected → Connect button. Click opens RainbowKit. -// 2. Wallet connected (no resume yet) → tiny "Connecting" indicator; -// a ref-guarded useEffect auto-resumes with {address, chainId} on -// the first render where wagmi reports connected. The ref guards -// against the Strict Mode dev double-invoke that previously caused -// the second resume to consume an already-finished interrupt and -// render as [object Object]. -// 3. Resolved (result set) → confirmation row with the chosen address. +// 1. Resolved (result set) → confirmation row with the chosen address. +// 2. Connected (no resume yet) → footer has Cancel (left) and a +// segmented "Use this wallet" / dropdown-arrow (right). The +// arrow opens a small menu with "Use a different wallet". +// 3. Not connected → single "Connect wallet" button. // -// Switching wallets: handled by the user's wallet app, not the card. -// RainbowKit's account modal still works if the user opens it -// elsewhere; the next tool that reads the address will see the new one. +// The card never auto-resumes — the user picks an action explicitly. type ResumePayload = { address: `0x${string}`; chainId: number } | { error: string }; @@ -58,26 +54,17 @@ export const ConnectWalletCard: ToolCallMessagePartComponent { - if (hasAutoResumedRef.current) return; - if (!isConnected || !address || !chainId) return; - if (parsed) return; // resume already completed, no need to re-fire - hasAutoResumedRef.current = true; - sendCommand({ resume: JSON.stringify({ address, chainId }) }); - }, [isConnected, address, chainId, parsed, sendCommand]); + const cancel = () => sendCommand({ resume: JSON.stringify({ error: "cancelled" }) }); if (parsed && "address" in parsed) { return (
      @@ -106,25 +93,42 @@ export const ConnectWalletCard: ToolCallMessagePartComponent + sendCommand({ resume: JSON.stringify({ address, chainId }) }); return (
      -
      -
      - -
      -
      -

      Connecting…

      -

      - - · {chainName(chainId)} -

      +
      +
      +
      + +
      +
      +

      Authorize Wallet

      +

      + + · {chainName(chainId)} +

      +
      +
      +
      + + openAccountModal?.()} />
      @@ -135,7 +139,7 @@ export const ConnectWalletCard: ToolCallMessagePartComponent
      @@ -159,3 +163,116 @@ export const ConnectWalletCard: ToolCallMessagePartComponent ); }; + +// Segmented control: "Use this wallet" (main action) on the left, +// vertical divider, chevron trigger on the right that opens a menu. +// flex-1 fills the right column of the footer; the chevron stays a +// fixed width so the divider stays centered. +function SegmentedConfirm({ + onConfirm, + onSwitchWallet, +}: { + onConfirm: () => void; + onSwitchWallet: () => void; +}) { + const [open, setOpen] = React.useState(false); + const [menuPos, setMenuPos] = React.useState<{ top: number; right: number } | null>(null); + const rootRef = React.useRef(null); + const triggerRef = React.useRef(null); + + const updatePos = React.useCallback(() => { + const el = triggerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); + }, []); + + React.useEffect(() => { + if (!open) { + setMenuPos(null); + return; + } + updatePos(); + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (!rootRef.current?.contains(target) && !(target as Element).closest?.("[data-slot=connect-wallet-menu]")) { + setOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("resize", updatePos); + window.addEventListener("scroll", updatePos, true); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("resize", updatePos); + window.removeEventListener("scroll", updatePos, true); + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open, updatePos]); + + return ( +
      +
      + + +
      + {open && + menuPos && + typeof document !== "undefined" && + createPortal( +
      + +
      , + document.body, + )} +
      + ); +} diff --git a/tests/frontend/crypto/connect-wallet-card.test.tsx b/tests/frontend/crypto/connect-wallet-card.test.tsx index a77be87..3e69cd8 100644 --- a/tests/frontend/crypto/connect-wallet-card.test.tsx +++ b/tests/frontend/crypto/connect-wallet-card.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, act } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { WagmiProvider, createConfig, http } from "wagmi"; import { mainnet } from "wagmi/chains"; @@ -8,6 +8,7 @@ import { ConnectWalletCard } from "@/components/tool-ui/crypto/connect-wallet-ca const mockSendCommand = vi.fn(); const mockOpenConnectModal = vi.fn(); +const mockOpenAccountModal = vi.fn(); const mockUseAccount = vi.fn(); vi.mock("@assistant-ui/react-langgraph", () => ({ @@ -28,12 +29,14 @@ vi.mock("@rainbow-me/rainbowkit", async () => { return { ...actual, useConnectModal: () => ({ openConnectModal: mockOpenConnectModal }), + useAccountModal: () => ({ openAccountModal: mockOpenAccountModal }), }; }); beforeEach(() => { mockSendCommand.mockReset(); mockOpenConnectModal.mockReset(); + mockOpenAccountModal.mockReset(); mockUseAccount.mockReset(); mockUseAccount.mockReturnValue({ address: undefined, isConnected: false, chainId: undefined }); }); @@ -86,7 +89,7 @@ describe("ConnectWalletCard — not connected", () => { }); }); -describe("ConnectWalletCard — connected, auto-resume", () => { +describe("ConnectWalletCard — connected, awaiting confirmation", () => { beforeEach(() => { mockUseAccount.mockReturnValue({ address: "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01", @@ -95,14 +98,23 @@ describe("ConnectWalletCard — connected, auto-resume", () => { }); }); - it("shows a brief 'Connecting…' indicator with the address", () => { + it("renders the connected address in the header", () => { wrap(); - expect(screen.getByText(/connecting/i)).toBeTruthy(); expect(screen.getByText(/0xAbCd…Ef01/)).toBeTruthy(); }); - it("auto-resumes with {address, chainId} on first connected render", () => { + it("renders Cancel on the left and Use this wallet on the right", () => { wrap(); + const cancel = screen.getByRole("button", { name: /^cancel$/i }); + const useThis = screen.getByRole("button", { name: /use this wallet/i }); + expect(cancel).toBeTruthy(); + expect(useThis).toBeTruthy(); + expect(cancel.compareDocumentPosition(useThis) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("clicking 'Use this wallet' sends resume with {address, chainId}", () => { + wrap(); + fireEvent.click(screen.getByRole("button", { name: /use this wallet/i })); expect(mockSendCommand).toHaveBeenCalledTimes(1); const arg = mockSendCommand.mock.calls[0]?.[0] as { resume: string }; const payload = JSON.parse(arg.resume); @@ -110,31 +122,26 @@ describe("ConnectWalletCard — connected, auto-resume", () => { expect(payload.chainId).toBe(8453); }); - it("does not auto-resume a second time (Strict Mode double-invoke safe)", () => { + it("clicking Cancel sends resume with {error:'cancelled'}", () => { wrap(); - // Simulate React Strict Mode's double-invoke by re-running the - // effect: the ref guard means the second call is a no-op. - act(() => { - mockUseAccount.mockReturnValue({ - address: "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01", - isConnected: true, - chainId: 8453, - }); - }); + fireEvent.click(screen.getByRole("button", { name: /^cancel$/i })); expect(mockSendCommand).toHaveBeenCalledTimes(1); + const arg = mockSendCommand.mock.calls[0]?.[0] as { resume: string }; + expect(JSON.parse(arg.resume)).toEqual({ error: "cancelled" }); }); - it("does not re-fire resume after the result is set", () => { - const resume = { - address: "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01", - chainId: 8453, - }; - wrap(); - // Re-render with the same result; the auto-resume effect is gated on - // `parsed` being null, so the ref guard is irrelevant here. - wrap(); - // The first render auto-resumes; the second render (same result) doesn't. - expect(mockSendCommand.mock.calls.length).toBeLessThanOrEqual(1); + it("chevron trigger opens a menu with 'Use a different wallet'", () => { + wrap(); + expect(screen.queryByRole("menu")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /more options/i })); + fireEvent.click(screen.getByRole("menuitem", { name: /use a different wallet/i })); + expect(mockOpenAccountModal).toHaveBeenCalledTimes(1); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + + it("does not auto-resume on mount", () => { + wrap(); + expect(mockSendCommand).not.toHaveBeenCalled(); }); }); From 16e9ae80e23f1cead0622173370d5eea144dd0f2 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 14:41:56 +0800 Subject: [PATCH 14/35] style(e2e): prettier-wrap signatures + trailing newlines --- tests/e2e/btc-catalog-removed.spec.ts | 2 +- tests/e2e/crypto-trade-flow.spec.ts | 6 +----- tests/e2e/mock-coin-card.spec.ts | 2 +- tests/e2e/wallet-mock.ts | 6 ++---- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/tests/e2e/btc-catalog-removed.spec.ts b/tests/e2e/btc-catalog-removed.spec.ts index f29d5dd..22332ae 100644 --- a/tests/e2e/btc-catalog-removed.spec.ts +++ b/tests/e2e/btc-catalog-removed.spec.ts @@ -25,4 +25,4 @@ test("card shows SPEND MC + BTC target + total spent row", async ({ page }) => { await expect(card.getByText(/not supported/i)).toHaveCount(0); // Total spent row (base + gas in MC). await expect(card.getByText(/total spent/i)).toBeVisible(); -}); \ No newline at end of file +}); diff --git a/tests/e2e/crypto-trade-flow.spec.ts b/tests/e2e/crypto-trade-flow.spec.ts index 84b2e61..474be71 100644 --- a/tests/e2e/crypto-trade-flow.spec.ts +++ b/tests/e2e/crypto-trade-flow.spec.ts @@ -1,9 +1,5 @@ import { test, expect } from "@playwright/test"; -import { - MOCK_WALLET_ADDRESS, - mockWalletConnected, - mockWalletDisconnected, -} from "./wallet-mock"; +import { MOCK_WALLET_ADDRESS, mockWalletConnected, mockWalletDisconnected } from "./wallet-mock"; // E2E for the crypto atomic-tools refactor. The vite harness mounts all // 3 cards in vertical sequence. Default state is DISCONNECTED so the diff --git a/tests/e2e/mock-coin-card.spec.ts b/tests/e2e/mock-coin-card.spec.ts index 152ab10..9a33f35 100644 --- a/tests/e2e/mock-coin-card.spec.ts +++ b/tests/e2e/mock-coin-card.spec.ts @@ -27,4 +27,4 @@ test("place-order card shows SPEND MC, FOR target, gas-in-MC", async ({ page }) // Total spent row is present (MC value). await expect(card.getByText(/total spent/i)).toBeVisible(); await expect(card.locator('[data-action="place-simulated-order"]')).toBeVisible(); -}); \ No newline at end of file +}); diff --git a/tests/e2e/wallet-mock.ts b/tests/e2e/wallet-mock.ts index 4e581d0..865aa30 100644 --- a/tests/e2e/wallet-mock.ts +++ b/tests/e2e/wallet-mock.ts @@ -20,9 +20,7 @@ export async function mockWalletConnected(page: import("@playwright/test").Page) ); } -export async function mockWalletDisconnected( - page: import("@playwright/test").Page, -): Promise { +export async function mockWalletDisconnected(page: import("@playwright/test").Page): Promise { await page.addInitScript(() => { (window as unknown as { __cryptoMockAccount: object }).__cryptoMockAccount = { isConnected: false, @@ -30,4 +28,4 @@ export async function mockWalletDisconnected( chainId: undefined, }; }); -} \ No newline at end of file +} From 982854d8950b1105f56b95bb7d36f1b60994b59a Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 14:48:50 +0800 Subject: [PATCH 15/35] chore: drop menu wallet icon + price-card rank spacing --- components/tool-ui/crypto/connect-wallet-card.tsx | 1 - components/tool-ui/crypto/price-card.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/components/tool-ui/crypto/connect-wallet-card.tsx b/components/tool-ui/crypto/connect-wallet-card.tsx index dc52c68..5b8cd69 100644 --- a/components/tool-ui/crypto/connect-wallet-card.tsx +++ b/components/tool-ui/crypto/connect-wallet-card.tsx @@ -267,7 +267,6 @@ function SegmentedConfirm({ }} className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors" > - Use a different wallet
      , diff --git a/components/tool-ui/crypto/price-card.tsx b/components/tool-ui/crypto/price-card.tsx index a966941..66322cf 100644 --- a/components/tool-ui/crypto/price-card.tsx +++ b/components/tool-ui/crypto/price-card.tsx @@ -142,7 +142,7 @@ export const CryptoPriceCard: ToolCallMessagePartComponent = ({ re {c.name}
      -
      +
      Rank #{c.market_cap_rank}
      From 502ae284d6b01d6382bdff390399f8ba3b82bfa1 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 15:01:52 +0800 Subject: [PATCH 16/35] chore(frontend): merge weather-widget directory into weather --- .../generated/weather-runtime-core.generated.ts | 0 components/tool-ui/{weather-widget => weather}/runtime.ts | 0 .../tool-ui/{weather-widget => weather}/schema-runtime.ts | 0 components/tool-ui/weather/weather-card.tsx | 8 ++++---- .../{weather-widget => weather}/weather-data-overlay.tsx | 0 .../weather-widget-container.tsx | 0 lib/open-meteo.ts | 2 +- 7 files changed, 5 insertions(+), 5 deletions(-) rename components/tool-ui/{weather-widget => weather}/generated/weather-runtime-core.generated.ts (100%) rename components/tool-ui/{weather-widget => weather}/runtime.ts (100%) rename components/tool-ui/{weather-widget => weather}/schema-runtime.ts (100%) rename components/tool-ui/{weather-widget => weather}/weather-data-overlay.tsx (100%) rename components/tool-ui/{weather-widget => weather}/weather-widget-container.tsx (100%) diff --git a/components/tool-ui/weather-widget/generated/weather-runtime-core.generated.ts b/components/tool-ui/weather/generated/weather-runtime-core.generated.ts similarity index 100% rename from components/tool-ui/weather-widget/generated/weather-runtime-core.generated.ts rename to components/tool-ui/weather/generated/weather-runtime-core.generated.ts diff --git a/components/tool-ui/weather-widget/runtime.ts b/components/tool-ui/weather/runtime.ts similarity index 100% rename from components/tool-ui/weather-widget/runtime.ts rename to components/tool-ui/weather/runtime.ts diff --git a/components/tool-ui/weather-widget/schema-runtime.ts b/components/tool-ui/weather/schema-runtime.ts similarity index 100% rename from components/tool-ui/weather-widget/schema-runtime.ts rename to components/tool-ui/weather/schema-runtime.ts diff --git a/components/tool-ui/weather/weather-card.tsx b/components/tool-ui/weather/weather-card.tsx index b71e66e..592a624 100644 --- a/components/tool-ui/weather/weather-card.tsx +++ b/components/tool-ui/weather/weather-card.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; import { ToolCardSkeleton } from "@/components/tool-ui/tool-card-skeleton"; -import { WeatherWidget } from "@/components/tool-ui/weather-widget/runtime"; +import { WeatherWidget } from "@/components/tool-ui/weather/runtime"; import { unwrapToolResult } from "@/components/tool-ui/tool-result"; type Args = { @@ -15,7 +15,7 @@ type Args = { type WeatherToolSuccess = { success: true; - widget: import("@/components/tool-ui/weather-widget/runtime").WeatherWidgetPayload; + widget: import("@/components/tool-ui/weather/runtime").WeatherWidgetPayload; }; type WeatherToolFailure = { @@ -29,7 +29,7 @@ type ParsedResult = | { kind: "loading" } | { kind: "ok"; - widget: import("@/components/tool-ui/weather-widget/runtime").WeatherWidgetPayload; + widget: import("@/components/tool-ui/weather/runtime").WeatherWidgetPayload; } | { kind: "error"; message: string }; @@ -77,7 +77,7 @@ export const WeatherCard: ToolCallMessagePartComponent = ({ result function WeatherCardWithRevivedEffects({ widget, }: { - widget: import("@/components/tool-ui/weather-widget/runtime").WeatherWidgetPayload; + widget: import("@/components/tool-ui/weather/runtime").WeatherWidgetPayload; }) { const ref = useRef(null); const [key, setKey] = useState(0); diff --git a/components/tool-ui/weather-widget/weather-data-overlay.tsx b/components/tool-ui/weather/weather-data-overlay.tsx similarity index 100% rename from components/tool-ui/weather-widget/weather-data-overlay.tsx rename to components/tool-ui/weather/weather-data-overlay.tsx diff --git a/components/tool-ui/weather-widget/weather-widget-container.tsx b/components/tool-ui/weather/weather-widget-container.tsx similarity index 100% rename from components/tool-ui/weather-widget/weather-widget-container.tsx rename to components/tool-ui/weather/weather-widget-container.tsx diff --git a/lib/open-meteo.ts b/lib/open-meteo.ts index d5ce235..5eed14e 100644 --- a/lib/open-meteo.ts +++ b/lib/open-meteo.ts @@ -8,7 +8,7 @@ import type { TemperatureUnit, WeatherConditionCode, WeatherWidgetPayload, -} from "@/components/tool-ui/weather-widget/runtime"; +} from "@/components/tool-ui/weather/runtime"; export interface WeatherSearchArgs { query: string; From bc8dd6ee30436c4bebc66db6bf29df46f1035c86 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 15:01:57 +0800 Subject: [PATCH 17/35] chore(frontend): drop no-op render on geocode_location --- components/tool-ui/toolkit.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/components/tool-ui/toolkit.tsx b/components/tool-ui/toolkit.tsx index a53e55c..710468d 100644 --- a/components/tool-ui/toolkit.tsx +++ b/components/tool-ui/toolkit.tsx @@ -26,9 +26,6 @@ const weatherToolkit = defineToolkit({ geocode_location: { description: "Geocode a place name. Server returns coords or an error.", parameters: z.object({ query: z.string() }), - // No render — geocode is fast (≤300ms) and is an internal helper, - // not user-facing. Showing a card here would be noise. - render: () => null, }, get_weather: { description: "Fetch and render the weather widget for the given coords.", From 8a44e172557059470082e15325b41447b582741b Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 15:02:01 +0800 Subject: [PATCH 18/35] chore(ui): drop icon prefix from ask-location button --- components/tool-ui/ask-location/ask-location-card.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/components/tool-ui/ask-location/ask-location-card.tsx b/components/tool-ui/ask-location/ask-location-card.tsx index 02a7504..48c8337 100644 --- a/components/tool-ui/ask-location/ask-location-card.tsx +++ b/components/tool-ui/ask-location/ask-location-card.tsx @@ -82,7 +82,7 @@ export const AskLocationCard: ToolCallMessagePartComponent
      @@ -148,10 +148,9 @@ export const AskLocationCard: ToolCallMessagePartComponent type="button" variant="default" size="sm" - className="w-full justify-center gap-2" + className="w-full justify-center" onClick={handleUseDeviceLocation} > - {mode.kind === "denied" ? "Try location again" : "Use my location"} From baa74d0d4e483a6b056fb0cdb27e5a59a1a57778 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 15:02:08 +0800 Subject: [PATCH 19/35] feat(crypto): hard-block investment advice in CRYPTO_AGENT_PROMPT Adds a NO INVESTMENT ADVICE section: no buy/sell/hold recommendations, no price-direction predictions, decline cleanly when asked, no pre-empting user intent, no promotional language. Applies across all reply languages. --- backend/prompt/system.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/prompt/system.ts b/backend/prompt/system.ts index 625a0a1..5771a36 100644 --- a/backend/prompt/system.ts +++ b/backend/prompt/system.ts @@ -118,4 +118,12 @@ GENERAL RULES: - On any tool returning {success: false} or an error, ask the user to clarify (different coin, valid amount, retry, different chain). Never invent prices, quantities, fx rates, addresses, or order ids. - CoinGecko's free tier rate-limits aggressively — if get_crypto_price keeps failing, tell the user to wait and try again. - ANY CoinGecko id is accepted as the target — BTC, ETH, USDC, dogecoin, solana, pepe, anything. The simulated flow has no allowlist. Just map the user's ticker to the right CoinGecko id. -- Never repeat CoinGecko numbers in your prose — the cards render them.`; +- Never repeat CoinGecko numbers in your prose — the cards render them. + +NO INVESTMENT ADVICE (HARD CONSTRAINT — applies to every turn): +- You are NOT a financial advisor. Never recommend buying, selling, holding, or swapping any token. Never suggest that a price is "low", "high", "about to go up", "about to crash", a "good entry", or otherwise frame timing or direction. +- Never predict future price movement, market direction, or outcomes ("BTC will hit 100k", "this looks bullish", "buy the dip"). On the price-query flow, just state the numbers the card already shows — no editorializing. +- If the user asks for advice ("should I buy", "is now a good time", "what do you think of ETH"), decline in one sentence and describe what the cards actually do — they execute a SIMULATED swap against the user's Mock Coin balance against live CoinGecko USD prices, nothing more. Do not soften the decline with directional language ("but historically…", "many people…"). +- The user always initiates trades. Never pre-empt them with suggestions of your own (e.g. "you might also want to swap some of your MC for…"). Describe only what they asked for. +- Never use persuasive / promotional language about a token ("strong project", "solid fundamentals", "community favourite"). Stick to neutral facts. +- This applies in every language you reply in.`; From f489fa8742848f6360da37634b4625d857775f02 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 15:02:18 +0800 Subject: [PATCH 20/35] docs: catalog tools + sync crypto interrupt examples TOOLS.md inventories every backend tool + frontend card (11 active, 1 dormant). INTERRUPT.md expands the example section from one (ask_location) to four, adding the crypto trade-flow interrupts (connect_wallet, place_crypto_order, get_order_status) with their resume payload shapes. --- docs/INTERRUPT.md | 45 ++++++++++++++++++++++++++++-- docs/TOOLS.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 docs/TOOLS.md diff --git a/docs/INTERRUPT.md b/docs/INTERRUPT.md index dc26337..9d992eb 100644 --- a/docs/INTERRUPT.md +++ b/docs/INTERRUPT.md @@ -68,9 +68,15 @@ mounts and how it resumes change. In inlined mode the card uses the `useLangGraphSendCommand` (the `addResult` prop never carries a useful value). -## Example: `ask_location` +## Examples -The one interrupt currently in use. Tool (`backend/tool/ask-location.ts`): +Four interrupts are currently in use: one for the weather flow, three for the +crypto trade flow. The contract (`{ ui, data, message }`) is the same — only +the `ui` discriminator and resume payload differ. + +### `ask_location` — weather flow + +Tool (`backend/tool/ask-location.ts`): ```ts export const ASK_LOCATION_TOOL_NAME = "ask_location"; @@ -93,6 +99,41 @@ type AskLocationResult = | { error: string }; // permission denied / geocode failed ``` +### `connect_wallet` — crypto trade flow, step 1 + +Tool (`backend/tool/crypto/connect-wallet.ts`) opens RainbowKit on resume. +The card reads the address + chain from wagmi state and forwards them back +as the resume value: + +```ts +type ConnectWalletResume = + | { address: `0x${string}`; chainId: number } // user picked a wallet + | { cancelled: true; message?: string }; // user dismissed the modal +``` + +### `place_crypto_order` — crypto trade flow, step 2 + +Tool (`backend/tool/crypto/place-crypto-order.ts`) fetches a live CoinGecko +USD quote against Mock Coin and synthesizes a `SimulatedOrder` on click. +Payload: + +```ts +type PlaceCryptoOrderResume = + | SimulatedOrder // status: "simulated_filled", has order_uid + amounts + | { status: "cancelled"; message?: string }; +``` + +### `get_order_status` — crypto trade flow, step 3 + +Tool (`backend/tool/crypto/get-order-status.ts`) polls the CoW `/orders/{uid}` +endpoint. Payload: + +```ts +type GetOrderStatusResume = + | { status: "open" | "filled" | "cancelled" | "expired" | "partially_filled"; /* … */ } + | { status: "error"; message: string }; +``` + ## Adding a new interrupt-driven tool 1. **Server.** Add a tool under `backend/tool/`. Call `interrupt({ ui, data, message })` and validate the resumed value before using it. Keep the `ui` string stable — it becomes the toolkit registry key. diff --git a/docs/TOOLS.md b/docs/TOOLS.md new file mode 100644 index 0000000..08fd5b7 --- /dev/null +++ b/docs/TOOLS.md @@ -0,0 +1,71 @@ +# Tools + +Inventory of every LangGraph tool the agent can call, and the matching +frontend card (if any). Backend definitions live under `backend/tool/`; the +single source of truth that wires them into the graph is `backend/tool/index.ts`. +Frontend registrations live in `components/tool-ui/toolkit.tsx`. + +Update this file when adding, removing, renaming, or re-routing a tool or its +card — same rule as `docs/APIS.md`. + +## Tool groups + +| Group | Backend path | Card path | +| ------- | ----------------------------- | ---------------------------------- | +| Weather | `backend/tool/` (root) | `components/tool-ui/weather/` | +| Crypto | `backend/tool/crypto/` | `components/tool-ui/crypto/` | +| Web | `backend/tool/` (root) | — (plain tool messages) | + +## Weather + +| Tool | Backend file | Frontend card | Notes | +| ----------------- | ------------------------ | ------------------------- | ------------------------------------------------------------------------- | +| `ask_location` | `ask-location.ts` | `ask-location-card.tsx` | Interrupt-driven. User clicks / types → `addResult` resumes the agent. | +| `geocode_location`| `geocode.ts` | — | Plain `ToolMessage`. Frontend just shows the tool-call part with the args. | +| `get_weather` | `fetch-weather.ts` | `weather-card.tsx` | Vendored widget re-renders on `IntersectionObserver` re-entry. | + +`WEATHER_AGENT_PROMPT` enforces one-tool-per-turn across this chain so the +ask-location card isn't raced by a parallel tool call. See +`docs/INTERRUPT.md` for the two runtime paths the ask-location card can take. + +## Crypto + +| Tool | Backend file | Frontend card | Notes | +| ------------------- | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------- | +| `get_crypto_price` | `crypto/get-crypto-price.ts` | `crypto/price-card.tsx` | One card per coin in `ids[]`. Result `{ success, prices[] }`. | +| `get_fx_rate` | `crypto/get-fx-rate.ts` | — | Frankfurter, 60s in-memory cache. Plain `ToolMessage`. | +| `connect_wallet` | `crypto/connect-wallet.ts` | `crypto/connect-wallet-card.tsx`| Interrupt-driven. Reads wallet from wagmi; resumes with `{ address }`. | +| `place_crypto_order`| `crypto/place-crypto-order.ts` | `crypto/place-crypto-order-card.tsx`| Interrupt-driven. Simulated swap; resumes with `SimulatedOrder` or `cancelled`. | +| `get_order_status` | `crypto/get-order-status.ts` | `crypto/order-status-card.tsx` | Interrupt-driven. Polls CoW `/orders/{uid}`; resumes with status payload. | +| `get_token_balances`| `crypto/get-token-balances.ts` | — | Defined but not wired into `ALL_TOOLS` yet — dormant. | + +Trade flow is split into three atomic interrupt tools (connect → place → check) +so each is its own user decision point and `ToolMessage` the LLM can reason +about independently. + +## Web + +| Tool | Backend file | Frontend card | Notes | +| ----------- | -------------------- | ------------- | ----------------------------------------------------------- | +| `searchWeb` | `web-search.ts` | — | Jina `s.jina.ai`, key-pool auth via `lib/jina.ts`. | +| `fetchUrl` | `web-fetch.ts` | — | Jina `r.jina.ai`, returns `{ title, content, url }` as MD. | + +## Frontend wiring + +`components/tool-ui/toolkit.tsx` is the only place that maps tool names to +`render` components (assistant-ui's `defineToolkit`). Each entry there is a +one-line registration; the actual card is a separate import. When you add a +tool: + +1. Drop the backend file under `backend/tool/` (or `backend/tool/crypto/`). +2. Export the tool from `backend/tool/index.ts` and add it to the relevant + `*_TOOLS` array (and `ALL_TOOLS` if the graph should see it). +3. If the tool has a card, drop it under `components/tool-ui//` and + register the name → component in `toolkit.tsx`. +4. Add a row to the matching table above. + +## Tool-call UI rules + +Components rendered inside a tool-call part live inside `ToolFallbackContent`, +which already provides `ps-6 pt-1 pb-2` padding. See CLAUDE.md rules #6 +(spacing) and #8 (buttons are text-only — no Lucide icon prefix). \ No newline at end of file From 72075a0e94177c2990a364ef9b5188c644d88b41 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 15:02:23 +0800 Subject: [PATCH 21/35] docs(CLAUDE): no-icon rule + sync crypto sub-agent architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule #8: tool-ui buttons are text-only — no Lucide icon prefix even with gap-2. Architecture tree, Backend graph prose (two→three sub-flows), Environment vars, and Frontend runtime section all updated to cover the crypto sub-agent that landed earlier (subgraph + interrupt cards + wagmi/RainbowKit + Alchemy proxy). --- CLAUDE.md | 49 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d84fed8..ef12816 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,11 @@ Copy `.env.example` to `.env.local` and fill in: - `LANGCHAIN_API_KEY` — sent as `x-api-key` by the proxy; leave blank for local dev. - `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` — graph id, must match a key in `langgraph.json` (`agent`). - `NEXT_PUBLIC_LANGGRAPH_API_URL` — optional. If set, the browser skips the `/api` proxy and talks to LangGraph directly. Leave unset to use the in-app proxy. -- `USE_SUBGRAPH` — backend graph topology toggle. `true` uses compiled `weatherAgent` / `chatAgent` subgraphs; unset (default) uses the inlined version that flattens them into the parent graph. The inlined default is a workaround for the `@langchain/core@1.2.1` `EventStreamCallbackHandler` "Run ID not found in run map" bug that LangGraph JS subgraphs trigger — see `memory/langgraph-subgraph-run-map-bug.md`. +- `ALCHEMY_API_KEY` — server-only, used by `app/api/alchemy/[...path]` to proxy JSON-RPC. Required for the price-card USD valuation (CoinGecko USD prices are converted through Alchemy `eth/usd`). +- `ALCHEMY_DISABLED_NETWORKS` — optional comma-separated Alchemy network slugs the proxy will reject. Default deny-list lives in `lib/alchemy/networks.ts`. +- `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` — Reown projectId, required for WalletConnect-based wallets (Binance, Bitget) to expose their mobile-QR fallback; injected wallets (MetaMask, Coinbase) work without it. +- `NEXT_PUBLIC_CRYPTO_REAL_SWAP` — feature flag for the live Uniswap V3 swap path. Unset/`false` keeps `place_crypto_order` in SIMULATED mode (Mock Coin balance, no signing, no broadcast). Set `true` to enable the real path (currently dormant). +- `USE_SUBGRAPH` — backend graph topology toggle. `true` uses compiled `weatherAgent` / `chatAgent` / `cryptoAgent` subgraphs; unset (default) uses the inlined version that flattens them into the parent graph. The inlined default is a workaround for the `@langchain/core@1.2.1` `EventStreamCallbackHandler` "Run ID not found in run map" bug that LangGraph JS subgraphs trigger — see `memory/langgraph-subgraph-run-map-bug.md`. - `NEXT_PUBLIC_USE_SUBGRAPH` — frontend mirror of `USE_SUBGRAPH`. Required because Next.js only inlines `NEXT_PUBLIC_*` vars into the browser bundle; the frontend reads this to decide whether to render the interrupt-UI card (`InterruptUI`) or the inline tool-call card. LangGraph CLI also reads `.env.local` (`langgraph.json` → `env: ".env.local"`) and pins Node 22. @@ -67,42 +71,53 @@ backend/ agent/ chat-agent.ts chatAgent compiled subgraph (USE_SUBGRAPH=true path) weather-agent.ts weatherAgent compiled subgraph (USE_SUBGRAPH=true path) + crypto-agent.ts cryptoAgent compiled subgraph (USE_SUBGRAPH=true path) node/ call-model-node.ts "agent" node — calls the model, appends AI reply rename-thread-agent-node.ts "renameThreadAgent" node — generates + persists the title - router-agent-node.ts "routerAgent" — picks weatherAgent vs chatAgent per turn + router-agent-node.ts "routerAgent" — picks weatherAgent / chatAgent / cryptoAgent per turn after-agent-node.ts "afterAgent" — touches threads.last_message_at - prompt/system.ts CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, ROUTER_AGENT_PROMPT, RENAME_THREAD_PROMPT + prompt/system.ts CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, CRYPTO_AGENT_PROMPT, ROUTER_AGENT_PROMPT, RENAME_THREAD_PROMPT tool/ ask_location, geocode_location, get_weather, searchWeb, fetchUrl + tool/crypto/ get_crypto_price, get_fx_rate, get_token_balances, connect_wallet, place_crypto_order, get_order_status langgraph.json CLI config: graph id, node version, env file app/ Next.js App Router layout.tsx Root layout, fonts, TooltipProvider page.tsx Renders in a full-viewport
      assistant.tsx Builds useLangGraphRuntime; chooses /api vs direct URL + web3-providers.tsx wagmi/RainbowKit QueryClient + WagmiProvider wrappers api/[..._path]/route.ts Edge catch-all proxy to LANGGRAPH_API_URL + api/alchemy/[...path]/route.ts Server-only JSON-RPC proxy to Alchemy (with key + per-network disabled list) + api/alchemy/status/route.ts Returns Alchemy key health + disabled-network list globals.css Tailwind v4 entry components/ assistant-ui/ Chat primitives (thread, attachment, markdown, reasoning, tool-fallback, tool-group, tooltip-icon-button) ui/ shadcn/ui primitives (avatar, button, collapsible, dialog, tooltip) — new-york style, lucide icons + ui/address-or-hash.tsx Truncated address/hash with copy-to-clipboard tool-ui/ask-location/ Interrupt-driven or addResult-driven location picker card - tool-ui/weather/ Forecast widget renderer + tool-ui/weather/ Forecast widget renderer (vendored runtime + container + overlay) + tool-ui/crypto/ Price, connect-wallet, place-order, order-status cards lib/utils.ts cn() = twMerge(clsx(...)) lib/threads/ Threads module (schema, queries, adapter, validators) +lib/wagmi.ts wagmi/RainbowKit config (chains, connectors, WalletConnect projectId) +lib/alchemy/ networks.ts (slug → Alchemy URL + disabled list) + portfolio.ts (RPC helpers) +lib/prices/coingecko.ts CoinGecko free-tier price client (60s in-memory cache) +lib/decimal.ts Decimal-based amount math for crypto (no native float) ``` ### Backend graph (`backend/agent.ts`) -The parent graph dispatches a router decision into one of two sub-flows, both ending in `afterAgent`: +The parent graph dispatches a router decision into one of three sub-flows, all ending in `afterAgent`: -- `routerAgent` — calls `chatModel.withStructuredOutput(RouteDecisionSchema, { method: "jsonSchema" })` (tagged `nostream` so partial tokens don't leak into the chat) and returns `{ routerDecision: { next: "weatherAgent" | "chatAgent" } }` for the conditional edge to read. -- `weatherAgent` / `chatAgent` — a model → tools loop driven by `toolsCondition`. Exits to `afterAgent` when the model emits no `tool_calls`. +- `routerAgent` — calls `chatModel.withStructuredOutput(RouteDecisionSchema, { method: "jsonSchema" })` (tagged `nostream` so partial tokens don't leak into the chat) and returns `{ routerDecision: { next: "weatherAgent" | "chatAgent" | "cryptoAgent" } }` for the conditional edge to read. +- `weatherAgent` / `chatAgent` / `cryptoAgent` — a model → tools loop driven by `toolsCondition`. Exits to `afterAgent` when the model emits no `tool_calls`. - `afterAgent` — touches `threads.last_message_at` for the current thread; no message-channel writes. - `renameThreadAgent` — fans out from `START` (parallel to `routerAgent`), generates the thread title on the first turn only, persists it to the `threads` row. Two topologies share the same router + rename + after nodes and are gated by `USE_SUBGRAPH` (env var, see Environment): -- **Inlined (default).** `weatherModel` / `weatherTools` / `chatModel` / `chatTools` are inlined as plain nodes in the parent graph. The model/tool logic is duplicated from `backend/agent/weather-agent.ts` and `backend/agent/chat-agent.ts` — keep them in sync. The router's pathMap remaps `"weatherAgent"`/`"chatAgent"` (the router's string enum) to `"weatherModel"`/`"chatModel"` (the inlined node names). -- **Subgraph (`USE_SUBGRAPH=true`).** The compiled `weatherAgent` and `chatAgent` from `backend/agent/*-agent.ts` are wired as opaque nodes via `addNode("weatherAgent", weatherAgent)`. PathMap is an array of allowed destinations, since the returned string already matches the node name. +- **Inlined (default).** `weatherModel` / `weatherTools` / `chatModel` / `chatTools` / `cryptoModel` / `cryptoTools` are inlined as plain nodes in the parent graph. The model/tool logic is duplicated from `backend/agent/weather-agent.ts`, `chat-agent.ts`, and `crypto-agent.ts` — keep them in sync. The router's pathMap remaps `"weatherAgent"`/`"chatAgent"`/`"cryptoAgent"` (the router's string enum) to `"weatherModel"`/`"chatModel"`/`"cryptoModel"` (the inlined node names). +- **Subgraph (`USE_SUBGRAPH=true`).** The compiled `weatherAgent`, `chatAgent`, and `cryptoAgent` from `backend/agent/*-agent.ts` are wired as opaque nodes via `addNode("weatherAgent", weatherAgent)`. PathMap is an array of allowed destinations, since the returned string already matches the node name. Both builders live in `backend/agent.ts` (`buildSubgraph()` and `buildInlined()`). When `USE_SUBGRAPH` flips, no other file needs to change — but if you add a node, prompt, or tool, update both builders. @@ -112,6 +127,10 @@ The chat models in `backend/model.ts` carry `modelKwargs: { reasoning_split: tru The weather prompt (in `backend/prompt/system.ts`) lists four steps in order — `ask_location` → `geocode_location` → `get_weather` → one-sentence reply — and explicitly forbids batching tools in a single turn. The frontend card (`components/tool-ui/ask-location`) keys off the `ask_location` `ToolMessage`, so any tool run alongside it would race the human input. See `docs/INTERRUPT.md` for the two runtime paths the card can take. +### `CRYPTO_AGENT_PROMPT` enforces one-tool-per-turn + no-investment-advice + +Same one-tool-per-turn discipline as weather, applied to the trade flow: `connect_wallet` → `place_crypto_order` → `get_order_status` are HARD checkpoints, each pauses for one user click. `place_crypto_order` is gated by a "no fiat amounts" rule — when the user names a dollar/yuan/euro amount, the agent declines rather than quoting. The prompt also hard-blocks investment advice: no "buy now", no price-direction predictions, no "good entry", no editorializing on token quality; the agent describes only what the user asked and what the card does. The cards render numbers — the prose never repeats them. + ### State persistence (dev vs prod) The checkpointer active for a run is chosen by the runner, not by us: @@ -135,6 +154,10 @@ Consequences worth knowing: `components/assistant-ui/thread.tsx` mounts `InterruptUI` (uses `useLangGraphInterruptState` + `useLangGraphSendCommand`) inside the last assistant message. The interrupt-driven render only fires when `NEXT_PUBLIC_USE_SUBGRAPH=true`; in default (inlined) mode, the ask_location card renders in the tool-call slot instead. See `docs/INTERRUPT.md` for the full two-mode flow. +### Web3 providers + +`app/layout.tsx` wraps the assistant tree in `` (`app/web3-providers.tsx`), which stacks `@tanstack/react-query` `QueryClientProvider`, `WagmiProvider` (configured by `lib/wagmi.ts`), and RainbowKit's `RainbowKitProvider`. Wallet state is global to the browser; the crypto cards read `address` / `chainId` from wagmi hooks directly — they never receive the wallet through tool args. The trade flow is fully SIMULATED regardless of wallet connectivity: `place_crypto_order` auto-funds Mock Coin on the first trade and synthesizes the order on click. Setting `NEXT_PUBLIC_CRYPTO_REAL_SWAP=true` is required to route through any real DEX path (currently dormant — wagmi hooks live in the React tree, so a server-side router alone can't reach them). + ### Patches `patches/` is currently empty. `pnpm-workspace.yaml` retains the `patchedDependencies:` header as a placeholder — when you need to patch a package, add the entry there and drop the `.patch` file under `patches/`. Re-check on every package bump; drop the entry + file when upstream ships the fix. @@ -239,6 +262,14 @@ Before running `pnpm dev` (or starting any dev server), check whether the releva Killing a running dev server loses unsaved browser state, breaks open browser tabs, and erases hot-reload history. If the dev server appears stale or stuck, surface the observation and ask the developer how they want to proceed; do not act unilaterally. +### 8. Tool-UI buttons are text-only — no icons + +Buttons inside `components/tool-ui/**` (and any new card added under that directory) render the label as the action. Do not put a Lucide icon (or any other icon) as a prefix to the label, even with `gap-2` to space them out. No ``, ``, etc. inside `
      diff --git a/components/tool-ui/crypto/place-crypto-order-card.tsx b/components/tool-ui/crypto/place-crypto-order-card.tsx index ade2026..5080bb1 100644 --- a/components/tool-ui/crypto/place-crypto-order-card.tsx +++ b/components/tool-ui/crypto/place-crypto-order-card.tsx @@ -155,7 +155,7 @@ export const PlaceCryptoOrderCard: ToolCallMessagePartComponent = ({ resul return (
      Quote failed: {parsed.error} diff --git a/components/tool-ui/crypto/price-card.tsx b/components/tool-ui/crypto/price-card.tsx index 66322cf..7e8617e 100644 --- a/components/tool-ui/crypto/price-card.tsx +++ b/components/tool-ui/crypto/price-card.tsx @@ -111,11 +111,11 @@ export const CryptoPriceCard: ToolCallMessagePartComponent = ({ re } if (parsed.kind === "error") { return ( -
      Couldn't fetch prices: {parsed.message}
      +
      Couldn't fetch prices: {parsed.message}
      ); } if (parsed.coins.length === 0) { - return
      No coins matched those ids.
      ; + return
      No coins matched those ids.
      ; } return ( diff --git a/components/tool-ui/tool-card-skeleton.tsx b/components/tool-ui/tool-card-skeleton.tsx index 78475f2..dd456cd 100644 --- a/components/tool-ui/tool-card-skeleton.tsx +++ b/components/tool-ui/tool-card-skeleton.tsx @@ -10,7 +10,7 @@ export function ToolCardSkeleton({ label }: { label: string }) {
      diff --git a/components/tool-ui/weather/weather-card.tsx b/components/tool-ui/weather/weather-card.tsx index 592a624..c1cc19a 100644 --- a/components/tool-ui/weather/weather-card.tsx +++ b/components/tool-ui/weather/weather-card.tsx @@ -28,9 +28,9 @@ type Result = WeatherToolSuccess | WeatherToolFailure; type ParsedResult = | { kind: "loading" } | { - kind: "ok"; - widget: import("@/components/tool-ui/weather/runtime").WeatherWidgetPayload; - } + kind: "ok"; + widget: import("@/components/tool-ui/weather/runtime").WeatherWidgetPayload; + } | { kind: "error"; message: string }; function parseWeatherResult(raw: unknown): ParsedResult { @@ -62,7 +62,7 @@ export const WeatherCard: ToolCallMessagePartComponent = ({ result } if (parsed.kind === "error") { return ( -
      +
      Couldn’t fetch weather: {parsed.message}
      ); From 59d00bb512f5e16682df59e97797b3054463d54f Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 15:10:03 +0800 Subject: [PATCH 23/35] chore(ui): resize tool-card-skeleton to inline row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was aspect-4/3 max-w-md (~448×336px card), sized to mimic the weather widget. But the skeleton is also used by the price card (much smaller), so the page inflated then collapsed every time a price query landed. Drop the chrome — a single inline row with a spinner + label works for any card size and doesn't trigger layout shift. --- components/tool-ui/tool-card-skeleton.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/components/tool-ui/tool-card-skeleton.tsx b/components/tool-ui/tool-card-skeleton.tsx index dd456cd..51732af 100644 --- a/components/tool-ui/tool-card-skeleton.tsx +++ b/components/tool-ui/tool-card-skeleton.tsx @@ -1,20 +1,19 @@ "use client"; import { Loader2Icon } from "lucide-react"; -import { cn } from "@/lib/utils"; -// Shared loading placeholder for tool-call parts. Sized to match the -// WeatherWidget so the layout doesn't jump when the result lands. +// Shared loading placeholder for tool-call parts. Inline row so it +// doesn't try to mimic any specific card's size — the actual card +// (weather widget, price list, …) takes whatever shape it needs when +// it lands. export function ToolCardSkeleton({ label }: { label: string }) { return (
      - - {label} + + {label}
      ); -} +} \ No newline at end of file From 35efb18bd216c0d956d41404704f09a04a412077 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 15:10:10 +0800 Subject: [PATCH 24/35] refactor(backend): rename searchWeb/fetchUrl to snake_case Wire tool names now match the rest of the toolset (ask_location, geocode_location, get_weather). JS exports stay camelCase (searchWeb, fetchUrl) per the existing askLocationTool -> ask_location pattern. Cascading references updated: CHAT_AGENT_PROMPT, CLAUDE.md, README.md, docs/APIS.md, docs/TOOLS.md. --- CLAUDE.md | 2 +- README.md | 6 +++--- backend/prompt/system.ts | 2 +- backend/tool/web-fetch.ts | 2 +- backend/tool/web-search.ts | 2 +- docs/APIS.md | 6 +++--- docs/TOOLS.md | 4 ++-- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef12816..c45c8bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ backend/ router-agent-node.ts "routerAgent" — picks weatherAgent / chatAgent / cryptoAgent per turn after-agent-node.ts "afterAgent" — touches threads.last_message_at prompt/system.ts CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, CRYPTO_AGENT_PROMPT, ROUTER_AGENT_PROMPT, RENAME_THREAD_PROMPT - tool/ ask_location, geocode_location, get_weather, searchWeb, fetchUrl + tool/ ask_location, geocode_location, get_weather, search_web, fetch_url tool/crypto/ get_crypto_price, get_fx_rate, get_token_balances, connect_wallet, place_crypto_order, get_order_status langgraph.json CLI config: graph id, node version, env file app/ Next.js App Router diff --git a/README.md b/README.md index b4be3f1..e03cd5e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A self-hostable chat app (this repo: `langgraph-app`) that streams tokens from a - **Type-safe DB layer**: Drizzle ORM + Zod validators, derived from the same schema source. - **TDD-tested**: Vitest with a separate test database. - **User accounts**: email + password (with email verification), GitHub and Google sign-in, 7-day persistent sessions, and per-user thread isolation. See [docs/AUTH.md](docs/AUTH.md) for the operator guide. -- **Tool-using agent**: the `agent` node is bound to `searchWeb` (Jina Search) and `fetchUrl` (Jina Reader) — the model can research topics and read pages mid-conversation. Tools run unconditionally; write-side tools added later will hang their own `interruptBefore` hook. See [docs/APIS.md](docs/APIS.md) for the contract. +- **Tool-using agent**: the `agent` node is bound to `search_web` (Jina Search) and `fetch_url` (Jina Reader) — the model can research topics and read pages mid-conversation. Tools run unconditionally; write-side tools added later will hang their own `interruptBefore` hook. See [docs/APIS.md](docs/APIS.md) for the contract. ## Tech stack @@ -115,8 +115,8 @@ backend/ model.ts ChatOpenAI singletons (with / without thinking) checkpointer.ts PostgresSaver (Postgres checkpoint tables) tool/ LangChain tools bound to the agent - web-search.ts searchWeb — Jina Search (s.jina.ai/{query}) - web-fetch.ts fetchUrl — Jina Reader (r.jina.ai/{url}) + web-search.ts search_web — Jina Search (s.jina.ai/{query}) + web-fetch.ts fetch_url — Jina Reader (r.jina.ai/{url}) node/ call-model-node.ts "agent" node — appends AI reply rename-thread-node.ts "renameThread" node — generates + persists title diff --git a/backend/prompt/system.ts b/backend/prompt/system.ts index 5771a36..ec1265e 100644 --- a/backend/prompt/system.ts +++ b/backend/prompt/system.ts @@ -12,7 +12,7 @@ export const CHAT_AGENT_PROMPT = `You are ${APP_NAME}, a careful and direct AI a Goals: - Give the user a correct, complete answer. If you are unsure, say so — never invent facts, numbers, citations, or tool outputs. -- Use the available tools (searchWeb, fetchUrl) whenever the answer depends on current information, a specific URL, or anything you cannot reliably recall. +- Use the available tools (search_web, fetch_url) whenever the answer depends on current information, a specific URL, or anything you cannot reliably recall. - Match the user's language. If they write in Chinese, reply in Chinese; English, reply in English; otherwise match the dominant language in the conversation. Style: diff --git a/backend/tool/web-fetch.ts b/backend/tool/web-fetch.ts index 2893701..f72c8b2 100644 --- a/backend/tool/web-fetch.ts +++ b/backend/tool/web-fetch.ts @@ -34,7 +34,7 @@ async function impl({ url }: { url: string }): Promise { } export const fetchUrl = tool(impl, { - name: "fetchUrl", + name: "fetch_url", description: "Fetch a public web page and return its content as markdown. Use when the user provides a URL or when a search result warrants reading in full.", schema, diff --git a/backend/tool/web-search.ts b/backend/tool/web-search.ts index b6213f0..6b99c2f 100644 --- a/backend/tool/web-search.ts +++ b/backend/tool/web-search.ts @@ -32,7 +32,7 @@ async function impl({ query }: { query: string }): Promise { } export const searchWeb = tool(impl, { - name: "searchWeb", + name: "search_web", description: "Search the web for a keyword or natural-language query and return the top results with title, URL, and snippet. Use this when the user asks a question that needs current or external information.", schema, diff --git a/docs/APIS.md b/docs/APIS.md index f2f69bc..c9af375 100644 --- a/docs/APIS.md +++ b/docs/APIS.md @@ -97,7 +97,7 @@ The LangGraph `agent` graph exposes the following tools to the chat model. Both Implementation: `backend/tool/{web-fetch,web-search}.ts`. Shared key pool: `lib/jina.ts`. -### `searchWeb(query)` +### `search_web(query)` Keyword / natural-language web search via Jina Search (`s.jina.ai`). @@ -108,7 +108,7 @@ Keyword / natural-language web search via Jina Search (`s.jina.ai`). | Auth | Uses one key from `JINA_API_KEYS` (comma-separated in `.env.example`) | | Failure modes | `500` from upstream → tool throws and the model reports the error; all keys exhausted → tool throws `"All N Jina keys exhausted"` | -### `fetchUrl(url)` +### `fetch_url(url)` Read a public web page and return it as markdown via Jina Reader (`r.jina.ai`). @@ -116,7 +116,7 @@ Read a public web page and return it as markdown via Jina Reader (`r.jina.ai`). | ------------- | ------------------------------------------------------------------------------------------------------------------ | | Input | `{ url: string }` — must be a valid absolute URL with scheme | | Output | `{ title, content, url }` (JSON string; `content` is markdown) | -| Auth | Same `JINA_API_KEYS` pool as `searchWeb` | +| Auth | Same `JINA_API_KEYS` pool as `search_web` | | Failure modes | Non-2xx from upstream → tool throws with status code; URL validation failure → schema rejection before the request | ### Key pool semantics diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 08fd5b7..b98686b 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -47,8 +47,8 @@ about independently. | Tool | Backend file | Frontend card | Notes | | ----------- | -------------------- | ------------- | ----------------------------------------------------------- | -| `searchWeb` | `web-search.ts` | — | Jina `s.jina.ai`, key-pool auth via `lib/jina.ts`. | -| `fetchUrl` | `web-fetch.ts` | — | Jina `r.jina.ai`, returns `{ title, content, url }` as MD. | +| `search_web` | `web-search.ts` | — | Jina `s.jina.ai`, key-pool auth via `lib/jina.ts`. | +| `fetch_url` | `web-fetch.ts` | — | Jina `r.jina.ai`, returns `{ title, content, url }` as MD. | ## Frontend wiring From 885f4be00ca8de9fa103cb2368bff88abfed5f6a Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 15:55:55 +0800 Subject: [PATCH 25/35] feat(ui): add breathing border glow on tool-call interrupt When a tool call enters requires-action (interrupt or approval), paint a soft conic arc that travels around the card's 1.5px border and pulses gently. CSS targets the actual card via [data-slot$="-card"] so the outer collapsible isn't lit up; mask+conic punches a hole in the middle so only the border ring shows. Theme via --glow-warm / --glow-bright / --glow-length on :root, animated through a typed @property --glow-angle. Thread.tsx forwards className="tool-call-glow-host" only when status.type === "requires-action", so other tool states stay untouched. Reduced-motion users get a static border. --- app/globals.css | 73 ++++++++++++++++++++++- components/assistant-ui/thread.tsx | 20 ++++++- components/assistant-ui/tool-fallback.tsx | 16 ++++- 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/app/globals.css b/app/globals.css index 0091c2a..7a3686f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -77,6 +77,9 @@ --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.015 286.067); + --glow-warm: oklch(0.78 0.2 25 / 0.7); + --glow-bright: oklch(0.96 0.08 70 / 0.8); + --glow-length: 90deg; } .dark { @@ -171,8 +174,72 @@ } } +/* Tool-call interrupt glow. Thread.tsx adds `.tool-call-glow-host` to + ToolFallbackRoot when status.type === "requires-action"; CSS targets + the actual card inside via [data-slot$="-card"] and renders a single + traveling ring on its border via mask+conic (same technique as the + weather widget's Edge shine). The mask punches a hole in the + middle so only the 1.5px padding ring is visible. */ +@property --glow-angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +.tool-call-glow-host [data-slot$="-card"], +.tool-call-glow-host [data-slot="weather-widget"] { + position: relative; + isolation: isolate; +} + +.tool-call-glow-host [data-slot$="-card"]::before, +.tool-call-glow-host [data-slot="weather-widget"]::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1.5px; + pointer-events: none; + background: conic-gradient( + from var(--glow-angle), + transparent calc(275deg - var(--glow-length) / 2), + var(--glow-warm) calc(275deg - var(--glow-length) / 4), + var(--glow-bright) calc(275deg + var(--glow-length) / 4), + transparent calc(275deg + var(--glow-length) / 2) + ); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + animation: + tool-call-marquee 4s linear infinite, + tool-call-breathe 2.5s ease-in-out infinite; +} + +@keyframes tool-call-marquee { + to { + --glow-angle: 360deg; + } +} +@keyframes tool-call-breathe { + 50% { + opacity: 0.45; + } +} +@media (prefers-reduced-motion: reduce) { + .tool-call-glow-host [data-slot$="-card"]::before, + .tool-call-glow-host [data-slot="weather-widget"]::before { + animation: none; + } +} + :where(.aui-md[data-status="running"]):empty::after, -:where(.aui-md[data-status="running"]) > :where(:not(ol):not(ul):not(pre)):last-child::after, +:where(.aui-md[data-status="running"]) + > :where(:not(ol):not(ul):not(pre)):last-child::after, :where(.aui-md[data-status="running"]) > pre:last-child code::after, :where(.aui-md[data-status="running"]) > :where(:is(ol, ul):last-child) @@ -191,8 +258,8 @@ > :where(li:last-child)::after { animation: aui-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; font-family: - ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; --aui-content: "\258d"; color: var(--muted-foreground); content: var(--aui-content); diff --git a/components/assistant-ui/thread.tsx b/components/assistant-ui/thread.tsx index e04232d..f08c228 100644 --- a/components/assistant-ui/thread.tsx +++ b/components/assistant-ui/thread.tsx @@ -442,8 +442,24 @@ const AssistantMessage: FC = () => { return ; case "reasoning": return ; - case "tool-call": - return + case "tool-call": { + // className is forwarded through ToolFallbackRoot at + // runtime, but the public ToolCallMessagePartComponent + // type doesn't include it. Cast locally for the glow hook. + const TF = ToolFallbackComponent as unknown as React.ComponentType< + { className?: string } & Record + >; + return ( + + ); + } case "data": return part.dataRendererUI; case "indicator": diff --git a/components/assistant-ui/tool-fallback.tsx b/components/assistant-ui/tool-fallback.tsx index 6a05fdf..396513a 100644 --- a/components/assistant-ui/tool-fallback.tsx +++ b/components/assistant-ui/tool-fallback.tsx @@ -461,8 +461,18 @@ function ToolFallbackApproval({ } const ToolFallbackImpl: ToolCallMessagePartComponent = (props) => { - const { toolName, argsText, result, status, addResult, resume, interrupt, approval, respondToApproval } = - props; + const { + toolName, + argsText, + result, + status, + addResult, + resume, + interrupt, + approval, + respondToApproval, + className, + } = props as typeof props & { className?: string }; // `toolUI` is the toolkit renderer's pre-rendered card. Not part of // ToolCallMessagePartProps — passed by thread.tsx as an extra prop. const toolUI = (props as unknown as { toolUI?: React.ReactNode }).toolUI; @@ -480,7 +490,7 @@ const ToolFallbackImpl: ToolCallMessagePartComponent = (props) => { } return ( - + {toolUI != null ? ( From e9466c8438f37f2c4724e359d967a5448652be0e Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 16:07:38 +0800 Subject: [PATCH 26/35] docs: sync crypto docs to post-Mock-Coin flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crypto sub-agent's swap flow is fully simulated now (Mock Coin balance, live CoinGecko USD pricing, synthesized order uid). Several doc pages still described the old CoW-based path that was dropped, plus TODOS.md had v1 / v2 task lists from earlier architectures. Resync: - docs/APIS.md: delete get_swap_quote section, delete Swap path section, rewrite the Crypto tools intro + place_crypto_order description around Mock Coin + CoinGecko. - docs/INTERRUPT.md / docs/TOOLS.md: reword get_order_status — synthesizes status because the uid is synthetic, not because 'we never hit CoW'. - docs/TODOS.md: drop the 2026-06-25 / 2026-06-26 crypto entries (resolved, superseded by current architecture). - .env.example: reword CRYPTO_REAL_SWAP — was 'live Uniswap V3 swap path / confirm_crypto_order with mode=real', neither accurate. - CLAUDE.md: fix ALCHEMY_API_KEY description — Alchemy is for the wallet portfolio view, not for eth/usd conversion (that path is CoinGecko). - cspell.json: drop 'cow' / 'cowswap' entries; no code or docs reference them anymore. --- .env.example | 12 +++++++----- CLAUDE.md | 2 +- cspell.json | 2 -- docs/APIS.md | 31 +++++++----------------------- docs/INTERRUPT.md | 5 +++-- docs/TODOS.md | 49 ----------------------------------------------- docs/TOOLS.md | 2 +- 7 files changed, 19 insertions(+), 84 deletions(-) diff --git a/.env.example b/.env.example index 9cfb71c..99f2ad8 100644 --- a/.env.example +++ b/.env.example @@ -73,11 +73,13 @@ ALCHEMY_API_KEY= # chains in production (e.g. testnets). Leave empty to enable all. ALCHEMY_DISABLED_NETWORKS= -# Crypto real-swap — feature flag for the live Uniswap V3 swap path. -# When unset / "false", confirm_crypto_order with mode=real returns an -# error (safe default). Set to "true" only after wiring a signer + RPC -# (see docs/SWAP.md). The flag is exposed to the browser (NEXT_PUBLIC_) -# because the wagmi hooks live in the React tree. +# Crypto real-swap — feature flag for routing `place_crypto_order` +# through a live DEX path. When unset / "false" (default), the card +# stays in SIMULATED mode (hardcoded Mock Coin balance, no signing, +# no on-chain broadcast). Set to "true" only after wiring a real DEX +# router + signer (currently dormant — wagmi hooks live in the React +# tree, so a server-only toggle won't reach them). Exposed to the +# browser (NEXT_PUBLIC_) on purpose. NEXT_PUBLIC_CRYPTO_REAL_SWAP= # WalletConnect / Reown projectId — https://dashboard.reown.com diff --git a/CLAUDE.md b/CLAUDE.md index c45c8bb..6501815 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ Copy `.env.example` to `.env.local` and fill in: - `LANGCHAIN_API_KEY` — sent as `x-api-key` by the proxy; leave blank for local dev. - `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` — graph id, must match a key in `langgraph.json` (`agent`). - `NEXT_PUBLIC_LANGGRAPH_API_URL` — optional. If set, the browser skips the `/api` proxy and talks to LangGraph directly. Leave unset to use the in-app proxy. -- `ALCHEMY_API_KEY` — server-only, used by `app/api/alchemy/[...path]` to proxy JSON-RPC. Required for the price-card USD valuation (CoinGecko USD prices are converted through Alchemy `eth/usd`). +- `ALCHEMY_API_KEY` — server-only, used by `app/api/alchemy/[...path]` to proxy JSON-RPC. Required for the wallet's portfolio view (per-chain token balances via Alchemy Portfolio API). - `ALCHEMY_DISABLED_NETWORKS` — optional comma-separated Alchemy network slugs the proxy will reject. Default deny-list lives in `lib/alchemy/networks.ts`. - `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` — Reown projectId, required for WalletConnect-based wallets (Binance, Bitget) to expose their mobile-QR fallback; injected wallets (MetaMask, Coinbase) work without it. - `NEXT_PUBLIC_CRYPTO_REAL_SWAP` — feature flag for the live Uniswap V3 swap path. Unset/`false` keeps `place_crypto_order` in SIMULATED mode (Mock Coin balance, no signing, no broadcast). Set `true` to enable the real path (currently dormant). diff --git a/cspell.json b/cspell.json index 0584919..0724115 100644 --- a/cspell.json +++ b/cspell.json @@ -130,8 +130,6 @@ "blast", "celo", "coincap", - "cow", - "cowswap", "cryptocurrency", "defillama", "eip", diff --git a/docs/APIS.md b/docs/APIS.md index c9af375..9a33e38 100644 --- a/docs/APIS.md +++ b/docs/APIS.md @@ -125,7 +125,7 @@ Read a public web page and return it as markdown via Jina Reader (`r.jina.ai`). ## Crypto tools -The crypto sub-agent (`CRYPTO_AGENT_PROMPT` in `backend/prompt/system.ts`) drives the swap flow. The wallet is the only source of spendable assets — there is no fiat on-ramp. Quotes come from CoW Protocol (no API key, no signup). Token balances come from Alchemy (server-only key via `ALCHEMY_API_KEY`). +The crypto sub-agent (`CRYPTO_AGENT_PROMPT` in `backend/prompt/system.ts`) drives the swap flow. The wallet is only used to identify the user — `place_crypto_order` does **not** read on-chain balances. The simulated flow auto-funds every user with 10,000 Mock Coin (MC, pegged 1:1 to USD) on the first trade, and the receive-side token is priced via live CoinGecko USD. There is no real signing and no on-chain broadcast in the current default path. ### `get_crypto_price(ids, vs_currency?)` @@ -150,20 +150,7 @@ Read-only FX lookup via frankfurter.app (ECB-sourced). Has a 60s in-memory cache ### `get_token_balances(chainId, address)` -**Not currently exposed to the LLM.** Listed here for reference only — the file and tests are kept in `backend/tool/crypto/get-token-balances.ts` for direct programmatic use, but `CRYPTO_TOOLS` does not register it. The wallet's address is not in the LLM's context (it lives in wagmi/RainbowKit on the frontend), so the agent has no way to call this tool without inventing an address. The confirm card reads balances via `/api/alchemy/portfolio/tokens/by-address` directly. If you want to re-enable this tool, register it in `backend/tool/index.ts` and update `CRYPTO_AGENT_PROMPT` step 2. - -### `get_swap_quote(chainId, sellToken, buyToken, amount, kind, slippageBps?)` - -Read-only. Returns a CoW Protocol quote for a given token pair. CoW is the canonical EVM swap aggregator with MEV protection via batch auctions. No API key, no signup, no rate-limit floor. - -| | | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Input | `{ chainId, sellToken, buyToken, amount, kind, slippageBps? }` — `amount` is the raw token-unit **string** (e.g. `"100000000"` for 100 USDC, `"100000000000000000"` for 0.1 WETH). `kind="sell"` = exact input, `kind="buy"` = exact output. `slippageBps` defaults to 50, hard cap 3000. | -| Output | `{ success: true, quote: }` — includes `quote.buyAmount`, `quote.sellAmount` (after fee), `quote.validTo` (unix timestamp, ~30 min), `quote.feeAmount`, `quote.kind`, `quote.appData`, and `id` / `expiration` at the top level. | -| Auth | None. Public CoW endpoint. | -| Failure modes | Unsupported chainId / zero amount → `{ success: false, error: "..." }` without any fetch. CoW 4xx/5xx → `{ success: false, error: "cow N: " }` (e.g. `NoLiquidity`). | - -**Currently called from the place-crypto-order card, not from the LLM.** The card reads source/amount/target from the intent and the user's wallet, hits `/quote` itself (`fetchCowQuote` in `components/tool-ui/crypto/place-crypto-order-card.tsx`), and renders the live preview. The LLM-facing flow is split into 3 atomic tools — `connect_wallet`, `place_crypto_order`, `get_order_status` — that the LLM calls one per turn. +**Not currently exposed to the LLM.** Listed here for reference only — the file and tests are kept in `backend/tool/crypto/get-token-balances.ts` for direct programmatic use, but `CRYPTO_TOOLS` does not register it. The wallet's address is not in the LLM's context (it lives in wagmi/RainbowKit on the frontend), so the agent has no way to call this tool without inventing an address. The simulated `place_crypto_order` flow does not consult on-chain balances — every user is auto-funded with Mock Coin. If you want to re-enable this tool, register it in `backend/tool/index.ts` and update `CRYPTO_AGENT_PROMPT` step 2. ### `connect_wallet(message?)` @@ -177,25 +164,21 @@ Wallet-authorization interrupt. Pauses via `interrupt()`; the frontend card open ### `place_crypto_order(side, source_coin_id?, amount?, target_coin_id?)` -Simulated swap interrupt. Pauses via `interrupt()`; the frontend card reads the wallet from wagmi (auto-inferred from the most recent `connect_wallet` ToolMessage — never pass an address), fetches the user's balances from Alchemy, picks a randomized source/target/amount (the LLM's hints override the random pick when provided), fetches a real-time CoW `/quote` for accurate pricing display, and exposes one Place simulated order button. On click, the card synthesizes an order — no real signing, no real CoW `/orders` POST. The closing ToolMessage is what the LLM uses to write the final sentence. +Simulated swap interrupt. Pauses via `interrupt()`; the frontend card reads the wallet from wagmi (auto-inferred from the most recent `connect_wallet` ToolMessage — never pass an address), spends from the auto-funded Mock Coin balance (10,000 MC, pegged 1:1 to USD), prices the receive-side token via live CoinGecko USD (with a hardcoded fallback table in `lib/prices/coingecko.ts` when CoinGecko is unreachable), polls the price every 30s with a visible countdown, lets the user pick slippage + simulated gas tier (gas is converted to MC at the live ETH/USD price the quote already loaded), and exposes one Place simulated order button. On click, the card synthesizes an order — no real signing, no real DEX POST. The closing ToolMessage is what the LLM uses to write the final sentence. | | | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Input | `side` (required, `"buy"` or `"sell"`) — `"sell my X"` / `"swap X for Y"` → sell; `"buy Y with X"` → buy. `source_coin_id?` (CoinGecko id) when the user named a source. `amount?` (positive number) when the user named a quantity. `target_coin_id?` when the user named what to receive. | -| Output | `{ status: "simulated_filled", order: { id, coin, symbol, side, amount_human, qty, status, timestamp, note, slippage_bps } }` (success) or `{ status: "cancelled" }` (Cancel clicked) or `{ status: "error", error: string }` (quote failed / unexpected). | -| Failure modes | Malformed CoinGecko id → zod rejection. Id not in `lib/tokens/catalog.ts` → zod rejection. Non-positive or NaN `amount` → zod rejection. CoW `/quote` errors (NoLiquidity, etc.) surface inside the card; the user can still click Cancel. | -| Missing source | If the LLM named a `source_coin_id` the user's wallet does not hold, the card picks the highest-balance token as a fallback. The LLM should NOT silently re-call with a different source — it should re-call only after the user explicitly names an alternative. | +| Output | `{ status: "simulated_filled", order: { id, coin, symbol, side, amount_human, qty, status, timestamp, note, slippage_bps } }` (success) or `{ status: "cancelled" }` (Cancel clicked) or `{ status: "error", error: string }` (price fetch failed and no fallback matched). | +| Failure modes | Malformed CoinGecko id → zod rejection. Id not in `lib/tokens/catalog.ts` → zod rejection. Non-positive or NaN `amount` → zod rejection. CoinGecko 4xx/5xx surfaces inside the card as a fallback-priced quote; the user can still click Cancel. | +| Missing source | The LLM does not pass a source — the card always spends from Mock Coin regardless of what the user names. The LLM does not need to verify wallet holdings before calling. | ### `get_order_status(order_uid, chain_id)` -Order-status interrupt. Pauses via `interrupt()`; the frontend card shows the order uid + chain and exposes one Check button. This is a simulated-order demo — the card never hits the real CoW `/orders/{uid}` endpoint (the synthetic uid from `place_crypto_order` would 404). On click, the card synthesizes a status (`filled` for the demo path) and returns it via the resume. +Order-status interrupt. Pauses via `interrupt()`; the frontend card shows the order uid + chain and exposes one Check button. This is a simulated-order demo — the synthetic uid from `place_crypto_order` isn't a real on-chain order, so on click the card synthesizes a status (`filled` for the demo path) and returns it via the resume. | | | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Input | `order_uid` (required, non-empty string) — the order uid returned by `place_crypto_order`. `chain_id` (required, integer) — EVM chain id where the order was placed: 1, 42161, 8453, or 11155111. | | Output | `{ status: "filled" \| "open" \| "partially_filled" \| "cancelled" \| "expired" \| "not_found", order_uid, chain_id, filled_buy_amount?, executed_at? }`. | | Failure modes | Empty `order_uid` or non-numeric `chain_id` → zod rejection. | - -## Swap path - -The end-to-end swap path (real mode) is a CoW Protocol batch-auction order signed by the user's wallet via EIP-712. There is no on-chain approval step — CoW uses a settlement contract that pulls tokens via allowances the user grants once via the wallet's typed-data signature. The single hardcoded contract address is the CoW Settlement (`0x9008D19f58AAbD9eD0D60971565AA8510560ab41`), which is the same on every EVM chain (deterministic CREATE2) — see `lib/swap/cow-config.ts`. All other token addresses, router addresses, and pool addresses come from CoW's quote response and the user's own wallet. diff --git a/docs/INTERRUPT.md b/docs/INTERRUPT.md index 9d992eb..a3480b4 100644 --- a/docs/INTERRUPT.md +++ b/docs/INTERRUPT.md @@ -125,8 +125,9 @@ type PlaceCryptoOrderResume = ### `get_order_status` — crypto trade flow, step 3 -Tool (`backend/tool/crypto/get-order-status.ts`) polls the CoW `/orders/{uid}` -endpoint. Payload: +Tool (`backend/tool/crypto/get-order-status.ts`) is a pure trigger; the +synthetic `order_uid` returned by `place_crypto_order` isn't a real +on-chain order, so the card synthesizes a status on click. Payload: ```ts type GetOrderStatusResume = diff --git a/docs/TODOS.md b/docs/TODOS.md index 7f4e16d..fcd6d44 100644 --- a/docs/TODOS.md +++ b/docs/TODOS.md @@ -86,52 +86,3 @@ moving (it's `0.0.x`, experimental). Reference if/when RAG lands: -## 2026-06-26 — Crypto agent v2: currency + wallet UI - -Follow-up to the 2026-06-25 crypto agent. Two new requirements: - -1. **Wallet selection UI**. "Order 按钮点击下去, 是唤醒钱包, 能够选择钱包". User pointed out the existing card had a tiny "Connect wallet (optional)" link; they want the order button to _open a wallet picker_. wagmi is headless (no built-in UI); RainbowKit is the canonical answer but its 2.x line is pinned to wagmi 2.x and we're on 3.6.20. Skipped RainbowKit, built a Radix-Dialog-based connector picker using the existing `injected` connector. `components/tool-ui/crypto/connect-wallet-dialog.tsx`. Order button is now `"Connect & buy BTC"` when no wallet is connected, `"Confirm buy BTC"` once connected. Resume is queued in component state and flushed when `isConnected` flips. No new deps. - -2. **Currency detection**. "现在好像说的是买 100 RMB 的加密货币, 会被认为是 USD". Old schema was `amount_usd: number` — the LLM was passing 100 as `amount_usd` regardless of the user's actual currency. New flow: - - `ask_crypto_intent` schema gains `currency` (3-char ISO) and `amount` (optional pre-fill) so the LLM signals what the user meant. - - LLM detects from message: 元/RMB/CNY/¥ → CNY, $/USD → USD, €/EUR, £/GBP, ¥/JPY (ambiguous — only JPY if user wrote JPY/日元/日本円 alongside it, else default to CNY). Rules live in `CRYPTO_AGENT_PROMPT`. - - Card shows the detected currency in the Amount label ("AMOUNT (CNY)") and pre-fills the amount. No UI selector — the LLM is the source of truth. - - New `get_fx_rate` tool backed by `frankfurter.app` (free, no key, ECB). Cached 60s. - - After the card resumes, the LLM calls `get_fx_rate(currency, USD)` and converts before `confirm_crypto_order`. USD is a fast path (skip the FX lookup). - -**Tasks** (tracked in TaskList #12–#17): all completed. 177/177 tests pass, lint clean, Chrome DevTools verified both USD and CNY flows. - -**Skipped**: - -- **Real DEX swap**. User said "保持 simulated 的 ui" — keep simulated, just improve UX. Real on-chain would need a contract + `useWriteContract` + testnet ETH; out of scope. -- **wagmi 3.x migration of deprecated hooks** (`useAccount`/`useConnect` → `useConnection`/`useConnections`). Punted — the picker uses the old API which still works in 3.x. Do this when wagmi 3.x removes the old hooks or the warning goes red. -- **Adding `@coinbase/wallet-sdk`** for a real Coinbase connector. Optional peer of `@wagmi/connectors`; not needed for the demo since the user has no MetaMask anyway and the picker falls back to "no wallet providers detected" gracefully. - -## 2026-06-25 — Crypto agent (in progress) - -Branched off `feat-crypto`. Reuses weather agent's human-in-the-loop -pattern: `ask_crypto_intent` (interrupt) → `get_crypto_price` → -`confirm_crypto_order`. Price source: CoinGecko public API, no key. -"Orders" are **simulated** — wagmi is used for wallet display only, -not for signing. Upgrade path to real DEX swap is out of scope. - -**Tasks** (tracked in TaskList): - -1. TDD `askCryptoIntentTool` — interrupt-based, mirrors askLocationTool. -2. TDD `getCryptoPriceTool` — CoinGecko `/coins/markets`, 4xx/5xx → `{success:false, error}`. -3. TDD `confirmCryptoOrderTool` — generates simulated order id + qty. -4. Wire `CRYPTO_TOOLS` into `backend/tool/index.ts`. -5. Update `RouterAgentState` + `CRYPTO_AGENT_PROMPT` + `ROUTER_AGENT_PROMPT`. -6. TDD `cryptoAgent` subgraph + extend `agent-topologies.test.ts`. -7. Wire `cryptoAgent` into both `buildSubgraph()` and `buildInlined()`. -8. Add `wagmi` + `viem` + `@tanstack/react-query`, `WagmiProvider` in layout. -9. Build `components/tool-ui/crypto/{price-card,ask-crypto-intent-card,order-receipt-card,index}.tsx`. -10. Register `cryptoToolkit`, run `pnpm test` + `pnpm lint`, Chrome DevTools MCP visual verify. - -**Constraints** (re-confirmed before starting): - -- File layout: `backend/tool/crypto/*` (mirrors `components/tool-ui/crypto/*`). -- All crypto tools use `crypto_` prefix (`ask_crypto_intent`, `get_crypto_price`, `confirm_crypto_order`). -- Subgraph name `cryptoAgent`, inlined node names `cryptoModel` / `cryptoTools`. -- TDD mandatory per CLAUDE.md rule 2; backend tool coverage ≥ 90%. -- Visual verify per CLAUDE.md rule 4 (Chrome DevTools MCP). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index b98686b..b92d1e2 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -36,7 +36,7 @@ ask-location card isn't raced by a parallel tool call. See | `get_fx_rate` | `crypto/get-fx-rate.ts` | — | Frankfurter, 60s in-memory cache. Plain `ToolMessage`. | | `connect_wallet` | `crypto/connect-wallet.ts` | `crypto/connect-wallet-card.tsx`| Interrupt-driven. Reads wallet from wagmi; resumes with `{ address }`. | | `place_crypto_order`| `crypto/place-crypto-order.ts` | `crypto/place-crypto-order-card.tsx`| Interrupt-driven. Simulated swap; resumes with `SimulatedOrder` or `cancelled`. | -| `get_order_status` | `crypto/get-order-status.ts` | `crypto/order-status-card.tsx` | Interrupt-driven. Polls CoW `/orders/{uid}`; resumes with status payload. | +| `get_order_status` | `crypto/get-order-status.ts` | `crypto/order-status-card.tsx` | Interrupt-driven. Synthesizes a status (simulated-swap demo); resumes with status payload. | | `get_token_balances`| `crypto/get-token-balances.ts` | — | Defined but not wired into `ALL_TOOLS` yet — dormant. | Trade flow is split into three atomic interrupt tools (connect → place → check) From 367fef59e114541cf940eaa664bfb4d09447d520 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 16:07:46 +0800 Subject: [PATCH 27/35] refactor(crypto): drop CoW references from tool descriptions and comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoW was the EVM DEX aggregator wired into the swap flow before it was dropped in a8a3121 (CoW / Alchemy scaffolding removed). Several tool descriptions and code comments still named it as the reason for the simulated path or as a future routing target — now misleading since there is no CoW integration anywhere in the codebase. - get-order-status.ts: reword tool description + body comment — 'we never hit CoW /orders/{uid}' → 'the status is fabricated rather than queried from any chain'. Same meaning to the LLM. - get-token-balances.ts: reword the Alchemy chain-mapping comment (no longer 'chains CoW is on') and the EIP-55 ponytail note (no longer 'matters for re-routing into CoW'). - order-status-card.tsx / toolkit.tsx / coingecko.ts: drop stale CoW mentions from comments. --- backend/tool/crypto/get-order-status.ts | 8 ++++---- backend/tool/crypto/get-token-balances.ts | 10 +++++----- components/tool-ui/crypto/order-status-card.tsx | 8 ++++---- components/tool-ui/toolkit.tsx | 6 +++--- lib/prices/coingecko.ts | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/backend/tool/crypto/get-order-status.ts b/backend/tool/crypto/get-order-status.ts index 3cba56d..e2d7b1b 100644 --- a/backend/tool/crypto/get-order-status.ts +++ b/backend/tool/crypto/get-order-status.ts @@ -7,9 +7,9 @@ export const GET_ORDER_STATUS_TOOL_NAME = "get_order_status"; // get_order_status is a pure trigger for the simulated swap flow. The // tool pauses via interrupt(); the frontend card (OrderStatusCard) // shows the quote uid + chain, and on user click synthesizes a status -// (the real CoW /orders/{uid} endpoint can't find the synthetic uid -// from a simulated place_crypto_order, so we don't even try). The -// synthesized status flows back to the LLM as-is via the resume. +// (the synthetic uid from place_crypto_order isn't a real on-chain +// order, so there's nothing to query). The synthesized status flows +// back to the LLM as-is via the resume. export const getOrderStatusTool = tool( async ({ order_uid, chain_id, message }) => { @@ -22,7 +22,7 @@ export const getOrderStatusTool = tool( { name: GET_ORDER_STATUS_TOOL_NAME, description: - "Render a swap status card for a previously accepted quote and pause for the user to click Check. The LLM passes the order_uid (returned by place_crypto_order) and chain_id (EVM chain id: 1, 42161, 8453, or 11155111). The card shows the quote uid and on user click synthesizes a status (filled / open / cancelled / not_found) — this is a simulated-swap demo, so we never hit CoW /orders/{uid}. The closing ToolMessage status field is what the model uses to write the final sentence. Call this AFTER place_crypto_order has returned status:'simulated_filled' with an order_uid. If the status is still 'open', do NOT loop — reply to the user and let them decide whether to check again. Do NOT batch with any other tool.", + "Render a swap status card for a previously accepted quote and pause for the user to click Check. The LLM passes the order_uid (returned by place_crypto_order) and chain_id (EVM chain id: 1, 42161, 8453, or 11155111). The card shows the quote uid and on user click synthesizes a status (filled / open / cancelled / not_found) — this is a simulated-swap demo, so the status is fabricated rather than queried from any chain. The closing ToolMessage status field is what the model uses to write the final sentence. Call this AFTER place_crypto_order has returned status:'simulated_filled' with an order_uid. If the status is still 'open', do NOT loop — reply to the user and let them decide whether to check again. Do NOT batch with any other tool.", schema: z.object({ order_uid: z .string() diff --git a/backend/tool/crypto/get-token-balances.ts b/backend/tool/crypto/get-token-balances.ts index 1807355..d214627 100644 --- a/backend/tool/crypto/get-token-balances.ts +++ b/backend/tool/crypto/get-token-balances.ts @@ -3,18 +3,18 @@ import { z } from "zod"; // Alchemy network slugs for the chains we support. Matches the slug // pattern used by app/api/alchemy/[...path]/route.ts. We only carry -// chains CoW is on (Ethereum / Arbitrum / Base); other chains the +// the mainnet L1/L2s the simulated flow surfaces; other chains the // Alchemy proxy accepts (Polygon, Optimism, etc.) are intentionally -// not here until a swap path is added for them. +// not here until a real swap path lands. const CHAIN_TO_ALCHEMY_SLUG: Record = { 1: "eth-mainnet", 42161: "arb-mainnet", 8453: "base-mainnet", }; -// ponytail: EIP-55 checksum matters for re-routing into CoW. Wagmi gives -// us mixed-case addresses; we keep them as-is so the same address works -// in both the Alchemy and CoW calls. +// ponytail: wagmi gives us mixed-case addresses; we keep them as-is +// so the same address works for both the Alchemy Portfolio API and +// any future DEX integration that requires EIP-55 checksumming. const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; type AlchemyBalance = { contractAddress: string; tokenBalance: string }; diff --git a/components/tool-ui/crypto/order-status-card.tsx b/components/tool-ui/crypto/order-status-card.tsx index 809c1fa..3665cce 100644 --- a/components/tool-ui/crypto/order-status-card.tsx +++ b/components/tool-ui/crypto/order-status-card.tsx @@ -19,9 +19,9 @@ import { unwrapToolResult } from "@/components/tool-ui/tool-result"; // OrderStatusCard — atomic status-check card. Reads the order_uid + // chain_id passed by the LLM, displays them, and on user click -// synthesizes a status (this is a simulated-order demo — the real CoW -// /orders/{uid} endpoint can't find the synthetic uid from -// place_crypto_order). The synthesized status flows back to the LLM. +// synthesizes a status (this is a simulated-order demo — the +// synthetic uid from place_crypto_order isn't a real on-chain order). +// The synthesized status flows back to the LLM. type Args = { order_uid: string; @@ -72,7 +72,7 @@ export const OrderStatusCard: ToolCallMessagePartComponent = ({ result, ar const handleCheck = () => { setChecking(true); // Synthetic status — the order came from place_crypto_order, which - // never actually submits to CoW. For demo purposes, deterministically + // never actually submits on-chain. For demo purposes, deterministically // fill it (matches the "place then check" narrative). const payload: ResumePayload = { status: "filled", diff --git a/components/tool-ui/toolkit.tsx b/components/tool-ui/toolkit.tsx index 710468d..a311c35 100644 --- a/components/tool-ui/toolkit.tsx +++ b/components/tool-ui/toolkit.tsx @@ -58,9 +58,9 @@ const cryptoToolkit = defineToolkit({ place_crypto_order: { // Pauses for one user click. Reads the wallet from wagmi // (auto-inferred from the most recent connect_wallet ToolMessage); - // picks a randomized source / target / amount, fetches a real-time - // CoW /quote for accurate pricing, and on click synthesizes a - // simulated order — no real signing, no real CoW /orders POST. + // spends from the auto-funded Mock Coin balance, prices the + // receive-side token via live CoinGecko USD, and on click + // synthesizes a simulated order — no real signing, no broadcast. description: "Render a simulated swap card and pause for the user to click Place order.", parameters: z.object({ side: z.enum(["buy", "sell"]), diff --git a/lib/prices/coingecko.ts b/lib/prices/coingecko.ts index 0f737d4..72ddf48 100644 --- a/lib/prices/coingecko.ts +++ b/lib/prices/coingecko.ts @@ -1,6 +1,6 @@ // ponytail: browser-side CoinGecko price fetcher with in-memory cache. // Used by the place-crypto-order card to compute receive amounts in real -// time (no CoW /quote). Free tier, no key needed. +// time. Free tier, no key needed. // // Fallback table is consulted when CoinGecko fails (rate-limit, network, // etc.) so the demo never goes blank — the card shows a stale or fake From 6b734a56bbc26eb08218935e6c6239f264a6c48d Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 16:30:51 +0800 Subject: [PATCH 28/35] feat(crypto): add get_NFT_holdings tool + NFT gallery card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only NFT holdings query to the crypto sub-agent. Resolves an address from the user's message or the most recent connect_wallet ToolMessage, hits Alchemy Portfolio API nfts/by-address across Ethereum / Arbitrum / Optimism / Base / Polygon, filters airdrop/claim-bait by name regex on top of Alchemy's own spam filter, and returns image URLs + contract metadata for the gallery card. The card groups by collection (contract + chain) with per-group 'Show N more' pagination, sorts largest collections first, and opens a fullscreen lightbox on tile click. Broken images fall back to an ImageOffIcon (the inline detail-view variant was tried and rolled back — lightbox keeps the chat scroll context intact). --- app/assistant.tsx | 5 + backend/prompt/system.ts | 15 +- backend/tool/crypto/get-nft-holdings.ts | 190 ++++++++++ backend/tool/index.ts | 4 + components/tool-ui/crypto/index.ts | 1 + .../tool-ui/crypto/nft-gallery-card.tsx | 324 ++++++++++++++++++ components/tool-ui/toolkit.tsx | 8 + docs/APIS.md | 13 + docs/TOOLS.md | 1 + .../tool/crypto/get-nft-holdings.test.ts | 279 +++++++++++++++ 10 files changed, 837 insertions(+), 3 deletions(-) create mode 100644 backend/tool/crypto/get-nft-holdings.ts create mode 100644 components/tool-ui/crypto/nft-gallery-card.tsx create mode 100644 tests/backend/tool/crypto/get-nft-holdings.test.ts diff --git a/app/assistant.tsx b/app/assistant.tsx index 5ce08d8..cc1d929 100644 --- a/app/assistant.tsx +++ b/app/assistant.tsx @@ -253,6 +253,11 @@ export function Assistant() { label: "", prompt: "I want to buy 2 ETH", }, + { + title: "Show me my NFTs", + label: "", + prompt: "Show me my NFTs", + } ]), }); diff --git a/backend/prompt/system.ts b/backend/prompt/system.ts index ec1265e..449ffc5 100644 --- a/backend/prompt/system.ts +++ b/backend/prompt/system.ts @@ -72,10 +72,11 @@ export const WEATHER_AGENT_PROMPT = `You answer weather questions by calling too On any tool returning {success: false} or an error, ask the user for the missing piece (a different spelling, a place name, location permission). Never invent coordinates or guess the location.`; -// Dropped into the crypto sub-agent node. Two distinct flows: +// Dropped into the crypto sub-agent node. Three distinct flows: // -// Price query → get_crypto_price → short reply -// Trade → get_crypto_price → connect_wallet → place_crypto_order → get_order_status +// Price query → get_crypto_price → short reply +// NFT holdings → get_NFT_holdings → short reply +// Trade → get_crypto_price → connect_wallet → place_crypto_order → get_order_status // // The trade flow never touches get_crypto_price — the user already // knows the market (they're initiating a trade), and burning a @@ -99,6 +100,14 @@ PRICE QUERY FLOW (user asks "what's the price", "compare X and Y", "how is BTC d 1. get_crypto_price — Call with ONLY the CoinGecko ids the user explicitly named (e.g. user says "how is BTC doing" → call ["bitcoin"]). Do NOT add a second id (no "ethereum/usd-coin fallback"). The price card renders exactly one row per id. Map tickers to ids yourself. 2. Reply in one short sentence. The card already shows the numbers — do not repeat prices, sparkline, or 24h change. +NFT HOLDINGS FLOW (user asks "show my NFTs", "what NFTs does 0x... hold", "any NFTs in this wallet"): +1. Resolve the address to query: + - If the user explicitly named a 0x... address in their message, use that exact string. + - Otherwise, look back at the most recent connect_wallet ToolMessage in this thread for an \`address\` field. Use that. + - If neither is available (no address in the message, no prior connect_wallet), do NOT call the tool. Reply in one sentence asking the user to either paste an address or say "connect my wallet" first, then try again. +2. get_NFT_holdings — Call ONCE with the resolved address. The tool scans Ethereum, Arbitrum, Optimism, Base, and Polygon, filters out airdrop/claim-bait spam by name pattern, and returns image URLs + contract + token id for each holding. Do NOT call it twice in the same turn — one shot is enough. Do NOT batch with any other tool. +3. Reply in one short sentence after the tool returns. The card shows the NFT gallery — do not list image URLs, contract addresses, token ids, or repeat what the user already sees. If the tool returned an empty list, say so directly (no apology, no "however, the API might be down"). If the tool errored, surface the error in one sentence and ask the user to retry. + TRADE FLOW (user wants to sell, buy, swap, or exchange tokens): The trade flow is a 4-step atomic sequence. Call the tools one at a time, in order. Do NOT batch them — each tool pauses for a user click. diff --git a/backend/tool/crypto/get-nft-holdings.ts b/backend/tool/crypto/get-nft-holdings.ts new file mode 100644 index 0000000..9dcdded --- /dev/null +++ b/backend/tool/crypto/get-nft-holdings.ts @@ -0,0 +1,190 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; + +const ALCHEMY_PORTFOLIO_BASE = "https://api.g.alchemy.com/data/v1"; +const PAGE_SIZE = 100; + +// Airdrop / claim-bait NFTs that slip past Alchemy's own `excludeSpam`. +// Mirrors SPAM_NAME_RE in lib/alchemy/portfolio.ts (token side) — same +// pattern, separate copy so a future tightening of one doesn't drag the +// other. Matched against contract name OR per-token name. +const SPAM_NAME_RE = /(?:claim|airdrop|visit|gift|giveaway|voucher|reward|drop|bonus)\b/i; + +const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; + +// Chains the portfolio card covers. Matches lib/alchemy/networks.ts's +// portfolio scope — Ethereum, Arbitrum, Optimism, Base, Polygon. Order +// is stable so chunked request URLs are deterministic for tests. +const NETWORKS = [ + "eth-mainnet", + "arb-mainnet", + "opt-mainnet", + "base-mainnet", + "polygon-mainnet", +]; + +type RawNft = { + contract?: { + address?: string; + name?: string; + symbol?: string; + totalSupply?: string; + tokenType?: string; + openSeaMetadata?: { + floorPrice?: number | string | null; + collectionName?: string; + collectionSlug?: string; + imageUrl?: string; + }; + }; + tokenId?: string; + tokenType?: string; + network?: string; + name?: string; + balance?: string; + image?: { + cachedUrl?: string; + thumbnailUrl?: string; + pngUrl?: string; + originalUrl?: string; + contentType?: string; + }; +}; + +type NormalizedNft = { + contractAddress: string; + contractName: string; + collectionName: string | null; + collectionSlug: string | null; + contractImageUrl: string | null; + network: string; + tokenId: string; + tokenType: string; + name: string; + thumbnailUrl: string | null; + cachedUrl: string | null; + balance: string; +}; + +function pickStr(...candidates: Array): string | null { + for (const c of candidates) { + if (typeof c === "string" && c.length > 0) return c; + } + return null; +} + +function normalize(raw: RawNft): NormalizedNft | null { + const contract = raw.contract; + const contractAddress = contract?.address; + const contractName = contract?.name?.trim() ?? ""; + if (!contractAddress || !contractName) return null; + if (SPAM_NAME_RE.test(contractName)) return null; + const tokenName = raw.name?.trim() ?? ""; + if (SPAM_NAME_RE.test(tokenName)) return null; + + return { + contractAddress, + contractName, + collectionName: contract.openSeaMetadata?.collectionName?.trim() || null, + collectionSlug: contract.openSeaMetadata?.collectionSlug?.trim() || null, + contractImageUrl: pickStr(contract.openSeaMetadata?.imageUrl), + network: raw.network ?? "", + tokenId: raw.tokenId ?? "", + tokenType: raw.tokenType ?? contract.tokenType ?? "", + name: tokenName || contractName, + thumbnailUrl: pickStr(raw.image?.thumbnailUrl, raw.image?.cachedUrl), + cachedUrl: pickStr(raw.image?.cachedUrl, raw.image?.originalUrl), + balance: raw.balance ?? "1", + }; +} + +async function fetchPage( + apiKey: string, + address: string, + pageKey: string | undefined, + signal?: AbortSignal, +): Promise<{ nfts: NormalizedNft[]; nextPageKey: string | null; totalCount: number }> { + const url = `${ALCHEMY_PORTFOLIO_BASE}/${apiKey}/assets/nfts/by-address`; + const body: Record = { + addresses: [{ address, networks: NETWORKS }], + withMetadata: true, + excludeSpam: true, + pageSize: PAGE_SIZE, + }; + if (pageKey) body.pageKey = pageKey; + + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + if (!res.ok) { + return Promise.reject(new Error(`alchemy ${res.status}`)); + } + const json = (await res.json()) as { + data?: { ownedNfts?: RawNft[]; pageKey?: string | null; totalCount?: number }; + }; + const owned = json.data?.ownedNfts ?? []; + return { + nfts: owned.map(normalize).filter((x): x is NormalizedNft => x !== null), + nextPageKey: json.data?.pageKey ?? null, + totalCount: json.data?.totalCount ?? owned.length, + }; +} + +export const getNftHoldingsTool = tool( + async ({ address }: { address: string }) => { + if (!ADDRESS_RE.test(address)) { + return JSON.stringify({ + success: false, + error: "address must be a 0x-prefixed 40-hex string", + }); + } + const apiKey = process.env.ALCHEMY_API_KEY; + if (!apiKey) { + return JSON.stringify({ + success: false, + error: "ALCHEMY_API_KEY is not configured on the server", + }); + } + + try { + const all: NormalizedNft[] = []; + let totalCount = 0; + let pageKey: string | undefined = undefined; + // Hard cap to keep a pathological wallet from spinning the loop. + // Alchemy caps at 1000 NFTs per wallet anyway (totalCount ceiling + // for the free tier), but defending in depth. + const MAX_PAGES = 20; + for (let i = 0; i < MAX_PAGES; i++) { + const page = await fetchPage(apiKey, address, pageKey); + all.push(...page.nfts); + totalCount = page.totalCount; + if (!page.nextPageKey) break; + pageKey = page.nextPageKey; + } + return JSON.stringify({ + success: true, + address, + totalCount, + nfts: all, + }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return JSON.stringify({ success: false, error: msg }); + } + }, + { + name: "get_NFT_holdings", + description: + "List the NFT holdings of an EVM wallet across Ethereum, Arbitrum, Optimism, Base, and Polygon. Returns image URLs, contract name, collection slug, token id, and network for each NFT. Airdrop/claim-bait NFTs (yield-eth.net, USDC vouchers, etc.) are filtered out by name pattern in addition to Alchemy's own spam filter. The address must be a 0x-prefixed 40-hex string; the LLM should pull it from the user's message or the most recent connect_wallet ToolMessage, not invent one. If the wallet holds no NFTs, returns an empty list. Requires ALCHEMY_API_KEY to be configured on the server.", + schema: z.object({ + address: z + .string() + .describe( + "Wallet address, 0x-prefixed 40-hex chars. Case-insensitive. Pull from the user's message or a previous connect_wallet ToolMessage — never invent.", + ), + }), + }, +); diff --git a/backend/tool/index.ts b/backend/tool/index.ts index 770456d..ca7c0b0 100644 --- a/backend/tool/index.ts +++ b/backend/tool/index.ts @@ -8,6 +8,7 @@ import { getFxRateTool } from "@/backend/tool/crypto/get-fx-rate"; import { connectWalletTool } from "@/backend/tool/crypto/connect-wallet"; import { placeCryptoOrderTool } from "@/backend/tool/crypto/place-crypto-order"; import { getOrderStatusTool } from "@/backend/tool/crypto/get-order-status"; +import { getNftHoldingsTool } from "@/backend/tool/crypto/get-nft-holdings"; // ponytail: keep the tool list in one place so the graph binds it from a // single source. Adding a tool = drop a file + add one line here. @@ -27,6 +28,7 @@ export const CRYPTO_TOOLS = [ connectWalletTool, placeCryptoOrderTool, getOrderStatusTool, + getNftHoldingsTool, ]; export const ALL_TOOLS = [ @@ -40,6 +42,7 @@ export const ALL_TOOLS = [ connectWalletTool, placeCryptoOrderTool, getOrderStatusTool, + getNftHoldingsTool, ]; export { @@ -53,4 +56,5 @@ export { connectWalletTool, placeCryptoOrderTool, getOrderStatusTool, + getNftHoldingsTool, }; diff --git a/components/tool-ui/crypto/index.ts b/components/tool-ui/crypto/index.ts index 81dbe58..a9335ac 100644 --- a/components/tool-ui/crypto/index.ts +++ b/components/tool-ui/crypto/index.ts @@ -2,3 +2,4 @@ export { CryptoPriceCard } from "./price-card"; export { ConnectWalletCard } from "./connect-wallet-card"; export { PlaceCryptoOrderCard } from "./place-crypto-order-card"; export { OrderStatusCard } from "./order-status-card"; +export { NftGalleryCard } from "./nft-gallery-card"; diff --git a/components/tool-ui/crypto/nft-gallery-card.tsx b/components/tool-ui/crypto/nft-gallery-card.tsx new file mode 100644 index 0000000..58351b5 --- /dev/null +++ b/components/tool-ui/crypto/nft-gallery-card.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronDownIcon, ImagesIcon, ImageOffIcon, XIcon } from "lucide-react"; +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; + +import { ToolCardSkeleton } from "@/components/tool-ui/tool-card-skeleton"; +import { unwrapToolResult } from "@/components/tool-ui/tool-result"; +import { cn } from "@/lib/utils"; + +type Nft = { + contractAddress: string; + contractName: string; + collectionName: string | null; + collectionSlug: string | null; + contractImageUrl: string | null; + network: string; + tokenId: string; + tokenType: string; + name: string; + thumbnailUrl: string | null; + cachedUrl: string | null; + balance: string; +}; + +type Result = + | { success: true; address: string; totalCount: number; nfts: Nft[] } + | { success: false; error: string }; + +type Args = { address: string }; + +function parse(raw: unknown): Result | null { + return unwrapToolResult(raw); +} + +const NETWORK_LABEL: Record = { + "eth-mainnet": "Ethereum", + "arb-mainnet": "Arbitrum", + "opt-mainnet": "Optimism", + "base-mainnet": "Base", + "polygon-mainnet": "Polygon", +}; + +function networkLabel(slug: string): string { + return NETWORK_LABEL[slug] ?? slug; +} + +function shortAddress(addr: string): string { + if (addr.length < 12) return addr; + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +// Group key: same contract on the same chain. contractAddress alone is +// usually enough (an ERC721/ERC1155 contract lives on one chain) but +// pinning the chain makes the key collision-proof if that ever changes. +function groupKey(nft: Nft): string { + return `${nft.network}:${nft.contractAddress.toLowerCase()}`; +} + +type Group = { + key: string; + contractAddress: string; + contractName: string; + collectionName: string | null; + contractImageUrl: string | null; + network: string; + items: Nft[]; +}; + +const GROUP_COLLAPSED_SIZE = 6; + +function NftTile({ nft, onOpen }: { nft: Nft; onOpen: (nft: Nft) => void }) { + const displayName = nft.name || nft.contractName || "Untitled NFT"; + const label = networkLabel(nft.network); + const [imgFailed, setImgFailed] = useState(false); + const showFallback = imgFailed || !(nft.thumbnailUrl || nft.cachedUrl); + + return ( + + ); +} + +function CollectionGroup({ + group, + onOpen, +}: { + group: Group; + onOpen: (nft: Nft) => void; +}) { + const [expanded, setExpanded] = useState(false); + const showCount = expanded ? group.items.length : Math.min(GROUP_COLLAPSED_SIZE, group.items.length); + const hidden = group.items.length - showCount; + const [headerImgFailed, setHeaderImgFailed] = useState(false); + + return ( +
      +
      + {group.contractImageUrl && !headerImgFailed ? ( + // eslint-disable-next-line @next/next/no-img-element + setHeaderImgFailed(true)} + className="border-border/40 size-5 shrink-0 rounded border object-cover" + /> + ) : ( +
      + +
      + )} + + {group.collectionName || group.contractName} + + + {group.items.length} + +
      +
      + {group.items.slice(0, showCount).map((nft) => ( + + ))} +
      + {hidden > 0 ? ( + + ) : null} +
      + ); +} + +function NftLightbox({ nft, onClose }: { nft: Nft; onClose: () => void }) { + const displayName = nft.name || nft.contractName || "Untitled NFT"; + const label = networkLabel(nft.network); + const [imgFailed, setImgFailed] = useState(false); + const showFallback = imgFailed || !(nft.cachedUrl || nft.thumbnailUrl); + + return ( + + ); +} + +export const NftGalleryCard: ToolCallMessagePartComponent = ({ result }) => { + const [selected, setSelected] = useState(null); + const parsed = parse(result); + + const groups = useMemo(() => { + if (!parsed || parsed.success === false) return []; + const map = new Map(); + for (const nft of parsed.nfts) { + const key = groupKey(nft); + const existing = map.get(key); + if (existing) { + existing.items.push(nft); + } else { + map.set(key, { + key, + contractAddress: nft.contractAddress, + contractName: nft.contractName, + collectionName: nft.collectionName, + contractImageUrl: nft.contractImageUrl, + network: nft.network, + items: [nft], + }); + } + } + // Sort: largest collections first (most noise there → user wants them up top), then alphabetical. + return Array.from(map.values()).sort((a, b) => { + const sizeDiff = b.items.length - a.items.length; + if (sizeDiff !== 0) return sizeDiff; + return (a.collectionName || a.contractName).localeCompare(b.collectionName || b.contractName); + }); + }, [parsed]); + + if (!parsed) { + return ; + } + + if (parsed.success === false) { + return ( +
      + Couldn't fetch NFTs: {parsed.error} +
      + ); + } + + const { address, nfts, totalCount } = parsed; + const networks = Array.from(new Set(nfts.map((n) => n.network))).sort(); + const filtered = totalCount - nfts.length; + + return ( + <> +
      +
      +
      + +
      +
      +

      NFT Holdings

      +

      + {shortAddress(address)} +

      +
      +
      + + {nfts.length} + {filtered > 0 ? ` / ${totalCount}` : ""} + + + {networks.length} {networks.length === 1 ? "chain" : "chains"} + +
      +
      + + {nfts.length === 0 ? ( +

      + No NFTs held at this address (after spam filter). +

      + ) : ( +
      + {groups.map((g) => ( + + ))} +
      + )} + +
      + Airdrop / claim-bait filtered by name pattern.{" "} + + Alchemy + + . +
      +
      + {selected ? setSelected(null)} /> : null} + + ); +}; diff --git a/components/tool-ui/toolkit.tsx b/components/tool-ui/toolkit.tsx index a311c35..d0b8fc4 100644 --- a/components/tool-ui/toolkit.tsx +++ b/components/tool-ui/toolkit.tsx @@ -8,6 +8,7 @@ import { WeatherCard } from "@/components/tool-ui/weather/weather-card"; import { ConnectWalletCard, CryptoPriceCard, + NftGalleryCard, OrderStatusCard, PlaceCryptoOrderCard, } from "@/components/tool-ui/crypto"; @@ -78,6 +79,13 @@ const cryptoToolkit = defineToolkit({ }), render: OrderStatusCard, }, + get_NFT_holdings: { + description: "Fetch and render an NFT gallery for the given wallet address.", + parameters: z.object({ + address: z.string(), + }), + render: NftGalleryCard, + }, }); export default defineToolkit({ diff --git a/docs/APIS.md b/docs/APIS.md index 9a33e38..eb4f85c 100644 --- a/docs/APIS.md +++ b/docs/APIS.md @@ -152,6 +152,19 @@ Read-only FX lookup via frankfurter.app (ECB-sourced). Has a 60s in-memory cache **Not currently exposed to the LLM.** Listed here for reference only — the file and tests are kept in `backend/tool/crypto/get-token-balances.ts` for direct programmatic use, but `CRYPTO_TOOLS` does not register it. The wallet's address is not in the LLM's context (it lives in wagmi/RainbowKit on the frontend), so the agent has no way to call this tool without inventing an address. The simulated `place_crypto_order` flow does not consult on-chain balances — every user is auto-funded with Mock Coin. If you want to re-enable this tool, register it in `backend/tool/index.ts` and update `CRYPTO_AGENT_PROMPT` step 2. +### `get_NFT_holdings(address)` + +Read-only. Lists the NFT holdings of an EVM wallet across Ethereum, Arbitrum, Optimism, Base, and Polygon. Calls Alchemy's Portfolio API `nfts/by-address` directly from the server (uses `ALCHEMY_API_KEY`). + +| | | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Input | `{ address: string }` — 0x-prefixed 40-hex chars. Case-insensitive. The LLM pulls this from the user's message or the most recent `connect_wallet` ToolMessage — never invents. | +| Output | `{ success: true, address, totalCount, nfts: Array<{ contractAddress, contractName, collectionName, collectionSlug, contractImageUrl, network, tokenId, tokenType, name, thumbnailUrl, cachedUrl, balance }> }` (JSON string). | +| Auth | None from the LLM's perspective. Server reads `ALCHEMY_API_KEY`. | +| Failure modes | Missing / malformed address → zod-style rejection. Missing `ALCHEMY_API_KEY` → `{ success: false, error }`. Alchemy 4xx/5xx → `{ success: false, error: "alchemy N: ..." }`. Empty list → `{ success: true, nfts: [] }`, not an error. | + +The tool always sends `excludeSpam: true` plus an in-house name filter (`claim | airdrop | visit | gift | giveaway | voucher | reward | drop | bonus`) — Alchemy's own spam classifier leaves a lot of airdrop-bait through, and this regex catches the obvious patterns (yield-eth.net, USDC vouchers, etc.). Items whose contract name OR per-token name matches the regex are dropped from the response. Pagination via `pageKey` is handled internally (capped at 20 pages so a pathological wallet can't spin). + ### `connect_wallet(message?)` Wallet-authorization interrupt. Pauses via `interrupt()`; the frontend card opens RainbowKit, then resumes with `{address, chainId}` from wagmi. That becomes the ToolMessage content the LLM reads. Subsequent tools (`place_crypto_order`, `get_order_status`) auto-infer the address from wagmi state — the LLM does not need to thread an address through the schema. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index b92d1e2..7803fbe 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -38,6 +38,7 @@ ask-location card isn't raced by a parallel tool call. See | `place_crypto_order`| `crypto/place-crypto-order.ts` | `crypto/place-crypto-order-card.tsx`| Interrupt-driven. Simulated swap; resumes with `SimulatedOrder` or `cancelled`. | | `get_order_status` | `crypto/get-order-status.ts` | `crypto/order-status-card.tsx` | Interrupt-driven. Synthesizes a status (simulated-swap demo); resumes with status payload. | | `get_token_balances`| `crypto/get-token-balances.ts` | — | Defined but not wired into `ALL_TOOLS` yet — dormant. | +| `get_NFT_holdings` | `crypto/get-nft-holdings.ts` | `crypto/nft-gallery-card.tsx` | Read-only. Lists NFTs across 5 chains; filters spam by name regex. Renders a gallery grid. | Trade flow is split into three atomic interrupt tools (connect → place → check) so each is its own user decision point and `ToolMessage` the LLM can reason diff --git a/tests/backend/tool/crypto/get-nft-holdings.test.ts b/tests/backend/tool/crypto/get-nft-holdings.test.ts new file mode 100644 index 0000000..87abec1 --- /dev/null +++ b/tests/backend/tool/crypto/get-nft-holdings.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const fetchMock = vi.fn(); + +beforeEach(async () => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + vi.resetModules(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.ALCHEMY_API_KEY; +}); + +async function loadTool() { + const mod = await import("@/backend/tool/crypto/get-nft-holdings"); + return mod.getNftHoldingsTool; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// Shape trimmed to fields the tool reads. +function makeNft(overrides: Record = {}) { + return { + tokenId: "2", + balance: "1", + network: "base-mainnet", + contract: { + address: "0x6d4733bbe176fad22e9e8d069ba34781f792d9af", + name: "Girl Base", + symbol: "", + totalSupply: "86", + tokenType: "ERC721", + openSeaMetadata: { + floorPrice: null, + collectionName: "Girl Base", + collectionSlug: "girl-base", + imageUrl: "https://i2c.seadn.io/base/collection.png", + }, + }, + tokenType: "ERC721", + name: "Girl Base #2", + isSpam: null, + image: { + cachedUrl: "https://nft-cdn.alchemy.com/base-mainnet/abc", + thumbnailUrl: "https://res.cloudinary.com/alchemyapi/thumb/abc", + pngUrl: "https://res.cloudinary.com/alchemyapi/png/abc", + contentType: "image/png", + originalUrl: "https://didspaces.com/abc", + }, + ...overrides, + }; +} + +describe("getNftHoldingsTool", () => { + it("rejects an address that is not 0x + 40 hex chars", async () => { + process.env.ALCHEMY_API_KEY = "test-key"; + const getNftHoldingsTool = await loadTool(); + const out = await getNftHoldingsTool.invoke({ address: "not-an-address" }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/address/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns an error envelope when ALCHEMY_API_KEY is not set", async () => { + delete process.env.ALCHEMY_API_KEY; + const getNftHoldingsTool = await loadTool(); + const out = await getNftHoldingsTool.invoke({ + address: "0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33", + }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/ALCHEMY_API_KEY/); + }); + + it("hits the Alchemy Portfolio nfts/by-address endpoint with the right shape", async () => { + process.env.ALCHEMY_API_KEY = "test-key"; + const getNftHoldingsTool = await loadTool(); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { ownedNfts: [], totalCount: 0 } })); + await getNftHoldingsTool.invoke({ + address: "0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33", + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.g.alchemy.com/data/v1/test-key/assets/nfts/by-address"); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body); + expect(body.addresses).toEqual([ + { + address: "0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33", + networks: expect.arrayContaining(["eth-mainnet", "arb-mainnet", "opt-mainnet", "base-mainnet", "polygon-mainnet"]), + }, + ]); + expect(body.withMetadata).toBe(true); + expect(body.excludeSpam).toBe(true); + expect(body.pageSize).toBe(100); + }); + + it("returns a normalized list of NFTs from a single page", async () => { + process.env.ALCHEMY_API_KEY = "test-key"; + const getNftHoldingsTool = await loadTool(); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + data: { + ownedNfts: [makeNft({ tokenId: "2" }), makeNft({ tokenId: "3", name: "Girl Base #3" })], + totalCount: 2, + pageKey: null, + }, + }), + ); + const out = await getNftHoldingsTool.invoke({ + address: "0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33", + }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(true); + expect(parsed.address).toBe("0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33"); + expect(parsed.totalCount).toBe(2); + expect(parsed.nfts).toHaveLength(2); + expect(parsed.nfts[0]).toMatchObject({ + contractAddress: "0x6d4733bbe176fad22e9e8d069ba34781f792d9af", + contractName: "Girl Base", + collectionName: "Girl Base", + collectionSlug: "girl-base", + network: "base-mainnet", + tokenId: "2", + tokenType: "ERC721", + name: "Girl Base #2", + thumbnailUrl: "https://res.cloudinary.com/alchemyapi/thumb/abc", + cachedUrl: "https://nft-cdn.alchemy.com/base-mainnet/abc", + }); + }); + + it("paginates with pageKey until the upstream returns null", async () => { + process.env.ALCHEMY_API_KEY = "test-key"; + const getNftHoldingsTool = await loadTool(); + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { + data: { + ownedNfts: [makeNft({ tokenId: "2" })], + totalCount: 3, + pageKey: "page-2-token", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse(200, { + data: { + ownedNfts: [makeNft({ tokenId: "3", name: "Girl Base #3" })], + totalCount: 3, + pageKey: null, + }, + }), + ); + const out = await getNftHoldingsTool.invoke({ + address: "0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33", + }); + const parsed = JSON.parse(out as string); + expect(parsed.nfts).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(secondBody.pageKey).toBe("page-2-token"); + }); + + it("filters out airdrop-bait NFTs that slipped past excludeSpam", async () => { + process.env.ALCHEMY_API_KEY = "test-key"; + const getNftHoldingsTool = await loadTool(); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + data: { + ownedNfts: [ + makeNft({ tokenId: "0", name: "Girl Base #0" }), + makeNft({ + tokenId: "1", + contract: { + address: "0xspam", + name: "yield-eth.net", + symbol: "claim rewards on yield-eth.net", + totalSupply: "1500", + tokenType: "ERC1155", + }, + name: "Visit yield-eth.net to claim rewards", + }), + makeNft({ + tokenId: "2", + contract: { + address: "0xvoucher", + name: "USDC Voucher", + symbol: "USDCV", + totalSupply: "1000", + tokenType: "ERC1155", + }, + name: "5000 USDC Voucher (claim.circlest.org)", + }), + ], + totalCount: 3, + pageKey: null, + }, + }), + ); + const out = await getNftHoldingsTool.invoke({ + address: "0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33", + }); + const parsed = JSON.parse(out as string); + expect(parsed.nfts).toHaveLength(1); + expect(parsed.nfts[0].tokenId).toBe("0"); + }); + + it("returns an empty list (not an error) when the wallet holds nothing", async () => { + process.env.ALCHEMY_API_KEY = "test-key"; + const getNftHoldingsTool = await loadTool(); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { data: { ownedNfts: [], totalCount: 0, pageKey: null } }), + ); + const out = await getNftHoldingsTool.invoke({ + address: "0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33", + }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(true); + expect(parsed.nfts).toEqual([]); + expect(parsed.totalCount).toBe(0); + }); + + it("propagates API failures as a serialized error result", async () => { + process.env.ALCHEMY_API_KEY = "test-key"; + const getNftHoldingsTool = await loadTool(); + fetchMock.mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized" })); + const out = await getNftHoldingsTool.invoke({ + address: "0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33", + }); + const parsed = JSON.parse(out as string); + expect(parsed.success).toBe(false); + expect(parsed.error).toMatch(/401/); + }); + + it("normalizes NFTs that lack image data to null URLs (no crash)", async () => { + process.env.ALCHEMY_API_KEY = "test-key"; + const getNftHoldingsTool = await loadTool(); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + data: { + ownedNfts: [ + makeNft({ + tokenId: "0", + image: {}, + contract: { + address: "0xnoimg", + name: "No Image NFT", + symbol: "", + totalSupply: "1", + tokenType: "ERC721", + openSeaMetadata: {}, + }, + }), + ], + totalCount: 1, + pageKey: null, + }, + }), + ); + const out = await getNftHoldingsTool.invoke({ + address: "0xc9c31b1Ad61713B10b30C38Fd88Ab0968B61EC33", + }); + const parsed = JSON.parse(out as string); + expect(parsed.nfts[0]).toMatchObject({ + contractName: "No Image NFT", + thumbnailUrl: null, + cachedUrl: null, + collectionName: null, + collectionSlug: null, + }); + }); +}); From 61c1ec62e1ff77580cacb247310e6fffc4e433a8 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 17:24:26 +0800 Subject: [PATCH 29/35] =?UTF-8?q?feat(crypto):=20NFT=20gallery=20=E2=80=94?= =?UTF-8?q?=20grouping,=20video,=20persistence,=20lightbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layered improvements to the get_NFT_holdings card on top of the initial implementation. Backend (get-nft-holdings.ts): - Normalize contentType, totalSupply, floorPriceEth from the Alchemy Portfolio response. Floor coerces from both number and legacy string shapes; non-finite / non-positive → null. Prompt (system.ts): - NFT HOLDINGS FLOW now auto-fires connect_wallet when no address is available in the message and no prior connect_wallet ToolMessage exists. Replaces the prior "ask the user to paste one" reply, which was a friction point. Constants (constants.ts): - NFT_GALLERY_COLLAPSED_STORAGE_KEY = "nft-gallery:collapsed-groups" for the persisted Set of group keys the user has collapsed. Card (nft-gallery-card.tsx): - Grouping: by collection (network + collectionName), not by contract. Multiple contracts that share a name (e.g. "APE NFT TICKETS" redeployed under 3 addresses) now land in one group; the header shows a chip per contract with its truncated address and token count. - Collapse persistence: group keys stored in localStorage; user collapses survive page reload. - Lightbox: two-column grid with full metadata (token id, floor, total supply, contract with copy-to-clipboard, OpenSea link). Renders via React Portal to document.body so it sits above the Thread scroll context. - Video: NftImage now branches on contentType (or img-onError retry) to render a
    -
    - {group.items.slice(0, showCount).map((nft) => ( - - ))} -
    - {hidden > 0 ? ( - + + + {!collapsed ? ( + <> +
    + {allItems.slice(0, visible).map((nft) => ( + + ))} +
    + {hidden > 0 ? ( + + ) : null} + ) : null} ); @@ -171,81 +387,189 @@ function CollectionGroup({ function NftLightbox({ nft, onClose }: { nft: Nft; onClose: () => void }) { const displayName = nft.name || nft.contractName || "Untitled NFT"; const label = networkLabel(nft.network); - const [imgFailed, setImgFailed] = useState(false); - const showFallback = imgFailed || !(nft.cachedUrl || nft.thumbnailUrl); + const collection = nft.collectionName || nft.contractName; + const externalHref = openseaUrl(nft); + const [mounted, setMounted] = useState(false); - return ( - +
    + +
    + + {label} + + + {nft.tokenType} + + {nft.balance && nft.balance !== "1" ? ( + + Balance {nft.balance} + ) : null} -

    - #{nft.tokenId} · {label} · {nft.tokenType} -

    - -
    -
    - {showFallback ? ( -
    - + +
    +
    +
    + Token ID +
    +
    #{nft.tokenId}
    - ) : ( - // eslint-disable-next-line @next/next/no-img-element - {displayName} setImgFailed(true)} - className="mx-auto max-h-[70vh] object-contain" - /> - )} +
    +
    + Floor +
    +
    + {nft.floorPriceEth != null ? `${nft.floorPriceEth.toFixed(3)} ETH` : "—"} +
    +
    + {nft.totalSupply ? ( +
    +
    + Total supply +
    +
    + {Number(nft.totalSupply).toLocaleString()} +
    +
    + ) : null} +
    + +
    +
    + Contract +
    +
    + +
    +
    + + {externalHref ? ( + + View on OpenSea + + + ) : null}
    - +
    , + document.body, ); } export const NftGalleryCard: ToolCallMessagePartComponent = ({ result }) => { const [selected, setSelected] = useState(null); + // The collapsed Set lives here (parent) so it can be persisted; each + // CollectionGroup only owns session-local UI state (paginated). + const [collapsed, setCollapsed] = useState>(() => readCollapsedSet()); const parsed = parse(result); + const toggleCollapsed = useCallback((key: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + writeCollapsedSet(next); + return next; + }); + }, []); + const groups = useMemo(() => { if (!parsed || parsed.success === false) return []; + // Two-level grouping: outer key = collection (network + name); inner + // bucket = individual contract. Lets one collection that minted + // across multiple contracts (e.g. redeploys, multi-chain mirrors) sit + // under a single header with one chip per contract. const map = new Map(); for (const nft of parsed.nfts) { const key = groupKey(nft); - const existing = map.get(key); - if (existing) { - existing.items.push(nft); - } else { - map.set(key, { + let group = map.get(key); + if (!group) { + group = { key, + network: nft.network, + collectionName: nft.collectionName?.trim() || nft.contractName.trim(), + contractName: nft.contractName.trim(), + contracts: [], + }; + map.set(key, group); + } + const contractKey = nft.contractAddress.toLowerCase(); + let bucket = group.contracts.find((b) => b.contractAddress.toLowerCase() === contractKey); + if (!bucket) { + bucket = { contractAddress: nft.contractAddress, - contractName: nft.contractName, - collectionName: nft.collectionName, contractImageUrl: nft.contractImageUrl, - network: nft.network, - items: [nft], - }); + items: [], + }; + group.contracts.push(bucket); } + bucket.items.push(nft); } - // Sort: largest collections first (most noise there → user wants them up top), then alphabetical. - return Array.from(map.values()).sort((a, b) => { - const sizeDiff = b.items.length - a.items.length; - if (sizeDiff !== 0) return sizeDiff; - return (a.collectionName || a.contractName).localeCompare(b.collectionName || b.contractName); - }); + return Array.from(map.values()) + .sort((a, b) => { + const aTotal = a.contracts.reduce((s, c) => s + c.items.length, 0); + const bTotal = b.contracts.reduce((s, c) => s + c.items.length, 0); + const sizeDiff = bTotal - aTotal; + if (sizeDiff !== 0) return sizeDiff; + return a.collectionName.localeCompare(b.collectionName); + }) + .map((g) => ({ + ...g, + contracts: [...g.contracts].sort((a, b) => b.items.length - a.items.length), + })); }, [parsed]); if (!parsed) { @@ -268,9 +592,7 @@ export const NftGalleryCard: ToolCallMessagePartComponent = ({ res <>
    @@ -300,7 +622,13 @@ export const NftGalleryCard: ToolCallMessagePartComponent = ({ res ) : (
    {groups.map((g) => ( - + ))}
    )} diff --git a/lib/constants.ts b/lib/constants.ts index 29d686e..1c46a36 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -17,3 +17,10 @@ export const APP_NAME = "LangGraph App"; // Used by the Drizzle column default, the threads module, and the UI as a // fallback when the runtime hasn't loaded a real title. export const DEFAULT_THREAD_TITLE = "New Chat"; + +// localStorage key for the set of NFT gallery group keys the user has +// collapsed. The card persists per-group collapse state across refreshes +// so a user who closes the "Bridge to Base" airdrop bucket doesn't have to +// re-collapse it every visit. Value is a JSON array of group keys (each +// key is "${network}:${contractAddress}"). +export const NFT_GALLERY_COLLAPSED_STORAGE_KEY = "nft-gallery:collapsed-groups"; From a986276bdf6bbd6d373f9336d773eff1b91b6779 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 17:27:21 +0800 Subject: [PATCH 30/35] style(crypto): tighten NFT gallery header spacing + lightbox image ratio --- components/tool-ui/crypto/nft-gallery-card.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/tool-ui/crypto/nft-gallery-card.tsx b/components/tool-ui/crypto/nft-gallery-card.tsx index 3a7fdc1..4d961cc 100644 --- a/components/tool-ui/crypto/nft-gallery-card.tsx +++ b/components/tool-ui/crypto/nft-gallery-card.tsx @@ -334,16 +334,16 @@ function CollectionGroup({
    )} - + {group.collectionName} - + {group.contracts.map((c) => ( ×{c.items.length} @@ -410,14 +410,14 @@ function NftLightbox({ nft, onClose }: { nft: Nft; onClose: () => void }) { >
    e.stopPropagation()} - className="bg-card text-card-foreground grid w-full max-w-4xl grid-cols-1 overflow-hidden rounded-2xl shadow-2xl md:grid-cols-[1fr_1fr]" + className="bg-card text-card-foreground grid w-full max-w-4xl grid-cols-1 overflow-hidden rounded-2xl shadow-2xl md:grid-cols-[1.5fr_1fr]" > From 110180ba1acbff49eaafbf9824404d0cba1fa480 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 17:31:28 +0800 Subject: [PATCH 31/35] docs: sync NFT gallery entries across APIS, TOOLS, CLAUDE, README Audit follow-up: get_NFT_holdings output shape omitted contentType/totalSupply/floorPriceEth, and the tool + card were missing from CLAUDE.md's inventory; TOOLS.md row also had misaligned columns. README was missing the crypto sub-agent and ALCHEMY_API_KEY. INTERRUPT.md and TODOS.md are intentionally untouched (NFT is a non-interrupt tool and the work is done). --- CLAUDE.md | 4 ++-- README.md | 4 ++++ docs/APIS.md | 2 +- docs/TOOLS.md | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6501815..76893f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ backend/ after-agent-node.ts "afterAgent" — touches threads.last_message_at prompt/system.ts CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, CRYPTO_AGENT_PROMPT, ROUTER_AGENT_PROMPT, RENAME_THREAD_PROMPT tool/ ask_location, geocode_location, get_weather, search_web, fetch_url - tool/crypto/ get_crypto_price, get_fx_rate, get_token_balances, connect_wallet, place_crypto_order, get_order_status + tool/crypto/ get_crypto_price, get_fx_rate, get_token_balances, get_NFT_holdings, connect_wallet, place_crypto_order, get_order_status langgraph.json CLI config: graph id, node version, env file app/ Next.js App Router layout.tsx Root layout, fonts, TooltipProvider @@ -96,7 +96,7 @@ components/ ui/address-or-hash.tsx Truncated address/hash with copy-to-clipboard tool-ui/ask-location/ Interrupt-driven or addResult-driven location picker card tool-ui/weather/ Forecast widget renderer (vendored runtime + container + overlay) - tool-ui/crypto/ Price, connect-wallet, place-order, order-status cards + tool-ui/crypto/ Price, connect-wallet, place-order, order-status, nft-gallery cards lib/utils.ts cn() = twMerge(clsx(...)) lib/threads/ Threads module (schema, queries, adapter, validators) lib/wagmi.ts wagmi/RainbowKit config (chains, connectors, WalletConnect projectId) diff --git a/README.md b/README.md index e03cd5e..96889f5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A self-hostable chat app (this repo: `langgraph-app`) that streams tokens from a - **TDD-tested**: Vitest with a separate test database. - **User accounts**: email + password (with email verification), GitHub and Google sign-in, 7-day persistent sessions, and per-user thread isolation. See [docs/AUTH.md](docs/AUTH.md) for the operator guide. - **Tool-using agent**: the `agent` node is bound to `search_web` (Jina Search) and `fetch_url` (Jina Reader) — the model can research topics and read pages mid-conversation. Tools run unconditionally; write-side tools added later will hang their own `interruptBefore` hook. See [docs/APIS.md](docs/APIS.md) for the contract. +- **Crypto sub-agent**: price, NFT holdings (5-chain gallery via Alchemy Portfolio), and a simulated swap flow against an auto-funded Mock Coin balance — see [docs/TOOLS.md](docs/TOOLS.md) and [docs/INTERRUPT.md](docs/INTERRUPT.md) for the per-card contract. ## Tech stack @@ -210,6 +211,7 @@ Test database stays isolated from dev — never put production-like data in `lan | `OPENAI_MODEL` | backend agent | optional (default `gpt-4o-mini`) | | `OPENAI_BASE_URL` | backend agent | optional (OpenAI-compatible gateway) | | `JINA_API_KEYS` | web-search + web-fetch | yes (comma-separated; one per Jina account) | +| `ALCHEMY_API_KEY` | NFT gallery + portfolio | yes (server-only; powers `get_NFT_holdings`) | | `LANGGRAPH_API_URL` | Next.js proxy | optional (default `http://localhost:2024`) | | `LANGCHAIN_API_KEY` | Next.js proxy → LangGraph | optional (leave blank locally) | | `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` | browser runtime | optional (default `agent`) | @@ -231,6 +233,8 @@ Test database stays isolated from dev — never put production-like data in `lan ## Documentation - [`docs/APIS.md`](docs/APIS.md) — HTTP endpoint reference. Update whenever a route under `app/api/` changes. +- [`docs/TOOLS.md`](docs/TOOLS.md) — LangGraph tool inventory and frontend card wiring. Update whenever a tool or card is added/removed/rerouted. +- [`docs/INTERRUPT.md`](docs/INTERRUPT.md) — interrupt-driven tool flows (ask_location, connect_wallet, place_crypto_order, get_order_status) — the two runtime paths the cards can take. - [`docs/AUTH.md`](docs/AUTH.md) — operator guide for the auth layer: env vars, OAuth app setup, Resend, troubleshooting. - [`docs/DB.md`](docs/DB.md) — database schema (Better Auth + `threads`), ownership model, indexes. Source of truth: `db/migrations/0000_*.sql`. diff --git a/docs/APIS.md b/docs/APIS.md index eb4f85c..ddae7ce 100644 --- a/docs/APIS.md +++ b/docs/APIS.md @@ -159,7 +159,7 @@ Read-only. Lists the NFT holdings of an EVM wallet across Ethereum, Arbitrum, Op | | | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Input | `{ address: string }` — 0x-prefixed 40-hex chars. Case-insensitive. The LLM pulls this from the user's message or the most recent `connect_wallet` ToolMessage — never invents. | -| Output | `{ success: true, address, totalCount, nfts: Array<{ contractAddress, contractName, collectionName, collectionSlug, contractImageUrl, network, tokenId, tokenType, name, thumbnailUrl, cachedUrl, balance }> }` (JSON string). | +| Output | `{ success: true, address, totalCount, nfts: Array<{ contractAddress, contractName, collectionName, collectionSlug, contractImageUrl, network, tokenId, tokenType, name, thumbnailUrl, cachedUrl, contentType, balance, totalSupply, floorPriceEth }> }` (JSON string). | | Auth | None from the LLM's perspective. Server reads `ALCHEMY_API_KEY`. | | Failure modes | Missing / malformed address → zod-style rejection. Missing `ALCHEMY_API_KEY` → `{ success: false, error }`. Alchemy 4xx/5xx → `{ success: false, error: "alchemy N: ..." }`. Empty list → `{ success: true, nfts: [] }`, not an error. | diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 7803fbe..9620f87 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -38,7 +38,7 @@ ask-location card isn't raced by a parallel tool call. See | `place_crypto_order`| `crypto/place-crypto-order.ts` | `crypto/place-crypto-order-card.tsx`| Interrupt-driven. Simulated swap; resumes with `SimulatedOrder` or `cancelled`. | | `get_order_status` | `crypto/get-order-status.ts` | `crypto/order-status-card.tsx` | Interrupt-driven. Synthesizes a status (simulated-swap demo); resumes with status payload. | | `get_token_balances`| `crypto/get-token-balances.ts` | — | Defined but not wired into `ALL_TOOLS` yet — dormant. | -| `get_NFT_holdings` | `crypto/get-nft-holdings.ts` | `crypto/nft-gallery-card.tsx` | Read-only. Lists NFTs across 5 chains; filters spam by name regex. Renders a gallery grid. | +| `get_NFT_holdings` | `crypto/get-nft-holdings.ts` | `crypto/nft-gallery-card.tsx` | Read-only. Lists NFTs across 5 chains; filters spam by name regex. Renders a gallery grid. | Trade flow is split into three atomic interrupt tools (connect → place → check) so each is its own user decision point and `ToolMessage` the LLM can reason From a66f5717fcad16f8cc421c81d63ae80f3d858806 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 18:01:52 +0800 Subject: [PATCH 32/35] feat(api): gate LangGraph + Alchemy proxies behind withAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior builds left the catch-all proxies unauthenticated; any website's JS could create / list / delete threads or burn Alchemy compute units. withAuth reads the session row from Postgres through drizzle/postgres-js, which needs Node net — the LangGraph and Alchemy catch-alls lose runtime = edge on this commit. /api/auth/[...all] is the only route that stays open (it's the login endpoint itself); OPTIONS preflight also stays open so the browser can issue the authed request. --- app/api/[..._path]/route.ts | 109 +++++++++++++++----------- app/api/alchemy/[...path]/route.ts | 18 +++-- app/api/alchemy/status/route.ts | 16 +++- tests/api/alchemy/auth.test.ts | 84 ++++++++++++++++++++ tests/api/alchemy/portfolio.test.ts | 13 ++++ tests/api/alchemy/proxy.test.ts | 18 ++++- tests/api/alchemy/status.test.ts | 32 ++++++-- tests/api/langgraph-proxy.test.ts | 116 ++++++++++++++++++++++++++++ 8 files changed, 345 insertions(+), 61 deletions(-) create mode 100644 tests/api/alchemy/auth.test.ts create mode 100644 tests/api/langgraph-proxy.test.ts diff --git a/app/api/[..._path]/route.ts b/app/api/[..._path]/route.ts index 86fc911..484b716 100644 --- a/app/api/[..._path]/route.ts +++ b/app/api/[..._path]/route.ts @@ -1,7 +1,22 @@ import { type NextRequest, NextResponse } from "next/server"; -export const runtime = "edge"; +export const runtime = "nodejs"; +import { withAuth } from "@/lib/auth/with-auth"; + +// ponytail: edge catch-all proxy to LANGGRAPH_API_URL. The browser +// sends `ANY /api/`; we forward to `${LANGGRAPH_API_URL}/` +// with `x-api-key: LANGCHAIN_API_KEY`. We also forward the user's +// cookie + Authorization so LangGraph can identify the calling thread. +// +// Auth: gated behind a Better Auth session via withAuth. The previous +// build accepted anonymous traffic — any website's JS could create / +// list / delete threads through this proxy. withAuth uses next/headers +// which works on edge as of Next 14. +// +// SSE: most calls are streaming runs (text/event-stream). The response +// body MUST stay a ReadableStream — buffering it to a string would +// break the stream. We do not touch res.body before returning. function getCorsHeaders() { return { "Access-Control-Allow-Origin": "*", @@ -10,57 +25,63 @@ function getCorsHeaders() { }; } -async function handleRequest(req: NextRequest, method: string) { - try { - const path = req.nextUrl.pathname.replace(/^\/?api\//, ""); - const url = new URL(req.url); - const searchParams = new URLSearchParams(url.search); - searchParams.delete("_path"); - searchParams.delete("nxtP_path"); - const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ""; +async function proxyRequest(req: NextRequest | Request): Promise { + // Next.js always passes a NextRequest here, but withAuth's generic + // typing widens it to Request — narrow at the call site so we keep + // nextUrl access in this function. + const nextReq = req as NextRequest; + const path = nextReq.nextUrl.pathname.replace(/^\/?api\//, ""); + const url = new URL(nextReq.url); + const searchParams = new URLSearchParams(url.search); + searchParams.delete("_path"); + searchParams.delete("nxtP_path"); + const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ""; - const options: RequestInit = { - method, - headers: { - "x-api-key": process.env.LANGCHAIN_API_KEY || "", - }, - signal: req.signal, - }; + const upstreamHeaders: Record = { + "x-api-key": process.env.LANGCHAIN_API_KEY || "", + }; + // Forward the user's session cookie / Authorization so LangGraph can + // identify the calling thread. We deliberately do NOT forward every + // header — only the two LangGraph consults. + const cookie = nextReq.headers.get("cookie"); + if (cookie) upstreamHeaders.cookie = cookie; + const authorization = nextReq.headers.get("authorization"); + if (authorization) upstreamHeaders.authorization = authorization; - if (["POST", "PUT", "PATCH"].includes(method)) { - options.body = await req.text(); - } + const options: RequestInit = { + method: nextReq.method, + headers: upstreamHeaders, + signal: nextReq.signal, + }; - const res = await fetch(`${process.env.LANGGRAPH_API_URL}/${path}${queryString}`, options); + if (["POST", "PUT", "PATCH"].includes(nextReq.method)) { + options.body = await nextReq.text(); + } - const headers = new Headers(res.headers); - headers.delete("content-encoding"); - headers.delete("content-length"); - headers.delete("transfer-encoding"); - const corsHeaders = getCorsHeaders(); - for (const [key, value] of Object.entries(corsHeaders)) { - headers.set(key, value); - } + const res = await fetch(`${process.env.LANGGRAPH_API_URL}/${path}${queryString}`, options); - return new NextResponse(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); - } catch (e: unknown) { - if (e instanceof Error) { - const typedError = e as Error & { status?: number }; - return NextResponse.json({ error: typedError.message }, { status: typedError.status ?? 500 }); - } - return NextResponse.json({ error: "Unknown error" }, { status: 500 }); + const headers = new Headers(res.headers); + headers.delete("content-encoding"); + headers.delete("content-length"); + headers.delete("transfer-encoding"); + for (const [key, value] of Object.entries(getCorsHeaders())) { + headers.set(key, value); } + + return new NextResponse(res.body, { + status: res.status, + statusText: res.statusText, + headers, + }); } -export const GET = (req: NextRequest) => handleRequest(req, "GET"); -export const POST = (req: NextRequest) => handleRequest(req, "POST"); -export const PUT = (req: NextRequest) => handleRequest(req, "PUT"); -export const PATCH = (req: NextRequest) => handleRequest(req, "PATCH"); -export const DELETE = (req: NextRequest) => handleRequest(req, "DELETE"); +const authedProxy = withAuth<{ path: string[] }>(async (req) => proxyRequest(req)); + +export const GET = authedProxy; +export const POST = authedProxy; +export const PUT = authedProxy; +export const PATCH = authedProxy; +export const DELETE = authedProxy; export const OPTIONS = () => new NextResponse(null, { status: 204, diff --git a/app/api/alchemy/[...path]/route.ts b/app/api/alchemy/[...path]/route.ts index 478faea..8ca8f5e 100644 --- a/app/api/alchemy/[...path]/route.ts +++ b/app/api/alchemy/[...path]/route.ts @@ -1,8 +1,12 @@ import { NextResponse } from "next/server"; -export const runtime = "edge"; +// Runtime: nodejs (not edge). Alchemy proxy itself only forwards HTTP +// and could run on edge, but withAuth reads the session row from +// Postgres through drizzle/postgres-js, which needs the `net` module +// from Node — edge throws `Failed to get session` if it tries. import { parseNetworkList, resolveAllowlist } from "@/lib/alchemy/networks"; +import { withAuth } from "@/lib/auth/with-auth"; function getCorsHeaders() { return { @@ -27,12 +31,12 @@ function getCorsHeaders() { // list of networks in its body, so the allowlist wouldn't even apply. async function handle( req: Request, - method: "GET" | "POST", - ctx: { params: Promise<{ path: string[] }> }, + ctx: { params: { path: string[] } }, ) { try { - const { path } = await ctx.params; + const { path } = ctx.params; const first = path?.[0]; + const method = req.method === "POST" ? "POST" : "GET"; const apiKey = process.env.ALCHEMY_API_KEY; if (!apiKey) { @@ -85,8 +89,6 @@ async function handle( } } -export const POST = (req: Request, ctx: { params: Promise<{ path: string[] }> }) => - handle(req, "POST", ctx); -export const GET = (req: Request, ctx: { params: Promise<{ path: string[] }> }) => - handle(req, "GET", ctx); +export const POST = withAuth<{ path: string[] }>(handle); +export const GET = withAuth<{ path: string[] }>(handle); export const OPTIONS = () => new NextResponse(null, { status: 204, headers: getCorsHeaders() }); diff --git a/app/api/alchemy/status/route.ts b/app/api/alchemy/status/route.ts index 9676f59..89cf6fe 100644 --- a/app/api/alchemy/status/route.ts +++ b/app/api/alchemy/status/route.ts @@ -1,11 +1,21 @@ import { NextResponse } from "next/server"; -export const runtime = "nodejs"; +import { withAuth } from "@/lib/auth/with-auth"; // ponytail: just reports whether ALCHEMY_API_KEY is set — never the // value. The frontend uses this for the "🔑 configured / ⚠ not set" // status badge on the Alchemy admin page. -export function GET() { +// +// Auth: gated behind a Better Auth session check via withAuth. The +// /status endpoint tells the browser whether a key is set; previously +// any anonymous visitor could enumerate it. We deliberately do NOT +// expose the key value, so the only thing an authenticated user can +// learn is `{ configured: true | false }` — useful for the admin badge, +// useless for an attacker. +// +// Runtime: nodejs (not edge) so withAuth can hit Postgres through +// drizzle/postgres-js to read the session row. +export const GET = withAuth(() => { const key = process.env.ALCHEMY_API_KEY; return NextResponse.json({ configured: Boolean(key && key.length > 0) }); -} +}); diff --git a/tests/api/alchemy/auth.test.ts b/tests/api/alchemy/auth.test.ts new file mode 100644 index 0000000..52f8aed --- /dev/null +++ b/tests/api/alchemy/auth.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: async () => new Headers(), +})); +vi.mock("@/lib/auth/config", () => ({ + auth: { api: { getSession } }, +})); + +function makeRequest(path: string[], body?: string, method = "POST"): Request { + const url = `http://localhost/api/alchemy/${path.join("/")}`; + return new Request(url, { + method, + headers: { "Content-Type": "application/json" }, + body, + }); +} + +let originalKey: string | undefined; + +beforeEach(() => { + originalKey = process.env.ALCHEMY_API_KEY; + process.env.ALCHEMY_API_KEY = "test-key"; + fetchMock.mockReset(); + getSession.mockReset(); +}); + +afterEach(() => { + if (originalKey === undefined) delete process.env.ALCHEMY_API_KEY; + else process.env.ALCHEMY_API_KEY = originalKey; + vi.resetModules(); +}); + +describe("withAuth gate on /api/alchemy routes", () => { + it("returns 401 with code:UNAUTHORIZED when the user has no session", async () => { + getSession.mockResolvedValueOnce(null); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST( + makeRequest(["eth-mainnet"], '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'), + { params: Promise.resolve({ path: ["eth-mainnet"] }) }, + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.code).toBe("UNAUTHORIZED"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("proxies the request when the user has a valid session", async () => { + getSession.mockResolvedValueOnce({ + user: { id: "u1", email: "u1@example.com" }, + session: { id: "s1", userId: "u1" }, + }); + fetchMock.mockResolvedValueOnce(new Response('{"result":"0x1"}', { status: 200 })); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest(["eth-mainnet"], '{"method":"eth_blockNumber"}'), { + params: Promise.resolve({ path: ["eth-mainnet"] }), + }); + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects unauthenticated calls to /api/alchemy/portfolio/* too", async () => { + getSession.mockResolvedValueOnce(null); + const { POST } = await import("@/app/api/alchemy/[...path]/route"); + const res = await POST(makeRequest(["portfolio", "tokens", "by-address"], "{}"), { + params: Promise.resolve({ path: ["portfolio", "tokens", "by-address"] }), + }); + expect(res.status).toBe(401); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects unauthenticated calls to GET /api/alchemy/status", async () => { + getSession.mockResolvedValueOnce(null); + const { GET } = await import("@/app/api/alchemy/status/route"); + const res = await GET(new Request("http://localhost/api/alchemy/status"), { + params: Promise.resolve({} as never), + }); + expect(res.status).toBe(401); + }); +}); diff --git a/tests/api/alchemy/portfolio.test.ts b/tests/api/alchemy/portfolio.test.ts index f663ad3..e3e567d 100644 --- a/tests/api/alchemy/portfolio.test.ts +++ b/tests/api/alchemy/portfolio.test.ts @@ -3,6 +3,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); +const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: async () => new Headers(), +})); +vi.mock("@/lib/auth/config", () => ({ + auth: { api: { getSession } }, +})); + function makeRequest(path: string[], body?: string, method = "POST"): Request { const url = `http://localhost/api/alchemy/${path.join("/")}`; return new Request(url, { @@ -17,6 +25,11 @@ let originalKey: string | undefined; beforeEach(() => { originalKey = process.env.ALCHEMY_API_KEY; fetchMock.mockReset(); + getSession.mockReset(); + getSession.mockResolvedValue({ + user: { id: "u1", email: "u1@example.com" }, + session: { id: "s1", userId: "u1" }, + }); }); afterEach(() => { diff --git a/tests/api/alchemy/proxy.test.ts b/tests/api/alchemy/proxy.test.ts index 2e23c41..dd34779 100644 --- a/tests/api/alchemy/proxy.test.ts +++ b/tests/api/alchemy/proxy.test.ts @@ -3,6 +3,17 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); +// Auth: the route reads session via auth.api.getSession({ headers }). +// Default = logged in so the existing proxy behaviour tests still cover +// the upstream logic; auth.test.ts covers the 401 path. +const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: async () => new Headers(), +})); +vi.mock("@/lib/auth/config", () => ({ + auth: { api: { getSession } }, +})); + function makeRequest(path: string[], body?: string, method = "POST"): Request { const url = `http://localhost/api/alchemy/${path.join("/")}`; return new Request(url, { @@ -26,6 +37,11 @@ beforeEach(() => { originalDisabled = process.env.ALCHEMY_DISABLED_NETWORKS; originalKey = process.env.ALCHEMY_API_KEY; fetchMock.mockReset(); + getSession.mockReset(); + getSession.mockResolvedValue({ + user: { id: "u1", email: "u1@example.com" }, + session: { id: "s1", userId: "u1" }, + }); }); afterEach(() => { @@ -147,7 +163,7 @@ describe("POST /api/alchemy/[...path] — proxy behavior", () => { describe("GET /api/alchemy/[...path] — healthcheck", () => { it("answers OPTIONS preflight with 204 + CORS headers", async () => { const { OPTIONS } = await import("@/app/api/alchemy/[...path]/route"); - const res = await OPTIONS(); + const res = OPTIONS(); expect(res.status).toBe(204); expect(res.headers.get("access-control-allow-origin")).toBe("*"); }); diff --git a/tests/api/alchemy/status.test.ts b/tests/api/alchemy/status.test.ts index 44a3118..22bd731 100644 --- a/tests/api/alchemy/status.test.ts +++ b/tests/api/alchemy/status.test.ts @@ -1,4 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: async () => new Headers(), +})); +vi.mock("@/lib/auth/config", () => ({ + auth: { api: { getSession } }, +})); // Env must be mutated BEFORE the route module is imported, because the // handler reads process.env at call-time (not module-init) — so we set @@ -8,17 +16,25 @@ describe("GET /api/alchemy/status", () => { beforeEach(() => { original = process.env.ALCHEMY_API_KEY; + getSession.mockReset(); + getSession.mockResolvedValue({ + user: { id: "u1", email: "u1@example.com" }, + session: { id: "s1", userId: "u1" }, + }); }); afterEach(() => { if (original === undefined) delete process.env.ALCHEMY_API_KEY; else process.env.ALCHEMY_API_KEY = original; + vi.resetModules(); }); it("returns configured: false when ALCHEMY_API_KEY is unset", async () => { delete process.env.ALCHEMY_API_KEY; const { GET } = await import("@/app/api/alchemy/status/route"); - const res = await GET(); + const res = await GET(new Request("http://localhost/api/alchemy/status"), { + params: Promise.resolve({} as never), + }); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual({ configured: false }); @@ -27,7 +43,9 @@ describe("GET /api/alchemy/status", () => { it("returns configured: false when ALCHEMY_API_KEY is empty", async () => { process.env.ALCHEMY_API_KEY = ""; const { GET } = await import("@/app/api/alchemy/status/route"); - const res = await GET(); + const res = await GET(new Request("http://localhost/api/alchemy/status"), { + params: Promise.resolve({} as never), + }); const body = await res.json(); expect(body).toEqual({ configured: false }); }); @@ -35,7 +53,9 @@ describe("GET /api/alchemy/status", () => { it("returns configured: true when ALCHEMY_API_KEY is set", async () => { process.env.ALCHEMY_API_KEY = "alchemy-test-key-abc123"; const { GET } = await import("@/app/api/alchemy/status/route"); - const res = await GET(); + const res = await GET(new Request("http://localhost/api/alchemy/status"), { + params: Promise.resolve({} as never), + }); const body = await res.json(); expect(body).toEqual({ configured: true }); }); @@ -43,7 +63,9 @@ describe("GET /api/alchemy/status", () => { it("never includes the key value in the response", async () => { process.env.ALCHEMY_API_KEY = "super-secret-key-12345"; const { GET } = await import("@/app/api/alchemy/status/route"); - const res = await GET(); + const res = await GET(new Request("http://localhost/api/alchemy/status"), { + params: Promise.resolve({} as never), + }); const text = await res.text(); expect(text).not.toContain("super-secret-key-12345"); }); diff --git a/tests/api/langgraph-proxy.test.ts b/tests/api/langgraph-proxy.test.ts new file mode 100644 index 0000000..1e8b4b3 --- /dev/null +++ b/tests/api/langgraph-proxy.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: async () => new Headers(), +})); +vi.mock("@/lib/auth/config", () => ({ + auth: { api: { getSession } }, +})); + +let originalLangchain: string | undefined; +let originalLanggraphUrl: string | undefined; + +beforeEach(() => { + originalLangchain = process.env.LANGCHAIN_API_KEY; + originalLanggraphUrl = process.env.LANGGRAPH_API_URL; + process.env.LANGCHAIN_API_KEY = "langchain-test-key"; + process.env.LANGGRAPH_API_URL = "http://localhost:2024"; + fetchMock.mockReset(); + getSession.mockReset(); +}); + +afterEach(() => { + if (originalLangchain === undefined) delete process.env.LANGCHAIN_API_KEY; + else process.env.LANGCHAIN_API_KEY = originalLangchain; + if (originalLanggraphUrl === undefined) delete process.env.LANGGRAPH_API_URL; + else process.env.LANGGRAPH_API_URL = originalLanggraphUrl; + vi.resetModules(); +}); + +function makeRequest(path: string, init: ConstructorParameters[1] = {}): NextRequest { + return new NextRequest(`http://localhost/${path}`, init); +} + +const CTX = { params: Promise.resolve({ path: ["threads", "abc"] }) }; + +describe("withAuth gate on /api/[...path] (LangGraph proxy)", () => { + it("returns 401 with code:UNAUTHORIZED when the user has no session", async () => { + getSession.mockResolvedValueOnce(null); + const { GET } = await import("@/app/api/[..._path]/route"); + const res = await GET(makeRequest("api/threads/abc"), CTX); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.code).toBe("UNAUTHORIZED"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("proxies GET /api/threads/abc to LangGraph when the user has a session", async () => { + getSession.mockResolvedValueOnce({ + user: { id: "u1", email: "u1@example.com" }, + session: { id: "s1", userId: "u1" }, + }); + fetchMock.mockResolvedValueOnce( + new Response('{"thread_id":"abc"}', { status: 200 }), + ); + const { GET } = await import("@/app/api/[..._path]/route"); + const res = await GET(makeRequest("api/threads/abc"), CTX); + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("http://localhost:2024/threads/abc"); + expect((init.headers as Record)["x-api-key"]).toBe("langchain-test-key"); + }); + + it("forwards the user's cookie header to LangGraph", async () => { + getSession.mockResolvedValueOnce({ + user: { id: "u1", email: "u1@example.com" }, + session: { id: "s1", userId: "u1" }, + }); + fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); + const { GET } = await import("@/app/api/[..._path]/route"); + const req = makeRequest("api/threads/abc", { + headers: { cookie: "better-auth.session_token=abc123" }, + }); + await GET(req, CTX); + const [, init] = fetchMock.mock.calls[0]; + expect((init.headers as Record)["cookie"]).toBe( + "better-auth.session_token=abc123", + ); + }); + + it("streams the upstream SSE body through to the client without buffering", async () => { + getSession.mockResolvedValueOnce({ + user: { id: "u1", email: "u1@example.com" }, + session: { id: "s1", userId: "u1" }, + }); + // Simulate an SSE stream: a ReadableStream of text/event-stream chunks. + const sseBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("event: message\ndata: hi\n\n")); + controller.enqueue(new TextEncoder().encode("event: end\ndata: bye\n\n")); + controller.close(); + }, + }); + fetchMock.mockResolvedValueOnce( + new Response(sseBody, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + const { POST } = await import("@/app/api/[..._path]/route"); + const res = await POST(makeRequest("api/threads/abc/runs/stream"), { + ...CTX, + params: Promise.resolve({ path: ["threads", "abc", "runs", "stream"] }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + // Body must be a ReadableStream so the runtime can iterate it chunk-by-chunk, + // not a buffered string. A string body would break streaming entirely. + expect(res.body).toBeInstanceOf(ReadableStream); + }); +}); From 4f55bff050bc743a04cba22ff19a2754efaaad86 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 18:02:13 +0800 Subject: [PATCH 33/35] chore(env): commit .env.test so BETTER_AUTH_SECRET ships with the repo Required for lib/auth/config.ts to load under pnpm test now that routes import auth.api.getSession (added in the prior commit). Without this, every pnpm test run from a fresh clone throws "BETTER_AUTH_SECRET is required" at module load. The file holds only a fake test secret; no production data. .gitignore gains a single exception for .env.test (the blanket .env* rule stays). --- .env.test | 13 +++++++++++++ .gitignore | 1 + 2 files changed, 14 insertions(+) create mode 100644 .env.test diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..e3fcf68 --- /dev/null +++ b/.env.test @@ -0,0 +1,13 @@ +# Postgres — used by vitest under NODE_ENV=test. +# @next/env reads this file when NODE_ENV=test (along with .env.test.local). +DATABASE_URL_TEST=postgresql://FireTable@localhost:5432/langgraph_app_test + +# Jina pool — fake keys for unit tests. The pool rotation / failover logic +# is exercised against `vi.stubGlobal("fetch", ...)`, so no real network. +JINA_API_KEYS=jina_test_key_1,jina_test_key_2 + +# Better Auth — fake secret for unit tests. Required for the auth module +# to load; withAuth HOC tests mock getSession() directly so the secret +# value is never validated against real signatures. +BETTER_AUTH_SECRET=test_secret_aabbccddeeff00112233445566778899 +BETTER_AUTH_URL=http://localhost:3000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index d9ecf43..2ad52b5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.test !.env.example # vercel From 478d78d2141abc426acfa432c959d5d1f7b39538 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 18:02:18 +0800 Subject: [PATCH 34/35] docs: document the withAuth rule + catch-all proxy runtime change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rule #9 to CLAUDE.md covering: every app/api/**/route.ts must be wrapped in withAuth (exceptions: auth catch-all + OPTIONS preflight), the withAuth HOC needs Node so runtime must be nodejs / unset, and the test mock pattern (vi.hoisted getSession + next/headers + auth/config mocks). Update the "Frontend runtime" + "Things to know" sections to drop the obsolete 'proxy hardcodes runtime = edge' claim — it's nodejs now and that's a deliberate trade-off for the auth gate. --- CLAUDE.md | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 76893f5..58b1850 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ app/ Next.js App Router page.tsx Renders in a full-viewport
    assistant.tsx Builds useLangGraphRuntime; chooses /api vs direct URL web3-providers.tsx wagmi/RainbowKit QueryClient + WagmiProvider wrappers - api/[..._path]/route.ts Edge catch-all proxy to LANGGRAPH_API_URL + api/[..._path]/route.ts Node catch-all proxy to LANGGRAPH_API_URL (withAuth-gated) api/alchemy/[...path]/route.ts Server-only JSON-RPC proxy to Alchemy (with key + per-network disabled list) api/alchemy/status/route.ts Returns Alchemy key health + disabled-network list globals.css Tailwind v4 entry @@ -150,7 +150,7 @@ Consequences worth knowing: `app/assistant.tsx` is a client component. It instantiates the runtime with `useLangGraphRuntime({ stream, create, load })` from `@assistant-ui/react-langgraph` (which wraps `@langchain/langgraph-sdk`'s `Client`). `stream` is built from `unstable_createLangGraphStream`; `apiUrl` is `NEXT_PUBLIC_LANGGRAPH_API_URL` if set, otherwise the same-origin `/api` URL. -`app/api/[..._path]/route.ts` is an edge-runtime catch-all that proxies every method (`GET/POST/PUT/PATCH/DELETE/OPTIONS`) to `${LANGGRAPH_API_URL}/${path}` with `x-api-key: LANGCHAIN_API_KEY`, strips hop-by-hop / content-encoding headers, and adds permissive CORS. The body of mutating requests is forwarded as text. +`app/api/[..._path]/route.ts` is a node-runtime catch-all (see rule #9 — edge throws on `auth.api.getSession`) that proxies every method (`GET/POST/PUT/PATCH/DELETE/OPTIONS`) to `${LANGGRAPH_API_URL}/${path}` with `x-api-key: LANGCHAIN_API_KEY`, strips hop-by-hop / content-encoding headers, and adds permissive CORS. The body of mutating requests is forwarded as text. The handler is wrapped in `withAuth` (cookie + Authorization are forwarded upstream so LangGraph can identify the calling thread). `components/assistant-ui/thread.tsx` mounts `InterruptUI` (uses `useLangGraphInterruptState` + `useLangGraphSendCommand`) inside the last assistant message. The interrupt-driven render only fires when `NEXT_PUBLIC_USE_SUBGRAPH=true`; in default (inlined) mode, the ask_location card renders in the tool-call slot instead. See `docs/INTERRUPT.md` for the full two-mode flow. @@ -268,12 +268,53 @@ Buttons inside `components/tool-ui/**` (and any new card added under that direct Icon-only controls (`size="icon"` or equivalent) are fine when there is no label to attach the icon to (e.g. the search-submit magnifier in `ask-location-card`). Decorative icons elsewhere in the card — header avatars, status row glyphs, inline spinners — are not affected by this rule. +### 9. Every `app/api/**/route.ts` handler is wrapped in `withAuth` + +**Rule.** Every HTTP route under `app/api/**/route.ts` must wrap its handler in `withAuth` from `lib/auth/with-auth.ts`. No anonymous traffic. The only exceptions are the Better Auth catch-all `app/api/auth/[...all]/route.ts` (it's the login endpoint itself) and the `OPTIONS` preflight in any proxy route (preflight must succeed for the browser to even attempt the authed request). + +Why: prior builds left the LangGraph + Alchemy catch-all proxies unauthenticated. Any website's JS could create / list / delete threads or burn the Alchemy compute-unit quota. CORS `*` made it a public RPC. + +#### How to wrap + +```ts +import { withAuth } from "@/lib/auth/with-auth"; + +// Static params, or no params: +export const GET = withAuth((_req, { user }) => NextResponse.json({ ... })); + +// Dynamic params (Next.js auto-unwraps the Promise): +export const GET = withAuth<{ id: string }>(async (req, { user, params }) => { ... }); +``` + +#### Runtime: default `nodejs` (don't reach for `edge`) + +`withAuth` reads the session row from Postgres through `drizzle/postgres-js`, which needs the Node `net` module. On edge it throws `Failed to get session` and the user sees 500. Leave the route's `runtime` unset (Next.js defaults to `nodejs`) or set it explicitly to `nodejs`. + +The Alchemy JSON-RPC and LangGraph catch-all proxies originally opted into `runtime = "edge"` for low cold-start. They both lost that on this audit — they are now `nodejs`. The trade-off is real: every request to those routes now spins up a Node handler instead of a V8 isolate. Don't try to claw edge back by calling `auth.api.getSession` directly or by reading session from a header — those paths skip the HOC, drift from the rest of the repo, and break the "every route goes through withAuth" guarantee. + +#### Test mock pattern + +Every test file that calls a route handler must mock `next/headers` and `@/lib/auth/config` and default `getSession` to a logged-in user in `beforeEach`; the 401 path is covered by an explicit `getSession.mockResolvedValueOnce(null)` in the dedicated auth tests: + +```ts +const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() })); +vi.mock("next/headers", () => ({ headers: async () => new Headers() })); +vi.mock("@/lib/auth/config", () => ({ auth: { api: { getSession } } })); + +beforeEach(() => { + getSession.mockReset(); + getSession.mockResolvedValue({ user: { id: "u1", email: "u1@example.com" }, session: { id: "s1", userId: "u1" } }); +}); +``` + +If a new route handler reads `process.env` at call time, also set / restore the env in `beforeEach` / `afterEach` — see `tests/api/alchemy/status.test.ts` for the pattern. + Rationale: the tool-ui cards are short-lived surfaces with one or two clear actions. Icons + text compete for attention and bloat the layout without adding signal. Text-only buttons stay scannable and match the rest of the assistant-ui primitives. ## Things to know before editing - The graph id `agent` is referenced in three places: `langgraph.json` (`graphs.agent`), `NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID` in `.env.example`, and the `unstable_createLangGraphStream({ assistantId })` call. Keep them aligned. -- The proxy hardcodes `runtime = "edge"`; any Node-only API in the route would break the build. +- The proxy used to hardcode `runtime = "edge"`; that was changed to `nodejs` so `withAuth` can hit Postgres. If you need edge back, route the session through a header (set in middleware) — see rule #9 for why. - `modelKwargs.reasoning_split` is provider-specific. If you switch back to stock OpenAI, remove it (or guard it) — OpenAI ignores unknown kwargs but it'll be a lie in the source. - `components.json` declares a `@assistant-ui` registry at `https://r.assistant-ui.com/{name}.json` for `shadcn`-style component adds. From ab7e7776bb13b047b2b2b396a6da2c1f35843181 Mon Sep 17 00:00:00 2001 From: Yongzhuo Liang Date: Sun, 28 Jun 2026 18:02:21 +0800 Subject: [PATCH 35/35] =?UTF-8?q?style(crypto):=20accept-swap=20drain=20ba?= =?UTF-8?q?r=20uses=20warm=E2=86=92bright=20gradient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the hardcoded bg-primary-foreground/50 fill for a linear-gradient(var(--glow-warm) → var(--glow-bright)) so the 30s countdown bar matches the project-wide tool-call glow. Bump --glow-warm / --glow-bright to 1.0 alpha and --glow-length to 75deg so the new bar reads clearly against the Accept Swap button background. --- app/globals.css | 6 +++--- components/tool-ui/crypto/place-crypto-order-card.tsx | 11 ++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/globals.css b/app/globals.css index 7a3686f..318aa72 100644 --- a/app/globals.css +++ b/app/globals.css @@ -77,9 +77,9 @@ --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.015 286.067); - --glow-warm: oklch(0.78 0.2 25 / 0.7); - --glow-bright: oklch(0.96 0.08 70 / 0.8); - --glow-length: 90deg; + --glow-warm: oklch(0.78 0.2 25 / 1); + --glow-bright: oklch(0.96 0.08 70 / 1); + --glow-length: 75deg; } .dark { diff --git a/components/tool-ui/crypto/place-crypto-order-card.tsx b/components/tool-ui/crypto/place-crypto-order-card.tsx index 5080bb1..ee91b0c 100644 --- a/components/tool-ui/crypto/place-crypto-order-card.tsx +++ b/components/tool-ui/crypto/place-crypto-order-card.tsx @@ -502,12 +502,17 @@ function PreviewWorkspace({ edge; transform-origin: right keeps the right edge fixed while scaleX shrinks the bar from the left. Uses transform (not width) so the per-second tick only triggers a - compositor repaint — no layout / no reflow. */} + compositor repaint — no layout / no reflow. Color matches + the project-wide tool-call glow (warm → bright). */} {quote && !submitting && ( )}