From 9fd4d9cb2b1c449e3f1aeee69e8d3b4978959308 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Sat, 31 Aug 2024 01:27:49 +0530 Subject: [PATCH] feat: Add retryFailedTransaction route to transaction API (#632) * feat: Add retryFailedTransaction route to transaction API * fix: Update retryFailedTransaction route URL to /transaction/retry-failed * feat: Refactor retryFailedTransaction route to improve error handling * feat: Register retryFailedTransaction route in transaction API * allow retrying queued -> failed transactions --- src/server/routes/index.ts | 2 + src/server/routes/transaction/retry-failed.ts | 135 ++++++++++++++++++ src/utils/transaction/types.ts | 8 +- src/worker/tasks/sendTransactionWorker.ts | 11 +- 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/server/routes/transaction/retry-failed.ts diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 80134c095..23245381c 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -99,6 +99,7 @@ import { cancelTransaction } from "./transaction/cancel"; import { getAllTx } from "./transaction/getAll"; import { getAllDeployedContracts } from "./transaction/getAllDeployedContracts"; import { retryTransaction } from "./transaction/retry"; +import { retryFailedTransaction } from "./transaction/retry-failed"; import { checkTxStatus } from "./transaction/status"; import { syncRetryTransaction } from "./transaction/syncRetry"; import { createWebhook } from "./webhooks/create"; @@ -216,6 +217,7 @@ export const withRoutes = async (fastify: FastifyInstance) => { await fastify.register(getAllDeployedContracts); await fastify.register(retryTransaction); await fastify.register(syncRetryTransaction); + await fastify.register(retryFailedTransaction); await fastify.register(cancelTransaction); await fastify.register(sendSignedTransaction); await fastify.register(sendSignedUserOp); diff --git a/src/server/routes/transaction/retry-failed.ts b/src/server/routes/transaction/retry-failed.ts new file mode 100644 index 000000000..fb562081f --- /dev/null +++ b/src/server/routes/transaction/retry-failed.ts @@ -0,0 +1,135 @@ +import { Static, Type } from "@sinclair/typebox"; +import { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { eth_getTransactionReceipt, getRpcClient } from "thirdweb"; +import { TransactionDB } from "../../../db/transactions/db"; +import { getChain } from "../../../utils/chain"; +import { thirdwebClient } from "../../../utils/sdk"; +import { SendTransactionQueue } from "../../../worker/queues/sendTransactionQueue"; +import { createCustomError } from "../../middleware/error"; +import { standardResponseSchema } from "../../schemas/sharedApiSchemas"; + +const requestBodySchema = Type.Object({ + queueId: Type.String({ + description: "Transaction queue ID", + examples: ["9eb88b00-f04f-409b-9df7-7dcc9003bc35"], + }), +}); + +export const responseBodySchema = Type.Object({ + result: Type.Object({ + message: Type.String(), + status: Type.String(), + }), +}); + +responseBodySchema.example = { + result: { + message: + "Transaction queued for retry with queueId: a20ed4ce-301d-4251-a7af-86bd88f6c015", + status: "success", + }, +}; + +export async function retryFailedTransaction(fastify: FastifyInstance) { + fastify.route<{ + Body: Static; + Reply: Static; + }>({ + method: "POST", + url: "/transaction/retry-failed", + schema: { + summary: "Retry failed transaction", + description: "Retry a failed transaction", + tags: ["Transaction"], + operationId: "retryFailed", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseBodySchema, + }, + }, + handler: async (request, reply) => { + const { queueId } = request.body; + + const transaction = await TransactionDB.get(queueId); + if (!transaction) { + throw createCustomError( + "Transaction not found.", + StatusCodes.BAD_REQUEST, + "TRANSACTION_NOT_FOUND", + ); + } + if (transaction.status !== "errored") { + throw createCustomError( + `Transaction cannot be retried because status: ${transaction.status}`, + StatusCodes.BAD_REQUEST, + "TRANSACTION_CANNOT_BE_RETRIED", + ); + } + + // temp do not handle userop + if (transaction.isUserOp) { + throw createCustomError( + `Transaction cannot be retried because it is a userop`, + StatusCodes.BAD_REQUEST, + "TRANSACTION_CANNOT_BE_RETRIED", + ); + } + + const rpcRequest = getRpcClient({ + client: thirdwebClient, + chain: await getChain(transaction.chainId), + }); + + // if transaction has sentTransactionHashes, we need to check if any of them are mined + if ("sentTransactionHashes" in transaction) { + const receiptPromises = transaction.sentTransactionHashes.map( + (hash) => { + // if receipt is not found, it will throw an error + // so we catch it and return null + return eth_getTransactionReceipt(rpcRequest, { + hash, + }).catch(() => null); + }, + ); + + const receipts = await Promise.all(receiptPromises); + + // If any of the transactions are mined, we should not retry. + const minedReceipt = receipts.find((receipt) => !!receipt); + + if (minedReceipt) { + throw createCustomError( + `Transaction cannot be retried because it has already been mined with hash: ${minedReceipt.transactionHash}`, + StatusCodes.BAD_REQUEST, + "TRANSACTION_CANNOT_BE_RETRIED", + ); + } + } + + const job = await SendTransactionQueue.q.getJob( + SendTransactionQueue.jobId({ + queueId: transaction.queueId, + resendCount: 0, + }), + ); + + if (job) { + await job.remove(); + } + + await SendTransactionQueue.add({ + queueId: transaction.queueId, + resendCount: 0, + }); + + reply.status(StatusCodes.OK).send({ + result: { + message: `Transaction queued for retry with queueId: ${queueId}`, + status: "success", + }, + }); + }, + }); +} diff --git a/src/utils/transaction/types.ts b/src/utils/transaction/types.ts index 1084e1f42..0bd92b58f 100644 --- a/src/utils/transaction/types.ts +++ b/src/utils/transaction/types.ts @@ -46,6 +46,8 @@ export type QueuedTransaction = InsertedTransaction & { queuedAt: Date; value: bigint; data?: Hex; + + manuallyResentAt?: Date; }; // SentTransaction has been submitted to RPC successfully. @@ -83,7 +85,11 @@ export type MinedTransaction = ( // ErroredTransaction received an error before or while sending to RPC. // A transaction that reverted onchain is not considered "errored". -export type ErroredTransaction = Omit & { +export type ErroredTransaction = ( + | Omit + | Omit<_SentTransactionEOA, "status"> + | Omit<_SentTransactionUserOp, "status"> +) & { status: "errored"; errorMessage: string; diff --git a/src/worker/tasks/sendTransactionWorker.ts b/src/worker/tasks/sendTransactionWorker.ts index 9990d320e..64487949f 100644 --- a/src/worker/tasks/sendTransactionWorker.ts +++ b/src/worker/tasks/sendTransactionWorker.ts @@ -61,8 +61,17 @@ const handler: Processor = async (job: Job) => { // An errored queued transaction (resendCount = 0) is safe to retry: the transaction wasn't sent to RPC. if (transaction.status === "errored" && resendCount === 0) { transaction = { - ...transaction, + ...{ + ...transaction, + nonce: undefined, + errorMessage: undefined, + gas: undefined, + gasPrice: undefined, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + }, status: "queued", + manuallyResentAt: new Date(), } satisfies QueuedTransaction; }