From 62b6bd6381e231918ea8666922341c404ac03527 Mon Sep 17 00:00:00 2001 From: Callum McIntyre Date: Thu, 11 Jan 2024 11:40:48 +0000 Subject: [PATCH] Add async function to decode a transaction using an RPC to fetch lookup tables (#2005) * refactor(experimental): add async function to decode a transaction using an RPC to fetch lookup tables * add tests for decode-transaction --- .../src/__tests__/decode-transaction-test.ts | 302 ++++++++++++++++++ packages/library/src/decode-transaction.ts | 75 +++++ packages/transactions/src/index.ts | 1 + .../src/serializers/transaction.ts | 2 +- 4 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 packages/library/src/__tests__/decode-transaction-test.ts create mode 100644 packages/library/src/decode-transaction.ts diff --git a/packages/library/src/__tests__/decode-transaction-test.ts b/packages/library/src/__tests__/decode-transaction-test.ts new file mode 100644 index 000000000000..d064e5c42fcc --- /dev/null +++ b/packages/library/src/__tests__/decode-transaction-test.ts @@ -0,0 +1,302 @@ +import { FetchAccountsConfig, fetchJsonParsedAccounts } from '@solana/accounts'; +import type { Address } from '@solana/addresses'; +import { GetMultipleAccountsApi } from '@solana/rpc-core'; +import { Rpc } from '@solana/rpc-transport'; +import { type Blockhash, decompileTransaction, getCompiledTransactionDecoder } from '@solana/transactions'; +import { CompiledTransaction } from '@solana/transactions/dist/types/compile-transaction'; + +import { LamportsUnsafeBeyond2Pow53Minus1 } from '..'; + +jest.mock('@solana/accounts'); +jest.mock('@solana/transactions'); + +describe('decodeTransaction', () => { + const blockhash = 'abc' as Blockhash; + const encodedTransaction = new Uint8Array([1, 2, 3]); + const rpc: Rpc = { + getMultipleAccounts: jest.fn(), + }; + + // Reload `decodeTransaction` before each test to reset memoized state + let decodeTransaction: typeof import('../decode-transaction').decodeTransaction; + beforeEach(async () => { + await jest.isolateModulesAsync(async () => { + const decodeTransactionModule = + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + import('../decode-transaction'); + decodeTransaction = (await decodeTransactionModule).decodeTransaction; + }); + + jest.clearAllMocks(); + }); + + describe('for a legacy transaction', () => { + const compiledTransaction: CompiledTransaction = { + compiledMessage: { + // no `addressTableLookups` field + header: { + numReadonlyNonSignerAccounts: 0, + numReadonlySignerAccounts: 0, + numSignerAccounts: 0, + }, + instructions: [], + lifetimeToken: blockhash, + staticAccounts: [], + version: 'legacy', + }, + signatures: [], + }; + + // mock `getCompiledTransactionDecoder` to return `compiledTransaction` + let mockCompiledTransactionDecode: () => CompiledTransaction; + beforeEach(() => { + mockCompiledTransactionDecode = jest.fn().mockReturnValue(compiledTransaction); + + jest.mocked(getCompiledTransactionDecoder).mockReturnValue({ + decode: mockCompiledTransactionDecode, + } as unknown as ReturnType); + }); + + it('should pass the given encoded transaction to the compiled transaction decoder', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(mockCompiledTransactionDecode).toHaveBeenCalledWith(encodedTransaction); + }); + + it('should not call the `fetchJsonParsedAccounts` function', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(fetchJsonParsedAccounts).not.toHaveBeenCalled(); + }); + + it('should call `decompileTransaction` with the compiled transaction and no lookup tables', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(decompileTransaction).toHaveBeenCalledWith(compiledTransaction, { + addressesByLookupTableAddress: {}, + lastValidBlockHeight: undefined, + }); + }); + + it('should pass `lastValidBlockHeight` to `decompileTransaction`', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc, { lastValidBlockHeight: 100n }); + expect(decompileTransaction).toHaveBeenCalledWith(compiledTransaction, { + addressesByLookupTableAddress: {}, + lastValidBlockHeight: 100n, + }); + }); + }); + + describe('for a versioned transaction with no `addressTableLookups` field', () => { + const compiledTransaction: CompiledTransaction = { + compiledMessage: { + // no `addressTableLookups` field + header: { + numReadonlyNonSignerAccounts: 0, + numReadonlySignerAccounts: 0, + numSignerAccounts: 0, + }, + instructions: [], + lifetimeToken: blockhash, + staticAccounts: [], + version: 0, + }, + signatures: [], + }; + + // mock `getCompiledTransactionDecoder` to return `compiledTransaction` + let mockCompiledTransactionDecode: () => CompiledTransaction; + beforeEach(() => { + mockCompiledTransactionDecode = jest.fn().mockReturnValue(compiledTransaction); + + jest.mocked(getCompiledTransactionDecoder).mockReturnValue({ + decode: mockCompiledTransactionDecode, + } as unknown as ReturnType); + }); + + it('should pass the given encoded transaction to the compiled transaction decoder', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(mockCompiledTransactionDecode).toHaveBeenCalledWith(encodedTransaction); + }); + + it('should not call the `fetchJsonParsedAccounts` function', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(fetchJsonParsedAccounts).not.toHaveBeenCalled(); + }); + + it('should call `decompileTransaction` with the compiled transaction and no lookup tables', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(decompileTransaction).toHaveBeenCalledWith(compiledTransaction, { + addressesByLookupTableAddress: {}, + lastValidBlockHeight: undefined, + }); + }); + }); + + describe('for a versioned transaction with empty `addressTableLookups`', () => { + const compiledTransaction: CompiledTransaction = { + compiledMessage: { + addressTableLookups: [], + header: { + numReadonlyNonSignerAccounts: 0, + numReadonlySignerAccounts: 0, + numSignerAccounts: 0, + }, + instructions: [], + lifetimeToken: blockhash, + staticAccounts: [], + version: 0, + }, + signatures: [], + }; + + // mock `getCompiledTransactionDecoder` to return `compiledTransaction` + let mockCompiledTransactionDecode: () => CompiledTransaction; + beforeEach(() => { + mockCompiledTransactionDecode = jest.fn().mockReturnValue(compiledTransaction); + + jest.mocked(getCompiledTransactionDecoder).mockReturnValue({ + decode: mockCompiledTransactionDecode, + } as unknown as ReturnType); + }); + + it('should pass the given encoded transaction to the compiled transaction decoder', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(mockCompiledTransactionDecode).toHaveBeenCalledWith(encodedTransaction); + }); + + it('should not call the `fetchJsonParsedAccounts` function', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(fetchJsonParsedAccounts).not.toHaveBeenCalled(); + }); + + it('should call `decompileTransaction` with the compiled transaction and no lookup tables', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(decompileTransaction).toHaveBeenCalledWith(compiledTransaction, { + addressesByLookupTableAddress: {}, + lastValidBlockHeight: undefined, + }); + }); + }); + + describe('for a versioned transaction with non-empty `addressTableLookups`', () => { + const lookupTableAddress1 = '1111' as Address; + const lookupTableAddress2 = '2222' as Address; + + const compiledTransaction: CompiledTransaction = { + compiledMessage: { + addressTableLookups: [ + { + lookupTableAddress: lookupTableAddress1, + readableIndices: [0], + writableIndices: [], + }, + { + lookupTableAddress: lookupTableAddress2, + readableIndices: [0], + writableIndices: [], + }, + ], + header: { + numReadonlyNonSignerAccounts: 0, + numReadonlySignerAccounts: 0, + numSignerAccounts: 0, + }, + instructions: [], + lifetimeToken: blockhash, + staticAccounts: [], + version: 0, + }, + signatures: [], + }; + + const addressInLookup1 = '3333' as Address; + const addressInLookup2 = '4444' as Address; + + const fetchedLookupTables: Awaited> = [ + { + address: lookupTableAddress1, + data: { + addresses: [addressInLookup1], + }, + executable: false, + exists: true, + lamports: 0n as LamportsUnsafeBeyond2Pow53Minus1, + programAddress: 'program' as Address, + }, + { + address: lookupTableAddress2, + data: { + addresses: [addressInLookup2], + }, + executable: false, + exists: true, + lamports: 0n as LamportsUnsafeBeyond2Pow53Minus1, + programAddress: 'program' as Address, + }, + ]; + + // mock `getCompiledTransactionDecoder` to return `compiledTransaction` + let mockCompiledTransactionDecode: () => CompiledTransaction; + beforeEach(() => { + mockCompiledTransactionDecode = jest.fn().mockReturnValue(compiledTransaction); + + jest.mocked(getCompiledTransactionDecoder).mockReturnValue({ + decode: mockCompiledTransactionDecode, + } as unknown as ReturnType); + + // mock `fetchJsonParsedAccounts` to resolve to `fetchedLookupTables` + jest.mocked(fetchJsonParsedAccounts).mockResolvedValue(fetchedLookupTables); + }); + + it('should pass the given encoded transaction to the compiled transaction decoder', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(mockCompiledTransactionDecode).toHaveBeenCalledWith(encodedTransaction); + }); + + it('should call the `fetchJsonParsedAccounts` function with the lookup table addresses', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(fetchJsonParsedAccounts).toHaveBeenCalledWith(rpc, [lookupTableAddress1, lookupTableAddress2], {}); + }); + + it('should pass config to `fetchJsonParsedAccounts`', async () => { + expect.assertions(1); + const fetchAccountsConfig: FetchAccountsConfig = { + abortSignal: new AbortController().signal, + commitment: 'confirmed', + minContextSlot: 100n, + }; + await decodeTransaction(encodedTransaction, rpc, { + ...fetchAccountsConfig, + lastValidBlockHeight: 100n, + }); + expect(fetchJsonParsedAccounts).toHaveBeenCalledWith( + rpc, + [lookupTableAddress1, lookupTableAddress2], + fetchAccountsConfig, + ); + }); + + it('should call `decompileTransaction` with the compiled transaction and the fetched lookup tables', async () => { + expect.assertions(1); + await decodeTransaction(encodedTransaction, rpc); + expect(decompileTransaction).toHaveBeenCalledWith(compiledTransaction, { + addressesByLookupTableAddress: { + [lookupTableAddress1]: [addressInLookup1], + [lookupTableAddress2]: [addressInLookup2], + }, + lastValidBlockHeight: undefined, + }); + }); + }); +}); diff --git a/packages/library/src/decode-transaction.ts b/packages/library/src/decode-transaction.ts new file mode 100644 index 000000000000..e9913feb6c56 --- /dev/null +++ b/packages/library/src/decode-transaction.ts @@ -0,0 +1,75 @@ +import { + assertAccountsDecoded, + assertAccountsExist, + type FetchAccountsConfig, + fetchJsonParsedAccounts, +} from '@solana/accounts'; +import { Address } from '@solana/addresses'; +import type { GetMultipleAccountsApi } from '@solana/rpc-core'; +import type { Rpc } from '@solana/rpc-transport'; +import { + type AddressesByLookupTableAddress, + type CompilableTransaction, + decompileTransaction, + getCompiledTransactionDecoder, + type ITransactionWithSignatures, +} from '@solana/transactions'; + +let compiledTransactionDecoder: ReturnType | undefined = undefined; + +type FetchedAddressLookup = { + addresses: Address[]; +}; + +async function fetchLookupTables( + lookupTableAddresses: Address[], + rpc: Rpc, + config?: FetchAccountsConfig, +): Promise { + const fetchedLookupTables = await fetchJsonParsedAccounts( + rpc, + lookupTableAddresses, + config, + ); + assertAccountsDecoded(fetchedLookupTables); + assertAccountsExist(fetchedLookupTables); + + return fetchedLookupTables.reduce((acc, lookup) => { + return { + ...acc, + [lookup.address]: lookup.data.addresses, + }; + }, {}); +} + +type DecodeTransactionConfig = FetchAccountsConfig & { + lastValidBlockHeight?: bigint; +}; + +export async function decodeTransaction( + encodedTransaction: Uint8Array, + rpc: Rpc, + config?: DecodeTransactionConfig, +): Promise { + const { lastValidBlockHeight, ...fetchAccountsConfig } = config ?? {}; + + if (!compiledTransactionDecoder) compiledTransactionDecoder = getCompiledTransactionDecoder(); + const compiledTransaction = compiledTransactionDecoder.decode(encodedTransaction); + const { compiledMessage } = compiledTransaction; + + const lookupTables = + 'addressTableLookups' in compiledMessage && + compiledMessage.addressTableLookups !== undefined && + compiledMessage.addressTableLookups.length > 0 + ? compiledMessage.addressTableLookups + : []; + const lookupTableAddresses = lookupTables.map(l => l.lookupTableAddress); + + const fetchedLookupTables = + lookupTableAddresses.length > 0 ? await fetchLookupTables(lookupTableAddresses, rpc, fetchAccountsConfig) : {}; + + return decompileTransaction(compiledTransaction, { + addressesByLookupTableAddress: fetchedLookupTables, + lastValidBlockHeight, + }); +} diff --git a/packages/transactions/src/index.ts b/packages/transactions/src/index.ts index 021ff0a83c45..b398323edae7 100644 --- a/packages/transactions/src/index.ts +++ b/packages/transactions/src/index.ts @@ -1,6 +1,7 @@ export * from './blockhash'; export * from './compilable-transaction'; export * from './create-transaction'; +export * from './decompile-transaction'; export * from './durable-nonce'; export * from './fee-payer'; export * from './instructions'; diff --git a/packages/transactions/src/serializers/transaction.ts b/packages/transactions/src/serializers/transaction.ts index 0e4eb177cd97..7e92b8fab6c0 100644 --- a/packages/transactions/src/serializers/transaction.ts +++ b/packages/transactions/src/serializers/transaction.ts @@ -31,7 +31,7 @@ function getCompiledTransactionEncoder(): VariableSizeEncoder { +export function getCompiledTransactionDecoder(): VariableSizeDecoder { return getStructDecoder([ [ 'signatures',