/** * — 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. */ 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" }, ]; 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; // Linked wallets state private isAuthenticated = false; private passKeyEOA = ""; private linkedWallets: LinkedWallet[] = []; private showProviderPicker = false; private discoveredProviders: DiscoveredProvider[] = []; private linkingInProgress = false; private linkError = ""; 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.checkAuthState(); this.render(); if (this.address) this.detectChains(); } 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.passKeyEOA = parsed.claims?.eid?.walletAddress || ""; this.loadLinkedWallets(); } } } 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 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; } // ── 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 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 || BigInt(b.balance || "0") > 0n).length}
Chains
${this.detectedChains.length}
Address
${this.shortenAddress(this.address)}
${this.balances.length > 0 ? ` ${this.balances .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")); }) .map((b) => ` `).join("")}
TokenBalanceUSD Value
${this.esc(b.token?.symbol || "ETH")} ${this.esc(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()} ${this.renderMyWallets()}
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(); }); }); // Linked wallet event listeners 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); }); }); this.shadow.querySelectorAll("[data-view-address]").forEach((item) => { item.addEventListener("click", (e) => { // Don't navigate if clicking the unlink button if ((e.target as HTMLElement).closest(".unlink-btn")) return; const addr = (item as HTMLElement).dataset.viewAddress!; this.handleViewLinkedWallet(addr); }); }); 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 esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-wallet-viewer", FolkWalletViewer);