From 35f3c18aa8c37432bf4c697a1fac8df37dcde192 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Fri, 2 Dec 2022 16:36:09 -0800 Subject: [PATCH] feat: you can now abort transaction confirmations in web3.js (#29057) * Upgrade Typescript, `@types/node`, and `typedoc` to versions that play well together In this instance it means they: * understand `AbortSignal` * don't cause build errors * You can now abort transaction confirmation using an `AbortSignal` * Pipe an `AbortSignal` down through `sendAndConfirmTransaction()` * Add `AbortController` polyfill to test so that test works in Node 14 --- web3.js/package.json | 7 +- web3.js/src/connection.ts | 98 +++++++++++++------ web3.js/src/epoch-schedule.ts | 2 +- .../src/utils/send-and-confirm-transaction.ts | 15 ++- web3.js/test/connection.test.ts | 79 +++++++++++++++ web3.js/yarn.lock | 65 ++++++++---- 6 files changed, 213 insertions(+), 53 deletions(-) diff --git a/web3.js/package.json b/web3.js/package.json index d259f0ab63..56332d79d7 100644 --- a/web3.js/package.json +++ b/web3.js/package.json @@ -95,7 +95,7 @@ "@types/express-serve-static-core": "^4.17.21", "@types/mocha": "^10.0.0", "@types/mz": "^2.7.3", - "@types/node": "^17.0.24", + "@types/node": "^18.11.10", "@types/node-fetch": "2", "@types/sinon": "^10.0.0", "@types/sinon-chai": "^3.2.8", @@ -114,6 +114,7 @@ "mocha": "^10.1.0", "mockttp": "^2.0.1", "mz": "^2.7.0", + "node-abort-controller": "^3.0.1", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", "prettier": "^2.3.0", @@ -129,8 +130,8 @@ "ts-mocha": "^10.0.0", "ts-node": "^10.0.0", "tslib": "^2.1.0", - "typedoc": "^0.22.2", - "typescript": "^4.3.2" + "typedoc": "^0.23", + "typescript": "^4.9" }, "engines": { "node": ">=12.20.0" diff --git a/web3.js/src/connection.ts b/web3.js/src/connection.ts index 9e643a9330..ed4c958432 100644 --- a/web3.js/src/connection.ts +++ b/web3.js/src/connection.ts @@ -311,9 +311,39 @@ export type BlockhashWithExpiryBlockHeight = Readonly<{ * A strategy for confirming transactions that uses the last valid * block height for a given blockhash to check for transaction expiration. */ -export type BlockheightBasedTransactionConfirmationStrategy = { +export type BlockheightBasedTransactionConfirmationStrategy = + BaseTransactionConfirmationStrategy & BlockhashWithExpiryBlockHeight; + +/** + * A strategy for confirming durable nonce transactions. + */ +export type DurableNonceTransactionConfirmationStrategy = + BaseTransactionConfirmationStrategy & { + /** + * The lowest slot at which to fetch the nonce value from the + * nonce account. This should be no lower than the slot at + * which the last-known value of the nonce was fetched. + */ + minContextSlot: number; + /** + * The account where the current value of the nonce is stored. + */ + nonceAccountPubkey: PublicKey; + /** + * The nonce value that was used to sign the transaction + * for which confirmation is being sought. + */ + nonceValue: DurableNonce; + }; + +/** + * Properties shared by all transaction confirmation strategies + */ +export type BaseTransactionConfirmationStrategy = Readonly<{ + /** A signal that, when aborted, cancels any outstanding transaction confirmation operations */ + abortSignal?: AbortSignal; signature: TransactionSignature; -} & BlockhashWithExpiryBlockHeight; +}>; /* @internal */ function assertEndpointUrl(putativeUrl: string) { @@ -340,28 +370,6 @@ function extractCommitmentFromConfig( return {commitment, config}; } -/** - * A strategy for confirming durable nonce transactions. - */ -export type DurableNonceTransactionConfirmationStrategy = { - /** - * The lowest slot at which to fetch the nonce value from the - * nonce account. This should be no lower than the slot at - * which the last-known value of the nonce was fetched. - */ - minContextSlot: number; - /** - * The account where the current value of the nonce is stored. - */ - nonceAccountPubkey: PublicKey; - /** - * The nonce value that was used to sign the transaction - * for which confirmation is being sought. - */ - nonceValue: DurableNonce; - signature: TransactionSignature; -}; - /** * @internal */ @@ -3571,6 +3579,9 @@ export class Connection { const config = strategy as | BlockheightBasedTransactionConfirmationStrategy | DurableNonceTransactionConfirmationStrategy; + if (config.abortSignal?.aborted) { + return Promise.reject(config.abortSignal.reason); + } rawSignature = config.signature; } @@ -3602,6 +3613,21 @@ export class Connection { } } + private getCancellationPromise(signal?: AbortSignal): Promise { + return new Promise((_, reject) => { + if (signal == null) { + return; + } + if (signal.aborted) { + reject(signal.reason); + } else { + signal.addEventListener('abort', () => { + reject(signal.reason); + }); + } + }); + } + private getTransactionConfirmationPromise({ commitment, signature, @@ -3722,7 +3748,7 @@ export class Connection { private async confirmTransactionUsingBlockHeightExceedanceStrategy({ commitment, - strategy: {lastValidBlockHeight, signature}, + strategy: {abortSignal, lastValidBlockHeight, signature}, }: { commitment?: Commitment; strategy: BlockheightBasedTransactionConfirmationStrategy; @@ -3753,9 +3779,14 @@ export class Connection { }); const {abortConfirmation, confirmationPromise} = this.getTransactionConfirmationPromise({commitment, signature}); + const cancellationPromise = this.getCancellationPromise(abortSignal); let result: RpcResponseAndContext; try { - const outcome = await Promise.race([confirmationPromise, expiryPromise]); + const outcome = await Promise.race([ + cancellationPromise, + confirmationPromise, + expiryPromise, + ]); if (outcome.__type === TransactionStatus.PROCESSED) { result = outcome.response; } else { @@ -3770,7 +3801,13 @@ export class Connection { private async confirmTransactionUsingDurableNonceStrategy({ commitment, - strategy: {minContextSlot, nonceAccountPubkey, nonceValue, signature}, + strategy: { + abortSignal, + minContextSlot, + nonceAccountPubkey, + nonceValue, + signature, + }, }: { commitment?: Commitment; strategy: DurableNonceTransactionConfirmationStrategy; @@ -3821,9 +3858,14 @@ export class Connection { }); const {abortConfirmation, confirmationPromise} = this.getTransactionConfirmationPromise({commitment, signature}); + const cancellationPromise = this.getCancellationPromise(abortSignal); let result: RpcResponseAndContext; try { - const outcome = await Promise.race([confirmationPromise, expiryPromise]); + const outcome = await Promise.race([ + cancellationPromise, + confirmationPromise, + expiryPromise, + ]); if (outcome.__type === TransactionStatus.PROCESSED) { result = outcome.response; } else { diff --git a/web3.js/src/epoch-schedule.ts b/web3.js/src/epoch-schedule.ts index afbc275e71..b6d0e7411b 100644 --- a/web3.js/src/epoch-schedule.ts +++ b/web3.js/src/epoch-schedule.ts @@ -26,7 +26,7 @@ function nextPowerOfTwo(n: number) { /** * Epoch schedule * (see https://docs.solana.com/terminology#epoch) - * Can be retrieved with the {@link connection.getEpochSchedule} method + * Can be retrieved with the {@link Connection.getEpochSchedule} method */ export class EpochSchedule { /** The maximum number of slots in each epoch */ diff --git a/web3.js/src/utils/send-and-confirm-transaction.ts b/web3.js/src/utils/send-and-confirm-transaction.ts index 0e28e4a2d6..67e1aa5fb7 100644 --- a/web3.js/src/utils/send-and-confirm-transaction.ts +++ b/web3.js/src/utils/send-and-confirm-transaction.ts @@ -19,7 +19,11 @@ export async function sendAndConfirmTransaction( connection: Connection, transaction: Transaction, signers: Array, - options?: ConfirmOptions, + options?: ConfirmOptions & + Readonly<{ + // A signal that, when aborted, cancels any outstanding transaction confirmation operations + abortSignal?: AbortSignal; + }>, ): Promise { const sendOptions = options && { skipPreflight: options.skipPreflight, @@ -42,6 +46,7 @@ export async function sendAndConfirmTransaction( status = ( await connection.confirmTransaction( { + abortSignal: options?.abortSignal, signature: signature, blockhash: transaction.recentBlockhash, lastValidBlockHeight: transaction.lastValidBlockHeight, @@ -58,6 +63,7 @@ export async function sendAndConfirmTransaction( status = ( await connection.confirmTransaction( { + abortSignal: options?.abortSignal, minContextSlot: transaction.minNonceContextSlot, nonceAccountPubkey, nonceValue: transaction.nonceInfo.nonce, @@ -67,6 +73,13 @@ export async function sendAndConfirmTransaction( ) ).value; } else { + if (options?.abortSignal != null) { + console.warn( + 'sendAndConfirmTransaction(): A transaction with a deprecated confirmation strategy was ' + + 'supplied along with an `abortSignal`. Only transactions having `lastValidBlockHeight` ' + + 'or a combination of `nonceInfo` and `minNonceContextSlot` are abortable.', + ); + } status = ( await connection.confirmTransaction( signature, diff --git a/web3.js/test/connection.test.ts b/web3.js/test/connection.test.ts index 7d62990c91..31fb8ac4f0 100644 --- a/web3.js/test/connection.test.ts +++ b/web3.js/test/connection.test.ts @@ -3,6 +3,7 @@ import {Buffer} from 'buffer'; import * as splToken from '@solana/spl-token'; import {expect, use} from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import {AbortController} from 'node-abort-controller'; import {mock, useFakeTimers, SinonFakeTimers} from 'sinon'; import sinonChai from 'sinon-chai'; @@ -1167,6 +1168,44 @@ describe('Connection', function () { }); describe('block height strategy', () => { + it('rejects if called with an already-aborted `abortSignal`', () => { + const mockSignature = + 'w2Zeq8YkpyB463DttvfzARD7k9ZxGEwbsEw4boEK7jDp3pfoxZbTdLFSsEPhzXhpCcjGi2kHtHFobgX49MMhbWt'; + const abortController = new AbortController(); + abortController.abort(); + expect( + connection.confirmTransaction({ + abortSignal: abortController.signal, + blockhash: 'sampleBlockhash', + lastValidBlockHeight: 1, + signature: mockSignature, + }), + ).to.eventually.be.rejectedWith('AbortError'); + }); + + it('rejects upon receiving an abort signal', async () => { + const mockSignature = + 'w2Zeq8YkpyB463DttvfzARD7k9ZxGEwbsEw4boEK7jDp3pfoxZbTdLFSsEPhzXhpCcjGi2kHtHFobgX49MMhbWt'; + const abortController = new AbortController(); + // Keep the subscription from ever returning data. + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(() => {}), // Never resolve. + }); + clock.runAllAsync(); + const confirmationPromise = connection.confirmTransaction({ + abortSignal: abortController.signal, + blockhash: 'sampleBlockhash', + lastValidBlockHeight: 1, + signature: mockSignature, + }); + clock.runAllAsync(); + expect(confirmationPromise).not.to.have.been.rejected; + abortController.abort(); + await expect(confirmationPromise).to.eventually.be.rejected; + }); + it('throws a `TransactionExpiredBlockheightExceededError` when the block height advances past the last valid one for this transaction without a signature confirmation', async () => { const mockSignature = '4oCEqwGrMdBeMxpzuWiukCYqSfV4DsSKXSiVVCh1iJ6pS772X7y219JZP3mgqBz5PhsvprpKyhzChjYc3VSBQXzG'; @@ -1295,6 +1334,46 @@ describe('Connection', function () { }); describe('nonce strategy', () => { + it('rejects if called with an already-aborted `abortSignal`', () => { + const mockSignature = + 'w2Zeq8YkpyB463DttvfzARD7k9ZxGEwbsEw4boEK7jDp3pfoxZbTdLFSsEPhzXhpCcjGi2kHtHFobgX49MMhbWt'; + const abortController = new AbortController(); + abortController.abort(); + expect( + connection.confirmTransaction({ + abortSignal: abortController.signal, + minContextSlot: 1, + nonceAccountPubkey: new PublicKey(1), + nonceValue: 'fakenonce', + signature: mockSignature, + }), + ).to.eventually.be.rejectedWith('AbortError'); + }); + + it('rejects upon receiving an abort signal', async () => { + const mockSignature = + 'w2Zeq8YkpyB463DttvfzARD7k9ZxGEwbsEw4boEK7jDp3pfoxZbTdLFSsEPhzXhpCcjGi2kHtHFobgX49MMhbWt'; + const abortController = new AbortController(); + // Keep the subscription from ever returning data. + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(() => {}), // Never resolve. + }); + clock.runAllAsync(); + const confirmationPromise = connection.confirmTransaction({ + abortSignal: abortController.signal, + minContextSlot: 1, + nonceAccountPubkey: new PublicKey(1), + nonceValue: 'fakenonce', + signature: mockSignature, + }); + clock.runAllAsync(); + expect(confirmationPromise).not.to.have.been.rejected; + abortController.abort(); + await expect(confirmationPromise).to.eventually.be.rejected; + }); + it('confirms the transaction if the signature confirmation is received before the nonce is advanced', async () => { const mockSignature = '4oCEqwGrMdBeMxpzuWiukCYqSfV4DsSKXSiVVCh1iJ6pS772X7y219JZP3mgqBz5PhsvprpKyhzChjYc3VSBQXzG'; diff --git a/web3.js/yarn.lock b/web3.js/yarn.lock index 09f8fe4391..d0c0b5c082 100644 --- a/web3.js/yarn.lock +++ b/web3.js/yarn.lock @@ -1654,7 +1654,7 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@^17.0.24": +"@types/node@*": version "17.0.35" resolved "https://registry.npmjs.org/@types/node/-/node-17.0.35.tgz" @@ -1666,6 +1666,11 @@ version "16.11.27" resolved "https://registry.npmjs.org/@types/node/-/node-16.11.27.tgz" +"@types/node@^18.11.10": + version "18.11.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.10.tgz#4c64759f3c2343b7e6c4b9caf761c7a3a05cee34" + integrity sha512-juG3RWMBOqcOuXC643OAdSA525V44cVgGV6dUDuiFtss+8Fk5x1hI93Rsld43VeJVIeqlP9I7Fn9/qaVqoEAuQ== + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz" @@ -3569,7 +3574,7 @@ glob-parent@^6.0.1: dependencies: is-glob "^4.0.3" -glob@7.2.0, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: +glob@7.2.0, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.2.0" resolved "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz" dependencies: @@ -4633,10 +4638,15 @@ marked-terminal@^5.0.0: node-emoji "^1.11.0" supports-hyperlinks "^2.2.0" -marked@^4.0.10, marked@^4.0.12: +marked@^4.0.10: version "4.0.16" resolved "https://registry.npmjs.org/marked/-/marked-4.0.16.tgz" +marked@^4.0.19: + version "4.2.3" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.2.3.tgz#bd76a5eb510ff1d8421bc6c3b2f0b93488c15bea" + integrity sha512-slWRdJkbTZ+PjkyJnE30Uid64eHwbwa1Q25INCAYfZlK4o6ylagBy/Le9eWntqJFoFT93ikUKMv47GZ4gTwHkw== + matched@^5.0.0: version "5.0.1" resolved "https://registry.npmjs.org/matched/-/matched-5.0.1.tgz" @@ -4743,6 +4753,13 @@ minimatch@^5.0.1: dependencies: brace-expansion "^2.0.1" +minimatch@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.1.tgz#6c9dffcf9927ff2a31e74b5af11adf8b9604b022" + integrity sha512-362NP+zlprccbEt/SkxKfRMHnNY85V74mVnpUpNyr3F35covl09Kec7/sEFLt3RA4oXmewtoaanoIf67SE5Y5g== + dependencies: + brace-expansion "^2.0.1" + minimist-options@4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz" @@ -4981,6 +4998,11 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-abort-controller@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.0.1.tgz#f91fa50b1dee3f909afabb7e261b1e1d6b0cb74e" + integrity sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw== + node-addon-api@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz" @@ -6224,13 +6246,14 @@ shell-quote@^1.6.1: version "1.7.3" resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz" -shiki@^0.10.1: - version "0.10.1" - resolved "https://registry.npmjs.org/shiki/-/shiki-0.10.1.tgz" +shiki@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.11.1.tgz#df0f719e7ab592c484d8b73ec10e215a503ab8cc" + integrity sha512-EugY9VASFuDqOexOgXR18ZV+TbFrQHeCpEYaXamO+SZlsnT/2LxuLBX25GGtIrwaEVFXUAbUQ601SWE2rMwWHA== dependencies: jsonc-parser "^3.0.0" vscode-oniguruma "^1.6.1" - vscode-textmate "5.2.0" + vscode-textmate "^6.0.0" side-channel@^1.0.4: version "1.0.4" @@ -6812,19 +6835,20 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typedoc@^0.22.2: - version "0.22.15" - resolved "https://registry.npmjs.org/typedoc/-/typedoc-0.22.15.tgz" +typedoc@^0.23: + version "0.23.21" + resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.21.tgz#2a6b0e155f91ffa9689086706ad7e3e4bc11d241" + integrity sha512-VNE9Jv7BgclvyH9moi2mluneSviD43dCE9pY8RWkO88/DrEgJZk9KpUk7WO468c9WWs/+aG6dOnoH7ccjnErhg== dependencies: - glob "^7.2.0" lunr "^2.3.9" - marked "^4.0.12" - minimatch "^5.0.1" - shiki "^0.10.1" + marked "^4.0.19" + minimatch "^5.1.0" + shiki "^0.11.1" -typescript@^4.3.2: - version "4.6.4" - resolved "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz" +typescript@^4.9: + version "4.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.3.tgz#3aea307c1746b8c384435d8ac36b8a2e580d85db" + integrity sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA== uglify-js@^3.1.4: version "3.15.3" @@ -6978,9 +7002,10 @@ vscode-oniguruma@^1.6.1: version "1.6.2" resolved "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.6.2.tgz" -vscode-textmate@5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-5.2.0.tgz" +vscode-textmate@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-6.0.0.tgz#a3777197235036814ac9a92451492f2748589210" + integrity sha512-gu73tuZfJgu+mvCSy4UZwd2JXykjK9zAZsfmDeut5dx/1a7FeTk0XwJsSuqQn+cuMCGVbIBfl+s53X4T19DnzQ== wait-on@6.0.0: version "6.0.0"