From 84979f73fd9a1d6ffbfd5ec94d06b314bd6b7c4e Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Fri, 5 Jun 2026 11:22:07 +0200 Subject: [PATCH 01/11] added image from das --- app/api/token-image/[mintAddress]/route.ts | 48 +++++++++++++++++++ app/components/account/AccountHeader.tsx | 23 ++++----- .../digital-asset/model/use-das-image.ts | 36 ++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 app/api/token-image/[mintAddress]/route.ts create mode 100644 app/entities/digital-asset/model/use-das-image.ts diff --git a/app/api/token-image/[mintAddress]/route.ts b/app/api/token-image/[mintAddress]/route.ts new file mode 100644 index 000000000..c89bf09a2 --- /dev/null +++ b/app/api/token-image/[mintAddress]/route.ts @@ -0,0 +1,48 @@ +import { PublicKey } from '@solana/web3.js'; +import { NextResponse } from 'next/server'; + +import { getAssetBatch } from '@/app/entities/digital-asset/server'; +import { NO_STORE_HEADERS } from '@/app/shared/lib/http-utils'; +import { Logger } from '@/app/shared/lib/logger'; +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; + + try { + new PublicKey(mintAddress); + } catch { + return NextResponse.json({ error: 'Invalid mint address' }, { status: 400 }); + } + + const { searchParams } = new URL(request.url); + const clusterParam = searchParams.get('cluster') ?? clusterSlug(Cluster.MainnetBeta); + const customUrl = searchParams.get('customUrl') ?? ''; + + const cluster = clusterFromSlug(clusterParam); + if (cluster === null) { + return NextResponse.json({ error: 'Invalid cluster' }, { headers: NO_STORE_HEADERS, status: 400 }); + } + + const rpcUrl = serverClusterUrl(cluster, customUrl); + + try { + const assets = await getAssetBatch([mintAddress], rpcUrl); + const asset = assets?.find(a => a.id === mintAddress); + const image = asset?.content.links?.image; + return NextResponse.json({ image }, { headers: IMAGE_CACHE_HEADERS }); + } catch { + Logger.warn('[api:token-image] DAS request failed', { mintAddress, sentry: false }); + return NextResponse.json({ image: undefined }, { headers: IMAGE_CACHE_HEADERS }); + } +} diff --git a/app/components/account/AccountHeader.tsx b/app/components/account/AccountHeader.tsx index 52fcd7d0d..8396331a1 100644 --- a/app/components/account/AccountHeader.tsx +++ b/app/components/account/AccountHeader.tsx @@ -19,6 +19,7 @@ import { create } from 'superstruct'; import { ProgramHeader } from '@/app/components/shared/account/ProgramHeader'; import { ProxiedImage } from '@/app/features/metadata'; +import { useDasImage } from '@/app/entities/digital-asset/model/use-das-image'; import { getProxiedUri } from '@/app/features/metadata/utils'; import { type FullTokenInfo, isRedactedTokenAddress } from '@/app/utils/token-info'; @@ -112,11 +113,13 @@ function TokenMintHeader({ const metadataPointerExtension = mintInfo?.extensions?.find( ({ extension }: { extension: string }) => extension === 'metadataPointer', ); + const dasImage = useDasImage(address); - const defaultCard = useMemo( - () => , - [tokenInfo], - ); + 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 +128,7 @@ function TokenMintHeader({ @@ -138,23 +142,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 +167,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/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; +} From 920f332406fe7688b3e710c560f1e11eefae4175 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Fri, 5 Jun 2026 11:41:56 +0200 Subject: [PATCH 02/11] error handling, tests, asking for image in case there is no image --- app/api/token-image/[mintAddress]/route.ts | 6 +- app/components/account/AccountHeader.tsx | 2 +- .../model/__tests__/use-das-image.spec.ts | 61 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 app/entities/digital-asset/model/__tests__/use-das-image.spec.ts diff --git a/app/api/token-image/[mintAddress]/route.ts b/app/api/token-image/[mintAddress]/route.ts index c89bf09a2..993b8c0a3 100644 --- a/app/api/token-image/[mintAddress]/route.ts +++ b/app/api/token-image/[mintAddress]/route.ts @@ -41,8 +41,8 @@ export async function GET(request: Request, props: Params) { const asset = assets?.find(a => a.id === mintAddress); const image = asset?.content.links?.image; return NextResponse.json({ image }, { headers: IMAGE_CACHE_HEADERS }); - } catch { - Logger.warn('[api:token-image] DAS request failed', { mintAddress, sentry: false }); - return NextResponse.json({ image: undefined }, { headers: IMAGE_CACHE_HEADERS }); + } catch (error) { + Logger.panic(error instanceof Error ? error : new Error('[api:token-image] DAS request failed')); + return NextResponse.json({ error: 'Failed to fetch token image' }, { headers: NO_STORE_HEADERS, status: 500 }); } } diff --git a/app/components/account/AccountHeader.tsx b/app/components/account/AccountHeader.tsx index 8396331a1..0002e804d 100644 --- a/app/components/account/AccountHeader.tsx +++ b/app/components/account/AccountHeader.tsx @@ -113,7 +113,7 @@ function TokenMintHeader({ const metadataPointerExtension = mintInfo?.extensions?.find( ({ extension }: { extension: string }) => extension === 'metadataPointer', ); - const dasImage = useDasImage(address); + const dasImage = useDasImage(tokenInfo?.logoURI ? undefined : address); const defaultCard = useMemo(() => { const logoURI = tokenInfo?.logoURI ?? dasImage; 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..e45420b7f --- /dev/null +++ b/app/entities/digital-asset/model/__tests__/use-das-image.spec.ts @@ -0,0 +1,61 @@ +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); + 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, + }); + }); +}); From 2c62036496caa321a16506c9212ab811a02d408c Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Fri, 5 Jun 2026 11:45:06 +0200 Subject: [PATCH 03/11] more cases handle --- app/api/token-image/[mintAddress]/route.ts | 5 ++++- .../digital-asset/model/__tests__/use-das-image.spec.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/api/token-image/[mintAddress]/route.ts b/app/api/token-image/[mintAddress]/route.ts index 993b8c0a3..59a3d76e0 100644 --- a/app/api/token-image/[mintAddress]/route.ts +++ b/app/api/token-image/[mintAddress]/route.ts @@ -38,7 +38,10 @@ export async function GET(request: Request, props: Params) { try { const assets = await getAssetBatch([mintAddress], rpcUrl); - const asset = assets?.find(a => a.id === mintAddress); + 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 }); } catch (error) { 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 index e45420b7f..154a8ac43 100644 --- a/app/entities/digital-asset/model/__tests__/use-das-image.spec.ts +++ b/app/entities/digital-asset/model/__tests__/use-das-image.spec.ts @@ -15,7 +15,9 @@ import { useDasImage } from '../use-das-image'; describe('useDasImage', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useCluster).mockReturnValue({ cluster: Cluster.MainnetBeta, customUrl: '' } as ReturnType); + vi.mocked(useCluster).mockReturnValue({ cluster: Cluster.MainnetBeta, customUrl: '' } as ReturnType< + typeof useCluster + >); vi.mocked(useSWR).mockReturnValue({ data: undefined } as ReturnType); }); @@ -38,7 +40,10 @@ describe('useDasImage', () => { }); it('should pass correct SWR key including cluster slug and customUrl', () => { - vi.mocked(useCluster).mockReturnValue({ cluster: Cluster.Devnet, customUrl: 'https://custom.rpc' } as ReturnType); + vi.mocked(useCluster).mockReturnValue({ + cluster: Cluster.Devnet, + customUrl: 'https://custom.rpc', + } as ReturnType); renderHook(() => useDasImage('SomeMintAddress')); From ddcdad821f3fe8400c89626fc6e450bece5fd91e Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Mon, 15 Jun 2026 12:34:09 +0200 Subject: [PATCH 04/11] resolve comments --- .../[mintAddress]/__tests__/route.spec.ts | 111 ++++++++++++++++++ app/api/token-image/[mintAddress]/route.ts | 27 ++--- app/components/account/AccountHeader.tsx | 8 +- app/entities/digital-asset/index.ts | 1 + 4 files changed, 128 insertions(+), 19 deletions(-) create mode 100644 app/api/token-image/[mintAddress]/__tests__/route.spec.ts create mode 100644 app/entities/digital-asset/index.ts 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..c23e70716 --- /dev/null +++ b/app/api/token-image/[mintAddress]/__tests__/route.spec.ts @@ -0,0 +1,111 @@ +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 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([ + { id: VALID_MINT, content: { links: { image: 'https://example.com/image.png' } } }, + ]); + + 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([{ id: VALID_MINT, content: { links: {} } }]); + + 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([ + { id: VALID_MINT, content: { links: { image: 'https://example.com/image.png' } } }, + ]); + + 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([{ id: VALID_MINT, content: { links: {} } }]); + + 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 index 59a3d76e0..d8c19f077 100644 --- a/app/api/token-image/[mintAddress]/route.ts +++ b/app/api/token-image/[mintAddress]/route.ts @@ -1,9 +1,8 @@ -import { PublicKey } from '@solana/web3.js'; +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 { Logger } from '@/app/shared/lib/logger'; import { Cluster, clusterFromSlug, clusterSlug, serverClusterUrl } from '@/app/utils/cluster'; const IMAGE_CACHE_HEADERS = { @@ -19,10 +18,8 @@ type Params = { export async function GET(request: Request, props: Params) { const { mintAddress } = await props.params; - try { - new PublicKey(mintAddress); - } catch { - return NextResponse.json({ error: 'Invalid mint address' }, { status: 400 }); + if (!isAddress(mintAddress)) { + return NextResponse.json({ error: 'Invalid mint address' }, { headers: NO_STORE_HEADERS, status: 400 }); } const { searchParams } = new URL(request.url); @@ -35,17 +32,13 @@ export async function GET(request: Request, props: Params) { } const rpcUrl = serverClusterUrl(cluster, customUrl); + const assets = await getAssetBatch([mintAddress], rpcUrl); - try { - 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 }); - } catch (error) { - Logger.panic(error instanceof Error ? error : new Error('[api:token-image] DAS request failed')); - return NextResponse.json({ error: 'Failed to fetch token image' }, { headers: NO_STORE_HEADERS, status: 500 }); + 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 0002e804d..f05e35aec 100644 --- a/app/components/account/AccountHeader.tsx +++ b/app/components/account/AccountHeader.tsx @@ -19,7 +19,7 @@ import { create } from 'superstruct'; import { ProgramHeader } from '@/app/components/shared/account/ProgramHeader'; import { ProxiedImage } from '@/app/features/metadata'; -import { useDasImage } from '@/app/entities/digital-asset/model/use-das-image'; +import { useDasImage } from '@entities/digital-asset'; import { getProxiedUri } from '@/app/features/metadata/utils'; import { type FullTokenInfo, isRedactedTokenAddress } from '@/app/utils/token-info'; @@ -113,7 +113,11 @@ function TokenMintHeader({ const metadataPointerExtension = mintInfo?.extensions?.find( ({ extension }: { extension: string }) => extension === 'metadataPointer', ); - const dasImage = useDasImage(tokenInfo?.logoURI ? undefined : address); + // 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; 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'; From 02bd684a9418d7c77331941be55e53f1623a09cb Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Mon, 15 Jun 2026 13:07:29 +0200 Subject: [PATCH 05/11] build fix --- app/api/token-image/[mintAddress]/__tests__/route.spec.ts | 8 ++++---- app/components/account/AccountHeader.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/api/token-image/[mintAddress]/__tests__/route.spec.ts b/app/api/token-image/[mintAddress]/__tests__/route.spec.ts index c23e70716..4c1def34a 100644 --- a/app/api/token-image/[mintAddress]/__tests__/route.spec.ts +++ b/app/api/token-image/[mintAddress]/__tests__/route.spec.ts @@ -59,7 +59,7 @@ describe('GET /api/token-image/[mintAddress]', () => { describe('successful requests', () => { it('should return the image URL when the asset has one', async () => { getAssetBatch.mockResolvedValueOnce([ - { id: VALID_MINT, content: { links: { image: 'https://example.com/image.png' } } }, + { content: { links: { image: 'https://example.com/image.png' } }, id: VALID_MINT }, ]); const response = await GET(makeRequest(), makeParams()); @@ -70,7 +70,7 @@ describe('GET /api/token-image/[mintAddress]', () => { }); it('should return undefined image when asset has no image link', async () => { - getAssetBatch.mockResolvedValueOnce([{ id: VALID_MINT, content: { links: {} } }]); + getAssetBatch.mockResolvedValueOnce([{ content: { links: {} }, id: VALID_MINT }]); const response = await GET(makeRequest(), makeParams()); @@ -90,7 +90,7 @@ describe('GET /api/token-image/[mintAddress]', () => { it('should return cache headers on a successful image response', async () => { getAssetBatch.mockResolvedValueOnce([ - { id: VALID_MINT, content: { links: { image: 'https://example.com/image.png' } } }, + { content: { links: { image: 'https://example.com/image.png' } }, id: VALID_MINT }, ]); const response = await GET(makeRequest(), makeParams()); @@ -101,7 +101,7 @@ describe('GET /api/token-image/[mintAddress]', () => { }); it('should call getAssetBatch with the mint address', async () => { - getAssetBatch.mockResolvedValueOnce([{ id: VALID_MINT, content: { links: {} } }]); + getAssetBatch.mockResolvedValueOnce([{ content: { links: {} }, id: VALID_MINT }]); await GET(makeRequest(), makeParams()); diff --git a/app/components/account/AccountHeader.tsx b/app/components/account/AccountHeader.tsx index f05e35aec..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, @@ -19,7 +20,6 @@ import { create } from 'superstruct'; import { ProgramHeader } from '@/app/components/shared/account/ProgramHeader'; import { ProxiedImage } from '@/app/features/metadata'; -import { useDasImage } from '@entities/digital-asset'; import { getProxiedUri } from '@/app/features/metadata/utils'; import { type FullTokenInfo, isRedactedTokenAddress } from '@/app/utils/token-info'; From 76785e09e34dbc24a357b804f3e88bdeb4c1db48 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Mon, 15 Jun 2026 13:39:59 +0200 Subject: [PATCH 06/11] fixed security --- .../token-image/[mintAddress]/__tests__/route.spec.ts | 11 +++++++++++ app/api/token-image/[mintAddress]/route.ts | 10 ++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/api/token-image/[mintAddress]/__tests__/route.spec.ts b/app/api/token-image/[mintAddress]/__tests__/route.spec.ts index 4c1def34a..d5730b06d 100644 --- a/app/api/token-image/[mintAddress]/__tests__/route.spec.ts +++ b/app/api/token-image/[mintAddress]/__tests__/route.spec.ts @@ -43,6 +43,17 @@ describe('GET /api/token-image/[mintAddress]', () => { 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')); diff --git a/app/api/token-image/[mintAddress]/route.ts b/app/api/token-image/[mintAddress]/route.ts index d8c19f077..78da07f86 100644 --- a/app/api/token-image/[mintAddress]/route.ts +++ b/app/api/token-image/[mintAddress]/route.ts @@ -24,14 +24,20 @@ export async function GET(request: Request, props: Params) { const { searchParams } = new URL(request.url); const clusterParam = searchParams.get('cluster') ?? clusterSlug(Cluster.MainnetBeta); - const customUrl = searchParams.get('customUrl') ?? ''; const cluster = clusterFromSlug(clusterParam); if (cluster === null) { return NextResponse.json({ error: 'Invalid cluster' }, { headers: NO_STORE_HEADERS, status: 400 }); } - const rpcUrl = serverClusterUrl(cluster, customUrl); + 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) { From e20f846036167eec35ca9d6db5cd8c401a0c6702 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Tue, 16 Jun 2026 11:34:23 +0200 Subject: [PATCH 07/11] debugging --- app/entities/digital-asset/api.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/entities/digital-asset/api.ts b/app/entities/digital-asset/api.ts index 4863089d0..d88b4d958 100644 --- a/app/entities/digital-asset/api.ts +++ b/app/entities/digital-asset/api.ts @@ -67,6 +67,11 @@ export async function getAssetBatch( const data = await response.json(); + if (data?.error) { + Logger.warn(`[das] getAssets RPC error: ${data.error?.message ?? 'unknown'}`); + return undefined; + } + if (!is(data, GetAssetBatchResponseSchema)) { Logger.warn('[das] getAssets invalid response', { sentry: true }); return undefined; From f719cae4be210359b3654c1470ee729fce6ed64e Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Tue, 16 Jun 2026 11:48:55 +0200 Subject: [PATCH 08/11] more loggs --- app/entities/digital-asset/api.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/entities/digital-asset/api.ts b/app/entities/digital-asset/api.ts index d88b4d958..e202a84d3 100644 --- a/app/entities/digital-asset/api.ts +++ b/app/entities/digital-asset/api.ts @@ -73,7 +73,11 @@ export async function getAssetBatch( } if (!is(data, GetAssetBatchResponseSchema)) { - Logger.warn('[das] getAssets invalid response', { sentry: true }); + const resultType = typeof data?.result; + const resultIsArray = Array.isArray(data?.result); + Logger.warn(`[das] getAssets invalid response (result type: ${resultType}, isArray: ${resultIsArray})`, { + sentry: true, + }); return undefined; } From b07b48b4e8404727eb042ddc1a558674bae36b2b Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Tue, 16 Jun 2026 12:10:10 +0200 Subject: [PATCH 09/11] schema type update --- app/api/search/__tests__/route.test.ts | 20 ++------------ app/entities/digital-asset/types.ts | 37 +------------------------- 2 files changed, 3 insertions(+), 54 deletions(-) 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/entities/digital-asset/types.ts b/app/entities/digital-asset/types.ts index cdd7a5a48..cc6ca6a3f 100644 --- a/app/entities/digital-asset/types.ts +++ b/app/entities/digital-asset/types.ts @@ -1,50 +1,15 @@ -import { array, boolean, Infer, number, optional, string, type } from 'superstruct'; +import { array, Infer, 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( - type({ - decimals: optional(number()), - mint_authority: optional(string()), - price_info: optional( - type({ - currency: optional(string()), - price_per_token: optional(number()), - }), - ), - supply: optional(number()), - symbol: optional(string()), - token_program: optional(string()), - }), - ), }); export const GetAssetBatchResponseSchema = type({ From 54e6b619451af97a34fc038f49381b07a86fe439 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Tue, 16 Jun 2026 12:23:14 +0200 Subject: [PATCH 10/11] validation update --- app/api/token-image/[mintAddress]/route.ts | 2 +- app/entities/digital-asset/api.ts | 13 +++++------- app/entities/digital-asset/types.ts | 21 +++++++++++-------- .../search/api/resolve-search-tokens.ts | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/app/api/token-image/[mintAddress]/route.ts b/app/api/token-image/[mintAddress]/route.ts index 78da07f86..8290558b6 100644 --- a/app/api/token-image/[mintAddress]/route.ts +++ b/app/api/token-image/[mintAddress]/route.ts @@ -45,6 +45,6 @@ export async function GET(request: Request, props: Params) { } const asset = assets.find(a => a.id === mintAddress); - const image = asset?.content.links?.image; + const image = asset?.content?.links?.image; return NextResponse.json({ image }, { headers: IMAGE_CACHE_HEADERS }); } diff --git a/app/entities/digital-asset/api.ts b/app/entities/digital-asset/api.ts index e202a84d3..e6e412666 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'; @@ -72,16 +72,13 @@ export async function getAssetBatch( return undefined; } - if (!is(data, GetAssetBatchResponseSchema)) { - const resultType = typeof data?.result; - const resultIsArray = Array.isArray(data?.result); - Logger.warn(`[das] getAssets invalid response (result type: ${resultType}, isArray: ${resultIsArray})`, { - sentry: true, - }); + const [validationError, validData] = validate(data, GetAssetBatchResponseSchema); + if (validationError) { + Logger.warn(`[das] getAssets invalid response: ${validationError.message}`, { sentry: true }); return undefined; } - return data.result; + return validData.result; } catch (error) { Logger.error(error instanceof Error ? error : new Error('[das] getAssets failed'), { sentry: true, diff --git a/app/entities/digital-asset/types.ts b/app/entities/digital-asset/types.ts index cc6ca6a3f..82f6e80b7 100644 --- a/app/entities/digital-asset/types.ts +++ b/app/entities/digital-asset/types.ts @@ -1,14 +1,17 @@ -import { array, Infer, optional, string, type } from 'superstruct'; +import { array, Infer, nullable, optional, string, type } from 'superstruct'; export const DigitalAssetSchema = type({ - content: type({ - links: optional( - type({ - external_url: optional(string()), - image: optional(string()), - }), - ), - }), + content: nullable( + type({ + links: nullable( + optional( + type({ + image: nullable(optional(string())), + }), + ), + ), + }), + ), id: string(), }); 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, From 5e6f2819fc7f93afef084d3b99dd31bb38c3d72c Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Tue, 16 Jun 2026 12:31:32 +0200 Subject: [PATCH 11/11] null support --- app/entities/digital-asset/api.ts | 2 +- app/entities/digital-asset/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/entities/digital-asset/api.ts b/app/entities/digital-asset/api.ts index e6e412666..f5e500264 100644 --- a/app/entities/digital-asset/api.ts +++ b/app/entities/digital-asset/api.ts @@ -78,7 +78,7 @@ export async function getAssetBatch( return undefined; } - return validData.result; + 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/types.ts b/app/entities/digital-asset/types.ts index 82f6e80b7..ceaa3e51b 100644 --- a/app/entities/digital-asset/types.ts +++ b/app/entities/digital-asset/types.ts @@ -16,7 +16,7 @@ export const DigitalAssetSchema = type({ }); export const GetAssetBatchResponseSchema = type({ - result: array(DigitalAssetSchema), + result: array(nullable(DigitalAssetSchema)), }); export type DigitalAsset = Infer;