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
This commit is contained in:
Steven Luscher 2022-12-02 16:36:09 -08:00 committed by GitHub
parent 9725a4552e
commit 35f3c18aa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 213 additions and 53 deletions

View File

@ -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"

View File

@ -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<TConfig>(
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<never> {
return new Promise<never>((_, 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<SignatureResult>;
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<SignatureResult>;
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 {

View File

@ -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 */

View File

@ -19,7 +19,11 @@ export async function sendAndConfirmTransaction(
connection: Connection,
transaction: Transaction,
signers: Array<Signer>,
options?: ConfirmOptions,
options?: ConfirmOptions &
Readonly<{
// A signal that, when aborted, cancels any outstanding transaction confirmation operations
abortSignal?: AbortSignal;
}>,
): Promise<TransactionSignature> {
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,

View File

@ -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';

View File

@ -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"