From 7646521a6e59869d96d31640671709adfab9bd42 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Mon, 28 Nov 2022 22:46:27 -0800 Subject: [PATCH] feat: a nonce-based transaction confirmation strategy for web3.js (#25839) * feat: you can now construct a `Transaction` with durable nonce information * chore: refactor confirmation logic so that each strategy gets its own method * feat: `geNonce` now accepts a `minContextSlot` param * feat: a nonce-based transaction confirmation strategy * feat: add nonce confirmation strategy to send-and-confirm helpers * fix: nits from July 8 review * Use Typescript narrowing to determine which strategy to use * Double check the signature confirmation against the slot in which the nonce was discovered to have advanced --- web3.js/src/connection.ts | 396 ++++++++-- web3.js/src/nonce-account.ts | 10 +- .../src/transaction/expiry-custom-errors.ts | 13 + web3.js/src/transaction/legacy.ts | 42 +- .../utils/send-and-confirm-raw-transaction.ts | 13 + .../src/utils/send-and-confirm-transaction.ts | 57 +- web3.js/test/connection.test.ts | 728 +++++++++++++++--- web3.js/test/mocks/rpc-http.ts | 4 +- web3.js/test/transaction.test.ts | 22 + 9 files changed, 1076 insertions(+), 209 deletions(-) diff --git a/web3.js/src/connection.ts b/web3.js/src/connection.ts index d6457567d8770a..6c2762bc2a84af 100644 --- a/web3.js/src/connection.ts +++ b/web3.js/src/connection.ts @@ -28,7 +28,7 @@ import {AgentManager} from './agent-manager'; import {EpochSchedule} from './epoch-schedule'; import {SendTransactionError, SolanaJSONRPCError} from './errors'; import fetchImpl, {Response} from './fetch-impl'; -import {NonceAccount} from './nonce-account'; +import {DurableNonce, NonceAccount} from './nonce-account'; import {PublicKey} from './publickey'; import {Signer} from './keypair'; import {MS_PER_SLOT} from './timing'; @@ -45,6 +45,7 @@ import {sleep} from './utils/sleep'; import {toBuffer} from './utils/to-buffer'; import { TransactionExpiredBlockheightExceededError, + TransactionExpiredNonceInvalidError, TransactionExpiredTimeoutError, } from './transaction/expiry-custom-errors'; import {makeWebsocketUrl} from './utils/makeWebsocketUrl'; @@ -338,6 +339,28 @@ 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 */ @@ -2438,6 +2461,26 @@ export type GetTransactionCountConfig = { minContextSlot?: number; }; +/** + * Configuration object for `getNonce` + */ +export type GetNonceConfig = { + /** Optional commitment level */ + commitment?: Commitment; + /** The minimum slot that the request can be evaluated at */ + minContextSlot?: number; +}; + +/** + * Configuration object for `getNonceAndContext` + */ +export type GetNonceAndContextConfig = { + /** Optional commitment level */ + commitment?: Commitment; + /** The minimum slot that the request can be evaluated at */ + minContextSlot?: number; +}; + /** * Information describing an account */ @@ -3348,7 +3391,9 @@ export class Connection { } confirmTransaction( - strategy: BlockheightBasedTransactionConfirmationStrategy, + strategy: + | BlockheightBasedTransactionConfirmationStrategy + | DurableNonceTransactionConfirmationStrategy, commitment?: Commitment, ): Promise>; @@ -3363,6 +3408,7 @@ export class Connection { async confirmTransaction( strategy: | BlockheightBasedTransactionConfirmationStrategy + | DurableNonceTransactionConfirmationStrategy | TransactionSignature, commitment?: Commitment, ): Promise> { @@ -3371,8 +3417,9 @@ export class Connection { if (typeof strategy == 'string') { rawSignature = strategy; } else { - const config = - strategy as BlockheightBasedTransactionConfirmationStrategy; + const config = strategy as + | BlockheightBasedTransactionConfirmationStrategy + | DurableNonceTransactionConfirmationStrategy; rawSignature = config.signature; } @@ -3386,31 +3433,58 @@ export class Connection { assert(decodedSignature.length === 64, 'signature has invalid length'); - const confirmationCommitment = commitment || this.commitment; - let timeoutId; + if (typeof strategy === 'string') { + return await this.confirmTransactionUsingLegacyTimeoutStrategy({ + commitment: commitment || this.commitment, + signature: rawSignature, + }); + } else if ('lastValidBlockHeight' in strategy) { + return await this.confirmTransactionUsingBlockHeightExceedanceStrategy({ + commitment: commitment || this.commitment, + strategy, + }); + } else { + return await this.confirmTransactionUsingDurableNonceStrategy({ + commitment: commitment || this.commitment, + strategy, + }); + } + } + + private getTransactionConfirmationPromise({ + commitment, + signature, + }: { + commitment?: Commitment; + signature: string; + }): { + abortConfirmation(): void; + confirmationPromise: Promise<{ + __type: TransactionStatus.PROCESSED; + response: RpcResponseAndContext; + }>; + } { let signatureSubscriptionId: number | undefined; let disposeSignatureSubscriptionStateChangeObserver: | SubscriptionStateChangeDisposeFn | undefined; let done = false; - const confirmationPromise = new Promise<{ __type: TransactionStatus.PROCESSED; response: RpcResponseAndContext; }>((resolve, reject) => { try { signatureSubscriptionId = this.onSignature( - rawSignature, + signature, (result: SignatureResult, context: Context) => { signatureSubscriptionId = undefined; const response = { context, value: result, }; - done = true; resolve({__type: TransactionStatus.PROCESSED, response}); }, - confirmationCommitment, + commitment, ); const subscriptionSetupPromise = new Promise( resolveSubscriptionSetup => { @@ -3432,7 +3506,7 @@ export class Connection { (async () => { await subscriptionSetupPromise; if (done) return; - const response = await this.getSignatureStatus(rawSignature); + const response = await this.getSignatureStatus(signature); if (done) return; if (response == null) { return; @@ -3444,7 +3518,7 @@ export class Connection { if (value?.err) { reject(value.err); } else { - switch (confirmationCommitment) { + switch (commitment) { case 'confirmed': case 'single': case 'singleGossip': { @@ -3482,80 +3556,250 @@ export class Connection { reject(err); } }); + const abortConfirmation = () => { + if (disposeSignatureSubscriptionStateChangeObserver) { + disposeSignatureSubscriptionStateChangeObserver(); + disposeSignatureSubscriptionStateChangeObserver = undefined; + } + if (signatureSubscriptionId) { + this.removeSignatureListener(signatureSubscriptionId); + signatureSubscriptionId = undefined; + } + }; + return {abortConfirmation, confirmationPromise}; + } - const expiryPromise = new Promise< - | {__type: TransactionStatus.BLOCKHEIGHT_EXCEEDED} - | {__type: TransactionStatus.TIMED_OUT; timeoutMs: number} - >(resolve => { - if (typeof strategy === 'string') { - let timeoutMs = this._confirmTransactionInitialTimeout || 60 * 1000; - switch (confirmationCommitment) { - case 'processed': - case 'recent': - case 'single': - case 'confirmed': - case 'singleGossip': { - timeoutMs = this._confirmTransactionInitialTimeout || 30 * 1000; - break; - } - // exhaust enums to ensure full coverage - case 'finalized': - case 'max': - case 'root': + private async confirmTransactionUsingBlockHeightExceedanceStrategy({ + commitment, + strategy: {lastValidBlockHeight, signature}, + }: { + commitment?: Commitment; + strategy: BlockheightBasedTransactionConfirmationStrategy; + }) { + let done: boolean = false; + const expiryPromise = new Promise<{ + __type: TransactionStatus.BLOCKHEIGHT_EXCEEDED; + }>(resolve => { + const checkBlockHeight = async () => { + try { + const blockHeight = await this.getBlockHeight(commitment); + return blockHeight; + } catch (_e) { + return -1; } - - timeoutId = setTimeout( - () => resolve({__type: TransactionStatus.TIMED_OUT, timeoutMs}), - timeoutMs, - ); + }; + (async () => { + let currentBlockHeight = await checkBlockHeight(); + if (done) return; + while (currentBlockHeight <= lastValidBlockHeight) { + await sleep(1000); + if (done) return; + currentBlockHeight = await checkBlockHeight(); + if (done) return; + } + resolve({__type: TransactionStatus.BLOCKHEIGHT_EXCEEDED}); + })(); + }); + const {abortConfirmation, confirmationPromise} = + this.getTransactionConfirmationPromise({commitment, signature}); + let result: RpcResponseAndContext; + try { + const outcome = await Promise.race([confirmationPromise, expiryPromise]); + if (outcome.__type === TransactionStatus.PROCESSED) { + result = outcome.response; } else { - let config = - strategy as BlockheightBasedTransactionConfirmationStrategy; - const checkBlockHeight = async () => { - try { - const blockHeight = await this.getBlockHeight(commitment); - return blockHeight; - } catch (_e) { - return -1; + throw new TransactionExpiredBlockheightExceededError(signature); + } + } finally { + done = true; + abortConfirmation(); + } + return result; + } + + private async confirmTransactionUsingDurableNonceStrategy({ + commitment, + strategy: {minContextSlot, nonceAccountPubkey, nonceValue, signature}, + }: { + commitment?: Commitment; + strategy: DurableNonceTransactionConfirmationStrategy; + }) { + let done: boolean = false; + const expiryPromise = new Promise<{ + __type: TransactionStatus.NONCE_INVALID; + slotInWhichNonceDidAdvance: number | null; + }>(resolve => { + let currentNonceValue: string | undefined = nonceValue; + let lastCheckedSlot: number | null = null; + const getCurrentNonceValue = async () => { + try { + const {context, value: nonceAccount} = await this.getNonceAndContext( + nonceAccountPubkey, + { + commitment, + minContextSlot, + }, + ); + lastCheckedSlot = context.slot; + return nonceAccount?.nonce; + } catch (e) { + // If for whatever reason we can't reach/read the nonce + // account, just keep using the last-known value. + return currentNonceValue; + } + }; + (async () => { + currentNonceValue = await getCurrentNonceValue(); + if (done) return; + while ( + true // eslint-disable-line no-constant-condition + ) { + if (nonceValue !== currentNonceValue) { + resolve({ + __type: TransactionStatus.NONCE_INVALID, + slotInWhichNonceDidAdvance: lastCheckedSlot, + }); + return; } - }; - (async () => { - let currentBlockHeight = await checkBlockHeight(); + await sleep(2000); if (done) return; - while (currentBlockHeight <= config.lastValidBlockHeight) { - await sleep(1000); - if (done) return; - currentBlockHeight = await checkBlockHeight(); - if (done) return; - } - resolve({__type: TransactionStatus.BLOCKHEIGHT_EXCEEDED}); - })(); - } + currentNonceValue = await getCurrentNonceValue(); + if (done) return; + } + })(); }); - + const {abortConfirmation, confirmationPromise} = + this.getTransactionConfirmationPromise({commitment, signature}); let result: RpcResponseAndContext; try { const outcome = await Promise.race([confirmationPromise, expiryPromise]); - switch (outcome.__type) { - case TransactionStatus.BLOCKHEIGHT_EXCEEDED: - throw new TransactionExpiredBlockheightExceededError(rawSignature); - case TransactionStatus.PROCESSED: - result = outcome.response; + if (outcome.__type === TransactionStatus.PROCESSED) { + result = outcome.response; + } else { + // Double check that the transaction is indeed unconfirmed. + let signatureStatus: + | RpcResponseAndContext + | null + | undefined; + while ( + true // eslint-disable-line no-constant-condition + ) { + const status = await this.getSignatureStatus(signature); + if (status == null) { + break; + } + if ( + status.context.slot < + (outcome.slotInWhichNonceDidAdvance ?? minContextSlot) + ) { + await sleep(400); + continue; + } + signatureStatus = status; break; - case TransactionStatus.TIMED_OUT: - throw new TransactionExpiredTimeoutError( - rawSignature, - outcome.timeoutMs / 1000, - ); + } + if (signatureStatus?.value) { + const commitmentForStatus = commitment || 'finalized'; + const {confirmationStatus} = signatureStatus.value; + switch (commitmentForStatus) { + case 'processed': + case 'recent': + if ( + confirmationStatus !== 'processed' && + confirmationStatus !== 'confirmed' && + confirmationStatus !== 'finalized' + ) { + throw new TransactionExpiredNonceInvalidError(signature); + } + break; + case 'confirmed': + case 'single': + case 'singleGossip': + if ( + confirmationStatus !== 'confirmed' && + confirmationStatus !== 'finalized' + ) { + throw new TransactionExpiredNonceInvalidError(signature); + } + break; + case 'finalized': + case 'max': + case 'root': + if (confirmationStatus !== 'finalized') { + throw new TransactionExpiredNonceInvalidError(signature); + } + break; + default: + // Exhaustive switch. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ((_: never) => {})(commitmentForStatus); + } + result = { + context: signatureStatus.context, + value: {err: signatureStatus.value.err}, + }; + } else { + throw new TransactionExpiredNonceInvalidError(signature); + } } } finally { - clearTimeout(timeoutId); - if (disposeSignatureSubscriptionStateChangeObserver) { - disposeSignatureSubscriptionStateChangeObserver(); + done = true; + abortConfirmation(); + } + return result; + } + + private async confirmTransactionUsingLegacyTimeoutStrategy({ + commitment, + signature, + }: { + commitment?: Commitment; + signature: string; + }) { + let timeoutId; + const expiryPromise = new Promise<{ + __type: TransactionStatus.TIMED_OUT; + timeoutMs: number; + }>(resolve => { + let timeoutMs = this._confirmTransactionInitialTimeout || 60 * 1000; + switch (commitment) { + case 'processed': + case 'recent': + case 'single': + case 'confirmed': + case 'singleGossip': { + timeoutMs = this._confirmTransactionInitialTimeout || 30 * 1000; + break; + } + // exhaust enums to ensure full coverage + case 'finalized': + case 'max': + case 'root': } - if (signatureSubscriptionId) { - this.removeSignatureListener(signatureSubscriptionId); + timeoutId = setTimeout( + () => resolve({__type: TransactionStatus.TIMED_OUT, timeoutMs}), + timeoutMs, + ); + }); + const {abortConfirmation, confirmationPromise} = + this.getTransactionConfirmationPromise({ + commitment, + signature, + }); + let result: RpcResponseAndContext; + try { + const outcome = await Promise.race([confirmationPromise, expiryPromise]); + if (outcome.__type === TransactionStatus.PROCESSED) { + result = outcome.response; + } else { + throw new TransactionExpiredTimeoutError( + signature, + outcome.timeoutMs / 1000, + ); } + } finally { + clearTimeout(timeoutId); + abortConfirmation(); } return result; } @@ -4708,11 +4952,11 @@ export class Connection { */ async getNonceAndContext( nonceAccount: PublicKey, - commitment?: Commitment, + commitmentOrConfig?: Commitment | GetNonceAndContextConfig, ): Promise> { const {context, value: accountInfo} = await this.getAccountInfoAndContext( nonceAccount, - commitment, + commitmentOrConfig, ); let value = null; @@ -4731,9 +4975,9 @@ export class Connection { */ async getNonce( nonceAccount: PublicKey, - commitment?: Commitment, + commitmentOrConfig?: Commitment | GetNonceConfig, ): Promise { - return await this.getNonceAndContext(nonceAccount, commitment) + return await this.getNonceAndContext(nonceAccount, commitmentOrConfig) .then(x => x.value) .catch(e => { throw new Error( diff --git a/web3.js/src/nonce-account.ts b/web3.js/src/nonce-account.ts index f7b5cb625be239..a09d506b7556fb 100644 --- a/web3.js/src/nonce-account.ts +++ b/web3.js/src/nonce-account.ts @@ -1,7 +1,6 @@ import * as BufferLayout from '@solana/buffer-layout'; import {Buffer} from 'buffer'; -import type {Blockhash} from './blockhash'; import * as Layout from './layout'; import {PublicKey} from './publickey'; import type {FeeCalculator} from './fee-calculator'; @@ -36,9 +35,14 @@ const NonceAccountLayout = BufferLayout.struct< export const NONCE_ACCOUNT_LENGTH = NonceAccountLayout.span; +/** + * A durable nonce is a 32 byte value encoded as a base58 string. + */ +export type DurableNonce = string; + type NonceAccountArgs = { authorizedPubkey: PublicKey; - nonce: Blockhash; + nonce: DurableNonce; feeCalculator: FeeCalculator; }; @@ -47,7 +51,7 @@ type NonceAccountArgs = { */ export class NonceAccount { authorizedPubkey: PublicKey; - nonce: Blockhash; + nonce: DurableNonce; feeCalculator: FeeCalculator; /** diff --git a/web3.js/src/transaction/expiry-custom-errors.ts b/web3.js/src/transaction/expiry-custom-errors.ts index 24c72824f63e6a..78c5f4dfe24c82 100644 --- a/web3.js/src/transaction/expiry-custom-errors.ts +++ b/web3.js/src/transaction/expiry-custom-errors.ts @@ -33,3 +33,16 @@ export class TransactionExpiredTimeoutError extends Error { Object.defineProperty(TransactionExpiredTimeoutError.prototype, 'name', { value: 'TransactionExpiredTimeoutError', }); + +export class TransactionExpiredNonceInvalidError extends Error { + signature: string; + + constructor(signature: string) { + super(`Signature ${signature} has expired: the nonce is no longer valid.`); + this.signature = signature; + } +} + +Object.defineProperty(TransactionExpiredNonceInvalidError.prototype, 'name', { + value: 'TransactionExpiredNonceInvalidError', +}); diff --git a/web3.js/src/transaction/legacy.ts b/web3.js/src/transaction/legacy.ts index 802a1a2ad0058b..f8871fa0678297 100644 --- a/web3.js/src/transaction/legacy.ts +++ b/web3.js/src/transaction/legacy.ts @@ -22,6 +22,7 @@ export const enum TransactionStatus { BLOCKHEIGHT_EXCEEDED, PROCESSED, TIMED_OUT, + NONCE_INVALID, } /** @@ -145,7 +146,9 @@ export type TransactionCtorFields_DEPRECATED = { export type TransactionCtorFields = TransactionCtorFields_DEPRECATED; /** - * List of Transaction object fields that may be initialized at construction + * Blockhash-based transactions have a lifetime that are defined by + * the blockhash they include. Any transaction whose blockhash is + * too old will be rejected. */ export type TransactionBlockhashCtor = { /** The transaction fee payer */ @@ -158,6 +161,18 @@ export type TransactionBlockhashCtor = { lastValidBlockHeight: number; }; +/** + * Use these options to construct a durable nonce transaction. + */ +export type TransactionNonceCtor = { + /** The transaction fee payer */ + feePayer?: PublicKey | null; + minContextSlot: number; + nonceInfo: NonceInformation; + /** One or more signatures */ + signatures?: Array; +}; + /** * Nonce information to be used to build an offline Transaction. */ @@ -228,6 +243,15 @@ export class Transaction { */ nonceInfo?: NonceInformation; + /** + * If this is a nonce transaction this represents the minimum slot from which + * to evaluate if the nonce has advanced when attempting to confirm the + * transaction. This protects against a case where the transaction confirmation + * logic loads the nonce account from an old slot and assumes the mismatch in + * nonce value implies that the nonce has been advanced. + */ + minNonceContextSlot?: number; + /** * @internal */ @@ -241,6 +265,9 @@ export class Transaction { // Construct a transaction with a blockhash and lastValidBlockHeight constructor(opts?: TransactionBlockhashCtor); + // Construct a transaction using a durable nonce + constructor(opts?: TransactionNonceCtor); + /** * @deprecated `TransactionCtorFields` has been deprecated and will be removed in a future version. * Please supply a `TransactionBlockhashCtor` instead. @@ -251,7 +278,10 @@ export class Transaction { * Construct an empty Transaction */ constructor( - opts?: TransactionBlockhashCtor | TransactionCtorFields_DEPRECATED, + opts?: + | TransactionBlockhashCtor + | TransactionNonceCtor + | TransactionCtorFields_DEPRECATED, ) { if (!opts) { return; @@ -262,7 +292,13 @@ export class Transaction { if (opts.signatures) { this.signatures = opts.signatures; } - if (Object.prototype.hasOwnProperty.call(opts, 'lastValidBlockHeight')) { + if (Object.prototype.hasOwnProperty.call(opts, 'nonceInfo')) { + const {minContextSlot, nonceInfo} = opts as TransactionNonceCtor; + this.minNonceContextSlot = minContextSlot; + this.nonceInfo = nonceInfo; + } else if ( + Object.prototype.hasOwnProperty.call(opts, 'lastValidBlockHeight') + ) { const {blockhash, lastValidBlockHeight} = opts as TransactionBlockhashCtor; this.recentBlockhash = blockhash; diff --git a/web3.js/src/utils/send-and-confirm-raw-transaction.ts b/web3.js/src/utils/send-and-confirm-raw-transaction.ts index 1b5c98fc98f503..b841cd39c4ca16 100644 --- a/web3.js/src/utils/send-and-confirm-raw-transaction.ts +++ b/web3.js/src/utils/send-and-confirm-raw-transaction.ts @@ -3,6 +3,7 @@ import type {Buffer} from 'buffer'; import { BlockheightBasedTransactionConfirmationStrategy, Connection, + DurableNonceTransactionConfirmationStrategy, } from '../connection'; import type {TransactionSignature} from '../transaction'; import type {ConfirmOptions} from '../connection'; @@ -42,12 +43,14 @@ export async function sendAndConfirmRawTransaction( rawTransaction: Buffer, confirmationStrategyOrConfirmOptions: | BlockheightBasedTransactionConfirmationStrategy + | DurableNonceTransactionConfirmationStrategy | ConfirmOptions | undefined, maybeConfirmOptions?: ConfirmOptions, ): Promise { let confirmationStrategy: | BlockheightBasedTransactionConfirmationStrategy + | DurableNonceTransactionConfirmationStrategy | undefined; let options: ConfirmOptions | undefined; if ( @@ -60,6 +63,16 @@ export async function sendAndConfirmRawTransaction( confirmationStrategy = confirmationStrategyOrConfirmOptions as BlockheightBasedTransactionConfirmationStrategy; options = maybeConfirmOptions; + } else if ( + confirmationStrategyOrConfirmOptions && + Object.prototype.hasOwnProperty.call( + confirmationStrategyOrConfirmOptions, + 'nonceValue', + ) + ) { + confirmationStrategy = + confirmationStrategyOrConfirmOptions as DurableNonceTransactionConfirmationStrategy; + options = maybeConfirmOptions; } else { options = confirmationStrategyOrConfirmOptions as | ConfirmOptions diff --git a/web3.js/src/utils/send-and-confirm-transaction.ts b/web3.js/src/utils/send-and-confirm-transaction.ts index 46abc5002a8b62..0e28e4a2d6d59a 100644 --- a/web3.js/src/utils/send-and-confirm-transaction.ts +++ b/web3.js/src/utils/send-and-confirm-transaction.ts @@ -1,4 +1,4 @@ -import {Connection} from '../connection'; +import {Connection, SignatureResult} from '../connection'; import {Transaction} from '../transaction'; import type {ConfirmOptions} from '../connection'; import type {Signer} from '../keypair'; @@ -34,25 +34,46 @@ export async function sendAndConfirmTransaction( sendOptions, ); - const status = + let status: SignatureResult; + if ( transaction.recentBlockhash != null && transaction.lastValidBlockHeight != null - ? ( - await connection.confirmTransaction( - { - signature: signature, - blockhash: transaction.recentBlockhash, - lastValidBlockHeight: transaction.lastValidBlockHeight, - }, - options && options.commitment, - ) - ).value - : ( - await connection.confirmTransaction( - signature, - options && options.commitment, - ) - ).value; + ) { + status = ( + await connection.confirmTransaction( + { + signature: signature, + blockhash: transaction.recentBlockhash, + lastValidBlockHeight: transaction.lastValidBlockHeight, + }, + options && options.commitment, + ) + ).value; + } else if ( + transaction.minNonceContextSlot != null && + transaction.nonceInfo != null + ) { + const {nonceInstruction} = transaction.nonceInfo; + const nonceAccountPubkey = nonceInstruction.keys[0].pubkey; + status = ( + await connection.confirmTransaction( + { + minContextSlot: transaction.minNonceContextSlot, + nonceAccountPubkey, + nonceValue: transaction.nonceInfo.nonce, + signature, + }, + options && options.commitment, + ) + ).value; + } else { + status = ( + await connection.confirmTransaction( + signature, + options && options.commitment, + ) + ).value; + } if (status.err) { throw new Error( diff --git a/web3.js/test/connection.test.ts b/web3.js/test/connection.test.ts index dd333a778bcb56..4d2e5b0b174e7a 100644 --- a/web3.js/test/connection.test.ts +++ b/web3.js/test/connection.test.ts @@ -21,6 +21,7 @@ import { Message, AddressLookupTableProgram, SYSTEM_INSTRUCTION_LAYOUTS, + NONCE_ACCOUNT_LENGTH, } from '../src'; import invariant from '../src/utils/assert'; import {MOCK_PORT, url} from './url'; @@ -53,9 +54,11 @@ import { mockRpcMessage, } from './mocks/rpc-websockets'; import { + NonceInformation, TransactionInstruction, TransactionSignature, TransactionExpiredBlockheightExceededError, + TransactionExpiredNonceInvalidError, TransactionExpiredTimeoutError, } from '../src/transaction'; import type { @@ -70,6 +73,33 @@ import {encodeData} from '../src/instruction'; use(chaiAsPromised); use(sinonChai); +async function mockNonceAccountResponse( + nonceAccountPubkey: string, + nonceValue: string, + nonceAuthority: string, + slot?: number, +) { + const mockNonceAccountData = Buffer.alloc(NONCE_ACCOUNT_LENGTH); + mockNonceAccountData.fill(0); + // Authority starts after 4 version bytes and 4 state bytes. + mockNonceAccountData.set(bs58.decode(nonceAuthority), 4 + 4); + // Nonce hash starts 32 bytes after the authority. + mockNonceAccountData.set(bs58.decode(nonceValue), 4 + 4 + 32); + await mockRpcResponse({ + method: 'getAccountInfo', + params: [nonceAccountPubkey, {encoding: 'base64'}], + value: { + owner: SystemProgram.programId.toBase58(), + lamports: LAMPORTS_PER_SOL, + data: [mockNonceAccountData.toString('base64'), 'base64'], + executable: false, + rentEpoch: 20, + }, + slot, + withContext: true, + }); +} + const verifySignatureStatus = ( status: SignatureStatus | null, err?: TransactionError, @@ -977,6 +1007,128 @@ describe('Connection', function () { ); }).timeout(60 * 1000); }); + + describe('nonce-based transaction confirmation', () => { + let keypair: Keypair; + let minContextSlot: number; + let nonceInfo: NonceInformation; + let nonceKeypair: Keypair; + let transaction: Transaction; + + beforeEach(async function () { + this.timeout(60 * 1000); + keypair = Keypair.generate(); + nonceKeypair = Keypair.generate(); + const [ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _, + blockhash, + minimumNonceAccountRentLamports, + ] = await Promise.all([ + connection.confirmTransaction( + await connection.requestAirdrop( + keypair.publicKey, + LAMPORTS_PER_SOL, + ), + ), + helpers.latestBlockhash({connection}), + connection.getMinimumBalanceForRentExemption(NONCE_ACCOUNT_LENGTH), + ]); + const createNonceAccountTransaction = + SystemProgram.createNonceAccount({ + authorizedPubkey: keypair.publicKey, + fromPubkey: keypair.publicKey, + lamports: minimumNonceAccountRentLamports, + noncePubkey: nonceKeypair.publicKey, + }); + createNonceAccountTransaction.recentBlockhash = blockhash.blockhash; + createNonceAccountTransaction.feePayer = keypair.publicKey; + const createNonceAccountTransactionSignature = + await connection.sendTransaction(createNonceAccountTransaction, [ + keypair, + nonceKeypair, + ]); + const {context} = await connection.confirmTransaction({ + ...blockhash, + signature: createNonceAccountTransactionSignature, + }); + minContextSlot = context.slot; + const nonceAccount = await connection.getNonce( + nonceKeypair.publicKey, + {minContextSlot}, + ); + nonceInfo = { + nonce: nonceAccount!.nonce, + nonceInstruction: SystemProgram.nonceAdvance({ + authorizedPubkey: keypair.publicKey, + noncePubkey: nonceKeypair.publicKey, + }), + }; + invariant( + nonceAccount, + 'Expected a nonce account to have been created in the test setup', + ); + const ix = new TransactionInstruction({ + keys: [ + { + pubkey: keypair.publicKey, + isSigner: true, + isWritable: true, + }, + ], + programId: new PublicKey( + 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr', + ), + data: Buffer.from('Hello world', 'utf8'), + }); + transaction = new Transaction({minContextSlot, nonceInfo}); + transaction.add(ix); + transaction.sign(keypair); + }); + + it('confirms transactions using the durable nonce strategy', async () => { + const signature = await connection.sendTransaction(transaction, [ + keypair, + ]); + const result = await connection.confirmTransaction( + { + minContextSlot, + nonceAccountPubkey: nonceKeypair.publicKey, + nonceValue: nonceInfo.nonce, + signature, + }, + 'processed', + ); + expect(result.value).to.have.property('err', null); + }).timeout(60 * 1000); + + it('throws when confirming using a nonce that is no longer valid', async () => { + // Advance the nonce. + const blockhash = await connection.getLatestBlockhash(); + await sendAndConfirmTransaction( + connection, + new Transaction({feePayer: keypair.publicKey, ...blockhash}).add( + nonceInfo.nonceInstruction, + ), + [keypair], + ); + const [currentSlot, signature] = await Promise.all([ + connection.getSlot(), + connection.sendTransaction(transaction, [keypair], { + skipPreflight: true, + }), + ]); + const confirmationPromise = connection.confirmTransaction({ + minContextSlot: currentSlot, + nonceAccountPubkey: nonceKeypair.publicKey, + nonceValue: nonceInfo.nonce, // The old nonce. + signature, + }); + await expect(confirmationPromise).to.eventually.be.rejectedWith( + TransactionExpiredNonceInvalidError, + ); + }).timeout(60 * 1000); + }); }); } @@ -991,148 +1143,508 @@ describe('Connection', function () { clock.restore(); }); - it('confirm transaction - timeout expired', async () => { - const mockSignature = - 'w2Zeq8YkpyB463DttvfzARD7k9ZxGEwbsEw4boEK7jDp3pfoxZbTdLFSsEPhzXhpCcjGi2kHtHFobgX49MMhbWt'; + describe('timeout strategy (deprecated)', () => { + it('throws a `TransactionExpiredTimeoutError` when the timer elapses without a signature confirmation', async () => { + const mockSignature = + 'w2Zeq8YkpyB463DttvfzARD7k9ZxGEwbsEw4boEK7jDp3pfoxZbTdLFSsEPhzXhpCcjGi2kHtHFobgX49MMhbWt'; - await mockRpcMessage({ - method: 'signatureSubscribe', - params: [mockSignature, {commitment: 'finalized'}], - result: new Promise(() => {}), - }); - const timeoutPromise = connection.confirmTransaction(mockSignature); + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(() => {}), + }); + const timeoutPromise = connection.confirmTransaction(mockSignature); - // Advance the clock past all waiting timers, notably the expiry timer. - clock.runAllAsync(); + // Advance the clock past all waiting timers, notably the expiry timer. + clock.runAllAsync(); - await expect(timeoutPromise).to.be.rejectedWith( - TransactionExpiredTimeoutError, - ); + await expect(timeoutPromise).to.be.rejectedWith( + TransactionExpiredTimeoutError, + ); + }); }); - it('confirm transaction - block height exceeded', async () => { - const mockSignature = - '4oCEqwGrMdBeMxpzuWiukCYqSfV4DsSKXSiVVCh1iJ6pS772X7y219JZP3mgqBz5PhsvprpKyhzChjYc3VSBQXzG'; + describe('block height strategy', () => { + 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'; - await mockRpcMessage({ - method: 'signatureSubscribe', - params: [mockSignature, {commitment: 'finalized'}], - result: new Promise(() => {}), // Never resolve this = never get a response. - }); + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(() => {}), // Never resolve this = never get a response. + }); - const lastValidBlockHeight = 3; + const lastValidBlockHeight = 3; - // Start the block height at `lastValidBlockHeight - 1`. - await mockRpcResponse({ - method: 'getBlockHeight', - params: [], - value: lastValidBlockHeight - 1, - }); + // Start the block height at `lastValidBlockHeight - 1`. + await mockRpcResponse({ + method: 'getBlockHeight', + params: [], + value: lastValidBlockHeight - 1, + }); - const confirmationPromise = connection.confirmTransaction({ - signature: mockSignature, - blockhash: 'sampleBlockhash', - lastValidBlockHeight, + const confirmationPromise = connection.confirmTransaction({ + signature: mockSignature, + blockhash: 'sampleBlockhash', + lastValidBlockHeight, + }); + clock.runAllAsync(); + + // Advance the block height to the `lastValidBlockHeight`. + await mockRpcResponse({ + method: 'getBlockHeight', + params: [], + value: lastValidBlockHeight, + }); + clock.runAllAsync(); + + // Advance the block height to `lastValidBlockHeight + 1`, + // past the last valid blockheight for this transaction. + await mockRpcResponse({ + method: 'getBlockHeight', + params: [], + value: lastValidBlockHeight + 1, + }); + clock.runAllAsync(); + await expect(confirmationPromise).to.be.rejectedWith( + TransactionExpiredBlockheightExceededError, + ); }); - clock.runAllAsync(); - // Advance the block height to the `lastValidBlockHeight`. - await mockRpcResponse({ - method: 'getBlockHeight', - params: [], - value: lastValidBlockHeight, + it('when the `getBlockHeight` method throws an error it does not timeout but rather keeps waiting for a confirmation', async () => { + const mockSignature = + 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; + + let resolveResultPromise: (result: SignatureResult) => void; + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(resolve => { + resolveResultPromise = resolve; + }), + }); + + // Simulate a failure to fetch the block height. + let rejectBlockheightPromise: () => void; + await mockRpcResponse({ + method: 'getBlockHeight', + params: [], + value: (() => { + const p = new Promise((_, reject) => { + rejectBlockheightPromise = reject; + }); + p.catch(() => {}); + return p; + })(), + }); + + const confirmationPromise = connection.confirmTransaction({ + signature: mockSignature, + blockhash: 'sampleBlockhash', + lastValidBlockHeight: 3, + }); + + rejectBlockheightPromise(); + clock.runToLastAsync(); + resolveResultPromise({err: null}); + clock.runToLastAsync(); + + expect(confirmationPromise).not.to.eventually.be.rejected; }); - clock.runAllAsync(); - // Advance the block height to `lastValidBlockHeight + 1`, - // past the last valid blockheight for this transaction. - await mockRpcResponse({ - method: 'getBlockHeight', - params: [], - value: lastValidBlockHeight + 1, + it('confirms the transaction if the signature confirmation is received before the block height is exceeded', async () => { + const mockSignature = + 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; + + let resolveResultPromise: (result: SignatureResult) => void; + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(resolve => { + resolveResultPromise = resolve; + }), + }); + + const lastValidBlockHeight = 3; + + // Advance the block height to the `lastValidBlockHeight`. + await mockRpcResponse({ + method: 'getBlockHeight', + params: [], + value: lastValidBlockHeight, + }); + + const confirmationPromise = connection.confirmTransaction({ + signature: mockSignature, + blockhash: 'sampleBlockhash', + lastValidBlockHeight, + }); + clock.runAllAsync(); + + // Return a signature result in the nick of time. + resolveResultPromise({err: null}); + + await expect(confirmationPromise).to.eventually.deep.equal({ + context: {slot: 11}, + value: {err: null}, + }); }); - clock.runAllAsync(); - await expect(confirmationPromise).to.be.rejectedWith( - TransactionExpiredBlockheightExceededError, - ); }); - it('when the `getBlockHeight` method throws an error it does not timeout but rather keeps waiting for a confirmation', async () => { - const mockSignature = - 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; + describe('nonce strategy', () => { + it('confirms the transaction if the signature confirmation is received before the nonce is advanced', async () => { + const mockSignature = + '4oCEqwGrMdBeMxpzuWiukCYqSfV4DsSKXSiVVCh1iJ6pS772X7y219JZP3mgqBz5PhsvprpKyhzChjYc3VSBQXzG'; + + let resolveResultPromise: (result: SignatureResult) => void; + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(resolve => { + resolveResultPromise = resolve; + }), + }); - let resolveResultPromise: (result: SignatureResult) => void; - await mockRpcMessage({ - method: 'signatureSubscribe', - params: [mockSignature, {commitment: 'finalized'}], - result: new Promise(resolve => { - resolveResultPromise = resolve; - }), + const nonceAccountPubkey = new PublicKey(1); + const nonceValue = new PublicKey(2).toBase58(); + const authority = new PublicKey(3); + + // Start with the nonce account matching the nonce used to sign the transaction. + await mockNonceAccountResponse( + nonceAccountPubkey.toBase58(), + nonceValue, + authority.toBase58(), + ); + + const confirmationPromise = connection.confirmTransaction({ + minContextSlot: 0, + nonceAccountPubkey, + nonceValue, + signature: mockSignature, + }); + clock.runAllAsync(); + + // Respond, a second time, with the same nonce hash. + await mockNonceAccountResponse( + nonceAccountPubkey.toBase58(), + nonceValue, + authority.toBase58(), + ); + clock.runAllAsync(); + + // Return a signature result in the nick of time. + resolveResultPromise({err: null}); + + await expect(confirmationPromise).to.eventually.deep.equal({ + context: {slot: 11}, + value: {err: null}, + }); }); - // Simulate a failure to fetch the block height. - let rejectBlockheightPromise: () => void; - await mockRpcResponse({ - method: 'getBlockHeight', - params: [], - value: (() => { - const p = new Promise((_, reject) => { - rejectBlockheightPromise = reject; - }); - p.catch(() => {}); - return p; - })(), + it('succeeds if double-checking the signature after the nonce-advances demonstrates that the transaction is confirmed', async () => { + const mockSignature = + 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; + + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(() => {}), // Never resolve this = never get a response. + }); + + const nonceAccountPubkey = new PublicKey(1); + const nonceValue = new PublicKey(2).toBase58(); + const authority = new PublicKey(3); + + const confirmationPromise = connection.confirmTransaction({ + minContextSlot: 0, + nonceAccountPubkey, + nonceValue, + signature: mockSignature, + }); + + // Simulate the nonce advancing but the double-check of the signature status succeeding. + await mockNonceAccountResponse( + nonceAccountPubkey.toBase58(), + new PublicKey(4).toBase58(), // A new nonce. + authority.toBase58(), + ); + await mockRpcResponse({ + method: 'getSignatureStatuses', + params: [[mockSignature]], + value: [ + { + err: null, + confirmations: 0, + confirmationStatus: 'finalized', // Demonstrate that the transaction is, in fact, confirmed. + slot: 0, + }, + ], + withContext: true, + }); + clock.runToLastAsync(); + + await expect(confirmationPromise).to.eventually.deep.equal({ + context: {slot: 11}, + value: {err: null}, + }); }); - const confirmationPromise = connection.confirmTransaction({ - signature: mockSignature, - blockhash: 'sampleBlockhash', - lastValidBlockHeight: 3, + it('keeps double-checking the signature after the nonce-advances until a signature from the minimum allowable slot is obtained', async () => { + const mockSignature = + 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; + + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(() => {}), // Never resolve this = never get a response. + }); + + const nonceAccountPubkey = new PublicKey(1); + const nonceValue = new PublicKey(2).toBase58(); + const authority = new PublicKey(3); + + const confirmationPromise = connection.confirmTransaction({ + minContextSlot: 11, + nonceAccountPubkey, + nonceValue, + signature: mockSignature, + }); + + // Simulate the nonce advancing but the double-check of the signature status succeeding. + await mockNonceAccountResponse( + nonceAccountPubkey.toBase58(), + new PublicKey(4).toBase58(), // A new nonce. + authority.toBase58(), + ); + + // Simulate getting a response from an old slot. + await mockRpcResponse({ + method: 'getSignatureStatuses', + params: [[mockSignature]], + value: [ + { + err: null, + confirmations: 0, + confirmationStatus: 'processed', // A non-finalized value from an old slot. + slot: 10, + }, + ], + slot: 10, + withContext: true, + }); + + // Then obtain a response from the minimum allowable slot. + await mockRpcResponse({ + method: 'getSignatureStatuses', + params: [[mockSignature]], + value: [ + { + err: null, + confirmations: 32, + confirmationStatus: 'finalized', // Demonstrate that the transaction is, in fact, confirmed. + slot: 11, + }, + ], + slot: 11, + withContext: true, + }); + clock.runAllAsync(); + + await expect(confirmationPromise).to.eventually.deep.equal({ + context: {slot: 11}, + value: {err: null}, + }); }); - rejectBlockheightPromise(); - clock.runToLastAsync(); - resolveResultPromise({err: null}); - clock.runToLastAsync(); + it('throws a `TransactionExpiredNonceInvalidError` when the nonce is no longer the one with which this transaction was signed', async () => { + const mockSignature = + 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; - expect(confirmationPromise).not.to.eventually.be.rejected; - }); + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(() => {}), // Never resolve this = never get a response. + }); - it('confirm transaction - block height confirmed', async () => { - const mockSignature = - 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; + const nonceAccountPubkey = new PublicKey(1); + const nonceValue = new PublicKey(2).toBase58(); + const authority = new PublicKey(3); - let resolveResultPromise: (result: SignatureResult) => void; - await mockRpcMessage({ - method: 'signatureSubscribe', - params: [mockSignature, {commitment: 'finalized'}], - result: new Promise(resolve => { - resolveResultPromise = resolve; - }), + const confirmationPromise = connection.confirmTransaction({ + minContextSlot: 0, + nonceAccountPubkey, + nonceValue, + signature: mockSignature, + }); + + // Simulate the nonce advancing but the double-check of the signature status succeeding. + await mockNonceAccountResponse( + nonceAccountPubkey.toBase58(), + new PublicKey(4).toBase58(), // A new nonce. + authority.toBase58(), + ); + await mockRpcResponse({ + method: 'getSignatureStatuses', + params: [[mockSignature]], + value: [ + { + err: null, + confirmations: 0, + confirmationStatus: 'processed', // Demonstrate that the transaction is, in fact, not confirmed. + slot: 0, + }, + ], + withContext: true, + }); + clock.runToLastAsync(); + + await expect(confirmationPromise).to.eventually.be.rejectedWith( + TransactionExpiredNonceInvalidError, + ); }); - const lastValidBlockHeight = 3; + it('when fetching the nonce account throws an error it does not timeout but rather keeps waiting for a confirmation', async () => { + const mockSignature = + 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; - // Advance the block height to the `lastValidBlockHeight`. - await mockRpcResponse({ - method: 'getBlockHeight', - params: [], - value: lastValidBlockHeight, + let resolveResultPromise: (result: SignatureResult) => void; + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(resolve => { + resolveResultPromise = resolve; + }), + }); + + // Simulate a failure to fetch the nonce account. + let rejectNonceAccountFetchPromise: () => void; + await mockRpcResponse({ + method: 'getAccountInfo', + params: [], + value: (() => { + const p = new Promise((_, reject) => { + rejectNonceAccountFetchPromise = reject; + }); + p.catch(() => {}); + return p; + })(), + }); + + const nonceAccountPubkey = new PublicKey(1); + const nonceValue = new PublicKey(2).toBase58(); + + const confirmationPromise = connection.confirmTransaction({ + minContextSlot: 0, + nonceAccountPubkey, + nonceValue, + signature: mockSignature, + }); + + rejectNonceAccountFetchPromise(); + clock.runToLastAsync(); + resolveResultPromise({err: null}); + clock.runToLastAsync(); + + await expect(confirmationPromise).to.eventually.deep.equal({ + context: {slot: 11}, + value: {err: null}, + }); }); - const confirmationPromise = connection.confirmTransaction({ - signature: mockSignature, - blockhash: 'sampleBlockhash', - lastValidBlockHeight, + it('throws `TransactionExpiredNonceInvalidError` when the nonce account does not exist', async () => { + const mockSignature = + 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; + + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(() => {}), // Never resolve this = never get a response. + }); + + const nonceAccountPubkey = new PublicKey(1); + const nonceValue = new PublicKey(2).toBase58(); + + const confirmationPromise = connection.confirmTransaction({ + minContextSlot: 0, + nonceAccountPubkey, + nonceValue, + signature: mockSignature, + }); + + // Simulate a non-existent nonce account. + await mockRpcResponse({ + method: 'getAccountInfo', + params: [], + value: null, + withContext: true, + }); + clock.runToLastAsync(); + await mockRpcResponse({ + method: 'getSignatureStatuses', + params: [[mockSignature]], + value: [ + { + err: null, + confirmations: 0, + confirmationStatus: 'processed', // Demonstrate that the transaction is, in fact, not confirmed. + slot: 0, + }, + ], + withContext: true, + }); + clock.runToLastAsync(); + + await expect(confirmationPromise).to.eventually.be.rejectedWith( + TransactionExpiredNonceInvalidError, + ); }); - clock.runAllAsync(); - // Return a signature result in the nick of time. - resolveResultPromise({err: null}); + it('when the nonce account data fails to deserialize', async () => { + const mockSignature = + 'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC'; - await expect(confirmationPromise).to.eventually.deep.equal({ - context: {slot: 11}, - value: {err: null}, + let resolveResultPromise: (result: SignatureResult) => void; + await mockRpcMessage({ + method: 'signatureSubscribe', + params: [mockSignature, {commitment: 'finalized'}], + result: new Promise(resolve => { + resolveResultPromise = resolve; + }), + }); + + const nonceAccountPubkey = new PublicKey(1); + const nonceValue = new PublicKey(2).toBase58(); + + // Simulate a failure to deserialize the nonce. + await mockRpcResponse({ + method: 'getAccountInfo', + params: [nonceAccountPubkey.toBase58(), {encoding: 'base64'}], + value: { + owner: SystemProgram.programId.toBase58(), + lamports: LAMPORTS_PER_SOL, + data: ['JUNK_DATA', 'base64'], + executable: false, + rentEpoch: 20, + }, + withContext: true, + }); + + const confirmationPromise = connection.confirmTransaction({ + minContextSlot: 0, + nonceAccountPubkey, + nonceValue, + signature: mockSignature, + }); + clock.runToLastAsync(); + + resolveResultPromise({err: null}); + clock.runToLastAsync(); + + await expect(confirmationPromise).to.eventually.deep.equal({ + context: {slot: 11}, + value: {err: null}, + }); }); }); diff --git a/web3.js/test/mocks/rpc-http.ts b/web3.js/test/mocks/rpc-http.ts index a56148fec3a6a5..5156201efbaa7f 100644 --- a/web3.js/test/mocks/rpc-http.ts +++ b/web3.js/test/mocks/rpc-http.ts @@ -71,6 +71,7 @@ export const mockRpcResponse = async ({ params, value, error, + slot, withContext, withHeaders, }: { @@ -78,6 +79,7 @@ export const mockRpcResponse = async ({ params: Array; value?: Promise | any; error?: any; + slot?: number; withContext?: boolean; withHeaders?: HttpHeaders; }) => { @@ -98,7 +100,7 @@ export const mockRpcResponse = async ({ if (withContext) { result = { context: { - slot: 11, + slot: slot != null ? slot : 11, }, value: unwrappedValue, }; diff --git a/web3.js/test/transaction.test.ts b/web3.js/test/transaction.test.ts index 6fec2cd8e14af8..d9639a028b1d67 100644 --- a/web3.js/test/transaction.test.ts +++ b/web3.js/test/transaction.test.ts @@ -732,6 +732,28 @@ describe('Transaction', () => { expect(transaction.lastValidBlockHeight).to.eq(lastValidBlockHeight); }); + it('constructs a transaction with nonce information', () => { + const nonceAuthority = new PublicKey(1); + const nonceAccountPubkey = new PublicKey(2); + const nonceValue = 'EETubP5AKHgjPAhzPAFcb8BAY1hMH639CWCFTqi3hq1k'; + const nonceInfo = { + nonce: nonceValue, + nonceInstruction: SystemProgram.nonceAdvance({ + noncePubkey: nonceAccountPubkey, + authorizedPubkey: nonceAuthority, + }), + }; + const minContextSlot = 1234; + const transaction = new Transaction({ + nonceInfo, + minContextSlot, + }); + expect(transaction.recentBlockhash).to.be.undefined; + expect(transaction.lastValidBlockHeight).to.be.undefined; + expect(transaction.minNonceContextSlot).to.eq(minContextSlot); + expect(transaction.nonceInfo).to.eq(nonceInfo); + }); + it('constructs a transaction with only a recent blockhash', () => { const recentBlockhash = 'EETubP5AKHgjPAhzPAFcb8BAY1hMH639CWCFTqi3hq1k'; const transaction = new Transaction({