rspace-online/modules/rwallet/mod.ts

872 lines
32 KiB
TypeScript

/**
* 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<string, { name: string; prefix: string }> = {
"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<string, string> = {
"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<string, { envName: string; alchemySlug?: string }> = {
"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<string, { name: string; symbol: string; decimals: number }> = {
"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<any> {
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<string, Array<{ address: string; name: string; symbol: string; decimals: number }>> = {
"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<string> {
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<void>[] = [];
// 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<void>[] = [];
// 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: `<folk-wallet-viewer></folk-wallet-viewer>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=6"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
}));
});
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"],
};