diff --git a/clients/js/src/instructionPlans/instructionPlan.ts b/clients/js/src/instructionPlans/instructionPlan.ts index 10f5130..ea795cd 100644 --- a/clients/js/src/instructionPlans/instructionPlan.ts +++ b/clients/js/src/instructionPlans/instructionPlan.ts @@ -12,7 +12,7 @@ export type InstructionPlan = | SequentialInstructionPlan | ParallelInstructionPlan | SingleInstructionPlan - | IterableInstructionPlan; + | MessagePackerInstructionPlan; export type SequentialInstructionPlan = Readonly<{ kind: 'sequential'; @@ -32,25 +32,38 @@ export type SingleInstructionPlan< instruction: TInstruction; }>; -export type IterableInstructionPlan< - TInstruction extends IInstruction = IInstruction, -> = Readonly<{ - kind: 'iterable'; - /** Get an iterator for the instructions. */ - getIterator: () => InstructionIterator; +export type MessagePackerInstructionPlan = Readonly<{ + kind: 'messagePacker'; + getMessagePacker: () => MessagePacker; }>; -export type InstructionIterator< - TInstruction extends IInstruction = IInstruction, -> = Readonly<{ +export type MessagePacker = Readonly<{ /** Checks whether there are more instructions to retrieve. */ - hasNext: () => boolean; - /** Get the next instruction for the given transaction message or return `null` if not possible. */ - next: ( + done: () => boolean; + /** Pack the provided transaction message with the next instructions or throws if not possible. */ + packMessageToCapacity: ( transactionMessage: CompilableTransactionMessage - ) => TInstruction | null; + ) => CompilableTransactionMessage; }>; +// TODO: Make SolanaError instead. +export class CannotPackUsingProvidedMessageError extends Error { + constructor() { + super('Cannot pack the next instructions using the provided message'); + this.name = 'CannotPackUsingProvidedMessageError'; + } +} + +// TODO: Make SolanaError instead. +export class MessagePackerIsAlreadyDoneError extends Error { + constructor() { + super( + 'Failed to pack the next message because the message packer is already done' + ); + this.name = 'MessagePackerIsAlreadyDoneError'; + } +} + export function parallelInstructionPlan( plans: (InstructionPlan | IInstruction)[] ): ParallelInstructionPlan { @@ -91,22 +104,25 @@ function parseSingleInstructionPlans( ); } -export function getLinearIterableInstructionPlan({ +export function getLinearMessagePackerInstructionPlan({ getInstruction, totalLength: totalBytes, }: { getInstruction: (offset: number, length: number) => IInstruction; totalLength: number; -}): IterableInstructionPlan { +}): MessagePackerInstructionPlan { return { - kind: 'iterable', - getIterator: () => { + kind: 'messagePacker', + getMessagePacker: () => { let offset = 0; return { - hasNext: () => offset < totalBytes, - next: (tx: CompilableTransactionMessage) => { + done: () => offset < totalBytes, + packMessageToCapacity: (message: CompilableTransactionMessage) => { const baseTransactionSize = getTransactionSize( - appendTransactionMessageInstruction(getInstruction(offset, 0), tx) + appendTransactionMessageInstruction( + getInstruction(offset, 0), + message + ) ); const maxLength = TRANSACTION_SIZE_LIMIT - @@ -114,44 +130,55 @@ export function getLinearIterableInstructionPlan({ 1; /* Leeway for shortU16 numbers in transaction headers. */ if (maxLength <= 0) { - return null; + throw new CannotPackUsingProvidedMessageError(); } const length = Math.min(totalBytes - offset, maxLength); const instruction = getInstruction(offset, length); offset += length; - return instruction; + return appendTransactionMessageInstruction(instruction, message); }, }; }, }; } -export function getIterableInstructionPlanFromInstructions< +export function getMessagePackerInstructionPlanFromInstructions< TInstruction extends IInstruction = IInstruction, ->(instructions: TInstruction[]): IterableInstructionPlan { +>(instructions: TInstruction[]): MessagePackerInstructionPlan { return { - kind: 'iterable', - getIterator: () => { + kind: 'messagePacker', + getMessagePacker: () => { let instructionIndex = 0; return { - hasNext: () => instructionIndex < instructions.length, - next: (tx: CompilableTransactionMessage) => { + done: () => instructionIndex < instructions.length, + packMessageToCapacity: (message: CompilableTransactionMessage) => { if (instructionIndex >= instructions.length) { - return null; + throw new MessagePackerIsAlreadyDoneError(); } - const instruction = instructions[instructionIndex]; - const transactionSize = getTransactionSize( - appendTransactionMessageInstruction(instruction, tx) - ); - - if (transactionSize > TRANSACTION_SIZE_LIMIT) { - return null; + let updatedMessage: CompilableTransactionMessage = message; + for ( + let index = instructionIndex; + index < instructions.length; + index++ + ) { + updatedMessage = appendTransactionMessageInstruction( + instructions[index], + message + ); + + if (getTransactionSize(updatedMessage) > TRANSACTION_SIZE_LIMIT) { + if (index === instructionIndex) { + throw new CannotPackUsingProvidedMessageError(); + } + instructionIndex = index; + return updatedMessage; + } } - instructionIndex++; - return instruction; + instructionIndex = instructions.length; + return updatedMessage; }, }; }, @@ -160,13 +187,13 @@ export function getIterableInstructionPlanFromInstructions< const REALLOC_LIMIT = 10_240; -export function getReallocIterableInstructionPlan({ +export function getReallocMessagePackerInstructionPlan({ getInstruction, totalSize, }: { getInstruction: (size: number) => IInstruction; totalSize: number; -}): IterableInstructionPlan { +}): MessagePackerInstructionPlan { const numberOfInstructions = Math.ceil(totalSize / REALLOC_LIMIT); const lastInstructionSize = totalSize % REALLOC_LIMIT; const instructions = new Array(numberOfInstructions) @@ -177,5 +204,5 @@ export function getReallocIterableInstructionPlan({ ) ); - return getIterableInstructionPlanFromInstructions(instructions); + return getMessagePackerInstructionPlanFromInstructions(instructions); } diff --git a/clients/js/src/instructionPlans/transactionPlannerBase.ts b/clients/js/src/instructionPlans/transactionPlannerBase.ts index 3f65e93..23f1d86 100644 --- a/clients/js/src/instructionPlans/transactionPlannerBase.ts +++ b/clients/js/src/instructionPlans/transactionPlannerBase.ts @@ -1,12 +1,12 @@ import { appendTransactionMessageInstructions, CompilableTransactionMessage, - IInstruction, + TransactionMessage, } from '@solana/kit'; import { - InstructionIterator, + CannotPackUsingProvidedMessageError as CannotPackUsingProvidedMessageError, InstructionPlan, - IterableInstructionPlan, + MessagePackerInstructionPlan, ParallelInstructionPlan, SequentialInstructionPlan, SingleInstructionPlan, @@ -18,109 +18,70 @@ import { } from './transactionHelpers'; import { getAllSingleTransactionPlans, + nonDivisibleSequentialTransactionPlan, + parallelTransactionPlan, + sequentialTransactionPlan, singleTransactionPlan, SingleTransactionPlan, TransactionPlan, } from './transactionPlan'; import { TransactionPlanner } from './transactionPlanner'; +type CreateTransactionMessage = (config?: { + abortSignal?: AbortSignal; +}) => Promise | CompilableTransactionMessage; + +type OnTransactionMessageUpdated = < + TTransactionMessage extends CompilableTransactionMessage, +>( + transactionMessage: TTransactionMessage, + config?: { abortSignal?: AbortSignal } +) => Promise | TTransactionMessage; + export type TransactionPlannerConfig = { - createTransactionMessage: (config?: { - abortSignal?: AbortSignal; - }) => Promise | CompilableTransactionMessage; - onTransactionMessageUpdated?: < - TTransactionMessage extends CompilableTransactionMessage, - >( - transactionMessage: TTransactionMessage, - config?: { abortSignal?: AbortSignal } - ) => Promise | TTransactionMessage; + createTransactionMessage: CreateTransactionMessage; + onTransactionMessageUpdated?: OnTransactionMessageUpdated; }; export function createBaseTransactionPlanner( config: TransactionPlannerConfig ): TransactionPlanner { return async ( - originalInstructionPlan, + instructionPlan, { abortSignal } = {} ): Promise => { - const createSingleTransactionPlan: CreateSingleTransactionPlanFunction = - async (instructions = []) => { - abortSignal?.throwIfAborted(); - const emptyMessage = await Promise.resolve( - config.createTransactionMessage({ abortSignal }) - ); - if (instructions.length <= 0) { - return { kind: 'single', message: emptyMessage }; - } - const plan: SingleTransactionPlan = { - kind: 'single', - message: appendTransactionMessageInstructions( - instructions, - emptyMessage - ), - }; - return await onSingleTransactionPlanUpdated(plan); - }; - - const onSingleTransactionPlanUpdated: OnSingleTransactionPlanUpdatedFunction = - async (plan) => { - abortSignal?.throwIfAborted(); - if (!config?.onTransactionMessageUpdated) { - return plan; - } - return { - kind: 'single', - message: await Promise.resolve( - config.onTransactionMessageUpdated(plan.message, { abortSignal }) - ), - }; - }; - - const plan = await traverse(originalInstructionPlan, { + const plan = await traverse(instructionPlan, { abortSignal, parent: null, parentCandidates: [], - createSingleTransactionPlan, - onSingleTransactionPlanUpdated, + createTransactionMessage: config.createTransactionMessage, + onTransactionMessageUpdated: + config.onTransactionMessageUpdated ?? ((msg) => msg), }); if (!plan) { - throw new Error('No instructions were found in the instruction plan.'); - } - - if (!isValidTransactionPlan(plan)) { - // TODO: Coded error. - const error = new Error( - 'Instruction plan results in invalid transaction plan' - ) as Error & { plan: TransactionPlan }; - error.plan = plan; - throw error; + throw new NoInstructionsFoundInInstructionPlanError(); } - return plan; + return freezeTransactionPlan(plan); }; } -type CreateSingleTransactionPlanFunction = ( - instructions?: IInstruction[] -) => Promise; - -type OnSingleTransactionPlanUpdatedFunction = ( - plan: SingleTransactionPlan -) => Promise; +type MutableTransactionPlan = Mutable; +type MutableSingleTransactionPlan = Mutable; type TraverseContext = { abortSignal?: AbortSignal; parent: InstructionPlan | null; - parentCandidates: SingleTransactionPlan[]; - createSingleTransactionPlan: CreateSingleTransactionPlanFunction; - onSingleTransactionPlanUpdated: OnSingleTransactionPlanUpdatedFunction; + parentCandidates: MutableSingleTransactionPlan[]; + createTransactionMessage: CreateTransactionMessage; + onTransactionMessageUpdated: OnTransactionMessageUpdated; }; async function traverse( instructionPlan: InstructionPlan, context: TraverseContext -): Promise { +): Promise { context.abortSignal?.throwIfAborted(); switch (instructionPlan.kind) { case 'sequential': @@ -129,8 +90,8 @@ async function traverse( return await traverseParallel(instructionPlan, context); case 'single': return await traverseSingle(instructionPlan, context); - case 'iterable': - return await traverseIterable(instructionPlan, context); + case 'messagePacker': + return await traverseMessagePacker(instructionPlan, context); default: instructionPlan satisfies never; throw new Error( @@ -142,25 +103,30 @@ async function traverse( async function traverseSequential( instructionPlan: SequentialInstructionPlan, context: TraverseContext -): Promise { - let candidate: SingleTransactionPlan | null = null; +): Promise { + let candidate: MutableSingleTransactionPlan | null = null; + + // Check if the sequential plan must fit entirely in its parent candidates + // due to constraints like being inside a parallel plan or not being divisible. const mustEntirelyFitInParentCandidate = context.parent && (context.parent.kind === 'parallel' || !instructionPlan.divisible); + + // If so, try to fit the entire plan inside one of the parent candidates. if (mustEntirelyFitInParentCandidate) { - for (const parentCandidate of context.parentCandidates) { - const transactionPlan = fitEntirePlanInsideCandidate( - instructionPlan, - parentCandidate - ); - if (transactionPlan) { - (parentCandidate as Mutable).message = - transactionPlan.message; - await context.onSingleTransactionPlanUpdated(parentCandidate); - return null; - } + const candidate = await selectAndMutateCandidate( + context, + context.parentCandidates, + (message) => fitEntirePlanInsideMessage(instructionPlan, message) + ); + // If that's possible, we the candidate is mutated and we can return null. + // Otherwise, we proceed with the normal traversal and no parent candidate. + if (candidate) { + return null; } } else { + // Otherwise, we can use the first parent candidate, if any, + // since we know it must be a divisible sequential plan. candidate = context.parentCandidates.length > 0 ? context.parentCandidates[0] : null; } @@ -182,6 +148,8 @@ async function traverseSequential( transactionPlans.push(...newPlans); } } + + // Wrap in a sequential plan or simplify. if (transactionPlans.length === 1) { return transactionPlans[0]; } @@ -198,14 +166,16 @@ async function traverseSequential( async function traverseParallel( instructionPlan: ParallelInstructionPlan, context: TraverseContext -): Promise { - const candidates: SingleTransactionPlan[] = [...context.parentCandidates]; +): Promise { + const candidates: MutableSingleTransactionPlan[] = [ + ...context.parentCandidates, + ]; const transactionPlans: TransactionPlan[] = []; - // Reorder children so iterable plans are last. + // Reorder children so message packer plans are last. const sortedChildren = [ - ...instructionPlan.plans.filter((plan) => plan.kind !== 'iterable'), - ...instructionPlan.plans.filter((plan) => plan.kind === 'iterable'), + ...instructionPlan.plans.filter((plan) => plan.kind !== 'messagePacker'), + ...instructionPlan.plans.filter((plan) => plan.kind === 'messagePacker'), ]; for (const plan of sortedChildren) { @@ -223,6 +193,8 @@ async function traverseParallel( transactionPlans.push(...newPlans); } } + + // Wrap in a parallel plan or simplify. if (transactionPlans.length === 1) { return transactionPlans[0]; } @@ -235,49 +207,46 @@ async function traverseParallel( async function traverseSingle( instructionPlan: SingleInstructionPlan, context: TraverseContext -): Promise { - const ix = instructionPlan.instruction; - const candidate = selectCandidate(context.parentCandidates, [ix]); +): Promise { + const predicate = (message: CompilableTransactionMessage) => + appendTransactionMessageInstructions( + [instructionPlan.instruction], + message + ); + const candidate = await selectAndMutateCandidate( + context, + context.parentCandidates, + predicate + ); if (candidate) { - (candidate.message as Mutable) = - appendTransactionMessageInstructions([ix], candidate.message); - await context.onSingleTransactionPlanUpdated(candidate); return null; } - return await context.createSingleTransactionPlan([ix]); + const message = await createNewMessage(context, instructionPlan, predicate); + return { kind: 'single', message }; } -async function traverseIterable( - instructionPlan: IterableInstructionPlan, +async function traverseMessagePacker( + instructionPlan: MessagePackerInstructionPlan, context: TraverseContext -): Promise { - const iterator = instructionPlan.getIterator(); +): Promise { + const messagePacker = instructionPlan.getMessagePacker(); const transactionPlans: SingleTransactionPlan[] = []; const candidates = [...context.parentCandidates]; - while (iterator.hasNext()) { - const candidateResult = selectCandidateForIterator(candidates, iterator); - if (candidateResult) { - const [candidate, ix] = candidateResult; - (candidate.message as Mutable) = - appendTransactionMessageInstructions([ix], candidate.message); - await context.onSingleTransactionPlanUpdated(candidate); - } else { - const newPlan = await context.createSingleTransactionPlan([]); - const ix = iterator.next(newPlan.message); - if (!ix) { - throw new Error( - 'Could not fit `InterableInstructionPlan` into a transaction' - ); - } - (newPlan.message as Mutable) = - appendTransactionMessageInstructions([ix], newPlan.message); - await context.onSingleTransactionPlanUpdated(newPlan); + while (messagePacker.done()) { + const candidate = await selectAndMutateCandidate( + context, + candidates, + messagePacker.packMessageToCapacity + ); + if (!candidate) { + const message = await createNewMessage( + context, + instructionPlan, + messagePacker.packMessageToCapacity + ); + const newPlan: MutableSingleTransactionPlan = { kind: 'single', message }; transactionPlans.push(newPlan); - - // Adding the new plan to the candidates is important for cases - // where the next instruction doesn't fill the entire transaction. - candidates.push(newPlan); } } @@ -299,8 +268,8 @@ async function traverseIterable( } function getSequentialCandidate( - latestPlan: TransactionPlan -): SingleTransactionPlan | null { + latestPlan: MutableTransactionPlan +): MutableSingleTransactionPlan | null { if (latestPlan.kind === 'single') { return latestPlan; } @@ -314,98 +283,126 @@ function getSequentialCandidate( function getParallelCandidates( latestPlan: TransactionPlan -): SingleTransactionPlan[] { +): MutableSingleTransactionPlan[] { return getAllSingleTransactionPlans(latestPlan); } -function selectCandidateForIterator( - candidates: SingleTransactionPlan[], - iterator: InstructionIterator -): [SingleTransactionPlan, IInstruction] | null { +async function selectAndMutateCandidate( + context: Pick, + candidates: MutableSingleTransactionPlan[], + predicate: ( + message: CompilableTransactionMessage + ) => CompilableTransactionMessage +): Promise { for (const candidate of candidates) { - const ix = iterator.next(candidate.message); - if (ix) { - return [candidate, ix]; + try { + const message = await Promise.resolve( + context.onTransactionMessageUpdated(predicate(candidate.message), { + abortSignal: context.abortSignal, + }) + ); + if (getTransactionSize(message) <= TRANSACTION_SIZE_LIMIT) { + candidate.message = message; + return candidate; + } + } catch (error) { + if ( + error instanceof CannotPackUsingProvidedMessageError || + error instanceof CannotFitEntirePlanInsideMessageError + ) { + // Try the next candidate. + } else { + throw error; + } } } return null; } -function selectCandidate( - candidates: SingleTransactionPlan[], - instructions: IInstruction[] -): SingleTransactionPlan | null { - const firstValidCandidate = candidates.find((candidate) => - isValidCandidate(candidate, instructions) +async function createNewMessage( + context: Pick< + TraverseContext, + 'createTransactionMessage' | 'onTransactionMessageUpdated' | 'abortSignal' + >, + instructionPlan: InstructionPlan, + predicate: ( + message: CompilableTransactionMessage + ) => CompilableTransactionMessage +): Promise { + const newMessage = await Promise.resolve( + context.createTransactionMessage({ abortSignal: context.abortSignal }) ); - return firstValidCandidate ?? null; -} - -function isValidCandidate( - candidate: SingleTransactionPlan, - instructions: IInstruction[] -): boolean { - const message = appendTransactionMessageInstructions( - instructions, - candidate.message + const updatedMessage = await Promise.resolve( + context.onTransactionMessageUpdated(predicate(newMessage), { + abortSignal: context.abortSignal, + }) ); - return getRemainingTransactionSize(message) >= 0; -} - -export function getRemainingTransactionSize( - message: CompilableTransactionMessage -) { - return TRANSACTION_SIZE_LIMIT - getTransactionSize(message); + if (getTransactionSize(updatedMessage) > TRANSACTION_SIZE_LIMIT) { + throw new FailedToFitPlanInNewMessageError(instructionPlan, updatedMessage); + } + return updatedMessage; } -function isValidTransactionPlan(transactionPlan: TransactionPlan): boolean { - if (transactionPlan.kind === 'single') { - const transactionSize = getTransactionSize(transactionPlan.message); - return transactionSize <= TRANSACTION_SIZE_LIMIT; +function freezeTransactionPlan(plan: MutableTransactionPlan): TransactionPlan { + switch (plan.kind) { + case 'single': + return singleTransactionPlan(plan.message); + case 'sequential': + return plan.divisible + ? sequentialTransactionPlan(plan.plans.map(freezeTransactionPlan)) + : nonDivisibleSequentialTransactionPlan( + plan.plans.map(freezeTransactionPlan) + ); + case 'parallel': + return parallelTransactionPlan(plan.plans.map(freezeTransactionPlan)); + default: + plan satisfies never; + throw new Error( + `Unknown transaction plan kind: ${(plan as { kind: string }).kind}` + ); } - return transactionPlan.plans.every(isValidTransactionPlan); } -function fitEntirePlanInsideCandidate( +function fitEntirePlanInsideMessage( instructionPlan: InstructionPlan, - candidate: SingleTransactionPlan -): SingleTransactionPlan | null { - let newCandidate: SingleTransactionPlan = candidate; + message: CompilableTransactionMessage +): CompilableTransactionMessage { + let newMessage: CompilableTransactionMessage = message; switch (instructionPlan.kind) { case 'sequential': case 'parallel': for (const plan of instructionPlan.plans) { - const result = fitEntirePlanInsideCandidate(plan, newCandidate); - if (result === null) { - return null; - } - newCandidate = result; + newMessage = fitEntirePlanInsideMessage(plan, newMessage); } - return newCandidate; + return newMessage; case 'single': - if (!isValidCandidate(candidate, [instructionPlan.instruction])) { - return null; - } - return singleTransactionPlan( - appendTransactionMessageInstructions( - [instructionPlan.instruction], - candidate.message - ) + // eslint-disable-next-line no-case-declarations + newMessage = appendTransactionMessageInstructions( + [instructionPlan.instruction], + message ); - case 'iterable': + if (getTransactionSize(newMessage) > TRANSACTION_SIZE_LIMIT) { + throw new CannotFitEntirePlanInsideMessageError(); + } + return newMessage; + case 'messagePacker': // eslint-disable-next-line no-case-declarations - const iterator = instructionPlan.getIterator(); - while (iterator.hasNext()) { - const ix = iterator.next(candidate.message); - if (!ix || !isValidCandidate(candidate, [ix])) { - return null; + const messagePacker = instructionPlan.getMessagePacker(); + while (messagePacker.done()) { + try { + newMessage = messagePacker.packMessageToCapacity(message); + if (getTransactionSize(newMessage) > TRANSACTION_SIZE_LIMIT) { + throw new CannotFitEntirePlanInsideMessageError(); + } + } catch (error) { + if (error instanceof CannotPackUsingProvidedMessageError) { + throw new CannotFitEntirePlanInsideMessageError(); + } + throw error; } - newCandidate = singleTransactionPlan( - appendTransactionMessageInstructions([ix], newCandidate.message) - ); } - return newCandidate; + return newMessage; default: instructionPlan satisfies never; throw new Error( @@ -413,3 +410,31 @@ function fitEntirePlanInsideCandidate( ); } } + +// TODO: Below should be SolanaErrors. + +export class FailedToFitPlanInNewMessageError extends Error { + constructor( + public readonly instructionPlan: InstructionPlan, + public readonly transactionMessage: TransactionMessage + ) { + super( + `The provided instruction plan could not fit in a new transaction message.` + ); + this.name = 'FailedToFitPlanInNewMessageError'; + } +} + +class NoInstructionsFoundInInstructionPlanError extends Error { + constructor() { + super('No instructions were found in the provided instruction plan.'); + this.name = 'NoInstructionsFoundInInstructionPlanError'; + } +} + +class CannotFitEntirePlanInsideMessageError extends Error { + constructor() { + super('Cannot fit the entire instruction plan inside the provided message'); + this.name = 'CannotFitEntirePlanInsideMessageError'; + } +} diff --git a/clients/js/src/utils.ts b/clients/js/src/utils.ts index 4f97eac..288451c 100644 --- a/clients/js/src/utils.ts +++ b/clients/js/src/utils.ts @@ -27,9 +27,9 @@ import { SeedArgs, } from './generated'; import { - getLinearIterableInstructionPlan, - getReallocIterableInstructionPlan, - IterableInstructionPlan, + getLinearMessagePackerInstructionPlan, + getReallocMessagePackerInstructionPlan, + MessagePackerInstructionPlan, TransactionPlanResult, } from './instructionPlans'; @@ -176,8 +176,8 @@ export function getExtendInstructionPlan(input: { extraLength: number; program?: Address; programData?: Address; -}): IterableInstructionPlan { - return getReallocIterableInstructionPlan({ +}): MessagePackerInstructionPlan { + return getReallocMessagePackerInstructionPlan({ totalSize: input.extraLength, getInstruction: (size) => getExtendInstruction({ @@ -194,8 +194,8 @@ export function getWriteInstructionPlan(input: { buffer: Address; authority: TransactionSigner; data: ReadonlyUint8Array; -}): IterableInstructionPlan { - return getLinearIterableInstructionPlan({ +}): MessagePackerInstructionPlan { + return getLinearMessagePackerInstructionPlan({ totalLength: input.data.length, getInstruction: (offset, length) => getWriteInstruction({ diff --git a/clients/js/test/instructionPlans/_instructionPlanHelpers.ts b/clients/js/test/instructionPlans/_instructionPlanHelpers.ts index c55f29d..ca5a647 100644 --- a/clients/js/test/instructionPlans/_instructionPlanHelpers.ts +++ b/clients/js/test/instructionPlans/_instructionPlanHelpers.ts @@ -8,8 +8,9 @@ import { IInstruction, } from '@solana/kit'; import { + CannotPackUsingProvidedMessageError, getTransactionSize, - IterableInstructionPlan, + MessagePackerInstructionPlan, TRANSACTION_SIZE_LIMIT, } from '../../src'; @@ -17,29 +18,31 @@ const MINIMUM_INSTRUCTION_SIZE = 35; const MINIMUM_TRANSACTION_SIZE = 136; const MAXIMUM_TRANSACTION_SIZE = TRANSACTION_SIZE_LIMIT - 1; // (for shortU16) -export function instructionIteratorFactory() { +export function messagePackerFactory() { const baseCounter = 1_000_000_000n; - const iteratorIncrement = 1_000_000_000n; - let iteratorCounter = 0n; + const messagePackerIncrement = 1_000_000_000n; + let messagePackerCounter = 0n; return ( totalBytes: number - ): IterableInstructionPlan & { + ): MessagePackerInstructionPlan & { get: (bytes: number, index: number) => IInstruction; } => { - const getInstruction = instructionFactory(baseCounter + iteratorCounter); - iteratorCounter += iteratorIncrement; + const getInstruction = instructionFactory( + baseCounter + messagePackerCounter + ); + messagePackerCounter += messagePackerIncrement; const baseInstruction = getInstruction(MINIMUM_INSTRUCTION_SIZE, 0); return { get: getInstruction, - kind: 'iterable', - getIterator: () => { + kind: 'messagePacker', + getMessagePacker: () => { let offset = 0; return { - hasNext: () => offset < totalBytes, - next: (tx) => { + done: () => offset < totalBytes, + packMessageToCapacity: (message) => { const baseTransactionSize = getTransactionSize( - appendTransactionMessageInstruction(baseInstruction, tx) + appendTransactionMessageInstruction(baseInstruction, message) ); const maxLength = TRANSACTION_SIZE_LIMIT - @@ -47,7 +50,7 @@ export function instructionIteratorFactory() { 1; /* Leeway for shortU16 numbers in transaction headers. */ if (maxLength <= 0) { - return null; + throw new CannotPackUsingProvidedMessageError(); } const length = Math.min( @@ -57,7 +60,7 @@ export function instructionIteratorFactory() { const instruction = getInstruction(length); offset += length; - return instruction; + return appendTransactionMessageInstruction(instruction, message); }, }; }, diff --git a/clients/js/test/instructionPlans/transactionPlanner.test.ts b/clients/js/test/instructionPlans/transactionPlanner.test.ts index 8d8d773..5ce9c55 100644 --- a/clients/js/test/instructionPlans/transactionPlanner.test.ts +++ b/clients/js/test/instructionPlans/transactionPlanner.test.ts @@ -1,7 +1,11 @@ -import { CompilableTransactionMessage } from '@solana/kit'; +import { + appendTransactionMessageInstruction, + CompilableTransactionMessage, +} from '@solana/kit'; import test from 'ava'; import { createBaseTransactionPlanner, + FailedToFitPlanInNewMessageError, nonDivisibleSequentialInstructionPlan, nonDivisibleSequentialTransactionPlan, parallelInstructionPlan, @@ -9,11 +13,10 @@ import { sequentialInstructionPlan, sequentialTransactionPlan, singleInstructionPlan, - TransactionPlan, } from '../../src'; import { instructionFactory, - instructionIteratorFactory, + messagePackerFactory, transactionPercentFactory, } from './_instructionPlanHelpers'; import { @@ -32,7 +35,7 @@ function defaultFactories( createTransactionMessage: effectiveCreateTransactionMessage, }), instruction: instructionFactory(), - iterator: instructionIteratorFactory(), + messagePacker: messagePackerFactory(), txPercent: transactionPercentFactory(effectiveCreateTransactionMessage), singleTransactionPlan: singleTransactionPlanFactory( effectiveCreateTransactionMessage @@ -60,17 +63,25 @@ test('it plans a single instruction', async (t) => { * [A: 200%] ───────────────────▶ Error */ test('it fail if a single instruction is too large', async (t) => { - const { createPlanner, instruction, txPercent, singleTransactionPlan } = - defaultFactories(); + const { createPlanner, instruction, txPercent } = defaultFactories(); const planner = createPlanner(); const instructionA = instruction(txPercent(200)); const promise = planner(singleInstructionPlan(instructionA)); - const error = (await t.throwsAsync(promise)) as Error & { - plan: TransactionPlan; - }; - t.deepEqual(error.plan, singleTransactionPlan([instructionA])); + const error = (await t.throwsAsync(promise, { + message: + 'The provided instruction plan could not fit in a new transaction message.', + })) as FailedToFitPlanInNewMessageError; + + t.deepEqual(error.instructionPlan, singleInstructionPlan(instructionA)); + t.deepEqual( + error.transactionMessage, + appendTransactionMessageInstruction( + instructionA, + getMockCreateTransactionMessage() + ) + ); }); /** @@ -223,6 +234,43 @@ test('it simplifies nested sequential plans', async (t) => { ); }); +/** + * [Seq] ──────────────────────▶ [Seq] + * │ │ + * ├── [A: 50%] ├── [Tx: A + B] + * ├── [Seq] └── [Tx: C + D] + * │ ├── [B: 50%] + * │ └── [C: 50%] + * └── [D: 50%] + */ +test('it simplifies sequential plans nested in the middle', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(50)); + const instructionB = instruction(txPercent(50)); + const instructionC = instruction(txPercent(50)); + const instructionD = instruction(txPercent(50)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + sequentialInstructionPlan([ + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]), + singleInstructionPlan(instructionD), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + singleTransactionPlan([instructionC, instructionD]), + ]) + ); +}); + /** * [Par] ───────────────────▶ [Tx: A + B] * │ @@ -1061,19 +1109,19 @@ test('it plans non-divisible sequentials plans with divisible sequential childre * ├── [Tx: A(2, 100%)] * └── [Tx: A(3, 50%)] */ -test('it iterate over iterable instruction plans', async (t) => { - const { createPlanner, txPercent, iterator, singleTransactionPlan } = +test('it iterate over message packer instruction plans', async (t) => { + const { createPlanner, txPercent, messagePacker, singleTransactionPlan } = defaultFactories(); const planner = createPlanner(); - const iteratorIx = iterator(txPercent(250)); + const messagePackerIx = messagePacker(txPercent(250)); t.deepEqual( - await planner(iteratorIx), + await planner(messagePackerIx), sequentialTransactionPlan([ - singleTransactionPlan([iteratorIx.get(txPercent(100), 0)]), - singleTransactionPlan([iteratorIx.get(txPercent(100), 1)]), - singleTransactionPlan([iteratorIx.get(txPercent(50), 2)]), + singleTransactionPlan([messagePackerIx.get(txPercent(100), 0)]), + singleTransactionPlan([messagePackerIx.get(txPercent(100), 1)]), + singleTransactionPlan([messagePackerIx.get(txPercent(50), 2)]), ]) ); }); @@ -1084,27 +1132,27 @@ test('it iterate over iterable instruction plans', async (t) => { * ├── [A: 50%] * └── [B(x, 50%)] */ -test('it combines single instruction plans with iterable instruction plans', async (t) => { +test('it combines single instruction plans with message packer instruction plans', async (t) => { const { createPlanner, txPercent, - iterator, + messagePacker, instruction, singleTransactionPlan, } = defaultFactories(); const planner = createPlanner(); const instructionA = instruction(txPercent(50)); - const iteratorB = iterator(txPercent(50)); + const messagePackerB = messagePacker(txPercent(50)); t.deepEqual( await planner( sequentialInstructionPlan([ singleInstructionPlan(instructionA), - iteratorB, + messagePackerB, ]) ), - singleTransactionPlan([instructionA, iteratorB.get(txPercent(50), 0)]) + singleTransactionPlan([instructionA, messagePackerB.get(txPercent(50), 0)]) ); }); @@ -1115,19 +1163,19 @@ test('it combines single instruction plans with iterable instruction plans', asy * ├── [Tx: A(2, 100%)] * └── [Tx: A(3, 50%)] */ -test('it can handle parallel iterable instruction plans', async (t) => { - const { createPlanner, txPercent, iterator, singleTransactionPlan } = +test('it can handle parallel message packer instruction plans', async (t) => { + const { createPlanner, txPercent, messagePacker, singleTransactionPlan } = defaultFactories(); const planner = createPlanner(); - const iteratorA = iterator(txPercent(250)); + const messagePackerA = messagePacker(txPercent(250)); t.deepEqual( - await planner(parallelInstructionPlan([iteratorA])), + await planner(parallelInstructionPlan([messagePackerA])), parallelTransactionPlan([ - singleTransactionPlan([iteratorA.get(txPercent(100), 0)]), - singleTransactionPlan([iteratorA.get(txPercent(100), 1)]), - singleTransactionPlan([iteratorA.get(txPercent(50), 2)]), + singleTransactionPlan([messagePackerA.get(txPercent(100), 0)]), + singleTransactionPlan([messagePackerA.get(txPercent(100), 1)]), + singleTransactionPlan([messagePackerA.get(txPercent(50), 2)]), ]) ); }); @@ -1139,19 +1187,19 @@ test('it can handle parallel iterable instruction plans', async (t) => { * ├── [Tx: A(2, 100%)] * └── [Tx: A(3, 50%)] */ -test('it can handle non-divisible sequential iterable instruction plans', async (t) => { - const { createPlanner, txPercent, iterator, singleTransactionPlan } = +test('it can handle non-divisible sequential message packer instruction plans', async (t) => { + const { createPlanner, txPercent, messagePacker, singleTransactionPlan } = defaultFactories(); const planner = createPlanner(); - const iteratorA = iterator(txPercent(250)); + const messagePackerA = messagePacker(txPercent(250)); t.deepEqual( - await planner(nonDivisibleSequentialInstructionPlan([iteratorA])), + await planner(nonDivisibleSequentialInstructionPlan([messagePackerA])), nonDivisibleSequentialTransactionPlan([ - singleTransactionPlan([iteratorA.get(txPercent(100), 0)]), - singleTransactionPlan([iteratorA.get(txPercent(100), 1)]), - singleTransactionPlan([iteratorA.get(txPercent(50), 2)]), + singleTransactionPlan([messagePackerA.get(txPercent(100), 0)]), + singleTransactionPlan([messagePackerA.get(txPercent(100), 1)]), + singleTransactionPlan([messagePackerA.get(txPercent(50), 2)]), ]) ); }); @@ -1159,16 +1207,16 @@ test('it can handle non-divisible sequential iterable instruction plans', async /** * [A(x, 100%)] ─────────────▶ [Tx: A(1, 100%)] */ -test('it simplifies iterable instruction plans that fit in a single transaction', async (t) => { - const { createPlanner, txPercent, iterator, singleTransactionPlan } = +test('it simplifies message packer instruction plans that fit in a single transaction', async (t) => { + const { createPlanner, txPercent, messagePacker, singleTransactionPlan } = defaultFactories(); const planner = createPlanner(); - const iteratorA = iterator(txPercent(100)); + const messagePackerA = messagePacker(txPercent(100)); t.deepEqual( - await planner(iteratorA), - singleTransactionPlan([iteratorA.get(txPercent(100), 0)]) + await planner(messagePackerA), + singleTransactionPlan([messagePackerA.get(txPercent(100), 0)]) ); }); @@ -1179,32 +1227,40 @@ test('it simplifies iterable instruction plans that fit in a single transaction' * ├── [B: 50%] ├── [Tx: B + C(2, 50%)] * └── [C(x, 125%)] └── [Tx: C(3, 50%)] */ -test('it uses iterable instruction plans to fill gaps in parallel candidates', async (t) => { +test('it uses message packer instruction plans to fill gaps in parallel candidates', async (t) => { const { createPlanner, txPercent, instruction, - iterator, + messagePacker, singleTransactionPlan, } = defaultFactories(); const planner = createPlanner(); const instructionA = instruction(txPercent(75)); const instructionB = instruction(txPercent(50)); - const iteratorC = iterator(txPercent(25) + txPercent(50) + txPercent(50)); // 125% + const messagePackerC = messagePacker( + txPercent(25) + txPercent(50) + txPercent(50) + ); // 125% t.deepEqual( await planner( parallelInstructionPlan([ singleInstructionPlan(instructionA), singleInstructionPlan(instructionB), - iteratorC, + messagePackerC, ]) ), parallelTransactionPlan([ - singleTransactionPlan([instructionA, iteratorC.get(txPercent(25), 0)]), - singleTransactionPlan([instructionB, iteratorC.get(txPercent(50), 1)]), - singleTransactionPlan([iteratorC.get(txPercent(50), 2)]), + singleTransactionPlan([ + instructionA, + messagePackerC.get(txPercent(25), 0), + ]), + singleTransactionPlan([ + instructionB, + messagePackerC.get(txPercent(50), 1), + ]), + singleTransactionPlan([messagePackerC.get(txPercent(50), 2)]), ]) ); }); @@ -1216,32 +1272,40 @@ test('it uses iterable instruction plans to fill gaps in parallel candidates', a * ├── [C: 50%] ├── [Tx: C + A(2, 50%)] * └── [B: 75%] └── [Tx: A(3, 50%)] */ -test('it handles parallel iterable instruction plans last to fill gaps in previous parallel candidates', async (t) => { +test('it handles parallel message packer instruction plans last to fill gaps in previous parallel candidates', async (t) => { const { createPlanner, txPercent, instruction, - iterator, + messagePacker, singleTransactionPlan, } = defaultFactories(); const planner = createPlanner(); - const iteratorA = iterator(txPercent(25) + txPercent(50) + txPercent(50)); // 125% + const messagePackerA = messagePacker( + txPercent(25) + txPercent(50) + txPercent(50) + ); // 125% const instructionB = instruction(txPercent(75)); const instructionC = instruction(txPercent(50)); t.deepEqual( await planner( parallelInstructionPlan([ - iteratorA, + messagePackerA, singleInstructionPlan(instructionB), singleInstructionPlan(instructionC), ]) ), parallelTransactionPlan([ - singleTransactionPlan([instructionB, iteratorA.get(txPercent(25), 0)]), - singleTransactionPlan([instructionC, iteratorA.get(txPercent(50), 1)]), - singleTransactionPlan([iteratorA.get(txPercent(50), 2)]), + singleTransactionPlan([ + instructionB, + messagePackerA.get(txPercent(25), 0), + ]), + singleTransactionPlan([ + instructionC, + messagePackerA.get(txPercent(50), 1), + ]), + singleTransactionPlan([messagePackerA.get(txPercent(50), 2)]), ]) ); }); @@ -1253,31 +1317,37 @@ test('it handles parallel iterable instruction plans last to fill gaps in previo * ├── [B(x, 75%)] └── [Tx: B(2, 50%) + C] * └── [C: 50%] */ -test('it uses iterable instruction plans to fill gaps in sequential candidates', async (t) => { +test('it uses message packer instruction plans to fill gaps in sequential candidates', async (t) => { const { createPlanner, txPercent, instruction, - iterator, + messagePacker, singleTransactionPlan, } = defaultFactories(); const planner = createPlanner(); const instructionA = instruction(txPercent(75)); - const iteratorB = iterator(txPercent(25) + txPercent(50)); // 75% + const messagePackerB = messagePacker(txPercent(25) + txPercent(50)); // 75% const instructionC = instruction(txPercent(50)); t.deepEqual( await planner( sequentialInstructionPlan([ singleInstructionPlan(instructionA), - iteratorB, + messagePackerB, singleInstructionPlan(instructionC), ]) ), sequentialTransactionPlan([ - singleTransactionPlan([instructionA, iteratorB.get(txPercent(25), 0)]), - singleTransactionPlan([iteratorB.get(txPercent(50), 1), instructionC]), + singleTransactionPlan([ + instructionA, + messagePackerB.get(txPercent(25), 0), + ]), + singleTransactionPlan([ + messagePackerB.get(txPercent(50), 1), + instructionC, + ]), ]) ); }); @@ -1289,31 +1359,37 @@ test('it uses iterable instruction plans to fill gaps in sequential candidates', * ├── [B(x, 75%)] └── [Tx: B(2, 50%) + C] * └── [C: 50%] */ -test('it uses iterable instruction plans to fill gaps in non-divisible sequential candidates', async (t) => { +test('it uses message packer instruction plans to fill gaps in non-divisible sequential candidates', async (t) => { const { createPlanner, txPercent, instruction, - iterator, + messagePacker, singleTransactionPlan, } = defaultFactories(); const planner = createPlanner(); const instructionA = instruction(txPercent(75)); - const iteratorB = iterator(txPercent(25) + txPercent(50)); // 75% + const messagePackerB = messagePacker(txPercent(25) + txPercent(50)); // 75% const instructionC = instruction(txPercent(50)); t.deepEqual( await planner( nonDivisibleSequentialInstructionPlan([ singleInstructionPlan(instructionA), - iteratorB, + messagePackerB, singleInstructionPlan(instructionC), ]) ), nonDivisibleSequentialTransactionPlan([ - singleTransactionPlan([instructionA, iteratorB.get(txPercent(25), 0)]), - singleTransactionPlan([iteratorB.get(txPercent(50), 1), instructionC]), + singleTransactionPlan([ + instructionA, + messagePackerB.get(txPercent(25), 0), + ]), + singleTransactionPlan([ + messagePackerB.get(txPercent(50), 1), + instructionC, + ]), ]) ); }); @@ -1326,18 +1402,18 @@ test('it uses iterable instruction plans to fill gaps in non-divisible sequentia * ├── [B(x, 75%)] * └── [C: 50%] */ -test('it uses parallel iterable instruction plans to fill gaps in sequential candidates', async (t) => { +test('it uses parallel message packer instruction plans to fill gaps in sequential candidates', async (t) => { const { createPlanner, txPercent, instruction, - iterator, + messagePacker, singleTransactionPlan, } = defaultFactories(); const planner = createPlanner(); const instructionA = instruction(txPercent(75)); - const iteratorB = iterator(txPercent(25) + txPercent(50)); // 75% + const messagePackerB = messagePacker(txPercent(25) + txPercent(50)); // 75% const instructionC = instruction(txPercent(50)); t.deepEqual( @@ -1345,14 +1421,20 @@ test('it uses parallel iterable instruction plans to fill gaps in sequential can sequentialInstructionPlan([ singleInstructionPlan(instructionA), parallelInstructionPlan([ - iteratorB, + messagePackerB, singleInstructionPlan(instructionC), ]), ]) ), sequentialTransactionPlan([ - singleTransactionPlan([instructionA, iteratorB.get(txPercent(25), 0)]), - singleTransactionPlan([instructionC, iteratorB.get(txPercent(50), 1)]), + singleTransactionPlan([ + instructionA, + messagePackerB.get(txPercent(25), 0), + ]), + singleTransactionPlan([ + instructionC, + messagePackerB.get(txPercent(50), 1), + ]), ]) ); }); @@ -1365,18 +1447,18 @@ test('it uses parallel iterable instruction plans to fill gaps in sequential can * ├── [B(x, 50%)] * └── [C: 25%] */ -test('it uses the whole sequential iterable instruction plan when it fits in the parent parallel candidate', async (t) => { +test('it uses the whole sequential message packer instruction plan when it fits in the parent parallel candidate', async (t) => { const { createPlanner, txPercent, instruction, - iterator, + messagePacker, singleTransactionPlan, } = defaultFactories(); const planner = createPlanner(); const instructionA = instruction(txPercent(25)); - const iteratorB = iterator(txPercent(50)); + const messagePackerB = messagePacker(txPercent(50)); const instructionC = instruction(txPercent(25)); t.deepEqual( @@ -1384,14 +1466,14 @@ test('it uses the whole sequential iterable instruction plan when it fits in the parallelInstructionPlan([ singleInstructionPlan(instructionA), sequentialInstructionPlan([ - iteratorB, + messagePackerB, singleInstructionPlan(instructionC), ]), ]) ), singleTransactionPlan([ instructionA, - iteratorB.get(txPercent(50), 0), + messagePackerB.get(txPercent(50), 0), instructionC, ]) ); @@ -1405,18 +1487,18 @@ test('it uses the whole sequential iterable instruction plan when it fits in the * ├── [B(x, 50%)] * └── [C: 25%] */ -test('it uses the whole non-divisible sequential iterable instruction plan when it fits in the parent sequential candidate', async (t) => { +test('it uses the whole non-divisible sequential message packer instruction plan when it fits in the parent sequential candidate', async (t) => { const { createPlanner, txPercent, instruction, - iterator, + messagePacker, singleTransactionPlan, } = defaultFactories(); const planner = createPlanner(); const instructionA = instruction(txPercent(25)); - const iteratorB = iterator(txPercent(50)); + const messagePackerB = messagePacker(txPercent(50)); const instructionC = instruction(txPercent(25)); t.deepEqual( @@ -1424,14 +1506,14 @@ test('it uses the whole non-divisible sequential iterable instruction plan when sequentialInstructionPlan([ singleInstructionPlan(instructionA), nonDivisibleSequentialInstructionPlan([ - iteratorB, + messagePackerB, singleInstructionPlan(instructionC), ]), ]) ), singleTransactionPlan([ instructionA, - iteratorB.get(txPercent(50), 0), + messagePackerB.get(txPercent(50), 0), instructionC, ]) ); @@ -1517,7 +1599,7 @@ test('complex example 2', async (t) => { const { createPlanner, instruction, - iterator, + messagePacker, txPercent, singleTransactionPlan, } = defaultFactories(); @@ -1527,7 +1609,7 @@ test('complex example 2', async (t) => { const instructionB = instruction(txPercent(20)); const instructionC = instruction(txPercent(20)); const instructionD = instruction(txPercent(50)); - const iteratorE = iterator(txPercent(250)); + const messagePackerE = messagePacker(txPercent(250)); const instructionF = instruction(txPercent(50)); const instructionG = instruction(txPercent(50)); @@ -1541,7 +1623,7 @@ test('complex example 2', async (t) => { ]), parallelInstructionPlan([ singleInstructionPlan(instructionD), - iteratorE, + messagePackerE, ]), singleInstructionPlan(instructionF), singleInstructionPlan(instructionG), @@ -1552,12 +1634,15 @@ test('complex example 2', async (t) => { instructionA, instructionB, instructionC, - iteratorE.get(txPercent(40) - 3, 0), + messagePackerE.get(txPercent(40) - 3, 0), ]), parallelTransactionPlan([ - singleTransactionPlan([instructionD, iteratorE.get(txPercent(50), 1)]), - singleTransactionPlan([iteratorE.get(txPercent(100), 2)]), - singleTransactionPlan([iteratorE.get(txPercent(60) + 3, 3)]), + singleTransactionPlan([ + instructionD, + messagePackerE.get(txPercent(50), 1), + ]), + singleTransactionPlan([messagePackerE.get(txPercent(100), 2)]), + singleTransactionPlan([messagePackerE.get(txPercent(60) + 3, 3)]), ]), singleTransactionPlan([instructionF, instructionG]), ])