/** * — 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", }; 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; 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(/^\/([^/]+)\/wallet/); return match ? `/${match[1]}/wallet` : ""; } 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.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/safe/detect/${this.address}`); 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.error = "No Safe wallets found for this address on any supported chain."; } else { // Auto-select first chain this.selectedChain = this.detectedChains[0].chainId; await this.loadBalances(); } } catch (e) { this.error = "Failed to detect chains. 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 res = await fetch(`${base}/api/safe/${this.selectedChain}/${this.address}/balances`); 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 render() { const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0); this.shadow.innerHTML = `
${!this.address && !this.loading ? `

Enter a Safe wallet address to visualize

Try: TEC Commons Fund

` : ""} ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Detecting Safe wallets across chains...
' : ""} ${!this.loading && this.detectedChains.length > 0 ? `
${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 balances found on this chain.
'} ` : ""} `; // Event listeners const form = this.shadow.querySelector("#address-form"); form?.addEventListener("submit", (e) => this.handleSubmit(e)); this.shadow.querySelectorAll(".chain-btn").forEach((btn) => { btn.addEventListener("click", () => { const chainId = (btn as HTMLElement).dataset.chain!; this.handleChainSelect(chainId); }); }); this.shadow.querySelectorAll(".demo-link").forEach((link) => { link.addEventListener("click", () => { const addr = (link as HTMLElement).dataset.address!; const input = this.shadow.querySelector("#address-input") as HTMLInputElement; if (input) input.value = addr; this.address = addr; this.detectChains(); }); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-wallet-viewer", FolkWalletViewer);