Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ NEXT_PUBLIC_METADATA_ENABLED=false
NEXT_PUBLIC_METADATA_TIMEOUT=
NEXT_PUBLIC_METADATA_MAX_CONTENT_SIZE=
NEXT_PUBLIC_METADATA_USER_AGENT="Solana Explorer"
## Configuration for "Program IDL" feature enabled
NEXT_PUBLIC_PMP_IDL_ENABLED=true
## Configuration for "security.txt" feature enabled
NEXT_PUBLIC_PMP_SECURITY_TXT_ENABLED=true
## Configuration for "interactive IDL" feature enabled
NEXT_PUBLIC_INTERACTIVE_IDL_ENABLED=true
NEXT_PUBLIC_BAD_TOKENS=
Expand Down
19 changes: 19 additions & 0 deletions app/__fixtures__/gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ export const gen = {
/** Unix seconds; deterministic when seed provided so story fixtures stay pixel-stable. */
timestamp: (seed?: number) =>
seed === undefined ? Math.floor(Math.random() * 2_000_000_000) : 1_700_000_000 + seed * 86_400,
/**
* A readable, self-documenting test address with a recognizable base58 prefix — e.g.
* `gen.vanityAddress('PMP')` → `PMP1111…` — for fixtures that want a legible placeholder instead of
* an opaque key. Right-pads the prefix with base58 `1`s until it forms a valid 32-byte address.
* Throws if `prefix` isn't valid base58 (it excludes `0`, `O`, `I`, `l`) or can't pad to 32 bytes.
*/
vanityAddress: (prefix: string) => {
for (let pad = 0; pad <= 44; pad++) {
const candidate = prefix + '1'.repeat(pad);
let bytes: Uint8Array;
try {
bytes = bs58.decode(candidate);
} catch {
break; // a non-base58 character in `prefix` — more padding won't help
}
if (bytes.length === 32) return candidate;
}
throw new Error(`gen.vanityAddress: "${prefix}" is not a base58 prefix that pads to a 32-byte address`);
},
};

/** Stable single-placeholder address (base58, 32 bytes). */
Expand Down
61 changes: 21 additions & 40 deletions app/api/idl-latest/__tests__/route.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR, SolanaError } from '@solana/kit';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { IdlVariant } from '@/app/entities/idl/server';
import { Logger } from '@/app/shared/lib/logger';
import { Cluster } from '@/app/utils/cluster';

Expand All @@ -25,11 +24,16 @@ vi.mock('@solana/kit', async () => {
return { ...actual, createSolanaRpc: vi.fn(() => ({})) };
});

function resolved(overrides: Partial<Record<'anchorIdl' | 'programMetadataIdl' | 'preferredVariant', unknown>> = {}) {
function resolved(
overrides: Partial<
Record<'anchorIdl' | 'anchorIdlAddress' | 'programMetadataIdl' | 'programMetadataIdlAddress', unknown>
> = {},
) {
return {
anchorIdl: undefined,
preferredVariant: IdlVariant.ProgramMetadata,
anchorIdlAddress: undefined,
programMetadataIdl: undefined,
programMetadataIdlAddress: undefined,
...overrides,
};
}
Expand All @@ -39,12 +43,6 @@ describe('GET /api/idl-latest', () => {
vi.clearAllMocks();
vi.spyOn(Logger, 'warn').mockImplementation(() => {});
vi.spyOn(Logger, 'panic').mockImplementation(() => {});
// PMP feature gate on by default; the off case is exercised explicitly below.
vi.stubEnv('NEXT_PUBLIC_PMP_IDL_ENABLED', 'true');
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('should return 400 when required params are missing', async () => {
Expand Down Expand Up @@ -79,48 +77,35 @@ describe('GET /api/idl-latest', () => {
mocks.resolveProgramIdls.mockResolvedValueOnce(
resolved({
anchorIdl: { name: 'anchor_idl' },
preferredVariant: IdlVariant.Anchor,
anchorIdlAddress: 'AnchrPDA11111111111111111111111111111111111',
programMetadataIdl: { name: 'pmp' },
programMetadataIdlAddress: 'PmpPDA111111111111111111111111111111111111',
}),
);

const { GET } = await importRoute();
const res = await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: PROGRAM_ADDRESS }));

expect(res.status).toBe(200);
// Storage accounts are forwarded alongside each IDL (anchorIdlAddress → anchorAddress, etc.).
expect(await res.json()).toEqual({
idls: { anchor: { name: 'anchor_idl' }, preferred: 'anchor', programMetadata: { name: 'pmp' } },
idls: {
anchor: { name: 'anchor_idl' },
anchorAddress: 'AnchrPDA11111111111111111111111111111111111',
programMetadata: { name: 'pmp' },
programMetadataAddress: 'PmpPDA111111111111111111111111111111111111',
},
});
expect(res.headers.get('Cache-Control')).toContain('max-age=');
});

it('should resolve with includePmp=true when the PMP feature flag is on', async () => {
it('should resolve IDLs for the program (both sources, no source options)', async () => {
mocks.resolveProgramIdls.mockResolvedValueOnce(resolved({ programMetadataIdl: { name: 'pmp' } }));

const { GET } = await importRoute();
await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: PROGRAM_ADDRESS }));

expect(mocks.resolveProgramIdls).toHaveBeenCalledWith(
expect.anything(),
PROGRAM_ADDRESS,
expect.objectContaining({ includePmp: true }),
);
});

it('should resolve with includePmp=false when the PMP feature flag is off', async () => {
vi.stubEnv('NEXT_PUBLIC_PMP_IDL_ENABLED', 'false');
mocks.resolveProgramIdls.mockResolvedValueOnce(
resolved({ anchorIdl: { name: 'a' }, preferredVariant: IdlVariant.Anchor }),
);

const { GET } = await importRoute();
await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: PROGRAM_ADDRESS }));

