diff --git a/package.json b/package.json index c911f11f7c4..07e8dd6c00c 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "eth-sig-util": "^3.0.0", "ethereumjs-util": "^7.0.10", "ethereumjs-wallet": "^1.0.1", + "ethjs-unit": "^0.1.6", "ethjs-util": "^0.1.6", "human-standard-collectible-abi": "^1.0.2", "human-standard-token-abi": "^2.0.0", diff --git a/src/ComposableController.test.ts b/src/ComposableController.test.ts index 189c6521f94..f33eb874f7b 100644 --- a/src/ComposableController.test.ts +++ b/src/ComposableController.test.ts @@ -132,6 +132,7 @@ describe('ComposableController', () => { }, NetworkController: { network: 'loading', + properties: { isEIP1559Compatible: false }, provider: { type: 'mainnet', chainId: NetworksChainId.mainnet }, }, PreferencesController: { @@ -188,6 +189,7 @@ describe('ComposableController', () => { ipfsGateway: 'https://ipfs.io/ipfs/', lostIdentities: {}, network: 'loading', + properties: { isEIP1559Compatible: false }, provider: { type: 'mainnet', chainId: NetworksChainId.mainnet }, selectedAddress: '', suggestedAssets: [], diff --git a/src/dependencies.d.ts b/src/dependencies.d.ts index 775b4b27f17..f564e5ea410 100644 --- a/src/dependencies.d.ts +++ b/src/dependencies.d.ts @@ -18,6 +18,8 @@ declare module 'ethjs-provider-http'; declare module 'ethjs-util'; +declare module 'ethjs-unit'; + declare module 'human-standard-collectible-abi'; declare module 'human-standard-token-abi'; diff --git a/src/gas/GasFeeController.test.ts b/src/gas/GasFeeController.test.ts new file mode 100644 index 00000000000..51f13d26fe1 --- /dev/null +++ b/src/gas/GasFeeController.test.ts @@ -0,0 +1,185 @@ +import { stub } from 'sinon'; +import nock from 'nock'; +import { ControllerMessenger } from '../ControllerMessenger'; +import { + GasFeeController, + GetGasFeeState, + GasFeeStateChange, + LegacyGasPriceEstimate, +} from './GasFeeController'; + +const TEST_GAS_FEE_API = 'https://mock-gas-server.herokuapp.com/'; +const TEST_LEGACY_FEE_API = 'https://test/'; + +const name = 'GasFeeController'; + +function getRestrictedMessenger() { + const controllerMessenger = new ControllerMessenger< + GetGasFeeState, + GasFeeStateChange + >(); + const messenger = controllerMessenger.getRestricted< + typeof name, + never, + never + >({ + name, + }); + return messenger; +} + +describe('GasFeeController', () => { + let gasFeeController: GasFeeController; + let getCurrentNetworkLegacyGasAPICompatibility: jest.Mock; + let getIsEIP1559Compatible: jest.Mock>; + let getChainId: jest.Mock<`0x${string}` | `${number}` | number>; + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.enableNetConnect(); + }); + + beforeEach(() => { + getChainId = jest.fn().mockImplementation(() => '0x1'); + getCurrentNetworkLegacyGasAPICompatibility = jest + .fn() + .mockImplementation(() => false); + getIsEIP1559Compatible = jest + .fn() + .mockImplementation(() => Promise.resolve(true)); + nock(TEST_GAS_FEE_API.replace('', '1')) + .get(/.+/u) + .reply(200, { + low: { + minWaitTimeEstimate: 60000, + maxWaitTimeEstimate: 600000, + suggestedMaxPriorityFeePerGas: '1', + suggestedMaxFeePerGas: '35', + }, + medium: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '1.8', + suggestedMaxFeePerGas: '38', + }, + high: { + minWaitTimeEstimate: 0, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '2', + suggestedMaxFeePerGas: '50', + }, + estimatedBaseFee: '28', + }) + .persist(); + + nock(TEST_LEGACY_FEE_API.replace('', '0x1')) + .get(/.+/u) + .reply(200, { + SafeGasPrice: '22', + ProposeGasPrice: '25', + FastGasPrice: '30', + }) + .persist(); + + gasFeeController = new GasFeeController({ + interval: 10000, + messenger: getRestrictedMessenger(), + getProvider: () => stub(), + getChainId, + legacyAPIEndpoint: TEST_LEGACY_FEE_API, + EIP1559APIEndpoint: TEST_GAS_FEE_API, + onNetworkStateChange: () => stub(), + getCurrentNetworkLegacyGasAPICompatibility, + getCurrentNetworkEIP1559Compatibility: getIsEIP1559Compatible, // change this for networkController.state.properties.isEIP1559Compatible ??? + }); + }); + + afterEach(() => { + nock.cleanAll(); + gasFeeController.destroy(); + }); + + it('should initialize', async () => { + expect(gasFeeController.name).toBe(name); + }); + + it('should getGasFeeEstimatesAndStartPolling', async () => { + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); + const result = await gasFeeController.getGasFeeEstimatesAndStartPolling( + undefined, + ); + expect(result).toHaveLength(36); + expect(gasFeeController.state.gasFeeEstimates).toHaveProperty('low'); + expect(gasFeeController.state.gasFeeEstimates).toHaveProperty('medium'); + expect(gasFeeController.state.gasFeeEstimates).toHaveProperty('high'); + expect(gasFeeController.state.gasFeeEstimates).toHaveProperty( + 'estimatedBaseFee', + ); + }); + + describe('when on any network supporting legacy gas estimation api', () => { + it('should _fetchGasFeeEstimateData', async () => { + getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); + getIsEIP1559Compatible.mockImplementation(() => Promise.resolve(false)); + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); + const estimates = await gasFeeController._fetchGasFeeEstimateData(); + expect(estimates).toHaveProperty('gasFeeEstimates'); + expect( + (gasFeeController.state.gasFeeEstimates as LegacyGasPriceEstimate).high, + ).toBe('30'); + }); + }); + + describe('getChainId', () => { + it('should work with a number input', async () => { + getChainId.mockImplementation(() => 1); + getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); + getIsEIP1559Compatible.mockImplementation(() => Promise.resolve(false)); + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); + const estimates = await gasFeeController._fetchGasFeeEstimateData(); + expect(estimates).toHaveProperty('gasFeeEstimates'); + expect( + (gasFeeController.state.gasFeeEstimates as LegacyGasPriceEstimate).high, + ).toBe('30'); + }); + + it('should work with a hexstring input', async () => { + getChainId.mockImplementation(() => '0x1'); + getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); + getIsEIP1559Compatible.mockImplementation(() => Promise.resolve(false)); + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); + const estimates = await gasFeeController._fetchGasFeeEstimateData(); + expect(estimates).toHaveProperty('gasFeeEstimates'); + expect( + (gasFeeController.state.gasFeeEstimates as LegacyGasPriceEstimate).high, + ).toBe('30'); + }); + + it('should work with a numeric string input', async () => { + getChainId.mockImplementation(() => '1'); + getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); + getIsEIP1559Compatible.mockImplementation(() => Promise.resolve(false)); + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); + const estimates = await gasFeeController._fetchGasFeeEstimateData(); + expect(estimates).toHaveProperty('gasFeeEstimates'); + expect( + (gasFeeController.state.gasFeeEstimates as LegacyGasPriceEstimate).high, + ).toBe('30'); + }); + }); + + describe('when on any network supporting EIP-1559', () => { + it('should _fetchGasFeeEstimateData', async () => { + getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); + const estimates = await gasFeeController._fetchGasFeeEstimateData(); + expect(estimates).toHaveProperty('gasFeeEstimates'); + expect(gasFeeController.state.gasFeeEstimates).toHaveProperty( + 'estimatedBaseFee', + ); + }); + }); +}); diff --git a/src/gas/GasFeeController.ts b/src/gas/GasFeeController.ts new file mode 100644 index 00000000000..f1e6d1e4033 --- /dev/null +++ b/src/gas/GasFeeController.ts @@ -0,0 +1,466 @@ +import type { Patch } from 'immer'; + +import EthQuery from 'eth-query'; +import { v1 as random } from 'uuid'; +import { isHexString } from 'ethereumjs-util'; +import { BaseController } from '../BaseControllerV2'; +import { safelyExecute } from '../util'; +import type { RestrictedControllerMessenger } from '../ControllerMessenger'; +import type { + NetworkController, + NetworkState, +} from '../network/NetworkController'; +import { + fetchGasEstimates as defaultFetchGasEstimates, + fetchEthGasPriceEstimate as defaultFetchEthGasPriceEstimate, + fetchLegacyGasPriceEstimates as defaultFetchLegacyGasPriceEstimates, + calculateTimeEstimate, +} from './gas-util'; + +const GAS_FEE_API = 'https://mock-gas-server.herokuapp.com/'; +export const LEGACY_GAS_PRICES_API_URL = `https://api.metaswap.codefi.network/gasPrices`; + +export type unknownString = 'unknown'; + +// Fee Market describes the way gas is set after the london hardfork, and was +// defined by EIP-1559. +export type FeeMarketEstimateType = 'fee-market'; +// Legacy describes gasPrice estimates from before london hardfork, when the +// user is connected to mainnet and are presented with fast/average/slow +// estimate levels to choose from. +export type LegacyEstimateType = 'legacy'; +// EthGasPrice describes a gasPrice estimate received from eth_gasPrice. Post +// london this value should only be used for legacy type transactions when on +// networks that support EIP-1559. This type of estimate is the most accurate +// to display on custom networks that don't support EIP-1559. +export type EthGasPriceEstimateType = 'eth_gasPrice'; +// NoEstimate describes the state of the controller before receiving its first +// estimate. +export type NoEstimateType = 'none'; + +/** + * Indicates which type of gasEstimate the controller is currently returning. + * This is useful as a way of asserting that the shape of gasEstimates matches + * expectations. NONE is a special case indicating that no previous gasEstimate + * has been fetched. + */ +export const GAS_ESTIMATE_TYPES = { + FEE_MARKET: 'fee-market' as FeeMarketEstimateType, + LEGACY: 'legacy' as LegacyEstimateType, + ETH_GASPRICE: 'eth_gasPrice' as EthGasPriceEstimateType, + NONE: 'none' as NoEstimateType, +}; + +export type GasEstimateType = + | FeeMarketEstimateType + | EthGasPriceEstimateType + | LegacyEstimateType + | NoEstimateType; + +export interface EstimatedGasFeeTimeBounds { + lowerTimeBound: number | null; + upperTimeBound: number | unknownString; +} + +/** + * @type EthGasPriceEstimate + * + * A single gas price estimate for networks and accounts that don't support EIP-1559 + * This estimate comes from eth_gasPrice but is converted to dec gwei to match other + * return values + * + * @property gasPrice - A GWEI dec string + */ + +export interface EthGasPriceEstimate { + gasPrice: string; +} + +/** + * @type LegacyGasPriceEstimate + * + * A set of gas price estimates for networks and accounts that don't support EIP-1559 + * These estimates include low, medium and high all as strings representing gwei in + * decimal format. + * + * @property high - gasPrice, in decimal gwei string format, suggested for fast inclusion + * @property medium - gasPrice, in decimal gwei string format, suggested for avg inclusion + * @property low - gasPrice, in decimal gwei string format, suggested for slow inclusion + */ +export interface LegacyGasPriceEstimate { + high: string; + medium: string; + low: string; +} + +/** + * @type Eip1559GasFee + * + * Data necessary to provide an estimate of a gas fee with a specific tip + * + * @property minWaitTimeEstimate - The fastest the transaction will take, in milliseconds + * @property maxWaitTimeEstimate - The slowest the transaction will take, in milliseconds + * @property suggestedMaxPriorityFeePerGas - A suggested "tip", a GWEI hex number + * @property suggestedMaxFeePerGas - A suggested max fee, the most a user will pay. a GWEI hex number + */ + +export interface Eip1559GasFee { + minWaitTimeEstimate: number; // a time duration in milliseconds + maxWaitTimeEstimate: number; // a time duration in milliseconds + suggestedMaxPriorityFeePerGas: string; // a GWEI decimal number + suggestedMaxFeePerGas: string; // a GWEI decimal number +} + +/** + * @type GasFeeEstimates + * + * Data necessary to provide multiple GasFee estimates, and supporting information, to the user + * + * @property low - A GasFee for a minimum necessary combination of tip and maxFee + * @property medium - A GasFee for a recommended combination of tip and maxFee + * @property high - A GasFee for a high combination of tip and maxFee + * @property estimatedBaseFee - An estimate of what the base fee will be for the pending/next block. A GWEI dec number + */ + +export interface GasFeeEstimates { + low: Eip1559GasFee; + medium: Eip1559GasFee; + high: Eip1559GasFee; + estimatedBaseFee: string; +} + +const metadata = { + gasFeeEstimates: { persist: true, anonymous: false }, + estimatedGasFeeTimeBounds: { persist: true, anonymous: false }, + gasEstimateType: { persist: true, anonymous: false }, +}; + +export type GasFeeStateEthGasPrice = { + gasFeeEstimates: EthGasPriceEstimate; + estimatedGasFeeTimeBounds: Record; + gasEstimateType: EthGasPriceEstimateType; +}; + +export type GasFeeStateFeeMarket = { + gasFeeEstimates: GasFeeEstimates; + estimatedGasFeeTimeBounds: EstimatedGasFeeTimeBounds | Record; + gasEstimateType: FeeMarketEstimateType; +}; + +export type GasFeeStateLegacy = { + gasFeeEstimates: LegacyGasPriceEstimate; + estimatedGasFeeTimeBounds: Record; + gasEstimateType: LegacyEstimateType; +}; + +export type GasFeeStateNoEstimates = { + gasFeeEstimates: Record; + estimatedGasFeeTimeBounds: Record; + gasEstimateType: NoEstimateType; +}; + +/** + * @type GasFeeState + * + * Gas Fee controller state + * + * @property gasFeeEstimates - Gas fee estimate data based on new EIP-1559 properties + * @property estimatedGasFeeTimeBounds - Estimates representing the minimum and maximum + */ +export type GasFeeState = + | GasFeeStateEthGasPrice + | GasFeeStateFeeMarket + | GasFeeStateLegacy + | GasFeeStateNoEstimates; + +const name = 'GasFeeController'; + +export type GasFeeStateChange = { + type: `${typeof name}:stateChange`; + payload: [GasFeeState, Patch[]]; +}; + +export type GetGasFeeState = { + type: `${typeof name}:getState`; + handler: () => GasFeeState; +}; + +const defaultState: GasFeeState = { + gasFeeEstimates: {}, + estimatedGasFeeTimeBounds: {}, + gasEstimateType: GAS_ESTIMATE_TYPES.NONE, +}; + +/** + * Controller that retrieves gas fee estimate data and polls for updated data on a set interval + */ +export class GasFeeController extends BaseController { + private intervalId?: NodeJS.Timeout; + + private intervalDelay; + + private pollTokens: Set; + + private legacyAPIEndpoint: string; + + private EIP1559APIEndpoint: string; + + private fetchGasEstimates; + + private fetchEthGasPriceEstimate; + + private fetchLegacyGasPriceEstimates; + + private getCurrentNetworkEIP1559Compatibility; + + private getCurrentNetworkLegacyGasAPICompatibility; + + private getCurrentAccountEIP1559Compatibility; + + private getChainId; + + private ethQuery: any; + + /** + * Creates a GasFeeController instance + * + */ + constructor({ + interval = 15000, + messenger, + state, + fetchGasEstimates = defaultFetchGasEstimates, + fetchEthGasPriceEstimate = defaultFetchEthGasPriceEstimate, + fetchLegacyGasPriceEstimates = defaultFetchLegacyGasPriceEstimates, + getCurrentNetworkEIP1559Compatibility, + getCurrentAccountEIP1559Compatibility, + getChainId, + getCurrentNetworkLegacyGasAPICompatibility, + getProvider, + onNetworkStateChange, + legacyAPIEndpoint = LEGACY_GAS_PRICES_API_URL, + EIP1559APIEndpoint = GAS_FEE_API, + }: { + interval?: number; + messenger: RestrictedControllerMessenger< + typeof name, + GetGasFeeState, + GasFeeStateChange, + never, + never + >; + state?: GasFeeState; + fetchGasEstimates?: typeof defaultFetchGasEstimates; + fetchEthGasPriceEstimate?: typeof defaultFetchEthGasPriceEstimate; + fetchLegacyGasPriceEstimates?: typeof defaultFetchLegacyGasPriceEstimates; + getCurrentNetworkEIP1559Compatibility: () => Promise; + getCurrentNetworkLegacyGasAPICompatibility: () => boolean; + getCurrentAccountEIP1559Compatibility?: () => boolean; + getChainId: () => `0x${string}` | `${number}` | number; + getProvider: () => NetworkController['provider']; + onNetworkStateChange: (listener: (state: NetworkState) => void) => void; + legacyAPIEndpoint?: string; + EIP1559APIEndpoint?: string; + }) { + super({ + name, + metadata, + messenger, + state: { ...defaultState, ...state }, + }); + this.intervalDelay = interval; + this.fetchGasEstimates = fetchGasEstimates; + this.fetchEthGasPriceEstimate = fetchEthGasPriceEstimate; + this.fetchLegacyGasPriceEstimates = fetchLegacyGasPriceEstimates; + this.pollTokens = new Set(); + this.getCurrentNetworkEIP1559Compatibility = getCurrentNetworkEIP1559Compatibility; + this.getCurrentNetworkLegacyGasAPICompatibility = getCurrentNetworkLegacyGasAPICompatibility; + this.getCurrentAccountEIP1559Compatibility = getCurrentAccountEIP1559Compatibility; + this.EIP1559APIEndpoint = EIP1559APIEndpoint; + this.legacyAPIEndpoint = legacyAPIEndpoint; + this.getChainId = getChainId; + + const provider = getProvider(); + this.ethQuery = new EthQuery(provider); + onNetworkStateChange(() => { + const newProvider = getProvider(); + this.ethQuery = new EthQuery(newProvider); + }); + } + + async fetchGasFeeEstimates() { + return await this._fetchGasFeeEstimateData(); + } + + async getGasFeeEstimatesAndStartPolling( + pollToken: string | undefined, + ): Promise { + if (this.pollTokens.size === 0) { + await this._fetchGasFeeEstimateData(); + } + + const _pollToken = pollToken || random(); + + this._startPolling(_pollToken); + + return _pollToken; + } + + /** + * Gets and sets gasFeeEstimates in state + * + * @returns GasFeeEstimates + */ + async _fetchGasFeeEstimateData(): Promise { + let isEIP1559Compatible; + const isLegacyGasAPICompatible = this.getCurrentNetworkLegacyGasAPICompatibility(); + + let chainId = this.getChainId(); + if (typeof chainId === 'string' && isHexString(chainId)) { + chainId = parseInt(chainId, 16); + } + try { + isEIP1559Compatible = await this.getEIP1559Compatibility(); + } catch (e) { + console.error(e); + isEIP1559Compatible = false; + } + + let newState: GasFeeState = { + gasFeeEstimates: {}, + estimatedGasFeeTimeBounds: {}, + gasEstimateType: GAS_ESTIMATE_TYPES.NONE, + }; + + try { + if (isEIP1559Compatible) { + const estimates = await this.fetchGasEstimates( + this.EIP1559APIEndpoint.replace('', `${chainId}`), + ); + const { + suggestedMaxPriorityFeePerGas, + suggestedMaxFeePerGas, + } = estimates.medium; + const estimatedGasFeeTimeBounds = this.getTimeEstimate( + suggestedMaxPriorityFeePerGas, + suggestedMaxFeePerGas, + ); + newState = { + gasFeeEstimates: estimates, + estimatedGasFeeTimeBounds, + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + }; + } else if (isLegacyGasAPICompatible) { + const estimates = await this.fetchLegacyGasPriceEstimates( + this.legacyAPIEndpoint.replace('', `${chainId}`), + ); + newState = { + gasFeeEstimates: estimates, + estimatedGasFeeTimeBounds: {}, + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + }; + } else { + throw new Error('Main gas fee/price estimation failed. Use fallback'); + } + } catch { + try { + const estimates = await this.fetchEthGasPriceEstimate(this.ethQuery); + newState = { + gasFeeEstimates: estimates, + estimatedGasFeeTimeBounds: {}, + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + }; + } catch (error) { + throw new Error( + `Gas fee/price estimation failed. Message: ${error.message}`, + ); + } + } + + this.update(() => { + return newState; + }); + + return newState; + } + + /** + * Remove the poll token, and stop polling if the set of poll tokens is empty + */ + disconnectPoller(pollToken: string) { + this.pollTokens.delete(pollToken); + if (this.pollTokens.size === 0) { + this.stopPolling(); + } + } + + stopPolling() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + this.pollTokens.clear(); + this.resetState(); + } + + /** + * Prepare to discard this controller. + * + * This stops any active polling. + */ + destroy() { + super.destroy(); + this.stopPolling(); + } + + // should take a token, so we know that we are only counting once for each open transaction + private async _startPolling(pollToken: string) { + if (this.pollTokens.size === 0) { + this._poll(); + } + this.pollTokens.add(pollToken); + } + + private async _poll() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + this.intervalId = setInterval(async () => { + await safelyExecute(() => this._fetchGasFeeEstimateData()); + }, this.intervalDelay); + } + + private resetState() { + this.update(() => { + return defaultState; + }); + } + + private async getEIP1559Compatibility() { + const currentNetworkIsEIP1559Compatible = await this.getCurrentNetworkEIP1559Compatibility(); + const currentAccountIsEIP1559Compatible = + this.getCurrentAccountEIP1559Compatibility?.() ?? true; + + return ( + currentNetworkIsEIP1559Compatible && currentAccountIsEIP1559Compatible + ); + } + + getTimeEstimate( + maxPriorityFeePerGas: string, + maxFeePerGas: string, + ): EstimatedGasFeeTimeBounds | Record { + if ( + !this.state.gasFeeEstimates || + this.state.gasEstimateType !== GAS_ESTIMATE_TYPES.FEE_MARKET + ) { + return {}; + } + return calculateTimeEstimate( + maxPriorityFeePerGas, + maxFeePerGas, + this.state.gasFeeEstimates, + ); + } +} + +export default GasFeeController; diff --git a/src/gas/gas-util.test.ts b/src/gas/gas-util.test.ts new file mode 100644 index 00000000000..cff089d42bb --- /dev/null +++ b/src/gas/gas-util.test.ts @@ -0,0 +1,27 @@ +import nock from 'nock'; +import { fetchLegacyGasPriceEstimates } from './gas-util'; + +describe('gas utils', () => { + describe('fetchLegacyGasPriceEstimates', () => { + it('should fetch external gasPrices and return high/medium/low', async () => { + const scope = nock('https://not-a-real-url/') + .get(/.+/u) + .reply(200, { + SafeGasPrice: '22', + ProposeGasPrice: '25', + FastGasPrice: '30', + }) + .persist(); + const result = await fetchLegacyGasPriceEstimates( + 'https://not-a-real-url/', + ); + expect(result).toMatchObject({ + high: '30', + medium: '25', + low: '22', + }); + scope.done(); + nock.cleanAll(); + }); + }); +}); diff --git a/src/gas/gas-util.ts b/src/gas/gas-util.ts new file mode 100644 index 00000000000..e2ca7346c36 --- /dev/null +++ b/src/gas/gas-util.ts @@ -0,0 +1,103 @@ +import { BN } from 'ethereumjs-util'; +import { query, handleFetch, gweiDecToWEIBN, weiHexToGweiDec } from '../util'; +import { + GasFeeEstimates, + EthGasPriceEstimate, + EstimatedGasFeeTimeBounds, + unknownString, + LegacyGasPriceEstimate, +} from './GasFeeController'; + +export async function fetchGasEstimates(url: string): Promise { + return await handleFetch(url); +} + +/** + * Hit the legacy MetaSwaps gasPrices estimate api and return the low, medium + * high values from that API. + */ +export async function fetchLegacyGasPriceEstimates( + url: string, +): Promise { + const result = await handleFetch(url, { + referrer: url, + referrerPolicy: 'no-referrer-when-downgrade', + method: 'GET', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + }); + return { + low: result.SafeGasPrice, + medium: result.ProposeGasPrice, + high: result.FastGasPrice, + }; +} + +export async function fetchEthGasPriceEstimate( + ethQuery: any, +): Promise { + const gasPrice = await query(ethQuery, 'gasPrice'); + return { + gasPrice: weiHexToGweiDec(gasPrice).toString(), + }; +} + +export function calculateTimeEstimate( + maxPriorityFeePerGas: string, + maxFeePerGas: string, + gasFeeEstimates: GasFeeEstimates, +): EstimatedGasFeeTimeBounds { + const { low, medium, high, estimatedBaseFee } = gasFeeEstimates; + + const maxPriorityFeePerGasInWEI = gweiDecToWEIBN(maxPriorityFeePerGas); + const maxFeePerGasInWEI = gweiDecToWEIBN(maxFeePerGas); + const estimatedBaseFeeInWEI = gweiDecToWEIBN(estimatedBaseFee); + + const effectiveMaxPriorityFee = BN.min( + maxPriorityFeePerGasInWEI, + maxFeePerGasInWEI.sub(estimatedBaseFeeInWEI), + ); + + const lowMaxPriorityFeeInWEI = gweiDecToWEIBN( + low.suggestedMaxPriorityFeePerGas, + ); + const mediumMaxPriorityFeeInWEI = gweiDecToWEIBN( + medium.suggestedMaxPriorityFeePerGas, + ); + const highMaxPriorityFeeInWEI = gweiDecToWEIBN( + high.suggestedMaxPriorityFeePerGas, + ); + + let lowerTimeBound; + let upperTimeBound; + + if (effectiveMaxPriorityFee.lt(lowMaxPriorityFeeInWEI)) { + lowerTimeBound = null; + upperTimeBound = 'unknown' as unknownString; + } else if ( + effectiveMaxPriorityFee.gte(lowMaxPriorityFeeInWEI) && + effectiveMaxPriorityFee.lt(mediumMaxPriorityFeeInWEI) + ) { + lowerTimeBound = low.minWaitTimeEstimate; + upperTimeBound = low.maxWaitTimeEstimate; + } else if ( + effectiveMaxPriorityFee.gte(mediumMaxPriorityFeeInWEI) && + effectiveMaxPriorityFee.lt(highMaxPriorityFeeInWEI) + ) { + lowerTimeBound = medium.minWaitTimeEstimate; + upperTimeBound = medium.maxWaitTimeEstimate; + } else if (effectiveMaxPriorityFee.eq(highMaxPriorityFeeInWEI)) { + lowerTimeBound = high.minWaitTimeEstimate; + upperTimeBound = high.maxWaitTimeEstimate; + } else { + lowerTimeBound = 0; + upperTimeBound = high.maxWaitTimeEstimate; + } + + return { + lowerTimeBound, + upperTimeBound, + }; +} diff --git a/src/index.ts b/src/index.ts index 587918200f7..9670251543d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,4 +33,6 @@ export * from './message-manager/PersonalMessageManager'; export * from './message-manager/TypedMessageManager'; export * from './notification/NotificationController'; export * from './assets/TokenListController'; +export * from './gas/GasFeeController'; +export * from './assets/TokenListController'; export { util }; diff --git a/src/network/NetworkController.test.ts b/src/network/NetworkController.test.ts index b6a2bbf8a3c..faaf24aac42 100644 --- a/src/network/NetworkController.test.ts +++ b/src/network/NetworkController.test.ts @@ -13,6 +13,7 @@ describe('NetworkController', () => { const controller = new NetworkController(); expect(controller.state).toStrictEqual({ network: 'loading', + properties: { isEIP1559Compatible: false }, provider: { type: 'mainnet', chainId: '1', diff --git a/src/network/NetworkController.ts b/src/network/NetworkController.ts index 734d18aab20..5a68d1bb521 100644 --- a/src/network/NetworkController.ts +++ b/src/network/NetworkController.ts @@ -47,6 +47,14 @@ export interface ProviderConfig { nickname?: string; } +export interface Block { + baseFeePerGas?: string; +} + +export interface NetworkProperties { + isEIP1559Compatible?: boolean; +} + /** * @type NetworkConfig * @@ -71,6 +79,7 @@ export interface NetworkConfig extends BaseConfig { export interface NetworkState extends BaseState { network: string; provider: ProviderConfig; + properties: NetworkProperties; } const LOCALHOST_RPC_URL = 'http://localhost:8545'; @@ -202,8 +211,10 @@ export class NetworkController extends BaseController< this.defaultState = { network: 'loading', provider: { type: MAINNET, chainId: NetworksChainId.mainnet }, + properties: { isEIP1559Compatible: false }, }; this.initialize(); + this.getEIP1559Compatibility(); } /** @@ -288,6 +299,36 @@ export class NetworkController extends BaseController< }); this.refreshNetwork(); } + + getEIP1559Compatibility() { + const { properties = {} } = this.state; + + if (!properties.isEIP1559Compatible) { + if (typeof this.ethQuery?.sendAsync !== 'function') { + return Promise.resolve(true); + } + return new Promise((resolve, reject) => { + this.ethQuery.sendAsync( + { method: 'eth_getBlockByNumber', params: ['latest', false] }, + (error: Error, block: Block) => { + if (error) { + reject(error); + } else { + const isEIP1559Compatible = + typeof block.baseFeePerGas !== 'undefined'; + this.update({ + properties: { + isEIP1559Compatible, + }, + }); + resolve(isEIP1559Compatible); + } + }, + ); + }); + } + return Promise.resolve(true); + } } export default NetworkController; diff --git a/src/transaction/TransactionController.test.ts b/src/transaction/TransactionController.test.ts index 7623a8b6a16..738f4baaf30 100644 --- a/src/transaction/TransactionController.test.ts +++ b/src/transaction/TransactionController.test.ts @@ -82,6 +82,7 @@ const MOCK_NETWORK = { getProvider: () => PROVIDER, state: { network: '3', + properties: { isEIP1559Compatible: false }, provider: { type: 'ropsten' as NetworkType, chainId: NetworksChainId.ropsten, @@ -98,6 +99,7 @@ const MOCK_MAINNET_NETWORK = { getProvider: () => MAINNET_PROVIDER, state: { network: '1', + properties: { isEIP1559Compatible: false }, provider: { type: 'mainnet' as NetworkType, chainId: NetworksChainId.mainnet, @@ -109,6 +111,7 @@ const MOCK_CUSTOM_NETWORK = { getProvider: () => MAINNET_PROVIDER, state: { network: '80001', + properties: { isEIP1559Compatible: false }, provider: { type: 'rpc' as NetworkType, chainId: '80001', diff --git a/src/util.test.ts b/src/util.test.ts index c615a6b9642..c6b06bf90c1 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -107,6 +107,120 @@ describe('util', () => { }); }); + describe('gweiDecToWEIBN', () => { + it('should convert a whole number to WEI', () => { + expect(util.gweiDecToWEIBN(1).toNumber()).toBe(1000000000); + expect(util.gweiDecToWEIBN(123).toNumber()).toBe(123000000000); + expect(util.gweiDecToWEIBN(101).toNumber()).toBe(101000000000); + expect(util.gweiDecToWEIBN(1234).toNumber()).toBe(1234000000000); + }); + + it('should convert a number with a decimal part to WEI', () => { + expect(util.gweiDecToWEIBN(1.1).toNumber()).toBe(1100000000); + expect(util.gweiDecToWEIBN(123.01).toNumber()).toBe(123010000000); + expect(util.gweiDecToWEIBN(101.001).toNumber()).toBe(101001000000); + expect(util.gweiDecToWEIBN(1234.567).toNumber()).toBe(1234567000000); + }); + + it('should convert a number < 1 to WEI', () => { + expect(util.gweiDecToWEIBN(0.1).toNumber()).toBe(100000000); + expect(util.gweiDecToWEIBN(0.01).toNumber()).toBe(10000000); + expect(util.gweiDecToWEIBN(0.001).toNumber()).toBe(1000000); + expect(util.gweiDecToWEIBN(0.567).toNumber()).toBe(567000000); + }); + + it('should round to whole WEI numbers', () => { + expect(util.gweiDecToWEIBN(0.1001).toNumber()).toBe(100100000); + expect(util.gweiDecToWEIBN(0.0109).toNumber()).toBe(10900000); + expect(util.gweiDecToWEIBN(0.0014).toNumber()).toBe(1400000); + expect(util.gweiDecToWEIBN(0.5676).toNumber()).toBe(567600000); + }); + + it('should handle NaN', () => { + expect(util.gweiDecToWEIBN(NaN).toNumber()).toBe(0); + }); + }); + + describe('weiHexToGweiDec', () => { + it('should convert a whole number to WEI', () => { + const testData = [ + { + input: '3b9aca00', + expectedResult: '1', + }, + { + input: '1ca35f0e00', + expectedResult: '123', + }, + { + input: '178411b200', + expectedResult: '101', + }, + { + input: '11f5021b400', + expectedResult: '1234', + }, + ]; + testData.forEach(({ input, expectedResult }) => { + expect(util.weiHexToGweiDec(input)).toBe(expectedResult); + }); + }); + + it('should convert a number with a decimal part to WEI', () => { + const testData = [ + { + input: '4190ab00', + expectedResult: '1.1', + }, + { + input: '1ca3f7a480', + expectedResult: '123.01', + }, + { + input: '178420f440', + expectedResult: '101.001', + }, + { + input: '11f71ed6fc0', + expectedResult: '1234.567', + }, + ]; + + testData.forEach(({ input, expectedResult }) => { + expect(util.weiHexToGweiDec(input)).toBe(expectedResult); + }); + }); + + it('should convert a number < 1 to WEI', () => { + const testData = [ + { + input: '5f5e100', + expectedResult: '0.1', + }, + { + input: '989680', + expectedResult: '0.01', + }, + { + input: 'f4240', + expectedResult: '0.001', + }, + { + input: '21cbbbc0', + expectedResult: '0.567', + }, + ]; + + testData.forEach(({ input, expectedResult }) => { + expect(util.weiHexToGweiDec(input)).toBe(expectedResult); + }); + }); + + it('should work with 0x prefixed values', () => { + expect(util.weiHexToGweiDec('0x5f48b0f7')).toBe('1.598599415'); + }); + }); + describe('safelyExecute', () => { it('should swallow errors', async () => { expect( diff --git a/src/util.ts b/src/util.ts index ef0e0eb703d..1379ee5765d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -7,6 +7,7 @@ import { toChecksumAddress, } from 'ethereumjs-util'; import { stripHexPrefix } from 'ethjs-util'; +import { fromWei, toWei } from 'ethjs-unit'; import { ethErrors } from 'eth-rpc-errors'; import ensNamehash from 'eth-ens-namehash'; import { TYPED_MESSAGE_SCHEMA, typedSignatureHash } from 'eth-sig-util'; @@ -62,6 +63,29 @@ export function fractionBN( return targetBN.mul(numBN).div(denomBN); } +/** + * Used to convert a base-10 number from GWEI to WEI. Can handle numbers with decimal parts + * + * @param n - The base 10 number to convert to WEI + * @returns - The number in WEI, as a BN + */ +export function gweiDecToWEIBN(n: number | string) { + if (Number.isNaN(n)) { + return new BN(0); + } + return toWei(n.toString(), 'gwei'); +} + +/** + * Used to convert values from wei hex format to dec gwei format + * @param hex - value in hex wei + * @returns - value in dec gwei as string + */ +export function weiHexToGweiDec(hex: string) { + const hexWei = new BN(stripHexPrefix(hex), 16); + return fromWei(hexWei, 'gwei').toString(10); +} + /** * Return a URL that can be used to obtain ETH for a given network * diff --git a/yarn.lock b/yarn.lock index 38368776c62..2c47fe7d330 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3036,7 +3036,7 @@ ethjs-schema@0.2.1: resolved "https://registry.yarnpkg.com/ethjs-schema/-/ethjs-schema-0.2.1.tgz#47e138920421453617069034684642e26bb310f4" integrity sha512-DXd8lwNrhT9sjsh/Vd2Z+4pfyGxhc0POVnLBUfwk5udtdoBzADyq+sK39dcb48+ZU+2VgtwHxtGWnLnCfmfW5g== -ethjs-unit@0.1.6: +ethjs-unit@0.1.6, ethjs-unit@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/ethjs-unit/-/ethjs-unit-0.1.6.tgz#c665921e476e87bce2a9d588a6fe0405b2c41699" integrity sha1-xmWSHkduh7ziqdWIpv4EBbLEFpk=