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 |