expect(mocks.resolveProgramIdls).toHaveBeenCalledWith(
expect.anything(),
PROGRAM_ADDRESS,
expect.objectContaining({ includePmp: false }),
);
expect(mocks.resolveProgramIdls).toHaveBeenCalledWith(expect.anything(), PROGRAM_ADDRESS);
});

it('should return a retryable 502 (no page) when the resolver keeps throwing a transient RPC error', async () => {
Expand All @@ -145,9 +130,7 @@ describe('GET /api/idl-latest', () => {
code: 'ERR_STREAM_PREMATURE_CLOSE',
}),
);
mocks.resolveProgramIdls.mockResolvedValueOnce(
resolved({ anchorIdl: { name: 'a' }, preferredVariant: IdlVariant.Anchor }),
);
mocks.resolveProgramIdls.mockResolvedValueOnce(resolved({ anchorIdl: { name: 'a' } }));

const { GET } = await importRoute();
const res = await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: PROGRAM_ADDRESS }));
Expand All @@ -164,9 +147,7 @@ describe('GET /api/idl-latest', () => {
cause: Object.assign(new Error('other side closed'), { code: 'UND_ERR_SOCKET' }),
}),
);
mocks.resolveProgramIdls.mockResolvedValueOnce(
resolved({ anchorIdl: { name: 'a' }, preferredVariant: IdlVariant.Anchor }),
);
mocks.resolveProgramIdls.mockResolvedValueOnce(resolved({ anchorIdl: { name: 'a' } }));

const { GET } = await importRoute();
const res = await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: PROGRAM_ADDRESS }));
Expand Down
24 changes: 12 additions & 12 deletions app/api/idl-latest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { type Address, address, createSolanaRpc } from '@solana/kit';
import { NextResponse } from 'next/server';

import { Logger } from '@/app/shared/lib/logger';
import { isEnvEnabled } from '@/app/utils/env';

const CACHE_DURATION = 30 * 60; // 30 minutes

