diff --git a/app/address/[address]/files/__tests__/page-client.test.tsx b/app/address/[address]/files/__tests__/page-client.test.tsx
new file mode 100644
index 000000000..a41e500c2
--- /dev/null
+++ b/app/address/[address]/files/__tests__/page-client.test.tsx
@@ -0,0 +1,97 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { vi } from 'vitest';
+
+import MetaplexFilesPageClient from '../page-client';
+
+// Mock the dependencies
+vi.mock('@/app/components/common/LoadingCard', () => ({
+ LoadingCard: () =>
Loading...
,
+}));
+
+vi.mock('@components/account/ParsedAccountRenderer', () => ({
+ ParsedAccountRenderer: ({
+ address,
+ renderComponent: RenderComponentProp,
+ }: {
+ address: string;
+ renderComponent: React.ComponentType;
+ }) => {
+ // Mock account data for testing
+ const mockAccount = {
+ data: {
+ parsed: {
+ nftData: {
+ metadata: {
+ data: {
+ uri: 'https://example.com/metadata.json',
+ },
+ },
+ },
+ parsed: { type: 'mint' },
+ program: 'spl-token',
+ },
+ },
+ pubkey: { toString: () => address },
+ };
+ const mockOnNotFound = vi.fn(() => {
+ throw new Error('Not found');
+ });
+
+ return (
+
+
+
+ );
+ },
+}));
+
+vi.mock('@components/account/MetaplexFilesCard', () => ({
+ MetaplexFilesCard: ({ account, onNotFound: _onNotFound }: { account: any; onNotFound: () => never }) => (
+ MetaplexFilesCard for {account?.pubkey?.toString()}
+ ),
+}));
+
+describe('MetaplexFilesPageClient', () => {
+ it('renders ParsedAccountRenderer with correct address', () => {
+ const testAddress = 'DemoKeypair1111111111111111111111111111111111';
+ const props = {
+ params: {
+ address: testAddress,
+ },
+ };
+
+ render();
+
+ expect(screen.getByTestId('parsed-account-renderer')).toBeInTheDocument();
+ });
+
+ it('renders MetaplexFilesCard within Suspense boundary', async () => {
+ const testAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
+ const props = {
+ params: {
+ address: testAddress,
+ },
+ };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('metaplex-files-card')).toBeInTheDocument();
+ });
+ });
+
+ it('renders the correct component structure', () => {
+ const testAddress = 'DemoKeypair1111111111111111111111111111111111';
+ const props = {
+ params: {
+ address: testAddress,
+ },
+ };
+
+ render();
+
+ // Should render the parsed account renderer with the component structure
+ expect(screen.getByTestId('parsed-account-renderer')).toBeInTheDocument();
+ expect(screen.getByTestId('metaplex-files-card')).toBeInTheDocument();
+ });
+});
diff --git a/app/address/[address]/files/__tests__/page.test.tsx b/app/address/[address]/files/__tests__/page.test.tsx
new file mode 100644
index 000000000..56ec8d48c
--- /dev/null
+++ b/app/address/[address]/files/__tests__/page.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react';
+import { vi } from 'vitest';
+
+import MetaplexFilesPage from '../page';
+
+// Mock the page-client component
+vi.mock('../page-client', () => ({
+ default: ({ params }: { params: { address: string } }) => (
+ Metaplex Files for address: {params.address}
+ ),
+}));
+
+describe('MetaplexFilesPage', () => {
+ it('renders the page with correct props', () => {
+ const props = {
+ params: {
+ address: 'DemoKeypair1111111111111111111111111111111111',
+ },
+ };
+
+ render();
+
+ expect(screen.getByTestId('metaplex-files-page-client')).toBeInTheDocument();
+ expect(
+ screen.getByText('Metaplex Files for address: DemoKeypair1111111111111111111111111111111111')
+ ).toBeInTheDocument();
+ });
+
+ it('passes address parameter correctly', () => {
+ const testAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
+ const props = {
+ params: {
+ address: testAddress,
+ },
+ };
+
+ render();
+
+ expect(screen.getByText(`Metaplex Files for address: ${testAddress}`)).toBeInTheDocument();
+ });
+});
diff --git a/app/address/[address]/files/page-client.tsx b/app/address/[address]/files/page-client.tsx
new file mode 100644
index 000000000..4a437abc6
--- /dev/null
+++ b/app/address/[address]/files/page-client.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { MetaplexFilesCard } from '@components/account/MetaplexFilesCard';
+import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
+import React, { Suspense } from 'react';
+
+import { LoadingCard } from '@/app/components/common/LoadingCard';
+
+type Props = Readonly<{
+ params: {
+ address: string;
+ };
+}>;
+
+function MetaplexFilesCardRenderer({
+ account,
+ onNotFound,
+}: React.ComponentProps['renderComponent']>) {
+ return (
+ }>
+ {}
+
+ );
+}
+
+export default function MetaplexFilesPageClient({ params: { address } }: Props) {
+ return ;
+}
diff --git a/app/address/[address]/files/page.tsx b/app/address/[address]/files/page.tsx
new file mode 100644
index 000000000..ed0837360
--- /dev/null
+++ b/app/address/[address]/files/page.tsx
@@ -0,0 +1,11 @@
+import MetaplexFilesPageClient from './page-client';
+
+type Props = Readonly<{
+ params: {
+ address: string;
+ };
+}>;
+
+export default function MetaplexFilesPage(props: Props) {
+ return ;
+}
diff --git a/app/address/[address]/layout.tsx b/app/address/[address]/layout.tsx
index cae7678c1..a8a9e3906 100644
--- a/app/address/[address]/layout.tsx
+++ b/app/address/[address]/layout.tsx
@@ -108,7 +108,7 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
title: 'Instructions',
},
],
- 'spl-token-2022:mint:metaplexNFT': [
+ 'spl-token-2022:mint:metaplex': [
{
path: 'metadata',
slug: 'metadata',
@@ -119,6 +119,11 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
slug: 'attributes',
title: 'Attributes',
},
+ {
+ path: 'files',
+ slug: 'files',
+ title: 'Files',
+ },
],
'spl-token:mint': [
{
@@ -132,7 +137,7 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
title: 'Instructions',
},
],
- 'spl-token:mint:metaplexNFT': [
+ 'spl-token:mint:metaplex': [
{
path: 'metadata',
slug: 'metadata',
@@ -143,6 +148,11 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
slug: 'attributes',
title: 'Attributes',
},
+ {
+ path: 'files',
+ slug: 'files',
+ title: 'Files',
+ },
],
stake: [
{
@@ -380,6 +390,7 @@ export type MoreTabs =
| 'rewards'
| 'metadata'
| 'attributes'
+ | 'files'
| 'domains'
| 'security'
| 'idl'
@@ -443,7 +454,7 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] {
(programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') &&
(parsedData as TokenProgramData).nftData
) {
- tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplexNFT`]);
+ tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplex`]);
}
// Compressed NFT tabs
diff --git a/app/components/account/MetaplexFilesCard.tsx b/app/components/account/MetaplexFilesCard.tsx
new file mode 100644
index 000000000..2518fc981
--- /dev/null
+++ b/app/components/account/MetaplexFilesCard.tsx
@@ -0,0 +1,103 @@
+import { ErrorCard } from '@components/common/ErrorCard';
+import { LoadingCard } from '@components/common/LoadingCard';
+import { Account, isTokenProgramData } from '@providers/accounts';
+import React from 'react';
+
+import { getProxiedUri } from '@/app/features/metadata/utils';
+import { useCluster } from '@/app/providers/cluster';
+import { useCompressedNft } from '@/app/providers/compressed-nft';
+
+interface File {
+ uri: string;
+ type: string;
+ cdn?: boolean;
+}
+
+export function MetaplexFilesCard({ account, onNotFound }: { account?: Account; onNotFound: () => never }) {
+ const { url } = useCluster();
+ const compressedNft = useCompressedNft({ address: account?.pubkey.toString() ?? '', url });
+
+ const parsedData = account?.data?.parsed;
+ if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) {
+ if (compressedNft && compressedNft.compression.compressed) {
+ return ;
+ }
+ return onNotFound();
+ }
+ return ;
+}
+
+function NormalMetaplexFilesCard({ metadataUri }: { metadataUri: string }) {
+ const [files, setFiles] = React.useState([]);
+ const [status, setStatus] = React.useState<'loading' | 'success' | 'error'>('loading');
+
+ React.useEffect(() => {
+ async function fetchMetadataFiles() {
+ try {
+ const response = await fetch(getProxiedUri(metadataUri));
+ if (!response.ok) {
+ throw new Error('Failed to fetch metadata');
+ }
+
+ const metadata = await response.json();
+ // Verify if the attributes value is an array
+ if (Array.isArray(metadata.properties.files)) {
+ // Filter files to keep objects matching schema
+ const filteredFiles = metadata.properties.files.filter((file: any) => {
+ return (
+ typeof file === 'object' && typeof file.uri === 'string' && typeof file.type === 'string'
+ );
+ });
+
+ setFiles(filteredFiles);
+ setStatus('success');
+ } else {
+ throw new Error('Files is not an array');
+ }
+ } catch (error) {
+ setStatus('error');
+ }
+ }
+ fetchMetadataFiles();
+ }, [metadataUri]);
+
+ if (status === 'loading') {
+ return ;
+ }
+
+ if (status === 'error') {
+ return ;
+ }
+
+ const filesList: React.ReactNode[] = files.map(({ uri, type }) => {
+ return (
+
+ |
+
+ {uri}
+
+ |
+ {type} |
+
+ );
+ });
+
+ return (
+
+
+
Files
+
+
+
+
+
+ | File URI |
+ File Type |
+
+
+ {filesList}
+
+
+
+ );
+}
diff --git a/app/components/account/__test__/MetaplexFilesCard.test.tsx b/app/components/account/__test__/MetaplexFilesCard.test.tsx
new file mode 100644
index 000000000..d2f1b11c1
--- /dev/null
+++ b/app/components/account/__test__/MetaplexFilesCard.test.tsx
@@ -0,0 +1,383 @@
+import { PublicKey } from '@solana/web3.js';
+import { render, screen, waitFor } from '@testing-library/react';
+import { ok } from 'assert';
+import { vi } from 'vitest';
+
+import { MetaplexFilesCard } from '../MetaplexFilesCard';
+
+// Mock the dependencies
+vi.mock('@/app/providers/cluster', () => ({
+ useCluster: () => ({
+ url: 'https://api.mainnet-beta.solana.com',
+ }),
+}));
+
+vi.mock('@/app/providers/compressed-nft', () => ({
+ useCompressedNft: vi.fn(),
+}));
+
+vi.mock('@/app/features/metadata/utils', () => ({
+ getProxiedUri: (uri: string) => uri,
+}));
+
+vi.mock('@components/common/ErrorCard', () => ({
+ ErrorCard: ({ text }: { text: string }) => {text}
,
+}));
+
+vi.mock('@components/common/LoadingCard', () => ({
+ LoadingCard: () => Loading...
,
+}));
+
+// Mock fetch globally
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+// Import the mocked function
+const { useCompressedNft } = await import('@/app/providers/compressed-nft');
+const mockUseCompressedNft = vi.mocked(useCompressedNft);
+
+describe('MetaplexFilesCard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseCompressedNft.mockReturnValue(null);
+ });
+
+ const createMockAccount = (overrides = {}) =>
+ ({
+ data: {
+ parsed: {
+ nftData: {
+ editionInfo: { edition: 'master', masterEdition: undefined },
+ json: undefined,
+ metadata: {
+ collection: null,
+ data: {
+ creators: null,
+ name: 'Test NFT',
+ sellerFeeBasisPoints: 0,
+ symbol: 'TEST',
+ uri: 'https://example.com/metadata.json',
+ },
+ editionNonce: null,
+ isMutable: true,
+ key: 1,
+ mint: 'So11111111111111111111111111111111111111112',
+ primarySaleHappened: false,
+ tokenStandard: null,
+ updateAuthority: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
+ uses: null,
+ },
+ },
+ parsed: { type: 'mint' as const },
+ program: 'spl-token' as const,
+ },
+ },
+ executable: false,
+ lamports: 0,
+ owner: new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'),
+ pubkey: new PublicKey('So11111111111111111111111111111111111111112'),
+ ...overrides,
+ } as any);
+
+ const mockOnNotFound = vi.fn(() => {
+ throw new Error('Not found');
+ });
+
+ it('shows loading card initially', () => {
+ const mockAccount = createMockAccount();
+ render();
+
+ expect(screen.getByTestId('loading-card')).toBeInTheDocument();
+ });
+
+ it('renders files table when fetch is successful', async () => {
+ const mockFiles = [
+ { type: 'image/png', uri: 'https://example.com/file1.png' },
+ { type: 'image/jpeg', uri: 'https://example.com/file2.jpg' },
+ ];
+
+ mockFetch.mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ properties: {
+ files: mockFiles,
+ },
+ }),
+ ok: true,
+ });
+
+ const mockAccount = createMockAccount();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Files')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('File URI')).toBeInTheDocument();
+ expect(screen.getByText('File Type')).toBeInTheDocument();
+
+ // Check if files are rendered
+ expect(screen.getByText('https://example.com/file1.png')).toBeInTheDocument();
+ expect(screen.getByText('image/png')).toBeInTheDocument();
+ expect(screen.getByText('https://example.com/file2.jpg')).toBeInTheDocument();
+ expect(screen.getByText('image/jpeg')).toBeInTheDocument();
+ });
+
+ it('shows error card when fetch fails', async () => {
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ const mockAccount = createMockAccount();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('error-card')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Failed to fetch files')).toBeInTheDocument();
+ });
+
+ it('shows error when files property is not an array', async () => {
+ mockFetch.mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ properties: {
+ files: 'not an array',
+ },
+ }),
+ ok: true,
+ });
+
+ const mockAccount = createMockAccount();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('error-card')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Failed to fetch files')).toBeInTheDocument();
+ });
+
+ it('filters out invalid file objects', async () => {
+ const mockFiles = [
+ { type: 'image/png', uri: 'https://example.com/valid.png' },
+ { type: null, uri: 'invalid' }, // invalid type
+ { type: 'image/jpeg', uri: null }, // invalid uri
+ 'not an object', // not an object
+ { type: 'image/jpeg', uri: 'https://example.com/valid2.jpg' },
+ ];
+
+ mockFetch.mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ properties: {
+ files: mockFiles,
+ },
+ }),
+ ok: true,
+ });
+
+ const mockAccount = createMockAccount();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Files')).toBeInTheDocument();
+ });
+
+ // Should only show valid files
+ expect(screen.getByText('https://example.com/valid.png')).toBeInTheDocument();
+ expect(screen.getByText('https://example.com/valid2.jpg')).toBeInTheDocument();
+
+ // Should not show invalid files
+ expect(screen.queryByText('invalid')).not.toBeInTheDocument();
+ });
+
+ it('calls onNotFound when account data is invalid', () => {
+ const invalidAccount = createMockAccount({
+ data: {
+ parsed: {
+ parsed: { type: 'account' },
+ program: 'spl-token',
+ },
+ },
+ });
+
+ expect(() => {
+ render();
+ }).toThrow('Not found');
+
+ expect(mockOnNotFound).toHaveBeenCalled();
+ });
+
+ it('calls onNotFound when account is undefined', () => {
+ expect(() => {
+ render();
+ }).toThrow('Not found');
+
+ expect(mockOnNotFound).toHaveBeenCalled();
+ });
+
+ it('renders file links with correct href and attributes', async () => {
+ const mockFiles = [{ type: 'image/png', uri: 'https://example.com/file1.png' }];
+
+ mockFetch.mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ properties: {
+ files: mockFiles,
+ },
+ }),
+ ok: true,
+ });
+
+ const mockAccount = createMockAccount();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Files')).toBeInTheDocument();
+ });
+
+ const fileLink = screen.getByRole('link', { name: 'https://example.com/file1.png' });
+ expect(fileLink).toHaveAttribute('href', 'https://example.com/file1.png');
+ expect(fileLink).toHaveAttribute('target', '_blank');
+ expect(fileLink).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('handles compressed NFT when regular NFT data is not available', () => {
+ mockUseCompressedNft.mockReturnValue({
+ compression: {
+ asset_hash: 'hash3',
+ compressed: true,
+ creator_hash: 'hash2',
+ data_hash: 'hash1',
+ eligible: false,
+ leaf_id: 1,
+ seq: 1,
+ tree: 'tree1',
+ },
+ content: {
+ $schema: 'schema',
+ files: [],
+ json_uri: 'https://compressed-nft.com/metadata.json',
+ links: {},
+ metadata: {
+ attributes: [],
+ description: 'desc',
+ name: 'name',
+ symbol: 'symbol',
+ token_standard: 'standard',
+ },
+ },
+ } as any);
+
+ const accountWithoutNftData = createMockAccount({
+ data: {
+ parsed: {
+ parsed: { type: 'account' },
+ program: 'spl-token',
+ },
+ },
+ });
+
+ render();
+
+ expect(screen.getByTestId('loading-card')).toBeInTheDocument();
+ });
+
+ it('handles empty files array', async () => {
+ mockFetch.mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ properties: {
+ files: [],
+ },
+ }),
+ ok: true,
+ });
+
+ const mockAccount = createMockAccount();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Files')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('File URI')).toBeInTheDocument();
+ expect(screen.getByText('File Type')).toBeInTheDocument();
+
+ // Should show table with no file rows (only header row)
+ const fileRows = screen.getAllByRole('row');
+ expect(fileRows).toHaveLength(1); // Only header row
+ });
+
+ it('uses proxied URI for metadata fetch', async () => {
+ const mockFiles = [{ type: 'image/png', uri: 'https://example.com/file1.png' }];
+
+ mockFetch.mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ properties: {
+ files: mockFiles,
+ },
+ }),
+ ok: true,
+ });
+
+ const mockAccount = createMockAccount();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Files')).toBeInTheDocument();
+ });
+
+ // Verify that fetch was called with the metadata URI
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/metadata.json');
+ });
+
+ it('handles missing properties in metadata', async () => {
+ mockFetch.mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ // Missing properties field
+ }),
+ ok: true,
+ });
+
+ const mockAccount = createMockAccount();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('error-card')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Failed to fetch files')).toBeInTheDocument();
+ });
+
+ it('creates unique keys for file rows', async () => {
+ const mockFiles = [
+ { type: 'image/png', uri: 'https://example.com/file1.png' },
+ { type: 'image/png', uri: 'https://example.com/file2.png' },
+ { type: 'image/jpeg', uri: 'https://example.com/file1.png' }, // Same URI, different type
+ ];
+
+ mockFetch.mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ properties: {
+ files: mockFiles,
+ },
+ }),
+ ok: true,
+ });
+
+ const mockAccount = createMockAccount();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Files')).toBeInTheDocument();
+ });
+
+ // Should render all files with unique keys
+ const fileRows = screen.getAllByRole('row').slice(1); // Skip header row
+ expect(fileRows).toHaveLength(3);
+ });
+});