diff --git a/apps/explorer/src/comps/Receipt.tsx b/apps/explorer/src/comps/Receipt.tsx index 4e7d51ca5..7dcf3fd3a 100644 --- a/apps/explorer/src/comps/Receipt.tsx +++ b/apps/explorer/src/comps/Receipt.tsx @@ -52,7 +52,7 @@ export function Receipt(props: Receipt.Props) { <>
diff --git a/apps/explorer/src/comps/TxTransactionCard.tsx b/apps/explorer/src/comps/TxTransactionCard.tsx index 0c3a251c8..476f2e60d 100644 --- a/apps/explorer/src/comps/TxTransactionCard.tsx +++ b/apps/explorer/src/comps/TxTransactionCard.tsx @@ -2,7 +2,6 @@ import { Link } from '@tanstack/react-router' import type { Address, Hex } from 'ox' import { InfoCard } from '#comps/InfoCard' import { Midcut } from '#comps/Midcut' -import { ReceiptMark } from '#comps/ReceiptMark' import { FormattedTimestamp, useTimeFormat } from '#comps/TimeFormat' import { cx } from '#lib/css' import { useCopy } from '#lib/hooks' @@ -117,10 +116,12 @@ export function TxTransactionCard(props: TxTransactionCard.Props) { key="receipt" to="/receipt/$hash" params={{ hash }} - className="press-down flex items-end justify-between w-full print:hidden py-[6px]" + className="press-down flex items-center justify-between w-full print:hidden py-[6px]" > Receipt - + + View → + , ]} /> diff --git a/apps/explorer/src/lib/domain/known-events.ts b/apps/explorer/src/lib/domain/known-events.ts index a3db80122..1115ec1d0 100644 --- a/apps/explorer/src/lib/domain/known-events.ts +++ b/apps/explorer/src/lib/domain/known-events.ts @@ -1115,7 +1115,7 @@ function detectContractCall( return { type: 'contract call', parts: [ - { type: 'action', value: failed ? 'Failed' : 'Call' }, + { type: 'action', value: 'Call' }, { type: 'contractCall', value: { address: Address.checksum(contractAddress), input: callInput }, diff --git a/apps/explorer/src/lib/og-params.ts b/apps/explorer/src/lib/og-params.ts index 3d42ef3ee..e5081f0aa 100644 --- a/apps/explorer/src/lib/og-params.ts +++ b/apps/explorer/src/lib/og-params.ts @@ -64,6 +64,8 @@ export interface AddressOgParams { tokens?: string[] accountType?: 'empty' | 'account' | 'contract' methods?: string[] + deployer?: string + contractName?: string } // ============ Utility Functions ============ @@ -177,6 +179,9 @@ export function buildAddressOgUrl( .join(','), ) } + if (params.deployer) search.set('deployer', params.deployer) + if (params.contractName) + search.set('contractName', truncateText(params.contractName, 64)) } return `${baseUrl}/address/${params.address}?${search.toString()}` diff --git a/apps/explorer/src/lib/og.ts b/apps/explorer/src/lib/og.ts index dd798ab4b..6e9c22575 100644 --- a/apps/explorer/src/lib/og.ts +++ b/apps/explorer/src/lib/og.ts @@ -110,6 +110,11 @@ function formatPartForOgClient(part: KnownEventPart): string { return HexFormatter.truncate(part.value) case 'token': return part.value.symbol || HexFormatter.truncate(part.value.address) + case 'contractCall': { + const selector = part.value.input.slice(0, 10) + const target = HexFormatter.truncate(part.value.address) + return `${selector} on ${target}` + } default: return '' } @@ -169,6 +174,11 @@ function formatEventPart(part: KnownEventPart): string { } case 'hex': return HexFormatter.truncate(part.value) + case 'contractCall': { + const selector = part.value.input.slice(0, 10) + const target = HexFormatter.truncate(part.value.address) + return `${selector} on ${target}` + } default: return '' } @@ -342,6 +352,8 @@ export function buildAddressOgImageUrl(params: { tokens?: string[] accountType?: AccountType methods?: string[] + deployer?: string + contractName?: string }): string { const ogParams: AddressOgParams = { address: params.address, @@ -356,6 +368,8 @@ export function buildAddressOgImageUrl(params: { tokens: params.tokens, accountType: params.accountType, methods: params.methods, + deployer: params.deployer, + contractName: params.contractName, } return buildAddressOgUrl(OG_BASE_URL, ogParams) } diff --git a/apps/explorer/src/routes/_layout/address/$address.tsx b/apps/explorer/src/routes/_layout/address/$address.tsx index a9a1251a2..66693eeb0 100644 --- a/apps/explorer/src/routes/_layout/address/$address.tsx +++ b/apps/explorer/src/routes/_layout/address/$address.tsx @@ -493,6 +493,7 @@ export const Route = createFileRoute('/_layout/address/$address')({ txCount, accountType, lastActive, + contractName: loaderData?.contractInfo?.name, }) } diff --git a/apps/explorer/src/routes/_layout/block/$id.tsx b/apps/explorer/src/routes/_layout/block/$id.tsx index a4876df8f..0295c5315 100644 --- a/apps/explorer/src/routes/_layout/block/$id.tsx +++ b/apps/explorer/src/routes/_layout/block/$id.tsx @@ -13,6 +13,7 @@ import * as React from 'react' import { decodeFunctionData, isHex, zeroAddress } from 'viem' import { Abis } from 'viem/tempo' import { useChains } from 'wagmi' +import { getBlock } from 'wagmi/actions' import * as z from 'zod/mini' import { Address as AddressLink } from '#comps/Address' import { BlockCard } from '#comps/BlockCard' @@ -26,6 +27,7 @@ import { TxEventDescription } from '#comps/TxEventDescription' import { cx } from '#lib/css' import type { KnownEvent } from '#lib/domain/known-events' import { PriceFormatter } from '#lib/formatting.ts' +import { OG_BASE_URL } from '#lib/og' import { withLoaderTiming } from '#lib/profiling' import { useMediaQuery } from '#lib/hooks' import { getFeeTokenForChain } from '#lib/tokenlist' @@ -37,7 +39,7 @@ import { TRANSACTIONS_PER_PAGE, } from '#lib/queries' import { fetchLatestBlock } from '#lib/server/latest-block.ts' -import { getTempoChain } from '#wagmi.config.ts' +import { getTempoChain, getWagmiConfig } from '#wagmi.config.ts' const defaultSearchValues = { page: 1 } as const @@ -88,9 +90,31 @@ export const Route = createFileRoute('/_layout/block/$id')({ blockRef = { kind: 'number', blockNumber: BigInt(parsedNumber) } } - return await context.queryClient.ensureQueryData( + const result = await context.queryClient.ensureQueryData( blockDetailQueryOptions(blockRef), ) + + let prevBlockTxCounts: number[] | undefined + try { + if (result.block.number != null) { + const bn = result.block.number + const config = getWagmiConfig() + const prevBlocks = await Promise.all( + Array.from({ length: 7 }, (_, i) => + getBlock(config, { + blockNumber: bn - BigInt(i + 1), + }) + .then((b) => b.transactions.length) + .catch(() => 0), + ), + ) + prevBlockTxCounts = prevBlocks.reverse() + } + } catch { + // Ignore errors fetching prev blocks + } + + return { ...result, prevBlockTxCounts } } catch (error) { console.error(error) throw notFound({ @@ -101,6 +125,65 @@ export const Route = createFileRoute('/_layout/block/$id')({ }) } }), + head: ({ params, loaderData }) => { + const blockNumber = loaderData?.block?.number + const title = blockNumber + ? `Block ${blockNumber} \u22c5 Tempo Explorer` + : `Block ${params.id} \u22c5 Tempo Explorer` + + const search = new URLSearchParams() + if (loaderData?.block) { + const block = loaderData.block + if (block.number != null) search.set('number', block.number.toString()) + + const date = new Date(Number(block.timestamp) * 1000) + const utc = `${String(date.getUTCMonth() + 1).padStart(2, '0')}/${String(date.getUTCDate()).padStart(2, '0')}/${String(date.getUTCFullYear()).slice(-2)} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')}` + search.set('timestamp', utc) + search.set('unixTimestamp', block.timestamp.toString()) + search.set('txCount', block.transactions.length.toString()) + search.set('miner', block.miner) + if (block.parentHash) search.set('parentHash', block.parentHash) + + const gasUsed = Number(block.gasUsed) + const gasLimit = Number(block.gasLimit) + const gasPercent = + gasLimit > 0 ? `${((gasUsed / gasLimit) * 100).toFixed(1)}%` : '0%' + search.set('gasUsage', gasPercent) + + if (loaderData.prevBlockTxCounts) { + search.set('prevBlocks', loaderData.prevBlockTxCounts.join(',')) + } + } + + const ogImageUrl = `${OG_BASE_URL}/block/${params.id}?${search.toString()}` + + let description = `View block ${params.id} on Tempo.` + if (loaderData?.block) { + const block = loaderData.block + const txCount = block.transactions.length + const gasUsed = Number(block.gasUsed) + const gasLimit = Number(block.gasLimit) + const gasPercent = + gasLimit > 0 ? `${((gasUsed / gasLimit) * 100).toFixed(1)}%` : '0%' + description = `Block ${block.number ?? params.id} · ${txCount} transaction${txCount !== 1 ? 's' : ''} · ${gasPercent} gas used. Explore block details on Tempo.` + } + + return { + title, + meta: [ + { title }, + { property: 'og:title', content: title }, + { property: 'og:description', content: description }, + { name: 'twitter:description', content: description }, + { property: 'og:image', content: ogImageUrl }, + { property: 'og:image:type', content: 'image/webp' }, + { property: 'og:image:width', content: '1200' }, + { property: 'og:image:height', content: '630' }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'twitter:image', content: ogImageUrl }, + ], + } + }, }) function RouteComponent() { diff --git a/apps/explorer/src/routes/_layout/blocks.tsx b/apps/explorer/src/routes/_layout/blocks.tsx index b8a92fb89..d92d2c861 100644 --- a/apps/explorer/src/routes/_layout/blocks.tsx +++ b/apps/explorer/src/routes/_layout/blocks.tsx @@ -37,7 +37,10 @@ export const Route = createFileRoute('/_layout/blocks')({ property: 'og:description', content: 'View the latest blocks on Tempo.', }, - { property: 'og:image', content: `${OG_BASE_URL}/blocks` }, + { + property: 'og:image', + content: `${OG_BASE_URL}/blocks`, + }, { property: 'og:image:type', content: 'image/webp' }, { property: 'og:image:width', content: '1200' }, { property: 'og:image:height', content: '630' }, diff --git a/apps/explorer/src/routes/_layout/receipt/$hash.tsx b/apps/explorer/src/routes/_layout/receipt/$hash.tsx index b98c71401..d440c2ac1 100644 --- a/apps/explorer/src/routes/_layout/receipt/$hash.tsx +++ b/apps/explorer/src/routes/_layout/receipt/$hash.tsx @@ -299,7 +299,6 @@ export const Route = createFileRoute('/_layout/receipt/$hash')({ search.set('date', ogTimestamp.date) search.set('time', ogTimestamp.time) - // Include fee so the OG receipt can render the Fee row. const gasUsed = BigInt(loaderData.receipt.gasUsed ?? 0) const gasPrice = BigInt( loaderData.receipt.effectiveGasPrice ?? @@ -323,7 +322,7 @@ export const Route = createFileRoute('/_layout/receipt/$hash')({ ) } - const ogImageUrl = `${OG_BASE_URL}/tx/${params.hash}?${search.toString()}` + const ogImageUrl = `${OG_BASE_URL}/receipt/${params.hash}?${search.toString()}` return { title, diff --git a/apps/explorer/src/routes/_layout/tokens.tsx b/apps/explorer/src/routes/_layout/tokens.tsx index 00c40ed7d..6821c34ab 100644 --- a/apps/explorer/src/routes/_layout/tokens.tsx +++ b/apps/explorer/src/routes/_layout/tokens.tsx @@ -27,7 +27,10 @@ export const Route = createFileRoute('/_layout/tokens')({ property: 'og:description', content: 'Browse all tokens on Tempo.', }, - { property: 'og:image', content: `${OG_BASE_URL}/tokens` }, + { + property: 'og:image', + content: `${OG_BASE_URL}/tokens`, + }, { property: 'og:image:type', content: 'image/webp' }, { property: 'og:image:width', content: '1200' }, { property: 'og:image:height', content: '630' }, diff --git a/apps/explorer/src/routes/_layout/tx/$hash.tsx b/apps/explorer/src/routes/_layout/tx/$hash.tsx index ab3c08851..513802479 100644 --- a/apps/explorer/src/routes/_layout/tx/$hash.tsx +++ b/apps/explorer/src/routes/_layout/tx/$hash.tsx @@ -38,7 +38,7 @@ import type { FeeBreakdownItem } from '#lib/domain/receipt' import { isTip20Address } from '#lib/domain/tip20' import { PriceFormatter } from '#lib/formatting' import { useKeyboardShortcut, useMediaQuery } from '#lib/hooks' -import { buildOgImageUrl, buildTxDescription } from '#lib/og' +import { buildOgImageUrl, buildTxDescription, OG_BASE_URL } from '#lib/og' import { autoloadAbiQueryOptions, LIMIT, @@ -121,7 +121,7 @@ export const Route = createFileRoute('/_layout/tx/$hash')({ const title = `Transaction ${params.hash.slice(0, 10)}…${params.hash.slice(-6)} ⋅ Tempo Explorer` const ogImageUrl = loaderData ? buildOgImageUrl(loaderData, params.hash) - : undefined + : `${OG_BASE_URL}/tx/${params.hash}` const description = loaderData ? buildTxDescription({ timestamp: Number(loaderData.block.timestamp) * 1000, @@ -137,16 +137,12 @@ export const Route = createFileRoute('/_layout/tx/$hash')({ { property: 'og:title', content: title }, { property: 'og:description', content: description }, { name: 'twitter:description', content: description }, - ...(ogImageUrl - ? [ - { property: 'og:image', content: ogImageUrl }, - { property: 'og:image:type', content: 'image/webp' }, - { property: 'og:image:width', content: '1200' }, - { property: 'og:image:height', content: '630' }, - { name: 'twitter:card', content: 'summary_large_image' }, - { name: 'twitter:image', content: ogImageUrl }, - ] - : []), + { property: 'og:image', content: ogImageUrl }, + { property: 'og:image:type', content: 'image/webp' }, + { property: 'og:image:width', content: '1200' }, + { property: 'og:image:height', content: '630' }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'twitter:image', content: ogImageUrl }, ], } }, diff --git a/apps/og/public/bg-default.webp b/apps/og/public/bg-default.webp new file mode 100644 index 000000000..fce1f0a36 Binary files /dev/null and b/apps/og/public/bg-default.webp differ diff --git a/apps/og/public/bg-list-blocks.webp b/apps/og/public/bg-list-blocks.webp new file mode 100644 index 000000000..6308256fa Binary files /dev/null and b/apps/og/public/bg-list-blocks.webp differ diff --git a/apps/og/public/bg-list-tokens.webp b/apps/og/public/bg-list-tokens.webp new file mode 100644 index 000000000..41de76deb Binary files /dev/null and b/apps/og/public/bg-list-tokens.webp differ diff --git a/apps/og/public/bg-template-address.webp b/apps/og/public/bg-template-address.webp index aa31a29e7..941de66c4 100644 Binary files a/apps/og/public/bg-template-address.webp and b/apps/og/public/bg-template-address.webp differ diff --git a/apps/og/public/bg-template-blocks.webp b/apps/og/public/bg-template-blocks.webp new file mode 100644 index 000000000..a243d8507 Binary files /dev/null and b/apps/og/public/bg-template-blocks.webp differ diff --git a/apps/og/public/bg-template-contract.webp b/apps/og/public/bg-template-contract.webp index 86473401d..4d096ef8c 100644 Binary files a/apps/og/public/bg-template-contract.webp and b/apps/og/public/bg-template-contract.webp differ diff --git a/apps/og/public/bg-template-receipt.webp b/apps/og/public/bg-template-receipt.webp new file mode 100644 index 000000000..ff2d9b47c Binary files /dev/null and b/apps/og/public/bg-template-receipt.webp differ diff --git a/apps/og/public/bg-template-token.webp b/apps/og/public/bg-template-token.webp index be8e03ccd..31e01f6ef 100644 Binary files a/apps/og/public/bg-template-token.webp and b/apps/og/public/bg-template-token.webp differ diff --git a/apps/og/public/bg-template-transaction.webp b/apps/og/public/bg-template-transaction.webp index 316bbbd6a..7dbe9e7a1 100644 Binary files a/apps/og/public/bg-template-transaction.webp and b/apps/og/public/bg-template-transaction.webp differ diff --git a/apps/og/public/bg-template.webp b/apps/og/public/bg-template.webp index db4a553ed..91385a695 100644 Binary files a/apps/og/public/bg-template.webp and b/apps/og/public/bg-template.webp differ diff --git a/apps/og/public/fonts/Pilat-Book.otf b/apps/og/public/fonts/Pilat-Book.otf new file mode 100644 index 000000000..1dcdd027a Binary files /dev/null and b/apps/og/public/fonts/Pilat-Book.otf differ diff --git a/apps/og/public/og-explorer.webp b/apps/og/public/og-explorer.webp deleted file mode 100644 index d0bf223f9..000000000 Binary files a/apps/og/public/og-explorer.webp and /dev/null differ diff --git a/apps/og/public/tempo-lockup.svg b/apps/og/public/tempo-lockup.svg index d691373dc..b50aed19b 100644 --- a/apps/og/public/tempo-lockup.svg +++ b/apps/og/public/tempo-lockup.svg @@ -1,5 +1,7 @@ - - - - + + + + + + diff --git a/apps/og/public/tempo-lockup.webp b/apps/og/public/tempo-lockup.webp deleted file mode 100644 index 19863b7dc..000000000 Binary files a/apps/og/public/tempo-lockup.webp and /dev/null differ diff --git a/apps/og/public/tempo-mark.webp b/apps/og/public/tempo-mark.webp new file mode 100644 index 000000000..3a8e3ea72 Binary files /dev/null and b/apps/og/public/tempo-mark.webp differ diff --git a/apps/og/src/index.tsx b/apps/og/src/index.tsx index f29646243..8b5e4b253 100644 --- a/apps/og/src/index.tsx +++ b/apps/og/src/index.tsx @@ -5,16 +5,20 @@ import { cache } from 'hono/cache' import { except } from 'hono/combine' import { createFactory, createMiddleware } from 'hono/factory' import { HTTPException } from 'hono/http-exception' + import { Address } from 'ox' import { addressOgQuerySchema, + blockOgQuerySchema, tokenOgQuerySchema, txOgQuerySchema, } from '#params.ts' import { AddressCard, type AddressData, + BlockCard, + type BlockData, ReceiptCard, type ReceiptData, TokenCard, @@ -71,7 +75,7 @@ app .get('/', (context) => context.text('OK')) .get('/health', (context) => context.text('OK')) .get('/explorer', (context) => - context.env.ASSETS.fetch(new URL('/og-explorer.webp', context.req.url)), + context.env.ASSETS.fetch(new URL('/bg-default.webp', context.req.url)), ) .get('/blocks', (context) => context.env.ASSETS.fetch(new URL('/og-blocks.webp', context.req.url)), @@ -84,30 +88,17 @@ app.use('/tx/*', rateLimiter) app.use('/tx', rateLimiter) app.use('/token/*', rateLimiter) app.use('/address/*', rateLimiter) +app.use('/receipt/*', rateLimiter) +app.use('/block/*', rateLimiter) +app.use('/blocks', rateLimiter) +app.use('/tokens', rateLimiter) app.use('/explorer', rateLimiter) app.use('/blocks', rateLimiter) app.use('/tokens', rateLimiter) app.use('*', except(isNotProd, cacheMiddleware)) -app.get('/tx', (context) => - context.env.ASSETS.fetch(new URL('/og-transactions.webp', context.req.url)), -) +// Dynamic OG image routes -/** - * Transaction OG Image - * - * URL Parameters: - * - hash: Transaction hash (0x...) - * - block: Block number - * - sender: Sender address (0x...) - * - date: Date string (e.g., "12/01/2025") - * - time: Time string (e.g., "18:32:21 GMT+0") - * - fee: Fee amount (e.g., "-$0.013") - * - feeToken: Token used for fee (e.g., "aUSD") - * - feePayer: Address that paid fee (e.g., "0x8f5a...3bc3") - * - total: Total display string (e.g., "-$1.55") - * - ev1..ev6 (or e1..e6, event1..event6): Event strings in format "Action|Details|Amount" (optional 4th field: Message) - */ app.get('/tx/:hash', zValidator('query', txOgQuerySchema), async (context) => { const hash = context.req.param('hash') if (!isTxHash(hash)) @@ -125,10 +116,12 @@ app.get('/tx/:hash', zValidator('query', txOgQuerySchema), async (context) => { feePayer: txParams.feePayer, total: txParams.total, events: txParams.events, + eventsFailed: txParams.eventsFailed, + status: txParams.status, } const [fonts, images] = await Promise.all([ - loadFonts(), + loadFonts(context.env), loadImages(context.env), ]) @@ -140,11 +133,8 @@ app.get('/tx/:hash', zValidator('query', txOgQuerySchema), async (context) => { tw="absolute inset-0 w-full h-full" style={{ objectFit: 'cover' }} /> -
- +
+
, { @@ -153,8 +143,19 @@ app.get('/tx/:hash', zValidator('query', txOgQuerySchema), async (context) => { format: 'webp', module, fonts: [ - { weight: 400, name: 'GeistMono', data: fonts.mono, style: 'normal' }, + { + weight: 400, + name: 'GeistMono', + data: fonts.mono, + style: 'normal', + }, { weight: 500, name: 'Inter', data: fonts.inter, style: 'normal' }, + { + weight: 400, + name: 'Pilat', + data: fonts.pilat, + style: 'normal', + }, ], }, ) @@ -164,9 +165,148 @@ app.get('/tx/:hash', zValidator('query', txOgQuerySchema), async (context) => { }) }) -/** - * Token/Asset OG Image - */ +app.get( + '/receipt/:hash', + zValidator('query', txOgQuerySchema), + async (context) => { + const hash = context.req.param('hash') + if (!isTxHash(hash)) + throw new HTTPException(400, { message: 'Invalid transaction hash' }) + + const txParams = context.req.valid('query') + const receiptData: ReceiptData = { + hash, + blockNumber: txParams.block, + sender: txParams.sender, + date: txParams.date, + time: txParams.time, + fee: txParams.fee, + feeToken: txParams.feeToken, + feePayer: txParams.feePayer, + total: txParams.total, + events: txParams.events, + eventsFailed: txParams.eventsFailed, + status: txParams.status, + } + + const [fonts, images] = await Promise.all([ + loadFonts(context.env), + loadImages(context.env), + ]) + + const imageResponse = new ImageResponse( +
+ +
+ +
+
, + { + width: 1200, + height: 630, + format: 'webp', + module, + fonts: [ + { + weight: 400, + name: 'GeistMono', + data: fonts.mono, + style: 'normal', + }, + { + weight: 500, + name: 'Inter', + data: fonts.inter, + style: 'normal', + }, + { + weight: 400, + name: 'Pilat', + data: fonts.pilat, + style: 'normal', + }, + ], + }, + ) + + return new Response(imageResponse.body, { + headers: { 'Content-Type': 'image/webp' }, + }) + }, +) + +app.get( + '/block/:id', + zValidator('query', blockOgQuerySchema), + async (context) => { + const blockParams = context.req.valid('query') + const blockData: BlockData = { + number: blockParams.number, + timestamp: blockParams.timestamp, + unixTimestamp: blockParams.unixTimestamp, + txCount: blockParams.txCount, + miner: blockParams.miner, + parentHash: blockParams.parentHash, + gasUsage: blockParams.gasUsage, + prevBlockTxCounts: blockParams.prevBlocks, + } + + const [fonts, images] = await Promise.all([ + loadFonts(context.env), + loadImages(context.env), + ]) + + const imageResponse = new ImageResponse( +
+ +
+ +
+
, + { + width: 1200, + height: 630, + format: 'webp', + module, + fonts: [ + { + weight: 400, + name: 'GeistMono', + data: fonts.mono, + style: 'normal', + }, + { + weight: 500, + name: 'Inter', + data: fonts.inter, + style: 'normal', + }, + { + weight: 400, + name: 'Pilat', + data: fonts.pilat, + style: 'normal', + }, + ], + }, + ) + + return new Response(imageResponse.body, { + headers: { 'Content-Type': 'image/webp' }, + }) + }, +) + app.get( '/token/:address', zValidator('query', tokenOgQuerySchema), @@ -180,7 +320,7 @@ app.get( const tokenData: TokenData = { address, ...tokenParams } const [fonts, images, tokenIcon] = await Promise.all([ - loadFonts(), + loadFonts(context.env), loadImages(context.env), tokenParams.chainId ? fetchTokenIcon(address, tokenParams.chainId) : null, ]) @@ -193,7 +333,7 @@ app.get( tw="absolute inset-0 w-full h-full" style={{ objectFit: 'cover' }} /> -
+
-
+
, @@ -272,8 +427,24 @@ app.get( format: 'webp', module, fonts: [ - { weight: 400, name: 'GeistMono', data: fonts.mono, style: 'normal' }, - { weight: 500, name: 'Inter', data: fonts.inter, style: 'normal' }, + { + weight: 400, + name: 'GeistMono', + data: fonts.mono, + style: 'normal', + }, + { + weight: 500, + name: 'Inter', + data: fonts.inter, + style: 'normal', + }, + { + weight: 400, + name: 'Pilat', + data: fonts.pilat, + style: 'normal', + }, ], }, ) @@ -284,4 +455,14 @@ app.get( }, ) +// Static listing OG images + +app.get('/blocks', (context) => + context.env.ASSETS.fetch(new URL('/bg-list-blocks.webp', context.req.url)), +) + +app.get('/tokens', (context) => + context.env.ASSETS.fetch(new URL('/bg-list-tokens.webp', context.req.url)), +) + export default app diff --git a/apps/og/src/params.ts b/apps/og/src/params.ts index 85773204e..9aac01c52 100644 --- a/apps/og/src/params.ts +++ b/apps/og/src/params.ts @@ -116,6 +116,8 @@ export const txOgQuerySchema = z.pipe( event4: optionalString, event5: optionalString, event6: optionalString, + eventsFailed: optionalString, + status: optionalString, }), z.transform((data) => { const events: TxOgEvent[] = [] @@ -137,6 +139,8 @@ export const txOgQuerySchema = z.pipe( feePayer: data.feePayer, total: data.total, events, + eventsFailed: data.eventsFailed === 'true', + status: data.status as 'success' | 'reverted' | undefined, } }), ) @@ -206,6 +210,8 @@ export const addressOgQuerySchema = z.pipe( tokens: commaSeparatedList(24, 12), methods: commaSeparatedList(32, 16), accountType: z.optional(zAccountType), + deployer: sanitized(MAX_PARAM_MED), + contractName: sanitized(MAX_PARAM_SHORT), }), z.transform((data) => ({ holdings: data.holdings, @@ -216,7 +222,42 @@ export const addressOgQuerySchema = z.pipe( tokens: data.tokens, methods: data.methods, accountType: data.accountType, + deployer: data.deployer, + contractName: data.contractName, })), ) export type AddressOgQueryParams = z.output + +// ============ Block OG Schema ============ + +export const blockOgQuerySchema = z.pipe( + z.object({ + number: sanitizedWithDefault(MAX_PARAM_SHORT), + timestamp: sanitizedWithDefault(MAX_PARAM_SHORT), + unixTimestamp: sanitizedWithDefault(MAX_PARAM_SHORT), + txCount: sanitizedWithDefault(16), + miner: sanitizedWithDefault(MAX_PARAM_MED), + parentHash: sanitizedWithDefault(MAX_PARAM_MED), + gasUsage: sanitizedWithDefault(16), + prevBlocks: optionalString, + }), + z.transform((data) => ({ + number: data.number, + timestamp: data.timestamp, + unixTimestamp: data.unixTimestamp, + txCount: data.txCount, + miner: data.miner, + parentHash: data.parentHash, + gasUsage: data.gasUsage, + prevBlocks: data.prevBlocks + ? data.prevBlocks + .split(',') + .map((s) => Number.parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n)) + .slice(0, 12) + : undefined, + })), +) + +export type BlockOgQueryParams = z.output diff --git a/apps/og/src/ui.tsx b/apps/og/src/ui.tsx index e4259417b..239cee305 100644 --- a/apps/og/src/ui.tsx +++ b/apps/og/src/ui.tsx @@ -11,9 +11,11 @@ export interface AddressData { lastActive: string created: string feeToken?: string - tokensHeld: string[] // Array of token symbols - accountType?: AccountType // Type of address: empty, account, or contract - methods?: string[] // Contract methods detected + tokensHeld: string[] + accountType?: AccountType + methods?: string[] + deployer?: string + contractName?: string } export interface TokenData { @@ -28,6 +30,17 @@ export interface TokenData { isFeeToken?: boolean } +export interface BlockData { + number: string + timestamp: string + unixTimestamp: string + txCount: string + miner: string + parentHash: string + gasUsage: string + prevBlockTxCounts?: number[] +} + export interface ReceiptData { hash: string blockNumber: string @@ -39,6 +52,8 @@ export interface ReceiptData { feePayer?: string total?: string events: ReceiptEvent[] + eventsFailed?: boolean + status?: 'success' | 'reverted' } interface ReceiptEvent { @@ -50,54 +65,172 @@ interface ReceiptEvent { // ============ Helpers ============ +const PILL_STYLE = { + borderRadius: '8px', + paddingLeft: '10px', + paddingRight: '10px', + paddingTop: '5px', + paddingBottom: '5px', +} + function truncateHash(hash: string, chars = 4): string { if (!hash || hash === '—') return hash if (hash.length <= chars * 2 + 2) return hash return `${hash.slice(0, chars + 2)}…${hash.slice(-chars)}` } -// Parse event details into groups for rendering +function formatDateSmart(date: string, time: string): string { + if (date === '—') return '—' + + const monthsFull = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ] + + let month = '' + let day = '' + let year = '' + let timeStr = time + + const dateMatch1 = date.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/) + if (dateMatch1) { + const m = Number.parseInt(dateMatch1[1] ?? '1', 10) + day = dateMatch1[2] ?? '1' + year = dateMatch1[3] ?? '' + month = monthsFull[m - 1] ?? '' + } + + const dateMatch2 = date.match(/^(\w+)\s+(\d{1,2}),?\s+(\d{4})$/) + if (dateMatch2) { + month = dateMatch2[1] ?? '' + day = dateMatch2[2] ?? '' + year = dateMatch2[3] ?? '' + if (month.length <= 3) { + const idx = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ].indexOf(month) + if (idx >= 0) month = monthsFull[idx] ?? month + } + } + + const dateMatch3 = date.match( + /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}:\d{2}(?::\d{2})?)\s*UTC$/, + ) + if (dateMatch3) { + year = dateMatch3[1] ?? '' + const m = Number.parseInt(dateMatch3[2] ?? '1', 10) + day = String(Number.parseInt(dateMatch3[3] ?? '1', 10)) + month = monthsFull[m - 1] ?? '' + timeStr = dateMatch3[4] ?? time + } + + if (!month || !day) return `${date} ${time}` + + let formattedTime = timeStr + const t12 = timeStr.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)(?:\s*GMT[+-]\d+)?$/i) + if (t12?.[1] && t12[2] && t12[3]) { + let h = Number.parseInt(t12[1], 10) + const p = t12[3].toUpperCase() + if (p === 'PM' && h !== 12) h += 12 + if (p === 'AM' && h === 12) h = 0 + formattedTime = `${h.toString().padStart(2, '0')}:${t12[2]}:00` + } else { + const t24 = timeStr.match( + /^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(?:GMT[+-]\d+)?$/i, + ) + if (t24?.[1] && t24[2]) { + formattedTime = `${t24[1].padStart(2, '0')}:${t24[2]}:${t24[3] ?? '00'}` + } + } + + const currentYear = new Date().getFullYear().toString() + const datePart = + year === currentYear ? `${month} ${day}` : `${month} ${day}, ${year}` + + return `${datePart} · ${formattedTime}` +} + +function isEmptyValue(val: string): boolean { + return ( + !val || + val === '—' || + val === '$0' || + val === '$0.00' || + val === '0' || + val === '0.00' + ) +} + +function isHexSelector(text: string): boolean { + return /^0x[0-9a-fA-F]{8}$/.test(text) +} + +const CURRENCY_SYMBOLS: Record = { + USD: '$', + EUR: '\u20AC', + GBP: '\u00A3', + JPY: '\u00A5', +} + export function parseEventDetails( details: string, -): { text: string; type: 'normal' | 'asset' | 'address' }[] { - const groups: { text: string; type: 'normal' | 'asset' | 'address' }[] = [] +): { text: string; type: 'normal' | 'asset' | 'address' | 'selector' }[] { + const groups: { + text: string + type: 'normal' | 'asset' | 'address' | 'selector' + }[] = [] const words = details.split(' ') let i = 0 while (i < words.length) { const word = words[i] - // Check if this is an address (starts with 0x or contains ...) - if ( + if (word && isHexSelector(word)) { + groups.push({ text: word, type: 'selector' }) + i++ + } else if ( word?.startsWith('0x') || (word?.includes('...') && word?.match(/[0-9a-fA-F]/)) ) { groups.push({ text: word, type: 'address' }) i++ - } - // Check if this is a number followed by a token name (asset) - else if ( + } else if ( word?.match(/^[\d.]+$/) && words[i + 1] && - !['for', 'to', 'from'].includes(words[i + 1] as string) + !['for', 'to', 'from', 'on'].includes(words[i + 1] as string) ) { - // Asset: "10.00 pathUSD" groups.push({ text: `${word} ${words[i + 1]}`, type: 'asset' }) i += 2 - // Add following connector word (for, to, from) as normal/gray const connector = words[i] - if (connector && ['for', 'to', 'from'].includes(connector)) { + if (connector && ['for', 'to', 'from', 'on'].includes(connector)) { groups.push({ text: connector, type: 'normal' }) i++ } - } - // Connector word at start (like "to 0x...") - else if (['for', 'to', 'from'].includes(word || '')) { + } else if (['for', 'to', 'from', 'on'].includes(word || '')) { groups.push({ text: word || '', type: 'normal' }) i++ - } - // Regular word - else { + } else { groups.push({ text: word || '', type: 'normal' }) i++ } @@ -106,244 +239,208 @@ export function parseEventDetails( return groups } -// ============ Receipt Component ============ +// ============ Shared card styles ============ + +const CARD_BASE = { + width: '700px', + maxWidth: '700px', + minHeight: '400px', + maxHeight: '583px', + overflow: 'hidden' as const, + fontFamily: 'Pilat', + fontWeight: 400, + fontFeatureSettings: '"tnum"', + boxShadow: + '0 4px 6px -1px rgba(0,0,0,0.05), 0 10px 15px -3px rgba(0,0,0,0.05), 0 25px 50px -12px rgba(0,0,0,0.08)', + borderTopRightRadius: '24px', + borderTopLeftRadius: '0', + borderBottomLeftRadius: '0', + borderBottomRightRadius: '0', +} -export function ReceiptCard({ - data, - receiptLogo, -}: { - data: ReceiptData - receiptLogo: string -}) { - // Format date to "Dec 1 2025" format - let formattedDate = data.date - // Handle "Dec 1, 2025" format - remove comma - if (data.date.includes(',')) { - formattedDate = data.date.replace(',', '') - } - // Handle "12/01/2025" or "MM/DD/YYYY" format - convert to "Dec 1 2025" - const dateMatch = data.date.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/) - if (dateMatch) { - const [, monthStr, dayStr, year] = dateMatch - const month = Number.parseInt(monthStr ?? '1', 10) - const day = Number.parseInt(dayStr ?? '1', 10) - const monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ] - formattedDate = `${monthNames[month - 1]} ${day} ${year}` - } +const GRADIENT = { + height: '100px', + background: + 'linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,1))', +} - // Format time to "HH:MM" (24-hour) - let formattedTime = data.time - // Handle "1:03 PM" or "10:32 AM GMT-8" format (12-hour with optional timezone) - const timeMatch12 = data.time.match( - /^(\d{1,2}):(\d{2})\s*(AM|PM)(?:\s*GMT[+-]\d+)?$/i, - ) - if (timeMatch12) { - const hoursStr = timeMatch12[1] - const minutes = timeMatch12[2] - const periodRaw = timeMatch12[3] - if (hoursStr && minutes && periodRaw) { - let hours = Number.parseInt(hoursStr, 10) - const period = periodRaw.toUpperCase() - if (period === 'PM' && hours !== 12) hours += 12 - if (period === 'AM' && hours === 12) hours = 0 - formattedTime = `${hours.toString().padStart(2, '0')}:${minutes}` - } - } - // Handle "10:32:21 GMT-8" format (24-hour) - extract HH:MM - else { - const timeMatch24 = data.time.match( - /^(\d{1,2}):(\d{2})(?::\d{2})?\s*(?:GMT[+-]\d+)?$/i, - ) - if (timeMatch24) { - const hours = timeMatch24[1] - const minutes = timeMatch24[2] - formattedTime = `${hours?.padStart(2, '0')}:${minutes}` - } - } +const DIVIDER = { height: '1px', backgroundColor: '#d1d5db' } + +// ============ Receipt Component ============ - // Combine date and time - const when = data.date !== '—' ? `${formattedDate} ${formattedTime}` : '—' +export function ReceiptCard({ data }: { data: ReceiptData }) { + const when = formatDateSmart(data.date, data.time) + const feeTokenLabel = data.feeToken || 'pathUSD' return ( -
+
{/* Header */}
- {/* Tempo Receipt logo */} -
- Tempo Receipt +
+ Block +
+ {data.blockNumber} + {data.status && ( + + {data.status === 'success' ? 'Success' : 'Failed'} + + )} +
+
+
+ Sender + {truncateHash(data.sender, 6)} +
+
+ Time (UTC) + {when}
+
- {/* Details - condensed */} -
-
- Block - #{data.blockNumber} + {/* Divider */} +
+ + {/* Events */} +
+ {data.eventsFailed ? ( +
+ Failed to render summary.
-
- Sender - {truncateHash(data.sender, 6)} + ) : data.events.length === 0 ? ( +
+ No events to display.
-
- Date - {when} + ) : ( +
+ {data.events.slice(0, 3).map((event, index) => { + const parts = parseEventDetails(event.details || '') + const showAmount = event.amount && event.amount !== '$0' + return ( +
+
+ {index + 1}. +
+ + {event.action} + + {parts.map((part) => ( + + {part.text} + + ))} +
+
+ {showAmount && {event.amount}} +
+ ) + })} + {data.events.length > 3 && ( +
+ ...and {data.events.length - 3} more +
+ )}
-
+ )}
{/* Divider */} +
+ + {/* Fee and Total */}
- - {/* Events - show max 3, then "...and n more" */} - {data.events.length > 0 && - data.events.slice(0, 3).map((event, index) => { - const parts = parseEventDetails(event.details || '') - return ( -
-
- {index + 1}. -
- - {event.action} - - {parts.map((part) => ( - - {part.text} - - ))} -
-
- {event.amount && {event.amount}} -
- ) - })} - {data.events.length > 3 && ( + >
- ...and {data.events.length - 3} more + Fee ({feeTokenLabel}) + + {data.fee || '$0.00'} +
- )} - - {/* Divider */} -
- - {/* Fee and Total rows */} - {(data.fee || data.total) && ( - <> + {data.total && (
-
- {data.fee && ( -
- - Fee{data.feeToken ? ` (${data.feeToken})` : ''} - - {data.fee} -
- )} - {data.total && ( -
- Total - {data.total} -
- )} + Total + {data.total}
- - )} + )} +
+
) } @@ -351,106 +448,105 @@ export function ReceiptCard({ // ============ Token Card Component ============ export function TokenCard({ data, icon }: { data: TokenData; icon: string }) { + const isLongName = data.name.length > 20 + const holdersGrey = isEmptyValue(data.holders) + const supplyGrey = isEmptyValue(data.supply) + const supplyDisplay = supplyGrey ? '0.00' : data.supply + const currSym = CURRENCY_SYMBOLS[data.currency] || '' + return ( -
- {/* Header with icon and name */} +
+ {/* Header */}
- {/* Token icon from tokenlist or fallback to null icon */} - -
- - {truncateText(data.name, 18)} +
+ + + {truncateText(data.name, 28)}
- {/* Symbol badge */} -
-
+ {truncateText(data.symbol, 12)} -
+ {data.isFeeToken && ( -
Fee Token -
+ )}
- {/* Divider */} -
+
{/* Details */}
- {/* Address - truncated */}
Address {truncateHash(data.address, 8)}
- - {/* Currency */}
Currency - {truncateText(data.currency, 16)} +
+ {currSym && ( + + {currSym} + + )} + {data.currency} +
- - {/* Holders */}
Holders - {truncateText(data.holders, 16)} + + {holdersGrey ? '0' : truncateText(data.holders, 16)} +
- - {/* Supply */}
Supply - {truncateText(data.supply, 20)} + + {supplyDisplay} +
- - {/* Created */}
Created {data.created}
- - {/* Quote Token (if available) */} {data.quoteToken && (
Quote Token @@ -458,6 +554,7 @@ export function TokenCard({ data, icon }: { data: TokenData; icon: string }) {
)}
+
) } @@ -471,25 +568,23 @@ export function TokenBadges({ tokens: string[] maxTokens?: number }) { - // Truncate token name if too long const truncateToken = (token: string, maxLen = 8) => { if (token.length <= maxLen) return token return `${token.slice(0, maxLen - 1)}…` } - - // Show up to maxTokens tokens const displayTokens = tokens.slice(0, maxTokens) const remaining = tokens.length - maxTokens return ( <> - {displayTokens.map((token) => ( + {displayTokens.map((token, idx) => ( 0 ? '8px' : '0', }} > {truncateToken(token)} @@ -497,8 +592,8 @@ export function TokenBadges({ ))} {remaining > 0 && ( +{remaining} @@ -510,8 +605,7 @@ export function TokenBadges({ // ============ Method Badges Helper ============ export function MethodBadges({ methods }: { methods: string[] }) { - // Split methods into rows based on character count (~40 chars per row) - const maxCharsPerRow = 40 + const maxCharsPerRow = 48 const row1: string[] = [] const row2: string[] = [] let row1Chars = 0 @@ -532,7 +626,6 @@ export function MethodBadges({ methods }: { methods: string[] }) { const displayed = row1.length + row2.length const remaining = methods.length - displayed - // Truncate method name if too long const truncateMethod = (m: string, maxLen = 14) => { if (m.length <= maxLen) return m return `${m.slice(0, maxLen - 1)}…` @@ -541,8 +634,8 @@ export function MethodBadges({ methods }: { methods: string[] }) { const renderBadge = (m: string, idx: number) => ( {truncateMethod(m)} @@ -550,161 +643,303 @@ export function MethodBadges({ methods }: { methods: string[] }) { return (
- {/* Row 1 */} -
- {row1[0] && renderBadge(row1[0], 0)} - {row1[1] && renderBadge(row1[1], 1)} - {row1[2] && renderBadge(row1[2], 2)} - {row1[3] && renderBadge(row1[3], 3)} -
- {/* Row 2 */} - {row2.length > 0 && ( -
- {row2[0] && renderBadge(row2[0], 10)} - {row2[1] && renderBadge(row2[1], 11)} - {row2[2] && renderBadge(row2[2], 12)} - {row2[3] && renderBadge(row2[3], 13)} - {remaining > 0 && ( - - +{remaining} - - )} -
- )} - {/* +remaining if only one row */} - {row2.length === 0 && remaining > 0 && ( -
+
+ {row1.map((m, i) => renderBadge(m, i))} + {row2.map((m, i) => renderBadge(m, i + 10))} + {remaining > 0 && ( +{remaining} -
- )} + )} +
) } -// ============ Address Card Component ============ +// ============ Block Card Component ============ + +function buildHistogramSvg(counts: number[], currentCount: number): string { + const allCounts = [...counts, currentCount] + const maxCount = Math.max(...allCounts, 1) + const barW = 8 + const gap = 2 + const maxH = 24 + const totalBars = allCounts.length + const svgW = totalBars * barW + (totalBars - 1) * gap + let rects = '' + for (let i = 0; i < allCounts.length; i++) { + const count = allCounts[i] ?? 0 + const h = count === 0 ? 3 : Math.max(4, (count / maxCount) * maxH) + const x = i * (barW + gap) + const y = maxH - h + const fill = i === allCounts.length - 1 ? '#3b82f6' : '#e5e7eb' + rects += `` + } + return `data:image/svg+xml,${encodeURIComponent(`${rects}`)}` +} -export function AddressCard({ data }: { data: AddressData }) { - // Split address into two lines for display - const addrLine1 = data.address.slice(0, 22) - const addrLine2 = data.address.slice(22) +function buildGasBarSvg(percentage: number): string { + const segments = 20 + const filled = Math.round((percentage / 100) * segments) + const segW = 4 + const segH = 24 + const gap = 2 + const svgW = segments * segW + (segments - 1) * gap + let rects = '' + for (let i = 0; i < segments; i++) { + const x = i * (segW + gap) + const fill = i < filled ? '#3b82f6' : '#f3f4f6' + rects += `` + } + return `data:image/svg+xml,${encodeURIComponent(`${rects}`)}` +} + +export function BlockCard({ data }: { data: BlockData }) { + const gasPercentMatch = data.gasUsage.match(/([\d.]+)%/) + const gasPercent = gasPercentMatch + ? Number.parseFloat(gasPercentMatch[1] ?? '0') + : undefined + const currentTxCount = + Number.parseInt(data.txCount.replace(/,/g, ''), 10) || 0 return ( -
- {/* Address header */} +
+ {/* Header */}
+ + Block + - {data.accountType === 'contract' - ? 'Contract' - : data.accountType === 'account' - ? 'Account' - : 'Empty'} + + {'0'.repeat(Math.max(0, 12 - data.number.length))} + + {data.number} -
- {addrLine1} - {addrLine2} -
- {/* Divider - dashed */} +
+ + {/* Details */}
+ > +
+ UTC + + {data.timestamp} + +
+
+ UNIX + + {data.unixTimestamp} + +
+ +
+ Transactions +
+ {data.prevBlockTxCounts && + data.prevBlockTxCounts.length > 0 && + data.prevBlockTxCounts.some((c) => c >= 0) && ( + + )} + {data.txCount} +
+
+ +
+ Gas Usage +
+ {gasPercent !== undefined && ( + + )} + {data.gasUsage} +
+
+ +
+ Miner + {truncateHash(data.miner, 6)} +
+
+ Parent + {truncateHash(data.parentHash, 6)} +
+
+
+
+ ) +} + +// ============ Address Card Component ============ + +export function AddressCard({ data }: { data: AddressData }) { + const addrLine1 = data.address.slice(0, 21) + const addrLine2 = data.address.slice(21) + const holdingsGrey = isEmptyValue(data.holdings) + const holdingsDisplay = holdingsGrey ? '$0.00' : data.holdings + + return ( +
+ {/* Header */} + {data.accountType === 'contract' && data.contractName ? ( +
+
+ + Contract + + + {data.contractName} + +
+
+ + {truncateHash(data.address, 8)} + +
+
+ ) : ( +
+ + {data.accountType === 'contract' ? 'Contract' : 'Address'} + +
+ {addrLine1} + {addrLine2} +
+
+ )} + +
{/* Details */}
- {/* Holdings - show for non-contracts only */} + {/* Holdings */} {data.accountType !== 'contract' && ( -
+
Holdings - {truncateText(data.holdings, 20)} + + {holdingsDisplay} +
)} - {/* Tokens Held section - show for non-contracts only */} + {/* Assets */} {data.tokensHeld.length > 0 && data.accountType !== 'contract' && ( -
- {/* Single row of tokens */} -
+
+ Assets +
- {/* Divider - dashed */} -
)} - {/* Divider - dashed (when no tokens and not contract) */} - {data.tokensHeld.length === 0 && data.accountType !== 'contract' && ( + {/* Divider (when not contract) */} + {data.accountType !== 'contract' && (
)} - {/* Transactions */} + {/* Transactions/Events */}
- Transactions + + {data.accountType === 'contract' ? 'Events' : 'Transactions'} + {data.txCount}
- {/* Last Active */} -
- Last Active - {data.lastActive} -
+ {/* Last Active - only for non-contracts */} + {data.accountType !== 'contract' && ( +
+ Last Active + {data.lastActive} +
+ )} {/* Created */}
@@ -712,18 +947,51 @@ export function AddressCard({ data }: { data: AddressData }) { {data.created}
- {/* Contract Methods - show for contracts only, at bottom */} + {/* Deployer */} + {data.accountType === 'contract' && data.deployer && ( +
+ Deployer + {truncateHash(data.deployer, 6)} +
+ )} + + {/* Methods */} {data.accountType === 'contract' && data.methods && data.methods.length > 0 && ( -
- +
+ Methods - +
+ {data.methods.slice(0, 6).map((m) => ( + + {m.length > 14 ? `${m.slice(0, 13)}…` : m} + + ))} + {data.methods.length > 6 && ( + + +{data.methods.length - 6} + + )} +
)}
+
) } diff --git a/apps/og/src/utilities.ts b/apps/og/src/utilities.ts index 25c5910f5..ecac5f8ae 100644 --- a/apps/og/src/utilities.ts +++ b/apps/og/src/utilities.ts @@ -11,13 +11,23 @@ interface ImageCache { bgToken: ArrayBuffer bgAddress: ArrayBuffer bgContract: ArrayBuffer - receiptLogo: ArrayBuffer + bgReceipt: ArrayBuffer + bgBlock: ArrayBuffer + tempoLockup: ArrayBuffer + tempoMark: ArrayBuffer nullIcon: ArrayBuffer } -let fontCache: { mono: ArrayBuffer; inter: ArrayBuffer } | null = null -let fontsInFlight: Promise<{ mono: ArrayBuffer; inter: ArrayBuffer }> | null = - null +let fontCache: { + mono: ArrayBuffer + inter: ArrayBuffer + pilat: ArrayBuffer +} | null = null +let fontsInFlight: Promise<{ + mono: ArrayBuffer + inter: ArrayBuffer + pilat: ArrayBuffer +}> | null = null let imageCache: ImageCache | null = null let imagesInFlight: Promise | null = null @@ -29,7 +39,7 @@ export const toBase64DataUrl = ( mime = 'image/webp', ): string => `data:${mime};base64,${Buffer.from(data).toString('base64')}` -export async function loadFonts() { +export async function loadFonts(env: Cloudflare.Env) { if (fontCache) return fontCache if (!fontsInFlight) { fontsInFlight = Promise.all([ @@ -37,8 +47,11 @@ export async function loadFonts() { fetch(FONT_INTER_URL).then((response: Response) => response.arrayBuffer(), ), - ]).then(([mono, inter]) => { - fontCache = { mono, inter } + env.ASSETS.fetch(new Request('https://assets/fonts/Pilat-Book.otf')).then( + (response: Response) => response.arrayBuffer(), + ), + ]).then(([mono, inter, pilat]) => { + fontCache = { mono, inter, pilat } fontsInFlight = null return fontCache }) @@ -50,33 +63,54 @@ export async function loadImages(env: Cloudflare.Env): Promise { if (imageCache) return imageCache if (!imagesInFlight) { imagesInFlight = (async () => { - const [bgTx, bgToken, bgAddress, bgContract, receiptLogo, nullIcon] = - await Promise.all([ - env.ASSETS.fetch( - new Request('https://assets/bg-template-transaction.webp'), - ).then((response: Response) => response.arrayBuffer()), - env.ASSETS.fetch( - new Request('https://assets/bg-template-token.webp'), - ).then((response: Response) => response.arrayBuffer()), - env.ASSETS.fetch( - new Request('https://assets/bg-template-address.webp'), - ).then((response: Response) => response.arrayBuffer()), - env.ASSETS.fetch( - new Request('https://assets/bg-template-contract.webp'), - ).then((response: Response) => response.arrayBuffer()), - env.ASSETS.fetch( - new Request('https://assets/tempo-receipt.webp'), - ).then((response: Response) => response.arrayBuffer()), - env.ASSETS.fetch(new Request('https://assets/null.webp')).then( - (response: Response) => response.arrayBuffer(), - ), - ]) + const [ + bgTx, + bgToken, + bgAddress, + bgContract, + bgReceipt, + bgBlock, + tempoLockup, + tempoMark, + nullIcon, + ] = await Promise.all([ + env.ASSETS.fetch( + new Request('https://assets/bg-template-transaction.webp'), + ).then((response: Response) => response.arrayBuffer()), + env.ASSETS.fetch( + new Request('https://assets/bg-template-token.webp'), + ).then((response: Response) => response.arrayBuffer()), + env.ASSETS.fetch( + new Request('https://assets/bg-template-address.webp'), + ).then((response: Response) => response.arrayBuffer()), + env.ASSETS.fetch( + new Request('https://assets/bg-template-contract.webp'), + ).then((response: Response) => response.arrayBuffer()), + env.ASSETS.fetch( + new Request('https://assets/bg-template-receipt.webp'), + ).then((response: Response) => response.arrayBuffer()), + env.ASSETS.fetch( + new Request('https://assets/bg-template-blocks.webp'), + ).then((response: Response) => response.arrayBuffer()), + env.ASSETS.fetch(new Request('https://assets/tempo-lockup.svg')).then( + (response: Response) => response.arrayBuffer(), + ), + env.ASSETS.fetch(new Request('https://assets/tempo-mark.webp')).then( + (response: Response) => response.arrayBuffer(), + ), + env.ASSETS.fetch(new Request('https://assets/null.webp')).then( + (response: Response) => response.arrayBuffer(), + ), + ]) imageCache = { bgTx, bgToken, bgAddress, bgContract, - receiptLogo, + bgReceipt, + bgBlock, + tempoLockup, + tempoMark, nullIcon, } imagesInFlight = null