/** * — 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 = ""; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { // 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 getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/wallet/); return match ? `/${match[1]}/wallet` : ""; } private async detectChains() { 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.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; 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);