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";