From bc54e9dd883f48a0bd23ef4321f5ac48feae5246 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 1 May 2026 21:01:28 +0000 Subject: [PATCH] fix(explorer): keep contract tab on aggregate failure --- .../routes/api/address/metadata/$address.ts | 83 +++++++++-------- .../test/address-metadata.node.test.ts | 91 +++++++++++++++++++ 2 files changed, 137 insertions(+), 37 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 f3ac5c604..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,29 +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 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: parseTimestamp(result.oldestTxsBlockTimestamp), - createdTxHash: result.oldestTxHash, - createdBy: 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, } } @@ -120,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', + }) + }) +})