rspace-online/modules/rwallet/components/folk-wallet-viewer.ts

1072 lines
38 KiB
TypeScript

/**
* <folk-wallet-viewer> — 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<string, string> = {
"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<string, string> = {
"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<any>((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<string> {
// 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 `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; }
/* ── Hero ── */
.hero {
text-align: center; padding: 32px 16px 24px;
border-bottom: 1px solid var(--rs-border-subtle); margin-bottom: 24px;
}
.hero-title {
font-size: 28px; font-weight: 700; margin: 0 0 6px;
background: linear-gradient(135deg, #00d4ff, #4ade80);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle { font-size: 14px; color: var(--rs-text-secondary); margin: 0 0 20px; }
/* ── Address bar ── */
.address-bar { display: flex; gap: 8px; margin-bottom: 12px; max-width: 640px; margin-left: auto; margin-right: auto; }
.address-bar input {
flex: 1; padding: 12px 16px; border-radius: 10px;
border: 1px solid var(--rs-input-border); background: var(--rs-bg-surface); color: var(--rs-text-primary);
font-family: monospace; font-size: 14px;
}
.address-bar input:focus { border-color: #00d4ff; outline: none; box-shadow: 0 0 0 2px rgba(0,212,255,0.15); }
.address-bar button {
padding: 12px 24px; border-radius: 10px; border: none;
background: linear-gradient(135deg, #00d4ff, #00b8d9); color: #000;
font-weight: 600; cursor: pointer; font-size: 14px; transition: opacity 0.2s;
}
.address-bar button:hover { opacity: 0.9; }
/* ── Controls row ── */
.controls-row {
display: flex; align-items: center; justify-content: space-between;
max-width: 640px; margin: 0 auto 24px; flex-wrap: wrap; gap: 8px;
}
.testnet-toggle {
display: flex; align-items: center; gap: 8px;
cursor: pointer; user-select: none; font-size: 13px; color: var(--rs-text-secondary);
}
.testnet-toggle:hover { color: var(--rs-text-primary); }
.toggle-track {
width: 34px; height: 18px; border-radius: 9px; background: var(--rs-border);
position: relative; transition: background 0.2s;
}
.testnet-toggle.active .toggle-track { background: #f59e0b; }
.toggle-thumb {
width: 14px; height: 14px; border-radius: 50%; background: var(--rs-text-muted);
position: absolute; top: 2px; left: 2px; transition: all 0.2s;
}
.testnet-toggle.active .toggle-thumb { left: 18px; background: #fff; }
.wallet-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px;
}
.wallet-badge.safe { background: rgba(74,222,128,0.12); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); }
.wallet-badge.eoa { background: rgba(0,212,255,0.12); color: #00d4ff; border: 1px solid rgba(0,212,255,0.25); }
.wallet-badge.encryptid { background: rgba(168,85,247,0.12); color: #a855f7; border: 1px solid rgba(168,85,247,0.25); }
/* ── My Wallets section ── */
.my-wallets {
max-width: 640px; margin: 0 auto 24px;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 12px;
padding: 16px;
}
.my-wallets-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;
}
.my-wallets-title { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--rs-text-secondary); }
.link-wallet-btn {
padding: 6px 14px; border-radius: 8px; border: 1px dashed var(--rs-border);
background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 12px;
transition: all 0.2s;
}
.link-wallet-btn:hover { border-color: #00d4ff; color: #00d4ff; background: rgba(0,212,255,0.05); }
.wallet-list { display: flex; flex-direction: column; gap: 6px; }
.wallet-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
border-radius: 8px; background: var(--rs-bg-hover); cursor: pointer;
transition: all 0.15s;
}
.wallet-item:hover { background: var(--rs-bg-surface); box-shadow: 0 0 0 1px var(--rs-border); }
.wallet-item-info { flex: 1; min-width: 0; }
.wallet-item-label { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); }
.wallet-item-addr { font-size: 11px; color: var(--rs-text-muted); font-family: monospace; }
.wallet-item-actions { display: flex; gap: 4px; }
.wallet-item-actions button {
padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; font-size: 11px;
background: transparent; color: var(--rs-text-muted); transition: all 0.15s;
}
.wallet-item-actions button:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface); }
.wallet-item-actions .unlink-btn:hover { color: var(--rs-error); }
/* ── Provider picker ── */
.provider-picker {
margin-top: 12px; padding: 12px; border-radius: 8px;
background: var(--rs-bg-hover); border: 1px solid var(--rs-border-subtle);
}
.provider-picker-title { font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 8px; }
.provider-list { display: flex; flex-wrap: wrap; gap: 8px; }
.provider-item {
display: flex; align-items: center; gap: 8px; padding: 8px 14px;
border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface);
cursor: pointer; transition: all 0.2s; font-size: 13px; color: var(--rs-text-primary);
}
.provider-item:hover { border-color: #00d4ff; background: rgba(0,212,255,0.05); }
.provider-item img { width: 20px; height: 20px; border-radius: 4px; }
.provider-cancel {
margin-top: 8px; padding: 4px 10px; border-radius: 6px; border: none;
background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 12px;
}
.provider-cancel:hover { color: var(--rs-text-primary); }
/* ── Supported chains ── */
.supported-chains {
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
margin-bottom: 28px; max-width: 640px; margin-left: auto; margin-right: auto;
}
.supported-chain {
display: flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 6px; background: var(--rs-bg-surface);
border: 1px solid var(--rs-border-subtle); font-size: 12px; color: var(--rs-text-secondary);
}
.supported-chain .dot { width: 6px; height: 6px; border-radius: 50%; }
/* ── Feature cards ── */
.features {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px; margin-bottom: 28px;
}
.feature-card {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 12px;
padding: 20px; text-align: center; transition: border-color 0.2s;
}
.feature-card:hover { border-color: var(--rs-border-strong); }
.feature-icon { font-size: 28px; margin-bottom: 10px; }
.feature-card h3 { font-size: 14px; font-weight: 600; margin: 0 0 6px; color: var(--rs-text-primary); }
.feature-card p { font-size: 12px; color: var(--rs-text-secondary); margin: 0; line-height: 1.5; }
/* ── Example wallets ── */
.examples {
max-width: 640px; margin: 0 auto 20px;
}
.examples-label { font-size: 11px; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
.example-list { display: flex; flex-direction: column; gap: 6px; }
.example-item {
display: flex; align-items: center; gap: 10px; padding: 10px 14px;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 10px;
cursor: pointer; transition: all 0.2s;
}
.example-item:hover { border-color: #00d4ff; background: var(--rs-bg-hover); }
.example-name { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); }
.example-addr { font-size: 11px; color: var(--rs-text-muted); font-family: monospace; }
.example-type {
margin-left: auto; font-size: 10px; font-weight: 600;
padding: 2px 8px; border-radius: 4px; text-transform: uppercase;
}
.example-type.safe { background: rgba(74,222,128,0.1); color: #4ade80; }
.example-type.eoa { background: rgba(0,212,255,0.1); color: #00d4ff; }
/* ── Dashboard: chains ── */
.chains { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
.chain-btn {
padding: 6px 14px; border-radius: 8px; border: 2px solid var(--rs-border);
background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 13px;
display: flex; align-items: center; gap: 6px; transition: all 0.2s;
}
.chain-btn:hover { border-color: var(--rs-border-strong); }
.chain-btn.active { border-color: var(--chain-color); background: var(--rs-bg-hover); }
.chain-dot { width: 8px; height: 8px; border-radius: 50%; }
/* ── Dashboard: stats ── */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
.stat-card {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px;
padding: 14px; text-align: center;
}
.stat-label { font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase; margin-bottom: 6px; }
.stat-value { font-size: 20px; font-weight: 700; color: #00d4ff; }
/* ── Dashboard: balance table ── */
.balance-table { width: 100%; border-collapse: collapse; }
.balance-table th {
text-align: left; padding: 10px 8px; border-bottom: 2px solid var(--rs-border);
color: var(--rs-text-secondary); font-size: 11px; text-transform: uppercase;
}
.balance-table td { padding: 10px 8px; border-bottom: 1px solid var(--rs-border-subtle); }
.balance-table tr:hover td { background: var(--rs-bg-hover); }
.token-symbol { font-weight: 600; color: var(--rs-text-primary); }
.token-name { font-size: 12px; color: var(--rs-text-secondary); }
.amount-cell { text-align: right; font-family: monospace; }
.fiat { color: var(--rs-success); }
/* ── States ── */
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
.loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; }
.loading .spinner {
display: inline-block; width: 20px; height: 20px;
border: 2px solid var(--rs-border); border-top-color: #00d4ff; border-radius: 50%;
animation: spin 0.8s linear infinite; margin-right: 8px; vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error {
text-align: center; color: var(--rs-error); padding: 16px;
background: rgba(239,83,80,0.08); border: 1px solid rgba(239,83,80,0.2);
border-radius: 10px; margin-bottom: 16px;
}
.link-error {
color: var(--rs-error); font-size: 12px; margin-top: 8px;
padding: 6px 10px; background: rgba(239,83,80,0.08); border-radius: 6px;
}
@media (max-width: 768px) {
.hero-title { font-size: 22px; }
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.address-bar { flex-wrap: wrap; }
.address-bar input { min-width: 0; }
.chains { flex-wrap: wrap; }
.features { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
.features { grid-template-columns: 1fr; }
}
</style>`;
}
private renderHero(): string {
if (this.hasData()) return "";
return `
<div class="hero">
<div class="hero-title">rWallet</div>
<div class="hero-subtitle">Multichain treasury visualization — Safe multisigs and EOA wallets</div>
</div>`;
}
private renderMyWallets(): string {
if (!this.isAuthenticated) return "";
const hasWallets = this.passKeyEOA || this.linkedWallets.length > 0;
return `
<div class="my-wallets">
<div class="my-wallets-header">
<div class="my-wallets-title">My Wallets</div>
<button class="link-wallet-btn" id="link-wallet-btn">+ Link Wallet</button>
</div>
<div class="wallet-list">
${this.passKeyEOA ? `
<div class="wallet-item" data-view-address="${this.esc(this.passKeyEOA)}">
<div class="wallet-item-info">
<div class="wallet-item-label">
<span class="wallet-badge encryptid">EncryptID</span>
Passkey EOA
</div>
<div class="wallet-item-addr">${this.shortenAddress(this.passKeyEOA)}</div>
</div>
</div>
` : ""}
${this.linkedWallets.map(w => `
<div class="wallet-item" data-view-address="${this.esc(w.address)}">
<div class="wallet-item-info">
<div class="wallet-item-label">
<span class="wallet-badge ${w.type}">${this.esc(w.providerName || w.type.toUpperCase())}</span>
${this.esc(w.label)}
${w.safeInfo && !w.safeInfo.isEncryptIdOwner ? '<span style="font-size:10px;color:var(--rs-text-muted)">(not co-signer)</span>' : ""}
</div>
<div class="wallet-item-addr">${this.shortenAddress(w.address)}</div>
</div>
<div class="wallet-item-actions">
<button class="unlink-btn" data-unlink="${this.esc(w.id)}" title="Unlink wallet">&#10005;</button>
</div>
</div>
`).join("")}
${!hasWallets ? '<div style="font-size:12px;color:var(--rs-text-muted);padding:8px;">No wallets linked yet. Click "Link Wallet" to connect a browser wallet.</div>' : ""}
</div>
${this.renderProviderPicker()}
</div>`;
}
private renderProviderPicker(): string {
if (!this.showProviderPicker) return "";
return `
<div class="provider-picker">
<div class="provider-picker-title">
${this.linkingInProgress ? '<span class="spinner" style="width:14px;height:14px;border-width:2px;display:inline-block;vertical-align:middle;margin-right:6px"></span> Connecting...' : 'Select a wallet to link:'}
</div>
${!this.linkingInProgress ? `
<div class="provider-list">
${this.discoveredProviders.map(p => `
<div class="provider-item" data-provider-uuid="${this.esc(p.uuid)}">
${p.icon ? `<img src="${this.esc(p.icon)}" alt="" onerror="this.style.display='none'">` : ""}
${this.esc(p.name)}
</div>
`).join("")}
${this.discoveredProviders.length === 0 ? '<div style="font-size:12px;color:var(--rs-text-muted);padding:4px;">Searching for wallets...</div>' : ""}
</div>
<button class="provider-cancel" id="provider-cancel">Cancel</button>
` : ""}
${this.linkError ? `<div class="link-error">${this.esc(this.linkError)}</div>` : ""}
</div>`;
}
private renderSupportedChains(): string {
if (this.hasData() || this.loading) return "";
return `
<div class="supported-chains">
${Object.entries(CHAIN_NAMES).map(([id, name]) => `
<div class="supported-chain">
<div class="dot" style="background: ${CHAIN_COLORS[id]}"></div>
${name}
</div>
`).join("")}
</div>`;
}
private renderFeatures(): string {
if (this.hasData() || this.loading) return "";
return `
<div class="features">
<div class="feature-card">
<div class="feature-icon">&#9939;</div>
<h3>Safe Multisig</h3>
<p>Visualize Gnosis Safe balances, signers, and thresholds across all chains.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#128279;</div>
<h3>Any Wallet (EOA)</h3>
<p>Paste any 0x address — works for regular wallets too, not just Safes.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#127760;</div>
<h3>10+ Chains</h3>
<p>Ethereum, Base, Polygon, Gnosis, Arbitrum, Optimism, and more in one view.</p>
</div>
<div class="feature-card">
<div class="feature-icon">&#128274;</div>
<h3>No Custody Risk</h3>
<p>Read-only. rWallet never holds keys or moves funds — just visualizes.</p>
</div>
</div>`;
}
private renderExamples(): string {
if (this.hasData() || this.loading) return "";
return `
<div class="examples">
<div class="examples-label">Try an example</div>
<div class="example-list">
${EXAMPLE_WALLETS.map((w) => `
<div class="example-item" data-address="${w.address}">
<div>
<div class="example-name">${w.name}</div>
<div class="example-addr">${w.address.slice(0, 6)}...${w.address.slice(-4)}</div>
</div>
<div class="example-type ${w.type.toLowerCase()}">${w.type}</div>
</div>
`).join("")}
</div>
</div>`;
}
private renderDashboard(): string {
if (!this.hasData()) return "";
const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
return `
<div class="chains">
${this.detectedChains.map((ch) => `
<div class="chain-btn ${this.selectedChain === ch.chainId ? "active" : ""}"
data-chain="${ch.chainId}" style="--chain-color: ${ch.color}">
<div class="chain-dot" style="background: ${ch.color}"></div>
${ch.name}
</div>
`).join("")}
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-label">Total Value</div>
<div class="stat-value">${this.formatUSD(String(totalUSD))}</div>
</div>
<div class="stat-card">
<div class="stat-label">Tokens</div>
<div class="stat-value">${this.balances.filter((b) => parseFloat(b.fiatBalance || "0") > 0 || BigInt(b.balance || "0") > 0n).length}</div>
</div>
<div class="stat-card">
<div class="stat-label">Chains</div>
<div class="stat-value">${this.detectedChains.length}</div>
</div>
<div class="stat-card">
<div class="stat-label">Address</div>
<div class="stat-value" style="font-size:13px;font-family:monospace">${this.shortenAddress(this.address)}</div>
</div>
</div>
${this.balances.length > 0 ? `
<table class="balance-table">
<thead>
<tr><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr>
</thead>
<tbody>
${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) => `
<tr>
<td>
<span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span>
<span class="token-name">${this.esc(b.token?.name || "Ether")}</span>
</td>
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>
</tr>
`).join("")}
</tbody>
</table>
` : '<div class="empty">No token balances found on this chain.</div>'}`;
}
private render() {
this.shadow.innerHTML = `
${this.renderStyles()}
${this.renderHero()}
${this.renderMyWallets()}
<form class="address-bar" id="address-form">
<input id="address-input" type="text" placeholder="Enter any wallet or Safe address (0x...)"
value="${this.address}" spellcheck="false">
<button type="submit">Load</button>
</form>
<div class="controls-row">
<div class="testnet-toggle ${this.includeTestnets ? "active" : ""}" id="testnet-toggle">
<div class="toggle-track"><div class="toggle-thumb"></div></div>
<span>Include testnets</span>
</div>
${this.walletType ? `
<div class="wallet-badge ${this.walletType}">
${this.walletType === "safe" ? "&#9939; Safe Multisig" : "&#128100; EOA Wallet"}
</div>
` : ""}
</div>
${this.renderSupportedChains()}
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
${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);