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:
parent
2054a239df
commit
0b08a286cf
|
|
@ -77,17 +77,25 @@ const EXAMPLE_WALLETS = [
|
|||
|
||||
type ViewTab = "balances" | "timeline" | "flow" | "sankey";
|
||||
|
||||
interface AllChainBalanceEntry {
|
||||
chainId: string;
|
||||
chainName: string;
|
||||
balances: BalanceItem[];
|
||||
}
|
||||
|
||||
class FolkWalletViewer extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private address = "";
|
||||
private detectedChains: ChainInfo[] = [];
|
||||
private selectedChain: string | null = null;
|
||||
private balances: BalanceItem[] = [];
|
||||
private allChainBalances: Map<string, AllChainBalanceEntry> = new Map();
|
||||
private chainFilter: string | null = null; // null = show all
|
||||
private loading = false;
|
||||
private error = "";
|
||||
private isDemo = false;
|
||||
private walletType: "safe" | "eoa" | "" = "";
|
||||
private includeTestnets = false;
|
||||
private includeTestnets = true;
|
||||
|
||||
// Linked wallets state
|
||||
private isAuthenticated = false;
|
||||
|
|
@ -310,6 +318,8 @@ class FolkWalletViewer extends HTMLElement {
|
|||
this.error = "";
|
||||
this.detectedChains = [];
|
||||
this.balances = [];
|
||||
this.allChainBalances = new Map();
|
||||
this.chainFilter = null;
|
||||
this.walletType = "";
|
||||
this.activeView = "balances";
|
||||
this.transfers = null;
|
||||
|
|
@ -318,35 +328,23 @@ class FolkWalletViewer extends HTMLElement {
|
|||
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const tn = this.includeTestnets ? "?testnets=true" : "";
|
||||
const tn = this.includeTestnets ? "" : "?testnets=false";
|
||||
|
||||
// Try Safe detection first
|
||||
const res = await fetch(`${base}/api/safe/detect/${this.address}${tn}`);
|
||||
const data = await res.json();
|
||||
// Try Safe all-balances first
|
||||
const safeRes = await fetch(`${base}/api/safe/${this.address}/all-balances${tn}`);
|
||||
const safeData = await safeRes.json();
|
||||
|
||||
this.detectedChains = (data.chains || []).map((c: any) => ({
|
||||
...c,
|
||||
color: CHAIN_COLORS[c.chainId] || "#888",
|
||||
}));
|
||||
|
||||
if (this.detectedChains.length > 0) {
|
||||
if (safeData.chains && safeData.chains.length > 0) {
|
||||
this.walletType = "safe";
|
||||
this.selectedChain = this.detectedChains[0].chainId;
|
||||
await this.loadBalances();
|
||||
this.populateFromAllBalances(safeData.chains);
|
||||
} else {
|
||||
// Fall back to EOA detection (any wallet)
|
||||
const eoaRes = await fetch(`${base}/api/eoa/detect/${this.address}${tn}`);
|
||||
// Fall back to EOA all-balances
|
||||
const eoaRes = await fetch(`${base}/api/eoa/${this.address}/all-balances${tn}`);
|
||||
const eoaData = await eoaRes.json();
|
||||
|
||||
this.detectedChains = (eoaData.chains || []).map((c: any) => ({
|
||||
...c,
|
||||
color: CHAIN_COLORS[c.chainId] || "#888",
|
||||
}));
|
||||
|
||||
if (this.detectedChains.length > 0) {
|
||||
if (eoaData.chains && eoaData.chains.length > 0) {
|
||||
this.walletType = "eoa";
|
||||
this.selectedChain = this.detectedChains[0].chainId;
|
||||
await this.loadBalances();
|
||||
this.populateFromAllBalances(eoaData.chains);
|
||||
} else {
|
||||
this.error = "No balances found for this address on any supported chain.";
|
||||
}
|
||||
|
|
@ -359,6 +357,73 @@ class FolkWalletViewer extends HTMLElement {
|
|||
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() {
|
||||
if (this.isDemo) return;
|
||||
if (!this.selectedChain) return;
|
||||
|
|
@ -534,16 +599,25 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleChainSelect(chainId: string) {
|
||||
this.selectedChain = chainId;
|
||||
private handleChainSelect(chainId: string) {
|
||||
if (this.isDemo) {
|
||||
this.selectedChain = chainId;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.render();
|
||||
await this.loadBalances();
|
||||
// Recompute sankey for new chain
|
||||
|
||||
// Toggle filter: click same chain again to show all
|
||||
if (this.chainFilter === chainId) {
|
||||
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)) {
|
||||
this.vizData.sankey = transformToSankeyData(
|
||||
this.transfers.get(chainId),
|
||||
|
|
@ -551,7 +625,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
chainId,
|
||||
);
|
||||
}
|
||||
this.loading = false;
|
||||
|
||||
this.render();
|
||||
if (this.activeView !== "balances") {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
|
|
@ -1009,6 +1083,13 @@ class FolkWalletViewer extends HTMLElement {
|
|||
.amount-cell { text-align: right; font-family: monospace; }
|
||||
.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 ── */
|
||||
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
|
||||
.loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; }
|
||||
|
|
@ -1214,47 +1295,77 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
private renderBalanceTable(): string {
|
||||
return this.balances.length > 0 ? `
|
||||
<table class="balance-table">
|
||||
<thead>
|
||||
<tr><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.balances
|
||||
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"));
|
||||
})
|
||||
.map((b) => `
|
||||
});
|
||||
|
||||
if (sorted.length === 0) return '<div class="empty">No token balances found.</div>';
|
||||
|
||||
return `
|
||||
<table class="balance-table">
|
||||
<thead>
|
||||
<tr><th>Chain</th><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sorted.map((b) => {
|
||||
const color = CHAIN_COLORS[b.chainId] || (b.chainId === "local" ? "#2775ca" : "#888");
|
||||
return `
|
||||
<tr>
|
||||
<td><div class="chain-cell"><span class="chain-dot-sm" style="background:${color}"></span>${this.esc(b.chainName)}</div></td>
|
||||
<td>
|
||||
<span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span>
|
||||
<span class="token-name">${this.esc(b.token?.name || "Ether")}</span>
|
||||
</td>
|
||||
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
|
||||
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
` : '<div class="empty">No token balances found on this chain.</div>';
|
||||
</table>`;
|
||||
}
|
||||
|
||||
private renderDashboard(): string {
|
||||
if (!this.hasData()) return "";
|
||||
const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
|
||||
|
||||
// Aggregate stats across ALL chains (ignoring filter)
|
||||
const allBalances = this.getUnifiedBalances(true);
|
||||
const totalUSD = allBalances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
|
||||
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="chains">
|
||||
${this.detectedChains.map((ch) => `
|
||||
<div class="chain-btn ${this.selectedChain === ch.chainId ? "active" : ""}"
|
||||
<div class="chain-btn ${isActive ? "active" : ""}"
|
||||
data-chain="${ch.chainId}" style="--chain-color: ${ch.color}">
|
||||
<div class="chain-dot" style="background: ${ch.color}"></div>
|
||||
${ch.name}
|
||||
</div>`;
|
||||
}).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>
|
||||
`).join("")}
|
||||
${chainButtons}
|
||||
${localBtn}
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
|
|
@ -1264,11 +1375,11 @@ class FolkWalletViewer extends HTMLElement {
|
|||
</div>
|
||||
<div class="stat-card">
|
||||
<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 class="stat-card">
|
||||
<div class="stat-label">Chains</div>
|
||||
<div class="stat-value">${this.detectedChains.length}</div>
|
||||
<div class="stat-value">${this.allChainBalances.size}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<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.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
|
||||
|
||||
${this.renderLocalTokens()}
|
||||
${this.renderFeatures()}
|
||||
${this.renderExamples()}
|
||||
${this.renderDashboard()}
|
||||
|
|
@ -1337,7 +1447,13 @@ class FolkWalletViewer extends HTMLElement {
|
|||
this.shadow.querySelectorAll(".chain-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const chainId = (btn as HTMLElement).dataset.chain!;
|
||||
if (chainId === "all") {
|
||||
this.chainFilter = null;
|
||||
this.balances = this.getFilteredBalances();
|
||||
this.render();
|
||||
} else {
|
||||
this.handleChainSelect(chainId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
||||
// 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)) });
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
const chainId = c.req.param("chainId");
|
||||
const address = validateAddress(c);
|
||||
|
|
@ -445,6 +494,12 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
|
|||
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");
|
||||
|
|
@ -458,11 +513,137 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
|
|||
});
|
||||
}
|
||||
} 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 };
|
||||
|
|
@ -658,7 +839,7 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue