import { isSolanaError, SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN, SOLANA_ERROR__INSTRUCTION_PLANS__NON_DIVISIBLE_TRANSACTION_PLANS_NOT_SUPPORTED, SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND, SolanaError, } from '@solana/errors'; import { getAbortablePromise } from '@solana/promises'; import type { TransactionMessage, TransactionMessageWithFeePayer } from '@solana/transaction-messages'; import type { ParallelTransactionPlan, SequentialTransactionPlan, SingleTransactionPlan, TransactionPlan, } from './transaction-plan'; import { createFailedToExecuteTransactionPlanError } from './transaction-plan-errors'; import { canceledSingleTransactionPlanResult, failedSingleTransactionPlanResult, isSuccessfulTransactionPlanResult, nonDivisibleSequentialTransactionPlanResult, parallelTransactionPlanResult, sequentialTransactionPlanResult, SingleTransactionPlanResult, successfulSingleTransactionPlanResult, type TransactionPlanResult, type TransactionPlanResultContext, type TransactionPlanResultContextWithSignature, } from './transaction-plan-result'; /** * Executes a transaction plan and returns the execution results. * * This function traverses the transaction plan tree, executing each transaction * message and collecting results that mirror the structure of the original plan. * * The zero-argument spelling defaults `TContext` to {@link TransactionPlanResultContextWithSignature}, * so its results guarantee a `context.signature` on every successful single result — but a different * `TContext` may drop that guarantee entirely. * * @typeParam TContext - The type of the context object that may be passed along with results. * @param transactionPlan - The transaction plan to execute. * @param config - Optional configuration object that can include an `AbortSignal` to cancel execution. * @return A promise that resolves to the execution results. * * @see {@link TransactionPlan} * @see {@link TransactionPlanResult} * @see {@link createTransactionPlanExecutor} */ export type TransactionPlanExecutor< TContext extends TransactionPlanResultContext = TransactionPlanResultContextWithSignature, > = ( transactionPlan: TransactionPlan, config?: { abortSignal?: AbortSignal }, ) => Promise>; type ExecuteTransactionMessage = ( context: Partial, transactionMessage: TransactionMessage & TransactionMessageWithFeePayer, config?: { abortSignal?: AbortSignal }, ) => Promise; /** * Configuration object for creating a new transaction plan executor. * * @see {@link createTransactionPlanExecutor} * @see {@link createTransactionPlanExecutorWithConcurrentLeaves} */ export type TransactionPlanExecutorConfig< TContext extends TransactionPlanResultContext = TransactionPlanResultContext, > = { /** * Called whenever a transaction message must be executed. * * It should return the context that the successful result must carry — every property * `TContext` promises, which by default includes a `signature`. */ executeTransactionMessage: ExecuteTransactionMessage; }; /** * Creates a new transaction plan executor based on the provided configuration. * * The executor will traverse the provided `TransactionPlan` sequentially or in parallel, * executing each transaction message using the `executeTransactionMessage` function. * * The `executeTransactionMessage` callback receives a mutable context object as its first * argument, which can be used to incrementally store useful data as execution progresses * (e.g. the latest version of the transaction message after setting its lifetime, the * compiled and signed transaction, the transaction signature, or any custom properties). * This context is included in the resulting {@link SingleTransactionPlanResult} regardless * of the outcome. This means that if an error is thrown at any point in the callback, any * attributes already saved to the context will still be available in the plan result, which * can be useful for debugging failures or building recovery plans. * * The callback then returns the context a successful result should carry, as a complete * `TContext`. The executor writes nothing to it on the callback's behalf — notably, it does not * derive a `signature` from a stored transaction. Producing `signature` is therefore the callback's * job, and an executor that produces transactions its fee payer has not signed can simply leave the * property out and declare a `TContext` that does not require it. * * Requiring that return value is what keeps `TContext` honest: a callback that declares a context * with a required `signature` and never produces one fails to compile, rather than yielding a * result whose `context.signature` is typed but `undefined` at runtime. Note that the mutable * context cannot itself be returned — every property on it is optional, so it does not satisfy * `TContext`. Return an object built from the values you have instead: * * ```ts * executeTransactionMessage: async (context, message) => { * const transaction = await signTransactionMessageWithSigners(message); * context.transaction = transaction; // Recorded now, in case the next step throws. * const signature = getSignatureFromTransaction(transaction); * await sendAndConfirmTransaction(transaction, { commitment: 'confirmed' }); * return { signature, transaction }; * } * ``` * * The two channels serve different outcomes. Mutating the context makes a value available to a * *failed* result; returning it makes a value available to a *successful* one. On success the two * are merged, with the returned value taking precedence, so a property stored on the context but * omitted from the return value is still reported. * * `TContext` is the only thing that says what a context contains — the executor adds nothing of its * own on top, in either direction. It defaults to {@link TransactionPlanResultContextWithSignature}, * which is why the zero-type-argument spelling hands the callback the familiar `message`, * `transaction` and `signature` properties and guarantees a `context.signature` on every successful * result. Supply a different `TContext` and you get exactly that instead, so intersect one of the * base context types in if you want those properties alongside your own: * * ```ts * createTransactionPlanExecutor(config); * ``` * * Note the asymmetry between the callback's two context types. A fresh context is created for * every single transaction plan, so on entry it is empty and *every* property of `TContext` is * optional on the parameter — populating them is the callback's job, not a guarantee the executor * makes. The value it returns is a complete `TContext`, which is what lets the * `TransactionPlanExecutor` this factory returns report a successful result's context as fully * populated. Declare the properties you intend to produce as an explicit type argument to this * function; a callback cannot annotate its own context parameter with required properties, because * none of them are present when it is called. * * - If that function is successful, the executor will return a successful `TransactionPlanResult` * for that message, carrying the context the callback returned merged over the one it mutated. * - If that function throws an error, the executor will stop processing and cancel all * remaining transaction messages in the plan. The context accumulated up to the point of * failure is preserved in the resulting {@link FailedSingleTransactionPlanResult}. * - If the `abortSignal` is triggered, the executor will immediately stop processing the plan and * return a `TransactionPlanResult` with the status set to `canceled`. * * @param config - Configuration object containing the transaction message executor function. * @return A {@link TransactionPlanExecutor} function that can execute transaction plans. * * @throws {@link SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN} * if any transaction in the plan fails to execute. The error context contains a * `transactionPlanResult` property with the partial results up to the point of failure. * @throws {@link SOLANA_ERROR__INSTRUCTION_PLANS__NON_DIVISIBLE_TRANSACTION_PLANS_NOT_SUPPORTED} * if the transaction plan contains non-divisible sequential plans, which are not * supported by this executor. * * @example * ```ts * const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }); * * const transactionPlanExecutor = createTransactionPlanExecutor({ * executeTransactionMessage: async (context, message) => { * const transaction = await signTransactionMessageWithSigners(message); * context.transaction = transaction; * const signature = getSignatureFromTransaction(transaction); * await sendAndConfirmTransaction(transaction, { commitment: 'confirmed' }); * return { signature, transaction }; * } * }); * ``` * * @see {@link TransactionPlanExecutorConfig} */ export function createTransactionPlanExecutor< TContext extends TransactionPlanResultContext = TransactionPlanResultContextWithSignature, >(config: TransactionPlanExecutorConfig): TransactionPlanExecutor { return async (plan, { abortSignal } = {}): Promise> => { const traverseConfig: TraverseConfig = { ...config, abortSignal: abortSignal, canceled: abortSignal?.aborted ?? false, }; // Fail early if there are non-divisible sequential plans in the // transaction plan as they are not supported by this executor. assertDivisibleSequentialPlansOnly(plan); const cancelHandler = () => { traverseConfig.canceled = true; }; abortSignal?.addEventListener('abort', cancelHandler); const transactionPlanResult = await traverse(plan, traverseConfig); abortSignal?.removeEventListener('abort', cancelHandler); if (traverseConfig.canceled) { const abortReason = abortSignal?.aborted ? abortSignal.reason : undefined; throw createFailedToExecuteTransactionPlanError(transactionPlanResult, abortReason); } return transactionPlanResult; }; } /** * Creates a transaction plan executor that processes every leaf concurrently. * * It takes the same configuration as {@link createTransactionPlanExecutor} and its * `executeTransactionMessage` callback follows the same contract: it receives a fresh mutable * context object for every leaf and returns the complete `TContext` a successful result must * carry. On success the two are merged with the returned value taking precedence; on a throw * the context accumulated so far is preserved in the failed result. * * The difference is the traversal. This executor preserves the input plan's nesting, order, and * divisibility in the returned result, but it does not enforce the execution dependencies * expressed by sequential plans: every leaf callback is started immediately, without a concurrency * limit. This makes it suitable for operations such as signing or serializing transactions that * can be performed independently. For the same reason, a callback that throws does not cancel the * other leaves — each one runs to completion — and non-divisible sequential plans are supported. * * The optional abort signal is forwarded to every leaf callback and enforced by the executor. * When the signal is already aborted, leaves are canceled without invoking their callbacks. When * it is aborted during execution, the executor stops waiting for active callbacks and records * failed results carrying the abort reason and the context accumulated so far — a value the * callback resolves with afterwards is discarded. * * After all leaves settle, a plan containing failed or canceled results throws a failed execution * error containing the complete result tree. * * @typeParam TContext - The context carried by each single transaction plan result. * @param config - Configuration object containing the transaction message executor function. * @return A {@link TransactionPlanExecutor} that processes all transaction plan leaves concurrently. * @throws {@link SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN} * if any leaf callback throws, any leaf is canceled, or the abort signal fires. The error * context contains a `transactionPlanResult` property with the complete result tree. * @throws {@link SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND} if the plan has an unknown kind. * * @example * Signing every transaction in a plan concurrently. * ```ts * const executor = createTransactionPlanExecutorWithConcurrentLeaves<{ transaction: Transaction }>({ * executeTransactionMessage: async (_context, message) => { * const transaction = await signTransactionMessageWithSigners(message); * return { transaction }; * }, * }); * const result = await executor(transactionPlan); * ``` * * @see {@link TransactionPlanExecutorConfig} * @see {@link createTransactionPlanExecutor} */ export function createTransactionPlanExecutorWithConcurrentLeaves< TContext extends TransactionPlanResultContext = TransactionPlanResultContextWithSignature, >(config: TransactionPlanExecutorConfig): TransactionPlanExecutor { return async (transactionPlan, executorConfig) => { const transactionPlanResult = await traverseTransactionPlanLeavesConcurrently( transactionPlan, config.executeTransactionMessage, executorConfig, ); if (executorConfig?.abortSignal?.aborted || !isSuccessfulTransactionPlanResult(transactionPlanResult)) { const abortReason = executorConfig?.abortSignal?.aborted ? executorConfig.abortSignal.reason : undefined; throw createFailedToExecuteTransactionPlanError(transactionPlanResult, abortReason); } return transactionPlanResult; }; } async function traverseTransactionPlanLeavesConcurrently( transactionPlan: TransactionPlan, executeTransactionMessage: ExecuteTransactionMessage, executorConfig?: { abortSignal?: AbortSignal }, ): Promise> { const kind = transactionPlan.kind; switch (kind) { case 'single': { // A fresh context is created for every leaf, so nothing is populated yet. Filling it in // is the `executeTransactionMessage` callback's job, which is why every property of // `TContext` is optional here. const context: Partial = {}; if (executorConfig?.abortSignal?.aborted) { return canceledSingleTransactionPlanResult(transactionPlan.message, context); } try { const returnedContext = await getAbortablePromise( executeTransactionMessage(context, transactionPlan.message, { abortSignal: executorConfig?.abortSignal, }), executorConfig?.abortSignal, ); // Anything the callback stored on the mutable context but left out of its return // value is kept, since dropping it would lose data it deliberately recorded. return successfulSingleTransactionPlanResult(transactionPlan.message, { ...context, ...returnedContext, }); } catch (error) { return failedSingleTransactionPlanResult(transactionPlan.message, error as Error, context); } } case 'parallel': case 'sequential': { const plans = await Promise.all( transactionPlan.plans.map(plan => traverseTransactionPlanLeavesConcurrently(plan, executeTransactionMessage, executorConfig), ), ); if (kind === 'parallel') { return parallelTransactionPlanResult(plans); } return transactionPlan.divisible ? sequentialTransactionPlanResult(plans) : nonDivisibleSequentialTransactionPlanResult(plans); } default: kind satisfies never; throw new SolanaError(SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND, { kind }); } } type TraverseConfig = TransactionPlanExecutorConfig & { abortSignal?: AbortSignal; canceled: boolean; }; async function traverse( transactionPlan: TransactionPlan, traverseConfig: TraverseConfig, ): Promise> { const kind = transactionPlan.kind; switch (kind) { case 'sequential': return await traverseSequential(transactionPlan, traverseConfig); case 'parallel': return await traverseParallel(transactionPlan, traverseConfig); case 'single': return await traverseSingle(transactionPlan, traverseConfig); default: transactionPlan satisfies never; throw new SolanaError(SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND, { kind }); } } async function traverseSequential( transactionPlan: SequentialTransactionPlan, traverseConfig: TraverseConfig, ): Promise> { if (!transactionPlan.divisible) { throw new SolanaError(SOLANA_ERROR__INSTRUCTION_PLANS__NON_DIVISIBLE_TRANSACTION_PLANS_NOT_SUPPORTED); } const results: TransactionPlanResult[] = []; for (const subPlan of transactionPlan.plans) { const result = await traverse(subPlan, traverseConfig); results.push(result); } return sequentialTransactionPlanResult(results); } async function traverseParallel( transactionPlan: ParallelTransactionPlan, traverseConfig: TraverseConfig, ): Promise> { const results = await Promise.all(transactionPlan.plans.map(plan => traverse(plan, traverseConfig))); return parallelTransactionPlanResult(results); } async function traverseSingle( transactionPlan: SingleTransactionPlan, traverseConfig: TraverseConfig, ): Promise> { // A fresh context is created for every single transaction plan, so nothing is populated // yet. Filling it in is the `executeTransactionMessage` callback's job, which is why // every property of `TContext` is optional here. Note that `TContext` is the only thing // that says what a context contains; the executor adds no fields of its own. const context: Partial = {}; if (traverseConfig.canceled) { return canceledSingleTransactionPlanResult(transactionPlan.message, context); } try { const returnedContext = await getAbortablePromise( traverseConfig.executeTransactionMessage(context, transactionPlan.message, { abortSignal: traverseConfig.abortSignal, }), traverseConfig.abortSignal, ); // The callback proved the context is fully populated by returning it, so no assertion is // needed here. Anything it stored on the mutable context but left out of its return value // is kept, since dropping it would lose data the callback deliberately recorded. return successfulSingleTransactionPlanResult(transactionPlan.message, { ...context, ...returnedContext, }); } catch (error) { traverseConfig.canceled = true; return failedSingleTransactionPlanResult(transactionPlan.message, error as Error, context); } } function assertDivisibleSequentialPlansOnly(transactionPlan: TransactionPlan): void { const kind = transactionPlan.kind; switch (kind) { case 'sequential': if (!transactionPlan.divisible) { throw new SolanaError(SOLANA_ERROR__INSTRUCTION_PLANS__NON_DIVISIBLE_TRANSACTION_PLANS_NOT_SUPPORTED); } for (const subPlan of transactionPlan.plans) { assertDivisibleSequentialPlansOnly(subPlan); } return; case 'parallel': for (const subPlan of transactionPlan.plans) { assertDivisibleSequentialPlansOnly(subPlan); } return; case 'single': default: return; } } /** * Wraps a transaction plan execution promise to return a * {@link TransactionPlanResult} even on execution failure. * * When a transaction plan executor throws a * {@link SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN} * error, this helper catches it and returns the `TransactionPlanResult` * from the error context instead of throwing. * * This allows us to handle the result of an execution in a single unified way * instead of using try/catch and examine the `TransactionPlanResult` in both * success and failure cases. * * Any other errors are re-thrown as normal. * * @typeParam TContext - The type of the context object attached to the results. Any context is * accepted, since this helper never reads from it. * @param promise - A promise returned by a transaction plan executor. * @return A promise that resolves to the transaction plan result, even if some transactions failed. * * @example * Handling failures using a single result object: * ```ts * const result = await passthroughFailedTransactionPlanExecution( * transactionPlanExecutor(transactionPlan) * ); * * const summary = summarizeTransactionPlanResult(result); * if (summary.successful) { * console.log('All transactions executed successfully'); * } else { * console.log(`${summary.successfulTransactions.length} succeeded`); * console.log(`${summary.failedTransactions.length} failed`); * console.log(`${summary.canceledTransactions.length} canceled`); * } * ``` * * @see {@link TransactionPlanResult} * @see {@link createTransactionPlanExecutor} * @see {@link summarizeTransactionPlanResult} */ export async function passthroughFailedTransactionPlanExecution< TContext extends TransactionPlanResultContext = TransactionPlanResultContextWithSignature, >(promise: Promise>): Promise>; export async function passthroughFailedTransactionPlanExecution< TContext extends TransactionPlanResultContext = TransactionPlanResultContextWithSignature, >(promise: Promise>): Promise>; export async function passthroughFailedTransactionPlanExecution< TContext extends TransactionPlanResultContext = TransactionPlanResultContextWithSignature, >(promise: Promise>): Promise> { try { return await promise; } catch (error) { if (isSolanaError(error, SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN)) { return error.context.transactionPlanResult as TransactionPlanResult; } throw error; } }