diff --git a/app/vibenet/explorer/address/[addr]/AccountShell.tsx b/app/vibenet/explorer/address/[addr]/AccountShell.tsx index c531d32..189674a 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 88ed35e..3287182 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 0000000..e8ffd09 --- /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 0000000..ee9c84b --- /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 0000000..f183d4d --- /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 ce9a419..ba7b0ca 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]); +}