diff --git a/app/address/[address]/layout.tsx b/app/address/[address]/layout.tsx index 551850111..4ccb092ba 100644 --- a/app/address/[address]/layout.tsx +++ b/app/address/[address]/layout.tsx @@ -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'; @@ -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; } diff --git a/app/api/idl-latest/__tests__/route.spec.ts b/app/api/idl-latest/__tests__/route.spec.ts index cdd934df7..2efcffa75 100644 --- a/app/api/idl-latest/__tests__/route.spec.ts +++ b/app/api/idl-latest/__tests__/route.spec.ts @@ -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' }), ); @@ -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')); diff --git a/app/api/idl-latest/route.ts b/app/api/idl-latest/route.ts index e3b6c0f4d..33f215802 100644 --- a/app/api/idl-latest/route.ts +++ b/app/api/idl-latest/route.ts @@ -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[2], + attempts = 3, +): Promise>> { + 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 @@ -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), diff --git a/app/components/account/AnchorAccountCard.tsx b/app/components/account/AnchorAccountCard.tsx index 5b02896fd..4c8c9651d 100644 --- a/app/components/account/AnchorAccountCard.tsx +++ b/app/components/account/AnchorAccountCard.tsx @@ -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'; @@ -16,24 +17,41 @@ 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 }); } } @@ -41,10 +59,10 @@ export function AnchorAccountCard({ account }: { account: Account }) { accountDef, decodedAccountData, }; - }, [anchorProgram, rawData]); + }, [idl, rawData]); if (lamports === undefined) return null; - if (!anchorProgram) return ; + if (!idl) return ; if (!decodedAccountData || !accountDef) { return ; } @@ -66,7 +84,7 @@ export function AnchorAccountCard({ account }: { account: Account }) { - {mapAccountToRows(decodedAccountData, accountDef as IdlTypeDef, anchorProgram.idl)} + {mapAccountToRows(decodedAccountData, accountDef as IdlTypeDef, idl)} diff --git a/app/components/inspector/InstructionsSection.tsx b/app/components/inspector/InstructionsSection.tsx index 978177ec4..b4b5b46c3 100755 --- a/app/components/inspector/InstructionsSection.tsx +++ b/app/components/inspector/InstructionsSection.tsx @@ -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'; @@ -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'; @@ -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), @@ -164,6 +171,40 @@ function InspectorInstructionCard({ ); } + if (programId.toBase58() === PROGRAM_METADATA_PROGRAM_ID) { + return ( + } + > + + + ); + } + + // Parse instructions of programs that publish an IDL via the Program Metadata Program. + if (programMetadataIdl) { + return ( + } + > + + + ); + } + if (!parsedIx) { return ( diff --git a/app/components/instruction/AnchorDetailsCard.tsx b/app/components/instruction/AnchorDetailsCard.tsx index 0ded755c6..9ebb2b2b2 100644 --- a/app/components/instruction/AnchorDetailsCard.tsx +++ b/app/components/instruction/AnchorDetailsCard.tsx @@ -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 diff --git a/app/components/instruction/program-metadata-idl/ProgramMetadataIdlInstructionDetailsCard.tsx b/app/components/instruction/program-metadata-idl/ProgramMetadataIdlInstructionDetailsCard.tsx index 932d6c5a4..64e8a4c69 100644 --- a/app/components/instruction/program-metadata-idl/ProgramMetadataIdlInstructionDetailsCard.tsx +++ b/app/components/instruction/program-metadata-idl/ProgramMetadataIdlInstructionDetailsCard.tsx @@ -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'; @@ -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, @@ -56,5 +65,19 @@ export function ProgramMetadataIdlInstructionDetailsCard({ parsedCard = tryParse(withSingleInstructionDiscriminator(idl)); } - return parsedCard ?? ; + 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 } signature={signature ?? ''} />; + } catch (error) { + Logger.debug('[program-metadata-idl] Anchor fallback failed', { error }); + } + + return ; } diff --git a/app/components/instruction/program-metadata-idl/__tests__/ProgramMetadataIdlInstructionDetailsCard.test.tsx b/app/components/instruction/program-metadata-idl/__tests__/ProgramMetadataIdlInstructionDetailsCard.test.tsx new file mode 100644 index 000000000..2ba94a157 --- /dev/null +++ b/app/components/instruction/program-metadata-idl/__tests__/ProgramMetadataIdlInstructionDetailsCard.test.tsx @@ -0,0 +1,76 @@ +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { ProgramMetadataIdlInstructionDetailsCard } from '../ProgramMetadataIdlInstructionDetailsCard'; + +const parseInstruction = vi.fn(); +const rootNodeFromAnchor = vi.fn(); + +vi.mock('@codama/dynamic-parsers', () => ({ parseInstruction: (...a: unknown[]) => parseInstruction(...a) })); +vi.mock('@codama/nodes-from-anchor', () => ({ rootNodeFromAnchor: (...a: unknown[]) => rootNodeFromAnchor(...a) })); +vi.mock('@providers/cluster', () => ({ useCluster: () => ({ url: 'http://localhost' }) })); +vi.mock('@entities/idl', () => ({ + formatSerdeIdl: 'formatSerdeIdl', + getFormattedIdl: (_fmt: unknown, idl: unknown) => idl, + getProvider: () => ({}), +})); +vi.mock('@coral-xyz/anchor', () => ({ + // A Program built from the IDL — its existence is enough for the fallback path. + Program: class { + idl: unknown; + constructor(idl: unknown) { + this.idl = idl; + } + }, +})); +vi.mock('../../AnchorDetailsCard', () => ({ + // Surface the signature so the test can assert it's forwarded (needed for event decoding). + default: ({ signature }: { signature: string }) =>
anchor:{signature}
, +})); +vi.mock('../../codama/CodamaInstructionDetailsCard', () => ({ + CodamaInstructionCard: () =>
codama decoded
, +})); +vi.mock('../../UnknownDetailsCard', () => ({ + UnknownDetailsCard: () =>
unknown
, +})); + +const ix = new TransactionInstruction({ + data: Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]), + keys: [], + programId: PublicKey.unique(), +}); +const props = { index: 0, ix, result: { err: null } } as any; +const anchorIdl = { accounts: [], address: ix.programId.toBase58(), instructions: [{ name: 'foo' }] }; + +describe('ProgramMetadataIdlInstructionDetailsCard', () => { + beforeEach(() => { + parseInstruction.mockReset(); + rootNodeFromAnchor.mockReset(); + }); + + it('should render the Codama card when the Codama parser succeeds', () => { + parseInstruction.mockReturnValue({ accounts: [], path: [] }); + render(); + expect(screen.getByTestId('codama-card')).toBeInTheDocument(); + }); + + it('should fall back to the Anchor card when Codama cannot parse the IDL', () => { + // Mirror the real failure: parseInstruction throws and the anchor->codama conversion rejects + // the IDL (e.g. an unnamed instruction arg). + parseInstruction.mockImplementation(() => { + throw new Error('not a root node'); + }); + rootNodeFromAnchor.mockImplementation(() => { + throw new Error('Argument name [id] is missing from the instruction definition'); + }); + + render(); + + const card = screen.getByTestId('anchor-card'); + expect(card).toBeInTheDocument(); + // Signature is forwarded so the Anchor card can decode events from the tx logs. + expect(card).toHaveTextContent('SIG123'); + expect(screen.queryByTestId('unknown-card')).not.toBeInTheDocument(); + }); +}); diff --git a/app/components/instruction/program-metadata/ProgramMetadataDetailsCard.tsx b/app/components/instruction/program-metadata/ProgramMetadataDetailsCard.tsx new file mode 100644 index 000000000..660a1defb --- /dev/null +++ b/app/components/instruction/program-metadata/ProgramMetadataDetailsCard.tsx @@ -0,0 +1,145 @@ +import { Address } from '@components/common/Address'; +import { HexData } from '@components/common/HexData'; +import { SignatureResult, TransactionInstruction } from '@solana/web3.js'; +import { + getAllocateInstructionDataDecoder, + getCloseInstructionDataDecoder, + getExtendInstructionDataDecoder, + getInitializeInstructionDataDecoder, + getSetAuthorityInstructionDataDecoder, + getSetDataInstructionDataDecoder, + getSetImmutableInstructionDataDecoder, + getTrimInstructionDataDecoder, + getWriteInstructionDataDecoder, + identifyProgramMetadataInstruction, + PROGRAM_METADATA_PROGRAM_ADDRESS, + ProgramMetadataInstruction, +} from '@solana-program/program-metadata'; +import { camelToTitleCase } from '@utils/index'; +import React from 'react'; + +import { Badge } from '@/app/components/shared/ui/badge'; +import { BaseTable } from '@/app/shared/ui/Table'; + +import { InstructionCard } from '../InstructionCard'; +import { UnknownDetailsCard } from '../UnknownDetailsCard'; + +export const PROGRAM_METADATA_PROGRAM_ID = PROGRAM_METADATA_PROGRAM_ADDRESS as string; + +// Deterministic decoders for the Program Metadata Program's own instructions (no IDL fetch needed). +const DATA_DECODERS: Partial { decode: (d: Uint8Array) => object }>> = { + [ProgramMetadataInstruction.Allocate]: getAllocateInstructionDataDecoder, + [ProgramMetadataInstruction.Close]: getCloseInstructionDataDecoder, + [ProgramMetadataInstruction.Extend]: getExtendInstructionDataDecoder, + [ProgramMetadataInstruction.Initialize]: getInitializeInstructionDataDecoder, + [ProgramMetadataInstruction.SetAuthority]: getSetAuthorityInstructionDataDecoder, + [ProgramMetadataInstruction.SetData]: getSetDataInstructionDataDecoder, + [ProgramMetadataInstruction.SetImmutable]: getSetImmutableInstructionDataDecoder, + [ProgramMetadataInstruction.Trim]: getTrimInstructionDataDecoder, + [ProgramMetadataInstruction.Write]: getWriteInstructionDataDecoder, +}; + +// Account ordering per instruction, taken from the @solana-program/program-metadata instruction +// builders. Used to label the otherwise-numbered account list; any extra accounts a transaction +// carries beyond these fall back to "Account #N". +const ACCOUNT_NAMES: Partial> = { + [ProgramMetadataInstruction.Allocate]: ['buffer', 'authority', 'program', 'programData', 'system'], + [ProgramMetadataInstruction.Close]: ['account', 'authority', 'program', 'programData', 'destination'], + [ProgramMetadataInstruction.Extend]: ['account', 'authority', 'program', 'programData'], + [ProgramMetadataInstruction.Initialize]: ['metadata', 'authority', 'program', 'programData', 'system'], + [ProgramMetadataInstruction.SetAuthority]: ['account', 'authority', 'program', 'programData', 'newAuthority'], + [ProgramMetadataInstruction.SetData]: ['metadata', 'authority', 'buffer', 'program', 'programData'], + [ProgramMetadataInstruction.SetImmutable]: ['metadata', 'authority', 'program', 'programData'], + [ProgramMetadataInstruction.Trim]: ['account', 'authority', 'program', 'programData', 'destination', 'rent'], + [ProgramMetadataInstruction.Write]: ['buffer', 'authority', 'sourceBuffer'], +}; + +type DetailsProps = { + ix: TransactionInstruction; + index: number; + result: SignatureResult; + innerCards?: JSX.Element[]; + childIndex?: number; + raw?: TransactionInstruction; + InstructionCardComponent?: React.FC[0]>; +}; + +// Render a Codama-decoded value: unwrap Option, stringify bigints/bytes, surface enum kinds. +function renderValue(value: unknown): React.ReactNode { + if (value === null || value === undefined) return None; + if (typeof value === 'object' && '__option' in (value as object)) { + const opt = value as { __option: string; value?: unknown }; + return opt.__option === 'Some' ? renderValue(opt.value) : None; + } + if (typeof value === 'bigint') return value.toString(); + if (value instanceof Uint8Array) return Buffer.from(value).toString('base64'); + if (typeof value === 'object' && '__kind' in (value as object)) return String((value as { __kind: string }).__kind); + if (typeof value === 'object') { + return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)); + } + return String(value); +} + +export function ProgramMetadataDetailsCard(props: DetailsProps) { + const { ix, InstructionCardComponent = InstructionCard } = props; + + try { + const instructionType = identifyProgramMetadataInstruction(ix.data); + const name = ProgramMetadataInstruction[instructionType]; + const decoder = DATA_DECODERS[instructionType]; + const decoded = decoder ? (decoder().decode(ix.data) as Record) : {}; + const accountNames = ACCOUNT_NAMES[instructionType] ?? []; + + // `discriminator` is the instruction selector, not a meaningful arg — hide it. + const fields = Object.entries(decoded).filter(([key]) => key !== 'discriminator'); + + return ( + + + Program + +
+ + + {fields.map(([key, value]) => ( + + {camelToTitleCase(key)} + {renderValue(value)} + + ))} + {ix.keys.map(({ pubkey, isWritable, isSigner }, i) => ( + + + + {accountNames[i] ? camelToTitleCase(accountNames[i]) : `Account #${i + 1}`} + + {isWritable && ( + + Writable + + )} + {isSigner && ( + + Signer + + )} + + +
+ + + ))} + + + Instruction Data (Hex) + + + + + + + ); + } catch { + return ; + } +} diff --git a/app/components/instruction/program-metadata/__tests__/ProgramMetadataDetailsCard.test.tsx b/app/components/instruction/program-metadata/__tests__/ProgramMetadataDetailsCard.test.tsx new file mode 100644 index 000000000..6e2626087 --- /dev/null +++ b/app/components/instruction/program-metadata/__tests__/ProgramMetadataDetailsCard.test.tsx @@ -0,0 +1,65 @@ +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + getAllocateInstructionDataEncoder, + getSetAuthorityInstructionDataEncoder, +} from '@solana-program/program-metadata'; +import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { PROGRAM_METADATA_PROGRAM_ID, ProgramMetadataDetailsCard } from '../ProgramMetadataDetailsCard'; + +vi.mock('../../InstructionCard', () => ({ + InstructionCard: ({ children, title }: { children: React.ReactNode; title: string }) => ( +
+
{title}
+ + {children} +
+
+ ), +})); + +vi.mock('@components/common/Address', () => ({ + Address: ({ pubkey }: { pubkey: PublicKey }) =>
{pubkey.toBase58()}
, +})); + +vi.mock('@components/common/HexData', () => ({ HexData: () => })); + +const PROGRAM_ID = new PublicKey(PROGRAM_METADATA_PROGRAM_ID); +const defaultProps = { index: 0, result: { err: null } } as any; + +describe('ProgramMetadataDetailsCard', () => { + it('should decode an Allocate instruction and render its seed and labeled accounts', () => { + const data = Buffer.from(getAllocateInstructionDataEncoder().encode({ seed: 'idl' })); + const keys = Array.from({ length: 5 }, () => ({ + isSigner: false, + isWritable: true, + pubkey: PublicKey.unique(), + })); + const ix = new TransactionInstruction({ data, keys, programId: PROGRAM_ID }); + + render(); + + expect(screen.getByText('Program Metadata Program: Allocate')).toBeInTheDocument(); + const seedRow = screen.getByTestId('ix-args-seed'); + expect(seedRow).toHaveTextContent('Seed'); + expect(seedRow).toHaveTextContent('idl'); + + // Accounts are labeled by role, not just numbered. + expect(screen.getByTestId('ix-account-0')).toHaveTextContent('Buffer'); + expect(screen.getByTestId('ix-account-1')).toHaveTextContent('Authority'); + expect(screen.getByTestId('ix-account-3')).toHaveTextContent('Program Data'); + expect(screen.getByTestId('ix-account-4')).toHaveTextContent('System'); + }); + + it('should decode a SetAuthority instruction', () => { + const data = Buffer.from( + getSetAuthorityInstructionDataEncoder().encode({ newAuthority: PublicKey.default.toBase58() as any }), + ); + const ix = new TransactionInstruction({ data, keys: [], programId: PROGRAM_ID }); + + render(); + + expect(screen.getByText('Program Metadata Program: Set Authority')).toBeInTheDocument(); + }); +}); diff --git a/app/features/transaction/ui/InstructionsSection.tsx b/app/features/transaction/ui/InstructionsSection.tsx index 6c3f5bcf5..8e5e4dece 100644 --- a/app/features/transaction/ui/InstructionsSection.tsx +++ b/app/features/transaction/ui/InstructionsSection.tsx @@ -13,6 +13,10 @@ import { isLighthouseInstruction } from '@components/instruction/lighthouse/type import { isMangoInstruction } from '@components/instruction/mango/types'; import { MangoDetailsCard } from '@components/instruction/MangoDetails'; import { MemoDetailsCard } from '@components/instruction/MemoDetailsCard'; +import { + PROGRAM_METADATA_PROGRAM_ID, + ProgramMetadataDetailsCard, +} from '@components/instruction/program-metadata/ProgramMetadataDetailsCard'; import { ProgramMetadataIdlInstructionDetailsCard } from '@components/instruction/program-metadata-idl/ProgramMetadataIdlInstructionDetailsCard'; import { PythDetailsCard } from '@components/instruction/pyth/PythDetailsCard'; import { isPythInstruction } from '@components/instruction/pyth/types'; @@ -293,6 +297,13 @@ function InstructionCard({ ); } + if (transactionIx.programId.toBase58() === PROGRAM_METADATA_PROGRAM_ID) { + return ( + } key={key}> + + + ); + } if (programMetadataIdl) { return ; } diff --git a/app/utils/program-logs.ts b/app/utils/program-logs.ts index 25ea091e7..0732803c1 100644 --- a/app/utils/program-logs.ts +++ b/app/utils/program-logs.ts @@ -175,29 +175,54 @@ export function parseProgramLogs(logs: string[], error: TransactionError | null, return prettyLogs; } +// A `Program log:` payload that looks like a base64-encoded event (≥ the 8-byte discriminator, +// padded to a multiple of 4, base64 charset). Plain text logs ("Instruction: Foo", "Price = 1") +// contain spaces/punctuation and fail this; any false positive is rejected by the event decoder. +function isLikelyBase64Event(payload: string): boolean { + // eslint-disable-next-line no-restricted-syntax -- base64 shape check + return payload.length >= 12 && payload.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(payload); +} + /** * Extracts event data from transaction logs for a specific instruction. * Returns an array of base64-encoded event data strings. + * + * Handles both event-emission styles: `Program data:` (Anchor `sol_log_data`) and base64 logged via + * `Program log:` (e.g. Drift). When `programIds` (the ordered top-level instruction program ids) is + * provided, invocations are matched to their instruction by program id so instructions that emit no + * `invoke` log — the ed25519/secp256k1 precompiles — don't shift the index. Without it, falls back to + * counting top-level invocations. */ -export function extractEventsFromLogs(logs: string[], instructionIndex: number): string[] { +export function extractEventsFromLogs(logs: string[], instructionIndex: number, programIds?: string[]): string[] { const events: string[] = []; let currentIxIndex = -1; let depth = 0; for (const log of logs) { - // Track program invocations to match instruction indices // eslint-disable-next-line no-restricted-syntax -- match program invoke pattern - if (log.match(/Program \w* invoke \[(\d)\]/)) { + const invoke = log.match(/^Program (\w+) invoke \[\d+\]/); + if (invoke) { if (depth === 0) { - currentIxIndex++; + if (programIds) { + // Advance to the next top-level instruction using this program, skipping any + // non-logging instructions (precompiles) between the previous one and this. + let i = currentIxIndex + 1; + while (i < programIds.length && programIds[i] !== invoke[1]) i++; + currentIxIndex = i < programIds.length ? i : currentIxIndex + 1; + } else { + currentIxIndex++; + } } depth++; } else if (log.includes('success') || log.includes('failed')) { depth--; - } else if (log.startsWith('Program data:') && currentIxIndex === instructionIndex) { - // Extract base64-encoded event data for the current instruction - const eventData = log.slice('Program data: '.length).trim(); - events.push(eventData); + } else if (currentIxIndex === instructionIndex) { + if (log.startsWith('Program data:')) { + events.push(log.slice('Program data: '.length).trim()); + } else if (log.startsWith('Program log:')) { + const payload = log.slice('Program log: '.length).trim(); + if (isLikelyBase64Event(payload)) events.push(payload); + } } }