Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/puny-camels-sort.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand Down Expand Up @@ -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),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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<SingleTransactionPlanResult>;
const result = passthroughFailedTransactionPlanExecution(promise);
void (result satisfies Promise<SingleTransactionPlanResult>);
}

// It widens the result of successful single results to include all possible single results.
{
const promise = null as unknown as Promise<SuccessfulSingleTransactionPlanResult>;
const result = passthroughFailedTransactionPlanExecution(promise);
void (result satisfies Promise<SingleTransactionPlanResult>);
// @ts-expect-error Can no longer expect successful result only.
void (result satisfies Promise<SuccessfulSingleTransactionPlanResult>);
}

// It widens the result of canceled single results to include all possible single results.
{
const promise = null as unknown as Promise<CanceledSingleTransactionPlanResult>;
const result = passthroughFailedTransactionPlanExecution(promise);
void (result satisfies Promise<SingleTransactionPlanResult>);
// @ts-expect-error Can no longer expect canceled result only.
void (result satisfies Promise<CanceledSingleTransactionPlanResult>);
}

// It widens the result of failed single results to include all possible single results.
{
const promise = null as unknown as Promise<FailedSingleTransactionPlanResult>;
const result = passthroughFailedTransactionPlanExecution(promise);
void (result satisfies Promise<SingleTransactionPlanResult>);
// @ts-expect-error Can no longer expect failed result only. It could be canceled too.
void (result satisfies Promise<FailedSingleTransactionPlanResult>);
}

// It returns any TransactionPlanResult otherwise.
{
const promise = null as unknown as Promise<TransactionPlanResult>;
const result = passthroughFailedTransactionPlanExecution(promise);
void (result satisfies Promise<TransactionPlanResult>);
}
}
89 changes: 87 additions & 2 deletions packages/instruction-plans/src/transaction-plan-executor.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<TContext extends TransactionPlanResultContext = TransactionPlanResultContext> = (
transactionPlan: TransactionPlan,
config?: { abortSignal?: AbortSignal },
Expand Down Expand Up @@ -63,20 +80,30 @@ 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 };
* }
* });
* ```
*
* @see {@link TransactionPlannerConfig}
* @see {@link TransactionPlanExecutorConfig}
*/
export function createTransactionPlanExecutor(config: TransactionPlanExecutorConfig): TransactionPlanExecutor {
return async (plan, { abortSignal } = {}): Promise<TransactionPlanResult> => {
Expand Down Expand Up @@ -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<SingleTransactionPlanResult>,
): Promise<SingleTransactionPlanResult>;
export async function passthroughFailedTransactionPlanExecution(
promise: Promise<TransactionPlanResult>,
): Promise<TransactionPlanResult>;
export async function passthroughFailedTransactionPlanExecution(
promise: Promise<TransactionPlanResult>,
): Promise<TransactionPlanResult> {
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;
}
}
Loading