Skip to content

Commit

Permalink
Add async function to decode a transaction using an RPC to fetch look…
Browse files Browse the repository at this point in the history
…up tables (#2005)

* refactor(experimental): add async function to decode a transaction using an RPC to fetch lookup tables

* add tests for decode-transaction
  • Loading branch information
mcintyre94 authored Jan 11, 2024
1 parent c588817 commit 62b6bd6
Show file tree
Hide file tree
Showing 4 changed files with 379 additions and 1 deletion.
302 changes: 302 additions & 0 deletions packages/library/src/__tests__/decode-transaction-test.ts
Original file line number Diff line number Diff line change
@@ -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<GetMultipleAccountsApi> = {
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<typeof getCompiledTransactionDecoder>);
});

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<typeof getCompiledTransactionDecoder>);
});

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<typeof getCompiledTransactionDecoder>);
});

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<ReturnType<typeof fetchJsonParsedAccounts>> = [
{
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<typeof getCompiledTransactionDecoder>);

// 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,
});
});
});
});
75 changes: 75 additions & 0 deletions packages/library/src/decode-transaction.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getCompiledTransactionDecoder> | undefined = undefined;

type FetchedAddressLookup = {
addresses: Address[];
};

async function fetchLookupTables(
lookupTableAddresses: Address[],
rpc: Rpc<GetMultipleAccountsApi>,
config?: FetchAccountsConfig,
): Promise<AddressesByLookupTableAddress> {
const fetchedLookupTables = await fetchJsonParsedAccounts<FetchedAddressLookup[]>(
rpc,
lookupTableAddresses,
config,
);
assertAccountsDecoded(fetchedLookupTables);
assertAccountsExist(fetchedLookupTables);

return fetchedLookupTables.reduce<AddressesByLookupTableAddress>((acc, lookup) => {
return {
...acc,
[lookup.address]: lookup.data.addresses,
};
}, {});
}

type DecodeTransactionConfig = FetchAccountsConfig & {
lastValidBlockHeight?: bigint;
};

export async function decodeTransaction(
encodedTransaction: Uint8Array,
rpc: Rpc<GetMultipleAccountsApi>,
config?: DecodeTransactionConfig,
): Promise<CompilableTransaction | (CompilableTransaction & ITransactionWithSignatures)> {
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,
});
}
1 change: 1 addition & 0 deletions packages/transactions/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/transactions/src/serializers/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function getCompiledTransactionEncoder(): VariableSizeEncoder<CompiledTransactio
]);
}

function getCompiledTransactionDecoder(): VariableSizeDecoder<CompiledTransaction> {
export function getCompiledTransactionDecoder(): VariableSizeDecoder<CompiledTransaction> {
return getStructDecoder([
[
'signatures',
Expand Down

0 comments on commit 62b6bd6

Please sign in to comment.