Skip to content
7 changes: 6 additions & 1 deletion app/address/[address]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import useSWRImmutable from 'swr/immutable';
import { CompressedNftCard } from '@/app/components/account/CompressedNftCard';
import { SolanaAttestationServiceCard } from '@/app/components/account/sas/SolanaAttestationCard';
import { getFeatureInfo, useFeatureInfo } from '@/app/entities/feature-gate';
import { useProgramMetadataIdl } from '@/app/entities/program-metadata';
import { hasTokenMetadata } from '@/app/features/metadata';
import { useCompressedNft } from '@/app/providers/compressed-nft';
import { useSquadsMultisigLookup } from '@/app/providers/squadsMultisig';
Expand Down Expand Up @@ -479,8 +480,12 @@ function ProgramMultisigTab({ authority }: { authority: PublicKey | null | undef
function AccountDataTab({ programId }: { programId: PublicKey }) {
const { url, cluster } = useCluster();
const { program: accountAnchorProgram } = useAnchorProgram(programId.toString(), url, cluster);
const { programMetadataIdl } = useProgramMetadataIdl(programId.toString(), url, cluster);

if (!accountAnchorProgram) {
// Show the tab when the program exposes an Anchor-format interface via either the legacy
// Anchor IDL or an Anchor-format IDL published through the Program Metadata Program.
const hasPmpAccounts = Array.isArray((programMetadataIdl as { accounts?: unknown[] } | undefined)?.accounts);
if (!accountAnchorProgram && !hasPmpAccounts) {
return null;
}

Expand Down
20 changes: 18 additions & 2 deletions app/api/idl-latest/__tests__/route.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ describe('GET /api/idl-latest', () => {
);
});

it('should return a retryable 502 (no page) when the resolver throws a transient RPC error', async () => {
mocks.resolveProgramIdls.mockRejectedValueOnce(
it('should return a retryable 502 (no page) when the resolver keeps throwing a transient RPC error', async () => {
mocks.resolveProgramIdls.mockRejectedValue(
new SolanaError(SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR, { __serverMessage: 'Internal error' }),
);

Expand All @@ -133,10 +133,26 @@ describe('GET /api/idl-latest', () => {

expect(res.status).toBe(502);
expect(await res.json()).toEqual({ error: 'Upstream RPC error' });
// Transient errors are retried before giving up.
expect(mocks.resolveProgramIdls).toHaveBeenCalledTimes(3);
expect(Logger.warn).toHaveBeenCalled();
expect(Logger.panic).not.toHaveBeenCalled();
});

it('should retry past a premature-close fetch error and succeed', async () => {
mocks.resolveProgramIdls.mockRejectedValueOnce(new Error('Invalid response body ...: Premature close'));
mocks.resolveProgramIdls.mockResolvedValueOnce(
resolved({ anchorIdl: { name: 'a' }, preferredVariant: IdlVariant.Anchor }),
);

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

expect(res.status).toBe(200);
expect(mocks.resolveProgramIdls).toHaveBeenCalledTimes(2);
expect(Logger.panic).not.toHaveBeenCalled();
});

it('should return 502 and escalate on an unexpected (non-RPC) error', async () => {
mocks.resolveProgramIdls.mockRejectedValueOnce(new Error('boom'));

Expand Down
46 changes: 40 additions & 6 deletions app/api/idl-latest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,42 @@ const CACHE_HEADERS = {
'Cache-Control': `public, max-age=${CACHE_DURATION}, s-maxage=${CACHE_DURATION}, stale-while-revalidate=60`,
};

// Connection-level fetch failures (undici "Premature close" / aborted body) that aren't classified as
// RPC errors but are still transient — large IDL account fetches intermittently hit these. Worth a retry.
function isRetryableFetchError(error: unknown): boolean {
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
return (
message.includes('premature close') ||
message.includes('terminated') ||
message.includes('econnreset') ||
message.includes('fetch failed') ||
message.includes('other side closed')
);
}

// Resolve IDLs with a few retries. The RPC itself is reliable, but resolving a large IDL through the
// server runtime occasionally premature-closes the response body; a fresh client per attempt clears it.
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);
} catch (error) {
lastError = error;
if (attempt < attempts - 1 && (isTransientRpcError(error) || isRetryableFetchError(error))) {
continue;
}
throw error;
}
}
throw lastError;
}

/**
* 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
Expand Down Expand Up @@ -50,18 +86,16 @@ export async function GET(request: Request) {
const context = { cluster: clusterProp, programAddress };

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

const idls = { anchor: anchorIdl, preferred: preferredVariant, programMetadata: programMetadataIdl };
return NextResponse.json({ idls }, { headers: CACHE_HEADERS, status: 200 });
} catch (error) {
// `resolveProgramIdls` surfaces absent/undecodable as values and throws only on RPC failure.
// Transient blips → retryable 502 (uncached) without paging; misconfiguration → Sentry page.
if (isTransientRpcError(error)) {
if (isTransientRpcError(error) || isRetryableFetchError(error)) {
Logger.warn('[api:idl-latest] RPC error resolving program IDLs', {
...context,
rpcError: error instanceof Error ? error.message : String(error),
Expand Down
50 changes: 34 additions & 16 deletions app/components/account/AnchorAccountCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ErrorCard } from '@components/common/ErrorCard';
import { BorshAccountsCoder } from '@coral-xyz/anchor';
import { BorshAccountsCoder, Idl } from '@coral-xyz/anchor';
import { IdlTypeDef } from '@coral-xyz/anchor/dist/cjs/idl';
import { useAnchorProgram } from '@entities/idl';
import { useProgramMetadataIdl } from '@entities/program-metadata';
import { Account } from '@providers/accounts';
import { useCluster } from '@providers/cluster';
import { getAnchorProgramName, mapAccountToRows } from '@utils/anchor';
Expand All @@ -16,35 +17,52 @@ export function AnchorAccountCard({ account }: { account: Account }) {
const { lamports } = account;
const { url, cluster } = useCluster();
const { program: anchorProgram } = useAnchorProgram(account.owner.toString(), url, cluster);
const { programMetadataIdl } = useProgramMetadataIdl(account.owner.toString(), url, cluster);
const rawData = account.data.raw;
const programName = getAnchorProgramName(anchorProgram) || 'Unknown Program';

// Prefer the legacy Anchor IDL; fall back to an Anchor-format IDL published via the Program
// Metadata Program so PMP-only programs (no on-chain Anchor IDL account) decode account data too.
const idl: Idl | undefined = useMemo(() => {
if (anchorProgram?.idl) return anchorProgram.idl;
const pmp = programMetadataIdl as Idl | undefined;
return pmp && Array.isArray(pmp.accounts) ? pmp : undefined;
}, [anchorProgram, programMetadataIdl]);

const programName =
getAnchorProgramName(anchorProgram) ||
(idl?.metadata as { name?: string } | undefined)?.name ||
'Unknown Program';

const { decodedAccountData, accountDef } = useMemo(() => {
let decodedAccountData: any | null = null;
let accountDef: IdlTypeDef | undefined = undefined;
if (anchorProgram && rawData) {
const coder = new BorshAccountsCoder(anchorProgram.idl);
const account = anchorProgram.idl.accounts?.find((accountType: any) =>
equals(rawData.slice(0, 8), coder.accountDiscriminator(accountType.name)),
);
if (account) {
accountDef = anchorProgram.idl.types?.find((type: any) => type.name === account.name);
try {
decodedAccountData = coder.decode(account.name, toBuffer(rawData));
} catch (err) {
Logger.debug('[components:anchor-account] Failed to decode account data', { error: err });
if (idl && rawData) {
try {
const coder = new BorshAccountsCoder(idl);
const account = idl.accounts?.find((accountType: any) =>
equals(rawData.slice(0, 8), coder.accountDiscriminator(accountType.name)),
);
if (account) {
accountDef = idl.types?.find((type: any) => type.name === account.name);
try {
decodedAccountData = coder.decode(account.name, toBuffer(rawData));
} catch (err) {
Logger.debug('[components:anchor-account] Failed to decode account data', { error: err });
}
}
} catch (err) {
Logger.debug('[components:anchor-account] Failed to build accounts coder', { error: err });
}
}

return {
accountDef,
decodedAccountData,
};
}, [anchorProgram, rawData]);
}, [idl, rawData]);

if (lamports === undefined) return null;
if (!anchorProgram) return <ErrorCard text="No Anchor IDL found" />;
if (!idl) return <ErrorCard text="No Anchor IDL found" />;
if (!decodedAccountData || !accountDef) {
return <ErrorCard text="Failed to decode account data according to the public Anchor interface" />;
}
Expand All @@ -66,7 +84,7 @@ export function AnchorAccountCard({ account }: { account: Account }) {
</BaseTable.Row>
</BaseTable.Head>
<BaseTable.Body>
{mapAccountToRows(decodedAccountData, accountDef as IdlTypeDef, anchorProgram.idl)}
{mapAccountToRows(decodedAccountData, accountDef as IdlTypeDef, idl)}
</BaseTable.Body>
</BaseTable>
</Card>
Expand Down
41 changes: 41 additions & 0 deletions app/components/inspector/InstructionsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getProgramName } from '@utils/tx';
import React, { useMemo } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

import { useProgramMetadataIdl } from '@/app/entities/program-metadata';
import { isTokenBatchInstruction, resolveInnerBatchInstructions, TokenBatchCard } from '@/app/features/token-batch';
import { useAddressLookupTables } from '@/app/providers/accounts';
import { FetchStatus } from '@/app/providers/cache';
Expand All @@ -24,6 +25,11 @@ import { InspectorInstructionCard as InspectorInstructionCardComponent } from '.
import { LoadingCard } from '../common/LoadingCard';
import AnchorDetailsCard from '../instruction/AnchorDetailsCard';
import { ComputeBudgetDetailsCard } from '../instruction/ComputeBudgetDetailsCard';
import {
PROGRAM_METADATA_PROGRAM_ID,
ProgramMetadataDetailsCard,
} from '../instruction/program-metadata/ProgramMetadataDetailsCard';
import { ProgramMetadataIdlInstructionDetailsCard } from '../instruction/program-metadata-idl/ProgramMetadataIdlInstructionDetailsCard';
import { SystemDetailsCard } from '../instruction/system/SystemDetailsCard';
import { TokenDetailsCard } from '../instruction/token/TokenDetailsCard';
import { AssociatedTokenDetailsCard } from './associated-token/AssociatedTokenDetailsCard';
Expand Down Expand Up @@ -115,6 +121,7 @@ function InspectorInstructionCard({
const programId = ix.programId;
const programName = getProgramName(programId.toBase58(), cluster);
const anchorProgram = useAnchorProgram(programId.toString(), url, cluster);
const { programMetadataIdl } = useProgramMetadataIdl(programId.toString(), url, cluster);
const parsedIx = useMemo(() => dispatcher.fromTransactionInstruction(ix), [dispatcher, ix]);
const parsedTx = useMemo(
() => (isParsedInstruction(parsedIx) ? toParsedTransaction(ix, message, [parsedIx]) : undefined),
Expand Down Expand Up @@ -164,6 +171,40 @@ function InspectorInstructionCard({
);
}

if (programId.toBase58() === PROGRAM_METADATA_PROGRAM_ID) {
return (
<ErrorBoundary
fallback={<UnknownDetailsCard key={index} index={index} ix={ix} programName={programName} />}
>
<ProgramMetadataDetailsCard
key={index}
ix={ix}
index={index}
result={INSPECTOR_RESULT}
InstructionCardComponent={BaseInstructionCard}
raw={ix}
/>
</ErrorBoundary>
);
}

// Parse instructions of programs that publish an IDL via the Program Metadata Program.
if (programMetadataIdl) {
return (
<ErrorBoundary
fallback={<UnknownDetailsCard key={index} index={index} ix={ix} programName={programName} />}
>
<ProgramMetadataIdlInstructionDetailsCard
key={index}
ix={ix}
index={index}
result={INSPECTOR_RESULT}
idl={programMetadataIdl}
/>
</ErrorBoundary>
);
}

if (!parsedIx) {
return (
<UnknownDetailsCard key={index} index={index} ix={ix} programName={programName} innerCards={innerCards} />
Expand Down
6 changes: 5 additions & 1 deletion app/components/instruction/AnchorDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@ export default function AnchorDetailsCard(props: {
const logMessages = transactionWithMeta.meta?.logMessages;
if (!logMessages) return undefined;

// Ordered top-level program ids let extractEventsFromLogs map invocations to instruction
// indices even when non-logging precompiles (ed25519/secp256k1) sit between them.
const programIds = transactionWithMeta.transaction.message.instructions.map(i => i.programId.toBase58());

// Extract event data for this specific instruction
const eventDataList = extractEventsFromLogs(logMessages, index);
const eventDataList = extractEventsFromLogs(logMessages, index, programIds);
if (eventDataList.length === 0) return undefined;

// Create event cards
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { parseInstruction } from '@codama/dynamic-parsers';
import { rootNodeFromAnchor } from '@codama/nodes-from-anchor';
import { Idl, Program } from '@coral-xyz/anchor';
import { formatSerdeIdl, getFormattedIdl, getProvider } from '@entities/idl';
import { useCluster } from '@providers/cluster';
import { SignatureResult, TransactionInstruction } from '@solana/web3.js';
import { type RootNode } from 'codama';

import { Logger } from '@/app/shared/lib/logger';
import { toKitInstruction } from '@/app/shared/lib/web3js-compat';

import AnchorDetailsCard from '../AnchorDetailsCard';
import { CodamaInstructionCard } from '../codama/CodamaInstructionDetailsCard';
import { UnknownDetailsCard } from '../UnknownDetailsCard';
import { withSingleInstructionDiscriminator } from './withSingleInstructionDiscriminator';
Expand All @@ -15,13 +20,17 @@ export function ProgramMetadataIdlInstructionDetailsCard({
index,
innerCards,
idl,
signature,
}: {
ix: TransactionInstruction;
result: SignatureResult;
index: number;
innerCards?: JSX.Element[];
idl: any;
// Present on the tx page; lets the Anchor fallback decode events from the transaction logs.
signature?: string;
}) {
const { url } = useCluster();
const props = {
index,
innerCards,
Expand Down Expand Up @@ -56,5 +65,19 @@ export function ProgramMetadataIdlInstructionDetailsCard({
parsedCard = tryParse(withSingleInstructionDiscriminator(idl));
}

return parsedCard ?? <UnknownDetailsCard {...props} />;
if (parsedCard) {
return parsedCard;
}

// Codama couldn't parse it — most commonly an Anchor-format IDL that `@codama/nodes-from-anchor`
// rejects (e.g. an instruction with an unnamed arg). Anchor's own BorshInstructionCoder is more
// lenient, so build a Program from the IDL and reuse the Anchor card before giving up.
try {
const program = new Program(getFormattedIdl(formatSerdeIdl, idl, ix.programId.toBase58()), getProvider(url));
return <AnchorDetailsCard {...props} anchorProgram={program as Program<Idl>} signature={signature ?? ''} />;
} catch (error) {
Logger.debug('[program-metadata-idl] Anchor fallback failed', { error });
}

return <UnknownDetailsCard {...props} />;
}
Loading
Loading