@@ -13,6 +13,7 @@ import { isNullLike, waitMS } from '@tensor-hq/ts-utils';
13
13
import bs58 from 'bs58' ;
14
14
import { backOff } from 'exponential-backoff' ;
15
15
import { getLatestBlockHeight } from './rpc' ;
16
+ import { VersionedTransactionResponse } from '@solana/web3.js' ;
16
17
17
18
const BLOCK_TIME_MS = 400 ;
18
19
@@ -58,6 +59,7 @@ export class RetryTxSender {
58
59
private start ?: number ;
59
60
private txSig ?: TransactionSignature ;
60
61
private confirmedTx ?: ConfirmedTx ;
62
+ private fetchedTx ?: VersionedTransactionResponse ;
61
63
readonly connection : Connection ;
62
64
readonly additionalConnections : Connection [ ] ;
63
65
readonly logger ?: Logger ;
@@ -93,6 +95,12 @@ export class RetryTxSender {
93
95
this . retrySleep = retrySleep ;
94
96
}
95
97
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
+ */
96
104
async send (
97
105
tx : Transaction | VersionedTransaction ,
98
106
) : Promise < TransactionSignature > {
@@ -149,6 +157,26 @@ export class RetryTxSender {
149
157
return this . txSig ;
150
158
}
151
159
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
+ */
152
180
async tryConfirm (
153
181
lastValidBlockHeight ?: number ,
154
182
opts ?: ConfirmOpts ,
@@ -162,6 +190,7 @@ export class RetryTxSender {
162
190
throw new Error ( 'you need to send the tx first' ) ;
163
191
}
164
192
193
+ this . done = false ;
165
194
try {
166
195
const result = await this . _confirmTransaction (
167
196
this . txSig ,
@@ -182,6 +211,56 @@ export class RetryTxSender {
182
211
}
183
212
}
184
213
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
+
185
264
cancelConfirm ( ) {
186
265
if ( this . cancelReference . resolve ) {
187
266
this . cancelReference . resolve ( ) ;
@@ -204,10 +283,11 @@ export class RetryTxSender {
204
283
throw new Error ( 'signature must be base58 encoded: ' + txSig ) ;
205
284
}
206
285
207
- if ( decodedSignature . length !== 64 )
286
+ if ( decodedSignature . length !== 64 ) {
208
287
throw new Error (
209
288
`signature has invalid length ${ decodedSignature . length } (expected 64)` ,
210
289
) ;
290
+ }
211
291
212
292
this . start = Date . now ( ) ;
213
293
const subscriptionCommitment = this . opts . commitment ;
@@ -223,7 +303,9 @@ export class RetryTxSender {
223
303
224
304
const pollPromise = backOff (
225
305
async ( ) => {
226
- this . logger ?. debug ( '[getSignatureStatus] Attept to get sig status' ) ;
306
+ this . logger ?. debug (
307
+ '[getSignatureStatus] Attempt to get sig status' ,
308
+ ) ;
227
309
const { value, context } = await connection . getSignatureStatus (
228
310
txSig ,
229
311
{
@@ -350,6 +432,105 @@ export class RetryTxSender {
350
432
return response ;
351
433
}
352
434
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
+
353
534
private _getTimestamp ( ) : number {
354
535
return new Date ( ) . getTime ( ) ;
355
536
}
0 commit comments