884 lines
32 KiB
TypeScript
884 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 ──
|
|
// ── Page routes: subnav tab links ──
|
|
|
|
function renderWallet(spaceSlug: string, initialView?: string) {
|
|
const viewAttr = initialView ? ` initial-view="${initialView}"` : "";
|
|
return renderShell({
|
|
title: `${spaceSlug} — Wallet | rSpace`,
|
|
moduleId: "rwallet",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-wallet-viewer${viewAttr}></folk-wallet-viewer>`,
|
|
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=8"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
|
|
});
|
|
}
|
|
|
|
routes.get("/wallets", (c) => c.html(renderWallet(c.req.param("space") || "demo")));
|
|
routes.get("/tokens", (c) => c.html(renderWallet(c.req.param("space") || "demo", "balances")));
|
|
routes.get("/transactions", (c) => c.html(renderWallet(c.req.param("space") || "demo", "timeline")));
|
|
|
|
routes.get("/", (c) => c.html(renderWallet(c.req.param("space") || "demo")));
|
|
|
|
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"],
|
|
outputPaths: [
|
|
{ path: "wallets", name: "Wallets", icon: "💳", description: "Connected Safe wallets and EOA accounts" },
|
|
{ path: "tokens", name: "Tokens", icon: "🪙", description: "Token balances across chains" },
|
|
{ path: "transactions", name: "Transactions", icon: "📜", description: "Transaction history and transfers" },
|
|
],
|
|
};
|