rspace-online/modules/wallet/mod.ts

111 lines
4.1 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";
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(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/balances/usd/?trusted=true&exclude_spam=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/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(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/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(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/`);
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
return c.json(await res.json());
});
// Detect which chains have a Safe for this address
routes.get("/api/safe/detect/:address", async (c) => {
const address = c.req.param("address");
const chains = Object.entries(CHAIN_MAP);
const results: Array<{ chainId: string; name: string; prefix: string }> = [];
await Promise.allSettled(
chains.map(async ([chainId, info]) => {
try {
const res = await fetch(`https://safe-transaction-${info.prefix}.safe.global/api/v1/safes/${address}/`, {
signal: AbortSignal.timeout(5000),
});
if (res.ok) results.push({ chainId, name: info.name, prefix: info.prefix });
} catch {}
})
);
return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) });
});
// ── Chain mapping ──
const CHAIN_MAP: Record<string, { name: string; prefix: string }> = {
"1": { name: "Ethereum", prefix: "mainnet" },
"10": { name: "Optimism", prefix: "optimism" },
"100": { name: "Gnosis", prefix: "gnosis-chain" },
"137": { name: "Polygon", prefix: "polygon" },
"8453": { name: "Base", prefix: "base" },
"42161": { name: "Arbitrum", prefix: "arbitrum" },
"42220": { name: "Celo", prefix: "celo" },
"43114": { name: "Avalanche", prefix: "avalanche" },
"56": { name: "BSC", prefix: "bsc" },
"324": { name: "zkSync", prefix: "zksync" },
};
function getSafePrefix(chainId: string): string | null {
return CHAIN_MAP[chainId]?.prefix || null;
}
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${spaceSlug} — Wallet | rSpace`,
moduleId: "wallet",
spaceSlug,
modules: getModuleInfoList(),
theme: "light",
styles: `<link rel="stylesheet" href="/modules/wallet/wallet.css">`,
body: `<folk-wallet-viewer space="${spaceSlug}"></folk-wallet-viewer>`,
scripts: `<script type="module" src="/modules/wallet/folk-wallet-viewer.js"></script>`,
}));
});
export const walletModule: RSpaceModule = {
id: "wallet",
name: "rWallet",
icon: "\uD83D\uDCB0",
description: "Multichain Safe wallet visualization and treasury management",
routes,
standaloneDomain: "rwallet.online",
};