117 lines
4.4 KiB
TypeScript
117 lines
4.4 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);
|
|
const data = await res.json();
|
|
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(`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);
|
|
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 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" },
|
|
"11155111": { name: "Sepolia", prefix: "sepolia" },
|
|
"84532": { name: "Base Sepolia", prefix: "base-sepolia" },
|
|
};
|
|
|
|
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",
|
|
};
|