diff --git a/.changeset/crisp-squids-flow.md b/.changeset/crisp-squids-flow.md new file mode 100644 index 000000000..455fa29b4 --- /dev/null +++ b/.changeset/crisp-squids-flow.md @@ -0,0 +1,36 @@ +--- +'@solana/transaction-introspection': minor +'@solana/errors': minor +'@solana/rpc-api': minor +'@solana/kit': minor +--- + +Add `@solana/transaction-introspection`, a new package that bridges a `getTransaction` response and the auto-generated `@solana-program/*` `parseXInstruction` clients. Decodes the transaction (`encoding: 'base64'`, `'base58'`, or `'json'`), resolves account indices against static + ALT-loaded addresses, normalizes inner instructions from `meta.innerInstructions`, and exposes `walkInstructions` to enumerate every instruction in display order — each outer instruction followed by its inner instructions — with a `trace` recording its location. Each returned instruction is a `ResolvedInstruction & { trace }` directly usable with `isInstructionForProgram` from `@solana/instructions` and with the auto-generated `identifyXInstruction` / `parseXInstruction` helpers. Supports `legacy`, `v0`, and `v1` compiled transaction messages. Re-exported from `@solana/kit`. + +```ts +import { createSolanaRpc, signature } from '@solana/kit'; +import { isInstructionForProgram } from '@solana/instructions'; +import { decodeTransactionFromRpcResponse, walkInstructions } from '@solana/transaction-introspection'; +import { identifyTokenInstruction, TOKEN_PROGRAM_ADDRESS, TokenInstruction } from '@solana-program/token'; + +const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com'); +const rpcTx = await rpc + .getTransaction(signature(txid), { + commitment: 'confirmed', + encoding: 'base64', + maxSupportedTransactionVersion: 0, + }) + .send(); +if (!rpcTx) throw new Error(`Transaction ${txid} not found`); + +const { compiledMessage, loadedAddresses } = decodeTransactionFromRpcResponse(rpcTx); + +for (const ix of walkInstructions({ compiledMessage, loadedAddresses, meta: rpcTx.meta })) { + if (!isInstructionForProgram(ix, TOKEN_PROGRAM_ADDRESS)) continue; + if (identifyTokenInstruction(ix) === TokenInstruction.SyncNative) { + console.log('SyncNative found at', ix.trace); + } +} +``` + +`@solana/rpc-api` now exports the non-null `getTransaction` response shapes as named types (`GetTransactionApiResponseBase58`, `GetTransactionApiResponseBase64`, `GetTransactionApiResponseJson`, `GetTransactionApiResponseJsonParsed`), which `decodeTransactionFromRpcResponse` accepts as inputs. `@solana/errors` gains `SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE` plus a new `TRANSACTION_INTROSPECTION` domain (`SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION`, `SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE`). diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index b95fda4a5..9be317dd9 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -262,6 +262,12 @@ export const SOLANA_ERROR__TRANSACTION__TOO_MANY_INSTRUCTIONS = 5663034; export const SOLANA_ERROR__TRANSACTION__TOO_MANY_ACCOUNTS_IN_INSTRUCTION = 5663035; export const SOLANA_ERROR__TRANSACTION__FAILED_TO_ESTIMATE_LOADED_ACCOUNTS_DATA_SIZE_LIMIT = 5663036; export const SOLANA_ERROR__TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_RESOURCE_LIMITS = 5663037; +export const SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE = 5663038; + +// Transaction-introspection-related errors. +// Reserve error codes in the range [5664000-5664999]. +export const SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION = 5664000; +export const SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE = 5664001; // Transaction errors. // Reserve error codes starting with [7050000-7050999] for the Rust enum `TransactionError`. @@ -673,6 +679,7 @@ export type SolanaErrorCode = | typeof SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS_MISSING | typeof SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_INDEX_OUT_OF_RANGE | typeof SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_FEE_PAYER_MISSING + | typeof SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE | typeof SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND | typeof SOLANA_ERROR__TRANSACTION__FAILED_TO_ESTIMATE_COMPUTE_LIMIT | typeof SOLANA_ERROR__TRANSACTION__FAILED_TO_ESTIMATE_LOADED_ACCOUNTS_DATA_SIZE_LIMIT @@ -737,6 +744,8 @@ export type SolanaErrorCode = | typeof SOLANA_ERROR__TRANSACTION_ERROR__WOULD_EXCEED_MAX_ACCOUNT_COST_LIMIT | typeof SOLANA_ERROR__TRANSACTION_ERROR__WOULD_EXCEED_MAX_BLOCK_COST_LIMIT | typeof SOLANA_ERROR__TRANSACTION_ERROR__WOULD_EXCEED_MAX_VOTE_COST_LIMIT + | typeof SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION + | typeof SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE | typeof SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE | typeof SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED | typeof SOLANA_ERROR__WALLET__NOT_CONNECTED diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index 3452ae8f0..201fcb763 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -201,6 +201,7 @@ import { SOLANA_ERROR__TRANSACTION__EXCEEDS_SIZE_LIMIT, SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS_MISSING, SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_INDEX_OUT_OF_RANGE, + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, SOLANA_ERROR__TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_COMPUTE_LIMIT, SOLANA_ERROR__TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_RESOURCE_LIMITS, @@ -885,6 +886,9 @@ export type SolanaErrorContext = ReadonlyContextValue< highestRequestedIndex: number; lookupTableAddress: string; }; + [SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE]: { + index: number; + }; [SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND]: { index: number; }; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index 71b18d000..3e4c4b07f 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -252,6 +252,7 @@ import { SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS_MISSING, SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_INDEX_OUT_OF_RANGE, SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_FEE_PAYER_MISSING, + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, SOLANA_ERROR__TRANSACTION__FAILED_TO_ESTIMATE_COMPUTE_LIMIT, SOLANA_ERROR__TRANSACTION__FAILED_TO_ESTIMATE_LOADED_ACCOUNTS_DATA_SIZE_LIMIT, @@ -316,6 +317,8 @@ import { SOLANA_ERROR__TRANSACTION_ERROR__WOULD_EXCEED_MAX_ACCOUNT_COST_LIMIT, SOLANA_ERROR__TRANSACTION_ERROR__WOULD_EXCEED_MAX_BLOCK_COST_LIMIT, SOLANA_ERROR__TRANSACTION_ERROR__WOULD_EXCEED_MAX_VOTE_COST_LIMIT, + SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION, + SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE, SOLANA_ERROR__WALLET__ACCOUNT_NOT_AVAILABLE, SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED, SOLANA_ERROR__WALLET__NOT_CONNECTED, @@ -882,6 +885,12 @@ export const SolanaErrorMessages: Readonly<{ 'Transaction has $actualCount instructions but the maximum allowed is $maxAllowed', [SOLANA_ERROR__TRANSACTION__TOO_MANY_ACCOUNTS_IN_INSTRUCTION]: 'The instruction at index $instructionIndex has $actualCount account references but the maximum allowed is $maxAllowed', + [SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE]: + 'Could not find an account address at index $index while decompiling an instruction', + [SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION]: + "`getTransaction` responses fetched with `encoding: 'jsonParsed'` cannot be decoded. Re-fetch the transaction with `encoding: 'base64'`, `'base58'`, or `'json'`", + [SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE]: + "Could not recognize the shape of this `getTransaction` response. Expected a response fetched with `encoding: 'base64'`, `'base58'`, or `'json'`", [SOLANA_ERROR__WALLET__NOT_CONNECTED]: 'Cannot $operation: no wallet connected', [SOLANA_ERROR__WALLET__NO_SIGNER_CONNECTED]: 'No signing wallet connected (status: $status)', [SOLANA_ERROR__WALLET__SIGNER_NOT_AVAILABLE]: 'Connected wallet does not support signing', diff --git a/packages/kit/package.json b/packages/kit/package.json index 379a2dea0..1b74e9998 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -124,6 +124,7 @@ "@solana/signers": "workspace:*", "@solana/sysvars": "workspace:*", "@solana/transaction-confirmation": "workspace:*", + "@solana/transaction-introspection": "workspace:*", "@solana/transaction-messages": "workspace:*", "@solana/transactions": "workspace:*" }, diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index 25fa9ff2c..6f055f1cb 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -23,6 +23,7 @@ export * from '@solana/rpc-parsed-types'; export * from '@solana/rpc-subscriptions'; export * from '@solana/rpc-types'; export * from '@solana/signers'; +export * from '@solana/transaction-introspection'; export * from '@solana/transaction-messages'; export * from '@solana/transactions'; export * from './create-async-generator-with-initial-value-and-slot-tracking'; diff --git a/packages/rpc-api/src/getTransaction.ts b/packages/rpc-api/src/getTransaction.ts index 0dc68de41..426430e8a 100644 --- a/packages/rpc-api/src/getTransaction.ts +++ b/packages/rpc-api/src/getTransaction.ts @@ -295,6 +295,71 @@ type TransactionAddressTableLookups = Readonly<{ }>; }>; +/** + * Common envelope for every non-null `getTransaction` response, regardless of + * encoding. The conditional shape mirrors the API: when + * `TMaxSupportedTransactionVersion` is unset (`void`), the response carries no + * `version` field; when set, the resolved `TransactionVersion` is included. + */ +type GetTransactionApiResponseEnvelope = + GetTransactionApiResponseBase & + (TMaxSupportedTransactionVersion extends void ? Record : { version: TransactionVersion }); + +/** + * The non-parsed `meta` shape returned with `encoding: 'base64'`, `'base58'`, or + * `'json'`. `loadedAddresses` is only populated when + * `TMaxSupportedTransactionVersion` is set on the request. + */ +type TransactionMetaNotParsed = + | (TransactionMetaBase & + TransactionMetaInnerInstructionsNotParsed & + (TMaxSupportedTransactionVersion extends void ? Record : TransactionMetaLoadedAddresses)) + | null; + +/** + * The shape of a non-null `getTransaction` response when called with + * `encoding: 'base64'`. + */ +export type GetTransactionApiResponseBase64 = + GetTransactionApiResponseEnvelope & { + meta: TransactionMetaNotParsed; + transaction: Base64EncodedDataResponse; + }; + +/** + * The shape of a non-null `getTransaction` response when called with + * `encoding: 'base58'`. + */ +export type GetTransactionApiResponseBase58 = + GetTransactionApiResponseEnvelope & { + meta: TransactionMetaNotParsed; + transaction: Base58EncodedDataResponse; + }; + +/** + * The shape of a non-null `getTransaction` response when called with + * `encoding: 'json'` (the default). + */ +export type GetTransactionApiResponseJson = + GetTransactionApiResponseEnvelope & { + meta: TransactionMetaNotParsed; + transaction: TransactionJson & + (TMaxSupportedTransactionVersion extends void ? Record : TransactionAddressTableLookups); + }; + +/** + * The shape of a non-null `getTransaction` response when called with + * `encoding: 'jsonParsed'`. Inner instructions and instruction data are + * parsed by the server when a parser is registered for the program. + */ +export type GetTransactionApiResponseJsonParsed< + TMaxSupportedTransactionVersion extends TransactionVersion | void = void, +> = GetTransactionApiResponseEnvelope & { + meta: (TransactionMetaBase & TransactionMetaInnerInstructionsParsed) | null; + transaction: TransactionJsonParsed & + (TMaxSupportedTransactionVersion extends void ? Record : TransactionAddressTableLookups); +}; + export type GetTransactionApi = { /** * Returns details of the confirmed transaction identified by the given signature. @@ -318,18 +383,7 @@ export type GetTransactionApi = { Readonly<{ encoding: 'jsonParsed'; }>, - ): - | (GetTransactionApiResponseBase & - (TMaxSupportedTransactionVersion extends void - ? Record - : { version: TransactionVersion }) & { - meta: (TransactionMetaBase & TransactionMetaInnerInstructionsParsed) | null; - transaction: TransactionJsonParsed & - (TMaxSupportedTransactionVersion extends void - ? Record - : TransactionAddressTableLookups); - }) - | null; + ): GetTransactionApiResponseJsonParsed | null; /** * Returns details of the confirmed transaction identified by the given signature. * @@ -349,21 +403,7 @@ export type GetTransactionApi = { Readonly<{ encoding: 'base64'; }>, - ): - | (GetTransactionApiResponseBase & - (TMaxSupportedTransactionVersion extends void - ? Record - : { version: TransactionVersion }) & { - meta: - | (TransactionMetaBase & - TransactionMetaInnerInstructionsNotParsed & - (TMaxSupportedTransactionVersion extends void - ? Record - : TransactionMetaLoadedAddresses)) - | null; - transaction: Base64EncodedDataResponse; - }) - | null; + ): GetTransactionApiResponseBase64 | null; /** * Returns details of the confirmed transaction identified by the given signature. * @@ -383,21 +423,7 @@ export type GetTransactionApi = { Readonly<{ encoding: 'base58'; }>, - ): - | (GetTransactionApiResponseBase & - (TMaxSupportedTransactionVersion extends void - ? Record - : { version: TransactionVersion }) & { - meta: - | (TransactionMetaBase & - TransactionMetaInnerInstructionsNotParsed & - (TMaxSupportedTransactionVersion extends void - ? Record - : TransactionMetaLoadedAddresses)) - | null; - transaction: Base58EncodedDataResponse; - }) - | null; + ): GetTransactionApiResponseBase58 | null; /** * Returns details of the confirmed transaction identified by the given signature. * @@ -416,22 +442,5 @@ export type GetTransactionApi = { Readonly<{ encoding?: 'json'; }>, - ): - | (GetTransactionApiResponseBase & - (TMaxSupportedTransactionVersion extends void - ? Record - : { version: TransactionVersion }) & { - meta: - | (TransactionMetaBase & - TransactionMetaInnerInstructionsNotParsed & - (TMaxSupportedTransactionVersion extends void - ? Record - : TransactionMetaLoadedAddresses)) - | null; - transaction: TransactionJson & - (TMaxSupportedTransactionVersion extends void - ? Record - : TransactionAddressTableLookups); - }) - | null; + ): GetTransactionApiResponseJson | null; }; diff --git a/packages/rpc-api/src/index.ts b/packages/rpc-api/src/index.ts index 78c166442..99f4f1585 100644 --- a/packages/rpc-api/src/index.ts +++ b/packages/rpc-api/src/index.ts @@ -79,7 +79,13 @@ import { GetTokenAccountsByDelegateApi } from './getTokenAccountsByDelegate'; import { GetTokenAccountsByOwnerApi } from './getTokenAccountsByOwner'; import { GetTokenLargestAccountsApi } from './getTokenLargestAccounts'; import { GetTokenSupplyApi } from './getTokenSupply'; -import { GetTransactionApi } from './getTransaction'; +import { + GetTransactionApi, + GetTransactionApiResponseBase58, + GetTransactionApiResponseBase64, + GetTransactionApiResponseJson, + GetTransactionApiResponseJsonParsed, +} from './getTransaction'; import { GetTransactionCountApi } from './getTransactionCount'; import { GetVersionApi } from './getVersion'; import { GetVoteAccountsApi } from './getVoteAccounts'; @@ -212,6 +218,10 @@ export type { GetTokenLargestAccountsApi, GetTokenSupplyApi, GetTransactionApi, + GetTransactionApiResponseBase58, + GetTransactionApiResponseBase64, + GetTransactionApiResponseJson, + GetTransactionApiResponseJsonParsed, GetTransactionCountApi, GetVersionApi, GetVoteAccountsApi, diff --git a/packages/transaction-introspection/.gitignore b/packages/transaction-introspection/.gitignore new file mode 100644 index 000000000..aff17b6df --- /dev/null +++ b/packages/transaction-introspection/.gitignore @@ -0,0 +1,2 @@ +.docs/ +dist/ diff --git a/packages/transaction-introspection/LICENSE b/packages/transaction-introspection/LICENSE new file mode 100644 index 000000000..ec09953d3 --- /dev/null +++ b/packages/transaction-introspection/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2023 Solana Labs, Inc + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/transaction-introspection/README.md b/packages/transaction-introspection/README.md new file mode 100644 index 000000000..d24beb38e --- /dev/null +++ b/packages/transaction-introspection/README.md @@ -0,0 +1,205 @@ +[![npm][npm-image]][npm-url] +[![npm-downloads][npm-downloads-image]][npm-url] +
+[![code-style-prettier][code-style-prettier-image]][code-style-prettier-url] + +[code-style-prettier-image]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square +[code-style-prettier-url]: https://github.com/prettier/prettier +[npm-downloads-image]: https://img.shields.io/npm/dm/@solana/transaction-introspection?style=flat +[npm-image]: https://img.shields.io/npm/v/@solana/transaction-introspection?style=flat +[npm-url]: https://www.npmjs.com/package/@solana/transaction-introspection + +# @solana/transaction-introspection + +This package contains helpers for inspecting a confirmed Solana transaction's instructions — both top-level and inner CPI — in a form that the auto-generated `@solana-program/*` clients can `identify` and `parse` directly. It can be used standalone, but it is also exported as part of Kit [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit). + +The kit codecs decode a `getTransaction` response down to a `CompiledTransactionMessage`. The per-program clients (`identifyTokenInstruction`, `parseSyncNativeInstruction`, etc.) accept kit `Instruction` objects. This package fills the gap between them: it decodes the wire transaction, resolves account indices against static keys plus ALT-loaded addresses, normalizes the JSON-shape inner instructions from `meta.innerInstructions`, and returns a single list of traced instructions directly usable with the auto-generated `@solana-program/*` clients and with `isInstructionForProgram` from `@solana/instructions`. Supports `legacy`, `v0`, and `v1` compiled transaction messages. + +## Quick start + +Audit every Token-Program `SyncNative` instruction — outer or inner CPI — in a confirmed transaction: + +```ts +import { createSolanaRpc, signature } from '@solana/kit'; +import { isInstructionForProgram, isInstructionWithData } from '@solana/instructions'; +import { decodeTransactionFromRpcResponse, walkInstructions } from '@solana/transaction-introspection'; +import { identifyTokenInstruction, TOKEN_PROGRAM_ADDRESS, TokenInstruction } from '@solana-program/token'; + +const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com'); +const rpcTx = await rpc + .getTransaction(signature(txid), { + commitment: 'confirmed', + encoding: 'base64', + maxSupportedTransactionVersion: 0, + }) + .send(); +if (!rpcTx) throw new Error(`Transaction ${txid} not found`); + +const { compiledMessage, loadedAddresses } = decodeTransactionFromRpcResponse(rpcTx); + +for (const ix of walkInstructions({ compiledMessage, loadedAddresses, meta: rpcTx.meta })) { + if (!isInstructionForProgram(ix, TOKEN_PROGRAM_ADDRESS) || !isInstructionWithData(ix)) continue; + if (identifyTokenInstruction(ix) !== TokenInstruction.SyncNative) continue; + console.log( + `SyncNative at ${ix.trace.kind === 'outer' ? `outer[${ix.trace.index}]` : `inner[${ix.trace.outerIndex}/${ix.trace.innerIndex}]`}`, + ); +} +``` + +## Functions + +### `decodeTransactionFromRpcResponse(rpcTx)` + +Decodes a `getTransaction` response — `encoding: 'base64'`, `'base58'`, or `'json'` — into a `DecodedRpcTransaction`: the `CompiledTransactionMessage` (always carrying the recent blockhash in `lifetimeToken`), the loaded ALT addresses pulled from `meta` (or empty arrays for legacy transactions where `meta.loadedAddresses` is not present), and — for `'base64'` and `'base58'` only — the re-encodable wire-format `Transaction`. + +Prefer `encoding: 'base64'` when bandwidth allows — it is the most compact, the wire bytes round-trip cleanly through the kit codecs, and the return type statically guarantees a non-undefined `transaction`. `encoding: 'json'` is also accepted, but `transaction` is omitted because the server has already decompiled the wire format and there are no message bytes to carry. `encoding: 'jsonParsed'` is **not** supported — its instructions arrive pre-parsed and lack raw bytes, so they cannot be round-tripped through the auto-generated `parseXInstruction` clients. + +```ts +import { createSolanaRpc, signature } from '@solana/kit'; +import { decodeTransactionFromRpcResponse } from '@solana/transaction-introspection'; + +const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com'); +const rpcTx = await rpc + .getTransaction(signature(txid), { + commitment: 'confirmed', + encoding: 'base64', + maxSupportedTransactionVersion: 0, + }) + .send(); +if (!rpcTx) throw new Error('not found'); + +const { compiledMessage, loadedAddresses, transaction } = decodeTransactionFromRpcResponse(rpcTx); +``` + +### `getAccountMetasFromCompiledTransactionMessage(compiledMessage, loadedAddresses?)` + +Builds the full ordered list of `AccountMeta`s for the message. If you only need the flat ordered `Address[]`, map over the result: `accountMetas.map(m => m.address)`. Roles are derived from the message header: writable signers, readonly signers, writable non-signers, readonly non-signers — followed by ALT-loaded writable (non-signer, writable) and ALT-loaded readonly (non-signer, readonly). Inner-instruction account indices reference the same flat list, so the result is also useful for resolving inner instructions. + +```ts +import { getAccountMetasFromCompiledTransactionMessage } from '@solana/transaction-introspection'; + +const accountMetas = getAccountMetasFromCompiledTransactionMessage(compiledMessage, loadedAddresses); +``` + +### `getInstructionsFromCompiledTransactionMessage(compiledMessage, loadedAddresses?)` + +Returns the outer instructions of a compiled transaction message as `ResolvedInstruction[]`. Each instruction has its account indices resolved to `AccountMeta`s (with proper signer/writable bits) and its data exposed as a `ReadonlyUint8Array` — the form the auto-generated `@solana-program/*` `parseXInstruction` and `identifyXInstruction` functions expect. Following the kit `Instruction` conventions, `accounts` and `data` are present only when non-empty, so `isInstructionWithAccounts` and `isInstructionWithData` behave as expected. + +```ts +import { isInstructionWithData } from '@solana/instructions'; +import { getInstructionsFromCompiledTransactionMessage } from '@solana/transaction-introspection'; +import { identifyTokenInstruction, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; + +const instructions = getInstructionsFromCompiledTransactionMessage(compiledMessage, loadedAddresses); +for (const ix of instructions) { + if (ix.programAddress === TOKEN_PROGRAM_ADDRESS && isInstructionWithData(ix)) { + const kind = identifyTokenInstruction(ix); + // ... + } +} +``` + +### `getInnerInstructionsFromMeta(meta, accountMetas)` + +Returns the inner instructions in a `getTransaction` response as `TracedInstruction`s. The RPC returns inner instructions in a different shape from the wire format — indices reference the same flat account list as outer instructions, but `data` is base58-encoded. This helper decodes the data, resolves the indices against the supplied `AccountMeta` list, and tags each instruction with an `inner` trace (carrying `outerIndex`, `innerIndex`, and `stackHeight` when the RPC provides one). + +```ts +import { + getAccountMetasFromCompiledTransactionMessage, + getInnerInstructionsFromMeta, +} from '@solana/transaction-introspection'; + +const accountMetas = getAccountMetasFromCompiledTransactionMessage(compiledMessage, loadedAddresses); +const inner = getInnerInstructionsFromMeta(rpcTx.meta, accountMetas); +``` + +### `walkInstructions({ compiledMessage, meta?, loadedAddresses? })` + +Returns every instruction in a confirmed transaction as an array of `TracedInstruction`s, in the order an explorer displays them: each outer instruction followed immediately by the inner instructions its CPIs produced. Each entry is itself a `ResolvedInstruction` (with addresses and roles already resolved) carrying a `trace` property that records whether the instruction is outer or inner (with stack height when the RPC provides it). + +Because each entry is a `ResolvedInstruction`, you can pass it directly to `isInstructionForProgram` from `@solana/instructions` (which narrows the `programAddress` type) and to the auto-generated `identifyXInstruction` / `parseXInstruction` helpers — no separate filter helper is needed. + +If `meta` is omitted, only outer instructions are returned. If `loadedAddresses` is omitted, only static accounts are used to resolve indices — pass `meta?.loadedAddresses` for v0 transactions that load accounts from address lookup tables. + +```ts +import { isInstructionForProgram, isInstructionWithAccounts, isInstructionWithData } from '@solana/instructions'; +import { walkInstructions } from '@solana/transaction-introspection'; +import { + identifyTokenInstruction, + parseSyncNativeInstruction, + TOKEN_PROGRAM_ADDRESS, + TokenInstruction, +} from '@solana-program/token'; + +for (const ix of walkInstructions({ compiledMessage, loadedAddresses, meta: rpcTx.meta })) { + if (!isInstructionForProgram(ix, TOKEN_PROGRAM_ADDRESS)) continue; + // `ix.programAddress` is narrowed to TOKEN_PROGRAM_ADDRESS. + if (!isInstructionWithData(ix) || !isInstructionWithAccounts(ix)) continue; + if (identifyTokenInstruction(ix) === TokenInstruction.SyncNative) { + const parsed = parseSyncNativeInstruction(ix); + console.log(ix.trace, parsed); + } +} +``` + +The same pattern works with any auto-generated `@solana-program/*` client. Here, tally the lamports moved by every System-program `TransferSol` — outer or inner CPI: + +```ts +import { isInstructionForProgram } from '@solana/instructions'; +import { walkInstructions } from '@solana/transaction-introspection'; +import { + identifySystemInstruction, + parseTransferSolInstruction, + SYSTEM_PROGRAM_ADDRESS, + SystemInstruction, +} from '@solana-program/system'; + +let totalLamports = 0n; +for (const ix of walkInstructions({ compiledMessage, loadedAddresses, meta: rpcTx.meta })) { + if (!isInstructionForProgram(ix, SYSTEM_PROGRAM_ADDRESS)) continue; + if (identifySystemInstruction(ix) !== SystemInstruction.TransferSol) continue; + totalLamports += parseTransferSolInstruction(ix).data.amount; +} +``` + +## Types + +### `LoadedAddresses` + +The shape of `meta.loadedAddresses` from `getTransaction`. Two arrays — `writable` and `readonly` — kept in the same order the runtime uses to resolve instruction account indices. + +```ts +import type { LoadedAddresses } from '@solana/transaction-introspection'; + +const loaded: LoadedAddresses = rpcTx.meta?.loadedAddresses ?? { readonly: [], writable: [] }; +``` + +### `DecodedRpcTransaction` + +`{ compiledMessage, loadedAddresses, transaction? }`. `compiledMessage` always carries a `lifetimeToken` (the recent blockhash). `transaction` is present only for `'base64'` and `'base58'` responses; the dispatcher's overloads narrow it to a non-optional `Transaction` for those encodings. + +The input side of `decodeTransactionFromRpcResponse` is typed with the `GetTransactionApiResponseBase64`, `GetTransactionApiResponseBase58`, and `GetTransactionApiResponseJson` types from `@solana/rpc-api` — the non-null response shapes of the corresponding `getTransaction` encodings. + +### `ResolvedInstruction` + +An `Instruction` whose account indices have been resolved to `AccountMeta`s and whose data is exposed as a `ReadonlyUint8Array`. Directly usable with the auto-generated `@solana-program/*` `parseXInstruction` and `identifyXInstruction` functions, and with `isInstructionForProgram` from `@solana/instructions` (which narrows the `TProgramAddress` parameter). `accounts` and `data` are present only when non-empty — use `isInstructionWithAccounts` / `isInstructionWithData` to narrow. + +### `InstructionTrace` + +A discriminated union recording an instruction's location within a transaction: + +- `{ kind: 'outer', index }` — a top-level instruction in the compiled message. +- `{ kind: 'inner', outerIndex, innerIndex, stackHeight? }` — an instruction emitted via cross-program invocation. `stackHeight` is included only when reported by the RPC. + +### `TracedInstruction` + +A `ResolvedInstruction` with an extra `trace: InstructionTrace` property — one entry returned by `walkInstructions`. Because it is itself a `ResolvedInstruction`, it can be passed directly to `isInstructionForProgram` and to the auto-generated `identifyXInstruction` / `parseXInstruction` helpers. + +### `MetaWithInnerInstructions` + +A structural type capturing the minimum shape of `getTransaction`'s `meta` field that `getInnerInstructionsFromMeta` needs. Accepting a structural type keeps callers free to pass the full RPC response without coupling to a specific overload. + +## Notes + +- Supports `legacy`, `v0`, and `v1` compiled transaction messages. Pass any version through `decodeTransactionFromRpcResponse` and `walkInstructions` — account indices, inner instructions, and ALT-loaded addresses resolve identically across versions. To actually receive `v0` or `v1` from the RPC you still need to set `maxSupportedTransactionVersion` on the `getTransaction` call — without it, the server downgrades anything past legacy to an error. +- `decodeTransactionFromRpcResponse` accepts `encoding: 'base64'`, `'base58'`, or `'json'`. `'jsonParsed'` is not supported — its instructions arrive pre-parsed by the server and lack raw bytes. diff --git a/packages/transaction-introspection/package.json b/packages/transaction-introspection/package.json new file mode 100644 index 000000000..ee450b74c --- /dev/null +++ b/packages/transaction-introspection/package.json @@ -0,0 +1,99 @@ +{ + "name": "@solana/transaction-introspection", + "version": "6.9.0", + "description": "Helpers for inspecting confirmed Solana transactions and walking their instructions", + "homepage": "https://www.solanakit.com/api#solanatransaction-introspection", + "exports": { + "edge-light": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "workerd": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "browser": { + "import": "./dist/index.browser.mjs", + "require": "./dist/index.browser.cjs" + }, + "node": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "react-native": "./dist/index.native.mjs", + "types": "./dist/types/index.d.ts" + }, + "browser": { + "./dist/index.node.cjs": "./dist/index.browser.cjs", + "./dist/index.node.mjs": "./dist/index.browser.mjs" + }, + "main": "./dist/index.node.cjs", + "module": "./dist/index.node.mjs", + "react-native": "./dist/index.native.mjs", + "types": "./dist/types/index.d.ts", + "type": "commonjs", + "files": [ + "./dist/", + "./src/" + ], + "sideEffects": false, + "keywords": [ + "blockchain", + "solana", + "web3" + ], + "scripts": { + "compile:docs": "typedoc", + "compile:js": "tsup --config build-scripts/tsup.config.package.ts", + "compile:typedefs": "tsc -p ./tsconfig.declarations.json", + "dev": "NODE_OPTIONS=\"--no-experimental-webstorage\" jest -c ../../node_modules/@solana/test-config/jest-dev.config.js --rootDir . --watch", + "prepublishOnly": "pnpm pkg delete devDependencies", + "publish-packages": "npm view $npm_package_name@$npm_package_version > /dev/null 2>&1 || (pnpm publish --tag ${PUBLISH_TAG:-canary} --access public --no-git-checks && (([ -n \"${GITHUB_OUTPUT:-}\" ] && echo 'published=true' >> \"$GITHUB_OUTPUT\") || true) && (([ \"$PUBLISH_TAG\" != \"canary\" ] && pnpm dist-tag add $npm_package_name@$npm_package_version latest) || true))", + "style:fix": "pnpm eslint --fix src && pnpm prettier --log-level warn --ignore-unknown --write ./*", + "test:lint": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-lint.config.js --rootDir . --silent", + "test:prettier": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-prettier.config.js --rootDir . --silent", + "test:treeshakability:browser": "agadoo dist/index.browser.mjs", + "test:treeshakability:native": "agadoo dist/index.native.mjs", + "test:treeshakability:node": "agadoo dist/index.node.mjs", + "test:typecheck": "tsc --noEmit", + "test:unit:browser": "NODE_OPTIONS=\"--no-experimental-webstorage\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.browser.js --rootDir . --silent", + "test:unit:node": "NODE_OPTIONS=\"--no-experimental-webstorage\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.node.js --rootDir . --silent" + }, + "author": "Solana Labs Maintainers ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anza-xyz/kit" + }, + "bugs": { + "url": "https://github.com/anza-xyz/kit/issues" + }, + "browserslist": [ + "supports bigint and not dead", + "maintained node versions" + ], + "dependencies": { + "@solana/addresses": "workspace:*", + "@solana/codecs-core": "workspace:*", + "@solana/codecs-strings": "workspace:*", + "@solana/errors": "workspace:*", + "@solana/instructions": "workspace:*", + "@solana/rpc-api": "workspace:*", + "@solana/transaction-messages": "workspace:*", + "@solana/transactions": "workspace:*" + }, + "devDependencies": { + "@solana/rpc-types": "workspace:*" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=20.18.0" + } +} diff --git a/packages/transaction-introspection/src/__tests__/decode-rpc-transaction-test.ts b/packages/transaction-introspection/src/__tests__/decode-rpc-transaction-test.ts new file mode 100644 index 000000000..d3d0a0855 --- /dev/null +++ b/packages/transaction-introspection/src/__tests__/decode-rpc-transaction-test.ts @@ -0,0 +1,374 @@ +import type { Address } from '@solana/addresses'; +import { getBase58Decoder, getBase64Decoder, getBase64Encoder } from '@solana/codecs-strings'; +import { + SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, + SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION, + SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE, + SolanaError, +} from '@solana/errors'; +import type { + GetTransactionApiResponseBase58, + GetTransactionApiResponseBase64, + GetTransactionApiResponseJson, +} from '@solana/rpc-api'; +import { getCompiledTransactionMessageEncoder } from '@solana/transaction-messages'; +import { getTransactionEncoder } from '@solana/transactions'; + +import { decodeTransactionFromRpcResponse } from '../decode-rpc-transaction'; + +describe('decodeTransactionFromRpcResponse', () => { + type EncodableCompiledMessage = Parameters['encode']>[0]; + + function buildBase64Tx( + compiledOverrides: Partial = { version: 'legacy' } as EncodableCompiledMessage, + ) { + const compiled = { + header: { + numReadonlyNonSignerAccounts: 0, + numReadonlySignerAccounts: 0, + numSignerAccounts: 1, + }, + instructions: [], + lifetimeToken: '11111111111111111111111111111111', + staticAccounts: ['11111111111111111111111111111112' as Address], + ...compiledOverrides, + } as EncodableCompiledMessage; + const messageBytes = getCompiledTransactionMessageEncoder().encode(compiled); + const transactionBytes = getTransactionEncoder().encode({ + messageBytes: messageBytes as Parameters< + ReturnType['encode'] + >[0]['messageBytes'], + signatures: { ['11111111111111111111111111111112' as Address]: null }, + }); + return getBase64Decoder().decode(transactionBytes); + } + + it('decodes a valid base64 response into a Transaction + CompiledTransactionMessage', () => { + const b64 = buildBase64Tx(); + const rpcTx = { + meta: { loadedAddresses: { readonly: ['ro' as Address], writable: ['w' as Address] } }, + transaction: [b64, 'base64'], + } as unknown as GetTransactionApiResponseBase64<0>; + const result = decodeTransactionFromRpcResponse(rpcTx); + + expect(result.compiledMessage.version).toBe('legacy'); + expect(result.compiledMessage.staticAccounts).toStrictEqual(['11111111111111111111111111111112']); + expect(result.transaction.signatures).toBeDefined(); + expect(result.loadedAddresses).toStrictEqual({ readonly: ['ro'], writable: ['w'] }); + // The wire-decoder path sets `lifetimeToken` from the encoded message. + expect(result.compiledMessage.lifetimeToken).toBe('11111111111111111111111111111111'); + }); + + it('decodes a base64 (v0) response into a v0 CompiledTransactionMessage, with empty loaded addresses when meta is null', () => { + const b64 = buildBase64Tx({ + addressTableLookups: [ + { + lookupTableAddress: '11111111111111111111111111111113' as Address, + readonlyIndexes: [1], + writableIndexes: [0], + }, + ], + version: 0, + } as Partial); + const rpcTx = { meta: null, transaction: [b64, 'base64'] } as unknown as GetTransactionApiResponseBase64<0>; + const result = decodeTransactionFromRpcResponse(rpcTx); + expect(result.compiledMessage.version).toBe(0); + const v0 = result.compiledMessage as Extract; + expect(v0.addressTableLookups).toStrictEqual([ + { + lookupTableAddress: '11111111111111111111111111111113', + readonlyIndexes: [1], + writableIndexes: [0], + }, + ]); + expect(result.loadedAddresses).toStrictEqual({ readonly: [], writable: [] }); + }); + + it('returns empty loaded addresses for a legacy response (meta has no `loadedAddresses` key)', () => { + const b64 = buildBase64Tx(); + // No `maxSupportedTransactionVersion` was passed, so meta lacks `loadedAddresses`. + const rpcTx = { + meta: { fee: 5000n }, + transaction: [b64, 'base64'], + } as unknown as GetTransactionApiResponseBase64; + const result = decodeTransactionFromRpcResponse(rpcTx); + expect(result.loadedAddresses).toStrictEqual({ readonly: [], writable: [] }); + }); + + it('decodes a base64 (v1) response into a v1 CompiledTransactionMessage', () => { + const b64 = buildBase64Tx({ + configMask: 0, + configValues: [], + header: { + numReadonlyNonSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numSignerAccounts: 1, + }, + instructionHeaders: [{ numInstructionAccounts: 1, numInstructionDataBytes: 1, programAccountIndex: 1 }], + instructionPayloads: [{ instructionAccountIndices: [0], instructionData: new Uint8Array([7]) }], + numInstructions: 1, + numStaticAccounts: 2, + staticAccounts: [ + '11111111111111111111111111111112' as Address, + '11111111111111111111111111111113' as Address, + ], + version: 1, + } as Partial); + const rpcTx = { meta: null, transaction: [b64, 'base64'] } as unknown as GetTransactionApiResponseBase64<1>; + const result = decodeTransactionFromRpcResponse(rpcTx); + + expect(result.compiledMessage.version).toBe(1); + const v1 = result.compiledMessage as Extract; + expect(v1.staticAccounts).toStrictEqual([ + '11111111111111111111111111111112', + '11111111111111111111111111111113', + ]); + expect(v1.instructionHeaders[0].programAccountIndex).toBe(1); + expect(v1.instructionPayloads[0].instructionData).toStrictEqual(new Uint8Array([7])); + expect(result.compiledMessage.lifetimeToken).toBe('11111111111111111111111111111111'); + expect(result.transaction.signatures).toBeDefined(); + }); + + it('decodes a valid base58 response into a Transaction + CompiledTransactionMessage', () => { + const b64 = buildBase64Tx(); + const wireBytes = getBase64Encoder().encode(b64) as Uint8Array; + const b58 = getBase58Decoder().decode(wireBytes); + const rpcTx = { + meta: null, + transaction: [b58, 'base58'], + } as unknown as GetTransactionApiResponseBase58<0>; + const result = decodeTransactionFromRpcResponse(rpcTx); + + expect(result.compiledMessage.version).toBe('legacy'); + expect(result.compiledMessage.staticAccounts).toStrictEqual(['11111111111111111111111111111112']); + }); + + it('decodes a JSON (legacy) response into a synthesized CompiledTransactionMessage', () => { + const innerData = '3Bxs411Dtc7pkFQj'; // arbitrary base58 + const rpcTx = { + meta: null, + transaction: { + message: { + accountKeys: ['fee-payer' as Address, 'program' as Address], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [{ accounts: [0], data: innerData, programIdIndex: 1 }], + recentBlockhash: '11111111111111111111111111111111', + }, + signatures: [], + }, + } as unknown as GetTransactionApiResponseJson; + const result = decodeTransactionFromRpcResponse(rpcTx); + + expect(result.compiledMessage.version).toBe('legacy'); + expect(result.compiledMessage.staticAccounts).toStrictEqual(['fee-payer', 'program']); + const legacy = result.compiledMessage as Extract; + expect(legacy.instructions).toHaveLength(1); + const ix = legacy.instructions[0]; + expect(ix.programAddressIndex).toBe(1); + expect(ix.accountIndices).toStrictEqual([0]); + expect(ix.data).toBeInstanceOf(Uint8Array); + // `transaction` is omitted for JSON responses (no re-encodable wire bytes). + expect(result.transaction).toBeUndefined(); + // `lifetimeToken` parity with the base64/base58 paths. + expect(result.compiledMessage.lifetimeToken).toBe('11111111111111111111111111111111'); + }); + + it('decodes a JSON (v0) response with addressTableLookups + loadedAddresses', () => { + const rpcTx = { + meta: { loadedAddresses: { readonly: ['alt-ro' as Address], writable: ['alt-w' as Address] } }, + transaction: { + message: { + accountKeys: ['fee-payer' as Address, 'program' as Address], + addressTableLookups: [{ accountKey: 'lut' as Address, readonlyIndexes: [3], writableIndexes: [2] }], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [{ accounts: [], data: '', programIdIndex: 1 }], + recentBlockhash: '11111111111111111111111111111111', + }, + signatures: [], + }, + version: 0, + } as unknown as GetTransactionApiResponseJson<0>; + const result = decodeTransactionFromRpcResponse(rpcTx); + + expect(result.compiledMessage.version).toBe(0); + expect(result.loadedAddresses).toStrictEqual({ readonly: ['alt-ro'], writable: ['alt-w'] }); + const v0 = result.compiledMessage as Extract; + expect(v0.addressTableLookups).toStrictEqual([ + { lookupTableAddress: 'lut', readonlyIndexes: [3], writableIndexes: [2] }, + ]); + }); + + it('omits `addressTableLookups` from a JSON (v0) response that has none', () => { + const rpcTx = { + meta: null, + transaction: { + message: { + accountKeys: ['fee-payer' as Address, 'program' as Address], + addressTableLookups: [], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [{ accounts: [0], data: '3Bxs411Dtc7pkFQj', programIdIndex: 1 }], + recentBlockhash: '11111111111111111111111111111111', + }, + signatures: [], + }, + version: 0, + } as unknown as GetTransactionApiResponseJson<0>; + const result = decodeTransactionFromRpcResponse(rpcTx); + expect(result.compiledMessage.version).toBe(0); + expect(result.compiledMessage).not.toHaveProperty('addressTableLookups'); + }); + + it('decodes a JSON (v1) response into a synthesized V1CompiledTransactionMessage', () => { + const innerData = '3Bxs411Dtc7pkFQj'; // arbitrary base58 + const rpcTx = { + meta: null, + transaction: { + message: { + accountKeys: ['fee-payer' as Address, 'program' as Address], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [{ accounts: [0], data: innerData, programIdIndex: 1 }], + recentBlockhash: '11111111111111111111111111111111', + }, + signatures: [], + }, + version: 1, + } as unknown as GetTransactionApiResponseJson<1>; + const result = decodeTransactionFromRpcResponse(rpcTx); + + expect(result.compiledMessage.version).toBe(1); + const v1 = result.compiledMessage as Extract; + expect(v1.staticAccounts).toStrictEqual(['fee-payer', 'program']); + expect(v1.numStaticAccounts).toBe(2); + expect(v1.numInstructions).toBe(1); + expect(v1.instructionHeaders).toHaveLength(1); + expect(v1.instructionHeaders[0].programAccountIndex).toBe(1); + expect(v1.instructionHeaders[0].numInstructionAccounts).toBe(1); + expect(v1.instructionPayloads[0].instructionAccountIndices).toStrictEqual([0]); + expect(v1.instructionHeaders[0].numInstructionDataBytes).toBe( + v1.instructionPayloads[0].instructionData.byteLength, + ); + expect(result.compiledMessage.lifetimeToken).toBe('11111111111111111111111111111111'); + expect(result.transaction).toBeUndefined(); + }); + + it('throws SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED for an unknown JSON transaction version', () => { + const rpcTx = { + meta: null, + transaction: { + message: { + accountKeys: ['fee-payer' as Address, 'program' as Address], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [{ accounts: [0], data: '3Bxs411Dtc7pkFQj', programIdIndex: 1 }], + recentBlockhash: '11111111111111111111111111111111', + }, + signatures: [], + }, + version: 99, + } as unknown as GetTransactionApiResponseJson; + expect(() => decodeTransactionFromRpcResponse(rpcTx)).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, { unsupportedVersion: 99 }), + ); + }); + + it('throws SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE for an unrecognized response shape', () => { + const rpcTx = { meta: null, transaction: 'totally bogus' } as unknown as GetTransactionApiResponseBase64; + expect(() => decodeTransactionFromRpcResponse(rpcTx)).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE), + ); + }); + + it('rejects a `jsonParsed` response with `parsed` instructions', () => { + // jsonParsed wraps each instruction in a `{ parsed, program, programId }` shape rather + // than the indexed shape `decodeFromJson` expects. Without an explicit shape check this + // would silently emit a corrupt CompiledTransactionMessage. + const rpcTx = { + meta: null, + transaction: { + message: { + accountKeys: [ + { pubkey: 'fee-payer' as Address, signer: true, source: 'transaction', writable: true }, + ], + instructions: [ + { + parsed: { info: {}, type: 'transfer' }, + program: 'system', + programId: '11111111111111111111111111111111' as Address, + }, + ], + recentBlockhash: '11111111111111111111111111111111', + }, + signatures: [], + }, + } as unknown as GetTransactionApiResponseJson; + expect(() => decodeTransactionFromRpcResponse(rpcTx)).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION), + ); + }); + + it('rejects a `jsonParsed` response with no instructions', () => { + // A fee-only transaction has no instructions to sniff, but the absence + // of `message.header` still identifies the response as `jsonParsed`. + const rpcTx = { + meta: null, + transaction: { + message: { + accountKeys: [ + { pubkey: 'fee-payer' as Address, signer: true, source: 'transaction', writable: true }, + ], + instructions: [], + recentBlockhash: '11111111111111111111111111111111', + }, + signatures: [], + }, + } as unknown as GetTransactionApiResponseJson; + expect(() => decodeTransactionFromRpcResponse(rpcTx)).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION), + ); + }); + + it('rejects a `jsonParsed` response with `partiallyDecoded` instructions', () => { + // Partially-decoded jsonParsed instructions lack programIdIndex/accounts:number[] too. + const rpcTx = { + meta: null, + transaction: { + message: { + accountKeys: [ + { pubkey: 'fee-payer' as Address, signer: true, source: 'transaction', writable: true }, + ], + instructions: [ + { + accounts: ['some-address' as Address], + data: 'base58data', + programId: '11111111111111111111111111111111' as Address, + }, + ], + recentBlockhash: '11111111111111111111111111111111', + }, + signatures: [], + }, + } as unknown as GetTransactionApiResponseJson; + expect(() => decodeTransactionFromRpcResponse(rpcTx)).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION), + ); + }); +}); diff --git a/packages/transaction-introspection/src/__tests__/get-inner-instructions-test.ts b/packages/transaction-introspection/src/__tests__/get-inner-instructions-test.ts new file mode 100644 index 000000000..4f94ff529 --- /dev/null +++ b/packages/transaction-introspection/src/__tests__/get-inner-instructions-test.ts @@ -0,0 +1,133 @@ +import type { Address } from '@solana/addresses'; +import { getBase58Decoder } from '@solana/codecs-strings'; +import { + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, + SolanaError, +} from '@solana/errors'; +import { type AccountMeta, AccountRole } from '@solana/instructions'; +import type { Base58EncodedBytes } from '@solana/rpc-types'; + +import { getInnerInstructionsFromMeta } from '../get-inner-instructions'; + +const base58 = getBase58Decoder(); + +function asB58(bytes: Uint8Array): Base58EncodedBytes { + return base58.decode(bytes) as Base58EncodedBytes; +} + +describe('getInnerInstructionsFromMeta', () => { + const accountMetas: AccountMeta[] = [ + { address: 'fee-payer' as Address, role: AccountRole.WRITABLE_SIGNER }, + { address: 'program' as Address, role: AccountRole.READONLY }, + { address: 'data-account' as Address, role: AccountRole.WRITABLE }, + ]; + + it('returns nothing when meta has no inner instructions', () => { + expect(getInnerInstructionsFromMeta({}, accountMetas)).toStrictEqual([]); + expect(getInnerInstructionsFromMeta({ innerInstructions: null }, accountMetas)).toStrictEqual([]); + }); + + it('decodes base58 data and resolves indices into AccountMetas', () => { + const out = getInnerInstructionsFromMeta( + { + innerInstructions: [ + { + index: 0, + instructions: [ + { + accounts: [0, 2], + data: asB58(new Uint8Array([9, 8, 7])), + programIdIndex: 1, + stackHeight: 2, + }, + ], + }, + ], + }, + accountMetas, + ); + + expect(out).toHaveLength(1); + expect(out[0].trace).toStrictEqual({ innerIndex: 0, kind: 'inner', outerIndex: 0, stackHeight: 2 }); + expect(out[0].programAddress).toBe('program'); + expect(out[0].accounts).toStrictEqual([ + { address: 'fee-payer', role: AccountRole.WRITABLE_SIGNER }, + { address: 'data-account', role: AccountRole.WRITABLE }, + ]); + expect(out[0].data).toStrictEqual(new Uint8Array([9, 8, 7])); + }); + + it('throws when an inner programIdIndex is out of range', () => { + const dataB58 = asB58(new Uint8Array([1])); + expect(() => + getInnerInstructionsFromMeta( + { + innerInstructions: [ + { + index: 0, + instructions: [{ accounts: [], data: dataB58, programIdIndex: 99 }], + }, + ], + }, + accountMetas, + ), + ).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, { + index: 99, + }), + ); + }); + + it('throws when an inner account index is out of range', () => { + const dataB58 = asB58(new Uint8Array([1])); + expect(() => + getInnerInstructionsFromMeta( + { + innerInstructions: [ + { + index: 0, + instructions: [{ accounts: [42], data: dataB58, programIdIndex: 1 }], + }, + ], + }, + accountMetas, + ), + ).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, { + index: 42, + }), + ); + }); + + it('omits `accounts` and `data` when an inner instruction has none', () => { + const [traced] = getInnerInstructionsFromMeta( + { + innerInstructions: [ + { + index: 0, + instructions: [{ accounts: [], data: asB58(new Uint8Array()), programIdIndex: 1 }], + }, + ], + }, + accountMetas, + ); + expect(traced).not.toHaveProperty('accounts'); + expect(traced).not.toHaveProperty('data'); + }); + + it('omits stackHeight from the trace when the RPC did not report one', () => { + const [traced] = getInnerInstructionsFromMeta( + { + innerInstructions: [ + { + index: 3, + instructions: [{ accounts: [], data: asB58(new Uint8Array([1])), programIdIndex: 1 }], + }, + ], + }, + accountMetas, + ); + expect(traced.trace).toStrictEqual({ innerIndex: 0, kind: 'inner', outerIndex: 3 }); + }); +}); diff --git a/packages/transaction-introspection/src/__tests__/get-instructions-test.ts b/packages/transaction-introspection/src/__tests__/get-instructions-test.ts new file mode 100644 index 000000000..32e0075ae --- /dev/null +++ b/packages/transaction-introspection/src/__tests__/get-instructions-test.ts @@ -0,0 +1,196 @@ +import type { Address } from '@solana/addresses'; +import { + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, + SOLANA_ERROR__TRANSACTION__INSTRUCTION_HEADERS_PAYLOADS_MISMATCH, + SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, + SolanaError, +} from '@solana/errors'; +import { AccountRole } from '@solana/instructions'; +import type { CompiledTransactionMessage } from '@solana/transaction-messages'; + +import { + getAccountMetasFromCompiledTransactionMessage, + getInstructionsFromCompiledTransactionMessage, +} from '../get-instructions'; + +describe('getAccountMetasFromCompiledTransactionMessage', () => { + it('produces signer/writable bits per the legacy header', () => { + const compiled = { + header: { + numReadonlyNonSignerAccounts: 1, + numReadonlySignerAccounts: 1, + numSignerAccounts: 2, + }, + staticAccounts: [ + 'writable-signer' as Address, + 'readonly-signer' as Address, + 'writable-nonsigner' as Address, + 'readonly-nonsigner' as Address, + ], + version: 'legacy', + } as CompiledTransactionMessage; + + expect(getAccountMetasFromCompiledTransactionMessage(compiled)).toStrictEqual([ + { address: 'writable-signer', role: AccountRole.WRITABLE_SIGNER }, + { address: 'readonly-signer', role: AccountRole.READONLY_SIGNER }, + { address: 'writable-nonsigner', role: AccountRole.WRITABLE }, + { address: 'readonly-nonsigner', role: AccountRole.READONLY }, + ]); + }); + + it('appends ALT writable then ALT readonly with non-signer roles', () => { + const compiled = { + header: { + numReadonlyNonSignerAccounts: 0, + numReadonlySignerAccounts: 0, + numSignerAccounts: 1, + }, + staticAccounts: ['fee-payer' as Address], + version: 0, + } as CompiledTransactionMessage; + + expect( + getAccountMetasFromCompiledTransactionMessage(compiled, { + readonly: ['alt-ro' as Address], + writable: ['alt-w' as Address], + }), + ).toStrictEqual([ + { address: 'fee-payer', role: AccountRole.WRITABLE_SIGNER }, + { address: 'alt-w', role: AccountRole.WRITABLE }, + { address: 'alt-ro', role: AccountRole.READONLY }, + ]); + }); +}); + +describe('getInstructionsFromCompiledTransactionMessage', () => { + const compiled = { + header: { + numReadonlyNonSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numSignerAccounts: 1, + }, + instructions: [ + { + accountIndices: [0, 2], + data: new Uint8Array([1, 2, 3]), + programAddressIndex: 1, + }, + ], + staticAccounts: ['fee-payer' as Address, 'program' as Address], + version: 'legacy', + } as CompiledTransactionMessage; + + it('resolves program address and account metas', () => { + const result = getInstructionsFromCompiledTransactionMessage(compiled, { + readonly: [], + writable: ['alt-w' as Address], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + accounts: [ + { address: 'fee-payer', role: AccountRole.WRITABLE_SIGNER }, + { address: 'alt-w', role: AccountRole.WRITABLE }, + ], + programAddress: 'program', + }); + expect(result[0].data).toStrictEqual(new Uint8Array([1, 2, 3])); + }); + + it('throws if a program address index is out of range', () => { + const broken = { + ...compiled, + instructions: [{ accountIndices: [0], data: new Uint8Array(), programAddressIndex: 99 }], + } as CompiledTransactionMessage; + expect(() => getInstructionsFromCompiledTransactionMessage(broken)).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, { + index: 99, + }), + ); + }); + + it('throws if an account index is out of range', () => { + const broken = { + ...compiled, + instructions: [{ accountIndices: [42], data: new Uint8Array([1]), programAddressIndex: 1 }], + } as CompiledTransactionMessage; + expect(() => getInstructionsFromCompiledTransactionMessage(broken)).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, { + index: 42, + }), + ); + }); + + it('omits `accounts` and `data` when the compiled instruction has none', () => { + const noArgs = { + ...compiled, + instructions: [{ programAddressIndex: 1 }], + } as CompiledTransactionMessage; + const [ix] = getInstructionsFromCompiledTransactionMessage(noArgs); + expect(ix).not.toHaveProperty('accounts'); + expect(ix).not.toHaveProperty('data'); + }); + + it('resolves v1 messages by zipping instructionHeaders + instructionPayloads', () => { + const v1 = { + configMask: 0, + configValues: [], + header: { + numReadonlyNonSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numSignerAccounts: 1, + }, + instructionHeaders: [{ numInstructionAccounts: 2, numInstructionDataBytes: 3, programAccountIndex: 1 }], + instructionPayloads: [{ instructionAccountIndices: [0, 2], instructionData: new Uint8Array([1, 2, 3]) }], + numInstructions: 1, + numStaticAccounts: 2, + staticAccounts: ['fee-payer' as Address, 'program' as Address], + version: 1, + } as unknown as CompiledTransactionMessage; + + const result = getInstructionsFromCompiledTransactionMessage(v1, { + readonly: [], + writable: ['alt-w' as Address], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + accounts: [ + { address: 'fee-payer', role: AccountRole.WRITABLE_SIGNER }, + { address: 'alt-w', role: AccountRole.WRITABLE }, + ], + programAddress: 'program', + }); + expect(result[0].data).toStrictEqual(new Uint8Array([1, 2, 3])); + }); + + it('throws SOLANA_ERROR__TRANSACTION__INSTRUCTION_HEADERS_PAYLOADS_MISMATCH for mismatched v1 messages', () => { + const v1 = { + configMask: 0, + configValues: [], + header: { + numReadonlyNonSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numSignerAccounts: 1, + }, + instructionHeaders: [{ numInstructionAccounts: 0, numInstructionDataBytes: 0, programAccountIndex: 1 }], + instructionPayloads: [], + numInstructions: 1, + numStaticAccounts: 2, + staticAccounts: ['fee-payer' as Address, 'program' as Address], + version: 1, + } as unknown as CompiledTransactionMessage; + expect(() => getInstructionsFromCompiledTransactionMessage(v1)).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION__INSTRUCTION_HEADERS_PAYLOADS_MISMATCH, { + numInstructionHeaders: 1, + numInstructionPayloads: 0, + }), + ); + }); + + it('throws SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED for unknown versions', () => { + const vN = { ...compiled, version: 99 } as unknown as CompiledTransactionMessage; + expect(() => getInstructionsFromCompiledTransactionMessage(vN)).toThrow( + new SolanaError(SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, { unsupportedVersion: 99 }), + ); + }); +}); diff --git a/packages/transaction-introspection/src/__tests__/walk-instructions-test.ts b/packages/transaction-introspection/src/__tests__/walk-instructions-test.ts new file mode 100644 index 000000000..75999d57d --- /dev/null +++ b/packages/transaction-introspection/src/__tests__/walk-instructions-test.ts @@ -0,0 +1,224 @@ +import type { Address } from '@solana/addresses'; +import { getBase58Decoder } from '@solana/codecs-strings'; +import { isInstructionForProgram } from '@solana/instructions'; +import type { GetTransactionApiResponseJson } from '@solana/rpc-api'; +import type { Base58EncodedBytes } from '@solana/rpc-types'; +import type { CompiledTransactionMessage } from '@solana/transaction-messages'; + +import { decodeTransactionFromRpcResponse } from '../decode-rpc-transaction'; +import { walkInstructions } from '../walk-instructions'; + +const base58 = getBase58Decoder(); +const asB58 = (bytes: Uint8Array): Base58EncodedBytes => base58.decode(bytes) as Base58EncodedBytes; + +const compiled = { + header: { + numReadonlyNonSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numSignerAccounts: 1, + }, + instructions: [ + { accountIndices: [0], data: new Uint8Array([1]), programAddressIndex: 1 }, + { accountIndices: [0], data: new Uint8Array([2]), programAddressIndex: 2 }, + ], + staticAccounts: ['fee-payer' as Address, 'program-a' as Address, 'program-b' as Address], + version: 'legacy', +} as CompiledTransactionMessage; + +describe('walkInstructions', () => { + it('returns outer instructions in order with `outer` traces', () => { + const out = walkInstructions({ compiledMessage: compiled }); + expect(out).toHaveLength(2); + expect(out[0].trace).toStrictEqual({ index: 0, kind: 'outer' }); + expect(out[0].programAddress).toBe('program-a'); + expect(out[1].trace).toStrictEqual({ index: 1, kind: 'outer' }); + expect(out[1].programAddress).toBe('program-b'); + }); + + it('interleaves inner instructions after their outer instruction', () => { + const out = walkInstructions({ + compiledMessage: compiled, + meta: { + innerInstructions: [ + { + index: 1, + instructions: [ + { accounts: [0], data: asB58(new Uint8Array([43])), programIdIndex: 2, stackHeight: 2 }, + ], + }, + { + index: 0, + instructions: [ + { accounts: [0], data: asB58(new Uint8Array([42])), programIdIndex: 1, stackHeight: 2 }, + { accounts: [0], data: asB58(new Uint8Array([44])), programIdIndex: 1, stackHeight: 3 }, + ], + }, + ], + }, + }); + // Display order: outer[0], its inner instructions, outer[1], its inner instructions. + expect(out.map(ix => ix.trace)).toStrictEqual([ + { index: 0, kind: 'outer' }, + { innerIndex: 0, kind: 'inner', outerIndex: 0, stackHeight: 2 }, + { innerIndex: 1, kind: 'inner', outerIndex: 0, stackHeight: 3 }, + { index: 1, kind: 'outer' }, + { innerIndex: 0, kind: 'inner', outerIndex: 1, stackHeight: 2 }, + ]); + expect(out[1].programAddress).toBe('program-a'); + expect(out[4].programAddress).toBe('program-b'); + }); + + it('appends inner groups whose index matches no outer instruction', () => { + const out = walkInstructions({ + compiledMessage: compiled, + meta: { + innerInstructions: [ + { + index: 99, + instructions: [ + { accounts: [0], data: asB58(new Uint8Array([42])), programIdIndex: 1, stackHeight: 2 }, + ], + }, + ], + }, + }); + expect(out).toHaveLength(3); + expect(out[2].trace).toStrictEqual({ innerIndex: 0, kind: 'inner', outerIndex: 99, stackHeight: 2 }); + }); + + it('returned items are usable directly with isInstructionForProgram', () => { + const PROGRAM_B = 'program-b' as Address<'program-b'>; + const filtered = walkInstructions({ compiledMessage: compiled }).filter(ix => + isInstructionForProgram(ix, PROGRAM_B), + ); + expect(filtered).toHaveLength(1); + expect(filtered[0].programAddress).toBe(PROGRAM_B); + }); +}); + +describe('walkInstructions over a JSON-decoded transaction', () => { + it('returns outer + inner instructions from a JSON `getTransaction` response', () => { + const innerData = asB58(new Uint8Array([42])); + const rpcTx = { + meta: { + innerInstructions: [ + { + index: 0, + instructions: [{ accounts: [0], data: innerData, programIdIndex: 1, stackHeight: 2 }], + }, + ], + }, + transaction: { + message: { + accountKeys: ['fee-payer' as Address, 'program-a' as Address], + header: { + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + numRequiredSignatures: 1, + }, + instructions: [{ accounts: [0], data: asB58(new Uint8Array([7])), programIdIndex: 1 }], + recentBlockhash: '11111111111111111111111111111111', + }, + signatures: [], + }, + } as unknown as GetTransactionApiResponseJson; + + const { compiledMessage, loadedAddresses } = decodeTransactionFromRpcResponse(rpcTx); + const traced = walkInstructions({ compiledMessage, loadedAddresses, meta: rpcTx.meta }); + expect(traced).toHaveLength(2); + expect(traced[0].trace).toStrictEqual({ index: 0, kind: 'outer' }); + expect(traced[0].programAddress).toBe('program-a'); + expect(traced[0].data).toStrictEqual(new Uint8Array([7])); + expect(traced[1].trace).toStrictEqual({ innerIndex: 0, kind: 'inner', outerIndex: 0, stackHeight: 2 }); + }); +}); + +describe('walkInstructions over a v1 transaction', () => { + const v1Compiled = { + configMask: 0, + configValues: [], + header: { + numReadonlyNonSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numSignerAccounts: 1, + }, + instructionHeaders: [ + { numInstructionAccounts: 1, numInstructionDataBytes: 1, programAccountIndex: 1 }, + { numInstructionAccounts: 1, numInstructionDataBytes: 1, programAccountIndex: 2 }, + ], + instructionPayloads: [ + { instructionAccountIndices: [0], instructionData: new Uint8Array([1]) }, + { instructionAccountIndices: [0], instructionData: new Uint8Array([2]) }, + ], + numInstructions: 2, + numStaticAccounts: 3, + staticAccounts: ['fee-payer' as Address, 'program-a' as Address, 'program-b' as Address], + version: 1, + } as unknown as CompiledTransactionMessage; + + it('returns v1 outer instructions in order', () => { + const out = walkInstructions({ compiledMessage: v1Compiled }); + expect(out).toHaveLength(2); + expect(out[0].trace).toStrictEqual({ index: 0, kind: 'outer' }); + expect(out[0].programAddress).toBe('program-a'); + expect(out[0].data).toStrictEqual(new Uint8Array([1])); + expect(out[1].trace).toStrictEqual({ index: 1, kind: 'outer' }); + expect(out[1].programAddress).toBe('program-b'); + }); + + it('walks v1 inner instructions alongside outer', () => { + const innerData = asB58(new Uint8Array([42])); + const out = walkInstructions({ + compiledMessage: v1Compiled, + meta: { + innerInstructions: [ + { + index: 0, + instructions: [{ accounts: [0], data: innerData, programIdIndex: 2, stackHeight: 2 }], + }, + ], + }, + }); + expect(out).toHaveLength(3); + expect(out[1].trace).toStrictEqual({ innerIndex: 0, kind: 'inner', outerIndex: 0, stackHeight: 2 }); + expect(out[1].programAddress).toBe('program-b'); + expect(out[2].trace).toStrictEqual({ index: 1, kind: 'outer' }); + }); +}); + +describe('walkInstructions over an ALT-loaded v0 transaction', () => { + const v0Compiled = { + header: { + numReadonlyNonSignerAccounts: 0, + numReadonlySignerAccounts: 0, + numSignerAccounts: 1, + }, + instructions: [{ accountIndices: [0, 3], data: new Uint8Array([7]), programAddressIndex: 2 }], + staticAccounts: ['fee-payer' as Address, 'static-readonly' as Address], + version: 0, + } as CompiledTransactionMessage; + + it('resolves indices spanning static + ALT writable + ALT readonly', () => { + const innerData = asB58(new Uint8Array([8])); + const traced = walkInstructions({ + compiledMessage: v0Compiled, + loadedAddresses: { readonly: ['alt-readonly' as Address], writable: ['alt-writable' as Address] }, + meta: { + innerInstructions: [ + { + index: 0, + instructions: [{ accounts: [0, 2], data: innerData, programIdIndex: 3, stackHeight: 2 }], + }, + ], + }, + }); + + expect(traced).toHaveLength(2); + expect(traced[0].trace).toStrictEqual({ index: 0, kind: 'outer' }); + expect(traced[0].programAddress).toBe('alt-writable'); + expect(traced[0].accounts?.map(a => a.address)).toStrictEqual(['fee-payer', 'alt-readonly']); + expect(traced[1].trace).toStrictEqual({ innerIndex: 0, kind: 'inner', outerIndex: 0, stackHeight: 2 }); + expect(traced[1].programAddress).toBe('alt-readonly'); + expect(traced[1].accounts?.map(a => a.address)).toStrictEqual(['fee-payer', 'alt-writable']); + }); +}); diff --git a/packages/transaction-introspection/src/__typetests__/decode-rpc-transaction-typetest.ts b/packages/transaction-introspection/src/__typetests__/decode-rpc-transaction-typetest.ts new file mode 100644 index 000000000..a16f36e7b --- /dev/null +++ b/packages/transaction-introspection/src/__typetests__/decode-rpc-transaction-typetest.ts @@ -0,0 +1,23 @@ +import type { + GetTransactionApiResponseBase58, + GetTransactionApiResponseBase64, + GetTransactionApiResponseJson, +} from '@solana/rpc-api'; +import type { Transaction } from '@solana/transactions'; + +import { decodeTransactionFromRpcResponse } from '../decode-rpc-transaction'; + +void (() => { + const b64 = null as unknown as GetTransactionApiResponseBase64; + const b58 = null as unknown as GetTransactionApiResponseBase58; + const j = null as unknown as GetTransactionApiResponseJson; + + // base64 / base58 narrow `transaction` to a guaranteed `Transaction`. + decodeTransactionFromRpcResponse(b64).transaction satisfies Transaction; + decodeTransactionFromRpcResponse(b58).transaction satisfies Transaction; + + // JSON path returns optional `transaction` — must not be assignable to a bare `Transaction`. + decodeTransactionFromRpcResponse(j).transaction satisfies Transaction | undefined; + // @ts-expect-error — JSON path's `transaction` is optional and must not narrow to `Transaction`. + decodeTransactionFromRpcResponse(j).transaction satisfies Transaction; +}); diff --git a/packages/transaction-introspection/src/__typetests__/traced-instruction-typetest.ts b/packages/transaction-introspection/src/__typetests__/traced-instruction-typetest.ts new file mode 100644 index 000000000..31a86d6b5 --- /dev/null +++ b/packages/transaction-introspection/src/__typetests__/traced-instruction-typetest.ts @@ -0,0 +1,58 @@ +import type { Address } from '@solana/addresses'; +import type { ReadonlyUint8Array } from '@solana/codecs-core'; +import { + type AccountMeta, + isInstructionForProgram, + isInstructionWithAccounts, + isInstructionWithData, +} from '@solana/instructions'; + +import type { TracedInstruction } from '../types'; +import { walkInstructions } from '../walk-instructions'; + +const PROGRAM = 'MyProgram1111111111111111111111111111111111' as Address<'MyProgram1111111111111111111111111111111111'>; + +void (() => { + const instructions: TracedInstruction[] = walkInstructions({ + compiledMessage: null as never, + }); + + { + // `isInstructionForProgram` narrows the iteration variable's + // `programAddress` while keeping the `trace` property accessible. + for (const ix of instructions) { + if (isInstructionForProgram(ix, PROGRAM)) { + ix.programAddress satisfies Address<'MyProgram1111111111111111111111111111111111'>; + ix.trace.kind satisfies 'inner' | 'outer'; + // @ts-expect-error — must not widen back to a different program address. + ix.programAddress satisfies Address<'OtherProgram111111111111111111111111111111'>; + } + } + } + + { + // Without the predicate, `programAddress` is `Address`. + for (const ix of instructions) { + ix.programAddress satisfies Address; + // @ts-expect-error — `Address` should not narrow to a specific program. + ix.programAddress satisfies Address<'MyProgram1111111111111111111111111111111111'>; + } + } + + { + // `accounts` and `data` are optional until narrowed; the narrows keep + // `trace` accessible, as the auto-generated `parseXInstruction` + // helpers (which require both) rely on. + for (const ix of instructions) { + // @ts-expect-error — `data` is optional until narrowed. + ix.data satisfies ReadonlyUint8Array; + // @ts-expect-error — `accounts` is optional until narrowed. + ix.accounts satisfies readonly AccountMeta[]; + if (isInstructionWithData(ix) && isInstructionWithAccounts(ix)) { + ix.data satisfies ReadonlyUint8Array; + ix.accounts satisfies readonly AccountMeta[]; + ix.trace.kind satisfies 'inner' | 'outer'; + } + } + } +}); diff --git a/packages/transaction-introspection/src/decode-rpc-transaction.ts b/packages/transaction-introspection/src/decode-rpc-transaction.ts new file mode 100644 index 000000000..6ec73b96f --- /dev/null +++ b/packages/transaction-introspection/src/decode-rpc-transaction.ts @@ -0,0 +1,315 @@ +import type { Address } from '@solana/addresses'; +import { getBase58Encoder, getBase64Encoder } from '@solana/codecs-strings'; +import { + SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, + SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION, + SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE, + SolanaError, +} from '@solana/errors'; +import type { + GetTransactionApiResponseBase58, + GetTransactionApiResponseBase64, + GetTransactionApiResponseJson, +} from '@solana/rpc-api'; +import type { + CompiledTransactionMessage, + CompiledTransactionMessageWithLifetime, + LegacyCompiledTransactionMessage, + TransactionVersion, + V0CompiledTransactionMessage, + V1CompiledTransactionMessage, +} from '@solana/transaction-messages'; +import { getCompiledTransactionMessageDecoder } from '@solana/transaction-messages'; +import type { Transaction } from '@solana/transactions'; +import { getTransactionDecoder } from '@solana/transactions'; + +import type { LoadedAddresses } from './loaded-addresses'; + +type AnyGetTransactionResponse = + | GetTransactionApiResponseBase58 + | GetTransactionApiResponseBase64 + | GetTransactionApiResponseJson; + +/** + * The result of decoding a `getTransaction` response: the + * {@link CompiledTransactionMessage} (always with a `lifetimeToken` carrying + * the recent blockhash), the loaded ALT addresses pulled from `meta` (if + * any), and — for `'base64'` and `'base58'` responses — the wire-format + * {@link Transaction}. + * + * `transaction` is omitted for `encoding: 'json'` responses: the server + * has already decompiled the wire format, so there are no message bytes + * to round-trip. If you need a re-encodable {@link Transaction}, fetch + * the response with `encoding: 'base64'`. + * + * @example + * ```ts + * const { compiledMessage, loadedAddresses, transaction } = + * decodeTransactionFromRpcResponse(rpcResponse); + * ``` + */ +export type DecodedRpcTransaction = Readonly<{ + compiledMessage: CompiledTransactionMessage & CompiledTransactionMessageWithLifetime; + loadedAddresses: LoadedAddresses; + transaction?: Transaction; +}>; + +const EMPTY_LOADED_ADDRESSES: LoadedAddresses = { readonly: [], writable: [] }; + +/** + * Pulls `loadedAddresses` off a `getTransaction` `meta` field if present, + * regardless of which encoding overload produced it. The conditional types + * in `@solana/rpc-api` mean some `meta` shapes statically lack the field + * (legacy responses fetched without `maxSupportedTransactionVersion`); we + * still need a uniform runtime extraction. + */ +function getLoadedAddresses(meta: unknown): LoadedAddresses { + const loaded = (meta as { loadedAddresses?: LoadedAddresses } | null | undefined)?.loadedAddresses; + return loaded ?? EMPTY_LOADED_ADDRESSES; +} + +function decodeFromWire(wireBytes: Uint8Array): { + compiledMessage: CompiledTransactionMessage & CompiledTransactionMessageWithLifetime; + transaction: Transaction; +} { + const transaction = getTransactionDecoder().decode(wireBytes); + const compiledMessage = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); + return { compiledMessage, transaction }; +} + +function decodeFromBase64( + rpcTx: GetTransactionApiResponseBase64, +): DecodedRpcTransaction { + const [b64] = rpcTx.transaction; + const { compiledMessage, transaction } = decodeFromWire(getBase64Encoder().encode(b64) as Uint8Array); + return { compiledMessage, loadedAddresses: getLoadedAddresses(rpcTx.meta), transaction }; +} + +function decodeFromBase58( + rpcTx: GetTransactionApiResponseBase58, +): DecodedRpcTransaction { + const [b58] = rpcTx.transaction; + const { compiledMessage, transaction } = decodeFromWire(getBase58Encoder().encode(b58) as Uint8Array); + return { compiledMessage, loadedAddresses: getLoadedAddresses(rpcTx.meta), transaction }; +} + +function decodeFromJson( + rpcTx: GetTransactionApiResponseJson, +): DecodedRpcTransaction { + const base58 = getBase58Encoder(); + const { message } = rpcTx.transaction; + const staticAccounts: Address[] = [...message.accountKeys]; + + // The wire decoder omits `accountIndices` and `data` when they are + // empty; do the same here so a JSON-derived message has the same shape + // as a wire-derived one. Only the legacy/v0 shapes use this form — the + // v1 branch builds instruction headers and payloads instead. + const getCompiledInstructions = () => + message.instructions.map(ix => ({ + ...(ix.accounts.length ? { accountIndices: [...ix.accounts] } : null), + ...(ix.data.length ? { data: base58.encode(ix.data) } : null), + programAddressIndex: ix.programIdIndex, + })); + + const header = { + numReadonlyNonSignerAccounts: message.header.numReadonlyUnsignedAccounts, + numReadonlySignerAccounts: message.header.numReadonlySignedAccounts, + numSignerAccounts: message.header.numRequiredSignatures, + }; + + // The envelope only carries `version` when `maxSupportedTransactionVersion` + // was set on the request; otherwise the response is necessarily legacy. + const version: TransactionVersion = 'version' in rpcTx ? rpcTx.version : 'legacy'; + + // For transactions whose lifetime is specified by a durable nonce, + // `message.recentBlockhash` is the nonce value, not a blockhash (see + // `GetTransactionApi`). Either way it is the message's lifetime token, + // so it maps onto `lifetimeToken` below — the same field the + // wire-decoder path produces — and consumers see the same shape on + // both encodings. + let compiledMessage: CompiledTransactionMessage & CompiledTransactionMessageWithLifetime; + switch (version) { + case 'legacy': + compiledMessage = { + header, + instructions: getCompiledInstructions(), + lifetimeToken: message.recentBlockhash, + staticAccounts, + version: 'legacy', + } satisfies CompiledTransactionMessageWithLifetime & LegacyCompiledTransactionMessage; + break; + case 0: { + // The wire decoder omits `addressTableLookups` when the message + // has none; match that here for shape parity. + const addressTableLookups = + 'addressTableLookups' in message + ? message.addressTableLookups.map(l => ({ + lookupTableAddress: l.accountKey, + readonlyIndexes: l.readonlyIndexes, + writableIndexes: l.writableIndexes, + })) + : []; + compiledMessage = { + ...(addressTableLookups.length ? { addressTableLookups } : null), + header, + instructions: getCompiledInstructions(), + lifetimeToken: message.recentBlockhash, + staticAccounts, + version: 0, + } satisfies CompiledTransactionMessageWithLifetime & V0CompiledTransactionMessage; + break; + } + case 1: { + const instructionData = message.instructions.map(ix => base58.encode(ix.data)); + compiledMessage = { + // The `'json'` encoding does not carry the v1 transaction + // config, so the synthesized message always reports an + // empty one. + configMask: 0, + configValues: [], + header, + instructionHeaders: message.instructions.map((ix, i) => ({ + numInstructionAccounts: ix.accounts.length, + numInstructionDataBytes: instructionData[i].byteLength, + programAccountIndex: ix.programIdIndex, + })), + instructionPayloads: message.instructions.map((ix, i) => ({ + instructionAccountIndices: [...ix.accounts], + instructionData: instructionData[i], + })), + lifetimeToken: message.recentBlockhash, + numInstructions: message.instructions.length, + numStaticAccounts: staticAccounts.length, + staticAccounts, + version: 1, + } satisfies CompiledTransactionMessageWithLifetime & V1CompiledTransactionMessage; + break; + } + default: { + // Compile-time exhaustiveness: a new `TransactionVersion` + // member will fail to typecheck here, forcing this switch to + // handle it explicitly. + const _exhaustiveCheck: never = version; + throw new SolanaError(SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, { + unsupportedVersion: _exhaustiveCheck as number, + }); + } + } + + return { compiledMessage, loadedAddresses: getLoadedAddresses(rpcTx.meta) }; +} + +function isBase64Response(rpcTx: AnyGetTransactionResponse): rpcTx is GetTransactionApiResponseBase64 { + const t = rpcTx.transaction; + return Array.isArray(t) && t[1] === 'base64'; +} + +function isBase58Response(rpcTx: AnyGetTransactionResponse): rpcTx is GetTransactionApiResponseBase58 { + const t = rpcTx.transaction; + return Array.isArray(t) && t[1] === 'base58'; +} + +function getJsonShapedMessage(rpcTx: AnyGetTransactionResponse): Record | undefined { + const t = rpcTx.transaction; + if (typeof t !== 'object' || t === null || Array.isArray(t) || !('message' in t)) return undefined; + const message = (t as { message?: { instructions?: readonly unknown[] } }).message; + if (!message || typeof message !== 'object' || !Array.isArray(message.instructions)) return undefined; + return message as Record; +} + +/** + * Detects an `encoding: 'json'` response specifically: its message carries + * the compiled-message `header` (signer/readonly counts). A `jsonParsed` + * message has no `header` — the server has already resolved the roles onto + * each of its `accountKeys` — so checking for it distinguishes the two + * encodings regardless of how many instructions the transaction has. + */ +function isJsonResponse(rpcTx: AnyGetTransactionResponse): rpcTx is GetTransactionApiResponseJson { + const message = getJsonShapedMessage(rpcTx); + return message != null && typeof message.header === 'object' && message.header !== null; +} + +/** + * Detects an `encoding: 'jsonParsed'` response: structurally a JSON message + * but without the compiled-message `header` that the `'json'` encoding + * carries. Its instructions arrive pre-parsed (with a `programId` address + * rather than a `programIdIndex`) and are not round-trippable through the + * kit codecs, so these responses are rejected. + */ +function isJsonParsedResponse(rpcTx: AnyGetTransactionResponse): boolean { + const message = getJsonShapedMessage(rpcTx); + return message != null && !('header' in message); +} + +/** + * Decodes a `getTransaction` response (any of `encoding: 'base64'`, + * `'base58'`, or `'json'`) into a {@link CompiledTransactionMessage} plus, + * for `'base64'` and `'base58'`, a re-encodable {@link Transaction}. The + * JSON path does not produce a `Transaction`: the server has already + * decompiled the wire format, so there are no message bytes to carry. + * + * `'jsonParsed'` is **not** supported — its instructions arrive + * pre-parsed by the server and lack raw bytes, so they cannot be + * round-tripped through the auto-generated `parseXInstruction` clients. + * Passing a `'jsonParsed'` response throws + * {@link SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION}; + * any other unrecognized input throws + * {@link SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE}. + * + * A response carrying a transaction version this package cannot decode + * throws {@link SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED} — + * raised by the JSON path for an unrecognized `version`, and by the wire + * decoders for malformed binary input. + * + * Use this together with {@link getInstructionsFromCompiledTransactionMessage} + * (or {@link walkInstructions}) to inspect a confirmed transaction's + * instructions in a form the auto-generated `@solana-program/*` clients + * can `parse` directly. + * + * Prefer `encoding: 'base64'` when bandwidth allows — it is the most + * compact, the wire bytes round-trip cleanly through the kit codecs, and + * the return type statically guarantees a re-encodable `transaction`. + * + * @example + * ```ts + * const rpcResponse = await rpc.getTransaction(signature(txid), { + * commitment: 'confirmed', + * encoding: 'base64', + * maxSupportedTransactionVersion: 0, + * }).send(); + * if (!rpcResponse) throw new Error('not found'); + * + * const { compiledMessage, loadedAddresses } = decodeTransactionFromRpcResponse(rpcResponse); + * const instructions = getInstructionsFromCompiledTransactionMessage(compiledMessage, loadedAddresses); + * ``` + */ +export function decodeTransactionFromRpcResponse< + TMaxSupportedTransactionVersion extends TransactionVersion | void = TransactionVersion | void, +>( + rpcTx: GetTransactionApiResponseBase64, +): DecodedRpcTransaction & { transaction: Transaction }; +export function decodeTransactionFromRpcResponse< + TMaxSupportedTransactionVersion extends TransactionVersion | void = TransactionVersion | void, +>( + rpcTx: GetTransactionApiResponseBase58, +): DecodedRpcTransaction & { transaction: Transaction }; +export function decodeTransactionFromRpcResponse< + TMaxSupportedTransactionVersion extends TransactionVersion | void = TransactionVersion | void, +>(rpcTx: GetTransactionApiResponseJson): DecodedRpcTransaction; +export function decodeTransactionFromRpcResponse< + TMaxSupportedTransactionVersion extends TransactionVersion | void = TransactionVersion | void, +>( + rpcTx: + | GetTransactionApiResponseBase58 + | GetTransactionApiResponseBase64 + | GetTransactionApiResponseJson, +): DecodedRpcTransaction { + const tx = rpcTx as AnyGetTransactionResponse; + if (isBase64Response(tx)) return decodeFromBase64(tx); + if (isBase58Response(tx)) return decodeFromBase58(tx); + if (isJsonResponse(tx)) return decodeFromJson(tx); + if (isJsonParsedResponse(tx)) { + throw new SolanaError(SOLANA_ERROR__TRANSACTION_INTROSPECTION__CANNOT_DECODE_JSON_PARSED_TRANSACTION); + } + throw new SolanaError(SOLANA_ERROR__TRANSACTION_INTROSPECTION__UNRECOGNIZED_GET_TRANSACTION_RESPONSE); +} diff --git a/packages/transaction-introspection/src/get-inner-instructions.ts b/packages/transaction-introspection/src/get-inner-instructions.ts new file mode 100644 index 000000000..9fb62954e --- /dev/null +++ b/packages/transaction-introspection/src/get-inner-instructions.ts @@ -0,0 +1,96 @@ +import { getBase58Encoder } from '@solana/codecs-strings'; +import { + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, + SolanaError, +} from '@solana/errors'; +import type { AccountMeta } from '@solana/instructions'; +import type { GetTransactionApiResponseBase64 } from '@solana/rpc-api'; + +import type { TracedInstruction } from './types'; + +/** + * The shape of an inner-instructions group as returned by the JSON-RPC + * `getTransaction` endpoint when not using `jsonParsed` encoding. Derived + * from `@solana/rpc-api` via indexed access so the two stay in sync. + */ +type RpcInnerInstructionsGroup = NonNullable< + NonNullable['meta']>['innerInstructions'] +>[number]; + +/** + * The minimum shape of `getTransaction`'s `meta` field that this helper + * needs. Accepting a structural type keeps callers free to pass the full + * RPC response without coupling to a specific overload. + * + * @example + * ```ts + * const inner = getInnerInstructionsFromMeta(rpcResponse.meta, accountMetas); + * ``` + */ +export type MetaWithInnerInstructions = Readonly<{ + innerInstructions?: readonly RpcInnerInstructionsGroup[] | null; +}>; + +/** + * Returns the inner instructions in a `getTransaction` response as + * {@link TracedInstruction}s. + * + * The RPC returns inner instructions in a different shape from the wire + * format: indices reference the same flat account list as the outer + * instructions, but `data` is a base58-encoded string. This helper decodes + * the data, resolves the indices against the supplied {@link AccountMeta} + * list, and tags each instruction with an `inner` trace. + * + * Throws if any `programIdIndex` or account index falls outside the + * supplied `accountMetas` list. + * + * @example + * ```ts + * const accountMetas = getAccountMetasFromCompiledTransactionMessage(compiledMessage, loadedAddresses); + * const inner = getInnerInstructionsFromMeta(rpcResponse.meta, accountMetas); + * ``` + */ +export function getInnerInstructionsFromMeta( + meta: MetaWithInnerInstructions, + accountMetas: readonly AccountMeta[], +): TracedInstruction[] { + if (!meta.innerInstructions) return []; + const base58 = getBase58Encoder(); + const result: TracedInstruction[] = []; + for (const group of meta.innerInstructions) { + for (let innerIndex = 0; innerIndex < group.instructions.length; innerIndex++) { + const ix = group.instructions[innerIndex]; + const programMeta = accountMetas[ix.programIdIndex]; + if (!programMeta) { + throw new SolanaError( + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, + { index: ix.programIdIndex }, + ); + } + const accounts: AccountMeta[] = ix.accounts.map(i => { + const accountMeta = accountMetas[i]; + if (!accountMeta) { + throw new SolanaError( + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, + { index: i }, + ); + } + return accountMeta; + }); + const data = base58.encode(ix.data); + result.push({ + ...(accounts.length ? { accounts } : null), + ...(data.byteLength ? { data } : null), + programAddress: programMeta.address, + trace: { + innerIndex, + kind: 'inner', + outerIndex: group.index, + ...(ix.stackHeight != null ? { stackHeight: ix.stackHeight } : {}), + }, + }); + } + } + return result; +} diff --git a/packages/transaction-introspection/src/get-instructions.ts b/packages/transaction-introspection/src/get-instructions.ts new file mode 100644 index 000000000..b76f8e1a5 --- /dev/null +++ b/packages/transaction-introspection/src/get-instructions.ts @@ -0,0 +1,215 @@ +import type { Address } from '@solana/addresses'; +import type { ReadonlyUint8Array } from '@solana/codecs-core'; +import { + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, + SOLANA_ERROR__TRANSACTION__INSTRUCTION_HEADERS_PAYLOADS_MISMATCH, + SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, + SolanaError, +} from '@solana/errors'; +import { type AccountMeta, AccountRole, type Instruction } from '@solana/instructions'; +import type { CompiledTransactionMessage } from '@solana/transaction-messages'; + +import type { LoadedAddresses } from './loaded-addresses'; + +/** + * An outer transaction instruction with its account indices resolved to + * full {@link AccountMeta}s and its data exposed as a `ReadonlyUint8Array`. + * + * Following the kit `Instruction` conventions, `accounts` and `data` are + * present only when non-empty, so `isInstructionWithAccounts` and + * `isInstructionWithData` from `@solana/instructions` behave as expected + * and can be used to narrow before passing the instruction to the + * auto-generated `parseXInstruction` helpers. + * + * @example + * ```ts + * for (const ix of getInstructionsFromCompiledTransactionMessage(compiled)) { + * // `ix` is a `ResolvedInstruction` — usable with `isInstructionForProgram` + * // directly, and with the auto-generated `identifyXInstruction` helpers + * // after narrowing `data`. + * if (isInstructionWithData(ix)) { + * identifyTokenInstruction(ix); + * } + * } + * ``` + */ +export type ResolvedInstruction = Instruction< + TProgramAddress, + readonly AccountMeta[] +>; + +/** + * The normalized shape of an instruction inside a compiled transaction + * message — `legacy`, `0`, and `1` are all reduced to this form before + * resolution. + */ +type NormalizedCompiledInstruction = Readonly<{ + accountIndices: readonly number[]; + data: ReadonlyUint8Array; + programAddressIndex: number; +}>; + +/** + * Builds the full ordered list of {@link AccountMeta}s for a compiled + * transaction message. + * + * The order matches the runtime's resolution order: + * + * 1. Static accounts, with role bits derived from the message header + * (writable signers, readonly signers, writable non-signers, readonly + * non-signers). + * 2. ALT-loaded writable accounts (always non-signer, writable). + * 3. ALT-loaded readonly accounts (always non-signer, readonly). + * + * Inner-instruction account indices reference the same flat list, so this + * helper is also useful for resolving inner instructions. + */ +export function getAccountMetasFromCompiledTransactionMessage( + compiledMessage: CompiledTransactionMessage, + loadedAddresses?: LoadedAddresses | null, +): AccountMeta[] { + const { header, staticAccounts } = compiledMessage; + const numWritableSignerAccounts = header.numSignerAccounts - header.numReadonlySignerAccounts; + const numWritableNonSignerAccounts = + staticAccounts.length - header.numSignerAccounts - header.numReadonlyNonSignerAccounts; + + const metas: AccountMeta[] = []; + let i = 0; + for (let n = 0; n < numWritableSignerAccounts; n++, i++) { + metas.push({ address: staticAccounts[i], role: AccountRole.WRITABLE_SIGNER }); + } + for (let n = 0; n < header.numReadonlySignerAccounts; n++, i++) { + metas.push({ address: staticAccounts[i], role: AccountRole.READONLY_SIGNER }); + } + for (let n = 0; n < numWritableNonSignerAccounts; n++, i++) { + metas.push({ address: staticAccounts[i], role: AccountRole.WRITABLE }); + } + for (let n = 0; n < header.numReadonlyNonSignerAccounts; n++, i++) { + metas.push({ address: staticAccounts[i], role: AccountRole.READONLY }); + } + + if (loadedAddresses) { + for (const address of loadedAddresses.writable) { + metas.push({ address, role: AccountRole.WRITABLE }); + } + for (const address of loadedAddresses.readonly) { + metas.push({ address, role: AccountRole.READONLY }); + } + } + + return metas; +} + +/** + * Returns the outer instructions of a compiled transaction message as kit + * {@link Instruction} objects. + * + * Each returned instruction has its account indices resolved to + * {@link AccountMeta}s (with the proper signer/writable bits) and its data + * exposed as a `ReadonlyUint8Array` — the form the auto-generated + * `@solana-program/*` `parseXInstruction` functions expect. `accounts` and + * `data` are omitted when empty. + * + * Supports `legacy`, `v0`, and `v1` compiled messages. Throws + * {@link SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED} for any + * other version, + * {@link SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND} + * if a `programAddressIndex` falls outside the resolved account list, and + * {@link SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE} + * if an account index does. + * + * @example + * ```ts + * const instructions = getInstructionsFromCompiledTransactionMessage( + * compiled, + * rpcResponse.meta?.loadedAddresses, + * ); + * for (const ix of instructions) { + * if (ix.programAddress === TOKEN_PROGRAM_ADDRESS && isInstructionWithData(ix)) { + * const kind = identifyTokenInstruction(ix); + * // ... + * } + * } + * ``` + */ +export function getInstructionsFromCompiledTransactionMessage( + compiledMessage: CompiledTransactionMessage, + loadedAddresses?: LoadedAddresses | null, +): ResolvedInstruction[] { + const metas = getAccountMetasFromCompiledTransactionMessage(compiledMessage, loadedAddresses); + return normalizeCompiledInstructions(compiledMessage).map(ix => resolveInstruction(ix, metas)); +} + +/** + * Internal variant of {@link getInstructionsFromCompiledTransactionMessage} + * that takes pre-built {@link AccountMeta}s. Used by {@link walkInstructions} + * to avoid rebuilding the meta list when it is already needed for resolving + * inner instructions. + * + * @internal + */ +export function getInstructionsFromCompiledTransactionMessageWithMetas( + compiledMessage: CompiledTransactionMessage, + accountMetas: readonly AccountMeta[], +): ResolvedInstruction[] { + return normalizeCompiledInstructions(compiledMessage).map(ix => resolveInstruction(ix, accountMetas)); +} + +function normalizeCompiledInstructions(compiledMessage: CompiledTransactionMessage): NormalizedCompiledInstruction[] { + if (compiledMessage.version === 'legacy' || compiledMessage.version === 0) { + return compiledMessage.instructions.map(ix => ({ + accountIndices: ix.accountIndices ?? [], + data: ix.data ?? new Uint8Array(), + programAddressIndex: ix.programAddressIndex, + })); + } + if (compiledMessage.version === 1) { + const { instructionHeaders, instructionPayloads } = compiledMessage; + if (instructionHeaders.length !== instructionPayloads.length) { + throw new SolanaError(SOLANA_ERROR__TRANSACTION__INSTRUCTION_HEADERS_PAYLOADS_MISMATCH, { + numInstructionHeaders: instructionHeaders.length, + numInstructionPayloads: instructionPayloads.length, + }); + } + return instructionHeaders.map((header, i) => ({ + accountIndices: instructionPayloads[i].instructionAccountIndices, + data: instructionPayloads[i].instructionData, + programAddressIndex: header.programAccountIndex, + })); + } + // Compile-time exhaustiveness: if a future `CompiledTransactionMessage` + // variant is added, `compiledMessage` will no longer narrow to `never` + // here and this assignment will fail to typecheck — forcing us to handle + // the new version explicitly. + const _exhaustiveCheck: never = compiledMessage; + throw new SolanaError(SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, { + unsupportedVersion: (_exhaustiveCheck as { version: number }).version, + }); +} + +function resolveInstruction(ix: NormalizedCompiledInstruction, metas: readonly AccountMeta[]): ResolvedInstruction { + const programMeta = metas[ix.programAddressIndex]; + if (!programMeta) { + throw new SolanaError(SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND, { + index: ix.programAddressIndex, + }); + } + const accounts: AccountMeta[] = ix.accountIndices.map(i => { + const accountMeta = metas[i]; + if (!accountMeta) { + throw new SolanaError( + SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_ACCOUNT_INDEX_OUT_OF_RANGE, + { + index: i, + }, + ); + } + return accountMeta; + }); + return { + ...(accounts.length ? { accounts } : null), + ...(ix.data.byteLength ? { data: ix.data } : null), + programAddress: programMeta.address as Address, + }; +} diff --git a/packages/transaction-introspection/src/index.ts b/packages/transaction-introspection/src/index.ts new file mode 100644 index 000000000..ca354d171 --- /dev/null +++ b/packages/transaction-introspection/src/index.ts @@ -0,0 +1,18 @@ +/** + * This package contains helpers for inspecting confirmed Solana transactions + * and walking their outer and inner instructions in a form that the + * auto-generated `@solana-program/*` clients can `identify` and `parse` + * directly. + * + * @packageDocumentation + */ +export { type DecodedRpcTransaction, decodeTransactionFromRpcResponse } from './decode-rpc-transaction'; +export type { LoadedAddresses } from './loaded-addresses'; +export { getInnerInstructionsFromMeta, type MetaWithInnerInstructions } from './get-inner-instructions'; +export { + getAccountMetasFromCompiledTransactionMessage, + getInstructionsFromCompiledTransactionMessage, + type ResolvedInstruction, +} from './get-instructions'; +export type { InstructionTrace, TracedInstruction } from './types'; +export { walkInstructions } from './walk-instructions'; diff --git a/packages/transaction-introspection/src/loaded-addresses.ts b/packages/transaction-introspection/src/loaded-addresses.ts new file mode 100644 index 000000000..e5b5fede4 --- /dev/null +++ b/packages/transaction-introspection/src/loaded-addresses.ts @@ -0,0 +1,20 @@ +import type { Address } from '@solana/addresses'; + +/** + * Loaded ALT addresses as returned by `getTransaction`'s `meta.loadedAddresses`. + * + * The two arrays are kept in the same order the runtime uses to resolve + * instruction account indices. + * + * @example + * ```ts + * const loaded: LoadedAddresses = rpcResponse.meta?.loadedAddresses ?? { + * readonly: [], + * writable: [], + * }; + * ``` + */ +export type LoadedAddresses = Readonly<{ + readonly: readonly Address[]; + writable: readonly Address[]; +}>; diff --git a/packages/transaction-introspection/src/types.ts b/packages/transaction-introspection/src/types.ts new file mode 100644 index 000000000..02626613e --- /dev/null +++ b/packages/transaction-introspection/src/types.ts @@ -0,0 +1,62 @@ +import type { ResolvedInstruction } from './get-instructions'; +/** + * The location of an instruction within a transaction. + * + * - `kind: 'outer'` — a top-level instruction in the transaction message. + * `index` is its position in the compiled message's instructions. + * - `kind: 'inner'` — an instruction emitted via cross-program invocation. + * `outerIndex` is the index of the outer instruction that triggered the + * CPI chain; `innerIndex` is the position within that outer instruction's + * inner-instruction group. + * + * @example + * ```ts + * function describe(trace: InstructionTrace): string { + * return trace.kind === 'outer' + * ? `outer[${trace.index}]` + * : `inner[outer=${trace.outerIndex}, idx=${trace.innerIndex}]`; + * } + * ``` + */ +export type InstructionTrace = + | Readonly<{ + index: number; + kind: 'outer'; + }> + | Readonly<{ + innerIndex: number; + kind: 'inner'; + outerIndex: number; + /** + * The CPI depth at which this instruction was invoked, when + * reported by the RPC. `1` is the outer-instruction depth, `2` + * is the first nested CPI, and so on. + */ + stackHeight?: number; + }>; + +/** + * A {@link ResolvedInstruction} carrying its location in the transaction + * as a `trace` property. + * + * Because a `TracedInstruction` is itself a {@link ResolvedInstruction}, + * it can be passed directly to the auto-generated `@solana-program/*` + * `identifyXInstruction` / `parseXInstruction` helpers, and to + * `isInstructionForProgram` from `@solana/instructions`. + * + * @example + * ```ts + * import { isInstructionForProgram, isInstructionWithData } from '@solana/instructions'; + * import { TOKEN_PROGRAM_ADDRESS, identifyTokenInstruction } from '@solana-program/token'; + * + * for (const ix of walkInstructions({ compiledMessage, meta, loadedAddresses })) { + * if (isInstructionForProgram(ix, TOKEN_PROGRAM_ADDRESS) && isInstructionWithData(ix)) { + * // `ix.programAddress` is narrowed to TOKEN_PROGRAM_ADDRESS and `ix.data` is present. + * identifyTokenInstruction(ix); + * console.log(ix.trace.kind); + * } + * } + * ``` + */ +export type TracedInstruction = Readonly<{ trace: InstructionTrace }> & + ResolvedInstruction; diff --git a/packages/transaction-introspection/src/walk-instructions.ts b/packages/transaction-introspection/src/walk-instructions.ts new file mode 100644 index 000000000..d4affa038 --- /dev/null +++ b/packages/transaction-introspection/src/walk-instructions.ts @@ -0,0 +1,81 @@ +import type { CompiledTransactionMessage } from '@solana/transaction-messages'; + +import { getInnerInstructionsFromMeta, type MetaWithInnerInstructions } from './get-inner-instructions'; +import { + getAccountMetasFromCompiledTransactionMessage, + getInstructionsFromCompiledTransactionMessageWithMetas, +} from './get-instructions'; +import type { LoadedAddresses } from './loaded-addresses'; +import type { TracedInstruction } from './types'; + +/** + * Returns every instruction in a confirmed transaction as + * {@link TracedInstruction}s, in the order an explorer displays them: each + * outer instruction followed immediately by the inner instructions its CPIs + * produced. + * + * Each returned instruction has its account indices resolved to + * {@link AccountMeta}s and its data exposed as a `ReadonlyUint8Array` + * (omitted when empty), making it directly usable with the auto-generated + * `@solana-program/*` `identifyXInstruction` and `parseXInstruction` + * functions, and with `isInstructionForProgram` from `@solana/instructions`. + * + * If `meta` is omitted, only outer instructions are returned. If + * `loadedAddresses` is omitted, only static accounts are used to resolve + * indices — pass `meta?.loadedAddresses` for v0 transactions that load + * accounts from address lookup tables. + * + * @example + * ```ts + * import { isInstructionForProgram, isInstructionWithData } from '@solana/instructions'; + * import { TOKEN_PROGRAM_ADDRESS, identifyTokenInstruction, TokenInstruction } from '@solana-program/token'; + * + * const instructions = walkInstructions({ compiledMessage, meta, loadedAddresses }); + * for (const ix of instructions) { + * if (isInstructionForProgram(ix, TOKEN_PROGRAM_ADDRESS) && + * isInstructionWithData(ix) && + * identifyTokenInstruction(ix) === TokenInstruction.SyncNative) { + * console.log(ix.trace); + * } + * } + * ``` + */ +export function walkInstructions(args: { + compiledMessage: CompiledTransactionMessage; + loadedAddresses?: LoadedAddresses | null; + meta?: MetaWithInnerInstructions | null; +}): TracedInstruction[] { + const { compiledMessage, loadedAddresses, meta } = args; + const accountMetas = getAccountMetasFromCompiledTransactionMessage(compiledMessage, loadedAddresses); + const outerInstructions = getInstructionsFromCompiledTransactionMessageWithMetas(compiledMessage, accountMetas); + + const innerByOuterIndex = new Map(); + if (meta) { + for (const inner of getInnerInstructionsFromMeta(meta, accountMetas)) { + if (inner.trace.kind !== 'inner') continue; + const group = innerByOuterIndex.get(inner.trace.outerIndex); + if (group) { + group.push(inner); + } else { + innerByOuterIndex.set(inner.trace.outerIndex, [inner]); + } + } + } + + const result: TracedInstruction[] = []; + outerInstructions.forEach((instruction, index) => { + result.push({ ...instruction, trace: { index, kind: 'outer' } }); + const group = innerByOuterIndex.get(index); + if (group) { + result.push(...group); + innerByOuterIndex.delete(index); + } + }); + // Inner groups whose index matches no outer instruction can only come + // from malformed input (e.g. `meta` paired with the wrong message). + // Append them rather than dropping them so no instruction is ever lost. + for (const group of innerByOuterIndex.values()) { + result.push(...group); + } + return result; +} diff --git a/packages/transaction-introspection/tsconfig.declarations.json b/packages/transaction-introspection/tsconfig.declarations.json new file mode 100644 index 000000000..67ad58e02 --- /dev/null +++ b/packages/transaction-introspection/tsconfig.declarations.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist/types" + }, + "extends": "./tsconfig.json", + "include": ["../build-scripts/build-time-constants.d.ts", "src/index.ts"] +} diff --git a/packages/transaction-introspection/tsconfig.json b/packages/transaction-introspection/tsconfig.json new file mode 100644 index 000000000..ba9cc1df4 --- /dev/null +++ b/packages/transaction-introspection/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["DOM", "ES2020", "ES2022.Error"] + }, + "display": "@solana/transaction-introspection", + "extends": "../tsconfig/base.json", + "include": ["../build-scripts/build-time-constants.d.ts", "src"] +} diff --git a/packages/transaction-introspection/typedoc.json b/packages/transaction-introspection/typedoc.json new file mode 100644 index 000000000..2c830355d --- /dev/null +++ b/packages/transaction-introspection/typedoc.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "extends": ["../../typedoc.json"], + "entryPoints": ["src/index.ts"], + "readme": "none", + "out": "./.docs" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee83dda1f..44fbb466f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -777,6 +777,9 @@ importers: '@solana/transaction-confirmation': specifier: workspace:* version: link:../transaction-confirmation + '@solana/transaction-introspection': + specifier: workspace:* + version: link:../transaction-introspection '@solana/transaction-messages': specifier: workspace:* version: link:../transaction-messages @@ -1545,6 +1548,40 @@ importers: specifier: workspace:* version: link:../instructions + packages/transaction-introspection: + dependencies: + '@solana/addresses': + specifier: workspace:* + version: link:../addresses + '@solana/codecs-core': + specifier: workspace:* + version: link:../codecs-core + '@solana/codecs-strings': + specifier: workspace:* + version: link:../codecs-strings + '@solana/errors': + specifier: workspace:* + version: link:../errors + '@solana/instructions': + specifier: workspace:* + version: link:../instructions + '@solana/rpc-api': + specifier: workspace:* + version: link:../rpc-api + '@solana/transaction-messages': + specifier: workspace:* + version: link:../transaction-messages + '@solana/transactions': + specifier: workspace:* + version: link:../transactions + typescript: + specifier: '>=5.4.0' + version: 5.9.3 + devDependencies: + '@solana/rpc-types': + specifier: workspace:* + version: link:../rpc-types + packages/transaction-messages: dependencies: '@solana/addresses':