diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 209a854..a327b45 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -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 = 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 { + const result: Array = []; + + 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 ? ` + const unified = this.getUnifiedBalances(); + if (unified.length === 0) return '
No token balances found.
'; + + 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 '
No token balances found.
'; + + return ` - + - ${this.balances - .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) => ` + ${sorted.map((b) => { + const color = CHAIN_COLORS[b.chainId] || (b.chainId === "local" ? "#2775ca" : "#888"); + return ` + - - `).join("")} + `; + }).join("")} -
TokenBalanceUSD Value
ChainTokenBalanceUSD Value
${this.esc(b.chainName)}
${this.esc(b.token?.symbol || "ETH")} ${this.esc(b.token?.name || "Ether")} ${this.formatBalance(b.balance, b.token?.decimals || 18)} ${this.formatUSD(b.fiatBalance)}
- ` : '
No token balances found on this chain.
'; + `; } private renderDashboard(): string { if (!this.hasData()) return ""; - const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0); - return ` -
- ${this.detectedChains.map((ch) => ` -
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 ` +
${ch.name} -
- `).join("")} +
`; + }).join(""); + + // Add "Local" filter button if CRDT tokens exist + const localBtn = (this.isAuthenticated && this.crdtBalances.length > 0) ? ` +
+
+ Local +
` : ""; + + return ` +
+
+ All +
+ ${chainButtons} + ${localBtn}
@@ -1264,11 +1375,11 @@ class FolkWalletViewer extends HTMLElement {
Tokens
-
${this.balances.filter((b) => parseFloat(b.fiatBalance || "0") > 0 || BigInt(b.balance || "0") > 0n).length}
+
${totalTokens}
Chains
-
${this.detectedChains.length}
+
${this.allChainBalances.size}
Address
@@ -1318,7 +1429,6 @@ class FolkWalletViewer extends HTMLElement { ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Detecting wallet across chains...
' : ""} - ${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!; - this.handleChainSelect(chainId); + if (chainId === "all") { + this.chainFilter = null; + this.balances = this.getFilteredBalances(); + this.render(); + } else { + this.handleChainSelect(chainId); + } }); }); diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 64aed73..3841d35 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -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> = { + "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 { + 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,24 +494,156 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => { 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 {} + // Fetch native + ERC-20 balances in parallel + const tokens = POPULAR_TOKENS[chainId] || []; + const promises: Promise[] = []; + + // Native balance + promises.push((async () => { + 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 {} + })()); + + // 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[] = []; + + // 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: ``, - scripts: ``, + scripts: ``, styles: ``, })); });