All files / src/services SmartTxSubmitProvider.ts

100% Statements 23/23
100% Branches 9/9
100% Functions 7/7
100% Lines 23/23

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103  42x                         42x 42x 42x                                           42x 132x 132x 132x 132x           132x 132x 132x 132x           25x   25x   29x 1x             1x                       28x       25x   26x       9x               1x      
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
  Cardano,
  CardanoNodeUtil,
  HealthCheckResponse,
  OutsideOfValidityIntervalData,
  ProviderError,
  ProviderFailure,
  Serialization,
  SubmitTxArgs,
  TxSubmissionError,
  TxSubmissionErrorCode,
  TxSubmitProvider
} from '@cardano-sdk/core';
import { ConnectionStatus, ConnectionStatusTracker } from './util';
import { Observable, combineLatest, filter, firstValueFrom, from, mergeMap, take, tap } from 'rxjs';
import { RetryBackoffConfig, retryBackoff } from 'backoff-rxjs';
 
export interface RetryingTxSubmitProviderProps {
  retryBackoffConfig: RetryBackoffConfig;
}
 
export type TipSlot = Pick<Cardano.Tip, 'slot'>;
 
export interface RetryingTxSubmitProviderDependencies {
  txSubmitProvider: TxSubmitProvider;
  tip$: Observable<TipSlot>;
  connectionStatus$: ConnectionStatusTracker;
}
 
/**
 * Wraps a `TxSubmitProvider` to enchance it's `submitTx` with the following functionality:
 * - Immediately rejects if network tip is already >= `ValidityInterval.invalidHereafter`
 * - Awaits for the following conditions before submitting:
 *   - Network tip is ahead of tx body `ValidityInterval.invalidBefore`.
 *   - Connection status is 'up'.
 * - Re-submits transactions that failed to submit due to connection or recoverable provider issue.
 */
export class SmartTxSubmitProvider implements TxSubmitProvider {
  readonly #txSubmitProvider: TxSubmitProvider;
  readonly #tip$: Observable<TipSlot>;
  readonly #retryBackoffConfig: RetryBackoffConfig;
  readonly #connectionStatus$: ConnectionStatusTracker;
 
  constructor(
    { retryBackoffConfig }: RetryingTxSubmitProviderProps,
    { connectionStatus$, tip$, txSubmitProvider }: RetryingTxSubmitProviderDependencies
  ) {
    this.#txSubmitProvider = txSubmitProvider;
    this.#tip$ = tip$;
    this.#connectionStatus$ = connectionStatus$;
    this.#retryBackoffConfig = retryBackoffConfig;
  }
 
  submitTx(args: SubmitTxArgs): Promise<void> {
    const {
      body: { validityInterval }
    } = Serialization.deserializeTx(args.signedTransaction);
 
    const onlineAndWithinValidityInterval$ = combineLatest([this.#connectionStatus$, this.#tip$]).pipe(
      tap(([_, { slot }]) => {
        if (slot >= (validityInterval?.invalidHereafter || Number.POSITIVE_INFINITY)) {
          const data: OutsideOfValidityIntervalData = {
            currentSlot: slot,
            validityInterval: {
              invalidBefore: validityInterval?.invalidBefore,
              invalidHereafter: validityInterval?.invalidHereafter
            }
          };
          throw new ProviderError(
            ProviderFailure.BadRequest,
            new TxSubmissionError(
              TxSubmissionErrorCode.OutsideOfValidityInterval,
              data,
              'Not submitting transaction due to validity interval'
            )
          );
        }
      }),
      filter(
        ([connectionStatus, { slot }]) =>
          connectionStatus === ConnectionStatus.up && slot >= (validityInterval?.invalidBefore || 0)
      ),
      take(1)
    );
    return firstValueFrom(
      onlineAndWithinValidityInterval$.pipe(
        mergeMap(() => from(this.#txSubmitProvider.submitTx(args))),
        retryBackoff({
          ...this.#retryBackoffConfig,
          shouldRetry: (error) =>
            CardanoNodeUtil.isProviderError(error) &&
            [ProviderFailure.Unhealthy, ProviderFailure.ConnectionFailure].includes(error.reason)
        })
      )
    );
  }
 
  healthCheck(): Promise<HealthCheckResponse> {
    return this.#txSubmitProvider.healthCheck();
  }
}