Skip to content

Commit

Permalink
feat: you can now abort transaction confirmations in web3.js (solana-…
Browse files Browse the repository at this point in the history
…labs#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
  • Loading branch information
steveluscher authored and nickfrosty committed Jan 4, 2023
1 parent d72cf22 commit c06420e
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 53 deletions.
7 changes: 4 additions & 3 deletions web3.js/package.json
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
Expand Down
98 changes: 70 additions & 28 deletions web3.js/src/connection.ts
Expand Up @@ -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) {
Expand All @@ -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
*/
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3722,7 +3748,7 @@ export class Connection {

private async confirmTransactionUsingBlockHeightExceedanceStrategy({
commitment,
strategy: {lastValidBlockHeight, signature},
strategy: {abortSignal, lastValidBlockHeight, signature},
}: {
commitment?: Commitment;
strategy: BlockheightBasedTransactionConfirmationStrategy;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion web3.js/src/epoch-schedule.ts
Expand Up @@ -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 */
Expand Down
15 changes: 14 additions & 1 deletion web3.js/src/utils/send-and-confirm-transaction.ts
Expand Up @@ -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,
Expand All @@ -42,6 +46,7 @@ export async function sendAndConfirmTransaction(
status = (
await connection.confirmTransaction(
{
abortSignal: options?.abortSignal,
signature: signature,
blockhash: transaction.recentBlockhash,
lastValidBlockHeight: transaction.lastValidBlockHeight,
Expand All @@ -58,6 +63,7 @@ export async function sendAndConfirmTransaction(
status = (
await connection.confirmTransaction(
{
abortSignal: options?.abortSignal,
minContextSlot: transaction.minNonceContextSlot,
nonceAccountPubkey,
nonceValue: transaction.nonceInfo.nonce,
Expand All @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions web3.js/test/connection.test.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down

0 comments on commit c06420e

Please sign in to comment.