Skip to content
Merged
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
40 changes: 37 additions & 3 deletions apps/explorer/src/comps/Contract.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 })

Expand Down Expand Up @@ -73,12 +75,44 @@ export function ContractTabContent(props: {

return (
<div className="flex flex-col h-full [&>*:last-child]:border-b-transparent">
{/* TIP-20 Banner */}
{isTip20 && (
<div className="flex flex-wrap items-center gap-x-[8px] gap-y-[4px] px-[16px] py-[10px] text-[13px] text-secondary border-b border-dashed border-distinct">
<span className="whitespace-nowrap">TIP-20 Native Precompile</span>
<span className="text-tertiary">·</span>
<a
href="https://docs.tempo.xyz/protocol/tip20/spec#tip20-1"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline whitespace-nowrap"
>
Spec
</a>
<a
href="https://github.com/tempoxyz/tempo/blob/main/tips/ref-impls/src/TIP20.sol"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline whitespace-nowrap"
>
Solidity
</a>
<a
href="https://github.com/tempoxyz/tempo/tree/main/crates/precompiles/src/tip20"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline whitespace-nowrap"
>
Rust
</a>
</div>
)}

{/* Source Section */}
{source && <SourceSection {...source} />}

{/* ABI Section */}
<CollapsibleSection
first
first={!isTip20}
title={<span title="Contract ABI">ABI</span>}
expanded={abiExpanded}
onToggle={() => setAbiExpanded(!abiExpanded)}
Expand Down Expand Up @@ -120,8 +154,8 @@ export function ContractTabContent(props: {
<AbiViewer abi={abi} />
</CollapsibleSection>

{/* Bytecode Section */}
<BytecodeSection address={address} />
{/* Bytecode Section - hidden for TIP-20 */}
{!isTip20 && <BytecodeSection address={address} />}
</div>
)
}
Expand Down
256 changes: 256 additions & 0 deletions apps/explorer/src/comps/Tip20ContractInfo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col [&>*:last-child]:border-b-transparent">
{/* Info Banner */}
<div className="flex flex-wrap items-center gap-x-[8px] gap-y-[4px] px-[16px] py-[10px] text-[13px] text-secondary border-b border-dashed border-distinct">
<span className="whitespace-nowrap">TIP-20 Native Token</span>
<span className="text-tertiary">·</span>
<a
href="https://docs.tempo.xyz/protocol/tip20/spec#tip20-1"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline whitespace-nowrap"
>
Spec
</a>
<a
href="https://github.com/tempoxyz/tempo/blob/main/tips/ref-impls/src/TIP20.sol"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline whitespace-nowrap"
>
Solidity
</a>
<a
href="https://github.com/tempoxyz/tempo/tree/main/crates/precompiles/src/tip20"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline whitespace-nowrap"
>
Rust
</a>
</div>

{/* Configuration Section */}
<CollapsibleSection
first
title="Configuration"
expanded={configExpanded}
onToggle={() => setConfigExpanded(!configExpanded)}
>
<div className="px-[18px] py-[12px]">
<div className="flex flex-col gap-[8px] text-[13px]">
<ConfigRow
label="Supply Cap"
value={config?.supplyCap ?? undefined}
/>
<ConfigRow label="Currency" value={config?.currency ?? undefined} />
<ConfigRow
label="Transfer Policy ID"
value={config?.transferPolicyId ?? undefined}
/>
<ConfigRow
label="Paused"
value={
config?.paused !== null && config?.paused !== undefined
? config.paused
? 'Yes'
: 'No'
: undefined
}
/>
<ConfigRow
label="Created"
value={
metadataData?.createdTimestamp
? formatDate(metadataData.createdTimestamp)
: undefined
}
/>
{metadataData?.createdBy && (
<div className="flex items-center justify-between gap-[12px]">
<span className="text-secondary">Created By</span>
<AddressComp
address={metadataData.createdBy}
className="text-[13px]"
/>
</div>
)}
{metadataData?.createdTxHash && (
<div className="flex items-center justify-between gap-[12px]">
<span className="text-secondary">Creation Tx</span>
<Link
to="/tx/$hash"
params={{ hash: metadataData.createdTxHash }}
className="text-[13px] font-mono text-accent hover:underline"
>
{metadataData.createdTxHash.slice(0, 10)}…
{metadataData.createdTxHash.slice(-8)}
</Link>
</div>
)}
</div>
</div>
</CollapsibleSection>

{/* Roles Section */}
<CollapsibleSection
title="Roles"
expanded={rolesExpanded}
onToggle={() => setRolesExpanded(!rolesExpanded)}
>
<div className="px-[18px] py-[12px]">
{roles && roles.length > 0 ? (
<div className="flex flex-col gap-[8px] text-[13px]">
{roles.map((r) => {
const info = getContractInfo(r.account)
const label = info?.name
return (
<div
key={`${r.role}:${r.account}`}
className="flex items-center gap-[8px]"
>
<span className="text-secondary shrink-0">{r.role}</span>
{label && (
<span className="text-[11px] text-tertiary shrink-0">
{label}
</span>
)}
<span className="min-w-0 flex-1">
<AddressComp
address={r.account}
className="text-[12px]"
align="end"
/>
</span>
{r.grantedAt && (
<span className="text-[11px] text-tertiary whitespace-nowrap hidden sm:inline">
{formatDate(r.grantedAt)}
</span>
)}
{r.grantedTx && (
<Link
to="/tx/$hash"
params={{ hash: r.grantedTx }}
className="text-[11px] text-accent hover:underline whitespace-nowrap shrink-0 inline-flex items-center gap-[2px]"
>
Grant <ArrowUpRightIcon className="size-[10px]" />
</Link>
)}
</div>
)
})}
</div>
) : (
<span className="text-[13px] text-tertiary">No roles found.</span>
)}
</div>
</CollapsibleSection>
</div>
)
}

function ConfigRow(props: { label: string; value: string | undefined }) {
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>
)
}

export declare namespace Tip20TokenTabContent {
type Props = {
address: Address.Address
}
}
15 changes: 15 additions & 0 deletions apps/explorer/src/lib/server/tempo-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
}
}

Expand Down
Loading
Loading