/** * 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(); // ── Address validation (prevents SSRF via path traversal) ── const VALID_ETH_ADDR = /^0x[0-9a-fA-F]{40}$/; function validateAddress(c: any): string | null { const address = c.req.param("address"); if (!VALID_ETH_ADDR.test(address)) { return null; } return address; } // ── 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 = validateAddress(c); if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); 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 = validateAddress(c); if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const chainPrefix = getSafePrefix(chainId); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); const rawLimit = parseInt(c.req.query("limit") || "100", 10); const limit = isNaN(rawLimit) || rawLimit < 1 ? 100 : Math.min(rawLimit, 200); 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 = validateAddress(c); if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); 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 = validateAddress(c); if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); 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 DEFAULT_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", }; // Chain ID → env var name fragment + Alchemy subdomain (for auto-construct) const CHAIN_ENV_NAMES: Record = { "1": { envName: "ETHEREUM", alchemySlug: "eth-mainnet" }, "10": { envName: "OPTIMISM", alchemySlug: "opt-mainnet" }, "137": { envName: "POLYGON", alchemySlug: "polygon-mainnet" }, "8453": { envName: "BASE", alchemySlug: "base-mainnet" }, "42161": { envName: "ARBITRUM", alchemySlug: "arb-mainnet" }, "100": { envName: "GNOSIS" }, "42220": { envName: "CELO" }, "43114": { envName: "AVALANCHE" }, "56": { envName: "BSC" }, "324": { envName: "ZKSYNC" }, "11155111": { envName: "SEPOLIA" }, "84532": { envName: "BASE_SEPOLIA" }, }; /** * Resolve the RPC URL for a given chain, with env var overrides and Alchemy auto-construct. * Priority: RPC_{CHAIN} env > Alchemy auto-construct > default public RPC. */ export function getRpcUrl(chainId: string): string | undefined { // 1. Explicit env var override (e.g. RPC_BASE, RPC_ETHEREUM) const chainEnv = CHAIN_ENV_NAMES[chainId]; if (chainEnv) { const envOverride = process.env[`RPC_${chainEnv.envName}`]; if (envOverride) return envOverride; } // 2. Alchemy auto-construct if API key is set const alchemyKey = process.env.ALCHEMY_API_KEY; if (alchemyKey && chainEnv?.alchemySlug) { return `https://${chainEnv.alchemySlug}.g.alchemy.com/v2/${alchemyKey}`; } // 3. Default public RPC return DEFAULT_RPC_URLS[chainId]; } 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 = validateAddress(c); if (!address) return c.json({ error: "Invalid Safe address" }, 400); 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); } if (!VALID_ETH_ADDR.test(sender)) { return c.json({ error: "Invalid sender address" }, 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) { console.warn('rwallet: Safe propose error', res.status, await res.text()); return c.json({ error: "Safe Transaction Service rejected the request" }, 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) { console.warn('rwallet: Safe confirm error', res.status, await res.text()); return c.json({ error: "Confirmation failed" }, 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 = validateAddress(c); if (!address) return c.json({ error: "Invalid Safe address" }, 400); 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, }); }); // ── Popular ERC-20 tokens to scan for EOA wallets ── const POPULAR_TOKENS: Record> = { "1": [ { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", name: "USD Coin", symbol: "USDC", decimals: 6 }, { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", name: "Tether USD", symbol: "USDT", decimals: 6 }, { address: "0x6B175474E89094C44Da98b954EedeAC495271d0F", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 }, { address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", name: "Wrapped Ether", symbol: "WETH", decimals: 18 }, ], "8453": [ { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", name: "USD Coin", symbol: "USDC", decimals: 6 }, { address: "0x4200000000000000000000000000000000000006", name: "Wrapped Ether", symbol: "WETH", decimals: 18 }, { address: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 }, ], "10": [ { address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", name: "USD Coin", symbol: "USDC", decimals: 6 }, { address: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", name: "Tether USD", symbol: "USDT", decimals: 6 }, { address: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 }, { address: "0x4200000000000000000000000000000000000006", name: "Wrapped Ether", symbol: "WETH", decimals: 18 }, ], "137": [ { address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", name: "USD Coin", symbol: "USDC", decimals: 6 }, { address: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", name: "Tether USD", symbol: "USDT", decimals: 6 }, { address: "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 }, { address: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", name: "Wrapped Ether", symbol: "WETH", decimals: 18 }, ], "42161": [ { address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", name: "USD Coin", symbol: "USDC", decimals: 6 }, { address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", name: "Tether USD", symbol: "USDT", decimals: 6 }, { address: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 }, { address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", name: "Wrapped Ether", symbol: "WETH", decimals: 18 }, ], "100": [ { address: "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", name: "USD Coin", symbol: "USDC", decimals: 6 }, { address: "0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1", name: "Wrapped Ether", symbol: "WETH", decimals: 18 }, ], "84532": [ { address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", name: "USD Coin", symbol: "USDC", decimals: 6 }, ], }; // ERC-20 balanceOf(address) — selector 0x70a08231 async function getErc20Balance(rpcUrl: string, tokenAddress: string, walletAddress: string): Promise { const paddedAddr = walletAddress.slice(2).toLowerCase().padStart(64, "0"); const data = `0x70a08231${paddedAddr}`; const result = await rpcCall(rpcUrl, "eth_call", [{ to: tokenAddress, data }, "latest"]); if (!result || result === "0x" || result === "0x0") return "0"; return BigInt(result).toString(); } // ── 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 = validateAddress(c); if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); 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 = getRpcUrl(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 + ERC-20 token balances for an EOA on a specific chain routes.get("/api/eoa/:chainId/:address/balances", async (c) => { const chainId = c.req.param("chainId"); const address = validateAddress(c); if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const rpcUrl = getRpcUrl(chainId); if (!rpcUrl) return c.json({ error: "Unsupported chain" }, 400); const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 }; const balances: BalanceItem[] = []; // Fetch native + ERC-20 balances in parallel const tokens = POPULAR_TOKENS[chainId] || []; const promises: Promise[] = []; // Native balance promises.push((async () => { 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 {} })()); // ERC-20 balances for (const tok of tokens) { promises.push((async () => { try { const bal = await getErc20Balance(rpcUrl, tok.address, address); if (bal !== "0") { balances.push({ tokenAddress: tok.address, token: { name: tok.name, symbol: tok.symbol, decimals: tok.decimals }, balance: bal, fiatBalance: "0", fiatConversion: "0", }); } } catch {} })()); } await Promise.allSettled(promises); c.header("Cache-Control", "public, max-age=30"); return c.json(balances); }); // ── All-chains balance endpoints (fan out to every chain in parallel) ── // Get all balances across all chains for an EOA routes.get("/api/eoa/:address/all-balances", async (c) => { const address = validateAddress(c); if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const includeTestnets = c.req.query("testnets") !== "false"; // default: include const chains = getChains(includeTestnets); const results: Array<{ chainId: string; chainName: string; balances: BalanceItem[] }> = []; await Promise.allSettled( chains.map(async ([chainId, info]) => { const rpcUrl = getRpcUrl(chainId); if (!rpcUrl) return; const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 }; const chainBalances: BalanceItem[] = []; const tokenPromises: Promise[] = []; // Native balance tokenPromises.push((async () => { try { const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]); const balWei = BigInt(balHex || "0x0"); if (balWei > 0n) { chainBalances.push({ tokenAddress: null, token: nativeToken, balance: balWei.toString(), fiatBalance: "0", fiatConversion: "0", }); } } catch {} })()); // ERC-20 balances for (const tok of (POPULAR_TOKENS[chainId] || [])) { tokenPromises.push((async () => { try { const bal = await getErc20Balance(rpcUrl, tok.address, address); if (bal !== "0") { chainBalances.push({ tokenAddress: tok.address, token: { name: tok.name, symbol: tok.symbol, decimals: tok.decimals }, balance: bal, fiatBalance: "0", fiatConversion: "0", }); } } catch {} })()); } await Promise.allSettled(tokenPromises); if (chainBalances.length > 0) { results.push({ chainId, chainName: info.name, balances: chainBalances }); } }) ); results.sort((a, b) => a.chainName.localeCompare(b.chainName)); c.header("Cache-Control", "public, max-age=30"); return c.json({ address, chains: results }); }); // Get all balances across all chains for a Safe routes.get("/api/safe/:address/all-balances", async (c) => { const address = validateAddress(c); if (!address) return c.json({ error: "Invalid Ethereum address" }, 400); const includeTestnets = c.req.query("testnets") !== "false"; // default: include const chains = getChains(includeTestnets); const results: Array<{ chainId: string; chainName: string; balances: BalanceItem[] }> = []; await Promise.allSettled( chains.map(async ([chainId, info]) => { try { const res = await fetch( `${safeApiBase(info.prefix)}/safes/${address}/balances/?trusted=true&exclude_spam=true`, { signal: AbortSignal.timeout(8000) }, ); if (!res.ok) return; const raw = await res.json() as any[]; const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 }; const chainBalances: BalanceItem[] = raw.map((item: any) => ({ tokenAddress: item.tokenAddress, token: item.token || nativeToken, balance: item.balance || "0", fiatBalance: item.fiatBalance || "0", fiatConversion: item.fiatConversion || "0", })).filter((b: BalanceItem) => BigInt(b.balance || "0") > 0n); if (chainBalances.length > 0) { results.push({ chainId, chainName: info.name, balances: chainBalances }); } } catch {} }) ); results.sort((a, b) => a.chainName.localeCompare(b.chainName)); c.header("Cache-Control", "public, max-age=30"); return c.json({ address, chains: results }); }); 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 = validateAddress(c); if (!address) return c.json({ error: "Invalid Safe address" }, 400); 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 (!VALID_ETH_ADDR.test(newOwner)) { return c.json({ error: "Invalid newOwner address" }, 400); } if (!VALID_ETH_ADDR.test(sender)) { return c.json({ error: "Invalid sender address" }, 400); } // H-4: Verify sender is the authenticated user's wallet address // The JWT contains claims.eid.walletAddress (if set) from the user profile const userWallet = claims.eid?.walletAddress; if (!userWallet || sender.toLowerCase() !== userWallet.toLowerCase()) { return c.json({ error: "Sender address does not match your authenticated wallet" }, 403); } // Get Safe info (need nonce + owner count for threshold validation) 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; owners?: string[]; threshold?: number }; // M-2: Validate threshold is a positive integer within safe bounds const currentOwnerCount = safeInfo.owners?.length || 1; const newOwnerCount = currentOwnerCount + 1; // adding an owner const th = Number(threshold) || safeInfo.threshold || 1; if (!Number.isInteger(th) || th < 1 || th > newOwnerCount) { return c.json({ error: `Threshold must be between 1 and ${newOwnerCount} (current owners + new)` }, 400); } // Encode addOwnerWithThreshold(address owner, uint256 _threshold) // Function selector: 0x0d582f13 const paddedOwner = newOwner.slice(2).toLowerCase().padStart(64, "0"); const paddedThreshold = th.toString(16).padStart(64, "0"); const data = `0x0d582f13${paddedOwner}${paddedThreshold}`; // 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) { console.warn('rwallet: Safe add-owner error', res.status, await res.text()); return c.json({ error: "Safe Transaction Service rejected the request" }, res.status as any); } return c.json(await res.json(), 201); }); // ── CRDT Token API ── import { getTokenDoc, listTokenDocs, getAllBalances, getBalance, mintTokens } from "../../server/token-service"; import { tokenDocId } from "../../server/token-schemas"; import type { TokenLedgerDoc } from "../../server/token-schemas"; // List all CRDT tokens with metadata routes.get("/api/crdt-tokens", (c) => { const docIds = listTokenDocs(); const tokens = docIds.map((docId) => { const tokenId = docId.replace('global:tokens:ledgers:', ''); const doc = getTokenDoc(tokenId); if (!doc) return null; return { id: doc.token.id, name: doc.token.name, symbol: doc.token.symbol, decimals: doc.token.decimals, description: doc.token.description, totalSupply: doc.token.totalSupply, icon: doc.token.icon, color: doc.token.color, createdAt: doc.token.createdAt, }; }).filter(Boolean); return c.json({ tokens }); }); // Get authenticated user's CRDT token balances routes.get("/api/crdt-tokens/my-balances", async (c) => { const claims = await verifyWalletAuth(c); if (!claims) return c.json({ error: "Authentication required" }, 401); const did = (claims as any).did || claims.sub; const docIds = listTokenDocs(); const balances = docIds.map((docId) => { const tokenId = docId.replace('global:tokens:ledgers:', ''); const doc = getTokenDoc(tokenId); if (!doc) return null; const balance = getBalance(doc, did); if (balance <= 0) return null; return { tokenId: doc.token.id, name: doc.token.name, symbol: doc.token.symbol, decimals: doc.token.decimals, icon: doc.token.icon, color: doc.token.color, balance, }; }).filter(Boolean); return c.json({ balances }); }); // Get all holder balances for a specific token routes.get("/api/crdt-tokens/:tokenId/balances", (c) => { const tokenId = c.req.param("tokenId"); const doc = getTokenDoc(tokenId); if (!doc || !doc.token.name) return c.json({ error: "Token not found" }, 404); const holders = getAllBalances(doc); return c.json({ token: { id: doc.token.id, name: doc.token.name, symbol: doc.token.symbol, decimals: doc.token.decimals, }, holders: Object.values(holders), }); }); // Mint tokens (authenticated) routes.post("/api/crdt-tokens/:tokenId/mint", async (c) => { const claims = await verifyWalletAuth(c); if (!claims) return c.json({ error: "Authentication required" }, 401); const tokenId = c.req.param("tokenId"); const doc = getTokenDoc(tokenId); if (!doc || !doc.token.name) return c.json({ error: "Token not found" }, 404); const body = await c.req.json(); const { toDid, toLabel, amount, memo } = body; if (!toDid || !amount || typeof amount !== 'number' || amount <= 0) { return c.json({ error: "Invalid mint parameters: toDid, amount (positive number) required" }, 400); } const success = mintTokens(tokenId, toDid, toLabel || '', amount, memo || '', claims.sub); if (!success) return c.json({ error: "Mint failed" }, 500); return c.json({ ok: true, minted: amount, to: toDid }); }); // ── 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"], };