Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: you can now abort transaction confirmations in web3.js #29057

Merged
merged 4 commits into from Dec 3, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 3 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 Down Expand Up @@ -129,8 +129,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
78 changes: 78 additions & 0 deletions web3.js/test/connection.test.ts
Expand Up @@ -1167,6 +1167,44 @@ describe('Connection', function () {
});

describe('block height strategy', () => {
it('rejects if called with an already-aborted `abortSignal`', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note: I would have written more test coverage here, to assert that when cancelled, the subscription gets disposed and the nonce account no longer gets pinged. Our test framework, however, just doesn't work. I tried setting up sinon spies, and they never fired. I tested other spies in previous tests and – surprise – they don't work either.

I, for one, can't wait to return to Jest as we rewrite web3.js.

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 +1333,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