Skip to content

Commit af85f61

Browse files
authored
feat: Allow retrying a failed userop (#783)
* feat: Allow retrying a failed userop * use transaction receipt helpers * query hashes in batches * linting * lint * set queuedAt to now
1 parent 313f1c7 commit af85f61

File tree

8 files changed

+186
-164
lines changed

8 files changed

+186
-164
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import assert from "node:assert";
2+
import { eth_getTransactionReceipt, getRpcClient } from "thirdweb";
3+
import type { UserOperationReceipt } from "thirdweb/dist/types/wallets/smart/types";
4+
import type { TransactionReceipt } from "thirdweb/transaction";
5+
import { getUserOpReceiptRaw } from "thirdweb/wallets/smart";
6+
import { getChain } from "../../utils/chain";
7+
import { thirdwebClient } from "../../utils/sdk";
8+
import type { AnyTransaction } from "../../utils/transaction/types";
9+
10+
/**
11+
* Returns the transaction receipt for a given transaction, or null if not found.
12+
* @param transaction
13+
* @returns TransactionReceipt | null
14+
*/
15+
export async function getReceiptForEOATransaction(
16+
transaction: AnyTransaction,
17+
): Promise<TransactionReceipt | null> {
18+
assert(!transaction.isUserOp);
19+
20+
if (!("sentTransactionHashes" in transaction)) {
21+
return null;
22+
}
23+
24+
const rpcRequest = getRpcClient({
25+
client: thirdwebClient,
26+
chain: await getChain(transaction.chainId),
27+
});
28+
29+
// Get the receipt for each transaction hash (in batches).
30+
// Return if any receipt is found.
31+
const BATCH_SIZE = 10;
32+
for (
33+
let i = 0;
34+
i < transaction.sentTransactionHashes.length;
35+
i += BATCH_SIZE
36+
) {
37+
const batch = transaction.sentTransactionHashes.slice(i, i + BATCH_SIZE);
38+
const results = await Promise.allSettled(
39+
batch.map((hash) => eth_getTransactionReceipt(rpcRequest, { hash })),
40+
);
41+
42+
for (const result of results) {
43+
if (result.status === "fulfilled") {
44+
return result.value;
45+
}
46+
}
47+
}
48+
49+
return null;
50+
}
51+
52+
/**
53+
* Returns the user operation receipt for a given transaction, or null if not found.
54+
* The transaction receipt is available in the result under `result.receipt`.
55+
* @param transaction
56+
* @returns UserOperationReceipt | null
57+
*/
58+
export async function getReceiptForUserOp(
59+
transaction: AnyTransaction,
60+
): Promise<UserOperationReceipt | null> {
61+
assert(transaction.isUserOp);
62+
63+
if (!("userOpHash" in transaction)) {
64+
return null;
65+
}
66+
67+
const receipt = await getUserOpReceiptRaw({
68+
client: thirdwebClient,
69+
chain: await getChain(transaction.chainId),
70+
userOpHash: transaction.userOpHash,
71+
});
72+
return receipt ?? null;
73+
}

src/server/routes/index.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,9 @@ import { cancelTransaction } from "./transaction/cancel";
103103
import { getAllTransactions } from "./transaction/getAll";
104104
import { getAllDeployedContracts } from "./transaction/getAllDeployedContracts";
105105
import { retryTransaction } from "./transaction/retry";
106-
import { retryFailedTransaction } from "./transaction/retry-failed";
106+
import { retryFailedTransactionRoute } from "./transaction/retry-failed";
107107
import { checkTxStatus } from "./transaction/status";
108-
import { syncRetryTransaction } from "./transaction/syncRetry";
108+
import { syncRetryTransactionRoute } from "./transaction/sync-retry";
109109
import { createWebhookRoute } from "./webhooks/create";
110110
import { getWebhooksEventTypes } from "./webhooks/events";
111111
import { getAllWebhooksData } from "./webhooks/getAll";
@@ -224,8 +224,8 @@ export async function withRoutes(fastify: FastifyInstance) {
224224
await fastify.register(checkTxStatus);
225225
await fastify.register(getAllDeployedContracts);
226226
await fastify.register(retryTransaction);
227-
await fastify.register(syncRetryTransaction);
228-
await fastify.register(retryFailedTransaction);
227+
await fastify.register(syncRetryTransactionRoute);
228+
await fastify.register(retryFailedTransactionRoute);
229229
await fastify.register(cancelTransaction);
230230
await fastify.register(sendSignedTransaction);
231231
await fastify.register(sendSignedUserOp);

src/server/routes/transaction/retry-failed.ts

+29-49
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { Static, Type } from "@sinclair/typebox";
2-
import { FastifyInstance } from "fastify";
1+
import { Type, type Static } from "@sinclair/typebox";
2+
import type { FastifyInstance } from "fastify";
33
import { StatusCodes } from "http-status-codes";
4-
import { eth_getTransactionReceipt, getRpcClient } from "thirdweb";
54
import { TransactionDB } from "../../../db/transactions/db";
6-
import { getChain } from "../../../utils/chain";
7-
import { thirdwebClient } from "../../../utils/sdk";
5+
import {
6+
getReceiptForEOATransaction,
7+
getReceiptForUserOp,
8+
} from "../../../lib/transaction/get-transaction-receipt";
9+
import type { QueuedTransaction } from "../../../utils/transaction/types";
810
import { MineTransactionQueue } from "../../../worker/queues/mineTransactionQueue";
911
import { SendTransactionQueue } from "../../../worker/queues/sendTransactionQueue";
1012
import { createCustomError } from "../../middleware/error";
@@ -26,13 +28,12 @@ export const responseBodySchema = Type.Object({
2628

2729
responseBodySchema.example = {
2830
result: {
29-
message:
30-
"Transaction queued for retry with queueId: a20ed4ce-301d-4251-a7af-86bd88f6c015",
31+
message: "Sent transaction to be retried.",
3132
status: "success",
3233
},
3334
};
3435

35-
export async function retryFailedTransaction(fastify: FastifyInstance) {
36+
export async function retryFailedTransactionRoute(fastify: FastifyInstance) {
3637
fastify.route<{
3738
Body: Static<typeof requestBodySchema>;
3839
Reply: Static<typeof responseBodySchema>;
@@ -63,69 +64,48 @@ export async function retryFailedTransaction(fastify: FastifyInstance) {
6364
}
6465
if (transaction.status !== "errored") {
6566
throw createCustomError(
66-
`Transaction cannot be retried because status: ${transaction.status}`,
67+
`Cannot retry a transaction with status ${transaction.status}.`,
6768
StatusCodes.BAD_REQUEST,
6869
"TRANSACTION_CANNOT_BE_RETRIED",
6970
);
7071
}
7172

72-
if (transaction.isUserOp) {
73+
const receipt = transaction.isUserOp
74+
? await getReceiptForUserOp(transaction)
75+
: await getReceiptForEOATransaction(transaction);
76+
if (receipt) {
7377
throw createCustomError(
74-
"Transaction cannot be retried because it is a userop",
78+
"Cannot retry a transaction that is already mined.",
7579
StatusCodes.BAD_REQUEST,
7680
"TRANSACTION_CANNOT_BE_RETRIED",
7781
);
7882
}
7983

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-
84+
// Remove existing jobs.
11185
const sendJob = await SendTransactionQueue.q.getJob(
11286
SendTransactionQueue.jobId({
11387
queueId: transaction.queueId,
11488
resendCount: 0,
11589
}),
11690
);
117-
if (sendJob) {
118-
await sendJob.remove();
119-
}
91+
await sendJob?.remove();
12092

12193
const mineJob = await MineTransactionQueue.q.getJob(
12294
MineTransactionQueue.jobId({
12395
queueId: transaction.queueId,
12496
}),
12597
);
126-
if (mineJob) {
127-
await mineJob.remove();
128-
}
98+
await mineJob?.remove();
99+
100+
// Reset the failed job as "queued" and re-enqueue it.
101+
const { errorMessage, ...omitted } = transaction;
102+
const queuedTransaction: QueuedTransaction = {
103+
...omitted,
104+
status: "queued",
105+
queuedAt: new Date(),
106+
resendCount: 0,
107+
};
108+
await TransactionDB.set(queuedTransaction);
129109

130110
await SendTransactionQueue.add({
131111
queueId: transaction.queueId,
@@ -134,7 +114,7 @@ export async function retryFailedTransaction(fastify: FastifyInstance) {
134114

135115
reply.status(StatusCodes.OK).send({
136116
result: {
137-
message: `Transaction queued for retry with queueId: ${queueId}`,
117+
message: "Sent transaction to be retried.",
138118
status: "success",
139119
},
140120
});

src/server/routes/transaction/syncRetry.ts renamed to src/server/routes/transaction/sync-retry.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
33
import { StatusCodes } from "http-status-codes";
44
import { toSerializableTransaction } from "thirdweb";
55
import { TransactionDB } from "../../../db/transactions/db";
6+
import { getReceiptForEOATransaction } from "../../../lib/transaction/get-transaction-receipt";
67
import { getAccount } from "../../../utils/account";
78
import { getBlockNumberish } from "../../../utils/block";
89
import { getChain } from "../../../utils/chain";
@@ -15,7 +16,6 @@ import { createCustomError } from "../../middleware/error";
1516
import { TransactionHashSchema } from "../../schemas/address";
1617
import { standardResponseSchema } from "../../schemas/sharedApiSchemas";
1718

18-
// INPUT
1919
const requestBodySchema = Type.Object({
2020
queueId: Type.String({
2121
description: "Transaction queue ID",
@@ -25,7 +25,6 @@ const requestBodySchema = Type.Object({
2525
maxPriorityFeePerGas: Type.Optional(Type.String()),
2626
});
2727

28-
// OUTPUT
2928
export const responseBodySchema = Type.Object({
3029
result: Type.Object({
3130
transactionHash: TransactionHashSchema,
@@ -39,7 +38,7 @@ responseBodySchema.example = {
3938
},
4039
};
4140

42-
export async function syncRetryTransaction(fastify: FastifyInstance) {
41+
export async function syncRetryTransactionRoute(fastify: FastifyInstance) {
4342
fastify.route<{
4443
Body: Static<typeof requestBodySchema>;
4544
Reply: Static<typeof responseBodySchema>;
@@ -69,6 +68,7 @@ export async function syncRetryTransaction(fastify: FastifyInstance) {
6968
"TRANSACTION_NOT_FOUND",
7069
);
7170
}
71+
7272
if (transaction.isUserOp || !("nonce" in transaction)) {
7373
throw createCustomError(
7474
"Transaction cannot be retried.",
@@ -77,6 +77,15 @@ export async function syncRetryTransaction(fastify: FastifyInstance) {
7777
);
7878
}
7979

80+
const receipt = await getReceiptForEOATransaction(transaction);
81+
if (receipt) {
82+
throw createCustomError(
83+
"Cannot retry a transaction that is already mined.",
84+
StatusCodes.BAD_REQUEST,
85+
"TRANSACTION_CANNOT_BE_RETRIED",
86+
);
87+
}
88+
8089
const { chainId, from } = transaction;
8190

8291
// Prepare transaction.

src/utils/transaction/types.ts

-2
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,6 @@ export type QueuedTransaction = InsertedTransaction & {
5858
queuedAt: Date;
5959
value: bigint;
6060
data?: Hex;
61-
62-
manuallyResentAt?: Date;
6361
};
6462

6563
// SentTransaction has been submitted to RPC successfully.

0 commit comments

Comments
 (0)