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