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
This commit is contained in:
Steven Luscher 2022-11-28 22:46:27 -08:00 committed by GitHub
parent 0d0a491f27
commit 7646521a6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1085 additions and 218 deletions

View File

@ -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<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
*/
@ -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<RpcResponseAndContext<SignatureResult>>;
@ -3363,6 +3408,7 @@ export class Connection {
async confirmTransaction(
strategy:
| BlockheightBasedTransactionConfirmationStrategy
| DurableNonceTransactionConfirmationStrategy
| TransactionSignature,
commitment?: Commitment,
): Promise<RpcResponseAndContext<SignatureResult>> {
@ -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<SignatureResult>;
}>;
} {
let signatureSubscriptionId: number | undefined;
let disposeSignatureSubscriptionStateChangeObserver:
| SubscriptionStateChangeDisposeFn
| undefined;
let done = false;
const confirmationPromise = new Promise<{
__type: TransactionStatus.PROCESSED;
response: RpcResponseAndContext<SignatureResult>;
}>((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<void>(
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 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':
}
timeoutId = setTimeout(
() => resolve({__type: TransactionStatus.TIMED_OUT, timeoutMs}),
timeoutMs,
);
} else {
let config =
strategy as BlockheightBasedTransactionConfirmationStrategy;
const checkBlockHeight = async () => {
try {
const blockHeight = await this.getBlockHeight(commitment);
return blockHeight;
} catch (_e) {
return -1;
}
};
(async () => {
let currentBlockHeight = await checkBlockHeight();
if (done) return;
while (currentBlockHeight <= config.lastValidBlockHeight) {
await sleep(1000);
if (done) return;
currentBlockHeight = await checkBlockHeight();
if (done) return;
}
resolve({__type: TransactionStatus.BLOCKHEIGHT_EXCEEDED});
})();
}
});
let result: RpcResponseAndContext<SignatureResult>;
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;
break;
case TransactionStatus.TIMED_OUT:
throw new TransactionExpiredTimeoutError(
rawSignature,
outcome.timeoutMs / 1000,
);
}
} finally {
clearTimeout(timeoutId);
const abortConfirmation = () => {
if (disposeSignatureSubscriptionStateChangeObserver) {
disposeSignatureSubscriptionStateChangeObserver();
disposeSignatureSubscriptionStateChangeObserver = undefined;
}
if (signatureSubscriptionId) {
this.removeSignatureListener(signatureSubscriptionId);
signatureSubscriptionId = undefined;
}
};
return {abortConfirmation, confirmationPromise};
}
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;
}
};
(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<SignatureResult>;
try {
const outcome = await Promise.race([confirmationPromise, expiryPromise]);
if (outcome.__type === TransactionStatus.PROCESSED) {
result = outcome.response;
} else {
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;
}
await sleep(2000);
if (done) return;
currentNonceValue = await getCurrentNonceValue();
if (done) return;
}
})();
});
const {abortConfirmation, confirmationPromise} =
this.getTransactionConfirmationPromise({commitment, signature});
let result: RpcResponseAndContext<SignatureResult>;
try {
const outcome = await Promise.race([confirmationPromise, expiryPromise]);
if (outcome.__type === TransactionStatus.PROCESSED) {
result = outcome.response;
} else {
// Double check that the transaction is indeed unconfirmed.
let signatureStatus:
| RpcResponseAndContext<SignatureStatus | null>
| 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;
}
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 {
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':
}
timeoutId = setTimeout(
() => resolve({__type: TransactionStatus.TIMED_OUT, timeoutMs}),
timeoutMs,
);
});
const {abortConfirmation, confirmationPromise} =
this.getTransactionConfirmationPromise({
commitment,
signature,
});
let result: RpcResponseAndContext<SignatureResult>;
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<RpcResponseAndContext<NonceAccount | null>> {
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<NonceAccount | null> {
return await this.getNonceAndContext(nonceAccount, commitment)
return await this.getNonceAndContext(nonceAccount, commitmentOrConfig)
.then(x => x.value)
.catch(e => {
throw new Error(

View File

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

View File

@ -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',
});

View File

@ -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<SignaturePubkeyPair>;
};
/**
* 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;

View File

@ -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<TransactionSignature> {
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

View File

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

View File

@ -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(() => {}),
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();
await expect(timeoutPromise).to.be.rejectedWith(
TransactionExpiredTimeoutError,
);
});
const timeoutPromise = connection.confirmTransaction(mockSignature);
// Advance the clock past all waiting timers, notably the expiry timer.
clock.runAllAsync();
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;
// Start the block height at `lastValidBlockHeight - 1`.
await mockRpcResponse({
method: 'getBlockHeight',
params: [],
value: lastValidBlockHeight - 1,
});
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,
);
});
const lastValidBlockHeight = 3;
it('when the `getBlockHeight` method throws an error it does not timeout but rather keeps waiting for a confirmation', async () => {
const mockSignature =
'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC';
// Start the block height at `lastValidBlockHeight - 1`.
await mockRpcResponse({
method: 'getBlockHeight',
params: [],
value: lastValidBlockHeight - 1,
let resolveResultPromise: (result: SignatureResult) => void;
await mockRpcMessage({
method: 'signatureSubscribe',
params: [mockSignature, {commitment: 'finalized'}],
result: new Promise<SignatureResult>(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;
});
const confirmationPromise = connection.confirmTransaction({
signature: mockSignature,
blockhash: 'sampleBlockhash',
lastValidBlockHeight,
});
clock.runAllAsync();
it('confirms the transaction if the signature confirmation is received before the block height is exceeded', async () => {
const mockSignature =
'LPJ18iiyfz3G1LpNNbcBnBtaS4dVBdPHKrnELqikjER2DcvB4iyTgz43nKQJH3JQAJHuZdM1xVh5Cnc5Hc7LrqC';
// Advance the block height to the `lastValidBlockHeight`.
await mockRpcResponse({
method: 'getBlockHeight',
params: [],
value: lastValidBlockHeight,
});
clock.runAllAsync();
let resolveResultPromise: (result: SignatureResult) => void;
await mockRpcMessage({
method: 'signatureSubscribe',
params: [mockSignature, {commitment: 'finalized'}],
result: new Promise<SignatureResult>(resolve => {
resolveResultPromise = resolve;
}),
});
// Advance the block height to `lastValidBlockHeight + 1`,
// past the last valid blockheight for this transaction.
await mockRpcResponse({
method: 'getBlockHeight',
params: [],
value: lastValidBlockHeight + 1,
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<SignatureResult>(resolve => {
resolveResultPromise = resolve;
}),
let resolveResultPromise: (result: SignatureResult) => void;
await mockRpcMessage({
method: 'signatureSubscribe',
params: [mockSignature, {commitment: 'finalized'}],
result: new Promise<SignatureResult>(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<SignatureResult>(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<SignatureResult>(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<SignatureResult>(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},
});
});
});

View File

@ -71,6 +71,7 @@ export const mockRpcResponse = async ({
params,
value,
error,
slot,
withContext,
withHeaders,
}: {
@ -78,6 +79,7 @@ export const mockRpcResponse = async ({
params: Array<any>;
value?: Promise<any> | 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,
};

View File

@ -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({