diff --git a/src/lib/transaction/get-transaction-receipt.ts b/src/lib/transaction/get-transaction-receipt.ts new file mode 100644 index 000000000..b95e108c8 --- /dev/null +++ b/src/lib/transaction/get-transaction-receipt.ts @@ -0,0 +1,73 @@ +import assert from "node:assert"; +import { eth_getTransactionReceipt, getRpcClient } from "thirdweb"; +import type { UserOperationReceipt } from "thirdweb/dist/types/wallets/smart/types"; +import type { TransactionReceipt } from "thirdweb/transaction"; +import { getUserOpReceiptRaw } from "thirdweb/wallets/smart"; +import { getChain } from "../../utils/chain"; +import { thirdwebClient } from "../../utils/sdk"; +import type { AnyTransaction } from "../../utils/transaction/types"; + +/** + * Returns the transaction receipt for a given transaction, or null if not found. + * @param transaction + * @returns TransactionReceipt | null + */ +export async function getReceiptForEOATransaction( + transaction: AnyTransaction, +): Promise { + assert(!transaction.isUserOp); + + if (!("sentTransactionHashes" in transaction)) { + return null; + } + + const rpcRequest = getRpcClient({ + client: thirdwebClient, + chain: await getChain(transaction.chainId), + }); + + // Get the receipt for each transaction hash (in batches). + // Return if any receipt is found. + const BATCH_SIZE = 10; + for ( + let i = 0; + i < transaction.sentTransactionHashes.length; + i += BATCH_SIZE + ) { + const batch = transaction.sentTransactionHashes.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map((hash) => eth_getTransactionReceipt(rpcRequest, { hash })), + ); + + for (const result of results) { + if (result.status === "fulfilled") { + return result.value; + } + } + } + + return null; +} + +/** + * Returns the user operation receipt for a given transaction, or null if not found. + * The transaction receipt is available in the result under `result.receipt`. + * @param transaction + * @returns UserOperationReceipt | null + */ +export async function getReceiptForUserOp( + transaction: AnyTransaction, +): Promise { + assert(transaction.isUserOp); + + if (!("userOpHash" in transaction)) { + return null; + } + + const receipt = await getUserOpReceiptRaw({ + client: thirdwebClient, + chain: await getChain(transaction.chainId), + userOpHash: transaction.userOpHash, + }); + return receipt ?? null; +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index b16ded326..173698fea 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -103,9 +103,9 @@ import { cancelTransaction } from "./transaction/cancel"; import { getAllTransactions } from "./transaction/getAll"; import { getAllDeployedContracts } from "./transaction/getAllDeployedContracts"; import { retryTransaction } from "./transaction/retry"; -import { retryFailedTransaction } from "./transaction/retry-failed"; +import { retryFailedTransactionRoute } from "./transaction/retry-failed"; import { checkTxStatus } from "./transaction/status"; -import { syncRetryTransaction } from "./transaction/syncRetry"; +import { syncRetryTransactionRoute } from "./transaction/sync-retry"; import { createWebhookRoute } from "./webhooks/create"; import { getWebhooksEventTypes } from "./webhooks/events"; import { getAllWebhooksData } from "./webhooks/getAll"; @@ -224,8 +224,8 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(checkTxStatus); await fastify.register(getAllDeployedContracts); await fastify.register(retryTransaction); - await fastify.register(syncRetryTransaction); - await fastify.register(retryFailedTransaction); + await fastify.register(syncRetryTransactionRoute); + await fastify.register(retryFailedTransactionRoute); 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 index 0227f2ca3..5ac43c43b 100644 --- a/src/server/routes/transaction/retry-failed.ts +++ b/src/server/routes/transaction/retry-failed.ts @@ -1,10 +1,12 @@ -import { Static, Type } from "@sinclair/typebox"; -import { FastifyInstance } from "fastify"; +import { Type, type Static } from "@sinclair/typebox"; +import type { 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 { + getReceiptForEOATransaction, + getReceiptForUserOp, +} from "../../../lib/transaction/get-transaction-receipt"; +import type { QueuedTransaction } from "../../../utils/transaction/types"; import { MineTransactionQueue } from "../../../worker/queues/mineTransactionQueue"; import { SendTransactionQueue } from "../../../worker/queues/sendTransactionQueue"; import { createCustomError } from "../../middleware/error"; @@ -26,13 +28,12 @@ export const responseBodySchema = Type.Object({ responseBodySchema.example = { result: { - message: - "Transaction queued for retry with queueId: a20ed4ce-301d-4251-a7af-86bd88f6c015", + message: "Sent transaction to be retried.", status: "success", }, }; -export async function retryFailedTransaction(fastify: FastifyInstance) { +export async function retryFailedTransactionRoute(fastify: FastifyInstance) { fastify.route<{ Body: Static; Reply: Static; @@ -63,69 +64,48 @@ export async function retryFailedTransaction(fastify: FastifyInstance) { } if (transaction.status !== "errored") { throw createCustomError( - `Transaction cannot be retried because status: ${transaction.status}`, + `Cannot retry a transaction with status ${transaction.status}.`, StatusCodes.BAD_REQUEST, "TRANSACTION_CANNOT_BE_RETRIED", ); } - if (transaction.isUserOp) { + const receipt = transaction.isUserOp + ? await getReceiptForUserOp(transaction) + : await getReceiptForEOATransaction(transaction); + if (receipt) { throw createCustomError( - "Transaction cannot be retried because it is a userop", + "Cannot retry a transaction that is already mined.", 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", - ); - } - } - + // Remove existing jobs. const sendJob = await SendTransactionQueue.q.getJob( SendTransactionQueue.jobId({ queueId: transaction.queueId, resendCount: 0, }), ); - if (sendJob) { - await sendJob.remove(); - } + await sendJob?.remove(); const mineJob = await MineTransactionQueue.q.getJob( MineTransactionQueue.jobId({ queueId: transaction.queueId, }), ); - if (mineJob) { - await mineJob.remove(); - } + await mineJob?.remove(); + + // Reset the failed job as "queued" and re-enqueue it. + const { errorMessage, ...omitted } = transaction; + const queuedTransaction: QueuedTransaction = { + ...omitted, + status: "queued", + queuedAt: new Date(), + resendCount: 0, + }; + await TransactionDB.set(queuedTransaction); await SendTransactionQueue.add({ queueId: transaction.queueId, @@ -134,7 +114,7 @@ export async function retryFailedTransaction(fastify: FastifyInstance) { reply.status(StatusCodes.OK).send({ result: { - message: `Transaction queued for retry with queueId: ${queueId}`, + message: "Sent transaction to be retried.", status: "success", }, }); diff --git a/src/server/routes/transaction/syncRetry.ts b/src/server/routes/transaction/sync-retry.ts similarity index 90% rename from src/server/routes/transaction/syncRetry.ts rename to src/server/routes/transaction/sync-retry.ts index 7185c2802..3eabdbd1e 100644 --- a/src/server/routes/transaction/syncRetry.ts +++ b/src/server/routes/transaction/sync-retry.ts @@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; import { toSerializableTransaction } from "thirdweb"; import { TransactionDB } from "../../../db/transactions/db"; +import { getReceiptForEOATransaction } from "../../../lib/transaction/get-transaction-receipt"; import { getAccount } from "../../../utils/account"; import { getBlockNumberish } from "../../../utils/block"; import { getChain } from "../../../utils/chain"; @@ -15,7 +16,6 @@ import { createCustomError } from "../../middleware/error"; import { TransactionHashSchema } from "../../schemas/address"; import { standardResponseSchema } from "../../schemas/sharedApiSchemas"; -// INPUT const requestBodySchema = Type.Object({ queueId: Type.String({ description: "Transaction queue ID", @@ -25,7 +25,6 @@ const requestBodySchema = Type.Object({ maxPriorityFeePerGas: Type.Optional(Type.String()), }); -// OUTPUT export const responseBodySchema = Type.Object({ result: Type.Object({ transactionHash: TransactionHashSchema, @@ -39,7 +38,7 @@ responseBodySchema.example = { }, }; -export async function syncRetryTransaction(fastify: FastifyInstance) { +export async function syncRetryTransactionRoute(fastify: FastifyInstance) { fastify.route<{ Body: Static; Reply: Static; @@ -69,6 +68,7 @@ export async function syncRetryTransaction(fastify: FastifyInstance) { "TRANSACTION_NOT_FOUND", ); } + if (transaction.isUserOp || !("nonce" in transaction)) { throw createCustomError( "Transaction cannot be retried.", @@ -77,6 +77,15 @@ export async function syncRetryTransaction(fastify: FastifyInstance) { ); } + const receipt = await getReceiptForEOATransaction(transaction); + if (receipt) { + throw createCustomError( + "Cannot retry a transaction that is already mined.", + StatusCodes.BAD_REQUEST, + "TRANSACTION_CANNOT_BE_RETRIED", + ); + } + const { chainId, from } = transaction; // Prepare transaction. diff --git a/src/utils/transaction/types.ts b/src/utils/transaction/types.ts index 238c71bde..d4846a4a8 100644 --- a/src/utils/transaction/types.ts +++ b/src/utils/transaction/types.ts @@ -58,8 +58,6 @@ export type QueuedTransaction = InsertedTransaction & { queuedAt: Date; value: bigint; data?: Hex; - - manuallyResentAt?: Date; }; // SentTransaction has been submitted to RPC successfully. diff --git a/src/worker/tasks/mineTransactionWorker.ts b/src/worker/tasks/mineTransactionWorker.ts index bed88e803..1d3ce45f9 100644 --- a/src/worker/tasks/mineTransactionWorker.ts +++ b/src/worker/tasks/mineTransactionWorker.ts @@ -3,17 +3,19 @@ import assert from "node:assert"; import superjson from "superjson"; import { eth_getBalance, - eth_getTransactionByHash, - eth_getTransactionReceipt, getAddress, getRpcClient, toTokens, type Address, } from "thirdweb"; import { stringify } from "thirdweb/utils"; -import { getUserOpReceipt, getUserOpReceiptRaw } from "thirdweb/wallets/smart"; +import { getUserOpReceipt } from "thirdweb/wallets/smart"; import { TransactionDB } from "../../db/transactions/db"; import { recycleNonce, removeSentNonce } from "../../db/wallets/walletNonce"; +import { + getReceiptForEOATransaction, + getReceiptForUserOp, +} from "../../lib/transaction/get-transaction-receipt"; import { WebhooksEventTypes } from "../../schema/webhooks"; import { getBlockNumberish } from "../../utils/block"; import { getConfig } from "../../utils/cache/getConfig"; @@ -65,7 +67,6 @@ const handler: Processor = async (job: Job) => { } if (!resultTransaction) { - job.log("Transaction is not mined yet. Check again later..."); throw new Error("NOT_CONFIRMED_YET"); } @@ -121,72 +122,61 @@ const _mineTransaction = async ( ): Promise => { assert(!sentTransaction.isUserOp); - const { queueId, chainId, sentTransactionHashes, sentAtBlock, resendCount } = - sentTransaction; - - // Check all sent transaction hashes since any of them might succeed. - const rpcRequest = getRpcClient({ - client: thirdwebClient, - chain: await getChain(chainId), - }); - job.log(`Mining transactionHashes: ${sentTransactionHashes}`); - const receiptResults = await Promise.allSettled( - sentTransactionHashes.map((hash) => - eth_getTransactionReceipt(rpcRequest, { hash }), - ), - ); + const receipt = await getReceiptForEOATransaction(sentTransaction); - // This transaction is mined if any receipt is found. - for (const result of receiptResults) { - if (result.status === "fulfilled") { - const receipt = result.value; - job.log(`Found receipt on block ${receipt.blockNumber}.`); - - const removed = await removeSentNonce( - sentTransaction.chainId, - sentTransaction.from, - sentTransaction.nonce, - ); - - logger({ - level: "debug", - message: `[mineTransactionWorker] Removed nonce ${sentTransaction.nonce} from nonce-sent set: ${removed}`, - service: "worker", - }); + if (receipt) { + job.log( + `Found receipt. transactionHash=${receipt.transactionHash} block=${receipt.blockNumber}`, + ); - const errorMessage = - receipt.status === "reverted" - ? "The transaction failed onchain. See: https://portal.thirdweb.com/engine/troubleshooting" - : undefined; + const removed = await removeSentNonce( + sentTransaction.chainId, + sentTransaction.from, + sentTransaction.nonce, + ); + logger({ + level: "debug", + message: `[mineTransactionWorker] Removed nonce ${sentTransaction.nonce} from nonce-sent set: ${removed}`, + service: "worker", + }); - return { - ...sentTransaction, - status: "mined", - transactionHash: receipt.transactionHash, - minedAt: new Date(), - minedAtBlock: receipt.blockNumber, - transactionType: receipt.type, - onchainStatus: receipt.status, - gasUsed: receipt.gasUsed, - effectiveGasPrice: receipt.effectiveGasPrice, - cumulativeGasUsed: receipt.cumulativeGasUsed, - errorMessage, - }; - } + // Though the transaction is mined successfully, set an error message if the transaction failed onchain. + const errorMessage = + receipt.status === "reverted" + ? "The transaction failed onchain. See: https://portal.thirdweb.com/engine/troubleshooting" + : undefined; + + return { + ...sentTransaction, + status: "mined", + transactionHash: receipt.transactionHash, + minedAt: new Date(), + minedAtBlock: receipt.blockNumber, + transactionType: receipt.type, + onchainStatus: receipt.status, + gasUsed: receipt.gasUsed, + effectiveGasPrice: receipt.effectiveGasPrice, + cumulativeGasUsed: receipt.cumulativeGasUsed, + errorMessage, + }; } + // Else the transaction is not mined yet. + job.log( + `Transaction is not mined yet. Check again later. sentTransactionHashes=${sentTransaction.sentTransactionHashes}`, + ); // Resend the transaction (after some initial delay). const config = await getConfig(); - const blockNumber = await getBlockNumberish(chainId); - const ellapsedBlocks = blockNumber - sentAtBlock; + const blockNumber = await getBlockNumberish(sentTransaction.chainId); + const ellapsedBlocks = blockNumber - sentTransaction.sentAtBlock; if (ellapsedBlocks >= config.minEllapsedBlocksBeforeRetry) { job.log( - `Resending transaction after ${ellapsedBlocks} blocks. blockNumber=${blockNumber} sentAtBlock=${sentAtBlock}`, + `Resending transaction after ${ellapsedBlocks} blocks. blockNumber=${blockNumber} sentAtBlock=${sentTransaction.sentAtBlock}`, ); await SendTransactionQueue.add({ - queueId, - resendCount: resendCount + 1, + queueId: sentTransaction.queueId, + resendCount: sentTransaction.resendCount + 1, }); } @@ -199,47 +189,36 @@ const _mineUserOp = async ( ): Promise => { assert(sentTransaction.isUserOp); - const { chainId, userOpHash } = sentTransaction; - const chain = await getChain(chainId); - - job.log(`Mining userOpHash: ${userOpHash}`); - const userOpReceiptRaw = await getUserOpReceiptRaw({ - client: thirdwebClient, - chain, - userOpHash, - }); - - if (!userOpReceiptRaw) { + const userOpReceipt = await getReceiptForUserOp(sentTransaction); + if (!userOpReceipt) { + job.log( + `UserOp is not mined yet. Check again later. userOpHash=${sentTransaction.userOpHash}`, + ); return null; } + const { receipt } = userOpReceipt; - const { transactionHash } = userOpReceiptRaw.receipt; - job.log(`Found transactionHash: ${transactionHash}`); - - const rpcRequest = getRpcClient({ client: thirdwebClient, chain }); - const transaction = await eth_getTransactionByHash(rpcRequest, { - hash: transactionHash, - }); - const receipt = await eth_getTransactionReceipt(rpcRequest, { - hash: transaction.hash, - }); + job.log( + `Found receipt. transactionHash=${receipt.transactionHash} block=${receipt.blockNumber}`, + ); let errorMessage: string | undefined; // if the userOpReceipt is not successful, try to get the parsed userOpReceipt // we expect this to fail, but we want the error message if it does - if (!userOpReceiptRaw.success) { + if (!userOpReceipt.success) { try { + const chain = await getChain(sentTransaction.chainId); const userOpReceipt = await getUserOpReceipt({ client: thirdwebClient, chain, - userOpHash, + userOpHash: sentTransaction.userOpHash, }); - await job.log(`Found userOpReceipt: ${userOpReceipt}`); + job.log(`Found userOpReceipt: ${userOpReceipt}`); } catch (e) { if (e instanceof Error) { errorMessage = e.message; - await job.log(`Failed to get userOpReceipt: ${e.message}`); + job.log(`Failed to get userOpReceipt: ${e.message}`); } else { throw e; } @@ -253,13 +232,13 @@ const _mineUserOp = async ( minedAt: new Date(), minedAtBlock: receipt.blockNumber, transactionType: receipt.type, - onchainStatus: userOpReceiptRaw.success ? "success" : "reverted", + onchainStatus: userOpReceipt.success ? "success" : "reverted", gasUsed: receipt.gasUsed, effectiveGasPrice: receipt.effectiveGasPrice, gas: receipt.gasUsed, cumulativeGasUsed: receipt.cumulativeGasUsed, - sender: userOpReceiptRaw.sender as Address, - nonce: userOpReceiptRaw.nonce.toString(), + sender: userOpReceipt.sender as Address, + nonce: userOpReceipt.nonce.toString(), errorMessage, }; }; diff --git a/src/worker/tasks/sendTransactionWorker.ts b/src/worker/tasks/sendTransactionWorker.ts index c83b3736f..783530ba0 100644 --- a/src/worker/tasks/sendTransactionWorker.ts +++ b/src/worker/tasks/sendTransactionWorker.ts @@ -68,29 +68,12 @@ const handler: Processor = async (job: Job) => { job.data, ); - let transaction = await TransactionDB.get(queueId); + const transaction = await TransactionDB.get(queueId); if (!transaction) { job.log(`Invalid transaction state: ${stringify(transaction)}`); return; } - // The transaction may be errored if it is manually retried. - // For example, the developer retried all failed transactions during an RPC outage. - // An errored queued transaction (resendCount = 0) is safe to retry: the transaction wasn't sent to RPC. - if (transaction.status === "errored" && resendCount === 0) { - const { errorMessage, ...omitted } = transaction; - transaction = { - ...omitted, - status: "queued", - resendCount: 0, - queueId: transaction.queueId, - queuedAt: transaction.queuedAt, - value: transaction.value, - data: transaction.data, - manuallyResentAt: new Date(), - } satisfies QueuedTransaction; - } - let resultTransaction: | SentTransaction // Transaction sent successfully. | ErroredTransaction // Transaction failed and will not be retried. diff --git a/test/e2e/tests/routes/write.test.ts b/test/e2e/tests/routes/write.test.ts index 1cad9a98d..d891055d6 100644 --- a/test/e2e/tests/routes/write.test.ts +++ b/test/e2e/tests/routes/write.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, test } from "bun:test"; import assert from "node:assert"; -import { type Address, stringToHex } from "thirdweb"; +import { stringToHex, type Address } from "thirdweb"; import { zeroAddress } from "viem"; import type { ApiError } from "../../../../sdk/dist/thirdweb-dev-engine.cjs.js"; import { CONFIG } from "../../config"; @@ -69,7 +69,7 @@ describe("/contract/write route", () => { expect(writeTransactionStatus.minedAt).toBeDefined(); }); - test.only("Write to a contract with untyped args", async () => { + test("Write to a contract with untyped args", async () => { const res = await engine.deploy.deployNftDrop( CONFIG.CHAIN.id.toString(), backendWallet,