From 5f3ffcc8d2cba689f0896e21b50bd6d624f92a60 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 6 Mar 2026 23:17:46 -0800 Subject: [PATCH] feat: add EOA wallet support to rWallet for any address Previously rWallet only supported Safe multisig wallets. Now it falls back to EOA detection via public RPC endpoints when no Safe is found, allowing any Ethereum address to view native balances across chains. Co-Authored-By: Claude Opus 4.6 --- .../rwallet/components/folk-wallet-viewer.ts | 43 ++++++-- modules/rwallet/mod.ts | 101 ++++++++++++++++++ 2 files changed, 134 insertions(+), 10 deletions(-) diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 83495c4..e4c11ad 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -42,6 +42,7 @@ class FolkWalletViewer extends HTMLElement { private loading = false; private error = ""; private isDemo = false; + private walletType: "safe" | "eoa" | "" = ""; constructor() { super(); @@ -102,10 +103,13 @@ class FolkWalletViewer extends HTMLElement { this.error = ""; this.detectedChains = []; this.balances = []; + this.walletType = ""; this.render(); try { const base = this.getApiBase(); + + // Try Safe detection first const res = await fetch(`${base}/api/safe/detect/${this.address}`); const data = await res.json(); @@ -114,15 +118,30 @@ class FolkWalletViewer extends HTMLElement { color: CHAIN_COLORS[c.chainId] || "#888", })); - if (this.detectedChains.length === 0) { - this.error = "No Safe wallets found for this address on any supported chain."; - } else { - // Auto-select first chain + if (this.detectedChains.length > 0) { + this.walletType = "safe"; this.selectedChain = this.detectedChains[0].chainId; await this.loadBalances(); + } else { + // Fall back to EOA detection (any wallet) + const eoaRes = await fetch(`${base}/api/eoa/detect/${this.address}`); + const eoaData = await eoaRes.json(); + + this.detectedChains = (eoaData.chains || []).map((c: any) => ({ + ...c, + color: CHAIN_COLORS[c.chainId] || "#888", + })); + + if (this.detectedChains.length > 0) { + this.walletType = "eoa"; + this.selectedChain = this.detectedChains[0].chainId; + await this.loadBalances(); + } else { + this.error = "No balances found for this address on any supported chain."; + } } } catch (e) { - this.error = "Failed to detect chains. Check the address and try again."; + this.error = "Failed to detect wallet. Check the address and try again."; } this.loading = false; @@ -134,7 +153,10 @@ class FolkWalletViewer extends HTMLElement { if (!this.selectedChain) return; try { const base = this.getApiBase(); - const res = await fetch(`${base}/api/safe/${this.selectedChain}/${this.address}/balances`); + const apiPath = this.walletType === "safe" + ? `${base}/api/safe/${this.selectedChain}/${this.address}/balances` + : `${base}/api/eoa/${this.selectedChain}/${this.address}/balances`; + const res = await fetch(apiPath); if (res.ok) { this.balances = await res.json(); } @@ -254,20 +276,21 @@ class FolkWalletViewer extends HTMLElement {
-
${!this.address && !this.loading ? `
-

Enter a Safe wallet address to visualize

-

Try: TEC Commons Fund

+

Enter any wallet address to visualize

+

Supports Safe multisigs and regular wallets (EOA)

+

Try: TEC Commons Fund (Safe)

` : ""} ${this.error ? `
${this.esc(this.error)}
` : ""} - ${this.loading ? '
Detecting Safe wallets across chains...
' : ""} + ${this.loading ? '
Detecting wallet across chains...
' : ""} ${!this.loading && this.detectedChains.length > 0 ? `
diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index ef7654a..98fa63a 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -92,6 +92,48 @@ function getSafePrefix(chainId: string): string | null { return CHAIN_MAP[chainId]?.prefix || null; } +// ── Public RPC endpoints for EOA balance lookups ── +const RPC_URLS: Record = { + "1": "https://eth.llamarpc.com", + "10": "https://mainnet.optimism.io", + "100": "https://rpc.gnosischain.com", + "137": "https://polygon-rpc.com", + "8453": "https://mainnet.base.org", + "42161": "https://arb1.arbitrum.io/rpc", + "42220": "https://forno.celo.org", + "43114": "https://api.avax.network/ext/bc/C/rpc", + "56": "https://bsc-dataseed.binance.org", + "324": "https://mainnet.era.zksync.io", + "11155111": "https://rpc.sepolia.org", + "84532": "https://sepolia.base.org", +}; + +const NATIVE_TOKENS: Record = { + "1": { name: "Ether", symbol: "ETH", decimals: 18 }, + "10": { name: "Ether", symbol: "ETH", decimals: 18 }, + "100": { name: "xDAI", symbol: "xDAI", decimals: 18 }, + "137": { name: "MATIC", symbol: "MATIC", decimals: 18 }, + "8453": { name: "Ether", symbol: "ETH", decimals: 18 }, + "42161": { name: "Ether", symbol: "ETH", decimals: 18 }, + "42220": { name: "CELO", symbol: "CELO", decimals: 18 }, + "43114": { name: "AVAX", symbol: "AVAX", decimals: 18 }, + "56": { name: "BNB", symbol: "BNB", decimals: 18 }, + "324": { name: "Ether", symbol: "ETH", decimals: 18 }, + "11155111": { name: "Sepolia ETH", symbol: "ETH", decimals: 18 }, + "84532": { name: "Base Sepolia ETH", symbol: "ETH", decimals: 18 }, +}; + +async function rpcCall(rpcUrl: string, method: string, params: any[]): Promise { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + signal: AbortSignal.timeout(5000), + }); + const data = await res.json(); + return data.result; +} + // ── Safe Proposal / Confirm / Execute (EncryptID-authenticated) ── // Helper: extract and verify JWT from request @@ -271,6 +313,65 @@ routes.post("/api/safe/:chainId/:address/execute", async (c) => { }); }); +// ── EOA (any wallet) balance endpoints ── + +// Detect which chains have a non-zero native balance for any address +routes.get("/api/eoa/detect/:address", async (c) => { + const address = c.req.param("address"); + const results: Array<{ chainId: string; name: string; prefix: string; balance: string }> = []; + + await Promise.allSettled( + Object.entries(CHAIN_MAP).map(async ([chainId, info]) => { + const rpcUrl = RPC_URLS[chainId]; + if (!rpcUrl) return; + try { + const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]); + if (balHex && balHex !== "0x0" && balHex !== "0x") { + results.push({ chainId, name: info.name, prefix: info.prefix, balance: balHex }); + } + } catch {} + }) + ); + + 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 +routes.get("/api/eoa/:chainId/:address/balances", async (c) => { + const chainId = c.req.param("chainId"); + const address = c.req.param("address"); + const rpcUrl = RPC_URLS[chainId]; + if (!rpcUrl) return c.json({ error: "Unsupported chain" }, 400); + + 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 {} + + c.header("Cache-Control", "public, max-age=30"); + return c.json(balances); +}); + +interface BalanceItem { + tokenAddress: string | null; + token: { name: string; symbol: string; decimals: number }; + balance: string; + fiatBalance: string; + fiatConversion: string; +} + // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo";