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:
parent
0d0a491f27
commit
7646521a6e
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Reference in New Issue