diff --git a/packages/transaction-manager/lib/RetryPolicyManager.ts b/packages/transaction-manager/lib/RetryPolicyManager.ts new file mode 100644 index 0000000000..f1a140aaf4 --- /dev/null +++ b/packages/transaction-manager/lib/RetryPolicyManager.ts @@ -0,0 +1,72 @@ +import type { TransactionReceipt } from "viem" +import type { Attempt, Transaction } from "./Transaction" +import type { TransactionManager } from "./TransactionManager" + +export type RevertedTransactionReceipt = TransactionReceipt + +/** + * Implement this interface and provide it in the {@link TransactionManager} constructor to define your custom retry policy. + * The default implementation is {@link DefaultRetryPolicyManager}. + * The default implementation will only retry if the transaction runs out of gas. + **/ +export interface RetryPolicyManager { + shouldRetry( + transactionManager: TransactionManager, + transaction: Transaction, + attempt: Attempt, + receipt: RevertedTransactionReceipt, + ): Promise +} + +/** + * This is the default retry policy manager that will we used if no custom retry policy manager is provided. + * It will only retry if the transaction runs out of gas. + */ +export class DefaultRetryPolicyManager implements RetryPolicyManager { + public async shouldRetry( + transactionManager: TransactionManager, + _: Transaction, + attempt: Attempt, + receipt: RevertedTransactionReceipt, + ): Promise { + return this.isOutOfGas(transactionManager, attempt, receipt) + } + + /** + * Retrieves the reason for transaction reversion by utilizing the debug_traceTransaction RPC method. + * Returns undefined if the request fails or if the transaction has not been reverted. + * @param transactionManager - The transaction manager + * @param attempt - The attempt + * @returns The revert reason or undefined if it cannot be retrieved or the rpc does not allow debug + */ + protected async getRevertReason( + transactionManager: TransactionManager, + attempt: Attempt, + ): Promise { + const traceResult = transactionManager.rpcAllowDebug + ? await transactionManager.viemClient.safeDebugTransaction(attempt.hash, { + tracer: "callTracer", + }) + : undefined + + if (!traceResult || traceResult.isErr()) { + return undefined + } + + return traceResult.value.revertReason + } + + protected async isOutOfGas( + transactionManager: TransactionManager, + attempt: Attempt, + receipt: RevertedTransactionReceipt, + ): Promise { + const revertReason = await this.getRevertReason(transactionManager, attempt) + + if (!revertReason) { + return receipt.gasUsed === attempt.gas + } + + return revertReason === "Out of Gas" + } +} diff --git a/packages/transaction-manager/lib/Transaction.ts b/packages/transaction-manager/lib/Transaction.ts index 67ca68c35f..48befbd9d8 100644 --- a/packages/transaction-manager/lib/Transaction.ts +++ b/packages/transaction-manager/lib/Transaction.ts @@ -157,8 +157,8 @@ export class Transaction { this.createdAt = createdAt ?? new Date() this.updatedAt = updatedAt ?? new Date() this.metadata = metadata ?? {} - this.pendingFlush = pendingFlush === undefined ? true : pendingFlush - this.notPersisted = notPersisted === undefined ? true : notPersisted + this.pendingFlush = pendingFlush ?? true + this.notPersisted = notPersisted ?? true } addAttempt(attempt: Attempt): void { diff --git a/packages/transaction-manager/lib/TransactionManager.ts b/packages/transaction-manager/lib/TransactionManager.ts index 7914899dc1..3e48e6e812 100644 --- a/packages/transaction-manager/lib/TransactionManager.ts +++ b/packages/transaction-manager/lib/TransactionManager.ts @@ -23,6 +23,7 @@ import { DefaultGasLimitEstimator, type GasEstimator } from "./GasEstimator.js" import { GasPriceOracle } from "./GasPriceOracle.js" import { HookManager, type TxmHookHandler, type TxmHookType } from "./HookManager.js" import { NonceManager } from "./NonceManager.js" +import { DefaultRetryPolicyManager, type RetryPolicyManager } from "./RetryPolicyManager.js" import { Transaction, type TransactionConstructorConfig } from "./Transaction.js" import { TransactionCollector } from "./TransactionCollector.js" import { TransactionRepository } from "./TransactionRepository.js" @@ -42,19 +43,16 @@ export type TransactionManagerConfig = { url: string /** * The timeout for the RPC node. - * It is very important that the value of (timeout + retryDelay) * retries be less than the time block to avoid slowing down the transaction manager. * Defaults to 500 milliseconds. */ timeout?: number /** * The number of retries for the RPC node. - * It is very important that the value of (timeout + retryDelay) * retries be less than the time block to avoid slowing down the transaction manager. * Defaults to 2. */ retries?: number /** * The delay between retries. - * It is very important that the value of (timeout + retryDelay) * retries be less than the time block to avoid slowing down the transaction manager. * Defaults to 50 milliseconds. */ retryDelay?: number @@ -119,6 +117,14 @@ export type TransactionManagerConfig = { * Default: {@link DefaultGasLimitEstimator} */ gasEstimator?: GasEstimator + + /** + * The retry policy manager to use for retrying failed transactions. + * You can provide your own implementation to override the default one. + * This is used to determine if a transaction should be retried based on the receipt of the transaction when it reverts. + * Default: {@link DefaultRetryPolicyManager} + */ + retryPolicyManager?: RetryPolicyManager } export type TransactionOriginator = (block: LatestBlock) => Promise @@ -144,6 +150,7 @@ export class TransactionManager { public readonly transactionCollector: TransactionCollector public readonly transactionSubmitter: TransactionSubmitter public readonly hookManager: HookManager + public readonly retryPolicyManager: RetryPolicyManager public readonly chainId: number public readonly eip1559: EIP1559Parameters @@ -228,6 +235,7 @@ export class TransactionManager { this.transactionCollector = new TransactionCollector(this) this.transactionSubmitter = new TransactionSubmitter(this) this.hookManager = new HookManager() + this.retryPolicyManager = _config.retryPolicyManager || new DefaultRetryPolicyManager() this.chainId = _config.chainId this.eip1559 = _config.eip1559 || opStackDefaultEIP1559Parameters @@ -239,13 +247,6 @@ export class TransactionManager { this.rpcAllowDebug = _config.rpc.allowDebug || false this.blockTime = _config.blockTime || 2n this.finalizedTransactionPurgeTime = _config.finalizedTransactionPurgeTime || 2 * 60 * 1000 - - const timePerRetry = timeout + retryDelay - if (timePerRetry * retries > this.blockTime * 1000n) { - console.warn( - "The value of (timeout + retryDelay) * retries is greater than the time block. This could slow down the transaction manager.", - ) - } } /** @@ -305,7 +306,6 @@ export class TransactionManager { if (rpcChainId.value !== this.chainId) { const errorMessage = `The chain ID of the RPC node (${rpcChainId.value}) does not match the chain ID of the transaction manager (${this.chainId}).` - console.error(errorMessage) throw new Error(errorMessage) } diff --git a/packages/transaction-manager/lib/TransactionRepository.ts b/packages/transaction-manager/lib/TransactionRepository.ts index a47a8a1e5d..6106609f97 100644 --- a/packages/transaction-manager/lib/TransactionRepository.ts +++ b/packages/transaction-manager/lib/TransactionRepository.ts @@ -82,10 +82,10 @@ export class TransactionRepository { ) if (result.isOk()) { - this.notFinalizedTransactions.push(...notPersistedTransactions) this.notFinalizedTransactions = this.notFinalizedTransactions.filter((transaction) => NotFinalizedStatuses.includes(transaction.status), ) + this.notFinalizedTransactions.push(...notPersistedTransactions) transactions.forEach((t) => t.markFlushed()) } diff --git a/packages/transaction-manager/lib/TxMonitor.ts b/packages/transaction-manager/lib/TxMonitor.ts index 0d68ea205f..5cc922f457 100644 --- a/packages/transaction-manager/lib/TxMonitor.ts +++ b/packages/transaction-manager/lib/TxMonitor.ts @@ -3,6 +3,7 @@ import { type Result, ResultAsync, err, ok } from "neverthrow" import { type GetTransactionReceiptErrorType, type TransactionReceipt, TransactionReceiptNotFoundError } from "viem" import type { LatestBlock } from "./BlockMonitor.js" import { Topics, eventBus } from "./EventBus.js" +import type { RevertedTransactionReceipt } from "./RetryPolicyManager" import { type Attempt, AttemptType, type Transaction, TransactionStatus } from "./Transaction.js" import type { TransactionManager } from "./TransactionManager.js" @@ -75,6 +76,12 @@ export class TxMonitor { let isResolved = false const { promise: receiptPromise, resolve, reject: _reject } = promiseWithResolvers() + /** + * We request receipts for all attempts in parallel. Since only one attempt can have a receipt due to all of them sharing the same nonce, + * we can terminate the process as soon as a receipt is obtained. This is why we use a {@link Promise.race} + * between a promise that resolves on the receipt and all individual promises that resolve when + * the call returns (with {@link TransactionReceiptNotFoundError}). + */ const promises: Promise>[] = inAirAttempts.map( async (attempt): Promise> => { @@ -123,28 +130,19 @@ export class TxMonitor { return transaction.changeStatus(TransactionStatus.Success) } - const traceResult = this.transactionManager.rpcAllowDebug - ? await this.transactionManager.viemClient.safeDebugTransaction(attempt.hash, { - tracer: "callTracer", - }) - : undefined + const shouldRetry = await this.transactionManager.retryPolicyManager.shouldRetry( + this.transactionManager, + transaction, + attempt, + receipt as RevertedTransactionReceipt, + ) - if (!traceResult || traceResult.isErr()) { - if (receipt.gasUsed === attempt.gas) { - return await this.handleOutOfGasTransaction(transaction) - } + if (!shouldRetry) { console.error(`Transaction ${transaction.intentId} failed`) return transaction.changeStatus(TransactionStatus.Failed) } - const trace = traceResult.value - - if (trace.revertReason === "Out of Gas") { - await this.handleOutOfGasTransaction(transaction) - } else { - console.error(`Transaction ${transaction.intentId} failed with reason: ${trace.revertReason}`) - return transaction.changeStatus(TransactionStatus.Failed) - } + return this.handleRetryTransaction(transaction) }) await Promise.all(promises) @@ -248,7 +246,7 @@ export class TxMonitor { } } - private async handleOutOfGasTransaction(transaction: Transaction): Promise { + private async handleRetryTransaction(transaction: Transaction): Promise { const nonce = this.transactionManager.nonceManager.requestNonce() const { maxFeePerGas: marketMaxFeePerGas, maxPriorityFeePerGas: marketMaxPriorityFeePerGas } = this.transactionManager.gasPriceOracle.suggestGasForNextBlock()