/** * Wallet module — multichain Safe wallet visualization. * * Client-side only (no DB). Queries Safe Global API for balances, * transfers, and multisig data across multiple chains. */ import { Hono } from "hono"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; const routes = new Hono(); // ── Proxy Safe Global API (avoid CORS issues from browser) ── routes.get("/api/safe/:chainId/:address/balances", async (c) => { const chainId = c.req.param("chainId"); const address = c.req.param("address"); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/balances/usd/?trusted=true&exclude_spam=true`); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); return c.json(await res.json()); }); routes.get("/api/safe/:chainId/:address/transfers", async (c) => { const chainId = c.req.param("chainId"); const address = c.req.param("address"); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); const limit = c.req.query("limit") || "100"; const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/all-transactions/?limit=${limit}&executed=true`); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); return c.json(await res.json()); }); routes.get("/api/safe/:chainId/:address/info", async (c) => { const chainId = c.req.param("chainId"); const address = c.req.param("address"); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/`); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); return c.json(await res.json()); }); // Detect which chains have a Safe for this address routes.get("/api/safe/detect/:address", async (c) => { const address = c.req.param("address"); const chains = Object.entries(CHAIN_MAP); const results: Array<{ chainId: string; name: string; prefix: string }> = []; await Promise.allSettled( chains.map(async ([chainId, info]) => { try { const res = await fetch(`https://safe-transaction-${info.prefix}.safe.global/api/v1/safes/${address}/`, { signal: AbortSignal.timeout(5000), }); if (res.ok) results.push({ chainId, name: info.name, prefix: info.prefix }); } catch {} }) ); return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) }); }); // ── Chain mapping ── const CHAIN_MAP: Record = { "1": { name: "Ethereum", prefix: "mainnet" }, "10": { name: "Optimism", prefix: "optimism" }, "100": { name: "Gnosis", prefix: "gnosis-chain" }, "137": { name: "Polygon", prefix: "polygon" }, "8453": { name: "Base", prefix: "base" }, "42161": { name: "Arbitrum", prefix: "arbitrum" }, "42220": { name: "Celo", prefix: "celo" }, "43114": { name: "Avalanche", prefix: "avalanche" }, "56": { name: "BSC", prefix: "bsc" }, "324": { name: "zkSync", prefix: "zksync" }, }; function getSafePrefix(chainId: string): string | null { return CHAIN_MAP[chainId]?.prefix || null; } // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${spaceSlug} — Wallet | rSpace`, moduleId: "wallet", spaceSlug, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const walletModule: RSpaceModule = { id: "wallet", name: "rWallet", icon: "\uD83D\uDCB0", description: "Multichain Safe wallet visualization and treasury management", routes, };