518 lines
18 KiB
TypeScript
518 lines
18 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();
|
|
|
|
// ── Proxy Safe Global API (avoid CORS issues from browser) ──
|
|
routes.get("/api/safe/:chainId/:address/balances", async (c) => {
|
|
const chainId = c.req.param("chainId");
|
|
const address = c.req.param("address");
|
|
const chainPrefix = getSafePrefix(chainId);
|
|
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
|
|
|
|
const res = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/balances/?trusted=true&exclude_spam=true`);
|
|
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
|
|
const raw = await res.json() as any[];
|
|
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };
|
|
// Normalize: fill in native token info and ensure fiatBalance fields exist
|
|
const data = raw.map((item: any) => ({
|
|
tokenAddress: item.tokenAddress,
|
|
token: item.token || nativeToken,
|
|
balance: item.balance || "0",
|
|
fiatBalance: item.fiatBalance || "0",
|
|
fiatConversion: item.fiatConversion || "0",
|
|
}));
|
|
c.header("Cache-Control", "public, max-age=30");
|
|
return c.json(data);
|
|
});
|
|
|
|
routes.get("/api/safe/:chainId/:address/transfers", async (c) => {
|
|
const chainId = c.req.param("chainId");
|
|
const address = c.req.param("address");
|
|
const chainPrefix = getSafePrefix(chainId);
|
|
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
|
|
|
|
const limit = c.req.query("limit") || "100";
|
|
const res = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/all-transactions/?limit=${limit}&executed=true`);
|
|
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
|
|
return c.json(await res.json());
|
|
});
|
|
|
|
routes.get("/api/safe/:chainId/:address/info", async (c) => {
|
|
const chainId = c.req.param("chainId");
|
|
const address = c.req.param("address");
|
|
const chainPrefix = getSafePrefix(chainId);
|
|
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
|
|
|
|
const res = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/`);
|
|
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
|
|
const data = await res.json();
|
|
c.header("Cache-Control", "public, max-age=300");
|
|
return c.json(data);
|
|
});
|
|
|
|
// Detect which chains have a Safe for this address
|
|
routes.get("/api/safe/detect/:address", async (c) => {
|
|
const address = c.req.param("address");
|
|
const includeTestnets = c.req.query("testnets") === "true";
|
|
const chains = getChains(includeTestnets);
|
|
const results: Array<{ chainId: string; name: string; prefix: string }> = [];
|
|
|
|
await Promise.allSettled(
|
|
chains.map(async ([chainId, info]) => {
|
|
try {
|
|
const res = await fetch(`${safeApiBase(info.prefix)}/safes/${address}/`, {
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (res.ok) results.push({ chainId, name: info.name, prefix: info.prefix });
|
|
} catch {}
|
|
})
|
|
);
|
|
|
|
// Always include testnet chains when toggled on (skip detection)
|
|
if (includeTestnets) {
|
|
for (const [chainId, info] of Object.entries(CHAIN_MAP)) {
|
|
if (TESTNET_CHAIN_IDS.has(chainId) && !results.some((r) => r.chainId === chainId)) {
|
|
results.push({ chainId, name: info.name, prefix: info.prefix });
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) });
|
|
});
|
|
|
|
// ── Chain mapping (prefix = Safe TX Service shortcode) ──
|
|
const CHAIN_MAP: Record<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 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",
|
|
};
|
|
|
|
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 = c.req.param("address");
|
|
const chainPrefix = getSafePrefix(chainId);
|
|
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
|
|
|
|
const body = await c.req.json();
|
|
const { to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce, signature, sender } = body;
|
|
|
|
if (!to || !signature || !sender) {
|
|
return c.json({ error: "Missing required fields: to, signature, sender" }, 400);
|
|
}
|
|
|
|
// Submit proposal to Safe Transaction Service
|
|
const res = await fetch(
|
|
`${safeApiBase(chainPrefix)}/safes/${address}/multisig-transactions/`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
to,
|
|
value: value || "0",
|
|
data: data || "0x",
|
|
operation: operation || 0,
|
|
safeTxGas: safeTxGas || "0",
|
|
baseGas: baseGas || "0",
|
|
gasPrice: gasPrice || "0",
|
|
gasToken: gasToken || "0x0000000000000000000000000000000000000000",
|
|
refundReceiver: refundReceiver || "0x0000000000000000000000000000000000000000",
|
|
nonce,
|
|
signature,
|
|
sender,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any);
|
|
}
|
|
|
|
return c.json(await res.json(), 201);
|
|
});
|
|
|
|
// POST /api/safe/:chainId/:address/confirm — Confirm a pending transaction
|
|
routes.post("/api/safe/:chainId/:address/confirm", async (c) => {
|
|
const claims = await verifyWalletAuth(c);
|
|
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
|
|
|
if (!claims.eid || claims.eid.authLevel < 3) {
|
|
return c.json({ error: "Elevated authentication required" }, 403);
|
|
}
|
|
if (!claims.eid.capabilities?.wallet) {
|
|
return c.json({ error: "Wallet capability required" }, 403);
|
|
}
|
|
|
|
const chainId = c.req.param("chainId");
|
|
const chainPrefix = getSafePrefix(chainId);
|
|
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
|
|
|
|
const { safeTxHash, signature } = await c.req.json();
|
|
if (!safeTxHash || !signature) {
|
|
return c.json({ error: "Missing required fields: safeTxHash, signature" }, 400);
|
|
}
|
|
|
|
const res = await fetch(
|
|
`${safeApiBase(chainPrefix)}/multisig-transactions/${safeTxHash}/confirmations/`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ signature }),
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
return c.json({ error: "Confirmation failed", details: err }, res.status as any);
|
|
}
|
|
|
|
return c.json(await res.json());
|
|
});
|
|
|
|
// POST /api/safe/:chainId/:address/execute — Execute a ready transaction (CRITICAL auth)
|
|
routes.post("/api/safe/:chainId/:address/execute", async (c) => {
|
|
const claims = await verifyWalletAuth(c);
|
|
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
|
|
|
// Require CRITICAL auth level (4) for execution
|
|
if (!claims.eid || claims.eid.authLevel < 4) {
|
|
return c.json({ error: "Critical authentication required for execution" }, 403);
|
|
}
|
|
if (!claims.eid.capabilities?.wallet) {
|
|
return c.json({ error: "Wallet capability required" }, 403);
|
|
}
|
|
|
|
// Check auth freshness (must be within 60 seconds)
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (claims.eid.authTime && (now - claims.eid.authTime) > 60) {
|
|
return c.json({ error: "Authentication too old for execution — re-authenticate" }, 403);
|
|
}
|
|
|
|
const chainId = c.req.param("chainId");
|
|
const address = c.req.param("address");
|
|
const chainPrefix = getSafePrefix(chainId);
|
|
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
|
|
|
|
// Get pending transactions and find the one to execute
|
|
const { safeTxHash } = await c.req.json();
|
|
if (!safeTxHash) {
|
|
return c.json({ error: "Missing required field: safeTxHash" }, 400);
|
|
}
|
|
|
|
// Fetch the transaction details from Safe Transaction Service
|
|
const txRes = await fetch(
|
|
`${safeApiBase(chainPrefix)}/multisig-transactions/${safeTxHash}/`,
|
|
);
|
|
|
|
if (!txRes.ok) {
|
|
return c.json({ error: "Transaction not found" }, 404);
|
|
}
|
|
|
|
const txData = await txRes.json() as { confirmations?: any[]; confirmationsRequired?: number; isExecuted?: boolean };
|
|
|
|
if (txData.isExecuted) {
|
|
return c.json({ error: "Transaction already executed" }, 400);
|
|
}
|
|
|
|
const confirmationsNeeded = txData.confirmationsRequired || 1;
|
|
const confirmationsReceived = txData.confirmations?.length || 0;
|
|
|
|
if (confirmationsReceived < confirmationsNeeded) {
|
|
return c.json({
|
|
error: "Not enough confirmations",
|
|
required: confirmationsNeeded,
|
|
received: confirmationsReceived,
|
|
}, 400);
|
|
}
|
|
|
|
// Transaction is ready — return execution data for client-side submission
|
|
// (Actual on-chain execution must happen client-side with the user's signer)
|
|
return c.json({
|
|
ready: true,
|
|
safeTxHash,
|
|
confirmations: confirmationsReceived,
|
|
required: confirmationsNeeded,
|
|
transaction: txData,
|
|
});
|
|
});
|
|
|
|
// ── EOA (any wallet) balance endpoints ──
|
|
|
|
// Detect which chains have a non-zero native balance for any address
|
|
routes.get("/api/eoa/detect/:address", async (c) => {
|
|
const address = c.req.param("address");
|
|
const includeTestnets = c.req.query("testnets") === "true";
|
|
const results: Array<{ chainId: string; name: string; prefix: string; balance: string }> = [];
|
|
|
|
await Promise.allSettled(
|
|
getChains(includeTestnets).map(async ([chainId, info]) => {
|
|
const rpcUrl = RPC_URLS[chainId];
|
|
if (!rpcUrl) return;
|
|
try {
|
|
const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]);
|
|
if (balHex && balHex !== "0x0" && balHex !== "0x") {
|
|
results.push({ chainId, name: info.name, prefix: info.prefix, balance: balHex });
|
|
}
|
|
} catch {}
|
|
})
|
|
);
|
|
|
|
// Always include testnet chains when toggled on (skip detection)
|
|
if (includeTestnets) {
|
|
for (const [chainId, info] of Object.entries(CHAIN_MAP)) {
|
|
if (TESTNET_CHAIN_IDS.has(chainId) && !results.some((r) => r.chainId === chainId)) {
|
|
results.push({ chainId, name: info.name, prefix: info.prefix, balance: "0x0" });
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) });
|
|
});
|
|
|
|
// Get native token balance for an EOA on a specific chain
|
|
routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
|
|
const chainId = c.req.param("chainId");
|
|
const address = c.req.param("address");
|
|
const rpcUrl = RPC_URLS[chainId];
|
|
if (!rpcUrl) return c.json({ error: "Unsupported chain" }, 400);
|
|
|
|
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };
|
|
const balances: BalanceItem[] = [];
|
|
|
|
try {
|
|
const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]);
|
|
const balWei = BigInt(balHex || "0x0");
|
|
if (balWei > 0n) {
|
|
balances.push({
|
|
tokenAddress: null,
|
|
token: nativeToken,
|
|
balance: balWei.toString(),
|
|
fiatBalance: "0",
|
|
fiatConversion: "0",
|
|
});
|
|
}
|
|
} catch {}
|
|
|
|
c.header("Cache-Control", "public, max-age=30");
|
|
return c.json(balances);
|
|
});
|
|
|
|
interface BalanceItem {
|
|
tokenAddress: string | null;
|
|
token: { name: string; symbol: string; decimals: number };
|
|
balance: string;
|
|
fiatBalance: string;
|
|
fiatConversion: string;
|
|
}
|
|
|
|
// ── Safe owner addition proposal (add EncryptID EOA as signer) ──
|
|
routes.post("/api/safe/:chainId/:address/add-owner-proposal", async (c) => {
|
|
const claims = await verifyWalletAuth(c);
|
|
if (!claims) return c.json({ error: "Authentication required" }, 401);
|
|
if (!claims.eid || claims.eid.authLevel < 3) {
|
|
return c.json({ error: "Elevated authentication required" }, 403);
|
|
}
|
|
if (!claims.eid.capabilities?.wallet) {
|
|
return c.json({ error: "Wallet capability required" }, 403);
|
|
}
|
|
|
|
const chainId = c.req.param("chainId");
|
|
const address = c.req.param("address");
|
|
const chainPrefix = getSafePrefix(chainId);
|
|
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
|
|
|
|
const { newOwner, threshold, signature, sender } = await c.req.json();
|
|
if (!newOwner || !signature || !sender) {
|
|
return c.json({ error: "Missing required fields: newOwner, signature, sender" }, 400);
|
|
}
|
|
|
|
// Encode addOwnerWithThreshold(address owner, uint256 _threshold)
|
|
// Function selector: 0x0d582f13
|
|
const paddedOwner = newOwner.slice(2).toLowerCase().padStart(64, "0");
|
|
const paddedThreshold = (threshold || 1).toString(16).padStart(64, "0");
|
|
const data = `0x0d582f13${paddedOwner}${paddedThreshold}`;
|
|
|
|
// Get Safe nonce
|
|
const infoRes = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/`);
|
|
if (!infoRes.ok) return c.json({ error: "Safe not found" }, 404);
|
|
const safeInfo = await infoRes.json() as { nonce?: number };
|
|
|
|
// Submit proposal to Safe Transaction Service
|
|
const res = await fetch(
|
|
`${safeApiBase(chainPrefix)}/safes/${address}/multisig-transactions/`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
to: address,
|
|
value: "0",
|
|
data,
|
|
operation: 0,
|
|
safeTxGas: "0",
|
|
baseGas: "0",
|
|
gasPrice: "0",
|
|
gasToken: "0x0000000000000000000000000000000000000000",
|
|
refundReceiver: "0x0000000000000000000000000000000000000000",
|
|
nonce: safeInfo.nonce,
|
|
signature,
|
|
sender,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
return c.json({ error: "Safe Transaction Service error", details: err }, res.status as any);
|
|
}
|
|
|
|
return c.json(await res.json(), 201);
|
|
});
|
|
|
|
// ── Page route ──
|
|
routes.get("/", (c) => {
|
|
const spaceSlug = c.req.param("space") || "demo";
|
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
|
return c.html(renderShell({
|
|
title: `${spaceSlug} — Wallet | rSpace`,
|
|
moduleId: "rwallet",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-wallet-viewer></folk-wallet-viewer>`,
|
|
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=4"></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"],
|
|
};
|