diff --git a/packages/transaction-messages/src/__tests__/compress-transaction-message-test.ts b/packages/transaction-messages/src/__tests__/compress-transaction-message-test.ts new file mode 100644 index 00000000000..14d40442c9b --- /dev/null +++ b/packages/transaction-messages/src/__tests__/compress-transaction-message-test.ts @@ -0,0 +1,581 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { Address } from '@solana/addresses'; +import { pipe } from '@solana/functional'; +import { AccountRole, IAccountLookupMeta, IAccountMeta, IInstruction } from '@solana/instructions'; + +import { AddressesByLookupTableAddress } from '../addresses-by-lookup-table-address'; +import { compressTransactionMessageUsingAddressLookupTables } from '../compress-transaction-message'; +import { createTransactionMessage } from '../create-transaction-message'; +import { appendTransactionMessageInstruction, appendTransactionMessageInstructions } from '../instructions'; + +const programAddress = 'program' as Address; + +describe('compressTransactionMessageUsingAddressLookupTables', () => { + it('should replace a read-only account with a lookup table', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address, + role: AccountRole.READONLY, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + const expectedLookupMeta: IAccountLookupMeta = { + address, + addressIndex: 0, + lookupTableAddress, + role: AccountRole.READONLY, + }; + + expect(result.instructions[0].accounts![0]).toStrictEqual(expectedLookupMeta); + }); + + it('should replace a writable account with a lookup table', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address, + role: AccountRole.WRITABLE, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + const expectedLookupMeta: IAccountLookupMeta = { + address, + addressIndex: 0, + lookupTableAddress, + role: AccountRole.WRITABLE, + }; + + expect(result.instructions[0].accounts![0]).toStrictEqual(expectedLookupMeta); + }); + + it('should not replace a read-only signer account with a lookup table', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const accountMeta: IAccountMeta = { + address, + role: AccountRole.READONLY_SIGNER, + }; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [accountMeta], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result.instructions[0].accounts![0]).toStrictEqual(accountMeta); + }); + + it('should not replace a writable signer account with a lookup table', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const accountMeta: IAccountMeta = { + address, + role: AccountRole.WRITABLE_SIGNER, + }; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [accountMeta], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result.instructions[0].accounts![0]).toStrictEqual(accountMeta); + }); + + it('should not modify an account that is already from a lookup table', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const lookupMeta: IAccountLookupMeta = { + address, + addressIndex: 0, + lookupTableAddress, + role: AccountRole.READONLY, + }; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [lookupMeta], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result.instructions[0].accounts![0]).toStrictEqual(lookupMeta); + }); + + it('should replace multiple accounts with different addresses from a lookup table', () => { + const address1 = 'address1' as Address; + const address2 = 'address2' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address: address1, + role: AccountRole.READONLY, + }, + { + address: address2, + role: AccountRole.WRITABLE, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address1, address2], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + const expectedLookupMeta1: IAccountLookupMeta = { + address: address1, + addressIndex: 0, + lookupTableAddress, + role: AccountRole.READONLY, + }; + + const expectedLookupMeta2: IAccountLookupMeta = { + address: address2, + addressIndex: 1, + lookupTableAddress, + role: AccountRole.WRITABLE, + }; + + expect(result.instructions[0].accounts![0]).toStrictEqual(expectedLookupMeta1); + expect(result.instructions[0].accounts![1]).toStrictEqual(expectedLookupMeta2); + }); + + it('should replace the same account in multiple instructions from a lookup table', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe( + createTransactionMessage({ version: 0 }), + tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address, + role: AccountRole.READONLY, + }, + ], + programAddress, + }, + tx, + ), + tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address, + role: AccountRole.READONLY, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + const expectedLookupMeta: IAccountLookupMeta = { + address: address, + addressIndex: 0, + lookupTableAddress, + role: AccountRole.READONLY, + }; + + expect(result.instructions[0].accounts![0]).toStrictEqual(expectedLookupMeta); + expect(result.instructions[1].accounts![0]).toStrictEqual(expectedLookupMeta); + }); + + it('should replace multiple accounts with different addresses from different lookup tables', () => { + const address1 = 'address1' as Address; + const address2 = 'address2' as Address; + const lookupTableAddress1 = 'lookupTableAddress1' as Address; + const lookupTableAddress2 = 'lookupTableAddress2' as Address; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address: address1, + role: AccountRole.READONLY, + }, + { + address: address2, + role: AccountRole.WRITABLE, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress1]: [address1], + [lookupTableAddress2]: [address2], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + const expectedLookupMeta1: IAccountLookupMeta = { + address: address1, + addressIndex: 0, + lookupTableAddress: lookupTableAddress1, + role: AccountRole.READONLY, + }; + + const expectedLookupMeta2: IAccountLookupMeta = { + address: address2, + addressIndex: 0, + lookupTableAddress: lookupTableAddress2, + role: AccountRole.WRITABLE, + }; + + expect(result.instructions[0].accounts![0]).toStrictEqual(expectedLookupMeta1); + expect(result.instructions[0].accounts![1]).toStrictEqual(expectedLookupMeta2); + }); + + it('should not replace an account that is not in lookup tables', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address, + role: AccountRole.READONLY, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: ['abc' as Address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result.instructions[0].accounts![0]).toStrictEqual({ + address, + role: AccountRole.READONLY, + }); + }); + + it('should replace some accounts if there is a mix of signers and not', () => { + const address1 = 'address1' as Address; + const address2 = 'address2' as Address; + + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe( + createTransactionMessage({ version: 0 }), + tx => + // address1 and address2 are non-signers in this instruction + appendTransactionMessageInstruction( + { + accounts: [ + { + address: address1, + role: AccountRole.READONLY, + }, + { + address: address2, + role: AccountRole.WRITABLE, + }, + ], + programAddress, + }, + tx, + ), + // address2 is a signer in this instruction + tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address: address1, + role: AccountRole.READONLY, + }, + { + address: address2, + role: AccountRole.READONLY_SIGNER, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address1, address2], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result.instructions[0].accounts![0]).toStrictEqual({ + address: address1, + addressIndex: 0, + lookupTableAddress, + role: AccountRole.READONLY, + }); + + // address2 uses the LUT in the first instruction + expect(result.instructions[0].accounts![1]).toStrictEqual({ + address: address2, + addressIndex: 1, + lookupTableAddress, + role: AccountRole.WRITABLE, + }); + + expect(result.instructions[1].accounts![0]).toStrictEqual({ + address: address1, + addressIndex: 0, + lookupTableAddress, + role: AccountRole.READONLY, + }); + + // but not the second + expect(result.instructions[1].accounts![1]).toStrictEqual({ + address: address2, + role: AccountRole.READONLY_SIGNER, + }); + }); + + it('should return the input instruction if no accounts are replaced', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const instruction: IInstruction = { + accounts: [ + { + address, + role: AccountRole.READONLY_SIGNER, + }, + ], + programAddress, + }; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction(instruction, tx), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result.instructions[0]).toBe(instruction); + }); + + it('should return the input transaction message if no accounts are replaced in any instruction', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstructions( + [ + { + accounts: [ + { + address, + role: AccountRole.READONLY_SIGNER, + }, + ], + programAddress, + }, + { + accounts: [ + { + address, + role: AccountRole.READONLY_SIGNER, + }, + ], + programAddress, + }, + ], + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result).toBe(transactionMessage); + }); + + it('should freeze the returned transaction message', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address, + role: AccountRole.READONLY, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result).toBeFrozenObject(); + }); + + it('should freeze the instructions in the returned transaction message', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address, + role: AccountRole.READONLY, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result.instructions[0]).toBeFrozenObject(); + }); + + it('should freeze the replaced accounts in the returned transaction message', () => { + const address = 'address1' as Address; + const lookupTableAddress = 'lookupTableAddress' as Address; + + const transactionMessage = pipe(createTransactionMessage({ version: 0 }), tx => + appendTransactionMessageInstruction( + { + accounts: [ + { + address, + role: AccountRole.READONLY, + }, + ], + programAddress, + }, + tx, + ), + ); + + const lookupTables: AddressesByLookupTableAddress = { + [lookupTableAddress]: [address], + }; + + const result = compressTransactionMessageUsingAddressLookupTables(transactionMessage, lookupTables); + + expect(result.instructions[0].accounts![0]).toBeFrozenObject(); + }); +}); diff --git a/packages/transaction-messages/src/addresses-by-lookup-table-address.ts b/packages/transaction-messages/src/addresses-by-lookup-table-address.ts new file mode 100644 index 00000000000..b44b339a1cb --- /dev/null +++ b/packages/transaction-messages/src/addresses-by-lookup-table-address.ts @@ -0,0 +1,3 @@ +import { Address } from '@solana/addresses'; + +export type AddressesByLookupTableAddress = { [lookupTableAddress: Address]: Address[] }; diff --git a/packages/transaction-messages/src/compress-transaction-message.ts b/packages/transaction-messages/src/compress-transaction-message.ts new file mode 100644 index 00000000000..07631d56d31 --- /dev/null +++ b/packages/transaction-messages/src/compress-transaction-message.ts @@ -0,0 +1,81 @@ +import { Address } from '@solana/addresses'; +import { AccountRole, IAccountLookupMeta, IInstruction, isSignerRole } from '@solana/instructions'; + +import { AddressesByLookupTableAddress } from './addresses-by-lookup-table-address'; +import { TransactionMessage } from './transaction-message'; + +type Mutable = { + -readonly [P in keyof T]: T[P]; +}; + +// Look up the address in lookup tables, return a lookup meta if it is found in any of them +function findAddressInLookupTables( + address: Address, + role: AccountRole.READONLY | AccountRole.WRITABLE, + addressesByLookupTableAddress: AddressesByLookupTableAddress, +): IAccountLookupMeta | undefined { + for (const [lookupTableAddress, addresses] of Object.entries(addressesByLookupTableAddress)) { + for (let i = 0; i < addresses.length; i++) { + if (address === addresses[i]) { + return { + address, + addressIndex: i, + lookupTableAddress: lookupTableAddress as Address, + role, + }; + } + } + } +} + +type TransactionMessageNotLegacy = Exclude; + +export function compressTransactionMessageUsingAddressLookupTables< + TTransactionMessage extends TransactionMessageNotLegacy = TransactionMessageNotLegacy, +>( + transactionMessage: TTransactionMessage, + addressesByLookupTableAddress: AddressesByLookupTableAddress, +): TTransactionMessage { + const lookupTableAddresses = new Set(Object.values(addressesByLookupTableAddress).flatMap(a => a)); + + const newInstructions: IInstruction[] = []; + let updatedAnyInstructions = false; + for (const instruction of transactionMessage.instructions) { + if (!instruction.accounts) { + newInstructions.push(instruction); + continue; + } + + const newAccounts: Mutable> = []; + let updatedAnyAccounts = false; + for (const account of instruction.accounts) { + // If the address is already a lookup, is not in any lookup tables, or is a signer role, return as-is + if ( + 'lookupTableAddress' in account || + !lookupTableAddresses.has(account.address) || + isSignerRole(account.role) + ) { + newAccounts.push(account); + continue; + } + + // We already checked it's in one of the lookup tables + const lookupMetaAccount = findAddressInLookupTables( + account.address, + account.role, + addressesByLookupTableAddress, + )!; + newAccounts.push(Object.freeze(lookupMetaAccount)); + updatedAnyAccounts = true; + updatedAnyInstructions = true; + } + + newInstructions.push( + Object.freeze(updatedAnyAccounts ? { ...instruction, accounts: newAccounts } : instruction), + ); + } + + return Object.freeze( + updatedAnyInstructions ? { ...transactionMessage, instructions: newInstructions } : transactionMessage, + ); +} diff --git a/packages/transaction-messages/src/decompile-message.ts b/packages/transaction-messages/src/decompile-message.ts index 1164f5f1e7f..0d52ce067cb 100644 --- a/packages/transaction-messages/src/decompile-message.ts +++ b/packages/transaction-messages/src/decompile-message.ts @@ -10,6 +10,7 @@ import { pipe } from '@solana/functional'; import { AccountRole, IAccountLookupMeta, IAccountMeta, IInstruction } from '@solana/instructions'; import type { Blockhash } from '@solana/rpc-types'; +import { AddressesByLookupTableAddress } from './addresses-by-lookup-table-address'; import { setTransactionMessageLifetimeUsingBlockhash } from './blockhash'; import { CompilableTransactionMessage } from './compilable-transaction-message'; import { CompiledTransactionMessage } from './compile'; @@ -68,8 +69,6 @@ function getAccountMetas(message: CompiledTransactionMessage): IAccountMeta[] { return accountMetas; } -export type AddressesByLookupTableAddress = { [lookupTableAddress: Address]: Address[] }; - function getAddressLookupMetas( compiledAddressTableLookups: ReturnType, addressesByLookupTableAddress: AddressesByLookupTableAddress, diff --git a/packages/transaction-messages/src/index.ts b/packages/transaction-messages/src/index.ts index 8d0a7594cd7..fd07d506d5a 100644 --- a/packages/transaction-messages/src/index.ts +++ b/packages/transaction-messages/src/index.ts @@ -1,7 +1,9 @@ +export * from './addresses-by-lookup-table-address'; export * from './blockhash'; export * from './codecs'; export * from './compilable-transaction-message'; export * from './compile'; +export * from './compress-transaction-message'; export * from './create-transaction-message'; export * from './decompile-message'; export * from './durable-nonce';