Skip to content
Open
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
139 changes: 132 additions & 7 deletions apps/explorer/src/comps/Contract.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -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<ContractTabContent.MetadataData>({
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<ContractTabContent.CreationResponse>({
queryKey: ['contract-creation', address],
queryFn: async () => {
const url = getApiUrl(`/api/contract/creation/${address}`)
const response = await fetch(url)
return response.json() as Promise<ContractTabContent.CreationResponse>
},
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))
Expand Down Expand Up @@ -102,9 +152,46 @@ export function ContractTabContent(props: {
{/* Source Section */}
{source && <SourceSection {...source} docsUrl={docsUrl} />}

{/* Deployment Section */}
{hasDeploymentInfo && (
<CollapsibleSection
first={!isTip20 && !source}
title="Deployment"
expanded={deploymentExpanded}
onToggle={() => setDeploymentExpanded(!deploymentExpanded)}
>
<div className="px-[18px] py-[12px] flex flex-col gap-[8px] text-[13px]">
<DeploymentRow
label="Created"
value={
createdTimestamp ? formatDate(createdTimestamp) : undefined
}
/>
{createdBy && (
<div className="flex items-center justify-between gap-[12px]">
<span className="text-secondary">Created By</span>
<AddressComp address={createdBy} className="text-[13px]" />
</div>
)}
{createdTxHash && (
<div className="flex items-center justify-between gap-[12px]">
<span className="text-secondary">Creation Tx</span>
<Link
to="/tx/$hash"
params={{ hash: createdTxHash }}
className="text-[13px] font-mono text-accent hover:underline"
>
{createdTxHash.slice(0, 10)}…{createdTxHash.slice(-8)}
</Link>
</div>
)}
</div>
</CollapsibleSection>
)}

{/* ABI Section */}
<CollapsibleSection
first={!isTip20}
first={!isTip20 && !source && !hasDeploymentInfo}
title={<span title="Contract ABI">ABI</span>}
expanded={abiExpanded}
onToggle={() => setAbiExpanded(!abiExpanded)}
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -199,6 +310,20 @@ export function CollapsibleSection(props: {
)
}

function DeploymentRow(props: {
label: string
value: string | undefined
}): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-[12px]">
<span className="text-secondary">{props.label}</span>
<span className="text-primary">
{props.value ?? <span className="text-tertiary">&mdash;</span>}
</span>
</div>
)
}

/**
* Bytecode section - shows raw bytecode
*/
Expand Down
24 changes: 24 additions & 0 deletions apps/explorer/src/lib/server/tempo-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1481,6 +1500,7 @@ export async function fetchAddressTxAggregate(
latestReceived,
oldestSent,
oldestReceived,
deployReceipt,
] = await Promise.all([
countByField('from'),
countByField('to'),
Expand All @@ -1497,6 +1517,7 @@ export async function fetchAddressTxAggregate(
fetchBoundaryByField('to', 'desc'),
fetchBoundaryByField('from', 'asc'),
fetchBoundaryByField('to', 'asc'),
fetchDeployReceipt(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am increasingly vary of adding "one more separate query" for features like these. Won't block this if creator wants to land it (can't find the Amp thread) but something we need to be more careful about.

])

const latest = pickBoundary('desc', [latestSent, latestReceived])
Expand All @@ -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,
}
}

Expand Down
5 changes: 4 additions & 1 deletion apps/explorer/src/routes/_layout/address/$address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,8 @@ async function fetchAddressMetadata(address: Address.Address) {
holdersCount?: number | null
lastActivityTimestamp: number | null
createdTimestamp: number | null
createdTxHash?: string | null
createdBy?: string | null
}>
}

Expand Down Expand Up @@ -793,7 +795,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
Expand Down
Loading
Loading