diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 36654de69..adcc8298f 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2314,6 +2314,9 @@ "signed": { "message": "Signed" }, + "simulationErrorMessage": { + "message": "This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended." + }, "skip": { "message": "Skip" }, @@ -2977,6 +2980,9 @@ "tryAgain": { "message": "Try again" }, + "tryAnywayOption": { + "message": "I will try anyway" + }, "turnOnTokenDetection": { "message": "Turn on enhanced token detection" }, diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js index 574967420..42fe0c744 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import { Tabs, Tab } from '../../../ui/tabs'; import ErrorMessage from '../../../ui/error-message'; +import ActionableMessage from '../../../ui/actionable-message/actionable-message'; import { PageContainerFooter } from '../../../ui/page-container'; import { ConfirmPageContainerSummary, ConfirmPageContainerWarning } from '.'; @@ -17,6 +18,7 @@ export default class ConfirmPageContainerContent extends Component { detailsComponent: PropTypes.node, errorKey: PropTypes.string, errorMessage: PropTypes.string, + hasSimulationError: PropTypes.bool, hideSubtitle: PropTypes.bool, identiconAddress: PropTypes.string, nonce: PropTypes.string, @@ -31,8 +33,10 @@ export default class ConfirmPageContainerContent extends Component { onCancel: PropTypes.func, cancelText: PropTypes.string, onSubmit: PropTypes.func, + onConfirmAnyways: PropTypes.func, submitText: PropTypes.string, disabled: PropTypes.bool, + hideConfirmAnyways: PropTypes.bool, unapprovedTxCount: PropTypes.number, rejectNText: PropTypes.string, hideTitle: PropTypes.boolean, @@ -71,6 +75,7 @@ export default class ConfirmPageContainerContent extends Component { action, errorKey, errorMessage, + hasSimulationError, title, titleComponent, subtitleComponent, @@ -91,14 +96,32 @@ export default class ConfirmPageContainerContent extends Component { origin, ethGasPriceWarning, hideTitle, + onConfirmAnyways, + hideConfirmAnyways, } = this.props; + const primaryAction = hideConfirmAnyways + ? null + : { + label: this.context.t('tryAnywayOption'), + onClick: onConfirmAnyways, + }; + return (
{warning ? : null} {ethGasPriceWarning && ( )} + {hasSimulationError && ( +
+ +
+ )} {this.renderContent()} - {(errorKey || errorMessage) && ( + {(errorKey || errorMessage) && !hasSimulationError && (
diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.test.js b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.test.js new file mode 100644 index 000000000..edd7f7bd5 --- /dev/null +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.test.js @@ -0,0 +1,124 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { renderWithProvider } from '../../../../../test/lib/render-helpers'; +import { TRANSACTION_ERROR_KEY } from '../../../../helpers/constants/error-keys'; +import ConfirmPageContainerContent from './confirm-page-container-content.component'; + +describe('Confirm Page Container Content', () => { + const mockStore = { + metamask: { + provider: { + type: 'test', + }, + }, + }; + + const store = configureMockStore()(mockStore); + + let props = {}; + + beforeEach(() => { + const mockOnCancel = jest.fn(); + const mockOnCancelAll = jest.fn(); + const mockOnSubmit = jest.fn(); + const mockOnConfirmAnyways = jest.fn(); + props = { + action: ' Withdraw Stake', + errorMessage: null, + errorKey: null, + hasSimulationError: true, + onCancelAll: mockOnCancelAll, + onCancel: mockOnCancel, + cancelText: 'Reject', + onSubmit: mockOnSubmit, + onConfirmAnyways: mockOnConfirmAnyways, + submitText: 'Confirm', + disabled: true, + origin: 'http://localhost:4200', + hideTitle: false, + }; + }); + + it('render ConfirmPageContainer component with simulation error', async () => { + const { queryByText, getByText } = renderWithProvider( + , + store, + ); + + expect( + queryByText('Transaction Error. Exception thrown in contract code.'), + ).not.toBeInTheDocument(); + expect( + queryByText( + 'This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended.', + ), + ).toBeInTheDocument(); + expect(queryByText('I will try anyway')).toBeInTheDocument(); + + const confirmButton = getByText('Confirm'); + expect(getByText('Confirm').closest('button')).toBeDisabled(); + fireEvent.click(confirmButton); + expect(props.onSubmit).toHaveBeenCalledTimes(0); + + const iWillTryButton = getByText('I will try anyway'); + fireEvent.click(iWillTryButton); + expect(props.onConfirmAnyways).toHaveBeenCalledTimes(1); + + const cancelButton = getByText('Reject'); + fireEvent.click(cancelButton); + expect(props.onCancel).toHaveBeenCalledTimes(1); + }); + + it('render ConfirmPageContainer component with another error', async () => { + props.hasSimulationError = false; + props.disabled = true; + props.errorKey = TRANSACTION_ERROR_KEY; + const { queryByText, getByText } = renderWithProvider( + , + store, + ); + + expect( + queryByText( + 'This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended.', + ), + ).not.toBeInTheDocument(); + expect(queryByText('I will try anyway')).not.toBeInTheDocument(); + expect(getByText('Confirm').closest('button')).toBeDisabled(); + expect( + getByText('Transaction Error. Exception thrown in contract code.'), + ).toBeInTheDocument(); + + const cancelButton = getByText('Reject'); + fireEvent.click(cancelButton); + expect(props.onCancel).toHaveBeenCalledTimes(1); + }); + + it('render ConfirmPageContainer component with no errors', async () => { + props.hasSimulationError = false; + props.disabled = false; + const { queryByText, getByText } = renderWithProvider( + , + store, + ); + + expect( + queryByText( + 'This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended.', + ), + ).not.toBeInTheDocument(); + expect( + queryByText('Transaction Error. Exception thrown in contract code.'), + ).not.toBeInTheDocument(); + expect(queryByText('I will try anyway')).not.toBeInTheDocument(); + + const confirmButton = getByText('Confirm'); + fireEvent.click(confirmButton); + expect(props.onSubmit).toHaveBeenCalledTimes(1); + + const cancelButton = getByText('Reject'); + fireEvent.click(cancelButton); + expect(props.onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/components/ui/actionable-message/actionable-message.js b/ui/components/ui/actionable-message/actionable-message.js index 8af6646c7..9e4f4728c 100644 --- a/ui/components/ui/actionable-message/actionable-message.js +++ b/ui/components/ui/actionable-message/actionable-message.js @@ -26,6 +26,7 @@ export default function ActionableMessage({ type = 'default', useIcon = false, iconFillColor = '', + roundedButtons, }) { const actionableMessageClassName = classnames( 'actionable-message', @@ -35,6 +36,9 @@ export default function ActionableMessage({ { 'actionable-message--with-icon': useIcon }, ); + const onlyOneAction = + (primaryAction && !secondaryAction) || (secondaryAction && !primaryAction); + return (
{useIcon ? : null} @@ -47,12 +51,19 @@ export default function ActionableMessage({ )}
{message}
{(primaryAction || secondaryAction) && ( -
+
{primaryAction && (
) : null; + const renderGasDetailsItem = () => { + return EIP_1559_V2 ? ( + + ) : ( + + {isMultiLayerFeeNetwork + ? t('transactionDetailLayer2GasHeading') + : t('transactionDetailGasHeading')} + + + + + ) : ( + <> + {isMultiLayerFeeNetwork + ? t('transactionDetailLayer2GasHeading') + : t('transactionDetailGasHeading')} + +

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

+

{t('transactionDetailGasTooltipExplanation')}

+

+ + {t('transactionDetailGasTooltipConversion')} + +

+ + } + position="top" + > + +
+ + ) + } + detailTitleColor={COLORS.BLACK} + detailText={ + !isMultiLayerFeeNetwork && ( +
+ {renderHeartBeatIfNotInTest()} + +
+ ) + } + detailTotal={ +
+ {renderHeartBeatIfNotInTest()} + +
+ } + subText={ + !isMultiLayerFeeNetwork && + t('editGasSubTextFee', [ + {t('editGasSubTextFeeLabel')}, +
+ {renderHeartBeatIfNotInTest()} + +
, + ]) + } + subTitle={ + <> + {txData.dappSuggestedGasFees ? ( + + {t('transactionDetailDappGasMoreInfo')} + + ) : ( + '' + )} + {supportsEIP1559 && ( + + )} + + } + /> + ); + }; + + const simulationFailureWarning = () => ( +
+ this.handleConfirmAnyways(), + }} + message={this.context.t('simulationErrorMessage')} + roundedButtons + /> +
+ ); + return (
{EIP_1559_V2 && } this.handleEditGas()} + disabled={isDisabled()} + onEdit={ + renderSimulationFailureWarning ? null : () => this.handleEditGas() + } rows={[ - EIP_1559_V2 ? ( - - ) : ( - - {isMultiLayerFeeNetwork - ? t('transactionDetailLayer2GasHeading') - : t('transactionDetailGasHeading')} - - - - - ) : ( - <> - {isMultiLayerFeeNetwork - ? t('transactionDetailLayer2GasHeading') - : t('transactionDetailGasHeading')} - -

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

-

{t('transactionDetailGasTooltipExplanation')}

-

- - {t('transactionDetailGasTooltipConversion')} - -

- - } - position="top" - > - -
- - ) - } - detailTitleColor={COLORS.BLACK} - detailText={ - !isMultiLayerFeeNetwork && ( -
- {renderHeartBeatIfNotInTest()} - -
- ) - } - detailTotal={ -
- {renderHeartBeatIfNotInTest()} - -
- } - subText={ - !isMultiLayerFeeNetwork && - t('editGasSubTextFee', [ - - {t('editGasSubTextFeeLabel')} - , -
- {renderHeartBeatIfNotInTest()} - -
, - ]) - } - subTitle={ - <> - {txData.dappSuggestedGasFees ? ( - - {t('transactionDetailDappGasMoreInfo')} - - ) : ( - '' - )} - {supportsEIP1559 && ( - - )} - - } - /> - ), - isMultiLayerFeeNetwork && ( + renderSimulationFailureWarning && simulationFailureWarning(), + !renderSimulationFailureWarning && renderGasDetailsItem(), + !renderSimulationFailureWarning && isMultiLayerFeeNetwork && ( { + return confirmAnyways ? false : !valid; + }; + let functionType = getMethodName(name); if (!functionType) { if (type) { @@ -965,6 +998,7 @@ export default class ConfirmTransactionBase extends Component { identiconAddress={identiconAddress} errorMessage={submitError} errorKey={errorKey} + hasSimulationError={hasSimulationError} warning={submitWarning} totalTx={totalTx} positionOfCurrentTx={positionOfCurrentTx} @@ -976,7 +1010,9 @@ export default class ConfirmTransactionBase extends Component { lastTx={lastTx} ofText={ofText} requestsWaitingText={requestsWaitingText} + hideConfirmAnyways={!isDisabled()} disabled={ + renderSimulationFailureWarning || !valid || submitting || hardwareWalletRequiresConnection || @@ -986,6 +1022,7 @@ export default class ConfirmTransactionBase extends Component { onCancelAll={() => this.handleCancelAll()} onCancel={() => this.handleCancel()} onSubmit={() => this.handleSubmit()} + onConfirmAnyways={() => this.handleConfirmAnyways()} hideSenderToRecipient={hideSenderToRecipient} origin={txData.origin} ethGasPriceWarning={ethGasPriceWarning}