diff --git a/.changeset/puny-camels-sort.md b/.changeset/puny-camels-sort.md new file mode 100644 index 000000000..60188dcfc --- /dev/null +++ b/.changeset/puny-camels-sort.md @@ -0,0 +1,5 @@ +--- +'@solana/instruction-plans': minor +--- + +Add `passthroughFailedTransactionPlanExecution` helper function that wraps a transaction plan execution promise to return a `TransactionPlanResult` even on execution failure. This allows handling execution results in a unified way without try/catch. diff --git a/packages/instruction-plans/src/__tests__/transaction-plan-executor-test.ts b/packages/instruction-plans/src/__tests__/transaction-plan-executor-test.ts index 2ac682a09..39d624bec 100644 --- a/packages/instruction-plans/src/__tests__/transaction-plan-executor-test.ts +++ b/packages/instruction-plans/src/__tests__/transaction-plan-executor-test.ts @@ -4,6 +4,7 @@ import { SOLANA_ERROR__INSTRUCTION_ERROR__INVALID_ARGUMENT, SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN, SOLANA_ERROR__INSTRUCTION_PLANS__NON_DIVISIBLE_TRANSACTION_PLANS_NOT_SUPPORTED, + SOLANA_ERROR__TRANSACTION_ERROR__INSUFFICIENT_FUNDS_FOR_FEE, SolanaError, } from '@solana/errors'; @@ -13,7 +14,7 @@ import { sequentialTransactionPlan, singleTransactionPlan, } from '../transaction-plan'; -import { createTransactionPlanExecutor } from '../transaction-plan-executor'; +import { createTransactionPlanExecutor, passthroughFailedTransactionPlanExecution } from '../transaction-plan-executor'; import { canceledSingleTransactionPlanResult, failedSingleTransactionPlanResult, @@ -738,3 +739,34 @@ describe('createTransactionPlanExecutor', () => { }); }); }); + +describe('passthroughFailedTransactionPlanExecution', () => { + it('returns the resolved result as-is', async () => { + expect.assertions(1); + const result = successfulSingleTransactionPlanResult(createMessage('A'), createTransaction('A')); + const promise = Promise.resolve(result); + await expect(passthroughFailedTransactionPlanExecution(promise)).resolves.toBe(result); + }); + it('returns the result inside the rejected execution error', async () => { + expect.assertions(1); + const result = failedSingleTransactionPlanResult( + createMessage('A'), + new SolanaError(SOLANA_ERROR__TRANSACTION_ERROR__INSUFFICIENT_FUNDS_FOR_FEE), + ); + const promise = Promise.reject( + new SolanaError(SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN, { + transactionPlanResult: result, + }), + ); + await expect(passthroughFailedTransactionPlanExecution(promise)).resolves.toBe(result); + }); + it('does not catch errors other than failed execution errors', async () => { + expect.assertions(1); + const promise = Promise.reject( + new SolanaError(SOLANA_ERROR__INSTRUCTION_PLANS__NON_DIVISIBLE_TRANSACTION_PLANS_NOT_SUPPORTED), + ); + await expect(passthroughFailedTransactionPlanExecution(promise)).rejects.toThrow( + new SolanaError(SOLANA_ERROR__INSTRUCTION_PLANS__NON_DIVISIBLE_TRANSACTION_PLANS_NOT_SUPPORTED), + ); + }); +}); diff --git a/packages/instruction-plans/src/__typetests__/transaction-plan-executor-typetest.ts b/packages/instruction-plans/src/__typetests__/transaction-plan-executor-typetest.ts index 9458df97a..4b570eafd 100644 --- a/packages/instruction-plans/src/__typetests__/transaction-plan-executor-typetest.ts +++ b/packages/instruction-plans/src/__typetests__/transaction-plan-executor-typetest.ts @@ -9,8 +9,18 @@ import { import { compileTransaction, Transaction, TransactionWithBlockhashLifetime } from '@solana/transactions'; import type { TransactionPlan } from '../transaction-plan'; -import { createTransactionPlanExecutor, type TransactionPlanExecutor } from '../transaction-plan-executor'; -import type { TransactionPlanResult } from '../transaction-plan-result'; +import { + createTransactionPlanExecutor, + passthroughFailedTransactionPlanExecution, + type TransactionPlanExecutor, +} from '../transaction-plan-executor'; +import { + CanceledSingleTransactionPlanResult, + FailedSingleTransactionPlanResult, + SingleTransactionPlanResult, + SuccessfulSingleTransactionPlanResult, + type TransactionPlanResult, +} from '../transaction-plan-result'; // [DESCRIBE] TransactionPlanExecutor { @@ -60,3 +70,47 @@ import type { TransactionPlanResult } from '../transaction-plan-result'; }); } } + +// [DESCRIBE] passthroughFailedTransactionPlanExecution +{ + // It returns a single result when the provided promise expects a single result. + { + const promise = null as unknown as Promise; + const result = passthroughFailedTransactionPlanExecution(promise); + void (result satisfies Promise); + } + + // It widens the result of successful single results to include all possible single results. + { + const promise = null as unknown as Promise; + const result = passthroughFailedTransactionPlanExecution(promise); + void (result satisfies Promise); + // @ts-expect-error Can no longer expect successful result only. + void (result satisfies Promise); + } + + // It widens the result of canceled single results to include all possible single results. + { + const promise = null as unknown as Promise; + const result = passthroughFailedTransactionPlanExecution(promise); + void (result satisfies Promise); + // @ts-expect-error Can no longer expect canceled result only. + void (result satisfies Promise); + } + + // It widens the result of failed single results to include all possible single results. + { + const promise = null as unknown as Promise; + const result = passthroughFailedTransactionPlanExecution(promise); + void (result satisfies Promise); + // @ts-expect-error Can no longer expect failed result only. It could be canceled too. + void (result satisfies Promise); + } + + // It returns any TransactionPlanResult otherwise. + { + const promise = null as unknown as Promise; + const result = passthroughFailedTransactionPlanExecution(promise); + void (result satisfies Promise); + } +} diff --git a/packages/instruction-plans/src/transaction-plan-executor.ts b/packages/instruction-plans/src/transaction-plan-executor.ts index 2eb255ca2..23cd082e6 100644 --- a/packages/instruction-plans/src/transaction-plan-executor.ts +++ b/packages/instruction-plans/src/transaction-plan-executor.ts @@ -1,4 +1,5 @@ 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, @@ -20,12 +21,28 @@ import { failedSingleTransactionPlanResult, parallelTransactionPlanResult, sequentialTransactionPlanResult, + SingleTransactionPlanResult, successfulSingleTransactionPlanResult, successfulSingleTransactionPlanResultFromSignature, type TransactionPlanResult, type TransactionPlanResultContext, } 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. + * + * @typeParam TContext - The type of the context object that may be passed along with successful 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 = ( transactionPlan: TransactionPlan, config?: { abortSignal?: AbortSignal }, @@ -63,12 +80,22 @@ export type TransactionPlanExecutorConfig = { * - 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: (message) => { + * executeTransactionMessage: async (message) => { * const transaction = await signTransactionMessageWithSigners(message); * await sendAndConfirmTransaction(transaction, { commitment: 'confirmed' }); * return { transaction }; @@ -76,7 +103,7 @@ export type TransactionPlanExecutorConfig = { * }); * ``` * - * @see {@link TransactionPlannerConfig} + * @see {@link TransactionPlanExecutorConfig} */ export function createTransactionPlanExecutor(config: TransactionPlanExecutorConfig): TransactionPlanExecutor { return async (plan, { abortSignal } = {}): Promise => { @@ -223,3 +250,61 @@ function assertDivisibleSequentialPlansOnly(transactionPlan: TransactionPlan): v 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. + * + * @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( + promise: Promise, +): Promise; +export async function passthroughFailedTransactionPlanExecution( + promise: Promise, +): Promise; +export async function passthroughFailedTransactionPlanExecution( + 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; + } +}