From ceede74483aba6d6404f488a4ef2600ccc0043f6 Mon Sep 17 00:00:00 2001 From: o-az Date: Mon, 27 Apr 2026 23:21:33 -0700 Subject: [PATCH 1/5] fix: fallback token holders for hot tokens --- apps/explorer/src/lib/server/tempo-queries.ts | 88 ++++++++++++++++--- apps/explorer/src/lib/server/token.ts | 87 ++++++++++++++++-- apps/explorer/test/tempo-queries.test.ts | 38 +++----- 3 files changed, 170 insertions(+), 43 deletions(-) diff --git a/apps/explorer/src/lib/server/tempo-queries.ts b/apps/explorer/src/lib/server/tempo-queries.ts index 3c337a526..c92dc9be7 100644 --- a/apps/explorer/src/lib/server/tempo-queries.ts +++ b/apps/explorer/src/lib/server/tempo-queries.ts @@ -22,6 +22,13 @@ type QueryWithWhere = TQuery & { export type TokenHolderBalance = { address: string; balance: bigint } +function topicToAddress( + topic: string | null | undefined, +): Address.Address | null { + if (!topic) return null + return `0x${topic.slice(-40)}` as Address.Address +} + type TokenHolderAggregationRow = { from: string to: string @@ -65,19 +72,78 @@ export async function fetchTokenHolderBalances( address: Address.Address, chainId: number, ): Promise { - const qb = QB(chainId).withSignatures([TRANSFER_SIGNATURE]) - const transfers = (await qb - .selectFrom('transfer') - .select((eb) => [ - eb.ref('from').as('from'), - eb.ref('to').as('to'), - eb.fn.sum('tokens').as('tokens'), - ]) + const [incoming, outgoing] = await Promise.all([ + QB(chainId) + .withSignatures([TRANSFER_SIGNATURE]) + .selectFrom('transfer') + .select((eb) => [ + eb.ref('to').as('holder'), + eb.fn.sum('tokens').as('tokens'), + ]) + .where('address', '=', address) + .where('to', '!=', zeroAddress) + .groupBy('to') + .execute(), + QB(chainId) + .withSignatures([TRANSFER_SIGNATURE]) + .selectFrom('transfer') + .select((eb) => [ + eb.ref('from').as('holder'), + eb.fn.sum('tokens').as('tokens'), + ]) + .where('address', '=', address) + .where('from', '!=', zeroAddress) + .groupBy('from') + .execute(), + ]) + + const balances = new Map() + + for (const row of incoming) { + const holder = String(row.holder) + balances.set(holder, (balances.get(holder) ?? 0n) + BigInt(row.tokens ?? 0)) + } + + for (const row of outgoing) { + const holder = String(row.holder) + balances.set(holder, (balances.get(holder) ?? 0n) - BigInt(row.tokens ?? 0)) + } + + return sortTokenHolderBalances(balances) +} + +export async function fetchTokenRecentTransferParticipants( + address: Address.Address, + chainId: number, + limit: number, +): Promise { + const rows = await QB(chainId) + .selectFrom('logs') + .select(['topic1', 'topic2']) .where('address', '=', address) - .groupBy(['from', 'to']) - .execute()) as TokenHolderAggregationRow[] + .where('topic0', '=', TRANSFER_TOPIC0) + .orderBy('block_num', 'desc') + .orderBy('log_idx', 'desc') + .limit(limit) + .execute() + + const participants: Address.Address[] = [] + const seen = new Set() + for (const row of rows) { + for (const participant of [ + topicToAddress(row.topic1), + topicToAddress(row.topic2), + ]) { + if (!participant) continue + if (participant.toLowerCase() === zeroAddress) continue + const key = participant.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + participants.push(participant) + } + } - return aggregateTokenHolderBalances(transfers) + return participants } export async function fetchTokenHoldersCountRows( diff --git a/apps/explorer/src/lib/server/token.ts b/apps/explorer/src/lib/server/token.ts index cbfac4c4e..cf4c1768f 100644 --- a/apps/explorer/src/lib/server/token.ts +++ b/apps/explorer/src/lib/server/token.ts @@ -1,11 +1,13 @@ import { createServerFn } from '@tanstack/react-start' import type { Address, Hex } from 'ox' -import { getChainId } from 'wagmi/actions' +import { Abis } from 'viem/tempo' +import { getChainId, readContract } from 'wagmi/actions' import * as z from 'zod/mini' import { TOKEN_COUNT_MAX } from '#lib/constants' import { fetchTokenFirstTransferTimestamp, fetchTokenHolderBalances, + fetchTokenRecentTransferParticipants, fetchTokenTransferCount, fetchTokenTransfers, } from '#lib/server/tempo-queries' @@ -16,6 +18,8 @@ const [MAX_LIMIT, DEFAULT_LIMIT] = [1_000, 100] const CACHE_TTL = 60_000 const COUNT_CAP = TOKEN_COUNT_MAX const HOLDERS_CACHE_MAX_ENTRIES = 20 +const HOLDERS_FALLBACK_CANDIDATE_LIMIT = 1_000 +const HOLDERS_RECENT_PARTICIPANT_FULL_QUERY_THRESHOLD = 300 const holdersCache = new Map< string, @@ -66,6 +70,47 @@ const EMPTY_HOLDERS_RESPONSE: TokenHoldersApiResponse = { limit: 0, } +function sortHolders( + holders: Array<{ address: string; balance: bigint }>, +): Array<{ address: string; balance: bigint }> { + return holders + .filter((holder) => holder.balance > 0n) + .sort((a, b) => (b.balance > a.balance ? 1 : -1)) +} + +async function fetchRecentHolderBalances(params: { + address: Address.Address + chainId: number + config: ReturnType + candidates?: Address.Address[] | undefined +}): Promise> { + const candidates = + params.candidates ?? + (await fetchTokenRecentTransferParticipants( + params.address, + params.chainId, + HOLDERS_FALLBACK_CANDIDATE_LIMIT, + )) + + const balances = await Promise.all( + candidates.map(async (candidate) => { + try { + const balance = await readContract(params.config, { + address: params.address, + abi: Abis.tip20, + functionName: 'balanceOf', + args: [candidate], + }) + return { address: candidate, balance } + } catch { + return { address: candidate, balance: 0n } + } + }), + ) + + return sortHolders(balances) +} + function setHoldersCache( cacheKey: string, allHolders: Array<{ address: string; balance: bigint }>, @@ -109,10 +154,42 @@ export const fetchHolders = createServerFn({ method: 'POST' }) if (cached && now - cached.timestamp < CACHE_TTL) { allHolders = cached.data.allHolders } else { - const fetched = await fetchTokenHolderBalances(data.address, chainId) - setHoldersCache(cacheKey, fetched, now) - allHolders = - fetched.length > COUNT_CAP ? fetched.slice(0, COUNT_CAP) : fetched + const recentParticipants = await fetchTokenRecentTransferParticipants( + data.address, + chainId, + HOLDERS_FALLBACK_CANDIDATE_LIMIT, + ) + + if ( + recentParticipants.length >= + HOLDERS_RECENT_PARTICIPANT_FULL_QUERY_THRESHOLD + ) { + allHolders = await fetchRecentHolderBalances({ + address: data.address, + chainId, + config, + candidates: recentParticipants, + }) + setHoldersCache(cacheKey, allHolders, now) + } else { + try { + const fetched = await fetchTokenHolderBalances( + data.address, + chainId, + ) + setHoldersCache(cacheKey, fetched, now) + allHolders = + fetched.length > COUNT_CAP ? fetched.slice(0, COUNT_CAP) : fetched + } catch { + allHolders = await fetchRecentHolderBalances({ + address: data.address, + chainId, + config, + candidates: recentParticipants, + }) + setHoldersCache(cacheKey, allHolders, now) + } + } } const paginatedHolders = allHolders.slice( diff --git a/apps/explorer/test/tempo-queries.test.ts b/apps/explorer/test/tempo-queries.test.ts index 764b1e15a..755f97691 100644 --- a/apps/explorer/test/tempo-queries.test.ts +++ b/apps/explorer/test/tempo-queries.test.ts @@ -839,20 +839,13 @@ describe('tempo-queries', () => { ) }) - it('fetchTokenHolderBalances aggregates holders from raw transfer rows', async () => { + it('fetchTokenHolderBalances aggregates holders from incoming and outgoing totals', async () => { mockQueryBuilder.setResponses([ [ - { - from: '0x0000000000000000000000000000000000000000', - to: '0xaaaa', - tokens: '10', - }, - { - from: '0xaaaa', - to: '0xbbbb', - tokens: '4', - }, + { holder: '0xaaaa', tokens: '10' }, + { holder: '0xbbbb', tokens: '4' }, ], + [{ holder: '0xaaaa', tokens: '4' }], ]) await expect( @@ -863,24 +856,15 @@ describe('tempo-queries', () => { ]) }) - it('fetchTokenHolderBalances aggregates incoming and outgoing balances', async () => { + it('fetchTokenHolderBalances filters zero and negative balances', async () => { mockQueryBuilder.setResponses([ [ - { - from: '0x1111', - to: '0x0000000000000000000000000000000000000000', - tokens: '5', - }, - { - from: '0x0000000000000000000000000000000000000000', - to: '0x1111', - tokens: '10', - }, - { - from: '0x0000000000000000000000000000000000000000', - to: '0x2222', - tokens: '3', - }, + { holder: '0x1111', tokens: '10' }, + { holder: '0x2222', tokens: '3' }, + ], + [ + { holder: '0x1111', tokens: '5' }, + { holder: '0x3333', tokens: '2' }, ], ]) From 0024bee1894699dddb1d09ec2428837aa69b7bcd Mon Sep 17 00:00:00 2001 From: o-az Date: Mon, 27 Apr 2026 23:41:29 -0700 Subject: [PATCH 2/5] fix: tune token holders rpc fallback --- apps/explorer/src/lib/server/token.ts | 65 +++++++++++++++++++-------- apps/explorer/src/wagmi.config.ts | 16 +++++-- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/apps/explorer/src/lib/server/token.ts b/apps/explorer/src/lib/server/token.ts index cf4c1768f..7479485fd 100644 --- a/apps/explorer/src/lib/server/token.ts +++ b/apps/explorer/src/lib/server/token.ts @@ -1,7 +1,7 @@ import { createServerFn } from '@tanstack/react-start' import type { Address, Hex } from 'ox' import { Abis } from 'viem/tempo' -import { getChainId, readContract } from 'wagmi/actions' +import { getChainId, readContracts } from 'wagmi/actions' import * as z from 'zod/mini' import { TOKEN_COUNT_MAX } from '#lib/constants' import { @@ -18,8 +18,9 @@ const [MAX_LIMIT, DEFAULT_LIMIT] = [1_000, 100] const CACHE_TTL = 60_000 const COUNT_CAP = TOKEN_COUNT_MAX const HOLDERS_CACHE_MAX_ENTRIES = 20 -const HOLDERS_FALLBACK_CANDIDATE_LIMIT = 1_000 -const HOLDERS_RECENT_PARTICIPANT_FULL_QUERY_THRESHOLD = 300 +const HOLDERS_FALLBACK_CANDIDATE_LIMIT = 25 +const HOLDERS_RECENT_PARTICIPANT_FULL_QUERY_THRESHOLD = 20 +const HOLDERS_BALANCE_READ_BATCH_SIZE = 25 const holdersCache = new Map< string, @@ -92,21 +93,36 @@ async function fetchRecentHolderBalances(params: { HOLDERS_FALLBACK_CANDIDATE_LIMIT, )) - const balances = await Promise.all( - candidates.map(async (candidate) => { - try { - const balance = await readContract(params.config, { - address: params.address, - abi: Abis.tip20, - functionName: 'balanceOf', - args: [candidate], - }) - return { address: candidate, balance } - } catch { - return { address: candidate, balance: 0n } + const balances: Array<{ address: string; balance: bigint }> = [] + + for ( + let start = 0; + start < candidates.length; + start += HOLDERS_BALANCE_READ_BATCH_SIZE + ) { + const batch = candidates.slice( + start, + start + HOLDERS_BALANCE_READ_BATCH_SIZE, + ) + const results = (await readContracts(params.config, { + contracts: batch.map((candidate) => ({ + address: params.address, + abi: Abis.tip20, + functionName: 'balanceOf', + args: [candidate], + })) as never, + })) as Array< + | { status: 'success'; result: unknown } + | { status: 'failure'; error?: unknown } + > + + for (const [index, result] of results.entries()) { + if (result.status !== 'success' || typeof result.result !== 'bigint') { + throw new Error('Failed to read holder balance') } - }), - ) + balances.push({ address: batch[index], balance: result.result }) + } + } return sortHolders(balances) } @@ -158,9 +174,20 @@ export const fetchHolders = createServerFn({ method: 'POST' }) data.address, chainId, HOLDERS_FALLBACK_CANDIDATE_LIMIT, - ) + ).catch((error) => { + console.error( + 'Failed to fetch recent transfer participants, trying full holders:', + error, + ) + return null + }) - if ( + if (!recentParticipants) { + const fetched = await fetchTokenHolderBalances(data.address, chainId) + setHoldersCache(cacheKey, fetched, now) + allHolders = + fetched.length > COUNT_CAP ? fetched.slice(0, COUNT_CAP) : fetched + } else if ( recentParticipants.length >= HOLDERS_RECENT_PARTICIPANT_FULL_QUERY_THRESHOLD ) { diff --git a/apps/explorer/src/wagmi.config.ts b/apps/explorer/src/wagmi.config.ts index 884312a7f..8951486ff 100644 --- a/apps/explorer/src/wagmi.config.ts +++ b/apps/explorer/src/wagmi.config.ts @@ -40,6 +40,8 @@ export const getTempoChain = createIsomorphicFn() ) const RPC_PROXY_HOSTNAME = 'proxy.tempo.xyz' +const CLIENT_RPC_REQUESTS_PER_SECOND = 5 +const SERVER_RPC_REQUESTS_PER_SECOND = 20 const getRpcProxyUrl = createIsomorphicFn() .client(() => { @@ -80,7 +82,7 @@ const getTempoTransport = createIsomorphicFn() // may require credentials that are only available server-side. return loadBalance([ rateLimit(http(proxy.http), { - requestsPerSecond: 20, + requestsPerSecond: CLIENT_RPC_REQUESTS_PER_SECOND, }), ]) }) @@ -88,8 +90,16 @@ const getTempoTransport = createIsomorphicFn() const proxy = getRpcProxyUrl() const fallbackUrls = getFallbackUrls() return loadBalance([ - http(proxy.http), - ...fallbackUrls.http.map((url) => http(url)), + rateLimit(http(proxy.http), { + browser: false, + requestsPerSecond: SERVER_RPC_REQUESTS_PER_SECOND, + }), + ...fallbackUrls.http.map((url) => + rateLimit(http(url), { + browser: false, + requestsPerSecond: SERVER_RPC_REQUESTS_PER_SECOND, + }), + ), ]) }) From 47987c3fc7e3f16bb2b4682e44f6a42f33794467 Mon Sep 17 00:00:00 2001 From: o-az Date: Tue, 28 Apr 2026 00:09:21 -0700 Subject: [PATCH 3/5] fix: use recent holders only as fallback --- apps/explorer/src/lib/server/token.ts | 43 ++++----------------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/apps/explorer/src/lib/server/token.ts b/apps/explorer/src/lib/server/token.ts index 7479485fd..f387ba1af 100644 --- a/apps/explorer/src/lib/server/token.ts +++ b/apps/explorer/src/lib/server/token.ts @@ -19,7 +19,6 @@ const CACHE_TTL = 60_000 const COUNT_CAP = TOKEN_COUNT_MAX const HOLDERS_CACHE_MAX_ENTRIES = 20 const HOLDERS_FALLBACK_CANDIDATE_LIMIT = 25 -const HOLDERS_RECENT_PARTICIPANT_FULL_QUERY_THRESHOLD = 20 const HOLDERS_BALANCE_READ_BATCH_SIZE = 25 const holdersCache = new Map< @@ -170,52 +169,22 @@ export const fetchHolders = createServerFn({ method: 'POST' }) if (cached && now - cached.timestamp < CACHE_TTL) { allHolders = cached.data.allHolders } else { - const recentParticipants = await fetchTokenRecentTransferParticipants( - data.address, - chainId, - HOLDERS_FALLBACK_CANDIDATE_LIMIT, - ).catch((error) => { - console.error( - 'Failed to fetch recent transfer participants, trying full holders:', - error, - ) - return null - }) - - if (!recentParticipants) { + try { const fetched = await fetchTokenHolderBalances(data.address, chainId) setHoldersCache(cacheKey, fetched, now) allHolders = fetched.length > COUNT_CAP ? fetched.slice(0, COUNT_CAP) : fetched - } else if ( - recentParticipants.length >= - HOLDERS_RECENT_PARTICIPANT_FULL_QUERY_THRESHOLD - ) { + } catch (error) { + console.error( + 'Failed to fetch full holders, trying recent participants:', + error, + ) allHolders = await fetchRecentHolderBalances({ address: data.address, chainId, config, - candidates: recentParticipants, }) setHoldersCache(cacheKey, allHolders, now) - } else { - try { - const fetched = await fetchTokenHolderBalances( - data.address, - chainId, - ) - setHoldersCache(cacheKey, fetched, now) - allHolders = - fetched.length > COUNT_CAP ? fetched.slice(0, COUNT_CAP) : fetched - } catch { - allHolders = await fetchRecentHolderBalances({ - address: data.address, - chainId, - config, - candidates: recentParticipants, - }) - setHoldersCache(cacheKey, allHolders, now) - } } } From f9d8db4231da8c32fe3b64aece47f2e4fbb55a92 Mon Sep 17 00:00:00 2001 From: o-az Date: Tue, 28 Apr 2026 00:11:40 -0700 Subject: [PATCH 4/5] fix: return no content for unverified contract code --- apps/explorer/src/lib/domain/contract-source.ts | 2 +- apps/explorer/src/routes/api/code.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/explorer/src/lib/domain/contract-source.ts b/apps/explorer/src/lib/domain/contract-source.ts index c49eb5432..d39c51802 100644 --- a/apps/explorer/src/lib/domain/contract-source.ts +++ b/apps/explorer/src/lib/domain/contract-source.ts @@ -243,7 +243,7 @@ export async function fetchContractSource(params: { const response = await fetch(url, { signal }) - if (response.status === 404) return null + if (response.status === 204 || response.status === 404) return null if (!response.ok) { console.error('Failed to fetch contract sources:', await response.text()) diff --git a/apps/explorer/src/routes/api/code.ts b/apps/explorer/src/routes/api/code.ts index eaff3a6c7..3f7b12bb4 100644 --- a/apps/explorer/src/routes/api/code.ts +++ b/apps/explorer/src/routes/api/code.ts @@ -32,6 +32,10 @@ const EXTENSION_LANGUAGE_MAP: Record = { rs: 'rust', } +const UNVERIFIED_CACHE_HEADERS = { + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600', +} + let highlighterPromise: Promise | null = null async function getHighlighter(): Promise { @@ -150,6 +154,13 @@ export const Route = createFileRoute('/api/code')({ apiUrl.searchParams.set('fields', CONTRACT_SOURCE_FIELDS) const response = await fetch(apiUrl.toString()) + if (response.status === 404) { + return new Response(null, { + status: 204, + headers: UNVERIFIED_CACHE_HEADERS, + }) + } + if (!response.ok) return Response.json( { error: 'Failed to fetch contract code' }, From b55a0aac24363816e6d8f328c5f90334f4bdafed Mon Sep 17 00:00:00 2001 From: o-az Date: Tue, 28 Apr 2026 00:23:07 -0700 Subject: [PATCH 5/5] fix: hide unknown token holder totals --- apps/explorer/src/lib/server/token.ts | 15 ++++++++++--- .../src/routes/_layout/address/$address.tsx | 22 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/explorer/src/lib/server/token.ts b/apps/explorer/src/lib/server/token.ts index f387ba1af..3f76cc993 100644 --- a/apps/explorer/src/lib/server/token.ts +++ b/apps/explorer/src/lib/server/token.ts @@ -26,6 +26,7 @@ const holdersCache = new Map< { data: { allHolders: Array<{ address: string; balance: bigint }> + totalKnown: boolean } timestamp: number } @@ -56,6 +57,7 @@ export type TokenHoldersApiResponse = { }> total: number totalCapped: boolean + totalKnown: boolean totalBalance: string offset: number limit: number @@ -65,6 +67,7 @@ const EMPTY_HOLDERS_RESPONSE: TokenHoldersApiResponse = { holders: [], total: 0, totalCapped: false, + totalKnown: true, totalBalance: '0', offset: 0, limit: 0, @@ -130,6 +133,7 @@ function setHoldersCache( cacheKey: string, allHolders: Array<{ address: string; balance: bigint }>, timestamp: number, + totalKnown = true, ): void { const hasExisting = holdersCache.has(cacheKey) @@ -148,6 +152,7 @@ function setHoldersCache( allHolders.length > COUNT_CAP ? allHolders.slice(0, COUNT_CAP) : allHolders, + totalKnown, }, timestamp, }) @@ -165,9 +170,11 @@ export const fetchHolders = createServerFn({ method: 'POST' }) const now = Date.now() let allHolders: Array<{ address: string; balance: bigint }> + let totalKnown = true if (cached && now - cached.timestamp < CACHE_TTL) { allHolders = cached.data.allHolders + totalKnown = cached.data.totalKnown } else { try { const fetched = await fetchTokenHolderBalances(data.address, chainId) @@ -184,7 +191,8 @@ export const fetchHolders = createServerFn({ method: 'POST' }) chainId, config, }) - setHoldersCache(cacheKey, allHolders, now) + totalKnown = false + setHoldersCache(cacheKey, allHolders, now, totalKnown) } } @@ -199,8 +207,8 @@ export const fetchHolders = createServerFn({ method: 'POST' }) })) const rawTotal = allHolders.length - const totalCapped = rawTotal >= COUNT_CAP - const total = totalCapped ? COUNT_CAP : rawTotal + const totalCapped = totalKnown && rawTotal >= COUNT_CAP + const total = rawTotal >= COUNT_CAP ? COUNT_CAP : rawTotal const nextOffset = data.offset + holders.length const totalBalance = allHolders.reduce((sum, h) => sum + h.balance, 0n) @@ -209,6 +217,7 @@ export const fetchHolders = createServerFn({ method: 'POST' }) holders, total, totalCapped, + totalKnown, totalBalance: totalBalance.toString(), offset: nextOffset, limit: holders.length, diff --git a/apps/explorer/src/routes/_layout/address/$address.tsx b/apps/explorer/src/routes/_layout/address/$address.tsx index a5ab1eac1..c2c62abc5 100644 --- a/apps/explorer/src/routes/_layout/address/$address.tsx +++ b/apps/explorer/src/routes/_layout/address/$address.tsx @@ -1069,6 +1069,7 @@ function SectionsWrapper(props: { holders = [], total: holdersTotal = 0, totalCapped: holdersTotalCapped = false, + totalKnown: holdersTotalKnown = true, totalBalance: holdersTotalBalance = '0', } = holdersData ?? {} @@ -1164,8 +1165,9 @@ function SectionsWrapper(props: { if (!isToken || !isHoldersTabActive) return const nextPage = page + 1 - const hasNextPage = - holdersTotalCapped || nextPage <= Math.ceil(holdersTotal / limit) + const hasNextPage = holdersTotalKnown + ? holdersTotalCapped || nextPage <= Math.ceil(holdersTotal / limit) + : holders.length === limit if (!hasNextPage) return void queryClient @@ -1182,6 +1184,8 @@ function SectionsWrapper(props: { address, holdersTotal, holdersTotalCapped, + holdersTotalKnown, + holders.length, isHoldersTabActive, isToken, limit, @@ -1548,7 +1552,11 @@ function SectionsWrapper(props: { return { title: 'Holders', totalItems: - holdersData && (holdersTotalCapped ? '100k+' : holdersTotal), + holdersData && holdersTotalKnown + ? holdersTotalCapped + ? '100k+' + : holdersTotal + : undefined, itemsLabel: 'holders', content: (