Skip to content

Commit 96a0f15

Browse files
committed
handle resend rpc errors
1 parent 04429a2 commit 96a0f15

File tree

7 files changed

+84
-71
lines changed

7 files changed

+84
-71
lines changed

src/db/wallets/walletNonce.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const acquireNonce = async (
5353
* @param walletAddress
5454
* @param nonce
5555
*/
56-
export const releaseNonce = async (
56+
export const recycleNonce = async (
5757
chainId: number,
5858
walletAddress: Address,
5959
nonce: number,
@@ -93,14 +93,7 @@ const _syncNonce = async (
9393

9494
const key = lastUsedNonceKey(chainId, walletAddress);
9595
await redis.set(key, transactionCount);
96-
97-
// Set last used nonce to transactionCount - 1.
98-
// But don't lower the Redis nonce.
99-
100-
// @TODO:
101-
// Set to max(value, transactionCount-1) -> 6
102-
103-
return await redis.incr(key);
96+
return transactionCount;
10497
};
10598

10699
/**

src/server/routes/backend-wallet/getTransactions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ export async function getAllTransactions(fastify: FastifyInstance) {
4242
const chainId = await getChainIdFromChain(chain);
4343
const walletAddress = normalizeAddress(_walletAddress);
4444

45-
// @TODO: This query is not optimized. Cap the results to the most recent 1000 total transactions for performance reasons.
45+
// @TODO: This query is not optimized. Cap the results to the most recent 10k total transactions for performance reasons.
4646
const { transactions } = await TransactionDB.listByStatus({
4747
status: "queued",
4848
page: 1,
49-
limit: 1000,
49+
limit: 10_000,
5050
});
5151
const filtered = transactions.filter(
5252
(t) => t.chainId === chainId && t.from === walletAddress,

src/server/schemas/transaction/index.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Static, Type } from "@sinclair/typebox";
2-
import { Address, Hex } from "thirdweb";
2+
import { Hex } from "thirdweb";
33
import { stringify } from "thirdweb/utils";
44
import { AnyTransaction } from "../../../utils/transaction/types";
55

@@ -300,12 +300,9 @@ export const toTransactionSchema = (
300300

301301
// User Operation
302302
signerAddress: transaction.from,
303-
accountAddress:
304-
"accountAddress" in transaction
305-
? (transaction.accountAddress as Address)
306-
: null,
307-
target: "target" in transaction ? (transaction.target as Address) : null,
308-
sender: "sender" in transaction ? (transaction.sender as Address) : null,
303+
accountAddress: transaction.accountAddress ?? null,
304+
target: transaction.target ?? null,
305+
sender: transaction.sender ?? null,
309306
initCode: null,
310307
callData: null,
311308
callGasLimit: null,

src/worker/queues/mineTransactionQueue.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ export class MineTransactionQueue {
1616
...defaultJobOptions,
1717
// Delay confirming the tx by 500ms.
1818
delay: 500,
19-
// Check in 5s, 10s, 20s, 40s, 80s, 160s, 320s, 640s, 1280s, 2560s (~45 minutes)
20-
// This needs to be long enough to handle transactions stuck in mempool for a while.
19+
// Retry after 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s, 512s, 1024s (17 minutes)
20+
// This needs to be long enough to handle transactions stuck in mempool.
2121
attempts: 10,
22-
backoff: { type: "exponential", delay: 5_000 },
22+
backoff: { type: "exponential", delay: 2_000 },
2323
},
2424
});
2525

src/worker/tasks/cancelUnusedNoncesWorker.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Job, Processor, Worker } from "bullmq";
22
import { ethers } from "ethers";
33
import { Address } from "thirdweb";
4-
import { releaseNonce } from "../../db/wallets/walletNonce";
4+
import { recycleNonce } from "../../db/wallets/walletNonce";
55
import { isEthersErrorCode } from "../../utils/ethers";
66
import { logger } from "../../utils/logger";
77
import { redis } from "../../utils/redis/redis";
@@ -38,8 +38,8 @@ const handler: Processor<any, void, string> = async (job: Job<string>) => {
3838
if (isEthersErrorCode(e, ethers.errors.NONCE_EXPIRED)) {
3939
ignore.push(nonce);
4040
} else {
41-
job.log(`Releasing nonce: ${nonce}`);
42-
await releaseNonce(chainId, walletAddress, nonce);
41+
job.log(`Recycling nonce: ${nonce}`);
42+
await recycleNonce(chainId, walletAddress, nonce);
4343
fail.push(nonce);
4444
}
4545
}

src/worker/tasks/mineTransactionWorker.ts

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { stringify } from "thirdweb/utils";
1111
import { getUserOpReceiptRaw } from "thirdweb/wallets/smart";
1212
import { TransactionDB } from "../../db/transactions/db";
13-
import { releaseNonce } from "../../db/wallets/walletNonce";
13+
import { recycleNonce } from "../../db/wallets/walletNonce";
1414
import { getBlockNumberish } from "../../utils/block";
1515
import { getConfig } from "../../utils/cache/getConfig";
1616
import { getChain } from "../../utils/chain";
@@ -108,13 +108,15 @@ const _mineTransaction = async (
108108
): Promise<MinedTransaction | null> => {
109109
assert(!sentTransaction.isUserOp);
110110

111-
const { queueId, chainId, sentTransactionHashes } = sentTransaction;
111+
const { queueId, chainId, sentTransactionHashes, sentAtBlock, resendCount } =
112+
sentTransaction;
112113

113114
// Check all sent transaction hashes since any of them might succeed.
114115
const rpcRequest = getRpcClient({
115116
client: thirdwebClient,
116117
chain: await getChain(chainId),
117118
});
119+
job.log(`Mining transactionHashes: ${sentTransactionHashes}`);
118120
const receiptResults = await Promise.allSettled(
119121
sentTransactionHashes.map((hash) =>
120122
eth_getTransactionReceipt(rpcRequest, { hash }),
@@ -125,7 +127,7 @@ const _mineTransaction = async (
125127
for (const result of receiptResults) {
126128
if (result.status === "fulfilled") {
127129
const receipt = result.value;
128-
job.log(`Receipt found: ${receipt.transactionHash} `);
130+
job.log(`Found receipt on block ${receipt.blockNumber}.`);
129131
return {
130132
...sentTransaction,
131133
status: "mined",
@@ -145,20 +147,17 @@ const _mineTransaction = async (
145147

146148
// Retry the transaction (after some initial delay).
147149
const config = await getConfig();
148-
if (sentTransaction.resendCount < config.maxRetriesPerTx) {
149-
const ellapsedBlocks =
150-
(await getBlockNumberish(chainId)) - sentTransaction.sentAtBlock;
150+
if (resendCount < config.maxRetriesPerTx) {
151+
const blockNumber = await getBlockNumberish(chainId);
152+
const ellapsedBlocks = blockNumber - sentAtBlock;
151153
if (ellapsedBlocks >= config.minEllapsedBlocksBeforeRetry) {
152-
job.log(`Retrying transaction after ${ellapsedBlocks} blocks`);
153-
logger({
154-
service: "worker",
155-
level: "warn",
156-
queueId,
157-
message: `Retrying transaction after ${ellapsedBlocks} blocks`,
158-
});
154+
const message = `Retrying transaction after ${ellapsedBlocks} blocks. blockNumber=${blockNumber} sentAtBlock=${sentAtBlock}`;
155+
job.log(message);
156+
logger({ service: "worker", level: "info", queueId, message });
157+
159158
await SendTransactionQueue.add({
160159
queueId,
161-
resendCount: sentTransaction.resendCount + 1,
160+
resendCount: resendCount + 1,
162161
});
163162
}
164163
}
@@ -175,6 +174,7 @@ const _mineUserOp = async (
175174
const { chainId, userOpHash } = sentTransaction;
176175
const chain = await getChain(chainId);
177176

177+
job.log(`Mining userOpHash: ${userOpHash}`);
178178
const userOpReceiptRaw = await getUserOpReceiptRaw({
179179
client: thirdwebClient,
180180
chain,
@@ -185,30 +185,28 @@ const _mineUserOp = async (
185185
}
186186

187187
const { transactionHash } = userOpReceiptRaw.receipt;
188-
job.log(
189-
`Found transactionHash ${transactionHash} from userOpHash ${userOpHash}.`,
190-
);
188+
job.log(`Found transactionHash: ${transactionHash}`);
191189

192190
const rpcRequest = getRpcClient({ client: thirdwebClient, chain });
193191
const transaction = await eth_getTransactionByHash(rpcRequest, {
194192
hash: transactionHash,
195193
});
196-
const transactionReceipt = await eth_getTransactionReceipt(rpcRequest, {
194+
const receipt = await eth_getTransactionReceipt(rpcRequest, {
197195
hash: transaction.hash,
198196
});
199197

200198
return {
201199
...sentTransaction,
202200
status: "mined",
203-
transactionHash: transactionReceipt.transactionHash,
201+
transactionHash: receipt.transactionHash,
204202
minedAt: new Date(),
205-
minedAtBlock: transactionReceipt.blockNumber,
206-
transactionType: transactionReceipt.type,
207-
onchainStatus: transactionReceipt.status,
208-
gasUsed: transactionReceipt.gasUsed,
209-
effectiveGasPrice: transactionReceipt.effectiveGasPrice,
210-
gas: transactionReceipt.gasUsed,
211-
cumulativeGasUsed: transactionReceipt.cumulativeGasUsed,
203+
minedAtBlock: receipt.blockNumber,
204+
transactionType: receipt.type,
205+
onchainStatus: receipt.status,
206+
gasUsed: receipt.gasUsed,
207+
effectiveGasPrice: receipt.effectiveGasPrice,
208+
gas: receipt.gasUsed,
209+
cumulativeGasUsed: receipt.cumulativeGasUsed,
212210
sender: userOpReceiptRaw.sender as Address,
213211
};
214212
};
@@ -243,8 +241,8 @@ _worker.on("failed", async (job: Job<string> | undefined) => {
243241

244242
if (!sentTransaction.isUserOp) {
245243
// Release the nonce to allow it to be reused or cancelled.
246-
job.log(`Releasing nonce: ${sentTransaction.nonce}`);
247-
await releaseNonce(
244+
job.log(`Recycling nonce: ${sentTransaction.nonce}`);
245+
await recycleNonce(
248246
sentTransaction.chainId,
249247
sentTransaction.from,
250248
sentTransaction.nonce,

src/worker/tasks/sendTransactionWorker.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { bundleUserOp } from "thirdweb/wallets/smart";
88
import type { TransactionSerializable } from "viem";
99
import { getContractAddress } from "viem";
1010
import { TransactionDB } from "../../db/transactions/db";
11-
import { acquireNonce, releaseNonce } from "../../db/wallets/walletNonce";
11+
import { acquireNonce, recycleNonce } from "../../db/wallets/walletNonce";
1212
import { getAccount } from "../../utils/account";
1313
import { getBlockNumberish } from "../../utils/block";
1414
import { getChain } from "../../utils/chain";
@@ -51,8 +51,9 @@ const handler: Processor<any, void, string> = async (job: Job<string>) => {
5151

5252
// SentTransaction = the transaction or userOp was submitted successfully.
5353
// ErroredTransaction = the transaction failed and should not be re-attempted.
54+
// null = the transaction attemped to resend but was not needed. Ignore.
5455
// A thrown exception indicates a retry-able error occurred (e.g. RPC outage).
55-
let resultTransaction: SentTransaction | ErroredTransaction;
56+
let resultTransaction: SentTransaction | ErroredTransaction | null;
5657
if (transaction.status === "queued") {
5758
if (transaction.isUserOp) {
5859
resultTransaction = await _sendUserOp(job, transaction);
@@ -66,16 +67,18 @@ const handler: Processor<any, void, string> = async (job: Job<string>) => {
6667
return;
6768
}
6869

69-
// Handle the resulting "sent" or "errored" transaction.
70-
await TransactionDB.set(resultTransaction);
71-
await enqueueTransactionWebhook(resultTransaction);
70+
if (resultTransaction) {
71+
// Handle the resulting "sent" or "errored" transaction.
72+
await TransactionDB.set(resultTransaction);
73+
await enqueueTransactionWebhook(resultTransaction);
7274

73-
if (resultTransaction.status === "sent") {
74-
job.log(`Transaction sent: ${stringify(resultTransaction)}.`);
75-
await MineTransactionQueue.add({ queueId: resultTransaction.queueId });
76-
await _reportUsageSuccess(resultTransaction);
77-
} else if (resultTransaction.status === "errored") {
78-
_reportUsageError(resultTransaction);
75+
if (resultTransaction.status === "sent") {
76+
job.log(`Transaction sent: ${stringify(resultTransaction)}.`);
77+
await MineTransactionQueue.add({ queueId: resultTransaction.queueId });
78+
await _reportUsageSuccess(resultTransaction);
79+
} else if (resultTransaction.status === "errored") {
80+
_reportUsageError(resultTransaction);
81+
}
7982
}
8083
};
8184

@@ -158,10 +161,10 @@ const _sendTransaction = async (
158161
transactionHash = sendTransactionResult.transactionHash;
159162
job.log(`Sent transaction: ${transactionHash}`);
160163
} catch (error: unknown) {
161-
// Release the nonce if it has not expired.
164+
// NONCE_EXPIRED indicates the nonce was already used onchain. Do not recycle it.
162165
if (!isEthersErrorCode(error, ethers.errors.NONCE_EXPIRED)) {
163-
job.log(`Releasing nonce: ${nonce}`);
164-
await releaseNonce(chainId, from, nonce);
166+
job.log(`Recycling nonce: ${nonce}`);
167+
await recycleNonce(chainId, from, nonce);
165168
}
166169
throw error;
167170
}
@@ -190,7 +193,7 @@ const _resendTransaction = async (
190193
job: Job,
191194
sentTransaction: SentTransaction,
192195
resendCount: number,
193-
): Promise<SentTransaction | ErroredTransaction> => {
196+
): Promise<SentTransaction | null> => {
194197
assert(!sentTransaction.isUserOp);
195198

196199
// Populate the transaction with double gas.
@@ -223,10 +226,32 @@ const _resendTransaction = async (
223226

224227
// Send transaction to RPC.
225228
// This call throws if the RPC rejects the transaction.
226-
const account = await getAccount({ chainId, from });
227-
const { transactionHash } = await account.sendTransaction(
228-
populatedTransaction,
229-
);
229+
let transactionHash: Hex;
230+
try {
231+
const account = await getAccount({ chainId, from });
232+
const sendTransactionResult = await account.sendTransaction(
233+
populatedTransaction,
234+
);
235+
transactionHash = sendTransactionResult.transactionHash;
236+
job.log(`Sent transaction: ${transactionHash}`);
237+
} catch (error: unknown) {
238+
// NONCE_EXPIRED indicates that this transaction was already mined.
239+
// This is not an error.
240+
if (!isEthersErrorCode(error, ethers.errors.NONCE_EXPIRED)) {
241+
job.log(
242+
`Nonce used. This transaction was likely already mined. Do not resend.`,
243+
);
244+
return null;
245+
}
246+
// REPLACEMENT_UNDERPRICED indicates the mempool is already aware of this transaction
247+
// with >= gas fees. Wait for that one to be mined instead.
248+
// This is not an error.
249+
if (!isEthersErrorCode(error, ethers.errors.REPLACEMENT_UNDERPRICED)) {
250+
job.log("A pending transaction exists with >= gas fees. Do not resend.");
251+
return null;
252+
}
253+
throw error;
254+
}
230255

231256
return {
232257
...sentTransaction,

0 commit comments

Comments
 (0)