feat(rwallet): unified all-chains balance view with ERC-20 scanning
- Add POPULAR_TOKENS map (USDC, USDT, DAI, WETH) for 7 chains - Add ERC-20 balanceOf scanning to EOA balance endpoint - Add /api/eoa/:address/all-balances and /api/safe/:address/all-balances endpoints that fan out to all chains in parallel - Replace single-chain view with unified multi-chain balance table - Add Chain column with colored dots, "All" filter button - Merge CRDT tokens into unified table (chainId="local") - Enable testnets by default - Chain buttons now act as filters (no extra API call) - Stats aggregate across all chains regardless of filter - Bump JS cache version to v=6 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2054a239df
commit
0b08a286cf
|
|
@ -77,17 +77,25 @@ const EXAMPLE_WALLETS = [
|
||||||
|
|
||||||
type ViewTab = "balances" | "timeline" | "flow" | "sankey";
|
type ViewTab = "balances" | "timeline" | "flow" | "sankey";
|
||||||
|
|
||||||
|
interface AllChainBalanceEntry {
|
||||||
|
chainId: string;
|
||||||
|
chainName: string;
|
||||||
|
balances: BalanceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
class FolkWalletViewer extends HTMLElement {
|
class FolkWalletViewer extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private address = "";
|
private address = "";
|
||||||
private detectedChains: ChainInfo[] = [];
|
private detectedChains: ChainInfo[] = [];
|
||||||
private selectedChain: string | null = null;
|
private selectedChain: string | null = null;
|
||||||
private balances: BalanceItem[] = [];
|
private balances: BalanceItem[] = [];
|
||||||
|
private allChainBalances: Map<string, AllChainBalanceEntry> = new Map();
|
||||||
|
private chainFilter: string | null = null; // null = show all
|
||||||
private loading = false;
|
private loading = false;
|
||||||
private error = "";
|
private error = "";
|
||||||
private isDemo = false;
|
private isDemo = false;
|
||||||
private walletType: "safe" | "eoa" | "" = "";
|
private walletType: "safe" | "eoa" | "" = "";
|
||||||
private includeTestnets = false;
|
private includeTestnets = true;
|
||||||
|
|
||||||
// Linked wallets state
|
// Linked wallets state
|
||||||
private isAuthenticated = false;
|
private isAuthenticated = false;
|
||||||
|
|
@ -310,6 +318,8 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
this.error = "";
|
this.error = "";
|
||||||
this.detectedChains = [];
|
this.detectedChains = [];
|
||||||
this.balances = [];
|
this.balances = [];
|
||||||
|
this.allChainBalances = new Map();
|
||||||
|
this.chainFilter = null;
|
||||||
this.walletType = "";
|
this.walletType = "";
|
||||||
this.activeView = "balances";
|
this.activeView = "balances";
|
||||||
this.transfers = null;
|
this.transfers = null;
|
||||||
|
|
@ -318,35 +328,23 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base = this.getApiBase();
|
const base = this.getApiBase();
|
||||||
const tn = this.includeTestnets ? "?testnets=true" : "";
|
const tn = this.includeTestnets ? "" : "?testnets=false";
|
||||||
|
|
||||||
// Try Safe detection first
|
// Try Safe all-balances first
|
||||||
const res = await fetch(`${base}/api/safe/detect/${this.address}${tn}`);
|
const safeRes = await fetch(`${base}/api/safe/${this.address}/all-balances${tn}`);
|
||||||
const data = await res.json();
|
const safeData = await safeRes.json();
|
||||||
|
|
||||||
this.detectedChains = (data.chains || []).map((c: any) => ({
|
if (safeData.chains && safeData.chains.length > 0) {
|
||||||
...c,
|
|
||||||
color: CHAIN_COLORS[c.chainId] || "#888",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (this.detectedChains.length > 0) {
|
|
||||||
this.walletType = "safe";
|
this.walletType = "safe";
|
||||||
this.selectedChain = this.detectedChains[0].chainId;
|
this.populateFromAllBalances(safeData.chains);
|
||||||
await this.loadBalances();
|
|
||||||
} else {
|
} else {
|
||||||
// Fall back to EOA detection (any wallet)
|
// Fall back to EOA all-balances
|
||||||
const eoaRes = await fetch(`${base}/api/eoa/detect/${this.address}${tn}`);
|
const eoaRes = await fetch(`${base}/api/eoa/${this.address}/all-balances${tn}`);
|
||||||
const eoaData = await eoaRes.json();
|
const eoaData = await eoaRes.json();
|
||||||
|
|
||||||
this.detectedChains = (eoaData.chains || []).map((c: any) => ({
|
if (eoaData.chains && eoaData.chains.length > 0) {
|
||||||
...c,
|
|
||||||
color: CHAIN_COLORS[c.chainId] || "#888",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (this.detectedChains.length > 0) {
|
|
||||||
this.walletType = "eoa";
|
this.walletType = "eoa";
|
||||||
this.selectedChain = this.detectedChains[0].chainId;
|
this.populateFromAllBalances(eoaData.chains);
|
||||||
await this.loadBalances();
|
|
||||||
} else {
|
} else {
|
||||||
this.error = "No balances found for this address on any supported chain.";
|
this.error = "No balances found for this address on any supported chain.";
|
||||||
}
|
}
|
||||||
|
|
@ -359,6 +357,73 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private populateFromAllBalances(chains: Array<{ chainId: string; chainName: string; balances: BalanceItem[] }>) {
|
||||||
|
this.allChainBalances = new Map();
|
||||||
|
this.detectedChains = [];
|
||||||
|
|
||||||
|
for (const ch of chains) {
|
||||||
|
this.allChainBalances.set(ch.chainId, {
|
||||||
|
chainId: ch.chainId,
|
||||||
|
chainName: ch.chainName,
|
||||||
|
balances: ch.balances,
|
||||||
|
});
|
||||||
|
this.detectedChains.push({
|
||||||
|
chainId: ch.chainId,
|
||||||
|
name: ch.chainName,
|
||||||
|
prefix: "",
|
||||||
|
color: CHAIN_COLORS[ch.chainId] || "#888",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set selectedChain for viz compatibility (first chain)
|
||||||
|
if (this.detectedChains.length > 0) {
|
||||||
|
this.selectedChain = this.detectedChains[0].chainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build flattened balances for backward compat (viz data, etc.)
|
||||||
|
this.balances = this.getFilteredBalances();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get balances respecting current chainFilter */
|
||||||
|
private getFilteredBalances(): BalanceItem[] {
|
||||||
|
const result: BalanceItem[] = [];
|
||||||
|
for (const [chainId, entry] of this.allChainBalances) {
|
||||||
|
if (this.chainFilter && this.chainFilter !== chainId) continue;
|
||||||
|
result.push(...entry.balances);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all balances (including CRDT) for the unified table, with chain info attached.
|
||||||
|
* Pass ignoreFilter=true to get all chains regardless of current filter (for stats). */
|
||||||
|
private getUnifiedBalances(ignoreFilter = false): Array<BalanceItem & { chainId: string; chainName: string }> {
|
||||||
|
const result: Array<BalanceItem & { chainId: string; chainName: string }> = [];
|
||||||
|
|
||||||
|
for (const [chainId, entry] of this.allChainBalances) {
|
||||||
|
if (!ignoreFilter && this.chainFilter && this.chainFilter !== chainId) continue;
|
||||||
|
for (const b of entry.balances) {
|
||||||
|
result.push({ ...b, chainId, chainName: entry.chainName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge CRDT tokens (when showing all or filtering to "local")
|
||||||
|
if (this.isAuthenticated && this.crdtBalances.length > 0 && (ignoreFilter || !this.chainFilter || this.chainFilter === "local")) {
|
||||||
|
for (const t of this.crdtBalances) {
|
||||||
|
result.push({
|
||||||
|
tokenAddress: `crdt:${t.tokenId}`,
|
||||||
|
token: { name: t.name, symbol: t.symbol, decimals: t.decimals },
|
||||||
|
balance: t.balance.toString(),
|
||||||
|
fiatBalance: "0",
|
||||||
|
fiatConversion: "0",
|
||||||
|
chainId: "local",
|
||||||
|
chainName: "Local",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private async loadBalances() {
|
private async loadBalances() {
|
||||||
if (this.isDemo) return;
|
if (this.isDemo) return;
|
||||||
if (!this.selectedChain) return;
|
if (!this.selectedChain) return;
|
||||||
|
|
@ -534,16 +599,25 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleChainSelect(chainId: string) {
|
private handleChainSelect(chainId: string) {
|
||||||
this.selectedChain = chainId;
|
|
||||||
if (this.isDemo) {
|
if (this.isDemo) {
|
||||||
|
this.selectedChain = chainId;
|
||||||
this.render();
|
this.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
|
||||||
this.render();
|
// Toggle filter: click same chain again to show all
|
||||||
await this.loadBalances();
|
if (this.chainFilter === chainId) {
|
||||||
// Recompute sankey for new chain
|
this.chainFilter = null;
|
||||||
|
} else {
|
||||||
|
this.chainFilter = chainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selectedChain for viz compatibility
|
||||||
|
this.selectedChain = chainId;
|
||||||
|
this.balances = this.getFilteredBalances();
|
||||||
|
|
||||||
|
// Recompute sankey for selected chain
|
||||||
if (this.transfers && this.transfers.has(chainId)) {
|
if (this.transfers && this.transfers.has(chainId)) {
|
||||||
this.vizData.sankey = transformToSankeyData(
|
this.vizData.sankey = transformToSankeyData(
|
||||||
this.transfers.get(chainId),
|
this.transfers.get(chainId),
|
||||||
|
|
@ -551,7 +625,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
chainId,
|
chainId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.loading = false;
|
|
||||||
this.render();
|
this.render();
|
||||||
if (this.activeView !== "balances") {
|
if (this.activeView !== "balances") {
|
||||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||||
|
|
@ -1009,6 +1083,13 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
.amount-cell { text-align: right; font-family: monospace; }
|
.amount-cell { text-align: right; font-family: monospace; }
|
||||||
.fiat { color: var(--rs-success); }
|
.fiat { color: var(--rs-success); }
|
||||||
|
|
||||||
|
/* ── Chain cell in balance table ── */
|
||||||
|
.chain-cell {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; color: var(--rs-text-secondary); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chain-dot-sm { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
/* ── States ── */
|
/* ── States ── */
|
||||||
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
|
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
|
||||||
.loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; }
|
.loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; }
|
||||||
|
|
@ -1214,47 +1295,77 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderBalanceTable(): string {
|
private renderBalanceTable(): string {
|
||||||
return this.balances.length > 0 ? `
|
const unified = this.getUnifiedBalances();
|
||||||
|
if (unified.length === 0) return '<div class="empty">No token balances found.</div>';
|
||||||
|
|
||||||
|
const sorted = unified
|
||||||
|
.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"));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sorted.length === 0) return '<div class="empty">No token balances found.</div>';
|
||||||
|
|
||||||
|
return `
|
||||||
<table class="balance-table">
|
<table class="balance-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr>
|
<tr><th>Chain</th><th>Token</th><th class="amount-cell">Balance</th><th class="amount-cell">USD Value</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${this.balances
|
${sorted.map((b) => {
|
||||||
.filter((b) => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n)
|
const color = CHAIN_COLORS[b.chainId] || (b.chainId === "local" ? "#2775ca" : "#888");
|
||||||
.sort((a, b) => {
|
return `
|
||||||
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>
|
<tr>
|
||||||
|
<td><div class="chain-cell"><span class="chain-dot-sm" style="background:${color}"></span>${this.esc(b.chainName)}</div></td>
|
||||||
<td>
|
<td>
|
||||||
<span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span>
|
<span class="token-symbol">${this.esc(b.token?.symbol || "ETH")}</span>
|
||||||
<span class="token-name">${this.esc(b.token?.name || "Ether")}</span>
|
<span class="token-name">${this.esc(b.token?.name || "Ether")}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
|
<td class="amount-cell">${this.formatBalance(b.balance, b.token?.decimals || 18)}</td>
|
||||||
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>
|
<td class="amount-cell fiat">${this.formatUSD(b.fiatBalance)}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`).join("")}
|
}).join("")}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>`;
|
||||||
` : '<div class="empty">No token balances found on this chain.</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderDashboard(): string {
|
private renderDashboard(): string {
|
||||||
if (!this.hasData()) return "";
|
if (!this.hasData()) return "";
|
||||||
const totalUSD = this.balances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
|
|
||||||
|
|
||||||
return `
|
// Aggregate stats across ALL chains (ignoring filter)
|
||||||
<div class="chains">
|
const allBalances = this.getUnifiedBalances(true);
|
||||||
${this.detectedChains.map((ch) => `
|
const totalUSD = allBalances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0);
|
||||||
<div class="chain-btn ${this.selectedChain === ch.chainId ? "active" : ""}"
|
const totalTokens = allBalances.filter((b) => parseFloat(b.fiatBalance || "0") > 0 || BigInt(b.balance || "0") > 0n).length;
|
||||||
|
|
||||||
|
// Build chain buttons with "All" filter
|
||||||
|
const chainButtons = this.detectedChains.map((ch) => {
|
||||||
|
const isActive = this.chainFilter === ch.chainId;
|
||||||
|
return `
|
||||||
|
<div class="chain-btn ${isActive ? "active" : ""}"
|
||||||
data-chain="${ch.chainId}" style="--chain-color: ${ch.color}">
|
data-chain="${ch.chainId}" style="--chain-color: ${ch.color}">
|
||||||
<div class="chain-dot" style="background: ${ch.color}"></div>
|
<div class="chain-dot" style="background: ${ch.color}"></div>
|
||||||
${ch.name}
|
${ch.name}
|
||||||
</div>
|
</div>`;
|
||||||
`).join("")}
|
}).join("");
|
||||||
|
|
||||||
|
// Add "Local" filter button if CRDT tokens exist
|
||||||
|
const localBtn = (this.isAuthenticated && this.crdtBalances.length > 0) ? `
|
||||||
|
<div class="chain-btn ${this.chainFilter === "local" ? "active" : ""}"
|
||||||
|
data-chain="local" style="--chain-color: #2775ca">
|
||||||
|
<div class="chain-dot" style="background: #2775ca"></div>
|
||||||
|
Local
|
||||||
|
</div>` : "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="chains">
|
||||||
|
<div class="chain-btn ${this.chainFilter === null ? "active" : ""}"
|
||||||
|
data-chain="all" style="--chain-color: var(--rs-accent, #14b8a6)">
|
||||||
|
All
|
||||||
|
</div>
|
||||||
|
${chainButtons}
|
||||||
|
${localBtn}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
|
|
@ -1264,11 +1375,11 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Tokens</div>
|
<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 class="stat-value">${totalTokens}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Chains</div>
|
<div class="stat-label">Chains</div>
|
||||||
<div class="stat-value">${this.detectedChains.length}</div>
|
<div class="stat-value">${this.allChainBalances.size}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Address</div>
|
<div class="stat-label">Address</div>
|
||||||
|
|
@ -1318,7 +1429,6 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
${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.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
|
||||||
|
|
||||||
${this.renderLocalTokens()}
|
|
||||||
${this.renderFeatures()}
|
${this.renderFeatures()}
|
||||||
${this.renderExamples()}
|
${this.renderExamples()}
|
||||||
${this.renderDashboard()}
|
${this.renderDashboard()}
|
||||||
|
|
@ -1337,7 +1447,13 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
this.shadow.querySelectorAll(".chain-btn").forEach((btn) => {
|
this.shadow.querySelectorAll(".chain-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const chainId = (btn as HTMLElement).dataset.chain!;
|
const chainId = (btn as HTMLElement).dataset.chain!;
|
||||||
this.handleChainSelect(chainId);
|
if (chainId === "all") {
|
||||||
|
this.chainFilter = null;
|
||||||
|
this.balances = this.getFilteredBalances();
|
||||||
|
this.render();
|
||||||
|
} else {
|
||||||
|
this.handleChainSelect(chainId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,55 @@ routes.post("/api/safe/:chainId/:address/execute", async (c) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Popular ERC-20 tokens to scan for EOA wallets ──
|
||||||
|
const POPULAR_TOKENS: Record<string, Array<{ address: string; name: string; symbol: string; decimals: number }>> = {
|
||||||
|
"1": [
|
||||||
|
{ address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", name: "USD Coin", symbol: "USDC", decimals: 6 },
|
||||||
|
{ address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", name: "Tether USD", symbol: "USDT", decimals: 6 },
|
||||||
|
{ address: "0x6B175474E89094C44Da98b954EedeAC495271d0F", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 },
|
||||||
|
{ address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", name: "Wrapped Ether", symbol: "WETH", decimals: 18 },
|
||||||
|
],
|
||||||
|
"8453": [
|
||||||
|
{ address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", name: "USD Coin", symbol: "USDC", decimals: 6 },
|
||||||
|
{ address: "0x4200000000000000000000000000000000000006", name: "Wrapped Ether", symbol: "WETH", decimals: 18 },
|
||||||
|
{ address: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 },
|
||||||
|
],
|
||||||
|
"10": [
|
||||||
|
{ address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", name: "USD Coin", symbol: "USDC", decimals: 6 },
|
||||||
|
{ address: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", name: "Tether USD", symbol: "USDT", decimals: 6 },
|
||||||
|
{ address: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 },
|
||||||
|
{ address: "0x4200000000000000000000000000000000000006", name: "Wrapped Ether", symbol: "WETH", decimals: 18 },
|
||||||
|
],
|
||||||
|
"137": [
|
||||||
|
{ address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", name: "USD Coin", symbol: "USDC", decimals: 6 },
|
||||||
|
{ address: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", name: "Tether USD", symbol: "USDT", decimals: 6 },
|
||||||
|
{ address: "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 },
|
||||||
|
{ address: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", name: "Wrapped Ether", symbol: "WETH", decimals: 18 },
|
||||||
|
],
|
||||||
|
"42161": [
|
||||||
|
{ address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", name: "USD Coin", symbol: "USDC", decimals: 6 },
|
||||||
|
{ address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", name: "Tether USD", symbol: "USDT", decimals: 6 },
|
||||||
|
{ address: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", name: "Dai Stablecoin", symbol: "DAI", decimals: 18 },
|
||||||
|
{ address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", name: "Wrapped Ether", symbol: "WETH", decimals: 18 },
|
||||||
|
],
|
||||||
|
"100": [
|
||||||
|
{ address: "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", name: "USD Coin", symbol: "USDC", decimals: 6 },
|
||||||
|
{ address: "0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1", name: "Wrapped Ether", symbol: "WETH", decimals: 18 },
|
||||||
|
],
|
||||||
|
"84532": [
|
||||||
|
{ address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", name: "USD Coin", symbol: "USDC", decimals: 6 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ERC-20 balanceOf(address) — selector 0x70a08231
|
||||||
|
async function getErc20Balance(rpcUrl: string, tokenAddress: string, walletAddress: string): Promise<string> {
|
||||||
|
const paddedAddr = walletAddress.slice(2).toLowerCase().padStart(64, "0");
|
||||||
|
const data = `0x70a08231${paddedAddr}`;
|
||||||
|
const result = await rpcCall(rpcUrl, "eth_call", [{ to: tokenAddress, data }, "latest"]);
|
||||||
|
if (!result || result === "0x" || result === "0x0") return "0";
|
||||||
|
return BigInt(result).toString();
|
||||||
|
}
|
||||||
|
|
||||||
// ── EOA (any wallet) balance endpoints ──
|
// ── EOA (any wallet) balance endpoints ──
|
||||||
|
|
||||||
// Detect which chains have a non-zero native balance for any address
|
// Detect which chains have a non-zero native balance for any address
|
||||||
|
|
@ -434,7 +483,7 @@ routes.get("/api/eoa/detect/:address", async (c) => {
|
||||||
return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) });
|
return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get native token balance for an EOA on a specific chain
|
// Get native + ERC-20 token balances for an EOA on a specific chain
|
||||||
routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
|
routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
|
||||||
const chainId = c.req.param("chainId");
|
const chainId = c.req.param("chainId");
|
||||||
const address = validateAddress(c);
|
const address = validateAddress(c);
|
||||||
|
|
@ -445,24 +494,156 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
|
||||||
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };
|
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };
|
||||||
const balances: BalanceItem[] = [];
|
const balances: BalanceItem[] = [];
|
||||||
|
|
||||||
try {
|
// Fetch native + ERC-20 balances in parallel
|
||||||
const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]);
|
const tokens = POPULAR_TOKENS[chainId] || [];
|
||||||
const balWei = BigInt(balHex || "0x0");
|
const promises: Promise<void>[] = [];
|
||||||
if (balWei > 0n) {
|
|
||||||
balances.push({
|
// Native balance
|
||||||
tokenAddress: null,
|
promises.push((async () => {
|
||||||
token: nativeToken,
|
try {
|
||||||
balance: balWei.toString(),
|
const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]);
|
||||||
fiatBalance: "0",
|
const balWei = BigInt(balHex || "0x0");
|
||||||
fiatConversion: "0",
|
if (balWei > 0n) {
|
||||||
});
|
balances.push({
|
||||||
}
|
tokenAddress: null,
|
||||||
} catch {}
|
token: nativeToken,
|
||||||
|
balance: balWei.toString(),
|
||||||
|
fiatBalance: "0",
|
||||||
|
fiatConversion: "0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})());
|
||||||
|
|
||||||
|
// ERC-20 balances
|
||||||
|
for (const tok of tokens) {
|
||||||
|
promises.push((async () => {
|
||||||
|
try {
|
||||||
|
const bal = await getErc20Balance(rpcUrl, tok.address, address);
|
||||||
|
if (bal !== "0") {
|
||||||
|
balances.push({
|
||||||
|
tokenAddress: tok.address,
|
||||||
|
token: { name: tok.name, symbol: tok.symbol, decimals: tok.decimals },
|
||||||
|
balance: bal,
|
||||||
|
fiatBalance: "0",
|
||||||
|
fiatConversion: "0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
c.header("Cache-Control", "public, max-age=30");
|
c.header("Cache-Control", "public, max-age=30");
|
||||||
return c.json(balances);
|
return c.json(balances);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── All-chains balance endpoints (fan out to every chain in parallel) ──
|
||||||
|
|
||||||
|
// Get all balances across all chains for an EOA
|
||||||
|
routes.get("/api/eoa/:address/all-balances", async (c) => {
|
||||||
|
const address = validateAddress(c);
|
||||||
|
if (!address) return c.json({ error: "Invalid Ethereum address" }, 400);
|
||||||
|
const includeTestnets = c.req.query("testnets") !== "false"; // default: include
|
||||||
|
const chains = getChains(includeTestnets);
|
||||||
|
|
||||||
|
const results: Array<{ chainId: string; chainName: string; balances: BalanceItem[] }> = [];
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
chains.map(async ([chainId, info]) => {
|
||||||
|
const rpcUrl = getRpcUrl(chainId);
|
||||||
|
if (!rpcUrl) return;
|
||||||
|
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };
|
||||||
|
const chainBalances: BalanceItem[] = [];
|
||||||
|
const tokenPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
// Native balance
|
||||||
|
tokenPromises.push((async () => {
|
||||||
|
try {
|
||||||
|
const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]);
|
||||||
|
const balWei = BigInt(balHex || "0x0");
|
||||||
|
if (balWei > 0n) {
|
||||||
|
chainBalances.push({
|
||||||
|
tokenAddress: null,
|
||||||
|
token: nativeToken,
|
||||||
|
balance: balWei.toString(),
|
||||||
|
fiatBalance: "0",
|
||||||
|
fiatConversion: "0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})());
|
||||||
|
|
||||||
|
// ERC-20 balances
|
||||||
|
for (const tok of (POPULAR_TOKENS[chainId] || [])) {
|
||||||
|
tokenPromises.push((async () => {
|
||||||
|
try {
|
||||||
|
const bal = await getErc20Balance(rpcUrl, tok.address, address);
|
||||||
|
if (bal !== "0") {
|
||||||
|
chainBalances.push({
|
||||||
|
tokenAddress: tok.address,
|
||||||
|
token: { name: tok.name, symbol: tok.symbol, decimals: tok.decimals },
|
||||||
|
balance: bal,
|
||||||
|
fiatBalance: "0",
|
||||||
|
fiatConversion: "0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(tokenPromises);
|
||||||
|
if (chainBalances.length > 0) {
|
||||||
|
results.push({ chainId, chainName: info.name, balances: chainBalances });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
results.sort((a, b) => a.chainName.localeCompare(b.chainName));
|
||||||
|
c.header("Cache-Control", "public, max-age=30");
|
||||||
|
return c.json({ address, chains: results });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all balances across all chains for a Safe
|
||||||
|
routes.get("/api/safe/:address/all-balances", async (c) => {
|
||||||
|
const address = validateAddress(c);
|
||||||
|
if (!address) return c.json({ error: "Invalid Ethereum address" }, 400);
|
||||||
|
const includeTestnets = c.req.query("testnets") !== "false"; // default: include
|
||||||
|
const chains = getChains(includeTestnets);
|
||||||
|
|
||||||
|
const results: Array<{ chainId: string; chainName: string; balances: BalanceItem[] }> = [];
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
chains.map(async ([chainId, info]) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${safeApiBase(info.prefix)}/safes/${address}/balances/?trusted=true&exclude_spam=true`,
|
||||||
|
{ signal: AbortSignal.timeout(8000) },
|
||||||
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const raw = await res.json() as any[];
|
||||||
|
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };
|
||||||
|
const chainBalances: BalanceItem[] = raw.map((item: any) => ({
|
||||||
|
tokenAddress: item.tokenAddress,
|
||||||
|
token: item.token || nativeToken,
|
||||||
|
balance: item.balance || "0",
|
||||||
|
fiatBalance: item.fiatBalance || "0",
|
||||||
|
fiatConversion: item.fiatConversion || "0",
|
||||||
|
})).filter((b: BalanceItem) => BigInt(b.balance || "0") > 0n);
|
||||||
|
|
||||||
|
if (chainBalances.length > 0) {
|
||||||
|
results.push({ chainId, chainName: info.name, balances: chainBalances });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
results.sort((a, b) => a.chainName.localeCompare(b.chainName));
|
||||||
|
c.header("Cache-Control", "public, max-age=30");
|
||||||
|
return c.json({ address, chains: results });
|
||||||
|
});
|
||||||
|
|
||||||
interface BalanceItem {
|
interface BalanceItem {
|
||||||
tokenAddress: string | null;
|
tokenAddress: string | null;
|
||||||
token: { name: string; symbol: string; decimals: number };
|
token: { name: string; symbol: string; decimals: number };
|
||||||
|
|
@ -658,7 +839,7 @@ routes.get("/", (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body: `<folk-wallet-viewer></folk-wallet-viewer>`,
|
body: `<folk-wallet-viewer></folk-wallet-viewer>`,
|
||||||
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=5"></script>`,
|
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=6"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
|
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue