/** * 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(`${safeApiBase(chainPrefix)}/safes/${address}/balances/?trusted=true&exclude_spam=true`); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); const raw = await res.json() as any[]; const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 }; // Normalize: fill in native token info and ensure fiatBalance fields exist const data = raw.map((item: any) => ({ tokenAddress: item.tokenAddress, token: item.token || nativeToken, balance: item.balance || "0", fiatBalance: item.fiatBalance || "0", fiatConversion: item.fiatConversion || "0", })); 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(`${safeApiBase(chainPrefix)}/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(`${safeApiBase(chainPrefix)}/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 includeTestnets = c.req.query("testnets") === "true"; const chains = getChains(includeTestnets); const results: Array<{ chainId: string; name: string; prefix: string }> = []; await Promise.allSettled( chains.map(async ([chainId, info]) => { try { const res = await fetch(`${safeApiBase(info.prefix)}/safes/${address}/`, { signal: AbortSignal.timeout(5000), }); if (res.ok) results.push({ chainId, name: info.name, prefix: info.prefix }); } catch {} }) ); // Always include testnet chains when toggled on (skip detection) if (includeTestnets) { for (const [chainId, info] of Object.entries(CHAIN_MAP)) { if (TESTNET_CHAIN_IDS.has(chainId) && !results.some((r) => r.chainId === chainId)) { results.push({ chainId, name: info.name, prefix: info.prefix }); } } } return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) }); }); // ── Chain mapping (prefix = Safe TX Service shortcode) ── const CHAIN_MAP: Record = { "1": { name: "Ethereum", prefix: "eth" }, "10": { name: "Optimism", prefix: "oeth" }, "100": { name: "Gnosis", prefix: "gno" }, "137": { name: "Polygon", prefix: "pol" }, "8453": { name: "Base", prefix: "base" }, "42161": { name: "Arbitrum", prefix: "arb1" }, "42220": { name: "Celo", prefix: "celo" }, "43114": { name: "Avalanche", prefix: "avax" }, "56": { name: "BSC", prefix: "bnb" }, "324": { name: "zkSync", prefix: "zksync" }, "11155111": { name: "Sepolia", prefix: "sep" }, "84532": { name: "Base Sepolia", prefix: "basesep" }, }; const TESTNET_CHAIN_IDS = new Set(["11155111", "84532"]); function getChains(includeTestnets: boolean): [string, { name: string; prefix: string }][] { return Object.entries(CHAIN_MAP).filter(([id]) => includeTestnets || !TESTNET_CHAIN_IDS.has(id)); } function getSafePrefix(chainId: string): string | null { return CHAIN_MAP[chainId]?.prefix || null; } function safeApiBase(prefix: string): string { return `https://api.safe.global/tx-service/${prefix}/api/v1`; } // ── Public RPC endpoints for EOA balance lookups ── const RPC_URLS: Record = { "1": "https://eth.llamarpc.com", "10": "https://mainnet.optimism.io", "100": "https://rpc.gnosischain.com", "137": "https://polygon-rpc.com", "8453": "https://mainnet.base.org", "42161": "https://arb1.arbitrum.io/rpc", "42220": "https://forno.celo.org", "43114": "https://api.avax.network/ext/bc/C/rpc", "56": "https://bsc-dataseed.binance.org", "324": "https://mainnet.era.zksync.io", "11155111": "https://rpc.sepolia.org", "84532": "https://sepolia.base.org", }; const NATIVE_TOKENS: Record = { "1": { name: "Ether", symbol: "ETH", decimals: 18 }, "10": { name: "Ether", symbol: "ETH", decimals: 18 }, "100": { name: "xDAI", symbol: "xDAI", decimals: 18 }, "137": { name: "MATIC", symbol: "MATIC", decimals: 18 }, "8453": { name: "Ether", symbol: "ETH", decimals: 18 }, "42161": { name: "Ether", symbol: "ETH", decimals: 18 }, "42220": { name: "CELO", symbol: "CELO", decimals: 18 }, "43114": { name: "AVAX", symbol: "AVAX", decimals: 18 }, "56": { name: "BNB", symbol: "BNB", decimals: 18 }, "324": { name: "Ether", symbol: "ETH", decimals: 18 }, "11155111": { name: "Sepolia ETH", symbol: "ETH", decimals: 18 }, "84532": { name: "Base Sepolia ETH", symbol: "ETH", decimals: 18 }, }; async function rpcCall(rpcUrl: string, method: string, params: any[]): Promise { const res = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), signal: AbortSignal.timeout(5000), }); const data = await res.json(); return data.result; } // ── 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( `${safeApiBase(chainPrefix)}/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( `${safeApiBase(chainPrefix)}/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( `${safeApiBase(chainPrefix)}/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, }); }); // ── EOA (any wallet) balance endpoints ── // Detect which chains have a non-zero native balance for any address routes.get("/api/eoa/detect/:address", async (c) => { const address = c.req.param("address"); const includeTestnets = c.req.query("testnets") === "true"; const results: Array<{ chainId: string; name: string; prefix: string; balance: string }> = []; await Promise.allSettled( getChains(includeTestnets).map(async ([chainId, info]) => { const rpcUrl = RPC_URLS[chainId]; if (!rpcUrl) return; try { const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]); if (balHex && balHex !== "0x0" && balHex !== "0x") { results.push({ chainId, name: info.name, prefix: info.prefix, balance: balHex }); } } catch {} }) ); // Always include testnet chains when toggled on (skip detection) if (includeTestnets) { for (const [chainId, info] of Object.entries(CHAIN_MAP)) { if (TESTNET_CHAIN_IDS.has(chainId) && !results.some((r) => r.chainId === chainId)) { results.push({ chainId, name: info.name, prefix: info.prefix, balance: "0x0" }); } } } return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) }); }); // Get native token balance for an EOA on a specific chain routes.get("/api/eoa/:chainId/:address/balances", async (c) => { const chainId = c.req.param("chainId"); const address = c.req.param("address"); const rpcUrl = RPC_URLS[chainId]; if (!rpcUrl) return c.json({ error: "Unsupported chain" }, 400); const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 }; const balances: BalanceItem[] = []; try { const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]); const balWei = BigInt(balHex || "0x0"); if (balWei > 0n) { balances.push({ tokenAddress: null, token: nativeToken, balance: balWei.toString(), fiatBalance: "0", fiatConversion: "0", }); } } catch {} c.header("Cache-Control", "public, max-age=30"); return c.json(balances); }); interface BalanceItem { tokenAddress: string | null; token: { name: string; symbol: string; decimals: number }; balance: string; fiatBalance: string; fiatConversion: string; } // ── Safe owner addition proposal (add EncryptID EOA as signer) ── routes.post("/api/safe/:chainId/:address/add-owner-proposal", 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 address = c.req.param("address"); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); const { newOwner, threshold, signature, sender } = await c.req.json(); if (!newOwner || !signature || !sender) { return c.json({ error: "Missing required fields: newOwner, signature, sender" }, 400); } if (!/^0x[0-9a-fA-F]{40}$/.test(newOwner)) { return c.json({ error: "Invalid newOwner address" }, 400); } // Encode addOwnerWithThreshold(address owner, uint256 _threshold) // Function selector: 0x0d582f13 const paddedOwner = newOwner.slice(2).toLowerCase().padStart(64, "0"); const paddedThreshold = (threshold || 1).toString(16).padStart(64, "0"); const data = `0x0d582f13${paddedOwner}${paddedThreshold}`; // Get Safe nonce const infoRes = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/`); if (!infoRes.ok) return c.json({ error: "Safe not found" }, 404); const safeInfo = await infoRes.json() as { nonce?: number }; // Submit proposal to Safe Transaction Service const res = await fetch( `${safeApiBase(chainPrefix)}/safes/${address}/multisig-transactions/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to: address, value: "0", data, operation: 0, safeTxGas: "0", baseGas: "0", gasPrice: "0", gasToken: "0x0000000000000000000000000000000000000000", refundReceiver: "0x0000000000000000000000000000000000000000", nonce: safeInfo.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); }); // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; 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", scoping: { defaultScope: 'global', userConfigurable: false }, 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"], };