From 4d03370784b5686beb2f8a1e1da9b1c3711ec3f4 Mon Sep 17 00:00:00 2001 From: Callum Date: Mon, 26 Jan 2026 16:14:29 +0000 Subject: [PATCH 1/2] Add appendTransactionMessageInstructionPlan function --- .../__tests__/append-instruction-plan-test.ts | 166 ++++++++++++++++++ .../append-instruction-plan-typetest.ts | 42 +++++ .../src/append-instruction-plan.ts | 90 ++++++++++ 3 files changed, 298 insertions(+) create mode 100644 packages/instruction-plans/src/__tests__/append-instruction-plan-test.ts create mode 100644 packages/instruction-plans/src/__typetests__/append-instruction-plan-typetest.ts create mode 100644 packages/instruction-plans/src/append-instruction-plan.ts diff --git a/packages/instruction-plans/src/__tests__/append-instruction-plan-test.ts b/packages/instruction-plans/src/__tests__/append-instruction-plan-test.ts new file mode 100644 index 000000000..ec9228b45 --- /dev/null +++ b/packages/instruction-plans/src/__tests__/append-instruction-plan-test.ts @@ -0,0 +1,166 @@ +import { Address } from '@solana/addresses'; +import { isSolanaError, SOLANA_ERROR__INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN } from '@solana/errors'; +import { pipe } from '@solana/functional'; +import { Instruction } from '@solana/instructions'; +import { + appendTransactionMessageInstruction, + createTransactionMessage, + setTransactionMessageFeePayer, +} from '@solana/transaction-messages'; + +import { appendTransactionMessageInstructionPlan } from '../append-instruction-plan'; +import { + getMessagePackerInstructionPlanFromInstructions, + parallelInstructionPlan, + sequentialInstructionPlan, + singleInstructionPlan, +} from '../instruction-plan'; + +function createInstruction(id: TId): Instruction & { id: TId } { + return { id, programAddress: '1'.repeat(32) as Address }; +} + +const feePayer = '2'.repeat(44) as Address; + +describe('appendTransactionMessageInstructionPlan', () => { + it('appends a single instruction plan to a transaction message', () => { + const message = pipe(createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayer(feePayer, m)); + const instructionA = createInstruction('A'); + const plan = singleInstructionPlan(instructionA); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([instructionA]); + }); + + it('appends instructions from a sequential instruction plan in order', () => { + const message = pipe(createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayer(feePayer, m)); + const instructionA = createInstruction('A'); + const instructionB = createInstruction('B'); + const instructionC = createInstruction('C'); + const plan = sequentialInstructionPlan([instructionA, instructionB, instructionC]); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([instructionA, instructionB, instructionC]); + }); + + it('appends instructions from a parallel instruction plan', () => { + const message = pipe(createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayer(feePayer, m)); + const instructionA = createInstruction('A'); + const instructionB = createInstruction('B'); + const plan = parallelInstructionPlan([instructionA, instructionB]); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([instructionA, instructionB]); + }); + + it('appends instructions from nested plans', () => { + const message = pipe(createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayer(feePayer, m)); + const instructionA = createInstruction('A'); + const instructionB = createInstruction('B'); + const instructionC = createInstruction('C'); + const instructionD = createInstruction('D'); + const plan = sequentialInstructionPlan([ + parallelInstructionPlan([instructionA, instructionB]), + parallelInstructionPlan([instructionC, instructionD]), + ]); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([instructionA, instructionB, instructionC, instructionD]); + }); + + it('returns the original message for an empty sequential plan', () => { + const message = pipe(createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayer(feePayer, m)); + const plan = sequentialInstructionPlan([]); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([]); + }); + + it('returns the original message for an empty parallel plan', () => { + const message = pipe(createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayer(feePayer, m)); + const plan = parallelInstructionPlan([]); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([]); + }); + + it('preserves existing instructions when appending', () => { + const existingInstruction = createInstruction('existing'); + const message = pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayer(feePayer, m), + m => appendTransactionMessageInstruction(existingInstruction, m), + ); + const instructionA = createInstruction('A'); + const instructionB = createInstruction('B'); + const plan = sequentialInstructionPlan([instructionA, instructionB]); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([existingInstruction, instructionA, instructionB]); + }); + + it('appends instructions from a message packer instruction plan', () => { + const message = pipe(createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayer(feePayer, m)); + const instructionA = createInstruction('A'); + const instructionB = createInstruction('B'); + const plan = getMessagePackerInstructionPlanFromInstructions([instructionA, instructionB]); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([instructionA, instructionB]); + }); + + it('appends instructions from a plan containing both single and message packer plans', () => { + const message = pipe(createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayer(feePayer, m)); + const instructionA = createInstruction('A'); + const instructionB = createInstruction('B'); + const instructionC = createInstruction('C'); + const plan = sequentialInstructionPlan([ + instructionA, + getMessagePackerInstructionPlanFromInstructions([instructionB, instructionC]), + ]); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([instructionA, instructionB, instructionC]); + }); + + it('preserves existing instructions when appending a message packer plan', () => { + const existingInstruction = createInstruction('existing'); + const message = pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayer(feePayer, m), + m => appendTransactionMessageInstruction(existingInstruction, m), + ); + const instructionA = createInstruction('A'); + const instructionB = createInstruction('B'); + const plan = getMessagePackerInstructionPlanFromInstructions([instructionA, instructionB]); + + const result = appendTransactionMessageInstructionPlan(plan, message); + + expect(result.instructions).toStrictEqual([existingInstruction, instructionA, instructionB]); + }); + + it('throws if the message packer plan cannot fit all instructions', () => { + expect.assertions(1); + const message = pipe(createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayer(feePayer, m)); + const instructionA = createInstruction('A'); + const instructionB = createInstruction('B'); + const largeInstruction = { ...createInstruction('C'), data: new Uint8Array(50_000) }; // Simulate a large instruction + const plan = getMessagePackerInstructionPlanFromInstructions([instructionA, instructionB, largeInstruction]); + + try { + appendTransactionMessageInstructionPlan(plan, message); + } catch (error) { + // eslint-disable-next-line jest/no-conditional-expect + expect(isSolanaError(error, SOLANA_ERROR__INSTRUCTION_PLANS__MESSAGE_CANNOT_ACCOMMODATE_PLAN)).toBe(true); + } + }); +}); diff --git a/packages/instruction-plans/src/__typetests__/append-instruction-plan-typetest.ts b/packages/instruction-plans/src/__typetests__/append-instruction-plan-typetest.ts new file mode 100644 index 000000000..5313656aa --- /dev/null +++ b/packages/instruction-plans/src/__typetests__/append-instruction-plan-typetest.ts @@ -0,0 +1,42 @@ +import { Instruction } from '@solana/instructions'; +import { + TransactionMessage, + TransactionMessageWithFeePayer, + TransactionMessageWithinSizeLimit, +} from '@solana/transaction-messages'; + +import { appendTransactionMessageInstructionPlan } from '../append-instruction-plan'; +import { InstructionPlan } from '../instruction-plan'; + +// [DESCRIBE] appendTransactionMessageInstructionPlan +{ + // It returns the same TransactionMessage type + { + const message = null as unknown as TransactionMessage & TransactionMessageWithFeePayer & { some: 1 }; + const newMessage = appendTransactionMessageInstructionPlan(null as unknown as InstructionPlan, message); + newMessage satisfies TransactionMessage & TransactionMessageWithFeePayer & { some: 1 }; + } + + // It maintains the existing instruction types + { + type InstructionA = Instruction & { identifier: 'A' }; + + const message = null as unknown as { + feePayer: TransactionMessageWithFeePayer['feePayer']; + instructions: [InstructionA]; + version: 0; + }; + const newMessage = appendTransactionMessageInstructionPlan(null as unknown as InstructionPlan, message); + newMessage.instructions[0] satisfies InstructionA; + } + + // It removes the size limit type safety. + { + const message = null as unknown as TransactionMessage & + TransactionMessageWithFeePayer & + TransactionMessageWithinSizeLimit; + const newMessage = appendTransactionMessageInstructionPlan(null as unknown as InstructionPlan, message); + // @ts-expect-error Potentially no longer within size limit. + newMessage satisfies TransactionMessageWithinSizeLimit; + } +} diff --git a/packages/instruction-plans/src/append-instruction-plan.ts b/packages/instruction-plans/src/append-instruction-plan.ts new file mode 100644 index 000000000..a92c56cc6 --- /dev/null +++ b/packages/instruction-plans/src/append-instruction-plan.ts @@ -0,0 +1,90 @@ +import { SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_INSTRUCTION_PLAN_KIND, SolanaError } from '@solana/errors'; +import { type Instruction } from '@solana/instructions'; +import { + appendTransactionMessageInstruction, + appendTransactionMessageInstructions, + TransactionMessage, + TransactionMessageWithFeePayer, +} from '@solana/transaction-messages'; + +import { flattenInstructionPlan, InstructionPlan } from './instruction-plan'; + +/** + * A helper type to append instructions to a transaction message + * without losing type information about the current instructions. + */ + +type AppendTransactionMessageInstructions = ReturnType< + typeof appendTransactionMessageInstructions +>; + +/** + * Appends all instructions from an instruction plan to a transaction message. + * + * This function flattens the instruction plan into its leaf plans and sequentially + * appends each instruction to the provided transaction message. It handles both + * single instructions and message packer plans. + * + * Note that any {@link MessagePackerInstructionPlan} is assumed to only append + * instructions. If it modifies other properties of the transaction message, the + * type of the returned transaction message may not accurately reflect those changes. + * + * @typeParam TTransactionMessage - The type of transaction message being modified. + * + * @param transactionMessage - The transaction message to append instructions to. + * @param instructionPlan - The instruction plan containing the instructions to append. + * @returns The transaction message with all instructions from the plan appended. + * + * @example + * Appending a simple instruction plan to a transaction message. + * ```ts + * import { appendTransactionMessageInstructionPlan } from '@solana/instruction-plans'; + * import { createTransactionMessage, setTransactionMessageFeePayer } from '@solana/transaction-messages'; + * + * const message = setTransactionMessageFeePayer(feePayer, createTransactionMessage({ version: 0 })); + * const plan = singleInstructionPlan(myInstruction); + * + * const messageWithInstructions = appendTransactionMessageInstructionPlan(message, plan); + * ``` + * + * @example + * Appending a sequential instruction plan. + * ```ts + * const plan = sequentialInstructionPlan([instructionA, instructionB, instructionC]); + * const messageWithInstructions = appendTransactionMessageInstructionPlan(message, plan); + * ``` + * + * @see {@link InstructionPlan} + * @see {@link flattenInstructionPlan} + */ +export function appendTransactionMessageInstructionPlan< + TTransactionMessage extends TransactionMessage & TransactionMessageWithFeePayer, +>( + instructionPlan: InstructionPlan, + transactionMessage: TTransactionMessage, +): AppendTransactionMessageInstructions { + type Out = AppendTransactionMessageInstructions; + + const leafInstructionPlans = flattenInstructionPlan(instructionPlan); + + return leafInstructionPlans.reduce( + (messageSoFar, plan) => { + const kind = plan.kind; + if (kind === 'single') { + return appendTransactionMessageInstruction(plan.instruction, messageSoFar) as unknown as Out; + } + if (kind === 'messagePacker') { + const messagerPacker = plan.getMessagePacker(); + let nextMessage: Out = messageSoFar; + while (!messagerPacker.done()) { + nextMessage = messagerPacker.packMessageToCapacity(nextMessage) as Out; + } + return nextMessage; + } + throw new SolanaError(SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_INSTRUCTION_PLAN_KIND, { + kind, + }); + }, + transactionMessage as unknown as Out, + ); +} From b73429920e093237bfc05ac327199a3ec59916fe Mon Sep 17 00:00:00 2001 From: Callum Date: Mon, 26 Jan 2026 18:36:32 +0000 Subject: [PATCH 2/2] Add changeset --- .changeset/funny-chairs-throw.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/funny-chairs-throw.md diff --git a/.changeset/funny-chairs-throw.md b/.changeset/funny-chairs-throw.md new file mode 100644 index 000000000..9249b7836 --- /dev/null +++ b/.changeset/funny-chairs-throw.md @@ -0,0 +1,5 @@ +--- +'@solana/instruction-plans': minor +--- + +Add a new function `appendTransactionMessageInstructionPlan` that can be used to add the instructions from an instruction plan to a transaction message