-
Notifications
You must be signed in to change notification settings - Fork 907
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add async function to decode a transaction using an RPC to fetch look…
…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
1 parent
c588817
commit 62b6bd6
Showing
4 changed files
with
379 additions
and
1 deletion.
There are no files selected for viewing
302 changes: 302 additions & 0 deletions
302
packages/library/src/__tests__/decode-transaction-test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters