From b5dd8260b7dd304b2ab63f93489998c8181cd1a6 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Thu, 19 Sep 2024 18:19:36 +0530 Subject: [PATCH] feat: Add getTxLogs route to retrieve transaction logs (#656) * feat: Add getTxLogs route to retrieve transaction logs * CR comments, accept parseLogs bool * fix: Ensure either queue ID or transaction hash is provided in getTransactionLogs --------- Co-authored-by: Phillip Ho --- src/server/routes/index.ts | 4 +- .../routes/transaction/blockchain/getLogs.ts | 229 ++++++++++++++++++ src/server/schemas/address.ts | 7 + 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/server/routes/transaction/blockchain/getLogs.ts diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 6d4baae85..0d79faf2b 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,4 +1,4 @@ -import { FastifyInstance } from "fastify"; +import type { FastifyInstance } from "fastify"; import { getNonceDetailsRoute } from "./admin/nonces"; import { getTransactionDetails } from "./admin/transaction"; import { createAccessToken } from "./auth/access-tokens/create"; @@ -93,6 +93,7 @@ import { revokeRelayer } from "./relayer/revoke"; import { updateRelayer } from "./relayer/update"; import { healthCheck } from "./system/health"; import { queueStatus } from "./system/queue"; +import { getTransactionLogs } from "./transaction/blockchain/getLogs"; import { getTxHashReceipt } from "./transaction/blockchain/getTxReceipt"; import { getUserOpReceipt } from "./transaction/blockchain/getUserOpReceipt"; import { sendSignedTransaction } from "./transaction/blockchain/sendSignedTx"; @@ -226,6 +227,7 @@ export const withRoutes = async (fastify: FastifyInstance) => { await fastify.register(sendSignedUserOp); await fastify.register(getTxHashReceipt); await fastify.register(getUserOpReceipt); + await fastify.register(getTransactionLogs); // Extensions await fastify.register(accountFactoryRoutes); diff --git a/src/server/routes/transaction/blockchain/getLogs.ts b/src/server/routes/transaction/blockchain/getLogs.ts new file mode 100644 index 000000000..3ae3b358e --- /dev/null +++ b/src/server/routes/transaction/blockchain/getLogs.ts @@ -0,0 +1,229 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { AbiEvent } from "abitype"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import superjson from "superjson"; +import { + eth_getTransactionReceipt, + getContract, + getRpcClient, + parseEventLogs, + prepareEvent, + type Hex, +} from "thirdweb"; +import { resolveContractAbi } from "thirdweb/contract"; +import { TransactionDB } from "../../../../db/transactions/db"; +import { getChain } from "../../../../utils/chain"; +import { thirdwebClient } from "../../../../utils/sdk"; +import { createCustomError } from "../../../middleware/error"; +import { AddressSchema, TransactionHashSchema } from "../../../schemas/address"; +import { standardResponseSchema } from "../../../schemas/sharedApiSchemas"; +import { getChainIdFromChain } from "../../../utils/chain"; + +// INPUT +const requestQuerystringSchema = Type.Object({ + chain: Type.String({ + examples: ["80002"], + description: "Chain ID or name", + }), + queueId: Type.Optional( + Type.String({ + description: "The queue ID for a mined transaction.", + }), + ), + transactionHash: Type.Optional({ + ...TransactionHashSchema, + description: "The transaction hash for a mined transaction.", + }), + parseLogs: Type.Optional( + Type.Boolean({ + description: + "If true, parse the raw logs as events defined in the contract ABI. (Default: true)", + }), + ), +}); + +// OUTPUT +const LogSchema = Type.Object({ + address: AddressSchema, + topics: Type.Array(Type.String()), + data: Type.String(), + blockNumber: Type.String(), + transactionHash: TransactionHashSchema, + transactionIndex: Type.Number(), + blockHash: Type.String(), + logIndex: Type.Number(), + removed: Type.Boolean(), +}); + +const ParsedLogSchema = Type.Object({ + ...LogSchema.properties, + eventName: Type.String(), + args: Type.Unknown({ + description: "Event arguments.", + examples: [ + { + from: "0xdeadbeeefdeadbeeefdeadbeeefdeadbeeefdead", + to: "0xdeadbeeefdeadbeeefdeadbeeefdeadbeeefdead", + value: "1000000000000000000n", + }, + ], + }), +}); + +export const responseBodySchema = Type.Object({ + result: Type.Union([ + // ParsedLogSchema is listed before LogSchema because it is more specific. + Type.Array(ParsedLogSchema), + Type.Array(LogSchema), + ]), +}); + +responseBodySchema.example = { + result: [ + { + eventName: "Transfer", + args: { + from: "0x0000000000000000000000000000000000000000", + to: "0x71B6267b5b2b0B64EE058C3D27D58e4E14e7327f", + value: "1000000000000000000n", + }, + address: "0x71b6267b5b2b0b64ee058c3d27d58e4e14e7327f", + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x00000000000000000000000071b6267b5b2b0b64ee058c3d27d58e4e14e7327f", + ], + data: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", + blockNumber: "79326434", + transactionHash: + "0x568eb49d738f7c02ebb24aa329efcf10883d951b1e13aa000b0e073d54a0246e", + transactionIndex: 1, + blockHash: + "0xaffbcf3232a76152206de5f6999c549404efc76060a34f8826b90c95993464c3", + logIndex: 0, + removed: false, + }, + ], +}; + +export async function getTransactionLogs(fastify: FastifyInstance) { + fastify.route<{ + Querystring: Static; + Reply: Static; + }>({ + method: "GET", + url: "/transaction/logs", + schema: { + summary: "Get transaction logs", + description: + "Get transaction logs for a mined transaction. A tranasction queue ID or hash must be provided. Set `parseLogs` to parse the event logs.", + tags: ["Transaction"], + operationId: "getTransactionLogs", + querystring: requestQuerystringSchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseBodySchema, + }, + }, + handler: async (request, reply) => { + const { + chain: inputChain, + queueId, + transactionHash, + parseLogs = true, + } = request.query; + + const chainId = await getChainIdFromChain(inputChain); + const chain = await getChain(chainId); + const rpcRequest = getRpcClient({ + client: thirdwebClient, + chain, + }); + + if (!queueId && !transactionHash) { + throw createCustomError( + "Either a queue ID or transaction hash must be provided.", + StatusCodes.BAD_REQUEST, + "MISSING_TRANSACTION_ID", + ); + } + + // Get the transaction hash from the provided input. + let hash: Hex | undefined; + if (queueId) { + const transaction = await TransactionDB.get(queueId); + if (transaction?.status === "mined") { + hash = transaction.transactionHash; + } + } else if (transactionHash) { + hash = transactionHash as Hex; + } + if (!hash) { + throw createCustomError( + "Could not find transaction, or transaction is not mined.", + StatusCodes.BAD_REQUEST, + "TRANSACTION_NOT_MINED", + ); + } + + // Try to get the receipt. + const transactionReceipt = await eth_getTransactionReceipt(rpcRequest, { + hash, + }); + if (!transactionReceipt) { + throw createCustomError( + "Cannot get logs for a transaction that is not mined.", + StatusCodes.BAD_REQUEST, + "TRANSACTION_NOT_MINED", + ); + } + + if (!parseLogs) { + return reply.status(StatusCodes.OK).send({ + result: superjson.serialize(transactionReceipt.logs).json as Static< + typeof LogSchema + >[], + }); + } + + if (!transactionReceipt.to) { + throw createCustomError( + "Transaction logs are only supported for contract calls.", + StatusCodes.BAD_REQUEST, + "TRANSACTION_LOGS_UNAVAILABLE", + ); + } + + const contract = getContract({ + address: transactionReceipt.to, + chain, + client: thirdwebClient, + }); + + const abi: AbiEvent[] = await resolveContractAbi(contract); + const eventSignatures = abi.filter((item) => item.type === "event"); + if (eventSignatures.length === 0) { + throw createCustomError( + "No events found in contract or could not resolve contract ABI", + StatusCodes.BAD_REQUEST, + "NO_EVENTS_FOUND", + ); + } + + const preparedEvents = eventSignatures.map((signature) => + prepareEvent({ signature }), + ); + const parsedLogs = parseEventLogs({ + events: preparedEvents, + logs: transactionReceipt.logs, + }); + + reply.status(StatusCodes.OK).send({ + result: superjson.serialize(parsedLogs).json as Static< + typeof ParsedLogSchema + >[], + }); + }, + }); +} diff --git a/src/server/schemas/address.ts b/src/server/schemas/address.ts index f81d5e1ba..7610e067b 100644 --- a/src/server/schemas/address.ts +++ b/src/server/schemas/address.ts @@ -14,3 +14,10 @@ export const AddressSchema = Type.RegExp(/^0x[a-fA-F0-9]{40}$/, { description: "A contract or wallet address", examples: ["0x152e208d08cd3ea1aa5d179b2e3eba7d1a733ef4"], }); + +export const TransactionHashSchema = Type.RegExp(/^0x[a-fA-F0-9]{64}$/, { + description: "A transaction hash", + examples: [ + "0x1f31b57601a6f90312fd5e57a2924bc8333477de579ee37b197a0681ab438431", + ], +});