/** * — multichain Safe wallet visualization. * * Enter a Safe address to see balances across chains, transfer history, * and flow visualizations. */ interface ChainInfo { chainId: string; name: string; prefix: string; color: string; } interface BalanceItem { tokenAddress: string | null; token: { name: string; symbol: string; decimals: number } | null; balance: string; fiatBalance: string; fiatConversion: string; } const CHAIN_COLORS: Record = { "1": "#627eea", "10": "#ff0420", "100": "#04795b", "137": "#8247e5", "8453": "#0052ff", "42161": "#28a0f0", "42220": "#35d07f", "43114": "#e84142", "56": "#f3ba2f", "324": "#8c8dfc", "11155111": "#f59e0b", "84532": "#f59e0b", }; const CHAIN_NAMES: Record = { "1": "Ethereum", "10": "Optimism", "100": "Gnosis", "137": "Polygon", "8453": "Base", "42161": "Arbitrum", "42220": "Celo", "43114": "Avalanche", "56": "BSC", "324": "zkSync", }; const EXAMPLE_WALLETS = [ { name: "TEC Commons Fund", address: "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1", type: "Safe" }, { name: "Gitcoin Treasury", address: "0xde21F729137C5Af1b01d73aF1dC21eFfa2B8a0d6", type: "Safe" }, { name: "Vitalik.eth", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", type: "EOA" }, ]; class FolkWalletViewer extends HTMLElement { private shadow: ShadowRoot; private address = ""; private detectedChains: ChainInfo[] = []; private selectedChain: string | null = null; private balances: BalanceItem[] = []; private loading = false; private error = ""; private isDemo = false; private walletType: "safe" | "eoa" | "" = ""; private includeTestnets = false; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { const space = this.getAttribute("space") || ""; if (space === "demo") { this.loadDemoData(); return; } // Check URL params for initial address const params = new URLSearchParams(window.location.search); this.address = params.get("address") || ""; this.render(); if (this.address) this.detectChains(); } private loadDemoData() { this.isDemo = true; this.address = "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1"; this.detectedChains = [ { chainId: "1", name: "Ethereum", prefix: "eth", color: "#627eea" }, { chainId: "10", name: "Optimism", prefix: "oeth", color: "#ff0420" }, { chainId: "100", name: "Gnosis", prefix: "gno", color: "#04795b" }, { chainId: "137", name: "Polygon", prefix: "pol", color: "#8247e5" }, { chainId: "8453", name: "Base", prefix: "base", color: "#0052ff" }, { chainId: "42161", name: "Arbitrum", prefix: "arb1", color: "#28a0f0" }, { chainId: "43114", name: "Avalanche", prefix: "avax", color: "#e84142" }, ]; this.selectedChain = "100"; this.balances = [ { tokenAddress: null, token: { name: "xDAI", symbol: "xDAI", decimals: 18 }, balance: "45230000000000000000000", fiatBalance: "45230", fiatConversion: "1" }, { tokenAddress: "0x5dF8339c5E282ee48c0c7cE252A7842F74e378b2", token: { name: "Token Engineering Commons", symbol: "TEC", decimals: 18 }, balance: "1250000000000000000000000", fiatBalance: "12500", fiatConversion: "0.01" }, { tokenAddress: "0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1", token: { name: "Wrapped Ether", symbol: "WETH", decimals: 18 }, balance: "8500000000000000000", fiatBalance: "28050", fiatConversion: "3300" }, { tokenAddress: "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", token: { name: "USD Coin", symbol: "USDC", decimals: 6 }, balance: "15750000000", fiatBalance: "15750", fiatConversion: "1" }, { tokenAddress: "0x4f4F9b8D5B4d0Dc10506e5551B0513B61fD59e75", token: { name: "Giveth", symbol: "GIV", decimals: 18 }, balance: "500000000000000000000000", fiatBalance: "2500", fiatConversion: "0.005" }, ]; this.render(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rwallet/); return match ? match[0] : ""; } private async detectChains() { if (this.isDemo) return; if (!this.address || !/^0x[a-fA-F0-9]{40}$/.test(this.address)) { this.error = "Please enter a valid Ethereum address (0x...)"; this.render(); return; } this.loading = true; this.error = ""; this.detectedChains = []; this.balances = []; this.walletType = ""; this.render(); try { const base = this.getApiBase(); const tn = this.includeTestnets ? "?testnets=true" : ""; // Try Safe detection first const res = await fetch(`${base}/api/safe/detect/${this.address}${tn}`); const data = await res.json(); this.detectedChains = (data.chains || []).map((c: any) => ({ ...c, color: CHAIN_COLORS[c.chainId] || "#888", })); 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}${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) { 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 wallet. Check the address and try again."; } this.loading = false; this.render(); } private async loadBalances() { if (this.isDemo) return; if (!this.selectedChain) return; try { const base = this.getApiBase(); 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(); } } catch { this.balances = []; } } private formatBalance(balance: string, decimals: number): string { const val = Number(balance) / Math.pow(10, decimals); if (val >= 1000000) return `${(val / 1000000).toFixed(2)}M`; if (val >= 1000) return `${(val / 1000).toFixed(2)}K`; if (val >= 1) return val.toFixed(2); if (val >= 0.0001) return val.toFixed(4); return val.toExponential(2); } private formatUSD(val: string): string { const n = parseFloat(val); if (isNaN(n) || n === 0) return "$0"; if (n >= 1000000) return `$${(n / 1000000).toFixed(2)}M`; if (n >= 1000) return `$${(n / 1000).toFixed(1)}K`; return `$${n.toFixed(2)}`; } private shortenAddress(addr: string): string { return `${addr.slice(0, 6)}...${addr.slice(-4)}`; } private handleSubmit(e: Event) { e.preventDefault(); const input = this.shadow.querySelector("#address-input") as HTMLInputElement; if (input) { this.address = input.value.trim(); const url = new URL(window.location.href); url.searchParams.set("address", this.address); window.history.replaceState({}, "", url.toString()); this.detectChains(); } } private async handleChainSelect(chainId: string) { this.selectedChain = chainId; if (this.isDemo) { this.render(); return; } this.loading = true; this.render(); await this.loadBalances(); this.loading = false; this.render(); } private hasData(): boolean { return this.detectedChains.length > 0; } private renderStyles(): string { return ` `; } private renderHero(): string { if (this.hasData()) return ""; return `
rWallet
Multichain treasury visualization — Safe multisigs and EOA wallets
`; } private renderSupportedChains(): string { if (this.hasData() || this.loading) return ""; return `
${Object.entries(CHAIN_NAMES).map(([id, name]) => `
${name}
`).join("")}
`; } private renderFeatures(): string { if (this.hasData() || this.loading) return ""; return `

Safe Multisig

Visualize Gnosis Safe balances, signers, and thresholds across all chains.

🔗

Any Wallet (EOA)

Paste any 0x address — works for regular wallets too, not just Safes.

🌐

10+ Chains

Ethereum, Base, Polygon, Gnosis, Arbitrum, Optimism, and more in one view.

🔒

No Custody Risk

Read-only. rWallet never holds keys or moves funds — just visualizes.

`; } private renderExamples(): string { if (this.hasData() || this.loading) return ""; return `
Try an example
${EXAMPLE_WALLETS.map((w) => `
${w.name}
${w.address.slice(0, 6)}...${w.address.slice(-4)}
${w.type}
`).join("")}
`; } 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) => `
${ch.name}
`).join("")}
Total Value
${this.formatUSD(String(totalUSD))}
Tokens
${this.balances.filter((b) => parseFloat(b.fiatBalance || "0") > 0).length}
Chains
${this.detectedChains.length}
Address
${this.shortenAddress(this.address)}
${this.balances.length > 0 ? ` ${this.balances .filter((b) => parseFloat(b.fiatBalance || "0") > 0.01) .sort((a, b) => parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0")) .map((b) => ` `).join("")}
TokenBalanceUSD Value
${b.token?.symbol || "ETH"} ${b.token?.name || "Ether"} ${this.formatBalance(b.balance, b.token?.decimals || 18)} ${this.formatUSD(b.fiatBalance)}
` : '
No token balances found on this chain.
'}`; } private render() { this.shadow.innerHTML = ` ${this.renderStyles()} ${this.renderHero()}
Include testnets
${this.walletType ? `
${this.walletType === "safe" ? "⛓ Safe Multisig" : "👤 EOA Wallet"}
` : ""}
${this.renderSupportedChains()} ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Detecting wallet across chains...
' : ""} ${this.renderFeatures()} ${this.renderExamples()} ${this.renderDashboard()} `; // Event listeners const form = this.shadow.querySelector("#address-form"); form?.addEventListener("submit", (e) => this.handleSubmit(e)); this.shadow.querySelector("#testnet-toggle")?.addEventListener("click", () => { this.includeTestnets = !this.includeTestnets; if (this.address) this.detectChains(); else this.render(); }); this.shadow.querySelectorAll(".chain-btn").forEach((btn) => { btn.addEventListener("click", () => { const chainId = (btn as HTMLElement).dataset.chain!; this.handleChainSelect(chainId); }); }); this.shadow.querySelectorAll(".example-item").forEach((item) => { item.addEventListener("click", () => { const addr = (item as HTMLElement).dataset.address!; const input = this.shadow.querySelector("#address-input") as HTMLInputElement; if (input) input.value = addr; this.address = addr; const url = new URL(window.location.href); url.searchParams.set("address", addr); window.history.replaceState({}, "", url.toString()); this.detectChains(); }); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-wallet-viewer", FolkWalletViewer);