diff --git a/src/server/schemas/transaction/delay.ts b/src/server/schemas/transaction/delay.ts new file mode 100644 index 00000000..400a20dd --- /dev/null +++ b/src/server/schemas/transaction/delay.ts @@ -0,0 +1,23 @@ +import { Type } from "@sinclair/typebox"; + +const BaseDelaySchema = Type.Object({ + timestamp: Type.String({ + description: "ISO timestamp when the delay occurred", + }), +}); + +// Specific delay schemas with their required properties +const GasDelaySchema = Type.Intersect([ + BaseDelaySchema, + Type.Object({ + reason: Type.Literal("max_fee_per_gas_too_low"), + requestedMaxFeePerGas: Type.String({ + description: "maxFeePerGas requested by the user", + }), + currentMaxFeePerGas: Type.String({ + description: "maxFeePerGas on chain", + }), + }), +]); + +export const TransactionDelaySchema = Type.Union([GasDelaySchema]); diff --git a/src/server/schemas/transaction/index.ts b/src/server/schemas/transaction/index.ts index ff82934f..cbbdd84b 100644 --- a/src/server/schemas/transaction/index.ts +++ b/src/server/schemas/transaction/index.ts @@ -3,6 +3,7 @@ import type { Hex } from "thirdweb"; import { stringify } from "thirdweb/utils"; import type { AnyTransaction } from "../../../shared/utils/transaction/types"; import { AddressSchema, TransactionHashSchema } from "../address"; +import { TransactionDelaySchema } from "./delay"; export const TransactionSchema = Type.Object({ queueId: Type.Union([ @@ -220,6 +221,10 @@ export const TransactionSchema = Type.Object({ ), Type.Null(), ]), + delays: Type.Array(TransactionDelaySchema, { + description: + "Array of deliberate transaction processing delays delays initiated by the worker due to user specified conditions", + }), }); export const toTransactionSchema = ( @@ -304,6 +309,20 @@ export const toTransactionSchema = ( return transaction.overrides?.maxPriorityFeePerGas?.toString() ?? null; }; + const resolveDelays = (): Static[] => { + return transaction.delays.map((delay) => { + switch (delay.reason) { + case "max_fee_per_gas_too_low": + return { + reason: "max_fee_per_gas_too_low", + timestamp: delay.timestamp.toISOString(), + requestedMaxFeePerGas: delay.requestedMaxFeePerGas.toString(), + currentMaxFeePerGas: delay.currentMaxFeePerGas.toString(), + }; + } + }); + }; + return { queueId: transaction.queueId, status: transaction.status, @@ -338,7 +357,7 @@ export const toTransactionSchema = ( ? transaction.cancelledAt.toISOString() : null, errorMessage: - "errorMessage" in transaction ? (transaction.errorMessage ?? null) : null, + "errorMessage" in transaction ? transaction.errorMessage ?? null : null, sentAtBlockNumber: "sentAtBlock" in transaction ? Number(transaction.sentAtBlock) : null, blockNumber: @@ -373,6 +392,7 @@ export const toTransactionSchema = ( "userOpHash" in transaction ? (transaction.userOpHash as Hex) : null, batchOperations: resolveBatchOperations(), + delays: resolveDelays(), // Deprecated retryGasValues: null, diff --git a/src/shared/utils/transaction/insert-transaction.ts b/src/shared/utils/transaction/insert-transaction.ts index 5e9da7d8..37c223ee 100644 --- a/src/shared/utils/transaction/insert-transaction.ts +++ b/src/shared/utils/transaction/insert-transaction.ts @@ -7,7 +7,6 @@ import { WalletDetailsError, type ParsedWalletDetails, } from "../../../shared/db/wallets/get-wallet-details"; -import { doesChainSupportService } from "../../lib/chain/chain-capabilities"; import { createCustomError } from "../../../server/middleware/error"; import { SendTransactionQueue } from "../../../worker/queues/send-transaction-queue"; import { getChecksumAddress } from "../primitive-types"; @@ -50,6 +49,7 @@ export const insertTransaction = async ( queueId, queuedAt: new Date(), resendCount: 0, + delays: [], from: getChecksumAddress(insertedTransaction.from), to: getChecksumAddress(insertedTransaction.to), diff --git a/src/shared/utils/transaction/types.ts b/src/shared/utils/transaction/types.ts index 6b28b88d..a19e328b 100644 --- a/src/shared/utils/transaction/types.ts +++ b/src/shared/utils/transaction/types.ts @@ -21,6 +21,15 @@ export type BatchOperation = { functionArgs?: unknown[]; }; +// we want to support more reasons for delays in the future, this will become a discriminated union type +export type TransactionDelay = { + reason: "max_fee_per_gas_too_low"; + timestamp: Date; + + requestedMaxFeePerGas: bigint; + currentMaxFeePerGas: bigint; +}; + // InsertedTransaction is the raw input from the caller. export type InsertedTransaction = { isUserOp: boolean; @@ -66,6 +75,7 @@ export type InsertedTransaction = { export type QueuedTransaction = InsertedTransaction & { status: "queued"; + delays: TransactionDelay[]; resendCount: number; queueId: string; queuedAt: Date; diff --git a/src/worker/tasks/send-transaction-worker.ts b/src/worker/tasks/send-transaction-worker.ts index e00825db..dcb9633f 100644 --- a/src/worker/tasks/send-transaction-worker.ts +++ b/src/worker/tasks/send-transaction-worker.ts @@ -77,6 +77,7 @@ const handler: Processor = async (job: Job) => { } let resultTransaction: + | QueuedTransaction // Transaction delayed and will be retried. | SentTransaction // Transaction sent successfully. | ErroredTransaction // Transaction failed and will not be retried. | null; // No attempt to send is made. @@ -282,7 +283,7 @@ const _sendUserOp = async ( const _sendTransaction = async ( job: Job, queuedTransaction: QueuedTransaction, -): Promise => { +): Promise => { assert(!queuedTransaction.isUserOp); if (_hasExceededTimeout(queuedTransaction)) { @@ -372,7 +373,15 @@ const _sendTransaction = async ( `Override gas fee (${overrides.maxFeePerGas}) is lower than onchain fee (${populatedTransaction.maxFeePerGas}). Delaying job until ${retryAt}.`, ); await job.moveToDelayed(retryAt.getTime()); - return null; + + queuedTransaction.delays.push({ + reason: "max_fee_per_gas_too_low", + currentMaxFeePerGas: populatedTransaction.maxFeePerGas, + requestedMaxFeePerGas: overrides.maxFeePerGas, + timestamp: new Date(), + }); + + return queuedTransaction; } } @@ -384,15 +393,18 @@ const _sendTransaction = async ( }); populatedTransaction.nonce = nonce; job.log( - `Populated transaction (isRecycledNonce=${isRecycledNonce}): ${stringify(populatedTransaction)}`, + `Populated transaction (isRecycledNonce=${isRecycledNonce}): ${stringify( + populatedTransaction, + )}`, ); // Send transaction to RPC. // This call throws if the RPC rejects the transaction. let transactionHash: Hex; try { - const sendTransactionResult = - await account.sendTransaction(populatedTransaction); + const sendTransactionResult = await account.sendTransaction( + populatedTransaction, + ); transactionHash = sendTransactionResult.transactionHash; } catch (error: unknown) { // If the nonce is already seen onchain (nonce too low) or in mempool (replacement underpriced),