Skip to content

Commit 9fd4d9c

Browse files
authored
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
1 parent ee21c4a commit 9fd4d9c

File tree

4 files changed

+154
-2
lines changed

4 files changed

+154
-2
lines changed

src/server/routes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import { cancelTransaction } from "./transaction/cancel";
9999
import { getAllTx } from "./transaction/getAll";
100100
import { getAllDeployedContracts } from "./transaction/getAllDeployedContracts";
101101
import { retryTransaction } from "./transaction/retry";
102+
import { retryFailedTransaction } from "./transaction/retry-failed";
102103
import { checkTxStatus } from "./transaction/status";
103104
import { syncRetryTransaction } from "./transaction/syncRetry";
104105
import { createWebhook } from "./webhooks/create";
@@ -216,6 +217,7 @@ export const withRoutes = async (fastify: FastifyInstance) => {
216217
await fastify.register(getAllDeployedContracts);
217218
await fastify.register(retryTransaction);
218219
await fastify.register(syncRetryTransaction);
220+
await fastify.register(retryFailedTransaction);
219221
await fastify.register(cancelTransaction);
220222
await fastify.register(sendSignedTransaction);
221223
await fastify.register(sendSignedUserOp);
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { Static, Type } from "@sinclair/typebox";
2+
import { FastifyInstance } from "fastify";
3+
import { StatusCodes } from "http-status-codes";
4+
import { eth_getTransactionReceipt, getRpcClient } from "thirdweb";
5+
import { TransactionDB } from "../../../db/transactions/db";
6+
import { getChain } from "../../../utils/chain";
7+
import { thirdwebClient } from "../../../utils/sdk";
8+
import { SendTransactionQueue } from "../../../worker/queues/sendTransactionQueue";
9+
import { createCustomError } from "../../middleware/error";
10+
import { standardResponseSchema } from "../../schemas/sharedApiSchemas";
11+
12+
const requestBodySchema = Type.Object({
13+
queueId: Type.String({
14+
description: "Transaction queue ID",
15+
examples: ["9eb88b00-f04f-409b-9df7-7dcc9003bc35"],
16+
}),
17+
});
18+
19+
export const responseBodySchema = Type.Object({
20+
result: Type.Object({
21+
message: Type.String(),
22+
status: Type.String(),
23+
}),
24+
});
25+
26+
responseBodySchema.example = {
27+
result: {
28+
message:
29+
"Transaction queued for retry with queueId: a20ed4ce-301d-4251-a7af-86bd88f6c015",
30+
status: "success",
31+
},
32+
};
33+
34+
export async function retryFailedTransaction(fastify: FastifyInstance) {
35+
fastify.route<{
36+
Body: Static<typeof requestBodySchema>;
37+
Reply: Static<typeof responseBodySchema>;
38+
}>({
39+
method: "POST",
40+
url: "/transaction/retry-failed",
41+
schema: {
42+
summary: "Retry failed transaction",
43+
description: "Retry a failed transaction",
44+
tags: ["Transaction"],
45+
operationId: "retryFailed",
46+
body: requestBodySchema,
47+
response: {
48+
...standardResponseSchema,
49+
[StatusCodes.OK]: responseBodySchema,
50+
},
51+
},
52+
handler: async (request, reply) => {
53+
const { queueId } = request.body;
54+
55+
const transaction = await TransactionDB.get(queueId);
56+
if (!transaction) {
57+
throw createCustomError(
58+
"Transaction not found.",
59+
StatusCodes.BAD_REQUEST,
60+
"TRANSACTION_NOT_FOUND",
61+
);
62+
}
63+
if (transaction.status !== "errored") {
64+
throw createCustomError(
65+
`Transaction cannot be retried because status: ${transaction.status}`,
66+
StatusCodes.BAD_REQUEST,
67+
"TRANSACTION_CANNOT_BE_RETRIED",
68+
);
69+
}
70+
71+
// temp do not handle userop
72+
if (transaction.isUserOp) {
73+
throw createCustomError(
74+
`Transaction cannot be retried because it is a userop`,
75+
StatusCodes.BAD_REQUEST,
76+
"TRANSACTION_CANNOT_BE_RETRIED",
77+
);
78+
}
79+
80+
const rpcRequest = getRpcClient({
81+
client: thirdwebClient,
82+
chain: await getChain(transaction.chainId),
83+
});
84+
85+
// if transaction has sentTransactionHashes, we need to check if any of them are mined
86+
if ("sentTransactionHashes" in transaction) {
87+
const receiptPromises = transaction.sentTransactionHashes.map(
88+
(hash) => {
89+
// if receipt is not found, it will throw an error
90+
// so we catch it and return null
91+
return eth_getTransactionReceipt(rpcRequest, {
92+
hash,
93+
}).catch(() => null);
94+
},
95+
);
96+
97+
const receipts = await Promise.all(receiptPromises);
98+
99+
// If any of the transactions are mined, we should not retry.
100+
const minedReceipt = receipts.find((receipt) => !!receipt);
101+
102+
if (minedReceipt) {
103+
throw createCustomError(
104+
`Transaction cannot be retried because it has already been mined with hash: ${minedReceipt.transactionHash}`,
105+
StatusCodes.BAD_REQUEST,
106+
"TRANSACTION_CANNOT_BE_RETRIED",
107+
);
108+
}
109+
}
110+
111+
const job = await SendTransactionQueue.q.getJob(
112+
SendTransactionQueue.jobId({
113+
queueId: transaction.queueId,
114+
resendCount: 0,
115+
}),
116+
);
117+
118+
if (job) {
119+
await job.remove();
120+
}
121+
122+
await SendTransactionQueue.add({
123+
queueId: transaction.queueId,
124+
resendCount: 0,
125+
});
126+
127+
reply.status(StatusCodes.OK).send({
128+
result: {
129+
message: `Transaction queued for retry with queueId: ${queueId}`,
130+
status: "success",
131+
},
132+
});
133+
},
134+
});
135+
}

src/utils/transaction/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export type QueuedTransaction = InsertedTransaction & {
4646
queuedAt: Date;
4747
value: bigint;
4848
data?: Hex;
49+
50+
manuallyResentAt?: Date;
4951
};
5052

5153
// SentTransaction has been submitted to RPC successfully.
@@ -83,7 +85,11 @@ export type MinedTransaction = (
8385

8486
// ErroredTransaction received an error before or while sending to RPC.
8587
// A transaction that reverted onchain is not considered "errored".
86-
export type ErroredTransaction = Omit<QueuedTransaction, "status"> & {
88+
export type ErroredTransaction = (
89+
| Omit<QueuedTransaction, "status">
90+
| Omit<_SentTransactionEOA, "status">
91+
| Omit<_SentTransactionUserOp, "status">
92+
) & {
8793
status: "errored";
8894

8995
errorMessage: string;

src/worker/tasks/sendTransactionWorker.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,17 @@ const handler: Processor<any, void, string> = async (job: Job<string>) => {
6161
// An errored queued transaction (resendCount = 0) is safe to retry: the transaction wasn't sent to RPC.
6262
if (transaction.status === "errored" && resendCount === 0) {
6363
transaction = {
64-
...transaction,
64+
...{
65+
...transaction,
66+
nonce: undefined,
67+
errorMessage: undefined,
68+
gas: undefined,
69+
gasPrice: undefined,
70+
maxFeePerGas: undefined,
71+
maxPriorityFeePerGas: undefined,
72+
},
6573
status: "queued",
74+
manuallyResentAt: new Date(),
6675
} satisfies QueuedTransaction;
6776
}
6877

0 commit comments

Comments
 (0)