/** * — multichain Safe wallet visualization. * * Enter a Safe address to see balances across chains, transfer history, * and flow visualizations. Authenticated users can link external wallets * via EIP-6963 + SIWE. */ import { transformToTimelineData, transformToSankeyData, transformToMultichainData } from "../lib/data-transform"; import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-transform"; import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz"; import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data"; import { TourEngine } from "../../../shared/tour-engine"; 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; } interface LinkedWallet { id: string; address: string; type: "eoa" | "safe"; label: string; providerName?: string; providerRdns?: string; safeInfo?: { threshold: number; ownerCount: number; isEncryptIdOwner: boolean; }; } interface DiscoveredProvider { uuid: string; name: string; icon: string; rdns: 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" }, ]; type ViewTab = "balances" | "timeline" | "flow" | "sankey"; type TopTab = "my-wallets" | "visualizer"; interface AllChainBalanceEntry { chainId: string; chainName: string; balances: BalanceItem[]; } class FolkWalletViewer extends HTMLElement { private shadow: ShadowRoot; private address = ""; private detectedChains: ChainInfo[] = []; private selectedChain: string | null = null; private balances: BalanceItem[] = []; private allChainBalances: Map = new Map(); private chainFilter: string | null = null; // null = show all private loading = false; private error = ""; private isDemo = false; private walletType: "safe" | "eoa" | "" = ""; private includeTestnets = true; // Linked wallets state private isAuthenticated = false; private passKeyEOA = ""; private userDID = ""; private linkedWallets: LinkedWallet[] = []; private showProviderPicker = false; private discoveredProviders: DiscoveredProvider[] = []; private linkingInProgress = false; private linkError = ""; // CRDT token balances private crdtBalances: Array<{ tokenId: string; name: string; symbol: string; decimals: number; icon: string; color: string; balance: number }> = []; private crdtLoading = false; // Top-level tab private topTab: TopTab = "visualizer"; private myWalletBalances: Map> = new Map(); private myWalletsLoading = false; // Visualization state private activeView: ViewTab = "balances"; private transfers: Map | null = null; private transfersLoading = false; private d3Ready = false; private vizData: { timeline?: TimelineEntry[]; sankey?: SankeyData; multichain?: MultichainData; } = {}; // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '#address-input', title: "Enter Address", message: "Paste any wallet or Safe multisig address to load balances across all supported chains.", advanceOnClick: false }, { target: '#testnet-toggle', title: "Testnet Toggle", message: "Include testnet chains in the scan. Useful for checking Safe deployments on Sepolia or Goerli.", advanceOnClick: true }, { target: '.chain-btn', title: "Chain Selector", message: "Click a chain to load balances for that specific network. Active chains are highlighted.", advanceOnClick: false }, { target: '.view-tab', title: "Dashboard Views", message: "Switch between balance view, transfer timeline, and flow visualisations for deeper analysis.", advanceOnClick: false }, ]; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); this._tour = new TourEngine( this.shadow, FolkWalletViewer.TOUR_STEPS, "rwallet_tour_done", () => this.shadow.host as HTMLElement, ); } connectedCallback() { // Read initial-view attribute from server route const initialView = this.getAttribute("initial-view"); if (initialView && ["balances", "timeline", "flow", "sankey"].includes(initialView)) { this.activeView = initialView as ViewTab; } const space = this.getAttribute("space") || ""; if (space === "demo") { this.loadDemoData(); } else { const params = new URLSearchParams(window.location.search); this.address = params.get("address") || ""; this.checkAuthState(); // If address in URL, show visualizer regardless of auth if (this.address) { this.topTab = "visualizer"; } // For visualizer tab: auto-load address or demo if (this.topTab === "visualizer" && !this.address) { if (this.passKeyEOA) { this.address = this.passKeyEOA; } else { this.address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; } } this.render(); if (this.topTab === "visualizer" && this.address) this.detectChains(); } if (!localStorage.getItem("rwallet_tour_done")) { setTimeout(() => this._tour.start(), 1200); } } private checkAuthState() { try { const session = localStorage.getItem("encryptid_session"); if (session) { const parsed = JSON.parse(session); if (parsed.claims?.exp > Math.floor(Date.now() / 1000)) { this.isAuthenticated = true; this.topTab = "my-wallets"; this.passKeyEOA = parsed.claims?.eid?.walletAddress || ""; this.userDID = parsed.claims?.did || ""; this.loadLinkedWallets().then(() => this.loadMyWalletBalances()); this.loadCRDTBalances(); } } } catch {} } private getAuthToken(): string | null { try { const session = localStorage.getItem("encryptid_session"); if (!session) return null; const parsed = JSON.parse(session); return parsed.accessToken || null; } catch { return null; } } private async loadLinkedWallets() { const token = this.getAuthToken(); if (!token) return; try { const res = await fetch("/encryptid/api/wallet-link/list", { headers: { "Authorization": `Bearer ${token}` }, }); if (!res.ok) return; const data = await res.json(); // Server decrypts at rest and returns entry data directly this.linkedWallets = (data.wallets || []).map((w: any) => ({ id: w.id, address: w.address || "", type: w.type || "eoa", label: w.label || "", providerName: w.providerName, providerRdns: w.providerRdns, safeInfo: w.safeInfo, })); this.render(); } catch {} } private async loadCRDTBalances() { const token = this.getAuthToken(); if (!token) return; this.crdtLoading = true; try { const base = this.getApiBase(); const res = await fetch(`${base}/api/crdt-tokens/my-balances`, { headers: { "Authorization": `Bearer ${token}` }, }); if (!res.ok) return; const data = await res.json(); this.crdtBalances = data.balances || []; } catch {} this.crdtLoading = false; this.render(); } private async loadMyWalletBalances() { const addresses: Array<{ address: string; type: "eoa" | "safe" }> = []; if (this.passKeyEOA) { addresses.push({ address: this.passKeyEOA, type: "eoa" }); } for (const w of this.linkedWallets) { addresses.push({ address: w.address, type: w.type }); } if (addresses.length === 0) return; this.myWalletsLoading = true; this.render(); const base = this.getApiBase(); const tn = this.includeTestnets ? "" : "?testnets=false"; await Promise.allSettled( addresses.map(async ({ address, type }) => { try { const endpoint = type === "safe" ? `${base}/api/safe/${address}/all-balances${tn}` : `${base}/api/eoa/${address}/all-balances${tn}`; const res = await fetch(endpoint); if (!res.ok) return; const data = await res.json(); if (data.chains && data.chains.length > 0) { this.myWalletBalances.set(address.toLowerCase(), data.chains); } } catch {} }), ); this.myWalletsLoading = false; this.render(); } private renderLocalTokens(): string { if (!this.isAuthenticated) return ""; if (this.crdtLoading) { return `
Loading local tokens...
`; } if (this.crdtBalances.length === 0) return ""; const rows = this.crdtBalances.map((t) => { const formatted = (t.balance / Math.pow(10, t.decimals)).toFixed(2); return ` ${t.icon || '🪙'} ${this.esc(t.symbol)} ${this.esc(t.name)} ${formatted} `; }).join(''); return `

Local Tokens (CRDT)

${rows}
Token Name Balance
`; } 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" }, ]; // Pre-load demo viz data this.vizData = { timeline: DEMO_TIMELINE_DATA, sankey: DEMO_SANKEY_DATA, multichain: DEMO_MULTICHAIN_DATA, }; 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.allChainBalances = new Map(); this.chainFilter = null; this.walletType = ""; this.activeView = "balances"; this.transfers = null; this.vizData = {}; this.render(); try { const base = this.getApiBase(); const tn = this.includeTestnets ? "" : "?testnets=false"; // Try Safe all-balances first const safeRes = await fetch(`${base}/api/safe/${this.address}/all-balances${tn}`); const safeData = await safeRes.json(); if (safeData.chains && safeData.chains.length > 0) { this.walletType = "safe"; this.populateFromAllBalances(safeData.chains); } else { // Fall back to EOA all-balances const eoaRes = await fetch(`${base}/api/eoa/${this.address}/all-balances${tn}`); const eoaData = await eoaRes.json(); if (eoaData.chains && eoaData.chains.length > 0) { this.walletType = "eoa"; this.populateFromAllBalances(eoaData.chains); } 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 populateFromAllBalances(chains: Array<{ chainId: string; chainName: string; balances: BalanceItem[] }>) { this.allChainBalances = new Map(); this.detectedChains = []; for (const ch of chains) { this.allChainBalances.set(ch.chainId, { chainId: ch.chainId, chainName: ch.chainName, balances: ch.balances, }); this.detectedChains.push({ chainId: ch.chainId, name: ch.chainName, prefix: "", color: CHAIN_COLORS[ch.chainId] || "#888", }); } // Set selectedChain for viz compatibility (first chain) if (this.detectedChains.length > 0) { this.selectedChain = this.detectedChains[0].chainId; } // Build flattened balances for backward compat (viz data, etc.) this.balances = this.getFilteredBalances(); } /** Get balances respecting current chainFilter */ private getFilteredBalances(): BalanceItem[] { const result: BalanceItem[] = []; for (const [chainId, entry] of this.allChainBalances) { if (this.chainFilter && this.chainFilter !== chainId) continue; result.push(...entry.balances); } return result; } /** Get all balances (including CRDT) for the unified table, with chain info attached. * Pass ignoreFilter=true to get all chains regardless of current filter (for stats). */ private getUnifiedBalances(ignoreFilter = false): Array { const result: Array = []; for (const [chainId, entry] of this.allChainBalances) { if (!ignoreFilter && this.chainFilter && this.chainFilter !== chainId) continue; for (const b of entry.balances) { result.push({ ...b, chainId, chainName: entry.chainName }); } } // Merge CRDT tokens (when showing all or filtering to "local") if (this.isAuthenticated && this.crdtBalances.length > 0 && (ignoreFilter || !this.chainFilter || this.chainFilter === "local")) { for (const t of this.crdtBalances) { result.push({ tokenAddress: `crdt:${t.tokenId}`, token: { name: t.name, symbol: t.symbol, decimals: t.decimals }, balance: t.balance.toString(), fiatBalance: "0", fiatConversion: "0", chainId: "local", chainName: "Local", }); } } return result; } 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 = []; } } // ── Transfer Loading for Visualizations ── private async loadTransfers() { if (this.isDemo || this.transfers || this.transfersLoading) return; if (this.walletType !== "safe") return; // Transfer API only for Safes this.transfersLoading = true; this.render(); try { const base = this.getApiBase(); const chainDataMap = new Map(); // Fan out requests to all detected chains await Promise.allSettled( this.detectedChains.map(async (ch) => { try { const res = await fetch(`${base}/api/safe/${ch.chainId}/${this.address}/transfers?limit=200`); if (!res.ok) return; const data = await res.json(); // Parse results into incoming/outgoing const incoming: any[] = []; const outgoing: any[] = []; const results = data.results || []; for (const tx of results) { if (tx.txType === "MULTISIG_TRANSACTION") { outgoing.push(tx); } // Incoming transfers from transfers array inside txs if (tx.transfers) { for (const t of tx.transfers) { if (t.to?.toLowerCase() === this.address.toLowerCase()) { incoming.push(t); } } } } chainDataMap.set(ch.chainId, { incoming, outgoing, chainId: ch.chainId }); } catch {} }), ); this.transfers = chainDataMap; this.computeVizData(); } catch { this.transfers = new Map(); } this.transfersLoading = false; this.render(); if (this.activeView !== "balances") { requestAnimationFrame(() => this.drawActiveVisualization()); } } private computeVizData() { if (!this.transfers) return; this.vizData.timeline = transformToTimelineData(this.transfers, this.address, CHAIN_NAMES); // Sankey for selected chain if (this.selectedChain && this.transfers.has(this.selectedChain)) { this.vizData.sankey = transformToSankeyData( this.transfers.get(this.selectedChain), this.address, this.selectedChain, ); } this.vizData.multichain = transformToMultichainData(this.transfers, this.address, CHAIN_NAMES); } private async drawActiveVisualization() { const container = this.shadow.querySelector("#viz-container") as HTMLElement; if (!container) return; // Lazy-load D3 if (!this.d3Ready) { container.innerHTML = '
Loading visualization library...
'; try { await loadD3(); this.d3Ready = true; } catch { container.innerHTML = '
Failed to load D3 visualization library.
'; return; } } // Build chain color map for flow chart const chainColorMap: Record = {}; for (const ch of this.detectedChains) { chainColorMap[ch.name.toLowerCase()] = ch.color; } switch (this.activeView) { case "timeline": if (this.vizData.timeline && this.vizData.timeline.length > 0) { renderTimeline(container, this.vizData.timeline, { chainColors: chainColorMap }); } else { container.innerHTML = '
No timeline data available. Transfer data may still be loading.
'; } break; case "flow": if (this.vizData.multichain) { const mc = this.vizData.multichain; renderFlowChart(container, mc.flowData["all"] || [], mc.chainStats["all"], { chainColors: chainColorMap, safeAddress: this.address, }); } else { container.innerHTML = '
No flow data available.
'; } break; case "sankey": if (this.vizData.sankey && this.vizData.sankey.links.length > 0) { renderSankey(container, this.vizData.sankey); } else { container.innerHTML = '
No Sankey data available for the selected chain.
'; } break; } } 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 handleChainSelect(chainId: string) { if (this.isDemo) { this.selectedChain = chainId; this.render(); return; } // Toggle filter: click same chain again to show all if (this.chainFilter === chainId) { this.chainFilter = null; } else { this.chainFilter = chainId; } // Update selectedChain for viz compatibility this.selectedChain = chainId; this.balances = this.getFilteredBalances(); // Recompute sankey for selected chain if (this.transfers && this.transfers.has(chainId)) { this.vizData.sankey = transformToSankeyData( this.transfers.get(chainId), this.address, chainId, ); } this.render(); if (this.activeView !== "balances") { requestAnimationFrame(() => this.drawActiveVisualization()); } } private handleViewTabClick(view: ViewTab) { if (this.activeView === view) return; this.activeView = view; if (view !== "balances" && !this.transfers && !this.isDemo) { this.loadTransfers(); } this.render(); if (view !== "balances") { requestAnimationFrame(() => this.drawActiveVisualization()); } } private hasData(): boolean { return this.detectedChains.length > 0; } // ── EIP-6963 Provider Discovery ── private sanitizeIconUri(uri: string): string { if (!uri) return ""; // Allow https: URLs and safe data: image types (no SVG — can contain scripts) if (/^https:\/\//i.test(uri)) return uri; if (/^data:image\/(png|jpeg|gif|webp);base64,/i.test(uri)) return uri; return ""; // Block javascript:, data:image/svg+xml, etc. } private startProviderDiscovery() { this.discoveredProviders = []; this.showProviderPicker = true; this.linkError = ""; this.render(); const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.info?.uuid || !detail?.provider) return; const exists = this.discoveredProviders.some(p => p.uuid === detail.info.uuid); if (!exists) { this.discoveredProviders.push({ uuid: detail.info.uuid, name: detail.info.name, icon: this.sanitizeIconUri(detail.info.icon || ""), rdns: detail.info.rdns, }); this.render(); } }; window.addEventListener("eip6963:announceProvider", handler); window.dispatchEvent(new Event("eip6963:requestProvider")); // Store handler for cleanup (this as any)._eip6963Handler = handler; // If no providers found after 2s, show message setTimeout(() => { if (this.discoveredProviders.length === 0 && this.showProviderPicker) { this.linkError = "No browser wallets detected. Install MetaMask or another EIP-6963 compatible wallet."; this.render(); } }, 2000); } private stopProviderDiscovery() { const handler = (this as any)._eip6963Handler; if (handler) { window.removeEventListener("eip6963:announceProvider", handler); delete (this as any)._eip6963Handler; } this.showProviderPicker = false; this.discoveredProviders = []; this.linkError = ""; } private async handleProviderSelect(uuid: string) { const provider = this.discoveredProviders.find(p => p.uuid === uuid); if (!provider) return; this.linkingInProgress = true; this.linkError = ""; this.render(); try { // Get the actual EIP-1193 provider reference let eip1193Provider: any = null; const getProvider = new Promise((resolve) => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (detail?.info?.uuid === uuid) { window.removeEventListener("eip6963:announceProvider", handler); resolve(detail.provider); } }; window.addEventListener("eip6963:announceProvider", handler); window.dispatchEvent(new Event("eip6963:requestProvider")); setTimeout(() => resolve(null), 3000); }); eip1193Provider = await getProvider; if (!eip1193Provider) throw new Error("Could not get wallet provider"); // 1. Request accounts const accounts = await eip1193Provider.request({ method: "eth_requestAccounts" }); if (!accounts || accounts.length === 0) throw new Error("No accounts returned"); const walletAddress = accounts[0] as string; // 2. Get nonce from server const token = this.getAuthToken(); if (!token) throw new Error("Not authenticated"); const nonceRes = await fetch("/encryptid/api/wallet-link/nonce", { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, }); if (!nonceRes.ok) throw new Error("Failed to get nonce"); const { nonce } = await nonceRes.json(); // 3. Build SIWE message const domain = window.location.host; const origin = window.location.origin; const issuedAt = new Date().toISOString(); const siweMessage = [ `${domain} wants you to sign in with your Ethereum account:`, walletAddress, "", "Link this wallet to your EncryptID identity", "", `URI: ${origin}`, `Version: 1`, `Chain ID: 1`, `Nonce: ${nonce}`, `Issued At: ${issuedAt}`, ].join("\n"); // 4. Sign with external wallet const signature = await eip1193Provider.request({ method: "personal_sign", params: [siweMessage, walletAddress], }); // 5. Hash the address for dedup (salted with user ID from session) const addressHash = await this.hashAddress(walletAddress); // 6. Build entry data — server encrypts at rest with AES-256-GCM const entry = { address: walletAddress, type: "eoa", label: provider.name, providerRdns: provider.rdns, providerName: provider.name, }; // 7. Verify with server (server handles encryption at rest) const verifyRes = await fetch("/encryptid/api/wallet-link/verify", { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ message: siweMessage, signature, addressHash, walletType: "eoa", entry, }), }); if (!verifyRes.ok) { const err = await verifyRes.json(); throw new Error(err.error || "Verification failed"); } const result = await verifyRes.json(); // 8. Add to local state const newWallet: LinkedWallet = { id: result.id, address: walletAddress, type: "eoa", label: provider.name, providerName: provider.name, providerRdns: provider.rdns, }; this.linkedWallets.push(newWallet); this.stopProviderDiscovery(); } catch (err: any) { this.linkError = err?.message || "Failed to link wallet"; } this.linkingInProgress = false; this.render(); } private async handleUnlinkWallet(id: string) { const token = this.getAuthToken(); if (!token) return; try { const res = await fetch(`/encryptid/api/wallet-link/${id}`, { method: "DELETE", headers: { "Authorization": `Bearer ${token}` }, }); if (res.ok) { this.linkedWallets = this.linkedWallets.filter(w => w.id !== id); this.render(); } } catch {} } private async handleViewLinkedWallet(address: string) { this.address = address; const input = this.shadow.querySelector("#address-input") as HTMLInputElement; if (input) input.value = address; const url = new URL(window.location.href); url.searchParams.set("address", address); window.history.replaceState({}, "", url.toString()); await this.detectChains(); } private async hashAddress(address: string): Promise { // Salt with user ID to prevent cross-user address correlation const session = localStorage.getItem("encryptid_session"); const userId = session ? (JSON.parse(session).claims?.sub || "") : ""; const normalized = userId + ":" + address.toLowerCase(); const encoded = new TextEncoder().encode(normalized); const hash = await crypto.subtle.digest("SHA-256", encoded); const bytes = new Uint8Array(hash); return btoa(String.fromCharCode(...bytes)) .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // ── Render Methods ── private renderStyles(): string { return ` `; } private renderHero(): string { if (this.hasData()) return ""; return `
rWallet
Multichain treasury visualization — Safe multisigs and EOA wallets
`; } private renderMyWallets(): string { if (!this.isAuthenticated) return ""; const hasWallets = this.passKeyEOA || this.linkedWallets.length > 0; return `
My Wallets
${this.passKeyEOA ? `
EncryptID Passkey EOA
${this.shortenAddress(this.passKeyEOA)}
` : ""} ${this.linkedWallets.map(w => `
${this.esc(w.providerName || w.type.toUpperCase())} ${this.esc(w.label)} ${w.safeInfo && !w.safeInfo.isEncryptIdOwner ? '(not co-signer)' : ""}
${this.shortenAddress(w.address)}
`).join("")} ${!hasWallets ? '
No wallets linked yet. Click "Link Wallet" to connect a browser wallet.
' : ""}
${this.renderProviderPicker()}
`; } private renderProviderPicker(): string { if (!this.showProviderPicker) return ""; return `
${this.linkingInProgress ? ' Connecting...' : 'Select a wallet to link:'}
${!this.linkingInProgress ? `
${this.discoveredProviders.map(p => `
${p.icon ? `` : ""} ${this.esc(p.name)}
`).join("")} ${this.discoveredProviders.length === 0 ? '
Searching for wallets...
' : ""}
` : ""} ${this.linkError ? `` : ""}
`; } 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 renderTopTabBar(): string { return `
`; } private renderMyWalletsTab(): string { if (!this.isAuthenticated) { return `

Sign in to view your wallets

Sign in with EncryptID →
`; } if (this.myWalletsLoading) { return `
Loading wallet balances...
`; } let html = '
'; // EncryptID identity card — always shown for authenticated users // Every EncryptID identity has both a CRDT wallet (DID) and an EVM wallet (passkey-derived) html += this.renderEncryptIdCard(); // Linked wallet cards (external wallets) for (const w of this.linkedWallets) { html += this.renderWalletCard(w.address, w.providerName || w.type.toUpperCase(), w.type, false, w.id); } html += '
'; // Aggregate total html += this.renderAggregateTotal(); // Link wallet button + provider picker html += ` ${this.renderProviderPicker()}`; return html; } private renderEncryptIdCard(): string { // ── CRDT Wallet (DID) ── const didShort = this.userDID ? `${this.userDID.slice(0, 16)}...${this.userDID.slice(-6)}` : "loading..."; let crdtRows = ""; if (this.crdtLoading) { crdtRows = `
Loading...
`; } else if (this.crdtBalances.length > 0) { crdtRows = this.crdtBalances.map(t => { const formatted = (t.balance / Math.pow(10, t.decimals)).toFixed(2); return `
${t.icon || '\u{1FA99}'} ${this.esc(t.symbol)} ${this.esc(t.name)} ${formatted}
`; }).join(""); } else { crdtRows = `
No CRDT token balances
`; } // ── EVM Wallet (passkey-derived) ── const chainBalances = this.passKeyEOA ? (this.myWalletBalances.get(this.passKeyEOA.toLowerCase()) || []) : []; const allBals: Array = []; for (const ch of chainBalances) { for (const b of ch.balances) { allBals.push({ ...b, chainId: ch.chainId, chainName: ch.chainName }); } } const sorted = allBals .filter(b => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n) .sort((a, b) => parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0")); const totalUSD = sorted.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0); let evmBalanceRows = ""; if (sorted.length > 0) { evmBalanceRows = sorted.slice(0, 10).map(b => { const color = CHAIN_COLORS[b.chainId] || "#888"; return `
${this.esc(b.chainName)}
${this.esc(b.token?.symbol || "ETH")} ${this.formatBalance(b.balance, b.token?.decimals || 18)} ${this.formatUSD(b.fiatBalance)} `; }).join(""); if (sorted.length > 10) { evmBalanceRows += `+ ${sorted.length - 10} more tokens`; } } return `
EncryptID ${this.formatUSD(String(totalUSD))}
${this.passKeyEOA ? `` : ""}
CRDT Wallet ${this.esc(didShort)}
${crdtRows}
EVM Wallet ${this.passKeyEOA ? this.shortenAddress(this.passKeyEOA) : "not derived"}
${evmBalanceRows ? ` ${evmBalanceRows}
ChainTokenBalanceUSD
` : `
No on-chain balances found
`}
`; } private renderWalletCard(address: string, label: string, badgeClass: string, _isEncryptId: boolean, walletId?: string): string { const chainBalances = this.myWalletBalances.get(address.toLowerCase()) || []; // Flatten all balances for this wallet const allBals: Array = []; for (const ch of chainBalances) { for (const b of ch.balances) { allBals.push({ ...b, chainId: ch.chainId, chainName: ch.chainName }); } } const sorted = allBals .filter(b => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n) .sort((a, b) => parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0")); const totalUSD = sorted.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0); let balanceRows = ""; if (sorted.length > 0) { balanceRows = sorted.slice(0, 10).map(b => { const color = CHAIN_COLORS[b.chainId] || "#888"; return `
${this.esc(b.chainName)}
${this.esc(b.token?.symbol || "ETH")} ${this.formatBalance(b.balance, b.token?.decimals || 18)} ${this.formatUSD(b.fiatBalance)} `; }).join(""); if (sorted.length > 10) { balanceRows += `+ ${sorted.length - 10} more tokens`; } } return `
${this.esc(label)} ${this.shortenAddress(address)}
${this.formatUSD(String(totalUSD))} ${walletId ? `` : ""}
${sorted.length > 0 ? ` ${balanceRows}
ChainTokenBalanceUSD
` : `
No on-chain balances found
`}
`; } private renderAggregateTotal(): string { let grandTotal = 0; let totalTokens = 0; const totalChains = new Set(); for (const [, chains] of this.myWalletBalances) { for (const ch of chains) { totalChains.add(ch.chainId); for (const b of ch.balances) { if (parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n) { grandTotal += parseFloat(b.fiatBalance || "0"); totalTokens++; } } } } if (grandTotal === 0 && totalTokens === 0) return ""; const walletCount = (this.passKeyEOA ? 1 : 0) + this.linkedWallets.length; return `
Total Portfolio
${this.formatUSD(String(grandTotal))}
Wallets
${walletCount}
Tokens
${totalTokens}
Chains
${totalChains.size}
`; } private renderViewTabs(): string { if (!this.hasData()) return ""; const tabs: { id: ViewTab; label: string }[] = [ { id: "balances", label: "Balances" }, { id: "timeline", label: "Timeline" }, { id: "flow", label: "Flow Map" }, { id: "sankey", label: "Sankey" }, ]; // Only show viz tabs for Safe wallets (or demo) const showViz = this.walletType === "safe" || this.isDemo; const visibleTabs = showViz ? tabs : [tabs[0]]; return `
${visibleTabs.map(t => ` `).join("")}
`; } private renderBalanceTable(): string { const unified = this.getUnifiedBalances(); if (unified.length === 0) return '
No token balances found.
'; const sorted = unified .filter((b) => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n) .sort((a, b) => { const fiatDiff = parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0"); if (fiatDiff !== 0) return fiatDiff; return Number(BigInt(b.balance || "0") - BigInt(a.balance || "0")); }); if (sorted.length === 0) return '
No token balances found.
'; return ` ${sorted.map((b) => { const color = CHAIN_COLORS[b.chainId] || (b.chainId === "local" ? "#2775ca" : "#888"); return ` `; }).join("")}
ChainTokenBalanceUSD Value
${this.esc(b.chainName)}
${this.esc(b.token?.symbol || "ETH")} ${this.esc(b.token?.name || "Ether")} ${this.formatBalance(b.balance, b.token?.decimals || 18)} ${this.formatUSD(b.fiatBalance)}
`; } private renderDashboard(): string { if (!this.hasData()) return ""; // Aggregate stats across ALL chains (ignoring filter) const allBalances = this.getUnifiedBalances(true); const totalUSD = allBalances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0); const totalTokens = allBalances.filter((b) => parseFloat(b.fiatBalance || "0") > 0 || BigInt(b.balance || "0") > 0n).length; // Build chain buttons with "All" filter const chainButtons = this.detectedChains.map((ch) => { const isActive = this.chainFilter === ch.chainId; return `
${ch.name}
`; }).join(""); // Add "Local" filter button if CRDT tokens exist const localBtn = (this.isAuthenticated && this.crdtBalances.length > 0) ? `
Local
` : ""; return `
All
${chainButtons} ${localBtn}
Total Value
${this.formatUSD(String(totalUSD))}
Tokens
${totalTokens}
Chains
${this.allChainBalances.size}
Address
${this.shortenAddress(this.address)}
${this.renderViewTabs()} ${this.activeView === "balances" ? this.renderBalanceTable() : `
${this.transfersLoading ? '
Loading transfer data...
' : ""}
` }`; } private renderVisualizerTab(): string { return ` ${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()} `; } private attachVisualizerListeners() { 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!; if (chainId === "all") { this.chainFilter = null; this.balances = this.getFilteredBalances(); this.render(); } else { 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(); }); }); // View tab listeners (skip tour button which has no data-view) this.shadow.querySelectorAll(".view-tab[data-view]").forEach((tab) => { tab.addEventListener("click", () => { const view = (tab as HTMLElement).dataset.view as ViewTab; this.handleViewTabClick(view); }); }); this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); // Draw visualization if active if (this.activeView !== "balances" && this.hasData()) { requestAnimationFrame(() => this.drawActiveVisualization()); } } private attachMyWalletsListeners() { // "View Flows →" buttons this.shadow.querySelectorAll("[data-view-in-viz]").forEach((btn) => { btn.addEventListener("click", () => { const addr = (btn as HTMLElement).dataset.viewInViz!; this.topTab = "visualizer"; this.address = addr; const url = new URL(window.location.href); url.searchParams.set("address", addr); window.history.replaceState({}, "", url.toString()); this.render(); this.detectChains(); }); }); // Link wallet this.shadow.querySelector("#link-wallet-btn")?.addEventListener("click", () => { this.startProviderDiscovery(); }); this.shadow.querySelector("#provider-cancel")?.addEventListener("click", () => { this.stopProviderDiscovery(); this.render(); }); this.shadow.querySelectorAll(".provider-item").forEach((item) => { item.addEventListener("click", () => { const uuid = (item as HTMLElement).dataset.providerUuid!; this.handleProviderSelect(uuid); }); }); // Unlink buttons this.shadow.querySelectorAll("[data-unlink]").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const id = (btn as HTMLElement).dataset.unlink!; if (confirm("Unlink this wallet?")) { this.handleUnlinkWallet(id); } }); }); } private render() { const isMyWallets = this.topTab === "my-wallets" && this.isAuthenticated; this.shadow.innerHTML = ` ${this.renderStyles()} ${this.isAuthenticated ? this.renderTopTabBar() : ''} ${isMyWallets ? this.renderMyWalletsTab() : this.renderVisualizerTab()} `; // Top tab listeners this.shadow.querySelectorAll(".top-tab").forEach((tab) => { tab.addEventListener("click", () => { this.topTab = (tab as HTMLElement).dataset.topTab as TopTab; this.render(); }); }); if (isMyWallets) { this.attachMyWalletsListeners(); } else { this.attachVisualizerListeners(); } this._tour.renderOverlay(); } startTour() { this._tour.start(); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-wallet-viewer", FolkWalletViewer);