feat(rwallet): unified all-chains balance view with ERC-20 scanning

- Add POPULAR_TOKENS map (USDC, USDT, DAI, WETH) for 7 chains
- Add ERC-20 balanceOf scanning to EOA balance endpoint
- Add /api/eoa/:address/all-balances and /api/safe/:address/all-balances
  endpoints that fan out to all chains in parallel
- Replace single-chain view with unified multi-chain balance table
- Add Chain column with colored dots, "All" filter button
- Merge CRDT tokens into unified table (chainId="local")
- Enable testnets by default
- Chain buttons now act as filters (no extra API call)
- Stats aggregate across all chains regardless of filter
- Bump JS cache version to v=6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 22:20:44 -07:00
parent 2054a239df
commit 0b08a286cf
2 changed files with 367 additions and 70 deletions

View File

@ -77,17 +77,25 @@ const EXAMPLE_WALLETS = [
type ViewTab = "balances" | "timeline" | "flow" | "sankey"; type ViewTab = "balances" | "timeline" | "flow" | "sankey";
interface AllChainBalanceEntry {
chainId: string;
chainName: string;
balances: BalanceItem[];
}
class FolkWalletViewer extends HTMLElement { class FolkWalletViewer extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private address = ""; private address = "";
private detectedChains: ChainInfo[] = []; private detectedChains: ChainInfo[] = [];
private selectedChain: string | null = null; private selectedChain: string | null = null;
private balances: BalanceItem[] = []; private balances: BalanceItem[] = [];
private allChainBalances: Map<string, AllChainBalanceEntry> = new Map();
private chainFilter: string | null = null; // null = show all
private loading = false; private loading = false;
private error = ""; private error = "";
private isDemo = false; private isDemo = false;
private walletType: "safe" | "eoa" | "" = ""; private walletType: "safe" | "eoa" | "" = "";
private includeTestnets = false; private includeTestnets = true;
// Linked wallets state // Linked wallets state
private isAuthenticated = false; private isAuthenticated = false;
@ -310,6 +318,8 @@ class FolkWalletViewer extends HTMLElement {
this.error = ""; this.error = "";
this.detectedChains = []; this.detectedChains = [];
this.balances = []; this.balances = [];
this.allChainBalances = new Map();
this.chainFilter = null;
this.walletType = ""; this.walletType = "";
this.activeView = "balances"; this.activeView = "balances";
this.transfers = null; this.transfers = null;
@ -318,35 +328,23 @@ class FolkWalletViewer extends HTMLElement {
try { try {
const base = this.getApiBase(); const base = this.getApiBase();
const tn = this.includeTestnets ? "?testnets=true" : ""; const tn = this.includeTestnets ? "" : "?testnets=false";
// Try Safe detection first // Try Safe all-balances first
const res = await fetch(`${base}/api/safe/detect/${this.address}${tn}`); const safeRes = await fetch(`${base}/api/safe/${this.address}/all-balances${tn}`);
const data = await res.json(); const safeData = await safeRes.json();
this.detectedChains = (data.chains || []).map((c: any) => ({ if (safeData.chains && safeData.chains.length > 0) {
...c,
color: CHAIN_COLORS[c.chainId] || "#888",
}));
if (this.detectedChains.length > 0) {
this.walletType = "safe"; this.walletType = "safe";
this.selectedChain = this.detectedChains[0].chainId; this.populateFromAllBalances(safeData.chains);
await this.loadBalances();
} else { } else {
// Fall back to EOA detection (any wallet) // Fall back to EOA all-balances
const eoaRes = await fetch(`${base}/api/eoa/detect/${this.address}${tn}`); const eoaRes = await fetch(`${base}/api/eoa/${this.address}/all-balances${tn}`);
const eoaData = await eoaRes.json(); const eoaData = await eoaRes.json();
this.detectedChains = (eoaData.chains || []).map((c: any) => ({ if (eoaData.chains && eoaData.chains.length > 0) {
...c,
color: CHAIN_COLORS[c.chainId] || "#888",
}));
if (this.detectedChains.length > 0) {
this.walletType = "eoa"; this.walletType = "eoa";
this.selectedChain = this.detectedChains[0].chainId; this.populateFromAllBalances(eoaData.chains);
await this.loadBalances();
} else { } else {
this.error = "No balances found for this address on any supported chain."; this.error = "No balances found for this address on any supported chain.";
} }
@ -359,6 +357,73 @@ class FolkWalletViewer extends HTMLElement {
this.render(); this.render();
} }
private populateFromAllBalances(chains: Array<{ chainId: string; chainName: string; balances: BalanceItem[] }>) {
this.allChainBalances = new Map();
this.detectedChains = [];
for (const ch of chains) {
this.allChainBalances.set(ch.chainId, {
chainId: ch.chainId,
chainName: ch.chainName,
balances: ch.balances,
});
this.detectedChains.push({
chainId: ch.chainId,
name: ch.chainName,
prefix: "",
color: CHAIN_COLORS[ch.chainId] || "#888",
});
}
// Set selectedChain for viz compatibility (first chain)
if (this.detectedChains.length > 0) {
this.selectedChain = this.detectedChains[0].chainId;
}
// Build flattened balances for backward compat (viz data, etc.)
this.balances = this.getFilteredBalances();
}
/** Get balances respecting current chainFilter */
private getFilteredBalances(): BalanceItem[] {
const result: BalanceItem[] = [];
for (const [chainId, entry] of this.allChainBalances) {
if (this.chainFilter && this.chainFilter !== chainId) continue;
result.push(...entry.balances);
}
return result;
}
/** Get all balances (including CRDT) for the unified table, with chain info attached.
* Pass ignoreFilter=true to get all chains regardless of current filter (for stats). */
private getUnifiedBalances(ignoreFilter = false): Array<BalanceItem & { chainId: string; chainName: string }> {
const result: Array<BalanceItem & { chainId: string; chainName: string }> = [];
for (const [chainId, entry] of this.allChainBalances) {
if (!ignoreFilter && this.chainFilter && this.chainFilter !== chainId) continue;
for (const b of entry.balances) {
result.push({ ...b, chainId, chainName: entry.chainName });
}
}
// Merge CRDT tokens (when showing all or filtering to "local")
if (this.isAuthenticated && this.crdtBalances.length > 0 && (ignoreFilter || !this.chainFilter || this.chainFilter === "local")) {
for (const t of this.crdtBalances) {
result.push({
tokenAddress: `crdt:${t.tokenId}`,
token: { name: t.name, symbol: t.symbol, decimals: t.decimals },
balance: t.balance.toString(),
fiatBalance: "0",
fiatConversion: "0",
chainId: "local",
chainName: "Local",
});
}
}
return result;
}
private async loadBalances() { private async loadBalances() {
if (this.isDemo) return; if (this.isDemo) return;
if (!this.selectedChain) return; if (!this.selectedChain) return;
@ -534,16 +599,25 @@ class FolkWalletViewer extends HTMLElement {
} }
} }
private async handleChainSelect(chainId: string) { private handleChainSelect(chainId: string) {
this.selectedChain = chainId;
if (this.isDemo) { if (this.isDemo) {
this.selectedChain = chainId;
this.render(); this.render();
return; return;
} }
this.loading = true;
this.render(); // Toggle filter: click same chain again to show all
await this.loadBalances(); if (this.chainFilter === chainId) {
// Recompute sankey for new chain this.chainFilter = null;
} else {
this.chainFilter = chainId;
}
// Update selectedChain for viz compatibility
this.selectedChain = chainId;
this.balances = this.getFilteredBalances();
// Recompute sankey for selected chain
if (this.transfers && this.transfers.has(chainId)) { if (this.transfers && this.transfers.has(chainId)) {
this.vizData.sankey = transformToSankeyData( this.vizData.sankey = transformToSankeyData(
this.transfers.get(chainId), this.transfers.get(chainId),
@ -551,7 +625,7 @@ class FolkWalletViewer extends HTMLElement {
chainId, chainId,
); );
} }
this.loading = false;
this.render(); this.render();
if (this.activeView !== "balances") { if (this.activeView !== "balances") {
requestAnimationFrame(() => this.drawActiveVisualization()); requestAnimationFrame(() => this.drawActiveVisualization());
@ -1009,6 +1083,13 @@ class FolkWalletViewer extends HTMLElement {
.amount-cell { text-align: right; font-family: monospace; } .amount-cell { text-align: right; font-family: monospace; }
.fiat { color: var(--rs-success); } .fiat { color: var(--rs-success); }
/* ── Chain cell in balance table ── */
.chain-cell {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--rs-text-secondary); white-space: nowrap;
}
.chain-dot-sm { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
/* ── States ── */ /* ── States ── */
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; } .empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
.loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; } .loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; }
@ -1214,47 +1295,77 @@ class FolkWalletViewer extends HTMLElement {
} }
private renderBalanceTable(): string { private renderBalanceTable(): string {
return this.balances.length > 0 ? ` const unified = this.getUnifiedBalances();
if (unified.length === 0) return '<div class="empty">No token balances found.</div>';
const sorted = unified
.filter((b) => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n)
.sort((a, b) => {
const fiatDiff = parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0");
if (fiatDiff !== 0) return fiatDiff;
return Number(BigInt(b.balance || "0") - BigInt(a.balance || "0"));
});
if (sorted.length === 0) return '<div class="empty">No token balances found.</div>';
return `
<table class="balance-table"> <table class="balance-table">
<thead> <thead>
<tr><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr> <tr><th>Chain</th><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr>
</thead> </thead>
<tbody> <tbody>
${this.balances ${sorted.map((b) => {
.filter((b) => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n) const color = CHAIN_COLORS[b.chainId] || (b.chainId === "local" ? "#2775ca" : "#888");
.sort((a, b) => { return `
const fiatDiff = parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0");
if (fiatDiff !== 0) return fiatDiff;
return Number(BigInt(b.balance || "0") - BigInt(a.balance || "0"));
})
.map((b) => `
<tr> <tr>
<td><div class="chain-cell"><span class="chain-dot-sm" style="background:${color}"></span>${this.esc(b.chainName)}</div></td>
<td> <td>
<span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span> <span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span>
<span class="token-name">${this.esc(b.token?.name || "Ether")}</span> <span class="token-name">${this.esc(b.token?.name || "Ether")}</span>
</td> </td>
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td> <td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td> <td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>
</tr> </tr>`;
`).join("")} }).join("")}
</tbody> </tbody>
</table> </table>`;
` : '<div class="empty">No token balances found on this chain.</div>';
} }
private renderDashboard(): string { private renderDashboard(): string {
if (!this.hasData()) return ""; if (!this.hasData()) return "";
const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
return ` // Aggregate stats across ALL chains (ignoring filter)
<div class="chains"> const allBalances = this.getUnifiedBalances(true);
${this.detectedChains.map((ch) => ` const totalUSD = allBalances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
<div class="chain-btn ${this.selectedChain === ch.chainId ? "active" : ""}" const totalTokens = allBalances.filter((b) => parseFloat(b.fiatBalance || "0") > 0 || BigInt(b.balance || "0") > 0n).length;
// Build chain buttons with "All" filter
const chainButtons = this.detectedChains.map((ch) => {
const isActive = this.chainFilter === ch.chainId;
return `
<div class="chain-btn ${isActive ? "active" : ""}"
data-chain="${ch.chainId}" style="--chain-color: ${ch.color}"> data-chain="${ch.chainId}" style="--chain-color: ${ch.color}">
<div class="chain-dot" style="background: ${ch.color}"></div> <div class="chain-dot" style="background: ${ch.color}"></div>
${ch.name} ${ch.name}
</div> </div>`;
`).join("")} }).join("");
// Add "Local" filter button if CRDT tokens exist
const localBtn = (this.isAuthenticated && this.crdtBalances.length > 0) ? `
<div class="chain-btn ${this.chainFilter === "local" ? "active" : ""}"
data-chain="local" style="--chain-color: #2775ca">
<div class="chain-dot" style="background: #2775ca"></div>
Local
</div>` : "";
return `
<div class="chains">
<div class="chain-btn ${this.chainFilter === null ? "active" : ""}"
data-chain="all" style="--chain-color: var(--rs-accent, #14b8a6)">
All
</div>
${chainButtons}
${localBtn}
</div> </div>
<div class="stats"> <div class="stats">
@ -1264,11 +1375,11 @@ class FolkWalletViewer extends HTMLElement {
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Tokens</div> <div class="stat-label">Tokens</div>
<div class="stat-value">${this.balances.filter((b) => parseFloat(b.fiatBalance || "0") > 0 || BigInt(b.balance || "0") > 0n).length}</div> <div class="stat-value">${totalTokens}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Chains</div> <div class="stat-label">Chains</div>
<div class="stat-value">${this.detectedChains.length}</div> <div class="stat-value">${this.allChainBalances.size}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Address</div> <div class="stat-label">Address</div>
@ -1318,7 +1429,6 @@ class FolkWalletViewer extends HTMLElement {
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""} ${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""} ${this.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
${this.renderLocalTokens()}
${this.renderFeatures()} ${this.renderFeatures()}
${this.renderExamples()} ${this.renderExamples()}
${this.renderDashboard()} ${this.renderDashboard()}
@ -1337,7 +1447,13 @@ class FolkWalletViewer extends HTMLElement {
this.shadow.querySelectorAll(".chain-btn").forEach((btn) => { this.shadow.querySelectorAll(".chain-btn").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const chainId = (btn as HTMLElement).dataset.chain!; const chainId = (btn as HTMLElement).dataset.chain!;
this.handleChainSelect(chainId); if (chainId === "all") {
this.chainFilter = null;
this.balances = this.getFilteredBalances();
this.render();
} else {
this.handleChainSelect(chainId);
}
}); });
}); });

View File

@ -400,6 +400,55 @@ routes.post("/api/safe/:chainId/:address/execute", async (c) => {
}); });
}); });
// ── 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 ── // ── EOA (any wallet) balance endpoints ──
// Detect which chains have a non-zero native balance for any address // Detect which chains have a non-zero native balance for any address
@ -434,7 +483,7 @@ routes.get("/api/eoa/detect/:address", async (c) => {
return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) }); 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 // Get native + ERC-20 token balances for an EOA on a specific chain
routes.get("/api/eoa/:chainId/:address/balances", async (c) => { routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
const chainId = c.req.param("chainId"); const chainId = c.req.param("chainId");
const address = validateAddress(c); const address = validateAddress(c);
@ -445,24 +494,156 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 }; const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };
const balances: BalanceItem[] = []; const balances: BalanceItem[] = [];
try { // Fetch native + ERC-20 balances in parallel
const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]); const tokens = POPULAR_TOKENS[chainId] || [];
const balWei = BigInt(balHex || "0x0"); const promises: Promise<void>[] = [];
if (balWei > 0n) {
balances.push({ // Native balance
tokenAddress: null, promises.push((async () => {
token: nativeToken, try {
balance: balWei.toString(), const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]);
fiatBalance: "0", const balWei = BigInt(balHex || "0x0");
fiatConversion: "0", if (balWei > 0n) {
}); balances.push({
} tokenAddress: null,
} catch {} 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"); c.header("Cache-Control", "public, max-age=30");
return c.json(balances); 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 { interface BalanceItem {
tokenAddress: string | null; tokenAddress: string | null;
token: { name: string; symbol: string; decimals: number }; token: { name: string; symbol: string; decimals: number };
@ -658,7 +839,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-wallet-viewer></folk-wallet-viewer>`, body: `<folk-wallet-viewer></folk-wallet-viewer>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=5"></script>`, scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=6"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`, styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
})); }));
}); });