From 395caa593fb711f1f67e0a3de482e16dcbcba490 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Fri, 1 May 2026 09:09:55 -0700 Subject: [PATCH 1/3] explorer: add deployment info to contract tab --- apps/explorer/src/comps/Contract.tsx | 139 +++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 7 deletions(-) diff --git a/apps/explorer/src/comps/Contract.tsx b/apps/explorer/src/comps/Contract.tsx index e236f88cf..3f72de109 100644 --- a/apps/explorer/src/comps/Contract.tsx +++ b/apps/explorer/src/comps/Contract.tsx @@ -1,8 +1,10 @@ +import { useQuery } from '@tanstack/react-query' import type { Address } from 'ox' import * as React from 'react' import type { Abi } from 'viem' import { Link } from '@tanstack/react-router' import { useBytecode, usePublicClient } from 'wagmi' +import { Address as AddressComp } from '#comps/Address.tsx' import { ConnectWallet } from '#comps/ConnectWallet.tsx' import { AbiViewer } from '#comps/ContractAbi.tsx' import { ContractReader } from '#comps/ContractReader.tsx' @@ -12,6 +14,7 @@ import { cx } from '#lib/css' import { ellipsis } from '#lib/chars.ts' import type { ContractSource } from '#lib/domain/contract-source.ts' import { autoloadAbi, getContractAbi } from '#lib/domain/contracts.ts' +import { getApiUrl } from '#lib/env.ts' import { detectProxy, type ProxyInfo, @@ -35,23 +38,70 @@ function proxyTypeUrl(type: ProxyType | undefined): string { return type ? proxyTypeUrls[type] : proxyTypeUrls['EIP-1967'] } +function formatDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + /** * Contract tab content - shows ABI and Source */ -export function ContractTabContent(props: { - address: Address.Address - abi?: Abi - docsUrl?: string - source?: ContractSource -}) { +export function ContractTabContent( + props: ContractTabContent.Props, +): React.JSX.Element { const { address, docsUrl, source } = props const isTip20 = isTip20Address(address) const { copy: copyAbi, notifying: copiedAbi } = useCopy({ timeout: 2_000 }) + const [deploymentExpanded, setDeploymentExpanded] = React.useState(true) const [abiExpanded, setAbiExpanded] = React.useState(false) const abi = props.abi ?? getContractAbi(address) + const { data: metadataData } = useQuery({ + 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: contractCreationData } = + useQuery({ + queryKey: ['contract-creation', address], + queryFn: async () => { + const url = getApiUrl(`/api/contract/creation/${address}`) + const response = await fetch(url) + return response.json() as Promise + }, + enabled: !metadataData?.createdTxHash || !metadataData?.createdBy, + staleTime: 60_000, + }) + + const createdTimestamp = + metadataData?.createdTimestamp ?? + (contractCreationData?.creation?.timestamp + ? Number(contractCreationData.creation.timestamp) + : null) + const createdTxHash = + metadataData?.createdTxHash ?? contractCreationData?.creation?.hash ?? null + const createdBy = + metadataData?.createdBy ?? contractCreationData?.creation?.from ?? null + const hasDeploymentInfo = Boolean( + createdTimestamp || createdTxHash || createdBy, + ) + const handleCopyAbi = React.useCallback(() => { if (!abi) return void copyAbi(JSON.stringify(abi, null, 2)) @@ -102,9 +152,46 @@ export function ContractTabContent(props: { {/* Source Section */} {source && } + {/* Deployment Section */} + {hasDeploymentInfo && ( + setDeploymentExpanded(!deploymentExpanded)} + > +
+ + {createdBy && ( +
+ Created By + +
+ )} + {createdTxHash && ( +
+ Creation Tx + + {createdTxHash.slice(0, 10)}…{createdTxHash.slice(-8)} + +
+ )} +
+
+ )} + {/* ABI Section */} ABI} expanded={abiExpanded} onToggle={() => setAbiExpanded(!abiExpanded)} @@ -152,6 +239,30 @@ export function ContractTabContent(props: { ) } +export declare namespace ContractTabContent { + type Props = { + address: Address.Address + abi?: Abi | undefined + docsUrl?: string | undefined + source?: ContractSource | undefined + } + + type MetadataData = { + createdTimestamp: number | null + createdTxHash: `0x${string}` | null + createdBy: Address.Address | null + } + + type CreationResponse = { + creation: { + timestamp: string + hash: `0x${string}` | null + from: Address.Address | null + } | null + error: string | null + } +} + /** * Collapsible section component */ @@ -199,6 +310,20 @@ export function CollapsibleSection(props: { ) } +function DeploymentRow(props: { + label: string + value: string | undefined +}): React.JSX.Element { + return ( +
+ {props.label} + + {props.value ?? } + +
+ ) +} + /** * Bytecode section - shows raw bytecode */ From c6e275fb1e1898dbac1991374a75dbc0afeb8b9d Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 1 May 2026 20:38:02 +0000 Subject: [PATCH 2/3] fix: resolve deploy tx from receipts table for contract creation info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metadata endpoint queried txs WHERE from=addr OR to=addr, but deploy txs have to=NULL — the deployed address only appears in receipts.contract_address. This caused January (and any older) deployments with no post-deploy interactions to show no creation data. Changes: - fetchAddressTxAggregate: add parallel receipts query for contract_address to find the actual deploy tx hash, deployer, and timestamp - metadata route: prefer deploy receipt data over oldest-tx heuristic - address page: gate RPC fallback on missing createdTxHash instead of createdTimestamp so the fallback fires when TIDX returns a timestamp from a post-deploy call but no deploy hash Amp-Thread-ID: https://ampcode.com/threads/T-019de496-ae30-77a9-8292-f1fbb9f7c49e Co-authored-by: Amp --- apps/explorer/src/lib/server/tempo-queries.ts | 24 +++++++++++++++++++ .../src/routes/_layout/address/$address.tsx | 3 ++- .../routes/api/address/metadata/$address.ts | 11 ++++++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/explorer/src/lib/server/tempo-queries.ts b/apps/explorer/src/lib/server/tempo-queries.ts index 3c337a526..b5daf8926 100644 --- a/apps/explorer/src/lib/server/tempo-queries.ts +++ b/apps/explorer/src/lib/server/tempo-queries.ts @@ -1410,6 +1410,9 @@ export async function fetchAddressTxAggregate( oldestTxsBlockTimestamp?: unknown oldestTxHash?: string oldestTxFrom?: string + deployTxHash?: string + deployTxFrom?: string + deployTimestamp?: unknown }> { type AddressTxBoundaryRow = { hash: Hex.Hex @@ -1444,6 +1447,22 @@ export async function fetchAddressTxAggregate( .limit(1) .executeTakeFirst()) as AddressTxBoundaryRow | undefined + type DeployReceiptRow = { + tx_hash: Hex.Hex + from: string + block_timestamp: string | number | bigint | null + } + + const fetchDeployReceipt = async (): Promise< + DeployReceiptRow | undefined + > => + (await QB(chainId) + .selectFrom('receipts') + .select(['tx_hash', 'from', 'block_timestamp']) + .where('contract_address', '=', address) + .limit(1) + .executeTakeFirst()) as DeployReceiptRow | undefined + const toComparableTimestamp = (value: unknown): number => { if (typeof value === 'bigint') return Number(value) return parseTimestamp(value) ?? Number.NaN @@ -1481,6 +1500,7 @@ export async function fetchAddressTxAggregate( latestReceived, oldestSent, oldestReceived, + deployReceipt, ] = await Promise.all([ countByField('from'), countByField('to'), @@ -1497,6 +1517,7 @@ export async function fetchAddressTxAggregate( fetchBoundaryByField('to', 'desc'), fetchBoundaryByField('from', 'asc'), fetchBoundaryByField('to', 'asc'), + fetchDeployReceipt(), ]) const latest = pickBoundary('desc', [latestSent, latestReceived]) @@ -1508,6 +1529,9 @@ export async function fetchAddressTxAggregate( oldestTxsBlockTimestamp: oldest?.block_timestamp, oldestTxHash: oldest?.hash as string | undefined, oldestTxFrom: oldest?.from as string | undefined, + deployTxHash: deployReceipt?.tx_hash as string | undefined, + deployTxFrom: deployReceipt?.from as string | undefined, + deployTimestamp: deployReceipt?.block_timestamp, } } diff --git a/apps/explorer/src/routes/_layout/address/$address.tsx b/apps/explorer/src/routes/_layout/address/$address.tsx index 9d740f5ae..509b5c925 100644 --- a/apps/explorer/src/routes/_layout/address/$address.tsx +++ b/apps/explorer/src/routes/_layout/address/$address.tsx @@ -793,7 +793,8 @@ function AccountCardWithTimestamps(props: { const resolvedAccountType = addressMetadata?.accountType ?? initialAccountType const isContract = resolvedAccountType === 'contract' - const missingCreated = !addressMetadata?.createdTimestamp + const missingCreated = + !addressMetadata?.createdTxHash || !addressMetadata?.createdBy // For contracts without a createdTimestamp from metadata (0-tx contracts), // fall back to binary-search contract creation lookup diff --git a/apps/explorer/src/routes/api/address/metadata/$address.ts b/apps/explorer/src/routes/api/address/metadata/$address.ts index f3ac5c604..50029b784 100644 --- a/apps/explorer/src/routes/api/address/metadata/$address.ts +++ b/apps/explorer/src/routes/api/address/metadata/$address.ts @@ -98,6 +98,8 @@ export const Route = createFileRoute('/api/address/metadata/$address')({ bytecodePromise, fetchAddressTxAggregate(address, chainId), ]) + const deployTs = parseTimestamp(result.deployTimestamp) + const oldestTs = parseTimestamp(result.oldestTxsBlockTimestamp) response = { address, chainId, @@ -106,9 +108,12 @@ export const Route = createFileRoute('/api/address/metadata/$address')({ lastActivityTimestamp: parseTimestamp( result.latestTxsBlockTimestamp, ), - createdTimestamp: parseTimestamp(result.oldestTxsBlockTimestamp), - createdTxHash: result.oldestTxHash, - createdBy: result.oldestTxFrom, + createdTimestamp: + deployTs && oldestTs + ? Math.min(deployTs, oldestTs) + : (deployTs ?? oldestTs), + createdTxHash: result.deployTxHash ?? result.oldestTxHash, + createdBy: result.deployTxFrom ?? result.oldestTxFrom, } } From b42781cb58dde31fb19e66af2c7de15f5da7737d Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 1 May 2026 21:18:45 +0000 Subject: [PATCH 3/3] fix(explorer): preserve contract tab when index queries fail --- .../routes/api/address/metadata/$address.ts | 88 +++++++++--------- .../test/address-metadata.node.test.ts | 91 +++++++++++++++++++ 2 files changed, 137 insertions(+), 42 deletions(-) create mode 100644 apps/explorer/test/address-metadata.node.test.ts diff --git a/apps/explorer/src/routes/api/address/metadata/$address.ts b/apps/explorer/src/routes/api/address/metadata/$address.ts index 50029b784..cd45de74a 100644 --- a/apps/explorer/src/routes/api/address/metadata/$address.ts +++ b/apps/explorer/src/routes/api/address/metadata/$address.ts @@ -32,51 +32,45 @@ export const Route = createFileRoute('/api/address/metadata/$address')({ server: { handlers: { GET: async ({ params }) => { - const fallback: AddressMetadataResponse = { - address: params.address, - chainId: 0, - accountType: 'empty', - } - - if (!hasIndexSupply()) return Response.json(fallback) + const { id: chainId } = getTempoChain() try { const address = zAddress().parse(params.address) Address.assert(address) const client = getBatchedClient() - const { id: chainId } = getTempoChain() const isTip20 = isTip20Address(address) const isVirtual = VirtualAddress.validate(address) - - const bytecodePromise = getCode(client, { address }).catch( + const bytecode = await getCode(client, { address }).catch( () => undefined, ) + const baseResponse: AddressMetadataResponse = { + address, + chainId, + accountType: getAccountType(bytecode), + } + + if (!hasIndexSupply()) return Response.json(baseResponse) let response: AddressMetadataResponse if (isVirtual) { - const [bytecode, result] = await Promise.all([ - bytecodePromise, - fetchVirtualAddressTransferAggregate(address, chainId).catch( - () => ({ - count: 0, - oldestTimestamp: undefined, - latestTimestamp: undefined, - }), - ), - ]) - response = { + const result = await fetchVirtualAddressTransferAggregate( address, chainId, - accountType: getAccountType(bytecode), + ).catch(() => ({ + count: 0, + oldestTimestamp: undefined, + latestTimestamp: undefined, + })) + response = { + ...baseResponse, txCount: result.count ?? 0, lastActivityTimestamp: parseTimestamp(result.latestTimestamp), createdTimestamp: parseTimestamp(result.oldestTimestamp), } } else if (isTip20) { - const [bytecode, result, holdersRows] = await Promise.all([ - bytecodePromise, + const [result, holdersRows] = await Promise.all([ fetchTokenTransferAggregate(address, chainId).catch(() => ({ oldestTimestamp: undefined, latestTimestamp: undefined, @@ -86,34 +80,39 @@ export const Route = createFileRoute('/api/address/metadata/$address')({ ), ]) response = { - address, - chainId, - accountType: getAccountType(bytecode), + ...baseResponse, holdersCount: holdersRows[0]?.count ?? 0, lastActivityTimestamp: parseTimestamp(result.latestTimestamp), createdTimestamp: parseTimestamp(result.oldestTimestamp), } } else { - const [bytecode, result] = await Promise.all([ - bytecodePromise, + const aggregate = await Promise.allSettled([ fetchAddressTxAggregate(address, chainId), ]) - const deployTs = parseTimestamp(result.deployTimestamp) - const oldestTs = parseTimestamp(result.oldestTxsBlockTimestamp) + const result = aggregate[0] + if (result.status === 'rejected') console.error(result.reason) response = { - address, - chainId, - accountType: getAccountType(bytecode), - txCount: result.count ?? 0, + ...baseResponse, + txCount: + result.status === 'fulfilled' ? result.value.count : undefined, lastActivityTimestamp: parseTimestamp( - result.latestTxsBlockTimestamp, + result.status === 'fulfilled' + ? result.value.latestTxsBlockTimestamp + : undefined, ), - createdTimestamp: - deployTs && oldestTs - ? Math.min(deployTs, oldestTs) - : (deployTs ?? oldestTs), - createdTxHash: result.deployTxHash ?? result.oldestTxHash, - createdBy: result.deployTxFrom ?? result.oldestTxFrom, + createdTimestamp: parseTimestamp( + result.status === 'fulfilled' + ? result.value.oldestTxsBlockTimestamp + : undefined, + ), + createdTxHash: + result.status === 'fulfilled' + ? result.value.oldestTxHash + : undefined, + createdBy: + result.status === 'fulfilled' + ? result.value.oldestTxFrom + : undefined, } } @@ -125,6 +124,11 @@ export const Route = createFileRoute('/api/address/metadata/$address')({ } catch (error) { console.error(error) const errorMessage = error instanceof Error ? error.message : error + const fallback: AddressMetadataResponse = { + address: params.address, + chainId, + accountType: 'empty', + } return Response.json( { ...fallback, error: String(errorMessage) }, { status: 500 }, diff --git a/apps/explorer/test/address-metadata.node.test.ts b/apps/explorer/test/address-metadata.node.test.ts new file mode 100644 index 000000000..183d621b9 --- /dev/null +++ b/apps/explorer/test/address-metadata.node.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getCode: vi.fn(), + hasIndexSupply: vi.fn(), + getBatchedClient: vi.fn(), + getTempoChain: vi.fn(), + isTip20Address: vi.fn(), + validateVirtualAddress: vi.fn(), + fetchAddressTxAggregate: vi.fn(), +})) + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => (config: unknown) => ({ + options: config, + }), +})) + +vi.mock('viem/actions', () => ({ + getCode: mocks.getCode, +})) + +vi.mock('#lib/env', () => ({ + hasIndexSupply: mocks.hasIndexSupply, +})) + +vi.mock('#wagmi.config.ts', () => ({ + getBatchedClient: mocks.getBatchedClient, + getTempoChain: mocks.getTempoChain, +})) + +vi.mock('#lib/domain/tip20', () => ({ + isTip20Address: mocks.isTip20Address, +})) + +vi.mock('ox/tempo', () => ({ + VirtualAddress: { + validate: mocks.validateVirtualAddress, + }, +})) + +vi.mock('#lib/server/tempo-queries', () => ({ + fetchAddressTxAggregate: mocks.fetchAddressTxAggregate, + fetchTokenHoldersCountRows: vi.fn(), + fetchTokenTransferAggregate: vi.fn(), + fetchVirtualAddressTransferAggregate: vi.fn(), +})) + +import { Route } from '../src/routes/api/address/metadata/$address' + +describe('/api/address/metadata/$address', () => { + const address = '0x112fd4042E442C3C12C67AD23587b0afe36eB74E' + const handler = Route.options.server.handlers.GET + + beforeEach(() => { + vi.clearAllMocks() + mocks.getTempoChain.mockReturnValue({ id: 31318 }) + mocks.getBatchedClient.mockReturnValue({}) + mocks.hasIndexSupply.mockReturnValue(true) + mocks.isTip20Address.mockReturnValue(false) + mocks.validateVirtualAddress.mockReturnValue(false) + }) + + it('uses the active chain id in fallback responses', async () => { + mocks.hasIndexSupply.mockReturnValue(false) + mocks.getCode.mockResolvedValue(undefined) + + const response = await handler({ params: { address } }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + address, + chainId: 31318, + accountType: 'empty', + }) + }) + + it('keeps contract account type when tx aggregate fetch fails', async () => { + mocks.getCode.mockResolvedValue('0x60016000') + mocks.fetchAddressTxAggregate.mockRejectedValue(new Error('Status: 400')) + + const response = await handler({ params: { address } }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + address, + chainId: 31318, + accountType: 'contract', + }) + }) +})