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 && (
+
+ )}
+
{/* 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 */}
+
+
+ {/* Configuration Section */}
+
setConfigExpanded(!configExpanded)}
+ >
+
+
+
+
+
+
+
+ {metadataData?.createdBy && (
+
+ )}
+ {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 },
+ )
+ }
+ },
+ },
+ },
+})