From 5a4192324746b587c8a3a8e6ca6a34baf0afc95c Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Fri, 10 Jul 2026 11:06:22 +0900 Subject: [PATCH 1/5] use viem instead of ethers for fetching gas prices --- src/libs/gasPrice/gasPrice.ts | 256 ++++++------ src/libs/gasPrice/tests/1559Network.test.ts | 379 +++++------------- src/libs/gasPrice/tests/MockProvider.ts | 42 +- .../gasPrice/tests/non1559Network.test.ts | 160 +++----- 4 files changed, 329 insertions(+), 508 deletions(-) diff --git a/src/libs/gasPrice/gasPrice.ts b/src/libs/gasPrice/gasPrice.ts index fdd06ddfbf..28e853071c 100644 --- a/src/libs/gasPrice/gasPrice.ts +++ b/src/libs/gasPrice/gasPrice.ts @@ -1,18 +1,16 @@ -import { Interface, JsonRpcProvider, Provider, toBeHex, toQuantity } from 'ethers' +import { Interface, JsonRpcProvider, toBeHex, toQuantity } from 'ethers' +import type { PublicClient } from 'viem' import AmbireAccount from '../../../contracts/compiled/AmbireAccount.json' import AmbireFactory from '../../../contracts/compiled/AmbireFactory.json' import { AccountOnchainState } from '../../interfaces/account' import { Network } from '../../interfaces/network' import { GasSpeeds } from '../../services/bundlers/types' +import { getViemClientForProvider } from '../../services/provider' import { BaseAccount } from '../account/BaseAccount' import { AccountOp, getSignableCalls } from '../accountOp/accountOp' import { getActivatorCall } from '../userOperation/userOperation' -// https://eips.ethereum.org/EIPS/eip-1559 -const DEFAULT_BASE_FEE_MAX_CHANGE_DENOMINATOR = 8n -const DEFAULT_ELASTICITY_MULTIPLIER = 2n - // a 1 gwei min for gas price, non1559 networks export const MIN_GAS_PRICE = 1000000000n @@ -25,6 +23,9 @@ const speeds = [ { name: 'ape', baseFeeAddBps: 1500n } ] +const FEE_HISTORY_BLOCK_COUNT = 5 +const FEE_HISTORY_REWARD_PERCENTILES = [25, 50, 75, 95] + export interface GasPriceRecommendation { name: string gasPrice: bigint @@ -91,18 +92,6 @@ function filterOutliers(data: bigint[]): bigint[] { return filteredValues } -function nthGroup(data: bigint[], n: number, outOf: number): bigint[] { - const step = Math.floor(data.length / outOf) - const at = n * step - - // if n is 3 (ape speed) and we have at least 4 txns in the previous block, - // we want to include the remaining high cost transactions in the group. - // Example: 15 txns make 3 groups of 3 for slow, medium and fast, totalling 9 - // the remaining 6 get included in the ape calculation - const end = n !== 3 || data.length < 4 ? at + Math.max(1, step) : data.length - return data.slice(at, end) -} - function average(data: bigint[]): bigint { if (data.length === 0) return 0n return data.reduce((a, b) => a + b, 0n) / BigInt(data.length) @@ -132,7 +121,10 @@ function hexToBigInt(value?: string | null): bigint | null { return value ? BigInt(value) : null } -async function getRpcBlockTag(provider: Provider, blockTag: string | number): Promise { +async function getRpcBlockTag( + provider: JsonRpcProvider, + blockTag: string | number +): Promise { if (typeof blockTag !== 'number') return blockTag if (blockTag >= 0) return toQuantity(blockTag) @@ -142,13 +134,14 @@ async function getRpcBlockTag(provider: Provider, blockTag: string | number): Pr } async function fetchRawBlock( - provider: Provider, - blockTag: string | number + provider: JsonRpcProvider, + blockTag: string | number, + includeTransactions = false ): Promise { const rpcBlockTag = await getRpcBlockTag(provider, blockTag) - const rawBlock = (await (provider as JsonRpcProvider).send('eth_getBlockByNumber', [ + const rawBlock = (await provider.send('eth_getBlockByNumber', [ rpcBlockTag, - true + includeTransactions ])) as RpcBlock | null if (!rawBlock) return null @@ -167,18 +160,18 @@ async function fetchRawBlock( } async function fetchBlock( - provider: Provider, + provider: JsonRpcProvider, network: Network, - blockTag: string | number + blockTag: string | number, + includeTransactions = false ): Promise { - if (network.chainId === 2741n) return fetchRawBlock(provider, blockTag) - return await provider.getBlock(blockTag, true) + if (network.chainId === 2741n) return fetchRawBlock(provider, blockTag, includeTransactions) + return await provider.getBlock(blockTag, includeTransactions) } -// if there's an RPC issue, try refetching the block at least -// 5 times before declaring a failure +// if there's an RPC issue, try refetching the block before declaring a failure async function refetchBlock( - provider: Provider, + provider: JsonRpcProvider, network: Network, blockTag: string | number, getIsActive?: () => boolean, @@ -200,6 +193,7 @@ async function refetchBlock( ]) lastBlock = response as GasPriceBlock } catch (e) { + console.error('refetch block failed', e) lastBlock = null } finally { if (timeoutId) clearTimeout(timeoutId) @@ -217,104 +211,142 @@ async function refetchBlock( return lastBlock } +function increaseByBps(value: bigint, bps: bigint): bigint { + return value + (value * bps) / 10000n +} + +function increaseByPercent(value: bigint, percent?: bigint): bigint { + if (!percent) return value + return value + (value * percent) / 100n +} + +function getAveragePriorityFeesFromHistory(rewards: bigint[][]): bigint[] { + return FEE_HISTORY_REWARD_PERCENTILES.map((_percentile, percentileIndex) => { + const fees = rewards + .map((reward) => reward[percentileIndex]) + .filter((fee): fee is bigint => typeof fee === 'bigint' && fee > 0n) + + return average(filterOutliers(fees)) + }) +} + +function getLastBaseFeeFromHistory(baseFeePerGas: bigint[]): bigint | null { + return baseFeePerGas[baseFeePerGas.length - 1] ?? null +} + +async function fetchViemFeeHistory(client: PublicClient) { + return client.getFeeHistory({ + blockCount: FEE_HISTORY_BLOCK_COUNT, + rewardPercentiles: FEE_HISTORY_REWARD_PERCENTILES + }) +} + +async function get1559ViemFees(client: PublicClient) { + return client.estimateFeesPerGas({ chain: null, type: 'eip1559' }) +} + +async function getLegacyViemFees(client: PublicClient) { + return client.estimateFeesPerGas({ chain: null, type: 'legacy' }) +} + +async function get1559GasPriceRecommendations( + client: PublicClient, + network: Network, + lastBlock: GasPriceBlock +): Promise { + const [estimatedFees, feeHistory] = await Promise.all([ + get1559ViemFees(client), + fetchViemFeeHistory(client).catch((e) => { + console.error('eth_feeHistory failed; falling back to viem fee estimation', e) + return null + }) + ]) + + const estimatedBaseFee = estimatedFees.maxFeePerGas - estimatedFees.maxPriorityFeePerGas + let expectedBaseFee = feeHistory + ? (getLastBaseFeeFromHistory(feeHistory.baseFeePerGas) ?? estimatedBaseFee) + : estimatedBaseFee + + const minBaseFee = getNetworkMinBaseFee(network, lastBlock) + if (expectedBaseFee < minBaseFee) expectedBaseFee = minBaseFee + + const priorityFees = feeHistory ? getAveragePriorityFeesFromHistory(feeHistory.reward ?? []) : [] + const fee: Gas1559Recommendation[] = [] + + speeds.forEach(({ name, baseFeeAddBps }, i) => { + const baseFeePerGas = increaseByBps(expectedBaseFee, baseFeeAddBps) + let maxPriorityFeePerGas = priorityFees[i] || estimatedFees.maxPriorityFeePerGas + + if (maxPriorityFeePerGas < estimatedFees.maxPriorityFeePerGas) { + maxPriorityFeePerGas = estimatedFees.maxPriorityFeePerGas + } + + // set a bare minimum of 100000n for maxPriorityFeePerGas + maxPriorityFeePerGas = maxPriorityFeePerGas >= 100000n ? maxPriorityFeePerGas : 100000n + + // compare the maxPriorityFeePerGas with the previous speed + // if it's not at least 12% bigger, then replace the calculated one + // with at least 12% bigger maxPriorityFeePerGas. + // This is most impactufull on L2s where txns get stuck for low maxPriorityFeePerGas + // + // if the speed is ape, make it 50% more + const prevSpeed = fee.length ? fee[i - 1]?.maxPriorityFeePerGas : null + if (prevSpeed) { + const divider = name === 'ape' ? 2n : 8n + const min = prevSpeed + prevSpeed / divider + if (maxPriorityFeePerGas < min) maxPriorityFeePerGas = min + } + + fee.push({ + name, + baseFeePerGas, + maxPriorityFeePerGas + }) + }) + + return fee +} + +async function getLegacyGasPriceRecommendations( + client: PublicClient, + network: Network +): Promise { + const { gasPrice } = await getLegacyViemFees(client) + const minGasPrice = increaseByPercent( + gasPrice > MIN_GAS_PRICE ? gasPrice : MIN_GAS_PRICE, + network.feeOptions.feeIncrease + ) + + return speeds.map(({ name, baseFeeAddBps }) => ({ + name, + gasPrice: increaseByBps(minGasPrice, baseFeeAddBps) + })) +} + export async function getGasPriceRecommendations( - provider: Provider, + provider: JsonRpcProvider, network: Network, _blockTag?: string | number, getIsActive?: () => boolean ): Promise<{ gasPrice: GasRecommendation[] }> { const blockTag = _blockTag ?? -1 + const client = getViemClientForProvider(provider) - const [lastBlock, ethGasPrice] = await Promise.all([ - refetchBlock(provider, network, blockTag, getIsActive), - (provider as JsonRpcProvider).send('eth_gasPrice', []).catch((e) => { - console.log('eth_gasPrice failed because of the following reason:') - console.log(e) - return '0x' - }) - ]) - // https://github.com/ethers-io/ethers.js/issues/3683#issuecomment-1436554995 - const txns = lastBlock.prefetchedTransactions + const lastBlock = await refetchBlock(provider, network, blockTag, getIsActive) + + if (getIsActive && !getIsActive()) { + throw new Error('operation aborted') + } if ( network.feeOptions.is1559 && lastBlock.baseFeePerGas != null && lastBlock.baseFeePerGas !== 0n ) { - // https://eips.ethereum.org/EIPS/eip-1559 - const elasticityMultiplier = - network.feeOptions.elasticityMultiplier ?? DEFAULT_ELASTICITY_MULTIPLIER - const baseFeeMaxChangeDenominator = - network.feeOptions.baseFeeMaxChangeDenominator ?? DEFAULT_BASE_FEE_MAX_CHANGE_DENOMINATOR - - const gasTarget = lastBlock.gasLimit / elasticityMultiplier - const baseFeePerGas = lastBlock.baseFeePerGas - const getBaseFeeDelta = (delta: bigint) => - (baseFeePerGas * delta) / gasTarget / baseFeeMaxChangeDenominator - let expectedBaseFee = baseFeePerGas - if (lastBlock.gasUsed > gasTarget) { - const baseFeeDelta = getBaseFeeDelta(lastBlock.gasUsed - gasTarget) - expectedBaseFee += baseFeeDelta === 0n ? 1n : baseFeeDelta - } - - // : commenting out the decrease as it's really bad UX - // if the user chooses slow on Ethereum and the next block doesn't - // actually meet the base fee and starts going up from there, the user - // will need to do an RBF or wait ~forever for the txn to complete - // the below code is good in theory, bad in practise - // else if (lastBlock.gasUsed < gasTarget) { - // const baseFeeDelta = getBaseFeeDelta(gasTarget - lastBlock.gasUsed) - // expectedBaseFee -= baseFeeDelta - // } - - // if the estimated fee is below the chain minimum, set it to the min - const minBaseFee = getNetworkMinBaseFee(network, lastBlock) - if (expectedBaseFee < minBaseFee) expectedBaseFee = minBaseFee - - const tips = filterOutliers(txns.map((x) => x.maxPriorityFeePerGas!).filter((x) => x > 0)) - const fee: Gas1559Recommendation[] = [] - speeds.forEach(({ name, baseFeeAddBps }, i) => { - const baseFee = expectedBaseFee + (expectedBaseFee * baseFeeAddBps) / 10000n - let maxPriorityFeePerGas = average(nthGroup(tips, i, speeds.length)) - - // set a bare minimum of 100000n for maxPriorityFeePerGas - maxPriorityFeePerGas = maxPriorityFeePerGas >= 100000n ? maxPriorityFeePerGas : 100000n - - // compare the maxPriorityFeePerGas with the previous speed - // if it's not at least 12% bigger, then replace the calculated one - // with at least 12% bigger maxPriorityFeePerGas. - // This is most impactufull on L2s where txns get stuck for low maxPriorityFeePerGas - // - // if the speed is ape, make it 50% more - const prevSpeed = fee.length ? fee[i - 1]?.maxPriorityFeePerGas : null - if (prevSpeed) { - const divider = name === 'ape' ? 2n : 8n - const min = prevSpeed + prevSpeed / divider - if (maxPriorityFeePerGas < min) maxPriorityFeePerGas = min - } - - fee.push({ - name, - baseFeePerGas: baseFee, - maxPriorityFeePerGas - }) - }) - return { gasPrice: fee } + return { gasPrice: await get1559GasPriceRecommendations(client, network, lastBlock) } } - const prices = filterOutliers(txns.map((x) => x.gasPrice!).filter((x) => x > 0)) - - // use th fetched price as a min if not 0 as it could be actually lower - // than the hardcoded MIN. - const minOrFetchedGasPrice = ethGasPrice !== '0x' ? BigInt(ethGasPrice) : MIN_GAS_PRICE - const fee = speeds.map(({ name }, i) => { - const avgGasPrice = average(nthGroup(prices, i, speeds.length)) - return { - name, - gasPrice: avgGasPrice >= minOrFetchedGasPrice ? avgGasPrice : minOrFetchedGasPrice - } - }) - return { gasPrice: fee } + return { gasPrice: await getLegacyGasPriceRecommendations(client, network) } } export function getProbableCallData( diff --git a/src/libs/gasPrice/tests/1559Network.test.ts b/src/libs/gasPrice/tests/1559Network.test.ts index b4e91aae23..d527424ddc 100644 --- a/src/libs/gasPrice/tests/1559Network.test.ts +++ b/src/libs/gasPrice/tests/1559Network.test.ts @@ -1,310 +1,125 @@ -// https://eips.ethereum.org/EIPS/eip-1559 import { ethers } from 'ethers' import { describe, expect, test } from '@jest/globals' import { suppressConsoleBeforeEach } from '../../../../test/helpers/console' import { networks } from '../../../consts/networks' -import { getGasPriceRecommendations } from '../gasPrice' +import { Gas1559Recommendation, getGasPriceRecommendations } from '../gasPrice' import MockProvider from './MockProvider' const network = networks.find((net) => net.chainId === 1n)! +const baseNetwork = networks.find((net) => net.chainId === 8453n)! + +const getByName = (recommendations: Gas1559Recommendation[], name: string) => + recommendations.find((recommendation) => recommendation.name === name)! describe('1559 Network gas price tests', () => { // Mock providers throw errors we can ignore suppressConsoleBeforeEach() - test('should make a prediction for a previous block of 30M gas (max), should increase the baseFeePerGas by 12.5% for slow, and increase gradually by baseFeeAddBps, defined in speeds in gasprice.ts for the remaining speeds', async () => { - const params = { - gasUsed: 30000000n - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const expectations = { - slow: ethers.parseUnits('1.125', 'gwei'), - medium: ethers.parseUnits('1.18125', 'gwei'), - fast: ethers.parseUnits('1.2375', 'gwei'), - ape: ethers.parseUnits('1.29375', 'gwei') - } - const slow: any = gasPrice[0]! - expect(slow.baseFeePerGas).toBe(expectations.slow) - const medium: any = gasPrice[1] - expect(medium.baseFeePerGas).toBe(expectations.medium) - const fast: any = gasPrice[2] - expect(fast.baseFeePerGas).toBe(expectations.fast) - const ape: any = gasPrice[3] - expect(ape.baseFeePerGas).toBe(expectations.ape) - provider.destroy() - }) - test('should make a prediction for a previous block of 15M gas (the target gas), should not change the baseFeePerGas from the previous block for slow, and increase gradually by baseFeeAddBps, defined in speeds in gasprice.ts for the remaining speeds', async () => { - const params = { - gasUsed: 15000000n - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice + test('should use fee history next block base fee and reward percentiles for speed recommendations', async () => { + const provider = MockProvider.init({ + ethMaxPriorityFeePerGas: 100n, + feeHistory: { + baseFeePerGas: [ + ethers.parseUnits('1', 'gwei'), + ethers.parseUnits('1.05', 'gwei'), + ethers.parseUnits('1.1', 'gwei'), + ethers.parseUnits('1.15', 'gwei'), + ethers.parseUnits('1.18', 'gwei'), + ethers.parseUnits('1.2', 'gwei') + ], + reward: Array.from({ length: 5 }, () => [ + ethers.parseUnits('1', 'gwei'), + ethers.parseUnits('2', 'gwei'), + ethers.parseUnits('3', 'gwei'), + ethers.parseUnits('5', 'gwei') + ]) + } + }) - const expectations = { - slow: ethers.parseUnits('1', 'gwei'), - medium: ethers.parseUnits('1.05', 'gwei'), - fast: ethers.parseUnits('1.1', 'gwei'), - ape: ethers.parseUnits('1.15', 'gwei') - } - const slow: any = gasPrice[0]! - expect(slow.baseFeePerGas).toBe(expectations.slow) - const medium: any = gasPrice[1] - expect(medium.baseFeePerGas).toBe(expectations.medium) - const fast: any = gasPrice[2] - expect(fast.baseFeePerGas).toBe(expectations.fast) - const ape: any = gasPrice[3] - expect(ape.baseFeePerGas).toBe(expectations.ape) - provider.destroy() - }) - test('should make a prediction for an empty previous block, should NOT decrease the baseFeePerGas as we do not decrease it anymore, set slow to the base gas and increase gradually by baseFeeAddBps, defined in speeds in gasprice.ts for the remaining speeds', async () => { - const params = { - gasUsed: 0n - } - const provider = MockProvider.init(params) const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice + const gasPrice = gasPriceData.gasPrice as Gas1559Recommendation[] - const expectations = { - slow: ethers.parseUnits('1', 'gwei'), - medium: ethers.parseUnits('1.05', 'gwei'), - fast: ethers.parseUnits('1.1', 'gwei'), - ape: ethers.parseUnits('1.15', 'gwei') - } - const slow: any = gasPrice[0]! - expect(slow.baseFeePerGas).toBe(expectations.slow) - const medium: any = gasPrice[1] - expect(medium.baseFeePerGas).toBe(expectations.medium) - const fast: any = gasPrice[2] - expect(fast.baseFeePerGas).toBe(expectations.fast) - const ape: any = gasPrice[3] - expect(ape.baseFeePerGas).toBe(expectations.ape) + expect(getByName(gasPrice, 'slow')).toEqual({ + name: 'slow', + baseFeePerGas: ethers.parseUnits('1.2', 'gwei'), + maxPriorityFeePerGas: ethers.parseUnits('1', 'gwei') + }) + expect(getByName(gasPrice, 'medium')).toEqual({ + name: 'medium', + baseFeePerGas: ethers.parseUnits('1.26', 'gwei'), + maxPriorityFeePerGas: ethers.parseUnits('2', 'gwei') + }) + expect(getByName(gasPrice, 'fast')).toEqual({ + name: 'fast', + baseFeePerGas: ethers.parseUnits('1.32', 'gwei'), + maxPriorityFeePerGas: ethers.parseUnits('3', 'gwei') + }) + expect(getByName(gasPrice, 'ape')).toEqual({ + name: 'ape', + baseFeePerGas: ethers.parseUnits('1.38', 'gwei'), + maxPriorityFeePerGas: ethers.parseUnits('5', 'gwei') + }) provider.destroy() }) - test('should make a prediction for a previous block of 10M gas, should NOT decrease the baseFeePerGas for slow, and increase gradually by baseFeeAddBps, defined in speeds in gasprice.ts for the remaining speeds', async () => { - const params = { - gasUsed: 10000000n - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - // 958333334 - const expectations = { - slow: ethers.parseUnits('1', 'gwei'), - medium: ethers.parseUnits('1.05', 'gwei'), - fast: ethers.parseUnits('1.1', 'gwei'), - ape: ethers.parseUnits('1.15', 'gwei') - } - const slow: any = gasPrice[0]! - expect(slow.baseFeePerGas).toBe(expectations.slow) - const medium: any = gasPrice[1] - expect(medium.baseFeePerGas).toBe(expectations.medium) - const fast: any = gasPrice[2] - expect(fast.baseFeePerGas).toBe(expectations.fast) - const ape: any = gasPrice[3] - expect(ape.baseFeePerGas).toBe(expectations.ape) - provider.destroy() - }) - test('should make a prediction for a previous block of 18.5M gas, should increase the gas by 2.9% for slow, and increase gradually by baseFeeAddBps, defined in speeds in gasprice.ts for the remaining speeds', async () => { - const params = { - gasUsed: 18500000n - } - const provider = MockProvider.init(params) + test('should fallback to viem fee estimation when fee history is unavailable', async () => { + const provider = MockProvider.init({ + baseFeePerGas: ethers.parseUnits('1', 'gwei'), + ethMaxPriorityFeePerGas: ethers.parseUnits('1', 'gwei'), + feeHistoryError: new Error('eth_feeHistory unsupported') + }) + const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice + const gasPrice = gasPriceData.gasPrice as Gas1559Recommendation[] - // const delta = ethers.parseUnits('1', 'gwei') * (params.gasUsed - gasTarget) / gasTarget / 8n - const expectations = { - slow: { - gasPrice: ethers.parseUnits('1.029166666', 'gwei') - }, - medium: { - gasPrice: ethers.parseUnits('1.080624999', 'gwei') - }, - fast: { - gasPrice: ethers.parseUnits('1.132083332', 'gwei') - }, - ape: { - gasPrice: ethers.parseUnits('1.183541665', 'gwei') - } - } - const slow: any = gasPrice[0]! - expect(slow.baseFeePerGas).toBe(expectations.slow.gasPrice) - const medium: any = gasPrice[1] - expect(medium.baseFeePerGas).toBe(expectations.medium.gasPrice) - const fast: any = gasPrice[2] - expect(fast.baseFeePerGas).toBe(expectations.fast.gasPrice) - const ape: any = gasPrice[3] - expect(ape.baseFeePerGas).toBe(expectations.ape.gasPrice) + expect(getByName(gasPrice, 'slow')).toEqual({ + name: 'slow', + baseFeePerGas: ethers.parseUnits('1.2', 'gwei'), + maxPriorityFeePerGas: ethers.parseUnits('1', 'gwei') + }) + expect(getByName(gasPrice, 'medium')).toEqual({ + name: 'medium', + baseFeePerGas: ethers.parseUnits('1.26', 'gwei'), + maxPriorityFeePerGas: ethers.parseUnits('1.125', 'gwei') + }) + expect(getByName(gasPrice, 'fast')).toEqual({ + name: 'fast', + baseFeePerGas: ethers.parseUnits('1.32', 'gwei'), + maxPriorityFeePerGas: 1265625000n + }) + expect(getByName(gasPrice, 'ape')).toEqual({ + name: 'ape', + baseFeePerGas: ethers.parseUnits('1.38', 'gwei'), + maxPriorityFeePerGas: 1898437500n + }) provider.destroy() }) - test('should return the lowest maxPriorityFeePerGas for a block with less than 4 txns', async () => { - const params = { - transactions: [ - { maxPriorityFeePerGas: 101000n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n } - ] - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const expectations = { - slow: { - maxPriorityFeePerGas: 100100n - }, - medium: { - maxPriorityFeePerGas: 112612n // 12% more - }, - fast: { - maxPriorityFeePerGas: 126688n // 12% more - }, - ape: { - maxPriorityFeePerGas: 190032n // 12% more + + test('should not return a base fee below the network minimum', async () => { + const provider = MockProvider.init({ + baseFeePerGas: ethers.parseUnits('2', 'gwei'), + feeHistory: { + baseFeePerGas: [ + ethers.parseUnits('2', 'gwei'), + ethers.parseUnits('1.8', 'gwei'), + ethers.parseUnits('1.6', 'gwei'), + ethers.parseUnits('1.4', 'gwei'), + ethers.parseUnits('1.2', 'gwei'), + ethers.parseUnits('1', 'gwei') + ], + reward: Array.from({ length: 5 }, () => [100000n, 200000n, 300000n, 500000n]) } - } - const slow: any = gasPrice[0]! - expect(slow.maxPriorityFeePerGas).toBe(expectations.slow.maxPriorityFeePerGas) - const medium: any = gasPrice[1] - expect(medium.maxPriorityFeePerGas).toBe(expectations.medium.maxPriorityFeePerGas) - const fast: any = gasPrice[2] - expect(fast.maxPriorityFeePerGas).toBe(expectations.fast.maxPriorityFeePerGas) - const ape: any = gasPrice[3] - expect(ape.maxPriorityFeePerGas).toBe(expectations.ape.maxPriorityFeePerGas) - provider.destroy() - }) - test('makes a maxPriorityFeePerGas prediction with an empty block and returns 200n for slow as that is the minimum but 12% more for each after', async () => { - const params = { - transactions: [] - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const slow: any = gasPrice[0]! - expect(slow.maxPriorityFeePerGas).toBe(100000n) - const medium: any = gasPrice[1] - expect(medium.maxPriorityFeePerGas).toBe(112500n) - const fast: any = gasPrice[2] - expect(fast.maxPriorityFeePerGas).toBe(126562n) - const ape: any = gasPrice[3] - expect(ape.maxPriorityFeePerGas).toBe(189843n) - provider.destroy() - }) - test('should remove an outlier from a group of 17, making the group 16, and calculate average at a step of 4, disregarding none', async () => { - const params = { - transactions: [ - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100100n }, - { maxPriorityFeePerGas: 100500n }, - { maxPriorityFeePerGas: 100500n }, - { maxPriorityFeePerGas: 103000n }, - { maxPriorityFeePerGas: 104000n }, - { maxPriorityFeePerGas: 104000n }, - { maxPriorityFeePerGas: 100000000000n } // removed as an outlier - ] - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const slow: any = gasPrice[0]! - expect(slow.maxPriorityFeePerGas).toBe(100100n) - const medium: any = gasPrice[1] - expect(medium.maxPriorityFeePerGas).toBe(112612n) - const fast: any = gasPrice[2] - expect(fast.maxPriorityFeePerGas).toBe(126688n) - const ape: any = gasPrice[3] - expect(ape.maxPriorityFeePerGas).toBe(190032n) - provider.destroy() - }) - test('should remove outliers from a group of 19, making the group 15, and return an average for each speed at a step of 3 for slow, medium and fast, and an avg of the remaining 6 for ape', async () => { - const params = { - transactions: [ - { maxPriorityFeePerGas: 1n }, // removed as an outlier - { maxPriorityFeePerGas: 1n }, // removed as an outlier - { maxPriorityFeePerGas: 100200n }, - { maxPriorityFeePerGas: 100200n }, - { maxPriorityFeePerGas: 100200n }, - { maxPriorityFeePerGas: 100210n }, - { maxPriorityFeePerGas: 100210n }, - { maxPriorityFeePerGas: 100210n }, - { maxPriorityFeePerGas: 100210n }, - { maxPriorityFeePerGas: 100210n }, - { maxPriorityFeePerGas: 100210n }, - { maxPriorityFeePerGas: 100210n }, - { maxPriorityFeePerGas: 100220n }, - { maxPriorityFeePerGas: 100220n }, - { maxPriorityFeePerGas: 100220n }, - { maxPriorityFeePerGas: 100250n }, - { maxPriorityFeePerGas: 100250n }, - { maxPriorityFeePerGas: 10000000000n }, // removed as an outlier - { maxPriorityFeePerGas: 20000000000n } // removed as an outlier - ] - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const slow: any = gasPrice[0]! - expect(slow.maxPriorityFeePerGas).toBe(100200n) - const medium: any = gasPrice[1] - expect(medium.maxPriorityFeePerGas).toBe(112725n) - const fast: any = gasPrice[2] - expect(fast.maxPriorityFeePerGas).toBe(126815n) - const ape: any = gasPrice[3] - expect(ape.maxPriorityFeePerGas).toBe(190222n) - provider.destroy() - }) - test('should remove 0s from maxPriorityFeePerGas but should keep 1s because they are not outliers, and should calculate an average of every group of 4 for slow, medium and fast, and an average of the remaining 5 for ape', async () => { - const params = { - transactions: [ - { maxPriorityFeePerGas: 0n }, // removed because no 0s are allowed - { maxPriorityFeePerGas: 0n }, // removed because no 0s are allowed - { maxPriorityFeePerGas: 0n }, // removed because no 0s are allowed - { maxPriorityFeePerGas: 100201n }, - { maxPriorityFeePerGas: 100201n }, - { maxPriorityFeePerGas: 100240n }, - { maxPriorityFeePerGas: 100240n }, - { maxPriorityFeePerGas: 100245n }, - { maxPriorityFeePerGas: 100250n }, - { maxPriorityFeePerGas: 100250n }, - { maxPriorityFeePerGas: 100250n }, - { maxPriorityFeePerGas: 100255n }, - { maxPriorityFeePerGas: 100255n }, - { maxPriorityFeePerGas: 100255n }, - { maxPriorityFeePerGas: 100255n }, - { maxPriorityFeePerGas: 100270n }, - { maxPriorityFeePerGas: 100270n }, - { maxPriorityFeePerGas: 100272n }, - { maxPriorityFeePerGas: 100285n }, - { maxPriorityFeePerGas: 100285n }, - { maxPriorityFeePerGas: 10028500n }, // removed as an outlier - { maxPriorityFeePerGas: 10128500 } // removed as an outlier - ] - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const slow: any = gasPrice[0]! - expect(slow.maxPriorityFeePerGas).toBe(100220n) - const medium: any = gasPrice[1] - expect(medium.maxPriorityFeePerGas).toBe(112747n) - const fast: any = gasPrice[2] - expect(fast.maxPriorityFeePerGas).toBe(126840n) - const ape: any = gasPrice[3] - expect(ape.maxPriorityFeePerGas).toBe(190260n) + }) + + const gasPriceData = await getGasPriceRecommendations(provider, baseNetwork) + const gasPrice = gasPriceData.gasPrice as Gas1559Recommendation[] + + expect(getByName(gasPrice, 'slow').baseFeePerGas).toBe(ethers.parseUnits('2', 'gwei')) + expect(getByName(gasPrice, 'medium').baseFeePerGas).toBe(ethers.parseUnits('2.1', 'gwei')) + expect(getByName(gasPrice, 'fast').baseFeePerGas).toBe(ethers.parseUnits('2.2', 'gwei')) + expect(getByName(gasPrice, 'ape').baseFeePerGas).toBe(ethers.parseUnits('2.3', 'gwei')) provider.destroy() }) }) diff --git a/src/libs/gasPrice/tests/MockProvider.ts b/src/libs/gasPrice/tests/MockProvider.ts index 2eac09bea6..bb6d159959 100644 --- a/src/libs/gasPrice/tests/MockProvider.ts +++ b/src/libs/gasPrice/tests/MockProvider.ts @@ -14,6 +14,12 @@ import { abiCoder, addressOne, localhost } from '../../../../test/config' const gasLimit = 30000000n +const defaultMaxPriorityFeePerGas = 100000n + +function toRpcQuantity(value: bigint | number | string): string { + return toQuantity(BigInt(value)) +} + export default class MockProvider extends JsonRpcProvider { blockParams: any @@ -27,7 +33,7 @@ export default class MockProvider extends JsonRpcProvider { this.blockParams = blockParams } - static init(params: {}): MockProvider { + static init(params: Record): MockProvider { return new MockProvider(localhost, 1, {}, params) } @@ -57,7 +63,39 @@ export default class MockProvider extends JsonRpcProvider { } async send(method: string, params: any[]): Promise { - if (method === 'eth_gasPrice') return this.blockParams.ethGasPrice ?? '0x' + if (method === 'eth_gasPrice') return toRpcQuantity(this.blockParams.ethGasPrice ?? 1000000000n) + + if (method === 'eth_maxPriorityFeePerGas') { + if (this.blockParams.ethMaxPriorityFeePerGasError) + throw this.blockParams.ethMaxPriorityFeePerGasError + + return toRpcQuantity(this.blockParams.ethMaxPriorityFeePerGas ?? defaultMaxPriorityFeePerGas) + } + + if (method === 'eth_feeHistory') { + if (this.blockParams.feeHistoryError) throw this.blockParams.feeHistoryError + + const [blockCountHex, , rewardPercentiles] = params + const blockCount = Number(BigInt(blockCountHex)) + const feeHistory = this.blockParams.feeHistory ?? {} + const baseFeePerGas = + feeHistory.baseFeePerGas ?? + Array.from({ length: blockCount + 1 }, () => this.getBlockParams().baseFeePerGas) + const reward = + feeHistory.reward ?? + Array.from({ length: blockCount }, () => + rewardPercentiles.map( + () => this.blockParams.ethMaxPriorityFeePerGas ?? defaultMaxPriorityFeePerGas + ) + ) + + return { + oldestBlock: toQuantity(1), + baseFeePerGas: baseFeePerGas.map(toRpcQuantity), + gasUsedRatio: feeHistory.gasUsedRatio ?? Array.from({ length: blockCount }, () => 0.5), + reward: reward.map((rewards: bigint[]) => rewards.map(toRpcQuantity)) + } + } if (method === 'eth_getBlockByNumber') { const block = this.getBlockParams() diff --git a/src/libs/gasPrice/tests/non1559Network.test.ts b/src/libs/gasPrice/tests/non1559Network.test.ts index 835ec57f35..bc5e4c2ecd 100644 --- a/src/libs/gasPrice/tests/non1559Network.test.ts +++ b/src/libs/gasPrice/tests/non1559Network.test.ts @@ -1,132 +1,68 @@ +import { ethers } from 'ethers' + import { describe, expect, test } from '@jest/globals' import { suppressConsoleBeforeEach } from '../../../../test/helpers/console' import { networks } from '../../../consts/networks' -import { getGasPriceRecommendations, MIN_GAS_PRICE } from '../gasPrice' +import { GasPriceRecommendation, getGasPriceRecommendations, MIN_GAS_PRICE } from '../gasPrice' import MockProvider from './MockProvider' const network = networks.find((n) => n.chainId === 1n)! +const polygon = networks.find((n) => n.chainId === 137n)! +const legacyNetwork = { ...network, feeOptions: { is1559: false } } + +const getByName = (recommendations: GasPriceRecommendation[], name: string) => + recommendations.find((recommendation) => recommendation.name === name)! -describe('1559 Network gas price tests', () => { +describe('non-1559 Network gas price tests', () => { // Mock providers throw errors we can ignore suppressConsoleBeforeEach() - test('should NOT return 0n for gasPrice on an empty block as we have a minimum set', async () => { - const params = { - baseFeePerGas: null, - transactions: [] - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const slow: any = gasPrice[0]! - expect(slow.gasPrice).toBe(MIN_GAS_PRICE) - const medium: any = gasPrice[1] - expect(medium.gasPrice).toBe(MIN_GAS_PRICE) - const fast: any = gasPrice[2] - expect(fast.gasPrice).toBe(MIN_GAS_PRICE) - const ape: any = gasPrice[3] - expect(ape.gasPrice).toBe(MIN_GAS_PRICE) - provider.destroy() - }) - test('should return the lowest maxPriorityFeePerGas for a block with less than 4 txns', async () => { - const params = { + + test('should use viem legacy gas price estimation for speed recommendations', async () => { + const provider = MockProvider.init({ baseFeePerGas: null, - transactions: [ - { gasPrice: MIN_GAS_PRICE + 800n }, // this gets disregarded - { gasPrice: MIN_GAS_PRICE + 500n }, // this gets disregarded - { gasPrice: MIN_GAS_PRICE + 100n } - ] - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const slow: any = gasPrice[0]! - expect(slow.gasPrice).toBe(MIN_GAS_PRICE + 100n) - const medium: any = gasPrice[1] - expect(medium.gasPrice).toBe(MIN_GAS_PRICE + 100n) - const fast: any = gasPrice[2] - expect(fast.gasPrice).toBe(MIN_GAS_PRICE + 100n) - const ape: any = gasPrice[3] - expect(ape.gasPrice).toBe(MIN_GAS_PRICE + 100n) + ethGasPrice: ethers.parseUnits('2', 'gwei') + }) + + const gasPriceData = await getGasPriceRecommendations(provider, legacyNetwork) + const gasPrice = gasPriceData.gasPrice as GasPriceRecommendation[] + + expect(getByName(gasPrice, 'slow').gasPrice).toBe(ethers.parseUnits('2.4', 'gwei')) + expect(getByName(gasPrice, 'medium').gasPrice).toBe(ethers.parseUnits('2.52', 'gwei')) + expect(getByName(gasPrice, 'fast').gasPrice).toBe(ethers.parseUnits('2.64', 'gwei')) + expect(getByName(gasPrice, 'ape').gasPrice).toBe(ethers.parseUnits('2.76', 'gwei')) provider.destroy() }) - test('should remove outliers from a group of 19, making the group 15, and return an average for each speed at a step of 3 for slow, medium and fast, and an avg of the remaining 6 for ape', async () => { - const params = { + + test('should not return a gas price below the minimum', async () => { + const provider = MockProvider.init({ baseFeePerGas: null, - transactions: [ - { gasPrice: MIN_GAS_PRICE + 1n }, // removed as an outlier - { gasPrice: MIN_GAS_PRICE + 1n }, // removed as an outlier - { gasPrice: MIN_GAS_PRICE + 100n }, - { gasPrice: MIN_GAS_PRICE + 100n }, - { gasPrice: MIN_GAS_PRICE + 100n }, - { gasPrice: MIN_GAS_PRICE + 110n }, - { gasPrice: MIN_GAS_PRICE + 110n }, - { gasPrice: MIN_GAS_PRICE + 110n }, - { gasPrice: MIN_GAS_PRICE + 110n }, - { gasPrice: MIN_GAS_PRICE + 110n }, - { gasPrice: MIN_GAS_PRICE + 110n }, - { gasPrice: MIN_GAS_PRICE + 110n }, - { gasPrice: MIN_GAS_PRICE + 120n }, - { gasPrice: MIN_GAS_PRICE + 120n }, - { gasPrice: MIN_GAS_PRICE + 120n }, - { gasPrice: MIN_GAS_PRICE + 150n }, - { gasPrice: MIN_GAS_PRICE + 150n }, - { gasPrice: MIN_GAS_PRICE + 10000n }, // removed as an outlier - { gasPrice: MIN_GAS_PRICE + 20000n } // removed as an outlier - ] - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const slow: any = gasPrice[0]! - expect(slow.gasPrice).toBe(MIN_GAS_PRICE + 100n) - const medium: any = gasPrice[1] - expect(medium.gasPrice).toBe(MIN_GAS_PRICE + 110n) - const fast: any = gasPrice[2] - expect(fast.gasPrice).toBe(MIN_GAS_PRICE + 110n) - const ape: any = gasPrice[3] - expect(ape.gasPrice).toBe(MIN_GAS_PRICE + 128n) + ethGasPrice: 100n + }) + + const gasPriceData = await getGasPriceRecommendations(provider, legacyNetwork) + const gasPrice = gasPriceData.gasPrice as GasPriceRecommendation[] + + expect(getByName(gasPrice, 'slow').gasPrice).toBe(MIN_GAS_PRICE) + expect(getByName(gasPrice, 'medium').gasPrice).toBe(1050000000n) + expect(getByName(gasPrice, 'fast').gasPrice).toBe(1100000000n) + expect(getByName(gasPrice, 'ape').gasPrice).toBe(1150000000n) provider.destroy() }) - test('should remove 0s from gasPrice but should keep 1s because they are not outliers, and should calculate an average of every group of 4 for slow, medium and fast, and an average of the remaining 5 for ape', async () => { - const params = { + + test('should apply the network fee increase before speed multipliers', async () => { + const provider = MockProvider.init({ baseFeePerGas: null, - transactions: [ - { gasPrice: 0n }, // removed because no 0s are allowed - { gasPrice: 0n }, // removed because no 0s are allowed - { gasPrice: 0n }, // removed because no 0s are allowed - { gasPrice: MIN_GAS_PRICE + 1n }, - { gasPrice: MIN_GAS_PRICE + 1n }, - { gasPrice: MIN_GAS_PRICE + 40n }, - { gasPrice: MIN_GAS_PRICE + 40n }, - { gasPrice: MIN_GAS_PRICE + 45n }, - { gasPrice: MIN_GAS_PRICE + 50n }, - { gasPrice: MIN_GAS_PRICE + 50n }, - { gasPrice: MIN_GAS_PRICE + 50n }, - { gasPrice: MIN_GAS_PRICE + 55n }, - { gasPrice: MIN_GAS_PRICE + 55n }, - { gasPrice: MIN_GAS_PRICE + 55n }, - { gasPrice: MIN_GAS_PRICE + 55n }, - { gasPrice: MIN_GAS_PRICE + 70n }, - { gasPrice: MIN_GAS_PRICE + 70n }, - { gasPrice: MIN_GAS_PRICE + 72n }, - { gasPrice: MIN_GAS_PRICE + 85n }, - { gasPrice: MIN_GAS_PRICE + 85n }, - { gasPrice: MIN_GAS_PRICE + 500n }, // removed as an outlier - { gasPrice: MIN_GAS_PRICE + 500n } // removed as an outlier - ] - } - const provider = MockProvider.init(params) - const gasPriceData = await getGasPriceRecommendations(provider, network) - const gasPrice = gasPriceData.gasPrice - const slow: any = gasPrice[0]! - expect(slow.gasPrice).toBe(MIN_GAS_PRICE + 20n) - const medium: any = gasPrice[1] - expect(medium.gasPrice).toBe(MIN_GAS_PRICE + 48n) - const fast: any = gasPrice[2] - expect(fast.gasPrice).toBe(MIN_GAS_PRICE + 55n) - const ape: any = gasPrice[3] - expect(ape.gasPrice).toBe(MIN_GAS_PRICE + 76n) + ethGasPrice: ethers.parseUnits('2', 'gwei') + }) + + const gasPriceData = await getGasPriceRecommendations(provider, polygon) + const gasPrice = gasPriceData.gasPrice as GasPriceRecommendation[] + + expect(getByName(gasPrice, 'slow').gasPrice).toBe(ethers.parseUnits('2.64', 'gwei')) + expect(getByName(gasPrice, 'medium').gasPrice).toBe(ethers.parseUnits('2.772', 'gwei')) + expect(getByName(gasPrice, 'fast').gasPrice).toBe(ethers.parseUnits('2.904', 'gwei')) + expect(getByName(gasPrice, 'ape').gasPrice).toBe(ethers.parseUnits('3.036', 'gwei')) provider.destroy() }) }) From b1fe08d141a91e1b23c3e028931c9fd6f1adc8ec Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Fri, 10 Jul 2026 11:48:50 +0900 Subject: [PATCH 2/5] add: erc4337 as an opt out option --- src/consts/featureFlags.ts | 2 + src/controllers/estimation/estimation.ts | 14 +++- src/controllers/gasPrice/gasPrice.test.ts | 84 +++++++++++++++++++ src/controllers/gasPrice/gasPrice.ts | 16 +++- src/controllers/main/main.ts | 3 + src/controllers/requests/requests.test.ts | 1 + src/controllers/requests/requests.ts | 7 ++ .../signAccountOp/signAccountOp.test.ts | 4 +- .../signAccountOp/signAccountOp.ts | 77 +++++++++++++++-- .../signAccountOp/signAccountOpTester.ts | 2 + .../swapAndBridge/swapAndBridge.test.ts | 3 + .../swapAndBridge/swapAndBridge.ts | 7 ++ src/controllers/transfer/transfer.ts | 6 ++ src/libs/account/BaseAccount.ts | 14 +++- src/libs/account/EOA7702.ts | 6 +- src/libs/account/Safe.ts | 6 +- src/libs/account/V2.ts | 10 ++- src/libs/account/customGasPrices.test.ts | 33 +++++++- src/libs/account/getBaseAccount.ts | 9 +- 19 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 src/controllers/gasPrice/gasPrice.test.ts diff --git a/src/consts/featureFlags.ts b/src/consts/featureFlags.ts index 4f11544be5..854c72983a 100644 --- a/src/consts/featureFlags.ts +++ b/src/consts/featureFlags.ts @@ -5,6 +5,7 @@ export interface FeatureFlags { testnetMode: boolean tokenAndDefiAutoDiscovery: boolean apiForFunctionSelectors: boolean + erc4337: boolean /** * Off by default for privacy: passively bulk-resolving ENS/Namoshi for all * accounts links them together. When enabled, the wallet keeps every account's @@ -20,5 +21,6 @@ export const defaultFeatureFlags: FeatureFlags = { testnetMode: false, tokenAndDefiAutoDiscovery: true, apiForFunctionSelectors: true, + erc4337: true, keepEnsProfilesUpToDate: false } diff --git a/src/controllers/estimation/estimation.ts b/src/controllers/estimation/estimation.ts index 38ecc60822..63afb7fc23 100644 --- a/src/controllers/estimation/estimation.ts +++ b/src/controllers/estimation/estimation.ts @@ -2,6 +2,7 @@ import ErrorHumanizerError from '../../classes/ErrorHumanizerError' import { Account, IAccountsController } from '../../interfaces/account' import { IActivityController } from '../../interfaces/activity' import { ErrorRef } from '../../interfaces/eventEmitter' +import { IFeatureFlagsController } from '../../interfaces/featureFlags' import { IKeystoreController } from '../../interfaces/keystore' import { INetworksController } from '../../interfaces/network' import { IPortfolioController } from '../../interfaces/portfolio' @@ -30,6 +31,8 @@ export class EstimationController extends EventEmitter { #portfolio: IPortfolioController + #featureFlags: IFeatureFlagsController + status: EstimationStatus = EstimationStatus.Initial estimation: FullEstimationSummary | null = null @@ -65,7 +68,8 @@ export class EstimationController extends EventEmitter { provider: RPCProvider, portfolio: IPortfolioController, bundlerSwitcher: BundlerSwitcher, - activity: IActivityController + activity: IActivityController, + featureFlags: IFeatureFlagsController ) { super() this.#keystore = keystore @@ -73,6 +77,7 @@ export class EstimationController extends EventEmitter { this.#networks = networks this.#provider = provider this.#portfolio = portfolio + this.#featureFlags = featureFlags this.#bundlerSwitcher = bundlerSwitcher this.#activity = activity } @@ -112,7 +117,12 @@ export class EstimationController extends EventEmitter { return } - const baseAcc = getBaseAccount(account, accountState, network) + const baseAcc = getBaseAccount( + account, + accountState, + network, + this.#featureFlags.isFeatureEnabled('erc4337') + ) // Take the fee tokens from two places: the user's tokens and his gasTank // The gasTank tokens participate on each network as they belong everywhere diff --git a/src/controllers/gasPrice/gasPrice.test.ts b/src/controllers/gasPrice/gasPrice.test.ts new file mode 100644 index 0000000000..c7885976f7 --- /dev/null +++ b/src/controllers/gasPrice/gasPrice.test.ts @@ -0,0 +1,84 @@ +import { jest } from '@jest/globals' + +import { Network } from '../../interfaces/network' +import { RPCProvider } from '../../interfaces/provider' +import { BaseAccount } from '../../libs/account/BaseAccount' +import { gasPriceToBundlerFormat, getGasPriceRecommendations } from '../../libs/gasPrice/gasPrice' +import { getAvailableBunlders } from '../../services/bundlers/getBundler' +import { GasPriceController } from './gasPrice' + +jest.mock('../../services/bundlers/getBundler', () => ({ + getAvailableBunlders: jest.fn() +})) + +jest.mock('../../libs/gasPrice/gasPrice', () => ({ + getGasPriceRecommendations: jest.fn(), + gasPriceToBundlerFormat: jest.fn() +})) + +const getAvailableBunldersMock = getAvailableBunlders as jest.Mock +const getGasPriceRecommendationsMock = getGasPriceRecommendations as jest.Mock +const gasPriceToBundlerFormatMock = gasPriceToBundlerFormat as jest.Mock + +const network = { chainId: 1n, name: 'Ethereum' } as Network +const provider = {} as RPCProvider +const baseAccount = { + supportsBundlerEstimation: () => false +} as BaseAccount + +const getSignAccountOpState = () => + ({ + estimation: null, + readyToSign: false, + stopRefetching: false + }) as any + +describe('GasPriceController', () => { + beforeEach(() => { + getAvailableBunldersMock.mockReset() + getGasPriceRecommendationsMock.mockReset() + gasPriceToBundlerFormatMock.mockReset() + gasPriceToBundlerFormatMock.mockReturnValue({}) + getGasPriceRecommendationsMock.mockResolvedValue({ gasPrice: {} } as never) + }) + + test('does not call bundlers when ERC-4337 is disabled', async () => { + const fetchGasPrices = jest.fn() + getAvailableBunldersMock.mockReturnValue([{ fetchGasPrices }]) + + const controller = new GasPriceController( + network, + provider, + baseAccount, + getSignAccountOpState, + () => false + ) + + await controller.fetch() + + expect(fetchGasPrices).not.toHaveBeenCalled() + expect(getGasPriceRecommendationsMock).toHaveBeenCalled() + }) + + test('calls bundlers when ERC-4337 is enabled and the account needs bundler gas prices', async () => { + const gasPrices = { + slow: { maxFeePerGas: '0x1', maxPriorityFeePerGas: '0x1' } + } + const fetchGasPrices = jest.fn().mockResolvedValue(gasPrices as never) + getAvailableBunldersMock.mockReturnValue([{ fetchGasPrices }]) + + const controller = new GasPriceController( + network, + provider, + baseAccount, + getSignAccountOpState, + () => true + ) + + await controller.fetch() + + expect(fetchGasPrices).toHaveBeenCalledWith(network) + expect(getGasPriceRecommendationsMock).not.toHaveBeenCalled() + expect(controller.gasPrices).toBe(gasPrices) + }) +}) diff --git a/src/controllers/gasPrice/gasPrice.ts b/src/controllers/gasPrice/gasPrice.ts index fea0c2df0e..305ff71bec 100644 --- a/src/controllers/gasPrice/gasPrice.ts +++ b/src/controllers/gasPrice/gasPrice.ts @@ -17,6 +17,8 @@ export class GasPriceController extends EventEmitter { #baseAccount: BaseAccount + #getIsErc4337Enabled: () => boolean + #getSignAccountOpState: () => { estimation: EstimationController readyToSign: boolean @@ -50,15 +52,21 @@ export class GasPriceController extends EventEmitter { estimation: EstimationController readyToSign: boolean stopRefetching: boolean - } + }, + getIsErc4337Enabled: () => boolean = () => true ) { super() this.#network = network this.#provider = provider this.#baseAccount = baseAccount + this.#getIsErc4337Enabled = getIsErc4337Enabled this.#getSignAccountOpState = getSignAccountOpState } + setBaseAccount(baseAccount: BaseAccount) { + this.#baseAccount = baseAccount + } + async fetch(emitLevelOnFailure: ErrorRef['level'] = 'silent') { if (this.areGasPricesUsedFromBundlerEstimation) return @@ -68,7 +76,11 @@ export class GasPriceController extends EventEmitter { // estimate, it would fetch the gas price from the bundler estimation itself, // therefore not being required here const availableBundlers = getAvailableBunlders(this.#network) - if (availableBundlers.length && !this.#baseAccount.supportsBundlerEstimation()) { + if ( + this.#getIsErc4337Enabled() && + availableBundlers.length && + !this.#baseAccount.supportsBundlerEstimation() + ) { let timeoutId const bundlerGasPrices = await Promise.race([ // Promise.any because we want the first success, ignoring errors diff --git a/src/controllers/main/main.ts b/src/controllers/main/main.ts index e5e1ee35db..28bb125d16 100644 --- a/src/controllers/main/main.ts +++ b/src/controllers/main/main.ts @@ -514,6 +514,7 @@ export class MainController extends EventEmitter implements IMainController { activity: this.activity, storage: this.storage, signAccountOpPreference: this.signAccountOpPreference, + featureFlags: this.featureFlags, phishing: this.phishing, dapps: this.dapps, swapProvider: new SwapProviderParallelExecutor( @@ -557,6 +558,7 @@ export class MainController extends EventEmitter implements IMainController { this.callRelayer, this.storage, this.signAccountOpPreference, + this.featureFlags, humanizerInfo as HumanizerMeta, this.selectedAccount, this.networks, @@ -621,6 +623,7 @@ export class MainController extends EventEmitter implements IMainController { networks: this.networks, providers: this.providers, storage: this.storage, + featureFlags: this.featureFlags, signAccountOpPreference: this.signAccountOpPreference, selectedAccount: this.selectedAccount, keystore: this.keystore, diff --git a/src/controllers/requests/requests.test.ts b/src/controllers/requests/requests.test.ts index a5e05aaecb..23d6e19879 100644 --- a/src/controllers/requests/requests.test.ts +++ b/src/controllers/requests/requests.test.ts @@ -127,6 +127,7 @@ const prepareTest = async (seedTestDapp = false) => { networks: mainCtrl.networks, keystore: mainCtrl.keystore, portfolio: mainCtrl.portfolio, + featureFlags: mainCtrl.featureFlags, signAccountOpPreference: mainCtrl.signAccountOpPreference, externalSignerControllers: {}, activity: mainCtrl.activity, diff --git a/src/controllers/requests/requests.ts b/src/controllers/requests/requests.ts index 52fc841627..b898626fec 100644 --- a/src/controllers/requests/requests.ts +++ b/src/controllers/requests/requests.ts @@ -14,6 +14,7 @@ import { AutoLoginStatus, IAutoLoginController } from '../../interfaces/autoLogi import { Banner } from '../../interfaces/banner' import { Dapp, DappProviderRequest, IDappsController } from '../../interfaces/dapp' import { IEventEmitterRegistryController, Statuses } from '../../interfaces/eventEmitter' +import { IFeatureFlagsController } from '../../interfaces/featureFlags' import { Hex } from '../../interfaces/hex' import { ExternalSignerController, IKeystoreController } from '../../interfaces/keystore' import { INetworksController, Network } from '../../interfaces/network' @@ -112,6 +113,8 @@ export class RequestsController extends EventEmitter implements IRequestsControl #portfolio: IPortfolioController + #featureFlags: IFeatureFlagsController + #externalSignerControllers: Partial<{ internal: ExternalSignerController trezor: ExternalSignerController @@ -210,6 +213,7 @@ export class RequestsController extends EventEmitter implements IRequestsControl relayerUrl, callRelayer, portfolio, + featureFlags, externalSignerControllers, activity, phishing, @@ -239,6 +243,7 @@ export class RequestsController extends EventEmitter implements IRequestsControl relayerUrl: string callRelayer: BindedRelayerCall portfolio: IPortfolioController + featureFlags: IFeatureFlagsController externalSignerControllers: Partial<{ internal: ExternalSignerController trezor: ExternalSignerController @@ -275,6 +280,7 @@ export class RequestsController extends EventEmitter implements IRequestsControl this.#relayerUrl = relayerUrl this.#callRelayer = callRelayer this.#portfolio = portfolio + this.#featureFlags = featureFlags this.#externalSignerControllers = externalSignerControllers this.#activity = activity this.#phishing = phishing @@ -1907,6 +1913,7 @@ export class RequestsController extends EventEmitter implements IRequestsControl networks: this.#networks, keystore: this.#keystore, portfolio: this.#portfolio, + featureFlags: this.#featureFlags, signAccountOpPreference: this.#signAccountOpPreference, externalSignerControllers: this.#externalSignerControllers, activity: this.#activity, diff --git a/src/controllers/signAccountOp/signAccountOp.test.ts b/src/controllers/signAccountOp/signAccountOp.test.ts index f32a12354e..1c823dbd3f 100644 --- a/src/controllers/signAccountOp/signAccountOp.test.ts +++ b/src/controllers/signAccountOp/signAccountOp.test.ts @@ -612,7 +612,8 @@ const init = async ( provider, portfolio, bundlerSwitcher, - activity + activity, + featureFlagsCtrl ) estimationController.estimation = estimationOrMock estimationController.hasEstimated = true @@ -664,6 +665,7 @@ const init = async ( networks: networksCtrl, keystore, portfolio, + featureFlags: featureFlagsCtrl, signAccountOpPreference, externalSignerControllers: {}, account, diff --git a/src/controllers/signAccountOp/signAccountOp.ts b/src/controllers/signAccountOp/signAccountOp.ts index abada9e701..9764bacc1f 100644 --- a/src/controllers/signAccountOp/signAccountOp.ts +++ b/src/controllers/signAccountOp/signAccountOp.ts @@ -41,6 +41,7 @@ import { IActivityController } from '../../interfaces/activity' import { Price } from '../../interfaces/assets' import { DAPP_VERIFICATION_BANNER_IDS, IDappsController } from '../../interfaces/dapp' import { ErrorRef, IEventEmitterRegistryController } from '../../interfaces/eventEmitter' +import { IFeatureFlagsController } from '../../interfaces/featureFlags' import { Hex } from '../../interfaces/hex' import { ExternalKey, @@ -250,6 +251,8 @@ export class SignAccountOpController #portfolio: IPortfolioController + #featureFlags: IFeatureFlagsController + #signAccountOpPreference: SignAccountOpPreferenceController #externalSignerControllers: ExternalSignerControllers @@ -420,6 +423,7 @@ export class SignAccountOpController networks, keystore, portfolio, + featureFlags, signAccountOpPreference, externalSignerControllers, account, @@ -442,6 +446,7 @@ export class SignAccountOpController networks: INetworksController keystore: IKeystoreController portfolio: IPortfolioController + featureFlags: IFeatureFlagsController signAccountOpPreference: SignAccountOpPreferenceController externalSignerControllers: ExternalSignerControllers account: Account @@ -463,12 +468,18 @@ export class SignAccountOpController this.#accounts = accounts this.#keystore = keystore this.#portfolio = portfolio + this.#featureFlags = featureFlags this.#signAccountOpPreference = signAccountOpPreference this.feeTokenPreference = this.#signAccountOpPreference.feeTokenPreference this.#externalSignerControllers = externalSignerControllers this.account = account const accountState = accounts.accountStates[account.addr]![network.chainId.toString()]! // ! is safe as otherwise, nothing will work - this.baseAccount = getBaseAccount(account, accountState, network) + this.baseAccount = getBaseAccount( + account, + accountState, + network, + this.#featureFlags.isFeatureEnabled('erc4337') + ) this.#network = network this.#activity = activity this.#dapps = dapps @@ -511,14 +522,21 @@ export class SignAccountOpController provider, portfolio, this.bundlerSwitcher, - this.#activity + this.#activity, + this.#featureFlags ) this.#onUpdateAfterTraceCallSuccess = onUpdateAfterTraceCallSuccess - this.gasPrice = new GasPriceController(network, provider, this.baseAccount, () => ({ - estimation: this.estimation, - readyToSign: this.readyToSign, - stopRefetching: this.#stopRefetching - })) + this.gasPrice = new GasPriceController( + network, + provider, + this.baseAccount, + () => ({ + estimation: this.estimation, + readyToSign: this.readyToSign, + stopRefetching: this.#stopRefetching + }), + () => this.#featureFlags.isFeatureEnabled('erc4337') + ) this.#shouldSimulate = shouldSimulate this.#onBroadcastSuccess = onBroadcastSuccess @@ -572,6 +590,30 @@ export class SignAccountOpController this.#updateSafeEip712Data() } + #rebuildBaseAccount() { + const accountState = + this.#accounts.accountStates[this.account.addr]?.[this.#network.chainId.toString()] + + if (!accountState) return + + this.baseAccount = getBaseAccount( + this.account, + accountState, + this.#network, + this.#featureFlags.isFeatureEnabled('erc4337') + ) + this.gasPrice?.setBaseAccount(this.baseAccount) + } + + #clearFeeSelection() { + this.#paidBy = null + this.feeTokenResult = null + this.selectedOption = undefined + this.selectedFeeSpeed = FeeSpeed.Fast + this.feeSpeeds = {} + this.#updateAccountOp({ gasFeePayment: null }) + } + #getSafeSigningData(accountState: AccountOnchainState) { const safeTxn = getSafeTxn(this.accountOp, accountState) const typedData = (this.baseAccount as Safe).getTxnTypedData(safeTxn) @@ -1514,6 +1556,17 @@ export class SignAccountOpController this.#simulateAndEstimateOrSimulateInterval.restart({ runImmediately: true }) } + async enableErc4337AndReestimate() { + await this.#featureFlags.setFeatureFlag('erc4337', true) + this.#rebuildBaseAccount() + this.#clearFeeSelection() + this.bundlerSwitcher.cleanUp() + this.gasPrice.areGasPricesUsedFromBundlerEstimation = false + + await this.estimation.estimate(this.accountOp) + this.update({ hasNewEstimation: true }) + } + update({ gasPrices, customGasPrices, @@ -3989,6 +4042,14 @@ export class SignAccountOpController return this.baseAccount.canSetCustomGas(this.selectedOption, this.accountOp) } + get isErc4337Enabled(): boolean { + return this.#featureFlags.isFeatureEnabled('erc4337') + } + + get canEnableErc4337(): boolean { + return !this.isErc4337Enabled && this.baseAccount.canUseErc4337() + } + get threshold(): number { const accountState = this.#accounts.accountStates[this.account.addr]![this.#network.chainId.toString()] @@ -4032,6 +4093,8 @@ export class SignAccountOpController canAccountBroadcastByItself: this.canAccountBroadcastByItself, canSetCustomGasPrices: this.canSetCustomGasPrices, canSetCustomGas: this.canSetCustomGas, + isErc4337Enabled: this.isErc4337Enabled, + canEnableErc4337: this.canEnableErc4337, threshold: this.threshold, canBroadcast: this.canBroadcast, hasSafeApiFailed: this.hasSafeApiFailed, diff --git a/src/controllers/signAccountOp/signAccountOpTester.ts b/src/controllers/signAccountOp/signAccountOpTester.ts index 0e8bebdf9e..5923658a5b 100644 --- a/src/controllers/signAccountOp/signAccountOpTester.ts +++ b/src/controllers/signAccountOp/signAccountOpTester.ts @@ -1,6 +1,7 @@ import { Account, IAccountsController } from '../../interfaces/account' import { IActivityController } from '../../interfaces/activity' import { IDappsController } from '../../interfaces/dapp' +import { IFeatureFlagsController } from '../../interfaces/featureFlags' import { ExternalSignerControllers, IKeystoreController } from '../../interfaces/keystore' import { INetworksController, Network } from '../../interfaces/network' import { IPhishingController } from '../../interfaces/phishing' @@ -23,6 +24,7 @@ export class SignAccountOpTesterController extends SignAccountOpController { networks: INetworksController keystore: IKeystoreController portfolio: IPortfolioController + featureFlags: IFeatureFlagsController signAccountOpPreference: SignAccountOpPreferenceController externalSignerControllers: ExternalSignerControllers account: Account diff --git a/src/controllers/swapAndBridge/swapAndBridge.test.ts b/src/controllers/swapAndBridge/swapAndBridge.test.ts index 7372e99239..8fcebc3845 100644 --- a/src/controllers/swapAndBridge/swapAndBridge.test.ts +++ b/src/controllers/swapAndBridge/swapAndBridge.test.ts @@ -317,6 +317,7 @@ const swapAndBridgeController = new SwapAndBridgeController({ activity: activityCtrl, storage: storageCtrl, signAccountOpPreference, + featureFlags: featureFlagsCtrl, swapProvider: socketAPIMock as any, keystore, portfolio: portfolioCtrl, @@ -336,6 +337,7 @@ const transferCtrl = new TransferController( async () => ({}), storageCtrl, signAccountOpPreference, + featureFlagsCtrl, humanizerInfo as HumanizerMeta, selectedAccountCtrl, networksCtrl, @@ -365,6 +367,7 @@ requestsCtrl = new RequestsController({ networks: networksCtrl, providers: providersCtrl, storage: storageCtrl, + featureFlags: featureFlagsCtrl, signAccountOpPreference, selectedAccount: selectedAccountCtrl, keystore, diff --git a/src/controllers/swapAndBridge/swapAndBridge.ts b/src/controllers/swapAndBridge/swapAndBridge.ts index 42fe8ed67b..06c1f754f6 100644 --- a/src/controllers/swapAndBridge/swapAndBridge.ts +++ b/src/controllers/swapAndBridge/swapAndBridge.ts @@ -14,6 +14,7 @@ import { IAccountsController } from '../../interfaces/account' import { IActivityController } from '../../interfaces/activity' import { IDappsController } from '../../interfaces/dapp' import { IEventEmitterRegistryController, Statuses } from '../../interfaces/eventEmitter' +import { IFeatureFlagsController } from '../../interfaces/featureFlags' import { ExternalSignerControllers, IKeystoreController } from '../../interfaces/keystore' import { INetworksController, Network } from '../../interfaces/network' import { IPhishingController } from '../../interfaces/phishing' @@ -275,6 +276,8 @@ export class SwapAndBridgeController extends EventEmitter implements ISwapAndBri #signAccountOpPreference: SignAccountOpPreferenceController + #featureFlags: IFeatureFlagsController + #serviceProviderAPI: SwapProvider #activeRoutes: SwapAndBridgeActiveRoute[] = [] @@ -425,6 +428,7 @@ export class SwapAndBridgeController extends EventEmitter implements ISwapAndBri activity, storage, signAccountOpPreference, + featureFlags, phishing, dapps, portfolioUpdate, @@ -449,6 +453,7 @@ export class SwapAndBridgeController extends EventEmitter implements ISwapAndBri activity: IActivityController storage: IStorageController signAccountOpPreference: SignAccountOpPreferenceController + featureFlags: IFeatureFlagsController phishing: IPhishingController dapps: IDappsController relayerUrl: string @@ -478,6 +483,7 @@ export class SwapAndBridgeController extends EventEmitter implements ISwapAndBri this.#serviceProviderAPI = swapProvider this.#storage = storage this.#signAccountOpPreference = signAccountOpPreference + this.#featureFlags = featureFlags this.#phishing = phishing this.#dapps = dapps this.#relayerUrl = relayerUrl @@ -2714,6 +2720,7 @@ export class SwapAndBridgeController extends EventEmitter implements ISwapAndBri networks: this.#networks, keystore: this.#keystore, portfolio: this.#portfolio, + featureFlags: this.#featureFlags, signAccountOpPreference: this.#signAccountOpPreference, externalSignerControllers: this.#externalSignerControllers, activity: this.#activity, diff --git a/src/controllers/transfer/transfer.ts b/src/controllers/transfer/transfer.ts index c8509bb336..6b890b324f 100644 --- a/src/controllers/transfer/transfer.ts +++ b/src/controllers/transfer/transfer.ts @@ -9,6 +9,7 @@ import { IAddressBookController } from '../../interfaces/addressBook' import { IDappsController } from '../../interfaces/dapp' import { AddressState } from '../../interfaces/domains' import { IEventEmitterRegistryController } from '../../interfaces/eventEmitter' +import { IFeatureFlagsController } from '../../interfaces/featureFlags' import { ExternalSignerControllers, IKeystoreController } from '../../interfaces/keystore' import { INetworksController } from '../../interfaces/network' import { IPhishingController } from '../../interfaces/phishing' @@ -95,6 +96,8 @@ export class TransferController extends EventEmitter implements ITransferControl #signAccountOpPreference: SignAccountOpPreferenceController + #featureFlags: IFeatureFlagsController + #networks: INetworksController #addressBook: IAddressBookController @@ -203,6 +206,7 @@ export class TransferController extends EventEmitter implements ITransferControl callRelayer: BindedRelayerCall, storage: IStorageController, signAccountOpPreference: SignAccountOpPreferenceController, + featureFlags: IFeatureFlagsController, humanizerInfo: HumanizerMeta, selectedAccount: ISelectedAccountController, networks: INetworksController, @@ -225,6 +229,7 @@ export class TransferController extends EventEmitter implements ITransferControl this.#callRelayer = callRelayer this.#storage = storage this.#signAccountOpPreference = signAccountOpPreference + this.#featureFlags = featureFlags this.#humanizerInfo = humanizerInfo this.#selectedAccount = selectedAccount this.#networks = networks @@ -1144,6 +1149,7 @@ export class TransferController extends EventEmitter implements ITransferControl networks: this.#networks, keystore: this.#keystore, portfolio: this.#portfolio, + featureFlags: this.#featureFlags, signAccountOpPreference: this.#signAccountOpPreference, externalSignerControllers: this.#externalSignerControllers, activity: this.#activity, diff --git a/src/libs/account/BaseAccount.ts b/src/libs/account/BaseAccount.ts index 1c7267ffa4..c9a5248aaa 100644 --- a/src/libs/account/BaseAccount.ts +++ b/src/libs/account/BaseAccount.ts @@ -21,10 +21,18 @@ export abstract class BaseAccount { protected accountState: AccountOnchainState - constructor(account: Account, network: Network, accountState: AccountOnchainState) { + protected isErc4337Enabled: boolean + + constructor( + account: Account, + network: Network, + accountState: AccountOnchainState, + isErc4337Enabled = true + ) { this.account = account this.network = network this.accountState = accountState + this.isErc4337Enabled = isErc4337Enabled } getAccount() { @@ -124,6 +132,10 @@ export abstract class BaseAccount { return false } + canUseErc4337(): boolean { + return false + } + /** * Do we allow the account to broadcast by itself */ diff --git a/src/libs/account/EOA7702.ts b/src/libs/account/EOA7702.ts index c436b72bab..abcc9946e4 100644 --- a/src/libs/account/EOA7702.ts +++ b/src/libs/account/EOA7702.ts @@ -59,7 +59,7 @@ export class EOA7702 extends BaseAccount { } supportsBundlerEstimation() { - return true + return this.isErc4337Enabled } /* @@ -197,6 +197,10 @@ export class EOA7702 extends BaseAccount { return this.network.chainId === 100n } + canUseErc4337(): boolean { + return true + } + getAtomicStatus(): 'unsupported' | 'supported' | 'ready' { // always supported to make dapps recognize it correctly return 'supported' diff --git a/src/libs/account/Safe.ts b/src/libs/account/Safe.ts index f111b3921c..2c39f82974 100644 --- a/src/libs/account/Safe.ts +++ b/src/libs/account/Safe.ts @@ -64,13 +64,17 @@ export class Safe extends BaseAccount { } supportsBundlerEstimation() { - return true + return this.isErc4337Enabled } isSponsorable() { return false } + canUseErc4337(): boolean { + return true + } + getAvailableFeeOptions( estimation: FullEstimationSummary, feePaymentOptions: FeePaymentOption[] diff --git a/src/libs/account/V2.ts b/src/libs/account/V2.ts index c9a70f3124..11d38946b4 100644 --- a/src/libs/account/V2.ts +++ b/src/libs/account/V2.ts @@ -45,7 +45,7 @@ export class V2 extends BaseAccount { } supportsBundlerEstimation() { - return !this.#isTransitioningTo4337() + return this.isErc4337Enabled && !this.#isTransitioningTo4337() } getAvailableFeeOptions( @@ -53,6 +53,10 @@ export class V2 extends BaseAccount { feePaymentOptions: FeePaymentOption[], op: AccountOp ): FeePaymentOption[] { + if (!this.isErc4337Enabled) { + return feePaymentOptions.filter((opt) => opt.paidBy !== this.account.addr) + } + const hasPaymaster = estimation.bundlerEstimation && estimation.bundlerEstimation.paymaster.isUsable() @@ -170,6 +174,10 @@ export class V2 extends BaseAccount { return this.network.chainId === 100n } + canUseErc4337(): boolean { + return true + } + getAtomicStatus(): 'unsupported' | 'supported' | 'ready' { return 'supported' } diff --git a/src/libs/account/customGasPrices.test.ts b/src/libs/account/customGasPrices.test.ts index 7be094e340..565fa4a712 100644 --- a/src/libs/account/customGasPrices.test.ts +++ b/src/libs/account/customGasPrices.test.ts @@ -58,6 +58,15 @@ const eoaNativeFeeOption = { paidBy: '0x2222222222222222222222222222222222222222' } as FeePaymentOption +const eoaTokenFeeOption = { + ...eoaNativeFeeOption, + token: { + ...nativeFeeOption.token, + address: '0x4444444444444444444444444444444444444444', + symbol: 'DAI' + } +} as FeePaymentOption + const tokenFeeOption = { ...nativeFeeOption, token: { @@ -105,9 +114,29 @@ describe('custom gas price support', () => { }) test('non-EOA custom gas support follows custom gas price support', () => { - expect(new V1(account, network, accountState).canSetCustomGas(nativeFeeOption)).toBe(true) - expect(new Safe(account, network, accountState).canSetCustomGas(nativeFeeOption)).toBe(true) + expect(new V1(account, network, accountState).canSetCustomGas()).toBe(true) + expect(new Safe(account, network, accountState).canSetCustomGas()).toBe(true) expect(new EOA7702(account, network, accountState).canSetCustomGas(tokenFeeOption)).toBe(false) expect(new V2(account, network, accountState).canSetCustomGas(eoaNativeFeeOption)).toBe(true) }) + + test('ERC-4337 capable accounts do not support bundler estimation when the feature is disabled', () => { + expect(new EOA7702(account, network, accountState, false).supportsBundlerEstimation()).toBe( + false + ) + expect(new V2(account, network, accountState, false).supportsBundlerEstimation()).toBe(false) + expect(new Safe(account, network, accountState, false).supportsBundlerEstimation()).toBe(false) + }) + + test('V2 returns only external fee payment options when ERC-4337 is disabled', () => { + const v2 = new V2(account, network, accountState, false) + + expect( + v2.getAvailableFeeOptions( + {} as any, + [nativeFeeOption, eoaNativeFeeOption, tokenFeeOption, eoaTokenFeeOption], + accountOp + ) + ).toEqual([eoaNativeFeeOption, eoaTokenFeeOption]) + }) }) diff --git a/src/libs/account/getBaseAccount.ts b/src/libs/account/getBaseAccount.ts index 030e5ed1ce..1f96d08599 100644 --- a/src/libs/account/getBaseAccount.ts +++ b/src/libs/account/getBaseAccount.ts @@ -11,18 +11,19 @@ import { V2 } from './V2' export function getBaseAccount( account: Account, accountState: AccountOnchainState, - network: Network + network: Network, + isErc4337Enabled = true ): BaseAccount { - if (account.safeCreation) return new Safe(account, network, accountState) + if (account.safeCreation) return new Safe(account, network, accountState, isErc4337Enabled) if (accountState.isEOA) { if (accountState.isSmarterEoa || canBecomeSmarterOnChain(network, account, accountState)) { - return new EOA7702(account, network, accountState) + return new EOA7702(account, network, accountState, isErc4337Enabled) } return new EOA(account, network, accountState) } return accountState.isV2 - ? new V2(account, network, accountState) + ? new V2(account, network, accountState, isErc4337Enabled) : new V1(account, network, accountState) } From 9e409736ff86bdfc4a2e7b7c423220602beac29d Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Fri, 10 Jul 2026 15:09:10 +0900 Subject: [PATCH 3/5] improve gas price fetching by removing the not needed call to fetch the latest block --- src/libs/gasPrice/gasPrice.ts | 167 ++-------------------------------- 1 file changed, 9 insertions(+), 158 deletions(-) diff --git a/src/libs/gasPrice/gasPrice.ts b/src/libs/gasPrice/gasPrice.ts index 28e853071c..29bcc51914 100644 --- a/src/libs/gasPrice/gasPrice.ts +++ b/src/libs/gasPrice/gasPrice.ts @@ -1,5 +1,4 @@ -import { Interface, JsonRpcProvider, toBeHex, toQuantity } from 'ethers' -import type { PublicClient } from 'viem' +import { Interface, JsonRpcProvider, toBeHex } from 'ethers' import AmbireAccount from '../../../contracts/compiled/AmbireAccount.json' import AmbireFactory from '../../../contracts/compiled/AmbireFactory.json' @@ -11,6 +10,8 @@ import { BaseAccount } from '../account/BaseAccount' import { AccountOp, getSignableCalls } from '../accountOp/accountOp' import { getActivatorCall } from '../userOperation/userOperation' +import type { PublicClient } from 'viem' + // a 1 gwei min for gas price, non1559 networks export const MIN_GAS_PRICE = 1000000000n @@ -37,30 +38,6 @@ export interface Gas1559Recommendation { } export type GasRecommendation = GasPriceRecommendation | Gas1559Recommendation -type GasPriceTransaction = { - gasPrice?: bigint | null - maxPriorityFeePerGas?: bigint | null -} - -type GasPriceBlock = { - baseFeePerGas?: bigint | null - gasLimit: bigint - gasUsed: bigint - prefetchedTransactions: GasPriceTransaction[] -} - -type RpcTransaction = { - gasPrice?: string | null - maxPriorityFeePerGas?: string | null -} - -type RpcBlock = { - baseFeePerGas?: string | null - gasLimit: string - gasUsed: string - transactions?: (string | RpcTransaction)[] -} - // https://stackoverflow.com/questions/20811131/javascript-remove-outlier-from-an-array function filterOutliers(data: bigint[]): bigint[] { if (!data.length) return [] @@ -97,120 +74,6 @@ function average(data: bigint[]): bigint { return data.reduce((a, b) => a + b, 0n) / BigInt(data.length) } -function getNetworkMinBaseFee(network: Network, lastBlock: GasPriceBlock): bigint { - // if we have a minBaseFee set in our config, use it - if (network.feeOptions.minBaseFee) return network.feeOptions.minBaseFee - - // if we don't have a config, we return 0 - if (network.predefined && !network.feeOptions.minBaseFeeEqualToLastBlock) return 0n - - // if it's a custom network and it has EIP-1559, set the minimum - // to the lastBlock's baseFeePerGas. Every chain is free to tweak - // its EIP-1559 implementation as it deems fit. Therefore, we have no - // guarantee the 12.5% block base fee reduction will actually happen. - // if it doesn't and we reduce the baseFee with our calculations, - // most often than not the transaction will just get stuck. - // - // Transaction fees are no longer an issue on L2s. - // Having the user spend a fraction of the cent more is way better - // than having his txns constantly getting stuck - return lastBlock.baseFeePerGas ?? 0n -} - -function hexToBigInt(value?: string | null): bigint | null { - return value ? BigInt(value) : null -} - -async function getRpcBlockTag( - provider: JsonRpcProvider, - blockTag: string | number -): Promise { - if (typeof blockTag !== 'number') return blockTag - - if (blockTag >= 0) return toQuantity(blockTag) - - const currentBlockNumber = await provider.getBlockNumber() - return toQuantity(Math.max(currentBlockNumber + blockTag, 0)) -} - -async function fetchRawBlock( - provider: JsonRpcProvider, - blockTag: string | number, - includeTransactions = false -): Promise { - const rpcBlockTag = await getRpcBlockTag(provider, blockTag) - const rawBlock = (await provider.send('eth_getBlockByNumber', [ - rpcBlockTag, - includeTransactions - ])) as RpcBlock | null - - if (!rawBlock) return null - - return { - baseFeePerGas: hexToBigInt(rawBlock.baseFeePerGas), - gasLimit: BigInt(rawBlock.gasLimit), - gasUsed: BigInt(rawBlock.gasUsed), - prefetchedTransactions: (rawBlock.transactions ?? []) - .filter((txn): txn is RpcTransaction => typeof txn !== 'string') - .map((txn) => ({ - gasPrice: hexToBigInt(txn.gasPrice), - maxPriorityFeePerGas: hexToBigInt(txn.maxPriorityFeePerGas) - })) - } -} - -async function fetchBlock( - provider: JsonRpcProvider, - network: Network, - blockTag: string | number, - includeTransactions = false -): Promise { - if (network.chainId === 2741n) return fetchRawBlock(provider, blockTag, includeTransactions) - return await provider.getBlock(blockTag, includeTransactions) -} - -// if there's an RPC issue, try refetching the block before declaring a failure -async function refetchBlock( - provider: JsonRpcProvider, - network: Network, - blockTag: string | number, - getIsActive?: () => boolean, - counter = 0 -): Promise { - if (counter >= 2) throw new Error('unable to retrieve block') - - let lastBlock = null - let timeoutId: NodeJS.Timeout | null = null - try { - const response = await Promise.race([ - fetchBlock(provider, network, blockTag), - new Promise((_resolve, reject) => { - timeoutId = setTimeout( - () => reject(new Error('last block failed to resolve, request too slow')), - 30000 - ) - }) - ]) - lastBlock = response as GasPriceBlock - } catch (e) { - console.error('refetch block failed', e) - lastBlock = null - } finally { - if (timeoutId) clearTimeout(timeoutId) - } - - if (getIsActive && !getIsActive()) { - throw new Error('operation aborted') - } - - if (!lastBlock) { - const localCounter = counter + 1 - lastBlock = await refetchBlock(provider, network, blockTag, getIsActive, localCounter) - } - - return lastBlock -} - function increaseByBps(value: bigint, bps: bigint): bigint { return value + (value * bps) / 10000n } @@ -250,9 +113,7 @@ async function getLegacyViemFees(client: PublicClient) { } async function get1559GasPriceRecommendations( - client: PublicClient, - network: Network, - lastBlock: GasPriceBlock + client: PublicClient ): Promise { const [estimatedFees, feeHistory] = await Promise.all([ get1559ViemFees(client), @@ -263,13 +124,10 @@ async function get1559GasPriceRecommendations( ]) const estimatedBaseFee = estimatedFees.maxFeePerGas - estimatedFees.maxPriorityFeePerGas - let expectedBaseFee = feeHistory + const expectedBaseFee = feeHistory ? (getLastBaseFeeFromHistory(feeHistory.baseFeePerGas) ?? estimatedBaseFee) : estimatedBaseFee - const minBaseFee = getNetworkMinBaseFee(network, lastBlock) - if (expectedBaseFee < minBaseFee) expectedBaseFee = minBaseFee - const priorityFees = feeHistory ? getAveragePriorityFeesFromHistory(feeHistory.reward ?? []) : [] const fee: Gas1559Recommendation[] = [] @@ -329,21 +187,14 @@ export async function getGasPriceRecommendations( _blockTag?: string | number, getIsActive?: () => boolean ): Promise<{ gasPrice: GasRecommendation[] }> { - const blockTag = _blockTag ?? -1 - const client = getViemClientForProvider(provider) - - const lastBlock = await refetchBlock(provider, network, blockTag, getIsActive) - if (getIsActive && !getIsActive()) { throw new Error('operation aborted') } - if ( - network.feeOptions.is1559 && - lastBlock.baseFeePerGas != null && - lastBlock.baseFeePerGas !== 0n - ) { - return { gasPrice: await get1559GasPriceRecommendations(client, network, lastBlock) } + const client = getViemClientForProvider(provider) + + if (network.feeOptions.is1559) { + return { gasPrice: await get1559GasPriceRecommendations(client) } } return { gasPrice: await getLegacyGasPriceRecommendations(client, network) } From 6dea787231c392cdcc6056760ad97520868e8731 Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Fri, 10 Jul 2026 15:43:00 +0900 Subject: [PATCH 4/5] disable sponsorship, paymaster service, eoa recording, gas tank top up if the erc4337 feature is disabled --- src/controllers/main/main.ts | 13 ++++++++---- .../signAccountOp/signAccountOp.ts | 17 +++++++++------- .../swapAndBridge/swapAndBridge.ts | 1 + src/libs/paymaster/paymaster.ts | 15 +++++++++++++- src/libs/swapAndBridge/swapAndBridge.test.ts | 20 ++++++++++++++++++- src/libs/swapAndBridge/swapAndBridge.ts | 3 +++ src/services/paymaster/PaymasterFactory.ts | 17 ++++++++++++++-- 7 files changed, 71 insertions(+), 15 deletions(-) diff --git a/src/controllers/main/main.ts b/src/controllers/main/main.ts index 28bb125d16..2a2e715f6f 100644 --- a/src/controllers/main/main.ts +++ b/src/controllers/main/main.ts @@ -690,10 +690,15 @@ export class MainController extends EventEmitter implements IMainController { }) }) } - paymasterFactory.init(relayerUrl, fetch, (e: ErrorRef) => { - if (this.requests.currentUserRequest?.kind !== 'calls') return - this.emitError(e) - }) + paymasterFactory.init( + relayerUrl, + fetch, + (e: ErrorRef) => { + if (this.requests.currentUserRequest?.kind !== 'calls') return + this.emitError(e) + }, + () => this.featureFlags.isFeatureEnabled('erc4337') + ) this.keystore.onUpdate(() => { if (this.keystore.statuses.unlockWithSecret === 'SUCCESS') { diff --git a/src/controllers/signAccountOp/signAccountOp.ts b/src/controllers/signAccountOp/signAccountOp.ts index 9764bacc1f..c41bc30309 100644 --- a/src/controllers/signAccountOp/signAccountOp.ts +++ b/src/controllers/signAccountOp/signAccountOp.ts @@ -3546,14 +3546,17 @@ export class SignAccountOpController } if (txnLength > 1) this.update({ signedTransactionsCount: i + 1 }) - // record the EOA txn - this.#callRelayer(`/v2/eoaSubmitTxn/${accountOp.chainId}`, 'POST', { - rawTxn: signedTxn - }).catch((e: any) => { - console.log('failed to record EOA txn to relayer', accountOp.chainId) + // record the EOA txn only if isErc4337Enabled as + // we need this for the gas tank + if (this.isErc4337Enabled) { + this.#callRelayer(`/v2/eoaSubmitTxn/${accountOp.chainId}`, 'POST', { + rawTxn: signedTxn + }).catch((e: any) => { + console.log('failed to record EOA txn to relayer', accountOp.chainId) - console.log(e) - }) + console.log(e) + }) + } } transactionRes = { diff --git a/src/controllers/swapAndBridge/swapAndBridge.ts b/src/controllers/swapAndBridge/swapAndBridge.ts index 06c1f754f6..bd3b0ea173 100644 --- a/src/controllers/swapAndBridge/swapAndBridge.ts +++ b/src/controllers/swapAndBridge/swapAndBridge.ts @@ -2659,6 +2659,7 @@ export class SwapAndBridgeController extends EventEmitter implements ISwapAndBri const nativePrice = native?.priceIn.find((price) => price.baseCurrency === 'usd')?.price const baseAcc = getBaseAccount(this.#selectedAccount.account, accountState, network) const swapSponsorship = getSwapSponsorship({ + isErc4337Enabled: this.#featureFlags.isFeatureEnabled('erc4337'), hasConvinienceFee: this.quote?.selectedRoute?.withConvenienceFee || false, nativePrice, fromAmountInUsd: Number(this.fromAmountInFiat), diff --git a/src/libs/paymaster/paymaster.ts b/src/libs/paymaster/paymaster.ts index 5c50f1caac..47e241894d 100644 --- a/src/libs/paymaster/paymaster.ts +++ b/src/libs/paymaster/paymaster.ts @@ -82,14 +82,22 @@ export class Paymaster extends AbstractPaymaster { errorCallback: Function | undefined = undefined + #getIsErc4337Enabled: () => boolean + // this is a temporary solution where the live relayer doesn't have // a chain id paymaster route open yet as it's not merged ambirePaymasterUrl: string | undefined - constructor(relayerUrl: string, fetch: Fetch, errorCallback: Function) { + constructor( + relayerUrl: string, + fetch: Fetch, + errorCallback: Function, + getIsErc4337Enabled: () => boolean = () => true + ) { super() this.callRelayer = relayerCall.bind({ url: relayerUrl, fetch }) this.errorCallback = errorCallback + this.#getIsErc4337Enabled = getIsErc4337Enabled } async init( @@ -104,6 +112,11 @@ export class Paymaster extends AbstractPaymaster { this.provider = provider this.ambirePaymasterUrl = `/v2/paymaster/${this.network.chainId}/request` + if (!this.#getIsErc4337Enabled()) { + this.type = 'None' + return + } + if (op.meta?.paymasterService && !op.meta?.paymasterService.failed) { try { this.paymasterService = op.meta.paymasterService diff --git a/src/libs/swapAndBridge/swapAndBridge.test.ts b/src/libs/swapAndBridge/swapAndBridge.test.ts index aead76de9a..11f9a05a73 100644 --- a/src/libs/swapAndBridge/swapAndBridge.test.ts +++ b/src/libs/swapAndBridge/swapAndBridge.test.ts @@ -8,7 +8,8 @@ import { calculateAmountWarnings, enrichRouteWithOutputUsdPrice, getFeeTokenForSponsorship, - getIsBridgeRoute + getIsBridgeRoute, + getSwapSponsorship } from './swapAndBridge' // Helper function to create a mock route for testing @@ -257,6 +258,23 @@ describe('swapAndBridge lib', () => { }) }) + describe('getSwapSponsorship', () => { + test('returns undefined when ERC-4337 is disabled', () => { + expect( + getSwapSponsorship({ + isErc4337Enabled: false, + hasConvinienceFee: true, + nativePrice: 3000, + fromAmountInUsd: 100, + feeTokenPriceInUsd: 1, + feeTokenDecimals: 6, + providerId: 'lifi', + isBridge: false + }) + ).toBeUndefined() + }) + }) + describe('enrichRouteWithOutputUsdPrice', () => { test('uses the fetched output token price and preserves the provider gas cost', () => { const selectedRoute = createMockRoute({ diff --git a/src/libs/swapAndBridge/swapAndBridge.ts b/src/libs/swapAndBridge/swapAndBridge.ts index 6c8fda1808..43076913ce 100644 --- a/src/libs/swapAndBridge/swapAndBridge.ts +++ b/src/libs/swapAndBridge/swapAndBridge.ts @@ -742,6 +742,7 @@ const convertNullAddressToZeroAddressIfNeeded = (addr: string) => * amount in USD to the fee percent */ const getSwapSponsorship = ({ + isErc4337Enabled, hasConvinienceFee, nativePrice, fromAmountInUsd, @@ -750,6 +751,7 @@ const getSwapSponsorship = ({ providerId, isBridge }: { + isErc4337Enabled: boolean hasConvinienceFee: boolean nativePrice: number | undefined fromAmountInUsd: number | undefined @@ -766,6 +768,7 @@ const getSwapSponsorship = ({ } | undefined => { if ( + !isErc4337Enabled || !hasConvinienceFee || !nativePrice || !fromAmountInUsd || diff --git a/src/services/paymaster/PaymasterFactory.ts b/src/services/paymaster/PaymasterFactory.ts index d84a9c64ed..cd6ab539d4 100644 --- a/src/services/paymaster/PaymasterFactory.ts +++ b/src/services/paymaster/PaymasterFactory.ts @@ -20,10 +20,18 @@ export class PaymasterFactory { errorCallback: Function | undefined = undefined - init(relayerUrl: string, fetch: Fetch, errorCallback: Function) { + getIsErc4337Enabled: () => boolean = () => true + + init( + relayerUrl: string, + fetch: Fetch, + errorCallback: Function, + getIsErc4337Enabled: () => boolean = () => true + ) { this.relayerUrl = relayerUrl this.fetch = fetch this.errorCallback = errorCallback + this.getIsErc4337Enabled = getIsErc4337Enabled } async create( @@ -48,7 +56,12 @@ export class PaymasterFactory { if (localOp.meta && localOp.meta.paymasterService) localOp.meta.paymasterService.failed = true } - const paymaster = new Paymaster(this.relayerUrl, this.fetch, this.errorCallback) + const paymaster = new Paymaster( + this.relayerUrl, + this.fetch, + this.errorCallback, + this.getIsErc4337Enabled + ) await paymaster.init(localOp, userOp, account, network, provider) return paymaster } From e4100ee987cc866aee9c2dc3356ebdab829f7a29 Mon Sep 17 00:00:00 2001 From: Borislav Itskov Date: Fri, 10 Jul 2026 16:40:40 +0900 Subject: [PATCH 5/5] fix: 1559 gas price fetch tests --- src/libs/gasPrice/tests/1559Network.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/libs/gasPrice/tests/1559Network.test.ts b/src/libs/gasPrice/tests/1559Network.test.ts index d527424ddc..fcc84b7cc7 100644 --- a/src/libs/gasPrice/tests/1559Network.test.ts +++ b/src/libs/gasPrice/tests/1559Network.test.ts @@ -97,7 +97,7 @@ describe('1559 Network gas price tests', () => { provider.destroy() }) - test('should not return a base fee below the network minimum', async () => { + test('should use fee history base fee even when below the network minimum', async () => { const provider = MockProvider.init({ baseFeePerGas: ethers.parseUnits('2', 'gwei'), feeHistory: { @@ -116,10 +116,10 @@ describe('1559 Network gas price tests', () => { const gasPriceData = await getGasPriceRecommendations(provider, baseNetwork) const gasPrice = gasPriceData.gasPrice as Gas1559Recommendation[] - expect(getByName(gasPrice, 'slow').baseFeePerGas).toBe(ethers.parseUnits('2', 'gwei')) - expect(getByName(gasPrice, 'medium').baseFeePerGas).toBe(ethers.parseUnits('2.1', 'gwei')) - expect(getByName(gasPrice, 'fast').baseFeePerGas).toBe(ethers.parseUnits('2.2', 'gwei')) - expect(getByName(gasPrice, 'ape').baseFeePerGas).toBe(ethers.parseUnits('2.3', 'gwei')) + expect(getByName(gasPrice, 'slow').baseFeePerGas).toBe(ethers.parseUnits('1', 'gwei')) + expect(getByName(gasPrice, 'medium').baseFeePerGas).toBe(ethers.parseUnits('1.05', 'gwei')) + expect(getByName(gasPrice, 'fast').baseFeePerGas).toBe(ethers.parseUnits('1.1', 'gwei')) + expect(getByName(gasPrice, 'ape').baseFeePerGas).toBe(ethers.parseUnits('1.15', 'gwei')) provider.destroy() }) })