From 1abe67b51aa262e9b2e3c5a1253d512bdb6f09b6 Mon Sep 17 00:00:00 2001 From: Liam Horne Date: Thu, 19 Feb 2026 23:01:11 -0500 Subject: [PATCH] feat(explorer): add TIP-20 native token info to Contract tab - New Tip20ContractInfo component showing configuration (supply cap, currency, transfer policy, paused state), creation details (date, deployer address, creation tx), and role holders with grant dates - New /api/tip20-roles endpoint querying indexed RoleMembershipUpdated events to determine current role holders - Extended address metadata API to return createdTxHash and createdBy (deployer) from the oldest transaction - Responsive layout: addresses use Midcut truncation, roles stay single-row on all screen sizes, dates hidden on mobile - Bytecode section hidden for TIP-20 precompiles (bytecode is 0xef) - Links to TIP-20 spec, Solidity reference impl, and Rust impl Amp-Thread-ID: https://ampcode.com/threads/T-019c78f2-7e7e-7572-b57f-064c64d9b457 Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019c78f2-7e7e-7572-b57f-064c64d9b457 Co-authored-by: Amp --- apps/explorer/src/comps/Contract.tsx | 40 ++- apps/explorer/src/comps/Tip20ContractInfo.tsx | 256 ++++++++++++++++++ apps/explorer/src/lib/server/tempo-queries.ts | 15 + apps/explorer/src/routeTree.gen.ts | 21 ++ .../src/routes/_layout/address/$address.tsx | 15 +- .../routes/api/address/metadata/$address.ts | 4 + apps/explorer/src/routes/api/tip20-roles.ts | 180 ++++++++++++ 7 files changed, 527 insertions(+), 4 deletions(-) create mode 100644 apps/explorer/src/comps/Tip20ContractInfo.tsx create mode 100644 apps/explorer/src/routes/api/tip20-roles.ts diff --git a/apps/explorer/src/comps/Contract.tsx b/apps/explorer/src/comps/Contract.tsx index 6278f35b..313f3681 100644 --- a/apps/explorer/src/comps/Contract.tsx +++ b/apps/explorer/src/comps/Contract.tsx @@ -17,6 +17,7 @@ import { type ProxyInfo, type ProxyType, } from '#lib/domain/proxy.ts' +import { isTip20Address } from '#lib/domain/tip20.ts' import { useCopy, useDownload } from '#lib/hooks.ts' import ChevronDownIcon from '~icons/lucide/chevron-down' import CopyIcon from '~icons/lucide/copy' @@ -44,6 +45,7 @@ export function ContractTabContent(props: { source?: ContractSource }) { const { address, docsUrl, source } = props + const isTip20 = isTip20Address(address) const { copy: copyAbi, notifying: copiedAbi } = useCopy({ timeout: 2_000 }) @@ -73,12 +75,44 @@ export function ContractTabContent(props: { return (
+ {/* TIP-20 Banner */} + {isTip20 && ( +
+ TIP-20 Native Precompile + · + + Spec + + + Solidity + + + Rust + +
+ )} + {/* Source Section */} {source && } {/* ABI Section */} ABI} expanded={abiExpanded} onToggle={() => setAbiExpanded(!abiExpanded)} @@ -120,8 +154,8 @@ export function ContractTabContent(props: { - {/* Bytecode Section */} - + {/* Bytecode Section - hidden for TIP-20 */} + {!isTip20 && }
) } diff --git a/apps/explorer/src/comps/Tip20ContractInfo.tsx b/apps/explorer/src/comps/Tip20ContractInfo.tsx new file mode 100644 index 00000000..d1f8c823 --- /dev/null +++ b/apps/explorer/src/comps/Tip20ContractInfo.tsx @@ -0,0 +1,256 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import type { Address } from 'ox' +import * as React from 'react' +import { useChainId } from 'wagmi' +import { Address as AddressComp } from '#comps/Address.tsx' +import { CollapsibleSection } from '#comps/Contract.tsx' +import { getContractInfo } from '#lib/domain/contracts.ts' +import { getApiUrl } from '#lib/env.ts' +import ArrowUpRightIcon from '~icons/lucide/arrow-up-right' + +function formatDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +export function Tip20TokenTabContent( + props: Tip20TokenTabContent.Props, +): React.JSX.Element { + const { address } = props + const chainId = useChainId() + + const [configExpanded, setConfigExpanded] = React.useState(true) + const [rolesExpanded, setRolesExpanded] = React.useState(true) + + const { data: metadataData } = useQuery<{ + createdTimestamp: number | null + createdTxHash: `0x${string}` | null + createdBy: Address.Address | null + }>({ + queryKey: ['address-metadata', address], + queryFn: async () => { + const url = getApiUrl(`/api/address/metadata/${address}`) + const response = await fetch(url) + if (!response.ok) + return { + createdTimestamp: null, + createdTxHash: null, + createdBy: null, + } as const + return response.json() + }, + }) + + const { data: tip20Data } = useQuery<{ + roles: Array<{ + role: string + roleHash: string + account: Address.Address + grantedAt?: number + grantedTx?: `0x${string}` + }> + config: { + supplyCap: string | null + currency: string | null + transferPolicyId: string | null + paused: boolean | null + } + }>({ + queryKey: ['tip20-data', address, chainId], + queryFn: async () => { + const url = getApiUrl( + '/api/tip20-roles', + new URLSearchParams({ + address, + chainId: String(chainId), + }), + ) + const response = await fetch(url) + if (!response.ok) + return { + roles: [], + config: { + supplyCap: null, + currency: null, + transferPolicyId: null, + paused: null, + }, + } + return response.json() + }, + }) + + const roles = tip20Data?.roles + const config = tip20Data?.config + + return ( +
+ {/* Info Banner */} +
+ TIP-20 Native Token + · + + Spec + + + Solidity + + + Rust + +
+ + {/* Configuration Section */} + setConfigExpanded(!configExpanded)} + > +
+
+ + + + + + {metadataData?.createdBy && ( +
+ Created By + +
+ )} + {metadataData?.createdTxHash && ( +
+ Creation Tx + + {metadataData.createdTxHash.slice(0, 10)}… + {metadataData.createdTxHash.slice(-8)} + +
+ )} +
+
+
+ + {/* Roles Section */} + setRolesExpanded(!rolesExpanded)} + > +
+ {roles && roles.length > 0 ? ( +
+ {roles.map((r) => { + const info = getContractInfo(r.account) + const label = info?.name + return ( +
+ {r.role} + {label && ( + + {label} + + )} + + + + {r.grantedAt && ( + + {formatDate(r.grantedAt)} + + )} + {r.grantedTx && ( + + Grant + + )} +
+ ) + })} +
+ ) : ( + No roles found. + )} +
+
+
+ ) +} + +function ConfigRow(props: { label: string; value: string | undefined }) { + return ( +
+ {props.label} + + {props.value ?? } + +
+ ) +} + +export declare namespace Tip20TokenTabContent { + type Props = { + address: Address.Address + } +} diff --git a/apps/explorer/src/lib/server/tempo-queries.ts b/apps/explorer/src/lib/server/tempo-queries.ts index 037b056a..41fccda8 100644 --- a/apps/explorer/src/lib/server/tempo-queries.ts +++ b/apps/explorer/src/lib/server/tempo-queries.ts @@ -589,6 +589,8 @@ export async function fetchAddressTxAggregate( count?: number latestTxsBlockTimestamp?: unknown oldestTxsBlockTimestamp?: unknown + oldestTxHash?: string + oldestTxFrom?: string }> { const result = await QB.selectFrom('txs') .where('txs.chain', '=', chainId) @@ -602,10 +604,23 @@ export async function fetchAddressTxAggregate( ]) .executeTakeFirst() + // Fetch the hash of the oldest transaction separately + const oldest = await QB.selectFrom('txs') + .where('txs.chain', '=', chainId) + .where((wb) => + wb.or([wb('txs.from', '=', address), wb('txs.to', '=', address)]), + ) + .select(['txs.hash', 'txs.from']) + .orderBy('txs.block_timestamp', 'asc') + .limit(1) + .executeTakeFirst() + return { count: result?.count ? Number(result.count) : undefined, latestTxsBlockTimestamp: result?.latestTxsBlockTimestamp, oldestTxsBlockTimestamp: result?.oldestTxsBlockTimestamp, + oldestTxHash: oldest?.hash as string | undefined, + oldestTxFrom: oldest?.from as string | undefined, } } diff --git a/apps/explorer/src/routeTree.gen.ts b/apps/explorer/src/routeTree.gen.ts index 416e01b0..884e3647 100644 --- a/apps/explorer/src/routeTree.gen.ts +++ b/apps/explorer/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' +import { Route as ApiTip20RolesRouteImport } from './routes/api/tip20-roles' import { Route as ApiSearchRouteImport } from './routes/api/search' import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiCodeRouteImport } from './routes/api/code' @@ -48,6 +49,11 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({ path: '/', getParentRoute: () => LayoutRoute, } as any) +const ApiTip20RolesRoute = ApiTip20RolesRouteImport.update({ + id: '/api/tip20-roles', + path: '/api/tip20-roles', + getParentRoute: () => rootRouteImport, +} as any) const ApiSearchRoute = ApiSearchRouteImport.update({ id: '/api/search', path: '/api/search', @@ -198,6 +204,7 @@ export interface FileRoutesByFullPath { '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute + '/api/tip20-roles': typeof ApiTip20RolesRoute '/address/$address': typeof LayoutAddressAddressRoute '/block/$id': typeof LayoutBlockIdRoute '/demo/address': typeof LayoutDemoAddressRoute @@ -227,6 +234,7 @@ export interface FileRoutesByTo { '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute + '/api/tip20-roles': typeof ApiTip20RolesRoute '/': typeof LayoutIndexRoute '/address/$address': typeof LayoutAddressAddressRoute '/block/$id': typeof LayoutBlockIdRoute @@ -259,6 +267,7 @@ export interface FileRoutesById { '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute + '/api/tip20-roles': typeof ApiTip20RolesRoute '/_layout/': typeof LayoutIndexRoute '/_layout/address/$address': typeof LayoutAddressAddressRoute '/_layout/block/$id': typeof LayoutBlockIdRoute @@ -292,6 +301,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/tip20-roles' | '/address/$address' | '/block/$id' | '/demo/address' @@ -321,6 +331,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/tip20-roles' | '/' | '/address/$address' | '/block/$id' @@ -352,6 +363,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/tip20-roles' | '/_layout/' | '/_layout/address/$address' | '/_layout/block/$id' @@ -381,6 +393,7 @@ export interface RootRouteChildren { ApiCodeRoute: typeof ApiCodeRoute ApiHealthRoute: typeof ApiHealthRoute ApiSearchRoute: typeof ApiSearchRoute + ApiTip20RolesRoute: typeof ApiTip20RolesRoute ApiAbiBatchRoute: typeof ApiAbiBatchRoute ApiAddressAddressRoute: typeof ApiAddressAddressRoute ApiTokensCountRoute: typeof ApiTokensCountRoute @@ -409,6 +422,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutIndexRouteImport parentRoute: typeof LayoutRoute } + '/api/tip20-roles': { + id: '/api/tip20-roles' + path: '/api/tip20-roles' + fullPath: '/api/tip20-roles' + preLoaderRoute: typeof ApiTip20RolesRouteImport + parentRoute: typeof rootRouteImport + } '/api/search': { id: '/api/search' path: '/api/search' @@ -645,6 +665,7 @@ const rootRouteChildren: RootRouteChildren = { ApiCodeRoute: ApiCodeRoute, ApiHealthRoute: ApiHealthRoute, ApiSearchRoute: ApiSearchRoute, + ApiTip20RolesRoute: ApiTip20RolesRoute, ApiAbiBatchRoute: ApiAbiBatchRoute, ApiAddressAddressRoute: ApiAddressAddressRoute, ApiTokensCountRoute: ApiTokensCountRoute, diff --git a/apps/explorer/src/routes/_layout/address/$address.tsx b/apps/explorer/src/routes/_layout/address/$address.tsx index 85dbbcce..aecb08aa 100644 --- a/apps/explorer/src/routes/_layout/address/$address.tsx +++ b/apps/explorer/src/routes/_layout/address/$address.tsx @@ -25,6 +25,7 @@ import { AddressCell } from '#comps/AddressCell' import { AmountCell, BalanceCell } from '#comps/AmountCell' import { BreadcrumbsSlot } from '#comps/Breadcrumbs' import { ContractTabContent, InteractTabContent } from '#comps/Contract' +import { Tip20TokenTabContent } from '#comps/Tip20ContractInfo' import { DataGrid } from '#comps/DataGrid' import { Midcut } from '#comps/Midcut' import { NotFound } from '#comps/NotFound' @@ -236,6 +237,7 @@ const allTabs = [ 'holdings', 'transfers', 'holders', + 'token', 'contract', 'interact', ] as const @@ -578,16 +580,20 @@ function RouteComponent() { }, [page, router, tab, limit, a]) // Build visible tabs based on address type + const isTip20 = Tip20.isTip20Address(address) const visibleTabs: TabValue[] = React.useMemo(() => { const tabs: TabValue[] = ['transactions', 'holdings'] if (isToken) { tabs.push('transfers', 'holders') } + if (isTip20) { + tabs.push('token') + } if (isContract) { tabs.push('contract', 'interact') } return tabs - }, [isToken, isContract]) + }, [isToken, isTip20, isContract]) const setActiveSection = React.useCallback( (newIndex: number) => { @@ -1289,6 +1295,13 @@ function SectionsWrapper(props: { /> ), } + case 'token': + return { + title: 'Token', + totalItems: 0, + itemsLabel: 'items', + content: , + } case 'contract': return { title: 'Contract', diff --git a/apps/explorer/src/routes/api/address/metadata/$address.ts b/apps/explorer/src/routes/api/address/metadata/$address.ts index 9f846470..281611b2 100644 --- a/apps/explorer/src/routes/api/address/metadata/$address.ts +++ b/apps/explorer/src/routes/api/address/metadata/$address.ts @@ -29,6 +29,8 @@ export type AddressMetadataResponse = { txCount?: number lastActivityTimestamp?: number createdTimestamp?: number + createdTxHash?: string + createdBy?: string error?: string } @@ -78,6 +80,8 @@ export const Route = createFileRoute('/api/address/metadata/$address')({ txCount, lastActivityTimestamp, createdTimestamp, + createdTxHash: txAggResult?.oldestTxHash, + createdBy: txAggResult?.oldestTxFrom, } return Response.json(response, { diff --git a/apps/explorer/src/routes/api/tip20-roles.ts b/apps/explorer/src/routes/api/tip20-roles.ts new file mode 100644 index 00000000..3a4a2735 --- /dev/null +++ b/apps/explorer/src/routes/api/tip20-roles.ts @@ -0,0 +1,180 @@ +import { createFileRoute } from '@tanstack/react-router' +import * as Hash from 'ox/Hash' +import * as Hex from 'ox/Hex' +import { formatUnits } from 'viem' +import { Abis } from 'viem/tempo' +import { getChainId, readContracts } from 'wagmi/actions' +import { tempoQueryBuilder } from '#lib/server/tempo-queries-provider' +import { zAddress } from '#lib/zod' +import { getWagmiConfig } from '#wagmi.config' + +const QB = tempoQueryBuilder + +const ROLE_MEMBERSHIP_UPDATED_SIGNATURE = + 'event RoleMembershipUpdated(bytes32 indexed role, address indexed account, address indexed sender, bool hasRole)' + +const ZERO_BYTES32 = + '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex.Hex + +const KNOWN_ROLES: Record = { + DEFAULT_ADMIN_ROLE: ZERO_BYTES32, + PAUSE_ROLE: Hash.keccak256(Hex.fromString('PAUSE_ROLE')), + UNPAUSE_ROLE: Hash.keccak256(Hex.fromString('UNPAUSE_ROLE')), + ISSUER_ROLE: Hash.keccak256(Hex.fromString('ISSUER_ROLE')), + BURN_BLOCKED_ROLE: Hash.keccak256(Hex.fromString('BURN_BLOCKED_ROLE')), +} + +const ROLE_HASH_TO_NAME = new Map( + Object.entries(KNOWN_ROLES).map(([name, hash]) => [hash, name]), +) + +export type RoleHolder = { + role: string + roleHash: string + account: string + grantedAt: number | null + grantedTx: string | null +} + +export type Tip20Config = { + supplyCap: string | null + currency: string | null + transferPolicyId: string | null + paused: boolean | null + decimals: number | null + symbol: string | null +} + +export type Tip20RolesResponse = { roles: RoleHolder[]; config: Tip20Config } + +export const Route = createFileRoute('/api/tip20-roles')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const address = zAddress().parse(url.searchParams.get('address')) + const chainIdParam = url.searchParams.get('chainId') + const config = getWagmiConfig() + const chainId = chainIdParam + ? Number(chainIdParam) + : getChainId(config) + + // Fetch TIP-20 config via server-side RPC (authenticated) + const contractResults = await readContracts(config, { + contracts: [ + { address, abi: Abis.tip20, functionName: 'supplyCap' }, + { address, abi: Abis.tip20, functionName: 'currency' }, + { address, abi: Abis.tip20, functionName: 'transferPolicyId' }, + { address, abi: Abis.tip20, functionName: 'paused' }, + { address, abi: Abis.tip20, functionName: 'decimals' }, + { address, abi: Abis.tip20, functionName: 'symbol' }, + ], + }) + + const supplyCap = contractResults[0].result as bigint | undefined + const currency = contractResults[1].result as string | undefined + const transferPolicyId = contractResults[2].result as + | bigint + | undefined + const paused = contractResults[3].result as boolean | undefined + const decimals = contractResults[4].result as number | undefined + const symbol = contractResults[5].result as string | undefined + + const MAX_UINT128 = 2n ** 128n - 1n + const tip20Config: Tip20Config = { + supplyCap: + supplyCap !== undefined && decimals !== undefined && symbol + ? supplyCap >= MAX_UINT128 + ? 'Unlimited' + : `${Number(formatUnits(supplyCap, decimals)).toLocaleString()} ${symbol}` + : null, + currency: currency ?? null, + transferPolicyId: + transferPolicyId !== undefined + ? transferPolicyId === 0n + ? '0 (none)' + : String(transferPolicyId) + : null, + paused: paused ?? null, + decimals: decimals ?? null, + symbol: symbol ?? null, + } + + const qb = QB.withSignatures([ROLE_MEMBERSHIP_UPDATED_SIGNATURE]) + + const events = await qb + .selectFrom('rolemembershipupdated') + .select([ + 'role', + 'account', + 'hasRole', + 'block_num', + 'log_idx', + 'block_timestamp', + 'tx_hash', + ]) + .where('chain', '=', chainId) + .where('address', '=', address) + .orderBy('block_num', 'asc') + .orderBy('log_idx', 'asc') + .execute() + + // Build current role holders by replaying grant/revoke events + // Key: `${role}:${account}` + const holders = new Map() + const grantMeta = new Map< + string, + { timestamp: number | null; txHash: string | null } + >() + for (const event of events) { + const key = `${event.role}:${event.account}` + holders.set(key, Boolean(event.hasRole)) + if (event.hasRole) { + grantMeta.set(key, { + timestamp: event.block_timestamp + ? Number(event.block_timestamp) + : null, + txHash: event.tx_hash ?? null, + }) + } + } + + const roles: RoleHolder[] = [] + for (const [key, hasRole] of holders) { + if (!hasRole) continue + const [roleHash, account] = key.split(':') + const rawName = ROLE_HASH_TO_NAME.get(roleHash) ?? roleHash + const roleName = rawName.endsWith('_ROLE') + ? rawName.slice(0, -5) + : rawName + const meta = grantMeta.get(key) + roles.push({ + role: roleName, + roleHash, + account, + grantedAt: meta?.timestamp ?? null, + grantedTx: meta?.txHash ?? null, + }) + } + + return Response.json( + { roles, config: tip20Config } satisfies Tip20RolesResponse, + { + headers: { + 'Cache-Control': 'public, max-age=300', + }, + }, + ) + } catch (error) { + console.error(error) + const errorMessage = error instanceof Error ? error.message : error + return Response.json( + { data: null, error: errorMessage }, + { status: 500 }, + ) + } + }, + }, + }, +})