Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/explorer/src/lib/domain/contract-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
88 changes: 77 additions & 11 deletions apps/explorer/src/lib/server/tempo-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ type QueryWithWhere<TQuery> = 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
Expand Down Expand Up @@ -65,19 +72,78 @@ export async function fetchTokenHolderBalances(
address: Address.Address,
chainId: number,
): Promise<TokenHolderBalance[]> {
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<string, bigint>()

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<Address.Address[]> {
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<string>()
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(
Expand Down
96 changes: 89 additions & 7 deletions apps/explorer/src/lib/server/token.ts
Original file line number Diff line number Diff line change
@@ -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, readContracts } 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'
Expand All @@ -16,12 +18,15 @@ 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 = 25
const HOLDERS_BALANCE_READ_BATCH_SIZE = 25

const holdersCache = new Map<
string,
{
data: {
allHolders: Array<{ address: string; balance: bigint }>
totalKnown: boolean
}
timestamp: number
}
Expand Down Expand Up @@ -52,6 +57,7 @@ export type TokenHoldersApiResponse = {
}>
total: number
totalCapped: boolean
totalKnown: boolean
totalBalance: string
offset: number
limit: number
Expand All @@ -61,15 +67,73 @@ const EMPTY_HOLDERS_RESPONSE: TokenHoldersApiResponse = {
holders: [],
total: 0,
totalCapped: false,
totalKnown: true,
totalBalance: '0',
offset: 0,
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<typeof getWagmiConfig>
candidates?: Address.Address[] | undefined
}): Promise<Array<{ address: string; balance: bigint }>> {
const candidates =
params.candidates ??
(await fetchTokenRecentTransferParticipants(
params.address,
params.chainId,
HOLDERS_FALLBACK_CANDIDATE_LIMIT,
))

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)
}

function setHoldersCache(
cacheKey: string,
allHolders: Array<{ address: string; balance: bigint }>,
timestamp: number,
totalKnown = true,
): void {
const hasExisting = holdersCache.has(cacheKey)

Expand All @@ -88,6 +152,7 @@ function setHoldersCache(
allHolders.length > COUNT_CAP
? allHolders.slice(0, COUNT_CAP)
: allHolders,
totalKnown,
},
timestamp,
})
Expand All @@ -105,14 +170,30 @@ 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 {
const fetched = await fetchTokenHolderBalances(data.address, chainId)
setHoldersCache(cacheKey, fetched, now)
allHolders =
fetched.length > COUNT_CAP ? fetched.slice(0, COUNT_CAP) : fetched
try {
const fetched = await fetchTokenHolderBalances(data.address, chainId)
setHoldersCache(cacheKey, fetched, now)
allHolders =
fetched.length > COUNT_CAP ? fetched.slice(0, COUNT_CAP) : fetched
} catch (error) {
console.error(
'Failed to fetch full holders, trying recent participants:',
error,
)
allHolders = await fetchRecentHolderBalances({
address: data.address,
chainId,
config,
})
totalKnown = false
setHoldersCache(cacheKey, allHolders, now, totalKnown)
}
}

const paginatedHolders = allHolders.slice(
Expand All @@ -126,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)
Expand All @@ -136,6 +217,7 @@ export const fetchHolders = createServerFn({ method: 'POST' })
holders,
total,
totalCapped,
totalKnown,
totalBalance: totalBalance.toString(),
offset: nextOffset,
limit: holders.length,
Expand Down
22 changes: 18 additions & 4 deletions apps/explorer/src/routes/_layout/address/$address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,7 @@ function SectionsWrapper(props: {
holders = [],
total: holdersTotal = 0,
totalCapped: holdersTotalCapped = false,
totalKnown: holdersTotalKnown = true,
totalBalance: holdersTotalBalance = '0',
} = holdersData ?? {}

Expand Down Expand Up @@ -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
Expand All @@ -1182,6 +1184,8 @@ function SectionsWrapper(props: {
address,
holdersTotal,
holdersTotalCapped,
holdersTotalKnown,
holders.length,
isHoldersTabActive,
isToken,
limit,
Expand Down Expand Up @@ -1548,7 +1552,11 @@ function SectionsWrapper(props: {
return {
title: 'Holders',
totalItems:
holdersData && (holdersTotalCapped ? '100k+' : holdersTotal),
holdersData && holdersTotalKnown
? holdersTotalCapped
? '100k+'
: holdersTotal
: undefined,
itemsLabel: 'holders',
content: (
<DataGrid
Expand Down Expand Up @@ -1594,14 +1602,20 @@ function SectionsWrapper(props: {
})
}}
totalItems={holdersTotal}
displayCount={holdersTotal}
pages={
holdersTotalKnown
? undefined
: { hasMore: holders.length === limit }
}
displayCount={holdersTotalKnown ? holdersTotal : undefined}
displayCountCapped={holdersTotalCapped}
page={page}
fetching={isHoldersFetchingNext}
loading={isHoldersLoading}
itemsLabel="holders"
itemsPerPage={limit}
pagination="simple"
showSimpleCount={holdersTotalKnown}
onPrefetchNextPage={prefetchHoldersNextPage}
emptyState="No holders found."
/>
Expand Down
11 changes: 11 additions & 0 deletions apps/explorer/src/routes/api/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const EXTENSION_LANGUAGE_MAP: Record<string, string> = {
rs: 'rust',
}

const UNVERIFIED_CACHE_HEADERS = {
'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
}

let highlighterPromise: Promise<HighlighterCore> | null = null

async function getHighlighter(): Promise<HighlighterCore> {
Expand Down Expand Up @@ -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' },
Expand Down
Loading
Loading