diff --git a/src/api/routers/v1/explorer/explorer.handler.ts b/src/api/routers/v1/explorer/explorer.handler.ts index 0c1d8604..3a2165e4 100644 --- a/src/api/routers/v1/explorer/explorer.handler.ts +++ b/src/api/routers/v1/explorer/explorer.handler.ts @@ -1,21 +1,31 @@ import { withTrace } from "@/common/utils/trace-wrapper"; -import { type GetQuoteOptions, QuotesService } from "@/quotes"; +import { type GetQuoteOptions, QuotesService, getQuoteSchema } from "@/quotes"; import { type RequestHandler } from "express"; import { Container } from "typedi"; import { API_VERSION } from "../constants"; export const explorerHandler: RequestHandler< - GetQuoteOptions, + Pick, unknown, never, - never + unknown > = async (req, res) => { - const { params } = req; + const { params, query } = req; + + // This query cannot be validated, parsed and attached to req object in validate middleware due to query being a strict readonly property in req object. + const validatedQuery = await getQuoteSchema + .pick({ confirmations: true }) + .parseAsync(query); await withTrace( `/${API_VERSION}/explorer`, async (_req, res) => { - res.send(await Container.get(QuotesService).getQuote(params)); + res.send( + await Container.get(QuotesService).getQuote({ + ...params, + ...validatedQuery, + }), + ); }, { hash: params.hash, diff --git a/src/api/routers/v1/index.ts b/src/api/routers/v1/index.ts index 506498fa..7df7614d 100644 --- a/src/api/routers/v1/index.ts +++ b/src/api/routers/v1/index.ts @@ -10,16 +10,14 @@ import { PATHS } from "./constants"; import { execHandler } from "./exec"; import { explorerHandler } from "./explorer"; import { infoHandler } from "./info"; -import { openAPIHandler } from "./openapi"; import { quoteHandler, quotePermitHandler } from "./quote"; export const v1Router = Router() - .get(PATHS.index, openAPIHandler) .get(PATHS.info, infoHandler) .get( `${PATHS.explorer}:hash`, validate({ - params: getQuoteSchema, + params: getQuoteSchema.pick({ hash: true }), }), explorerHandler, ) diff --git a/src/api/routers/v1/openapi/index.ts b/src/api/routers/v1/openapi/index.ts deleted file mode 100644 index 48b2780f..00000000 --- a/src/api/routers/v1/openapi/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./openapi.handler"; diff --git a/src/api/routers/v1/openapi/openapi.doc.ts b/src/api/routers/v1/openapi/openapi.doc.ts deleted file mode 100644 index 6733443c..00000000 --- a/src/api/routers/v1/openapi/openapi.doc.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - executeQuoteSchema, - getQuoteSchema, - requestQuotePermitSchema, - requestQuoteSchema, -} from "@/quotes"; -import { createDocument } from "zod-openapi"; -import { API_VERSION, PATHS } from "../constants"; - -export const openAPIDoc = createDocument({ - openapi: "3.0.0", - info: { - title: "MEE Node API", - description: "The Biconomy MEE (Modular Execution Environment) Node API", - version: API_VERSION, - }, - paths: { - [PATHS.info]: { - get: { - tags: ["Info"], - responses: { - // TODO: Add description - [200]: {}, - }, - }, - }, - [`${PATHS.explorer}{hash}`]: { - get: { - tags: ["Explorer"], - requestParams: { - path: getQuoteSchema, - }, - responses: { - // TODO: Add descriptions - [200]: {}, - [400]: {}, - [404]: {}, - }, - }, - }, - [PATHS.quote]: { - post: { - tags: ["Quote"], - requestBody: { - content: { - "application/json": { - schema: requestQuoteSchema, - }, - }, - }, - responses: { - // TODO: Add descriptions - [200]: {}, - [400]: {}, - }, - }, - }, - [PATHS.quotePermit]: { - post: { - tags: ["Quote"], - requestBody: { - content: { - "application/json": { - schema: requestQuotePermitSchema, - }, - }, - }, - responses: { - // TODO: Add descriptions - [200]: {}, - [400]: {}, - }, - }, - }, - [PATHS.exec]: { - post: { - tags: ["Exec"], - requestBody: { - content: { - "application/json": { - schema: executeQuoteSchema, - }, - }, - }, - responses: { - // TODO: Add descriptions - [200]: {}, - [400]: {}, - [404]: {}, - }, - }, - }, - }, - components: { - schemas: { - ExplorerParams: getQuoteSchema, - QuoteBody: requestQuoteSchema, - QuotePermitBody: requestQuotePermitSchema, - ExecBody: executeQuoteSchema, - }, - }, -}); diff --git a/src/api/routers/v1/openapi/openapi.handler.ts b/src/api/routers/v1/openapi/openapi.handler.ts deleted file mode 100644 index 59f4d82d..00000000 --- a/src/api/routers/v1/openapi/openapi.handler.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type RequestHandler } from "express"; -import { API_VERSION } from "../constants"; -import { openAPIDoc } from "./openapi.doc"; - -export const openAPIHandler: RequestHandler< - never, - unknown, - never, - { - download?: unknown; - } -> = (req, res) => { - const { - query: { download }, - } = req; - - // `/?download - if (typeof download !== "undefined") { - res.set( - "Content-Disposition", - `attachment;filename=mee-node-api-${API_VERSION}.json`, - ); - } - - res.send(openAPIDoc); -}; diff --git a/src/modules/executor/executor.processor.ts b/src/modules/executor/executor.processor.ts index eadf6617..a83583ff 100644 --- a/src/modules/executor/executor.processor.ts +++ b/src/modules/executor/executor.processor.ts @@ -104,7 +104,7 @@ export class ExecutorProcessor implements Processor { const transactionReceipt = await withTrace( "executionPhase.defaultBlockTxReceiptForPreviousTxHash", async () => { - // Fallback RPC providers are skipped here to avoid timeout retries on all fallbacks which is time consuming + // Tx receipt is always fetched from primary RPC provider to make sure to avoid RPC node state sync issues in confirmations const { client } = this.rpcManagerService.getPrimaryRpcProvider(chainId); @@ -1050,6 +1050,9 @@ export class ExecutorProcessor implements Processor { this.storageService.updateUserOpCustomFields(meeUserOpHash, { txHash, isConfirmed, + confirmations: isConfirmed + ? BigInt(this.chainsService.chainSettings.waitConfirmations) + : 1n, executionFinishedAt: Date.now(), }); } @@ -1085,6 +1088,11 @@ export class ExecutorProcessor implements Processor { // TODO: If there are any ways to match these fee number, we should explore this in the future. actualGasCost, isConfirmed, + confirmations: isConfirmed + ? BigInt( + this.chainsService.chainSettings.waitConfirmations, + ) + : 1n, ...(success ? {} : { @@ -1140,6 +1148,9 @@ export class ExecutorProcessor implements Processor { this.storageService.updateUserOpCustomFields(meeUserOpHash, { txHash, isConfirmed, + confirmations: isConfirmed + ? BigInt(this.chainsService.chainSettings.waitConfirmations) + : 1n, executionFinishedAt: Date.now(), error: "Failed to execute userOp", }); diff --git a/src/modules/quotes/quotes.service.ts b/src/modules/quotes/quotes.service.ts index b697c220..a046b1f2 100644 --- a/src/modules/quotes/quotes.service.ts +++ b/src/modules/quotes/quotes.service.ts @@ -47,6 +47,7 @@ import { Service } from "typedi"; import { type GetTransactionReturnType, type Hex, + TransactionReceiptNotFoundError, concat, concatHex, decodeAbiParameters, @@ -663,7 +664,7 @@ export class QuotesService { async executeQuote( requestId: string, options: ExecuteQuoteOptions, - ): Promise { + ): Promise> { const { commitment, hash, @@ -1477,7 +1478,7 @@ export class QuotesService { } async getQuote(options: GetQuoteOptions) { - const { hash } = options; + const { hash, confirmations: customConfirmations } = options; const quote = await withTrace( "explorer.getQuote", @@ -1491,13 +1492,8 @@ export class QuotesService { const { node, commitment, paymentInfo, userOps, trigger } = quote; - return { - itxHash: hash, - node, - commitment, - paymentInfo, - fundingTransaction: trigger, - userOps: userOps.map((userOp) => { + const userOpInfo = await Promise.all( + userOps.map(async (userOp, index) => { let executionStatus = ExecutionStatus.PENDING; const { @@ -1510,13 +1506,23 @@ export class QuotesService { simulationTransactionData, actualGasCost, isExecutionSkipped, + isConfirmed, + confirmations: confirmationsFromStorage, ...rest } = userOp; + let isTransactionConfirmed = isConfirmed || false; + + const { waitConfirmations } = this.chainsService.getChainSettings( + userOp.chainId, + ); + + const isTxHashExists = executionData && executionData.length > 0; + if (isExecutionSkipped) { executionStatus = ExecutionStatus.SKIPPED; } else { - if (executionData && executionData.length > 0) { + if (isTxHashExists) { if (minedTimestamp) { if (executionError && executionError.length > 0) { executionStatus = ExecutionStatus.MINED_FAIL; @@ -1533,11 +1539,145 @@ export class QuotesService { } } - return { + const isTxSubmittedOnChain = [ + ExecutionStatus.MINED_SUCCESS, + ExecutionStatus.MINED_FAIL, + ].includes(executionStatus); + + // Defaults to zero + let confirmations = 0n; + + if (isTxSubmittedOnChain) { + // If the tx got executed, the confirmations are considered from storage. If confirms does not exists in storage ? + // It will defaults to chain config confirms or fast block confirms for backwards compatibility. + confirmations = + confirmationsFromStorage || + (isTransactionConfirmed ? BigInt(waitConfirmations) : 1n); + } + + // Only when non payment userOp and custom confirmations > node's default confirmations for the chain. + const isCustomBlockConfirmationsToBeApplied = + index !== 0 && customConfirmations > waitConfirmations; + + const txReceiptError = { + isError: false, + isTxDropped: false, + errorMessage: "", + }; + + // Only when txHash exists, tx got submitted on chain and custom confirmations is requested. + if ( + isTxHashExists && + isTxSubmittedOnChain && + isCustomBlockConfirmationsToBeApplied + ) { + // Check if existing confirmations are enough and meets the custom confirms requirement. + const isConfirmationsNotEnough = customConfirmations > confirmations; + + if (isConfirmationsNotEnough) { + try { + const [transactionReceipt, currentBlockNumber] = + await Promise.all([ + withTrace( + "explorer.getTxReceipt", + async () => + await this.rpcManagerService.executeRequest( + userOp.chainId, + (chainClient) => { + return chainClient.getTransactionReceipt({ + hash: executionData, + }); + }, + ), + { + chainId: userOp.chainId, + meeUserOpHash: userOp.meeUserOpHash, + }, + )(), + withTrace( + "explorer.blockNumber", + async () => + await this.rpcManagerService.executeRequest( + userOp.chainId, + (chainClient) => { + return chainClient.getBlockNumber(); + }, + ), + { + chainId: userOp.chainId, + meeUserOpHash: userOp.meeUserOpHash, + }, + )(), + ]); + + // If there is a reorg happened and the tx is remined in a new block ? The block number can vary. + // So the number of confirmations might reduced from the previous one as well. However, the new confirmations value + // will be stored from here which handles the reorg case. + confirmations = + currentBlockNumber - transactionReceipt.blockNumber + 1n; + + // Custom confirmations are optimistically stored in the storage layer. So we can avoid RPC calls if customConfirmation is + // less than the actual confirmations recorded on chain. + this.storageService.updateUserOpCustomFields( + userOp.meeUserOpHash, + { + confirmations, + }, + ); + } catch (error) { + txReceiptError.isError = true; + + let errorMessage = + "Failed to fetch block confirmations for the transaction"; + + if (error instanceof TransactionReceiptNotFoundError) { + // if there is any existing confirmations but the tx was dropped after few blocks either due to reorg or RPC node issue. + const isTxDrop = confirmations > 0n; + + if (isTxDrop) { + txReceiptError.isTxDropped = true; + errorMessage = + "The transaction might be dropped due to possible block reorg or node propagation failure."; + + // update the state in storage layer as well. The txHash is no longer valid + this.storageService.updateUserOpCustomFields( + userOp.meeUserOpHash, + { + error: errorMessage, + }, + ); + } + } + + txReceiptError.errorMessage = errorMessage; + + this.logger.error( + { + error, + errorMessage, + executionData, + meeUserOpHash: userOp.meeUserOpHash, + chainId: userOp.chainId, + }, + "Failed to fetch transaction receipt and block number on explorer request", + ); + } + } + + const isCustomConfirmationsSatisfied = + confirmations >= customConfirmations; + + isTransactionConfirmed = isCustomConfirmationsSatisfied + ? isTransactionConfirmed + : false; + } + + const explorerResponse = { ...omit(rest, ["simulationAttempts", "batchHash"]), executionStatus, executionData, - isConfirmed: userOp.isConfirmed || false, + isConfirmed: isTransactionConfirmed, + confirmations, actualGasCost, executionError, revertError, @@ -1549,7 +1689,29 @@ export class QuotesService { ? { simulationTransactionData } : {}), }; + + if (txReceiptError.isError === true) { + if (txReceiptError.isTxDropped === true) { + // If there is a txDrop issue ? The txHash no longer has any on chain submission. So we mark the tx as failed and update the error message + explorerResponse.executionStatus = ExecutionStatus.FAILED; + explorerResponse.executionError = txReceiptError.errorMessage; + } + + // If its any other error, it is purely considered as RPC error and do nothing. + // In worst case, the confirmations will not be updated from RPC call and the tx will stay isConfirmed false until the RPC issue is fixed. + } + + return explorerResponse; }), + ); + + return { + itxHash: hash, + node, + commitment, + paymentInfo, + fundingTransaction: trigger, + userOps: userOpInfo, }; } diff --git a/src/modules/quotes/schemas.ts b/src/modules/quotes/schemas.ts index 2248edc0..fe51cd04 100644 --- a/src/modules/quotes/schemas.ts +++ b/src/modules/quotes/schemas.ts @@ -139,4 +139,5 @@ export const meeQuoteSchema = executeQuoteSchema.omit({ export const getQuoteSchema = z.object({ hash: hashSchema, + confirmations: bigIntLikeSchema.default(3n), }); diff --git a/src/modules/user-ops/interfaces.ts b/src/modules/user-ops/interfaces.ts index 68afc1ea..0ff4b40b 100644 --- a/src/modules/user-ops/interfaces.ts +++ b/src/modules/user-ops/interfaces.ts @@ -87,6 +87,7 @@ export interface UserOpEntityCustomFields { actualGasCost?: bigint; revertReason?: string; isConfirmed?: boolean; + confirmations?: bigint; isExecutionSkipped?: boolean; simulationTransactionData?: SimulationTransactionData; stateTransitions?: {