diff --git a/clients/js/src/cli.ts b/clients/js/src/cli.ts index 0b81f40..6e8ca71 100644 --- a/clients/js/src/cli.ts +++ b/clients/js/src/cli.ts @@ -21,6 +21,7 @@ import { RpcSubscriptions, SolanaRpcApi, SolanaRpcSubscriptionsApi, + Transaction, } from '@solana/kit'; import chalk from 'chalk'; import { Command, Option } from 'commander'; @@ -35,11 +36,15 @@ import { getSetImmutableInstruction, } from './generated'; import { - getComputeUnitInstructions, - getDefaultMessageFactory, - getMetadataInstructionPlanExecutor, - getPdaDetails, -} from './internals'; + createDefaultTransactionPlanExecutor, + createDefaultTransactionPlanner, + InstructionPlan, + sequentialInstructionPlan, + TransactionPlanExecutor, + TransactionPlanner, + TransactionPlanResult, +} from './instructionPlans'; +import { getPdaDetails } from './internals'; import { packDirectData, PackedData, @@ -164,33 +169,35 @@ program cmd: Command ) => { const options = cmd.optsWithGlobals() as UploadOptions; - const client = getClient(options); - const [keypair, payer] = await getKeyPairSigners(options, client.configs); + const client = await getClient(options); const { authority: programAuthority } = await getProgramAuthority( client.rpc, program ); - if (!options.nonCanonical && keypair.address !== programAuthority) { + if ( + !options.nonCanonical && + client.authority.address !== programAuthority + ) { logErrorAndExit( 'You must be the program authority to upload a canonical metadata account. Use `--non-canonical` option to upload as a third party.' ); } - const { lastTransaction } = await uploadMetadata({ + await uploadMetadata({ ...client, ...getPackedData(content, options), - payer, - authority: keypair, + payer: client.payer, + authority: client.authority, program, seed, format: getFormat(options), - buffer: options.bufferOnly ? true : undefined, - extractLastTransaction: options.bufferOnly, closeBuffer: true, priorityFees: options.priorityFees, }); - if (lastTransaction) { - const transactionBytes = - getTransactionEncoder().encode(lastTransaction); + const exportTransaction = false; // TODO: Option + if (exportTransaction) { + const transactionBytes = getTransactionEncoder().encode( + {} as Transaction + ); const base64EncodedTransaction = getBase64Decoder().decode(transactionBytes); const base58EncodedTransaction = @@ -230,7 +237,7 @@ program ) .action(async (seed: string, program: Address, _, cmd: Command) => { const options = cmd.optsWithGlobals() as DownloadOptions; - const client = getClient(options); + const client = getReadonlyClient(options); const authority = options.nonCanonical === true ? (await getKeyPairSigners(options, client.configs))[0].address @@ -278,31 +285,24 @@ program cmd: Command ) => { const options = cmd.optsWithGlobals() as GlobalOptions; - const client = getClient(options); - const [keypair, payer] = await getKeyPairSigners(options, client.configs); + const client = await getClient(options); const { metadata, programData } = await getPdaDetails({ rpc: client.rpc, program, - authority: keypair, + authority: client.authority, seed, }); - const planExecutor = await getCliPlanExecutor(client, payer, metadata); - await planExecutor({ - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: options.priorityFees, - computeUnitLimit: 'simulated', - }), + await client.planAndExecute( + sequentialInstructionPlan([ getSetAuthorityInstruction({ account: metadata, - authority: keypair, + authority: client.authority, newAuthority: address(newAuthority), program, programData, }), - ], - }); + ]) + ); logSuccess( `Additional authority successfully set to ${chalk.bold(newAuthority)}` ); @@ -320,31 +320,24 @@ program .description('Remove the additional authority on canonical metadata accounts') .action(async (seed: string, program: Address, _, cmd: Command) => { const options = cmd.optsWithGlobals() as GlobalOptions; - const client = getClient(options); - const [keypair, payer] = await getKeyPairSigners(options, client.configs); + const client = await getClient(options); const { metadata, programData } = await getPdaDetails({ rpc: client.rpc, program, - authority: keypair, + authority: client.authority, seed, }); - const planExecutor = await getCliPlanExecutor(client, payer, metadata); - await planExecutor({ - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: options.priorityFees, - computeUnitLimit: 'simulated', - }), + await client.planAndExecute( + sequentialInstructionPlan([ getSetAuthorityInstruction({ account: metadata, - authority: keypair, + authority: client.authority, newAuthority: null, program, programData, }), - ], - }); + ]) + ); logSuccess('Additional authority successfully removed'); }); @@ -369,13 +362,15 @@ program ) .action(async (seed: string, program: Address, _, cmd: Command) => { const options = cmd.optsWithGlobals() as SetImmutableOptions; - const client = getClient(options); - const [keypair, payer] = await getKeyPairSigners(options, client.configs); + const client = await getClient(options); const { authority: programAuthority } = await getProgramAuthority( client.rpc, program ); - if (!options.nonCanonical && keypair.address !== programAuthority) { + if ( + !options.nonCanonical && + client.authority.address !== programAuthority + ) { logErrorAndExit( 'You must be the program authority to update a canonical metadata account. Use `--non-canonical` option to update as a third party.' ); @@ -383,25 +378,19 @@ program const { metadata, programData } = await getPdaDetails({ rpc: client.rpc, program, - authority: keypair, + authority: client.authority, seed, }); - const planExecutor = await getCliPlanExecutor(client, payer, metadata); - await planExecutor({ - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: options.priorityFees, - computeUnitLimit: 'simulated', - }), + await client.planAndExecute( + sequentialInstructionPlan([ getSetImmutableInstruction({ metadata, - authority: keypair, + authority: client.authority, program, programData, }), - ], - }); + ]) + ); logSuccess('Metadata account successfully set as immutable'); }); @@ -424,13 +413,15 @@ program ) .action(async (seed: string, program: Address, _, cmd: Command) => { const options = cmd.optsWithGlobals() as CloseOptions; - const client = getClient(options); - const [keypair, payer] = await getKeyPairSigners(options, client.configs); + const client = await getClient(options); const { authority: programAuthority } = await getProgramAuthority( client.rpc, program ); - if (!options.nonCanonical && keypair.address !== programAuthority) { + if ( + !options.nonCanonical && + client.authority.address !== programAuthority + ) { logErrorAndExit( 'You must be the program authority to close a canonical metadata account. Use `--non-canonical` option to close as a third party.' ); @@ -438,26 +429,20 @@ program const { metadata, programData } = await getPdaDetails({ rpc: client.rpc, program, - authority: keypair, + authority: client.authority, seed, }); - const planExecutor = await getCliPlanExecutor(client, payer, metadata); - await planExecutor({ - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: options.priorityFees, - computeUnitLimit: 'simulated', - }), + await client.planAndExecute( + sequentialInstructionPlan([ getCloseInstruction({ account: metadata, - authority: keypair, + authority: client.authority, program, programData, - destination: payer.address, + destination: client.payer.address, }), - ], - }); + ]) + ); logSuccess('Account successfully closed and rent recovered'); }); @@ -475,71 +460,66 @@ program // TODO }); -async function getKeyPairSigners( - options: { keypair?: string; payer?: string }, - configs: SolanaConfigs -): Promise<[KeyPairSigner, KeyPairSigner]> { - const keypairPath = getKeyPairPath(options, configs); - const keypairPromise = getKeyPairSignerFromPath(keypairPath); - const payerPromise = options.payer - ? getKeyPairSignerFromPath(options.payer) - : keypairPromise; - return await Promise.all([keypairPromise, payerPromise]); -} - -function getKeyPairPath( - options: { keypair?: string }, - configs: SolanaConfigs -): string { - if (options.keypair) return options.keypair; - if (configs.keypair_path) return configs.keypair_path; - return path.join(os.homedir(), '.config', 'solana', 'id.json'); -} - -async function getKeyPairSignerFromPath( - keypairPath: string -): Promise { - if (!fs.existsSync(keypairPath)) { - logErrorAndExit(`Keypair file not found at: ${keypairPath}`); - } - const keypairString = fs.readFileSync(keypairPath, 'utf-8'); - const keypairData = new Uint8Array(JSON.parse(keypairString)); - return await createKeyPairSignerFromBytes(keypairData); -} +type Client = ReadonlyClient & { + authority: KeyPairSigner; + executor: TransactionPlanExecutor; + payer: KeyPairSigner; + planAndExecute: ( + instructionPlan: InstructionPlan + ) => Promise; + planner: TransactionPlanner; +}; -async function getCliPlanExecutor( - client: Client, - payer: KeyPairSigner, - metadata: Address -) { - const getDefaultMessage = getDefaultMessageFactory({ - rpc: client.rpc, - payer, +async function getClient(options: { + keypair?: string; + payer?: string; + priorityFees?: MicroLamports; + rpc?: string; +}): Promise { + const readonlyClient = getReadonlyClient(options); + const [authority, payer] = await getKeyPairSigners( + options, + readonlyClient.configs + ); + const planner = createDefaultTransactionPlanner({ + feePayer: payer, + computeUnitPrice: options.priorityFees, }); - const [defaultMessage] = await Promise.all([getDefaultMessage()]); - return getMetadataInstructionPlanExecutor({ - ...client, - getDefaultMessage, - payer, - metadata, - defaultMessage, + const executor = createDefaultTransactionPlanExecutor({ + rpc: readonlyClient.rpc, + rpcSubscriptions: readonlyClient.rpcSubscriptions, + parallelChunkSize: 5, }); + const planAndExecute = async ( + instructionPlan: InstructionPlan + ): Promise => { + const transactionPlan = await planner(instructionPlan); + return await executor(transactionPlan); + }; + return { + ...readonlyClient, + authority, + executor, + payer, + planAndExecute, + planner, + }; } -type Client = { +type ReadonlyClient = { + configs: SolanaConfigs; rpc: Rpc; rpcSubscriptions: RpcSubscriptions; - configs: SolanaConfigs; }; -function getClient(options: { rpc?: string }): Client { +function getReadonlyClient(options: { rpc?: string }): ReadonlyClient { const configs = getSolanaConfigs(); const rpcUrl = getRpcUrl(options, configs); const rpcSubscriptionsUrl = getRpcSubscriptionsUrl(rpcUrl, configs); return { + configs, rpc: createSolanaRpc(rpcUrl), rpcSubscriptions: createSolanaRpcSubscriptions(rpcSubscriptionsUrl), - configs, }; } @@ -578,6 +558,38 @@ function getSolanaConfigPath(): string { return path.join(os.homedir(), '.config', 'solana', 'cli', 'config.yml'); } +async function getKeyPairSigners( + options: { keypair?: string; payer?: string }, + configs: SolanaConfigs +): Promise<[KeyPairSigner, KeyPairSigner]> { + const keypairPath = getKeyPairPath(options, configs); + const keypairPromise = getKeyPairSignerFromPath(keypairPath); + const payerPromise = options.payer + ? getKeyPairSignerFromPath(options.payer) + : keypairPromise; + return await Promise.all([keypairPromise, payerPromise]); +} + +function getKeyPairPath( + options: { keypair?: string }, + configs: SolanaConfigs +): string { + if (options.keypair) return options.keypair; + if (configs.keypair_path) return configs.keypair_path; + return path.join(os.homedir(), '.config', 'solana', 'id.json'); +} + +async function getKeyPairSignerFromPath( + keypairPath: string +): Promise { + if (!fs.existsSync(keypairPath)) { + logErrorAndExit(`Keypair file not found at: ${keypairPath}`); + } + const keypairString = fs.readFileSync(keypairPath, 'utf-8'); + const keypairData = new Uint8Array(JSON.parse(keypairString)); + return await createKeyPairSignerFromBytes(keypairData); +} + function getCompression(options: { compression?: string }): Compression { switch (options.compression) { case 'none': diff --git a/clients/js/src/createMetadata.ts b/clients/js/src/createMetadata.ts index ba0ff63..86a108e 100644 --- a/clients/js/src/createMetadata.ts +++ b/clients/js/src/createMetadata.ts @@ -1,173 +1,128 @@ import { getTransferSolInstruction } from '@solana-program/system'; import { - CompilableTransactionMessage, GetMinimumBalanceForRentExemptionApi, Lamports, + ReadonlyUint8Array, Rpc, + TransactionSigner, } from '@solana/kit'; import { getAllocateInstruction, getInitializeInstruction, + InitializeInput, PROGRAM_METADATA_PROGRAM_ADDRESS, } from './generated'; import { - getTransactionMessageFromPlan, - InstructionPlan, - MessageInstructionPlan, + createDefaultTransactionPlanExecutor, + createDefaultTransactionPlanner, + parallelInstructionPlan, + sequentialInstructionPlan, } from './instructionPlans'; import { - calculateMaxChunkSize, - getComputeUnitInstructions, - getExtendedMetadataInput, getExtendInstructionPlan, - getMetadataInstructionPlanExecutor, + getPdaDetails, getWriteInstructionPlan, - messageFitsInOneTransaction, - PdaDetails, REALLOC_LIMIT, } from './internals'; import { getAccountSize, MetadataInput, MetadataResponse } from './utils'; export async function createMetadata( - input: MetadataInput -): Promise { - const extendedInput = await getExtendedMetadataInput(input); - const executor = getMetadataInstructionPlanExecutor(extendedInput); - const plan = await getCreateMetadataInstructionPlan(extendedInput); - return await executor(plan); -} - -export async function getCreateMetadataInstructionPlan( - input: Omit & - PdaDetails & { - rpc: Rpc; - defaultMessage: CompilableTransactionMessage; - } -): Promise { - const rent = await input.rpc - .getMinimumBalanceForRentExemption(getAccountSize(input.data.length)) - .send(); - const planUsingInstructionData = - getCreateMetadataInstructionPlanUsingInstructionData({ ...input, rent }); - const messageUsingInstructionData = getTransactionMessageFromPlan( - input.defaultMessage, - planUsingInstructionData - ); - const useBuffer = - input.buffer === undefined - ? !messageFitsInOneTransaction(messageUsingInstructionData) - : !!input.buffer; - - if (!useBuffer) { - return planUsingInstructionData; + input: MetadataInput & { + rpc: Rpc & + Parameters[0]['rpc']; + rpcSubscriptions: Parameters< + typeof createDefaultTransactionPlanExecutor + >[0]['rpcSubscriptions']; } - - const chunkSize = calculateMaxChunkSize(input.defaultMessage, { - ...input, - buffer: input.metadata, +): Promise { + const planner = createDefaultTransactionPlanner({ + feePayer: input.payer, + computeUnitPrice: input.priorityFees, + }); + const executor = createDefaultTransactionPlanExecutor({ + rpc: input.rpc, + rpcSubscriptions: input.rpcSubscriptions, + parallelChunkSize: 5, }); - return getCreateMetadataInstructionPlanUsingBuffer({ + + const [{ programData, isCanonical, metadata }, rent] = await Promise.all([ + getPdaDetails(input), + input.rpc + .getMinimumBalanceForRentExemption(getAccountSize(input.data.length)) + .send(), + ]); + const extendedInput = { ...input, - chunkSize, + programData: isCanonical ? programData : undefined, + metadata, rent, - }); + }; + + const transactionPlan = await planner( + getCreateMetadataInstructionPlanUsingInstructionData(extendedInput) + ).catch(() => + planner(getCreateMetadataInstructionPlanUsingBuffer(extendedInput)) + ); + + const result = await executor(transactionPlan); + return { metadata, result }; } export function getCreateMetadataInstructionPlanUsingInstructionData( - input: Omit & - PdaDetails & { rent: Lamports } -): MessageInstructionPlan { - return { - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: input.priorityFees, - computeUnitLimit: 'simulated', - }), - getTransferSolInstruction({ - source: input.payer, - destination: input.metadata, - amount: input.rent, - }), - getInitializeInstruction({ - ...input, - programData: input.isCanonical ? input.programData : undefined, - }), - ], - }; + input: InitializeInput & { payer: TransactionSigner; rent: Lamports } +) { + return sequentialInstructionPlan([ + getTransferSolInstruction({ + source: input.payer, + destination: input.metadata, + amount: input.rent, + }), + getInitializeInstruction(input), + ]); } export function getCreateMetadataInstructionPlanUsingBuffer( - input: Omit & - PdaDetails & { rent: Lamports; chunkSize: number } -): InstructionPlan { - const mainPlan: InstructionPlan = { kind: 'sequential', plans: [] }; - - mainPlan.plans.push({ - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: input.priorityFees, - computeUnitLimit: 'simulated', - }), - getTransferSolInstruction({ - source: input.payer, - destination: input.metadata, - amount: input.rent, - }), - getAllocateInstruction({ - buffer: input.metadata, - authority: input.authority, - program: input.program, - programData: input.isCanonical ? input.programData : undefined, - seed: input.seed, - }), - ], - }); - - if (input.data.length > REALLOC_LIMIT) { - mainPlan.plans.push( - getExtendInstructionPlan({ - account: input.metadata, - authority: input.authority, - extraLength: input.data.length, - program: input.program, - programData: input.isCanonical ? input.programData : undefined, - }) - ); + input: Omit & { + data: ReadonlyUint8Array; + payer: TransactionSigner; + rent: Lamports; } - - let offset = 0; - const writePlan: InstructionPlan = { kind: 'parallel', plans: [] }; - while (offset < input.data.length) { - writePlan.plans.push( +) { + return sequentialInstructionPlan([ + getTransferSolInstruction({ + source: input.payer, + destination: input.metadata, + amount: input.rent, + }), + getAllocateInstruction({ + buffer: input.metadata, + authority: input.authority, + program: input.program, + programData: input.programData, + seed: input.seed, + }), + ...(input.data.length > REALLOC_LIMIT + ? [ + getExtendInstructionPlan({ + account: input.metadata, + authority: input.authority, + extraLength: input.data.length, + program: input.program, + programData: input.programData, + }), + ] + : []), + parallelInstructionPlan([ getWriteInstructionPlan({ buffer: input.metadata, authority: input.authority, - offset, - data: input.data.slice(offset, offset + input.chunkSize), - priorityFees: input.priorityFees, - }) - ); - offset += input.chunkSize; - } - mainPlan.plans.push(writePlan); - - mainPlan.plans.push({ - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: input.priorityFees, - computeUnitLimit: 'simulated', - }), - getInitializeInstruction({ - ...input, - programData: input.isCanonical ? input.programData : undefined, - system: PROGRAM_METADATA_PROGRAM_ADDRESS, - data: undefined, + data: input.data, }), - ], - }); - - return mainPlan; + ]), + getInitializeInstruction({ + ...input, + system: PROGRAM_METADATA_PROGRAM_ADDRESS, + data: undefined, + }), + ]); } diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 32f0679..5ce6031 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1,4 +1,5 @@ export * from './generated'; +export * from './instructionPlans'; export * from './createMetadata'; export * from './downloadMetadata'; diff --git a/clients/js/src/instructionPlans/computeBudgetHelpers.ts b/clients/js/src/instructionPlans/computeBudgetHelpers.ts new file mode 100644 index 0000000..f7f8368 --- /dev/null +++ b/clients/js/src/instructionPlans/computeBudgetHelpers.ts @@ -0,0 +1,145 @@ +// TODO: This will need decoupling from `@solana-program/compute-budget` when added to `@solana/instruction-plans` + +// TODO: The function `getComputeUnitEstimateForTransactionMessageFactory` will need to +// move in a granular package so `instruction-plans` can use it. + +import { + COMPUTE_BUDGET_PROGRAM_ADDRESS, + ComputeBudgetInstruction, + getSetComputeUnitLimitInstruction, + getSetComputeUnitPriceInstruction, + identifyComputeBudgetInstruction, +} from '@solana-program/compute-budget'; +import { + appendTransactionMessageInstruction, + BaseTransactionMessage, + CompilableTransactionMessage, + getComputeUnitEstimateForTransactionMessageFactory, + getU32Decoder, + IInstruction, + ITransactionMessageWithFeePayer, + offsetDecoder, + Rpc, + SimulateTransactionApi, + TransactionMessage, +} from '@solana/kit'; + +// Setting it to zero ensures the transaction fails unless it is properly estimated. +export const PROVISORY_COMPUTE_UNIT_LIMIT = 0; + +// This is the maximum compute unit limit that can be set for a transaction. +export const MAX_COMPUTE_UNIT_LIMIT = 1_400_000; + +export function setTransactionMessageComputeUnitPrice< + TTransactionMessage extends BaseTransactionMessage, +>(microLamports: number | bigint, transactionMessage: TTransactionMessage) { + return appendTransactionMessageInstruction( + getSetComputeUnitPriceInstruction({ microLamports }), + transactionMessage + ); +} + +export function fillProvisorySetComputeUnitLimitInstruction< + TTransactionMessage extends BaseTransactionMessage, +>(transactionMessage: TTransactionMessage) { + return updateOrAppendSetComputeUnitLimitInstruction( + (previousUnits) => + previousUnits === null ? PROVISORY_COMPUTE_UNIT_LIMIT : previousUnits, + transactionMessage + ); +} + +export async function estimateAndUpdateProvisorySetComputeUnitLimitInstruction< + TTransactionMessage extends + | CompilableTransactionMessage + | (ITransactionMessageWithFeePayer & TransactionMessage), +>( + rpc: Rpc, + transactionMessage: TTransactionMessage +): Promise { + const getComputeUnitEstimateForTransactionMessage = + getComputeUnitEstimateForTransactionMessageFactory({ rpc }); + + const instructionDetails = + getSetComputeUnitLimitInstructionIndexAndUnits(transactionMessage); + + // If the transaction message already has a compute unit limit instruction + // which is set to a specific value — i.e. not 0 or the maximum limit — + // we don't need to estimate the compute unit limit. + if ( + instructionDetails && + instructionDetails.units !== PROVISORY_COMPUTE_UNIT_LIMIT && + instructionDetails.units !== MAX_COMPUTE_UNIT_LIMIT + ) { + return transactionMessage; + } + + const units = + await getComputeUnitEstimateForTransactionMessage(transactionMessage); + return updateOrAppendSetComputeUnitLimitInstruction( + () => units, + transactionMessage + ); +} + +function updateOrAppendSetComputeUnitLimitInstruction< + TTransactionMessage extends BaseTransactionMessage, +>( + getUnits: (previousUnits: number | null) => number, + transactionMessage: TTransactionMessage +): TTransactionMessage { + const instructionDetails = + getSetComputeUnitLimitInstructionIndexAndUnits(transactionMessage); + + if (!instructionDetails) { + return appendTransactionMessageInstruction( + getSetComputeUnitLimitInstruction({ units: getUnits(null) }), + transactionMessage + ); + } + + const { index, units: previousUnits } = instructionDetails; + const units = getUnits(previousUnits); + if (units === previousUnits) { + return transactionMessage; + } + + const nextInstruction = getSetComputeUnitLimitInstruction({ units }); + const nextInstructions = [...transactionMessage.instructions]; + nextInstructions.splice(index, 1, nextInstruction); + return { ...transactionMessage, instructions: nextInstructions }; +} + +function getSetComputeUnitLimitInstructionIndexAndUnits( + transactionMessage: BaseTransactionMessage +): { index: number; units: number } | null { + const index = getSetComputeUnitLimitInstructionIndex(transactionMessage); + if (index < 0) { + return null; + } + + const units = getSetComputeUnitLimitInstructionUnits( + transactionMessage.instructions[index] + ); + + return { index, units }; +} + +function getSetComputeUnitLimitInstructionIndex( + transactionMessage: BaseTransactionMessage +) { + return transactionMessage.instructions.findIndex((ix) => { + return ( + ix.programAddress === COMPUTE_BUDGET_PROGRAM_ADDRESS && + identifyComputeBudgetInstruction(ix.data as Uint8Array) === + ComputeBudgetInstruction.SetComputeUnitLimit + ); + }); +} + +function getSetComputeUnitLimitInstructionUnits(instruction: IInstruction) { + const unitsDecoder = offsetDecoder(getU32Decoder(), { + preOffset: ({ preOffset }) => preOffset + 1, + }); + return unitsDecoder.decode(instruction.data as Uint8Array); +} diff --git a/clients/js/src/instructionPlans/defaultInstructionPlanExecutor.ts b/clients/js/src/instructionPlans/defaultInstructionPlanExecutor.ts deleted file mode 100644 index 71c4aa7..0000000 --- a/clients/js/src/instructionPlans/defaultInstructionPlanExecutor.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { - COMPUTE_BUDGET_PROGRAM_ADDRESS, - ComputeBudgetInstruction, - getSetComputeUnitLimitInstruction, - identifyComputeBudgetInstruction, -} from '@solana-program/compute-budget'; -import { - Commitment, - CompilableTransactionMessage, - compileTransaction, - FullySignedTransaction, - getBase64EncodedWireTransaction, - GetEpochInfoApi, - GetSignatureStatusesApi, - pipe, - Rpc, - RpcSubscriptions, - sendAndConfirmTransactionFactory, - SendTransactionApi, - SignatureNotificationsApi, - signTransactionMessageWithSigners, - SimulateTransactionApi, - SlotNotificationsApi, - TransactionMessageWithBlockhashLifetime, - TransactionMessageWithDurableNonceLifetime, - TransactionWithBlockhashLifetime, -} from '@solana/kit'; -import { - getTransactionMessageFromPlan, - MessageInstructionPlan, -} from './instructionPlan'; -import { - chunkParallelInstructionPlans, - createInstructionPlanExecutor, - InstructionPlanExecutor, -} from './instructionPlanExecutor'; - -export type DefaultInstructionPlanExecutorConfig = Readonly<{ - rpc: Rpc< - GetEpochInfoApi & - GetSignatureStatusesApi & - SendTransactionApi & - SimulateTransactionApi - >; - - rpcSubscriptions: RpcSubscriptions< - SignatureNotificationsApi & SlotNotificationsApi - >; - - /** - * The commitment to use when confirming transactions. - */ - commitment?: Commitment; - - /** - * When provided, chunks the plans inside a {@link ParallelInstructionPlan}. - * Each chunk is executed sequentially but each plan within a chunk is - * executed in parallel. - */ - parallelChunkSize?: number; - - /** - * If true _and_ if the transaction message contains an instruction - * that sets the compute unit limit to any value, the executor will - * simulate the transaction to determine the optimal compute unit limit - * before updating the compute budget instruction with the computed value. - */ - simulateComputeUnitLimit?: boolean; - - /** - * Returns the default transaction message used to send transactions. - * Any instructions inside a {@link MessageInstructionPlan} will be - * appended to this message. - */ - getDefaultMessage: (config?: { - abortSignal?: AbortSignal; - }) => Promise< - CompilableTransactionMessage & - ( - | TransactionMessageWithBlockhashLifetime - | TransactionMessageWithDurableNonceLifetime - ) - >; -}>; - -export function getDefaultInstructionPlanExecutor( - config: DefaultInstructionPlanExecutorConfig -): InstructionPlanExecutor { - const { - rpc, - commitment, - getDefaultMessage, - parallelChunkSize: chunkSize, - simulateComputeUnitLimit: shouldSimulateComputeUnitLimit, - } = config; - const sendAndConfirm = sendAndConfirmTransactionFactory(config); - - return async (plan, config) => { - const handleMessage = async (plan: MessageInstructionPlan) => { - const defaultMessage = await getDefaultMessage(config); - let message = getTransactionMessageFromPlan(defaultMessage, plan); - - if (shouldSimulateComputeUnitLimit) { - message = await setComputeUnitLimitBySimulatingTransaction( - message, - rpc - ); - } - - const tx = (await signTransactionMessageWithSigners( - message - )) as FullySignedTransaction & TransactionWithBlockhashLifetime; - await sendAndConfirm(tx, { - ...config, - commitment: commitment ?? 'confirmed', - skipPreflight: shouldSimulateComputeUnitLimit, - }); - }; - - const executor = pipe(createInstructionPlanExecutor(handleMessage), (e) => - chunkSize ? chunkParallelInstructionPlans(e, chunkSize) : e - ); - - return await executor(plan, config); - }; -} - -async function setComputeUnitLimitBySimulatingTransaction< - TTransactionMessage extends - CompilableTransactionMessage = CompilableTransactionMessage, ->( - message: TTransactionMessage, - rpc: Rpc -): Promise { - const instructionIndex = message.instructions.findIndex((instruction) => { - return ( - instruction.programAddress === COMPUTE_BUDGET_PROGRAM_ADDRESS && - identifyComputeBudgetInstruction(instruction.data as Uint8Array) === - ComputeBudgetInstruction.SetComputeUnitLimit - ); - }); - - // Ignore if no compute unit limit instruction is found. - if (instructionIndex === -1) { - return message; - } - - const limit = await getComputeUnitLimitBySimulatingTransaction(message, rpc); - - // Ignore if the limit is not found. - if (limit === undefined) { - return message; - } - - return Object.freeze({ - ...message, - instructions: [ - ...message.instructions.slice(0, instructionIndex), - getSetComputeUnitLimitInstruction({ - // Use a 1.1x multiplier to the computed limit. - units: Number((limit * 110n) / 100n), - }), - ...message.instructions.slice(instructionIndex + 1), - ], - } as TTransactionMessage); -} - -async function getComputeUnitLimitBySimulatingTransaction< - TTransactionMessage extends - CompilableTransactionMessage = CompilableTransactionMessage, ->( - message: TTransactionMessage, - rpc: Rpc -): Promise { - const tx = getBase64EncodedWireTransaction(compileTransaction(message)); - const result = await rpc - .simulateTransaction(tx, { encoding: 'base64' }) - .send(); - return result.value.unitsConsumed; -} diff --git a/clients/js/src/instructionPlans/index.ts b/clients/js/src/instructionPlans/index.ts index 3aa870b..f2eff47 100644 --- a/clients/js/src/instructionPlans/index.ts +++ b/clients/js/src/instructionPlans/index.ts @@ -1,3 +1,12 @@ -export * from './defaultInstructionPlanExecutor'; +export * from './computeBudgetHelpers'; export * from './instructionPlan'; -export * from './instructionPlanExecutor'; +export * from './transactionHelpers'; +export * from './transactionPlan'; +export * from './transactionPlanExecutor'; +export * from './transactionPlanExecutorBase'; +export * from './transactionPlanExecutorDecorators'; +export * from './transactionPlanExecutorDefault'; +export * from './transactionPlanner'; +export * from './transactionPlannerBase'; +export * from './transactionPlannerDefault'; +export * from './transactionPlanResult'; diff --git a/clients/js/src/instructionPlans/instructionPlan.ts b/clients/js/src/instructionPlans/instructionPlan.ts index 47da486..2378e95 100644 --- a/clients/js/src/instructionPlans/instructionPlan.ts +++ b/clients/js/src/instructionPlans/instructionPlan.ts @@ -1,17 +1,23 @@ import { - appendTransactionMessageInstructions, - BaseTransactionMessage, + appendTransactionMessageInstruction, + CompilableTransactionMessage, IInstruction, } from '@solana/kit'; +import { + getTransactionSize, + TRANSACTION_SIZE_LIMIT, +} from './transactionHelpers'; export type InstructionPlan = | SequentialInstructionPlan | ParallelInstructionPlan - | MessageInstructionPlan; + | SingleInstructionPlan + | IterableInstructionPlan; export type SequentialInstructionPlan = Readonly<{ kind: 'sequential'; plans: InstructionPlan[]; + divisible: boolean; }>; export type ParallelInstructionPlan = Readonly<{ @@ -19,21 +25,161 @@ export type ParallelInstructionPlan = Readonly<{ plans: InstructionPlan[]; }>; -export type MessageInstructionPlan< - TInstructions extends IInstruction[] = IInstruction[], +export type SingleInstructionPlan< + TInstruction extends IInstruction = IInstruction, +> = Readonly<{ + kind: 'single'; + instruction: TInstruction; +}>; + +export type IterableInstructionPlan< + TInstruction extends IInstruction = IInstruction, +> = Readonly<{ + kind: 'iterable'; + /** Get all the instructions in one go or return `null` if not possible */ + getAll: () => TInstruction[] | null; + /** Get an iterator for the instructions. */ + getIterator: () => InstructionIterator; +}>; + +export type InstructionIterator< + TInstruction extends IInstruction = IInstruction, > = Readonly<{ - kind: 'message'; - instructions: TInstructions; + /** 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: ( + transactionMessage: CompilableTransactionMessage + ) => TInstruction | null; }>; -export function getTransactionMessageFromPlan< - TTransactionMessage extends BaseTransactionMessage = BaseTransactionMessage, ->( - defaultMessage: TTransactionMessage, - plan: MessageInstructionPlan -): TTransactionMessage { - return appendTransactionMessageInstructions( - plan.instructions, - defaultMessage +export function parallelInstructionPlan( + plans: (InstructionPlan | IInstruction)[] +): ParallelInstructionPlan { + return { kind: 'parallel', plans: parseSingleInstructionPlans(plans) }; +} + +export function sequentialInstructionPlan( + plans: (InstructionPlan | IInstruction)[] +): SequentialInstructionPlan { + return { + kind: 'sequential', + divisible: true, + plans: parseSingleInstructionPlans(plans), + }; +} + +export function nonDivisibleSequentialInstructionPlan( + plans: (InstructionPlan | IInstruction)[] +): SequentialInstructionPlan { + return { + kind: 'sequential', + divisible: false, + plans: parseSingleInstructionPlans(plans), + }; +} + +export function singleInstructionPlan( + instruction: IInstruction +): SingleInstructionPlan { + return { kind: 'single', instruction }; +} + +function parseSingleInstructionPlans( + plans: (InstructionPlan | IInstruction)[] +): InstructionPlan[] { + return plans.map((plan) => + 'kind' in plan ? plan : singleInstructionPlan(plan) ); } + +export function getLinearIterableInstructionPlan({ + getInstruction, + totalLength: totalBytes, +}: { + getInstruction: (offset: number, length: number) => IInstruction; + totalLength: number; +}): IterableInstructionPlan { + return { + kind: 'iterable', + getAll: () => [getInstruction(0, totalBytes)], + getIterator: () => { + let offset = 0; + return { + hasNext: () => offset < totalBytes, + next: (tx: CompilableTransactionMessage) => { + const baseTransactionSize = getTransactionSize( + appendTransactionMessageInstruction(getInstruction(offset, 0), tx) + ); + const maxLength = + TRANSACTION_SIZE_LIMIT - + baseTransactionSize - + 1; /* Leeway for shortU16 numbers in transaction headers. */ + + if (maxLength <= 0) { + return null; + } + + const length = Math.min(totalBytes - offset, maxLength); + const instruction = getInstruction(offset, length); + offset += length; + return instruction; + }, + }; + }, + }; +} + +export function getIterableInstructionPlanFromInstructions< + TInstruction extends IInstruction = IInstruction, +>(instructions: TInstruction[]): IterableInstructionPlan { + return { + kind: 'iterable', + getAll: () => instructions, + getIterator: () => { + let instructionIndex = 0; + return { + hasNext: () => instructionIndex < instructions.length, + next: (tx: CompilableTransactionMessage) => { + if (instructionIndex >= instructions.length) { + return null; + } + + const instruction = instructions[instructionIndex]; + const transactionSize = getTransactionSize( + appendTransactionMessageInstruction(instruction, tx) + ); + + if (transactionSize > TRANSACTION_SIZE_LIMIT) { + return null; + } + + instructionIndex++; + return instruction; + }, + }; + }, + }; +} + +const REALLOC_LIMIT = 10_240; + +export function getReallocIterableInstructionPlan({ + getInstruction, + totalSize, +}: { + getInstruction: (size: number) => IInstruction; + totalSize: number; +}): IterableInstructionPlan { + const numberOfInstructions = Math.ceil(totalSize / REALLOC_LIMIT); + const lastInstructionSize = totalSize % REALLOC_LIMIT; + const instructions = new Array(numberOfInstructions) + .fill(0) + .map((_, i) => + getInstruction( + i === numberOfInstructions - 1 ? lastInstructionSize : REALLOC_LIMIT + ) + ); + + return getIterableInstructionPlanFromInstructions(instructions); +} diff --git a/clients/js/src/instructionPlans/instructionPlanExecutor.ts b/clients/js/src/instructionPlans/instructionPlanExecutor.ts deleted file mode 100644 index 5291c60..0000000 --- a/clients/js/src/instructionPlans/instructionPlanExecutor.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - InstructionPlan, - MessageInstructionPlan, - ParallelInstructionPlan, -} from './instructionPlan'; - -export type InstructionPlanExecutor = ( - plan: InstructionPlan, - config?: { abortSignal?: AbortSignal } -) => Promise; - -export function createInstructionPlanExecutor( - handleMessage: ( - plan: MessageInstructionPlan, - config?: { abortSignal?: AbortSignal } - ) => Promise -): InstructionPlanExecutor { - return async function self(plan, config) { - switch (plan.kind) { - case 'sequential': - for (const subPlan of plan.plans) { - await self(subPlan, config); - } - break; - case 'parallel': - await Promise.all(plan.plans.map((subPlan) => self(subPlan, config))); - break; - case 'message': - return await handleMessage(plan, config); - default: - throw new Error('Unsupported instruction plan'); - } - }; -} - -export function chunkParallelInstructionPlans( - executor: InstructionPlanExecutor, - chunkSize: number -): InstructionPlanExecutor { - const chunkPlan = (plan: ParallelInstructionPlan) => { - return plan.plans - .reduce( - (chunks, subPlan) => { - const lastChunk = chunks[chunks.length - 1]; - if (lastChunk && lastChunk.length < chunkSize) { - lastChunk.push(subPlan); - } else { - chunks.push([subPlan]); - } - return chunks; - }, - [[]] as InstructionPlan[][] - ) - .map((plans) => ({ kind: 'parallel', plans }) as ParallelInstructionPlan); - }; - return async function self(plan, config) { - switch (plan.kind) { - case 'sequential': - return await self(plan, config); - case 'parallel': - for (const chunk of chunkPlan(plan)) { - await executor(chunk, config); - } - break; - default: - return await executor(plan, config); - } - }; -} diff --git a/clients/js/src/instructionPlans/internal.ts b/clients/js/src/instructionPlans/internal.ts new file mode 100644 index 0000000..924a5b9 --- /dev/null +++ b/clients/js/src/instructionPlans/internal.ts @@ -0,0 +1,22 @@ +export type Mutable = { -readonly [P in keyof T]: T[P] }; + +export function getTimedCacheFunction( + fn: () => Promise, + timeoutInMilliseconds: number +): () => Promise { + let cache: T | null = null; + let lastFetchTime = 0; + return async () => { + const currentTime = Date.now(); + + // Cache hit. + if (cache && currentTime - lastFetchTime < timeoutInMilliseconds) { + return cache; + } + + // Cache miss. + cache = await fn(); + lastFetchTime = currentTime; + return cache; + }; +} diff --git a/clients/js/src/instructionPlans/transactionHelpers.ts b/clients/js/src/instructionPlans/transactionHelpers.ts new file mode 100644 index 0000000..602b8a8 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionHelpers.ts @@ -0,0 +1,47 @@ +/** + * TODO: The helpers in this file would need to be a first-class citizen of @solana/transactions. + */ + +import { + BaseTransactionMessage, + Blockhash, + CompilableTransactionMessage, + compileTransaction, + getTransactionEncoder, + setTransactionMessageLifetimeUsingBlockhash, + TransactionMessageWithBlockhashLifetime, +} from '@solana/kit'; + +export const TRANSACTION_PACKET_SIZE = 1280; + +export const TRANSACTION_PACKET_HEADER = + 40 /* 40 bytes is the size of the IPv6 header. */ + + 8; /* 8 bytes is the size of the fragment header. */ + +export const TRANSACTION_SIZE_LIMIT = + TRANSACTION_PACKET_SIZE - TRANSACTION_PACKET_HEADER; + +// It should accepts both `Transaction` and `CompilableTransactionMessage` instances. +// Over time, efforts should be made to improve the performance of this function. +// E.g. maybe we don't need to compile the transaction message to get the size. +export function getTransactionSize( + message: CompilableTransactionMessage +): number { + const transaction = compileTransaction(message); + return getTransactionEncoder().getSizeFromValue(transaction); +} + +const PROVISORY_BLOCKHASH_LIFETIME_CONSTRAINT: TransactionMessageWithBlockhashLifetime['lifetimeConstraint'] = + { + blockhash: '11111111111111111111111111111111' as Blockhash, + lastValidBlockHeight: 0n, + }; + +export function setTransactionMessageLifetimeUsingProvisoryBlockhash< + TTransactionMessage extends BaseTransactionMessage, +>(transactionMessage: TTransactionMessage) { + return setTransactionMessageLifetimeUsingBlockhash( + PROVISORY_BLOCKHASH_LIFETIME_CONSTRAINT, + transactionMessage + ); +} diff --git a/clients/js/src/instructionPlans/transactionPlan.ts b/clients/js/src/instructionPlans/transactionPlan.ts new file mode 100644 index 0000000..36914e5 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionPlan.ts @@ -0,0 +1,52 @@ +import { CompilableTransactionMessage } from '@solana/kit'; + +export type TransactionPlan = + | SequentialTransactionPlan + | ParallelTransactionPlan + | SingleTransactionPlan; + +export type SequentialTransactionPlan = Readonly<{ + kind: 'sequential'; + plans: TransactionPlan[]; + divisible: boolean; +}>; + +export type ParallelTransactionPlan = Readonly<{ + kind: 'parallel'; + plans: TransactionPlan[]; +}>; + +export type SingleTransactionPlan< + TTransactionMessage extends + CompilableTransactionMessage = CompilableTransactionMessage, +> = Readonly<{ + kind: 'single'; + message: TTransactionMessage; +}>; + +export function parallelTransactionPlan( + plans: TransactionPlan[] +): ParallelTransactionPlan { + return { kind: 'parallel', plans }; +} + +export function sequentialTransactionPlan( + plans: TransactionPlan[] +): SequentialTransactionPlan { + return { kind: 'sequential', divisible: true, plans }; +} + +export function nonDivisibleSequentialTransactionPlan( + plans: TransactionPlan[] +): SequentialTransactionPlan { + return { kind: 'sequential', divisible: false, plans }; +} + +export function getAllSingleTransactionPlans( + transactionPlan: TransactionPlan +): SingleTransactionPlan[] { + if (transactionPlan.kind === 'single') { + return [transactionPlan]; + } + return transactionPlan.plans.flatMap(getAllSingleTransactionPlans); +} diff --git a/clients/js/src/instructionPlans/transactionPlanExecutor.ts b/clients/js/src/instructionPlans/transactionPlanExecutor.ts new file mode 100644 index 0000000..27a9b77 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionPlanExecutor.ts @@ -0,0 +1,7 @@ +import { TransactionPlan } from './transactionPlan'; +import { TransactionPlanResult } from './transactionPlanResult'; + +export type TransactionPlanExecutor = ( + transactionPlan: TransactionPlan, + config?: { abortSignal?: AbortSignal } +) => Promise>; diff --git a/clients/js/src/instructionPlans/transactionPlanExecutorBase.ts b/clients/js/src/instructionPlans/transactionPlanExecutorBase.ts new file mode 100644 index 0000000..d8068e6 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionPlanExecutorBase.ts @@ -0,0 +1,171 @@ +import { + CompilableTransactionMessage, + SolanaError, + Transaction, +} from '@solana/kit'; +import { + ParallelTransactionPlan, + SequentialTransactionPlan, + SingleTransactionPlan, + TransactionPlan, +} from './transactionPlan'; +import { TransactionPlanResult } from './transactionPlanResult'; +import { TransactionPlanExecutor } from './transactionPlanExecutor'; + +export type TransactionPlanExecutorSendAndConfirm = < + TTransactionMessage extends CompilableTransactionMessage, + TContext extends object = object, +>( + transactionMessage: TTransactionMessage, + config?: { abortSignal?: AbortSignal } +) => Promise<{ context?: TContext; transaction: Transaction }>; + +type TransactionPlanExecutorConfig = { + parallelChunkSize?: number; + sendAndConfirm: TransactionPlanExecutorSendAndConfirm; +}; + +export function createBaseTransactionPlanExecutor( + executorConfig: TransactionPlanExecutorConfig +): TransactionPlanExecutor { + return async (plan, config): Promise => { + const context: TraverseContext = { + ...executorConfig, + abortSignal: config?.abortSignal, + canceled: config?.abortSignal?.aborted ?? false, + }; + + const cancelHandler = () => { + context.canceled = true; + }; + config?.abortSignal?.addEventListener('abort', cancelHandler); + const result = await traverse(plan, context); + config?.abortSignal?.removeEventListener('abort', cancelHandler); + + if (context.canceled) { + // TODO: Coded error. + const error = new Error('Transaction plan execution failed') as Error & { + result: TransactionPlanResult; + }; + error.result = result; + throw error; + } + + return result; + }; +} + +type TraverseContext = TransactionPlanExecutorConfig & { + abortSignal?: AbortSignal; + canceled: boolean; +}; + +async function traverse( + transactionPlan: TransactionPlan, + context: TraverseContext +): Promise { + switch (transactionPlan.kind) { + case 'sequential': + return await traverseSequential(transactionPlan, context); + case 'parallel': + return await traverseParallel(transactionPlan, context); + case 'single': + return await traverseSingle(transactionPlan, context); + default: + transactionPlan satisfies never; + throw new Error( + `Unknown instruction plan kind: ${(transactionPlan as { kind: string }).kind}` + ); + } +} + +async function traverseSequential( + transactionPlan: SequentialTransactionPlan, + context: TraverseContext +): Promise { + const results: TransactionPlanResult[] = []; + for (const subPlan of transactionPlan.plans) { + const result = await traverse(subPlan, context); + results.push(result); + } + return { + kind: 'sequential', + divisible: transactionPlan.divisible, + plans: results, + }; +} + +async function traverseParallel( + transactionPlan: ParallelTransactionPlan, + context: TraverseContext +): Promise { + const chunks = chunkPlans(transactionPlan.plans, context.parallelChunkSize); + const results: TransactionPlanResult[] = []; + + for (const chunk of chunks) { + const chunkResults = await Promise.all( + chunk.map((plan) => traverse(plan, context)) + ); + results.push(...chunkResults); + } + + return { kind: 'parallel', plans: results }; +} + +function chunkPlans( + plans: TransactionPlan[], + chunkSize?: number +): TransactionPlan[][] { + if (!chunkSize) { + return [plans]; + } + + return plans.reduce( + (chunks, subPlan) => { + const lastChunk = chunks[chunks.length - 1]; + if (lastChunk && lastChunk.length < chunkSize) { + lastChunk.push(subPlan); + } else { + chunks.push([subPlan]); + } + return chunks; + }, + [[]] as TransactionPlan[][] + ); +} + +async function traverseSingle( + transactionPlan: SingleTransactionPlan, + context: TraverseContext +): Promise { + if (context.canceled) { + return { + kind: 'single', + message: transactionPlan.message, + status: { kind: 'canceled' }, + }; + } + + try { + const result = await context.sendAndConfirm(transactionPlan.message, { + abortSignal: context.abortSignal, + }); + + return { + kind: 'single', + message: transactionPlan.message, + status: { + kind: 'successful', + transaction: result.transaction, + context: result.context ?? {}, + }, + }; + } catch (error) { + context.canceled = true; + return { + kind: 'single', + message: transactionPlan.message, + status: { kind: 'failed', error: error as SolanaError }, + }; + } +} diff --git a/clients/js/src/instructionPlans/transactionPlanExecutorDecorators.ts b/clients/js/src/instructionPlans/transactionPlanExecutorDecorators.ts new file mode 100644 index 0000000..8f3b348 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionPlanExecutorDecorators.ts @@ -0,0 +1,66 @@ +import { + GetLatestBlockhashApi, + Rpc, + setTransactionMessageLifetimeUsingBlockhash, + SimulateTransactionApi, +} from '@solana/kit'; +import { estimateAndUpdateProvisorySetComputeUnitLimitInstruction } from './computeBudgetHelpers'; +import { getTimedCacheFunction } from './internal'; +import { TransactionPlanExecutorSendAndConfirm } from './transactionPlanExecutorBase'; + +export function estimateAndUpdateComputeUnitLimitForTransactionPlanExecutor( + rpc: Rpc, + sendAndConfirm: TransactionPlanExecutorSendAndConfirm +): TransactionPlanExecutorSendAndConfirm { + return async (transactionMessage) => { + return await sendAndConfirm( + await estimateAndUpdateProvisorySetComputeUnitLimitInstruction( + rpc, + transactionMessage + ) + ); + }; +} + +export function refreshBlockhashForTransactionPlanExecutor( + rpc: Rpc, + sendAndConfirm: TransactionPlanExecutorSendAndConfirm +): TransactionPlanExecutorSendAndConfirm { + // Cache the latest blockhash for 60 seconds. + const getBlockhash = getTimedCacheFunction(async () => { + const { value } = await rpc.getLatestBlockhash().send(); + return value; + }, 60_000); + + return async (transactionMessage) => { + return await sendAndConfirm( + setTransactionMessageLifetimeUsingBlockhash( + await getBlockhash(), + transactionMessage + ) + ); + }; +} + +export function retryTransactionPlanExecutor( + maxRetries: number, + sendAndConfirm: TransactionPlanExecutorSendAndConfirm +): TransactionPlanExecutorSendAndConfirm { + return async (transactionMessage) => { + let retries = 0; + let lastError: Error | null = null; + + // x retries means x+1 attempts. + while (retries < maxRetries + 1) { + try { + return await sendAndConfirm(transactionMessage); + } catch (error) { + // TODO: Should we not retry on certain error codes? + retries++; + lastError = error as Error; + } + } + + throw lastError; + }; +} diff --git a/clients/js/src/instructionPlans/transactionPlanExecutorDefault.ts b/clients/js/src/instructionPlans/transactionPlanExecutorDefault.ts new file mode 100644 index 0000000..9c19859 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionPlanExecutorDefault.ts @@ -0,0 +1,134 @@ +import { + AccountNotificationsApi, + Commitment, + compileTransaction, + FullySignedTransaction, + GetAccountInfoApi, + GetEpochInfoApi, + GetLatestBlockhashApi, + GetSignatureStatusesApi, + isTransactionMessageWithSingleSendingSigner, + pipe, + Rpc, + RpcSubscriptions, + sendAndConfirmDurableNonceTransactionFactory, + sendAndConfirmTransactionFactory, + SendTransactionApi, + signAndSendTransactionMessageWithSigners, + SignatureNotificationsApi, + signTransactionMessageWithSigners, + SimulateTransactionApi, + SlotNotificationsApi, + TransactionWithBlockhashLifetime, + TransactionWithDurableNonceLifetime, + TransactionWithLifetime, +} from '@solana/kit'; +import { TransactionPlanExecutor } from './transactionPlanExecutor'; +import { + createBaseTransactionPlanExecutor, + TransactionPlanExecutorSendAndConfirm, +} from './transactionPlanExecutorBase'; +import { + estimateAndUpdateComputeUnitLimitForTransactionPlanExecutor, + refreshBlockhashForTransactionPlanExecutor, + retryTransactionPlanExecutor, +} from './transactionPlanExecutorDecorators'; + +export function createDefaultTransactionPlanExecutor( + config: SendAndConfirmTransactionFactoryConfig & { + rpc: Rpc; + commitment?: Commitment; + parallelChunkSize?: number; + } +): TransactionPlanExecutor { + return createBaseTransactionPlanExecutor({ + parallelChunkSize: config.parallelChunkSize, + sendAndConfirm: pipe( + getDefaultTransactionPlanExecutorSendAndConfirm({ + ...config, + commitment: config.commitment ?? 'confirmed', + }), + (fn) => + estimateAndUpdateComputeUnitLimitForTransactionPlanExecutor( + config.rpc, + fn + ), + (fn) => refreshBlockhashForTransactionPlanExecutor(config.rpc, fn), + (fn) => retryTransactionPlanExecutor(3, fn) + ), + }); +} + +function getDefaultTransactionPlanExecutorSendAndConfirm( + config: SendAndConfirmTransactionFactoryConfig & { commitment: Commitment } +): TransactionPlanExecutorSendAndConfirm { + const sendAndConfirm = sendAndConfirmTransactionFactoryWithAnyLifetime({ + rpc: config.rpc, + rpcSubscriptions: config.rpcSubscriptions, + }); + return async (transactionMessage, executorConfig) => { + if (isTransactionMessageWithSingleSendingSigner(transactionMessage)) { + await signAndSendTransactionMessageWithSigners(transactionMessage, { + abortSignal: executorConfig?.abortSignal, + }); + return { transaction: compileTransaction(transactionMessage) }; + } + + const transaction = + await signTransactionMessageWithSigners(transactionMessage); + await sendAndConfirm(transaction, { + abortSignal: executorConfig?.abortSignal, + commitment: config.commitment, + }); + + return { transaction }; + }; +} + +type SendAndConfirmTransactionFactoryConfig = { + rpc: Rpc< + GetEpochInfoApi & + GetSignatureStatusesApi & + SendTransactionApi & + GetAccountInfoApi + >; + rpcSubscriptions: RpcSubscriptions< + SignatureNotificationsApi & SlotNotificationsApi & AccountNotificationsApi + >; +}; + +type SendAndConfirmTransactionFunction = ( + transaction: FullySignedTransaction & TransactionWithLifetime, + config: { abortSignal?: AbortSignal; commitment: Commitment } +) => Promise; + +function sendAndConfirmTransactionFactoryWithAnyLifetime( + factoryConfig: SendAndConfirmTransactionFactoryConfig +): SendAndConfirmTransactionFunction { + const sendAndConfirmWithBlockhash = + sendAndConfirmTransactionFactory(factoryConfig); + const sendAndConfirmWithDurableNonce = + sendAndConfirmDurableNonceTransactionFactory(factoryConfig); + + return async (transaction, config) => { + if (!isBlockhashTransaction(transaction)) { + return await sendAndConfirmWithDurableNonce( + transaction as typeof transaction & TransactionWithDurableNonceLifetime, + config + ); + } + return await sendAndConfirmWithBlockhash(transaction, config); + }; +} + +function isBlockhashTransaction( + transaction: TransactionWithLifetime +): transaction is TransactionWithBlockhashLifetime { + return ( + 'lifetimeConstraint' in transaction && + 'blockhash' in transaction.lifetimeConstraint && + 'lastValidBlockHeight' in transaction.lifetimeConstraint && + typeof transaction.lifetimeConstraint.blockhash === 'string' && + typeof transaction.lifetimeConstraint.lastValidBlockHeight === 'bigint' + ); +} diff --git a/clients/js/src/instructionPlans/transactionPlanResult.ts b/clients/js/src/instructionPlans/transactionPlanResult.ts new file mode 100644 index 0000000..618c441 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionPlanResult.ts @@ -0,0 +1,38 @@ +import { + CompilableTransactionMessage, + SolanaError, + Transaction, +} from '@solana/kit'; + +export type TransactionPlanResult = + | SequentialTransactionPlanResult + | ParallelTransactionPlanResult + | SingleTransactionPlanResult; + +export type SequentialTransactionPlanResult = + Readonly<{ + kind: 'sequential'; + divisible: boolean; + plans: TransactionPlanResult[]; + }>; + +export type ParallelTransactionPlanResult = + Readonly<{ + kind: 'parallel'; + plans: TransactionPlanResult[]; + }>; + +export type SingleTransactionPlanResult< + TContext extends object = object, + TTransactionMessage extends + CompilableTransactionMessage = CompilableTransactionMessage, +> = Readonly<{ + kind: 'single'; + message: TTransactionMessage; + status: TransactionPlanResultStatus; +}>; + +export type TransactionPlanResultStatus = + | { kind: 'canceled' } + | { kind: 'failed'; error: SolanaError } + | { kind: 'successful'; context: TContext; transaction: Transaction }; diff --git a/clients/js/src/instructionPlans/transactionPlanner.ts b/clients/js/src/instructionPlans/transactionPlanner.ts new file mode 100644 index 0000000..f960d73 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionPlanner.ts @@ -0,0 +1,7 @@ +import { InstructionPlan } from './instructionPlan'; +import { TransactionPlan } from './transactionPlan'; + +export type TransactionPlanner = ( + instructionPlan: InstructionPlan, + config?: { abortSignal?: AbortSignal } +) => Promise; diff --git a/clients/js/src/instructionPlans/transactionPlannerBase.ts b/clients/js/src/instructionPlans/transactionPlannerBase.ts new file mode 100644 index 0000000..f45a321 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionPlannerBase.ts @@ -0,0 +1,397 @@ +import { + appendTransactionMessageInstructions, + CompilableTransactionMessage, + IInstruction, +} from '@solana/kit'; +import { + InstructionIterator, + InstructionPlan, + IterableInstructionPlan, + ParallelInstructionPlan, + SequentialInstructionPlan, + SingleInstructionPlan, +} from './instructionPlan'; +import { Mutable } from './internal'; +import { + getTransactionSize, + TRANSACTION_SIZE_LIMIT, +} from './transactionHelpers'; +import { + getAllSingleTransactionPlans, + SingleTransactionPlan, + TransactionPlan, +} from './transactionPlan'; +import { TransactionPlanner } from './transactionPlanner'; + +export type TransactionPlannerConfig = { + createTransactionMessage: (config?: { + abortSignal?: AbortSignal; + }) => Promise | CompilableTransactionMessage; + newInstructionsTransformer?: < + TTransactionMessage extends CompilableTransactionMessage, + >( + transactionMessage: TTransactionMessage, + config?: { abortSignal?: AbortSignal } + ) => Promise | TTransactionMessage; +}; + +export function createBaseTransactionPlanner( + config: TransactionPlannerConfig +): TransactionPlanner { + const createSingleTransactionPlan = async ( + instructions: IInstruction[] = [], + abortSignal?: AbortSignal + ): Promise => { + abortSignal?.throwIfAborted(); + const plan: SingleTransactionPlan = { + kind: 'single', + message: await Promise.resolve( + config.createTransactionMessage({ abortSignal }) + ), + }; + if (instructions.length > 0) { + abortSignal?.throwIfAborted(); + await addInstructionsToSingleTransactionPlan( + plan, + instructions, + abortSignal + ); + } + return plan; + }; + + const addInstructionsToSingleTransactionPlan = async ( + plan: SingleTransactionPlan, + instructions: IInstruction[], + abortSignal?: AbortSignal + ): Promise => { + let message = appendTransactionMessageInstructions( + instructions, + plan.message + ); + if (config?.newInstructionsTransformer) { + abortSignal?.throwIfAborted(); + message = await Promise.resolve( + config.newInstructionsTransformer(plan.message, { abortSignal }) + ); + } + (plan as Mutable).message = message; + }; + + return async ( + originalInstructionPlan, + { abortSignal } = {} + ): Promise => { + const plan = await traverse(originalInstructionPlan, { + abortSignal, + parent: null, + parentCandidates: [], + createSingleTransactionPlan, + addInstructionsToSingleTransactionPlan, + }); + + 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; + } + + return plan; + }; +} + +type TraverseContext = { + abortSignal?: AbortSignal; + parent: InstructionPlan | null; + parentCandidates: SingleTransactionPlan[]; + createSingleTransactionPlan: ( + instructions?: IInstruction[], + abortSignal?: AbortSignal + ) => Promise; + addInstructionsToSingleTransactionPlan: ( + plan: SingleTransactionPlan, + instructions: IInstruction[], + abortSignal?: AbortSignal + ) => Promise; +}; + +async function traverse( + instructionPlan: InstructionPlan, + context: TraverseContext +): Promise { + context.abortSignal?.throwIfAborted(); + switch (instructionPlan.kind) { + case 'sequential': + return await traverseSequential(instructionPlan, context); + case 'parallel': + return await traverseParallel(instructionPlan, context); + case 'single': + return await traverseSingle(instructionPlan, context); + case 'iterable': + return await traverseIterable(instructionPlan, context); + default: + instructionPlan satisfies never; + throw new Error( + `Unknown instruction plan kind: ${(instructionPlan as { kind: string }).kind}` + ); + } +} + +async function traverseSequential( + instructionPlan: SequentialInstructionPlan, + context: TraverseContext +): Promise { + let candidate: SingleTransactionPlan | null = null; + const mustEntirelyFitInCandidate = + context.parent && + (context.parent.kind === 'parallel' || !instructionPlan.divisible); + if (mustEntirelyFitInCandidate) { + const allInstructions = getAllInstructions(instructionPlan); + candidate = allInstructions + ? selectCandidate(context.parentCandidates, allInstructions) + : null; + if (candidate && allInstructions) { + await context.addInstructionsToSingleTransactionPlan( + candidate, + allInstructions + ); + return null; + } + } else { + candidate = + context.parentCandidates.length > 0 ? context.parentCandidates[0] : null; + } + + const transactionPlans: TransactionPlan[] = []; + for (const plan of instructionPlan.plans) { + const transactionPlan = await traverse(plan, { + ...context, + parent: instructionPlan, + parentCandidates: candidate ? [candidate] : [], + }); + if (transactionPlan) { + candidate = getSequentialCandidate(transactionPlan); + const newPlans = + transactionPlan.kind === 'sequential' && + (transactionPlan.divisible || !instructionPlan.divisible) + ? transactionPlan.plans + : [transactionPlan]; + transactionPlans.push(...newPlans); + } + } + if (transactionPlans.length === 1) { + return transactionPlans[0]; + } + if (transactionPlans.length === 0) { + return null; + } + return { + kind: 'sequential', + divisible: instructionPlan.divisible, + plans: transactionPlans, + }; +} + +async function traverseParallel( + instructionPlan: ParallelInstructionPlan, + context: TraverseContext +): Promise { + const candidates: SingleTransactionPlan[] = [...context.parentCandidates]; + const transactionPlans: TransactionPlan[] = []; + + // Reorder children so iterable plans are last. + const sortedChildren = [ + ...instructionPlan.plans.filter((plan) => plan.kind !== 'iterable'), + ...instructionPlan.plans.filter((plan) => plan.kind === 'iterable'), + ]; + + for (const plan of sortedChildren) { + const transactionPlan = await traverse(plan, { + ...context, + parent: instructionPlan, + parentCandidates: candidates, + }); + if (transactionPlan) { + candidates.push(...getParallelCandidates(transactionPlan)); + const newPlans = + transactionPlan.kind === 'parallel' + ? transactionPlan.plans + : [transactionPlan]; + transactionPlans.push(...newPlans); + } + } + if (transactionPlans.length === 1) { + return transactionPlans[0]; + } + if (transactionPlans.length === 0) { + return null; + } + return { kind: 'parallel', plans: transactionPlans }; +} + +async function traverseSingle( + instructionPlan: SingleInstructionPlan, + context: TraverseContext +): Promise { + const ix = instructionPlan.instruction; + const candidate = selectCandidate(context.parentCandidates, [ix]); + if (candidate) { + await context.addInstructionsToSingleTransactionPlan(candidate, [ix]); + return null; + } + return await context.createSingleTransactionPlan([ix], context.abortSignal); +} + +async function traverseIterable( + instructionPlan: IterableInstructionPlan, + context: TraverseContext +): Promise { + const iterator = instructionPlan.getIterator(); + const transactionPlans: SingleTransactionPlan[] = []; + const candidates = [...context.parentCandidates]; + + while (iterator.hasNext()) { + const candidateResult = selectCandidateForIterator(candidates, iterator); + if (candidateResult) { + const [candidate, ix] = candidateResult; + await context.addInstructionsToSingleTransactionPlan( + candidate, + [ix], + context.abortSignal + ); + } else { + const newPlan = await context.createSingleTransactionPlan( + [], + context.abortSignal + ); + const ix = iterator.next(newPlan.message); + if (!ix) { + throw new Error( + 'Could not fit `InterableInstructionPlan` into a transaction' + ); + } + await context.addInstructionsToSingleTransactionPlan( + newPlan, + [ix], + context.abortSignal + ); + 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); + } + } + + if (transactionPlans.length === 1) { + return transactionPlans[0]; + } + if (transactionPlans.length === 0) { + return null; + } + if (context.parent?.kind === 'parallel') { + return { kind: 'parallel', plans: transactionPlans }; + } + return { + kind: 'sequential', + divisible: + context.parent?.kind === 'sequential' ? context.parent.divisible : true, + plans: transactionPlans, + }; +} + +function getSequentialCandidate( + latestPlan: TransactionPlan +): SingleTransactionPlan | null { + if (latestPlan.kind === 'single') { + return latestPlan; + } + if (latestPlan.kind === 'sequential' && latestPlan.plans.length > 0) { + return getSequentialCandidate( + latestPlan.plans[latestPlan.plans.length - 1] + ); + } + return null; +} + +function getParallelCandidates( + latestPlan: TransactionPlan +): SingleTransactionPlan[] { + return getAllSingleTransactionPlans(latestPlan); +} + +function getAllInstructions( + instructionPlan: InstructionPlan +): IInstruction[] | null { + if (instructionPlan.kind === 'single') { + return [instructionPlan.instruction]; + } + if (instructionPlan.kind === 'iterable') { + return instructionPlan.getAll(); + } + return instructionPlan.plans.reduce( + (acc, plan) => { + if (acc === null) return null; + const instructions = getAllInstructions(plan); + if (instructions === null) return null; + acc.push(...instructions); + return acc; + }, + [] as IInstruction[] | null + ); +} + +function selectCandidateForIterator( + candidates: SingleTransactionPlan[], + iterator: InstructionIterator +): [SingleTransactionPlan, IInstruction] | null { + for (const candidate of candidates) { + const ix = iterator.next(candidate.message); + if (ix) { + return [candidate, ix]; + } + } + return null; +} + +function selectCandidate( + candidates: SingleTransactionPlan[], + instructions: IInstruction[] +): SingleTransactionPlan | null { + const firstValidCandidate = candidates.find((candidate) => + isValidCandidate(candidate, instructions) + ); + return firstValidCandidate ?? null; +} + +function isValidCandidate( + candidate: SingleTransactionPlan, + instructions: IInstruction[] +): boolean { + const message = appendTransactionMessageInstructions( + instructions, + candidate.message + ); + return getRemainingTransactionSize(message) >= 0; +} + +export function getRemainingTransactionSize( + message: CompilableTransactionMessage +) { + return TRANSACTION_SIZE_LIMIT - getTransactionSize(message); +} + +function isValidTransactionPlan(transactionPlan: TransactionPlan): boolean { + if (transactionPlan.kind === 'single') { + const transactionSize = getTransactionSize(transactionPlan.message); + return transactionSize <= TRANSACTION_SIZE_LIMIT; + } + return transactionPlan.plans.every(isValidTransactionPlan); +} diff --git a/clients/js/src/instructionPlans/transactionPlannerDefault.ts b/clients/js/src/instructionPlans/transactionPlannerDefault.ts new file mode 100644 index 0000000..08d0ea9 --- /dev/null +++ b/clients/js/src/instructionPlans/transactionPlannerDefault.ts @@ -0,0 +1,33 @@ +import { + createTransactionMessage, + MicroLamports, + pipe, + setTransactionMessageFeePayerSigner, + TransactionSigner, +} from '@solana/kit'; +import { + fillProvisorySetComputeUnitLimitInstruction, + setTransactionMessageComputeUnitPrice, +} from './computeBudgetHelpers'; +import { setTransactionMessageLifetimeUsingProvisoryBlockhash } from './transactionHelpers'; +import { TransactionPlanner } from './transactionPlanner'; +import { createBaseTransactionPlanner } from './transactionPlannerBase'; + +export function createDefaultTransactionPlanner(config: { + feePayer: TransactionSigner; + computeUnitPrice?: MicroLamports; +}): TransactionPlanner { + return createBaseTransactionPlanner({ + createTransactionMessage: () => + pipe( + createTransactionMessage({ version: 0 }), + setTransactionMessageLifetimeUsingProvisoryBlockhash, + fillProvisorySetComputeUnitLimitInstruction, + (tx) => setTransactionMessageFeePayerSigner(config.feePayer, tx), + (tx) => + config.computeUnitPrice + ? setTransactionMessageComputeUnitPrice(config.computeUnitPrice, tx) + : tx + ), + }); +} diff --git a/clients/js/src/internals.ts b/clients/js/src/internals.ts index 8b6ba97..127c8c6 100644 --- a/clients/js/src/internals.ts +++ b/clients/js/src/internals.ts @@ -1,68 +1,24 @@ -import { - getSetComputeUnitLimitInstruction, - getSetComputeUnitPriceInstruction, -} from '@solana-program/compute-budget'; import { Address, - Commitment, - CompilableTransactionMessage, - compileTransaction, - createTransactionMessage, GetAccountInfoApi, - GetLatestBlockhashApi, - getTransactionEncoder, - IInstruction, - MicroLamports, - pipe, ReadonlyUint8Array, Rpc, - setTransactionMessageFeePayerSigner, - setTransactionMessageLifetimeUsingBlockhash, - Transaction, - TransactionMessageWithBlockhashLifetime, TransactionSigner, } from '@solana/kit'; import { - ExtendInstruction, findMetadataPda, getExtendInstruction, getWriteInstruction, SeedArgs, } from './generated'; import { - getDefaultInstructionPlanExecutor, - getTransactionMessageFromPlan, - InstructionPlan, - InstructionPlanExecutor, - MessageInstructionPlan, + getLinearIterableInstructionPlan, + getReallocIterableInstructionPlan, + IterableInstructionPlan, } from './instructionPlans'; -import { getProgramAuthority, MetadataInput, MetadataResponse } from './utils'; +import { getProgramAuthority } from './utils'; export const REALLOC_LIMIT = 10_240; -const TRANSACTION_SIZE_LIMIT = - 1_280 - - 40 /* 40 bytes is the size of the IPv6 header. */ - - 8; /* 8 bytes is the size of the fragment header. */ - -export type ExtendedMetadataInput = MetadataInput & - PdaDetails & { - getDefaultMessage: () => Promise< - CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime - >; - defaultMessage: CompilableTransactionMessage & - TransactionMessageWithBlockhashLifetime; - }; - -export async function getExtendedMetadataInput( - input: MetadataInput -): Promise { - const getDefaultMessage = getDefaultMessageFactory(input); - const [pdaDetails, defaultMessage] = await Promise.all([ - getPdaDetails(input), - getDefaultMessage(), - ]); - return { ...input, ...pdaDetails, defaultMessage, getDefaultMessage }; -} export type PdaDetails = { metadata: Address; @@ -93,272 +49,39 @@ export async function getPdaDetails(input: { return { metadata, isCanonical, programData }; } -export function getDefaultMessageFactory(input: { - rpc: Rpc; - payer: TransactionSigner; -}): () => Promise< - CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime -> { - const getBlockhash = getTimedCacheFunction(async () => { - const { value } = await input.rpc.getLatestBlockhash().send(); - return value; - }, 60_000); - return async () => { - const latestBlockhash = await getBlockhash(); - return pipe( - createTransactionMessage({ version: 0 }), - (tx) => setTransactionMessageFeePayerSigner(input.payer, tx), - (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx) - ); - }; -} - -function getTimedCacheFunction( - fn: () => Promise, - timeoutInMilliseconds: number -): () => Promise { - let cache: T | null = null; - let lastFetchTime = 0; - return async () => { - const currentTime = Date.now(); - - // Cache hit. - if (cache && currentTime - lastFetchTime < timeoutInMilliseconds) { - return cache; - } - - // Cache miss. - cache = await fn(); - lastFetchTime = currentTime; - return cache; - }; -} - -const MAX_COMPUTE_UNIT_LIMIT = 1_400_000; - -export function getComputeUnitInstructions(input: { - computeUnitPrice?: MicroLamports; - computeUnitLimit?: number | 'simulated'; -}) { - const instructions: IInstruction[] = []; - if (input.computeUnitPrice !== undefined) { - instructions.push( - getSetComputeUnitPriceInstruction({ - microLamports: input.computeUnitPrice, - }) - ); - } - if (input.computeUnitLimit !== undefined) { - instructions.push( - getSetComputeUnitLimitInstruction({ - units: - input.computeUnitLimit === 'simulated' - ? MAX_COMPUTE_UNIT_LIMIT - : input.computeUnitLimit, - }) - ); - } - return instructions; -} - -export function calculateMaxChunkSize( - defaultMessage: CompilableTransactionMessage, - input: { - buffer: Address; - authority: TransactionSigner; - priorityFees?: MicroLamports; - } -) { - const plan = getWriteInstructionPlan({ - ...input, - offset: 0, - data: new Uint8Array(0), - }); - const message = getTransactionMessageFromPlan(defaultMessage, plan); - return getRemainingTransactionSpaceFromMessage(message); -} - -export function messageFitsInOneTransaction( - message: CompilableTransactionMessage -): boolean { - return getRemainingTransactionSpaceFromMessage(message) >= 0; -} - -function getRemainingTransactionSpaceFromMessage( - message: CompilableTransactionMessage -) { - return ( - TRANSACTION_SIZE_LIMIT - - getTransactionSizeFromMessage(message) - - 1 /* Subtract 1 byte buffer to account for shortvec encoding. */ - ); -} - -function getTransactionSizeFromMessage( - message: CompilableTransactionMessage -): number { - const transaction = compileTransaction(message); - return getTransactionEncoder().encode(transaction).length; -} - export function getExtendInstructionPlan(input: { account: Address; authority: TransactionSigner; extraLength: number; - priorityFees?: MicroLamports; program?: Address; programData?: Address; -}): InstructionPlan { - const plans: MessageInstructionPlan[] = []; - const extendsPerTransaction = 50; - let chunkOffset = 0; - - while (chunkOffset < input.extraLength) { - const extendInstructions: ExtendInstruction[] = []; - let offset = chunkOffset; - - while (offset < input.extraLength) { - const length = Math.min(input.extraLength - offset, REALLOC_LIMIT); - extendInstructions.push( - getExtendInstruction({ - account: input.account, - authority: input.authority, - length, - program: input.program, - programData: input.programData, - }) - ); - offset += length; - } - - plans.push({ - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: input.priorityFees, - computeUnitLimit: 'simulated', - }), - ...extendInstructions, - ], - }); - chunkOffset += REALLOC_LIMIT * extendsPerTransaction; - } - - return { kind: 'parallel', plans }; +}): IterableInstructionPlan { + return getReallocIterableInstructionPlan({ + totalSize: input.extraLength, + getInstruction: (size) => + getExtendInstruction({ + account: input.account, + authority: input.authority, + length: size, + program: input.program, + programData: input.programData, + }), + }); } export function getWriteInstructionPlan(input: { buffer: Address; authority: TransactionSigner; - offset: number; data: ReadonlyUint8Array; - priorityFees?: MicroLamports; -}): MessageInstructionPlan { - return { - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: input.priorityFees, - computeUnitLimit: 'simulated', +}): IterableInstructionPlan { + return getLinearIterableInstructionPlan({ + totalLength: input.data.length, + getInstruction: (offset, length) => + getWriteInstruction({ + buffer: input.buffer, + authority: input.authority, + offset, + data: input.data.slice(offset, offset + length), }), - getWriteInstruction({ ...input }), - ], - }; -} - -export function getMetadataInstructionPlanExecutor( - input: Pick< - ExtendedMetadataInput, - | 'rpc' - | 'rpcSubscriptions' - | 'payer' - | 'extractLastTransaction' - | 'metadata' - | 'defaultMessage' - | 'getDefaultMessage' - > & { commitment?: Commitment } -): ( - plan: InstructionPlan, - config?: { abortSignal?: AbortSignal } -) => Promise { - const executor = getDefaultInstructionPlanExecutor({ - ...input, - simulateComputeUnitLimit: true, - getDefaultMessage: input.getDefaultMessage, }); - - return async (plan, config) => { - const [planToSend, lastTransaction] = extractLastTransactionIfRequired( - plan, - input - ); - await executor(planToSend, config); - return { metadata: input.metadata, lastTransaction }; - }; -} - -export async function executeInstructionPlanAndGetMetadataResponse( - plan: InstructionPlan, - executor: InstructionPlanExecutor, - input: { - metadata: Address; - defaultMessage: CompilableTransactionMessage; - extractLastTransaction?: boolean; - } -): Promise { - const [planToSend, lastTransaction] = extractLastTransactionIfRequired( - plan, - input - ); - await executor(planToSend); - return { metadata: input.metadata, lastTransaction }; -} - -function extractLastTransactionIfRequired( - plan: InstructionPlan, - input: { - defaultMessage: CompilableTransactionMessage; - extractLastTransaction?: boolean; - } -): [InstructionPlan, Transaction | undefined] { - if (!input.extractLastTransaction) { - return [plan, undefined]; - } - const result = extractLastMessageFromPlan(plan); - const lastMessage = getTransactionMessageFromPlan( - input.defaultMessage, - result.lastMessage - ); - return [result.plan, compileTransaction(lastMessage)]; -} - -function extractLastMessageFromPlan(plan: InstructionPlan): { - plan: InstructionPlan; - lastMessage: MessageInstructionPlan; -} { - switch (plan.kind) { - case 'sequential': - // eslint-disable-next-line no-case-declarations - const lastMessage = plan.plans[plan.plans.length - 1]; - if (lastMessage.kind !== 'message') { - throw Error( - `Expected last plan to be a message plan, got: ${lastMessage.kind}` - ); - } - return { - plan: { kind: 'sequential', plans: plan.plans.slice(0, -1) }, - lastMessage, - }; - case 'message': - throw new Error( - 'This operation can be executed without a buffer. ' + - 'Therefore, the `extractLastTransaction` option is redundant. ' + - 'Use the `buffer` option to force the use of a buffer.' - ); - case 'parallel': - default: - throw Error( - `Cannot extract last transaction from plan kind: "${plan.kind}"` - ); - } } diff --git a/clients/js/src/updateMetadata.ts b/clients/js/src/updateMetadata.ts index 49dcb0e..ac47487 100644 --- a/clients/js/src/updateMetadata.ts +++ b/clients/js/src/updateMetadata.ts @@ -3,12 +3,12 @@ import { getTransferSolInstruction, } from '@solana-program/system'; import { - CompilableTransactionMessage, generateKeyPairSigner, + GetAccountInfoApi, GetMinimumBalanceForRentExemptionApi, - isTransactionSigner, lamports, Lamports, + ReadonlyUint8Array, Rpc, TransactionSigner, } from '@solana/kit'; @@ -20,181 +20,138 @@ import { getSetDataInstruction, getTrimInstruction, PROGRAM_METADATA_PROGRAM_ADDRESS, + SetDataInput, } from './generated'; import { - calculateMaxChunkSize, - getComputeUnitInstructions, - getExtendedMetadataInput, + createDefaultTransactionPlanExecutor, + createDefaultTransactionPlanner, + parallelInstructionPlan, + sequentialInstructionPlan, +} from './instructionPlans'; +import { getExtendInstructionPlan, - getMetadataInstructionPlanExecutor, + getPdaDetails, getWriteInstructionPlan, - messageFitsInOneTransaction, - PdaDetails, REALLOC_LIMIT, } from './internals'; import { getAccountSize, MetadataInput, MetadataResponse } from './utils'; -import { - getTransactionMessageFromPlan, - InstructionPlan, - MessageInstructionPlan, -} from './instructionPlans'; export async function updateMetadata( - input: MetadataInput -): Promise { - const extendedInput = await getExtendedMetadataInput(input); - const executor = getMetadataInstructionPlanExecutor(extendedInput); - const metadataAccount = await fetchMetadata( - input.rpc, - extendedInput.metadata - ); - if (!metadataAccount.data.mutable) { - throw new Error('Metadata account is immutable'); + input: MetadataInput & { + rpc: Rpc & + Parameters[0]['rpc']; + rpcSubscriptions: Parameters< + typeof createDefaultTransactionPlanExecutor + >[0]['rpcSubscriptions']; } - const plan = await getUpdateMetadataInstructionPlan({ - ...extendedInput, - currentDataLength: BigInt(metadataAccount.data.data.length), +): Promise { + const planner = createDefaultTransactionPlanner({ + feePayer: input.payer, + computeUnitPrice: input.priorityFees, + }); + const executor = createDefaultTransactionPlanExecutor({ + rpc: input.rpc, + rpcSubscriptions: input.rpcSubscriptions, + parallelChunkSize: 5, }); - return await executor(plan); -} -export async function getUpdateMetadataInstructionPlan( - input: Omit & - PdaDetails & { - rpc: Rpc; - currentDataLength: bigint; - defaultMessage: CompilableTransactionMessage; - } -): Promise { - const newDataLength = BigInt(input.data.length); - const sizeDifference = newDataLength - BigInt(input.currentDataLength); - const extraRent = - sizeDifference > 0 - ? await input.rpc.getMinimumBalanceForRentExemption(sizeDifference).send() - : lamports(0n); - const planUsingInstructionData = - getUpdateMetadataInstructionPlanUsingInstructionData({ - ...input, - sizeDifference, - extraRent, - }); - const messageUsingInstructionData = getTransactionMessageFromPlan( - input.defaultMessage, - planUsingInstructionData - ); - const useBuffer = - input.buffer === undefined - ? !messageFitsInOneTransaction(messageUsingInstructionData) - : !!input.buffer; + const [{ programData, isCanonical, metadata }, bufferRent] = + await Promise.all([ + getPdaDetails(input), + input.rpc + .getMinimumBalanceForRentExemption(getAccountSize(input.data.length)) + .send(), + ]); - if (!useBuffer) { - return planUsingInstructionData; + const metadataAccount = await fetchMetadata(input.rpc, metadata); + if (!metadataAccount.data.mutable) { + throw new Error('Metadata account is immutable'); } - const newAccountSize = getAccountSize(newDataLength); - const [buffer, bufferRent] = await Promise.all([ - typeof input.buffer === 'object' && isTransactionSigner(input.buffer) - ? Promise.resolve(input.buffer) - : generateKeyPairSigner(), - input.rpc.getMinimumBalanceForRentExemption(newAccountSize).send(), + const sizeDifference = + BigInt(input.data.length) - BigInt(metadataAccount.data.data.length); + const extraRentPromise = + sizeDifference > 0 + ? input.rpc.getMinimumBalanceForRentExemption(sizeDifference).send() + : Promise.resolve(lamports(0n)); + const [extraRent, buffer] = await Promise.all([ + extraRentPromise, + generateKeyPairSigner(), ]); - const chunkSize = calculateMaxChunkSize(input.defaultMessage, { - ...input, - buffer: buffer.address, - authority: buffer, - }); - return getUpdateMetadataInstructionPlanUsingBuffer({ + + const extendedInput = { ...input, - sizeDifference, - extraRent, - bufferRent, + programData: isCanonical ? programData : undefined, + metadata, buffer, - chunkSize, - }); -} - -export function getUpdateMetadataInstructionPlanUsingInstructionData( - input: Omit & - PdaDetails & { - sizeDifference: bigint; - extraRent: Lamports; - } -): MessageInstructionPlan { - const plan: InstructionPlan = { - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: input.priorityFees, - computeUnitLimit: 'simulated', - }), - ], + bufferRent, + extraRent, + sizeDifference, }; - if (input.sizeDifference > 0) { - plan.instructions.push( - getTransferSolInstruction({ - source: input.payer, - destination: input.metadata, - amount: input.extraRent, - }) - ); - } - - plan.instructions.push( - getSetDataInstruction({ - ...input, - programData: input.isCanonical ? input.programData : undefined, - buffer: undefined, - }) + const transactionPlan = await planner( + getUpdateMetadataInstructionPlanUsingInstructionData(extendedInput) + ).catch(() => + planner(getUpdateMetadataInstructionPlanUsingBuffer(extendedInput)) ); - if (input.sizeDifference < 0) { - plan.instructions.push( - getTrimInstruction({ - account: input.metadata, - authority: input.authority, - destination: input.payer.address, - program: input.program, - programData: input.isCanonical ? input.programData : undefined, - }) - ); - } + const result = await executor(transactionPlan); + return { metadata, result }; +} - return plan; +export function getUpdateMetadataInstructionPlanUsingInstructionData( + input: Omit & { + extraRent: Lamports; + payer: TransactionSigner; + sizeDifference: bigint | number; + } +) { + return sequentialInstructionPlan([ + ...(input.sizeDifference > 0 + ? [ + getTransferSolInstruction({ + source: input.payer, + destination: input.metadata, + amount: input.extraRent, + }), + ] + : []), + getSetDataInstruction({ ...input, buffer: undefined }), + ...(input.sizeDifference < 0 + ? [ + getTrimInstruction({ + account: input.metadata, + authority: input.authority, + destination: input.payer.address, + program: input.program, + programData: input.program, + }), + ] + : []), + ]); } export function getUpdateMetadataInstructionPlanUsingBuffer( - input: Omit & - PdaDetails & { - chunkSize: number; - sizeDifference: bigint; - extraRent: Lamports; - bufferRent: Lamports; - buffer: TransactionSigner; - } -): InstructionPlan { - const mainPlan: InstructionPlan = { kind: 'sequential', plans: [] }; - const initialMessage: InstructionPlan = { - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: input.priorityFees, - computeUnitLimit: 'simulated', - }), - ], - }; - - if (input.sizeDifference > 0) { - initialMessage.instructions.push( - getTransferSolInstruction({ - source: input.payer, - destination: input.metadata, - amount: input.extraRent, - }) - ); + input: Omit & { + buffer: TransactionSigner; + bufferRent: Lamports; + closeBuffer?: boolean; + data: ReadonlyUint8Array; + extraRent: Lamports; + payer: TransactionSigner; + sizeDifference: number | bigint; } - - initialMessage.instructions.push( +) { + return sequentialInstructionPlan([ + ...(input.sizeDifference > 0 + ? [ + getTransferSolInstruction({ + source: input.payer, + destination: input.metadata, + amount: input.extraRent, + }), + ] + : []), getCreateAccountInstruction({ payer: input.payer, newAccount: input.buffer, @@ -210,80 +167,51 @@ export function getUpdateMetadataInstructionPlanUsingBuffer( account: input.buffer.address, authority: input.buffer, newAuthority: input.authority.address, - }) - ); - mainPlan.plans.push(initialMessage); - - if (input.sizeDifference > REALLOC_LIMIT) { - mainPlan.plans.push( - getExtendInstructionPlan({ - account: input.metadata, - authority: input.authority, - extraLength: Number(input.sizeDifference), - program: input.program, - programData: input.isCanonical ? input.programData : undefined, - priorityFees: input.priorityFees, - }) - ); - } - - let offset = 0; - const writePlan: InstructionPlan = { kind: 'parallel', plans: [] }; - while (offset < input.data.length) { - writePlan.plans.push( + }), + ...(input.sizeDifference > REALLOC_LIMIT + ? [ + getExtendInstructionPlan({ + account: input.metadata, + authority: input.authority, + extraLength: Number(input.sizeDifference), + program: input.program, + programData: input.programData, + }), + ] + : []), + parallelInstructionPlan([ getWriteInstructionPlan({ buffer: input.buffer.address, authority: input.authority, - offset, - data: input.data.slice(offset, offset + input.chunkSize), - priorityFees: input.priorityFees, - }) - ); - offset += input.chunkSize; - } - mainPlan.plans.push(writePlan); - - const finalizeMessage: InstructionPlan = { - kind: 'message', - instructions: [ - ...getComputeUnitInstructions({ - computeUnitPrice: input.priorityFees, - computeUnitLimit: 'simulated', - }), - getSetDataInstruction({ - ...input, - buffer: input.buffer.address, - programData: input.isCanonical ? input.programData : undefined, - data: undefined, + data: input.data, }), - ], - }; - - if (input.closeBuffer) { - finalizeMessage.instructions.push( - getCloseInstruction({ - account: input.buffer.address, - authority: input.authority, - destination: input.payer.address, - program: input.program, - programData: input.isCanonical ? input.programData : undefined, - }) - ); - } - - if (input.sizeDifference < 0) { - finalizeMessage.instructions.push( - getTrimInstruction({ - account: input.metadata, - authority: input.authority, - destination: input.payer.address, - program: input.program, - programData: input.isCanonical ? input.programData : undefined, - }) - ); - } - - mainPlan.plans.push(finalizeMessage); - - return mainPlan; + ]), + getSetDataInstruction({ + ...input, + buffer: input.buffer.address, + data: undefined, + }), + ...(input.closeBuffer + ? [ + getCloseInstruction({ + account: input.buffer.address, + authority: input.authority, + destination: input.payer.address, + program: input.program, + programData: input.programData, + }), + ] + : []), + ...(input.sizeDifference < 0 + ? [ + getTrimInstruction({ + account: input.metadata, + authority: input.authority, + destination: input.payer.address, + program: input.program, + programData: input.programData, + }), + ] + : []), + ]); } diff --git a/clients/js/src/uploadMetadata.ts b/clients/js/src/uploadMetadata.ts index b673cbb..ff965a8 100644 --- a/clients/js/src/uploadMetadata.ts +++ b/clients/js/src/uploadMetadata.ts @@ -1,33 +1,103 @@ -import { getCreateMetadataInstructionPlan } from './createMetadata'; +import { + generateKeyPairSigner, + GetAccountInfoApi, + GetMinimumBalanceForRentExemptionApi, + lamports, + Rpc, +} from '@solana/kit'; +import { + getCreateMetadataInstructionPlanUsingBuffer, + getCreateMetadataInstructionPlanUsingInstructionData, +} from './createMetadata'; import { fetchMaybeMetadata } from './generated'; import { - getExtendedMetadataInput, - getMetadataInstructionPlanExecutor, -} from './internals'; -import { getUpdateMetadataInstructionPlan } from './updateMetadata'; -import { MetadataInput } from './utils'; - -export async function uploadMetadata(input: MetadataInput) { - const extendedInput = await getExtendedMetadataInput(input); - const executor = getMetadataInstructionPlanExecutor(extendedInput); - const metadataAccount = await fetchMaybeMetadata( - input.rpc, - extendedInput.metadata - ); + createDefaultTransactionPlanExecutor, + createDefaultTransactionPlanner, +} from './instructionPlans'; +import { getPdaDetails } from './internals'; +import { + getUpdateMetadataInstructionPlanUsingBuffer, + getUpdateMetadataInstructionPlanUsingInstructionData, +} from './updateMetadata'; +import { getAccountSize, MetadataInput, MetadataResponse } from './utils'; + +export async function uploadMetadata( + input: MetadataInput & { + rpc: Rpc & + Parameters[0]['rpc']; + rpcSubscriptions: Parameters< + typeof createDefaultTransactionPlanExecutor + >[0]['rpcSubscriptions']; + } +): Promise { + const planner = createDefaultTransactionPlanner({ + feePayer: input.payer, + computeUnitPrice: input.priorityFees, + }); + const executor = createDefaultTransactionPlanExecutor({ + rpc: input.rpc, + rpcSubscriptions: input.rpcSubscriptions, + parallelChunkSize: 5, + }); + + const [{ programData, isCanonical, metadata }, rent] = await Promise.all([ + getPdaDetails(input), + input.rpc + .getMinimumBalanceForRentExemption(getAccountSize(input.data.length)) + .send(), + ]); + + const metadataAccount = await fetchMaybeMetadata(input.rpc, metadata); - // Create metadata if it doesn't exist. if (!metadataAccount.exists) { - const plan = await getCreateMetadataInstructionPlan(extendedInput); - return await executor(plan); + const extendedInput = { + ...input, + programData: isCanonical ? programData : undefined, + metadata, + rent, + }; + + const transactionPlan = await planner( + getCreateMetadataInstructionPlanUsingInstructionData(extendedInput) + ).catch(() => + planner(getCreateMetadataInstructionPlanUsingBuffer(extendedInput)) + ); + + const result = await executor(transactionPlan); + return { metadata, result }; } - // Update metadata if it exists. if (!metadataAccount.data.mutable) { throw new Error('Metadata account is immutable'); } - const plan = await getUpdateMetadataInstructionPlan({ - ...extendedInput, - currentDataLength: BigInt(metadataAccount.data.data.length), - }); - return await executor(plan); + + const sizeDifference = + BigInt(input.data.length) - BigInt(metadataAccount.data.data.length); + const extraRentPromise = + sizeDifference > 0 + ? input.rpc.getMinimumBalanceForRentExemption(sizeDifference).send() + : Promise.resolve(lamports(0n)); + const [extraRent, buffer] = await Promise.all([ + extraRentPromise, + generateKeyPairSigner(), + ]); + + const extendedInput = { + ...input, + programData: isCanonical ? programData : undefined, + metadata, + buffer, + bufferRent: rent, + extraRent, + sizeDifference, + }; + + const transactionPlan = await planner( + getUpdateMetadataInstructionPlanUsingInstructionData(extendedInput) + ).catch(() => + planner(getUpdateMetadataInstructionPlanUsingBuffer(extendedInput)) + ); + + const result = await executor(transactionPlan); + return { metadata, result }; } diff --git a/clients/js/src/utils.ts b/clients/js/src/utils.ts index cbc5b57..9a22f40 100644 --- a/clients/js/src/utils.ts +++ b/clients/js/src/utils.ts @@ -6,24 +6,14 @@ import { GetAccountInfoApi, getAddressDecoder, getAddressEncoder, - GetEpochInfoApi, - GetLatestBlockhashApi, - GetMinimumBalanceForRentExemptionApi, getOptionDecoder, getProgramDerivedAddress, - GetSignatureStatusesApi, getStructDecoder, getU32Decoder, getU64Decoder, MicroLamports, ReadonlyUint8Array, Rpc, - RpcSubscriptions, - SendTransactionApi, - SignatureNotificationsApi, - SimulateTransactionApi, - SlotNotificationsApi, - Transaction, TransactionSigner, unwrapOption, } from '@solana/kit'; @@ -34,6 +24,7 @@ import { FormatArgs, SeedArgs, } from './generated'; +import { TransactionPlanResult } from './instructionPlans'; export const ACCOUNT_HEADER_LENGTH = 96; @@ -47,18 +38,6 @@ export const LOADER_V4_PROGRAM_ADDRESS = 'CoreBPFLoaderV41111111111111111111111111111' as Address<'CoreBPFLoaderV41111111111111111111111111111'>; export type MetadataInput = { - rpc: Rpc< - GetLatestBlockhashApi & - GetEpochInfoApi & - GetSignatureStatusesApi & - SendTransactionApi & - SimulateTransactionApi & - GetAccountInfoApi & - GetMinimumBalanceForRentExemptionApi - >; - rpcSubscriptions: RpcSubscriptions< - SignatureNotificationsApi & SlotNotificationsApi - >; payer: TransactionSigner; authority: TransactionSigner; program: Address; @@ -73,12 +52,6 @@ export type MetadataInput = { * Defaults to no extra fees. */ priorityFees?: MicroLamports; - /** - * Whether to use a buffer for creating or updating a metadata account. - * If a `TransactionSigner` is provided, the provided buffer will be used for updating only. - * Defaults to `true` unless the entire operation can be done in a single transaction. - */ - buffer?: TransactionSigner | boolean; /** * When using a buffer, whether to close the buffer account after the operation. * This is only relevant when updating a metadata account since, when creating @@ -86,17 +59,11 @@ export type MetadataInput = { * Defaults to `true`. */ closeBuffer?: boolean; - /** - * When using a buffer, whether to extract the last transaction from the buffer - * and return it as serialized bytes instead of sending it. - * Defaults to `false`. - */ - extractLastTransaction?: boolean; }; export type MetadataResponse = { metadata: Address; - lastTransaction?: Transaction; + result: TransactionPlanResult; }; export function getAccountSize(dataLength: bigint | number) { diff --git a/clients/js/test/instructionPlans/_instructionPlanHelpers.ts b/clients/js/test/instructionPlans/_instructionPlanHelpers.ts new file mode 100644 index 0000000..d409c0a --- /dev/null +++ b/clients/js/test/instructionPlans/_instructionPlanHelpers.ts @@ -0,0 +1,106 @@ +import { + Address, + appendTransactionMessageInstruction, + CompilableTransactionMessage, + fixEncoderSize, + getAddressDecoder, + getU64Encoder, + IInstruction, +} from '@solana/kit'; +import { + getTransactionSize, + IterableInstructionPlan, + TRANSACTION_SIZE_LIMIT, +} from '../../src'; + +const MINIMUM_INSTRUCTION_SIZE = 35; +const MINIMUM_TRANSACTION_SIZE = 136; +const MAXIMUM_TRANSACTION_SIZE = TRANSACTION_SIZE_LIMIT - 1; // (for shortU16) + +export function instructionIteratorFactory() { + const baseCounter = 1_000_000_000n; + const iteratorIncrement = 1_000_000_000n; + let iteratorCounter = 0n; + return ( + totalBytes: number + ): IterableInstructionPlan & { + get: (bytes: number, index: number) => IInstruction; + } => { + const getInstruction = instructionFactory(baseCounter + iteratorCounter); + iteratorCounter += iteratorIncrement; + const baseInstruction = getInstruction(MINIMUM_INSTRUCTION_SIZE, 0); + + return { + get: getInstruction, + kind: 'iterable', + getAll: () => [getInstruction(totalBytes, 0)], + getIterator: () => { + let offset = 0; + return { + hasNext: () => offset < totalBytes, + next: (tx) => { + const baseTransactionSize = getTransactionSize( + appendTransactionMessageInstruction(baseInstruction, tx) + ); + const maxLength = + TRANSACTION_SIZE_LIMIT - + baseTransactionSize - + 1; /* Leeway for shortU16 numbers in transaction headers. */ + + if (maxLength <= 0) { + return null; + } + + const length = Math.min( + totalBytes - offset, + maxLength + MINIMUM_INSTRUCTION_SIZE + ); + + const instruction = getInstruction(length); + offset += length; + return instruction; + }, + }; + }, + }; + }; +} + +export function instructionFactory(baseCounter: bigint = 0n) { + const counterEncoder = fixEncoderSize(getU64Encoder(), 32); + const addressDecoder = getAddressDecoder(); + const getProgramAddress = (counter: bigint): Address => + addressDecoder.decode(counterEncoder.encode(counter)); + + let counter = 0n; + return (bytes: number, counterOverride?: number): IInstruction => { + if (bytes < MINIMUM_INSTRUCTION_SIZE) { + throw new Error( + `Instruction size must be at least ${MINIMUM_INSTRUCTION_SIZE} bytes` + ); + } + const currentCounter = + baseCounter + + (counterOverride === undefined ? counter : BigInt(counterOverride)); + if (counterOverride === undefined) { + counter += 1n; + } + return { + programAddress: getProgramAddress(currentCounter), + data: new Uint8Array(bytes - MINIMUM_INSTRUCTION_SIZE), + }; + }; +} + +export function transactionPercentFactory( + createTransactionMessage?: () => CompilableTransactionMessage +) { + const minimumTransactionSize = createTransactionMessage + ? getTransactionSize(createTransactionMessage()) + : MINIMUM_TRANSACTION_SIZE; + return (percent: number) => { + return Math.floor( + ((MAXIMUM_TRANSACTION_SIZE - minimumTransactionSize) * percent) / 100 + ); + }; +} diff --git a/clients/js/test/instructionPlans/_transactionPlanHelpers.ts b/clients/js/test/instructionPlans/_transactionPlanHelpers.ts new file mode 100644 index 0000000..abc0ccd --- /dev/null +++ b/clients/js/test/instructionPlans/_transactionPlanHelpers.ts @@ -0,0 +1,38 @@ +import { + Address, + appendTransactionMessageInstructions, + CompilableTransactionMessage, + IInstruction, + createTransactionMessage as kitCreateTransactionMessage, + pipe, + setTransactionMessageFeePayer, +} from '@solana/kit'; +import { + setTransactionMessageLifetimeUsingProvisoryBlockhash, + SingleTransactionPlan, +} from '../../src'; + +const MOCK_FEE_PAYER = + 'Gm1uVH3JxiLgafByNNmnoxLncB7ytpyWNqX3kRM9tSxN' as Address; + +export const getMockCreateTransactionMessage = () => { + return pipe( + kitCreateTransactionMessage({ version: 0 }), + setTransactionMessageLifetimeUsingProvisoryBlockhash, + (tx) => setTransactionMessageFeePayer(MOCK_FEE_PAYER, tx) + ); +}; + +export function singleTransactionPlanFactory( + createTransactionMessage?: () => CompilableTransactionMessage +) { + return (instructions: IInstruction[] = []): SingleTransactionPlan => { + return { + kind: 'single', + message: appendTransactionMessageInstructions( + instructions, + (createTransactionMessage ?? getMockCreateTransactionMessage)() + ), + }; + }; +} diff --git a/clients/js/test/instructionPlans/_transactionPlanResultHelpers.ts b/clients/js/test/instructionPlans/_transactionPlanResultHelpers.ts new file mode 100644 index 0000000..83429cd --- /dev/null +++ b/clients/js/test/instructionPlans/_transactionPlanResultHelpers.ts @@ -0,0 +1,63 @@ +import { compileTransaction, SolanaError, Transaction } from '@solana/kit'; +import { + ParallelTransactionPlanResult, + SequentialTransactionPlanResult, + SingleTransactionPlan, + SingleTransactionPlanResult, + TransactionPlanResult, +} from '../../src'; + +export function parallelTransactionPlanResult( + plans: TransactionPlanResult[] +): ParallelTransactionPlanResult { + return { kind: 'parallel', plans }; +} + +export function sequentialTransactionPlanResult( + plans: TransactionPlanResult[] +): SequentialTransactionPlanResult { + return { kind: 'sequential', divisible: true, plans }; +} + +export function nonDivisibleSequentialTransactionPlanResult( + plans: TransactionPlanResult[] +): SequentialTransactionPlanResult { + return { kind: 'sequential', divisible: false, plans }; +} + +export function successfulSingleTransactionPlan( + plan: SingleTransactionPlan, + transaction?: Transaction, + context?: object +): SingleTransactionPlanResult { + return { + kind: 'single', + message: plan.message, + status: { + kind: 'successful', + transaction: transaction ?? compileTransaction(plan.message), + context: context ?? {}, + }, + }; +} + +export function failedSingleTransactionPlan( + plan: SingleTransactionPlan, + error: SolanaError +): SingleTransactionPlanResult { + return { + kind: 'single', + message: plan.message, + status: { kind: 'failed', error }, + }; +} + +export function canceledSingleTransactionPlan( + plan: SingleTransactionPlan +): SingleTransactionPlanResult { + return { + kind: 'single', + message: plan.message, + status: { kind: 'canceled' }, + }; +} diff --git a/clients/js/test/instructionPlans/transactionPlanExecutor.test.ts b/clients/js/test/instructionPlans/transactionPlanExecutor.test.ts new file mode 100644 index 0000000..5bdd941 --- /dev/null +++ b/clients/js/test/instructionPlans/transactionPlanExecutor.test.ts @@ -0,0 +1,135 @@ +import { compileTransaction, SolanaError } from '@solana/kit'; +import test, { Assertions } from 'ava'; +import { + createBaseTransactionPlanExecutor, + parallelTransactionPlan, + sequentialTransactionPlan, + TransactionPlanResult, +} from '../../src'; +import { singleTransactionPlanFactory } from './_transactionPlanHelpers'; +import { + canceledSingleTransactionPlan, + failedSingleTransactionPlan, + parallelTransactionPlanResult, + sequentialTransactionPlanResult, + successfulSingleTransactionPlan, +} from './_transactionPlanResultHelpers'; + +function getMockSolanaError(): SolanaError { + return {} as SolanaError; +} + +async function assertFailedResult( + t: Assertions, + promise: Promise +): Promise { + const executorError = (await t.throwsAsync(promise)) as Error & { + result: TransactionPlanResult; + }; + return executorError.result; +} + +test('it handles successful single transactions', async (t) => { + const singleTransactionPlan = singleTransactionPlanFactory(); + const plan = singleTransactionPlan(); + + const executor = createBaseTransactionPlanExecutor({ + sendAndConfirm: (tx) => + Promise.resolve({ transaction: compileTransaction(tx) }), + }); + + t.deepEqual(await executor(plan), successfulSingleTransactionPlan(plan)); +}); + +test('it handles failed single transactions', async (t) => { + const singleTransactionPlan = singleTransactionPlanFactory(); + const plan = singleTransactionPlan(); + + const planError = getMockSolanaError(); + const executor = createBaseTransactionPlanExecutor({ + sendAndConfirm: () => Promise.reject(planError), + }); + + const result = await assertFailedResult(t, executor(plan)); + + t.deepEqual(result, failedSingleTransactionPlan(plan, planError)); +}); + +test('it handles aborted single transactions', async (t) => { + const singleTransactionPlan = singleTransactionPlanFactory(); + const plan = singleTransactionPlan(); + + const executor = createBaseTransactionPlanExecutor({ + sendAndConfirm: (tx) => + Promise.resolve({ transaction: compileTransaction(tx) }), + }); + + const abortController = new AbortController(); + abortController.abort(); + const promise = executor(plan, { abortSignal: abortController.signal }); + + const result = await assertFailedResult(t, promise); + + t.deepEqual(result, canceledSingleTransactionPlan(plan)); +}); + +test('it cancels transactions after a failed one in a sequential plan', async (t) => { + const singleTransactionPlan = singleTransactionPlanFactory(); + + const planA = singleTransactionPlan(); + const planB = singleTransactionPlan(); + const planC = singleTransactionPlan(); + + const planBError = getMockSolanaError(); + const executor = createBaseTransactionPlanExecutor({ + sendAndConfirm: (tx) => + tx === planB.message + ? Promise.reject(planBError) + : Promise.resolve({ transaction: compileTransaction(tx) }), + }); + + const promise = executor(sequentialTransactionPlan([planA, planB, planC])); + const result = await assertFailedResult(t, promise); + + t.deepEqual( + result, + sequentialTransactionPlanResult([ + successfulSingleTransactionPlan(planA), + failedSingleTransactionPlan(planB, planBError), + canceledSingleTransactionPlan(planC), + ]) + ); +}); + +test('it cancels transactions after a failed chunk in a chunked parallel plans', async (t) => { + const singleTransactionPlan = singleTransactionPlanFactory(); + + const planA = singleTransactionPlan(); + const planB = singleTransactionPlan(); + const planC = singleTransactionPlan(); + const planD = singleTransactionPlan(); + + const planAError = getMockSolanaError(); + const executor = createBaseTransactionPlanExecutor({ + parallelChunkSize: 2, + sendAndConfirm: (tx) => + tx === planA.message + ? Promise.reject(planAError) + : Promise.resolve({ transaction: compileTransaction(tx) }), + }); + + const promise = executor( + parallelTransactionPlan([planA, planB, planC, planD]) + ); + const result = await assertFailedResult(t, promise); + + t.deepEqual( + result, + parallelTransactionPlanResult([ + failedSingleTransactionPlan(planA, planAError), + successfulSingleTransactionPlan(planB), + canceledSingleTransactionPlan(planC), + canceledSingleTransactionPlan(planD), + ]) + ); +}); diff --git a/clients/js/test/instructionPlans/transactionPlanner.test.ts b/clients/js/test/instructionPlans/transactionPlanner.test.ts new file mode 100644 index 0000000..8d8d773 --- /dev/null +++ b/clients/js/test/instructionPlans/transactionPlanner.test.ts @@ -0,0 +1,1565 @@ +import { CompilableTransactionMessage } from '@solana/kit'; +import test from 'ava'; +import { + createBaseTransactionPlanner, + nonDivisibleSequentialInstructionPlan, + nonDivisibleSequentialTransactionPlan, + parallelInstructionPlan, + parallelTransactionPlan, + sequentialInstructionPlan, + sequentialTransactionPlan, + singleInstructionPlan, + TransactionPlan, +} from '../../src'; +import { + instructionFactory, + instructionIteratorFactory, + transactionPercentFactory, +} from './_instructionPlanHelpers'; +import { + getMockCreateTransactionMessage, + singleTransactionPlanFactory, +} from './_transactionPlanHelpers'; + +function defaultFactories( + createTransactionMessage?: () => CompilableTransactionMessage +) { + const effectiveCreateTransactionMessage = + createTransactionMessage ?? getMockCreateTransactionMessage; + return { + createPlanner: () => + createBaseTransactionPlanner({ + createTransactionMessage: effectiveCreateTransactionMessage, + }), + instruction: instructionFactory(), + iterator: instructionIteratorFactory(), + txPercent: transactionPercentFactory(effectiveCreateTransactionMessage), + singleTransactionPlan: singleTransactionPlanFactory( + effectiveCreateTransactionMessage + ), + }; +} + +/** + * [A: 42] ───────────────────▶ [Tx: A] + */ +test('it plans a single instruction', async (t) => { + const { createPlanner, instruction, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(42); + + t.deepEqual( + await planner(singleInstructionPlan(instructionA)), + singleTransactionPlan([instructionA]) + ); +}); + +/** + * [A: 200%] ───────────────────▶ Error + */ +test('it fail if a single instruction is too large', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + 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])); +}); + +/** + * [Seq] ───────────────────▶ [Tx: A + B] + * │ + * ├── [A: 50%] + * └── [B: 50%] + */ +test('it plans a sequential plan with instructions that all fit in a single transaction', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(50)); + const instructionB = instruction(txPercent(50)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]) + ), + singleTransactionPlan([instructionA, instructionB]) + ); +}); + +/** + * [Seq] ───────────────────▶ [Seq] + * │ │ + * ├── [A: 50%] ├── [Tx: A + B] + * ├── [B: 50%] └── [Tx: C] + * └── [C: 50%] + */ +test('it plans a sequential plan with instructions that must be split accross multiple transactions (v1)', 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)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + singleTransactionPlan([instructionC]), + ]) + ); +}); + +/** + * [Seq] ───────────────────▶ [Seq] + * │ │ + * ├── [A: 60%] ├── [Tx: A] + * ├── [B: 50%] └── [Tx: B + C] + * └── [C: 50%] + */ +test('it plans a sequential plan with instructions that must be split accross multiple transactions (v2)', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(60)); // Tx A cannot have Ix B. + const instructionB = instruction(txPercent(50)); + const instructionC = instruction(txPercent(50)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA]), + singleTransactionPlan([instructionB, instructionC]), + ]) + ); +}); + +/** + * [Seq] ───────────────────▶ [Tx: A + B] + * │ + * ├── [A: 50%] + * ├── [Seq] + * └── [Seq] + * └── [B: 50%] + */ +test('it simplifies sequential plans with one child or less', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(50)); + const instructionB = instruction(txPercent(50)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + sequentialInstructionPlan([]), + sequentialInstructionPlan([singleInstructionPlan(instructionB)]), + ]) + ), + singleTransactionPlan([instructionA, instructionB]) + ); +}); + +/** + * [Seq] ──────────────────────▶ [Seq] + * │ │ + * ├── [A: 100%] ├── [Tx: A] + * └── [Seq] ├── [Tx: B] + * ├── [B: 100%] └── [Tx: C] + * └── [C: 100%] + */ +test('it simplifies nested sequential plans', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(100)); + const instructionB = instruction(txPercent(100)); + const instructionC = instruction(txPercent(100)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + sequentialInstructionPlan([ + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA]), + singleTransactionPlan([instructionB]), + singleTransactionPlan([instructionC]), + ]) + ); +}); + +/** + * [Par] ───────────────────▶ [Tx: A + B] + * │ + * ├── [A: 50%] + * └── [B: 50%] + */ +test('it plans a parallel plan with instructions that all fit in a single transaction', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(50)); + const instructionB = instruction(txPercent(50)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]) + ), + singleTransactionPlan([instructionA, instructionB]) + ); +}); + +/** + * [Par] ───────────────────▶ [Par] + * │ │ + * ├── [A: 50%] ├── [Tx: A + B] + * ├── [B: 50%] └── [Tx: C] + * └── [C: 50%] + */ +test('it plans a parallel plan with instructions that must be split accross multiple transactions (v1)', 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)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]) + ), + parallelTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + singleTransactionPlan([instructionC]), + ]) + ); +}); + +/** + * [Par] ───────────────────▶ [Par] + * │ │ + * ├── [A: 60%] ├── [Tx: A] + * ├── [B: 50%] └── [Tx: B + C] + * └── [C: 50%] + */ +test('it plans a parallel plan with instructions that must be split accross multiple transactions (v2)', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(60)); // Tx A cannot have Ix B. + const instructionB = instruction(txPercent(50)); + const instructionC = instruction(txPercent(50)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]) + ), + parallelTransactionPlan([ + singleTransactionPlan([instructionA]), + singleTransactionPlan([instructionB, instructionC]), + ]) + ); +}); + +/** + * [Par] ───────────────────▶ [Tx: A + B] + * │ + * ├── [A: 50%] + * ├── [Par] + * └── [Par] + * └── [B: 50%] + */ +test('it simplifies parallel plans with one child or less', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(50)); + const instructionB = instruction(txPercent(50)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + parallelInstructionPlan([]), + parallelInstructionPlan([singleInstructionPlan(instructionB)]), + ]) + ), + singleTransactionPlan([instructionA, instructionB]) + ); +}); + +/** + * [Par] ──────────────────────▶ [Par] + * │ │ + * ├── [A: 100%] ├── [Tx: A] + * └── [Par] ├── [Tx: B] + * ├── [B: 100%] └── [Tx: C] + * └── [C: 100%] + */ +test('it simplifies nested parallel plans', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(100)); + const instructionB = instruction(txPercent(100)); + const instructionC = instruction(txPercent(100)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + parallelInstructionPlan([ + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]), + ]) + ), + parallelTransactionPlan([ + singleTransactionPlan([instructionA]), + singleTransactionPlan([instructionB]), + singleTransactionPlan([instructionC]), + ]) + ); +}); + +/** + * [Par] ───────────────────▶ [Par] + * │ │ + * ├── [Seq] ├── [Tx: A + B + D] + * │ ├── [A: 50%] └── [Tx: C] + * │ └── [B: 25%] + * ├── [C: 90%] + * └── [D: 25%] + */ +test('it re-uses previous parallel transactions if there is space', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(50)); + const instructionB = instruction(txPercent(25)); + const instructionC = instruction(txPercent(90)); + const instructionD = instruction(txPercent(25)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]) + ), + parallelTransactionPlan([ + singleTransactionPlan([instructionA, instructionB, instructionD]), + singleTransactionPlan([instructionC]), + ]) + ); +}); + +/** + * [Par] ───────────────────▶ [Tx: A + B + C + D] + * │ + * ├── [Seq] + * │ ├── [A: 25%] + * │ └── [B: 25%] + * └── [Seq] + * ├── [C: 25%] + * └── [D: 25%] + */ +test('it can merge sequential plans in a parallel plan if the whole sequential plan fits', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(25)); + const instructionB = instruction(txPercent(25)); + const instructionC = instruction(txPercent(25)); + const instructionD = instruction(txPercent(25)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + sequentialInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + ]) + ), + singleTransactionPlan([ + instructionA, + instructionB, + instructionC, + instructionD, + ]) + ); +}); + +/** + * [Par] ───────────────────▶ [Par] + * │ │ + * ├── [Seq] ├── [Tx: A + B] + * │ ├── [A: 33%] └── [Tx: C + D] + * │ └── [B: 33%] + * └── [Seq] + * ├── [C: 33%] + * └── [D: 33%] + */ +test('it does not split a sequential plan on a parallel parent', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(33)); + const instructionB = instruction(txPercent(33)); + const instructionC = instruction(txPercent(33)); + const instructionD = instruction(txPercent(33)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + sequentialInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + ]) + ), + parallelTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + singleTransactionPlan([instructionC, instructionD]), + ]) + ); +}); + +/** + * [Seq] ───────────────────▶ [Seq] + * │ │ + * ├── [Par] ├── [Tx: A + B + C] + * │ ├── [A: 33%] └── [Tx: D] + * │ └── [B: 33%] + * └── [Par] + * ├── [C: 33%] + * └── [D: 33%] + */ +test('it can split parallel plans inside sequential plans as long as they follow the sequence', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(33)); + const instructionB = instruction(txPercent(33)); + const instructionC = instruction(txPercent(33)); + const instructionD = instruction(txPercent(33)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + parallelInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA, instructionB, instructionC]), + singleTransactionPlan([instructionD]), + ]) + ); +}); + +/** + * [Seq] ───────────────────▶ [Seq] + * │ │ + * ├── [Par] ├── [Tx: A + B] + * │ ├── [A: 33%] ├── [Tx: C + D] + * │ └── [B: 33%] └── [Tx: E + F] + * ├── [Par] + * │ ├── [C: 50%] + * │ └── [D: 50%] + * └── [Par] + * ├── [E: 33%] + * └── [F: 33%] + */ +test('it cannnot split a parallel plan in a sequential plan if that would break the sequence', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(33)); + const instructionB = instruction(txPercent(33)); + const instructionC = instruction(txPercent(50)); + const instructionD = instruction(txPercent(50)); + const instructionE = instruction(txPercent(33)); + const instructionF = instruction(txPercent(33)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + parallelInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + parallelInstructionPlan([ + singleInstructionPlan(instructionE), + singleInstructionPlan(instructionF), + ]), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + singleTransactionPlan([instructionC, instructionD]), + singleTransactionPlan([instructionE, instructionF]), + ]) + ); +}); + +/** + * [NonDivSeq] ───────────────────▶ [Tx: A + B] + * │ + * ├── [A: 50%] + * └── [B: 50%] + */ +test('it plans an non-divisible sequential plan with instructions that all fit in a single transaction', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(50)); + const instructionB = instruction(txPercent(50)); + + t.deepEqual( + await planner( + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]) + ), + singleTransactionPlan([instructionA, instructionB]) + ); +}); + +/** + * [NonDivSeq] ─────────────▶ [NonDivSeq] + * │ │ + * ├── [A: 50%] ├── [Tx: A + B] + * ├── [B: 50%] └── [Tx: C] + * └── [C: 50%] + */ +test('it plans a non-divisible sequential plan with instructions that must be split accross multiple transactions (v1)', 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)); + + t.deepEqual( + await planner( + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]) + ), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + singleTransactionPlan([instructionC]), + ]) + ); +}); + +/** + * [NonDivSeq] ─────────────▶ [NonDivSeq] + * │ │ + * ├── [A: 60%] ├── [Tx: A] + * ├── [B: 50%] └── [Tx: B + C] + * └── [C: 50%] + */ +test('it plans a non-divisible sequential plan with instructions that must be split accross multiple transactions (v2)', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(60)); // Tx A cannot have Ix B. + const instructionB = instruction(txPercent(50)); + const instructionC = instruction(txPercent(50)); + + t.deepEqual( + await planner( + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]) + ), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([instructionA]), + singleTransactionPlan([instructionB, instructionC]), + ]) + ); +}); + +/** + * [NonDivSeq] ─────────────▶ [Tx: A + B] + * │ + * ├── [A: 50%] + * ├── [NonDivSeq] + * └── [NonDivSeq] + * └── [B: 50%] + */ +test('it simplifies non-divisible sequential plans with one child or less', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(50)); + const instructionB = instruction(txPercent(50)); + + t.deepEqual( + await planner( + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + nonDivisibleSequentialInstructionPlan([]), + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionB), + ]), + ]) + ), + singleTransactionPlan([instructionA, instructionB]) + ); +}); + +/** + * [NonDivSeq] ────────────────▶ [NonDivSeq] + * │ │ + * ├── [A: 100%] ├── [Tx: A] + * └── [NonDivSeq] ├── [Tx: B] + * ├── [B: 100%] └── [Tx: C] + * └── [C: 100%] + */ +test('it simplifies nested non-divisible sequential plans', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(100)); + const instructionB = instruction(txPercent(100)); + const instructionC = instruction(txPercent(100)); + + t.deepEqual( + await planner( + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]), + ]) + ), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([instructionA]), + singleTransactionPlan([instructionB]), + singleTransactionPlan([instructionC]), + ]) + ); +}); + +/** + * [NonDivSeq] ────────────────▶ [NonDivSeq] + * │ │ + * ├── [A: 100%] ├── [Tx: A] + * └── [Seq] ├── [Tx: B] + * ├── [B: 100%] └── [Tx: C] + * └── [C: 100%] + */ +test('it simplifies divisible sequential plans inside non-divisible sequential plans', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(100)); + const instructionB = instruction(txPercent(100)); + const instructionC = instruction(txPercent(100)); + + t.deepEqual( + await planner( + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + sequentialInstructionPlan([ + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]), + ]) + ), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([instructionA]), + singleTransactionPlan([instructionB]), + singleTransactionPlan([instructionC]), + ]) + ); +}); + +/** + * [Seq] ──────────────────────▶ [Seq] + * │ │ + * ├── [A: 100%] ├── [Tx: A] + * └── [NonDivSeq] └── [NonDivSeq] + * ├── [B: 100%] ├── [Tx: B] + * └── [C: 100%] └── [Tx: C] + */ +test('it does not simplify non-divisible sequential plans inside divisible sequential plans', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(100)); + const instructionB = instruction(txPercent(100)); + const instructionC = instruction(txPercent(100)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA]), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([instructionB]), + singleTransactionPlan([instructionC]), + ]), + ]) + ); +}); + +/** + * [Par] ───────────────────▶ [Tx: A + B + C + D] + * │ + * ├── [NonDivSeq] + * │ ├── [A: 25%] + * │ └── [B: 25%] + * └── [NonDivSeq] + * ├── [C: 25%] + * └── [D: 25%] + */ +test('it can merge non-divisible sequential plans in a parallel plan if the whole sequential plan fits', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(25)); + const instructionB = instruction(txPercent(25)); + const instructionC = instruction(txPercent(25)); + const instructionD = instruction(txPercent(25)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + ]) + ), + singleTransactionPlan([ + instructionA, + instructionB, + instructionC, + instructionD, + ]) + ); +}); + +/** + * [Par] ───────────────────▶ [Par] + * │ │ + * ├── [NonDivSeq] ├── [Tx: A + B] + * │ ├── [A: 33%] └── [Tx: C + D] + * │ └── [B: 33%] + * └── [NonDivSeq] + * ├── [C: 33%] + * └── [D: 33%] + */ +test('it does not split a non-divisible sequential plan on a parallel parent', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(33)); + const instructionB = instruction(txPercent(33)); + const instructionC = instruction(txPercent(33)); + const instructionD = instruction(txPercent(33)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + ]) + ), + parallelTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + singleTransactionPlan([instructionC, instructionD]), + ]) + ); +}); + +/** + * [Seq] ───────────────────▶ [Tx: A + B + C + D] + * │ + * ├── [NonDivSeq] + * │ ├── [A: 25%] + * │ └── [B: 25%] + * └── [NonDivSeq] + * ├── [C: 25%] + * └── [D: 25%] + */ +test('it can merge non-divisible sequential plans in a sequential plan if the whole plan fits', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(25)); + const instructionB = instruction(txPercent(25)); + const instructionC = instruction(txPercent(25)); + const instructionD = instruction(txPercent(25)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + ]) + ), + singleTransactionPlan([ + instructionA, + instructionB, + instructionC, + instructionD, + ]) + ); +}); + +/** + * [Seq] ───────────────────▶ [Seq] + * │ │ + * ├── [NonDivSeq] ├── [Tx: A + B] + * │ ├── [A: 33%] └── [Tx: C + D] + * │ └── [B: 33%] + * └── [NonDivSeq] + * ├── [C: 33%] + * └── [D: 33%] + */ +test('it does not split a non-divisible sequential plan on a sequential parent', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(33)); + const instructionB = instruction(txPercent(33)); + const instructionC = instruction(txPercent(33)); + const instructionD = instruction(txPercent(33)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + singleTransactionPlan([instructionC, instructionD]), + ]) + ); +}); + +/** + * [NonDivSeq] ─────────────▶ [NonDivSeq] + * │ │ + * ├── [Par] ├── [Tx: A + B] + * │ ├── [A: 50%] └── [Par] + * │ └── [B: 50%] ├── [Tx: C] + * └── [Par] └── [Tx: D] + * ├── [C: 100%] + * └── [D: 100%] + */ +test('it plans non-divisible sequentials plans with parallel children', 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(100)); + const instructionD = instruction(txPercent(100)); + + t.deepEqual( + await planner( + nonDivisibleSequentialInstructionPlan([ + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + parallelInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + ]) + ), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + parallelTransactionPlan([ + singleTransactionPlan([instructionC]), + singleTransactionPlan([instructionD]), + ]), + ]) + ); +}); + +/** + * [NonDivSeq] ─────────────▶ [NonDivSeq] + * │ │ + * ├── [Seq] ├── [Tx: A + B] + * │ ├── [A: 50%] ├── [Tx: C] + * │ └── [B: 50%] └── [Tx: D] + * └── [Seq] + * ├── [C: 100%] + * └── [D: 100%] + */ +test('it plans non-divisible sequentials plans with divisible sequential children', 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(100)); + const instructionD = instruction(txPercent(100)); + + t.deepEqual( + await planner( + nonDivisibleSequentialInstructionPlan([ + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + sequentialInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + ]) + ), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + singleTransactionPlan([instructionC]), + singleTransactionPlan([instructionD]), + ]) + ); +}); + +/** + * [A(x, 250%)] ─────────────▶ [Seq] + * │ + * ├── [Tx: A(1, 100%)] + * ├── [Tx: A(2, 100%)] + * └── [Tx: A(3, 50%)] + */ +test('it iterate over iterable instruction plans', async (t) => { + const { createPlanner, txPercent, iterator, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const iteratorIx = iterator(txPercent(250)); + + t.deepEqual( + await planner(iteratorIx), + sequentialTransactionPlan([ + singleTransactionPlan([iteratorIx.get(txPercent(100), 0)]), + singleTransactionPlan([iteratorIx.get(txPercent(100), 1)]), + singleTransactionPlan([iteratorIx.get(txPercent(50), 2)]), + ]) + ); +}); + +/** + * [Seq] ───────────────────▶ [Tx: A + B(1, 50%)] + * │ + * ├── [A: 50%] + * └── [B(x, 50%)] + */ +test('it combines single instruction plans with iterable instruction plans', async (t) => { + const { + createPlanner, + txPercent, + iterator, + instruction, + singleTransactionPlan, + } = defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(50)); + const iteratorB = iterator(txPercent(50)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + iteratorB, + ]) + ), + singleTransactionPlan([instructionA, iteratorB.get(txPercent(50), 0)]) + ); +}); + +/** + * [Par] ────────────────────▶ [Par] + * │ │ + * └── [A(x, 250%)] ├── [Tx: A(1, 100%)] + * ├── [Tx: A(2, 100%)] + * └── [Tx: A(3, 50%)] + */ +test('it can handle parallel iterable instruction plans', async (t) => { + const { createPlanner, txPercent, iterator, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const iteratorA = iterator(txPercent(250)); + + t.deepEqual( + await planner(parallelInstructionPlan([iteratorA])), + parallelTransactionPlan([ + singleTransactionPlan([iteratorA.get(txPercent(100), 0)]), + singleTransactionPlan([iteratorA.get(txPercent(100), 1)]), + singleTransactionPlan([iteratorA.get(txPercent(50), 2)]), + ]) + ); +}); + +/** + * [NonDivSeq] ──────────────▶ [NonDivSeq] + * │ │ + * └── [A(x, 250%)] ├── [Tx: A(1, 100%)] + * ├── [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 } = + defaultFactories(); + const planner = createPlanner(); + + const iteratorA = iterator(txPercent(250)); + + t.deepEqual( + await planner(nonDivisibleSequentialInstructionPlan([iteratorA])), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([iteratorA.get(txPercent(100), 0)]), + singleTransactionPlan([iteratorA.get(txPercent(100), 1)]), + singleTransactionPlan([iteratorA.get(txPercent(50), 2)]), + ]) + ); +}); + +/** + * [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 } = + defaultFactories(); + const planner = createPlanner(); + + const iteratorA = iterator(txPercent(100)); + + t.deepEqual( + await planner(iteratorA), + singleTransactionPlan([iteratorA.get(txPercent(100), 0)]) + ); +}); + +/** + * [Par] ─────────────────────▶ [Par] + * │ │ + * ├── [A: 75%] ├── [Tx: A + C(1, 25%)] + * ├── [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) => { + const { + createPlanner, + txPercent, + instruction, + iterator, + 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% + + t.deepEqual( + await planner( + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + iteratorC, + ]) + ), + parallelTransactionPlan([ + singleTransactionPlan([instructionA, iteratorC.get(txPercent(25), 0)]), + singleTransactionPlan([instructionB, iteratorC.get(txPercent(50), 1)]), + singleTransactionPlan([iteratorC.get(txPercent(50), 2)]), + ]) + ); +}); + +/** + * [Par] ─────────────────────▶ [Par] + * │ │ + * ├── [A(x, 125%)] ├── [Tx: B + A(1, 25%)] + * ├── [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) => { + const { + createPlanner, + txPercent, + instruction, + iterator, + singleTransactionPlan, + } = defaultFactories(); + const planner = createPlanner(); + + const iteratorA = iterator(txPercent(25) + txPercent(50) + txPercent(50)); // 125% + const instructionB = instruction(txPercent(75)); + const instructionC = instruction(txPercent(50)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + iteratorA, + 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)]), + ]) + ); +}); + +/** + * [Seq] ─────────────────────▶ [Seq] + * │ │ + * ├── [A: 75%] ├── [Tx: A + B(1, 25%)] + * ├── [B(x, 75%)] └── [Tx: B(2, 50%) + C] + * └── [C: 50%] + */ +test('it uses iterable instruction plans to fill gaps in sequential candidates', async (t) => { + const { + createPlanner, + txPercent, + instruction, + iterator, + singleTransactionPlan, + } = defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(75)); + const iteratorB = iterator(txPercent(25) + txPercent(50)); // 75% + const instructionC = instruction(txPercent(50)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + iteratorB, + singleInstructionPlan(instructionC), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA, iteratorB.get(txPercent(25), 0)]), + singleTransactionPlan([iteratorB.get(txPercent(50), 1), instructionC]), + ]) + ); +}); + +/** + * [NonDivSeq] ───────────────▶ [NonDivSeq] + * │ │ + * ├── [A: 75%] ├── [Tx: A + B(1, 25%)] + * ├── [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) => { + const { + createPlanner, + txPercent, + instruction, + iterator, + singleTransactionPlan, + } = defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(75)); + const iteratorB = iterator(txPercent(25) + txPercent(50)); // 75% + const instructionC = instruction(txPercent(50)); + + t.deepEqual( + await planner( + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionA), + iteratorB, + singleInstructionPlan(instructionC), + ]) + ), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([instructionA, iteratorB.get(txPercent(25), 0)]), + singleTransactionPlan([iteratorB.get(txPercent(50), 1), instructionC]), + ]) + ); +}); + +/** + * [Seq] ───────────────────────▶ [Seq] + * │ │ + * ├── [A: 75%] ├── [Tx: A + B(1, 25%)] + * └── [Par] └── [Tx: C + B(2, 50%)] + * ├── [B(x, 75%)] + * └── [C: 50%] + */ +test('it uses parallel iterable instruction plans to fill gaps in sequential candidates', async (t) => { + const { + createPlanner, + txPercent, + instruction, + iterator, + singleTransactionPlan, + } = defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(75)); + const iteratorB = iterator(txPercent(25) + txPercent(50)); // 75% + const instructionC = instruction(txPercent(50)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + parallelInstructionPlan([ + iteratorB, + singleInstructionPlan(instructionC), + ]), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([instructionA, iteratorB.get(txPercent(25), 0)]), + singleTransactionPlan([instructionC, iteratorB.get(txPercent(50), 1)]), + ]) + ); +}); + +/** + * [Par] ─────────────────────────▶ [Tx: A + B(1, 50%) + C] + * │ + * ├── [A: 25%] + * └── [Seq] + * ├── [B(x, 50%)] + * └── [C: 25%] + */ +test('it uses the whole sequential iterable instruction plan when it fits in the parent parallel candidate', async (t) => { + const { + createPlanner, + txPercent, + instruction, + iterator, + singleTransactionPlan, + } = defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(25)); + const iteratorB = iterator(txPercent(50)); + const instructionC = instruction(txPercent(25)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + singleInstructionPlan(instructionA), + sequentialInstructionPlan([ + iteratorB, + singleInstructionPlan(instructionC), + ]), + ]) + ), + singleTransactionPlan([ + instructionA, + iteratorB.get(txPercent(50), 0), + instructionC, + ]) + ); +}); + +/** + * [Seq] ─────────────────────────▶ [Tx: A + B(1, 50%) + C] + * │ + * ├── [A: 25%] + * └── [NonDivSeq] + * ├── [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) => { + const { + createPlanner, + txPercent, + instruction, + iterator, + singleTransactionPlan, + } = defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(25)); + const iteratorB = iterator(txPercent(50)); + const instructionC = instruction(txPercent(25)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + nonDivisibleSequentialInstructionPlan([ + iteratorB, + singleInstructionPlan(instructionC), + ]), + ]) + ), + singleTransactionPlan([ + instructionA, + iteratorB.get(txPercent(50), 0), + instructionC, + ]) + ); +}); + +/** + * [Par] ───────────────────────────▶ [Par] + * │ │ + * ├── [Seq] ├── [Tx: A + B] + * │ ├── [A: 40%] └── [NonDivSeq] + * │ └── [B: 40%] ├── [Tx: C + D + E + G] + * ├── [NonDivSeq] └── [Tx: F] + * │ ├── [Par] + * │ │ ├── [C: 25%] + * │ │ └── [D: 25%] + * │ └── [Par] + * │ ├── [E: 25%] + * │ └── [F: 50%] + * └── [G: 25%] + */ +test('complex example 1', async (t) => { + const { createPlanner, instruction, txPercent, singleTransactionPlan } = + defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(40)); + const instructionB = instruction(txPercent(40)); + const instructionC = instruction(txPercent(25)); + const instructionD = instruction(txPercent(25)); + const instructionE = instruction(txPercent(25)); + const instructionF = instruction(txPercent(50)); + const instructionG = instruction(txPercent(25)); + + t.deepEqual( + await planner( + parallelInstructionPlan([ + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + singleInstructionPlan(instructionB), + ]), + nonDivisibleSequentialInstructionPlan([ + parallelInstructionPlan([ + singleInstructionPlan(instructionC), + singleInstructionPlan(instructionD), + ]), + parallelInstructionPlan([ + singleInstructionPlan(instructionE), + singleInstructionPlan(instructionF), + ]), + ]), + singleInstructionPlan(instructionG), + ]) + ), + parallelTransactionPlan([ + singleTransactionPlan([instructionA, instructionB]), + nonDivisibleSequentialTransactionPlan([ + singleTransactionPlan([ + instructionC, + instructionD, + instructionE, + instructionG, + ]), + singleTransactionPlan([instructionF]), + ]), + ]) + ); +}); + +/** + * [Seq] ─────────────────────────────────▶ [Seq] + * │ │ + * ├── [A: 20%] ├── [Tx: A + B + C + E(1, 40%)] + * ├── [NonDivSeq] ├── [Par] + * │ ├── [B: 20%] │ ├── [Tx: D + E(2, 50%)] + * │ └── [C: 20%] │ ├── [Tx: E(3, 100%)] + * ├── [Par] │ └── [Tx: E(4, 60%)] + * │ ├── [D: 50%] └── [Tx: F + G] + * │ └── [E(x, 250%)] + * ├── [F: 50%] + * └── [G: 50%] + */ +test('complex example 2', async (t) => { + const { + createPlanner, + instruction, + iterator, + txPercent, + singleTransactionPlan, + } = defaultFactories(); + const planner = createPlanner(); + + const instructionA = instruction(txPercent(20)); + const instructionB = instruction(txPercent(20)); + const instructionC = instruction(txPercent(20)); + const instructionD = instruction(txPercent(50)); + const iteratorE = iterator(txPercent(250)); + const instructionF = instruction(txPercent(50)); + const instructionG = instruction(txPercent(50)); + + t.deepEqual( + await planner( + sequentialInstructionPlan([ + singleInstructionPlan(instructionA), + nonDivisibleSequentialInstructionPlan([ + singleInstructionPlan(instructionB), + singleInstructionPlan(instructionC), + ]), + parallelInstructionPlan([ + singleInstructionPlan(instructionD), + iteratorE, + ]), + singleInstructionPlan(instructionF), + singleInstructionPlan(instructionG), + ]) + ), + sequentialTransactionPlan([ + singleTransactionPlan([ + instructionA, + instructionB, + instructionC, + iteratorE.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([instructionF, instructionG]), + ]) + ); +});