diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 263d01c51..f9b6121ae 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -27,7 +27,11 @@ import { TRANSACTION_ENVELOPE_TYPES, } from '../../../../shared/constants/transaction'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; -import { GAS_LIMITS } from '../../../../shared/constants/gas'; +import { + GAS_LIMITS, + GAS_ESTIMATE_TYPES, +} from '../../../../shared/constants/gas'; +import { decGWEIToHexWEI } from '../../../../shared/modules/conversion.utils'; import { HARDFORKS, MAINNET, @@ -107,6 +111,7 @@ export default class TransactionController extends EventEmitter { this.inProcessOfSigning = new Set(); this._trackMetaMetricsEvent = opts.trackMetaMetricsEvent; this._getParticipateInMetrics = opts.getParticipateInMetrics; + this._getEIP1559GasFeeEstimates = opts.getEIP1559GasFeeEstimates; this.memStore = new ObservableStore({}); this.query = new EthQuery(this.provider); @@ -399,7 +404,13 @@ export default class TransactionController extends EventEmitter { * @returns {Promise} resolves with txMeta */ async addTxGasDefaults(txMeta, getCodeResponse) { - const defaultGasPrice = await this._getDefaultGasPrice(txMeta); + const eip1559Compatibility = await this.getEIP1559Compatibility(); + + const { + gasPrice: defaultGasPrice, + maxFeePerGas: defaultMaxFeePerGas, + maxPriorityFeePerGas: defaultMaxPriorityFeePerGas, + } = await this._getDefaultGasFees(txMeta, eip1559Compatibility); const { gasLimit: defaultGasLimit, simulationFails, @@ -410,6 +421,67 @@ export default class TransactionController extends EventEmitter { if (simulationFails) { txMeta.simulationFails = simulationFails; } + + if (eip1559Compatibility) { + // If the dapp has suggested a gas price, but no maxFeePerGas or maxPriorityFeePerGas + // then we set maxFeePerGas and maxPriorityFeePerGas to the suggested gasPrice. + if ( + txMeta.txParams.gasPrice && + !txMeta.txParams.maxFeePerGas && + !txMeta.txParams.maxPriorityFeePerGas + ) { + txMeta.txParams.maxFeePerGas = txMeta.txParams.gasPrice; + txMeta.txParams.maxPriorityFeePerGas = txMeta.txParams.gasPrice; + } else { + if (defaultMaxFeePerGas && !txMeta.txParams.maxFeePerGas) { + // If the dapp has not set the gasPrice or the maxFeePerGas, then we set maxFeePerGas + // with the one returned by the gasFeeController, if that is available. + txMeta.txParams.maxFeePerGas = defaultMaxFeePerGas; + } + + if ( + defaultMaxPriorityFeePerGas && + !txMeta.txParams.maxPriorityFeePerGas + ) { + // If the dapp has not set the gasPrice or the maxPriorityFeePerGas, then we set maxPriorityFeePerGas + // with the one returned by the gasFeeController, if that is available. + txMeta.txParams.maxPriorityFeePerGas = defaultMaxPriorityFeePerGas; + } + + if (defaultGasPrice && !txMeta.txParams.maxFeePerGas) { + // If the dapp has not set the gasPrice or the maxFeePerGas, and no maxFeePerGas is available + // from the gasFeeController, then we set maxFeePerGas to the defaultGasPrice, assuming it is + // available. + txMeta.txParams.maxFeePerGas = defaultGasPrice; + } + + if ( + txMeta.txParams.maxFeePerGas && + !txMeta.txParams.maxPriorityFeePerGas + ) { + // If the dapp has not set the gasPrice or the maxPriorityFeePerGas, and no maxPriorityFeePerGas is + // available from the gasFeeController, then we set maxPriorityFeePerGas to + // txMeta.txParams.maxFeePerGas, which will either be the gasPrice from the controller, the maxFeePerGas + // set by the dapp, or the maxFeePerGas from the controller. + txMeta.txParams.maxPriorityFeePerGas = txMeta.txParams.maxFeePerGas; + } + } + + // We remove the gasPrice param entirely when on an eip1559 compatible network + + delete txMeta.txParams.gasPrice; + } else { + // We ensure that maxFeePerGas and maxPriorityFeePerGas are not in the transaction params + // when not on a EIP1559 compatible network + + delete txMeta.txParams.maxPriorityFeePerGas; + delete txMeta.txParams.maxFeePerGas; + } + + // If we have gotten to this point, and none of gasPrice, maxPriorityFeePerGas or maxFeePerGas are + // set on txParams, it means that either we are on a non-EIP1559 network and the dapp didn't suggest + // a gas price, or we are on an EIP1559 network, and none of gasPrice, maxPriorityFeePerGas or maxFeePerGas + // were available from either the dapp or the network. if ( defaultGasPrice && !txMeta.txParams.gasPrice && @@ -418,6 +490,7 @@ export default class TransactionController extends EventEmitter { ) { txMeta.txParams.gasPrice = defaultGasPrice; } + if (defaultGasLimit && !txMeta.txParams.gas) { txMeta.txParams.gas = defaultGasLimit; } @@ -425,20 +498,59 @@ export default class TransactionController extends EventEmitter { } /** - * Gets default gas price, or returns `undefined` if gas price is already set + * Gets default gas fees, or returns `undefined` if gas fees are already set * @param {Object} txMeta - The txMeta object * @returns {Promise} The default gas price */ - async _getDefaultGasPrice(txMeta) { + async _getDefaultGasFees(txMeta, eip1559Compatibility) { if ( - txMeta.txParams.gasPrice || + (!eip1559Compatibility && txMeta.txParams.gasPrice) || (txMeta.txParams.maxFeePerGas && txMeta.txParams.maxPriorityFeePerGas) ) { - return undefined; + return {}; } + + try { + const { + gasFeeEstimates, + gasEstimateType, + } = await this._getEIP1559GasFeeEstimates(); + if ( + eip1559Compatibility && + gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET + ) { + const { + medium: { suggestedMaxPriorityFeePerGas, suggestedMaxFeePerGas } = {}, + } = gasFeeEstimates; + + if (suggestedMaxPriorityFeePerGas && suggestedMaxFeePerGas) { + return { + maxFeePerGas: decGWEIToHexWEI(suggestedMaxFeePerGas), + maxPriorityFeePerGas: decGWEIToHexWEI( + suggestedMaxPriorityFeePerGas, + ), + }; + } + } else if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { + // The LEGACY type includes low, medium and high estimates of + // gas price values. + return { + gasPrice: decGWEIToHexWEI(gasFeeEstimates.medium), + }; + } else if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { + // The ETH_GASPRICE type just includes a single gas price property, + // which we can assume was retrieved from eth_gasPrice + return { + gasPrice: decGWEIToHexWEI(gasFeeEstimates.gasPrice), + }; + } + } catch (e) { + console.error(e); + } + const gasPrice = await this.query.gasPrice(); - return addHexPrefix(gasPrice.toString(16)); + return { gasPrice: gasPrice && addHexPrefix(gasPrice.toString(16)) }; } /** @@ -682,6 +794,7 @@ export default class TransactionController extends EventEmitter { this.txStateManager.setTxStatusApproved(txId); // get next nonce const txMeta = this.txStateManager.getTransaction(txId); + const fromAddress = txMeta.txParams.from; // wait for a nonce let { customNonceValue } = txMeta; diff --git a/app/scripts/controllers/transactions/index.test.js b/app/scripts/controllers/transactions/index.test.js index 9a415b280..8d12c4138 100644 --- a/app/scripts/controllers/transactions/index.test.js +++ b/app/scripts/controllers/transactions/index.test.js @@ -14,6 +14,7 @@ import { TRANSACTION_TYPES, } from '../../../../shared/constants/transaction'; import { SECOND } from '../../../../shared/constants/time'; +import { GAS_ESTIMATE_TYPES } from '../../../../shared/constants/gas'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; import TransactionController, { TRANSACTION_EVENTS } from '.'; @@ -50,9 +51,8 @@ describe('Transaction Controller', function () { return '0xee6b2800'; }, networkStore: new ObservableStore(currentNetworkId), - getEIP1559Compatibility: () => Promise.resolve(true), - getCurrentNetworkEIP1559Compatibility: () => Promise.resolve(true), - getCurrentAccountEIP1559Compatibility: () => true, + getCurrentNetworkEIP1559Compatibility: () => Promise.resolve(false), + getCurrentAccountEIP1559Compatibility: () => false, txHistoryLimit: 10, blockTracker: blockTrackerStub, signTransaction: (ethTx) => @@ -64,6 +64,7 @@ describe('Transaction Controller', function () { getCurrentChainId: () => currentChainId, getParticipateInMetrics: () => false, trackMetaMetricsEvent: () => undefined, + getEIP1559GasFeeEstimates: () => undefined, }); txController.nonceTracker.getNonceLock = () => Promise.resolve({ nextNonce: 0, releaseLock: noop }); @@ -419,6 +420,237 @@ describe('Transaction Controller', function () { 'should have added the gas field', ); }); + + it('should add EIP1559 tx defaults', async function () { + const TEST_MAX_FEE_PER_GAS = '0x12a05f200'; + const TEST_MAX_PRIORITY_FEE_PER_GAS = '0x77359400'; + + const stub1 = sinon + .stub(txController, 'getEIP1559Compatibility') + .returns(true); + + const stub2 = sinon + .stub(txController, '_getDefaultGasFees') + .callsFake(() => ({ + maxFeePerGas: TEST_MAX_FEE_PER_GAS, + maxPriorityFeePerGas: TEST_MAX_PRIORITY_FEE_PER_GAS, + })); + + txController.txStateManager._addTransactionsToState([ + { + id: 1, + status: TRANSACTION_STATUSES.UNAPPROVED, + metamaskNetworkId: currentNetworkId, + txParams: { + to: VALID_ADDRESS, + from: VALID_ADDRESS_TWO, + }, + history: [{}], + }, + ]); + const txMeta = { + id: 1, + txParams: { + from: '0xc684832530fcbddae4b4230a47e991ddcec2831d', + to: '0xc684832530fcbddae4b4230a47e991ddcec2831d', + }, + history: [{}], + }; + providerResultStub.eth_getBlockByNumber = { gasLimit: '47b784' }; + providerResultStub.eth_estimateGas = '5209'; + + const txMetaWithDefaults = await txController.addTxGasDefaults(txMeta); + + assert.equal( + txMetaWithDefaults.txParams.maxFeePerGas, + TEST_MAX_FEE_PER_GAS, + 'should have added the correct max fee per gas', + ); + assert.equal( + txMetaWithDefaults.txParams.maxPriorityFeePerGas, + TEST_MAX_PRIORITY_FEE_PER_GAS, + 'should have added the correct max priority fee per gas', + ); + stub1.restore(); + stub2.restore(); + }); + + it('should add gasPrice as maxFeePerGas and maxPriorityFeePerGas if there are no sources of other fee data available', async function () { + const TEST_GASPRICE = '0x12a05f200'; + + const stub1 = sinon + .stub(txController, 'getEIP1559Compatibility') + .returns(true); + + const stub2 = sinon + .stub(txController, '_getDefaultGasFees') + .callsFake(() => ({ gasPrice: TEST_GASPRICE })); + + txController.txStateManager._addTransactionsToState([ + { + id: 1, + status: TRANSACTION_STATUSES.UNAPPROVED, + metamaskNetworkId: currentNetworkId, + txParams: { + to: VALID_ADDRESS, + from: VALID_ADDRESS_TWO, + }, + history: [{}], + }, + ]); + const txMeta = { + id: 1, + txParams: { + from: '0xc684832530fcbddae4b4230a47e991ddcec2831d', + to: '0xc684832530fcbddae4b4230a47e991ddcec2831d', + }, + history: [{}], + }; + providerResultStub.eth_getBlockByNumber = { gasLimit: '47b784' }; + providerResultStub.eth_estimateGas = '5209'; + + const txMetaWithDefaults = await txController.addTxGasDefaults(txMeta); + + assert.equal( + txMetaWithDefaults.txParams.maxFeePerGas, + TEST_GASPRICE, + 'should have added the correct max fee per gas', + ); + assert.equal( + txMetaWithDefaults.txParams.maxPriorityFeePerGas, + TEST_GASPRICE, + 'should have added the correct max priority fee per gas', + ); + stub1.restore(); + stub2.restore(); + }); + + it('should not add gasPrice if the fee data is available from the dapp', async function () { + const TEST_GASPRICE = '0x12a05f200'; + const TEST_MAX_FEE_PER_GAS = '0x12a05f200'; + const TEST_MAX_PRIORITY_FEE_PER_GAS = '0x77359400'; + + const stub1 = sinon + .stub(txController, 'getEIP1559Compatibility') + .returns(true); + + const stub2 = sinon + .stub(txController, '_getDefaultGasFees') + .callsFake(() => ({ gasPrice: TEST_GASPRICE })); + + txController.txStateManager._addTransactionsToState([ + { + id: 1, + status: TRANSACTION_STATUSES.UNAPPROVED, + metamaskNetworkId: currentNetworkId, + txParams: { + to: VALID_ADDRESS, + from: VALID_ADDRESS_TWO, + maxFeePerGas: TEST_MAX_FEE_PER_GAS, + maxPriorityFeePerGas: TEST_MAX_PRIORITY_FEE_PER_GAS, + }, + history: [{}], + }, + ]); + const txMeta = { + id: 1, + txParams: { + from: '0xc684832530fcbddae4b4230a47e991ddcec2831d', + to: '0xc684832530fcbddae4b4230a47e991ddcec2831d', + }, + history: [{}], + }; + providerResultStub.eth_getBlockByNumber = { gasLimit: '47b784' }; + providerResultStub.eth_estimateGas = '5209'; + + const txMetaWithDefaults = await txController.addTxGasDefaults(txMeta); + + assert.equal( + txMetaWithDefaults.txParams.maxFeePerGas, + TEST_MAX_FEE_PER_GAS, + 'should have added the correct max fee per gas', + ); + assert.equal( + txMetaWithDefaults.txParams.maxPriorityFeePerGas, + TEST_MAX_PRIORITY_FEE_PER_GAS, + 'should have added the correct max priority fee per gas', + ); + stub1.restore(); + stub2.restore(); + }); + }); + + describe('_getDefaultGasFees', function () { + let getGasFeeStub; + + beforeEach(function () { + getGasFeeStub = sinon.stub(txController, '_getEIP1559GasFeeEstimates'); + }); + + afterEach(function () { + getGasFeeStub.restore(); + }); + + it('should return the correct fee data when the gas estimate type is FEE_MARKET', async function () { + const EXPECTED_MAX_FEE_PER_GAS = '12a05f200'; + const EXPECTED_MAX_PRIORITY_FEE_PER_GAS = '77359400'; + + getGasFeeStub.callsFake(() => ({ + gasFeeEstimates: { + medium: { + suggestedMaxPriorityFeePerGas: '2', + suggestedMaxFeePerGas: '5', + }, + }, + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + })); + + const defaultGasFees = await txController._getDefaultGasFees( + { txParams: {} }, + true, + ); + + assert.deepEqual(defaultGasFees, { + maxPriorityFeePerGas: EXPECTED_MAX_PRIORITY_FEE_PER_GAS, + maxFeePerGas: EXPECTED_MAX_FEE_PER_GAS, + }); + }); + + it('should return the correct fee data when the gas estimate type is LEGACY', async function () { + const EXPECTED_GAS_PRICE = '77359400'; + + getGasFeeStub.callsFake(() => ({ + gasFeeEstimates: { medium: '2' }, + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + })); + + const defaultGasFees = await txController._getDefaultGasFees( + { txParams: {} }, + false, + ); + + assert.deepEqual(defaultGasFees, { + gasPrice: EXPECTED_GAS_PRICE, + }); + }); + + it('should return the correct fee data when the gas estimate type is ETH_GASPRICE', async function () { + const EXPECTED_GAS_PRICE = '77359400'; + + getGasFeeStub.callsFake(() => ({ + gasFeeEstimates: { gasPrice: '2' }, + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + })); + + const defaultGasFees = await txController._getDefaultGasFees( + { txParams: {} }, + false, + ); + + assert.deepEqual(defaultGasFees, { + gasPrice: EXPECTED_GAS_PRICE, + }); + }); }); describe('#addTransaction', function () { @@ -807,6 +1039,9 @@ describe('Transaction Controller', function () { }); it('sets txParams.type to 0x2 (EIP-1559)', async function () { + const eip1559CompatibilityStub = sinon + .stub(txController, 'getEIP1559Compatibility') + .returns(true); txController.txStateManager._addTransactionsToState([ { status: TRANSACTION_STATUSES.UNAPPROVED, @@ -825,6 +1060,7 @@ describe('Transaction Controller', function () { ]); await txController.signTransaction('2'); assert.equal(fromTxDataSpy.getCall(0).args[0].type, '0x2'); + eip1559CompatibilityStub.restore(); }); }); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 1d6c044f6..8028eb11b 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -423,6 +423,9 @@ export default class MetamaskController extends EventEmitter { ), getParticipateInMetrics: () => this.metaMetricsController.state.participateInMetaMetrics, + getEIP1559GasFeeEstimates: this.gasFeeController.fetchGasFeeEstimates.bind( + this.gasFeeController, + ), }); this.txController.on('newUnapprovedTx', () => opts.showUserConfirmation()); diff --git a/jest.config.js b/jest.config.js index 425f7580f..7b556fa22 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,10 @@ module.exports = { restoreMocks: true, coverageDirectory: 'jest-coverage/', - collectCoverageFrom: ['/ui/**/swaps/**'], + collectCoverageFrom: [ + '/ui/**/swaps/**', + '/ui/ducks/send/**', + ], coveragePathIgnorePatterns: ['.stories.js', '.snap'], coverageThreshold: { global: { diff --git a/shared/modules/conversion.utils.js b/shared/modules/conversion.utils.js index 94ce17495..bad872787 100644 --- a/shared/modules/conversion.utils.js +++ b/shared/modules/conversion.utils.js @@ -268,7 +268,7 @@ const toNegative = (n, options = {}) => { return multiplyCurrencies(n, -1, options); }; -export function decGWEIToHexWEI(decGWEI) { +function decGWEIToHexWEI(decGWEI) { return conversionUtil(decGWEI, { fromNumericBase: 'dec', toNumericBase: 'hex', @@ -288,4 +288,5 @@ export { conversionMax, toNegative, subtractCurrencies, + decGWEIToHexWEI, }; diff --git a/ui/components/app/advanced-gas-controls/advanced-gas-controls.component.js b/ui/components/app/advanced-gas-controls/advanced-gas-controls.component.js index b3b43625e..591f8064a 100644 --- a/ui/components/app/advanced-gas-controls/advanced-gas-controls.component.js +++ b/ui/components/app/advanced-gas-controls/advanced-gas-controls.component.js @@ -34,31 +34,33 @@ export default function AdvancedGasControls({ maxFeeFiat, gasErrors, minimumGasLimit = 21000, + networkSupportsEIP1559, }) { const t = useContext(I18nContext); const suggestedValues = {}; - switch (gasEstimateType) { - case GAS_ESTIMATE_TYPES.FEE_MARKET: - suggestedValues.maxPriorityFeePerGas = - gasFeeEstimates?.[estimateToUse]?.suggestedMaxPriorityFeePerGas; - suggestedValues.maxFeePerGas = - gasFeeEstimates?.[estimateToUse]?.suggestedMaxFeePerGas; - break; - case GAS_ESTIMATE_TYPES.LEGACY: - suggestedValues.gasPrice = gasFeeEstimates?.[estimateToUse]; - break; - case GAS_ESTIMATE_TYPES.ETH_GASPRICE: - suggestedValues.gasPrice = gasFeeEstimates?.gasPrice; - break; - default: - break; + if (networkSupportsEIP1559) { + suggestedValues.maxFeePerGas = + gasFeeEstimates?.[estimateToUse]?.suggestedMaxFeePerGas || + gasFeeEstimates?.gasPrice; + suggestedValues.maxPriorityFeePerGas = + gasFeeEstimates?.[estimateToUse]?.suggestedMaxPriorityFeePerGas || + suggestedValues.maxFeePerGas; + } else { + switch (gasEstimateType) { + case GAS_ESTIMATE_TYPES.LEGACY: + suggestedValues.gasPrice = gasFeeEstimates?.[estimateToUse]; + break; + case GAS_ESTIMATE_TYPES.ETH_GASPRICE: + suggestedValues.gasPrice = gasFeeEstimates?.gasPrice; + break; + default: + break; + } } - const showFeeMarketFields = - process.env.SHOW_EIP_1559_UI && - gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET; + const showFeeMarketFields = networkSupportsEIP1559; return (
@@ -233,4 +235,5 @@ AdvancedGasControls.propTypes = { maxFeeFiat: PropTypes.string, gasErrors: PropTypes.object, minimumGasLimit: PropTypes.number, + networkSupportsEIP1559: PropTypes.object, }; diff --git a/ui/components/app/edit-gas-display/edit-gas-display.component.js b/ui/components/app/edit-gas-display/edit-gas-display.component.js index 2bdef8a9a..5e39e2cf2 100644 --- a/ui/components/app/edit-gas-display/edit-gas-display.component.js +++ b/ui/components/app/edit-gas-display/edit-gas-display.component.js @@ -7,10 +7,10 @@ import { EDIT_GAS_MODES, } from '../../../../shared/constants/gas'; -import { isEIP1559Network } from '../../../ducks/metamask/metamask'; - import Button from '../../ui/button'; import Typography from '../../ui/typography/typography'; +import { isEIP1559Network } from '../../../ducks/metamask/metamask'; + import { COLORS, TYPOGRAPHY, @@ -63,6 +63,7 @@ export default function EditGasDisplay({ balanceError, }) { const t = useContext(I18nContext); + const supportsEIP1559 = useSelector(isEIP1559Network); const dappSuggestedAndTxParamGasFeesAreTheSame = areDappSuggestedAndTxParamGasFeesTheSame( transaction, @@ -220,6 +221,7 @@ export default function EditGasDisplay({ gasErrors={gasErrors} onManualChange={onManualChange} minimumGasLimit={minimumGasLimit} + networkSupportsEIP1559={supportsEIP1559} /> )}
diff --git a/ui/components/app/edit-gas-popover/edit-gas-popover.component.js b/ui/components/app/edit-gas-popover/edit-gas-popover.component.js index 4c213a722..1878ac0b9 100644 --- a/ui/components/app/edit-gas-popover/edit-gas-popover.component.js +++ b/ui/components/app/edit-gas-popover/edit-gas-popover.component.js @@ -1,13 +1,11 @@ import React, { useCallback, useContext, useState } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; +import { isEIP1559Network } from '../../../ducks/metamask/metamask'; import { useGasFeeInputs } from '../../../hooks/useGasFeeInputs'; import { useShouldAnimateGasEstimations } from '../../../hooks/useShouldAnimateGasEstimations'; -import { - GAS_ESTIMATE_TYPES, - EDIT_GAS_MODES, -} from '../../../../shared/constants/gas'; +import { EDIT_GAS_MODES } from '../../../../shared/constants/gas'; import { decGWEIToHexWEI, @@ -44,6 +42,7 @@ export default function EditGasPopover({ const t = useContext(I18nContext); const dispatch = useDispatch(); const showSidebar = useSelector((state) => state.appState.sidebar.isOpen); + const supportsEIP1559 = useSelector(isEIP1559Network); const shouldAnimate = useShouldAnimateGasEstimations(); @@ -110,19 +109,20 @@ export default function EditGasPopover({ closePopover(); } - const newGasSettings = - gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET - ? { - gas: decimalToHex(gasLimit), - gasLimit: decimalToHex(gasLimit), - maxFeePerGas: decGWEIToHexWEI(maxFeePerGas), - maxPriorityFeePerGas: decGWEIToHexWEI(maxPriorityFeePerGas), - } - : { - gas: decimalToHex(gasLimit), - gasLimit: decimalToHex(gasLimit), - gasPrice: decGWEIToHexWEI(gasPrice), - }; + const newGasSettings = supportsEIP1559 + ? { + gas: decimalToHex(gasLimit), + gasLimit: decimalToHex(gasLimit), + maxFeePerGas: decGWEIToHexWEI(maxFeePerGas ?? gasPrice), + maxPriorityFeePerGas: decGWEIToHexWEI( + maxPriorityFeePerGas ?? maxFeePerGas ?? gasPrice, + ), + } + : { + gas: decimalToHex(gasLimit), + gasLimit: decimalToHex(gasLimit), + gasPrice: decGWEIToHexWEI(gasPrice), + }; switch (mode) { case EDIT_GAS_MODES.CANCEL: @@ -144,7 +144,7 @@ export default function EditGasPopover({ break; case EDIT_GAS_MODES.SWAPS: // This popover component should only be used for the "FEE_MARKET" type in Swaps. - if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { + if (supportsEIP1559) { dispatch(updateCustomSwapsEIP1559GasParams(newGasSettings)); } break; @@ -162,7 +162,7 @@ export default function EditGasPopover({ gasPrice, maxFeePerGas, maxPriorityFeePerGas, - gasEstimateType, + supportsEIP1559, ]); let title = t('editGasTitle'); diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index d4013de84..78a7b906b 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -73,6 +73,7 @@ import { getGasEstimateType, getTokens, getUnapprovedTxs, + isEIP1559Network, } from '../metamask/metamask'; import { resetEnsResolution } from '../ens'; import { @@ -81,6 +82,7 @@ import { } from '../../../shared/modules/hexstring-utils'; import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network'; import { ETH, GWEI } from '../../helpers/constants/common'; +import { TRANSACTION_ENVELOPE_TYPES } from '../../../shared/constants/transaction'; // typedefs /** @@ -91,7 +93,7 @@ const name = 'send'; /** * The Stages that the send slice can be in - * 1. UNINITIALIZED - The send state is idle, and hasn't yet fetched required + * 1. INACTIVE - The send state is idle, and hasn't yet fetched required * data for gasPrice and gasLimit estimations, etc. * 2. ADD_RECIPIENT - The user is selecting which address to send an asset to * 3. DRAFT - The send form is shown for a transaction yet to be sent to the @@ -407,6 +409,7 @@ export const initializeSendState = createAsyncThunk( const state = thunkApi.getState(); const isNonStandardEthChain = getIsNonStandardEthChain(state); const chainId = getCurrentChainId(state); + const eip1559support = isEIP1559Network(state); const { send: { asset, stage, recipient, amount, draftTransaction }, metamask, @@ -424,7 +427,10 @@ export const initializeSendState = createAsyncThunk( // selector and returns the account at the specified address. const account = getTargetAccount(state, fromAddress); - // Default gasPrice to 1 gwei if all estimation fails + // Default gasPrice to 1 gwei if all estimation fails, this is only used + // for gasLimit estimation and won't be set directly in state. Instead, we + // will return the gasFeeEstimates and gasEstimateType so that the reducer + // can set the appropriate gas fees in state. let gasPrice = '0x1'; let gasEstimatePollToken = null; @@ -434,6 +440,9 @@ export const initializeSendState = createAsyncThunk( metamask: { gasFeeEstimates, gasEstimateType }, } = thunkApi.getState(); + // Because we are only interested in getting a gasLimit estimation we only + // need to worry about gasPrice. So we use maxFeePerGas as gasPrice if we + // have a fee market estimation. if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { gasPrice = getGasPriceInHexWei(gasFeeEstimates.medium); } else if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { @@ -442,6 +451,10 @@ export const initializeSendState = createAsyncThunk( gasPrice = getGasPriceInHexWei( gasFeeEstimates.medium.suggestedMaxFeePerGas, ); + } else { + gasPrice = gasFeeEstimates.gasPrice + ? getRoundedGasPrice(gasFeeEstimates.gasPrice) + : '0x0'; } // Set a basic gasLimit in the event that other estimation fails @@ -493,19 +506,25 @@ export const initializeSendState = createAsyncThunk( assetBalance: balance, chainId: getCurrentChainId(state), tokens: getTokens(state), - gasPrice, + gasFeeEstimates, + gasEstimateType, gasLimit, gasTotal: addHexPrefix(calcGasTotal(gasLimit, gasPrice)), gasEstimatePollToken, + eip1559support, }; }, ); export const initialState = { // which stage of the send flow is the user on - stage: SEND_STAGES.UNINITIALIZED, + stage: SEND_STAGES.INACTIVE, // status of the send slice, either VALID or INVALID status: SEND_STATUSES.VALID, + // Determines type of transaction being sent, defaulted to 0x0 (legacy) + transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, + // tracks whether the current network supports EIP 1559 transactions + eip1559support: false, account: { // from account address, defaults to selected account. will be the account // the original transaction was sent from in the case of the EDIT stage @@ -524,6 +543,10 @@ export const initialState = { gasLimit: '0x0', // price in wei to pay per gas gasPrice: '0x0', + // maximum price in wei to pay per gas + maxFeePerGas: '0x0', + // maximum priority fee in wei to pay per gas + maxPriorityFeePerGas: '0x0', // expected price in wei necessary to pay per gas used for a transaction // to be included in a reasonable timeframe. Comes from GasFeeController. gasPriceEstimate: '0x0', @@ -570,6 +593,7 @@ export const initialState = { value: '0x0', gas: '0x0', gasPrice: '0x0', + type: TRANSACTION_ENVELOPE_TYPES.LEGACY, }, }, recipient: { @@ -683,9 +707,17 @@ const slice = createSlice({ * field and send state, then updates the draft transaction. */ calculateGasTotal: (state) => { - state.gas.gasTotal = addHexPrefix( - calcGasTotal(state.gas.gasLimit, state.gas.gasPrice), - ); + // use maxFeePerGas as the multiplier if working with a FEE_MARKET transaction + // otherwise use gasPrice + if (state.transactionType === TRANSACTION_ENVELOPE_TYPES.FEE_MARKET) { + state.gas.gasTotal = addHexPrefix( + calcGasTotal(state.gas.gasLimit, state.gas.maxFeePerGas), + ); + } else { + state.gas.gasTotal = addHexPrefix( + calcGasTotal(state.gas.gasLimit, state.gas.gasPrice), + ); + } if ( state.amount.mode === AMOUNT_MODES.MAX && state.asset.type === ASSET_TYPES.NATIVE @@ -705,12 +737,86 @@ const slice = createSlice({ slice.caseReducers.calculateGasTotal(state); }, /** - * sets the provided gasPrice in state and then recomputes the gasTotal + * Sets the appropriate gas fees in state and determines and sets the + * appropriate transactionType based on gas fee fields received. */ - updateGasPrice: (state, action) => { - state.gas.gasPrice = addHexPrefix(action.payload); + updateGasFees: (state, action) => { + if ( + action.payload.transactionType === TRANSACTION_ENVELOPE_TYPES.FEE_MARKET + ) { + state.gas.maxFeePerGas = addHexPrefix(action.payload.maxFeePerGas); + state.gas.maxPriorityFeePerGas = addHexPrefix( + action.payload.maxPriorityFeePerGas, + ); + state.transactionType = TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; + } else { + // Until we remove the old UI we don't want to automatically update + // gasPrice if the user has already manually changed the field value. + // When receiving a new estimate the isAutomaticUpdate property will be + // on the payload (and set to true). If isAutomaticUpdate is true, + // then we check if the previous estimate was '0x0' or if the previous + // gasPrice equals the previous gasEstimate. if either of those cases + // are true then we update the gasPrice otherwise we skip it because + // it indicates the user has ejected from the estimates by modifying + // the field. + if ( + action.payload.isAutomaticUpdate !== true || + state.gas.gasPriceEstimate === '0x0' || + state.gas.gasPrice === state.gas.gasPriceEstimate + ) { + state.gas.gasPrice = addHexPrefix(action.payload.gasPrice); + } + state.transactionType = TRANSACTION_ENVELOPE_TYPES.LEGACY; + } slice.caseReducers.calculateGasTotal(state); }, + /** + * Sets the appropriate gas fees in state after receiving new estimates. + */ + updateGasFeeEstimates: (state, action) => { + const { gasFeeEstimates, gasEstimateType } = action.payload; + let gasPriceEstimate = '0x0'; + switch (gasEstimateType) { + case GAS_ESTIMATE_TYPES.FEE_MARKET: + slice.caseReducers.updateGasFees(state, { + payload: { + transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, + maxFeePerGas: getGasPriceInHexWei( + gasFeeEstimates.medium.suggestedMaxFeePerGas, + ), + maxPriorityFeePerGas: getGasPriceInHexWei( + gasFeeEstimates.medium.suggestedMaxPriorityFeePerGas, + ), + }, + }); + break; + case GAS_ESTIMATE_TYPES.LEGACY: + gasPriceEstimate = getRoundedGasPrice(gasFeeEstimates.medium); + slice.caseReducers.updateGasFees(state, { + payload: { + gasPrice: gasPriceEstimate, + type: TRANSACTION_ENVELOPE_TYPES.LEGACY, + isAutomaticUpdate: true, + }, + }); + break; + case GAS_ESTIMATE_TYPES.ETH_GASPRICE: + gasPriceEstimate = getRoundedGasPrice(gasFeeEstimates.gasPrice); + slice.caseReducers.updateGasFees(state, { + payload: { + gasPrice: getRoundedGasPrice(gasFeeEstimates.gasPrice), + type: TRANSACTION_ENVELOPE_TYPES.LEGACY, + isAutomaticUpdate: true, + }, + }); + break; + case GAS_ESTIMATE_TYPES.NONE: + default: + break; + } + // Record the latest gasPriceEstimate for future comparisons + state.gas.gasPriceEstimate = addHexPrefix(gasPriceEstimate); + }, /** * sets the amount mode to the provided value as long as it is one of the * supported modes (MAX|INPUT) @@ -778,9 +884,15 @@ const slice = createSlice({ // We keep a copy of txParams in state that could be submitted to the // network if the form state is valid. if (state.status === SEND_STATUSES.VALID) { + // We don't/shouldn't modify the from address when editing an + // existing transaction. if (state.stage !== SEND_STAGES.EDIT) { state.draftTransaction.txParams.from = state.account.address; } + + // gasLimit always needs to be set regardless of the asset being sent + // or the type of transaction. + state.draftTransaction.txParams.gas = state.gas.gasLimit; switch (state.asset.type) { case ASSET_TYPES.TOKEN: // When sending a token the to address is the contract address of @@ -789,8 +901,6 @@ const slice = createSlice({ // amount. state.draftTransaction.txParams.to = state.asset.details.address; state.draftTransaction.txParams.value = '0x0'; - state.draftTransaction.txParams.gas = state.gas.gasLimit; - state.draftTransaction.txParams.gasPrice = state.gas.gasPrice; state.draftTransaction.txParams.data = generateTokenTransferData({ toAddress: state.recipient.address, amount: state.amount.value, @@ -804,11 +914,46 @@ const slice = createSlice({ // populated with the user input provided in hex field. state.draftTransaction.txParams.to = state.recipient.address; state.draftTransaction.txParams.value = state.amount.value; - state.draftTransaction.txParams.gas = state.gas.gasLimit; - state.draftTransaction.txParams.gasPrice = state.gas.gasPrice; state.draftTransaction.txParams.data = state.draftTransaction.userInputHexData ?? undefined; } + + // We need to make sure that we only include the right gas fee fields + // based on the type of transaction the network supports. We will also set + // the type param here. We must delete the opposite fields to avoid + // stale data in txParams. + if (state.eip1559support) { + state.draftTransaction.txParams.type = + TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; + + state.draftTransaction.txParams.maxFeePerGas = state.gas.maxFeePerGas; + state.draftTransaction.txParams.maxPriorityFeePerGas = + state.gas.maxPriorityFeePerGas; + + if ( + !state.draftTransaction.txParams.maxFeePerGas || + state.draftTransaction.txParams.maxFeePerGas === '0x0' + ) { + state.draftTransaction.txParams.maxFeePerGas = state.gas.gasPrice; + } + + if ( + !state.draftTransaction.txParams.maxPriorityFeePerGas || + state.draftTransaction.txParams.maxPriorityFeePerGas === '0x0' + ) { + state.draftTransaction.txParams.maxPriorityFeePerGas = + state.draftTransaction.txParams.maxFeePerGas; + } + + delete state.draftTransaction.txParams.gasPrice; + } else { + delete state.draftTransaction.txParams.maxFeePerGas; + delete state.draftTransaction.txParams.maxPriorityFeePerGas; + + state.draftTransaction.txParams.gasPrice = state.gas.gasPrice; + state.draftTransaction.txParams.type = + TRANSACTION_ENVELOPE_TYPES.LEGACY; + } } }, useDefaultGas: (state) => { @@ -937,7 +1082,7 @@ const slice = createSlice({ case state.asset.type === ASSET_TYPES.TOKEN && state.asset.details === null: case state.stage === SEND_STAGES.ADD_RECIPIENT: - case state.stage === SEND_STAGES.UNINITIALIZED: + case state.stage === SEND_STAGES.INACTIVE: case state.gas.isGasEstimateLoading: case new BigNumber(state.gas.gasLimit, 16).lessThan( new BigNumber(state.gas.minimumGasLimit), @@ -1039,17 +1184,23 @@ const slice = createSlice({ .addCase(initializeSendState.fulfilled, (state, action) => { // writes the computed initialized state values into the slice and then // calculates slice validity using the caseReducers. + state.eip1559support = action.payload.eip1559support; state.account.address = action.payload.address; state.account.balance = action.payload.nativeBalance; state.asset.balance = action.payload.assetBalance; state.gas.gasLimit = action.payload.gasLimit; - state.gas.gasPrice = action.payload.gasPrice; + slice.caseReducers.updateGasFeeEstimates(state, { + payload: { + gasFeeEstimates: action.payload.gasFeeEstimates, + gasEstimateType: action.payload.gasEstimateType, + }, + }); state.gas.gasTotal = action.payload.gasTotal; state.gas.gasEstimatePollToken = action.payload.gasEstimatePollToken; if (action.payload.gasEstimatePollToken) { state.gas.isGasEstimateLoading = false; } - if (state.stage !== SEND_STAGES.UNINITIALIZED) { + if (state.stage !== SEND_STAGES.INACTIVE) { slice.caseReducers.validateRecipientUserInput(state, { payload: { chainId: action.payload.chainId, @@ -1058,7 +1209,7 @@ const slice = createSlice({ }); } state.stage = - state.stage === SEND_STAGES.UNINITIALIZED + state.stage === SEND_STAGES.INACTIVE ? SEND_STAGES.ADD_RECIPIENT : state.stage; slice.caseReducers.validateAmountField(state); @@ -1091,33 +1242,9 @@ const slice = createSlice({ .addCase(GAS_FEE_ESTIMATES_UPDATED, (state, action) => { // When the gasFeeController updates its gas fee estimates we need to // update and validate state based on those new values - const { gasFeeEstimates, gasEstimateType } = action.payload; - let payload = null; - if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { - payload = getGasPriceInHexWei( - gasFeeEstimates.medium.suggestedMaxFeePerGas, - ); - } else if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { - payload = getGasPriceInHexWei(gasFeeEstimates.medium); - } else if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { - payload = getRoundedGasPrice(gasFeeEstimates.gasPrice); - } - // If a new gasPrice can be derived, and either the gasPriceEstimate - // was '0x0' or the gasPrice selected matches the previous estimate, - // update the gasPrice. This will ensure that we only update the - // gasPrice if the user is using our previous estimated value. - if ( - payload && - (state.gas.gasPriceEstimate === '0x0' || - state.gas.gasPrice === state.gas.gasPriceEstimate) - ) { - slice.caseReducers.updateGasPrice(state, { - payload, - }); - } - - // Record the latest gasPriceEstimate for future comparisons - state.gas.gasPriceEstimate = payload ?? state.gas.gasPriceEstimate; + slice.caseReducers.updateGasFeeEstimates(state, { + payload: action.payload, + }); }); }, }); @@ -1130,15 +1257,37 @@ const { useDefaultGas, useCustomGas, updateGasLimit, - updateGasPrice, validateRecipientUserInput, updateRecipientSearchMode, } = actions; -export { useDefaultGas, useCustomGas, updateGasLimit, updateGasPrice }; +export { useDefaultGas, useCustomGas, updateGasLimit }; // Action Creators +/** + * This method is a temporary placeholder to support the old UI in both the + * gas modal and the send flow. Soon we won't need to modify gasPrice from the + * send flow based on user input, it'll just be a shallow copy of the current + * estimate. This method is necessary because the internal structure of this + * slice has been changed such that it is agnostic to transaction envelope + * type, and this method calls into the new structure in the appropriate way. + * + * @deprecated - don't extend the usage of this temporary method + * @param {string} gasPrice - new gas price in hex wei + * @returns {void} + */ +export function updateGasPrice(gasPrice) { + return (dispatch) => { + dispatch( + actions.updateGasFees({ + gasPrice, + transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, + }), + ); + }; +} + export function resetSendState() { return async (dispatch, getState) => { const state = getState(); @@ -1587,7 +1736,7 @@ export function getSendErrors(state) { } export function isSendStateInitialized(state) { - return state[name].stage !== SEND_STAGES.UNINITIALIZED; + return state[name].stage !== SEND_STAGES.INACTIVE; } export function isSendFormInvalid(state) { diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js index 189251b8e..bc34c4ec5 100644 --- a/ui/ducks/send/send.test.js +++ b/ui/ducks/send/send.test.js @@ -10,9 +10,15 @@ import { KNOWN_RECIPIENT_ADDRESS_WARNING, NEGATIVE_ETH_ERROR, } from '../../pages/send/send.constants'; -import { RINKEBY_CHAIN_ID } from '../../../shared/constants/network'; +import { + MAINNET_CHAIN_ID, + RINKEBY_CHAIN_ID, +} from '../../../shared/constants/network'; import { GAS_ESTIMATE_TYPES, GAS_LIMITS } from '../../../shared/constants/gas'; -import { TRANSACTION_TYPES } from '../../../shared/constants/transaction'; +import { + TRANSACTION_ENVELOPE_TYPES, + TRANSACTION_TYPES, +} from '../../../shared/constants/transaction'; import sendReducer, { initialState, initializeSendState, @@ -32,6 +38,30 @@ import sendReducer, { AMOUNT_MODES, RECIPIENT_SEARCH_MODES, editTransaction, + getGasLimit, + getGasPrice, + getGasTotal, + gasFeeIsInError, + getMinimumGasLimitForSend, + getGasInputMode, + GAS_INPUT_MODES, + getSendAsset, + getSendAssetAddress, + getIsAssetSendable, + getSendAmount, + getIsBalanceInsufficient, + getSendMaxModeState, + sendAmountIsInError, + getSendHexData, + getSendTo, + getIsUsingMyAccountForRecipientSearch, + getRecipientUserInput, + getRecipient, + getSendErrors, + isSendStateInitialized, + isSendFormInvalid, + getSendStage, + updateGasPrice, } from './send'; const mockStore = createMockStore([thunk]); @@ -89,8 +119,48 @@ describe('Send Slice', () => { }); }); + describe('updateGasFees', () => { + it('should work with FEE_MARKET gas fees', () => { + const action = { + type: 'send/updateGasFees', + payload: { + transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + }, + }; + const result = sendReducer(initialState, action); + + expect(result.gas.maxFeePerGas).toStrictEqual( + action.payload.maxFeePerGas, + ); + + expect(result.gas.maxPriorityFeePerGas).toStrictEqual( + action.payload.maxPriorityFeePerGas, + ); + + expect(result.transactionType).toBe( + TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, + ); + }); + + it('should work with LEGACY gas fees', () => { + const action = { + type: 'send/updateGasFees', + payload: { + transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, + gasPrice: '0x1', + }, + }; + const result = sendReducer(initialState, action); + + expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + expect(result.transactionType).toBe(TRANSACTION_ENVELOPE_TYPES.LEGACY); + }); + }); + describe('updateUserInputHexData', () => { - it('should', () => { + it('should update the state with the provided data', () => { const action = { type: 'send/updateUserInputHexData', payload: 'TestData', @@ -142,56 +212,6 @@ describe('Send Slice', () => { }); }); - describe('updateGasPrice', () => { - const action = { - type: 'send/updateGasPrice', - payload: '0x3b9aca00', // 1000000000 - }; - - it('should update gas price and update draft transaction with validated state', () => { - const validSendState = { - ...initialState, - stage: SEND_STAGES.DRAFT, - account: { - balance: '0x56bc75e2d63100000', - }, - asset: { - balance: '0x56bc75e2d63100000', - type: ASSET_TYPES.NATIVE, - }, - gas: { - isGasEstimateLoading: false, - gasTotal: '0x1319718a5000', // 21000000000000 - gasLimit: '0x5208', // 21000 - minimumGasLimit: '0x5208', - }, - }; - - const result = sendReducer(validSendState, action); - - expect(result.gas.gasPrice).toStrictEqual(action.payload); - expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( - action.payload, - ); - }); - - it('should recalculate gasTotal', () => { - const gasState = { - gas: { - gasLimit: '0x5208', // 21000, - gasPrice: '0x0', - }, - }; - - const state = { ...initialState, ...gasState }; - const result = sendReducer(state, action); - - expect(result.gas.gasPrice).toStrictEqual(action.payload); - expect(result.gas.gasLimit).toStrictEqual(gasState.gas.gasLimit); - expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 - }); - }); - describe('updateAmountMode', () => { it('should change to INPUT amount mode', () => { const emptyAmountModeState = { @@ -342,89 +362,199 @@ describe('Send Slice', () => { }); describe('updateDraftTransaction', () => { - it('should', () => { - const detailsForDraftTransactionState = { - ...initialState, - status: SEND_STATUSES.VALID, - account: { - address: '0xCurrentAddress', - }, - asset: { - type: '', - }, - recipient: { - address: '0xRecipientAddress', - }, - amount: { - value: '0x1', - }, - gas: { - gasPrice: '0x3b9aca00', // 1000000000 - gasLimit: '0x5208', // 21000 - }, - }; + describe('with LEGACY transactions', () => { + it('should properly set fields', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, + account: { + address: '0xCurrentAddress', + }, + asset: { + type: '', + }, + recipient: { + address: '0xRecipientAddress', + }, + amount: { + value: '0x1', + }, + gas: { + gasPrice: '0x3b9aca00', // 1000000000 + gasLimit: '0x5208', // 21000 + }, + }; - const action = { - type: 'send/updateDraftTransaction', - }; + const action = { + type: 'send/updateDraftTransaction', + }; - const result = sendReducer(detailsForDraftTransactionState, action); + const result = sendReducer(detailsForDraftTransactionState, action); - expect(result.draftTransaction.txParams.to).toStrictEqual( - detailsForDraftTransactionState.recipient.address, - ); - expect(result.draftTransaction.txParams.value).toStrictEqual( - detailsForDraftTransactionState.amount.value, - ); - expect(result.draftTransaction.txParams.gas).toStrictEqual( - detailsForDraftTransactionState.gas.gasLimit, - ); - expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( - detailsForDraftTransactionState.gas.gasPrice, - ); + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.recipient.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + detailsForDraftTransactionState.amount.value, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + detailsForDraftTransactionState.gas.gasPrice, + ); + }); + + it('should update the draftTransaction txParams recipient to token address when asset is type TOKEN', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, + account: { + address: '0xCurrentAddress', + }, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + }, + }, + amount: { + value: '0x1', + }, + gas: { + gasPrice: '0x3b9aca00', // 1000000000 + gasLimit: '0x5208', // 21000 + }, + }; + + const action = { + type: 'send/updateDraftTransaction', + }; + + const result = sendReducer(detailsForDraftTransactionState, action); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.asset.details.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual('0x0'); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( + detailsForDraftTransactionState.gas.gasPrice, + ); + expect(result.draftTransaction.txParams.data).toStrictEqual( + '0xa9059cbb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001', + ); + }); }); - it('should update the draftTransaction txParams recipient to token address when asset is type TOKEN', () => { - const detailsForDraftTransactionState = { - ...initialState, - status: SEND_STATUSES.VALID, - account: { - address: '0xCurrentAddress', - }, - asset: { - type: ASSET_TYPES.TOKEN, - details: { - address: '0xTokenAddress', + describe('with FEE_MARKET transactions', () => { + it('should properly set fields', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, + account: { + address: '0xCurrentAddress', }, - }, - amount: { - value: '0x1', - }, - gas: { - gasPrice: '0x3b9aca00', // 1000000000 - gasLimit: '0x5208', // 21000 - }, - }; + asset: { + type: '', + }, + recipient: { + address: '0xRecipientAddress', + }, + amount: { + value: '0x1', + }, + gas: { + maxFeePerGas: '0x2540be400', // 10 GWEI + maxPriorityFeePerGas: '0x3b9aca00', // 1 GWEI + gasLimit: '0x5208', // 21000 + }, + eip1559support: true, + }; - const action = { - type: 'send/updateDraftTransaction', - }; + const action = { + type: 'send/updateDraftTransaction', + }; - const result = sendReducer(detailsForDraftTransactionState, action); + const result = sendReducer(detailsForDraftTransactionState, action); - expect(result.draftTransaction.txParams.to).toStrictEqual( - detailsForDraftTransactionState.asset.details.address, - ); - expect(result.draftTransaction.txParams.value).toStrictEqual('0x0'); - expect(result.draftTransaction.txParams.gas).toStrictEqual( - detailsForDraftTransactionState.gas.gasLimit, - ); - expect(result.draftTransaction.txParams.gasPrice).toStrictEqual( - detailsForDraftTransactionState.gas.gasPrice, - ); - expect(result.draftTransaction.txParams.data).toStrictEqual( - '0xa9059cbb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001', - ); + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.recipient.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual( + detailsForDraftTransactionState.amount.value, + ); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + expect(result.draftTransaction.txParams.gasPrice).toBeUndefined(); + expect(result.draftTransaction.txParams.maxFeePerGas).toStrictEqual( + detailsForDraftTransactionState.gas.maxFeePerGas, + ); + expect( + result.draftTransaction.txParams.maxPriorityFeePerGas, + ).toStrictEqual( + detailsForDraftTransactionState.gas.maxPriorityFeePerGas, + ); + }); + + it('should update the draftTransaction txParams recipient to token address when asset is type TOKEN', () => { + const detailsForDraftTransactionState = { + ...initialState, + status: SEND_STATUSES.VALID, + transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, + account: { + address: '0xCurrentAddress', + }, + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0xTokenAddress', + }, + }, + amount: { + value: '0x1', + }, + gas: { + maxFeePerGas: '0x2540be400', // 10 GWEI + maxPriorityFeePerGas: '0x3b9aca00', // 1 GWEI + gasLimit: '0x5208', // 21000 + }, + eip1559support: true, + }; + + const action = { + type: 'send/updateDraftTransaction', + }; + + const result = sendReducer(detailsForDraftTransactionState, action); + + expect(result.draftTransaction.txParams.to).toStrictEqual( + detailsForDraftTransactionState.asset.details.address, + ); + expect(result.draftTransaction.txParams.value).toStrictEqual('0x0'); + expect(result.draftTransaction.txParams.gas).toStrictEqual( + detailsForDraftTransactionState.gas.gasLimit, + ); + + expect(result.draftTransaction.txParams.maxFeePerGas).toStrictEqual( + detailsForDraftTransactionState.gas.maxFeePerGas, + ); + expect( + result.draftTransaction.txParams.maxPriorityFeePerGas, + ).toStrictEqual( + detailsForDraftTransactionState.gas.maxPriorityFeePerGas, + ); + expect(result.draftTransaction.txParams.gasPrice).toBeUndefined(); + expect(result.draftTransaction.txParams.data).toStrictEqual( + '0xa9059cbb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001', + ); + }); }); }); @@ -1032,6 +1162,36 @@ describe('Send Slice', () => { }); describe('Action Creators', () => { + describe('updateGasPrice', () => { + it('should update gas price and update draft transaction with validated state', async () => { + const store = mockStore({ + send: { + gas: { + gasPrice: undefined, + }, + }, + }); + + const newGasPrice = '0x0'; + + await store.dispatch(updateGasPrice(newGasPrice)); + + const actionResult = store.getActions(); + + const expectedActionResult = [ + { + type: 'send/updateGasFees', + payload: { + gasPrice: '0x0', + transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, + }, + }, + ]; + + expect(actionResult).toStrictEqual(expectedActionResult); + }); + }); + describe('UpdateSendAmount', () => { const defaultSendAmountState = { send: { @@ -2013,4 +2173,329 @@ describe('Send Slice', () => { }); }); }); + + describe('selectors', () => { + describe('gas selectors', () => { + it('has a selector that gets gasLimit', () => { + expect(getGasLimit({ send: initialState })).toBe('0x0'); + }); + + it('has a selector that gets gasPrice', () => { + expect(getGasPrice({ send: initialState })).toBe('0x0'); + }); + + it('has a selector that gets gasTotal', () => { + expect(getGasTotal({ send: initialState })).toBe('0x0'); + }); + + it('has a selector to determine if gas fee is in error', () => { + expect(gasFeeIsInError({ send: initialState })).toBe(false); + expect( + gasFeeIsInError({ + send: { + ...initialState, + gas: { + ...initialState.gas, + error: 'yes', + }, + }, + }), + ).toBe(true); + }); + + it('has a selector that gets minimumGasLimit', () => { + expect(getMinimumGasLimitForSend({ send: initialState })).toBe( + GAS_LIMITS.SIMPLE, + ); + }); + + describe('getGasInputMode selector', () => { + it('returns BASIC when on mainnet and advanced inline gas is false', () => { + expect( + getGasInputMode({ + metamask: { + provider: { chainId: MAINNET_CHAIN_ID }, + featureFlags: { advancedInlineGas: false }, + }, + send: initialState, + }), + ).toBe(GAS_INPUT_MODES.BASIC); + }); + + it('returns BASIC when on localhost and advanced inline gas is false and IN_TEST is set', () => { + process.env.IN_TEST = true; + expect( + getGasInputMode({ + metamask: { + provider: { chainId: '0x539' }, + featureFlags: { advancedInlineGas: false }, + }, + send: initialState, + }), + ).toBe(GAS_INPUT_MODES.BASIC); + process.env.IN_TEST = false; + }); + + it('returns INLINE when on mainnet and advanced inline gas is true', () => { + expect( + getGasInputMode({ + metamask: { + provider: { chainId: MAINNET_CHAIN_ID }, + featureFlags: { advancedInlineGas: true }, + }, + send: initialState, + }), + ).toBe(GAS_INPUT_MODES.INLINE); + }); + + it('returns INLINE when on mainnet and advanced inline gas is false but eth_gasPrice estimate is used', () => { + expect( + getGasInputMode({ + metamask: { + provider: { chainId: MAINNET_CHAIN_ID }, + featureFlags: { advancedInlineGas: false }, + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + }, + send: initialState, + }), + ).toBe(GAS_INPUT_MODES.INLINE); + }); + + it('returns INLINE when on mainnet and advanced inline gas is false but eth_gasPrice estimate is used even IN_TEST', () => { + process.env.IN_TEST = true; + expect( + getGasInputMode({ + metamask: { + provider: { chainId: MAINNET_CHAIN_ID }, + featureFlags: { advancedInlineGas: false }, + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + }, + send: initialState, + }), + ).toBe(GAS_INPUT_MODES.INLINE); + process.env.IN_TEST = false; + }); + + it('returns CUSTOM if isCustomGasSet is true', () => { + expect( + getGasInputMode({ + metamask: { + provider: { chainId: MAINNET_CHAIN_ID }, + featureFlags: { advancedInlineGas: true }, + }, + send: { + ...initialState, + gas: { + ...initialState.send, + isCustomGasSet: true, + }, + }, + }), + ).toBe(GAS_INPUT_MODES.CUSTOM); + }); + }); + }); + + describe('asset selectors', () => { + it('has a selector to get the asset', () => { + expect(getSendAsset({ send: initialState })).toMatchObject( + initialState.asset, + ); + }); + + it('has a selector to get the asset address', () => { + expect( + getSendAssetAddress({ + send: { + ...initialState, + asset: { + balance: '0x0', + details: { address: '0x0' }, + type: ASSET_TYPES.TOKEN, + }, + }, + }), + ).toBe('0x0'); + }); + + it('has a selector that determines if asset is sendable based on ERC721 status', () => { + expect(getIsAssetSendable({ send: initialState })).toBe(true); + expect( + getIsAssetSendable({ + send: { + ...initialState, + asset: { + ...initialState, + type: ASSET_TYPES.TOKEN, + details: { isERC721: true }, + }, + }, + }), + ).toBe(false); + }); + }); + + describe('amount selectors', () => { + it('has a selector to get send amount', () => { + expect(getSendAmount({ send: initialState })).toBe('0x0'); + }); + + it('has a selector to get if there is an insufficient funds error', () => { + expect(getIsBalanceInsufficient({ send: initialState })).toBe(false); + expect( + getIsBalanceInsufficient({ + send: { + ...initialState, + gas: { ...initialState.gas, error: INSUFFICIENT_FUNDS_ERROR }, + }, + }), + ).toBe(true); + }); + + it('has a selector to get max mode state', () => { + expect(getSendMaxModeState({ send: initialState })).toBe(false); + expect( + getSendMaxModeState({ + send: { + ...initialState, + amount: { ...initialState.amount, mode: AMOUNT_MODES.MAX }, + }, + }), + ).toBe(true); + }); + + it('has a selector to get the user entered hex data', () => { + expect(getSendHexData({ send: initialState })).toBeNull(); + expect( + getSendHexData({ + send: { + ...initialState, + draftTransaction: { + ...initialState.draftTransaction, + userInputHexData: '0x0', + }, + }, + }), + ).toBe('0x0'); + }); + + it('has a selector to get if there is an amount error', () => { + expect(sendAmountIsInError({ send: initialState })).toBe(false); + expect( + sendAmountIsInError({ + send: { + ...initialState, + amount: { ...initialState.amount, error: 'any' }, + }, + }), + ).toBe(true); + }); + }); + + describe('recipient selectors', () => { + it('has a selector to get recipient address', () => { + expect(getSendTo({ send: initialState })).toBe(''); + expect( + getSendTo({ + send: { + ...initialState, + recipient: { ...initialState.recipient, address: '0xb' }, + }, + }), + ).toBe('0xb'); + }); + + it('has a selector to check if using the my accounts option for recipient selection', () => { + expect( + getIsUsingMyAccountForRecipientSearch({ send: initialState }), + ).toBe(false); + expect( + getIsUsingMyAccountForRecipientSearch({ + send: { + ...initialState, + recipient: { + ...initialState.recipient, + mode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, + }, + }, + }), + ).toBe(true); + }); + + it('has a selector to get recipient user input in input field', () => { + expect(getRecipientUserInput({ send: initialState })).toBe(''); + expect( + getRecipientUserInput({ + send: { + ...initialState, + recipient: { + ...initialState.recipient, + userInput: 'domain.eth', + }, + }, + }), + ).toBe('domain.eth'); + }); + + it('has a selector to get recipient state', () => { + expect(getRecipient({ send: initialState })).toMatchObject( + initialState.recipient, + ); + }); + }); + + describe('send validity selectors', () => { + it('has a selector to get send errors', () => { + expect(getSendErrors({ send: initialState })).toMatchObject({ + gasFee: null, + amount: null, + }); + expect( + getSendErrors({ + send: { + ...initialState, + gas: { + ...initialState.gas, + error: 'gasFeeTest', + }, + amount: { + ...initialState.amount, + error: 'amountTest', + }, + }, + }), + ).toMatchObject({ gasFee: 'gasFeeTest', amount: 'amountTest' }); + }); + + it('has a selector to get send state initialization status', () => { + expect(isSendStateInitialized({ send: initialState })).toBe(false); + expect( + isSendStateInitialized({ + send: { + ...initialState, + stage: SEND_STATUSES.ADD_RECIPIENT, + }, + }), + ).toBe(true); + }); + + it('has a selector to get send state validity', () => { + expect(isSendFormInvalid({ send: initialState })).toBe(false); + expect( + isSendFormInvalid({ + send: { ...initialState, status: SEND_STATUSES.INVALID }, + }), + ).toBe(true); + }); + + it('has a selector to get send stage', () => { + expect(getSendStage({ send: initialState })).toBe(SEND_STAGES.INACTIVE); + expect( + getSendStage({ + send: { ...initialState, stage: SEND_STAGES.ADD_RECIPIENT }, + }), + ).toBe(SEND_STAGES.ADD_RECIPIENT); + }); + }); + }); }); diff --git a/ui/hooks/useGasFeeEstimates.js b/ui/hooks/useGasFeeEstimates.js index ec378b134..c17c43e75 100644 --- a/ui/hooks/useGasFeeEstimates.js +++ b/ui/hooks/useGasFeeEstimates.js @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { useSelector } from 'react-redux'; import { GAS_ESTIMATE_TYPES } from '../../shared/constants/gas'; import { @@ -7,10 +6,7 @@ import { getGasFeeEstimates, isEIP1559Network, } from '../ducks/metamask/metamask'; -import { - disconnectGasFeeEstimatePoller, - getGasFeeEstimatesAndStartPolling, -} from '../store/actions'; +import { useSafeGasEstimatePolling } from './useSafeGasEstimatePolling'; /** * @typedef {keyof typeof GAS_ESTIMATE_TYPES} GasEstimateTypes @@ -43,31 +39,17 @@ export function useGasFeeEstimates() { const gasEstimateType = useSelector(getGasEstimateType); const gasFeeEstimates = useSelector(getGasFeeEstimates); const estimatedGasFeeTimeBounds = useSelector(getEstimatedGasFeeTimeBounds); - useEffect(() => { - let active = true; - let pollToken; - getGasFeeEstimatesAndStartPolling().then((newPollToken) => { - if (active) { - pollToken = newPollToken; - } else { - disconnectGasFeeEstimatePoller(newPollToken); - } - }); - return () => { - active = false; - if (pollToken) { - disconnectGasFeeEstimatePoller(pollToken); - } - }; - }, []); + useSafeGasEstimatePolling(); // We consider the gas estimate to be loading if the gasEstimateType is - // 'NONE' or if the current gasEstimateType does not match the type we expect - // for the current network. e.g, a ETH_GASPRICE estimate when on a network - // supporting EIP-1559. + // 'NONE' or if the current gasEstimateType cannot be supported by the current + // network + const isEIP1559TolerableEstimateType = + gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET || + gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE; const isGasEstimatesLoading = gasEstimateType === GAS_ESTIMATE_TYPES.NONE || - (supportsEIP1559 && gasEstimateType !== GAS_ESTIMATE_TYPES.FEE_MARKET) || + (supportsEIP1559 && !isEIP1559TolerableEstimateType) || (!supportsEIP1559 && gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET); return { diff --git a/ui/hooks/useGasFeeEstimates.test.js b/ui/hooks/useGasFeeEstimates.test.js index d30127759..3b54dd825 100644 --- a/ui/hooks/useGasFeeEstimates.test.js +++ b/ui/hooks/useGasFeeEstimates.test.js @@ -173,11 +173,11 @@ describe('useGasFeeEstimates', () => { }); }); - it('indicates that gas estimates are loading when gasEstimateType is not FEE_MARKET but network supports EIP-1559', () => { + it('indicates that gas estimates are loading when gasEstimateType is not FEE_MARKET or ETH_GASPRICE, but network supports EIP-1559', () => { useSelector.mockImplementation( generateUseSelectorRouter({ isEIP1559Network: true, - gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, gasFeeEstimates: { gasPrice: '10', }, @@ -189,7 +189,7 @@ describe('useGasFeeEstimates', () => { } = renderHook(() => useGasFeeEstimates()); expect(current).toMatchObject({ gasFeeEstimates: { gasPrice: '10' }, - gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, estimatedGasFeeTimeBounds: undefined, isGasEstimatesLoading: true, }); diff --git a/ui/hooks/useGasFeeInputs.js b/ui/hooks/useGasFeeInputs.js index 0312a2123..8fa776b70 100644 --- a/ui/hooks/useGasFeeInputs.js +++ b/ui/hooks/useGasFeeInputs.js @@ -18,6 +18,8 @@ import { getMinimumGasTotalInHexWei, } from '../../shared/modules/gas.utils'; import { PRIMARY, SECONDARY } from '../helpers/constants/common'; +import { isEIP1559Network } from '../ducks/metamask/metamask'; + import { hexWEIToDecGWEI, decGWEIToHexWEI, @@ -173,7 +175,7 @@ export function useGasFeeInputs( ) { const { balance: ethBalance } = useSelector(getSelectedAccount); const txData = useSelector(txDataSelector); - + const networkSupportsEIP1559 = useSelector(isEIP1559Network); // We need to know whether to show fiat conversions or not, so that we can // default our fiat values to empty strings if showing fiat is not wanted or // possible. @@ -282,12 +284,13 @@ export function useGasFeeInputs( const gasSettings = { gasLimit: decimalToHex(gasLimit), }; - - if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { - gasSettings.maxFeePerGas = decGWEIToHexWEI(maxFeePerGasToUse); - gasSettings.maxPriorityFeePerGas = decGWEIToHexWEI( - maxPriorityFeePerGasToUse, - ); + if (networkSupportsEIP1559) { + gasSettings.maxFeePerGas = maxFeePerGasToUse + ? decGWEIToHexWEI(maxFeePerGasToUse) + : decGWEIToHexWEI(gasPriceToUse || '0'); + gasSettings.maxPriorityFeePerGas = maxPriorityFeePerGasToUse + ? decGWEIToHexWEI(maxPriorityFeePerGasToUse) + : gasSettings.maxFeePerGas; gasSettings.baseFeePerGas = decGWEIToHexWEI( gasFeeEstimates.estimatedBaseFee ?? '0', ); diff --git a/ui/hooks/useGasFeeInputs.test.js b/ui/hooks/useGasFeeInputs.test.js index 96353be8f..75358ba03 100644 --- a/ui/hooks/useGasFeeInputs.test.js +++ b/ui/hooks/useGasFeeInputs.test.js @@ -3,9 +3,11 @@ import { useSelector } from 'react-redux'; import { GAS_ESTIMATE_TYPES } from '../../shared/constants/gas'; import { multiplyCurrencies } from '../../shared/modules/conversion.utils'; import { + isEIP1559Network, getConversionRate, getNativeCurrency, } from '../ducks/metamask/metamask'; + import { ETH, PRIMARY } from '../helpers/constants/common'; import { getCurrentCurrency, @@ -102,7 +104,9 @@ const HIGH_FEE_MARKET_ESTIMATE_RETURN_VALUE = { estimatedGasFeeTimeBounds: {}, }; -const generateUseSelectorRouter = () => (selector) => { +const generateUseSelectorRouter = ({ isEIP1559NetworkResponse } = {}) => ( + selector, +) => { if (selector === getConversionRate) { return MOCK_ETH_USD_CONVERSION_RATE; } @@ -127,6 +131,9 @@ const generateUseSelectorRouter = () => (selector) => { balance: '0x440aa47cc2556', }; } + if (selector === isEIP1559Network) { + return isEIP1559NetworkResponse; + } return undefined; }; @@ -178,6 +185,9 @@ describe('useGasFeeInputs', () => { }); it('updates values when user modifies gasPrice', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ isEIP1559NetworkResponse: false }), + ); const { result } = renderHook(() => useGasFeeInputs()); expect(result.current.gasPrice).toBe( LEGACY_GAS_ESTIMATE_RETURN_VALUE.gasFeeEstimates.medium, @@ -242,6 +252,9 @@ describe('useGasFeeInputs', () => { }); it('updates values when user modifies maxFeePerGas', () => { + useSelector.mockImplementation( + generateUseSelectorRouter({ isEIP1559NetworkResponse: true }), + ); const { result } = renderHook(() => useGasFeeInputs()); expect(result.current.maxFeePerGas).toBe( FEE_MARKET_ESTIMATE_RETURN_VALUE.gasFeeEstimates.medium @@ -297,7 +310,9 @@ describe('useGasFeeInputs', () => { useGasFeeEstimates.mockImplementation( () => HIGH_FEE_MARKET_ESTIMATE_RETURN_VALUE, ); - useSelector.mockImplementation(generateUseSelectorRouter()); + useSelector.mockImplementation( + generateUseSelectorRouter({ isEIP1559NetworkResponse: true }), + ); }); it('should return true', () => { diff --git a/ui/hooks/useSafeGasEstimatePolling.js b/ui/hooks/useSafeGasEstimatePolling.js new file mode 100644 index 000000000..a5b803eb7 --- /dev/null +++ b/ui/hooks/useSafeGasEstimatePolling.js @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { + disconnectGasFeeEstimatePoller, + getGasFeeEstimatesAndStartPolling, +} from '../store/actions'; + +/** + * Provides a reusable hook that can be used for safely updating the polling + * data in the gas fee controller. It makes a request to get estimates and + * begin polling, keeping track of the poll token for the lifetime of the hook. + * It then disconnects polling upon unmount. If the hook is unmounted while waiting + * for `getGasFeeEstimatesAndStartPolling` to resolve, the `active` flag ensures + * that a call to disconnect happens after promise resolution. + */ +export function useSafeGasEstimatePolling() { + useEffect(() => { + let active = true; + let pollToken; + getGasFeeEstimatesAndStartPolling().then((newPollToken) => { + if (active) { + pollToken = newPollToken; + } else { + disconnectGasFeeEstimatePoller(newPollToken); + } + }); + return () => { + active = false; + if (pollToken) { + disconnectGasFeeEstimatePoller(pollToken); + } + }; + }, []); +} diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js index fda840142..7c29cf490 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -39,6 +39,10 @@ import InfoTooltip from '../../components/ui/info-tooltip/info-tooltip'; import GasTiming from '../../components/app/gas-timing/gas-timing.component'; import { COLORS } from '../../helpers/constants/design-system'; +import { + disconnectGasFeeEstimatePoller, + getGasFeeEstimatesAndStartPolling, +} from '../../store/actions'; export default class ConfirmTransactionBase extends Component { static contextTypes = { @@ -58,7 +62,8 @@ export default class ConfirmTransactionBase extends Component { fromAddress: PropTypes.string, fromName: PropTypes.string, hexTransactionAmount: PropTypes.string, - hexTransactionFee: PropTypes.string, + hexMinimumTransactionFee: PropTypes.string, + hexMaximumTransactionFee: PropTypes.string, hexTransactionTotal: PropTypes.string, methodData: PropTypes.object, nonce: PropTypes.string, @@ -191,7 +196,7 @@ export default class ConfirmTransactionBase extends Component { const { balance, conversionRate, - hexTransactionFee, + hexMaximumTransactionFee, txData: { simulationFails, txParams: { value: amount } = {} } = {}, customGas, noGasPrice, @@ -201,7 +206,7 @@ export default class ConfirmTransactionBase extends Component { balance && !isBalanceSufficient({ amount, - gasTotal: hexTransactionFee || '0x0', + gasTotal: hexMaximumTransactionFee || '0x0', balance, conversionRate, }); @@ -282,7 +287,7 @@ export default class ConfirmTransactionBase extends Component { const { primaryTotalTextOverride, secondaryTotalTextOverride, - hexTransactionFee, + hexMinimumTransactionFee, hexTransactionTotal, useNonceField, customNonceValue, @@ -422,14 +427,14 @@ export default class ConfirmTransactionBase extends Component { detailText={ } detailTotal={ } @@ -496,7 +501,7 @@ export default class ConfirmTransactionBase extends Component {
{ + if (this._isMounted) { + this.setState({ pollingToken }); + } else { + disconnectGasFeeEstimatePoller(pollingToken); + } + }); } componentWillUnmount() { + this._isMounted = false; + if (this.state.pollingToken) { + disconnectGasFeeEstimatePoller(this.state.pollingToken); + } this._removeBeforeUnload(); } diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js index df2a0b670..cd793b80d 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -38,7 +38,10 @@ import { import { getMostRecentOverviewPage } from '../../ducks/history/history'; import { transactionMatchesNetwork } from '../../../shared/modules/transaction.utils'; import { toChecksumHexAddress } from '../../../shared/modules/hexstring-utils'; -import { updateTransactionGasFees } from '../../ducks/metamask/metamask'; +import { + updateTransactionGasFees, + isEIP1559Network, +} from '../../ducks/metamask/metamask'; import ConfirmTransactionBase from './confirm-transaction-base.component'; const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { @@ -65,6 +68,7 @@ const mapStateToProps = (state, ownProps) => { } = ownProps; const { id: paramsTransactionId } = params; const isMainnet = getIsMainnet(state); + const supportsEIP1599 = isEIP1559Network(state); const { confirmTransaction, metamask } = state; const { ensResolutionsByAddress, @@ -111,7 +115,8 @@ const mapStateToProps = (state, ownProps) => { const { hexTransactionAmount, - hexTransactionFee, + hexMinimumTransactionFee, + hexMaximumTransactionFee, hexTransactionTotal, } = transactionFeeSelector(state, transaction); @@ -158,7 +163,8 @@ const mapStateToProps = (state, ownProps) => { toName, toNickname, hexTransactionAmount, - hexTransactionFee, + hexMinimumTransactionFee, + hexMaximumTransactionFee, hexTransactionTotal, txData: fullTxData, tokenData, @@ -187,6 +193,7 @@ const mapStateToProps = (state, ownProps) => { isMainnet, isEthGasPrice, noGasPrice, + supportsEIP1599, }; }; diff --git a/ui/pages/send/send-header/send-header.component.js b/ui/pages/send/send-header/send-header.component.js index 1b8af5312..e77c2c012 100644 --- a/ui/pages/send/send-header/send-header.component.js +++ b/ui/pages/send/send-header/send-header.component.js @@ -27,10 +27,7 @@ export default function SendHeader() { let title = asset.type === ASSET_TYPES.NATIVE ? t('send') : t('sendTokens'); - if ( - stage === SEND_STAGES.ADD_RECIPIENT || - stage === SEND_STAGES.UNINITIALIZED - ) { + if (stage === SEND_STAGES.ADD_RECIPIENT || stage === SEND_STAGES.INACTIVE) { title = t('addRecipient'); } else if (stage === SEND_STAGES.EDIT) { title = t('edit'); diff --git a/ui/pages/send/send-header/send-header.component.test.js b/ui/pages/send/send-header/send-header.component.test.js index a8fa64342..02598756d 100644 --- a/ui/pages/send/send-header/send-header.component.test.js +++ b/ui/pages/send/send-header/send-header.component.test.js @@ -21,7 +21,7 @@ jest.mock('react-router-dom', () => { describe('SendHeader Component', () => { describe('Title', () => { - it('should render "Add Recipient" for UNINITIALIZED or ADD_RECIPIENT stages', () => { + it('should render "Add Recipient" for INACTIVE or ADD_RECIPIENT stages', () => { const { getByText, rerender } = renderWithProvider( , configureMockStore(middleware)({ diff --git a/ui/selectors/confirm-transaction.js b/ui/selectors/confirm-transaction.js index 08c40cda8..d97b009af 100644 --- a/ui/selectors/confirm-transaction.js +++ b/ui/selectors/confirm-transaction.js @@ -4,14 +4,25 @@ import { calcTokenAmount } from '../helpers/utils/token-util'; import { roundExponential, getValueFromWeiHex, - getHexGasTotal, getTransactionFee, addFiat, addEth, } from '../helpers/utils/confirm-tx.util'; import { sumHexes } from '../helpers/utils/transactions.util'; import { transactionMatchesNetwork } from '../../shared/modules/transaction.utils'; -import { getNativeCurrency } from '../ducks/metamask/metamask'; +import { + getGasEstimateType, + getGasFeeEstimates, + getNativeCurrency, + isEIP1559Network, +} from '../ducks/metamask/metamask'; +import { TRANSACTION_ENVELOPE_TYPES } from '../../shared/constants/transaction'; +import { decGWEIToHexWEI } from '../helpers/utils/conversions.util'; +import { GAS_ESTIMATE_TYPES } from '../../shared/constants/gas'; +import { + getMaximumGasTotalInHexWei, + getMinimumGasTotalInHexWei, +} from '../../shared/modules/gas.utils'; import { getAveragePriceEstimateInHexWEI } from './custom-gas'; import { getCurrentChainId, deprecatedGetCurrentNetworkId } from './selectors'; @@ -218,17 +229,56 @@ export const transactionFeeSelector = function (state, txData) { const currentCurrency = currentCurrencySelector(state); const conversionRate = conversionRateSelector(state); const nativeCurrency = getNativeCurrency(state); + const gasFeeEstimates = getGasFeeEstimates(state) || {}; + const gasEstimateType = getGasEstimateType(state); + const networkSupportsEIP1559 = isEIP1559Network(state); - const { txParams: { value = '0x0', gas: gasLimit = '0x0' } = {} } = txData; + const gasEstimationObject = { + gasLimit: txData.txParams?.gas ?? '0x0', + }; - // if the gas price from our infura endpoint is null or undefined - // use the metaswap average price estimation as a fallback - let { txParams: { gasPrice } = {} } = txData; - - if (!gasPrice) { - gasPrice = getAveragePriceEstimateInHexWEI(state) || '0x0'; + if (networkSupportsEIP1559) { + const { medium = {}, gasPrice = '0' } = gasFeeEstimates; + if (txData.txParams?.type === TRANSACTION_ENVELOPE_TYPES.LEGACY) { + gasEstimationObject.gasPrice = + txData.txParams?.gasPrice ?? decGWEIToHexWEI(gasPrice); + } else { + const { suggestedMaxPriorityFeePerGas, suggestedMaxFeePerGas } = medium; + gasEstimationObject.maxFeePerGas = + txData.txParams?.maxFeePerGas ?? + decGWEIToHexWEI(suggestedMaxFeePerGas || gasPrice); + gasEstimationObject.maxPriorityFeePerGas = + txData.txParams?.maxPriorityFeePerGas ?? + ((suggestedMaxPriorityFeePerGas && + decGWEIToHexWEI(suggestedMaxPriorityFeePerGas)) || + gasEstimationObject.maxFeePerGas); + gasEstimationObject.baseFeePerGas = decGWEIToHexWEI( + gasFeeEstimates.estimatedBaseFee, + ); + } + } else { + switch (gasEstimateType) { + case GAS_ESTIMATE_TYPES.NONE: + gasEstimationObject.gasPrice = txData.txParams?.gasPrice ?? '0x0'; + break; + case GAS_ESTIMATE_TYPES.ETH_GASPRICE: + gasEstimationObject.gasPrice = + txData.txParams?.gasPrice ?? + decGWEIToHexWEI(gasFeeEstimates.gasPrice); + break; + case GAS_ESTIMATE_TYPES.LEGACY: + gasEstimationObject.gasPrice = + txData.txParams?.gasPrice ?? getAveragePriceEstimateInHexWEI(state); + break; + case GAS_ESTIMATE_TYPES.FEE_MARKET: + break; + default: + break; + } } + const { txParams: { value = '0x0' } = {} } = txData; + const fiatTransactionAmount = getValueFromWeiHex({ value, fromCurrency: nativeCurrency, @@ -244,17 +294,31 @@ export const transactionFeeSelector = function (state, txData) { numberOfDecimals: 6, }); - const hexTransactionFee = getHexGasTotal({ gasLimit, gasPrice }); + const hexMinimumTransactionFee = getMinimumGasTotalInHexWei( + gasEstimationObject, + ); + const hexMaximumTransactionFee = getMaximumGasTotalInHexWei( + gasEstimationObject, + ); - const fiatTransactionFee = getTransactionFee({ - value: hexTransactionFee, + const fiatMinimumTransactionFee = getTransactionFee({ + value: hexMinimumTransactionFee, fromCurrency: nativeCurrency, toCurrency: currentCurrency, numberOfDecimals: 2, conversionRate, }); + + const fiatMaximumTransactionFee = getTransactionFee({ + value: hexMaximumTransactionFee, + fromCurrency: nativeCurrency, + toCurrency: currentCurrency, + numberOfDecimals: 2, + conversionRate, + }); + const ethTransactionFee = getTransactionFee({ - value: hexTransactionFee, + value: hexMinimumTransactionFee, fromCurrency: nativeCurrency, toCurrency: nativeCurrency, numberOfDecimals: 6, @@ -262,18 +326,20 @@ export const transactionFeeSelector = function (state, txData) { }); const fiatTransactionTotal = addFiat( - fiatTransactionFee, + fiatMinimumTransactionFee, fiatTransactionAmount, ); const ethTransactionTotal = addEth(ethTransactionFee, ethTransactionAmount); - const hexTransactionTotal = sumHexes(value, hexTransactionFee); + const hexTransactionTotal = sumHexes(value, hexMinimumTransactionFee); return { hexTransactionAmount: value, fiatTransactionAmount, ethTransactionAmount, - hexTransactionFee, - fiatTransactionFee, + hexMinimumTransactionFee, + fiatMinimumTransactionFee, + hexMaximumTransactionFee, + fiatMaximumTransactionFee, ethTransactionFee, fiatTransactionTotal, ethTransactionTotal,