315 lines
10 KiB
TypeScript
315 lines
10 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",
|
|
};
|
|
|
|
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 = "";
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
// 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 getApiBase(): string {
|
|
const path = window.location.pathname;
|
|
const match = path.match(/^\/([^/]+)\/wallet/);
|
|
return match ? `/${match[1]}/wallet` : "";
|
|
}
|
|
|
|
private async detectChains() {
|
|
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.render();
|
|
|
|
try {
|
|
const base = this.getApiBase();
|
|
const res = await fetch(`${base}/api/safe/detect/${this.address}`);
|
|
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.error = "No Safe wallets found for this address on any supported chain.";
|
|
} else {
|
|
// Auto-select first chain
|
|
this.selectedChain = this.detectedChains[0].chainId;
|
|
await this.loadBalances();
|
|
}
|
|
} catch (e) {
|
|
this.error = "Failed to detect chains. Check the address and try again.";
|
|
}
|
|
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
private async loadBalances() {
|
|
if (!this.selectedChain) return;
|
|
try {
|
|
const base = this.getApiBase();
|
|
const res = await fetch(`${base}/api/safe/${this.selectedChain}/${this.address}/balances`);
|
|
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;
|
|
this.loading = true;
|
|
this.render();
|
|
await this.loadBalances();
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
private render() {
|
|
const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
|
|
|
|
this.shadow.innerHTML = `
|
|
<style>
|
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
|
* { box-sizing: border-box; }
|
|
|
|
.address-bar { display: flex; gap: 8px; margin-bottom: 24px; }
|
|
.address-bar input {
|
|
flex: 1; padding: 10px 14px; border-radius: 8px;
|
|
border: 1px solid #444; background: #2a2a3e; color: #e0e0e0;
|
|
font-family: monospace; font-size: 13px;
|
|
}
|
|
.address-bar input:focus { border-color: #00d4ff; outline: none; }
|
|
.address-bar button {
|
|
padding: 10px 20px; border-radius: 8px; border: none;
|
|
background: #00d4ff; color: #000; font-weight: 600; cursor: pointer;
|
|
}
|
|
.address-bar button:hover { background: #00b8d9; }
|
|
|
|
.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%; }
|
|
|
|
.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; }
|
|
|
|
.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; }
|
|
|
|
.empty { text-align: center; color: #666; padding: 40px; }
|
|
.loading { text-align: center; color: #888; padding: 40px; }
|
|
.error { text-align: center; color: #ef5350; padding: 20px; }
|
|
.demo-link { color: #00d4ff; cursor: pointer; text-decoration: underline; }
|
|
</style>
|
|
|
|
<form class="address-bar" id="address-form">
|
|
<input id="address-input" type="text" placeholder="Enter Safe address (0x...)"
|
|
value="${this.address}" spellcheck="false">
|
|
<button type="submit">Load</button>
|
|
</form>
|
|
|
|
${!this.address && !this.loading ? `
|
|
<div class="empty">
|
|
<p style="font-size:16px;margin-bottom:8px">Enter a Safe wallet address to visualize</p>
|
|
<p>Try: <span class="demo-link" data-address="0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1">TEC Commons Fund</span></p>
|
|
</div>
|
|
` : ""}
|
|
|
|
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
|
${this.loading ? '<div class="loading">Detecting Safe wallets across chains...</div>' : ""}
|
|
|
|
${!this.loading && this.detectedChains.length > 0 ? `
|
|
<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 balances found on this chain.</div>'}
|
|
` : ""}
|
|
`;
|
|
|
|
// Event listeners
|
|
const form = this.shadow.querySelector("#address-form");
|
|
form?.addEventListener("submit", (e) => this.handleSubmit(e));
|
|
|
|
this.shadow.querySelectorAll(".chain-btn").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const chainId = (btn as HTMLElement).dataset.chain!;
|
|
this.handleChainSelect(chainId);
|
|
});
|
|
});
|
|
|
|
this.shadow.querySelectorAll(".demo-link").forEach((link) => {
|
|
link.addEventListener("click", () => {
|
|
const addr = (link as HTMLElement).dataset.address!;
|
|
const input = this.shadow.querySelector("#address-input") as HTMLInputElement;
|
|
if (input) input.value = addr;
|
|
this.address = addr;
|
|
this.detectChains();
|
|
});
|
|
});
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s || "";
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-wallet-viewer", FolkWalletViewer);
|