/** * 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"; import { renderLanding } from "./landing"; 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); const data = await res.json(); c.header("Cache-Control", "public, max-age=30"); return c.json(data); }); 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); const data = await res.json(); c.header("Cache-Control", "public, max-age=300"); return c.json(data); }); // 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" }, "11155111": { name: "Sepolia", prefix: "sepolia" }, "84532": { name: "Base Sepolia", prefix: "base-sepolia" }, }; function getSafePrefix(chainId: string): string | null { return CHAIN_MAP[chainId]?.prefix || null; } // ── Safe Proposal / Confirm / Execute (EncryptID-authenticated) ── // Helper: extract and verify JWT from request async function verifyWalletAuth(c: any): Promise<{ sub: string; did?: string; username?: string; eid?: any } | null> { const authorization = c.req.header("Authorization"); if (!authorization?.startsWith("Bearer ")) return null; try { // Import verify dynamically to avoid adding hono/jwt as a module-level dep const { verify } = await import("hono/jwt"); const secret = process.env.JWT_SECRET; if (!secret) return null; const payload = await verify(authorization.slice(7), secret, "HS256"); return payload as any; } catch { return null; } } // POST /api/safe/:chainId/:address/propose — Create a Safe transaction proposal routes.post("/api/safe/:chainId/:address/propose", async (c) => { const claims = await verifyWalletAuth(c); if (!claims) return c.json({ error: "Authentication required" }, 401); // Require ELEVATED auth level (3+) if (!claims.eid || claims.eid.authLevel < 3) { return c.json({ error: "Elevated authentication required for proposals" }, 403); } if (!claims.eid.capabilities?.wallet) { return c.json({ error: "Wallet capability required" }, 403); } 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 body = await c.req.json(); const { to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce, signature, sender } = body; if (!to || !signature || !sender) { return c.json({ error: "Missing required fields: to, signature, sender" }, 400); } // Submit proposal to Safe Transaction Service const res = await fetch( `https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/multisig-transactions/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to, value: value || "0", data: data || "0x", operation: operation || 0, safeTxGas: safeTxGas || "0", baseGas: baseGas || "0", gasPrice: gasPrice || "0", gasToken: gasToken || "0x0000000000000000000000000000000000000000", refundReceiver: refundReceiver || "0x0000000000000000000000000000000000000000", nonce, signature, sender, }), }, ); if (!res.ok) { const err = await res.text(); return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any); } return c.json(await res.json(), 201); }); // POST /api/safe/:chainId/:address/confirm — Confirm a pending transaction routes.post("/api/safe/:chainId/:address/confirm", async (c) => { const claims = await verifyWalletAuth(c); if (!claims) return c.json({ error: "Authentication required" }, 401); if (!claims.eid || claims.eid.authLevel < 3) { return c.json({ error: "Elevated authentication required" }, 403); } if (!claims.eid.capabilities?.wallet) { return c.json({ error: "Wallet capability required" }, 403); } const chainId = c.req.param("chainId"); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); const { safeTxHash, signature } = await c.req.json(); if (!safeTxHash || !signature) { return c.json({ error: "Missing required fields: safeTxHash, signature" }, 400); } const res = await fetch( `https://safe-transaction-${chainPrefix}.safe.global/api/v1/multisig-transactions/${safeTxHash}/confirmations/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ signature }), }, ); if (!res.ok) { const err = await res.text(); return c.json({ error: "Confirmation failed", details: err }, res.status as any); } return c.json(await res.json()); }); // POST /api/safe/:chainId/:address/execute — Execute a ready transaction (CRITICAL auth) routes.post("/api/safe/:chainId/:address/execute", async (c) => { const claims = await verifyWalletAuth(c); if (!claims) return c.json({ error: "Authentication required" }, 401); // Require CRITICAL auth level (4) for execution if (!claims.eid || claims.eid.authLevel < 4) { return c.json({ error: "Critical authentication required for execution" }, 403); } if (!claims.eid.capabilities?.wallet) { return c.json({ error: "Wallet capability required" }, 403); } // Check auth freshness (must be within 60 seconds) const now = Math.floor(Date.now() / 1000); if (claims.eid.authTime && (now - claims.eid.authTime) > 60) { return c.json({ error: "Authentication too old for execution — re-authenticate" }, 403); } 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); // Get pending transactions and find the one to execute const { safeTxHash } = await c.req.json(); if (!safeTxHash) { return c.json({ error: "Missing required field: safeTxHash" }, 400); } // Fetch the transaction details from Safe Transaction Service const txRes = await fetch( `https://safe-transaction-${chainPrefix}.safe.global/api/v1/multisig-transactions/${safeTxHash}/`, ); if (!txRes.ok) { return c.json({ error: "Transaction not found" }, 404); } const txData = await txRes.json() as { confirmations?: any[]; confirmationsRequired?: number; isExecuted?: boolean }; if (txData.isExecuted) { return c.json({ error: "Transaction already executed" }, 400); } const confirmationsNeeded = txData.confirmationsRequired || 1; const confirmationsReceived = txData.confirmations?.length || 0; if (confirmationsReceived < confirmationsNeeded) { return c.json({ error: "Not enough confirmations", required: confirmationsNeeded, received: confirmationsReceived, }, 400); } // Transaction is ready — return execution data for client-side submission // (Actual on-chain execution must happen client-side with the user's signer) return c.json({ ready: true, safeTxHash, confirmations: confirmationsReceived, required: confirmationsNeeded, transaction: txData, }); }); // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${spaceSlug} — Wallet | rSpace`, moduleId: "rwallet", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); export const walletModule: RSpaceModule = { id: "rwallet", name: "rWallet", icon: "💰", description: "Multichain Safe wallet visualization and treasury management", routes, standaloneDomain: "rwallet.online", landingPage: renderLanding, feeds: [ { id: "balances", name: "Token Balances", kind: "economic", description: "Multichain Safe token balances with USD valuations", }, { id: "transfers", name: "Transfer History", kind: "economic", description: "Incoming and outgoing token transfers across chains", }, ], acceptsFeeds: ["economic", "governance"], };