From 627e15ec7320b321871679dd8a269b9262d1f7c8 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Mon, 2 Feb 2026 15:45:40 +0000 Subject: [PATCH] Pass mutable context to `executeTransactionMessage` callback --- .changeset/dry-trees-judge.md | 39 ++ examples/token-airdrop/src/example.ts | 11 +- .../transaction-plan-executor-test.ts | 335 ++++++++++++++---- .../transaction-plan-executor-typetest.ts | 70 +++- .../src/transaction-plan-executor.ts | 127 ++++--- 5 files changed, 460 insertions(+), 122 deletions(-) create mode 100644 .changeset/dry-trees-judge.md diff --git a/.changeset/dry-trees-judge.md b/.changeset/dry-trees-judge.md new file mode 100644 index 000000000..29352de65 --- /dev/null +++ b/.changeset/dry-trees-judge.md @@ -0,0 +1,39 @@ +--- +'@solana/instruction-plans': major +--- + +The `executeTransactionMessage` callback in `createTransactionPlanExecutor` now receives a mutable context object as its first argument. This context can be incrementally populated during execution (e.g. with the latest transaction message, the compiled transaction, or custom properties) and is preserved in the resulting `SingleTransactionPlanResult` regardless of the outcome. If an error is thrown at any point in the callback, any attributes already saved to the context will still be available in the `FailedSingleTransactionPlanResult`, which is useful for debugging failures or building recovery plans. + +The callback must now return either a `Signature` or a full `Transaction` object directly, instead of wrapping the result in an object. + +**BREAKING CHANGES** + +**`executeTransactionMessage` callback signature changed.** The callback now receives `(context, message, config)` instead of `(message, config)` and returns `Signature | Transaction` instead of `{ transaction: Transaction } | { signature: Signature }`. + +```diff + const executor = createTransactionPlanExecutor({ +- executeTransactionMessage: async (message, { abortSignal }) => { ++ executeTransactionMessage: async (context, message, { abortSignal }) => { + const transaction = await signTransactionMessageWithSigners(message); ++ context.transaction = transaction; + await sendAndConfirmTransaction(transaction, { commitment: 'confirmed' }); +- return { transaction }; ++ return transaction; + } + }); +``` + +**Custom context is now set via mutation instead of being returned.** Previously, custom context was returned as part of the result object. Now, it must be set directly on the mutable context argument. + +```diff + const executor = createTransactionPlanExecutor({ +- executeTransactionMessage: async (message) => { +- const transaction = await signAndSend(message); +- return { transaction, context: { custom: 'value' } }; ++ executeTransactionMessage: async (context, message) => { ++ context.custom = 'value'; ++ const transaction = await signAndSend(message); ++ return transaction; + } + }); +``` diff --git a/examples/token-airdrop/src/example.ts b/examples/token-airdrop/src/example.ts index 74e137080..9fee39ac3 100644 --- a/examples/token-airdrop/src/example.ts +++ b/examples/token-airdrop/src/example.ts @@ -177,7 +177,7 @@ const transactionExecutor = createTransactionPlanExecutor({ * It is responsible for signing, sending, and confirming the transaction message. * It only needs to deal with one transaction message. */ - async executeTransactionMessage(message, config) { + async executeTransactionMessage(context, message, config) { const abortSignal = config ? config.abortSignal : undefined; /** @@ -197,12 +197,19 @@ const transactionExecutor = createTransactionPlanExecutor({ tx => estimateAndSetCULimit(tx, { abortSignal }), ); + // Store the updated message in the context for potential error handling. + context.message = updatedMessage; + // Sign this updated transaction message with any signers included in its instructions const signedTransaction = await signTransactionMessageWithSigners(updatedMessage, { abortSignal }); const signature = getSignatureFromTransaction(signedTransaction); log.info({ signature }, `[transaction executor] Sending transaction`); + // Store the signed transaction and its signature in the context for potential error handling. + context.transaction = signedTransaction; + context.signature = signature; + // Send and confirm the transaction using the helper we created earlier assertIsTransactionWithBlockhashLifetime(signedTransaction); await sendAndConfirmTransaction(signedTransaction, { abortSignal, commitment: 'confirmed' }); @@ -210,7 +217,7 @@ const transactionExecutor = createTransactionPlanExecutor({ { signature }, `[transaction executor] Transaction confirmed: https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=127.0.0.1:8899`, ); - return { transaction: signedTransaction }; + return signedTransaction; }, }); 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 e9f2ae459..85a3d8ae8 100644 --- a/packages/instruction-plans/src/__tests__/transaction-plan-executor-test.ts +++ b/packages/instruction-plans/src/__tests__/transaction-plan-executor-test.ts @@ -7,6 +7,8 @@ import { SOLANA_ERROR__TRANSACTION_ERROR__INSUFFICIENT_FUNDS_FOR_FEE, SolanaError, } from '@solana/errors'; +import { Signature } from '@solana/keys'; +import { TransactionMessage, TransactionMessageWithFeePayer } from '@solana/transaction-messages'; import { canceledSingleTransactionPlanResult, @@ -19,6 +21,7 @@ import { sequentialTransactionPlan, sequentialTransactionPlanResult, singleTransactionPlan, + successfulSingleTransactionPlanResult, successfulSingleTransactionPlanResultFromTransaction, TransactionPlanResult, } from '../index'; @@ -46,8 +49,10 @@ async function expectFailedToExecute( ); } -function forwardId(message: { id: string }) { - return Promise.resolve({ transaction: createTransaction(message.id) }); +function forwardId(_: unknown, message: TransactionMessage & TransactionMessageWithFeePayer) { + return Promise.resolve( + createTransaction((message as TransactionMessage & TransactionMessageWithFeePayer & { id: string }).id), + ); } describe('createTransactionPlanExecutor', () => { @@ -56,14 +61,16 @@ describe('createTransactionPlanExecutor', () => { expect.assertions(2); const messageA = createMessage('A'); const transactionA = createTransaction('A'); - const executeTransactionMessage = jest.fn().mockResolvedValue({ transaction: transactionA }); + const executeTransactionMessage = jest.fn().mockResolvedValue(transactionA); const executor = createTransactionPlanExecutor({ executeTransactionMessage }); const promise = executor(singleTransactionPlan(messageA)); await expect(promise).resolves.toStrictEqual( successfulSingleTransactionPlanResultFromTransaction(messageA, transactionA), ); - expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, messageA, { abortSignal: undefined }); + expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, expect.any(Object), messageA, { + abortSignal: undefined, + }); }); it('passes the abort signal to the `executeTransactionMessage` function', async () => { @@ -71,26 +78,141 @@ describe('createTransactionPlanExecutor', () => { const messageA = createMessage('A'); const abortController = new AbortController(); const abortSignal = abortController.signal; - const executeTransactionMessage = jest.fn().mockResolvedValue({ transaction: createTransaction('A') }); + const executeTransactionMessage = jest.fn().mockResolvedValue(createTransaction('A')); const executor = createTransactionPlanExecutor({ executeTransactionMessage }); await executor(singleTransactionPlan(messageA), { abortSignal }); - expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, messageA, { abortSignal }); + expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, expect.any(Object), messageA, { abortSignal }); + }); + + it('uses the returned signature for the successful context', async () => { + expect.assertions(1); + const messageA = createMessage('A'); + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: () => Promise.resolve('A' as Signature), + }); + + const promise = executor(singleTransactionPlan(messageA)); + await expect(promise).resolves.toStrictEqual( + successfulSingleTransactionPlanResult(messageA, { signature: 'A' as Signature }), + ); }); - it('executes a single transaction message with custom context', async () => { + it('uses the signature from the returned transaction for the successful context', async () => { expect.assertions(1); const messageA = createMessage('A'); const transactionA = createTransaction('A'); - const executeTransactionMessage = jest.fn().mockResolvedValue({ - context: { custom: 'context' }, - transaction: transactionA, + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: () => Promise.resolve(transactionA), + }); + + const promise = executor(singleTransactionPlan(messageA)); + await expect(promise).resolves.toStrictEqual( + successfulSingleTransactionPlanResult(messageA, { + signature: 'A' as Signature, + transaction: transactionA, + }), + ); + }); + + it('override any set signature with the returned signature', async () => { + expect.assertions(1); + const messageA = createMessage('A'); + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: context => { + context.signature = 'CONTEXT_SIGNATURE' as Signature; + return Promise.resolve('RETURNED_SIGNATURE' as Signature); + }, + }); + + const promise = executor(singleTransactionPlan(messageA)); + await expect(promise).resolves.toStrictEqual( + successfulSingleTransactionPlanResult(messageA, { signature: 'RETURNED_SIGNATURE' as Signature }), + ); + }); + + it('override any set signature with the signature of the returned transaction', async () => { + expect.assertions(1); + const messageA = createMessage('A'); + const transactionA = createTransaction('A'); + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: context => { + context.signature = 'CONTEXT_SIGNATURE' as Signature; + return Promise.resolve(transactionA); + }, + }); + + const promise = executor(singleTransactionPlan(messageA)); + await expect(promise).resolves.toStrictEqual( + successfulSingleTransactionPlanResult(messageA, { + signature: 'A' as Signature, + transaction: transactionA, + }), + ); + }); + + it('override any set transaction with the returned transaction', async () => { + expect.assertions(1); + const messageA = createMessage('A'); + const transactionA = createTransaction('A'); + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: context => { + context.transaction = createTransaction('B'); + return Promise.resolve(transactionA); + }, + }); + + const promise = executor(singleTransactionPlan(messageA)); + await expect(promise).resolves.toStrictEqual( + successfulSingleTransactionPlanResult(messageA, { + signature: 'A' as Signature, + transaction: transactionA, + }), + ); + }); + + it('stores the base context', async () => { + expect.assertions(1); + const messageA = createMessage('A'); + const transactionA = createTransaction('A'); + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: (context, _) => { + context.message = createMessage('NEW A'); + context.transaction = transactionA; + context.signature = 'A' as Signature; + return Promise.resolve(transactionA); + }, + }); + + const promise = executor(singleTransactionPlan(messageA)); + await expect(promise).resolves.toStrictEqual( + successfulSingleTransactionPlanResult(messageA, { + message: createMessage('NEW A'), + signature: 'A' as Signature, + transaction: transactionA, + }), + ); + }); + + it('stores custom context properties', async () => { + expect.assertions(1); + const messageA = createMessage('A'); + const messageB = createMessage('B'); + const executor = createTransactionPlanExecutor<{ custom: string }>({ + executeTransactionMessage: context => { + context.custom = 'custom value'; + context.message = messageB; + return Promise.resolve('A' as Signature); + }, }); - const executor = createTransactionPlanExecutor({ executeTransactionMessage }); const promise = executor(singleTransactionPlan(messageA)); await expect(promise).resolves.toStrictEqual( - successfulSingleTransactionPlanResultFromTransaction(messageA, transactionA, { custom: 'context' }), + successfulSingleTransactionPlanResult(messageA, { + custom: 'custom value', + message: messageB, + signature: 'A' as Signature, + }), ); }); @@ -98,8 +220,9 @@ describe('createTransactionPlanExecutor', () => { expect.assertions(2); const messageA = createMessage('A'); const cause = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__INVALID_ARGUMENT, { index: 0 }); - const executeTransactionMessage = jest.fn().mockRejectedValue(cause); - const executor = createTransactionPlanExecutor({ executeTransactionMessage }); + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: () => Promise.reject(cause), + }); const promise = executor(singleTransactionPlan(messageA)); await expectFailedToExecute( @@ -111,12 +234,78 @@ describe('createTransactionPlanExecutor', () => { ); }); + it('keeps all information provided to the context before failure', async () => { + expect.assertions(2); + const messageA = createMessage('A'); + const messageB = createMessage('B'); + const transactionA = createTransaction('A'); + const cause = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__INVALID_ARGUMENT, { index: 0 }); + const throwCause = (): void => { + throw cause; + }; + const executor = createTransactionPlanExecutor<{ afterFailure: string; beforeFailure: string }>({ + executeTransactionMessage: async context => { + context.beforeFailure = 'before failure'; + context.message = messageB; + context.transaction = transactionA; + context.signature = 'B' as Signature; + throwCause(); + context.afterFailure = 'after failure'; + return await Promise.resolve('C' as Signature); + }, + }); + + const promise = executor(singleTransactionPlan(messageA)); + await expectFailedToExecute( + promise, + new SolanaError(SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN, { + cause, + transactionPlanResult: failedSingleTransactionPlanResult(messageA, cause, { + beforeFailure: 'before failure', + message: messageB, + signature: 'B' as Signature, + transaction: transactionA, + }), + }), + ); + }); + + it('adds the signature to a failed context if a transaction is present', async () => { + expect.assertions(2); + const messageA = createMessage('A'); + const transactionA = createTransaction('A'); + const cause = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__INVALID_ARGUMENT, { index: 0 }); + const throwCause = (): void => { + throw cause; + }; + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: async context => { + context.transaction = transactionA; + throwCause(); + return await Promise.resolve(transactionA); + }, + }); + + const promise = executor(singleTransactionPlan(messageA)); + await expectFailedToExecute( + promise, + new SolanaError(SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN, { + cause, + transactionPlanResult: failedSingleTransactionPlanResult(messageA, cause, { + signature: 'A' as Signature, + transaction: transactionA, + }), + }), + ); + }); + it('can use any error object as a failure cause', async () => { expect.assertions(2); const messageA = createMessage('A'); const cause = new Error('Custom error message'); - const executeTransactionMessage = jest.fn().mockRejectedValue(cause); - const executor = createTransactionPlanExecutor({ executeTransactionMessage }); + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: () => Promise.reject(cause), + }); const promise = executor(singleTransactionPlan(messageA)); await expectFailedToExecute( @@ -200,8 +389,12 @@ describe('createTransactionPlanExecutor', () => { ); expect(executeTransactionMessage).toHaveBeenCalledTimes(2); - expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, messageA, { abortSignal: undefined }); - expect(executeTransactionMessage).toHaveBeenNthCalledWith(2, messageB, { abortSignal: undefined }); + expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, expect.any(Object), messageA, { + abortSignal: undefined, + }); + expect(executeTransactionMessage).toHaveBeenNthCalledWith(2, expect.any(Object), messageB, { + abortSignal: undefined, + }); }); it('throws when encountering a non-divisible sequential transaction plan', async () => { @@ -241,30 +434,30 @@ describe('createTransactionPlanExecutor', () => { const executor = createTransactionPlanExecutor({ executeTransactionMessage }); await executor(sequentialTransactionPlan([messageA, messageB]), { abortSignal }); - expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, messageA, { abortSignal }); - expect(executeTransactionMessage).toHaveBeenNthCalledWith(2, messageB, { abortSignal }); + expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, expect.any(Object), messageA, { abortSignal }); + expect(executeTransactionMessage).toHaveBeenNthCalledWith(2, expect.any(Object), messageB, { abortSignal }); }); it('executes a sequential transaction plan with custom context', async () => { expect.assertions(1); const messageA = createMessage('A'); const messageB = createMessage('B'); - const executeTransactionMessage = jest.fn().mockImplementation((message: { id: string }) => { - return Promise.resolve({ - context: { custom: 'context' }, - transaction: createTransaction(message.id), - }); + const executor = createTransactionPlanExecutor<{ custom: string }>({ + executeTransactionMessage: (context, message) => { + const id = (message as TransactionMessage & TransactionMessageWithFeePayer & { id: string }).id; + context.custom = 'Message ' + id; + return forwardId(context, message); + }, }); - const executor = createTransactionPlanExecutor({ executeTransactionMessage }); const promise = executor(sequentialTransactionPlan([messageA, messageB])); await expect(promise).resolves.toStrictEqual( sequentialTransactionPlanResult([ successfulSingleTransactionPlanResultFromTransaction(messageA, createTransaction('A'), { - custom: 'context', + custom: 'Message A', }), successfulSingleTransactionPlanResultFromTransaction(messageB, createTransaction('B'), { - custom: 'context', + custom: 'Message B', }), ]), ); @@ -322,7 +515,9 @@ describe('createTransactionPlanExecutor', () => { await executor(sequentialTransactionPlan([messageA, messageB])).catch(() => {}); expect(executeTransactionMessage).toHaveBeenCalledTimes(1); - expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, messageA, { abortSignal: undefined }); + expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, expect.any(Object), messageA, { + abortSignal: undefined, + }); }); it('can abort sequential transaction plans', async () => { @@ -357,9 +552,9 @@ describe('createTransactionPlanExecutor', () => { ); expect(executeTransactionMessage).toHaveBeenCalledTimes(2); - expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, messageA, { abortSignal }); - expect(executeTransactionMessage).toHaveBeenNthCalledWith(2, messageB, { abortSignal }); - expect(executeTransactionMessage).not.toHaveBeenCalledWith(messageC, { abortSignal }); + expect(executeTransactionMessage).toHaveBeenNthCalledWith(1, expect.any(Object), messageA, { abortSignal }); + expect(executeTransactionMessage).toHaveBeenNthCalledWith(2, expect.any(Object), messageB, { abortSignal }); + expect(executeTransactionMessage).not.toHaveBeenCalledWith(expect.any(Object), messageC, { abortSignal }); }); it('can abort sequential transaction plans before execution', async () => { @@ -416,8 +611,12 @@ describe('createTransactionPlanExecutor', () => { ); expect(executeTransactionMessage).toHaveBeenCalledTimes(2); - expect(executeTransactionMessage).toHaveBeenCalledWith(messageA, { abortSignal: undefined }); - expect(executeTransactionMessage).toHaveBeenCalledWith(messageB, { abortSignal: undefined }); + expect(executeTransactionMessage).toHaveBeenCalledWith(expect.any(Object), messageA, { + abortSignal: undefined, + }); + expect(executeTransactionMessage).toHaveBeenCalledWith(expect.any(Object), messageB, { + abortSignal: undefined, + }); }); it('passes the abort signal to the `executeTransactionMessage` function', async () => { @@ -430,30 +629,30 @@ describe('createTransactionPlanExecutor', () => { const executor = createTransactionPlanExecutor({ executeTransactionMessage }); await executor(parallelTransactionPlan([messageA, messageB]), { abortSignal }); - expect(executeTransactionMessage).toHaveBeenCalledWith(messageA, { abortSignal }); - expect(executeTransactionMessage).toHaveBeenCalledWith(messageB, { abortSignal }); + expect(executeTransactionMessage).toHaveBeenCalledWith(expect.any(Object), messageA, { abortSignal }); + expect(executeTransactionMessage).toHaveBeenCalledWith(expect.any(Object), messageB, { abortSignal }); }); it('executes a parallel transaction plan with custom context', async () => { expect.assertions(1); const messageA = createMessage('A'); const messageB = createMessage('B'); - const executeTransactionMessage = jest.fn().mockImplementation((message: { id: string }) => { - return Promise.resolve({ - context: { custom: 'context' }, - transaction: createTransaction(message.id), - }); + const executor = createTransactionPlanExecutor<{ custom: string }>({ + executeTransactionMessage: (context, message) => { + const id = (message as TransactionMessage & TransactionMessageWithFeePayer & { id: string }).id; + context.custom = 'Message ' + id; + return forwardId(context, message); + }, }); - const executor = createTransactionPlanExecutor({ executeTransactionMessage }); const promise = executor(parallelTransactionPlan([messageA, messageB])); await expect(promise).resolves.toStrictEqual( parallelTransactionPlanResult([ successfulSingleTransactionPlanResultFromTransaction(messageA, createTransaction('A'), { - custom: 'context', + custom: 'Message A', }), successfulSingleTransactionPlanResultFromTransaction(messageB, createTransaction('B'), { - custom: 'context', + custom: 'Message B', }), ]), ); @@ -465,10 +664,14 @@ describe('createTransactionPlanExecutor', () => { const messageB = createMessage('B'); const messageC = createMessage('C'); const cause = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__INVALID_ARGUMENT, { index: 0 }); - const executeTransactionMessage = jest.fn().mockImplementation((message: { id: string }) => { - // eslint-disable-next-line jest/no-conditional-in-test - return message.id === 'B' ? Promise.reject(cause) : forwardId(message); - }); + const executeTransactionMessage = jest + .fn() + .mockImplementation( + (context, message: TransactionMessage & TransactionMessageWithFeePayer & { id: string }) => { + // eslint-disable-next-line jest/no-conditional-in-test + return message.id === 'B' ? Promise.reject(cause) : forwardId(context, message); + }, + ); const executor = createTransactionPlanExecutor({ executeTransactionMessage }); const promise = executor(parallelTransactionPlan([messageA, messageB, messageC])); @@ -495,10 +698,14 @@ describe('createTransactionPlanExecutor', () => { const abortController = new AbortController(); const abortSignal = abortController.signal; const cause = new Error('Aborted during execution'); - const executeTransactionMessage = jest.fn().mockImplementation((message: { id: string }) => { - // eslint-disable-next-line jest/no-conditional-in-test - return message.id === 'B' ? FOREVER_PROMISE : forwardId(message); - }); + const executeTransactionMessage = jest + .fn() + .mockImplementation( + (context, message: TransactionMessage & TransactionMessageWithFeePayer & { id: string }) => { + // eslint-disable-next-line jest/no-conditional-in-test + return message.id === 'B' ? FOREVER_PROMISE : forwardId(context, message); + }, + ); const executor = createTransactionPlanExecutor({ executeTransactionMessage }); const promise = executor(parallelTransactionPlan([messageA, messageB, messageC]), { abortSignal }); @@ -607,10 +814,14 @@ describe('createTransactionPlanExecutor', () => { const messageF = createMessage('F'); const messageG = createMessage('G'); const cause = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__INVALID_ARGUMENT, { index: 0 }); - const executeTransactionMessage = jest.fn().mockImplementation((message: { id: string }) => { - // eslint-disable-next-line jest/no-conditional-in-test - return message.id === 'C' ? Promise.reject(cause) : forwardId(message); - }); + const executeTransactionMessage = jest + .fn() + .mockImplementation( + (context, message: TransactionMessage & TransactionMessageWithFeePayer & { id: string }) => { + // eslint-disable-next-line jest/no-conditional-in-test + return message.id === 'C' ? Promise.reject(cause) : forwardId(context, message); + }, + ); const executor = createTransactionPlanExecutor({ executeTransactionMessage }); const promise = executor( @@ -658,10 +869,14 @@ describe('createTransactionPlanExecutor', () => { const abortController = new AbortController(); const abortSignal = abortController.signal; const cause = new Error('Aborted during execution'); - const executeTransactionMessage = jest.fn().mockImplementation((message: { id: string }) => { - // eslint-disable-next-line jest/no-conditional-in-test - return message.id === 'C' ? FOREVER_PROMISE : forwardId(message); - }); + const executeTransactionMessage = jest + .fn() + .mockImplementation( + (context, message: TransactionMessage & TransactionMessageWithFeePayer & { id: string }) => { + // eslint-disable-next-line jest/no-conditional-in-test + return message.id === 'C' ? FOREVER_PROMISE : forwardId(context, message); + }, + ); const executor = createTransactionPlanExecutor({ executeTransactionMessage }); const promise = executor( 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 f30a80747..2d832f58e 100644 --- a/packages/instruction-plans/src/__typetests__/transaction-plan-executor-typetest.ts +++ b/packages/instruction-plans/src/__typetests__/transaction-plan-executor-typetest.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ +import { Signature } from '@solana/keys'; import { setTransactionMessageLifetimeUsingBlockhash, TransactionMessage, @@ -42,20 +43,81 @@ import { // [DESCRIBE] createTransactionPlanExecutor { + // It can return a signature or a full transaction. + { + createTransactionPlanExecutor({ + executeTransactionMessage: () => Promise.resolve({} as Signature), + }); + createTransactionPlanExecutor({ + executeTransactionMessage: () => Promise.resolve({} as Transaction), + }); + } + // It always receives a transaction message with fee payer. { createTransactionPlanExecutor({ - executeTransactionMessage: message => { + executeTransactionMessage: (_, message) => { message satisfies TransactionMessage & TransactionMessageWithFeePayer; - return Promise.resolve({ transaction: {} as Transaction }); + return Promise.resolve({} as Transaction); + }, + }); + } + + // It receives a base context by default. + { + createTransactionPlanExecutor({ + executeTransactionMessage: context => { + context.message satisfies (TransactionMessage & TransactionMessageWithFeePayer) | undefined; + context.transaction satisfies Transaction | undefined; + context.signature satisfies Signature | undefined; + return Promise.resolve({} as Signature); + }, + }); + } + + // It removes undefined after assignment in the context. + { + createTransactionPlanExecutor({ + executeTransactionMessage: context => { + // @ts-expect-error Initially, the context transaction may be undefined. + context.transaction satisfies Transaction; + context.transaction satisfies Transaction | undefined; + const mySignedTransaction = {} as unknown as Transaction; + context.transaction = mySignedTransaction; + context.transaction satisfies Transaction; + return Promise.resolve(context.transaction); + }, + }); + } + + // It can use a custom context which is then assigned to the created TransactionPlanExecutor. + { + const executor = createTransactionPlanExecutor({ + executeTransactionMessage: (_: { custom: string }) => { + return Promise.resolve({} as Signature); + }, + }); + executor satisfies TransactionPlanExecutor<{ custom: string }>; + } + + // It can use a custom context with the base context. + { + const executor = createTransactionPlanExecutor<{ custom: string }>({ + executeTransactionMessage: context => { + context.custom satisfies string; + context.message satisfies (TransactionMessage & TransactionMessageWithFeePayer) | undefined; + context.transaction satisfies Transaction | undefined; + context.signature satisfies Signature | undefined; + return Promise.resolve({} as Signature); }, }); + executor satisfies TransactionPlanExecutor<{ custom: string }>; } // It transfers the lifetime to the compiled transaction. { createTransactionPlanExecutor({ - executeTransactionMessage: message => { + executeTransactionMessage: (_, message) => { const latestBlockhash = {} as unknown as Parameters< typeof setTransactionMessageLifetimeUsingBlockhash >[0]; @@ -63,7 +125,7 @@ import { messageWithBlockhash satisfies TransactionMessageWithBlockhashLifetime; const transaction = compileTransaction(messageWithBlockhash); transaction satisfies TransactionWithBlockhashLifetime; - return Promise.resolve({ transaction }); + return Promise.resolve(transaction); }, }); } diff --git a/packages/instruction-plans/src/transaction-plan-executor.ts b/packages/instruction-plans/src/transaction-plan-executor.ts index 0a654d132..a97a09711 100644 --- a/packages/instruction-plans/src/transaction-plan-executor.ts +++ b/packages/instruction-plans/src/transaction-plan-executor.ts @@ -5,10 +5,10 @@ import { SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND, SolanaError, } from '@solana/errors'; -import { Signature } from '@solana/keys'; +import type { Signature } from '@solana/keys'; import { getAbortablePromise } from '@solana/promises'; -import { TransactionMessage, TransactionMessageWithFeePayer } from '@solana/transaction-messages'; -import { Transaction } from '@solana/transactions'; +import type { TransactionMessage, TransactionMessageWithFeePayer } from '@solana/transaction-messages'; +import { getSignatureFromTransaction, type Transaction } from '@solana/transactions'; import type { ParallelTransactionPlan, @@ -17,6 +17,7 @@ import type { TransactionPlan, } from './transaction-plan'; import { + BaseTransactionPlanResultContext, canceledSingleTransactionPlanResult, failedSingleTransactionPlanResult, parallelTransactionPlanResult, @@ -48,23 +49,22 @@ export type TransactionPlanExecutor Promise>; -type ExecuteResult = { - context?: TContext; -} & ({ signature: Signature } | { transaction: Transaction }); - -type ExecuteTransactionMessage = ( +type ExecuteTransactionMessage = ( + context: BaseTransactionPlanResultContext & TContext, transactionMessage: TransactionMessage & TransactionMessageWithFeePayer, config?: { abortSignal?: AbortSignal }, -) => Promise>; +) => Promise; /** * Configuration object for creating a new transaction plan executor. * * @see {@link createTransactionPlanExecutor} */ -export type TransactionPlanExecutorConfig = { +export type TransactionPlanExecutorConfig< + TContext extends TransactionPlanResultContext = TransactionPlanResultContext, +> = { /** Called whenever a transaction message must be sent to the blockchain. */ - executeTransactionMessage: ExecuteTransactionMessage; + executeTransactionMessage: ExecuteTransactionMessage; }; /** @@ -73,10 +73,21 @@ export type TransactionPlanExecutorConfig = { * 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, 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 must return either a + * {@link Signature} or a full {@link Transaction} object. + * * - If that function is successful, the executor will return a successful `TransactionPlanResult` - * for that message including the transaction and any custom context. + * for that message. The returned signature or transaction is stored in the context automatically. * - If that function throws an error, the executor will stop processing and cancel all - * remaining transaction messages in the plan. + * 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`. * @@ -95,19 +106,22 @@ export type TransactionPlanExecutorConfig = { * const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }); * * const transactionPlanExecutor = createTransactionPlanExecutor({ - * executeTransactionMessage: async (message) => { + * executeTransactionMessage: async (context, message) => { * const transaction = await signTransactionMessageWithSigners(message); + * context.transaction = transaction; * await sendAndConfirmTransaction(transaction, { commitment: 'confirmed' }); - * return { transaction }; + * return transaction; * } * }); * ``` * * @see {@link TransactionPlanExecutorConfig} */ -export function createTransactionPlanExecutor(config: TransactionPlanExecutorConfig): TransactionPlanExecutor { - return async (plan, { abortSignal } = {}): Promise => { - const context: TraverseContext = { +export function createTransactionPlanExecutor< + TContext extends TransactionPlanResultContext = TransactionPlanResultContext, +>(config: TransactionPlanExecutorConfig): TransactionPlanExecutor { + return async (plan, { abortSignal } = {}): Promise> => { + const traverseConfig: TraverseConfig = { ...config, abortSignal: abortSignal, canceled: abortSignal?.aborted ?? false, @@ -118,13 +132,13 @@ export function createTransactionPlanExecutor(config: TransactionPlanExecutorCon assertDivisibleSequentialPlansOnly(plan); const cancelHandler = () => { - context.canceled = true; + traverseConfig.canceled = true; }; abortSignal?.addEventListener('abort', cancelHandler); - const transactionPlanResult = await traverse(plan, context); + const transactionPlanResult = await traverse(plan, traverseConfig); abortSignal?.removeEventListener('abort', cancelHandler); - if (context.canceled) { + if (traverseConfig.canceled) { const abortReason = abortSignal?.aborted ? abortSignal.reason : undefined; const context = { cause: findErrorFromTransactionPlanResult(transactionPlanResult) ?? abortReason }; // Here we want the `transactionPlanResult` to be available in the error context @@ -143,80 +157,81 @@ export function createTransactionPlanExecutor(config: TransactionPlanExecutorCon }; } -type TraverseContext = TransactionPlanExecutorConfig & { +type TraverseConfig = TransactionPlanExecutorConfig & { abortSignal?: AbortSignal; canceled: boolean; }; -async function traverse(transactionPlan: TransactionPlan, context: TraverseContext): Promise { +async function traverse( + transactionPlan: TransactionPlan, + traverseConfig: TraverseConfig, +): Promise> { const kind = transactionPlan.kind; switch (kind) { case 'sequential': - return await traverseSequential(transactionPlan, context); + return await traverseSequential(transactionPlan, traverseConfig); case 'parallel': - return await traverseParallel(transactionPlan, context); + return await traverseParallel(transactionPlan, traverseConfig); case 'single': - return await traverseSingle(transactionPlan, context); + return await traverseSingle(transactionPlan, traverseConfig); default: transactionPlan satisfies never; throw new SolanaError(SOLANA_ERROR__INVARIANT_VIOLATION__INVALID_TRANSACTION_PLAN_KIND, { kind }); } } -async function traverseSequential( +async function traverseSequential( transactionPlan: SequentialTransactionPlan, - context: TraverseContext, -): Promise { + traverseConfig: TraverseConfig, +): Promise> { if (!transactionPlan.divisible) { throw new SolanaError(SOLANA_ERROR__INSTRUCTION_PLANS__NON_DIVISIBLE_TRANSACTION_PLANS_NOT_SUPPORTED); } - const results: TransactionPlanResult[] = []; + const results: TransactionPlanResult[] = []; for (const subPlan of transactionPlan.plans) { - const result = await traverse(subPlan, context); + const result = await traverse(subPlan, traverseConfig); results.push(result); } return sequentialTransactionPlanResult(results); } -async function traverseParallel( +async function traverseParallel( transactionPlan: ParallelTransactionPlan, - context: TraverseContext, -): Promise { - const results = await Promise.all(transactionPlan.plans.map(plan => traverse(plan, context))); + traverseConfig: TraverseConfig, +): Promise> { + const results = await Promise.all(transactionPlan.plans.map(plan => traverse(plan, traverseConfig))); return parallelTransactionPlanResult(results); } -async function traverseSingle( +async function traverseSingle( transactionPlan: SingleTransactionPlan, - context: TraverseContext, -): Promise { - if (context.canceled) { - return canceledSingleTransactionPlanResult(transactionPlan.message); + traverseConfig: TraverseConfig, +): Promise> { + const context = {} as BaseTransactionPlanResultContext & TContext; + if (traverseConfig.canceled) { + return canceledSingleTransactionPlanResult(transactionPlan.message, context); } try { const result = await getAbortablePromise( - context.executeTransactionMessage(transactionPlan.message, { abortSignal: context.abortSignal }), - context.abortSignal, + traverseConfig.executeTransactionMessage(context, transactionPlan.message, { + abortSignal: traverseConfig.abortSignal, + }), + traverseConfig.abortSignal, ); - if ('transaction' in result) { - return successfulSingleTransactionPlanResultFromTransaction( - transactionPlan.message, - result.transaction, - result.context, - ); - } else { - return successfulSingleTransactionPlanResult(transactionPlan.message, { - ...result.context, - signature: result.signature, - }); - } + return typeof result === 'string' + ? successfulSingleTransactionPlanResult(transactionPlan.message, { ...context, signature: result }) + : successfulSingleTransactionPlanResultFromTransaction(transactionPlan.message, result, context); } catch (error) { - context.canceled = true; - return failedSingleTransactionPlanResult(transactionPlan.message, error as Error); + traverseConfig.canceled = true; + const contextWithSignature = + 'transaction' in context && typeof context.transaction === 'object' && context.signature == null + ? { ...context, signature: getSignatureFromTransaction(context.transaction) } + : context; + return failedSingleTransactionPlanResult(transactionPlan.message, error as Error, contextWithSignature); } }