Skip to content

Commit 86efbd9

Browse files
authored
Add tryFetchTx method to RetryTxSender (#70)
1 parent 1c38143 commit 86efbd9

File tree

1 file changed

+183
-2
lines changed

1 file changed

+183
-2
lines changed

packages/solana-v1-contrib/src/retry_tx_sender.ts

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { isNullLike, waitMS } from '@tensor-hq/ts-utils';
1313
import bs58 from 'bs58';
1414
import { backOff } from 'exponential-backoff';
1515
import { getLatestBlockHeight } from './rpc';
16+
import { VersionedTransactionResponse } from '@solana/web3.js';
1617

1718
const BLOCK_TIME_MS = 400;
1819

@@ -58,6 +59,7 @@ export class RetryTxSender {
5859
private start?: number;
5960
private txSig?: TransactionSignature;
6061
private confirmedTx?: ConfirmedTx;
62+
private fetchedTx?: VersionedTransactionResponse;
6163
readonly connection: Connection;
6264
readonly additionalConnections: Connection[];
6365
readonly logger?: Logger;
@@ -93,6 +95,12 @@ export class RetryTxSender {
9395
this.retrySleep = retrySleep;
9496
}
9597

98+
/**
99+
* Send transaction to RPCs and asynchronously retry sending until
100+
* 1. The transaction is confirmed via tryConfirm/tryFetchTx
101+
* 2. The transaction times out
102+
* 3. Confirmation is cancelled via cancelConfirm
103+
*/
96104
async send(
97105
tx: Transaction | VersionedTransaction,
98106
): Promise<TransactionSignature> {
@@ -149,6 +157,26 @@ export class RetryTxSender {
149157
return this.txSig;
150158
}
151159

160+
/**
161+
* Confirm the status of a transaction sent by this sender by
162+
* 1. Polling getSignatureStatus
163+
* 2. Optionally listening for the onSignature WS event
164+
*
165+
* Stops polling once
166+
* 1. The transaction is confirmed
167+
* 2. The transaction times out (via timeout promise or lastValidBlockHeight)
168+
* 3. Confirmation is cancelled via cancelConfirm
169+
*
170+
* Notes:
171+
* * After confirming, subsequent calls will return a cached ConfirmedTx
172+
* * tryConfirm should not be invoked multiple times in parallel
173+
* * tryConfirm should not be invoked in parallel with tryFetchTx
174+
*
175+
* @param lastValidBlockHeight cancel tx confirmation loop once this block height is reached
176+
* @param opts {
177+
* @param disableWs don't listen for onSignature WS events when confirming
178+
* }
179+
*/
152180
async tryConfirm(
153181
lastValidBlockHeight?: number,
154182
opts?: ConfirmOpts,
@@ -162,6 +190,7 @@ export class RetryTxSender {
162190
throw new Error('you need to send the tx first');
163191
}
164192

193+
this.done = false;
165194
try {
166195
const result = await this._confirmTransaction(
167196
this.txSig,
@@ -182,6 +211,56 @@ export class RetryTxSender {
182211
}
183212
}
184213

214+
/**
215+
* Fetch a transaction sent by this sender by polling getTransaction.
216+
*
217+
* Stops polling once
218+
* 1. The transaction is fetched
219+
* 2. The transaction times out (via timeout promise or lastValidBlockHeight)
220+
* 3. Confirmation is cancelled via cancelConfirm
221+
*
222+
* Notes:
223+
* * After confirming, subsequent calls will return a cached tx
224+
* * tryFetchTx should not be invoked multiple times in parallel
225+
* * tryFetchTx should not be invoked in parallel with tryConfirm
226+
*
227+
* @param lastValidBlockHeight cancel tx confirmation loop once this block height is reached
228+
* @param opts {
229+
* @param disableWs don't listen for onSignature WS events when confirming
230+
* }
231+
*/
232+
async tryFetchTx(
233+
lastValidBlockHeight?: number,
234+
): Promise<VersionedTransactionResponse> {
235+
if (this.fetchedTx) {
236+
this.logger?.info('✅ Tx already fetched');
237+
return this.fetchedTx;
238+
}
239+
240+
if (!this.txSig) {
241+
throw new Error('you need to send the tx first');
242+
}
243+
244+
this.done = false;
245+
try {
246+
this.fetchedTx = await this._fetchTransaction(
247+
this.txSig,
248+
lastValidBlockHeight,
249+
);
250+
this.confirmedTx = {
251+
txSig: this.txSig,
252+
slot: this.fetchedTx.slot,
253+
err: this.fetchedTx.meta?.err ?? null,
254+
};
255+
return this.fetchedTx;
256+
} catch (e) {
257+
this.logger?.error(`${JSON.stringify(e)}`);
258+
throw e;
259+
} finally {
260+
this._stopWaiting();
261+
}
262+
}
263+
185264
cancelConfirm() {
186265
if (this.cancelReference.resolve) {
187266
this.cancelReference.resolve();
@@ -204,10 +283,11 @@ export class RetryTxSender {
204283
throw new Error('signature must be base58 encoded: ' + txSig);
205284
}
206285

207-
if (decodedSignature.length !== 64)
286+
if (decodedSignature.length !== 64) {
208287
throw new Error(
209288
`signature has invalid length ${decodedSignature.length} (expected 64)`,
210289
);
290+
}
211291

212292
this.start = Date.now();
213293
const subscriptionCommitment = this.opts.commitment;
@@ -223,7 +303,9 @@ export class RetryTxSender {
223303

224304
const pollPromise = backOff(
225305
async () => {
226-
this.logger?.debug('[getSignatureStatus] Attept to get sig status');
306+
this.logger?.debug(
307+
'[getSignatureStatus] Attempt to get sig status',
308+
);
227309
const { value, context } = await connection.getSignatureStatus(
228310
txSig,
229311
{
@@ -350,6 +432,105 @@ export class RetryTxSender {
350432
return response;
351433
}
352434

435+
private async _fetchTransaction(
436+
txSig: TransactionSignature,
437+
lastValidBlockHeight?: number,
438+
): Promise<VersionedTransactionResponse> {
439+
this.logger?.info(`⏳ [${txSig.substring(0, 5)}] begin trying to fetch tx`);
440+
441+
let decodedSignature: Uint8Array;
442+
try {
443+
decodedSignature = bs58.decode(txSig);
444+
} catch (err) {
445+
throw new Error('signature must be base58 encoded: ' + txSig);
446+
}
447+
448+
if (decodedSignature.length !== 64) {
449+
throw new Error(
450+
`signature has invalid length ${decodedSignature.length} (expected 64)`,
451+
);
452+
}
453+
454+
this.start = Date.now();
455+
const connections = [this.connection, ...this.additionalConnections];
456+
let response: VersionedTransactionResponse | null = null;
457+
458+
const promises = connections.map((connection) =>
459+
backOff(
460+
async () => {
461+
this.logger?.debug('[getTransaction] Attempt to get sig status');
462+
const maybeTx = await connection.getTransaction(txSig, {
463+
commitment: 'confirmed',
464+
maxSupportedTransactionVersion: 0,
465+
});
466+
if (!maybeTx) {
467+
this.logger?.debug(
468+
`[getTransaction] tx ${txSig} not found, try again in ${this.retrySleep}ms`,
469+
);
470+
throw new Error(`tx ${txSig} not found`);
471+
}
472+
return maybeTx;
473+
},
474+
{
475+
maxDelay: this.retrySleep,
476+
startingDelay: this.retrySleep,
477+
numOfAttempts: Math.ceil(this.timeout / this.retrySleep),
478+
retry: (e) => {
479+
if (
480+
typeof e.message === 'string' &&
481+
e.message.endsWith('not found')
482+
) {
483+
this.logger?.info(`sig ${txSig} not found yet, retrying`);
484+
} else {
485+
console.error(`[getTransaction] received error, ${e} retrying`);
486+
}
487+
return !this.done;
488+
},
489+
},
490+
)
491+
.then((res) => {
492+
response = res;
493+
})
494+
.catch((err) => {
495+
this.logger?.error(
496+
`[${txSig.substring(0, 5)}] error polling: ${err}`,
497+
);
498+
}),
499+
);
500+
501+
await this._racePromises(
502+
txSig,
503+
promises,
504+
this.timeout,
505+
lastValidBlockHeight,
506+
);
507+
508+
const duration = (Date.now() - this.start) / 1000;
509+
if (response === null) {
510+
const errMsg = `❌ [${txSig.substring(
511+
0,
512+
5,
513+
)}] NOT confirmed in ${duration.toFixed(2)}sec`;
514+
this.logger?.error(errMsg);
515+
throw new Error(errMsg);
516+
}
517+
518+
if ((<VersionedTransactionResponse>response).meta?.err) {
519+
this.logger?.warn(
520+
`⚠️ [${txSig.substring(
521+
0,
522+
5,
523+
)}] confirmed AS FAILED TX in ${duration.toFixed(2)}sec`,
524+
);
525+
} else {
526+
this.logger?.info(
527+
`✅ [${txSig.substring(0, 5)}] confirmed in ${duration.toFixed(2)}sec`,
528+
);
529+
}
530+
531+
return response;
532+
}
533+
353534
private _getTimestamp(): number {
354535
return new Date().getTime();
355536
}

0 commit comments

Comments
 (0)