diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index e09f10a0b..8b5c41cf0 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -978,6 +978,9 @@ "deleteNetworkDescription": { "message": "Are you sure you want to delete this network?" }, + "deposit": { + "message": "Deposit" + }, "depositCrypto": { "message": "Deposit $1", "description": "$1 represents the crypto symbol to be purchased" @@ -1756,6 +1759,10 @@ "message": "You do not have enough $1 in your account to pay for transaction fees on $2 network. $3 or deposit from another account.", "description": "$1 is the native currency of the network, $2 is the name of the current network, $3 is the key 'buy' + the ticker symbol of the native currency of the chain wrapped in a button" }, + "insufficientCurrencyBuyOrReceive": { + "message": "You do not have enough $1 in your account to pay for transaction fees on $2 network. $3 or $4 from another account.", + "description": "$1 is the native currency of the network, $2 is the name of the current network, $3 is the key 'buy' + the ticker symbol of the native currency of the chain wrapped in a button, $4 is the key 'deposit' button" + }, "insufficientCurrencyDeposit": { "message": "You do not have enough $1 in your account to pay for transaction fees on $2 network. Deposit $1 from another account.", "description": "$1 is the native currency of the network, $2 is the name of the current network" diff --git a/test/e2e/tests/send-eth.spec.js b/test/e2e/tests/send-eth.spec.js index b9500e22d..34a219020 100644 --- a/test/e2e/tests/send-eth.spec.js +++ b/test/e2e/tests/send-eth.spec.js @@ -37,8 +37,8 @@ describe('Send ETH from inside MetaMask using default gas', function () { const errorAmount = await driver.findElement('.send-v2__error-amount'); assert.equal( await errorAmount.getText(), - 'Insufficient funds.', - 'send screen should render an insufficient fund error message', + 'Insufficient funds for gas', + 'send screen should render an insufficient fund for gas error message', ); await inputAmount.press(driver.Key.BACK_SPACE); diff --git a/ui/components/app/gas-timing/gas-timing.component.js b/ui/components/app/gas-timing/gas-timing.component.js index 4bf3aa629..6b10d0def 100644 --- a/ui/components/app/gas-timing/gas-timing.component.js +++ b/ui/components/app/gas-timing/gas-timing.component.js @@ -14,6 +14,7 @@ import { getGasFeeEstimates, getIsGasEstimatesLoading, } from '../../../ducks/metamask/metamask'; +import { getEIP1559V2Enabled } from '../../../selectors'; import Typography from '../../ui/typography/typography'; import { @@ -45,6 +46,7 @@ export default function GasTiming({ const gasEstimateType = useSelector(getGasEstimateType); const gasFeeEstimates = useSelector(getGasFeeEstimates); const isGasEstimatesLoading = useSelector(getIsGasEstimatesLoading); + const eip1559V2Enabled = useSelector(getEIP1559V2Enabled); const [customEstimatedTime, setCustomEstimatedTime] = useState(null); const t = useContext(I18nContext); @@ -195,8 +197,10 @@ export default function GasTiming({ {text} diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 10df1f5a1..576ad544d 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -13,6 +13,7 @@ import { GAS_ESTIMATE_TYPES, GAS_LIMITS } from '../../../shared/constants/gas'; import { CONTRACT_ADDRESS_ERROR, INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_FUNDS_FOR_GAS_ERROR, INSUFFICIENT_TOKENS_ERROR, INVALID_RECIPIENT_ADDRESS_ERROR, INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, @@ -1277,7 +1278,7 @@ const slice = createSlice({ const draftTransaction = state.draftTransactions[state.currentTransactionUUID]; switch (true) { - // set error to INSUFFICIENT_FUNDS_ERROR if the account balance is lower + // set error to INSUFFICIENT_FUNDS_FOR_GAS_ERROR if the account balance is lower // than the total price of the transaction inclusive of gas fees. case draftTransaction.asset.type === ASSET_TYPES.NATIVE && !isBalanceSufficient({ @@ -1285,9 +1286,9 @@ const slice = createSlice({ balance: draftTransaction.asset.balance, gasTotal: draftTransaction.gas.gasTotal ?? '0x0', }): - draftTransaction.amount.error = INSUFFICIENT_FUNDS_ERROR; + draftTransaction.amount.error = INSUFFICIENT_FUNDS_FOR_GAS_ERROR; break; - // set error to INSUFFICIENT_FUNDS_ERROR if the token balance is lower + // set error to INSUFFICIENT_TOKENS_ERROR if the token balance is lower // than the amount of token the user is attempting to send. case draftTransaction.asset.type === ASSET_TYPES.TOKEN && !isTokenBalanceSufficient({ diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js index f068305ae..658070c08 100644 --- a/ui/ducks/send/send.test.js +++ b/ui/ducks/send/send.test.js @@ -5,6 +5,7 @@ import { ethers } from 'ethers'; import { CONTRACT_ADDRESS_ERROR, INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_FUNDS_FOR_GAS_ERROR, INSUFFICIENT_TOKENS_ERROR, INVALID_RECIPIENT_ADDRESS_ERROR, KNOWN_RECIPIENT_ADDRESS_WARNING, @@ -856,7 +857,7 @@ describe('Send Slice', () => { const draftTransaction = getTestUUIDTx(result); expect(draftTransaction.amount.error).toStrictEqual( - INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_FUNDS_FOR_GAS_ERROR, ); }); diff --git a/ui/pages/pages.scss b/ui/pages/pages.scss index ec51b6783..8150dcdb3 100644 --- a/ui/pages/pages.scss +++ b/ui/pages/pages.scss @@ -15,6 +15,7 @@ @import 'create-account/index'; @import 'error/index'; @import 'first-time-flow/index'; +@import 'send/gas-display/index'; @import 'home/index'; @import 'keychains/index'; @import 'permissions-connect/index'; diff --git a/ui/pages/send/gas-display/gas-display.js b/ui/pages/send/gas-display/gas-display.js new file mode 100644 index 000000000..24d201652 --- /dev/null +++ b/ui/pages/send/gas-display/gas-display.js @@ -0,0 +1,500 @@ +import React, { useContext, useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { I18nContext } from '../../../contexts/i18n'; +import { useGasFeeContext } from '../../../contexts/gasFee'; +import { PRIMARY, SECONDARY } from '../../../helpers/constants/common'; +import { isLegacyTransaction } from '../../../helpers/utils/transactions.util'; +import { hexWEIToDecGWEI } from '../../../../shared/lib/transactions-controller-utils'; +import UserPreferencedCurrencyDisplay from '../../../components/app/user-preferenced-currency-display'; +import GasTiming from '../../../components/app/gas-timing'; +import InfoTooltip from '../../../components/ui/info-tooltip'; +import Typography from '../../../components/ui/typography'; +import Button from '../../../components/ui/button'; +import Box from '../../../components/ui/box'; +import { + TYPOGRAPHY, + DISPLAY, + FLEX_DIRECTION, + BLOCK_SIZES, + COLORS, + FONT_STYLE, + FONT_WEIGHT, +} from '../../../helpers/constants/design-system'; +import { + ERC1155, + ERC20, + ERC721, +} from '../../../../shared/constants/transaction'; +import LoadingHeartBeat from '../../../components/ui/loading-heartbeat'; +import TransactionDetailItem from '../../../components/app/transaction-detail-item'; +import { NETWORK_TO_NAME_MAP } from '../../../../shared/constants/network'; +import TransactionDetail from '../../../components/app/transaction-detail'; +import ActionableMessage from '../../../components/ui/actionable-message'; +import DepositPopover from '../../../components/app/deposit-popover'; +import { + getProvider, + getPreferences, + getIsBuyableChain, + transactionFeeSelector, + getIsMainnet, + getEIP1559V2Enabled, + checkNetworkAndAccountSupports1559, +} from '../../../selectors'; + +import { + hexWEIToDecETH, + addHexes, +} from '../../../helpers/utils/conversions.util'; +import { INSUFFICIENT_TOKENS_ERROR } from '../send.constants'; +import { getCurrentDraftTransaction } from '../../../ducks/send'; +import { showModal } from '../../../store/actions'; + +export default function GasDisplay({ gasError }) { + const t = useContext(I18nContext); + const dispatch = useDispatch(); + const { estimateUsed } = useGasFeeContext(); + const [showDepositPopover, setShowDepositPopover] = useState(false); + const currentProvider = useSelector(getProvider); + const isMainnet = useSelector(getIsMainnet); + const isBuyableChain = useSelector(getIsBuyableChain); + const draftTransaction = useSelector(getCurrentDraftTransaction); + const eip1559V2Enabled = useSelector(getEIP1559V2Enabled); + const networkAndAccountSupports1559 = useSelector( + checkNetworkAndAccountSupports1559, + ); + const { useNativeCurrencyAsPrimaryCurrency } = useSelector(getPreferences); + const { nativeCurrency, provider, unapprovedTxs } = useSelector( + (state) => state.metamask, + ); + const { confirmTransaction } = useSelector((state) => state); + const { txData } = confirmTransaction; + const { txParams = {} } = txData; + const supportsEIP1559 = + networkAndAccountSupports1559 && !isLegacyTransaction(txParams); + const { chainId } = provider; + const networkName = NETWORK_TO_NAME_MAP[chainId]; + const isInsufficientTokenError = + draftTransaction?.amount.error === INSUFFICIENT_TOKENS_ERROR; + const editingTransaction = unapprovedTxs[draftTransaction.id]; + const supportsEIP1559V2 = eip1559V2Enabled && supportsEIP1559; + + const transactionData = { + txParams: { + gasPrice: draftTransaction.gas?.gasPrice, + gas: editingTransaction?.userEditedGasLimit + ? editingTransaction?.txParams?.gas + : draftTransaction.gas?.gasLimit, + maxFeePerGas: editingTransaction?.txParams?.maxFeePerGas + ? editingTransaction?.txParams?.maxFeePerGas + : draftTransaction.gas?.maxFeePerGas, + maxPriorityFeePerGas: editingTransaction?.txParams?.maxPriorityFeePerGas + ? editingTransaction?.txParams?.maxPriorityFeePerGas + : draftTransaction.gas?.maxPriorityFeePerGas, + value: draftTransaction.amount?.value, + type: draftTransaction.transactionType, + }, + userFeeLevel: editingTransaction?.userFeeLevel, + }; + + const { + hexMinimumTransactionFee, + hexMaximumTransactionFee, + hexTransactionTotal, + } = useSelector((state) => transactionFeeSelector(state, transactionData)); + + let title; + if ( + draftTransaction?.asset.details?.standard === ERC721 || + draftTransaction?.asset.details?.standard === ERC1155 + ) { + title = draftTransaction?.asset.details?.name; + } else if (draftTransaction?.asset.details?.standard === ERC20) { + title = `${hexWEIToDecETH(draftTransaction.amount.value)} ${ + draftTransaction?.asset.details?.symbol + }`; + } + + const ethTransactionTotalMaxAmount = Number( + hexWEIToDecETH(hexMaximumTransactionFee), + ); + + const primaryTotalTextOverrideMaxAmount = `${title} + ${ethTransactionTotalMaxAmount} ${nativeCurrency}`; + + let detailTotal, maxAmount; + + if (draftTransaction?.asset.type === 'NATIVE') { + detailTotal = ( + + + + + ); + maxAmount = ( + + ); + } else if (useNativeCurrencyAsPrimaryCurrency) { + detailTotal = primaryTotalTextOverrideMaxAmount; + maxAmount = primaryTotalTextOverrideMaxAmount; + } + + return ( + <> + {showDepositPopover && ( + setShowDepositPopover(false)} /> + )} + + + {t('gas')} + + ({t('transactionDetailGasInfoV2')}) + + + + {t('transactionDetailGasTooltipIntro', [ + isMainnet ? t('networkNameEthereum') : '', + ])} + + + {t('transactionDetailGasTooltipExplanation')} + + + + {t('transactionDetailGasTooltipConversion')} + + + > + } + position="right" + /> + + } + detailTitleColor={COLORS.TEXT_DEFAULT} + detailText={ + + + + + } + detailTotal={ + + + + + } + subText={ + <> + + + + + {estimateUsed === 'high' && '⚠ '} + {t('editGasSubTextFeeLabel')} + + + + + + + + > + } + subTitle={ + + } + /> + ) : ( + + {t('transactionDetailGasHeading')} + + + {t('transactionDetailGasTooltipIntro', [ + isMainnet ? t('networkNameEthereum') : '', + ])} + + {t('transactionDetailGasTooltipExplanation')} + + + {t('transactionDetailGasTooltipConversion')} + + + > + } + position="right" + > + + + > + } + detailText={ + + + + + } + detailTotal={ + + + + + } + subText={ + <> + + {t('editGasSubTextFeeLabel')} + + + + + + > + } + subTitle={ + <> + + > + } + /> + ), + (gasError || isInsufficientTokenError) && ( + + + + + } + detailTotal={detailTotal} + subTitle={t('transactionDetailGasTotalSubtitle')} + subText={ + + + + {t('editGasSubTextAmountLabel')} + {' '} + {maxAmount} + + } + /> + ), + ]} + /> + + {(gasError || isInsufficientTokenError) && ( + + + + {t('insufficientCurrencyBuyOrReceive', [ + nativeCurrency, + networkName ?? currentProvider.nickname, + { + setShowDepositPopover(true); + }} + key={`${nativeCurrency}-buy-button`} + > + {t('buyAsset', [nativeCurrency])} + , + + dispatch(showModal({ name: 'ACCOUNT_DETAILS' })) + } + key="receive-button" + > + {t('deposit')} + , + ])} + + ) : ( + + {t('insufficientCurrencyBuyOrReceive', [ + draftTransaction.asset.details?.symbol ?? nativeCurrency, + networkName ?? currentProvider.nickname, + `${t('buyAsset', [ + draftTransaction.asset.details?.symbol ?? + nativeCurrency, + ])}`, + + dispatch(showModal({ name: 'ACCOUNT_DETAILS' })) + } + key="receive-button" + > + {t('deposit')} + , + ])} + + ) + } + useIcon + iconFillColor="var(--color-error-default)" + type="danger" + /> + + + )} + > + ); +} +GasDisplay.propTypes = { + gasError: PropTypes.string, +}; diff --git a/ui/pages/send/gas-display/index.js b/ui/pages/send/gas-display/index.js new file mode 100644 index 000000000..2e1e485d8 --- /dev/null +++ b/ui/pages/send/gas-display/index.js @@ -0,0 +1 @@ +export { default } from './gas-display'; diff --git a/ui/pages/send/gas-display/index.scss b/ui/pages/send/gas-display/index.scss new file mode 100644 index 000000000..861e2b4aa --- /dev/null +++ b/ui/pages/send/gas-display/index.scss @@ -0,0 +1,58 @@ +.gas-display { + overflow-y: auto; + flex: 1; + + .transaction-detail-rows { + padding: 10px; + border-radius: 8px; + border: 1px solid var(--color-border-default); + margin: 16px 16px; + + .transaction-detail-item { + &:not(:first-child) { + border-top: 1px solid var(--color-border-default); + } + } + } + + &__title { + &__estimate { + font-size: 12px; + line-height: inherit; + } + } + + &__gas-fee-warning { + color: var(--color-warning-default); + } + + &__gas-fee-label { + position: relative; + white-space: nowrap; + } + + &__warning-message { + height: 120px; + } + + &__currency-container, + &__total-amount, + &__total-value { + position: relative; + } + + &__confirm-approve-content { + &__warning { + @media screen and (max-width: $break-small) { + padding: 0 32px 16px 16px; + position: fixed; + bottom: 80px; + z-index: 1; + } + } + } + + &__link { + text-transform: lowercase; + } +} diff --git a/ui/pages/send/send-content/send-content.component.js b/ui/pages/send/send-content/send-content.component.js index 9a157c0b7..630e7a796 100644 --- a/ui/pages/send/send-content/send-content.component.js +++ b/ui/pages/send/send-content/send-content.component.js @@ -12,6 +12,7 @@ import { } from '../../../helpers/constants/error-keys'; import { ASSET_TYPES } from '../../../../shared/constants/transaction'; import { CONTRACT_ADDRESS_LINK } from '../../../helpers/constants/common'; +import GasDisplay from '../gas-display'; import SendAmountRow from './send-amount-row'; import SendHexDataRow from './send-hex-data-row'; import SendAssetRow from './send-asset-row'; @@ -81,7 +82,6 @@ export default class SendContent extends Component { {assetError ? this.renderError(assetError) : null} - {gasError ? this.renderError(gasError) : null} {isEthGasPrice ? this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY) : null} @@ -97,6 +97,7 @@ export default class SendContent extends Component { {networkOrAccountNotSupports1559 ? : null} {showHexData ? : null} + ); diff --git a/ui/pages/send/send-content/send-content.component.test.js b/ui/pages/send/send-content/send-content.component.test.js index ec8ed3ecf..cef260cc4 100644 --- a/ui/pages/send/send-content/send-content.component.test.js +++ b/ui/pages/send/send-content/send-content.component.test.js @@ -150,21 +150,6 @@ describe('SendContent Component', () => { ).toStrictEqual(true); expect(wrapper.find(Dialog)).toHaveLength(0); }); - - it('should render insufficient gas dialog', () => { - wrapper.setProps({ - showHexData: false, - getIsBalanceInsufficient: true, - }); - const PageContainerContentChild = wrapper - .find(PageContainerContent) - .children(); - const errorDialogProps = PageContainerContentChild.childAt(0).props(); - expect(errorDialogProps.className).toStrictEqual('send__error-dialog'); - expect(errorDialogProps.children).toStrictEqual( - 'insufficientFundsForGas_t', - ); - }); }); it('should not render the asset dropdown if token length is 0', () => { diff --git a/ui/pages/send/send-content/send-content.container.js b/ui/pages/send/send-content/send-content.container.js index 07ac323ad..be28faf8d 100644 --- a/ui/pages/send/send-content/send-content.container.js +++ b/ui/pages/send/send-content/send-content.container.js @@ -15,7 +15,6 @@ import { acknowledgeRecipientWarning, getRecipientWarningAcknowledgement, } from '../../../ducks/send'; - import SendContent from './send-content.component'; function mapStateToProps(state) { @@ -24,6 +23,7 @@ function mapStateToProps(state) { const recipient = getRecipient(state); const recipientWarningAcknowledged = getRecipientWarningAcknowledgement(state); + return { isOwnedAccount: Boolean( ownedAccounts.find( diff --git a/ui/pages/send/send.constants.js b/ui/pages/send/send.constants.js index 6fefd204f..38acb8081 100644 --- a/ui/pages/send/send.constants.js +++ b/ui/pages/send/send.constants.js @@ -31,6 +31,7 @@ const TOKEN_TRANSFER_FUNCTION_SIGNATURE = '0xa9059cbb'; const COLLECTIBLE_TRANSFER_FROM_FUNCTION_SIGNATURE = '0x23b872dd'; const INSUFFICIENT_FUNDS_ERROR = 'insufficientFunds'; +const INSUFFICIENT_FUNDS_FOR_GAS_ERROR = 'insufficientFundsForGas'; const INSUFFICIENT_TOKENS_ERROR = 'insufficientTokens'; const NEGATIVE_ETH_ERROR = 'negativeETH'; const INVALID_RECIPIENT_ADDRESS_ERROR = 'invalidAddressRecipient'; @@ -56,6 +57,7 @@ export { MAX_GAS_LIMIT_DEC, HIGH_FEE_WARNING_MULTIPLIER, INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_FUNDS_FOR_GAS_ERROR, INSUFFICIENT_TOKENS_ERROR, INVALID_RECIPIENT_ADDRESS_ERROR, KNOWN_RECIPIENT_ADDRESS_WARNING, diff --git a/ui/pages/send/send.test.js b/ui/pages/send/send.test.js index 49199fa20..1d5ca3841 100644 --- a/ui/pages/send/send.test.js +++ b/ui/pages/send/send.test.js @@ -4,8 +4,11 @@ import thunk from 'redux-thunk'; import { useLocation } from 'react-router-dom'; import { SEND_STAGES, startNewDraftTransaction } from '../../ducks/send'; import { domainInitialState } from '../../ducks/domains'; -import { renderWithProvider } from '../../../test/jest'; import { CHAIN_IDS } from '../../../shared/constants/network'; +import { + renderWithProvider, + setBackgroundConnection, +} from '../../../test/jest'; import { GAS_ESTIMATE_TYPES } from '../../../shared/constants/gas'; import { KEYRING_TYPES } from '../../../shared/constants/keyrings'; import { INITIAL_SEND_STATE_FOR_EXISTING_DRAFT } from '../../../test/jest/mocks'; @@ -38,6 +41,12 @@ jest.mock('react-router-dom', () => { }; }); +setBackgroundConnection({ + getGasFeeTimeEstimate: jest.fn(), + getGasFeeEstimatesAndStartPolling: jest.fn(), + promisifiedBackground: jest.fn(), +}); + jest.mock('ethers', () => { const originalModule = jest.requireActual('ethers'); return { @@ -60,6 +69,14 @@ const baseStore = { }, history: { mostRecentOverviewPage: 'activity' }, metamask: { + unapprovedTxs: { + 1: { + id: 1, + txParams: { + value: 'oldTxValue', + }, + }, + }, gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, gasFeeEstimates: { low: '0', @@ -204,6 +221,27 @@ describe('Send Page', () => { const store = configureMockStore(middleware)({ ...baseStore, send: { ...baseStore.send, stage: SEND_STAGES.DRAFT }, + confirmTransaction: { + txData: { + id: 3111025347726181, + time: 1620723786838, + status: 'unapproved', + metamaskNetworkId: '5', + chainId: '0x5', + loadingDefaults: false, + txParams: { + from: '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4', + to: '0xaD6D458402F60fD3Bd25163575031ACDce07538D', + value: '0x0', + data: '0x095ea7b30000000000000000000000009bc5baf874d2da8d216ae9f137804184ee5afef40000000000000000000000000000000000000000000000000000000000011170', + gas: '0xea60', + gasPrice: '0x4a817c800', + }, + type: 'transfer', + origin: 'https://metamask.github.io', + transactionCategory: 'approve', + }, + }, }); const { getByText } = renderWithProvider(, store); expect(getByText('Send')).toBeTruthy(); @@ -221,6 +259,27 @@ describe('Send Page', () => { const store = configureMockStore(middleware)({ ...baseStore, send: { ...baseStore.send, stage: SEND_STAGES.DRAFT }, + confirmTransaction: { + txData: { + id: 3111025347726181, + time: 1620723786838, + status: 'unapproved', + metamaskNetworkId: '5', + chainId: '0x5', + loadingDefaults: false, + txParams: { + from: '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4', + to: '0xaD6D458402F60fD3Bd25163575031ACDce07538D', + value: '0x0', + data: '0x095ea7b30000000000000000000000009bc5baf874d2da8d216ae9f137804184ee5afef40000000000000000000000000000000000000000000000000000000000011170', + gas: '0xea60', + gasPrice: '0x4a817c800', + }, + type: 'transfer', + origin: 'https://metamask.github.io', + transactionCategory: 'approve', + }, + }, }); const { getByText } = renderWithProvider(, store); expect(getByText('Next')).toBeTruthy();
+ {t('transactionDetailGasTooltipIntro', [ + isMainnet ? t('networkNameEthereum') : '', + ])} +
{t('transactionDetailGasTooltipExplanation')}
+ + {t('transactionDetailGasTooltipConversion')} + +