diff --git a/app/components/inspector/InstructionsSection.tsx b/app/components/inspector/InstructionsSection.tsx index 8caa6636c6..06d633f090 100755 --- a/app/components/inspector/InstructionsSection.tsx +++ b/app/components/inspector/InstructionsSection.tsx @@ -4,6 +4,7 @@ import { type InstructionSurface, InstructionSurfaceProvider } from '@entities/i import { isParsedInstruction, toParsedTransaction, useInstructionParser } from '@entities/instruction-parser'; import { ADDRESS_LOOKUP_TABLE_PROGRAM_LABEL, + BPF_LOADER_PROGRAM_LABEL, BPF_UPGRADEABLE_LOADER_PROGRAM_LABEL, SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_LABEL, SPL_MEMO_PROGRAM_LABEL, @@ -13,11 +14,13 @@ import { } from '@explorer/parsers'; import { AddressLookupTableDetailsCard } from '@features/decode-instruction-address-lookup-table'; import { AssociatedTokenDetailsCard } from '@features/decode-instruction-associated-token'; +import { BpfLoaderDetailsCard } from '@features/decode-instruction-bpf-loader'; import { ComputeBudgetDetailsCard } from '@features/decode-instruction-compute-budget'; import { Ed25519DetailsCard } from '@features/decode-instruction-ed25519'; import { LighthouseDetailsCard } from '@features/decode-instruction-lighthouse'; import { MemoDetailsCard } from '@features/decode-instruction-memo'; import { isProgramMetadataInstruction } from '@features/decode-instruction-pmp/detection'; +import { SolanaAttestationDetailsCard } from '@features/decode-instruction-sas'; import { IdlInstructionCard, useIdlInstructionDecode } from '@features/decode-instruction-with-idl'; import { ZkElGamalProofDetailsCard } from '@features/decode-instruction-zk-elgamal-proof'; import { PythDetailsCard } from '@features/instruction-program-pyth'; @@ -297,6 +300,18 @@ function InspectorInstructionCard({ /> ); } + if (parsedIx.programLabel === 'solana-attestation-service') { + return ( + + ); + } if (parsedIx.programLabel === 'pyth') { return ( @@ -383,6 +398,17 @@ function InspectorInstructionCard({ innerCards={innerCards} /> ); + case BPF_LOADER_PROGRAM_LABEL: + return ( + + ); case BPF_UPGRADEABLE_LOADER_PROGRAM_LABEL: return ( @@ -493,6 +519,19 @@ function InspectorInstructionCard({ /> ); + case 'solana-attestation-service': + return ( + + + + ); } return unknownCard; diff --git a/app/components/instruction/bpf-loader/BpfLoaderDetailsCard.tsx b/app/components/instruction/bpf-loader/BpfLoaderDetailsCard.tsx deleted file mode 100644 index fa0d6bbfe6..0000000000 --- a/app/components/instruction/bpf-loader/BpfLoaderDetailsCard.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { address, defineInstructionCard, type InstructionNode, preformatted, text } from '@entities/instruction-card'; -import { ParsedInstruction, ParsedTransaction, SignatureResult } from '@solana/web3.js'; -import { wrap } from '@utils/index'; -import { ParsedInfo } from '@validators/index'; -import React from 'react'; -import { create } from 'superstruct'; - -import { Logger } from '@/app/shared/lib/logger'; - -import { UnknownDetailsCard } from '../UnknownDetailsCard'; -import { FinalizeInfo, WriteInfo } from './types'; - -type DetailsProps = { - tx: ParsedTransaction; - ix: ParsedInstruction; - index: number; - result: SignatureResult; - innerCards?: JSX.Element[]; - childIndex?: number; -}; - -export function BpfLoaderDetailsCard(props: DetailsProps) { - const node: InstructionNode = { - childIndex: props.childIndex, - index: props.index, - innerCards: props.innerCards, - ix: props.ix, - programId: props.ix.programId, - }; - - try { - const parsed = create(props.ix.parsed, ParsedInfo); - - switch (parsed.type) { - case 'write': { - const info = create(parsed.info, WriteInfo); - return ; - } - case 'finalize': { - const info = create(parsed.info, FinalizeInfo); - return ; - } - default: - return ; - } - } catch (error) { - Logger.error(error, { - signature: props.tx.signatures[0], - }); - return ; - } -} - -export const BpfLoaderWriteDetailsCard = defineInstructionCard({ - fields: info => [ - address('Account', info.account), - preformatted('Bytes (Base 64)', wrap(info.bytes, 50)), - text('Offset', info.offset), - ], - title: 'BPF Loader 2: Write', -}); - -export const BpfLoaderFinalizeDetailsCard = defineInstructionCard({ - fields: info => [address('Account', info.account)], - title: 'BPF Loader 2: Finalize', -}); diff --git a/app/components/instruction/sas/SolanaAttestationDetailsCard.tsx b/app/components/instruction/sas/SolanaAttestationDetailsCard.tsx deleted file mode 100644 index 3ec4b23b11..0000000000 --- a/app/components/instruction/sas/SolanaAttestationDetailsCard.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { DecodedInstructionCard, parseCodamaArgs, useInstructionSurface } from '@entities/instruction-card'; -import { - identifySolanaAttestationServiceInstruction, - parseChangeAuthorizedSignersInstruction, - parseChangeSchemaDescriptionInstruction, - parseChangeSchemaStatusInstruction, - parseChangeSchemaVersionInstruction, - parseCloseAttestationInstruction, - parseCloseTokenizedAttestationInstruction, - parseCreateAttestationInstruction, - parseCreateCredentialInstruction, - parseCreateSchemaInstruction, - parseCreateTokenizedAttestationInstruction, - parseEmitEventInstruction, - parseTokenizeSchemaInstruction, - SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS as SAS_PROGRAM_ID, - SolanaAttestationServiceInstruction, -} from '@solana/attestation'; -import { AccountMeta, isSolanaError } from '@solana/kit'; -import { TransactionInstruction } from '@solana/web3.js'; - -import { toKitInstruction } from '@/app/shared/lib/web3js-compat'; - -import { UnknownDetailsCard } from '../UnknownDetailsCard'; - -export function isSolanaAttestationInstruction(transactionIx: TransactionInstruction) { - return transactionIx.programId.toBase58() === SAS_PROGRAM_ID; -} - -/** Every SAS instruction parses to accounts plus an argument struct; only the struct's shape differs. */ -type ParsedSasInstruction = { - accounts: Record; - data: Record; -}; - -export function SolanaAttestationDetailsCard({ - ix, - index, - innerCards, - childIndex, -}: { - ix: TransactionInstruction; - index: number; - innerCards?: JSX.Element[]; - childIndex?: number; -}) { - const { result } = useInstructionSurface(); - const decoded = tryParseSolanaAttestationInstruction(ix); - - if (!decoded) { - return ( - - ); - } - - return ( - - ); -} - -type DecodedSasInstruction = { name: string; parsed: ParsedSasInstruction }; - -function tryParseSolanaAttestationInstruction(ix: TransactionInstruction): DecodedSasInstruction | undefined { - try { - return parseSolanaAttestationInstruction(ix); - } catch (error) { - if (isSolanaError(error)) { - return undefined; - } - throw error; - } -} - -function parseSolanaAttestationInstruction(ix: TransactionInstruction): DecodedSasInstruction { - const kitIx = toKitInstruction(ix); - - switch (identifySolanaAttestationServiceInstruction(ix)) { - case SolanaAttestationServiceInstruction.CreateCredential: - return { name: 'Create Credential', parsed: parseCreateCredentialInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.CreateSchema: - return { name: 'Create Schema', parsed: parseCreateSchemaInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.ChangeSchemaStatus: - return { name: 'Change Schema Status', parsed: parseChangeSchemaStatusInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.ChangeAuthorizedSigners: - return { name: 'Change Authorized Signers', parsed: parseChangeAuthorizedSignersInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.ChangeSchemaDescription: - return { name: 'Change Schema Description', parsed: parseChangeSchemaDescriptionInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.ChangeSchemaVersion: - return { name: 'Change Schema Version', parsed: parseChangeSchemaVersionInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.CreateAttestation: - return { name: 'Create Attestation', parsed: parseCreateAttestationInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.CloseAttestation: - return { name: 'Close Attestation', parsed: parseCloseAttestationInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.EmitEvent: - return { name: 'Emit Event', parsed: parseEmitEventInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.TokenizeSchema: - return { name: 'Tokenize Schema', parsed: parseTokenizeSchemaInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.CreateTokenizedAttestation: - return { name: 'Create Tokenized Attestation', parsed: parseCreateTokenizedAttestationInstruction(kitIx) }; - case SolanaAttestationServiceInstruction.CloseTokenizedAttestation: - return { name: 'Close Tokenized Attestation', parsed: parseCloseTokenizedAttestationInstruction(kitIx) }; - } -} diff --git a/app/features/decode-instruction-bpf-loader/index.ts b/app/features/decode-instruction-bpf-loader/index.ts new file mode 100644 index 0000000000..caf7a26cb3 --- /dev/null +++ b/app/features/decode-instruction-bpf-loader/index.ts @@ -0,0 +1,9 @@ +export { bpfLoaderInstructionParser } from './lib/bpf-loader-client'; +export { + BPF_LOADER_PARSER_LABEL, + BPF_LOADER_PROGRAM_ADDRESS, + type BpfLoaderParsed, + parseBpfLoaderKitInstruction, + parseBpfLoaderRpcInstruction, +} from './lib/bpf-loader-parser'; +export { BpfLoaderDetailsCard } from './ui/BpfLoaderDetailsCard'; diff --git a/app/features/decode-instruction-bpf-loader/lib/__tests__/bpf-loader-parser.spec.ts b/app/features/decode-instruction-bpf-loader/lib/__tests__/bpf-loader-parser.spec.ts new file mode 100644 index 0000000000..da7faef48f --- /dev/null +++ b/app/features/decode-instruction-bpf-loader/lib/__tests__/bpf-loader-parser.spec.ts @@ -0,0 +1,145 @@ +import { createInstructionParserDispatcher, isParsedInstruction } from '@entities/instruction-parser'; +import { AccountRole, type Address, address } from '@solana/kit'; +import { type ParsedInstruction, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/app/shared/lib/logger', () => ({ Logger: { error: vi.fn() } })); + +import { Logger } from '@/app/shared/lib/logger'; + +import { bpfLoaderInstructionParser } from '../bpf-loader-client'; +import { + BPF_LOADER_PARSER_LABEL, + BPF_LOADER_PROGRAM_ADDRESS, + parseBpfLoaderKitInstruction, + parseBpfLoaderRpcInstruction, +} from '../bpf-loader-parser'; + +const ACCOUNT = address('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'); +const RENT = address('SysvarRent111111111111111111111111111111111'); + +const dispatcher = createInstructionParserDispatcher([bpfLoaderInstructionParser]); + +/** Bincode `LoaderInstruction::Write`: u32 variant, u32 offset, then a u64-length-prefixed byte vec. */ +function writeData(offset: number, bytes: number[]): Uint8Array { + const data = new Uint8Array(16 + bytes.length); + const view = new DataView(data.buffer); + view.setUint32(0, 0, true); + view.setUint32(4, offset, true); + view.setBigUint64(8, BigInt(bytes.length), true); + data.set(bytes, 16); + return data; +} + +const FINALIZE_DATA = new Uint8Array([1, 0, 0, 0]); + +function parse(data: Uint8Array, accounts: Address[]) { + return parseBpfLoaderKitInstruction({ + accounts: accounts.map(account => ({ address: account, role: AccountRole.WRITABLE_SIGNER })), + data, + programAddress: address(BPF_LOADER_PROGRAM_ADDRESS), + }); +} + +describe('parseBpfLoaderKitInstruction', () => { + it('should decode a write onto the RPC field names, with the bytes as base64', () => { + const parsed = parse(writeData(1024, [2, 0, 0, 0]), [ACCOUNT]); + + expect(parsed?.type).toBe('write'); + expect(parsed?.info).toEqual({ account: new PublicKey(ACCOUNT), bytes: 'AgAAAA==', offset: 1024 }); + }); + + it('should decode a finalize from its first account', () => { + const parsed = parse(FINALIZE_DATA, [ACCOUNT, RENT]); + + expect(parsed).toEqual({ info: { account: new PublicKey(ACCOUNT) }, type: 'finalize' }); + }); + + it('should return undefined for a discriminant the loader does not define', () => { + expect(parse(new Uint8Array([2, 0, 0, 0]), [ACCOUNT])).toBeUndefined(); + }); + + // A write whose length prefix runs past the data is truncated, not a shorter write. + it('should return undefined for a write shorter than its length prefix', () => { + expect(parse(writeData(0, [1, 2, 3]).slice(0, 17), [ACCOUNT])).toBeUndefined(); + }); + + it('should return undefined when the instruction carries no account', () => { + expect(parse(writeData(0, [1]), [])).toBeUndefined(); + }); + + it('should return undefined for a finalize without its rent sysvar', () => { + expect(parse(FINALIZE_DATA, [ACCOUNT])).toBeUndefined(); + }); +}); + +describe('parseBpfLoaderRpcInstruction', () => { + beforeEach(() => { + vi.mocked(Logger.error).mockClear(); + }); + + it('should coerce the account of an RPC write into a key', () => { + const parsed = parseBpfLoaderRpcInstruction( + rpcInstruction('write', { account: ACCOUNT, bytes: 'AgAAAA==', offset: 1024 }), + ); + + expect(parsed?.type).toBe('write'); + expect(parsed?.info.account).toEqual(new PublicKey(ACCOUNT)); + }); + + it('should return undefined for an instruction type it does not model', () => { + expect(parseBpfLoaderRpcInstruction(rpcInstruction('initializeBuffer', { account: ACCOUNT }))).toBeUndefined(); + expect(Logger.error).not.toHaveBeenCalled(); + }); + + it('should ignore another program', () => { + const ix = { ...rpcInstruction('write', {}), program: 'bpf-upgradeable-loader' }; + + expect(parseBpfLoaderRpcInstruction(ix)).toBeUndefined(); + }); + + // The label already matched, so a payload the schema rejects is RPC drift worth reporting. + it('should report a payload its schema rejects', () => { + expect(parseBpfLoaderRpcInstruction(rpcInstruction('write', { account: ACCOUNT }))).toBeUndefined(); + expect(Logger.error).toHaveBeenCalled(); + }); +}); + +describe('bpfLoaderInstructionParser', () => { + it('should decode the byte path through the dispatcher', () => { + const ix = new TransactionInstruction({ + data: Buffer.from(FINALIZE_DATA), + keys: [ + { isSigner: true, isWritable: true, pubkey: new PublicKey(ACCOUNT) }, + { isSigner: false, isWritable: false, pubkey: new PublicKey(RENT) }, + ], + programId: new PublicKey(BPF_LOADER_PROGRAM_ADDRESS), + }); + + const dispatched = dispatcher.fromTransactionInstruction(ix); + + expect(isParsedInstruction(dispatched)).toBe(true); + expect(dispatched).toMatchObject({ parsed: { type: 'finalize' }, program: BPF_LOADER_PARSER_LABEL }); + }); + + it('should report an undecodable byte instruction as unknown to this program', () => { + const ix = new TransactionInstruction({ + data: Buffer.from([9, 0, 0, 0]), + keys: [], + programId: new PublicKey(BPF_LOADER_PROGRAM_ADDRESS), + }); + + expect(dispatcher.fromTransactionInstruction(ix)).toMatchObject({ + programLabel: BPF_LOADER_PARSER_LABEL, + unknown: true, + }); + }); +}); + +function rpcInstruction(type: string, info: Record): ParsedInstruction { + return { + parsed: { info, type }, + program: BPF_LOADER_PARSER_LABEL, + programId: new PublicKey(BPF_LOADER_PROGRAM_ADDRESS), + }; +} diff --git a/app/features/decode-instruction-bpf-loader/lib/bpf-loader-client.ts b/app/features/decode-instruction-bpf-loader/lib/bpf-loader-client.ts new file mode 100644 index 0000000000..627e655ebe --- /dev/null +++ b/app/features/decode-instruction-bpf-loader/lib/bpf-loader-client.ts @@ -0,0 +1,16 @@ +import type { InstructionParser } from '@entities/instruction-parser'; + +import { + BPF_LOADER_PARSER_LABEL, + BPF_LOADER_PROGRAM_ADDRESS, + type BpfLoaderParsed, + parseBpfLoaderKitInstruction, + parseBpfLoaderRpcInstruction, +} from './bpf-loader-parser'; + +export const bpfLoaderInstructionParser: InstructionParser = { + fromParsed: parseBpfLoaderRpcInstruction, + fromTransaction: parseBpfLoaderKitInstruction, + programId: BPF_LOADER_PROGRAM_ADDRESS, + programLabel: BPF_LOADER_PARSER_LABEL, +}; diff --git a/app/features/decode-instruction-bpf-loader/lib/bpf-loader-parser.ts b/app/features/decode-instruction-bpf-loader/lib/bpf-loader-parser.ts new file mode 100644 index 0000000000..3502910641 --- /dev/null +++ b/app/features/decode-instruction-bpf-loader/lib/bpf-loader-parser.ts @@ -0,0 +1,96 @@ +import { + BPF_LOADER_PROGRAM_LABEL, + isParsedInstructionProgram, + type KitInstruction, + type ParsedInstructionInfo, + type ParserProgramLabel, +} from '@explorer/parsers'; +import { + addDecoderSizePrefix, + getBase64Decoder, + getBytesDecoder, + getStructDecoder, + getU32Decoder, + getU64Decoder, +} from '@solana/kit'; +import type { ParsedInstruction } from '@solana/web3.js'; +import { create } from 'superstruct'; + +import { Logger } from '@/app/shared/lib/logger'; + +import { FinalizeInfo, WriteInfo } from './types'; + +/** On-chain id of BPF Loader 2, the only loader the RPC pre-parses as `bpf-loader`. */ +export const BPF_LOADER_PROGRAM_ADDRESS = 'BPFLoader2111111111111111111111111111111111'; + +/** RPC `parsed.program` discriminator for the program; also the slice's `programLabel`. */ +export const BPF_LOADER_PARSER_LABEL = BPF_LOADER_PROGRAM_LABEL satisfies ParserProgramLabel; + +/** + * Canonical shape for a parsed BPF Loader 2 instruction. The type strings and `info` field + * names are the RPC's, so the byte path maps onto them and both paths feed one card. + */ +export type BpfLoaderParsed = + ParsedInstructionInfo<'write', WriteInfo> | ParsedInstructionInfo<'finalize', FinalizeInfo>; + +// Bincode u32 discriminants of `LoaderInstruction` (agave loader-v2). +const WRITE_DISCRIMINANT = 0; +const FINALIZE_DISCRIMINANT = 1; + +const discriminantDecoder = getU32Decoder(); +const writeDataDecoder = getStructDecoder([ + ['discriminant', getU32Decoder()], + ['offset', getU32Decoder()], + ['bytes', addDecoderSizePrefix(getBytesDecoder(), getU64Decoder())], +]); + +/** + * Decode a raw BPF Loader 2 instruction (inspector path). Account positions mirror agave's + * `parse_bpf_loader.rs`, so the inspector renders the same rows as the tx page. + */ +export function parseBpfLoaderKitInstruction(ix: KitInstruction): BpfLoaderParsed | undefined { + try { + const [account] = ix.accounts; + switch (discriminantDecoder.decode(ix.data)) { + case WRITE_DISCRIMINANT: { + const { bytes, offset } = writeDataDecoder.decode(ix.data); + return { + info: create( + { account: account.address, bytes: getBase64Decoder().decode(bytes), offset }, + WriteInfo, + ), + type: 'write', + }; + } + case FINALIZE_DISCRIMINANT: + // Agave rejects a finalize without its rent sysvar, even though only the first account is shown. + if (ix.accounts.length < 2) return undefined; + return { info: create({ account: account.address }, FinalizeInfo), type: 'finalize' }; + default: + return undefined; + } + } catch { + // Short data or a missing account; the dispatcher renders that as unknown. + return undefined; + } +} + +/** Normalise an RPC-pre-parsed BPF Loader 2 instruction into `BpfLoaderParsed`. */ +export function parseBpfLoaderRpcInstruction(ix: ParsedInstruction): BpfLoaderParsed | undefined { + if (!isParsedInstructionProgram(ix, BPF_LOADER_PARSER_LABEL)) return undefined; + try { + switch (ix.parsed.type) { + case 'write': + return { info: create(ix.parsed.info, WriteInfo), type: 'write' }; + case 'finalize': + return { info: create(ix.parsed.info, FinalizeInfo), type: 'finalize' }; + default: + return undefined; + } + } catch (error) { + // The program label already matched, so a validation failure here means the RPC sent a payload + // we don't model — worth surfacing. Returning undefined falls back to the unknown-instruction card. + Logger.error(error, { instructionType: ix.parsed.type, program: ix.program }); + return undefined; + } +} diff --git a/app/components/instruction/bpf-loader/types.ts b/app/features/decode-instruction-bpf-loader/lib/types.ts similarity index 61% rename from app/components/instruction/bpf-loader/types.ts rename to app/features/decode-instruction-bpf-loader/lib/types.ts index d5bbe1eb23..72ad44cab9 100644 --- a/app/components/instruction/bpf-loader/types.ts +++ b/app/features/decode-instruction-bpf-loader/lib/types.ts @@ -1,5 +1,5 @@ import { PublicKeyFromString } from '@validators/pubkey'; -import { enums, Infer, number, string, type } from 'superstruct'; +import { Infer, number, string, type } from 'superstruct'; export type WriteInfo = Infer; export const WriteInfo = type({ @@ -12,6 +12,3 @@ export type FinalizeInfo = Infer; export const FinalizeInfo = type({ account: PublicKeyFromString, }); - -export type BpfLoaderInstructionType = Infer; -export const BpfLoaderInstructionType = enums(['write', 'finalize']); diff --git a/app/features/decode-instruction-bpf-loader/ui/BpfLoaderDetailsCard.tsx b/app/features/decode-instruction-bpf-loader/ui/BpfLoaderDetailsCard.tsx new file mode 100644 index 0000000000..ef6aecaec0 --- /dev/null +++ b/app/features/decode-instruction-bpf-loader/ui/BpfLoaderDetailsCard.tsx @@ -0,0 +1,73 @@ +import { + address, + defineInstructionCard, + InstructionCardView, + type InstructionNode, + preformatted, + text, +} from '@entities/instruction-card'; +import type { ParsedInstruction, TransactionInstruction } from '@solana/web3.js'; +import { wrap } from '@utils/index'; +import React from 'react'; +import { is } from 'superstruct'; + +import type { BpfLoaderParsed } from '../lib/bpf-loader-parser'; +import { FinalizeInfo, WriteInfo } from '../lib/types'; + +const TITLE_PREFIX = 'BPF Loader 2'; + +export const BpfLoaderWriteDetailsCard = defineInstructionCard({ + fields: info => [ + address('Account', info.account), + preformatted('Bytes (Base 64)', wrap(info.bytes, 50)), + text('Offset', info.offset), + ], + title: `${TITLE_PREFIX}: Write`, +}); + +export const BpfLoaderFinalizeDetailsCard = defineInstructionCard({ + fields: info => [address('Account', info.account)], + title: `${TITLE_PREFIX}: Finalize`, +}); + +type BpfLoaderDetailsCardProps = { + /** Already normalised by the dispatcher — this card does not decode. */ + ix: ParsedInstruction; + /** Byte form, when the caller has one, so the shell's Raw view can show accounts and data. */ + raw?: TransactionInstruction; + index: number; + innerCards?: JSX.Element[]; + childIndex?: number; +}; + +export function BpfLoaderDetailsCard({ ix, raw, index, innerCards, childIndex }: BpfLoaderDetailsCardProps) { + const node: InstructionNode = { childIndex, index, innerCards, ix, programId: ix.programId, raw }; + + if (!isBpfLoaderParsed(ix.parsed)) { + return ; + } + + const parsed = ix.parsed; + switch (parsed.type) { + case 'write': + return ; + case 'finalize': + return ; + } +} + +/** + * The dispatcher hands the RPC's own view back when the slice rejects a payload, and that view + * carries the same type strings — only the slice's schemas tell a decoded envelope from a raw one. + */ +function isBpfLoaderParsed(parsed: unknown): parsed is BpfLoaderParsed { + if (typeof parsed !== 'object' || parsed === null || !('type' in parsed) || !('info' in parsed)) return false; + switch (parsed.type) { + case 'write': + return is(parsed.info, WriteInfo); + case 'finalize': + return is(parsed.info, FinalizeInfo); + default: + return false; + } +} diff --git a/app/components/instruction/bpf-loader/__tests__/BpfLoaderDetailsCard.spec.tsx b/app/features/decode-instruction-bpf-loader/ui/__tests__/BpfLoaderDetailsCard.spec.tsx similarity index 78% rename from app/components/instruction/bpf-loader/__tests__/BpfLoaderDetailsCard.spec.tsx rename to app/features/decode-instruction-bpf-loader/ui/__tests__/BpfLoaderDetailsCard.spec.tsx index c039844290..eeb4974154 100644 --- a/app/components/instruction/bpf-loader/__tests__/BpfLoaderDetailsCard.spec.tsx +++ b/app/features/decode-instruction-bpf-loader/ui/__tests__/BpfLoaderDetailsCard.spec.tsx @@ -1,5 +1,6 @@ import { TxInstructionSurface } from '@entities/instruction-card'; -import { BPF_LOADER_PROGRAM_ID, type ParsedInstruction, type ParsedTransaction, PublicKey } from '@solana/web3.js'; +import { createInstructionParserDispatcher } from '@entities/instruction-parser'; +import { BPF_LOADER_PROGRAM_ID, type ParsedInstruction, PublicKey } from '@solana/web3.js'; import { render, screen, waitFor, within } from '@testing-library/react'; import React from 'react'; import { vi } from 'vitest'; @@ -18,8 +19,11 @@ import { ScrollAnchorProvider } from '@/app/providers/scroll-anchor'; import { TransactionsProvider } from '@/app/providers/transactions'; import { Logger } from '@/app/shared/lib/logger'; +import { bpfLoaderInstructionParser } from '../../lib/bpf-loader-client'; import { BpfLoaderDetailsCard } from '../BpfLoaderDetailsCard'; +const dispatcher = createInstructionParserDispatcher([bpfLoaderInstructionParser]); + const ACCOUNT = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'; const PROGRAM = BPF_LOADER_PROGRAM_ID.toBase58(); @@ -31,7 +35,7 @@ describe('BpfLoaderDetailsCard', () => { }); it('should render the rows of a write', async () => { - renderCard(bpfLoaderInstruction('write', { account: ACCOUNT, bytes: BYTES, offset: 1024 })); + renderCard(dispatch('write', { account: ACCOUNT, bytes: BYTES, offset: 1024 })); await waitFor(() => { expect(readRows()).toEqual([ @@ -50,7 +54,7 @@ describe('BpfLoaderDetailsCard', () => { it('should break write bytes longer than a line', async () => { const bytes = 'A'.repeat(60); - renderCard(bpfLoaderInstruction('write', { account: ACCOUNT, bytes, offset: 0 })); + renderCard(dispatch('write', { account: ACCOUNT, bytes, offset: 0 })); await waitFor(() => { expect(readRows()[2]).toEqual(['Bytes (Base 64)', ['A'.repeat(50), 'A'.repeat(10)].join('\n')]); @@ -58,7 +62,7 @@ describe('BpfLoaderDetailsCard', () => { }); it('should render the rows of a finalize', async () => { - renderCard(bpfLoaderInstruction('finalize', { account: ACCOUNT })); + renderCard(dispatch('finalize', { account: ACCOUNT })); await waitFor(() => { expect(readRows()).toEqual([ @@ -71,7 +75,7 @@ describe('BpfLoaderDetailsCard', () => { }); it('should fall back for an instruction type it does not decode', async () => { - renderCard(bpfLoaderInstruction('initializeBuffer', { account: ACCOUNT })); + renderCard(dispatch('initializeBuffer', { account: ACCOUNT })); await waitFor(() => { expect(screen.getByText('BPF Loader 2: Unknown Instruction')).toBeInTheDocument(); @@ -80,7 +84,7 @@ describe('BpfLoaderDetailsCard', () => { // The schemas are what stop a renamed or missing field reaching the rows uncoerced. it('should fall back and report a payload its schema rejects', async () => { - renderCard(bpfLoaderInstruction('write', { account: ACCOUNT, offset: 'not a number' })); + renderCard(dispatch('write', { account: ACCOUNT, offset: 'not a number' })); await waitFor(() => { expect(screen.getByText('BPF Loader 2: Unknown Instruction')).toBeInTheDocument(); @@ -91,7 +95,7 @@ describe('BpfLoaderDetailsCard', () => { // A foreign program id proves the row reads the node rather than a BPF-loader constant. it('should render the program row from the node', async () => { const loaderV1 = new PublicKey('BPFLoader1111111111111111111111111111111111'); - const ix = bpfLoaderInstruction('finalize', { account: ACCOUNT }); + const ix = dispatch('finalize', { account: ACCOUNT }); renderCard({ ...ix, programId: loaderV1 }); @@ -101,23 +105,23 @@ describe('BpfLoaderDetailsCard', () => { }); }); -function bpfLoaderInstruction(type: string, info: Record): ParsedInstruction { - return { parsed: { info, type }, program: 'bpf-loader', programId: BPF_LOADER_PROGRAM_ID }; +/** An RPC-parsed instruction, run through the dispatcher the way the tx page does. */ +function dispatch(type: string, info: Record): ParsedInstruction { + return dispatcher.fromParsedInstruction({ + parsed: { info, type }, + program: 'bpf-loader', + programId: BPF_LOADER_PROGRAM_ID, + }); } function renderCard(ix: ParsedInstruction) { - const tx = { - message: { accountKeys: [], instructions: [ix] }, - signatures: ['sig'], - } as unknown as ParsedTransaction; - return render( - + diff --git a/app/features/decode-instruction-sas/index.ts b/app/features/decode-instruction-sas/index.ts new file mode 100644 index 0000000000..fc57d449e2 --- /dev/null +++ b/app/features/decode-instruction-sas/index.ts @@ -0,0 +1,9 @@ +export { isSolanaAttestationInstruction } from './lib/is-solana-attestation-instruction'; +export { solanaAttestationInstructionParser } from './lib/sas-client'; +export { + parseSolanaAttestationKitInstruction, + SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS, + SOLANA_ATTESTATION_SERVICE_PROGRAM_LABEL, + type SolanaAttestationParsed, +} from './lib/sas-parser'; +export { SolanaAttestationDetailsCard } from './ui/SolanaAttestationDetailsCard'; diff --git a/app/features/decode-instruction-sas/lib/__tests__/sas-parser.spec.ts b/app/features/decode-instruction-sas/lib/__tests__/sas-parser.spec.ts new file mode 100644 index 0000000000..341cbf4ba8 --- /dev/null +++ b/app/features/decode-instruction-sas/lib/__tests__/sas-parser.spec.ts @@ -0,0 +1,82 @@ +import { createInstructionParserDispatcher, isParsedInstruction } from '@entities/instruction-parser'; +import { AccountRole, type Address, address } from '@solana/kit'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { describe, expect, it } from 'vitest'; + +import { solanaAttestationInstructionParser } from '../sas-client'; +import { + parseSolanaAttestationKitInstruction, + SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS, + SOLANA_ATTESTATION_SERVICE_PROGRAM_LABEL, +} from '../sas-parser'; + +const PAYER = address('9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'); +const CREDENTIAL = address('7Np41oeYqPefeNQEHSv1UDhYrehxin3NStELsSKCT4K2'); +const AUTHORITY = address('3EbFtRfKRMTrhPrRQjxbfWCB6NUyTQxwsWTKQFVKgNbb'); +const SYSTEM = address('11111111111111111111111111111111'); + +/** CreateCredential: u8 discriminator 0, then a u32-prefixed name and a u32-prefixed signer list, both empty. */ +const CREATE_CREDENTIAL = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0]); + +const dispatcher = createInstructionParserDispatcher([solanaAttestationInstructionParser]); + +function parse(data: Uint8Array, accounts: Address[]) { + return parseSolanaAttestationKitInstruction({ + accounts: accounts.map(account => ({ address: account, role: AccountRole.READONLY })), + data, + programAddress: SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS, + }); +} + +describe('parseSolanaAttestationKitInstruction', () => { + // The card titles itself from the type, so the name has to survive the enum round trip. + it('should name the instruction in camelCase', () => { + expect(parse(CREATE_CREDENTIAL, [PAYER, CREDENTIAL, AUTHORITY, SYSTEM])?.type).toBe('createCredential'); + }); + + it('should carry the named accounts and decoded arguments', () => { + const parsed = parse(CREATE_CREDENTIAL, [PAYER, CREDENTIAL, AUTHORITY, SYSTEM]); + + expect(Object.fromEntries(Object.entries(parsed?.info.accounts ?? {}).map(([k, v]) => [k, v.address]))).toEqual( + { authority: AUTHORITY, credential: CREDENTIAL, payer: PAYER, systemProgram: SYSTEM }, + ); + expect(parsed?.info.data).toEqual({ discriminator: 0, name: '', signers: [] }); + }); + + it('should return undefined for a discriminator the program does not define', () => { + expect(parse(new Uint8Array([200]), [PAYER])).toBeUndefined(); + }); + + it('should return undefined when the instruction is short of its accounts', () => { + expect(parse(CREATE_CREDENTIAL, [PAYER])).toBeUndefined(); + }); +}); + +describe('solanaAttestationInstructionParser', () => { + it('should decode the byte path through the dispatcher', () => { + const dispatched = dispatcher.fromTransactionInstruction( + transactionInstruction(CREATE_CREDENTIAL, [PAYER, CREDENTIAL, AUTHORITY, SYSTEM]), + ); + + expect(isParsedInstruction(dispatched)).toBe(true); + expect(dispatched).toMatchObject({ + parsed: { type: 'createCredential' }, + program: SOLANA_ATTESTATION_SERVICE_PROGRAM_LABEL, + }); + }); + + it('should report an undecodable instruction as unknown to this program', () => { + expect(dispatcher.fromTransactionInstruction(transactionInstruction(new Uint8Array([200]), []))).toMatchObject({ + programLabel: SOLANA_ATTESTATION_SERVICE_PROGRAM_LABEL, + unknown: true, + }); + }); +}); + +function transactionInstruction(data: Uint8Array, accounts: Address[]) { + return new TransactionInstruction({ + data: Buffer.from(data), + keys: accounts.map(account => ({ isSigner: false, isWritable: false, pubkey: new PublicKey(account) })), + programId: new PublicKey(SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS), + }); +} diff --git a/app/features/decode-instruction-sas/lib/is-solana-attestation-instruction.ts b/app/features/decode-instruction-sas/lib/is-solana-attestation-instruction.ts new file mode 100644 index 0000000000..7e02e1bfed --- /dev/null +++ b/app/features/decode-instruction-sas/lib/is-solana-attestation-instruction.ts @@ -0,0 +1,8 @@ +import type { TransactionInstruction } from '@solana/web3.js'; + +import { SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS } from './sas-parser'; + +/** Routes the tx-page byte path to the dispatcher-decoded card, alongside the other per-program guards. */ +export function isSolanaAttestationInstruction(ix: TransactionInstruction): boolean { + return ix.programId.toBase58() === SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS; +} diff --git a/app/features/decode-instruction-sas/lib/sas-client.ts b/app/features/decode-instruction-sas/lib/sas-client.ts new file mode 100644 index 0000000000..bb6fcde891 --- /dev/null +++ b/app/features/decode-instruction-sas/lib/sas-client.ts @@ -0,0 +1,15 @@ +import type { InstructionParser } from '@entities/instruction-parser'; + +import { + parseSolanaAttestationKitInstruction, + SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS, + SOLANA_ATTESTATION_SERVICE_PROGRAM_LABEL, + type SolanaAttestationParsed, +} from './sas-parser'; + +export const solanaAttestationInstructionParser: InstructionParser = { + // No `fromParsed` — the RPC never pre-parses this program, so only the byte path applies. + fromTransaction: parseSolanaAttestationKitInstruction, + programId: SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS, + programLabel: SOLANA_ATTESTATION_SERVICE_PROGRAM_LABEL, +}; diff --git a/app/features/decode-instruction-sas/lib/sas-parser.ts b/app/features/decode-instruction-sas/lib/sas-parser.ts new file mode 100644 index 0000000000..89fdd7a50f --- /dev/null +++ b/app/features/decode-instruction-sas/lib/sas-parser.ts @@ -0,0 +1,37 @@ +import type { KitInstruction, ParsedInstructionInfo, ParserProgramLabel } from '@explorer/parsers'; +import { + parseSolanaAttestationServiceInstruction, + SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS, + SolanaAttestationServiceInstruction, +} from '@solana/attestation'; +import type { AccountMeta } from '@solana/kit'; + +export { SOLANA_ATTESTATION_SERVICE_PROGRAM_ADDRESS }; + +/** The RPC never pre-parses this program, so this is a synthetic label carried through the dispatcher. */ +export const SOLANA_ATTESTATION_SERVICE_PROGRAM_LABEL = 'solana-attestation-service' satisfies ParserProgramLabel; + +/** Every SAS instruction parses to named accounts plus an argument struct; only the struct's shape differs. */ +export type SolanaAttestationInfo = { + accounts: Record; + data: Record; +}; + +/** `type` is the instruction's name in camelCase, e.g. `createCredential`. */ +export type SolanaAttestationParsed = ParsedInstructionInfo; + +export function parseSolanaAttestationKitInstruction(ix: KitInstruction): SolanaAttestationParsed | undefined { + let parsed: ReturnType; + try { + parsed = parseSolanaAttestationServiceInstruction(ix); + } catch { + // The generated client throws for a discriminator it does not know; the dispatcher renders that as unknown. + return undefined; + } + + const name = SolanaAttestationServiceInstruction[parsed.instructionType]; + return { + info: { accounts: parsed.accounts, data: parsed.data }, + type: name.charAt(0).toLowerCase() + name.slice(1), + }; +} diff --git a/app/features/decode-instruction-sas/ui/SolanaAttestationDetailsCard.tsx b/app/features/decode-instruction-sas/ui/SolanaAttestationDetailsCard.tsx new file mode 100644 index 0000000000..9a879552f5 --- /dev/null +++ b/app/features/decode-instruction-sas/ui/SolanaAttestationDetailsCard.tsx @@ -0,0 +1,49 @@ +import { + DecodedInstructionCard, + InstructionCardView, + type InstructionNode, + parseCodamaArgs, +} from '@entities/instruction-card'; +import type { DispatchResult } from '@entities/instruction-parser'; +import type { TransactionInstruction } from '@solana/web3.js'; +import { camelToTitleCase } from '@utils/index'; + +import type { SolanaAttestationParsed } from '../lib/sas-parser'; + +const TITLE_PREFIX = 'Solana Attestation'; + +type SolanaAttestationDetailsCardProps = { + /** The dispatcher's verdict for a SAS instruction: decoded, or registered-but-unparsed. */ + ix: DispatchResult; + /** Raw form, for the shell's account table and hex view. */ + raw: TransactionInstruction; + index: number; + innerCards?: JSX.Element[]; + childIndex?: number; +}; + +export function SolanaAttestationDetailsCard({ + ix, + raw, + index, + innerCards, + childIndex, +}: SolanaAttestationDetailsCardProps) { + const node: InstructionNode = { childIndex, index, innerCards, ix: raw, programId: raw.programId }; + + if ('unknown' in ix) { + return ; + } + + const { info, type } = ix.parsed as SolanaAttestationParsed; + + return ( + + ); +} diff --git a/app/components/instruction/sas/__stories__/SolanaAttestationDetailsCard.responsive.stories.tsx b/app/features/decode-instruction-sas/ui/__stories__/SolanaAttestationDetailsCard.responsive.stories.tsx similarity index 78% rename from app/components/instruction/sas/__stories__/SolanaAttestationDetailsCard.responsive.stories.tsx rename to app/features/decode-instruction-sas/ui/__stories__/SolanaAttestationDetailsCard.responsive.stories.tsx index 0965c17432..03f78b1c73 100644 --- a/app/components/instruction/sas/__stories__/SolanaAttestationDetailsCard.responsive.stories.tsx +++ b/app/features/decode-instruction-sas/ui/__stories__/SolanaAttestationDetailsCard.responsive.stories.tsx @@ -1,5 +1,6 @@ import { gen } from '@__fixtures__/gen'; -import { PublicKey } from '@solana/web3.js'; +import { createInstructionParserDispatcher, type DispatchResult } from '@entities/instruction-parser'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; import { nextjsParameters, withCluster, @@ -11,11 +12,12 @@ import { import { INITIAL_VIEWPORTS, withViewportFromGlobal } from '@storybook-config/responsive-decorators'; import type { Meta, StoryObj } from '@storybook-config/types'; +import { solanaAttestationInstructionParser } from '../../lib/sas-client'; import { SolanaAttestationDetailsCard } from '../SolanaAttestationDetailsCard'; // CreateCredential data layout: u8 discriminator + u32 name length + u32 signers length (9 bytes // minimum for empty values). Requires 4 accounts (payer, credential, authority, systemProgram). -const sasIx = { +const raw = new TransactionInstruction({ data: Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 0]), keys: [ { isSigner: true, isWritable: true, pubkey: gen.publicKey(1) }, @@ -24,7 +26,10 @@ const sasIx = { { isSigner: false, isWritable: false, pubkey: new PublicKey('11111111111111111111111111111111') }, ], programId: new PublicKey('22zoJMtdu4tQc2PzL74ZUT7FrwgB1Udec8DdW4yw4BdG'), -}; +}); +const ix = createInstructionParserDispatcher([solanaAttestationInstructionParser]).fromTransactionInstruction( + raw, +) as DispatchResult; const meta: Meta = { component: SolanaAttestationDetailsCard, @@ -41,13 +46,13 @@ const meta: Meta = { viewport: { options: INITIAL_VIEWPORTS }, }, tags: ['autodocs', 'test'], - title: 'Components/Instruction/SolanaAttestationDetailsCard@Media', + title: 'Features/DecodeInstructionSas/SolanaAttestationDetailsCard@Media', }; export default meta; type Story = StoryObj; -const args = { childIndex: undefined, index: 0, innerCards: undefined, ix: sasIx }; +const args = { childIndex: undefined, index: 0, innerCards: undefined, ix, raw }; export const Mobile: Story = { args, globals: { viewport: { value: 'iphonex' } } }; export const TabletPortrait: Story = { args, globals: { viewport: { value: 'ipad' } } }; diff --git a/app/components/instruction/sas/__stories__/SolanaAttestationDetailsCard.stories.tsx b/app/features/decode-instruction-sas/ui/__stories__/SolanaAttestationDetailsCard.stories.tsx similarity index 74% rename from app/components/instruction/sas/__stories__/SolanaAttestationDetailsCard.stories.tsx rename to app/features/decode-instruction-sas/ui/__stories__/SolanaAttestationDetailsCard.stories.tsx index 509723c29b..ebfc6529b8 100644 --- a/app/components/instruction/sas/__stories__/SolanaAttestationDetailsCard.stories.tsx +++ b/app/features/decode-instruction-sas/ui/__stories__/SolanaAttestationDetailsCard.stories.tsx @@ -1,5 +1,6 @@ import { gen } from '@__fixtures__/gen'; -import { PublicKey } from '@solana/web3.js'; +import { createInstructionParserDispatcher, type DispatchResult } from '@entities/instruction-parser'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; import { nextjsParameters, withCluster, @@ -10,12 +11,13 @@ import { } from '@storybook-config/decorators'; import type { Meta, StoryObj } from '@storybook-config/types'; +import { solanaAttestationInstructionParser } from '../../lib/sas-client'; import { SolanaAttestationDetailsCard } from '../SolanaAttestationDetailsCard'; // CreateCredential layout: u8 discriminator (0) + u32 name length (0) + u32 signers length (0). // Minimum 9 bytes for empty name and empty signers list. Requires 4 accounts (payer, credential, // authority, systemProgram). -const sasIx = { +const raw = new TransactionInstruction({ data: Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 0]), keys: [ { isSigner: true, isWritable: true, pubkey: gen.publicKey(1) }, @@ -24,19 +26,22 @@ const sasIx = { { isSigner: false, isWritable: false, pubkey: new PublicKey('11111111111111111111111111111111') }, ], programId: new PublicKey('22zoJMtdu4tQc2PzL74ZUT7FrwgB1Udec8DdW4yw4BdG'), -}; +}); +const ix = createInstructionParserDispatcher([solanaAttestationInstructionParser]).fromTransactionInstruction( + raw, +) as DispatchResult; const meta: Meta = { component: SolanaAttestationDetailsCard, decorators: [withCluster, withScrollAnchor, withTokenInfoBatch, withMockTransactions, withTxInstructionSurface], parameters: nextjsParameters, tags: ['autodocs', 'test'], - title: 'Components/Instruction/SolanaAttestationDetailsCard', + title: 'Features/DecodeInstructionSas/SolanaAttestationDetailsCard', }; export default meta; type Story = StoryObj; export const Default: Story = { - args: { childIndex: undefined, index: 0, innerCards: undefined, ix: sasIx }, + args: { childIndex: undefined, index: 0, innerCards: undefined, ix, raw }, }; diff --git a/app/components/instruction/sas/__tests__/SolanaAttestationDetailsCard.spec.tsx b/app/features/decode-instruction-sas/ui/__tests__/SolanaAttestationDetailsCard.spec.tsx similarity index 93% rename from app/components/instruction/sas/__tests__/SolanaAttestationDetailsCard.spec.tsx rename to app/features/decode-instruction-sas/ui/__tests__/SolanaAttestationDetailsCard.spec.tsx index f856606458..46704d3860 100644 --- a/app/components/instruction/sas/__tests__/SolanaAttestationDetailsCard.spec.tsx +++ b/app/features/decode-instruction-sas/ui/__tests__/SolanaAttestationDetailsCard.spec.tsx @@ -1,4 +1,5 @@ import { TxInstructionSurface } from '@entities/instruction-card'; +import { createInstructionParserDispatcher, type DispatchResult } from '@entities/instruction-parser'; import { PublicKey, TransactionInstruction } from '@solana/web3.js'; import { render, screen, waitFor, within } from '@testing-library/react'; import React from 'react'; @@ -15,8 +16,11 @@ import { ClusterProvider } from '@/app/providers/cluster'; import { ScrollAnchorProvider } from '@/app/providers/scroll-anchor'; import { TransactionsProvider } from '@/app/providers/transactions'; +import { solanaAttestationInstructionParser } from '../../lib/sas-client'; import { SolanaAttestationDetailsCard } from '../SolanaAttestationDetailsCard'; +const dispatcher = createInstructionParserDispatcher([solanaAttestationInstructionParser]); + const SAS_PROGRAM_ID = new PublicKey('22zoJMtdu4tQc2PzL74ZUT7FrwgB1Udec8DdW4yw4BdG'); const SYSTEM_PROGRAM = '11111111111111111111111111111111'; @@ -128,7 +132,10 @@ describe('SolanaAttestationDetailsCard', () => { it('should render the program row from the node', async () => { const ix = sasInstruction(CLOSE_ATTESTATION, 7); - renderCard(new TransactionInstruction({ ...ix, programId: new PublicKey(ACCOUNTS[0]) })); + renderCard( + new TransactionInstruction({ ...ix, programId: new PublicKey(ACCOUNTS[0]) }), + dispatcher.fromTransactionInstruction(ix), + ); await waitFor(() => { expect(readRows()[0]).toEqual(['Program', ACCOUNTS[0]]); @@ -158,14 +165,14 @@ function sasInstruction(data: number[], accountCount: number): TransactionInstru return new TransactionInstruction({ data: Buffer.from(data), keys, programId: SAS_PROGRAM_ID }); } -function renderCard(ix: TransactionInstruction) { +function renderCard(raw: TransactionInstruction, ix = dispatcher.fromTransactionInstruction(raw)) { return render( - + diff --git a/app/features/transaction/ui/InstructionsSection.tsx b/app/features/transaction/ui/InstructionsSection.tsx index e335b5e56f..dd28daf0de 100644 --- a/app/features/transaction/ui/InstructionsSection.tsx +++ b/app/features/transaction/ui/InstructionsSection.tsx @@ -1,11 +1,6 @@ import { ErrorCard } from '@components/common/ErrorCard'; import { LoadingCard } from '@components/common/LoadingCard'; -import { BpfLoaderDetailsCard } from '@components/instruction/bpf-loader/BpfLoaderDetailsCard'; import { BpfUpgradeableLoaderDetailsCard } from '@components/instruction/bpf-upgradeable-loader/BpfUpgradeableLoaderDetailsCard'; -import { - isSolanaAttestationInstruction, - SolanaAttestationDetailsCard, -} from '@components/instruction/sas/SolanaAttestationDetailsCard'; import { SystemDetailsCard } from '@components/instruction/system/SystemDetailsCard'; import { TokenDetailsCard } from '@components/instruction/token/TokenDetailsCard'; import { isTokenLendingInstruction } from '@components/instruction/token-lending/types'; @@ -40,6 +35,7 @@ import { } from '@explorer/parsers'; import { AddressLookupTableDetailsCard } from '@features/decode-instruction-address-lookup-table'; import { AssociatedTokenDetailsCard } from '@features/decode-instruction-associated-token'; +import { BpfLoaderDetailsCard } from '@features/decode-instruction-bpf-loader'; import { ComputeBudgetDetailsCard, isComputeBudgetInstruction } from '@features/decode-instruction-compute-budget'; import { Ed25519DetailsCard, @@ -49,6 +45,7 @@ import { import { isLighthouseInstruction, LighthouseDetailsCard } from '@features/decode-instruction-lighthouse'; import { MemoDetailsCard } from '@features/decode-instruction-memo'; import { isProgramMetadataInstruction } from '@features/decode-instruction-pmp/detection'; +import { isSolanaAttestationInstruction, SolanaAttestationDetailsCard } from '@features/decode-instruction-sas'; import { IdlInstructionCard, useIdlInstructionDecode } from '@features/decode-instruction-with-idl'; import { isZkElGamalProofInstruction, ZkElGamalProofDetailsCard } from '@features/decode-instruction-zk-elgamal-proof'; import { PythDetailsCard } from '@features/instruction-program-pyth'; @@ -281,7 +278,15 @@ function InstructionCard({ ); } case BPF_LOADER_PROGRAM_LABEL: - return ; + return ( + + ); case BPF_UPGRADEABLE_LOADER_PROGRAM_LABEL: return ; case SYSTEM_PROGRAM_LABEL: @@ -467,16 +472,21 @@ function InstructionCard({ ); } if (isSolanaAttestationInstruction(transactionIx)) { - return ( - } key={key}> - - - ); + const dispatched = dispatcher.fromTransactionInstruction(transactionIx); + if (dispatched) { + return ( + } key={key}> + + + ); + } + return ; } if (transactionIx.programId.toBase58() === MPL_TOKEN_METADATA_PROGRAM_ID) { return ( diff --git a/app/tx/instruction-parser-dispatcher.ts b/app/tx/instruction-parser-dispatcher.ts index c7097e031c..a4bbb83524 100644 --- a/app/tx/instruction-parser-dispatcher.ts +++ b/app/tx/instruction-parser-dispatcher.ts @@ -4,11 +4,13 @@ import { createInstructionParserDispatcher } from '@explorer/parsers'; import { addressLookupTableInstructionParser } from '@features/decode-instruction-address-lookup-table/lib/address-lookup-table-client'; import { associatedTokenInstructionParser } from '@features/decode-instruction-associated-token/lib/associated-token-client'; +import { bpfLoaderInstructionParser } from '@features/decode-instruction-bpf-loader/lib/bpf-loader-client'; import { bpfUpgradeableLoaderInstructionParser } from '@features/decode-instruction-bpf-upgradeable-loader/lib/bpf-upgradeable-loader-client'; import { computeBudgetInstructionParser } from '@features/decode-instruction-compute-budget/lib/compute-budget-client'; import { ed25519InstructionParser } from '@features/decode-instruction-ed25519/lib/ed25519-client'; import { lighthouseInstructionParser } from '@features/decode-instruction-lighthouse/lib/lighthouse-client'; import { memoInstructionParsers } from '@features/decode-instruction-memo/lib/memo-client'; +import { solanaAttestationInstructionParser } from '@features/decode-instruction-sas/lib/sas-client'; import { systemInstructionParser } from '@features/decode-instruction-system/lib/system-client'; import { tokenInstructionParser } from '@features/decode-instruction-token/lib/token-client'; import { token2022InstructionParser } from '@features/decode-instruction-token-2022/lib/token-2022-client'; @@ -23,11 +25,13 @@ export const instructionParserDispatcher = createInstructionParserDispatcher([ associatedTokenInstructionParser, addressLookupTableInstructionParser, metaplexTokenMetadataInstructionParser, + bpfLoaderInstructionParser, bpfUpgradeableLoaderInstructionParser, lighthouseInstructionParser, ed25519InstructionParser, computeBudgetInstructionParser, zkElGamalProofInstructionParser, + solanaAttestationInstructionParser, ...pythInstructionParsers, ...memoInstructionParsers, ]); diff --git a/packages/parsers/src/program-registry.ts b/packages/parsers/src/program-registry.ts index 15612af246..46ff084c0e 100644 --- a/packages/parsers/src/program-registry.ts +++ b/packages/parsers/src/program-registry.ts @@ -29,12 +29,14 @@ export const VOTE_PROGRAM_LABEL = 'vote'; */ export type ParserProgramLabel = | typeof ADDRESS_LOOKUP_TABLE_PROGRAM_LABEL + | typeof BPF_LOADER_PROGRAM_LABEL | typeof BPF_UPGRADEABLE_LOADER_PROGRAM_LABEL | 'compute-budget' | 'ed25519' | 'lighthouse' | 'mpl-token-metadata' | 'pyth' + | 'solana-attestation-service' | typeof SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_LABEL | typeof SPL_MEMO_PROGRAM_LABEL | typeof SPL_TOKEN_PROGRAM_LABEL