import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ConfirmPageContainer from '../../components/app/confirm-page-container'; import TransactionDecoding from '../../components/app/transaction-decoding'; import { isBalanceSufficient } from '../send/send.utils'; import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; import { INSUFFICIENT_FUNDS_ERROR_KEY, GAS_LIMIT_TOO_LOW_ERROR_KEY, ETH_GAS_PRICE_FETCH_WARNING_KEY, GAS_PRICE_FETCH_FAILURE_ERROR_KEY, } from '../../helpers/constants/error-keys'; import UserPreferencedCurrencyDisplay from '../../components/app/user-preferenced-currency-display'; import CopyRawData from '../../components/app/transaction-decoding/components/ui/copy-raw-data'; import { PRIMARY, SECONDARY } from '../../helpers/constants/common'; import TextField from '../../components/ui/text-field'; import SimulationErrorMessage from '../../components/ui/simulation-error-message'; import Disclosure from '../../components/ui/disclosure'; import { EVENT } from '../../../shared/constants/metametrics'; import { TransactionType, TransactionStatus, } from '../../../shared/constants/transaction'; import { getMethodName } from '../../helpers/utils/metrics'; import { getTransactionTypeTitle, isLegacyTransaction, } from '../../helpers/utils/transactions.util'; import { toBuffer } from '../../../shared/modules/buffer-utils'; import { TransactionModalContextProvider } from '../../contexts/transaction-modal'; import TransactionDetail from '../../components/app/transaction-detail/transaction-detail.component'; import TransactionDetailItem from '../../components/app/transaction-detail-item/transaction-detail-item.component'; import InfoTooltip from '../../components/ui/info-tooltip/info-tooltip'; import LoadingHeartBeat from '../../components/ui/loading-heartbeat'; import GasDetailsItem from '../../components/app/gas-details-item'; import GasTiming from '../../components/app/gas-timing/gas-timing.component'; import LedgerInstructionField from '../../components/app/ledger-instruction-field'; import MultiLayerFeeMessage from '../../components/app/multilayer-fee-message'; import Typography from '../../components/ui/typography/typography'; import { TextColor, FONT_STYLE, TypographyVariant, } from '../../helpers/constants/design-system'; import { disconnectGasFeeEstimatePoller, getGasFeeEstimatesAndStartPolling, addPollingTokenToAppState, removePollingTokenFromAppState, } from '../../store/actions'; import { MIN_GAS_LIMIT_DEC } from '../send/send.constants'; import { NETWORK_TO_NAME_MAP } from '../../../shared/constants/network'; import { addHexes, hexToDecimal, hexWEIToDecGWEI, } from '../../../shared/modules/conversion.utils'; import TransactionAlerts from '../../components/app/transaction-alerts'; const renderHeartBeatIfNotInTest = () => process.env.IN_TEST ? null : ; export default class ConfirmTransactionBase extends Component { static contextTypes = { t: PropTypes.func, trackEvent: PropTypes.func, }; static propTypes = { // react-router props history: PropTypes.object, // Redux props balance: PropTypes.string, cancelTransaction: PropTypes.func, cancelAllTransactions: PropTypes.func, clearConfirmTransaction: PropTypes.func, conversionRate: PropTypes.number, fromAddress: PropTypes.string, fromName: PropTypes.string, hexTransactionAmount: PropTypes.string, hexMinimumTransactionFee: PropTypes.string, hexMaximumTransactionFee: PropTypes.string, hexTransactionTotal: PropTypes.string, methodData: PropTypes.object, nonce: PropTypes.string, useNonceField: PropTypes.bool, customNonceValue: PropTypes.string, updateCustomNonce: PropTypes.func, sendTransaction: PropTypes.func, showTransactionConfirmedModal: PropTypes.func, showRejectTransactionsConfirmationModal: PropTypes.func, toAccounts: PropTypes.object, toAddress: PropTypes.string, tokenData: PropTypes.object, tokenProps: PropTypes.object, toName: PropTypes.string, toEns: PropTypes.string, toNickname: PropTypes.string, transactionStatus: PropTypes.string, txData: PropTypes.object, unapprovedTxCount: PropTypes.number, customGas: PropTypes.object, addToAddressBookIfNew: PropTypes.func, // Component props actionKey: PropTypes.string, contentComponent: PropTypes.node, dataComponent: PropTypes.node, dataHexComponent: PropTypes.node, hideData: PropTypes.bool, hideSubtitle: PropTypes.bool, tokenAddress: PropTypes.string, customTokenAmount: PropTypes.string, dappProposedTokenAmount: PropTypes.string, currentTokenBalance: PropTypes.string, onEdit: PropTypes.func, subtitleComponent: PropTypes.node, title: PropTypes.string, image: PropTypes.string, type: PropTypes.string, getNextNonce: PropTypes.func, nextNonce: PropTypes.number, tryReverseResolveAddress: PropTypes.func.isRequired, hideSenderToRecipient: PropTypes.bool, showAccountInHeader: PropTypes.bool, mostRecentOverviewPage: PropTypes.string.isRequired, isEthGasPrice: PropTypes.bool, noGasPrice: PropTypes.bool, setDefaultHomeActiveTabName: PropTypes.func, primaryTotalTextOverride: PropTypes.string, secondaryTotalTextOverride: PropTypes.string, gasIsLoading: PropTypes.bool, primaryTotalTextOverrideMaxAmount: PropTypes.string, useNativeCurrencyAsPrimaryCurrency: PropTypes.bool, maxFeePerGas: PropTypes.string, maxPriorityFeePerGas: PropTypes.string, baseFeePerGas: PropTypes.string, isMainnet: PropTypes.bool, gasFeeIsCustom: PropTypes.bool, showLedgerSteps: PropTypes.bool.isRequired, nativeCurrency: PropTypes.string, supportsEIP1559: PropTypes.bool, hardwareWalletRequiresConnection: PropTypes.bool, isMultiLayerFeeNetwork: PropTypes.bool, isBuyableChain: PropTypes.bool, isApprovalOrRejection: PropTypes.bool, assetStandard: PropTypes.string, useCurrencyRateCheck: PropTypes.bool, }; state = { submitting: false, submitError: null, submitWarning: '', ethGasPriceWarning: '', editingGas: false, userAcknowledgedGasMissing: false, showWarningModal: false, }; componentDidUpdate(prevProps) { const { transactionStatus, showTransactionConfirmedModal, history, clearConfirmTransaction, nextNonce, customNonceValue, toAddress, tryReverseResolveAddress, isEthGasPrice, setDefaultHomeActiveTabName, } = this.props; const { customNonceValue: prevCustomNonceValue, nextNonce: prevNextNonce, toAddress: prevToAddress, transactionStatus: prevTxStatus, isEthGasPrice: prevIsEthGasPrice, } = prevProps; const statusUpdated = transactionStatus !== prevTxStatus; const txDroppedOrConfirmed = transactionStatus === TransactionStatus.dropped || transactionStatus === TransactionStatus.confirmed; if ( nextNonce !== prevNextNonce || customNonceValue !== prevCustomNonceValue ) { if (nextNonce !== null && customNonceValue > nextNonce) { this.setState({ submitWarning: this.context.t('nextNonceWarning', [nextNonce]), }); } else { this.setState({ submitWarning: '' }); } } if (statusUpdated && txDroppedOrConfirmed) { showTransactionConfirmedModal({ onSubmit: () => { clearConfirmTransaction(); setDefaultHomeActiveTabName('activity').then(() => { history.push(DEFAULT_ROUTE); }); }, }); } if (toAddress && toAddress !== prevToAddress) { tryReverseResolveAddress(toAddress); } if (isEthGasPrice !== prevIsEthGasPrice) { if (isEthGasPrice) { this.setState({ ethGasPriceWarning: this.context.t(ETH_GAS_PRICE_FETCH_WARNING_KEY), }); } else { this.setState({ ethGasPriceWarning: '', }); } } } getErrorKey() { const { balance, conversionRate, hexMaximumTransactionFee, txData: { txParams: { value: amount } = {} } = {}, customGas, noGasPrice, gasFeeIsCustom, } = this.props; const insufficientBalance = balance && !isBalanceSufficient({ amount, gasTotal: hexMaximumTransactionFee || '0x0', balance, conversionRate, }); if (insufficientBalance) { return { valid: false, errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, }; } if (hexToDecimal(customGas.gasLimit) < Number(MIN_GAS_LIMIT_DEC)) { return { valid: false, errorKey: GAS_LIMIT_TOO_LOW_ERROR_KEY, }; } if (noGasPrice && !gasFeeIsCustom) { return { valid: false, errorKey: GAS_PRICE_FETCH_FAILURE_ERROR_KEY, }; } return { valid: true, }; } handleEditGas() { const { actionKey, txData: { origin }, methodData = {}, } = this.props; this.context.trackEvent({ category: EVENT.CATEGORIES.TRANSACTIONS, event: 'User clicks "Edit" on gas', properties: { action: 'Confirm Screen', legacy_event: true, recipientKnown: null, functionType: actionKey || getMethodName(methodData.name) || TransactionType.contractInteraction, origin, }, }); this.setState({ editingGas: true }); } handleCloseEditGas() { this.setState({ editingGas: false }); } setUserAcknowledgedGasMissing() { this.setState({ userAcknowledgedGasMissing: true }); } renderDetails() { const { primaryTotalTextOverride, secondaryTotalTextOverride, hexMinimumTransactionFee, hexMaximumTransactionFee, hexTransactionTotal, useNonceField, customNonceValue, updateCustomNonce, nextNonce, getNextNonce, txData, useNativeCurrencyAsPrimaryCurrency, primaryTotalTextOverrideMaxAmount, maxFeePerGas, maxPriorityFeePerGas, isMainnet, showLedgerSteps, supportsEIP1559, isMultiLayerFeeNetwork, nativeCurrency, isBuyableChain, useCurrencyRateCheck, } = this.props; const { t } = this.context; const { userAcknowledgedGasMissing } = this.state; const { valid } = this.getErrorKey(); const isDisabled = () => { return userAcknowledgedGasMissing ? false : !valid; }; const hasSimulationError = Boolean(txData.simulationFails); const renderSimulationFailureWarning = hasSimulationError && !userAcknowledgedGasMissing; const networkName = NETWORK_TO_NAME_MAP[txData.chainId]; const renderTotalMaxAmount = () => { if ( primaryTotalTextOverrideMaxAmount === undefined && secondaryTotalTextOverride === undefined ) { // Native Send return ( ); } // Token send return useNativeCurrencyAsPrimaryCurrency ? primaryTotalTextOverrideMaxAmount : secondaryTotalTextOverride; }; const renderTotalDetailTotal = () => { if ( primaryTotalTextOverride === undefined && secondaryTotalTextOverride === undefined ) { return (
); } return useNativeCurrencyAsPrimaryCurrency ? primaryTotalTextOverride : secondaryTotalTextOverride; }; const renderTotalDetailText = () => { if ( primaryTotalTextOverride === undefined && secondaryTotalTextOverride === undefined ) { return (
); } return useNativeCurrencyAsPrimaryCurrency ? secondaryTotalTextOverride : primaryTotalTextOverride; }; const nonceField = useNonceField ? (
{t('nonceFieldHeading')}
{ if (!value.length || Number(value) < 0) { updateCustomNonce(''); } else { updateCustomNonce(String(Math.floor(value))); } getNextNonce(); }} fullWidth margin="dense" value={customNonceValue || ''} />
) : null; const renderGasDetailsItem = () => { return this.supportsEIP1559 ? ( ) : ( {t('transactionDetailGasHeading')} ) : ( <> {t('transactionDetailGasHeading')}

{t('transactionDetailGasTooltipIntro', [ isMainnet ? t('networkNameEthereum') : '', ])}

{t('transactionDetailGasTooltipExplanation')}

{t('transactionDetailGasTooltipConversion')}

} position="top" >
) } detailText={ useCurrencyRateCheck && (
{renderHeartBeatIfNotInTest()}
) } detailTotal={
{renderHeartBeatIfNotInTest()}
} subText={ <> {t('editGasSubTextFeeLabel')}
{renderHeartBeatIfNotInTest()}
} subTitle={ <> {txData.dappSuggestedGasFees ? ( {t('transactionDetailDappGasMoreInfo')} ) : ( '' )} {supportsEIP1559 && ( )} } /> ); }; const simulationFailureWarning = () => (
this.setUserAcknowledgedGasMissing() } />
); return (
this.setUserAcknowledgedGasMissing() } userAcknowledgedGasMissing={userAcknowledgedGasMissing} nativeCurrency={nativeCurrency} networkName={networkName} type={txData.type} isBuyableChain={isBuyableChain} /> this.handleEditGas() } rows={[ renderSimulationFailureWarning && simulationFailureWarning(), !renderSimulationFailureWarning && !isMultiLayerFeeNetwork && renderGasDetailsItem(), !renderSimulationFailureWarning && isMultiLayerFeeNetwork && ( ), !isMultiLayerFeeNetwork && ( {t('editGasSubTextAmountLabel')} {' '} {renderTotalMaxAmount()}
} /> ), ]} /> {nonceField} {showLedgerSteps ? ( ) : null} ); } renderData(functionType) { const { t } = this.context; const { txData: { txParams } = {}, methodData: { params } = {}, hideData, dataComponent, } = this.props; if (hideData) { return null; } const functionParams = params?.length ? `(${params.map(({ type }) => type).join(', ')})` : ''; return ( dataComponent || (
{`${t('functionType')}:`} {`${functionType} ${functionParams}`}
) ); } renderDataHex(functionType) { const { t } = this.context; const { txData: { txParams } = {}, methodData: { params } = {}, hideData, dataHexComponent, } = this.props; if (hideData || !txParams.to) { return null; } const functionParams = params?.length ? `(${params.map(({ type }) => type).join(', ')})` : ''; return ( dataHexComponent || (
{`${t('functionType')}:`} {`${functionType} ${functionParams}`}
{params && (
{`${t('parameters')}:`}
{JSON.stringify(params, null, 2)}
)}
{`${t('hexData')}: ${toBuffer(txParams?.data).length} bytes`}
{txParams?.data}
) ); } handleEdit() { const { txData, tokenData, tokenProps, onEdit, actionKey, txData: { origin }, methodData = {}, } = this.props; this.context.trackEvent({ category: EVENT.CATEGORIES.TRANSACTIONS, event: 'Edit Transaction', properties: { action: 'Confirm Screen', legacy_event: true, recipientKnown: null, functionType: actionKey || getMethodName(methodData.name) || TransactionType.contractInteraction, origin, }, }); onEdit({ txData, tokenData, tokenProps }); } handleCancelAll() { const { cancelAllTransactions, clearConfirmTransaction, history, mostRecentOverviewPage, showRejectTransactionsConfirmationModal, unapprovedTxCount, } = this.props; showRejectTransactionsConfirmationModal({ unapprovedTxCount, onSubmit: async () => { this._removeBeforeUnload(); await cancelAllTransactions(); clearConfirmTransaction(); history.push(mostRecentOverviewPage); }, }); } handleCancel() { const { txData, cancelTransaction, history, mostRecentOverviewPage, clearConfirmTransaction, updateCustomNonce, } = this.props; this._removeBeforeUnload(); updateCustomNonce(''); cancelTransaction(txData).then(() => { clearConfirmTransaction(); history.push(mostRecentOverviewPage); }); } handleSubmit() { const { sendTransaction, clearConfirmTransaction, txData, history, mostRecentOverviewPage, updateCustomNonce, maxFeePerGas, customTokenAmount, dappProposedTokenAmount, currentTokenBalance, maxPriorityFeePerGas, baseFeePerGas, methodData, addToAddressBookIfNew, toAccounts, toAddress, } = this.props; const { submitting } = this.state; const { name } = methodData; if (txData.type === TransactionType.simpleSend) { addToAddressBookIfNew(toAddress, toAccounts); } if (submitting) { return; } if (baseFeePerGas) { txData.estimatedBaseFee = baseFeePerGas; } if (name) { txData.contractMethodName = name; } if (dappProposedTokenAmount) { txData.dappProposedTokenAmount = dappProposedTokenAmount; txData.originalApprovalAmount = dappProposedTokenAmount; } if (customTokenAmount) { txData.customTokenAmount = customTokenAmount; txData.finalApprovalAmount = customTokenAmount; } else if (dappProposedTokenAmount !== undefined) { txData.finalApprovalAmount = dappProposedTokenAmount; } if (currentTokenBalance) { txData.currentTokenBalance = currentTokenBalance; } if (maxFeePerGas) { txData.txParams = { ...txData.txParams, maxFeePerGas, }; } if (maxPriorityFeePerGas) { txData.txParams = { ...txData.txParams, maxPriorityFeePerGas, }; } this.setState( { submitting: true, submitError: null, }, () => { this._removeBeforeUnload(); sendTransaction(txData) .then(() => { clearConfirmTransaction(); this.setState( { submitting: false, }, () => { history.push(mostRecentOverviewPage); updateCustomNonce(''); }, ); }) .catch((error) => { this.setState({ submitting: false, submitError: error.message, }); updateCustomNonce(''); }); }, ); } handleSetApprovalForAll() { this.setState({ showWarningModal: true }); } renderTitleComponent() { const { title, hexTransactionAmount, txData } = this.props; // Title string passed in by props takes priority if (title) { return null; } const isContractInteraction = txData.type === TransactionType.contractInteraction; return ( ); } renderSubtitleComponent() { const { subtitleComponent, hexTransactionAmount } = this.props; return ( subtitleComponent || ( ) ); } _beforeUnloadForGasPolling = () => { this._isMounted = false; if (this.state.pollingToken) { disconnectGasFeeEstimatePoller(this.state.pollingToken); removePollingTokenFromAppState(this.state.pollingToken); } }; _removeBeforeUnload = () => { window.removeEventListener('beforeunload', this._beforeUnloadForGasPolling); }; componentDidMount() { this._isMounted = true; const { toAddress, txData: { origin } = {}, getNextNonce, tryReverseResolveAddress, } = this.props; const { trackEvent } = this.context; trackEvent({ category: EVENT.CATEGORIES.TRANSACTIONS, event: 'Confirm: Started', properties: { action: 'Confirm Screen', legacy_event: true, origin, }, }); getNextNonce(); if (toAddress) { tryReverseResolveAddress(toAddress); } /** * This makes a request to get estimates and begin polling, keeping track of the poll * token in component state. * It then disconnects polling upon componentWillUnmount. If the hook is unmounted * while waiting for `getGasFeeEstimatesAndStartPolling` to resolve, the `_isMounted` * flag ensures that a call to disconnect happens after promise resolution. */ getGasFeeEstimatesAndStartPolling().then((pollingToken) => { if (this._isMounted) { addPollingTokenToAppState(pollingToken); this.setState({ pollingToken }); } else { disconnectGasFeeEstimatePoller(pollingToken); removePollingTokenFromAppState(this.state.pollingToken); } }); window.addEventListener('beforeunload', this._beforeUnloadForGasPolling); } componentWillUnmount() { this._beforeUnloadForGasPolling(); this._removeBeforeUnload(); } supportsEIP1559 = this.props.supportsEIP1559 && !isLegacyTransaction(this.props.txData); render() { const { t } = this.context; const { fromName, fromAddress, toName, toAddress, toEns, toNickname, methodData, title, hideSubtitle, tokenAddress, contentComponent, onEdit, nonce, customNonceValue, unapprovedTxCount, type, hideSenderToRecipient, showAccountInHeader, txData, gasIsLoading, gasFeeIsCustom, nativeCurrency, hardwareWalletRequiresConnection, image, isApprovalOrRejection, assetStandard, } = this.props; const { submitting, submitError, submitWarning, ethGasPriceWarning, editingGas, userAcknowledgedGasMissing, showWarningModal, } = this.state; const { name } = methodData; const { valid, errorKey } = this.getErrorKey(); const hasSimulationError = Boolean(txData.simulationFails); const renderSimulationFailureWarning = hasSimulationError && !userAcknowledgedGasMissing; // This `isTokenApproval` case is added to handle possible rendering of this component from // confirm-approve.js when `assetStandard` is `undefined`. That will happen if the request to // get the asset standard fails. In that scenario, confirm-approve.js returns the `` // component, which in turn returns this `` component. We meed to prevent // the user from editing the transaction in those cases. const isTokenApproval = txData.type === TransactionType.tokenMethodSetApprovalForAll || txData.type === TransactionType.tokenMethodApprove; const isContractInteraction = txData.type === TransactionType.contractInteraction; const isContractInteractionFromDapp = (isTokenApproval || isContractInteraction) && txData.origin !== 'metamask'; let functionType; if (isContractInteractionFromDapp) { functionType = getMethodName(name); } if (!functionType) { if (type) { functionType = getTransactionTypeTitle(t, type, nativeCurrency); } else { functionType = t('contractInteraction'); } } return ( this.handleEdit()} onCancelAll={() => this.handleCancelAll()} onCancel={() => this.handleCancel()} onSubmit={() => this.handleSubmit()} onSetApprovalForAll={() => this.handleSetApprovalForAll()} showWarningModal={showWarningModal} hideSenderToRecipient={hideSenderToRecipient} origin={txData.origin} ethGasPriceWarning={ethGasPriceWarning} editingGas={editingGas} handleCloseEditGas={() => this.handleCloseEditGas()} currentTransaction={txData} supportsEIP1559={this.supportsEIP1559} nativeCurrency={nativeCurrency} isApprovalOrRejection={isApprovalOrRejection} assetStandard={assetStandard} txData={txData} /> ); } }