From dc77bad30f1b17b9d27d873bb91070a7576a8645 Mon Sep 17 00:00:00 2001 From: Dennis Won Date: Thu, 23 Nov 2023 16:35:53 -0800 Subject: [PATCH] feat: aa-core smart account provider fee options to handle more systematic fee options for userops --- packages/alchemy/src/middleware/gas-fees.ts | 2 +- .../alchemy/src/middleware/gas-manager.ts | 56 +++- .../core/e2e-tests/simple-account.test.ts | 76 +++++- packages/core/src/account/base.ts | 8 +- packages/core/src/provider/base.ts | 247 +++++++++++------- packages/core/src/provider/schema.ts | 45 ++-- packages/core/src/provider/types.ts | 59 ++++- packages/core/src/schema.ts | 16 ++ packages/core/src/types.ts | 26 +- packages/core/src/utils/bigint.ts | 48 +++- packages/core/src/utils/defaults.ts | 18 ++ packages/core/src/utils/index.ts | 59 ++++- packages/core/src/utils/schema.ts | 24 +- .../middleware/withAlchemyGasManager.md | 2 +- 14 files changed, 541 insertions(+), 145 deletions(-) create mode 100644 packages/core/src/schema.ts diff --git a/packages/alchemy/src/middleware/gas-fees.ts b/packages/alchemy/src/middleware/gas-fees.ts index 9047d832cc..f8d8d921d8 100644 --- a/packages/alchemy/src/middleware/gas-fees.ts +++ b/packages/alchemy/src/middleware/gas-fees.ts @@ -29,6 +29,6 @@ export const withAlchemyGasFeeEstimator =

