diff --git a/app/components/inspector/InspectorPage.tsx b/app/components/inspector/InspectorPage.tsx index db70e863f..967e35b9e 100755 --- a/app/components/inspector/InspectorPage.tsx +++ b/app/components/inspector/InspectorPage.tsx @@ -9,7 +9,14 @@ import { useFetchAccountInfo } from '@providers/accounts'; import { FetchStatus } from '@providers/cache'; import { useFetchRawTransaction, useRawTransactionDetails } from '@providers/transactions/raw'; import usePrevious from '@react-hook/previous'; -import { Connection, MessageV0, PACKET_DATA_SIZE, PublicKey, VersionedMessage } from '@solana/web3.js'; +import { + type CompiledInnerInstruction, + Connection, + MessageV0, + PACKET_DATA_SIZE, + PublicKey, + VersionedMessage, +} from '@solana/web3.js'; import { generated, PROGRAM_ADDRESS as SQUADS_V4_PROGRAM_ADDRESS } from '@sqds/multisig'; import { useClusterPath } from '@utils/url'; import bs58 from 'bs58'; @@ -38,6 +45,7 @@ export type TransactionData = { preBalances: number[]; postBalances: number[]; }; + compiledInnerInstructions?: CompiledInnerInstruction[]; }; export type SquadsProposalAccountData = { @@ -397,6 +405,7 @@ function PermalinkView({ const { message, signatures, meta } = transaction; const tx = { accountBalances: meta, + compiledInnerInstructions: meta?.innerInstructions, message, rawMessage: message.serialize(), signatures, @@ -413,7 +422,7 @@ function LoadedView({ onClear: () => void; showTokenBalanceChanges: boolean; }) { - const { message, rawMessage, signatures, accountBalances } = transaction; + const { message, rawMessage, signatures, accountBalances, compiledInnerInstructions } = transaction; const fetchAccountInfo = useFetchAccountInfo(); React.useEffect(() => { @@ -433,7 +442,7 @@ function LoadedView({ {signatures && } - + ); } diff --git a/app/components/inspector/InstructionsSection.tsx b/app/components/inspector/InstructionsSection.tsx index cbc7b6bed..1fa978aa3 100755 --- a/app/components/inspector/InstructionsSection.tsx +++ b/app/components/inspector/InstructionsSection.tsx @@ -4,6 +4,7 @@ import { useCluster } from '@providers/cluster'; import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { AddressLookupTableAccount, + type CompiledInnerInstruction, ComputeBudgetProgram, SystemProgram, TransactionInstruction, @@ -15,6 +16,7 @@ import { getProgramName } from '@utils/tx'; import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +import { isTokenBatchInstruction, resolveInnerBatchInstructions, TokenBatchCard } from '@/app/features/token-batch'; import { useAddressLookupTables } from '@/app/providers/accounts'; import { FetchStatus } from '@/app/providers/cache'; @@ -29,7 +31,15 @@ import { AssociatedTokenDetailsCard } from './associated-token/AssociatedTokenDe import { intoParsedInstruction, intoParsedTransaction } from './into-parsed-data'; import { UnknownDetailsCard } from './UnknownDetailsCard'; -export function InstructionsSection({ message }: { message: VersionedMessage }) { +const INSPECTOR_RESULT = { err: null }; + +export function InstructionsSection({ + message, + compiledInnerInstructions, +}: { + message: VersionedMessage; + compiledInnerInstructions?: CompiledInnerInstruction[]; +}) { // Fetch all address lookup tables const hydratedTables = useAddressLookupTables( message.addressTableLookups.map(lookup => lookup.accountKey.toString()), @@ -59,10 +69,32 @@ export function InstructionsSection({ message }: { message: VersionedMessage }) ); const transactionMessage = TransactionMessage.decompile(message, { addressLookupTableAccounts }); + const batchByIndex = compiledInnerInstructions + ? resolveInnerBatchInstructions( + compiledInnerInstructions, + message.getAccountKeys({ addressLookupTableAccounts }), + message, + ) + : {}; + return ( <> {transactionMessage.instructions.map((ix, index) => { - return ; + const batchInnerCards = batchByIndex[index]?.map((innerIx, childIndex) => ( + + + + )); + + return ( + + ); })} ); @@ -72,10 +104,12 @@ function InspectorInstructionCard({ message, ix, index, + innerCards, }: { message: VersionedMessage; ix: TransactionInstruction; index: number; + innerCards?: React.ReactNode[]; }) { const { cluster, url } = useCluster(); @@ -91,23 +125,25 @@ function InspectorInstructionCard({ ); } + if (isTokenBatchInstruction(ix)) { + return ( + } + > + + + ); + } + /// Handle program-specific cards here // - keep signature (empty string as we do not submit anything) for backward compatibility with the data from Transaction // - result is `err: null` as at this point there should not be errors @@ -116,8 +152,6 @@ function InspectorInstructionCard({ switch (ix.programId.toString()) { case ASSOCIATED_TOKEN_PROGRAM_ID.toString(): { - // NOTE: current limitation is that innerInstructions won't be present at the AssociatedTokenDetailsCard. For that purpose we might need to simulateTransactions to get them. - const asParsedInstruction = intoParsedInstruction(ix); return ( ; + return ; } diff --git a/app/components/inspector/UnknownDetailsCard.tsx b/app/components/inspector/UnknownDetailsCard.tsx index 12cf744d6..294297d03 100644 --- a/app/components/inspector/UnknownDetailsCard.tsx +++ b/app/components/inspector/UnknownDetailsCard.tsx @@ -13,12 +13,15 @@ export function UnknownDetailsCard({ index, ix, programName, + innerCards, }: { index: number; ix: TransactionInstruction; programName: string; + innerCards?: React.ReactNode[]; }) { - const [expanded, setExpanded] = React.useState(false); + const hasInnerCards = innerCards && innerCards.length > 0; + const [expanded, setExpanded] = React.useState(hasInnerCards ?? false); const scrollAnchorRef = useScrollAnchor(getInstructionCardScrollAnchorId([index + 1])); @@ -41,6 +44,18 @@ export function UnknownDetailsCard({ + {hasInnerCards && ( + <> + + Inner Instructions + + + +
{innerCards}
+ + + + )}
)} diff --git a/app/features/token-batch/index.ts b/app/features/token-batch/index.ts index 39ef27014..8d6ddadbc 100644 --- a/app/features/token-batch/index.ts +++ b/app/features/token-batch/index.ts @@ -1,2 +1,3 @@ export { isTokenBatchInstruction } from './lib/batch-parser'; +export { resolveInnerBatchInstructions } from './lib/resolve-inner-batch-instructions'; export { TokenBatchCard } from './ui/TokenBatchCard'; diff --git a/app/features/token-batch/lib/__tests__/resolve-inner-batch-instructions.spec.ts b/app/features/token-batch/lib/__tests__/resolve-inner-batch-instructions.spec.ts new file mode 100644 index 000000000..e52fc27da --- /dev/null +++ b/app/features/token-batch/lib/__tests__/resolve-inner-batch-instructions.spec.ts @@ -0,0 +1,203 @@ +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@providers/accounts/tokens'; +import { Keypair, MessageAccountKeys, MessageV0, PublicKey } from '@solana/web3.js'; +import bs58 from 'bs58'; +import { describe, expect, it } from 'vitest'; + +import { concatBytes, toBuffer } from '@/app/shared/lib/bytes'; + +import { BATCH_DISCRIMINATOR } from '../const'; +import { resolveInnerBatchInstructions } from '../resolve-inner-batch-instructions'; +import { encodeSubIx, makeTransferData } from './test-utils'; + +describe('resolveInnerBatchInstructions', () => { + it('should return batch instructions grouped by parent index', () => { + const keys = makeKeys(3); + keys[2] = TOKEN_PROGRAM_ID; + const message = makeMessage(keys, { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }); + const accountKeys = new MessageAccountKeys(keys); + + const result = resolveInnerBatchInstructions( + [ + { + index: 0, + instructions: [ + { + accounts: [0, 1], + data: makeBatchCompiledData([{ data: makeTransferData(100n), numAccounts: 2 }]), + programIdIndex: 2, + }, + ], + }, + ], + accountKeys, + message, + ); + + expect(result[0]).toHaveLength(1); + expect(result[0][0].programId).toEqual(keys[2]); + }); + + it('should skip non-batch inner instructions', () => { + const keys = makeKeys(4); + const message = makeMessage(keys, { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }); + const accountKeys = new MessageAccountKeys(keys); + + const result = resolveInnerBatchInstructions( + [ + { + index: 0, + instructions: [ + { + accounts: [0, 1], + data: bs58.encode(new Uint8Array([1, 2, 3])), + programIdIndex: 3, + }, + ], + }, + ], + accountKeys, + message, + ); + + expect(result[0]).toBeUndefined(); + }); + + it('should resolve signer and writable flags from the message', () => { + // 5 accounts: [0]=writable signer, [1]=readonly signer, [2]=writable unsigned, [3]=readonly unsigned, [4]=token program (readonly) + const keys = makeKeys(5); + keys[4] = TOKEN_PROGRAM_ID; + const message = makeMessage(keys, { + numReadonlySignedAccounts: 1, + numReadonlyUnsignedAccounts: 2, + numRequiredSignatures: 2, + }); + const accountKeys = new MessageAccountKeys(keys); + + const result = resolveInnerBatchInstructions( + [ + { + index: 0, + instructions: [ + { + accounts: [0, 1, 2, 3], + data: makeBatchCompiledData([{ data: makeTransferData(50n), numAccounts: 4 }]), + programIdIndex: 4, + }, + ], + }, + ], + accountKeys, + message, + ); + + const ix = result[0][0]; + expect(ix.keys[0]).toMatchObject({ isSigner: true, isWritable: true }); + expect(ix.keys[1]).toMatchObject({ isSigner: true, isWritable: false }); + expect(ix.keys[2]).toMatchObject({ isSigner: false, isWritable: true }); + expect(ix.keys[3]).toMatchObject({ isSigner: false, isWritable: false }); + }); + + it('should handle multiple parent indices', () => { + const keys = makeKeys(3); + keys[2] = TOKEN_2022_PROGRAM_ID; + const message = makeMessage(keys, { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }); + const accountKeys = new MessageAccountKeys(keys); + + const batchData = makeBatchCompiledData([{ data: makeTransferData(1n), numAccounts: 2 }]); + + const result = resolveInnerBatchInstructions( + [ + { index: 1, instructions: [{ accounts: [0, 1], data: batchData, programIdIndex: 2 }] }, + { index: 3, instructions: [{ accounts: [0, 1], data: batchData, programIdIndex: 2 }] }, + ], + accountKeys, + message, + ); + + expect(Object.keys(result)).toEqual(['1', '3']); + expect(result[1]).toHaveLength(1); + expect(result[3]).toHaveLength(1); + }); + + it('should skip instructions with out-of-range account indices', () => { + const keys = makeKeys(3); + keys[2] = TOKEN_PROGRAM_ID; + const message = makeMessage(keys, { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }); + const accountKeys = new MessageAccountKeys(keys); + + const result = resolveInnerBatchInstructions( + [ + { + index: 0, + instructions: [ + { + accounts: [0, 99], // 99 is out of range + data: makeBatchCompiledData([{ data: makeTransferData(100n), numAccounts: 2 }]), + programIdIndex: 2, + }, + ], + }, + ], + accountKeys, + message, + ); + + expect(result[0]).toBeUndefined(); + }); + + it('should return empty object for empty input', () => { + const keys = makeKeys(2); + const message = makeMessage(keys, { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 0, + numRequiredSignatures: 1, + }); + const accountKeys = new MessageAccountKeys(keys); + + const result = resolveInnerBatchInstructions([], accountKeys, message); + + expect(result).toEqual({}); + }); +}); + +// Builds a minimal MessageV0 with the given static keys and header. +function makeMessage( + keys: PublicKey[], + header: { numRequiredSignatures: number; numReadonlySignedAccounts: number; numReadonlyUnsignedAccounts: number }, +): MessageV0 { + return new MessageV0({ + addressTableLookups: [], + compiledInstructions: [], + header, + recentBlockhash: bs58.encode(new Uint8Array(32)), + staticAccountKeys: keys, + }); +} + +function makeKeys(count: number): PublicKey[] { + return Array.from({ length: count }, () => Keypair.generate().publicKey); +} + +function makeBatchCompiledData(subIxs: { numAccounts: number; data: Uint8Array }[]): string { + const body = concatBytes( + new Uint8Array([BATCH_DISCRIMINATOR]), + ...subIxs.map(s => encodeSubIx(s.numAccounts, s.data)), + ); + return bs58.encode(toBuffer(body)); +} diff --git a/app/features/token-batch/lib/compiled-to-transaction-instruction.ts b/app/features/token-batch/lib/compiled-to-transaction-instruction.ts new file mode 100644 index 000000000..daddfbe90 --- /dev/null +++ b/app/features/token-batch/lib/compiled-to-transaction-instruction.ts @@ -0,0 +1,48 @@ +import { + type AccountMeta, + type CompiledInstruction, + type MessageAccountKeys, + TransactionInstruction, + type VersionedMessage, +} from '@solana/web3.js'; +import bs58 from 'bs58'; + +import { Logger } from '@/app/shared/lib/logger'; + +// Converts a CompiledInstruction (index-based) into a TransactionInstruction +// (pubkey-based) using VersionedMessage helpers for signer/writable resolution. +// Returns undefined if any account index is out of range. +export function compiledToTransactionInstruction( + ix: CompiledInstruction, + accountKeys: MessageAccountKeys, + message: VersionedMessage, +): TransactionInstruction | undefined { + const programId = accountKeys.get(ix.programIdIndex); + if (!programId) { + Logger.warn('[token-batch] Program ID index out of range', { + index: ix.programIdIndex, + total: accountKeys.length, + }); + return undefined; + } + + const keys: AccountMeta[] = []; + for (const accountIndex of ix.accounts) { + const pubkey = accountKeys.get(accountIndex); + if (!pubkey) { + Logger.warn('[token-batch] Account index out of range', { index: accountIndex, total: accountKeys.length }); + return undefined; + } + keys.push({ + isSigner: message.isAccountSigner(accountIndex), + isWritable: message.isAccountWritable(accountIndex), + pubkey, + }); + } + + return new TransactionInstruction({ + data: bs58.decode(ix.data), + keys, + programId, + }); +} diff --git a/app/features/token-batch/lib/resolve-inner-batch-instructions.ts b/app/features/token-batch/lib/resolve-inner-batch-instructions.ts new file mode 100644 index 000000000..1169ce9f2 --- /dev/null +++ b/app/features/token-batch/lib/resolve-inner-batch-instructions.ts @@ -0,0 +1,31 @@ +import type { + CompiledInnerInstruction, + MessageAccountKeys, + TransactionInstruction, + VersionedMessage, +} from '@solana/web3.js'; + +import { isTokenBatchInstruction } from './batch-parser'; +import { compiledToTransactionInstruction } from './compiled-to-transaction-instruction'; + +// Extracts token batch TransactionInstructions from compiled inner instructions, +// grouped by parent instruction index. +export function resolveInnerBatchInstructions( + compiledInnerInstructions: CompiledInnerInstruction[], + accountKeys: MessageAccountKeys, + message: VersionedMessage, +): Record { + const result: Record = {}; + + for (const inner of compiledInnerInstructions) { + const batch = inner.instructions + .map(ix => compiledToTransactionInstruction(ix, accountKeys, message)) + .filter((ix): ix is TransactionInstruction => ix !== undefined && isTokenBatchInstruction(ix)); + + if (batch.length > 0) { + result[inner.index] = batch; + } + } + + return result; +} diff --git a/app/providers/transactions/raw.tsx b/app/providers/transactions/raw.tsx index 599ec1561..28b9541a0 100644 --- a/app/providers/transactions/raw.tsx +++ b/app/providers/transactions/raw.tsx @@ -3,7 +3,14 @@ import * as Cache from '@providers/cache'; import { ActionType, FetchStatus } from '@providers/cache'; import { useCluster } from '@providers/cluster'; -import { Connection, DecompileArgs, TransactionMessage, TransactionSignature, VersionedMessage } from '@solana/web3.js'; +import { + type CompiledInnerInstruction, + Connection, + type DecompileArgs, + TransactionMessage, + type TransactionSignature, + type VersionedMessage, +} from '@solana/web3.js'; import { Cluster } from '@utils/cluster'; import React from 'react'; @@ -13,6 +20,7 @@ export interface Details { raw?: { message: VersionedMessage; meta?: { + innerInstructions?: CompiledInnerInstruction[]; postBalances: number[]; preBalances: number[]; }; @@ -71,6 +79,7 @@ async function fetchRawTransaction(dispatch: Dispatch, signature: TransactionSig message, meta: response.meta ? { + innerInstructions: response.meta.innerInstructions ?? undefined, postBalances: response.meta.postBalances, preBalances: response.meta.preBalances, } diff --git a/bench/BUILD.md b/bench/BUILD.md index 070f0c800..4c5aa0ea2 100644 --- a/bench/BUILD.md +++ b/bench/BUILD.md @@ -57,6 +57,6 @@ | Static | `/supply` | 10 kB | 1.04 MB | | Static | `/tos` | 330 B | 170 kB | | Dynamic | `/tx/[signature]` | 60 kB | 1.53 MB | -| Dynamic | `/tx/[signature]/inspect` | 620 B | 1.29 MB | -| Static | `/tx/inspector` | 630 B | 1.29 MB | +| Dynamic | `/tx/[signature]/inspect` | 620 B | 1.30 MB | +| Static | `/tx/inspector` | 630 B | 1.30 MB | | Static | `/verified-programs` | 10 kB | 180 kB |