From f7e7e30cf947a5a17e26937342ff2d9c40830594 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 23 Oct 2026 07:27:45 -0400 Subject: [PATCH] feat(vibenet): show ERC-20 identity and transfers in the explorer Token contracts were listed as generic addresses. The address page now reads the token's name, supply, and transfers, and the Actors tab is hidden for now. --- .../explorer/address/[addr]/AccountShell.tsx | 13 +- .../address/[addr]/PublicAddressView.tsx | 155 +++++------ .../explorer/address/[addr]/TokenOverview.tsx | 41 +++ .../address/[addr]/TokenTransfers.tsx | 151 +++++++++++ app/vibenet/library/token.test.ts | 140 ++++++++++ app/vibenet/library/token.ts | 242 +++++++++++++++++- 6 files changed, 645 insertions(+), 97 deletions(-) create mode 100644 app/vibenet/explorer/address/[addr]/TokenOverview.tsx create mode 100644 app/vibenet/explorer/address/[addr]/TokenTransfers.tsx create mode 100644 app/vibenet/library/token.test.ts diff --git a/app/vibenet/explorer/address/[addr]/AccountShell.tsx b/app/vibenet/explorer/address/[addr]/AccountShell.tsx index c531d32a..189674ab 100644 --- a/app/vibenet/explorer/address/[addr]/AccountShell.tsx +++ b/app/vibenet/explorer/address/[addr]/AccountShell.tsx @@ -53,6 +53,10 @@ export function useSectionParam({ type AccountShellProps = { name: string; address: string; + /** Line under the title, above the address. Token pages use it for the token name. */ + subtitle?: ReactNode; + /** Replaces the account avatar. Token pages pass a symbol mark. */ + avatar?: ReactNode; avatarVariant?: 'default' | 'spending'; badges?: ReactNode; actions?: ReactNode; @@ -65,6 +69,8 @@ type AccountShellProps = { export function AccountShell({ name, address, + subtitle, + avatar, avatarVariant = 'default', badges, actions, @@ -77,7 +83,7 @@ export function AccountShell({
- + {avatar ?? }
@@ -85,6 +91,11 @@ export function AccountShell({ {badges}
+ {subtitle ? ( + + {subtitle} + + ) : null}
diff --git a/app/vibenet/explorer/address/[addr]/PublicAddressView.tsx b/app/vibenet/explorer/address/[addr]/PublicAddressView.tsx index 88ed35ea..32871829 100644 --- a/app/vibenet/explorer/address/[addr]/PublicAddressView.tsx +++ b/app/vibenet/explorer/address/[addr]/PublicAddressView.tsx @@ -1,125 +1,106 @@ 'use client'; // Read-only inspector for an address that is not one of your local accounts: -// assets, actors (owners + session keys as indexed), and activity. No `@aa` — -// this is the light public path. Shares the assets + activity components with -// the owned management view and renders inside the shared AccountShell. +// assets and activity. Contract addresses that implement ERC-20 swap in a +// token profile and a transfer list. No `@aa` — this is the light public path. +// Shares the assets + activity components with the owned management view and +// renders inside AccountShell. -import { useMemo } from 'react'; +import { useEffect, useState } from 'react'; -import { Card } from '../../../../components/ui/Card'; -import { Text } from '../../../../components/ui/Text'; -import { DetailList, DetailRow } from '../../../components/DetailList'; -import { ExplorerLink } from '../../../components/ExplorerLink'; +import { Spinner } from '../../../../components/ui/Spinner'; import { Badge } from '../../../demos/_shared/primitives'; -import type { ActorEntry, ExplorerAddressResponse } from '../../../library/api-types'; -import { authLabel, expiryLabel, K1_AUTHENTICATOR, scopeChips } from '../../../library/explorer'; -import { shortAddress } from '../../../library/format'; +import type { ExplorerAddressResponse } from '../../../library/api-types'; +import { fetchErc20Profile, type Erc20Profile } from '../../../library/token'; import { AccountShell, useSectionParam, type ShellSection } from './AccountShell'; import { ActivityTable } from './ActivityTable'; import { AssetsCard } from './AssetsCard'; - -const CHIP = - 'inline-flex items-center rounded-full border border-bds-gray-10 px-2.5 py-1 text-[11px] leading-none text-bds-gray-60 dark:border-white/10 dark:text-bds-gray-40'; +import { TokenMark, TokenOverview } from './TokenOverview'; +import { TokenTransfers } from './TokenTransfers'; const SECTIONS: ShellSection[] = [ { id: 'overview', label: 'Overview' }, - { id: 'actors', label: 'Actors' }, { id: 'activity', label: 'Activity' }, ]; -function ActorCard({ actor }: { actor: ActorEntry }) { +const TOKEN_SECTIONS: ShellSection[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'activity', label: 'Transfers' }, +]; + +function SectionSpinner() { return ( - -
- - {shortAddress(actor.actorId, 14, 4)} - - {actor.isSelf ? self : null} - {authLabel(actor.authenticator)} -
-
- {scopeChips(actor.scope).map((chip) => ( - - {chip} - - ))} - {expiryLabel(actor.expiry)} - {actor.policyType !== 0 ? policy · type {actor.policyType} : null} -
- {actor.policyManager ? ( - - - - - {actor.policyCommitment ? ( - - - {shortAddress(actor.policyCommitment, 14, 4)} - - - ) : null} - - ) : null} -
+
+ +
); } export function PublicAddressView({ address, data }: { address: string; data: ExplorerAddressResponse }) { + // undefined while a contract's ERC-20 probe is in flight, null when it isn't a token. + const [profile, setProfile] = useState(data.is_contract ? undefined : null); + + useEffect(() => { + if (!data.is_contract) { + setProfile(null); + return; + } + let cancelled = false; + setProfile(undefined); + fetchErc20Profile(address).then((next) => { + if (!cancelled) setProfile(next); + }); + return () => { + cancelled = true; + }; + }, [address, data.is_contract]); + + useEffect(() => { + if (!profile) return; + const previous = document.title; + document.title = `${profile.symbol} · Vibenet Explorer`; + return () => { + document.title = previous; + }; + }, [profile]); + + const sections = profile ? TOKEN_SECTIONS : SECTIONS; const [section, selectSection] = useSectionParam({ - valid: SECTIONS.map((s) => s.id), + valid: sections.map((s) => s.id), fallback: 'overview', }); const typeBadge = data.is_contract ? 'Contract' : data.is_aa ? 'Smart account' : 'EOA'; - - // Implicit secp256k1 self key when no AccountConfiguration events are indexed. - const selfActor = useMemo( - () => ({ - actorId: data.self_actor_id ?? '', - authenticator: K1_AUTHENTICATOR, - scope: 0, - expiry: 0, - policyType: 0, - policyManager: null, - policyCommitment: null, - isSelf: true, - }), - [data.self_actor_id], - ); + const tokenName = profile && profile.name.toLowerCase() !== profile.symbol.toLowerCase() ? profile.name : null; return ( : undefined} address={address} - badges={{typeBadge}} - sections={SECTIONS} + badges={profile ? ERC-20 : {typeBadge}} + sections={sections} activeSection={section} onSelectSection={selectSection} > {section === 'overview' ? ( - - ) : section === 'actors' ? ( - data.actors_indexed ? ( - data.actors.length === 0 ? ( - - No active actors — every registered actor has been revoked. - - ) : ( -
- {data.actors.map((actor) => ( - - ))} -
- ) + profile ? ( + + ) : profile === undefined ? ( + ) : ( -
- - - Implicit secp256k1 self key — no AccountConfiguration events indexed for this address yet. - -
+ ) + ) : profile ? ( + + ) : profile === undefined ? ( + ) : ( )} diff --git a/app/vibenet/explorer/address/[addr]/TokenOverview.tsx b/app/vibenet/explorer/address/[addr]/TokenOverview.tsx new file mode 100644 index 00000000..e8ffd091 --- /dev/null +++ b/app/vibenet/explorer/address/[addr]/TokenOverview.tsx @@ -0,0 +1,41 @@ +'use client'; + +// ERC-20 identity for a contract address: the facts the address index doesn't +// store (name, decimals, supply). Shared by the public inspector's overview. + +import { Card } from '../../../../components/ui/Card'; +import { Text } from '../../../../components/ui/Text'; +import { DetailList, DetailRow } from '../../../components/DetailList'; +import { fmtTokenAmount } from '../../../library/explorer'; +import type { Erc20Profile } from '../../../library/token'; + +export function TokenMark({ symbol, size = 52 }: { symbol: string; size?: number }) { + return ( + + ); +} + +export function TokenOverview({ profile, codeSize }: { profile: Erc20Profile; codeSize: number }) { + return ( + + + Total supply + + + {fmtTokenAmount(profile.totalSupply, profile.decimals)} {profile.symbol} + + + {profile.name} + {profile.symbol} + {profile.decimals} + {codeSize.toLocaleString()} bytes + + + ); +} diff --git a/app/vibenet/explorer/address/[addr]/TokenTransfers.tsx b/app/vibenet/explorer/address/[addr]/TokenTransfers.tsx new file mode 100644 index 00000000..ee9c84b0 --- /dev/null +++ b/app/vibenet/explorer/address/[addr]/TokenTransfers.tsx @@ -0,0 +1,151 @@ +'use client'; + +// Transfer history for an ERC-20 address. The explorer index only records that +// the contract was the transaction recipient, so this loads the receipts and +// recent Transfer logs and renders from / to / amount. + +import { useEffect, useMemo, useState } from 'react'; + +import { Card } from '../../../../components/ui/Card'; +import { Spinner } from '../../../../components/ui/Spinner'; +import { Text } from '../../../../components/ui/Text'; +import { ExplorerLink } from '../../../components/ExplorerLink'; +import type { ActivityRow } from '../../../library/api-types'; +import { fmtTokenAmount } from '../../../library/explorer'; +import { fetchTokenTransfers, tokenMovement, type TokenTransfer } from '../../../library/token'; +import { ActivityTable } from './ActivityTable'; + +const TH = 'whitespace-nowrap px-4 py-3 text-left text-[13px] font-normal text-bds-gray-50'; +const TD = 'whitespace-nowrap px-4 py-3 text-[13px]'; + +function Party({ address, label }: { address: string; label: string | null }) { + if (label) { + return {label}; + } + return ; +} + +function TransferTable({ rows, symbol, decimals }: { rows: TokenTransfer[]; symbol: string; decimals: number }) { + return ( + +
+ + + + + + + + + + + + {rows.map((row) => { + const kind = tokenMovement(row.from, row.to); + return ( + + + + + + + + ); + })} + +
BlockTxFromToAmount
+ + + + + + + + + {fmtTokenAmount(row.amount, decimals)} {symbol} +
+
+
+ ); +} + +export function TokenTransfers({ + token, + symbol, + decimals, + activity, +}: { + token: string; + symbol: string; + decimals: number; + activity: ActivityRow[]; +}) { + const [rows, setRows] = useState(null); + const [failed, setFailed] = useState(false); + const txHashes = useMemo(() => activity.map((row) => row.tx_hash), [activity]); + const hashKey = txHashes.join(','); + + useEffect(() => { + let cancelled = false; + setRows(null); + setFailed(false); + fetchTokenTransfers(token, hashKey ? hashKey.split(',') : []) + .then((next) => { + if (!cancelled) setRows(next); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + return () => { + cancelled = true; + }; + }, [token, hashKey]); + + if (failed) { + return ( +
+ + Could not read transfer events for these transactions. + + +
+ ); + } + + if (rows !== null && rows.length === 0 && activity.length > 0) { + return ( +
+ + These transactions did not emit a Transfer event. + + +
+ ); + } + + if (rows === null) { + return ( +
+ + + Loading transfers + +
+ ); + } + + if (rows.length === 0) { + return ( + + + No transfers yet. + + + ); + } + + return ; +} diff --git a/app/vibenet/library/token.test.ts b/app/vibenet/library/token.test.ts new file mode 100644 index 00000000..f183d4d3 --- /dev/null +++ b/app/vibenet/library/token.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { padHex, type Hex } from 'viem'; + +import { ERC20_TRANSFER_TOPIC } from './explorer'; +import type { ExplorerTxResponse } from './api-types'; +import { + erc20ProfileFromReads, + mergeTokenTransfers, + readTransferLog, + tokenMovement, + transfersInExplorerTx, + type TokenTransfer, +} from './token'; + +const TOKEN = '0xA271Cfe48095C84A641726105b1a5E53926151e3'; +const HOLDER = '0x85d8fc6c089d6484b5ed1e3056878abe48550e42'; +const ZERO = '0x0000000000000000000000000000000000000000'; + +function addressTopic(address: string): Hex { + return padHex(address as Hex, { size: 32 }); +} + +function amountData(amount: bigint): Hex { + return padHex(`0x${amount.toString(16)}` as Hex, { size: 32 }); +} + +describe('erc20ProfileFromReads', () => { + it('builds a profile and falls back to the symbol when name is missing', () => { + expect( + erc20ProfileFromReads({ + name: ' Vibe USD ', + symbol: 'USDV', + decimals: 6, + totalSupply: 257_194_000_000n, + }), + ).toEqual({ + name: 'Vibe USD', + symbol: 'USDV', + decimals: 6, + totalSupply: 257_194_000_000n, + }); + + expect( + erc20ProfileFromReads({ + name: null, + symbol: 'VIBE', + decimals: 18, + totalSupply: 0n, + })?.name, + ).toBe('VIBE'); + }); + + it('rejects reads that are not an ERC-20', () => { + expect( + erc20ProfileFromReads({ name: 'Non-Fungible Vibe', symbol: 'NFV', decimals: null, totalSupply: null }), + ).toBeNull(); + expect( + erc20ProfileFromReads({ name: 'Nope', symbol: '', decimals: 18, totalSupply: 1n }), + ).toBeNull(); + expect( + erc20ProfileFromReads({ name: 'Nope', symbol: 'NOPE', decimals: 18, totalSupply: null }), + ).toBeNull(); + expect( + erc20ProfileFromReads({ name: 'Nope', symbol: 'BAD🙂', decimals: 18, totalSupply: 1n }), + ).toBeNull(); + }); +}); + +describe('token transfers', () => { + it('labels mints and burns from the zero address', () => { + expect(tokenMovement(ZERO, HOLDER)).toBe('mint'); + expect(tokenMovement(HOLDER, ZERO)).toBe('burn'); + expect(tokenMovement(HOLDER, TOKEN)).toBe('transfer'); + }); + + it('decodes a Transfer log for the token and ignores anything else', () => { + const log = { + address: TOKEN, + topics: [ERC20_TRANSFER_TOPIC, addressTopic(ZERO), addressTopic(HOLDER)], + data: amountData(1_000_000_000n), + transactionHash: `0x${'ab'.repeat(32)}`, + blockNumber: 2_549_855n, + logIndex: 4, + }; + expect(readTransferLog(TOKEN, log)).toEqual({ + txHash: log.transactionHash, + blockNumber: 2_549_855, + logIndex: 4, + from: ZERO, + to: HOLDER, + amount: 1_000_000_000n, + }); + expect(readTransferLog(HOLDER, log)).toBeNull(); + expect(readTransferLog(TOKEN, { ...log, topics: ['0x1234', addressTopic(ZERO), addressTopic(HOLDER)] })).toBeNull(); + }); + + it('reads a mint out of an explorer transaction payload', () => { + const hash = `0x${'ab'.repeat(32)}`; + const tx = { + hash, + blockNumber: '0x26e9df', + logs: [ + { + address: TOKEN, + topics: [ERC20_TRANSFER_TOPIC, addressTopic(ZERO), addressTopic(HOLDER)], + data: amountData(1_000_000_000n), + logIndex: 0, + decoded: null, + }, + ], + } as ExplorerTxResponse; + expect(transfersInExplorerTx(TOKEN, hash, tx)).toEqual([ + { + txHash: hash, + blockNumber: 0x26e9df, + logIndex: 0, + from: ZERO, + to: HOLDER, + amount: 1_000_000_000n, + }, + ]); + }); + + it('dedupes transfers and orders them newest first', () => { + const older: TokenTransfer = { + txHash: '0x1111111111111111111111111111111111111111111111111111111111111111', + blockNumber: 10, + logIndex: 1, + from: ZERO, + to: HOLDER, + amount: 1n, + }; + const newer: TokenTransfer = { ...older, txHash: `0x${'22'.repeat(32)}`, blockNumber: 11, logIndex: 0 }; + const duplicate = { ...newer, amount: 99n }; + expect(mergeTokenTransfers([older, newer, duplicate]).map((row) => row.txHash)).toEqual([ + newer.txHash, + older.txHash, + ]); + }); +}); diff --git a/app/vibenet/library/token.ts b/app/vibenet/library/token.ts index ce9a4197..ba7b0caf 100644 --- a/app/vibenet/library/token.ts +++ b/app/vibenet/library/token.ts @@ -1,31 +1,255 @@ -// Best-effort on-chain ERC-20 metadata lookup for the explorer. The tx -// receipt has no symbol/decimals for an arbitrary token (the b20 demo lets -// users deploy their own), so resolving a display name means reading the -// contract directly. +// On-chain ERC-20 reads for the explorer. Receipts and the address index don't +// carry a token's name, supply, or transfer parties, so the address page reads +// the contract itself and decodes its Transfer logs. -import { createPublicClient, http, type Address as ViemAddress } from 'viem'; +import { createPublicClient, http, parseAbiItem, type Address as ViemAddress, type Hex } from 'viem'; +import type { ExplorerTxResponse } from './api-types'; +import { vibenetApi } from './client'; import { VIBENET_RPC_URL } from './config'; +import { decodeErc20TransferLog } from './explorer'; +import { isAddress } from './format'; -const ERC20_METADATA_ABI = [ +const ERC20_ABI = [ + { type: 'function', name: 'name', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] }, { type: 'function', name: 'symbol', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] }, { type: 'function', name: 'decimals', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] }, + { type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }, ] as const; -const client = createPublicClient({ transport: http(VIBENET_RPC_URL) }); +// A short wait lets the parallel reads and receipt lookups below leave as one +// JSON-RPC batch instead of a request per call. +const client = createPublicClient({ + transport: http(VIBENET_RPC_URL, { batch: { wait: 16 }, retryCount: 1 }), +}); export type TokenMeta = { symbol: string; decimals: number }; +export type Erc20Profile = { + name: string; + symbol: string; + decimals: number; + totalSupply: bigint; +}; + +export type TokenTransfer = { + txHash: string; + blockNumber: number; + logIndex: number; + from: string; + to: string; + amount: bigint; +}; + +export type RawTransferLog = { + address: string; + topics: readonly string[]; + data: string; + transactionHash?: string | null; + blockNumber?: bigint | number | null; + logIndex?: number | null; +}; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +// The public RPC rejects `eth_getLogs` ranges wider than 100,000 blocks. +const RECENT_TRANSFER_SPAN = 99_000n; + +const TRANSFER_EVENT = parseAbiItem( + 'event Transfer(address indexed from, address indexed to, uint256 value)', +); + /** Reads `symbol()`/`decimals()`; null if the address isn't a readable ERC-20. */ export async function fetchTokenMeta(address: string): Promise { try { const addr = address as ViemAddress; const [symbol, decimals] = await Promise.all([ - client.readContract({ address: addr, abi: ERC20_METADATA_ABI, functionName: 'symbol' }), - client.readContract({ address: addr, abi: ERC20_METADATA_ABI, functionName: 'decimals' }), + client.readContract({ address: addr, abi: ERC20_ABI, functionName: 'symbol' }), + client.readContract({ address: addr, abi: ERC20_ABI, functionName: 'decimals' }), ]); return { symbol, decimals }; } catch { return null; } } + +function cleanLabel(value: string | null, max: number): string | null { + if (!value) return null; + const text = value.trim(); + if (!text || text.length > max || /[^\x20-\x7E]/.test(text)) return null; + return text; +} + +/** Turn raw `name` / `symbol` / `decimals` / `totalSupply` reads into a profile. */ +export function erc20ProfileFromReads(reads: { + name: string | null; + symbol: string | null; + decimals: number | null; + totalSupply: bigint | null; +}): Erc20Profile | null { + const symbol = cleanLabel(reads.symbol, 32); + const { decimals, totalSupply } = reads; + if (!symbol || decimals === null || !Number.isInteger(decimals) || decimals < 0 || decimals > 36) return null; + if (typeof totalSupply !== 'bigint' || totalSupply < 0n) return null; + return { + name: cleanLabel(reads.name, 64) ?? symbol, + symbol, + decimals, + totalSupply, + }; +} + +/** Reads ERC-20 metadata. Null when the address doesn't implement the views. */ +export async function fetchErc20Profile(address: string): Promise { + if (!isAddress(address)) return null; + const addr = address as ViemAddress; + try { + const [name, symbol, decimals, totalSupply] = await Promise.all([ + client.readContract({ address: addr, abi: ERC20_ABI, functionName: 'name' }).catch(() => null), + client.readContract({ address: addr, abi: ERC20_ABI, functionName: 'symbol' }).catch(() => null), + client.readContract({ address: addr, abi: ERC20_ABI, functionName: 'decimals' }).catch(() => null), + client.readContract({ address: addr, abi: ERC20_ABI, functionName: 'totalSupply' }).catch(() => null), + ]); + return erc20ProfileFromReads({ name, symbol, decimals, totalSupply }); + } catch { + return null; + } +} + +export function tokenMovement(from: string, to: string): 'mint' | 'burn' | 'transfer' { + if (from.toLowerCase() === ZERO_ADDRESS) return 'mint'; + if (to.toLowerCase() === ZERO_ADDRESS) return 'burn'; + return 'transfer'; +} + +/** Decode one log as a Transfer of `token`. Null for anything else. */ +export function readTransferLog(token: string, log: RawTransferLog): TokenTransfer | null { + if (log.address.toLowerCase() !== token.toLowerCase()) return null; + if (!log.transactionHash || log.blockNumber == null) return null; + const decoded = decodeErc20TransferLog({ + address: log.address, + topics: log.topics.map((topic) => topic.toLowerCase()) as Hex[], + data: (log.data || '0x') as Hex, + logIndex: log.logIndex ?? 0, + decoded: null, + }); + if (!decoded) return null; + const blockNumber = typeof log.blockNumber === 'bigint' ? Number(log.blockNumber) : log.blockNumber; + if (!Number.isSafeInteger(blockNumber)) return null; + try { + return { + txHash: log.transactionHash, + blockNumber, + logIndex: log.logIndex ?? 0, + from: decoded.from, + to: decoded.to, + amount: BigInt(decoded.amount), + }; + } catch { + return null; + } +} + +/** Dedupe by tx + log index and order newest first. */ +export function mergeTokenTransfers(rows: TokenTransfer[]): TokenTransfer[] { + const seen = new Set(); + const merged: TokenTransfer[] = []; + for (const row of rows) { + const key = `${row.txHash.toLowerCase()}:${row.logIndex}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(row); + } + merged.sort((a, b) => b.blockNumber - a.blockNumber || b.logIndex - a.logIndex); + return merged; +} + +function transfersFromLogs(token: string, logs: RawTransferLog[]): TokenTransfer[] { + return logs.flatMap((log) => { + const row = readTransferLog(token, log); + return row ? [row] : []; + }); +} + +async function recentTransferLogs(token: ViemAddress): Promise { + try { + const head = await client.getBlockNumber(); + const fromBlock = head > RECENT_TRANSFER_SPAN ? head - RECENT_TRANSFER_SPAN : 0n; + const logs = await client.getLogs({ + address: token, + event: TRANSFER_EVENT, + fromBlock, + toBlock: head, + }); + return transfersFromLogs(token, logs); + } catch { + return []; + } +} + +/** Transfer rows from one explorer transaction payload. */ +export function transfersInExplorerTx(token: string, hash: string, tx: ExplorerTxResponse): TokenTransfer[] { + if (!tx.blockNumber) return []; + let blockNumber: number; + try { + blockNumber = Number(BigInt(tx.blockNumber)); + } catch { + return []; + } + if (!Number.isSafeInteger(blockNumber)) return []; + return transfersFromLogs( + token, + tx.logs.map((log) => ({ + address: log.address, + topics: log.topics, + data: log.data, + transactionHash: tx.hash || hash, + blockNumber, + logIndex: log.logIndex, + })), + ); +} + +// The public RPC rate-limits large parallel `eth_getTransactionReceipt` bursts, +// so indexed history comes from the explorer API instead. A small pool keeps +// that fan-out polite. +async function mapPool(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const results = new Array(items.length); + let cursor = 0; + async function worker() { + while (cursor < items.length) { + const index = cursor; + cursor += 1; + results[index] = await fn(items[index]); + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())); + return results; +} + +async function transfersFromExplorer(token: string, txHashes: string[]): Promise { + const txs = await mapPool(txHashes, 8, async (hash) => { + try { + return { hash, tx: await vibenetApi.explorer.tx(hash) }; + } catch { + return null; + } + }); + return txs.flatMap((row) => (row ? transfersInExplorerTx(token, row.hash, row.tx) : [])); +} + +/** + * Transfers for a token page: Transfer logs from the indexed transactions + * (which reach further back than `eth_getLogs` allows) plus every Transfer + * in the most recent block window, so account-batched transfers that never + * set the token as the transaction's `to` still show up. + */ +export async function fetchTokenTransfers(token: string, txHashes: string[]): Promise { + if (!isAddress(token)) return []; + const unique = [...new Set(txHashes.filter((hash) => /^0x[0-9a-fA-F]{64}$/.test(hash)).map((hash) => hash.toLowerCase()))]; + const [recent, historical] = await Promise.all([ + recentTransferLogs(token as ViemAddress), + transfersFromExplorer(token, unique), + ]); + return mergeTokenTransfers([...recent, ...historical]); +}