diff --git a/app/api/search/__tests__/route.test.ts b/app/api/search/__tests__/route.test.ts index d0e02ddf0..1aee1b4b4 100644 --- a/app/api/search/__tests__/route.test.ts +++ b/app/api/search/__tests__/route.test.ts @@ -238,16 +238,8 @@ describe('GET /api/search', () => { mockFetch(200, [makeJupiterToken({ logoURI: null })]); getAssetBatchMock.mockResolvedValueOnce([ { - burnt: false, - content: { - $schema: '', - json_uri: '', - links: { image: 'https://das.example.com/sol.png' }, - metadata: {}, - }, + content: { links: { image: 'https://das.example.com/sol.png' } }, id: VALID_ADDRESS, - interface: 'FungibleToken', - mutable: true, }, ]); @@ -260,16 +252,8 @@ describe('GET /api/search', () => { mockFetch(200, [makeJupiterToken({ logoURI: 'https://original.com/sol.png' })]); getAssetBatchMock.mockResolvedValueOnce([ { - burnt: false, - content: { - $schema: '', - json_uri: '', - links: { image: 'https://das.example.com/sol.png' }, - metadata: {}, - }, + content: { links: { image: 'https://das.example.com/sol.png' } }, id: VALID_ADDRESS, - interface: 'FungibleToken', - mutable: true, }, ]); diff --git a/app/api/token-image/[mintAddress]/__tests__/route.spec.ts b/app/api/token-image/[mintAddress]/__tests__/route.spec.ts new file mode 100644 index 000000000..d5730b06d --- /dev/null +++ b/app/api/token-image/[mintAddress]/__tests__/route.spec.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GET } from '../route'; + +vi.mock('@/app/entities/digital-asset/server', () => ({ + getAssetBatch: vi.fn(), +})); + +const VALID_MINT = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const BASE_URL = `http://localhost:3000/api/token-image/${VALID_MINT}`; + +function makeRequest(url = BASE_URL) { + return new Request(url); +} + +function makeParams(mintAddress = VALID_MINT) { + return { params: Promise.resolve({ mintAddress }) }; +} + +describe('GET /api/token-image/[mintAddress]', () => { + let getAssetBatch: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + const mod = await import('@/app/entities/digital-asset/server'); + getAssetBatch = vi.mocked(mod.getAssetBatch); + }); + + describe('validation', () => { + it('should return 400 for an invalid mint address', async () => { + const response = await GET(makeRequest(), makeParams('not-a-pubkey')); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe('Invalid mint address'); + }); + + it('should return 400 for an invalid cluster slug', async () => { + const response = await GET(makeRequest(`${BASE_URL}?cluster=unknown-cluster`), makeParams()); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe('Invalid cluster'); + }); + + it('should return 400 for custom cluster to prevent SSRF', async () => { + const response = await GET( + makeRequest(`${BASE_URL}?cluster=custom&customUrl=http://internal-service`), + makeParams(), + ); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe('Custom cluster is not supported'); + }); + + it('should not cache invalid mint address 400 errors', async () => { + const response = await GET(makeRequest(), makeParams('not-a-pubkey')); + + expect(response.headers.get('Cache-Control')).toBe('no-store, max-age=0'); + }); + + it('should not cache invalid cluster 400 errors', async () => { + const response = await GET(makeRequest(`${BASE_URL}?cluster=unknown-cluster`), makeParams()); + + expect(response.headers.get('Cache-Control')).toBe('no-store, max-age=0'); + }); + }); + + describe('successful requests', () => { + it('should return the image URL when the asset has one', async () => { + getAssetBatch.mockResolvedValueOnce([ + { content: { links: { image: 'https://example.com/image.png' } }, id: VALID_MINT }, + ]); + + const response = await GET(makeRequest(), makeParams()); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.image).toBe('https://example.com/image.png'); + }); + + it('should return undefined image when asset has no image link', async () => { + getAssetBatch.mockResolvedValueOnce([{ content: { links: {} }, id: VALID_MINT }]); + + const response = await GET(makeRequest(), makeParams()); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.image).toBeUndefined(); + }); + + it('should return no-store headers when no assets are returned', async () => { + getAssetBatch.mockResolvedValueOnce(null); + + const response = await GET(makeRequest(), makeParams()); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store, max-age=0'); + }); + + it('should return cache headers on a successful image response', async () => { + getAssetBatch.mockResolvedValueOnce([ + { content: { links: { image: 'https://example.com/image.png' } }, id: VALID_MINT }, + ]); + + const response = await GET(makeRequest(), makeParams()); + + expect(response.headers.get('Cache-Control')).toBe( + 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600', + ); + }); + + it('should call getAssetBatch with the mint address', async () => { + getAssetBatch.mockResolvedValueOnce([{ content: { links: {} }, id: VALID_MINT }]); + + await GET(makeRequest(), makeParams()); + + expect(getAssetBatch).toHaveBeenCalledWith([VALID_MINT], expect.any(String)); + }); + }); +}); diff --git a/app/api/token-image/[mintAddress]/route.ts b/app/api/token-image/[mintAddress]/route.ts new file mode 100644 index 000000000..8290558b6 --- /dev/null +++ b/app/api/token-image/[mintAddress]/route.ts @@ -0,0 +1,50 @@ +import { isAddress } from '@solana/kit'; +import { NextResponse } from 'next/server'; + +import { getAssetBatch } from '@/app/entities/digital-asset/server'; +import { NO_STORE_HEADERS } from '@/app/shared/lib/http-utils'; +import { Cluster, clusterFromSlug, clusterSlug, serverClusterUrl } from '@/app/utils/cluster'; + +const IMAGE_CACHE_HEADERS = { + 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=3600', +}; + +type Params = { + params: Promise<{ + mintAddress: string; + }>; +}; + +export async function GET(request: Request, props: Params) { + const { mintAddress } = await props.params; + + if (!isAddress(mintAddress)) { + return NextResponse.json({ error: 'Invalid mint address' }, { headers: NO_STORE_HEADERS, status: 400 }); + } + + const { searchParams } = new URL(request.url); + const clusterParam = searchParams.get('cluster') ?? clusterSlug(Cluster.MainnetBeta); + + const cluster = clusterFromSlug(clusterParam); + if (cluster === null) { + return NextResponse.json({ error: 'Invalid cluster' }, { headers: NO_STORE_HEADERS, status: 400 }); + } + + if (cluster === Cluster.Custom) { + return NextResponse.json( + { error: 'Custom cluster is not supported' }, + { headers: NO_STORE_HEADERS, status: 400 }, + ); + } + + const rpcUrl = serverClusterUrl(cluster, ''); + const assets = await getAssetBatch([mintAddress], rpcUrl); + + if (!assets) { + return NextResponse.json({ image: undefined }, { headers: NO_STORE_HEADERS }); + } + + const asset = assets.find(a => a.id === mintAddress); + const image = asset?.content?.links?.image; + return NextResponse.json({ image }, { headers: IMAGE_CACHE_HEADERS }); +} diff --git a/app/components/account/AccountHeader.tsx b/app/components/account/AccountHeader.tsx index 52fcd7d0d..e79680138 100644 --- a/app/components/account/AccountHeader.tsx +++ b/app/components/account/AccountHeader.tsx @@ -2,6 +2,7 @@ import { CompressedNftAccountHeader } from '@components/account/CompressedNftCar import { MetaplexNFTHeader } from '@components/account/MetaplexNFTHeader'; import { isNFTokenAccount } from '@components/account/nftoken/isNFTokenAccount'; import { NFTokenAccountHeader } from '@components/account/nftoken/NFTokenAccountHeader'; +import { useDasImage } from '@entities/digital-asset'; import { isMetaplexNFT } from '@entities/nft'; import { Account, @@ -112,12 +113,18 @@ function TokenMintHeader({ const metadataPointerExtension = mintInfo?.extensions?.find( ({ extension }: { extension: string }) => extension === 'metadataPointer', ); - - const defaultCard = useMemo( - () => , - [tokenInfo], + // Skip DAS fetch when a definitive image is already available. Token-2022 is excluded: + // its image comes from an async metadata URI fetch, so DAS still serves as a useful fallback. + const dasImage = useDasImage( + tokenInfo?.logoURI || isRedactedTokenAddress(address) || parsedData?.nftData?.json?.image ? undefined : address, ); + const defaultCard = useMemo(() => { + const logoURI = tokenInfo?.logoURI ?? dasImage; + const token = tokenInfo ? { ...tokenInfo, logoURI } : { logoURI, name: undefined }; + return ; + }, [dasImage, tokenInfo]); + if (metadataPointerExtension && metadataExtension) { return ( <> @@ -125,6 +132,7 @@ function TokenMintHeader({ @@ -138,23 +146,23 @@ function TokenMintHeader({ return defaultCard; } else if (parsedData?.nftData) { const token = { - logoURI: parsedData?.nftData?.json?.image, + logoURI: parsedData?.nftData?.json?.image ?? dasImage, name: parsedData?.nftData?.json?.name ?? parsedData?.nftData.metadata.name, symbol: parsedData?.nftData?.metadata.symbol, }; return ; - } else if (tokenInfo) { - return defaultCard; } return defaultCard; } function Token22MintHeader({ address, + fallbackLogoURI, metadataExtension, metadataPointerExtension, }: { address: string; + fallbackLogoURI?: string; metadataExtension: { extension: 'tokenMetadata'; state?: any }; metadataPointerExtension: { extension: 'metadataPointer'; state?: any }; }) { @@ -163,13 +171,10 @@ function Token22MintHeader({ const metadata = useMetadataJsonLink(getProxiedUri(tokenMetadata.uri)); const headerTokenMetadata = { - logoURI: '', + logoURI: metadata?.image ?? fallbackLogoURI ?? '', name: tokenMetadata.name, symbol: tokenMetadata.symbol, }; - if (metadata) { - headerTokenMetadata.logoURI = metadata.image; - } // Handles the basic case where MetadataPointer is referencing the Token Metadata extension directly // Does not handle the case where MetadataPointer is pointing at a separate account. diff --git a/app/entities/digital-asset/api.ts b/app/entities/digital-asset/api.ts index 4863089d0..f5e500264 100644 --- a/app/entities/digital-asset/api.ts +++ b/app/entities/digital-asset/api.ts @@ -5,7 +5,7 @@ * module fetches them from the cluster's DAS API as a best-effort fallback. */ -import { is } from 'superstruct'; +import { validate } from 'superstruct'; import { Logger } from '@/app/shared/lib/logger'; @@ -67,12 +67,18 @@ export async function getAssetBatch( const data = await response.json(); - if (!is(data, GetAssetBatchResponseSchema)) { - Logger.warn('[das] getAssets invalid response', { sentry: true }); + if (data?.error) { + Logger.warn(`[das] getAssets RPC error: ${data.error?.message ?? 'unknown'}`); return undefined; } - return data.result; + const [validationError, validData] = validate(data, GetAssetBatchResponseSchema); + if (validationError) { + Logger.warn(`[das] getAssets invalid response: ${validationError.message}`, { sentry: true }); + return undefined; + } + + return validData.result.filter((item): item is DigitalAsset => item !== null); } catch (error) { Logger.error(error instanceof Error ? error : new Error('[das] getAssets failed'), { sentry: true, diff --git a/app/entities/digital-asset/index.ts b/app/entities/digital-asset/index.ts new file mode 100644 index 000000000..aad94110a --- /dev/null +++ b/app/entities/digital-asset/index.ts @@ -0,0 +1 @@ +export { useDasImage } from './model/use-das-image'; diff --git a/app/entities/digital-asset/model/__tests__/use-das-image.spec.ts b/app/entities/digital-asset/model/__tests__/use-das-image.spec.ts new file mode 100644 index 000000000..154a8ac43 --- /dev/null +++ b/app/entities/digital-asset/model/__tests__/use-das-image.spec.ts @@ -0,0 +1,66 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Cluster, clusterSlug } from '@/app/utils/cluster'; + +vi.mock('@/app/providers/cluster', () => ({ useCluster: vi.fn() })); +vi.mock('swr', () => ({ default: vi.fn() })); + +import useSWR from 'swr'; + +import { useCluster } from '@/app/providers/cluster'; + +import { useDasImage } from '../use-das-image'; + +describe('useDasImage', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useCluster).mockReturnValue({ cluster: Cluster.MainnetBeta, customUrl: '' } as ReturnType< + typeof useCluster + >); + vi.mocked(useSWR).mockReturnValue({ data: undefined } as ReturnType); + }); + + it('should return undefined when no mintAddress', () => { + const { result } = renderHook(() => useDasImage(undefined)); + expect(result.current).toBeUndefined(); + expect(useSWR).toHaveBeenCalledWith(undefined, expect.any(Function), expect.any(Object)); + }); + + it('should return the image URL from SWR data', () => { + vi.mocked(useSWR).mockReturnValue({ data: 'https://example.com/image.png' } as ReturnType); + + const { result } = renderHook(() => useDasImage('SomeMintAddress')); + expect(result.current).toBe('https://example.com/image.png'); + }); + + it('should return undefined when SWR has no data', () => { + const { result } = renderHook(() => useDasImage('SomeMintAddress')); + expect(result.current).toBeUndefined(); + }); + + it('should pass correct SWR key including cluster slug and customUrl', () => { + vi.mocked(useCluster).mockReturnValue({ + cluster: Cluster.Devnet, + customUrl: 'https://custom.rpc', + } as ReturnType); + + renderHook(() => useDasImage('SomeMintAddress')); + + expect(useSWR).toHaveBeenCalledWith( + ['das-image', 'SomeMintAddress', clusterSlug(Cluster.Devnet), 'https://custom.rpc'], + expect.any(Function), + expect.any(Object), + ); + }); + + it('should pass correct SWR config', () => { + renderHook(() => useDasImage('SomeMintAddress')); + + expect(useSWR).toHaveBeenCalledWith(expect.any(Array), expect.any(Function), { + dedupingInterval: 5 * 60 * 1000, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }); + }); +}); diff --git a/app/entities/digital-asset/model/use-das-image.ts b/app/entities/digital-asset/model/use-das-image.ts new file mode 100644 index 000000000..989621774 --- /dev/null +++ b/app/entities/digital-asset/model/use-das-image.ts @@ -0,0 +1,36 @@ +import useSWR from 'swr'; + +import { useCluster } from '@/app/providers/cluster'; +import { Cluster, clusterSlug } from '@/app/utils/cluster'; + +type DasImageKey = ['das-image', string, string, string]; + +function getDasImageKey(cluster: Cluster, mintAddress: string, customUrl: string): DasImageKey { + return ['das-image', mintAddress, clusterSlug(cluster), customUrl]; +} + +async function fetchDasImage([, mintAddress, cluster, customUrl]: DasImageKey): Promise { + try { + const params = new URLSearchParams({ cluster }); + if (customUrl) params.set('customUrl', customUrl); + const response = await fetch(`/api/token-image/${mintAddress}?${params}`); + if (!response.ok) return undefined; + const data = await response.json(); + return typeof data.image === 'string' ? data.image : undefined; + } catch { + return undefined; + } +} + +const DAS_IMAGE_SWR_CONFIG = { + dedupingInterval: 5 * 60 * 1000, + revalidateOnFocus: false, + revalidateOnReconnect: false, +}; + +export function useDasImage(mintAddress?: string): string | undefined { + const { cluster, customUrl } = useCluster(); + const swrKey = mintAddress ? getDasImageKey(cluster, mintAddress, customUrl) : undefined; + const { data } = useSWR(swrKey, fetchDasImage, DAS_IMAGE_SWR_CONFIG); + return data; +} diff --git a/app/entities/digital-asset/types.ts b/app/entities/digital-asset/types.ts index cdd7a5a48..ceaa3e51b 100644 --- a/app/entities/digital-asset/types.ts +++ b/app/entities/digital-asset/types.ts @@ -1,54 +1,22 @@ -import { array, boolean, Infer, number, optional, string, type } from 'superstruct'; +import { array, Infer, nullable, optional, string, type } from 'superstruct'; export const DigitalAssetSchema = type({ - burnt: boolean(), - content: type({ - $schema: string(), - files: optional( - array( - type({ - cdn_uri: optional(string()), - mime: optional(string()), - uri: optional(string()), - }), - ), - ), - json_uri: string(), - links: optional( - type({ - external_url: optional(string()), - image: optional(string()), - }), - ), - metadata: type({ - description: optional(string()), - name: optional(string()), - symbol: optional(string()), - token_standard: optional(string()), - }), - }), - id: string(), - interface: string(), - mutable: boolean(), - token_info: optional( + content: nullable( type({ - decimals: optional(number()), - mint_authority: optional(string()), - price_info: optional( - type({ - currency: optional(string()), - price_per_token: optional(number()), - }), + links: nullable( + optional( + type({ + image: nullable(optional(string())), + }), + ), ), - supply: optional(number()), - symbol: optional(string()), - token_program: optional(string()), }), ), + id: string(), }); export const GetAssetBatchResponseSchema = type({ - result: array(DigitalAssetSchema), + result: array(nullable(DigitalAssetSchema)), }); export type DigitalAsset = Infer; diff --git a/app/features/search/api/resolve-search-tokens.ts b/app/features/search/api/resolve-search-tokens.ts index d7489694d..4a5aa5a87 100644 --- a/app/features/search/api/resolve-search-tokens.ts +++ b/app/features/search/api/resolve-search-tokens.ts @@ -83,7 +83,7 @@ export async function resolveSearchTokens( clearTimeout(imageTimeout); } - const iconMap = new Map(assets?.map(a => [a.id, a.content.links?.image]) ?? []); + const iconMap = new Map(assets?.map(a => [a.id, a.content?.links?.image]) ?? []); return discovered.map(t => ({ decimals: t.decimals,