diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 2abbd8984..bce8cbc76 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -651,7 +651,7 @@ "message": "High" }, "editGasLimitOutOfBounds": { - "message": "Gas limit must be greater than 20999 and less than 7920027" + "message": "Gas limit must be at least $1" }, "editGasLimitTooltip": { "message": "Gas limit is the maximum units of gas you are willing to use. Units of gas are a multiplier to “Max priority fee” and “Max fee”." @@ -1337,6 +1337,9 @@ "networkNamePolygon": { "message": "Polygon" }, + "networkNameRinkeby": { + "message": "Rinkeby" + }, "networkNameTestnet": { "message": "Testnet" }, @@ -2146,9 +2149,19 @@ "message": "The swap of $1 to $2", "description": "Tells a user that they need to confirm on their hardware wallet a swap of 2 tokens. $1 is a source token and $2 is a destination token" }, + "swapGasFeesDetails": { + "message": "Gas fees are estimated and will fluctuate based on network traffic and transaction complexity." + }, + "swapGasFeesLearnMore": { + "message": "Learn more about gas fees" + }, "swapGasFeesSplit": { "message": "Gas fees on the previous screen are split between these two transactions." }, + "swapGasFeesSummary": { + "message": "Gas fees are paid to crypto miners who process transactions on the $1 network. MetaMask does not profit from gas fees.", + "description": "$1 is the selected network, e.g. Ethereum or BSC" + }, "swapHighSlippageWarning": { "message": "Slippage amount is very high." }, diff --git a/app/scripts/controllers/swaps.js b/app/scripts/controllers/swaps.js index 17425b678..1615b2594 100644 --- a/app/scripts/controllers/swaps.js +++ b/app/scripts/controllers/swaps.js @@ -6,7 +6,11 @@ import { mapValues, cloneDeep } from 'lodash'; import abi from 'human-standard-token-abi'; import { calcTokenAmount } from '../../../ui/helpers/utils/token-util'; import { calcGasTotal } from '../../../ui/pages/send/send.utils'; -import { conversionUtil } from '../../../shared/modules/conversion.utils'; +import { + conversionUtil, + decGWEIToHexWEI, + addCurrencies, +} from '../../../shared/modules/conversion.utils'; import { DEFAULT_ERC20_APPROVE_GAS, QUOTES_EXPIRED_ERROR, @@ -14,6 +18,7 @@ import { SWAPS_FETCH_ORDER_CONFLICT, SWAPS_CHAINID_CONTRACT_ADDRESS_MAP, } from '../../../shared/constants/swaps'; +import { GAS_ESTIMATE_TYPES } from '../../../shared/constants/gas'; import { isSwapsDefaultTokenAddress } from '../../../shared/modules/swaps.utils'; @@ -66,6 +71,8 @@ const initialState = { quotesLastFetched: null, customMaxGas: '', customGasPrice: null, + customMaxFeePerGas: null, + customMaxPriorityFeePerGas: null, selectedAggId: null, customApproveTxData: '', errorKey: '', @@ -87,6 +94,7 @@ export default class SwapsController { fetchTradesInfo = defaultFetchTradesInfo, fetchSwapsQuoteRefreshTime = defaultFetchSwapsQuoteRefreshTime, getCurrentChainId, + getEIP1559GasFeeEstimates, }) { this.store = new ObservableStore({ swapsState: { ...initialState.swapsState }, @@ -95,6 +103,7 @@ export default class SwapsController { this._fetchTradesInfo = fetchTradesInfo; this._fetchSwapsQuoteRefreshTime = fetchSwapsQuoteRefreshTime; this._getCurrentChainId = getCurrentChainId; + this._getEIP1559GasFeeEstimates = getEIP1559GasFeeEstimates; this.getBufferedGasLimit = getBufferedGasLimit; this.tokenRatesStore = tokenRatesStore; @@ -440,6 +449,23 @@ export default class SwapsController { }); } + setSwapsTxMaxFeePerGas(maxFeePerGas) { + const { swapsState } = this.store.getState(); + this.store.updateState({ + swapsState: { ...swapsState, customMaxFeePerGas: maxFeePerGas }, + }); + } + + setSwapsTxMaxFeePriorityPerGas(maxPriorityFeePerGas) { + const { swapsState } = this.store.getState(); + this.store.updateState({ + swapsState: { + ...swapsState, + customMaxPriorityFeePerGas: maxPriorityFeePerGas, + }, + }); + } + setSwapsTxGasLimit(gasLimit) { const { swapsState } = this.store.getState(); this.store.updateState({ @@ -494,16 +520,11 @@ export default class SwapsController { clearTimeout(this.pollingTimeout); } - async _getEthersGasPrice() { - const ethersGasPrice = await this.ethersProvider.getGasPrice(); - return ethersGasPrice.toHexString(); - } - async _findTopQuoteAndCalculateSavings(quotes = {}) { const tokenConversionRates = this.tokenRatesStore.getState() .contractExchangeRates; const { - swapsState: { customGasPrice }, + swapsState: { customGasPrice, customMaxPriorityFeePerGas }, } = this.store.getState(); const chainId = this._getCurrentChainId(); @@ -514,7 +535,36 @@ export default class SwapsController { const newQuotes = cloneDeep(quotes); - const usedGasPrice = customGasPrice || (await this._getEthersGasPrice()); + const { + gasFeeEstimates, + gasEstimateType, + } = await this._getEIP1559GasFeeEstimates(); + + let usedGasPrice = '0x0'; + + if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { + const { + high: { suggestedMaxPriorityFeePerGas }, + estimatedBaseFee, + } = gasFeeEstimates; + + usedGasPrice = addCurrencies( + customMaxPriorityFeePerGas || // Is already in hex WEI. + decGWEIToHexWEI(suggestedMaxPriorityFeePerGas), + decGWEIToHexWEI(estimatedBaseFee), + { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + numberOfDecimals: 6, + }, + ); + } else if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { + usedGasPrice = customGasPrice || decGWEIToHexWEI(gasFeeEstimates.high); + } else if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { + usedGasPrice = + customGasPrice || decGWEIToHexWEI(gasFeeEstimates.gasPrice); + } let topAggId = null; let overallValueOfBestQuoteForSorting = null; diff --git a/app/scripts/controllers/swaps.test.js b/app/scripts/controllers/swaps.test.js index 642191f92..25d7738aa 100644 --- a/app/scripts/controllers/swaps.test.js +++ b/app/scripts/controllers/swaps.test.js @@ -13,6 +13,7 @@ import { import { ETH_SWAPS_TOKEN_OBJECT } from '../../../shared/constants/swaps'; import { createTestProviderTools } from '../../../test/stub/provider'; import { SECOND } from '../../../shared/constants/time'; +import { GAS_ESTIMATE_TYPES } from '../../../shared/constants/gas'; import SwapsController, { utils } from './swaps'; import { NETWORK_EVENTS } from './network'; @@ -120,7 +121,9 @@ const EMPTY_INIT_STATE = { tradeTxId: null, approveTxId: null, quotesLastFetched: null, + customMaxFeePerGas: null, customMaxGas: '', + customMaxPriorityFeePerGas: null, customGasPrice: null, selectedAggId: null, customApproveTxData: '', @@ -138,6 +141,14 @@ const fetchTradesInfoStub = sandbox.stub(); const fetchSwapsQuoteRefreshTimeStub = sandbox.stub(); const getCurrentChainIdStub = sandbox.stub(); getCurrentChainIdStub.returns(MAINNET_CHAIN_ID); +const getEIP1559GasFeeEstimatesStub = sandbox.stub(() => { + return { + gasFeeEstimates: { + high: '150', + }, + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + }; +}); describe('SwapsController', function () { let provider; @@ -152,6 +163,7 @@ describe('SwapsController', function () { fetchTradesInfo: fetchTradesInfoStub, fetchSwapsQuoteRefreshTime: fetchSwapsQuoteRefreshTimeStub, getCurrentChainId: getCurrentChainIdStub, + getEIP1559GasFeeEstimates: getEIP1559GasFeeEstimatesStub, }); }; @@ -687,8 +699,8 @@ describe('SwapsController', function () { total: '5.4949494949494949495', medianMetaMaskFee: '0.44444444444444444444', }, - ethFee: '33554432', - overallValueOfQuote: '-33554382', + ethFee: '5.033165', + overallValueOfQuote: '44.966835', metaMaskFeeInEth: '0.5050505050505050505', ethValueOfTokens: '50', }); diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 7d5c130d9..263d01c51 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -1200,10 +1200,12 @@ export default class TransactionController extends EventEmitter { txMeta.chainId, ); - const quoteVsExecutionRatio = `${new BigNumber(tokensReceived, 10) - .div(txMeta.swapMetaData.token_to_amount, 10) - .times(100) - .round(2)}%`; + const quoteVsExecutionRatio = tokensReceived + ? `${new BigNumber(tokensReceived, 10) + .div(txMeta.swapMetaData.token_to_amount, 10) + .times(100) + .round(2)}%` + : null; const estimatedVsUsedGasRatio = `${new BigNumber( txMeta.txReceipt.gasUsed, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 5e095e2a5..3267e12e3 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -498,6 +498,9 @@ export default class MetamaskController extends EventEmitter { getCurrentChainId: this.networkController.getCurrentChainId.bind( this.networkController, ), + getEIP1559GasFeeEstimates: this.gasFeeController.fetchGasFeeEstimates.bind( + this.gasFeeController, + ), }); // ensure accountTracker updates balances after network change @@ -1042,6 +1045,14 @@ export default class MetamaskController extends EventEmitter { swapsController.setSwapsTxGasLimit, swapsController, ), + setSwapsTxMaxFeePerGas: nodeify( + swapsController.setSwapsTxMaxFeePerGas, + swapsController, + ), + setSwapsTxMaxFeePriorityPerGas: nodeify( + swapsController.setSwapsTxMaxFeePriorityPerGas, + swapsController, + ), safeRefetchQuotes: nodeify( swapsController.safeRefetchQuotes, swapsController, diff --git a/shared/constants/gas.js b/shared/constants/gas.js index 004e0919b..553414fff 100644 --- a/shared/constants/gas.js +++ b/shared/constants/gas.js @@ -37,4 +37,5 @@ export const EDIT_GAS_MODES = { SPEED_UP: 'speed-up', CANCEL: 'cancel', MODIFY_IN_PLACE: 'modify-in-place', + SWAPS: 'swaps', }; diff --git a/shared/constants/swaps.js b/shared/constants/swaps.js index dba447b2c..b9d75f99a 100644 --- a/shared/constants/swaps.js +++ b/shared/constants/swaps.js @@ -9,6 +9,7 @@ import { POLYGON_CHAIN_ID, MATIC_SYMBOL, MATIC_TOKEN_IMAGE_URL, + RINKEBY_CHAIN_ID, } from './network'; export const QUOTES_EXPIRED_ERROR = 'quotes-expired'; @@ -54,6 +55,14 @@ export const TEST_ETH_SWAPS_TOKEN_OBJECT = { iconUrl: TEST_ETH_TOKEN_IMAGE_URL, }; +export const RINKEBY_SWAPS_TOKEN_OBJECT = { + symbol: ETH_SYMBOL, + name: 'Ether', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: TEST_ETH_TOKEN_IMAGE_URL, +}; + // A gas value for ERC20 approve calls that should be sufficient for all ERC20 approve implementations export const DEFAULT_ERC20_APPROVE_GAS = '0x1d4c0'; @@ -78,6 +87,7 @@ const SWAPS_TESTNET_HOST = 'https://metaswap-api.airswap-dev.codefi.network'; const BSC_DEFAULT_BLOCK_EXPLORER_URL = 'https://bscscan.com/'; const MAINNET_DEFAULT_BLOCK_EXPLORER_URL = 'https://etherscan.io/'; +const RINKEBY_DEFAULT_BLOCK_EXPLORER_URL = 'https://rinkeby.etherscan.io/'; const POLYGON_DEFAULT_BLOCK_EXPLORER_URL = 'https://polygonscan.com/'; export const ALLOWED_SWAPS_CHAIN_IDS = { @@ -85,6 +95,7 @@ export const ALLOWED_SWAPS_CHAIN_IDS = { [SWAPS_TESTNET_CHAIN_ID]: true, [BSC_CHAIN_ID]: true, [POLYGON_CHAIN_ID]: true, + [RINKEBY_CHAIN_ID]: true, }; // This is mapping for v1 URLs and will be removed once we migrate to v2. @@ -92,6 +103,7 @@ export const METASWAP_CHAINID_API_HOST_MAP = { [MAINNET_CHAIN_ID]: METASWAP_ETH_API_HOST, [SWAPS_TESTNET_CHAIN_ID]: SWAPS_TESTNET_HOST, [BSC_CHAIN_ID]: METASWAP_BSC_API_HOST, + [RINKEBY_CHAIN_ID]: SWAPS_TESTNET_HOST, }; export const SWAPS_CHAINID_CONTRACT_ADDRESS_MAP = { @@ -99,6 +111,7 @@ export const SWAPS_CHAINID_CONTRACT_ADDRESS_MAP = { [SWAPS_TESTNET_CHAIN_ID]: TESTNET_CONTRACT_ADDRESS, [BSC_CHAIN_ID]: BSC_CONTRACT_ADDRESS, [POLYGON_CHAIN_ID]: POLYGON_CONTRACT_ADDRESS, + [RINKEBY_CHAIN_ID]: TESTNET_CONTRACT_ADDRESS, }; export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = { @@ -106,14 +119,17 @@ export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = { [SWAPS_TESTNET_CHAIN_ID]: TEST_ETH_SWAPS_TOKEN_OBJECT, [BSC_CHAIN_ID]: BNB_SWAPS_TOKEN_OBJECT, [POLYGON_CHAIN_ID]: MATIC_SWAPS_TOKEN_OBJECT, + [RINKEBY_CHAIN_ID]: RINKEBY_SWAPS_TOKEN_OBJECT, }; export const SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP = { [BSC_CHAIN_ID]: BSC_DEFAULT_BLOCK_EXPLORER_URL, [MAINNET_CHAIN_ID]: MAINNET_DEFAULT_BLOCK_EXPLORER_URL, [POLYGON_CHAIN_ID]: POLYGON_DEFAULT_BLOCK_EXPLORER_URL, + [RINKEBY_CHAIN_ID]: RINKEBY_DEFAULT_BLOCK_EXPLORER_URL, }; export const ETHEREUM = 'ethereum'; export const POLYGON = 'polygon'; export const BSC = 'bsc'; +export const RINKEBY = 'rinkeby'; diff --git a/shared/modules/conversion.utils.js b/shared/modules/conversion.utils.js index 0b550e67b..94ce17495 100644 --- a/shared/modules/conversion.utils.js +++ b/shared/modules/conversion.utils.js @@ -268,6 +268,15 @@ const toNegative = (n, options = {}) => { return multiplyCurrencies(n, -1, options); }; +export function decGWEIToHexWEI(decGWEI) { + return conversionUtil(decGWEI, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + fromDenomination: 'GWEI', + toDenomination: 'WEI', + }); +} + export { conversionUtil, addCurrencies, diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index 325c9f2b5..0e93e04ee 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -217,7 +217,7 @@ export const createSwapsMockStore = () => { selectedAggId: 'TEST_AGG_2', customApproveTxData: '', errorKey: '', - topAggId: null, + topAggId: 'TEST_AGG_BEST', routeState: '', swapsFeatureIsLive: false, useNewSwapsApi: false, diff --git a/test/jest/mocks.js b/test/jest/mocks.js index 4d7b4cd68..6e257adaa 100644 --- a/test/jest/mocks.js +++ b/test/jest/mocks.js @@ -79,3 +79,27 @@ export const createFeatureFlagsResponse = () => { }, }; }; + +export const createGasFeeEstimatesForFeeMarket = () => { + return { + low: { + minWaitTimeEstimate: 180000, + maxWaitTimeEstimate: 300000, + suggestedMaxPriorityFeePerGas: '3', + suggestedMaxFeePerGas: '53', + }, + medium: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '7', + suggestedMaxFeePerGas: '70', + }, + high: { + minWaitTimeEstimate: 0, + maxWaitTimeEstimate: 15000, + suggestedMaxPriorityFeePerGas: '10', + suggestedMaxFeePerGas: '100', + }, + estimatedBaseFee: '50', + }; +}; 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 23f2e77cb..b3b43625e 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 @@ -33,6 +33,7 @@ export default function AdvancedGasControls({ maxPriorityFeeFiat, maxFeeFiat, gasErrors, + minimumGasLimit = 21000, }) { const t = useContext(I18nContext); @@ -65,7 +66,7 @@ export default function AdvancedGasControls({ titleText={t('gasLimit')} error={ gasErrors?.gasLimit - ? getGasFormErrorText(gasErrors.gasLimit, t) + ? getGasFormErrorText(gasErrors.gasLimit, t, { minimumGasLimit }) : null } onChange={setGasLimit} @@ -231,4 +232,5 @@ AdvancedGasControls.propTypes = { maxPriorityFeeFiat: PropTypes.string, maxFeeFiat: PropTypes.string, gasErrors: PropTypes.object, + minimumGasLimit: PropTypes.number, }; 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 184e25ab3..ee80bddee 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 @@ -60,6 +60,7 @@ export default function EditGasDisplay({ warning, gasErrors, onManualChange, + minimumGasLimit, }) { const t = useContext(I18nContext); @@ -218,6 +219,7 @@ export default function EditGasDisplay({ maxFeeFiat={maxFeePerGasFiat} gasErrors={gasErrors} onManualChange={onManualChange} + minimumGasLimit={minimumGasLimit} /> )} @@ -266,4 +268,5 @@ EditGasDisplay.propTypes = { transaction: PropTypes.object, gasErrors: PropTypes.object, onManualChange: PropTypes.func, + minimumGasLimit: PropTypes.number, }; 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 fc55ec9a2..8796b894a 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 @@ -13,6 +13,7 @@ import { import { decGWEIToHexWEI, decimalToHex, + hexToDecimal, } from '../../../helpers/utils/conversions.util'; import Popover from '../../ui/popover'; @@ -27,6 +28,7 @@ import { hideModal, hideSidebar, updateTransaction, + updateCustomSwapsEIP1559GasParams, } from '../../../store/actions'; import LoadingHeartBeat from '../../ui/loading-heartbeat'; @@ -38,6 +40,7 @@ export default function EditGasPopover({ transaction, mode, onClose, + minimumGasLimit, }) { const t = useContext(I18nContext); const dispatch = useDispatch(); @@ -56,6 +59,8 @@ export default function EditGasPopover({ setDappSuggestedGasFeeAcknowledged, ] = useState(false); + const minimumGasLimitDec = hexToDecimal(minimumGasLimit); + const { maxPriorityFeePerGas, setMaxPriorityFeePerGas, @@ -78,7 +83,7 @@ export default function EditGasPopover({ hasGasErrors, gasErrors, onManualChange, - } = useGasFeeInputs(defaultEstimateToUse, transaction); + } = useGasFeeInputs(defaultEstimateToUse, transaction, minimumGasLimit, mode); const [showAdvancedForm, setShowAdvancedForm] = useState( !estimateToUse || hasGasErrors, @@ -137,6 +142,12 @@ 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) { + dispatch(updateCustomSwapsEIP1559GasParams(newGasSettings)); + } + break; default: break; } @@ -227,6 +238,7 @@ export default function EditGasPopover({ hasGasErrors={hasGasErrors} gasErrors={gasErrors} onManualChange={onManualChange} + minimumGasLimit={minimumGasLimitDec} {...editGasDisplayProps} /> @@ -244,4 +256,5 @@ EditGasPopover.propTypes = { transaction: PropTypes.object, mode: PropTypes.oneOf(Object.values(EDIT_GAS_MODES)), defaultEstimateToUse: PropTypes.string, + minimumGasLimit: PropTypes.string, }; diff --git a/ui/components/app/transaction-detail-item/transaction-detail-item.component.js b/ui/components/app/transaction-detail-item/transaction-detail-item.component.js index bfb446bd0..d8f6cf299 100644 --- a/ui/components/app/transaction-detail-item/transaction-detail-item.component.js +++ b/ui/components/app/transaction-detail-item/transaction-detail-item.component.js @@ -22,14 +22,14 @@ export default function TransactionDetailItem({ {detailTitle} {detailText && ( @@ -39,20 +39,24 @@ export default function TransactionDetailItem({ {detailTotal}
- - {subTitle} - + {React.isValidElement(subTitle) ? ( +
{subTitle}
+ ) : ( + + {subTitle} + + )} export const getCustomSwapsGasPrice = (state) => state.metamask.swapsState.customGasPrice; +export const getCustomMaxFeePerGas = (state) => + state.metamask.swapsState.customMaxFeePerGas; + +export const getCustomMaxPriorityFeePerGas = (state) => + state.metamask.swapsState.customMaxPriorityFeePerGas; + export const getFetchParams = (state) => state.metamask.swapsState.fetchParams; export const getQuotes = (state) => state.metamask.swapsState.quotes; @@ -503,6 +511,7 @@ export const fetchQuotesAndSetQuoteState = ( const hardwareWalletUsed = isHardwareWallet(state); const hardwareWalletType = getHardwareWalletType(state); + const EIP1559Network = isEIP1559Network(state); metaMetricsEvent({ event: 'Quotes Requested', category: 'swaps', @@ -544,7 +553,9 @@ export const fetchQuotesAndSetQuoteState = ( ), ); - const gasPriceFetchPromise = dispatch(fetchAndSetSwapsGasPriceInfo()); + const gasPriceFetchPromise = EIP1559Network + ? null // For EIP 1559 we can get gas prices via "useGasFeeEstimates". + : dispatch(fetchAndSetSwapsGasPriceInfo()); const [[fetchedQuotes, selectedAggId]] = await Promise.all([ fetchAndSetQuotesPromise, @@ -616,6 +627,7 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => { const state = getState(); const chainId = getCurrentChainId(state); const hardwareWalletUsed = isHardwareWallet(state); + const EIP1559Network = isEIP1559Network(state); let swapsLivenessForNetwork = { swapsFeatureIsLive: false, useNewSwapsApi: false, @@ -637,6 +649,8 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => { } const customSwapsGas = getCustomSwapsGas(state); + const customMaxFeePerGas = getCustomMaxFeePerGas(state); + const customMaxPriorityFeePerGas = getCustomMaxPriorityFeePerGas(state); const fetchParams = getFetchParams(state); const { metaData, value: swapTokenValue, slippage } = fetchParams; const { sourceTokenInfo = {}, destinationTokenInfo = {} } = metaData; @@ -649,6 +663,26 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => { const { fast: fastGasEstimate } = getSwapGasPriceEstimateData(state); + let maxFeePerGas; + let maxPriorityFeePerGas; + let baseAndPriorityFeePerGas; + + if (EIP1559Network) { + const { + high: { suggestedMaxFeePerGas, suggestedMaxPriorityFeePerGas }, + estimatedBaseFee = '0', + } = getGasFeeEstimates(state); + maxFeePerGas = + customMaxFeePerGas || decGWEIToHexWEI(suggestedMaxFeePerGas); + maxPriorityFeePerGas = + customMaxPriorityFeePerGas || + decGWEIToHexWEI(suggestedMaxPriorityFeePerGas); + baseAndPriorityFeePerGas = addHexes( + decGWEIToHexWEI(estimatedBaseFee), + maxPriorityFeePerGas, + ); + } + const usedQuote = getUsedQuote(state); const usedTradeTxParams = usedQuote.trade; @@ -668,7 +702,13 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => { const usedGasPrice = getUsedSwapsGasPrice(state); usedTradeTxParams.gas = maxGasLimit; - usedTradeTxParams.gasPrice = usedGasPrice; + if (EIP1559Network) { + usedTradeTxParams.maxFeePerGas = maxFeePerGas; + usedTradeTxParams.maxPriorityFeePerGas = maxPriorityFeePerGas; + delete usedTradeTxParams.gasPrice; + } else { + usedTradeTxParams.gasPrice = usedGasPrice; + } const usdConversionRate = getUSDConversionRate(state); const destinationValue = calcTokenAmount( @@ -682,7 +722,10 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => { .plus(usedQuote.approvalNeeded?.gas || '0x0', 16) .toString(16); const gasEstimateTotalInUSD = getValueFromWeiHex({ - value: calcGasTotal(totalGasLimitEstimate, usedGasPrice), + value: calcGasTotal( + totalGasLimitEstimate, + EIP1559Network ? baseAndPriorityFeePerGas : usedGasPrice, + ), toCurrency: 'usd', conversionRate: usdConversionRate, numberOfDecimals: 6, @@ -714,6 +757,11 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => { is_hardware_wallet: hardwareWalletUsed, hardware_wallet_type: getHardwareWalletType(state), }; + if (EIP1559Network) { + swapMetaData.max_fee_per_gas = maxFeePerGas; + swapMetaData.max_priority_fee_per_gas = maxPriorityFeePerGas; + swapMetaData.base_and_priority_fee_per_gas = baseAndPriorityFeePerGas; + } metaMetricsEvent({ event: 'Swap Started', @@ -744,6 +792,11 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => { } if (approveTxParams) { + if (EIP1559Network) { + approveTxParams.maxFeePerGas = maxFeePerGas; + approveTxParams.maxPriorityFeePerGas = maxPriorityFeePerGas; + delete approveTxParams.gasPrice; + } const approveTxMeta = await dispatch( addUnapprovedTransaction( { ...approveTxParams, amount: '0x0' }, diff --git a/ui/ducks/swaps/swaps.test.js b/ui/ducks/swaps/swaps.test.js index c3a9c9e3e..192e1bb9d 100644 --- a/ui/ducks/swaps/swaps.test.js +++ b/ui/ducks/swaps/swaps.test.js @@ -1,6 +1,6 @@ import nock from 'nock'; -import { MOCKS } from '../../../test/jest'; +import { MOCKS, createSwapsMockStore } from '../../../test/jest'; import { setSwapsLiveness } from '../../store/actions'; import { setStorageItem } from '../../helpers/utils/storage-helpers'; import * as swaps from './swaps'; @@ -164,4 +164,82 @@ describe('Ducks - Swaps', () => { expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); }); }); + + describe('getCustomSwapsGas', () => { + it('returns "customMaxGas"', () => { + const state = createSwapsMockStore(); + const customMaxGas = '29000'; + state.metamask.swapsState.customMaxGas = customMaxGas; + expect(swaps.getCustomSwapsGas(state)).toBe(customMaxGas); + }); + }); + + describe('getCustomMaxFeePerGas', () => { + it('returns "customMaxFeePerGas"', () => { + const state = createSwapsMockStore(); + const customMaxFeePerGas = '20'; + state.metamask.swapsState.customMaxFeePerGas = customMaxFeePerGas; + expect(swaps.getCustomMaxFeePerGas(state)).toBe(customMaxFeePerGas); + }); + }); + + describe('getCustomMaxPriorityFeePerGas', () => { + it('returns "customMaxPriorityFeePerGas"', () => { + const state = createSwapsMockStore(); + const customMaxPriorityFeePerGas = '3'; + state.metamask.swapsState.customMaxPriorityFeePerGas = customMaxPriorityFeePerGas; + expect(swaps.getCustomMaxPriorityFeePerGas(state)).toBe( + customMaxPriorityFeePerGas, + ); + }); + }); + + describe('getSwapsFeatureIsLive', () => { + it('returns true for "swapsFeatureIsLive"', () => { + const state = createSwapsMockStore(); + const swapsFeatureIsLive = true; + state.metamask.swapsState.swapsFeatureIsLive = swapsFeatureIsLive; + expect(swaps.getSwapsFeatureIsLive(state)).toBe(swapsFeatureIsLive); + }); + + it('returns false for "swapsFeatureIsLive"', () => { + const state = createSwapsMockStore(); + const swapsFeatureIsLive = false; + state.metamask.swapsState.swapsFeatureIsLive = swapsFeatureIsLive; + expect(swaps.getSwapsFeatureIsLive(state)).toBe(swapsFeatureIsLive); + }); + }); + + describe('getUseNewSwapsApi', () => { + it('returns true for "useNewSwapsApi"', () => { + const state = createSwapsMockStore(); + const useNewSwapsApi = true; + state.metamask.swapsState.useNewSwapsApi = useNewSwapsApi; + expect(swaps.getUseNewSwapsApi(state)).toBe(useNewSwapsApi); + }); + + it('returns false for "useNewSwapsApi"', () => { + const state = createSwapsMockStore(); + const useNewSwapsApi = false; + state.metamask.swapsState.useNewSwapsApi = useNewSwapsApi; + expect(swaps.getUseNewSwapsApi(state)).toBe(useNewSwapsApi); + }); + }); + + describe('getUsedQuote', () => { + it('returns selected quote', () => { + const state = createSwapsMockStore(); + expect(swaps.getUsedQuote(state)).toMatchObject( + state.metamask.swapsState.quotes.TEST_AGG_2, + ); + }); + + it('returns best quote', () => { + const state = createSwapsMockStore(); + state.metamask.swapsState.selectedAggId = null; + expect(swaps.getUsedQuote(state)).toMatchObject( + state.metamask.swapsState.quotes.TEST_AGG_BEST, + ); + }); + }); }); diff --git a/ui/helpers/constants/gas.js b/ui/helpers/constants/gas.js index d9bb1fbd5..94ea4f7ef 100644 --- a/ui/helpers/constants/gas.js +++ b/ui/helpers/constants/gas.js @@ -7,10 +7,10 @@ export const GAS_FORM_ERRORS = { MAX_FEE_HIGH_WARNING: 'editGasMaxFeeHigh', }; -export function getGasFormErrorText(type, t) { +export function getGasFormErrorText(type, t, { minimumGasLimit } = {}) { switch (type) { case GAS_FORM_ERRORS.GAS_LIMIT_OUT_OF_BOUNDS: - return t('editGasLimitOutOfBounds'); + return t('editGasLimitOutOfBounds', [minimumGasLimit]); case GAS_FORM_ERRORS.MAX_PRIORITY_FEE_TOO_LOW: return t('editGasMaxPriorityFeeLow'); case GAS_FORM_ERRORS.MAX_FEE_TOO_LOW: diff --git a/ui/hooks/useGasFeeInputs.js b/ui/hooks/useGasFeeInputs.js index 140e0fa19..b56303e74 100644 --- a/ui/hooks/useGasFeeInputs.js +++ b/ui/hooks/useGasFeeInputs.js @@ -3,8 +3,15 @@ import { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { findKey } from 'lodash'; -import { GAS_ESTIMATE_TYPES } from '../../shared/constants/gas'; -import { multiplyCurrencies } from '../../shared/modules/conversion.utils'; +import { + GAS_ESTIMATE_TYPES, + EDIT_GAS_MODES, + GAS_LIMITS, +} from '../../shared/constants/gas'; +import { + multiplyCurrencies, + conversionLessThan, +} from '../../shared/modules/conversion.utils'; import { getMaximumGasTotalInHexWei, getMinimumGasTotalInHexWei, @@ -152,7 +159,12 @@ function getMatchingEstimateFromGasFees( * './useGasFeeEstimates' * ).GasEstimates} - gas fee input state and the GasFeeEstimates object */ -export function useGasFeeInputs(defaultEstimateToUse = 'medium', transaction) { +export function useGasFeeInputs( + defaultEstimateToUse = 'medium', + transaction, + minimumGasLimit, + editGasMode, +) { // 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. @@ -257,9 +269,11 @@ export function useGasFeeInputs(defaultEstimateToUse = 'medium', transaction) { // conditionally set to the appropriate fields to compute the minimum // and maximum cost of a transaction given the current estimates or selected // gas fees. + const gasSettings = { gasLimit: decimalToHex(gasLimit), }; + if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { gasSettings.maxFeePerGas = decGWEIToHexWEI(maxFeePerGasToUse); gasSettings.maxPriorityFeePerGas = decGWEIToHexWEI( @@ -276,8 +290,18 @@ export function useGasFeeInputs(defaultEstimateToUse = 'medium', transaction) { // The maximum amount this transaction will cost const maximumCostInHexWei = getMaximumGasTotalInHexWei(gasSettings); + + // If in swaps, we want to calculate the minimum gas fee differently than the max + const minGasSettings = {}; + if (editGasMode === EDIT_GAS_MODES.SWAPS) { + minGasSettings.gasLimit = decimalToHex(minimumGasLimit); + } + // The minimum amount this transaction will cost's - const minimumCostInHexWei = getMinimumGasTotalInHexWei(gasSettings); + const minimumCostInHexWei = getMinimumGasTotalInHexWei({ + ...gasSettings, + ...minGasSettings, + }); // We need to display the estimated fiat currency impact of the // maxPriorityFeePerGas field to the user. This hook calculates that amount. @@ -331,7 +355,12 @@ export function useGasFeeInputs(defaultEstimateToUse = 'medium', transaction) { const gasErrors = {}; const gasWarnings = {}; - if (gasLimit < 21000 || gasLimit > 7920027) { + const gasLimitTooLow = conversionLessThan( + { value: gasLimit, fromNumericBase: 'dec' }, + { value: minimumGasLimit || GAS_LIMITS.SIMPLE, fromNumericBase: 'hex' }, + ); + + if (gasLimitTooLow) { gasErrors.gasLimit = GAS_FORM_ERRORS.GAS_LIMIT_OUT_OF_BOUNDS; } diff --git a/ui/pages/swaps/awaiting-signatures/__snapshots__/swap-step-icon.test.js.snap b/ui/pages/swaps/awaiting-signatures/__snapshots__/swap-step-icon.test.js.snap index 681947428..bb0b0b5ae 100644 --- a/ui/pages/swaps/awaiting-signatures/__snapshots__/swap-step-icon.test.js.snap +++ b/ui/pages/swaps/awaiting-signatures/__snapshots__/swap-step-icon.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SwapStepIcon renders the component 1`] = ` +exports[`SwapStepIcon renders the component with step 1 by default 1`] = `
`; + +exports[`SwapStepIcon renders the component with step 2 1`] = ` +
+ + + + +
+`; diff --git a/ui/pages/swaps/awaiting-signatures/swap-step-icon.test.js b/ui/pages/swaps/awaiting-signatures/swap-step-icon.test.js index 47c92d7aa..941bca984 100644 --- a/ui/pages/swaps/awaiting-signatures/swap-step-icon.test.js +++ b/ui/pages/swaps/awaiting-signatures/swap-step-icon.test.js @@ -4,8 +4,13 @@ import { renderWithProvider } from '../../../../test/jest'; import SwapStepIcon from './swap-step-icon'; describe('SwapStepIcon', () => { - it('renders the component', () => { + it('renders the component with step 1 by default', () => { const { container } = renderWithProvider(); expect(container).toMatchSnapshot(); }); + + it('renders the component with step 2', () => { + const { container } = renderWithProvider(); + expect(container).toMatchSnapshot(); + }); }); diff --git a/ui/pages/swaps/awaiting-swap/awaiting-swap.test.js b/ui/pages/swaps/awaiting-swap/awaiting-swap.test.js index 4f4286995..921f86cef 100644 --- a/ui/pages/swaps/awaiting-swap/awaiting-swap.test.js +++ b/ui/pages/swaps/awaiting-swap/awaiting-swap.test.js @@ -11,7 +11,7 @@ const createProps = (customProps = {}) => { return { swapComplete: false, txHash: 'txHash', - tokensReceived: 'tokensReceived', + tokensReceived: 'tokens received:', submittingSwap: true, inputValue: 5, maxSlippage: 3, @@ -34,4 +34,16 @@ describe('AwaitingSwap', () => { ).toMatchSnapshot(); expect(getByText('View in activity')).toBeInTheDocument(); }); + + it('renders the component with for completed swap', () => { + const store = configureMockStore()(createSwapsMockStore()); + const { getByText } = renderWithProvider( + , + store, + ); + expect(getByText('Transaction complete')).toBeInTheDocument(); + expect(getByText('tokens received: ETH')).toBeInTheDocument(); + expect(getByText('View at etherscan.io')).toBeInTheDocument(); + expect(getByText('Create a new swap')).toBeInTheDocument(); + }); }); diff --git a/ui/pages/swaps/fee-card/__snapshots__/fee-card.test.js.snap b/ui/pages/swaps/fee-card/__snapshots__/fee-card.test.js.snap index 7b1c1f078..e10139024 100644 --- a/ui/pages/swaps/fee-card/__snapshots__/fee-card.test.js.snap +++ b/ui/pages/swaps/fee-card/__snapshots__/fee-card.test.js.snap @@ -1,5 +1,80 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`FeeCard renders the component with EIP-1559 enabled 1`] = ` +
+
+

+ Using the best quote +

+ +
+
+`; + +exports[`FeeCard renders the component with EIP-1559 enabled 2`] = ` +
+
+
+ Quote includes a 0.875% MetaMask fee +
+
+
+
+ + + +
+
+
+
+
+`; + exports[`FeeCard renders the component with initial props 1`] = `
-
-
-
- {t('swapEstimatedNetworkFee')} -
- -

- {t('swapNetworkFeeSummary', [getTranslatedNetworkName()])} -

-

- {t('swapEstimatedNetworkFeeSummary', [ - - {t('swapEstimatedNetworkFee')} - , - ])} -

-

- {t('swapMaxNetworkFeeInfo', [ - - {t('swapMaxNetworkFees')} - , - ])} -

- - } - containerClassName="fee-card__info-tooltip-content-container" - wrapperClassName="fee-card__row-label fee-card__info-tooltip-container" - wide - /> -
-
-
- {primaryFee.fee} -
- {secondaryFee && ( -
- {secondaryFee.fee} + {EIP1559Network && ( + + {t('transactionDetailGasHeading')} + +

+ {t('swapGasFeesSummary', [ + getTranslatedNetworkName(), + ])} +

+

+ {t('swapGasFeesDetails')} +

+

+ { + gasFeesLearnMoreLinkClickedEvent(); + global.platform.openTab({ + url: GAS_FEES_LEARN_MORE_URL, + }); + }} + target="_blank" + rel="noopener noreferrer" + > + {t('swapGasFeesLearnMore')} + +

+ + } + containerClassName="fee-card__info-tooltip-content-container" + wrapperClassName="fee-card__row-label fee-card__info-tooltip-container" + wide + /> + + } + detailText={primaryFee.fee} + detailTotal={secondaryFee.fee} + subTitle={ + + } + subText={ + secondaryFee?.maxFee !== undefined && ( + <> + + {t('maxFee')} + + {`: ${secondaryFee.maxFee}`} + onFeeCardMaxRowClick()} + > + {t('edit')} + + + ) + } + />, + ]} + /> + )} + {!EIP1559Network && ( +
+
+
+ {t('swapEstimatedNetworkFee')}
- )} -
-
-
onFeeCardMaxRowClick()} - > -
-
- {t('swapMaxNetworkFees')} + +

+ {t('swapNetworkFeeSummary', [getTranslatedNetworkName()])} +

+

+ {t('swapEstimatedNetworkFeeSummary', [ + + {t('swapEstimatedNetworkFee')} + , + ])} +

+

+ {t('swapMaxNetworkFeeInfo', [ + + {t('swapMaxNetworkFees')} + , + ])} +

+ + } + containerClassName="fee-card__info-tooltip-content-container" + wrapperClassName="fee-card__row-label fee-card__info-tooltip-container" + wide + />
-
{t('edit')}
-
-
-
- {primaryFee.maxFee} -
- {secondaryFee?.maxFee !== undefined && ( -
- {secondaryFee.maxFee} +
+
+ {primaryFee.fee}
- )} + {secondaryFee && ( +
+ {secondaryFee.fee} +
+ )} +
-
+ )} + {!EIP1559Network && ( +
onFeeCardMaxRowClick()} + > +
+
+ {t('swapMaxNetworkFees')} +
+
{t('edit')}
+
+
+
+ {primaryFee.maxFee} +
+ {secondaryFee?.maxFee !== undefined && ( +
+ {secondaryFee.maxFee} +
+ )} +
+
+ )} + {!hideTokenApprovalRow && (
@@ -199,4 +302,6 @@ FeeCard.propTypes = { numberOfQuotes: PropTypes.number.isRequired, tokenConversionRate: PropTypes.number, chainId: PropTypes.string.isRequired, + EIP1559Network: PropTypes.bool.isRequired, + maxPriorityFeePerGasDecGWEI: PropTypes.string, }; diff --git a/ui/pages/swaps/fee-card/fee-card.test.js b/ui/pages/swaps/fee-card/fee-card.test.js index 7a81e4903..f1cbd05a2 100644 --- a/ui/pages/swaps/fee-card/fee-card.test.js +++ b/ui/pages/swaps/fee-card/fee-card.test.js @@ -1,9 +1,30 @@ import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; -import { renderWithProvider } from '../../../../test/jest'; +import { + renderWithProvider, + createSwapsMockStore, + MOCKS, +} from '../../../../test/jest'; import { MAINNET_CHAIN_ID } from '../../../../shared/constants/network'; import FeeCard from '.'; +const middleware = [thunk]; + +jest.mock('../../../hooks/useGasFeeEstimates', () => { + return { + useGasFeeEstimates: () => { + return { + gasFeeEstimates: MOCKS.createGasFeeEstimatesForFeeMarket(), + gasEstimateType: 'fee-market', + estimatedGasFeeTimeBounds: undefined, + isGasEstimatesLoading: false, + }; + }, + }; +}); + const createProps = (customProps = {}) => { return { primaryFee: { @@ -32,6 +53,7 @@ const createProps = (customProps = {}) => { onQuotesClick: jest.fn(), tokenConversionRate: 0.015, chainId: MAINNET_CHAIN_ID, + EIP1559Network: false, ...customProps, }; }; @@ -58,4 +80,29 @@ describe('FeeCard', () => { document.querySelector('.fee-card__top-bordered-row'), ).toMatchSnapshot(); }); + + it('renders the component with EIP-1559 enabled', () => { + const store = configureMockStore(middleware)(createSwapsMockStore()); + const props = createProps({ + EIP1559Network: true, + maxPriorityFeePerGasDecGWEI: '3', + }); + const { getByText } = renderWithProvider(, store); + expect(getByText('Using the best quote')).toBeInTheDocument(); + expect(getByText('6 quotes')).toBeInTheDocument(); + expect(getByText('Estimated gas fee')).toBeInTheDocument(); + expect(getByText('Maybe in 5 minutes')).toBeInTheDocument(); + expect(getByText(props.primaryFee.fee)).toBeInTheDocument(); + expect(getByText(props.secondaryFee.fee)).toBeInTheDocument(); + expect(getByText(`: ${props.secondaryFee.maxFee}`)).toBeInTheDocument(); + expect( + getByText('Quote includes a 0.875% MetaMask fee'), + ).toBeInTheDocument(); + expect( + document.querySelector('.fee-card__savings-and-quotes-header'), + ).toMatchSnapshot(); + expect( + document.querySelector('.fee-card__top-bordered-row'), + ).toMatchSnapshot(); + }); }); diff --git a/ui/pages/swaps/fee-card/index.scss b/ui/pages/swaps/fee-card/index.scss index d1807fc0d..1737fa1b3 100644 --- a/ui/pages/swaps/fee-card/index.scss +++ b/ui/pages/swaps/fee-card/index.scss @@ -21,8 +21,8 @@ border-top-right-radius: 8px; border-top-left-radius: 8px; border-bottom: 0; - padding-left: 8px; - padding-right: 8px; + padding-left: 16px; + padding-right: 16px; } &__savings-text { @@ -141,11 +141,18 @@ margin-right: 4px; } - &__link { + &__link, + &__link:hover { color: $Blue-500; cursor: pointer; } + &__edit-link { + color: $Blue-500; + cursor: pointer; + padding-left: 6px; + } + &__total-box { border-top: 1px solid $Grey-100; padding: 12px 16px 16px 16px; diff --git a/ui/pages/swaps/index.js b/ui/pages/swaps/index.js index 45f6eb14a..daf88bfad 100644 --- a/ui/pages/swaps/index.js +++ b/ui/pages/swaps/index.js @@ -36,6 +36,7 @@ import { getUseNewSwapsApi, getFromToken, } from '../../ducks/swaps/swaps'; +import { isEIP1559Network } from '../../ducks/metamask/metamask'; import { AWAITING_SIGNATURES_ROUTE, AWAITING_SWAP_ROUTE, @@ -63,7 +64,7 @@ import { } from '../../store/actions'; import { currentNetworkTxListSelector } from '../../selectors'; import { useNewMetricEvent } from '../../hooks/useMetricEvent'; - +import { useGasFeeEstimates } from '../../hooks/useGasFeeEstimates'; import FeatureToggledRoute from '../../helpers/higher-order-components/feature-toggled-route'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; import { @@ -111,8 +112,15 @@ export default function Swap() { const chainId = useSelector(getCurrentChainId); const isSwapsChain = useSelector(getIsSwapsChain); const useNewSwapsApi = useSelector(getUseNewSwapsApi); + const EIP1559Network = useSelector(isEIP1559Network); const fromToken = useSelector(getFromToken); + if (EIP1559Network) { + // This will pre-load gas fees before going to the View Quote page. + // eslint-disable-next-line react-hooks/rules-of-hooks + useGasFeeEstimates(); + } + const { balance: ethBalance, address: selectedAccountAddress, @@ -187,12 +195,14 @@ export default function Swap() { dispatch(setAggregatorMetadata(newAggregatorMetadata)); }, ); - dispatch(fetchAndSetSwapsGasPriceInfo(chainId)); + if (!EIP1559Network) { + dispatch(fetchAndSetSwapsGasPriceInfo(chainId)); + } return () => { dispatch(prepareToLeaveSwaps()); }; } - }, [dispatch, chainId, isFeatureFlagLoaded, useNewSwapsApi]); + }, [dispatch, chainId, isFeatureFlagLoaded, useNewSwapsApi, EIP1559Network]); const hardwareWalletUsed = useSelector(isHardwareWallet); const hardwareWalletType = useSelector(getHardwareWalletType); diff --git a/ui/pages/swaps/index.test.js b/ui/pages/swaps/index.test.js index ed2323e40..a380171da 100644 --- a/ui/pages/swaps/index.test.js +++ b/ui/pages/swaps/index.test.js @@ -21,6 +21,8 @@ setBackgroundConnection({ setSwapsLiveness: jest.fn(() => true), setSwapsTokens: jest.fn(), setSwapsTxGasPrice: jest.fn(), + disconnectGasFeeEstimatePoller: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest.fn(), }); describe('Swap', () => { diff --git a/ui/pages/swaps/swaps-gas-customization-modal/swaps-gas-customization-modal.container.js b/ui/pages/swaps/swaps-gas-customization-modal/swaps-gas-customization-modal.container.js index 9bcd95717..e4e78009b 100644 --- a/ui/pages/swaps/swaps-gas-customization-modal/swaps-gas-customization-modal.container.js +++ b/ui/pages/swaps/swaps-gas-customization-modal/swaps-gas-customization-modal.container.js @@ -59,10 +59,15 @@ const mapStateToProps = (state) => { const customGasTotal = calcGasTotal(customGasLimit, customGasPrice); - const swapsGasPriceEstimates = getSwapGasPriceEstimateData(state); + const gasEstimates = getSwapGasPriceEstimateData(state); + const gasEstimatesInNewFormat = { + low: gasEstimates.safeLow, + medium: gasEstimates.average, + high: gasEstimates.fast, + }; const { averageEstimateData, fastEstimateData } = getRenderableGasButtonData( - swapsGasPriceEstimates, + gasEstimatesInNewFormat, customGasLimit, true, conversionRate, diff --git a/ui/pages/swaps/swaps.util.js b/ui/pages/swaps/swaps.util.js index 701409a4a..c818aa3df 100644 --- a/ui/pages/swaps/swaps.util.js +++ b/ui/pages/swaps/swaps.util.js @@ -9,7 +9,9 @@ import { ETHEREUM, POLYGON, BSC, + RINKEBY, } from '../../../shared/constants/swaps'; +import { TRANSACTION_ENVELOPE_TYPES } from '../../../shared/constants/transaction'; import { isSwapsDefaultTokenAddress, isSwapsDefaultTokenSymbol, @@ -21,6 +23,7 @@ import { BSC_CHAIN_ID, POLYGON_CHAIN_ID, LOCALHOST_CHAIN_ID, + RINKEBY_CHAIN_ID, } from '../../../shared/constants/network'; import { SECOND } from '../../../shared/constants/time'; import { @@ -656,6 +659,8 @@ export function getSwapsTokensReceivedFromTxMeta( chainId, ) { const txReceipt = txMeta?.txReceipt; + const EIP1559Network = + txMeta?.txReceipt?.type === TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; if (isSwapsDefaultTokenSymbol(tokenSymbol, chainId)) { if ( !txReceipt || @@ -670,11 +675,16 @@ export function getSwapsTokensReceivedFromTxMeta( if (approvalTxMeta && approvalTxMeta.txReceipt) { approvalTxGasCost = calcGasTotal( approvalTxMeta.txReceipt.gasUsed, - approvalTxMeta.txParams.gasPrice, + EIP1559Network + ? approvalTxMeta.txReceipt.effectiveGasPrice // Base fee + priority fee. + : approvalTxMeta.txParams.gasPrice, ); } - const gasCost = calcGasTotal(txReceipt.gasUsed, txMeta.txParams.gasPrice); + const gasCost = calcGasTotal( + txReceipt.gasUsed, + EIP1559Network ? txReceipt.effectiveGasPrice : txMeta.txParams.gasPrice, + ); const totalGasCost = new BigNumber(gasCost, 16) .plus(approvalTxGasCost, 16) .toString(16); @@ -786,6 +796,8 @@ export const getNetworkNameByChainId = (chainId) => { return BSC; case POLYGON_CHAIN_ID: return POLYGON; + case RINKEBY_CHAIN_ID: + return RINKEBY; default: return ''; } @@ -799,8 +811,8 @@ export const getNetworkNameByChainId = (chainId) => { */ export const getSwapsLivenessForNetwork = (swapsFeatureFlags = {}, chainId) => { const networkName = getNetworkNameByChainId(chainId); - // Use old APIs for testnet. - if (chainId === LOCALHOST_CHAIN_ID) { + // Use old APIs for testnet and Rinkeby. + if ([LOCALHOST_CHAIN_ID, RINKEBY_CHAIN_ID].includes(chainId)) { return { swapsFeatureIsLive: true, useNewSwapsApi: false, diff --git a/ui/pages/swaps/swaps.util.test.js b/ui/pages/swaps/swaps.util.test.js index f4a2fdfbe..7d4957dd0 100644 --- a/ui/pages/swaps/swaps.util.test.js +++ b/ui/pages/swaps/swaps.util.test.js @@ -8,6 +8,7 @@ import { POLYGON_CHAIN_ID, LOCALHOST_CHAIN_ID, RINKEBY_CHAIN_ID, + KOVAN_CHAIN_ID, } from '../../../shared/constants/network'; import { SWAPS_CHAINID_CONTRACT_ADDRESS_MAP, @@ -15,6 +16,7 @@ import { ETHEREUM, POLYGON, BSC, + RINKEBY, } from '../../../shared/constants/swaps'; import { TOKENS, @@ -394,8 +396,12 @@ describe('Swaps Util', () => { expect(getNetworkNameByChainId(POLYGON_CHAIN_ID)).toBe(POLYGON); }); + it('returns "rinkeby" for Rinkeby chain ID', () => { + expect(getNetworkNameByChainId(RINKEBY_CHAIN_ID)).toBe(RINKEBY); + }); + it('returns an empty string for an unsupported network', () => { - expect(getNetworkNameByChainId(RINKEBY_CHAIN_ID)).toBe(''); + expect(getNetworkNameByChainId(KOVAN_CHAIN_ID)).toBe(''); }); }); @@ -413,6 +419,19 @@ describe('Swaps Util', () => { ).toMatchObject(expectedSwapsLiveness); }); + it('returns info that Swaps are enabled and cannot use API v2 for Rinkeby chain ID', () => { + const expectedSwapsLiveness = { + swapsFeatureIsLive: true, + useNewSwapsApi: false, + }; + expect( + getSwapsLivenessForNetwork( + MOCKS.createFeatureFlagsResponse(), + RINKEBY_CHAIN_ID, + ), + ).toMatchObject(expectedSwapsLiveness); + }); + it('returns info that Swaps are disabled and cannot use API v2 if network name is not found', () => { const expectedSwapsLiveness = { swapsFeatureIsLive: false, @@ -421,7 +440,7 @@ describe('Swaps Util', () => { expect( getSwapsLivenessForNetwork( MOCKS.createFeatureFlagsResponse(), - RINKEBY_CHAIN_ID, + KOVAN_CHAIN_ID, ), ).toMatchObject(expectedSwapsLiveness); }); diff --git a/ui/pages/swaps/view-quote/__snapshots__/view-quote.test.js.snap b/ui/pages/swaps/view-quote/__snapshots__/view-quote.test.js.snap index 26456e10a..b0f5bc601 100644 --- a/ui/pages/swaps/view-quote/__snapshots__/view-quote.test.js.snap +++ b/ui/pages/swaps/view-quote/__snapshots__/view-quote.test.js.snap @@ -1,5 +1,106 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`ViewQuote renders the component with EIP-1559 enabled 1`] = ` +
+ + 10 + + + + DAI + +
+`; + +exports[`ViewQuote renders the component with EIP-1559 enabled 2`] = ` +
+
+ + 1 + + + DAI + + + = + + + 2.2 + + + USDC + +
+ + + +
+
+
+`; + +exports[`ViewQuote renders the component with EIP-1559 enabled 3`] = ` +
+
+ + +
+
+`; + exports[`ViewQuote renders the component with initial props 1`] = `
{ return quotesToRenderableData( quotes, - gasPrice, + EIP1559Network ? baseAndPriorityFeePerGas : gasPrice, conversionRate, currentCurrency, approveGas, @@ -195,6 +241,8 @@ export default function ViewQuote() { }, [ quotes, gasPrice, + baseAndPriorityFeePerGas, + EIP1559Network, conversionRate, currentCurrency, approveGas, @@ -221,7 +269,7 @@ export default function ViewQuote() { const { feeInFiat, feeInEth } = getRenderableNetworkFeesForQuote({ tradeGas: usedGasLimit, approveGas, - gasPrice, + gasPrice: EIP1559Network ? baseAndPriorityFeePerGas : gasPrice, currentCurrency, conversionRate, tradeValue, @@ -238,7 +286,7 @@ export default function ViewQuote() { } = getRenderableNetworkFeesForQuote({ tradeGas: maxGasLimit, approveGas, - gasPrice, + gasPrice: EIP1559Network ? maxFeePerGas : gasPrice, currentCurrency, conversionRate, tradeValue, @@ -455,7 +503,10 @@ export default function ViewQuote() { }; const nonGasFeeIsPositive = new BigNumber(nonGasFee, 16).gt(0); - const approveGasTotal = calcGasTotal(approveGas || '0x0', gasPrice); + const approveGasTotal = calcGasTotal( + approveGas || '0x0', + EIP1559Network ? baseAndPriorityFeePerGas : gasPrice, + ); const extraNetworkFeeTotalInHexWEI = new BigNumber(nonGasFee, 16) .plus(approveGasTotal, 16) .toString(16); @@ -474,26 +525,29 @@ export default function ViewQuote() { extraInfoRowLabel = t('aggregatorFeeCost'); } - const onFeeCardMaxRowClick = () => - dispatch( - showModal({ - name: 'CUSTOMIZE_METASWAP_GAS', - value: tradeValue, - customGasLimitMessage: approveGas - ? t('extraApprovalGas', [hexToDecimal(approveGas)]) - : '', - customTotalSupplement: approveGasTotal, - extraInfoRow: extraInfoRowLabel - ? { - label: extraInfoRowLabel, - value: `${extraNetworkFeeTotalInEth} ${nativeCurrencySymbol}`, - } - : null, - initialGasPrice: gasPrice, - initialGasLimit: maxGasLimit, - minimumGasLimit: new BigNumber(nonCustomMaxGasLimit, 16).toNumber(), - }), - ); + const onFeeCardMaxRowClick = () => { + EIP1559Network + ? setShowEditGasPopover(true) + : dispatch( + showModal({ + name: 'CUSTOMIZE_METASWAP_GAS', + value: tradeValue, + customGasLimitMessage: approveGas + ? t('extraApprovalGas', [hexToDecimal(approveGas)]) + : '', + customTotalSupplement: approveGasTotal, + extraInfoRow: extraInfoRowLabel + ? { + label: extraInfoRowLabel, + value: `${extraNetworkFeeTotalInEth} ${nativeCurrencySymbol}`, + } + : null, + initialGasPrice: gasPrice, + initialGasLimit: maxGasLimit, + minimumGasLimit: new BigNumber(nonCustomMaxGasLimit, 16).toNumber(), + }), + ); + }; const tokenApprovalTextComponent = ( @@ -590,6 +644,10 @@ export default function ViewQuote() { const isShowingWarning = showInsufficientWarning || shouldShowPriceDifferenceWarning; + const onCloseEditGasPopover = () => { + setShowEditGasPopover(false); + }; + return (
)} + + {showEditGasPopover && EIP1559Network && ( + + )} +
@@ -697,8 +775,8 @@ export default function ViewQuote() { balanceError || tokenBalanceUnavailable || disableSubmissionDueToPriceWarning || - gasPrice === null || - gasPrice === undefined + (EIP1559Network && baseAndPriorityFeePerGas === undefined) || + (!EIP1559Network && (gasPrice === null || gasPrice === undefined)) } className={isShowingWarning && 'view-quote__thin-swaps-footer'} showTopBorder diff --git a/ui/pages/swaps/view-quote/view-quote.test.js b/ui/pages/swaps/view-quote/view-quote.test.js index f1b362f80..89b802169 100644 --- a/ui/pages/swaps/view-quote/view-quote.test.js +++ b/ui/pages/swaps/view-quote/view-quote.test.js @@ -6,6 +6,7 @@ import { renderWithProvider, createSwapsMockStore, setBackgroundConnection, + MOCKS, } from '../../../../test/jest'; import ViewQuote from '.'; @@ -13,6 +14,18 @@ jest.mock('../../../components/ui/info-tooltip/info-tooltip-icon', () => () => '', ); +jest.mock('../../../hooks/useGasFeeInputs', () => { + return { + useGasFeeInputs: () => { + return { + maxFeePerGas: 16, + maxPriorityFeePerGas: 3, + gasFeeEstimates: MOCKS.createGasFeeEstimatesForFeeMarket(), + }; + }, + }; +}); + const middleware = [thunk]; const createProps = (customProps = {}) => { return { @@ -31,6 +44,8 @@ setBackgroundConnection({ resetPostFetchState: jest.fn(), safeRefetchQuotes: jest.fn(), setSwapsErrorKey: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest.fn(), + updateTransaction: jest.fn(), }); describe('ViewQuote', () => { @@ -53,4 +68,32 @@ describe('ViewQuote', () => { expect(getByText('Back')).toBeInTheDocument(); expect(getByText('Swap')).toBeInTheDocument(); }); + + it('renders the component with EIP-1559 enabled', () => { + const state = createSwapsMockStore(); + state.metamask.networkDetails = { + EIPS: { + 1559: true, + }, + }; + const store = configureMockStore(middleware)(state); + const props = createProps(); + const { getByText, getByTestId } = renderWithProvider( + , + store, + ); + expect(getByText('New quotes in')).toBeInTheDocument(); + expect(getByTestId('main-quote-summary__source-row')).toMatchSnapshot(); + expect( + getByTestId('main-quote-summary__exchange-rate-container'), + ).toMatchSnapshot(); + expect( + getByTestId('fee-card__savings-and-quotes-header'), + ).toMatchSnapshot(); + expect(getByText('Estimated gas fee')).toBeInTheDocument(); + expect(getByText('0.01044 ETH')).toBeInTheDocument(); + expect(getByText('Max fee')).toBeInTheDocument(); + expect(getByText('Back')).toBeInTheDocument(); + expect(getByText('Swap')).toBeInTheDocument(); + }); }); diff --git a/ui/store/actions.js b/ui/store/actions.js index 84175fd6a..25d923c8f 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -2199,6 +2199,23 @@ export function setSwapsTxGasLimit(gasLimit) { }; } +export function updateCustomSwapsEIP1559GasParams({ + gasLimit, + maxFeePerGas, + maxPriorityFeePerGas, +}) { + return async (dispatch) => { + await Promise.all([ + promisifiedBackground.setSwapsTxGasLimit(gasLimit), + promisifiedBackground.setSwapsTxMaxFeePerGas(maxFeePerGas), + promisifiedBackground.setSwapsTxMaxFeePriorityPerGas( + maxPriorityFeePerGas, + ), + ]); + await forceUpdateMetamaskState(dispatch); + }; +} + export function customSwapsGasParamsUpdated(gasLimit, gasPrice) { return async (dispatch) => { await promisifiedBackground.setSwapsTxGasPrice(gasPrice);