( maxFeePerGas: baseFeeIncrease + prioFeeIncrease, maxPriorityFeePerGas: prioFeeIncrease, }; - }); + }, provider.feeOptions); return provider; }; diff --git a/packages/alchemy/src/middleware/gas-manager.ts b/packages/alchemy/src/middleware/gas-manager.ts index 413df2f5a5..c73d90fb15 100644 --- a/packages/alchemy/src/middleware/gas-manager.ts +++ b/packages/alchemy/src/middleware/gas-manager.ts @@ -13,7 +13,7 @@ export interface AlchemyGasManagerConfig { /** * This middleware wraps the Alchemy Gas Manager APIs to provide more flexible UserOperation gas sponsorship. * - * If `estimateGas` is true, it will use `alchemy_requestGasAndPaymasterAndData` to get all of the gas estimates + paymaster data + * If `delegateGasEstimation` is true, it will use `alchemy_requestGasAndPaymasterAndData` to get all of the gas estimates + paymaster data * in one RPC call. * * Otherwise, it will use `alchemy_requestPaymasterAndData` to get only paymaster data, allowing you @@ -21,27 +21,57 @@ export interface AlchemyGasManagerConfig { * * @param provider - the smart account provider to override to use the alchemy gas manager * @param config - the alchemy gas manager configuration - * @param estimateGas - if true, this will use `alchemy_requestGasAndPaymasterAndData` else will use `alchemy_requestPaymasterAndData` + * @param delegateGasEstimation - if true, this will use `alchemy_requestGasAndPaymasterAndData` else will use `alchemy_requestPaymasterAndData` * @returns the provider augmented to use the alchemy gas manager */ export const withAlchemyGasManager =

( provider: P, config: AlchemyGasManagerConfig, - estimateGas: boolean = true + delegateGasEstimation: boolean = true ): P => { - return estimateGas + const fallbackGasEstimator = provider.gasEstimator; + const fallbackFeeDataGetter = provider.feeDataGetter; + + return delegateGasEstimation ? provider // no-op gas estimator - .withGasEstimator(async () => ({ - callGasLimit: 0n, - preVerificationGas: 0n, - verificationGasLimit: 0n, - })) + .withGasEstimator(async (struct, overrides) => { + // but if user is bypassing paymaster to fallback to having the account to pay the gas (one-off override), + // we cannot delegate gas estimation to the bundler because paymaster middleware will not be called + if (overrides?.paymasterAndData !== undefined) { + const result = await fallbackGasEstimator(struct, overrides); + return { + callGasLimit: (await result.callGasLimit) ?? 0n, + preVerificationGas: (await result.preVerificationGas) ?? 0n, + verificationGasLimit: (await result.verificationGasLimit) ?? 0n, + }; + } else { + return { + callGasLimit: 0n, + preVerificationGas: 0n, + verificationGasLimit: 0n, + }; + } + }) // no-op fee because the alchemy api will do it - .withFeeDataGetter(async (struct) => ({ - maxFeePerGas: (await struct.maxFeePerGas) ?? 0n, - maxPriorityFeePerGas: (await struct.maxPriorityFeePerGas) ?? 0n, - })) + .withFeeDataGetter(async (struct, overrides) => { + let maxFeePerGas = (await struct.maxFeePerGas) ?? 0n; + let maxPriorityFeePerGas = (await struct.maxPriorityFeePerGas) ?? 0n; + + // but if user is bypassing paymaster to fallback to having the account to pay the gas (one-off override), + // we cannot delegate gas estimation to the bundler because paymaster middleware will not be called + if (overrides?.paymasterAndData !== undefined) { + const result = await fallbackFeeDataGetter(struct, overrides); + maxFeePerGas = (await result.maxFeePerGas) ?? maxFeePerGas; + maxPriorityFeePerGas = + (await result.maxPriorityFeePerGas) ?? maxPriorityFeePerGas; + } + + return { + maxFeePerGas, + maxPriorityFeePerGas, + }; + }) .withPaymasterMiddleware( withAlchemyGasAndPaymasterAndDataMiddleware(provider, config) ) diff --git a/packages/core/e2e-tests/simple-account.test.ts b/packages/core/e2e-tests/simple-account.test.ts index 302d521599..2e38894846 100644 --- a/packages/core/e2e-tests/simple-account.test.ts +++ b/packages/core/e2e-tests/simple-account.test.ts @@ -1,10 +1,18 @@ -import { isAddress, type Address, type Chain, type Hash } from "viem"; +import { + fromHex, + isAddress, + type Address, + type Chain, + type Hash, + type Hex, +} from "viem"; import { generatePrivateKey } from "viem/accounts"; import { polygonMumbai } from "viem/chains"; import { SimpleSmartContractAccount } from "../src/account/simple.js"; import { getDefaultSimpleAccountFactoryAddress, type SmartAccountSigner, + type UserOperationFeeOptions, } from "../src/index.js"; import { SmartAccountProvider } from "../src/provider/base.js"; import { LocalAccountSigner } from "../src/signer/local-account.js"; @@ -58,20 +66,86 @@ describe("Simple Account Tests", () => { await expect(address).resolves.not.toThrowError(); expect(isAddress(await address)).toBe(true); }); + + it("should correctly handle provider feeOptions set during init", async () => { + const signer = givenConnectedProvider({ + owner, + chain, + }); + + const structPromise = signer.buildUserOperation({ + target: await signer.getAddress(), + data: "0x", + }); + await expect(structPromise).resolves.not.toThrowError(); + + const signerWithFeeOptions = givenConnectedProvider({ + owner, + chain, + feeOptions: { + preVerificationGas: { percentage: 100 }, + }, + }); + + const structWithFeeOptionsPromise = signerWithFeeOptions.buildUserOperation( + { + target: await signer.getAddress(), + data: "0x", + } + ); + await expect(structWithFeeOptionsPromise).resolves.not.toThrowError(); + + const [struct, structWithFeeOptions] = await Promise.all([ + structPromise, + structWithFeeOptionsPromise, + ]); + + const preVerificationGas = + typeof struct.preVerificationGas === "string" + ? fromHex(struct.preVerificationGas as Hex, "bigint") + : struct.preVerificationGas; + const preVerificationGasWithFeeOptions = + typeof structWithFeeOptions.preVerificationGas === "string" + ? fromHex(structWithFeeOptions.preVerificationGas as Hex, "bigint") + : structWithFeeOptions.preVerificationGas; + + expect(preVerificationGasWithFeeOptions).toBeGreaterThan( + preVerificationGas! + ); + }, 60000); + + it("should correctly handle percentage overrides for sendUserOperation", async () => { + const signer = givenConnectedProvider({ + owner, + chain, + feeOptions: { + preVerificationGas: { percentage: 100 }, + }, + }); + + const struct = signer.sendUserOperation({ + target: await signer.getAddress(), + data: "0x", + }); + await expect(struct).resolves.not.toThrowError(); + }, 60000); }); const givenConnectedProvider = ({ owner, chain, accountAddress, + feeOptions, }: { owner: SmartAccountSigner; chain: Chain; accountAddress?: Address; + feeOptions?: UserOperationFeeOptions; }) => { const provider = new SmartAccountProvider({ rpcProvider: `${chain.rpcUrls.alchemy.http[0]}/${API_KEY}`, chain, + opts: { feeOptions }, }); const feeDataGetter = async () => ({ maxFeePerGas: 100_000_000_000n, diff --git a/packages/core/src/account/base.ts b/packages/core/src/account/base.ts index b95cedc38a..aacc58d471 100644 --- a/packages/core/src/account/base.ts +++ b/packages/core/src/account/base.ts @@ -256,12 +256,12 @@ export abstract class BaseSmartContractAccount< try { await this.entryPoint.simulate.getSenderAddress([initCode]); } catch (err: any) { - Logger.debug( - "[BaseSmartContractAccount](getAddress) entrypoint.getSenderAddress result: ", - err - ); if (err.cause?.data?.errorName === "SenderAddressResult") { this.accountAddress = err.cause.data.args[0] as Address; + Logger.debug( + "[BaseSmartContractAccount](getAddress) entrypoint.getSenderAddress result:", + this.accountAddress + ); return this.accountAddress; } } diff --git a/packages/core/src/provider/base.ts b/packages/core/src/provider/base.ts index aa8b11abf5..2e7939224d 100644 --- a/packages/core/src/provider/base.ts +++ b/packages/core/src/provider/base.ts @@ -10,7 +10,6 @@ import { type Transaction, type Transport, } from "viem"; -import { arbitrum, arbitrumGoerli, arbitrumSepolia } from "viem/chains"; import type { ISmartContractAccount, SignTypedDataParams, @@ -20,9 +19,12 @@ import type { PublicErc4337Client, SupportedTransports, } from "../client/types.js"; +import { Logger } from "../logger.js"; import { type BatchUserOperationCallData, + type BigNumberish, type UserOperationCallData, + type UserOperationFeeOptions, type UserOperationOverrides, type UserOperationReceipt, type UserOperationRequest, @@ -30,12 +32,15 @@ import { type UserOperationStruct, } from "../types.js"; import { + applyFeeOption, asyncPipe, bigIntMax, bigIntPercent, deepHexlify, defineReadOnly, + filterUndefined, getDefaultEntryPointAddress, + getDefaultUserOperationFeeOptions, getUserOperationHash, isValidRequest, resolveProperties, @@ -45,7 +50,10 @@ import { createSmartAccountProviderConfigSchema } from "./schema.js"; import type { AccountMiddlewareFn, AccountMiddlewareOverrideFn, + FeeDataFeeOptions, FeeDataMiddleware, + FeeOptionsMiddleware, + GasEstimatorFeeOptions, GasEstimatorMiddleware, ISmartAccountProvider, PaymasterAndDataMiddleware, @@ -55,15 +63,10 @@ import type { } from "./types.js"; export const noOpMiddleware: AccountMiddlewareFn = async ( - struct: Deferrable + struct: Deferrable, + _overrides?: UserOperationOverrides ) => struct; -const minPriorityFeePerBidDefaults = new Map([ - [arbitrum.id, 10_000_000n], - [arbitrumGoerli.id, 10_000_000n], - [arbitrumSepolia.id, 10_000_000n], -]); - export class SmartAccountProvider< TTransport extends SupportedTransports = Transport > @@ -74,10 +77,8 @@ export class SmartAccountProvider< private txRetryIntervalMs: number; private txRetryMulitplier: number; - private minPriorityFeePerBid: bigint; - private maxPriorityFeePerGasEstimateBuffer: number; - readonly account?: ISmartContractAccount; + readonly feeOptions: UserOperationFeeOptions; protected entryPointAddress?: Address; protected chain: Chain; @@ -100,13 +101,8 @@ export class SmartAccountProvider< this.txRetryMulitplier = opts?.txRetryMulitplier ?? 1.5; this.entryPointAddress = entryPointAddress; - this.minPriorityFeePerBid = - opts?.minPriorityFeePerBid ?? - minPriorityFeePerBidDefaults.get(chain.id) ?? - 100_000_000n; - - this.maxPriorityFeePerGasEstimateBuffer = - opts?.maxPriorityFeePerGasEstimateBuffer ?? 33; + this.feeOptions = + opts?.feeOptions ?? getDefaultUserOperationFeeOptions(chain); this.rpcClient = typeof rpcProvider === "string" @@ -221,15 +217,19 @@ export class SmartAccountProvider< throw new Error("transaction is missing to address"); } - const _overrides: UserOperationOverrides = {}; - if (overrides?.maxFeePerGas || request.maxFeePerGas) { - _overrides.maxFeePerGas = overrides?.maxFeePerGas ?? request.maxFeePerGas; - } - if (overrides?.maxPriorityFeePerGas || request.maxPriorityFeePerGas) { - _overrides.maxPriorityFeePerGas = - overrides?.maxPriorityFeePerGas ?? request.maxPriorityFeePerGas; - } - _overrides.paymasterAndData = overrides?.paymasterAndData; + const _overrides: UserOperationOverrides = { + maxFeePerGas: + overrides?.maxFeePerGas ?? + (request.maxFeePerGas + ? fromHex(request.maxFeePerGas, "bigint") + : undefined), + maxPriorityFeePerGas: + overrides?.maxPriorityFeePerGas ?? + (request.maxPriorityFeePerGas + ? fromHex(request.maxPriorityFeePerGas, "bigint") + : undefined), + }; + filterUndefined(_overrides); return this.buildUserOperation( { @@ -259,26 +259,26 @@ export class SmartAccountProvider< }; }); - const maxFeePerGas = bigIntMax( - ...requests - .filter((x) => x.maxFeePerGas != null) - .map((x) => fromHex(x.maxFeePerGas!, "bigint")) - ); - - const maxPriorityFeePerGas = bigIntMax( - ...requests - .filter((x) => x.maxPriorityFeePerGas != null) - .map((x) => fromHex(x.maxPriorityFeePerGas!, "bigint")) - ); - const _overrides: UserOperationOverrides = {}; - if (overrides?.maxFeePerGas || maxFeePerGas != null) { - _overrides.maxFeePerGas = overrides?.maxFeePerGas ?? maxFeePerGas; - } + const maxFeePerGas = + overrides?.maxFeePerGas ?? + bigIntMax( + ...requests + .filter((x) => x.maxFeePerGas != null) + .map((x) => fromHex(x.maxFeePerGas!, "bigint")) + ); + const maxPriorityFeePerGas = + overrides?.maxPriorityFeePerGas ?? + bigIntMax( + ...requests + .filter((x) => x.maxPriorityFeePerGas != null) + .map((x) => fromHex(x.maxPriorityFeePerGas!, "bigint")) + ); - if (overrides?.maxPriorityFeePerGas || maxPriorityFeePerGas != null) { - _overrides.maxPriorityFeePerGas = - overrides?.maxPriorityFeePerGas ?? maxPriorityFeePerGas; - } + const _overrides: UserOperationOverrides = { + maxFeePerGas, + maxPriorityFeePerGas, + }; + filterUndefined(_overrides); return { batch, @@ -309,9 +309,13 @@ export class SmartAccountProvider< await new Promise((resolve) => setTimeout(resolve, txRetryIntervalWithJitterMs) ); - const receipt = await this.getUserOperationReceipt(hash as `0x${string}`) - // TODO: should maybe log the error? - .catch(() => null); + const receipt = await this.getUserOperationReceipt( + hash as `0x${string}` + ).catch((e) => { + Logger.debug( + `[SmartAccountProvider] waitForUserOperationTransaction error fetching receipt for ${hash}: ${e}` + ); + }); if (receipt) { return this.getTransaction(receipt.receipt.transactionHash).then( (x) => x.hash @@ -405,10 +409,10 @@ export class SmartAccountProvider< BigInt(maxPriorityFeePerGas ?? 0n), bigIntPercent(uoToDrop.maxPriorityFeePerGas, 110n) ), - paymasterAndData: uoToDrop.paymasterAndData, }; const uoToSend = await this._runMiddlewareStack(uoToSubmit, _overrides); + return this._sendUserOperation(uoToSend); }; @@ -433,14 +437,13 @@ export class SmartAccountProvider< this.dummyPaymasterDataMiddleware, this.feeDataGetter, this.gasEstimator, - // run this before paymaster middleware - async (struct) => ({ ...struct, ...overrides }), - this.customMiddleware, - overrides?.paymasterAndData - ? noOpMiddleware + this.customMiddleware ?? noOpMiddleware, + this.feeOptionsMiddleware, + overrides?.paymasterAndData != null + ? this.overridePaymasterDataMiddleware : this.paymasterDataMiddleware, this.simulateUOMiddleware - )(uo); + )(uo, overrides); return resolveProperties(result); }; @@ -483,18 +486,30 @@ export class SmartAccountProvider< // You should implement your own middleware to override these // or extend this class and provider your own implemenation readonly dummyPaymasterDataMiddleware: AccountMiddlewareFn = async ( - struct + struct, + _overrides ) => { struct.paymasterAndData = "0x"; return struct; }; - readonly paymasterDataMiddleware: AccountMiddlewareFn = async (struct) => { + readonly overridePaymasterDataMiddleware: AccountMiddlewareFn = async ( + struct, + overrides + ) => { + struct.paymasterAndData = overrides?.paymasterAndData ?? "0x"; + return struct; + }; + + readonly paymasterDataMiddleware: AccountMiddlewareFn = async ( + struct, + _overrides + ) => { struct.paymasterAndData = "0x"; return struct; }; - readonly gasEstimator: AccountMiddlewareFn = async (struct) => { + readonly gasEstimator: AccountMiddlewareFn = async (struct, _overrides) => { const request = deepHexlify(await resolveProperties(struct)); const estimates = await this.rpcClient.estimateUserOperationGas( request, @@ -508,36 +523,66 @@ export class SmartAccountProvider< return struct; }; - readonly feeDataGetter: AccountMiddlewareFn = async (struct) => { - const [maxPriorityFeePerGas, feeData] = await Promise.all([ - this.rpcClient.estimateMaxPriorityFeePerGas(), - this.rpcClient.estimateFeesPerGas(), - ]); - if (!feeData.maxFeePerGas || !feeData.maxPriorityFeePerGas) { - throw new Error( - "feeData is missing maxFeePerGas or maxPriorityFeePerGas" + readonly feeDataGetter: AccountMiddlewareFn = async (struct, overrides) => { + // maxFeePerGas must be at least the sum of maxPriorityFeePerGas and baseFee + // so we need to accommodate for the fee option applied maxPriorityFeePerGas for the maxFeePerGas + // + // Note that if maxFeePerGas is not at least the sum of maxPriorityFeePerGas and required baseFee + // after applying the fee options, then the transaction will fail + // + // Refer to https://docs.alchemy.com/docs/maxpriorityfeepergas-vs-maxfeepergas + // for more information about maxFeePerGas and maxPriorityFeePerGas + const estimateMaxFeePerGas = async (maxPriorityFeePerGas: BigNumberish) => { + const feeData = await this.rpcClient.estimateFeesPerGas(); + if (!feeData.maxFeePerGas || !feeData.maxPriorityFeePerGas) { + throw new Error( + "feeData is missing maxFeePerGas or maxPriorityFeePerGas" + ); + } + + return ( + BigInt(feeData.maxFeePerGas) - + BigInt(feeData.maxPriorityFeePerGas) + + BigInt(maxPriorityFeePerGas) ); - } + }; - // set maxPriorityFeePerGasBid to the max between 33% added priority fee estimate and - // the min priority fee per gas set for the provider - const maxPriorityFeePerGasBid = bigIntMax( - bigIntPercent( - maxPriorityFeePerGas, - BigInt(100 + this.maxPriorityFeePerGasEstimateBuffer) - ), - this.minPriorityFeePerBid - ); + struct.maxPriorityFeePerGas = + overrides?.maxPriorityFeePerGas ?? + (await this.rpcClient.estimateMaxPriorityFeePerGas()); + struct.maxFeePerGas = + overrides?.maxFeePerGas ?? + (await estimateMaxFeePerGas(struct.maxPriorityFeePerGas)); + + return struct; + }; - const maxFeePerGasBid = - BigInt(feeData.maxFeePerGas) - - BigInt(feeData.maxPriorityFeePerGas) + - maxPriorityFeePerGasBid; + readonly feeOptionsMiddleware: AccountMiddlewareFn = async ( + struct, + overrides + ) => { + const resolved = await resolveProperties(struct); + + // max priority fee per gas to be added back after fee options applied + // maxFeePerGas fee option will be applied at the base fee level + resolved.maxFeePerGas = + BigInt(resolved.maxFeePerGas ?? 0n) - + BigInt(resolved.maxPriorityFeePerGas ?? 0n); + + Object.keys(this.feeOptions ?? {}).forEach((field) => { + if (overrides?.[field as keyof UserOperationOverrides] !== undefined) + return; + resolved[field as keyof UserOperationFeeOptions] = applyFeeOption( + resolved[field as keyof UserOperationFeeOptions], + this.feeOptions[field as keyof UserOperationFeeOptions] + ); + }); - struct.maxFeePerGas = maxFeePerGasBid; - struct.maxPriorityFeePerGas = maxPriorityFeePerGasBid; + resolved.maxFeePerGas = + BigInt(resolved.maxFeePerGas ?? 0n) + + BigInt(resolved.maxPriorityFeePerGas ?? 0n); - return struct; + return resolved; }; readonly customMiddleware: AccountMiddlewareFn = noOpMiddleware; @@ -561,7 +606,16 @@ export class SmartAccountProvider< return this; }; - withGasEstimator = (override: GasEstimatorMiddleware): this => { + withGasEstimator = ( + override: GasEstimatorMiddleware, + feeOptions?: GasEstimatorFeeOptions + ): this => { + // Note that this overrides the default gasEstimator middleware and + // also the gas estimator fee options set on the provider upon initialization + this.feeOptions.callGasLimit = feeOptions?.callGasLimit; + this.feeOptions.verificationGasLimit = feeOptions?.verificationGasLimit; + this.feeOptions.preVerificationGas = feeOptions?.preVerificationGas; + defineReadOnly( this, "gasEstimator", @@ -570,7 +624,15 @@ export class SmartAccountProvider< return this; }; - withFeeDataGetter = (override: FeeDataMiddleware): this => { + withFeeDataGetter = ( + override: FeeDataMiddleware, + feeOptions?: FeeDataFeeOptions + ): this => { + // Note that this overrides the default gasEstimator middleware and + // also the gas estimator fee options set on the provider upon initialization + this.feeOptions.maxFeePerGas = feeOptions?.maxFeePerGas; + this.feeOptions.maxPriorityFeePerGas = feeOptions?.maxPriorityFeePerGas; + defineReadOnly( this, "feeDataGetter", @@ -579,6 +641,15 @@ export class SmartAccountProvider< return this; }; + withFeeOptionsMiddleware = (override: FeeOptionsMiddleware): this => { + defineReadOnly( + this, + "feeOptionsMiddleware", + this.overrideMiddlewareFunction(override) + ); + return this; + }; + withCustomMiddleware = (override: AccountMiddlewareFn): this => { defineReadOnly(this, "customMiddleware", override); @@ -688,10 +759,10 @@ export class SmartAccountProvider< private overrideMiddlewareFunction = ( override: AccountMiddlewareOverrideFn ): AccountMiddlewareFn => { - return async (struct) => { + return async (struct, overrides) => { return { ...struct, - ...(await override(struct)), + ...(await override(struct, overrides)), }; }; }; diff --git a/packages/core/src/provider/schema.ts b/packages/core/src/provider/schema.ts index 5dfa7f7ec0..e96cae28cf 100644 --- a/packages/core/src/provider/schema.ts +++ b/packages/core/src/provider/schema.ts @@ -3,35 +3,32 @@ import type { Transport } from "viem"; import z from "zod"; import { createPublicErc4337ClientSchema } from "../client/schema.js"; import type { SupportedTransports } from "../client/types"; +import { UserOperationFeeOptionsSchema } from "../schema.js"; import { ChainSchema } from "../utils/index.js"; -export const SmartAccountProviderOptsSchema = z.object({ - /** - * The maximum number of times to try fetching a transaction receipt before giving up (default: 5) - */ - txMaxRetries: z.number().min(0).optional(), - - /** - * The interval in milliseconds to wait between retries while waiting for tx receipts (default: 2_000) - */ - txRetryIntervalMs: z.number().min(0).optional(), +export const SmartAccountProviderOptsSchema = z + .object({ + /** + * The maximum number of times to try fetching a transaction receipt before giving up (default: 5) + */ + txMaxRetries: z.number().min(0).optional(), - /** - * The mulitplier on interval length to wait between retries while waiting for tx receipts (default: 1.5) - */ - txRetryMulitplier: z.number().min(0).optional(), + /** + * The interval in milliseconds to wait between retries while waiting for tx receipts (default: 2_000) + */ + txRetryIntervalMs: z.number().min(0).optional(), - /** - * Used when computing the fees for a user operation (default: 100_000_000n) - */ - minPriorityFeePerBid: z.bigint().min(0n).optional(), + /** + * The mulitplier on interval length to wait between retries while waiting for tx receipts (default: 1.5) + */ + txRetryMulitplier: z.number().min(0).optional(), - /** - * Percent value for maxPriorityFeePerGas estimate added buffer. maxPriorityFeePerGasBid is set to the max - * between the buffer "added" priority fee estimate and the minPriorityFeePerBid (default: 33) - */ - maxPriorityFeePerGasEstimateBuffer: z.number().min(0).optional(), -}); + /** + * Optional user operation fee options to be set globally at the provider level + */ + feeOptions: UserOperationFeeOptionsSchema.optional(), + }) + .strict(); export const createSmartAccountProviderConfigSchema = < TTransport extends SupportedTransports = Transport diff --git a/packages/core/src/provider/types.ts b/packages/core/src/provider/types.ts index 1ed87c1f41..05cde55e15 100644 --- a/packages/core/src/provider/types.ts +++ b/packages/core/src/provider/types.ts @@ -19,6 +19,7 @@ import type { import type { BatchUserOperationCallData, UserOperationCallData, + UserOperationFeeOptions, UserOperationOverrides, UserOperationReceipt, UserOperationRequest, @@ -53,14 +54,16 @@ export type SendUserOperationResult = { }; export type AccountMiddlewareFn = ( - struct: Deferrable + struct: Deferrable, + overrides?: UserOperationOverrides ) => Promise>; export type AccountMiddlewareOverrideFn< Req extends keyof UserOperationStruct = never, Opt extends keyof UserOperationStruct = never > = ( - struct: Deferrable + struct: Deferrable, + overrides?: UserOperationOverrides ) => Promise< WithRequired & WithOptional @@ -78,9 +81,28 @@ export type PaymasterAndDataMiddleware = AccountMiddlewareOverrideFn< export type GasEstimatorMiddleware = AccountMiddlewareOverrideFn< "callGasLimit" | "preVerificationGas" | "verificationGasLimit" >; +export type GasEstimatorFeeOptions = Partial< + Pick< + UserOperationFeeOptions, + "callGasLimit" | "preVerificationGas" | "verificationGasLimit" + > +>; + export type FeeDataMiddleware = AccountMiddlewareOverrideFn< "maxFeePerGas" | "maxPriorityFeePerGas" >; +export type FeeDataFeeOptions = Partial< + Pick +>; + +export type FeeOptionsMiddleware = AccountMiddlewareOverrideFn< + never, + | "callGasLimit" + | "preVerificationGas" + | "verificationGasLimit" + | "maxFeePerGas" + | "maxPriorityFeePerGas" +>; export type SmartAccountProviderOpts = z.infer< typeof SmartAccountProviderOptsSchema @@ -103,9 +125,11 @@ export interface ISmartAccountProvider< readonly paymasterDataMiddleware: AccountMiddlewareFn; readonly gasEstimator: AccountMiddlewareFn; readonly feeDataGetter: AccountMiddlewareFn; + readonly feeOptionsMiddleware: AccountMiddlewareFn; readonly customMiddleware?: AccountMiddlewareFn; readonly account?: ISmartContractAccount; + readonly feeOptions: UserOperationFeeOptions; /** * Sends a user operation using the connected account. @@ -316,19 +340,44 @@ export interface ISmartAccountProvider< * Overrides the gasEstimator middleware which is used for setting the gasLimit fields on the UserOperation * prior to execution. * + * Note that when using your custom gas estimator with this override method, not only the default gasEstimator middleware, + * but also the gas estimator fee options set during initialization is overriden. + * Thus, when using your custom gas estimator, you need to set the fee options from this method instead. + * * @param override - a function for overriding the default gas estimator middleware + * @param feeOptions - optional GasEstimatorFeeOptions to set at the global level of the provider. * @returns */ - withGasEstimator: (override: GasEstimatorMiddleware) => this; + withGasEstimator: ( + override: GasEstimatorMiddleware, + feeOptions?: GasEstimatorFeeOptions + ) => this; /** * Overrides the feeDataGetter middleware which is used for setting the fee fields on the UserOperation * prior to execution. * - * @param override - a function for overriding the default feeDataGetter middleware + * Note that when using your custom fee data getter with this override method, not only the default feeDataGetter middleware, + * but also the fee data getter fee options set during initialization is overriden. + * Thus, when using your custom fee data getter, you need to set the fee options from this method instead. + * + * @param override - a function for overriding the default feeDataGetter middleware + * @param feeOptions - optional FeeDataFeeOptions to set at the global level of the provider. + * @returns + */ + withFeeDataGetter: ( + override: FeeDataMiddleware, + feeOptions?: FeeDataFeeOptions + ) => this; + + /** + * Overrides the feeOptions middleware which is used for applying the provider feeOptions + * on UserOperationFeeOptions fields on the UserOperation prior to execution. + * + * @param override - a function for overriding the default gas estimator middleware * @returns */ - withFeeDataGetter: (override: FeeDataMiddleware) => this; + withFeeOptionsMiddleware: (override: FeeOptionsMiddleware) => this; /** * Adds a function to the middleware call stack that runs before calling the paymaster middleware. diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts new file mode 100644 index 0000000000..c452931779 --- /dev/null +++ b/packages/core/src/schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { BigNumberishRangeSchema, PercentageSchema } from "./utils/index.js"; + +export const UserOperationFeeOptionsFieldSchema = + BigNumberishRangeSchema.merge(PercentageSchema).partial(); + +export const UserOperationFeeOptionsSchema = z + .object({ + maxFeePerGas: UserOperationFeeOptionsFieldSchema, + maxPriorityFeePerGas: UserOperationFeeOptionsFieldSchema, + callGasLimit: UserOperationFeeOptionsFieldSchema, + verificationGasLimit: UserOperationFeeOptionsFieldSchema, + preVerificationGas: UserOperationFeeOptionsFieldSchema, + }) + .partial() + .strict(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8babbe411a..0db876be0d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,12 +1,26 @@ import type { Address, Hash } from "viem"; +import type { z } from "zod"; +import type { + UserOperationFeeOptionsFieldSchema, + UserOperationFeeOptionsSchema, +} from "./schema"; +import type { + BigNumberishRangeSchema, + BigNumberishSchema, + HexSchema, + PercentageSchema, +} from "./utils"; -export type Hex = `0x${string}`; +export type Hex = z.infer; export type EmptyHex = `0x`; // based on @account-abstraction/common export type PromiseOrValue = T | Promise; -export type BigNumberish = string | bigint | number; export type BytesLike = Uint8Array | string; +export type Percentage = z.infer; + +export type BigNumberish = z.infer; +export type BigNumberishRange = z.infer; export type UserOperationCallData = | { @@ -21,6 +35,14 @@ export type UserOperationCallData = export type BatchUserOperationCallData = Exclude[]; +export type UserOperationFeeOptionsField = z.infer< + typeof UserOperationFeeOptionsFieldSchema +>; + +export type UserOperationFeeOptions = z.infer< + typeof UserOperationFeeOptionsSchema +>; + export type UserOperationOverrides = Partial< Pick< UserOperationStruct, diff --git a/packages/core/src/utils/bigint.ts b/packages/core/src/utils/bigint.ts index 7aecaae565..b07252073a 100644 --- a/packages/core/src/utils/bigint.ts +++ b/packages/core/src/utils/bigint.ts @@ -8,12 +8,58 @@ import type { BigNumberish } from "../types"; */ export const bigIntMax = (...args: bigint[]) => { if (!args.length) { - throw new Error("bigIntMax requires at least one argument"); + return undefined; } return args.reduce((m, c) => (m > c ? m : c)); }; +/** + * Returns the min bigint in a list of bigints + * + * @param args a list of bigints to get the max of + * @returns the min bigint in the list + */ +export const bigIntMin = (...args: bigint[]) => { + if (!args.length) { + return undefined; + } + + return args.reduce((m, c) => (m < c ? m : c)); +}; + +/** + * Given a bigint and a min-max range, returns the min-max clamped bigint value + * + * @param value a bigint value to clamp + * @param lower lower bound min max tuple value + * @param upper upper bound min max tuple value + * @returns the clamped bigint value per given range + */ +export const bigIntClamp = ( + value: BigNumberish, + lower?: BigNumberish, + upper?: BigNumberish +) => { + lower = lower ? BigInt(lower) : undefined; + upper = upper ? BigInt(upper) : undefined; + + if (upper !== undefined && lower !== undefined && upper < lower) { + throw new Error( + `invalid range: upper bound ${upper} is less than lower bound ${lower}` + ); + } + + let ret = BigInt(value); + if (lower !== undefined && lower > ret) { + ret = lower; + } + if (upper !== undefined && upper < ret) { + ret = upper; + } + return ret; +}; + /** * Useful if you want to increment a bigint by N% or decrement by N% * diff --git a/packages/core/src/utils/defaults.ts b/packages/core/src/utils/defaults.ts index 8b490f0e2e..349697a829 100644 --- a/packages/core/src/utils/defaults.ts +++ b/packages/core/src/utils/defaults.ts @@ -15,6 +15,7 @@ import { polygonMumbai, sepolia, } from "viem/chains"; +import type { UserOperationFeeOptions } from "../types"; /** * Utility method returning the entry point contrafct address given a {@link Chain} object @@ -79,3 +80,20 @@ export const getDefaultSimpleAccountFactoryAddress = ( `no default simple account factory contract exists for ${chain.name}` ); }; + +export const minPriorityFeePerBidDefaults = new Map([ + [arbitrum.id, 10_000_000n], + [arbitrumGoerli.id, 10_000_000n], + [arbitrumSepolia.id, 10_000_000n], +]); + +export const getDefaultUserOperationFeeOptions = ( + chain: Chain +): UserOperationFeeOptions => { + return { + maxPriorityFeePerGas: { + min: minPriorityFeePerBidDefaults.get(chain.id) ?? 100_000_000n, + percentage: 33, + }, + }; +}; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 3b6c49269e..271223cf15 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,7 +1,15 @@ import type { Address, Hash, Hex } from "viem"; import { encodeAbiParameters, hexToBigInt, keccak256, toHex } from "viem"; import * as chains from "viem/chains"; -import type { PromiseOrValue, UserOperationRequest } from "../types.js"; +import type { + BigNumberish, + Percentage, + PromiseOrValue, + UserOperationFeeOptionsField, + UserOperationRequest, +} from "../types.js"; +import { bigIntClamp, bigIntPercent } from "./bigint.js"; +import { BigNumberishSchema, PercentageSchema } from "./schema.js"; /** * Utility method for converting a chainId to a {@link chains.Chain} object @@ -26,11 +34,11 @@ export const getChain = (chainId: number): chains.Chain => { * @returns result of the pipe */ export const asyncPipe = - (...fns: ((x: T) => Promise)[]) => - async (x: T) => { + (...fns: ((x: T, o?: O) => Promise)[]) => + async (x: T, o?: O) => { let result = x; for (const fn of fns) { - result = await fn(result); + result = await fn(result, o); } return result; }; @@ -89,6 +97,24 @@ export function deepHexlify(obj: any): any { ); } +export function applyFeeOption( + value: BigNumberish | undefined, + feeOption: UserOperationFeeOptionsField | undefined +): BigNumberish { + if (feeOption === undefined) { + return value ?? 0n; + } + return value + ? bigIntClamp( + feeOption.percentage + ? bigIntPercent(value, BigInt(100 + feeOption.percentage)) + : value, + feeOption.min, + feeOption.max + ) + : feeOption.min ?? 0n; +} + /** * Generates a hash for a UserOperation valid from entrypoint version 0.6 onwards * @@ -156,6 +182,31 @@ export function defineReadOnly( }); } +export function isBigNumberish(x: any): x is BigNumberish { + return BigNumberishSchema.safeParse(x).success; +} + +export function isPercentage(x: any): x is Percentage { + return PercentageSchema.safeParse(x).success; +} + +export function filterUndefined( + obj: Record +): Record { + Object.keys(obj).forEach((key) => { + if (obj[key] === undefined) { + delete obj[key]; + } + }); + return obj; +} + +export function pick(obj: Record, keys: string | string[]) { + return Object.keys(obj) + .filter((k) => keys.includes(k)) + .reduce((res, k) => Object.assign(res, { [k]: obj[k] }), {}); +} + export * from "./bigint.js"; export * from "./defaults.js"; export * from "./schema.js"; diff --git a/packages/core/src/utils/schema.ts b/packages/core/src/utils/schema.ts index 140dd4f42b..f7428efcc2 100644 --- a/packages/core/src/utils/schema.ts +++ b/packages/core/src/utils/schema.ts @@ -1,4 +1,4 @@ -import type { Chain } from "viem"; +import { isHex, type Chain } from "viem"; import { z } from "zod"; import { getChain } from "./index.js"; @@ -18,3 +18,25 @@ export const ChainSchema = z.custom((chain) => { return false; } }); + +export const HexSchema = z.custom<`0x${string}` | "0x">((val) => { + return isHex(val) || val === "0x"; +}); + +export const BigNumberishSchema = z.union([HexSchema, z.number(), z.bigint()]); + +export const BigNumberishRangeSchema = z + .object({ + min: BigNumberishSchema.optional(), + max: BigNumberishSchema.optional(), + }) + .strict(); + +export const PercentageSchema = z + .object({ + /** + * Percent value between 1 and 1000 inclusive + */ + percentage: z.number().min(1).max(1000), + }) + .strict(); diff --git a/site/packages/aa-alchemy/middleware/withAlchemyGasManager.md b/site/packages/aa-alchemy/middleware/withAlchemyGasManager.md index 1a0ec1e4e4..21c9de5fd7 100644 --- a/site/packages/aa-alchemy/middleware/withAlchemyGasManager.md +++ b/site/packages/aa-alchemy/middleware/withAlchemyGasManager.md @@ -53,4 +53,4 @@ A new instance of an `AlchemyProvider` with the same attributes as the input, no - `policyId: string` -- the Alchemy Gas Manager policy ID -### `estimateGas: boolean` -- a flag to additionally estimate gas as part of +### `delegateGasEstimation: boolean` -- a flag to additionally estimate gas as part of