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

604 lines
21 KiB
TypeScript

/**
* <folk-wallet-viewer> — multichain Safe wallet visualization.
*
* Enter a Safe address to see balances across chains, transfer history,
* and flow visualizations.
*/
interface ChainInfo {
chainId: string;
name: string;
prefix: string;
color: string;
}
interface BalanceItem {
tokenAddress: string | null;
token: { name: string; symbol: string; decimals: number } | null;
balance: string;
fiatBalance: string;
fiatConversion: string;
}
const CHAIN_COLORS: Record<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;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
const space = this.getAttribute("space") || "";
if (space === "demo") {
this.loadDemoData();
return;
}
// Check URL params for initial address
const params = new URLSearchParams(window.location.search);
this.address = params.get("address") || "";
this.render();
if (this.address) this.detectChains();
}
private loadDemoData() {
this.isDemo = true;
this.address = "0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1";
this.detectedChains = [
{ chainId: "1", name: "Ethereum", prefix: "eth", color: "#627eea" },
{ chainId: "10", name: "Optimism", prefix: "oeth", color: "#ff0420" },
{ chainId: "100", name: "Gnosis", prefix: "gno", color: "#04795b" },
{ chainId: "137", name: "Polygon", prefix: "pol", color: "#8247e5" },
{ chainId: "8453", name: "Base", prefix: "base", color: "#0052ff" },
{ chainId: "42161", name: "Arbitrum", prefix: "arb1", color: "#28a0f0" },
{ chainId: "43114", name: "Avalanche", prefix: "avax", color: "#e84142" },
];
this.selectedChain = "100";
this.balances = [
{ tokenAddress: null, token: { name: "xDAI", symbol: "xDAI", decimals: 18 }, balance: "45230000000000000000000", fiatBalance: "45230", fiatConversion: "1" },
{ tokenAddress: "0x5dF8339c5E282ee48c0c7cE252A7842F74e378b2", token: { name: "Token Engineering Commons", symbol: "TEC", decimals: 18 }, balance: "1250000000000000000000000", fiatBalance: "12500", fiatConversion: "0.01" },
{ tokenAddress: "0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1", token: { name: "Wrapped Ether", symbol: "WETH", decimals: 18 }, balance: "8500000000000000000", fiatBalance: "28050", fiatConversion: "3300" },
{ tokenAddress: "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", token: { name: "USD Coin", symbol: "USDC", decimals: 6 }, balance: "15750000000", fiatBalance: "15750", fiatConversion: "1" },
{ tokenAddress: "0x4f4F9b8D5B4d0Dc10506e5551B0513B61fD59e75", token: { name: "Giveth", symbol: "GIV", decimals: 18 }, balance: "500000000000000000000000", fiatBalance: "2500", fiatConversion: "0.005" },
];
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/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;
}
private renderStyles(): string {
return `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
/* ── Hero ── */
.hero {
text-align: center; padding: 32px 16px 24px;
border-bottom: 1px solid #2a2a3e; 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: #888; 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 #444; background: #1e1e2e; color: #e0e0e0;
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: #888;
}
.testnet-toggle:hover { color: #bbb; }
.toggle-track {
width: 34px; height: 18px; border-radius: 9px; background: #333;
position: relative; transition: background 0.2s;
}
.testnet-toggle.active .toggle-track { background: #f59e0b; }
.toggle-thumb {
width: 14px; height: 14px; border-radius: 50%; background: #666;
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); }
/* ── 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: #1e1e2e;
border: 1px solid #2a2a3e; font-size: 12px; color: #999;
}
.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: #1e1e2e; border: 1px solid #2a2a3e; border-radius: 12px;
padding: 20px; text-align: center; transition: border-color 0.2s;
}
.feature-card:hover { border-color: #444; }
.feature-icon { font-size: 28px; margin-bottom: 10px; }
.feature-card h3 { font-size: 14px; font-weight: 600; margin: 0 0 6px; color: #e0e0e0; }
.feature-card p { font-size: 12px; color: #888; margin: 0; line-height: 1.5; }
/* ── Example wallets ── */
.examples {
max-width: 640px; margin: 0 auto 20px;
}
.examples-label { font-size: 11px; color: #666; 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: #1e1e2e; border: 1px solid #2a2a3e; border-radius: 10px;
cursor: pointer; transition: all 0.2s;
}
.example-item:hover { border-color: #00d4ff; background: #252540; }
.example-name { font-size: 13px; font-weight: 500; color: #e0e0e0; }
.example-addr { font-size: 11px; color: #666; 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 #333;
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px;
display: flex; align-items: center; gap: 6px; transition: all 0.2s;
}
.chain-btn:hover { border-color: #555; }
.chain-btn.active { border-color: var(--chain-color); background: rgba(255,255,255,0.05); }
.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: #1e1e2e; border: 1px solid #333; border-radius: 10px;
padding: 14px; text-align: center;
}
.stat-label { font-size: 11px; color: #888; 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 #333;
color: #888; font-size: 11px; text-transform: uppercase;
}
.balance-table td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
.balance-table tr:hover td { background: rgba(255,255,255,0.02); }
.token-symbol { font-weight: 600; color: #e0e0e0; }
.token-name { font-size: 12px; color: #888; }
.amount-cell { text-align: right; font-family: monospace; }
.fiat { color: #4ade80; }
/* ── States ── */
.empty { text-align: center; color: #666; padding: 40px; }
.loading { text-align: center; color: #888; padding: 40px; }
.loading .spinner {
display: inline-block; width: 20px; height: 20px;
border: 2px solid #333; 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: #ef5350; padding: 16px;
background: rgba(239,83,80,0.08); border: 1px solid rgba(239,83,80,0.2);
border-radius: 10px; margin-bottom: 16px;
}
@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 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).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)
.sort((a, b) => parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0"))
.map((b) => `
<tr>
<td>
<span class="token-symbol">${b.token?.symbol || "ETH"}</span>
<span class="token-name">${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()}
<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();
});
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-wallet-viewer", FolkWalletViewer);