604 lines
21 KiB
TypeScript
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">⛓</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">🔗</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">🌐</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">🔒</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" ? "⛓ Safe Multisig" : "👤 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);
|