Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/transaction-manager/lib/RetryPolicyManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { TransactionReceipt } from "viem"
import type { Attempt, Transaction } from "./Transaction"
import type { TransactionManager } from "./TransactionManager"

export type RevertedTransactionReceipt = TransactionReceipt<bigint, number, "reverted", "eip1559">

/**
* 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<boolean>
}

/**
* 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add docstring that says this retries if running out of gas.

public async shouldRetry(
transactionManager: TransactionManager,
_: Transaction,
attempt: Attempt,
receipt: RevertedTransactionReceipt,
): Promise<boolean> {
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<string | undefined> {
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<boolean> {
const revertReason = await this.getRevertReason(transactionManager, attempt)
Comment thread
norswap marked this conversation as resolved.

if (!revertReason) {
return receipt.gasUsed === attempt.gas
}

return revertReason === "Out of Gas"
}
}
4 changes: 2 additions & 2 deletions packages/transaction-manager/lib/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 11 additions & 11 deletions packages/transaction-manager/lib/TransactionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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<Transaction[]>
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.",
)
}
}

/**
Expand Down Expand Up @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion packages/transaction-manager/lib/TransactionRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down
34 changes: 16 additions & 18 deletions packages/transaction-manager/lib/TxMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -75,6 +76,12 @@ export class TxMonitor {
let isResolved = false
const { promise: receiptPromise, resolve, reject: _reject } = promiseWithResolvers<AttemptWithReceipt>()

/**
* 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<Result<AttemptWithReceipt | null, GetTransactionReceiptErrorType>>[] =
inAirAttempts.map(
async (attempt): Promise<Result<AttemptWithReceipt | null, GetTransactionReceiptErrorType>> => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -248,7 +246,7 @@ export class TxMonitor {
}
}

private async handleOutOfGasTransaction(transaction: Transaction): Promise<void> {
private async handleRetryTransaction(transaction: Transaction): Promise<void> {
const nonce = this.transactionManager.nonceManager.requestNonce()
const { maxFeePerGas: marketMaxFeePerGas, maxPriorityFeePerGas: marketMaxPriorityFeePerGas } =
this.transactionManager.gasPriceOracle.suggestGasForNextBlock()
Expand Down