diff --git a/src/executor/executor.ts b/src/executor/executor.ts index 78f58fbb..cf996459 100644 --- a/src/executor/executor.ts +++ b/src/executor/executor.ts @@ -30,9 +30,10 @@ import { getContract, type Account, type Hex, - NonceTooHighError + NonceTooHighError, + BaseError } from "viem" -import { getAuthorizationList } from "./utils" +import { getAuthorizationList, isTransactionUnderpricedError } from "./utils" import type { SendTransactionErrorType } from "viem" import type { AltoConfig } from "../createConfig" import type { SendTransactionOptions } from "./types" @@ -47,7 +48,7 @@ export interface GasEstimateResult { export type HandleOpsTxParam = { ops: UserOperation[] - isUserOpVersion06: boolean + isUserOpV06: boolean isReplacementTx: boolean entryPoint: Address } @@ -109,6 +110,7 @@ export class Executor { cancelOps(_entryPoint: Address, _ops: UserOperation[]): Promise { throw new Error("Method not implemented.") } + async sendHandleOpsTransaction({ txParam, opts @@ -132,17 +134,17 @@ export class Executor { nonce: number } }) { - const { isUserOpVersion06, ops, entryPoint } = txParam + const { isUserOpV06, ops, entryPoint } = txParam const packedOps = ops.map((op) => { - if (isUserOpVersion06) { + if (isUserOpV06) { return op } return toPackedUserOperation(op as UserOperationV07) }) as PackedUserOperation[] const data = encodeFunctionData({ - abi: isUserOpVersion06 ? EntryPointV06Abi : EntryPointV07Abi, + abi: isUserOpV06 ? EntryPointV06Abi : EntryPointV07Abi, functionName: "handleOps", args: [packedOps, opts.account.address] }) @@ -168,7 +170,7 @@ export class Executor { try { if ( this.config.enableFastlane && - isUserOpVersion06 && + isUserOpV06 && !txParam.isReplacementTx && attempts === 0 ) { @@ -190,6 +192,21 @@ export class Executor { break } catch (e: unknown) { + if (e instanceof BaseError) { + if (isTransactionUnderpricedError(e)) { + this.logger.warn("Transaction underpriced, retrying") + + request.maxFeePerGas = scaleBigIntByPercent( + request.maxFeePerGas, + 150n + ) + request.maxPriorityFeePerGas = scaleBigIntByPercent( + request.maxPriorityFeePerGas, + 150n + ) + } + } + const error = e as SendTransactionErrorType if (error instanceof TransactionExecutionError) { @@ -243,18 +260,21 @@ export class Executor { bundle, nonce, gasPriceParameters, - gasLimitSuggestion + gasLimitSuggestion, + isReplacementTx }: { wallet: Account bundle: UserOperationBundle nonce: number gasPriceParameters: GasPriceParameters gasLimitSuggestion?: bigint + isReplacementTx: boolean }): Promise { const { entryPoint, userOperations, version } = bundle + const isUserOpV06 = version === "0.6" const ep = getContract({ - abi: version === "0.6" ? EntryPointV06Abi : EntryPointV07Abi, + abi: isUserOpV06 ? EntryPointV06Abi : EntryPointV07Abi, address: entryPoint, client: { public: this.config.publicClient, @@ -268,7 +288,7 @@ export class Executor { }) let estimateResult = await filterOpsAndEstimateGas({ - isUserOpV06: version === "0.6", + isUserOpV06, ops: userOperations, ep, wallet, @@ -280,18 +300,17 @@ export class Executor { logger: childLogger }) - if (estimateResult.status === "unexpectedFailure") { + if (estimateResult.status === "unexpected_failure") { childLogger.error( "gas limit simulation encountered unexpected failure" ) return { status: "unhandled_simulation_failure", - reason: "INTERNAL FAILURE", - userOps: userOperations + reason: "INTERNAL FAILURE" } } - if (estimateResult.status === "allOpsFailedSimulation") { + if (estimateResult.status === "all_ops_failed_simulation") { childLogger.warn("all ops failed simulation") return { status: "all_ops_failed_simulation", @@ -307,6 +326,17 @@ export class Executor { entryPoint }) + // https://github.com/eth-infinitism/account-abstraction/blob/fa61290d37d079e928d92d53a122efcc63822214/contracts/core/EntryPoint.sol#L236 + let innerHandleOpFloor = 0n + for (const op of opsToBundle) { + innerHandleOpFloor += + op.callGasLimit + op.verificationGasLimit + 5000n + } + + if (gasLimit < innerHandleOpFloor) { + gasLimit += innerHandleOpFloor + } + // sometimes the estimation rounds down, adding a fixed constant accounts for this gasLimit += 10_000n gasLimit = gasLimitSuggestion @@ -353,8 +383,8 @@ export class Executor { transactionHash = await this.sendHandleOpsTransaction({ txParam: { ops: opsToBundle, - isReplacementTx: false, - isUserOpVersion06: version === "0.6", + isReplacementTx, + isUserOpV06, entryPoint }, opts @@ -366,11 +396,14 @@ export class Executor { }) } catch (err: unknown) { const e = parseViemError(err) + + const { failedOps, opsToBundle } = estimateResult if (e) { return { + rejectedUserOps: failedOps, + userOpsToBundle: opsToBundle, status: "bundle_submission_failure", - reason: e, - userOps: userOperations + reason: e } } @@ -380,9 +413,10 @@ export class Executor { "error submitting bundle transaction" ) return { + rejectedUserOps: failedOps, + userOpsToBundle: opsToBundle, status: "bundle_submission_failure", - reason: "INTERNAL FAILURE", - userOps: userOperations + reason: "INTERNAL FAILURE" } } diff --git a/src/executor/executorManager.ts b/src/executor/executorManager.ts index bf686f17..38b418d6 100644 --- a/src/executor/executorManager.ts +++ b/src/executor/executorManager.ts @@ -135,7 +135,7 @@ export class ExecutorManager { (timestamp) => now - timestamp < RPM_WINDOW ) - const bundles = await this.getMempoolBundles() + const bundles = await this.mempool.getBundles() if (bundles.length > 0) { const opsCount: number = bundles @@ -166,29 +166,9 @@ export class ExecutorManager { } } - async getMempoolBundles( - maxBundleCount?: number - ): Promise { - const bundlePromises = this.config.entrypoints.map( - async (entryPoint) => { - return await this.mempool.process({ - entryPoint, - maxGasLimit: this.config.maxGasPerBundle, - minOpsPerBundle: 1, - maxBundleCount - }) - } - ) - - const bundlesNested = await Promise.all(bundlePromises) - const bundles = bundlesNested.flat() - - return bundles - } - // Debug endpoint async sendBundleNow(): Promise { - const bundle = (await this.getMempoolBundles(1))[0] + const bundle = (await this.mempool.getBundles(1))[0] if (bundle.userOperations.length === 0) { throw new Error("no ops to bundle") @@ -220,22 +200,26 @@ export class ExecutorManager { blockTag: "latest" }) ]).catch((_) => { + return [] + }) + + if (!gasPriceParameters || nonce === undefined) { this.resubmitUserOperations( bundle.userOperations, bundle.entryPoint, "Failed to get nonce and gas parameters for bundling" ) + // Free executor if failed to get initial params. this.senderManager.markWalletProcessed(wallet) - return [] - }) - - if (!gasPriceParameters || nonce === undefined) return undefined + return undefined + } const bundleResult = await this.executor.bundle({ wallet, bundle, nonce, - gasPriceParameters + gasPriceParameters, + isReplacementTx: false }) // Free wallet if no bundle was sent. @@ -250,10 +234,11 @@ export class ExecutorManager { return undefined } - // Unhandled error during simulation. + // Unhandled error during simulation, drop all ops. if (bundleResult.status === "unhandled_simulation_failure") { - const { reason, userOps } = bundleResult - const rejectedUserOps = userOps.map((op) => ({ + const { reason } = bundleResult + const { userOperations } = bundle + const rejectedUserOps = userOperations.map((op) => ({ userOperation: op, reason })) @@ -267,27 +252,35 @@ export class ExecutorManager { bundleResult.status === "bundle_submission_failure" && bundleResult.reason instanceof InsufficientFundsError ) { - const { userOps, reason } = bundleResult - this.resubmitUserOperations(userOps, entryPoint, reason.name) + const { reason, userOpsToBundle, rejectedUserOps } = bundleResult + this.dropUserOps(rejectedUserOps) + this.resubmitUserOperations( + userOpsToBundle, + entryPoint, + reason.name + ) this.metrics.bundlesSubmitted.labels({ status: "resubmit" }).inc() return undefined } // Encountered unhandled error during bundle simulation. if (bundleResult.status === "bundle_submission_failure") { - const { userOps } = bundleResult - const droppedUserOperations = userOps.map((op) => ({ - userOperation: op, - reason: "INTERNAL FAILURE" - })) - this.dropUserOps(droppedUserOperations) + const { rejectedUserOps, userOpsToBundle, reason } = bundleResult + // NOTE: these ops passed validation but dropped due to error during bundling + this.dropUserOps(rejectedUserOps) + this.resubmitUserOperations( + userOpsToBundle, + entryPoint, + reason instanceof BaseError ? reason.name : "INTERNAL FAILURE" + ) this.metrics.bundlesSubmitted.labels({ status: "failed" }).inc() + return undefined } if (bundleResult.status === "bundle_success") { const { userOpsBundled, - rejectedUserOperations, + rejectedUserOps: rejectedUserOperations, transactionRequest, transactionHash } = bundleResult @@ -343,16 +336,13 @@ export class ExecutorManager { } // update the current status of the bundling transaction/s - private async refreshTransactionStatus( - entryPoint: Address, - transactionInfo: TransactionInfo - ) { + private async refreshTransactionStatus(transactionInfo: TransactionInfo) { const { transactionHash: currentTransactionHash, bundle, previousTransactionHashes } = transactionInfo - const { userOperations, version } = bundle + const { userOperations, version, entryPoint } = bundle const isVersion06 = version === "0.6" const txHashesToCheck = [ @@ -393,6 +383,24 @@ export class ExecutorManager { transactionHash: `0x${string}` } + // TODO: there has to be a better way of solving onchain AA95 errors. + if (bundlingStatus.status === "reverted" && bundlingStatus.isAA95) { + // resubmit with more gas when bundler encounters AA95 + transactionInfo.transactionRequest.gas = scaleBigIntByPercent( + transactionInfo.transactionRequest.gas, + this.config.aa95GasMultiplier + ) + transactionInfo.transactionRequest.nonce += 1 + + await this.replaceTransaction(transactionInfo, "AA95") + return + } + + // Free executor if tx landed onchain + if (bundlingStatus.status !== "not_found") { + this.senderManager.markWalletProcessed(transactionInfo.executor) + } + if (bundlingStatus.status === "included") { this.metrics.userOperationsOnChain .labels({ status: bundlingStatus.status }) @@ -439,21 +447,9 @@ export class ExecutorManager { "user op included" ) }) + } - this.senderManager.markWalletProcessed(transactionInfo.executor) - } else if ( - bundlingStatus.status === "reverted" && - bundlingStatus.isAA95 - ) { - // resubmit with more gas when bundler encounters AA95 - transactionInfo.transactionRequest.gas = scaleBigIntByPercent( - transactionInfo.transactionRequest.gas, - this.config.aa95GasMultiplier - ) - transactionInfo.transactionRequest.nonce += 1 - - await this.replaceTransaction(transactionInfo, "AA95") - } else { + if (bundlingStatus.status === "reverted") { await Promise.all( userOperations.map((userOperation) => { this.checkFrontrun({ @@ -463,11 +459,7 @@ export class ExecutorManager { }) }) ) - - userOperations.map((userOperation) => { - this.mempool.removeSubmitted(userOperation.hash) - }) - this.senderManager.markWalletProcessed(transactionInfo.executor) + this.removeSubmitted(userOperations) } } @@ -667,43 +659,6 @@ export class ExecutorManager { return userOperationReceipt } - async refreshUserOperationStatuses(): Promise { - const ops = this.mempool.dumpSubmittedOps() - - const opEntryPointMap = new Map() - - for (const op of ops) { - if (!opEntryPointMap.has(op.userOperation.entryPoint)) { - opEntryPointMap.set(op.userOperation.entryPoint, []) - } - opEntryPointMap.get(op.userOperation.entryPoint)?.push(op) - } - - await Promise.all( - this.config.entrypoints.map(async (entryPoint) => { - const ops = opEntryPointMap.get(entryPoint) - - if (ops) { - const txs = getTransactionsFromUserOperationEntries(ops) - - await Promise.all( - txs.map(async (txInfo) => { - await this.refreshTransactionStatus( - entryPoint, - txInfo - ) - }) - ) - } else { - this.logger.warn( - { entryPoint }, - "no user operations for entry point" - ) - } - }) - ) - } - async handleBlock(block: Block) { if (this.currentlyHandlingBlock) { return @@ -720,23 +675,19 @@ export class ExecutorManager { } // refresh op statuses - await this.refreshUserOperationStatuses() + const ops = this.mempool.dumpSubmittedOps() + const txs = getTransactionsFromUserOperationEntries(ops) + await Promise.all( + txs.map((txInfo) => this.refreshTransactionStatus(txInfo)) + ) // for all still not included check if needs to be replaced (based on gas price) - let gasPriceParameters: { - maxFeePerGas: bigint - maxPriorityFeePerGas: bigint - } - - try { - gasPriceParameters = - await this.gasPriceManager.tryGetNetworkGasPrice() - } catch { - gasPriceParameters = { + const gasPriceParameters = await this.gasPriceManager + .tryGetNetworkGasPrice() + .catch(() => ({ maxFeePerGas: 0n, maxPriorityFeePerGas: 0n - } - } + })) const transactionInfos = getTransactionsFromUserOperationEntries( this.mempool.dumpSubmittedOps() @@ -778,17 +729,22 @@ export class ExecutorManager { const gasPriceParameters = await this.gasPriceManager .tryGetNetworkGasPrice() .catch((_) => { - this.failedToReplaceTransaction({ - txInfo, - reason: "Failed to get network gas price" - }) - this.senderManager.markWalletProcessed(txInfo.executor) - return + return undefined }) - if (!gasPriceParameters) return + if (!gasPriceParameters) { + this.failedToReplaceTransaction({ + txInfo, + reason: "Failed to get network gas price during replacement" + }) + // Free executor if failed to get initial params. + this.senderManager.markWalletProcessed(txInfo.executor) + return + } + // Setup vars const { bundle, executor, transactionRequest } = txInfo + const oldTxHash = txInfo.transactionHash const bundleResult = await this.executor.bundle({ wallet: executor, @@ -804,9 +760,11 @@ export class ExecutorManager { 115n ) }, - gasLimitSuggestion: transactionRequest.gas + gasLimitSuggestion: transactionRequest.gas, + isReplacementTx: true }) + // Log metrics. const replaceStatus = bundleResult && bundleResult.status === "bundle_success" ? "succeeded" @@ -820,6 +778,7 @@ export class ExecutorManager { const nonceTooLow = bundleResult.status === "bundle_submission_failure" && bundleResult.reason instanceof NonceTooLowError + const allOpsFailedSimulation = bundleResult.status === "all_ops_failed_simulation" && bundleResult.rejectedUserOps.every( @@ -827,10 +786,25 @@ export class ExecutorManager { op.reason === "AA25 invalid account nonce" || op.reason === "AA10 sender already constructed" ) + const potentiallyIncluded = nonceTooLow || allOpsFailedSimulation if (potentiallyIncluded) { - this.handlePotentiallyIncluded({ txInfo }) + this.logger.info( + { oldTxHash }, + "transaction potentially already included" + ) + txInfo.timesPotentiallyIncluded += 1 + + if (txInfo.timesPotentiallyIncluded >= 3) { + this.removeSubmitted(bundle.userOperations) + this.logger.warn( + { oldTxHash }, + "transaction potentially already included too many times, removing" + ) + this.senderManager.markWalletProcessed(executor) + } + return } @@ -844,6 +818,7 @@ export class ExecutorManager { txInfo, reason: bundleResult.reason }) + return } @@ -853,6 +828,7 @@ export class ExecutorManager { reason: "all ops failed simulation", rejectedUserOperations: bundleResult.rejectedUserOps }) + return } @@ -871,7 +847,7 @@ export class ExecutorManager { } const { - rejectedUserOperations, + rejectedUserOps: rejectedUserOperations, userOpsBundled, transactionRequest: newTransactionRequest, transactionHash: newTransactionHash @@ -945,31 +921,6 @@ export class ExecutorManager { }) } - handlePotentiallyIncluded({ - txInfo - }: { - txInfo: TransactionInfo - }) { - const { bundle, transactionHash: oldTxHash, executor } = txInfo - - this.logger.info( - { oldTxHash }, - "transaction potentially already included" - ) - txInfo.timesPotentiallyIncluded += 1 - - if (txInfo.timesPotentiallyIncluded >= 3) { - bundle.userOperations.map((userOperation) => { - this.mempool.removeSubmitted(userOperation.hash) - }) - this.logger.warn( - { oldTxHash }, - "transaction potentially already included too many times, removing" - ) - this.senderManager.markWalletProcessed(executor) - } - } - failedToReplaceTransaction({ txInfo, rejectedUserOperations, @@ -979,9 +930,8 @@ export class ExecutorManager { rejectedUserOperations?: RejectedUserOperation[] reason: string }) { - const { executor, transactionHash: oldTxHash } = txInfo + const { transactionHash: oldTxHash } = txInfo this.logger.warn({ oldTxHash, reason }, "failed to replace transaction") - this.senderManager.markWalletProcessed(executor) const opsToDrop = rejectedUserOperations ?? @@ -992,6 +942,12 @@ export class ExecutorManager { this.dropUserOps(opsToDrop) } + removeSubmitted(userOperations: UserOperationInfo[]) { + userOperations.map((userOperation) => { + this.mempool.removeSubmitted(userOperation.hash) + }) + } + dropUserOps(rejectedUserOperations: RejectedUserOperation[]) { rejectedUserOperations.map((rejectedUserOperation) => { const { userOperation, reason } = rejectedUserOperation diff --git a/src/executor/filterOpsAndEStimateGas.ts b/src/executor/filterOpsAndEStimateGas.ts index 98e3e661..0b1123e9 100644 --- a/src/executor/filterOpsAndEStimateGas.ts +++ b/src/executor/filterOpsAndEStimateGas.ts @@ -44,11 +44,11 @@ export type FilterOpsAndEstimateGasResult = gasLimit: bigint } | { - status: "unexpectedFailure" + status: "unexpected_failure" reason: string } | { - status: "allOpsFailedSimulation" + status: "all_ops_failed_simulation" failedOps: FailedOpWithReason[] } @@ -149,7 +149,7 @@ export async function filterOpsAndEstimateGas({ "failed to parse failedOpError" ) return { - status: "unexpectedFailure", + status: "unexpected_failure", reason: "failed to parse failedOpError" } } @@ -244,7 +244,7 @@ export async function filterOpsAndEstimateGas({ "unexpected error result" ) return { - status: "unexpectedFailure", + status: "unexpected_failure", reason: "unexpected error result" } } @@ -265,7 +265,7 @@ export async function filterOpsAndEstimateGas({ "failed to parse error result" ) return { - status: "unexpectedFailure", + status: "unexpected_failure", reason: "failed to parse error result" } } @@ -276,12 +276,12 @@ export async function filterOpsAndEstimateGas({ "error estimating gas" ) return { - status: "unexpectedFailure", + status: "unexpected_failure", reason: "error estimating gas" } } } } - return { status: "allOpsFailedSimulation", failedOps } + return { status: "all_ops_failed_simulation", failedOps } } diff --git a/src/mempool/mempool.ts b/src/mempool/mempool.ts index 1447dbcd..857c7c2d 100644 --- a/src/mempool/mempool.ts +++ b/src/mempool/mempool.ts @@ -657,6 +657,26 @@ export class MemoryMempool { } } + public async getBundles( + maxBundleCount?: number + ): Promise { + const bundlePromises = this.config.entrypoints.map( + async (entryPoint) => { + return await this.process({ + entryPoint, + maxGasLimit: this.config.maxGasPerBundle, + minOpsPerBundle: 1, + maxBundleCount + }) + } + ) + + const bundlesNested = await Promise.all(bundlePromises) + const bundles = bundlesNested.flat() + + return bundles + } + // Returns a bundle of userOperations in array format. async process({ maxGasLimit, diff --git a/src/types/mempool.ts b/src/types/mempool.ts index 1085357f..946b5529 100644 --- a/src/types/mempool.ts +++ b/src/types/mempool.ts @@ -60,7 +60,7 @@ export type BundleResult = // Successfully sent bundle. status: "bundle_success" userOpsBundled: UserOperationInfo[] - rejectedUserOperations: RejectedUserOperation[] + rejectedUserOps: RejectedUserOperation[] transactionHash: HexData32 transactionRequest: { gas: bigint @@ -73,7 +73,6 @@ export type BundleResult = // Encountered unhandled error during bundle simulation. status: "unhandled_simulation_failure" reason: string - userOps: UserOperationInfo[] } | { // All user operations failed during simulation. @@ -84,5 +83,6 @@ export type BundleResult = // Encountered error whilst trying to send bundle. status: "bundle_submission_failure" reason: BaseError | "INTERNAL FAILURE" - userOps: UserOperationInfo[] + userOpsToBundle: UserOperationInfo[] + rejectedUserOps: RejectedUserOperation[] }