Expand All @@ -18,13 +17,12 @@ const CACHE_HEADERS = {
async function resolveProgramIdlsWithRetry(
url: string,
programId: Address,
options: Parameters<typeof resolveProgramIdls>[2],
attempts = 3,
): Promise<Awaited<ReturnType<typeof resolveProgramIdls>>> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await resolveProgramIdls(createSolanaRpc(url), programId, options);
return await resolveProgramIdls(createSolanaRpc(url), programId);
} catch (error) {
lastError = error;
if (attempt < attempts - 1 && isRetryableError(error)) {
Expand All @@ -39,9 +37,9 @@ async function resolveProgramIdlsWithRetry(
/**
* The single IDL-resolution endpoint for known clusters. Resolution lives in `resolveProgramIdls`
* (shared with the custom/localhost client path); this route is the server transport edge: query
* parsing, the PMP feature gate, CDN cache headers, and the error-to-HTTP policy. It always resolves
* the Anchor IDL (unless the program is native) and includes the PMP `idl` IDL when the feature flag
* is on — consumers read the field they need (`idls.anchor` / `idls.programMetadata`).
* parsing, CDN cache headers, and the error-to-HTTP policy. It always resolves the Anchor IDL (unless
* the program is native) and the PMP `idl` IDL — consumers read the field they need (`idls.anchor` /
* `idls.programMetadata`).
*
* Error policy: `resolveProgramIdls` throws only on RPC failure — transient blips → retryable,
* *uncached* 502 (no page); persistent misconfiguration → Sentry page. We never cache a
Expand All @@ -51,8 +49,6 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const clusterProp = searchParams.get('cluster');
const programAddress = searchParams.get('programAddress');
// The PMP IDL feature gate lives server-side: include the PMP `idl` IDL only when the flag is on.
const includePmp = isEnvEnabled(process.env.NEXT_PUBLIC_PMP_IDL_ENABLED);

if (!programAddress || !clusterProp) {
return NextResponse.json({ error: 'Invalid query params' }, { status: 400 });
Expand All @@ -73,11 +69,15 @@ export async function GET(request: Request) {
const context = { cluster: clusterProp, programAddress };

try {
const { anchorIdl, programMetadataIdl, preferredVariant } = await resolveProgramIdlsWithRetry(url, programId, {
includePmp,
});
const { anchorIdl, anchorIdlAddress, programMetadataIdl, programMetadataIdlAddress } =
await resolveProgramIdlsWithRetry(url, programId);

const idls = { anchor: anchorIdl, preferred: preferredVariant, programMetadata: programMetadataIdl };
const idls = {
anchor: anchorIdl,
anchorAddress: anchorIdlAddress,
programMetadata: programMetadataIdl,
programMetadataAddress: programMetadataIdlAddress,
};
return NextResponse.json({ idls }, { headers: CACHE_HEADERS, status: 200 });
} catch (error) {
// `resolveProgramIdls` surfaces absent/undecodable as values and throws only on RPC failure.
Expand Down
28 changes: 4 additions & 24 deletions app/api/security-txt/__tests__/route.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import { SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR, SolanaError } from '@solana/kit';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { Logger } from '@/app/shared/lib/logger';
import { Cluster } from '@/app/utils/cluster';

const mockAddress = '11111111111111111111111111111111';

const mocks = vi.hoisted(() => ({
fetchElfSecurityTxt: vi.fn(),
fetchSecurityTxt: vi.fn(),
}));

// The route resolves security.txt via `@solana/security-txt`. We mock those fetchers (keeping the
// real `isTransientRpcError` from `@solana/idl`) to assert canonical-only resolution, response
// shaping, the PMP feature gate, and error classification.
// The route resolves security.txt via `@solana/security-txt`. We mock the fetcher (keeping the real
// `isTransientRpcError` from `@solana/idl`) to assert canonical-only resolution, response shaping,
// and error classification.
vi.mock('@solana/security-txt', () => ({
fetchElfSecurityTxt: mocks.fetchElfSecurityTxt,
fetchSecurityTxt: mocks.fetchSecurityTxt,
}));

Expand All @@ -30,12 +28,6 @@ describe('GET /api/security-txt', () => {
vi.clearAllMocks();
vi.spyOn(Logger, 'panic').mockImplementation(() => {});
vi.spyOn(Logger, 'warn').mockImplementation(() => {});
// PMP gate on by default; the off case is exercised explicitly below.
vi.stubEnv('NEXT_PUBLIC_PMP_SECURITY_TXT_ENABLED', 'true');
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('should return 400 when required params are missing', async () => {
Expand Down Expand Up @@ -102,18 +94,6 @@ describe('GET /api/security-txt', () => {
expect(mocks.fetchSecurityTxt).toHaveBeenCalledWith(expect.anything(), mockAddress, { authority: null });
});

it('should read only the ELF section when the PMP gate is off', async () => {
vi.stubEnv('NEXT_PUBLIC_PMP_SECURITY_TXT_ENABLED', 'false');
mocks.fetchElfSecurityTxt.mockResolvedValueOnce({ address: mockAddress, content: '', fields: { name: 'elf' } });

const { GET } = await importRoute();
const res = await GET(createRequest(mockAddress, Cluster.MainnetBeta));
expect(res.status).toBe(200);
expect(mocks.fetchSecurityTxt).not.toHaveBeenCalled();
expect(mocks.fetchElfSecurityTxt).toHaveBeenCalledWith(expect.anything(), mockAddress);
expect(await res.json()).toEqual({ securityTxt: { fields: { name: 'elf' }, type: 'elf' } });
});

it('should return a retryable 502 (no page) on a transient RPC error', async () => {
mocks.fetchSecurityTxt.mockRejectedValueOnce(
new SolanaError(SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR, { __serverMessage: 'Internal error' }),
Expand Down
24 changes: 5 additions & 19 deletions app/api/security-txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,10 @@ import { serverClusterUrlFromParam } from '@entities/cluster/server';
import { errors } from '@entities/program-metadata/server';
import { isRetryableError } from '@shared/lib/errors';
import { type Address, address, createSolanaRpc } from '@solana/kit';
import {
fetchElfSecurityTxt,
fetchSecurityTxt,
type SecurityTxtFields,
type SecurityTxtSource,
} from '@solana/security-txt';
import { fetchSecurityTxt, type SecurityTxtFields, type SecurityTxtSource } from '@solana/security-txt';
import { NextResponse } from 'next/server';

import { Logger } from '@/app/shared/lib/logger';
import { isEnvEnabled } from '@/app/utils/env';

const CACHE_DURATION = 30 * 60; // 30 minutes

Expand All @@ -21,8 +15,7 @@ const CACHE_HEADERS = {

/**
* Resolve a program's security.txt for a known cluster via `@solana/security-txt`: the PMP `security`
* seed (canonical authority only — no fndn fallback) then the legacy Neodyme ELF section. The PMP leg
* is gated by `NEXT_PUBLIC_PMP_SECURITY_TXT_ENABLED`; with it off, only the ELF section is read.
* seed (canonical authority only — no fndn fallback) then the legacy Neodyme ELF section.
*
* Error policy mirrors `/api/idl-latest`: a transient RPC blip → retryable, *uncached* 502 (no page);
* persistent misconfiguration → Sentry page. Absent / unparseable security.txt is a cacheable 200.
Expand All @@ -31,8 +24,6 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const clusterProp = searchParams.get('cluster');
const programAddress = searchParams.get('programAddress');
// The PMP security.txt feature gate lives server-side: include the PMP `security` seed only when on.
const includePmp = isEnvEnabled(process.env.NEXT_PUBLIC_PMP_SECURITY_TXT_ENABLED);

if (!programAddress || !clusterProp) {
return NextResponse.json({ error: 'Invalid query params' }, { status: 400 });
Expand All @@ -55,14 +46,9 @@ export async function GET(request: Request) {
try {
const rpc = createSolanaRpc(url);
let securityTxt: { type: SecurityTxtSource; fields: SecurityTxtFields } | undefined;
if (includePmp) {
// eslint-disable-next-line unicorn/no-null -- library API: null = canonical-only PMP lookup (no fndn fallback)
const result = await fetchSecurityTxt(rpc, programId, { authority: null });
if (result) securityTxt = { fields: result.fields, type: result.type };
} else {
const result = await fetchElfSecurityTxt(rpc, programId);
if (result) securityTxt = { fields: result.fields, type: 'elf' };
}
// eslint-disable-next-line unicorn/no-null -- library API: null = canonical-only PMP lookup (no fndn fallback)
const result = await fetchSecurityTxt(rpc, programId, { authority: null });
if (result) securityTxt = { fields: result.fields, type: result.type };

// `securityTxt` omitted (undefined) when absent — the "no security.txt" case, cacheable.
return NextResponse.json({ securityTxt }, { headers: CACHE_HEADERS, status: 200 });
Expand Down
2 changes: 1 addition & 1 deletion app/components/account/VerifiedBuildCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { Copyable } from '../common/Copyable';
import { LoadingCard } from '../common/LoadingCard';

export function VerifiedBuildCard({ data, pubkey }: { data: UpgradeableLoaderAccountData; pubkey: PublicKey }) {
// suspense:false -- the chain mixes with a non-suspense SWR (useIdlFromAnchorProgramSeed); the mixed path triggers hook-order warnings under HMR.
// suspense:false -- the chain mixes with a non-suspense SWR (useProgramIdls via useAnchorProgram); the mixed path triggers hook-order warnings under HMR.
const { data: registryInfo, isLoading } = useVerifiedProgram({
options: { suspense: false },
programAuthority: data.programData?.authority ? new PublicKey(data.programData.authority) : null,
Expand Down
3 changes: 0 additions & 3 deletions app/entities/cluster/@x/program-metadata/index.ts

This file was deleted.

9 changes: 3 additions & 6 deletions app/entities/idl/@x/program-metadata/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
// Cross-entity public API (FSD `@x` notation): the slice of the `idl` entity that the
// `program-metadata` entity is allowed to consume. The program-name label resolves the PMP IDL
// through the same idl-entity fetchers the IDL card uses, so both read one resolution.
export { fetchProgramIdls } from '../../api/fetch-program-idls';
export type { FetchedProgramIdls } from '../../api/fetch-program-idls';
export { resolveProgramIdlsClient } from '../../api/load-resolve-program-idls';
export type { ResolvedClientIdls, ResolveProgramIdlsClientArgs } from '../../api/load-resolve-program-idls';
// `program-metadata` entity is allowed to consume. The program-name label selects its PMP IDL from
// the shared `useProgramIdls` resolution the IDL card and tx decoder also use, so all read one result.
export { useProgramIdls } from '../../model/use-program-idls';
Loading
Loading