diff --git a/src/cli/config/bundler.ts b/src/cli/config/bundler.ts index ce35428a..f0524ebd 100644 --- a/src/cli/config/bundler.ts +++ b/src/cli/config/bundler.ts @@ -147,7 +147,6 @@ export const compatibilityArgsSchema = z.object({ .optional() .transform((val) => val as ApiVersion), "balance-override": z.boolean(), - "local-gas-limit-calculation": z.boolean(), "flush-stuck-transactions-during-startup": z.boolean(), "fixed-gas-limit-for-estimation": z .string() diff --git a/src/cli/config/options.ts b/src/cli/config/options.ts index 38cd958c..dc6ce2a1 100644 --- a/src/cli/config/options.ts +++ b/src/cli/config/options.ts @@ -343,13 +343,6 @@ export const compatibilityOptions: CliCommandOptions = require: true, default: true }, - "local-gas-limit-calculation": { - description: - "Calculate the bundle transaction gas limits locally instead of using the RPC gas limit estimation", - type: "boolean", - require: true, - default: false - }, "flush-stuck-transactions-during-startup": { description: "Flush stuck transactions with old nonces during bundler startup", diff --git a/src/cli/setupServer.ts b/src/cli/setupServer.ts index 8e88253d..41e55cda 100644 --- a/src/cli/setupServer.ts +++ b/src/cli/setupServer.ts @@ -97,7 +97,6 @@ const getEventManager = ({ const getExecutor = ({ mempool, config, - senderManager, reputationManager, metrics, gasPriceManager, @@ -105,7 +104,6 @@ const getExecutor = ({ }: { mempool: MemoryMempool config: AltoConfig - senderManager: SenderManager reputationManager: InterfaceReputationManager metrics: Metrics gasPriceManager: GasPriceManager @@ -114,7 +112,6 @@ const getExecutor = ({ return new Executor({ mempool, config, - senderManager, reputationManager, metrics, gasPriceManager, @@ -127,6 +124,7 @@ const getExecutorManager = ({ executor, mempool, monitor, + senderManager, reputationManager, metrics, gasPriceManager, @@ -137,6 +135,7 @@ const getExecutorManager = ({ mempool: MemoryMempool monitor: Monitor reputationManager: InterfaceReputationManager + senderManager: SenderManager metrics: Metrics gasPriceManager: GasPriceManager eventManager: EventManager @@ -146,6 +145,7 @@ const getExecutorManager = ({ executor, mempool, monitor, + senderManager, reputationManager, metrics, gasPriceManager, @@ -275,7 +275,6 @@ export const setupServer = async ({ const executor = getExecutor({ mempool, config, - senderManager, reputationManager, metrics, gasPriceManager, @@ -287,6 +286,7 @@ export const setupServer = async ({ executor, mempool, monitor, + senderManager, reputationManager, metrics, gasPriceManager, @@ -314,7 +314,7 @@ export const setupServer = async ({ }) if (config.flushStuckTransactionsDuringStartup) { - executor.flushStuckTransactions() + senderManager.flushOnStartUp() } const rootLogger = config.getLogger( diff --git a/src/executor/executor.ts b/src/executor/executor.ts index ee04787e..51b3fa07 100644 --- a/src/executor/executor.ts +++ b/src/executor/executor.ts @@ -1,96 +1,82 @@ -import type { SenderManager } from "@alto/executor" import type { EventManager, GasPriceManager } from "@alto/handlers" import type { InterfaceReputationManager, MemoryMempool } from "@alto/mempool" import { type Address, type BundleResult, - EntryPointV06Abi, - EntryPointV07Abi, type HexData32, - type PackedUserOperation, - type TransactionInfo, type UserOperation, - type UserOperationV07, - type GasPriceParameters + type GasPriceParameters, + UserOperationBundle, + UserOpInfo } from "@alto/types" import type { Logger, Metrics } from "@alto/utils" -import { - getRequiredPrefund, - getUserOperationHash, - isVersion06, - maxBigInt, - parseViemError, - scaleBigIntByPercent, - toPackedUserOperation -} from "@alto/utils" +import { maxBigInt, parseViemError, scaleBigIntByPercent } from "@alto/utils" import * as sentry from "@sentry/node" -import { Mutex } from "async-mutex" import { - FeeCapTooLowError, - InsufficientFundsError, IntrinsicGasTooLowError, NonceTooLowError, TransactionExecutionError, - encodeFunctionData, - getContract, type Account, type Hex, - BaseError, - NonceTooHighError + NonceTooHighError, + BaseError } from "viem" import { - filterOpsAndEstimateGas, - flushStuckTransaction, - simulatedOpsToResults, - isTransactionUnderpricedError, - getAuthorizationList + calculateAA95GasFloor, + encodeHandleOpsCalldata, + getAuthorizationList, + getUserOpHashes, + isTransactionUnderpricedError } from "./utils" import type { SendTransactionErrorType } from "viem" import type { AltoConfig } from "../createConfig" -import type { SendTransactionOptions } from "./types" import { sendPflConditional } from "./fastlane" - -export interface GasEstimateResult { - preverificationGas: bigint - verificationGasLimit: bigint - callGasLimit: bigint -} - -export type HandleOpsTxParam = { - ops: PackedUserOperation[] - isUserOpVersion06: boolean +import { filterOpsAndEstimateGas } from "./filterOpsAndEStimateGas" +import { SignedAuthorizationList } from "viem/experimental" + +type HandleOpsTxParams = { + gas: bigint + account: Account + nonce: number + userOps: UserOpInfo[] + isUserOpV06: boolean isReplacementTx: boolean entryPoint: Address } -export type ReplaceTransactionResult = +type HandleOpsGasParams = | { - status: "replaced" - transactionInfo: TransactionInfo + type: "legacy" + gasPrice: bigint + maxFeePerGas?: undefined + maxPriorityFeePerGas?: undefined } | { - status: "potentially_already_included" + type: "eip1559" + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + gasPrice?: undefined } | { - status: "failed" + type: "eip7702" + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + gasPrice?: undefined + authorizationList: SignedAuthorizationList } export class Executor { - // private unWatch: WatchBlocksReturnType | undefined config: AltoConfig - senderManager: SenderManager logger: Logger metrics: Metrics reputationManager: InterfaceReputationManager gasPriceManager: GasPriceManager - mutex: Mutex mempool: MemoryMempool eventManager: EventManager constructor({ config, mempool, - senderManager, reputationManager, metrics, gasPriceManager, @@ -98,7 +84,6 @@ export class Executor { }: { config: AltoConfig mempool: MemoryMempool - senderManager: SenderManager reputationManager: InterfaceReputationManager metrics: Metrics gasPriceManager: GasPriceManager @@ -106,7 +91,6 @@ export class Executor { }) { this.config = config this.mempool = mempool - this.senderManager = senderManager this.reputationManager = reputationManager this.logger = config.getLogger( { module: "executor" }, @@ -117,387 +101,32 @@ export class Executor { this.metrics = metrics this.gasPriceManager = gasPriceManager this.eventManager = eventManager - - this.mutex = new Mutex() } cancelOps(_entryPoint: Address, _ops: UserOperation[]): Promise { throw new Error("Method not implemented.") } - markWalletProcessed(executor: Account) { - if (!this.senderManager.availableWallets.includes(executor)) { - this.senderManager.pushWallet(executor) - } - return Promise.resolve() - } - - async replaceTransaction( - transactionInfo: TransactionInfo - ): Promise { - const newRequest = { ...transactionInfo.transactionRequest } - - let gasPriceParameters: GasPriceParameters - try { - gasPriceParameters = - await this.gasPriceManager.tryGetNetworkGasPrice() - } catch (err) { - this.logger.error({ error: err }, "Failed to get network gas price") - this.markWalletProcessed(transactionInfo.executor) - return { status: "failed" } - } - - newRequest.maxFeePerGas = scaleBigIntByPercent( - gasPriceParameters.maxFeePerGas, - 115n - ) - - newRequest.maxPriorityFeePerGas = scaleBigIntByPercent( - gasPriceParameters.maxPriorityFeePerGas, - 115n - ) - newRequest.account = transactionInfo.executor - - const opsWithHashes = transactionInfo.userOperationInfos.map( - (opInfo) => { - const op = opInfo.userOperation - return { - userOperation: opInfo.userOperation, - userOperationHash: getUserOperationHash( - op, - transactionInfo.entryPoint, - this.config.walletClient.chain.id - ), - entryPoint: opInfo.entryPoint - } - } - ) - - const [isUserOpVersion06, entryPoint] = opsWithHashes.reduce( - (acc, owh) => { - if ( - acc[0] !== isVersion06(owh.userOperation) || - acc[1] !== owh.entryPoint - ) { - throw new Error( - "All user operations must be of the same version" - ) - } - return acc - }, - [ - isVersion06(opsWithHashes[0].userOperation), - opsWithHashes[0].entryPoint - ] - ) - - const ep = getContract({ - abi: isUserOpVersion06 ? EntryPointV06Abi : EntryPointV07Abi, - address: entryPoint, - client: { - public: this.config.publicClient, - wallet: this.config.walletClient - } - }) - - let { simulatedOps, gasLimit } = await filterOpsAndEstimateGas( - transactionInfo.entryPoint, - ep, - transactionInfo.executor, - opsWithHashes, - newRequest.nonce, - newRequest.maxFeePerGas, - newRequest.maxPriorityFeePerGas, - this.config.blockTagSupport ? "latest" : undefined, - this.config.legacyTransactions, - this.config.fixedGasLimitForEstimation, - this.reputationManager, - this.logger - ) - - const childLogger = this.logger.child({ - transactionHash: transactionInfo.transactionHash, - executor: transactionInfo.executor.address - }) - - if (simulatedOps.length === 0) { - childLogger.warn("no ops to bundle") - this.markWalletProcessed(transactionInfo.executor) - return { status: "failed" } - } - - if ( - simulatedOps.every( - (op) => - op.reason === "AA25 invalid account nonce" || - op.reason === "AA10 sender already constructed" - ) - ) { - childLogger.trace( - { reasons: simulatedOps.map((sop) => sop.reason) }, - "all ops failed simulation with nonce error" - ) - return { status: "potentially_already_included" } - } - - if (simulatedOps.every((op) => op.reason !== undefined)) { - childLogger.warn("all ops failed simulation") - this.markWalletProcessed(transactionInfo.executor) - return { status: "failed" } - } - - const opsToBundle = simulatedOps - .filter((op) => op.reason === undefined) - .map((op) => { - const opInfo = transactionInfo.userOperationInfos.find( - (info) => - info.userOperationHash === op.owh.userOperationHash - ) - if (!opInfo) { - throw new Error("opInfo not found") - } - return opInfo - }) - - if (this.config.localGasLimitCalculation) { - gasLimit = opsToBundle.reduce((acc, opInfo) => { - const userOperation = opInfo.userOperation - return ( - acc + - userOperation.preVerificationGas + - 3n * userOperation.verificationGasLimit + - userOperation.callGasLimit - ) - }, 0n) - } - - // https://github.com/eth-infinitism/account-abstraction/blob/fa61290d37d079e928d92d53a122efcc63822214/contracts/core/EntryPoint.sol#L236 - let innerHandleOpFloor = 0n - for (const owh of opsToBundle) { - const op = owh.userOperation - 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 - - // ensures that we don't submit again with too low of a gas value - newRequest.gas = maxBigInt(newRequest.gas, gasLimit) - - // update calldata to include only ops that pass simulation - let txParam: HandleOpsTxParam - - const userOps = opsToBundle.map((op) => - isUserOpVersion06 - ? op.userOperation - : toPackedUserOperation(op.userOperation as UserOperationV07) - ) as PackedUserOperation[] - - txParam = { - isUserOpVersion06, - isReplacementTx: true, - ops: userOps, - entryPoint: transactionInfo.entryPoint - } - - try { - childLogger.info( - { - newRequest: { - ...newRequest, - abi: undefined, - chain: undefined - }, - executor: newRequest.account.address, - opsToBundle: opsToBundle.map( - (opInfo) => opInfo.userOperationHash - ) - }, - "replacing transaction" - ) - - const txHash = await this.sendHandleOpsTransaction({ - txParam, - opts: this.config.legacyTransactions - ? { - account: newRequest.account, - gasPrice: newRequest.maxFeePerGas, - gas: newRequest.gas, - nonce: newRequest.nonce - } - : { - account: newRequest.account, - maxFeePerGas: newRequest.maxFeePerGas, - maxPriorityFeePerGas: newRequest.maxPriorityFeePerGas, - gas: newRequest.gas, - nonce: newRequest.nonce - } - }) - - opsToBundle.map(({ entryPoint, userOperation }) => { - const chainId = this.config.publicClient.chain?.id - const opHash = getUserOperationHash( - userOperation, - entryPoint, - chainId as number - ) - - this.eventManager.emitSubmitted(opHash, txHash) - }) - - const newTxInfo: TransactionInfo = { - ...transactionInfo, - transactionRequest: newRequest, - transactionHash: txHash, - previousTransactionHashes: [ - transactionInfo.transactionHash, - ...transactionInfo.previousTransactionHashes - ], - lastReplaced: Date.now(), - userOperationInfos: opsToBundle.map((opInfo) => { - return { - entryPoint: opInfo.entryPoint, - userOperation: opInfo.userOperation, - userOperationHash: opInfo.userOperationHash, - lastReplaced: Date.now(), - firstSubmitted: opInfo.firstSubmitted - } - }) - } - - return { - status: "replaced", - transactionInfo: newTxInfo - } - } catch (err: unknown) { - const e = parseViemError(err) - if (!e) { - sentry.captureException(err) - childLogger.error( - { error: err }, - "unknown error replacing transaction" - ) - } - - if (e instanceof NonceTooLowError) { - childLogger.trace( - { error: e }, - "nonce too low, potentially already included" - ) - return { status: "potentially_already_included" } - } - - if (e instanceof FeeCapTooLowError) { - childLogger.warn({ error: e }, "fee cap too low, not replacing") - } - - if (e instanceof InsufficientFundsError) { - childLogger.warn( - { error: e }, - "insufficient funds, not replacing" - ) - } - - if (e instanceof IntrinsicGasTooLowError) { - childLogger.warn( - { error: e }, - "intrinsic gas too low, not replacing" - ) - } - - childLogger.warn({ error: e }, "error replacing transaction") - this.markWalletProcessed(transactionInfo.executor) - - return { status: "failed" } - } - } - - async flushStuckTransactions(): Promise { - const allWallets = new Set(this.senderManager.wallets) - - const utilityWallet = this.senderManager.utilityAccount - if (utilityWallet) { - allWallets.add(utilityWallet) - } - - const wallets = Array.from(allWallets) - - let gasPrice: { - maxFeePerGas: bigint - maxPriorityFeePerGas: bigint - } - - try { - gasPrice = await this.gasPriceManager.tryGetNetworkGasPrice() - } catch (e) { - this.logger.error({ error: e }, "error flushing stuck transaction") - return - } - - const promises = wallets.map((wallet) => { - try { - flushStuckTransaction( - this.config.publicClient, - this.config.walletClient, - wallet, - gasPrice.maxFeePerGas * 5n, - this.logger - ) - } catch (e) { - this.logger.error( - { error: e }, - "error flushing stuck transaction" - ) - } - }) - - await Promise.all(promises) - } - async sendHandleOpsTransaction({ txParam, - opts + gasOpts }: { - txParam: HandleOpsTxParam - opts: - | { - gasPrice: bigint - maxFeePerGas?: undefined - maxPriorityFeePerGas?: undefined - account: Account - gas: bigint - nonce: number - } - | { - maxFeePerGas: bigint - maxPriorityFeePerGas: bigint - gasPrice?: undefined - account: Account - gas: bigint - nonce: number - } + txParam: HandleOpsTxParams + gasOpts: HandleOpsGasParams }) { - let data: Hex - let to: Address - - const { isUserOpVersion06, ops, entryPoint } = txParam - data = encodeFunctionData({ - abi: isUserOpVersion06 ? EntryPointV06Abi : EntryPointV07Abi, - functionName: "handleOps", - args: [ops, opts.account.address] + const { isUserOpV06, entryPoint, userOps } = txParam + + const handleOpsCalldata = encodeHandleOpsCalldata({ + userOps, + beneficiary: txParam.account.address }) - to = entryPoint const request = await this.config.walletClient.prepareTransactionRequest({ - to, - data, - ...opts + to: entryPoint, + data: handleOpsCalldata, + ...txParam, + ...gasOpts }) request.gas = scaleBigIntByPercent( @@ -505,7 +134,6 @@ export class Executor { this.config.executorGasMultiplier ) - let isTransactionUnderPriced = false let attempts = 0 let transactionHash: Hex | undefined const maxAttempts = 3 @@ -515,7 +143,7 @@ export class Executor { try { if ( this.config.enableFastlane && - isUserOpVersion06 && + isUserOpV06 && !txParam.isReplacementTx && attempts === 0 ) { @@ -537,8 +165,6 @@ export class Executor { break } catch (e: unknown) { - isTransactionUnderPriced = false - if (e instanceof BaseError) { if (isTransactionUnderpricedError(e)) { this.logger.warn("Transaction underpriced, retrying") @@ -551,7 +177,6 @@ export class Executor { request.maxPriorityFeePerGas, 150n ) - isTransactionUnderPriced = true } } @@ -587,21 +212,14 @@ export class Executor { } } + attempts++ + if (attempts === maxAttempts) { throw error } - - attempts++ } } - if (isTransactionUnderPriced) { - await this.handleTransactionUnderPriced({ - nonce: request.nonce, - executor: request.account - }) - } - // needed for TS if (!transactionHash) { throw new Error("Transaction hash not assigned") @@ -610,358 +228,178 @@ export class Executor { return transactionHash as Hex } - // Occurs when tx was sent with conflicting nonce, we want to resubmit all conflicting ops - async handleTransactionUnderPriced({ + async bundle({ + executor, + userOpBundle, nonce, - executor - }: { nonce: number; executor: Account }) { - const submitted = this.mempool.dumpSubmittedOps() - - const conflictingOps = submitted - .filter((submitted) => { - const tx = submitted.transactionInfo - - return ( - tx.executor.address === executor.address && - tx.transactionRequest.nonce === nonce - ) - }) - .map(({ userOperation }) => userOperation) - - conflictingOps.map((op) => { - this.logger.info( - `Resubmitting ${op.userOperationHash} due to transaction underpriced` - ) - this.mempool.removeSubmitted(op.userOperationHash) - this.mempool.add(op.userOperation, op.entryPoint) - }) - - if (conflictingOps.length > 0) { - this.markWalletProcessed(executor) - } - } - - async bundle( - entryPoint: Address, - ops: UserOperation[] - ): Promise { - const wallet = await this.senderManager.getWallet() - - const opsWithHashes = ops.map((userOperation) => { - return { - userOperation, - userOperationHash: getUserOperationHash( - userOperation, - entryPoint, - this.config.walletClient.chain.id - ) - } - }) - - const isUserOpVersion06 = opsWithHashes.reduce((acc, op) => { - if (acc !== isVersion06(op.userOperation)) { - throw new Error( - "All user operations must be of the same version" - ) - } - return acc - }, isVersion06(opsWithHashes[0].userOperation)) - - const ep = getContract({ - abi: isUserOpVersion06 ? EntryPointV06Abi : EntryPointV07Abi, - address: entryPoint, - client: { - public: this.config.publicClient, - wallet: this.config.walletClient - } - }) + gasPriceParams, + gasLimitSuggestion, + isReplacementTx + }: { + executor: Account + userOpBundle: UserOperationBundle + nonce: number + gasPriceParams: GasPriceParameters + gasLimitSuggestion?: bigint + isReplacementTx: boolean + }): Promise { + const { entryPoint, userOps, version } = userOpBundle + const { maxFeePerGas, maxPriorityFeePerGas } = gasPriceParams + const isUserOpV06 = version === "0.6" let childLogger = this.logger.child({ - userOperations: opsWithHashes.map((oh) => oh.userOperationHash), + isReplacementTx, + userOperations: getUserOpHashes(userOps), entryPoint }) - childLogger.debug("bundling user operation") - // These calls can throw, so we try/catch them to mark wallet as processed in event of error. - let nonce: number - let gasPriceParameters: GasPriceParameters - try { - ;[gasPriceParameters, nonce] = await Promise.all([ - this.gasPriceManager.tryGetNetworkGasPrice(), - this.config.publicClient.getTransactionCount({ - address: wallet.address, - blockTag: "pending" - }) - ]) - } catch (err) { - childLogger.error( - { error: err }, - "Failed to get parameters for bundling" - ) - this.markWalletProcessed(wallet) - return opsWithHashes.map((owh) => { - return { - status: "resubmit", - info: { - entryPoint, - userOpHash: owh.userOperationHash, - userOperation: owh.userOperation, - reason: "Failed to get parameters for bundling" - } - } - }) - } - - let { gasLimit, simulatedOps } = await filterOpsAndEstimateGas( - entryPoint, - ep, - wallet, - opsWithHashes, + let estimateResult = await filterOpsAndEstimateGas({ + userOpBundle, + executor, nonce, - gasPriceParameters.maxFeePerGas, - gasPriceParameters.maxPriorityFeePerGas, - this.config.blockTagSupport ? "pending" : undefined, - this.config.legacyTransactions, - this.config.fixedGasLimitForEstimation, - this.reputationManager, - childLogger, - getAuthorizationList( - opsWithHashes.map(({ userOperation }) => userOperation) - ) - ) + maxFeePerGas, + maxPriorityFeePerGas, + reputationManager: this.reputationManager, + config: this.config, + logger: childLogger + }) - if (simulatedOps.length === 0) { + if (estimateResult.status === "unhandled_failure") { childLogger.error( "gas limit simulation encountered unexpected failure" ) - this.markWalletProcessed(wallet) - return opsWithHashes.map((owh) => { - return { - status: "failure", - error: { - entryPoint, - userOpHash: owh.userOperationHash, - userOperation: owh.userOperation, - reason: "INTERNAL FAILURE" - } - } - }) + return { + status: "unhandled_simulation_failure", + rejectedUserOps: estimateResult.rejectedUserOps, + reason: "INTERNAL FAILURE" + } } - if (simulatedOps.every((op) => op.reason !== undefined)) { + if (estimateResult.status === "all_ops_failed_simulation") { childLogger.warn("all ops failed simulation") - this.markWalletProcessed(wallet) - return simulatedOps.map(({ reason, owh }) => { - return { - status: "failure", - error: { - entryPoint, - userOpHash: owh.userOperationHash, - userOperation: owh.userOperation, - reason: reason as string - } - } - }) + return { + status: "all_ops_failed_simulation", + rejectedUserOps: estimateResult.rejectedUserOps + } } - const opsWithHashToBundle = simulatedOps - .filter((op) => op.reason === undefined) - .map((op) => op.owh) + let { gasLimit, userOpsToBundle, rejectedUserOps } = estimateResult + // Update child logger with userOperations being sent for bundling. childLogger = this.logger.child({ - userOperations: opsWithHashToBundle.map( - (owh) => owh.userOperationHash - ), + isReplacementTx, + userOperations: getUserOpHashes(userOpsToBundle), entryPoint }) - // https://github.com/eth-infinitism/account-abstraction/blob/fa61290d37d079e928d92d53a122efcc63822214/contracts/core/EntryPoint.sol#L236 - let innerHandleOpFloor = 0n - let totalBeneficiaryFees = 0n - for (const owh of opsWithHashToBundle) { - const op = owh.userOperation - innerHandleOpFloor += - op.callGasLimit + op.verificationGasLimit + 5000n + // Ensure that we don't submit with gas too low leading to AA95. + const aa95GasFloor = calculateAA95GasFloor(userOpsToBundle) - totalBeneficiaryFees += getRequiredPrefund(op) - } - - if (gasLimit < innerHandleOpFloor) { - gasLimit += innerHandleOpFloor + if (gasLimit < aa95GasFloor) { + gasLimit += aa95GasFloor } // sometimes the estimation rounds down, adding a fixed constant accounts for this gasLimit += 10_000n - - childLogger.debug({ gasLimit }, "got gas limit") + gasLimit = gasLimitSuggestion + ? maxBigInt(gasLimit, gasLimitSuggestion) + : gasLimit let transactionHash: HexData32 try { const isLegacyTransaction = this.config.legacyTransactions + const authorizationList = getAuthorizationList(userOpsToBundle) + const { maxFeePerGas, maxPriorityFeePerGas } = gasPriceParams - if (this.config.noProfitBundling) { - const gasPrice = totalBeneficiaryFees / gasLimit - if (isLegacyTransaction) { - gasPriceParameters.maxFeePerGas = gasPrice - gasPriceParameters.maxPriorityFeePerGas = gasPrice - } else { - gasPriceParameters.maxFeePerGas = maxBigInt( - gasPrice, - gasPriceParameters.maxFeePerGas || 0n - ) - } - } - - const authorizationList = getAuthorizationList( - opsWithHashToBundle.map(({ userOperation }) => userOperation) - ) - - let opts: SendTransactionOptions + let gasOpts: HandleOpsGasParams if (isLegacyTransaction) { - opts = { + gasOpts = { type: "legacy", - gasPrice: gasPriceParameters.maxFeePerGas, - account: wallet, - gas: gasLimit, - nonce + gasPrice: maxFeePerGas } } else if (authorizationList) { - opts = { + gasOpts = { type: "eip7702", - maxFeePerGas: gasPriceParameters.maxFeePerGas, - maxPriorityFeePerGas: - gasPriceParameters.maxPriorityFeePerGas, - account: wallet, - gas: gasLimit, - nonce, + maxFeePerGas, + maxPriorityFeePerGas, authorizationList } } else { - opts = { + gasOpts = { type: "eip1559", - maxFeePerGas: gasPriceParameters.maxFeePerGas, - maxPriorityFeePerGas: - gasPriceParameters.maxPriorityFeePerGas, - account: wallet, - gas: gasLimit, - nonce + maxFeePerGas, + maxPriorityFeePerGas } } - const userOps = opsWithHashToBundle.map(({ userOperation }) => { - if (isUserOpVersion06) { - return userOperation - } - - return toPackedUserOperation(userOperation as UserOperationV07) - }) as PackedUserOperation[] - transactionHash = await this.sendHandleOpsTransaction({ txParam: { - ops: userOps, - isReplacementTx: false, - isUserOpVersion06, + account: executor, + nonce, + gas: gasLimit, + userOps: userOpsToBundle, + isReplacementTx, + isUserOpV06, entryPoint }, - opts + gasOpts }) - opsWithHashToBundle.map(({ userOperationHash }) => { - this.eventManager.emitSubmitted( - userOperationHash, - transactionHash - ) + this.eventManager.emitSubmitted({ + userOpHashes: getUserOpHashes(userOpsToBundle), + transactionHash }) } catch (err: unknown) { const e = parseViemError(err) - if (e instanceof InsufficientFundsError) { + const { rejectedUserOps, userOpsToBundle } = estimateResult + + // if unknown error, return INTERNAL FAILURE + if (!e) { + sentry.captureException(err) childLogger.error( - { error: e }, - "insufficient funds, not submitting transaction" + { error: JSON.stringify(err) }, + "error submitting bundle transaction" ) - this.markWalletProcessed(wallet) - return opsWithHashToBundle.map((owh) => { - return { - status: "resubmit", - info: { - entryPoint, - userOpHash: owh.userOperationHash, - userOperation: owh.userOperation, - reason: InsufficientFundsError.name - } - } - }) - } - - sentry.captureException(err) - childLogger.error( - { error: JSON.stringify(err) }, - "error submitting bundle transaction" - ) - this.markWalletProcessed(wallet) - return opsWithHashes.map((owh) => { return { - status: "failure", - error: { - entryPoint, - userOpHash: owh.userOperationHash, - userOperation: owh.userOperation, - reason: "INTERNAL FAILURE" - } + rejectedUserOps, + userOpsToBundle, + status: "bundle_submission_failure", + reason: "INTERNAL FAILURE" } - }) - } + } - const userOperationInfos = opsWithHashToBundle.map((op) => { return { - entryPoint, - userOperation: op.userOperation, - userOperationHash: op.userOperationHash, - lastReplaced: Date.now(), - firstSubmitted: Date.now() + rejectedUserOps, + userOpsToBundle, + status: "bundle_submission_failure", + reason: e } - }) + } + + const userOpsBundled = userOpsToBundle - const transactionInfo: TransactionInfo = { - entryPoint, - isVersion06: isUserOpVersion06, - transactionHash: transactionHash, - previousTransactionHashes: [], + const bundleResult: BundleResult = { + status: "bundle_success", + userOpsBundled, + rejectedUserOps, + transactionHash, transactionRequest: { - account: wallet, - to: ep.address, gas: gasLimit, - chain: this.config.walletClient.chain, - maxFeePerGas: gasPriceParameters.maxFeePerGas, - maxPriorityFeePerGas: gasPriceParameters.maxPriorityFeePerGas, - nonce: nonce - }, - executor: wallet, - userOperationInfos, - lastReplaced: Date.now(), - firstSubmitted: Date.now(), - timesPotentiallyIncluded: 0 + maxFeePerGas: gasPriceParams.maxFeePerGas, + maxPriorityFeePerGas: gasPriceParams.maxPriorityFeePerGas, + nonce + } } - const userOperationResults: BundleResult[] = simulatedOpsToResults( - simulatedOps, - transactionInfo - ) - childLogger.info( { - transactionRequest: { - ...transactionInfo.transactionRequest, - abi: undefined - }, + transactionRequest: bundleResult.transactionRequest, txHash: transactionHash, - opHashes: opsWithHashToBundle.map( - (owh) => owh.userOperationHash - ) + opHashes: getUserOpHashes(userOpsBundled) }, "submitted bundle transaction" ) - return userOperationResults + return bundleResult } } diff --git a/src/executor/executorManager.ts b/src/executor/executorManager.ts index ba52b769..40d6510d 100644 --- a/src/executor/executorManager.ts +++ b/src/executor/executorManager.ts @@ -5,14 +5,14 @@ import type { Monitor } from "@alto/mempool" import { - type BundleResult, type BundlingMode, EntryPointV06Abi, type HexData32, - type UserOperation, - type SubmittedUserOperation, + type SubmittedUserOp, type TransactionInfo, - type UserOperationInfo + RejectedUserOp, + UserOperationBundle, + UserOpInfo } from "@alto/types" import type { BundlingStatus, Logger, Metrics } from "@alto/utils" import { @@ -28,21 +28,26 @@ import { type TransactionReceipt, TransactionReceiptNotFoundError, type WatchBlocksReturnType, - getAbiItem + getAbiItem, + Hex, + InsufficientFundsError, + NonceTooLowError } from "viem" -import type { Executor, ReplaceTransactionResult } from "./executor" +import type { Executor } from "./executor" import type { AltoConfig } from "../createConfig" +import { SenderManager } from "./senderManager" +import { BaseError } from "abitype" +import { getUserOpHashes } from "./utils" function getTransactionsFromUserOperationEntries( - entries: SubmittedUserOperation[] + submittedOps: SubmittedUserOp[] ): TransactionInfo[] { - return Array.from( - new Set( - entries.map((entry) => { - return entry.transactionInfo - }) - ) + const transactionInfos = submittedOps.map( + (userOpInfo) => userOpInfo.transactionInfo ) + + // Remove duplicates + return Array.from(new Set(transactionInfos)) } const MIN_INTERVAL = 100 // 0.1 seconds (100ms) @@ -51,6 +56,7 @@ const SCALE_FACTOR = 10 // Interval increases by 5ms per task per minute const RPM_WINDOW = 60000 // 1 minute window in ms export class ExecutorManager { + private senderManager: SenderManager private config: AltoConfig private executor: Executor private mempool: MemoryMempool @@ -73,7 +79,8 @@ export class ExecutorManager { reputationManager, metrics, gasPriceManager, - eventManager + eventManager, + senderManager }: { config: AltoConfig executor: Executor @@ -83,6 +90,7 @@ export class ExecutorManager { metrics: Metrics gasPriceManager: GasPriceManager eventManager: EventManager + senderManager: SenderManager }) { this.config = config this.reputationManager = reputationManager @@ -98,6 +106,7 @@ export class ExecutorManager { this.metrics = metrics this.gasPriceManager = gasPriceManager this.eventManager = eventManager + this.senderManager = senderManager this.bundlingMode = this.config.bundleMode @@ -126,14 +135,23 @@ export class ExecutorManager { (timestamp) => now - timestamp < RPM_WINDOW ) - const opsToBundle = await this.getOpsToBundle() + const bundles = await this.mempool.getBundles() - if (opsToBundle.length > 0) { - const opsCount: number = opsToBundle.length - const timestamp: number = Date.now() - this.opsCount.push(...Array(opsCount).fill(timestamp)) // Add timestamps for each task + if (bundles.length > 0) { + const opsCount: number = bundles + .map(({ userOps }) => userOps.length) + .reduce((a, b) => a + b) - await this.bundle(opsToBundle) + // Add timestamps for each task + const timestamp = Date.now() + this.opsCount.push(...Array(opsCount).fill(timestamp)) + + // Send bundles to executor + await Promise.all( + bundles.map(async (bundle) => { + await this.sendBundleToExecutor(bundle) + }) + ) } const rpm: number = this.opsCount.length @@ -142,220 +160,152 @@ export class ExecutorManager { MIN_INTERVAL + rpm * SCALE_FACTOR, // Linear scaling MAX_INTERVAL // Cap at 1000ms ) + if (this.bundlingMode === "auto") { setTimeout(this.autoScalingBundling.bind(this), nextInterval) } } - async getOpsToBundle() { - const opsToBundle: UserOperationInfo[][] = [] - - while (true) { - const ops = await this.mempool.process( - this.config.maxGasPerBundle, - 1 - ) - if (ops?.length > 0) { - opsToBundle.push(ops) - } else { - break - } - } - - if (opsToBundle.length === 0) { - return [] - } - - return opsToBundle - } + // Debug endpoint + async sendBundleNow(): Promise { + const bundles = await this.mempool.getBundles(1) + const bundle = bundles[0] - async bundleNow(): Promise { - const ops = await this.mempool.process(this.config.maxGasPerBundle, 1) - if (ops.length === 0) { + if (bundle.userOps.length === 0) { throw new Error("no ops to bundle") } - const opEntryPointMap = new Map() + const txHash = await this.sendBundleToExecutor(bundle) - for (const op of ops) { - if (!opEntryPointMap.has(op.entryPoint)) { - opEntryPointMap.set(op.entryPoint, []) - } - opEntryPointMap.get(op.entryPoint)?.push(op.userOperation) + if (!txHash) { + throw new Error("no tx hash") } - const txHashes: Hash[] = [] + return txHash + } - await Promise.all( - this.config.entrypoints.map(async (entryPoint) => { - const ops = opEntryPointMap.get(entryPoint) - if (ops) { - const txHash = await this.sendToExecutor(entryPoint, ops) + async sendBundleToExecutor( + userOpBundle: UserOperationBundle + ): Promise { + const { entryPoint, userOps, version } = userOpBundle + if (userOps.length === 0) { + return undefined + } - if (!txHash) { - throw new Error("no tx hash") - } + const wallet = await this.senderManager.getWallet() - txHashes.push(txHash) - } else { - this.logger.warn( - { entryPoint }, - "no user operations for entry point" - ) - } + const [gasPriceParams, nonce] = await Promise.all([ + this.gasPriceManager.tryGetNetworkGasPrice(), + this.config.publicClient.getTransactionCount({ + address: wallet.address, + blockTag: "latest" }) - ) + ]).catch((_) => { + return [] + }) - return txHashes - } + if (!gasPriceParams || nonce === undefined) { + this.resubmitUserOperations( + userOps, + entryPoint, + "Failed to get nonce and gas parameters for bundling" + ) + // Free executor if failed to get initial params. + this.senderManager.markWalletProcessed(wallet) + return undefined + } - async sendToExecutor(entryPoint: Address, mempoolOps: UserOperation[]) { - const ops = mempoolOps.map((op) => op as UserOperation) + const bundleResult = await this.executor.bundle({ + executor: wallet, + userOpBundle, + nonce, + gasPriceParams, + isReplacementTx: false + }) - const bundles: BundleResult[][] = [] - if (ops.length > 0) { - bundles.push(await this.executor.bundle(entryPoint, ops)) + // Free wallet if no bundle was sent. + if (bundleResult.status !== "bundle_success") { + this.senderManager.markWalletProcessed(wallet) } - for (const bundle of bundles) { - const isBundleSuccess = bundle.every( - (result) => result.status === "success" - ) - const isBundleResubmit = bundle.every( - (result) => result.status === "resubmit" - ) - const isBundleFailed = bundle.every( - (result) => result.status === "failure" - ) - if (isBundleSuccess) { - this.metrics.bundlesSubmitted - .labels({ status: "success" }) - .inc() - } - if (isBundleResubmit) { - this.metrics.bundlesSubmitted - .labels({ status: "resubmit" }) - .inc() - } - if (isBundleFailed) { - this.metrics.bundlesSubmitted.labels({ status: "failed" }).inc() - } + // All ops failed simulation, drop them and return. + if (bundleResult.status === "all_ops_failed_simulation") { + const { rejectedUserOps } = bundleResult + this.dropUserOps(rejectedUserOps) + return undefined } - const results = bundles.flat() + // Unhandled error during simulation, drop all ops. + if (bundleResult.status === "unhandled_simulation_failure") { + const { rejectedUserOps } = bundleResult + this.dropUserOps(rejectedUserOps) + this.metrics.bundlesSubmitted.labels({ status: "failed" }).inc() + return undefined + } - const filteredOutOps = mempoolOps.length - results.length - if (filteredOutOps > 0) { - this.logger.debug( - { filteredOutOps }, - "user operations filtered out" + // Resubmit if executor has insufficient funds. + if ( + bundleResult.status === "bundle_submission_failure" && + bundleResult.reason instanceof InsufficientFundsError + ) { + const { reason, userOpsToBundle, rejectedUserOps } = bundleResult + this.dropUserOps(rejectedUserOps) + this.resubmitUserOperations( + userOpsToBundle, + entryPoint, + reason.name ) - this.metrics.userOperationsSubmitted - .labels({ status: "filtered" }) - .inc(filteredOutOps) + this.metrics.bundlesSubmitted.labels({ status: "resubmit" }).inc() + return undefined } - let txHash: HexData32 | undefined = undefined - for (const result of results) { - if (result.status === "success") { - const res = result.value - - this.mempool.markSubmitted( - res.userOperation.userOperationHash, - res.transactionInfo - ) + // Encountered unhandled error during bundle simulation. + if (bundleResult.status === "bundle_submission_failure") { + const { rejectedUserOps, userOpsToBundle, reason } = bundleResult + this.dropUserOps(rejectedUserOps) + // NOTE: these ops passed validation, so we can try resubmitting them + this.resubmitUserOperations( + userOpsToBundle, + entryPoint, + reason instanceof BaseError + ? reason.name + : "Encountered unhandled error during bundle simulation" + ) + this.metrics.bundlesSubmitted.labels({ status: "failed" }).inc() + return undefined + } - this.monitor.setUserOperationStatus( - res.userOperation.userOperationHash, - { - status: "submitted", - transactionHash: res.transactionInfo.transactionHash - } - ) + if (bundleResult.status === "bundle_success") { + const { + userOpsBundled, + rejectedUserOps, + transactionRequest, + transactionHash + } = bundleResult - txHash = res.transactionInfo.transactionHash - this.startWatchingBlocks(this.handleBlock.bind(this)) - this.metrics.userOperationsSubmitted - .labels({ status: "success" }) - .inc() - } - if (result.status === "failure") { - const { userOpHash, reason } = result.error - this.mempool.removeProcessing(userOpHash) - this.eventManager.emitDropped( - userOpHash, - reason, - getAAError(reason) - ) - this.monitor.setUserOperationStatus(userOpHash, { - status: "rejected", - transactionHash: null - }) - this.logger.warn( - { - userOperation: JSON.stringify( - result.error.userOperation, - (_k, v) => - typeof v === "bigint" ? v.toString() : v - ), - userOpHash, - reason - }, - "user operation rejected" - ) - this.metrics.userOperationsSubmitted - .labels({ status: "failed" }) - .inc() - } - if (result.status === "resubmit") { - this.logger.info( - { - userOpHash: result.info.userOpHash, - reason: result.info.reason - }, - "resubmitting user operation" - ) - this.mempool.removeProcessing(result.info.userOpHash) - this.mempool.add( - result.info.userOperation, - result.info.entryPoint - ) - this.metrics.userOperationsResubmitted.inc() + const transactionInfo: TransactionInfo = { + executor: wallet, + transactionHash, + transactionRequest, + bundle: { + entryPoint, + version, + userOps: userOpsBundled + }, + previousTransactionHashes: [], + lastReplaced: Date.now(), + firstSubmitted: Date.now(), + timesPotentiallyIncluded: 0 } - } - return txHash - } - async bundle(opsToBundle: UserOperationInfo[][] = []) { - await Promise.all( - opsToBundle.map(async (ops) => { - const opEntryPointMap = new Map() + this.markUserOperationsAsSubmitted(userOpsBundled, transactionInfo) + this.dropUserOps(rejectedUserOps) + this.metrics.bundlesSubmitted.labels({ status: "success" }).inc() - for (const op of ops) { - if (!opEntryPointMap.has(op.entryPoint)) { - opEntryPointMap.set(op.entryPoint, []) - } - opEntryPointMap.get(op.entryPoint)?.push(op.userOperation) - } + return transactionHash + } - await Promise.all( - this.config.entrypoints.map(async (entryPoint) => { - const userOperations = opEntryPointMap.get(entryPoint) - if (userOperations) { - await this.sendToExecutor( - entryPoint, - userOperations - ) - } else { - this.logger.warn( - { entryPoint }, - "no user operations for entry point" - ) - } - }) - ) - }) - ) + return undefined } startWatchingBlocks(handleBlock: (block: Block) => void): void { @@ -364,18 +314,6 @@ export class ExecutorManager { } this.unWatch = this.config.publicClient.watchBlocks({ onBlock: handleBlock, - // onBlock: async (block) => { - // // Use an arrow function to ensure correct binding of `this` - // this.checkAndReplaceTransactions(block) - // .then(() => { - // this.logger.trace("block handled") - // // Handle the resolution of the promise here, if needed - // }) - // .catch((error) => { - // // Handle any errors that occur during the execution of the promise - // this.logger.error({ error }, "error while handling block") - // }) - // }, onError: (error) => { this.logger.error({ error }, "error while watching blocks") }, @@ -396,32 +334,25 @@ 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, - userOperationInfos: opInfos, - previousTransactionHashes, - isVersion06 + transactionHash: currentTxhash, + bundle, + previousTransactionHashes } = transactionInfo - const txHashesToCheck = [ - currentTransactionHash, - ...previousTransactionHashes - ] + const { userOps, entryPoint } = bundle + const txHashesToCheck = [currentTxhash, ...previousTransactionHashes] const transactionDetails = await Promise.all( txHashesToCheck.map(async (transactionHash) => ({ transactionHash, - ...(await getBundleStatus( - isVersion06, + ...(await getBundleStatus({ transactionHash, - this.config.publicClient, - this.logger, - entryPoint - )) + bundle: transactionInfo.bundle, + publicClient: this.config.publicClient, + logger: this.logger + })) })) ) @@ -435,15 +366,6 @@ export class ExecutorManager { const finalizedTransaction = mined ?? reverted if (!finalizedTransaction) { - for (const { userOperationHash } of opInfos) { - this.logger.trace( - { - userOperationHash, - currentTransactionHash - }, - "user op still pending" - ) - } return } @@ -454,62 +376,8 @@ export class ExecutorManager { transactionHash: `0x${string}` } - if (bundlingStatus.status === "included") { - this.metrics.userOperationsOnChain - .labels({ status: bundlingStatus.status }) - .inc(opInfos.length) - - const { userOperationDetails } = bundlingStatus - opInfos.map((opInfo) => { - const { - userOperation, - userOperationHash, - entryPoint, - firstSubmitted - } = opInfo - const opDetails = userOperationDetails[userOperationHash] - - this.metrics.userOperationInclusionDuration.observe( - (Date.now() - firstSubmitted) / 1000 - ) - this.mempool.removeSubmitted(userOperationHash) - this.reputationManager.updateUserOperationIncludedStatus( - userOperation, - entryPoint, - opDetails.accountDeployed - ) - if (opDetails.status === "succesful") { - this.eventManager.emitIncludedOnChain( - userOperationHash, - transactionHash, - blockNumber as bigint - ) - } else { - this.eventManager.emitExecutionRevertedOnChain( - userOperationHash, - transactionHash, - opDetails.revertReason || "0x", - blockNumber as bigint - ) - } - this.monitor.setUserOperationStatus(userOperationHash, { - status: "included", - transactionHash - }) - this.logger.info( - { - userOperationHash, - transactionHash - }, - "user op included" - ) - }) - - this.executor.markWalletProcessed(transactionInfo.executor) - } else if ( - bundlingStatus.status === "reverted" && - bundlingStatus.isAA95 - ) { + // 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, @@ -518,30 +386,46 @@ export class ExecutorManager { transactionInfo.transactionRequest.nonce += 1 await this.replaceTransaction(transactionInfo, "AA95") - } else { + return + } + + // Free executor if tx landed onchain + if (bundlingStatus.status !== "not_found") { + this.senderManager.markWalletProcessed(transactionInfo.executor) + } + + if (bundlingStatus.status === "included") { + const { userOperationDetails } = bundlingStatus + this.markUserOpsIncluded( + userOps, + entryPoint, + blockNumber, + transactionHash, + userOperationDetails + ) + } + + if (bundlingStatus.status === "reverted") { await Promise.all( - opInfos.map(({ userOperationHash }) => { + userOps.map((userOpInfo) => { + const { userOpHash } = userOpInfo this.checkFrontrun({ - userOperationHash, + userOpHash, transactionHash, blockNumber }) }) ) - - opInfos.map(({ userOperationHash }) => { - this.mempool.removeSubmitted(userOperationHash) - }) - this.executor.markWalletProcessed(transactionInfo.executor) + this.removeSubmitted(userOps) } } checkFrontrun({ - userOperationHash, + userOpHash, transactionHash, blockNumber }: { - userOperationHash: HexData32 + userOpHash: HexData32 transactionHash: Hash blockNumber: bigint }) { @@ -549,7 +433,7 @@ export class ExecutorManager { onBlockNumber: async (currentBlockNumber) => { if (currentBlockNumber > blockNumber + 1n) { const userOperationReceipt = - await this.getUserOperationReceipt(userOperationHash) + await this.getUserOperationReceipt(userOpHash) if (userOperationReceipt) { const transactionHash = @@ -557,20 +441,21 @@ export class ExecutorManager { const blockNumber = userOperationReceipt.receipt.blockNumber - this.monitor.setUserOperationStatus(userOperationHash, { + this.mempool.removeSubmitted(userOpHash) + this.monitor.setUserOperationStatus(userOpHash, { status: "included", transactionHash }) this.eventManager.emitFrontranOnChain( - userOperationHash, + userOpHash, transactionHash, blockNumber ) this.logger.info( { - userOpHash: userOperationHash, + userOpHash, transactionHash }, "user op frontrun onchain" @@ -580,23 +465,22 @@ export class ExecutorManager { .labels({ status: "frontran" }) .inc(1) } else { - this.monitor.setUserOperationStatus(userOperationHash, { - status: "rejected", + this.monitor.setUserOperationStatus(userOpHash, { + status: "failed", transactionHash }) this.eventManager.emitFailedOnChain( - userOperationHash, + userOpHash, transactionHash, blockNumber ) this.logger.info( { - userOpHash: userOperationHash, + userOpHash, transactionHash }, "user op failed onchain" ) - this.metrics.userOperationsOnChain .labels({ status: "reverted" }) .inc(1) @@ -733,50 +617,12 @@ 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 } this.currentlyHandlingBlock = true - this.logger.debug({ blockNumber: block.number }, "handling block") const submittedEntries = this.mempool.dumpSubmittedOps() @@ -787,28 +633,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 - } - } - - this.logger.trace( - { gasPriceParameters }, - "fetched gas price parameters" - ) + })) const transactionInfos = getTransactionsFromUserOperationEntries( this.mempool.dumpSubmittedOps() @@ -816,33 +653,30 @@ export class ExecutorManager { await Promise.all( transactionInfos.map(async (txInfo) => { - if ( - txInfo.transactionRequest.maxFeePerGas >= - gasPriceParameters.maxFeePerGas && - txInfo.transactionRequest.maxPriorityFeePerGas >= - gasPriceParameters.maxPriorityFeePerGas - ) { - return - } + const { transactionRequest } = txInfo + const { maxFeePerGas, maxPriorityFeePerGas } = + transactionRequest - await this.replaceTransaction(txInfo, "gas_price") - }) - ) + const isMaxFeeTooLow = + maxFeePerGas < gasPriceParameters.maxFeePerGas - // for any left check if enough time has passed, if so replace - const transactionInfos2 = getTransactionsFromUserOperationEntries( - this.mempool.dumpSubmittedOps() - ) - await Promise.all( - transactionInfos2.map(async (txInfo) => { - if ( - Date.now() - txInfo.lastReplaced < + const isPriorityFeeTooLow = + maxPriorityFeePerGas < + gasPriceParameters.maxPriorityFeePerGas + + const isStuck = + Date.now() - txInfo.lastReplaced > this.config.resubmitStuckTimeout - ) { + + if (isMaxFeeTooLow || isPriorityFeeTooLow) { + await this.replaceTransaction(txInfo, "gas_price") return } - await this.replaceTransaction(txInfo, "stuck") + if (isStuck) { + await this.replaceTransaction(txInfo, "stuck") + return + } }) ) @@ -853,104 +687,185 @@ export class ExecutorManager { txInfo: TransactionInfo, reason: string ): Promise { - let replaceResult: ReplaceTransactionResult | undefined = undefined + // Setup vars + const { + bundle, + executor, + transactionRequest, + transactionHash: oldTxHash + } = txInfo + const { userOps } = bundle + + const gasPriceParameters = await this.gasPriceManager + .tryGetNetworkGasPrice() + .catch((_) => { + return undefined + }) - try { - replaceResult = await this.executor.replaceTransaction(txInfo) - } finally { - this.metrics.replacedTransactions - .labels({ reason, status: replaceResult?.status || "failed" }) - .inc() + if (!gasPriceParameters) { + const rejectedUserOps = userOps.map((userOpInfo) => ({ + ...userOpInfo, + reason: "Failed to get network gas price during replacement" + })) + this.failedToReplaceTransaction({ + rejectedUserOps, + oldTxHash, + reason: "Failed to get network gas price during replacement" + }) + // Free executor if failed to get initial params. + this.senderManager.markWalletProcessed(txInfo.executor) + return } - if (replaceResult.status === "failed") { - txInfo.userOperationInfos.map((opInfo) => { - const userOperation = opInfo.userOperation - - this.eventManager.emitDropped( - opInfo.userOperationHash, - "Failed to replace transaction" + const bundleResult = await this.executor.bundle({ + executor: executor, + userOpBundle: bundle, + nonce: transactionRequest.nonce, + gasPriceParams: { + maxFeePerGas: scaleBigIntByPercent( + gasPriceParameters.maxFeePerGas, + 115n + ), + maxPriorityFeePerGas: scaleBigIntByPercent( + gasPriceParameters.maxPriorityFeePerGas, + 115n ) + }, + gasLimitSuggestion: transactionRequest.gas, + isReplacementTx: true + }) + // Free wallet and return if potentially included too many times. + if (txInfo.timesPotentiallyIncluded >= 3) { + if (txInfo.timesPotentiallyIncluded >= 3) { + this.removeSubmitted(bundle.userOps) this.logger.warn( { - userOperation: JSON.stringify(userOperation, (_k, v) => - typeof v === "bigint" ? v.toString() : v - ), - userOpHash: opInfo.userOperationHash, - reason + oldTxHash, + userOps: getUserOpHashes(bundleResult.rejectedUserOps) }, - "user operation rejected" + "transaction potentially already included too many times, removing" ) + } - this.mempool.removeSubmitted(opInfo.userOperationHash) - }) + this.senderManager.markWalletProcessed(txInfo.executor) + return + } - this.logger.warn( - { oldTxHash: txInfo.transactionHash, reason }, - "failed to replace transaction" + // Free wallet if no bundle was sent or potentially included. + if (bundleResult.status !== "bundle_success") { + this.senderManager.markWalletProcessed(txInfo.executor) + } + + // Check if the transaction is potentially included. + const nonceTooLow = + bundleResult.status === "bundle_submission_failure" && + bundleResult.reason instanceof NonceTooLowError + + const allOpsFailedSimulation = + bundleResult.status === "all_ops_failed_simulation" && + bundleResult.rejectedUserOps.every( + (op) => + op.reason === "AA25 invalid account nonce" || + op.reason === "AA10 sender already constructed" ) - return - } + const potentiallyIncluded = nonceTooLow || allOpsFailedSimulation + + // log metrics + const replaceStatus = (() => { + switch (true) { + case potentiallyIncluded: + return "potentially_already_included" + case bundleResult?.status === "bundle_success": + return "replaced" + default: + return "failed" + } + })() + this.metrics.replacedTransactions + .labels({ reason, status: replaceStatus }) + .inc() - if (replaceResult.status === "potentially_already_included") { + if (potentiallyIncluded) { this.logger.info( - { oldTxHash: txInfo.transactionHash, reason }, + { + oldTxHash, + userOpHashes: getUserOpHashes(bundleResult.rejectedUserOps) + }, "transaction potentially already included" ) txInfo.timesPotentiallyIncluded += 1 + return + } - if (txInfo.timesPotentiallyIncluded >= 3) { - txInfo.userOperationInfos.map((opInfo) => { - this.mempool.removeSubmitted(opInfo.userOperationHash) - }) - this.executor.markWalletProcessed(txInfo.executor) - - this.logger.warn( - { oldTxHash: txInfo.transactionHash, reason }, - "transaction potentially already included too many times, removing" - ) - } + if (bundleResult.status === "unhandled_simulation_failure") { + const { rejectedUserOps, reason } = bundleResult + this.failedToReplaceTransaction({ + oldTxHash, + reason, + rejectedUserOps + }) + return + } + if (bundleResult.status === "all_ops_failed_simulation") { + this.failedToReplaceTransaction({ + oldTxHash, + reason: "all ops failed simulation", + rejectedUserOps: bundleResult.rejectedUserOps + }) return } - const newTxInfo = replaceResult.transactionInfo + if (bundleResult.status === "bundle_submission_failure") { + const { reason, rejectedUserOps } = bundleResult + const submissionFailureReason = + reason instanceof BaseError ? reason.name : "INTERNAL FAILURE" - const missingOps = txInfo.userOperationInfos.filter( - (info) => - !newTxInfo.userOperationInfos - .map((ni) => ni.userOperationHash) - .includes(info.userOperationHash) - ) + this.failedToReplaceTransaction({ + oldTxHash, + rejectedUserOps, + reason: submissionFailureReason + }) + return + } - const matchingOps = txInfo.userOperationInfos.filter((info) => - newTxInfo.userOperationInfos - .map((ni) => ni.userOperationHash) - .includes(info.userOperationHash) - ) + const { + rejectedUserOps, + userOpsBundled, + transactionRequest: newTransactionRequest, + transactionHash: newTxHash + } = bundleResult + + const userOpsReplaced = userOpsBundled + + const newTxInfo: TransactionInfo = { + ...txInfo, + transactionRequest: newTransactionRequest, + transactionHash: newTxHash, + previousTransactionHashes: [ + txInfo.transactionHash, + ...txInfo.previousTransactionHashes + ], + lastReplaced: Date.now(), + bundle: { + ...txInfo.bundle, + userOps: userOpsReplaced + } + } - matchingOps.map((opInfo) => { - this.mempool.replaceSubmitted(opInfo, newTxInfo) + userOpsReplaced.map((userOp) => { + this.mempool.replaceSubmitted(userOp, newTxInfo) }) - missingOps.map((opInfo) => { - this.mempool.removeSubmitted(opInfo.userOperationHash) - this.logger.warn( - { - oldTxHash: txInfo.transactionHash, - newTxHash: newTxInfo.transactionHash, - reason - }, - "missing op in new tx" - ) - }) + // Drop all userOperations that were rejected during simulation. + this.dropUserOps(rejectedUserOps) this.logger.info( { - oldTxHash: txInfo.transactionHash, - newTxHash: newTxInfo.transactionHash, + oldTxHash, + newTxHash, reason }, "replaced transaction" @@ -958,4 +873,145 @@ export class ExecutorManager { return } + + markUserOperationsAsSubmitted( + userOpInfos: UserOpInfo[], + transactionInfo: TransactionInfo + ) { + userOpInfos.map((userOpInfo) => { + const { userOpHash } = userOpInfo + this.mempool.markSubmitted(userOpHash, transactionInfo) + this.startWatchingBlocks(this.handleBlock.bind(this)) + this.metrics.userOperationsSubmitted + .labels({ status: "success" }) + .inc() + }) + } + + resubmitUserOperations( + userOps: UserOpInfo[], + entryPoint: Address, + reason: string + ) { + userOps.map((userOpInfo) => { + const { userOpHash, userOp } = userOpInfo + this.logger.info( + { + userOpHash, + reason + }, + "resubmitting user operation" + ) + this.mempool.removeProcessing(userOpHash) + this.mempool.add(userOp, entryPoint) + this.metrics.userOperationsResubmitted.inc() + }) + } + + failedToReplaceTransaction({ + oldTxHash, + rejectedUserOps, + reason + }: { + oldTxHash: Hex + rejectedUserOps: RejectedUserOp[] + reason: string + }) { + this.logger.warn({ oldTxHash, reason }, "failed to replace transaction") + this.dropUserOps(rejectedUserOps) + } + + removeSubmitted(userOps: UserOpInfo[]) { + userOps.map((userOpInfo) => { + const { userOpHash } = userOpInfo + this.mempool.removeSubmitted(userOpHash) + }) + } + + markUserOpsIncluded( + userOps: UserOpInfo[], + entryPoint: Address, + blockNumber: bigint, + transactionHash: Hash, + userOperationDetails: Record + ) { + userOps.map((userOpInfo) => { + this.metrics.userOperationsOnChain + .labels({ status: "included" }) + .inc() + + const { userOpHash, userOp } = userOpInfo + const opDetails = userOperationDetails[userOpHash] + + const firstSubmitted = userOpInfo.addedToMempool + this.metrics.userOperationInclusionDuration.observe( + (Date.now() - firstSubmitted) / 1000 + ) + + this.mempool.removeSubmitted(userOpHash) + this.reputationManager.updateUserOperationIncludedStatus( + userOp, + entryPoint, + opDetails.accountDeployed + ) + + if (opDetails.status === "succesful") { + this.eventManager.emitIncludedOnChain( + userOpHash, + transactionHash, + blockNumber as bigint + ) + } else { + this.eventManager.emitExecutionRevertedOnChain( + userOpHash, + transactionHash, + opDetails.revertReason || "0x", + blockNumber as bigint + ) + } + + this.monitor.setUserOperationStatus(userOpHash, { + status: "included", + transactionHash + }) + + this.logger.info( + { + opHash: userOpHash, + transactionHash + }, + "user op included" + ) + }) + } + + dropUserOps(rejectedUserOps: RejectedUserOp[]) { + rejectedUserOps.map((rejectedUserOp) => { + const { userOp, reason, userOpHash } = rejectedUserOp + this.mempool.removeProcessing(userOpHash) + this.mempool.removeSubmitted(userOpHash) + this.eventManager.emitDropped( + userOpHash, + reason, + getAAError(reason) + ) + this.monitor.setUserOperationStatus(userOpHash, { + status: "rejected", + transactionHash: null + }) + this.logger.warn( + { + userOperation: JSON.stringify(userOp, (_k, v) => + typeof v === "bigint" ? v.toString() : v + ), + userOpHash, + reason + }, + "user operation rejected" + ) + this.metrics.userOperationsSubmitted + .labels({ status: "failed" }) + .inc() + }) + } } diff --git a/src/executor/filterOpsAndEStimateGas.ts b/src/executor/filterOpsAndEStimateGas.ts new file mode 100644 index 00000000..14fa3abb --- /dev/null +++ b/src/executor/filterOpsAndEStimateGas.ts @@ -0,0 +1,329 @@ +import { InterfaceReputationManager } from "@alto/mempool" +import { + EntryPointV06Abi, + EntryPointV07Abi, + FailedOpWithRevert, + RejectedUserOp, + UserOpInfo, + UserOperationBundle, + failedOpErrorSchema, + failedOpWithRevertErrorSchema +} from "@alto/types" +import { + Account, + ContractFunctionRevertedError, + EstimateGasExecutionError, + FeeCapTooLowError, + Hex, + decodeErrorResult, + getContract +} from "viem" +import { AltoConfig } from "../createConfig" +import { + Logger, + getRevertErrorData, + parseViemError, + scaleBigIntByPercent +} from "@alto/utils" +import { z } from "zod" +import { getAuthorizationList, packUserOps } from "./utils" +import * as sentry from "@sentry/node" + +export type FilterOpsAndEstimateGasResult = + | { + status: "success" + userOpsToBundle: UserOpInfo[] + rejectedUserOps: RejectedUserOp[] + gasLimit: bigint + } + | { + status: "unhandled_failure" + rejectedUserOps: RejectedUserOp[] + } + | { + status: "all_ops_failed_simulation" + rejectedUserOps: RejectedUserOp[] + } + +function rejectUserOp(userOpInfo: UserOpInfo, reason: string): RejectedUserOp { + return { + ...userOpInfo, + reason + } +} + +function rejectUserOps( + userOpInfos: UserOpInfo[], + reason: string +): RejectedUserOp[] { + return userOpInfos.map((userOpInfo) => rejectUserOp(userOpInfo, reason)) +} + +// Attempt to create a handleOps bundle + estimate bundling tx gas. +export async function filterOpsAndEstimateGas({ + executor, + userOpBundle, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + reputationManager, + config, + logger +}: { + executor: Account + userOpBundle: UserOperationBundle + nonce: number + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + reputationManager: InterfaceReputationManager + config: AltoConfig + logger: Logger +}): Promise { + const { userOps, version, entryPoint } = userOpBundle + let { + fixedGasLimitForEstimation, + legacyTransactions, + blockTagSupport, + publicClient, + walletClient + } = config + + const isUserOpV06 = version === "0.6" + const epContract = getContract({ + abi: isUserOpV06 ? EntryPointV06Abi : EntryPointV07Abi, + address: entryPoint, + client: { + public: publicClient, + wallet: walletClient + } + }) + + // Keep track of invalid and valid ops + const userOpsToBundle = [...userOps] + const rejectedUserOps: RejectedUserOp[] = [] + + // Prepare bundling tx params + const gasOptions = legacyTransactions + ? { gasPrice: maxFeePerGas } + : { maxFeePerGas, maxPriorityFeePerGas } + const blockTag = blockTagSupport ? "latest" : undefined + + let gasLimit: bigint + let retriesLeft = 5 + + while (userOpsToBundle.length > 0) { + if (retriesLeft === 0) { + logger.error("max retries reached") + return { + status: "unhandled_failure", + rejectedUserOps: [ + ...rejectedUserOps, + ...rejectUserOps(userOpsToBundle, "INTERNAL FAILURE") + ] + } + } + + try { + const packedUserOps = packUserOps(userOpsToBundle) + const authorizationList = getAuthorizationList(userOpsToBundle) + + gasLimit = await epContract.estimateGas.handleOps( + // @ts-ignore - ep is set correctly for opsToSend, but typescript doesn't know that + [packedUserOps, executor.address], + { + account: executor, + nonce: nonce, + blockTag, + ...(fixedGasLimitForEstimation && { + gas: fixedGasLimitForEstimation + }), + ...(authorizationList && { + authorizationList + }), + ...gasOptions + } + ) + + return { + status: "success", + userOpsToBundle, + rejectedUserOps, + gasLimit + } + } catch (err: unknown) { + logger.error({ err, blockTag }, "handling error estimating gas") + const e = parseViemError(err) + + if (e instanceof ContractFunctionRevertedError) { + let parseResult = z + .union([failedOpErrorSchema, failedOpWithRevertErrorSchema]) + .safeParse(e.data) + + if (!parseResult.success) { + sentry.captureException(err) + logger.error( + { + error: parseResult.error + }, + "failed to parse failedOpError" + ) + return { + status: "unhandled_failure", + rejectedUserOps: [ + ...rejectedUserOps, + ...rejectUserOps( + userOpsToBundle, + "INTERNAL FAILURE" + ) + ] + } + } + + const errorData = parseResult.data.args + + if (errorData) { + if (errorData.reason.includes("AA95 out of gas")) { + fixedGasLimitForEstimation = scaleBigIntByPercent( + fixedGasLimitForEstimation || BigInt(30_000_000), + 110n + ) + retriesLeft-- + continue + } + + const failingOpIndex = Number(errorData.opIndex) + const failingUserOp = userOpsToBundle[failingOpIndex] + userOpsToBundle.splice(failingOpIndex, 1) + + reputationManager.crashedHandleOps( + failingUserOp.userOp, + epContract.address, + errorData.reason + ) + + const innerError = (errorData as FailedOpWithRevert)?.inner + const revertReason = innerError + ? `${errorData.reason} - ${innerError}` + : errorData.reason + + rejectedUserOps.push( + rejectUserOp(failingUserOp, revertReason) + ) + } + } else if ( + e instanceof EstimateGasExecutionError || + err instanceof EstimateGasExecutionError + ) { + if (e?.cause instanceof FeeCapTooLowError) { + logger.info( + { error: e.shortMessage }, + "error estimating gas due to max fee < basefee" + ) + + if ("gasPrice" in gasOptions) { + gasOptions.gasPrice = scaleBigIntByPercent( + gasOptions.gasPrice || maxFeePerGas, + 125n + ) + } + if ("maxFeePerGas" in gasOptions) { + gasOptions.maxFeePerGas = scaleBigIntByPercent( + gasOptions.maxFeePerGas || maxFeePerGas, + 125n + ) + } + if ("maxPriorityFeePerGas" in gasOptions) { + gasOptions.maxPriorityFeePerGas = scaleBigIntByPercent( + gasOptions.maxPriorityFeePerGas || + maxPriorityFeePerGas, + 125n + ) + } + + retriesLeft-- + continue + } + + try { + let errorHexData: Hex = "0x" + + if (err instanceof EstimateGasExecutionError) { + errorHexData = getRevertErrorData(err) as Hex + } else { + errorHexData = e?.details.split("Reverted ")[1] as Hex + } + const errorResult = decodeErrorResult({ + abi: isUserOpV06 ? EntryPointV06Abi : EntryPointV07Abi, + data: errorHexData + }) + + if ( + errorResult.errorName !== "FailedOpWithRevert" && + errorResult.errorName !== "FailedOp" + ) { + logger.error( + { + errorName: errorResult.errorName, + args: errorResult.args + }, + "unexpected error result" + ) + return { + status: "unhandled_failure", + rejectedUserOps: [ + ...rejectedUserOps, + ...rejectUserOps( + userOpsToBundle, + "INTERNAL FAILURE" + ) + ] + } + } + + const [opIndex, reason] = errorResult.args + + const failedOpIndex = Number(opIndex) + const failingUserOp = userOpsToBundle[failedOpIndex] + + rejectedUserOps.push(rejectUserOp(failingUserOp, reason)) + userOpsToBundle.splice(failedOpIndex, 1) + + continue + } catch (e: unknown) { + logger.error( + { error: JSON.stringify(err) }, + "failed to parse error result" + ) + return { + status: "unhandled_failure", + rejectedUserOps: [ + ...rejectedUserOps, + ...rejectUserOps( + userOpsToBundle, + "INTERNAL FAILURE" + ) + ] + } + } + } else { + sentry.captureException(err) + logger.error( + { error: JSON.stringify(err), blockTag }, + "error estimating gas" + ) + return { + status: "unhandled_failure", + rejectedUserOps: [ + ...rejectedUserOps, + ...rejectUserOps(userOpsToBundle, "INTERNAL FAILURE") + ] + } + } + } + } + + return { + status: "all_ops_failed_simulation", + rejectedUserOps + } +} diff --git a/src/executor/senderManager.ts b/src/executor/senderManager.ts index 7fa33c3d..279a276e 100644 --- a/src/executor/senderManager.ts +++ b/src/executor/senderManager.ts @@ -15,6 +15,7 @@ import { getContract } from "viem" import type { AltoConfig } from "../createConfig" +import { flushStuckTransaction } from "./utils" const waitForTransactionReceipt = async ( publicClient: PublicClient, @@ -271,4 +272,53 @@ export class SenderManager { this.metrics.walletsAvailable.set(this.availableWallets.length) return } + + public markWalletProcessed(executor: Account) { + if (!this.availableWallets.includes(executor)) { + this.pushWallet(executor) + } + return Promise.resolve() + } + + async flushOnStartUp(): Promise { + const allWallets = new Set(this.wallets) + + const utilityWallet = this.utilityAccount + if (utilityWallet) { + allWallets.add(utilityWallet) + } + + const wallets = Array.from(allWallets) + + let gasPrice: { + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + } + + try { + gasPrice = await this.gasPriceManager.tryGetNetworkGasPrice() + } catch (e) { + this.logger.error({ error: e }, "error flushing stuck transaction") + return + } + + const promises = wallets.map((wallet) => { + try { + flushStuckTransaction( + this.config.publicClient, + this.config.walletClient, + wallet, + gasPrice.maxFeePerGas * 5n, + this.logger + ) + } catch (e) { + this.logger.error( + { error: e }, + "error flushing stuck transaction" + ) + } + }) + + await Promise.all(promises) + } } diff --git a/src/executor/types.ts b/src/executor/types.ts deleted file mode 100644 index 4d43cc8d..00000000 --- a/src/executor/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Account } from "viem" -import { SignedAuthorizationList } from "viem/experimental" - -export type SendTransactionOptions = - | { - type: "legacy" - gasPrice: bigint - account: Account - gas: bigint - nonce: number - } - | { - type: "eip1559" - maxFeePerGas: bigint - maxPriorityFeePerGas: bigint - account: Account - gas: bigint - nonce: number - } - | { - type: "eip7702" - maxFeePerGas: bigint - maxPriorityFeePerGas: bigint - account: Account - gas: bigint - nonce: number - authorizationList: SignedAuthorizationList - } diff --git a/src/executor/utils.ts b/src/executor/utils.ts index 554af530..24d3a40b 100644 --- a/src/executor/utils.ts +++ b/src/executor/utils.ts @@ -1,41 +1,28 @@ -import type { InterfaceReputationManager } from "@alto/mempool" import { - type BundleResult, EntryPointV06Abi, EntryPointV07Abi, - type FailedOp, - type FailedOpWithRevert, - type TransactionInfo, - type UserOperation, - type UserOperationV07, - type UserOperationWithHash, - failedOpErrorSchema, - failedOpWithRevertErrorSchema + PackedUserOperation, + UserOpInfo, + UserOperationV07 } from "@alto/types" -import type { Logger } from "@alto/utils" import { - getRevertErrorData, isVersion06, - parseViemError, - scaleBigIntByPercent, - toPackedUserOperation + toPackedUserOperation, + type Logger, + isVersion07 } from "@alto/utils" // biome-ignore lint/style/noNamespaceImport: explicitly make it clear when sentry is used import * as sentry from "@sentry/node" import { type Account, - type Address, type Chain, - ContractFunctionRevertedError, - EstimateGasExecutionError, - FeeCapTooLowError, - type GetContractReturnType, - type Hex, type PublicClient, type Transport, type WalletClient, - decodeErrorResult, - BaseError + BaseError, + encodeFunctionData, + Address, + Hex } from "viem" import { SignedAuthorizationList } from "viem/experimental" @@ -45,302 +32,68 @@ export const isTransactionUnderpricedError = (e: BaseError) => { .includes("replacement transaction underpriced") } -export const getAuthorizationList = ( - userOperations: UserOperation[] -): SignedAuthorizationList | undefined => { - const authorizationList = userOperations - .map((op) => { - if (op.eip7702Auth) { - return op.eip7702Auth - } - return undefined - }) - .filter((auth) => auth !== undefined) as SignedAuthorizationList +// V7 source: https://github.com/eth-infinitism/account-abstraction/blob/releases/v0.7/contracts/core/EntryPoint.sol +// V6 source: https://github.com/eth-infinitism/account-abstraction/blob/fa61290d37d079e928d92d53a122efcc63822214/contracts/core/EntryPoint.sol#L236 +export function calculateAA95GasFloor(userOps: UserOpInfo[]): bigint { + let gasFloor = 0n + + for (const userOpInfo of userOps) { + const { userOp } = userOpInfo + if (isVersion07(userOp)) { + const totalGas = + userOp.callGasLimit + + (userOp.paymasterPostOpGasLimit || 0n) + + 10_000n + gasFloor += (totalGas * 64n) / 63n + } else { + gasFloor += + userOp.callGasLimit + userOp.verificationGasLimit + 5000n + } + } - return authorizationList.length > 0 ? authorizationList : undefined + return gasFloor } -export function simulatedOpsToResults( - simulatedOps: { - owh: UserOperationWithHash - reason: string | undefined - }[], - transactionInfo: TransactionInfo -): BundleResult[] { - return simulatedOps.map(({ reason, owh }) => { - if (reason === undefined) { - return { - status: "success", - value: { - userOperation: { - entryPoint: transactionInfo.entryPoint, - userOperation: owh.userOperation, - userOperationHash: owh.userOperationHash, - lastReplaced: Date.now(), - firstSubmitted: Date.now() - }, - transactionInfo - } - } - } - return { - status: "failure", - error: { - entryPoint: transactionInfo.entryPoint, - userOperation: owh.userOperation, - userOpHash: owh.userOperationHash, - reason: reason as string - } - } - }) +export const getUserOpHashes = (userOpInfos: UserOpInfo[]) => { + return userOpInfos.map(({ userOpHash }) => userOpHash) } -export type DefaultFilterOpsAndEstimateGasParams = {} +export const packUserOps = (userOpInfos: UserOpInfo[]) => { + const userOps = userOpInfos.map(({ userOp }) => userOp) + const isV06 = isVersion06(userOps[0]) + const packedUserOps = isV06 + ? userOps + : userOps.map((op) => toPackedUserOperation(op as UserOperationV07)) + return packedUserOps as PackedUserOperation[] +} -export async function filterOpsAndEstimateGas( - entryPoint: Address, - ep: GetContractReturnType< - typeof EntryPointV06Abi | typeof EntryPointV07Abi, - { - public: PublicClient - wallet: WalletClient - } - >, - wallet: Account, - ops: UserOperationWithHash[], - nonce: number, - maxFeePerGas: bigint, - maxPriorityFeePerGas: bigint, - blockTag: "latest" | "pending" | undefined, - onlyPre1559: boolean, - fixedGasLimitForEstimation: bigint | undefined, - reputationManager: InterfaceReputationManager, - logger: Logger, - authorizationList?: SignedAuthorizationList -) { - const simulatedOps: { - owh: UserOperationWithHash - reason: string | undefined - }[] = ops.map((owh) => { - return { owh, reason: undefined } +export const encodeHandleOpsCalldata = ({ + userOps, + beneficiary +}: { + userOps: UserOpInfo[] + beneficiary: Address +}): Hex => { + const ops = userOps.map(({ userOp }) => userOp) + const isV06 = isVersion06(ops[0]) + const packedUserOps = packUserOps(userOps) + + return encodeFunctionData({ + abi: isV06 ? EntryPointV06Abi : EntryPointV07Abi, + functionName: "handleOps", + args: [packedUserOps, beneficiary] }) +} - let gasLimit: bigint - - const isUserOpV06 = isVersion06( - simulatedOps[0].owh.userOperation as UserOperation - ) - - const gasOptions = onlyPre1559 - ? { gasPrice: maxFeePerGas } - : { maxFeePerGas, maxPriorityFeePerGas } - - let fixedEstimationGasLimit: bigint | undefined = fixedGasLimitForEstimation - let retriesLeft = 5 - - while (simulatedOps.filter((op) => op.reason === undefined).length > 0) { - try { - const opsToSend = simulatedOps - .filter((op) => op.reason === undefined) - .map(({ owh }) => { - const op = owh.userOperation - return isUserOpV06 - ? op - : toPackedUserOperation(op as UserOperationV07) - }) - - gasLimit = await ep.estimateGas.handleOps( - // @ts-ignore - ep is set correctly for opsToSend, but typescript doesn't know that - [opsToSend, wallet.address], - { - account: wallet, - nonce: nonce, - blockTag: blockTag, - ...(fixedEstimationGasLimit !== undefined && { - gas: fixedEstimationGasLimit - }), - ...(authorizationList !== undefined && { - authorizationList - }), - ...gasOptions - } - ) - - return { simulatedOps, gasLimit } - } catch (err: unknown) { - logger.error({ err, blockTag }, "error estimating gas") - const e = parseViemError(err) - - if (e instanceof ContractFunctionRevertedError) { - const failedOpError = failedOpErrorSchema.safeParse(e.data) - const failedOpWithRevertError = - failedOpWithRevertErrorSchema.safeParse(e.data) - - let errorData: FailedOp | FailedOpWithRevert | undefined = - undefined - - if (failedOpError.success) { - errorData = failedOpError.data.args - } - if (failedOpWithRevertError.success) { - errorData = failedOpWithRevertError.data.args - } - - if (errorData) { - if ( - errorData.reason.indexOf("AA95 out of gas") !== -1 && - retriesLeft > 0 - ) { - retriesLeft-- - fixedEstimationGasLimit = scaleBigIntByPercent( - fixedEstimationGasLimit || BigInt(30_000_000), - 110n - ) - continue - } - - logger.debug( - { - errorData, - userOpHashes: simulatedOps - .filter((op) => op.reason === undefined) - .map((op) => op.owh.userOperationHash) - }, - "user op in batch invalid" - ) - - const failingOp = simulatedOps.filter( - (op) => op.reason === undefined - )[Number(errorData.opIndex)] - - failingOp.reason = `${errorData.reason}${ - (errorData as FailedOpWithRevert)?.inner - ? ` - ${(errorData as FailedOpWithRevert).inner}` - : "" - }` - - reputationManager.crashedHandleOps( - failingOp.owh.userOperation, - entryPoint, - failingOp.reason - ) - } - - if ( - !(failedOpError.success || failedOpWithRevertError.success) - ) { - sentry.captureException(err) - logger.error( - { - error: `${failedOpError.error} ${failedOpWithRevertError.error}` - }, - "failed to parse failedOpError" - ) - return { - simulatedOps: [], - gasLimit: 0n - } - } - } else if ( - e instanceof EstimateGasExecutionError || - err instanceof EstimateGasExecutionError - ) { - if (e?.cause instanceof FeeCapTooLowError && retriesLeft > 0) { - retriesLeft-- - - logger.info( - { error: e.shortMessage }, - "error estimating gas due to max fee < basefee" - ) - - if ("gasPrice" in gasOptions) { - gasOptions.gasPrice = scaleBigIntByPercent( - gasOptions.gasPrice || maxFeePerGas, - 125n - ) - } - if ("maxFeePerGas" in gasOptions) { - gasOptions.maxFeePerGas = scaleBigIntByPercent( - gasOptions.maxFeePerGas || maxFeePerGas, - 125n - ) - } - if ("maxPriorityFeePerGas" in gasOptions) { - gasOptions.maxPriorityFeePerGas = scaleBigIntByPercent( - gasOptions.maxPriorityFeePerGas || - maxPriorityFeePerGas, - 125n - ) - } - continue - } - - try { - let errorHexData: Hex = "0x" - - if (err instanceof EstimateGasExecutionError) { - errorHexData = getRevertErrorData(err) as Hex - } else { - errorHexData = e?.details.split("Reverted ")[1] as Hex - } - const errorResult = decodeErrorResult({ - abi: isUserOpV06 ? EntryPointV06Abi : EntryPointV07Abi, - data: errorHexData - }) - logger.debug( - { - errorName: errorResult.errorName, - args: errorResult.args, - userOpHashes: simulatedOps - .filter((op) => op.reason === undefined) - .map((op) => op.owh.userOperationHash) - }, - "user op in batch invalid" - ) - - if ( - errorResult.errorName !== "FailedOpWithRevert" && - errorResult.errorName !== "FailedOp" - ) { - logger.error( - { - errorName: errorResult.errorName, - args: errorResult.args - }, - "unexpected error result" - ) - return { - simulatedOps: [], - gasLimit: 0n - } - } - - const failingOp = simulatedOps.filter( - (op) => op.reason === undefined - )[Number(errorResult.args[0])] +export const getAuthorizationList = ( + userOpInfos: UserOpInfo[] +): SignedAuthorizationList | undefined => { + const authList = userOpInfos + .map(({ userOp }) => userOp) + .map(({ eip7702Auth }) => eip7702Auth) + .filter(Boolean) as SignedAuthorizationList - failingOp.reason = errorResult.args[1] - } catch (e: unknown) { - logger.error( - { error: JSON.stringify(err) }, - "failed to parse error result" - ) - return { - simulatedOps: [], - gasLimit: 0n - } - } - } else { - sentry.captureException(err) - logger.error( - { error: JSON.stringify(err), blockTag }, - "error estimating gas" - ) - return { simulatedOps: [], gasLimit: 0n } - } - } - } - return { simulatedOps, gasLimit: 0n } + return authList.length ? authList : undefined } export async function flushStuckTransaction( diff --git a/src/handlers/eventManager.ts b/src/handlers/eventManager.ts index 83ce0aaa..6bab6192 100644 --- a/src/handlers/eventManager.ts +++ b/src/handlers/eventManager.ts @@ -164,14 +164,19 @@ export class EventManager { } // emits when the userOperation has been submitted to the network - async emitSubmitted(userOperationHash: Hex, transactionHash: Hex) { - await this.emitEvent({ - userOperationHash, - event: { - eventType: "submitted", - transactionHash - } - }) + async emitSubmitted({ + userOpHashes, + transactionHash + }: { userOpHashes: Hex[]; transactionHash: Hex }) { + for (const hash of userOpHashes) { + await this.emitEvent({ + userOperationHash: hash, + event: { + eventType: "submitted", + transactionHash + } + }) + } } // emits when the userOperation was dropped from the internal mempool diff --git a/src/mempool/mempool.ts b/src/mempool/mempool.ts index 6413ae1c..48108144 100644 --- a/src/mempool/mempool.ts +++ b/src/mempool/mempool.ts @@ -1,7 +1,4 @@ import type { EventManager } from "@alto/handlers" -// import { MongoClient, Collection, Filter } from "mongodb" -// import { PublicClient, getContract } from "viem" -// import { EntryPointAbi } from "../types/EntryPoint" import { EntryPointV06Abi, EntryPointV07Abi, @@ -9,22 +6,23 @@ import { type ReferencedCodeHashes, RpcError, type StorageMap, - type SubmittedUserOperation, + type SubmittedUserOp, type TransactionInfo, type UserOperation, - type UserOperationInfo, ValidationErrors, - type ValidationResult + type ValidationResult, + UserOperationBundle, + UserOpInfo } from "@alto/types" -import type { HexData32 } from "@alto/types" import type { Metrics } from "@alto/utils" import type { Logger } from "@alto/utils" import { getAddressFromInitCodeOrPaymasterAndData, - getNonceKeyAndValue, + getNonceKeyAndSequence, getUserOperationHash, isVersion06, - isVersion07 + isVersion07, + scaleBigIntByPercent } from "@alto/utils" import { type Address, getAddress, getContract } from "viem" import type { Monitor } from "./monitoring" @@ -76,29 +74,24 @@ export class MemoryMempool { } replaceSubmitted( - userOperation: UserOperationInfo, + userOpInfo: UserOpInfo, transactionInfo: TransactionInfo ): void { - const op = this.store + const { userOpHash } = userOpInfo + const existingUserOpToReplace = this.store .dumpSubmitted() - .find( - (op) => - op.userOperation.userOperationHash === - userOperation.userOperationHash - ) - if (op) { - this.store.removeSubmitted(userOperation.userOperationHash) + .find((userOpInfo) => userOpInfo.userOpHash === userOpHash) + + if (existingUserOpToReplace) { + this.store.removeSubmitted(userOpHash) this.store.addSubmitted({ - userOperation, + ...userOpInfo, transactionInfo }) - this.monitor.setUserOperationStatus( - userOperation.userOperationHash, - { - status: "submitted", - transactionHash: transactionInfo.transactionHash - } - ) + this.monitor.setUserOperationStatus(userOpHash, { + status: "submitted", + transactionHash: transactionInfo.transactionHash + }) } } @@ -106,13 +99,14 @@ export class MemoryMempool { userOpHash: `0x${string}`, transactionInfo: TransactionInfo ): void { - const op = this.store + const processingUserOp = this.store .dumpProcessing() - .find((op) => op.userOperationHash === userOpHash) - if (op) { + .find((userOpInfo) => userOpInfo.userOpHash === userOpHash) + + if (processingUserOp) { this.store.removeProcessing(userOpHash) this.store.addSubmitted({ - userOperation: op, + ...processingUserOp, transactionInfo }) this.monitor.setUserOperationStatus(userOpHash, { @@ -122,15 +116,15 @@ export class MemoryMempool { } } - dumpOutstanding(): UserOperationInfo[] { - return this.store.dumpOutstanding() + dumpOutstanding(): UserOperation[] { + return this.store.dumpOutstanding().map(({ userOp }) => userOp) } - dumpProcessing(): UserOperationInfo[] { + dumpProcessing(): UserOpInfo[] { return this.store.dumpProcessing() } - dumpSubmittedOps(): SubmittedUserOperation[] { + dumpSubmittedOps(): SubmittedUserOp[] { return this.store.dumpSubmitted() } @@ -207,23 +201,25 @@ export class MemoryMempool { factories: new Set() } - for (const mempoolOp of allOps) { - const op = mempoolOp.userOperation - entities.sender.add(op.sender) + for (const userOpInfo of allOps) { + const { userOp } = userOpInfo + entities.sender.add(userOp.sender) - const isUserOpV06 = isVersion06(op) + const isUserOpV06 = isVersion06(userOp) const paymaster = isUserOpV06 - ? getAddressFromInitCodeOrPaymasterAndData(op.paymasterAndData) - : op.paymaster + ? getAddressFromInitCodeOrPaymasterAndData( + userOp.paymasterAndData + ) + : userOp.paymaster if (paymaster) { entities.paymasters.add(paymaster) } const factory = isUserOpV06 - ? getAddressFromInitCodeOrPaymasterAndData(op.initCode) - : op.factory + ? getAddressFromInitCodeOrPaymasterAndData(userOp.initCode) + : userOp.factory if (factory) { entities.factories.add(factory) @@ -236,12 +232,12 @@ export class MemoryMempool { // TODO: add check for adding a userop with conflicting nonce // In case of concurrent requests add( - userOperation: UserOperation, + userOp: UserOperation, entryPoint: Address, referencedContracts?: ReferencedCodeHashes ): [boolean, string] { - const opHash = getUserOperationHash( - userOperation, + const userOpHash = getUserOperationHash( + userOp, entryPoint, this.config.publicClient.chain.id ) @@ -250,27 +246,25 @@ export class MemoryMempool { const processedOrSubmittedOps = [ ...this.store.dumpProcessing(), - ...this.store - .dumpSubmitted() - .map(({ userOperation }) => userOperation) + ...this.store.dumpSubmitted() ] // Check if the exact same userOperation is already in the mempool. const existingUserOperation = [ ...outstandingOps, ...processedOrSubmittedOps - ].find(({ userOperationHash }) => userOperationHash === opHash) + ].find((userOpInfo) => userOpInfo.userOpHash === userOpHash) if (existingUserOperation) { return [false, "Already known"] } if ( - processedOrSubmittedOps.find((opInfo) => { - const mempoolUserOp = opInfo.userOperation + processedOrSubmittedOps.find((userOpInfo) => { + const { userOp: mempoolUserOp } = userOpInfo return ( - mempoolUserOp.sender === userOperation.sender && - mempoolUserOp.nonce === userOperation.nonce + mempoolUserOp.sender === userOp.sender && + mempoolUserOp.nonce === userOp.nonce ) }) ) { @@ -280,68 +274,59 @@ export class MemoryMempool { ] } - this.reputationManager.updateUserOperationSeenStatus( - userOperation, - entryPoint - ) - const oldUserOp = [...outstandingOps, ...processedOrSubmittedOps].find( - (opInfo) => { - const mempoolUserOp = opInfo.userOperation + this.reputationManager.updateUserOperationSeenStatus(userOp, entryPoint) + const oldUserOpInfo = [ + ...outstandingOps, + ...processedOrSubmittedOps + ].find((userOpInfo) => { + const { userOp: mempoolUserOp } = userOpInfo - const isSameSender = - mempoolUserOp.sender === userOperation.sender + const isSameSender = mempoolUserOp.sender === userOp.sender + if (isSameSender && mempoolUserOp.nonce === userOp.nonce) { + return true + } - if ( + // Check if there is already a userOperation with initCode + same sender (stops rejected ops due to AA10). + if ( + isVersion06(mempoolUserOp) && + isVersion06(userOp) && + userOp.initCode && + userOp.initCode !== "0x" + ) { + return ( isSameSender && - mempoolUserOp.nonce === userOperation.nonce - ) { - return true - } - - // Check if there is already a userOperation with initCode + same sender (stops rejected ops due to AA10). - if ( - isVersion06(mempoolUserOp) && - isVersion06(userOperation) && - userOperation.initCode && - userOperation.initCode !== "0x" - ) { - return ( - isSameSender && - mempoolUserOp.initCode && - mempoolUserOp.initCode !== "0x" - ) - } - - // Check if there is already a userOperation with factory + same sender (stops rejected ops due to AA10). - if ( - isVersion07(mempoolUserOp) && - isVersion07(userOperation) && - userOperation.factory && - userOperation.factory !== "0x" - ) { - return ( - isSameSender && - mempoolUserOp.factory && - mempoolUserOp.factory !== "0x" - ) - } + mempoolUserOp.initCode && + mempoolUserOp.initCode !== "0x" + ) + } - return false + // Check if there is already a userOperation with factory + same sender (stops rejected ops due to AA10). + if ( + isVersion07(mempoolUserOp) && + isVersion07(userOp) && + userOp.factory && + userOp.factory !== "0x" + ) { + return ( + isSameSender && + mempoolUserOp.factory && + mempoolUserOp.factory !== "0x" + ) } - ) + + return false + }) const isOldUserOpProcessingOrSubmitted = processedOrSubmittedOps.some( - (submittedOp) => - submittedOp.userOperationHash === oldUserOp?.userOperationHash + (userOpInfo) => userOpInfo.userOpHash === oldUserOpInfo?.userOpHash ) - if (oldUserOp) { - const oldOp = oldUserOp.userOperation - + if (oldUserOpInfo) { + const { userOp: oldUserOp } = oldUserOpInfo let reason = "AA10 sender already constructed: A conflicting userOperation with initCode for this sender is already in the mempool. bump the gas price by minimum 10%" - if (oldOp.nonce === userOperation.nonce) { + if (oldUserOp.nonce === userOp.nonce) { reason = "AA25 invalid account nonce: User operation already present in mempool, bump the gas price by minimum 10%" } @@ -351,33 +336,32 @@ export class MemoryMempool { return [false, reason] } - const oldMaxPriorityFeePerGas = oldOp.maxPriorityFeePerGas - const newMaxPriorityFeePerGas = userOperation.maxPriorityFeePerGas - const oldMaxFeePerGas = oldOp.maxFeePerGas - const newMaxFeePerGas = userOperation.maxFeePerGas + const oldOp = oldUserOp + const newOp = userOp - const incrementMaxPriorityFeePerGas = - (oldMaxPriorityFeePerGas * BigInt(10)) / BigInt(100) - const incrementMaxFeePerGas = - (oldMaxFeePerGas * BigInt(10)) / BigInt(100) + const hasHigherPriorityFee = + newOp.maxPriorityFeePerGas >= + scaleBigIntByPercent(oldOp.maxPriorityFeePerGas, 110n) - if ( - newMaxPriorityFeePerGas < - oldMaxPriorityFeePerGas + incrementMaxPriorityFeePerGas || - newMaxFeePerGas < oldMaxFeePerGas + incrementMaxFeePerGas - ) { + const hasHigherMaxFee = + newOp.maxFeePerGas >= + scaleBigIntByPercent(oldOp.maxFeePerGas, 110n) + + const hasHigherFees = hasHigherPriorityFee || hasHigherMaxFee + + if (!hasHigherFees) { return [false, reason] } - this.store.removeOutstanding(oldUserOp.userOperationHash) + this.store.removeOutstanding(oldUserOpInfo.userOpHash) } // Check if mempool already includes max amount of parallel user operations const parallelUserOperationsCount = this.store .dumpOutstanding() .filter((userOpInfo) => { - const userOp = userOpInfo.userOperation - return userOp.sender === userOperation.sender + const { userOp: mempoolUserOp } = userOpInfo + return mempoolUserOp.sender === userOp.sender }).length if (parallelUserOperationsCount > this.config.mempoolMaxParallelOps) { @@ -388,15 +372,15 @@ export class MemoryMempool { } // Check if mempool already includes max amount of queued user operations - const [nonceKey] = getNonceKeyAndValue(userOperation.nonce) + const [nonceKey] = getNonceKeyAndSequence(userOp.nonce) const queuedUserOperationsCount = this.store .dumpOutstanding() .filter((userOpInfo) => { - const userOp = userOpInfo.userOperation - const [opNonceKey] = getNonceKeyAndValue(userOp.nonce) + const { userOp: mempoolUserOp } = userOpInfo + const [opNonceKey] = getNonceKeyAndSequence(mempoolUserOp.nonce) return ( - userOp.sender === userOperation.sender && + mempoolUserOp.sender === userOp.sender && opNonceKey === nonceKey ) }).length @@ -409,25 +393,24 @@ export class MemoryMempool { } this.store.addOutstanding({ - userOperation, + userOp, entryPoint, - userOperationHash: opHash, - firstSubmitted: oldUserOp ? oldUserOp.firstSubmitted : Date.now(), - lastReplaced: Date.now(), - referencedContracts + userOpHash: userOpHash, + referencedContracts, + addedToMempool: Date.now() }) - this.monitor.setUserOperationStatus(opHash, { + this.monitor.setUserOperationStatus(userOpHash, { status: "not_submitted", transactionHash: null }) - this.eventManager.emitAddedToMempool(opHash) + this.eventManager.emitAddedToMempool(userOpHash) return [true, ""] } // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: async shouldSkip( - opInfo: UserOperationInfo, + userOpInfo: UserOpInfo, paymasterDeposit: { [paymaster: string]: bigint }, stakedEntityCount: { [addr: string]: number }, knownEntities: { @@ -449,7 +432,6 @@ export class MemoryMempool { senders: Set storageMap: StorageMap }> { - const op = opInfo.userOperation if (!this.config.safeMode) { return { skip: false, @@ -461,20 +443,23 @@ export class MemoryMempool { } } - const isUserOpV06 = isVersion06(op) + const { userOp, entryPoint, userOpHash, referencedContracts } = + userOpInfo + + const isUserOpV06 = isVersion06(userOp) const paymaster = isUserOpV06 - ? getAddressFromInitCodeOrPaymasterAndData(op.paymasterAndData) - : op.paymaster + ? getAddressFromInitCodeOrPaymasterAndData(userOp.paymasterAndData) + : userOp.paymaster const factory = isUserOpV06 - ? getAddressFromInitCodeOrPaymasterAndData(op.initCode) - : op.factory + ? getAddressFromInitCodeOrPaymasterAndData(userOp.initCode) + : userOp.factory const paymasterStatus = this.reputationManager.getStatus( - opInfo.entryPoint, + entryPoint, paymaster ) const factoryStatus = this.reputationManager.getStatus( - opInfo.entryPoint, + entryPoint, factory ) @@ -482,7 +467,7 @@ export class MemoryMempool { paymasterStatus === ReputationStatuses.banned || factoryStatus === ReputationStatuses.banned ) { - this.store.removeOutstanding(opInfo.userOperationHash) + this.store.removeOutstanding(userOpHash) return { skip: true, paymasterDeposit, @@ -501,7 +486,7 @@ export class MemoryMempool { this.logger.trace( { paymaster, - opHash: opInfo.userOperationHash + userOpHash }, "Throttled paymaster skipped" ) @@ -523,7 +508,7 @@ export class MemoryMempool { this.logger.trace( { factory, - opHash: opInfo.userOperationHash + userOpHash }, "Throttled factory skipped" ) @@ -538,13 +523,13 @@ export class MemoryMempool { } if ( - senders.has(op.sender) && + senders.has(userOp.sender) && this.config.enforceUniqueSendersPerBundle ) { this.logger.trace( { - sender: op.sender, - opHash: opInfo.userOperationHash + sender: userOp.sender, + userOpHash }, "Sender skipped because already included in bundle" ) @@ -565,27 +550,27 @@ export class MemoryMempool { if (!isUserOpV06) { queuedUserOperations = await this.getQueuedUserOperations( - op, - opInfo.entryPoint + userOp, + entryPoint ) } validationResult = await this.validator.validateUserOperation({ shouldCheckPrefund: false, - userOperation: op, + userOperation: userOp, queuedUserOperations, - entryPoint: opInfo.entryPoint, - referencedContracts: opInfo.referencedContracts + entryPoint, + referencedContracts }) } catch (e) { this.logger.error( { - opHash: opInfo.userOperationHash, + userOpHash, error: JSON.stringify(e) }, "2nd Validation error" ) - this.store.removeOutstanding(opInfo.userOperationHash) + this.store.removeOutstanding(userOpHash) return { skip: true, paymasterDeposit, @@ -599,11 +584,14 @@ export class MemoryMempool { for (const storageAddress of Object.keys(validationResult.storageMap)) { const address = getAddress(storageAddress) - if (address !== op.sender && knownEntities.sender.has(address)) { + if ( + address !== userOp.sender && + knownEntities.sender.has(address) + ) { this.logger.trace( { storageAddress, - opHash: opInfo.userOperationHash + userOpHash }, "Storage address skipped" ) @@ -622,7 +610,7 @@ export class MemoryMempool { if (paymasterDeposit[paymaster] === undefined) { const entryPointContract = getContract({ abi: isUserOpV06 ? EntryPointV06Abi : EntryPointV07Abi, - address: opInfo.entryPoint, + address: entryPoint, client: { public: this.config.publicClient } @@ -637,7 +625,7 @@ export class MemoryMempool { this.logger.trace( { paymaster, - opHash: opInfo.userOperationHash + userOpHash }, "Paymaster skipped because of insufficient balance left to sponsor all user ops in the bundle" ) @@ -659,7 +647,7 @@ export class MemoryMempool { stakedEntityCount[factory] = (stakedEntityCount[factory] ?? 0) + 1 } - senders.add(op.sender) + senders.add(userOp.sender) return { skip: false, @@ -671,130 +659,180 @@ export class MemoryMempool { } } - async process( - maxGasLimit: bigint, - minOps?: number - ): Promise { - const outstandingUserOperations = this.store.dumpOutstanding().slice() - - // Sort userops before the execution - // Decide the order of the userops based on the sender and nonce - // If sender is the same, sort by nonce key - outstandingUserOperations.sort((a, b) => { - const aUserOp = a.userOperation - const bUserOp = b.userOperation - - if (aUserOp.sender === bUserOp.sender) { - const [aNonceKey, aNonceValue] = getNonceKeyAndValue( - aUserOp.nonce - ) - const [bNonceKey, bNonceValue] = getNonceKeyAndValue( - bUserOp.nonce - ) + 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 + }) + } + ) - if (aNonceKey === bNonceKey) { - return Number(aNonceValue - bNonceValue) - } + const bundlesNested = await Promise.all(bundlePromises) + const bundles = bundlesNested.flat() - return Number(aNonceKey - bNonceKey) - } + return bundles + } - return 0 - }) + // Returns a bundle of userOperations in array format. + async process({ + maxGasLimit, + entryPoint, + minOpsPerBundle, + maxBundleCount + }: { + maxGasLimit: bigint + entryPoint: Address + minOpsPerBundle: number + maxBundleCount?: number + }): Promise { + let outstandingUserOps = this.store + .dumpOutstanding() + .filter((op) => op.entryPoint === entryPoint) + .sort((aUserOpInfo, bUserOpInfo) => { + // Sort userops before the execution + // Decide the order of the userops based on the sender and nonce + // If sender is the same, sort by nonce key + const aUserOp = aUserOpInfo.userOp + const bUserOp = bUserOpInfo.userOp + + if (aUserOp.sender === bUserOp.sender) { + const [aNonceKey, aNonceValue] = getNonceKeyAndSequence( + aUserOp.nonce + ) + const [bNonceKey, bNonceValue] = getNonceKeyAndSequence( + bUserOp.nonce + ) - let opsTaken = 0 - let gasUsed = 0n - const result: UserOperationInfo[] = [] + if (aNonceKey === bNonceKey) { + return Number(aNonceValue - bNonceValue) + } - // paymaster deposit should be enough for all UserOps in the bundle. - let paymasterDeposit: { [paymaster: string]: bigint } = {} - // throttled paymasters and factories are allowed only small UserOps per bundle. - let stakedEntityCount: { [addr: string]: number } = {} - // each sender is allowed only once per bundle - let senders = new Set() - let knownEntities = this.getKnownEntities() + return Number(aNonceKey - bNonceKey) + } - let storageMap: StorageMap = {} + return 0 + }) + .slice() - for (const opInfo of outstandingUserOperations) { - const op = opInfo.userOperation - gasUsed += op.callGasLimit + op.verificationGasLimit + if (outstandingUserOps.length === 0) return [] - if (isVersion07(op)) { - gasUsed += - (op.paymasterPostOpGasLimit ?? 0n) + - (op.paymasterVerificationGasLimit ?? 0n) - } + // Get EntryPoint version. (Ideally version should be derived from CLI flags) + const isV6 = isVersion06(outstandingUserOps[0].userOp) + const allSameVersion = outstandingUserOps.every((userOpInfo) => { + const { userOp } = userOpInfo + return isVersion06(userOp) === isV6 + }) + if (!allSameVersion) { + throw new Error( + "All user operations from same EntryPoint must be of the same version" + ) + } + + const bundles: UserOperationBundle[] = [] - if (gasUsed > maxGasLimit && opsTaken >= (minOps || 0)) { + // Process all outstanding ops. + while (outstandingUserOps.length > 0) { + // If maxBundles is set and we reached the limit, break. + if (maxBundleCount && bundles.length >= maxBundleCount) { break } - const skipResult = await this.shouldSkip( - opInfo, - paymasterDeposit, - stakedEntityCount, - knownEntities, - senders, - storageMap - ) - paymasterDeposit = skipResult.paymasterDeposit - stakedEntityCount = skipResult.stakedEntityCount - knownEntities = skipResult.knownEntities - senders = skipResult.senders - storageMap = skipResult.storageMap - - if (skipResult.skip) { - continue + + // Setup for next bundle. + const currentBundle: UserOperationBundle = { + entryPoint, + version: isV6 ? "0.6" : "0.7", + userOps: [] } + let gasUsed = 0n - this.reputationManager.decreaseUserOperationCount(op) - this.store.removeOutstanding(opInfo.userOperationHash) - this.store.addProcessing(opInfo) - result.push(opInfo) - opsTaken++ - } - return result - } + let paymasterDeposit: { [paymaster: string]: bigint } = {} // paymaster deposit should be enough for all UserOps in the bundle. + let stakedEntityCount: { [addr: string]: number } = {} // throttled paymasters and factories are allowed only small UserOps per bundle. + let senders = new Set() // each sender is allowed only once per bundle + let knownEntities = this.getKnownEntities() + let storageMap: StorageMap = {} - get(opHash: HexData32): UserOperation | null { - const outstanding = this.store - .dumpOutstanding() - .find((op) => op.userOperationHash === opHash) - if (outstanding) { - return outstanding.userOperation - } + // Keep adding ops to current bundle. + while (outstandingUserOps.length > 0) { + const userOpInfo = outstandingUserOps.shift() + if (!userOpInfo) break - const submitted = this.store - .dumpSubmitted() - .find((op) => op.userOperation.userOperationHash === opHash) - if (submitted) { - return submitted.userOperation.userOperation + const { userOp, userOpHash } = userOpInfo + + // NOTE: currently if a userOp is skipped due to sender enforceUniqueSendersPerBundle it will be picked up + // again the next time mempool.process is called. + const skipResult = await this.shouldSkip( + userOpInfo, + paymasterDeposit, + stakedEntityCount, + knownEntities, + senders, + storageMap + ) + if (skipResult.skip) continue + + gasUsed += + userOp.callGasLimit + + userOp.verificationGasLimit + + (isVersion07(userOp) + ? (userOp.paymasterPostOpGasLimit || 0n) + + (userOp.paymasterVerificationGasLimit || 0n) + : 0n) + + // Only break on gas limit if we've hit minOpsPerBundle. + if ( + gasUsed > maxGasLimit && + currentBundle.userOps.length >= minOpsPerBundle + ) { + outstandingUserOps.unshift(userOpInfo) // re-add op to front of queue + break + } + + // Update state based on skip result + paymasterDeposit = skipResult.paymasterDeposit + stakedEntityCount = skipResult.stakedEntityCount + knownEntities = skipResult.knownEntities + senders = skipResult.senders + storageMap = skipResult.storageMap + + this.reputationManager.decreaseUserOperationCount(userOp) + this.store.removeOutstanding(userOpHash) + this.store.addProcessing(userOpInfo) + + // Add op to current bundle + currentBundle.userOps.push(userOpInfo) + } + + if (currentBundle.userOps.length > 0) { + bundles.push(currentBundle) + } } - return null + return bundles } // For a specfic user operation, get all the queued user operations // They should be executed first, ordered by nonce value // If cuurentNonceValue is not provided, it will be fetched from the chain async getQueuedUserOperations( - userOperation: UserOperation, + userOp: UserOperation, entryPoint: Address, _currentNonceValue?: bigint ): Promise { const entryPointContract = getContract({ address: entryPoint, - abi: isVersion06(userOperation) - ? EntryPointV06Abi - : EntryPointV07Abi, + abi: isVersion06(userOp) ? EntryPointV06Abi : EntryPointV07Abi, client: { public: this.config.publicClient } }) - const [nonceKey, userOperationNonceValue] = getNonceKeyAndValue( - userOperation.nonce - ) + const [nonceKey, nonceSequence] = getNonceKeyAndSequence(userOp.nonce) let currentNonceValue: bigint = BigInt(0) @@ -802,39 +840,42 @@ export class MemoryMempool { currentNonceValue = _currentNonceValue } else { const getNonceResult = await entryPointContract.read.getNonce( - [userOperation.sender, nonceKey], + [userOp.sender, nonceKey], { blockTag: "latest" } ) - currentNonceValue = getNonceKeyAndValue(getNonceResult)[1] + currentNonceValue = getNonceKeyAndSequence(getNonceResult)[1] } const outstanding = this.store .dumpOutstanding() - .map(({ userOperation }) => userOperation) - .filter((mempoolUserOp) => { - const [opNonceKey, opNonceValue] = getNonceKeyAndValue( - mempoolUserOp.nonce - ) + .filter((userOpInfo) => { + const { userOp: mempoolUserOp } = userOpInfo + + const [mempoolNonceKey, mempoolNonceSequence] = + getNonceKeyAndSequence(mempoolUserOp.nonce) return ( - mempoolUserOp.sender === userOperation.sender && - opNonceKey === nonceKey && - opNonceValue >= currentNonceValue && - opNonceValue < userOperationNonceValue + mempoolUserOp.sender === userOp.sender && + mempoolNonceKey === nonceKey && + mempoolNonceSequence >= currentNonceValue && + mempoolNonceSequence < nonceSequence ) }) outstanding.sort((a, b) => { - const [, aNonceValue] = getNonceKeyAndValue(a.nonce) - const [, bNonceValue] = getNonceKeyAndValue(b.nonce) + const aUserOp = a.userOp + const bUserOp = b.userOp + + const [, aNonceValue] = getNonceKeyAndSequence(aUserOp.nonce) + const [, bNonceValue] = getNonceKeyAndSequence(bUserOp.nonce) return Number(aNonceValue - bNonceValue) }) - return outstanding + return outstanding.map((userOpInfo) => userOpInfo.userOp) } clear(): void { diff --git a/src/mempool/store.ts b/src/mempool/store.ts index 7d8d23dd..153bf4fd 100644 --- a/src/mempool/store.ts +++ b/src/mempool/store.ts @@ -1,16 +1,12 @@ -import type { - HexData32, - SubmittedUserOperation, - UserOperationInfo -} from "@alto/types" +import type { HexData32, SubmittedUserOp, UserOpInfo } from "@alto/types" import type { Metrics } from "@alto/utils" import type { Logger } from "@alto/utils" export class MemoryStore { // private monitoredTransactions: Map = new Map() // tx hash to info - private outstandingUserOperations: UserOperationInfo[] = [] - private processingUserOperations: UserOperationInfo[] = [] - private submittedUserOperations: SubmittedUserOperation[] = [] + private outstandingUserOperations: UserOpInfo[] = [] + private processingUserOperations: UserOpInfo[] = [] + private submittedUserOperations: SubmittedUserOp[] = [] private logger: Logger private metrics: Metrics @@ -20,12 +16,12 @@ export class MemoryStore { this.metrics = metrics } - addOutstanding(op: UserOperationInfo) { + addOutstanding(userOpInfo: UserOpInfo) { const store = this.outstandingUserOperations - store.push(op) + store.push(userOpInfo) this.logger.debug( - { userOpHash: op.userOperationHash, store: "outstanding" }, + { userOpHash: userOpInfo.userOpHash, store: "outstanding" }, "added user op to mempool" ) this.metrics.userOperationsInMempool @@ -35,12 +31,12 @@ export class MemoryStore { .inc() } - addProcessing(op: UserOperationInfo) { + addProcessing(userOpInfo: UserOpInfo) { const store = this.processingUserOperations - store.push(op) + store.push(userOpInfo) this.logger.debug( - { userOpHash: op.userOperationHash, store: "processing" }, + { userOpHash: userOpInfo.userOpHash, store: "processing" }, "added user op to mempool" ) this.metrics.userOperationsInMempool @@ -50,13 +46,14 @@ export class MemoryStore { .inc() } - addSubmitted(op: SubmittedUserOperation) { + addSubmitted(submittedInfo: SubmittedUserOp) { + const { userOpHash } = submittedInfo const store = this.submittedUserOperations - store.push(op) + store.push(submittedInfo) this.logger.debug( { - userOpHash: op.userOperation.userOperationHash, + userOpHash, store: "submitted" }, "added user op to submitted mempool" @@ -70,7 +67,7 @@ export class MemoryStore { removeOutstanding(userOpHash: HexData32) { const index = this.outstandingUserOperations.findIndex( - (op) => op.userOperationHash === userOpHash + (userOpInfo) => userOpInfo.userOpHash === userOpHash ) if (index === -1) { this.logger.warn( @@ -94,7 +91,7 @@ export class MemoryStore { removeProcessing(userOpHash: HexData32) { const index = this.processingUserOperations.findIndex( - (op) => op.userOperationHash === userOpHash + (userOpInfo) => userOpInfo.userOpHash === userOpHash ) if (index === -1) { this.logger.warn( @@ -118,7 +115,7 @@ export class MemoryStore { removeSubmitted(userOpHash: HexData32) { const index = this.submittedUserOperations.findIndex( - (op) => op.userOperation.userOperationHash === userOpHash + (userOpInfo) => userOpInfo.userOpHash === userOpHash ) if (index === -1) { this.logger.warn( @@ -140,7 +137,7 @@ export class MemoryStore { .dec() } - dumpOutstanding(): UserOperationInfo[] { + dumpOutstanding(): UserOpInfo[] { this.logger.trace( { store: "outstanding", @@ -151,7 +148,7 @@ export class MemoryStore { return this.outstandingUserOperations } - dumpProcessing(): UserOperationInfo[] { + dumpProcessing(): UserOpInfo[] { this.logger.trace( { store: "processing", @@ -162,7 +159,7 @@ export class MemoryStore { return this.processingUserOperations } - dumpSubmitted(): SubmittedUserOperation[] { + dumpSubmitted(): SubmittedUserOp[] { this.logger.trace( { store: "submitted", length: this.submittedUserOperations.length }, "dumping mempool" diff --git a/src/rpc/nonceQueuer.ts b/src/rpc/nonceQueuer.ts index 3c12b013..edc5ac38 100644 --- a/src/rpc/nonceQueuer.ts +++ b/src/rpc/nonceQueuer.ts @@ -4,7 +4,7 @@ import { EntryPointV06Abi, EntryPointV07Abi, UserOperation } from "@alto/types" import type { Logger } from "@alto/utils" import { encodeNonce, - getNonceKeyAndValue, + getNonceKeyAndSequence, getUserOperationHash, isVersion06 } from "@alto/utils" @@ -93,7 +93,7 @@ export class NonceQueuer { } add(userOperation: UserOperation, entryPoint: Address) { - const [nonceKey, nonceSequence] = getNonceKeyAndValue( + const [nonceKey, nonceSequence] = getNonceKeyAndSequence( userOperation.nonce ) diff --git a/src/rpc/rpcHandler.ts b/src/rpc/rpcHandler.ts index bcdd1a81..4020abe4 100644 --- a/src/rpc/rpcHandler.ts +++ b/src/rpc/rpcHandler.ts @@ -9,8 +9,8 @@ import type { ApiVersion, PackedUserOperation, StateOverrides, - TransactionInfo, - UserOperationInfo, + UserOpInfo, + UserOperationBundle, UserOperationV06, UserOperationV07 } from "@alto/types" @@ -50,7 +50,7 @@ import { calcVerificationGasAndCallGasLimit, deepHexlify, getAAError, - getNonceKeyAndValue, + getNonceKeyAndSequence, getUserOperationHash, isVersion06, isVersion07, @@ -579,16 +579,13 @@ export class RpcHandler implements IRpcEndpoint { this.ensureDebugEndpointsAreEnabled("debug_bundler_dumpMempool") this.ensureEntryPointIsSupported(entryPoint) - return this.mempool - .dumpOutstanding() - .map(({ userOperation }) => userOperation) + return this.mempool.dumpOutstanding() } async debug_bundler_sendBundleNow(): Promise { this.ensureDebugEndpointsAreEnabled("debug_bundler_sendBundleNow") - - const transactions = await this.executorManager.bundleNow() - return transactions[0] + const transaction = await this.executorManager.sendBundleNow() + return transaction } async debug_bundler_setBundlingMode( @@ -692,7 +689,7 @@ export class RpcHandler implements IRpcEndpoint { userOperation, entryPoint ) - const [, userOperationNonceValue] = getNonceKeyAndValue( + const [, userOperationNonceValue] = getNonceKeyAndSequence( userOperation.nonce ) @@ -854,7 +851,6 @@ export class RpcHandler implements IRpcEndpoint { } this.ensureEntryPointIsSupported(entryPoint) - const opHash = getUserOperationHash( userOperation, entryPoint, @@ -868,56 +864,41 @@ export class RpcHandler implements IRpcEndpoint { entryPoint ) - const result = ( - await this.executor.bundle(entryPoint, [userOperation]) - )[0] - - if (result.status === "failure") { - const { userOpHash, reason } = result.error - this.monitor.setUserOperationStatus(userOpHash, { - status: "rejected", - transactionHash: null - }) - this.logger.warn( - { - userOperation: JSON.stringify( - result.error.userOperation, - (_k, v) => (typeof v === "bigint" ? v.toString() : v) - ), - userOpHash, - reason - }, - "user operation rejected" - ) - this.metrics.userOperationsSubmitted - .labels({ status: "failed" }) - .inc() + // Prepare bundle + const userOperationInfo: UserOpInfo = { + userOp: userOperation, + entryPoint, + userOpHash: getUserOperationHash( + userOperation, + entryPoint, + this.config.publicClient.chain.id + ), + addedToMempool: Date.now() + } + const bundle: UserOperationBundle = { + entryPoint, + userOps: [userOperationInfo], + version: isVersion06(userOperation) + ? ("0.6" as const) + : ("0.7" as const) + } + const result = await this.executorManager.sendBundleToExecutor(bundle) - const { error } = result + if (!result) { throw new RpcError( - `userOperation reverted during simulation with reason: ${error.reason}` + "unhandled error during bundle submission", + ValidationErrors.InvalidFields ) } - const res = result as unknown as { - status: "success" - value: { - userOperation: UserOperationInfo - transactionInfo: TransactionInfo - } - } - - this.executor.markWalletProcessed(res.value.transactionInfo.executor) - - // wait for receipt + // Wait for receipt. const receipt = await this.config.publicClient.waitForTransactionReceipt({ - hash: res.value.transactionInfo.transactionHash, + hash: result, pollingInterval: 100 }) const userOperationReceipt = parseUserOperationReceipt(opHash, receipt) - return userOperationReceipt } @@ -969,7 +950,7 @@ export class RpcHandler implements IRpcEndpoint { } }) - const [nonceKey] = getNonceKeyAndValue(userOperation.nonce) + const [nonceKey] = getNonceKeyAndSequence(userOperation.nonce) const getNonceResult = await entryPointContract.read.getNonce( [userOperation.sender, nonceKey], @@ -978,7 +959,7 @@ export class RpcHandler implements IRpcEndpoint { } ) - const [_, currentNonceValue] = getNonceKeyAndValue(getNonceResult) + const [_, currentNonceValue] = getNonceKeyAndSequence(getNonceResult) return currentNonceValue } @@ -1009,7 +990,7 @@ export class RpcHandler implements IRpcEndpoint { userOperation, entryPoint ) - const [, userOperationNonceValue] = getNonceKeyAndValue( + const [, userOperationNonceValue] = getNonceKeyAndSequence( userOperation.nonce ) diff --git a/src/types/mempool.ts b/src/types/mempool.ts index 3abd06cf..57d2a885 100644 --- a/src/types/mempool.ts +++ b/src/types/mempool.ts @@ -1,4 +1,4 @@ -import type { Address, Chain } from "viem" +import type { Address, BaseError, Hex } from "viem" import type { Account } from "viem/accounts" import type { HexData32, UserOperation } from "." @@ -13,31 +13,23 @@ export interface ReferencedCodeHashes { export type TransactionInfo = { transactionHash: HexData32 previousTransactionHashes: HexData32[] - entryPoint: Address - isVersion06: boolean transactionRequest: { - account: Account - to: Address gas: bigint - chain: Chain maxFeePerGas: bigint maxPriorityFeePerGas: bigint nonce: number } + bundle: UserOperationBundle executor: Account - userOperationInfos: UserOperationInfo[] lastReplaced: number firstSubmitted: number timesPotentiallyIncluded: number } -export type UserOperationInfo = { - userOperation: UserOperation +export type UserOperationBundle = { entryPoint: Address - userOperationHash: HexData32 - lastReplaced: number - firstSubmitted: number - referencedContracts?: ReferencedCodeHashes + version: "0.6" | "0.7" + userOps: UserOpInfo[] } export enum SubmissionStatus { @@ -47,43 +39,55 @@ export enum SubmissionStatus { Included = "included" } -export type SubmittedUserOperation = { - userOperation: UserOperationInfo - transactionInfo: TransactionInfo +export type UserOpDetails = { + userOpHash: Hex + entryPoint: Address + // timestamp when the bundling process begins (when it leaves outstanding mempool) + addedToMempool: number + referencedContracts?: ReferencedCodeHashes } -type Result = Success | Failure | Resubmit - -interface Success { - status: "success" - value: T -} +export type UserOpInfo = { + userOp: UserOperation +} & UserOpDetails -interface Failure { - status: "failure" - error: E +export type SubmittedUserOp = UserOpInfo & { + transactionInfo: TransactionInfo } -interface Resubmit { - status: "resubmit" - info: R +export type RejectedUserOp = UserOpInfo & { + reason: string } -export type BundleResult = Result< - { - userOperation: UserOperationInfo - transactionInfo: TransactionInfo - }, - { - reason: string - userOpHash: HexData32 - entryPoint: Address - userOperation: UserOperation - }, - { - reason: string - userOpHash: HexData32 - entryPoint: Address - userOperation: UserOperation - } -> +export type BundleResult = + | { + // Successfully sent bundle. + status: "bundle_success" + userOpsBundled: UserOpInfo[] + rejectedUserOps: RejectedUserOp[] + transactionHash: HexData32 + transactionRequest: { + gas: bigint + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + nonce: number + } + } + | { + // Encountered unhandled error during bundle simulation. + status: "unhandled_simulation_failure" + rejectedUserOps: RejectedUserOp[] + reason: string + } + | { + // All user operations failed during simulation. + status: "all_ops_failed_simulation" + rejectedUserOps: RejectedUserOp[] + } + | { + // Encountered error whilst trying to send bundle. + status: "bundle_submission_failure" + reason: BaseError | "INTERNAL FAILURE" + userOpsToBundle: UserOpInfo[] + rejectedUserOps: RejectedUserOp[] + } diff --git a/src/types/schemas.ts b/src/types/schemas.ts index 65e94f83..75f88b4a 100644 --- a/src/types/schemas.ts +++ b/src/types/schemas.ts @@ -200,11 +200,6 @@ export type UserOperationRequest = { entryPoint: Address } -export type UserOperationWithHash = { - userOperation: UserOperation - userOperationHash: HexData32 -} - const jsonRpcSchema = z .object({ jsonrpc: z.literal("2.0"), diff --git a/src/utils/userop.ts b/src/utils/userop.ts index f32f1b15..65e2786f 100644 --- a/src/utils/userop.ts +++ b/src/utils/userop.ts @@ -2,13 +2,13 @@ import { EntryPointV06Abi, EntryPointV07Abi, type GetUserOperationReceiptResponseResult, - type HexData32, type PackedUserOperation, type UserOperation, type UserOperationV06, type UserOperationV07, logSchema, - receiptSchema + receiptSchema, + UserOperationBundle } from "@alto/types" import * as sentry from "@sentry/node" import type { Logger } from "pino" @@ -227,19 +227,26 @@ export type BundlingStatus = } // Return the status of the bundling transaction. -export const getBundleStatus = async ( - isVersion06: boolean, - txHash: HexData32, - publicClient: PublicClient, - logger: Logger, - entryPoint: Address -): Promise<{ +export const getBundleStatus = async ({ + transactionHash, + publicClient, + bundle, + logger +}: { + transactionHash: Hex + bundle: UserOperationBundle + publicClient: PublicClient + logger: Logger +}): Promise<{ bundlingStatus: BundlingStatus blockNumber: bigint | undefined }> => { try { + const { entryPoint, version } = bundle + const isVersion06 = version === "0.6" + const receipt = await publicClient.getTransactionReceipt({ - hash: txHash + hash: transactionHash }) const blockNumber = receipt.blockNumber @@ -528,7 +535,7 @@ export const getUserOperationHash = ( ) } -export const getNonceKeyAndValue = (nonce: bigint) => { +export const getNonceKeyAndSequence = (nonce: bigint) => { const nonceKey = nonce >> 64n // first 192 bits of nonce const nonceSequence = nonce & 0xffffffffffffffffn // last 64 bits of nonce diff --git a/test/e2e/deploy-contracts/index.ts b/test/e2e/deploy-contracts/index.ts index 9b637053..6bd8b7e9 100644 --- a/test/e2e/deploy-contracts/index.ts +++ b/test/e2e/deploy-contracts/index.ts @@ -22,7 +22,7 @@ const verifyDeployed = async ({ client }: { addresses: Address[]; client: PublicClient }) => { for (const address of addresses) { - const bytecode = await client.getCode({ + const bytecode = await client.getBytecode({ address }) diff --git a/test/e2e/setup.ts b/test/e2e/setup.ts index 5c1c9146..f1fe2ffc 100644 --- a/test/e2e/setup.ts +++ b/test/e2e/setup.ts @@ -116,7 +116,7 @@ export default async function setup({ provide }) { const anvilInstance = anvil({ chainId: foundry.id, - port: 8485, + port: 8545, codeSizeLimit: 1000_000 }) await anvilInstance.start()