From c281352f946815bbc1a5066760750b5a43ee4d0f Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Wed, 5 Feb 2025 10:02:38 +0000 Subject: [PATCH] Introduce `InstructionPlan` executors in JS client (#4) --- clients/js/src/createMetadata.ts | 38 ++-- .../defaultInstructionPlanExecutor.ts | 83 ++++++++ clients/js/src/instructionPlans/index.ts | 3 + .../src/instructionPlans/instructionPlan.ts | 39 ++++ .../instructionPlanExecutor.ts | 69 ++++++ clients/js/src/internals.ts | 200 +++++++----------- clients/js/src/updateMetadata.ts | 49 ++--- clients/js/src/uploadMetadata.ts | 39 ++-- 8 files changed, 320 insertions(+), 200 deletions(-) create mode 100644 clients/js/src/instructionPlans/defaultInstructionPlanExecutor.ts create mode 100644 clients/js/src/instructionPlans/index.ts create mode 100644 clients/js/src/instructionPlans/instructionPlan.ts create mode 100644 clients/js/src/instructionPlans/instructionPlanExecutor.ts diff --git a/clients/js/src/createMetadata.ts b/clients/js/src/createMetadata.ts index bc14ce4..f88570a 100644 --- a/clients/js/src/createMetadata.ts +++ b/clients/js/src/createMetadata.ts @@ -10,38 +10,32 @@ import { getInitializeInstruction, PROGRAM_METADATA_PROGRAM_ADDRESS, } from './generated'; +import { + getTransactionMessageFromPlan, + InstructionPlan, + MessageInstructionPlan, +} from './instructionPlans'; import { calculateMaxChunkSize, getComputeUnitInstructions, - getDefaultInstructionPlanContext, - getPdaDetails, - getTransactionMessageFromPlan, + getExtendedMetadataInput, + getMetadataInstructionPlanExecutor, getWriteInstructionPlan, - InstructionPlan, messageFitsInOneTransaction, - MessageInstructionPlan, PdaDetails, - sendInstructionPlanAndGetMetadataResponse, } from './internals'; import { getAccountSize, MetadataInput, MetadataResponse } from './utils'; export async function createMetadata( input: MetadataInput ): Promise { - const context = getDefaultInstructionPlanContext(input); - const [pdaDetails, defaultMessage] = await Promise.all([ - getPdaDetails(input), - context.createMessage(), - ]); - const extendedInput = { ...input, ...pdaDetails, defaultMessage }; - return await sendInstructionPlanAndGetMetadataResponse( - await getCreateMetadataInstructions(extendedInput), - context, - extendedInput - ); + const extendedInput = await getExtendedMetadataInput(input); + const executor = getMetadataInstructionPlanExecutor(extendedInput); + const plan = await getCreateMetadataInstructionPlan(extendedInput); + return await executor(plan); } -export async function getCreateMetadataInstructions( +export async function getCreateMetadataInstructionPlan( input: Omit & PdaDetails & { rpc: Rpc; @@ -52,7 +46,7 @@ export async function getCreateMetadataInstructions( .getMinimumBalanceForRentExemption(getAccountSize(input.data.length)) .send(); const planUsingInstructionData = - getCreateMetadataInstructionsUsingInstructionData({ ...input, rent }); + getCreateMetadataInstructionPlanUsingInstructionData({ ...input, rent }); const messageUsingInstructionData = getTransactionMessageFromPlan( input.defaultMessage, planUsingInstructionData @@ -70,14 +64,14 @@ export async function getCreateMetadataInstructions( ...input, buffer: input.metadata, }); - return getCreateMetadataInstructionsUsingBuffer({ + return getCreateMetadataInstructionPlanUsingBuffer({ ...input, chunkSize, rent, }); } -export function getCreateMetadataInstructionsUsingInstructionData( +export function getCreateMetadataInstructionPlanUsingInstructionData( input: Omit & PdaDetails & { rent: Lamports } ): MessageInstructionPlan { @@ -101,7 +95,7 @@ export function getCreateMetadataInstructionsUsingInstructionData( }; } -export function getCreateMetadataInstructionsUsingBuffer( +export function getCreateMetadataInstructionPlanUsingBuffer( input: Omit & PdaDetails & { rent: Lamports; chunkSize: number } ): InstructionPlan { diff --git a/clients/js/src/instructionPlans/defaultInstructionPlanExecutor.ts b/clients/js/src/instructionPlans/defaultInstructionPlanExecutor.ts new file mode 100644 index 0000000..7e22aa5 --- /dev/null +++ b/clients/js/src/instructionPlans/defaultInstructionPlanExecutor.ts @@ -0,0 +1,83 @@ +import { + appendTransactionMessageInstructions, + CompilableTransactionMessage, + FullySignedTransaction, + pipe, + signTransactionMessageWithSigners, + TransactionMessageWithBlockhashLifetime, + TransactionMessageWithDurableNonceLifetime, + TransactionWithLifetime, +} from '@solana/web3.js'; +import { MessageInstructionPlan } from './instructionPlan'; +import { + chunkParallelInstructionPlans, + createInstructionPlanExecutor, + InstructionPlanExecutor, +} from './instructionPlanExecutor'; + +export type DefaultInstructionPlanExecutorConfig = Readonly<{ + /** + * 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; // TODO + + /** + * 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 + ) + >; + + /** + * Sends and confirms a constructed transaction. + */ + sendAndConfirm: ( + transaction: FullySignedTransaction & TransactionWithLifetime, + config?: { abortSignal?: AbortSignal } + ) => Promise; +}>; + +export function getDefaultInstructionPlanExecutor( + config: DefaultInstructionPlanExecutorConfig +): InstructionPlanExecutor { + const { + getDefaultMessage, + parallelChunkSize: chunkSize, + sendAndConfirm, + } = config; + + return async (plan, config) => { + const handleMessage = async (plan: MessageInstructionPlan) => { + const tx = await pipe( + await getDefaultMessage(config), + (tx) => appendTransactionMessageInstructions(plan.instructions, tx), + (tx) => signTransactionMessageWithSigners(tx) + ); + await sendAndConfirm(tx, config); + }; + + const executor = pipe(createInstructionPlanExecutor(handleMessage), (e) => + chunkSize ? chunkParallelInstructionPlans(e, chunkSize) : e + ); + + return await executor(plan, config); + }; +} diff --git a/clients/js/src/instructionPlans/index.ts b/clients/js/src/instructionPlans/index.ts new file mode 100644 index 0000000..3aa870b --- /dev/null +++ b/clients/js/src/instructionPlans/index.ts @@ -0,0 +1,3 @@ +export * from './defaultInstructionPlanExecutor'; +export * from './instructionPlan'; +export * from './instructionPlanExecutor'; diff --git a/clients/js/src/instructionPlans/instructionPlan.ts b/clients/js/src/instructionPlans/instructionPlan.ts new file mode 100644 index 0000000..d0337f5 --- /dev/null +++ b/clients/js/src/instructionPlans/instructionPlan.ts @@ -0,0 +1,39 @@ +import { + appendTransactionMessageInstructions, + BaseTransactionMessage, + IInstruction, +} from '@solana/web3.js'; + +export type InstructionPlan = + | SequentialInstructionPlan + | ParallelInstructionPlan + | MessageInstructionPlan; + +export type SequentialInstructionPlan = Readonly<{ + kind: 'sequential'; + plans: InstructionPlan[]; +}>; + +export type ParallelInstructionPlan = Readonly<{ + kind: 'parallel'; + plans: InstructionPlan[]; +}>; + +export type MessageInstructionPlan< + TInstructions extends IInstruction[] = IInstruction[], +> = Readonly<{ + kind: 'message'; + instructions: TInstructions; +}>; + +export function getTransactionMessageFromPlan< + TTransactionMessage extends BaseTransactionMessage = BaseTransactionMessage, +>( + defaultMessage: TTransactionMessage, + plan: MessageInstructionPlan +): TTransactionMessage { + return appendTransactionMessageInstructions( + plan.instructions, + defaultMessage + ); +} diff --git a/clients/js/src/instructionPlans/instructionPlanExecutor.ts b/clients/js/src/instructionPlans/instructionPlanExecutor.ts new file mode 100644 index 0000000..5291c60 --- /dev/null +++ b/clients/js/src/instructionPlans/instructionPlanExecutor.ts @@ -0,0 +1,69 @@ +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/internals.ts b/clients/js/src/internals.ts index 5c745d9..09400e7 100644 --- a/clients/js/src/internals.ts +++ b/clients/js/src/internals.ts @@ -4,42 +4,61 @@ import { } from '@solana-program/compute-budget'; import { Address, - appendTransactionMessageInstructions, Commitment, CompilableTransactionMessage, compileTransaction, createTransactionMessage, + FullySignedTransaction, GetAccountInfoApi, - GetEpochInfoApi, GetLatestBlockhashApi, - GetMinimumBalanceForRentExemptionApi, - GetSignatureStatusesApi, getTransactionEncoder, IInstruction, MicroLamports, pipe, ReadonlyUint8Array, Rpc, - RpcSubscriptions, sendAndConfirmTransactionFactory, - SendTransactionApi, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, - SignatureNotificationsApi, - signTransactionMessageWithSigners, - SlotNotificationsApi, Transaction, TransactionMessageWithBlockhashLifetime, TransactionSigner, } from '@solana/web3.js'; import { findMetadataPda, getWriteInstruction, SeedArgs } from './generated'; -import { getProgramAuthority, MetadataResponse } from './utils'; +import { + getDefaultInstructionPlanExecutor, + getTransactionMessageFromPlan, + InstructionPlan, + InstructionPlanExecutor, + MessageInstructionPlan, +} from './instructionPlans'; +import { getProgramAuthority, MetadataInput, MetadataResponse } from './utils'; 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; isCanonical: boolean; @@ -69,21 +88,21 @@ export async function getPdaDetails(input: { return { metadata, isCanonical, programData }; } -export function getDefaultCreateMessage( - rpc: Rpc, - payer: TransactionSigner -): () => Promise< +export function getDefaultMessageFactory(input: { + rpc: Rpc; + payer: TransactionSigner; +}): () => Promise< CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime > { const getBlockhash = getTimedCacheFunction(async () => { - const { value } = await rpc.getLatestBlockhash().send(); + const { value } = await input.rpc.getLatestBlockhash().send(); return value; }, 60_000); return async () => { const latestBlockhash = await getBlockhash(); return pipe( createTransactionMessage({ version: 0 }), - (tx) => setTransactionMessageFeePayerSigner(payer, tx), + (tx) => setTransactionMessageFeePayerSigner(input.payer, tx), (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx) ); }; @@ -110,113 +129,6 @@ function getTimedCacheFunction( }; } -async function signAndSendTransaction( - transactionMessage: CompilableTransactionMessage & - TransactionMessageWithBlockhashLifetime, - sendAndConfirm: ReturnType, - commitment: Commitment = 'confirmed' -) { - const tx = await signTransactionMessageWithSigners(transactionMessage); - await sendAndConfirm(tx, { commitment }); -} - -export type SequentialInstructionPlan = { - kind: 'sequential'; - plans: InstructionPlan[]; -}; -export type ParallelInstructionPlan = { - kind: 'parallel'; - plans: InstructionPlan[]; -}; -export type MessageInstructionPlan = { - kind: 'message'; - instructions: IInstruction[]; -}; -export type InstructionPlan = - | SequentialInstructionPlan - | ParallelInstructionPlan - | MessageInstructionPlan; - -type SendInstructionPlanContext = { - createMessage: () => Promise< - CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime - >; - sendAndConfirm: ReturnType; -}; - -export function getDefaultInstructionPlanContext(input: { - rpc: Rpc< - GetLatestBlockhashApi & - GetEpochInfoApi & - GetSignatureStatusesApi & - SendTransactionApi & - GetMinimumBalanceForRentExemptionApi - >; - rpcSubscriptions: RpcSubscriptions< - SignatureNotificationsApi & SlotNotificationsApi - >; - payer: TransactionSigner; -}): SendInstructionPlanContext { - return { - createMessage: getDefaultCreateMessage(input.rpc, input.payer), - sendAndConfirm: sendAndConfirmTransactionFactory(input), - }; -} - -export async function sendInstructionPlan( - plan: InstructionPlan, - ctx: SendInstructionPlanContext -) { - switch (plan.kind) { - case 'sequential': - return await sendSequentialInstructionPlan(plan, ctx); - case 'parallel': - return await sendParallelInstructionPlan(plan, ctx); - case 'message': - return await sendMessageInstructionPlan(plan, ctx); - default: - throw new Error('Unsupported instruction plan'); - } -} - -async function sendSequentialInstructionPlan( - plan: SequentialInstructionPlan, - ctx: SendInstructionPlanContext -) { - for (const subPlan of plan.plans) { - await sendInstructionPlan(subPlan, ctx); - } -} - -async function sendParallelInstructionPlan( - plan: ParallelInstructionPlan, - ctx: SendInstructionPlanContext -) { - await Promise.all( - plan.plans.map((subPlan) => sendInstructionPlan(subPlan, ctx)) - ); -} - -async function sendMessageInstructionPlan( - plan: MessageInstructionPlan, - ctx: SendInstructionPlanContext -) { - await pipe( - await ctx.createMessage(), - (tx) => appendTransactionMessageInstructions(plan.instructions, tx), - (tx) => signAndSendTransaction(tx, ctx.sendAndConfirm) - ); -} - -export function getTransactionMessageFromPlan( - defaultMessage: CompilableTransactionMessage, - plan: MessageInstructionPlan -) { - return pipe(defaultMessage, (tx) => - appendTransactionMessageInstructions(plan.instructions, tx) - ); -} - export function getComputeUnitInstructions(input: { computeUnitPrice?: MicroLamports; computeUnitLimit?: number; @@ -293,9 +205,45 @@ export function getWriteInstructionPlan(input: { }; } -export async function sendInstructionPlanAndGetMetadataResponse( +export function getMetadataInstructionPlanExecutor( + input: Pick< + ExtendedMetadataInput, + | 'rpc' + | 'rpcSubscriptions' + | 'payer' + | 'extractLastTransaction' + | 'metadata' + | 'defaultMessage' + | 'getDefaultMessage' + > & { commitment?: Commitment } +): ( + plan: InstructionPlan, + config?: { abortSignal?: AbortSignal } +) => Promise { + const sendAndConfirm = sendAndConfirmTransactionFactory(input); + const executor = getDefaultInstructionPlanExecutor({ + getDefaultMessage: input.getDefaultMessage, + sendAndConfirm: async (tx, config) => { + await sendAndConfirm( + tx as FullySignedTransaction & TransactionMessageWithBlockhashLifetime, + { commitment: input.commitment ?? 'confirmed', ...config } + ); + }, + }); + + 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, - context: SendInstructionPlanContext, + executor: InstructionPlanExecutor, input: { metadata: Address; defaultMessage: CompilableTransactionMessage; @@ -306,7 +254,7 @@ export async function sendInstructionPlanAndGetMetadataResponse( plan, input ); - await sendInstructionPlan(planToSend, context); + await executor(planToSend); return { metadata: input.metadata, lastTransaction }; } diff --git a/clients/js/src/updateMetadata.ts b/clients/js/src/updateMetadata.ts index 14e6a9e..41ec6bb 100644 --- a/clients/js/src/updateMetadata.ts +++ b/clients/js/src/updateMetadata.ts @@ -17,44 +17,39 @@ import { import { calculateMaxChunkSize, getComputeUnitInstructions, - getDefaultInstructionPlanContext, - getPdaDetails, - getTransactionMessageFromPlan, + getExtendedMetadataInput, + getMetadataInstructionPlanExecutor, getWriteInstructionPlan, - InstructionPlan, messageFitsInOneTransaction, - MessageInstructionPlan, PdaDetails, - sendInstructionPlanAndGetMetadataResponse, } from './internals'; import { getAccountSize, MetadataInput, MetadataResponse } from './utils'; +import { + getTransactionMessageFromPlan, + InstructionPlan, + MessageInstructionPlan, +} from './instructionPlans'; export async function updateMetadata( input: MetadataInput ): Promise { - const context = getDefaultInstructionPlanContext(input); - const [pdaDetails, defaultMessage] = await Promise.all([ - getPdaDetails(input), - context.createMessage(), - ]); - const metadataAccount = await fetchMetadata(input.rpc, pdaDetails.metadata); + 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'); } - const extendedInput = { + const plan = await getUpdateMetadataInstructionPlan({ + ...extendedInput, currentDataLength: BigInt(metadataAccount.data.data.length), - defaultMessage, - ...input, - ...pdaDetails, - }; - return await sendInstructionPlanAndGetMetadataResponse( - await getUpdateMetadataInstructions(extendedInput), - context, - extendedInput - ); + }); + return await executor(plan); } -export async function getUpdateMetadataInstructions( +export async function getUpdateMetadataInstructionPlan( input: Omit & PdaDetails & { rpc: Rpc; @@ -69,7 +64,7 @@ export async function getUpdateMetadataInstructions( ? await input.rpc.getMinimumBalanceForRentExemption(sizeDifference).send() : lamports(0n); const planUsingInstructionData = - getUpdateMetadataInstructionsUsingInstructionData({ + getUpdateMetadataInstructionPlanUsingInstructionData({ ...input, sizeDifference, extraRent, @@ -99,7 +94,7 @@ export async function getUpdateMetadataInstructions( buffer: buffer.address, authority: buffer, }); - return getUpdateMetadataInstructionsUsingBuffer({ + return getUpdateMetadataInstructionPlanUsingBuffer({ ...input, sizeDifference, extraRent, @@ -109,7 +104,7 @@ export async function getUpdateMetadataInstructions( }); } -export function getUpdateMetadataInstructionsUsingInstructionData( +export function getUpdateMetadataInstructionPlanUsingInstructionData( input: Omit & PdaDetails & { sizeDifference: bigint; @@ -153,7 +148,7 @@ export function getUpdateMetadataInstructionsUsingInstructionData( return plan; } -export function getUpdateMetadataInstructionsUsingBuffer( +export function getUpdateMetadataInstructionPlanUsingBuffer( input: Omit & PdaDetails & { chunkSize: number; diff --git a/clients/js/src/uploadMetadata.ts b/clients/js/src/uploadMetadata.ts index 992c5bc..b673cbb 100644 --- a/clients/js/src/uploadMetadata.ts +++ b/clients/js/src/uploadMetadata.ts @@ -1,44 +1,33 @@ -import { getCreateMetadataInstructions } from './createMetadata'; +import { getCreateMetadataInstructionPlan } from './createMetadata'; import { fetchMaybeMetadata } from './generated'; import { - getDefaultInstructionPlanContext, - getPdaDetails, - sendInstructionPlanAndGetMetadataResponse, + getExtendedMetadataInput, + getMetadataInstructionPlanExecutor, } from './internals'; -import { getUpdateMetadataInstructions } from './updateMetadata'; +import { getUpdateMetadataInstructionPlan } from './updateMetadata'; import { MetadataInput } from './utils'; export async function uploadMetadata(input: MetadataInput) { - const context = getDefaultInstructionPlanContext(input); - const [pdaDetails, defaultMessage] = await Promise.all([ - getPdaDetails(input), - context.createMessage(), - ]); - const extendedInput = { ...input, ...pdaDetails, defaultMessage }; + const extendedInput = await getExtendedMetadataInput(input); + const executor = getMetadataInstructionPlanExecutor(extendedInput); const metadataAccount = await fetchMaybeMetadata( input.rpc, - pdaDetails.metadata + extendedInput.metadata ); // Create metadata if it doesn't exist. if (!metadataAccount.exists) { - return await sendInstructionPlanAndGetMetadataResponse( - await getCreateMetadataInstructions(extendedInput), - context, - extendedInput - ); + const plan = await getCreateMetadataInstructionPlan(extendedInput); + return await executor(plan); } // Update metadata if it exists. if (!metadataAccount.data.mutable) { throw new Error('Metadata account is immutable'); } - return await sendInstructionPlanAndGetMetadataResponse( - await getUpdateMetadataInstructions({ - ...extendedInput, - currentDataLength: BigInt(metadataAccount.data.data.length), - }), - context, - extendedInput - ); + const plan = await getUpdateMetadataInstructionPlan({ + ...extendedInput, + currentDataLength: BigInt(metadataAccount.data.data.length), + }); + return await executor(plan); }