1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Confirm transaction page - onchain failure handling (#12743)

This commit is contained in:
Jyoti Puri 2021-11-24 22:32:53 +05:30 committed by GitHub
parent fb27e170ac
commit 7609841902
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 295 additions and 101 deletions

View File

@ -1382,7 +1382,8 @@
"message": "Want to $1 about gas?" "message": "Want to $1 about gas?"
}, },
"learnCancelSpeeedup": { "learnCancelSpeeedup": {
"message": "Learn how to $1" "message": "Learn how to $1",
"description": "$1 is link to cancel or speed up transactions"
}, },
"learnMore": { "learnMore": {
"message": "learn more" "message": "learn more"
@ -1969,12 +1970,16 @@
"pending": { "pending": {
"message": "Pending" "message": "Pending"
}, },
"pendingTransaction": {
"message": "You have ($1) pending transaction(s)."
},
"pendingTransactionInfo": { "pendingTransactionInfo": {
"message": "This transaction will not process until that one is complete." "message": "This transaction will not process until that one is complete."
}, },
"pendingTransactionMultiple": {
"message": "You have ($1) pending transactions."
},
"pendingTransactionSingle": {
"message": "You have (1) pending transaction.",
"description": "$1 is count of pending transactions"
},
"permissionCheckedIconDescription": { "permissionCheckedIconDescription": {
"message": "You have approved this permission" "message": "You have approved this permission"
}, },
@ -2020,6 +2025,9 @@
"privateNetwork": { "privateNetwork": {
"message": "Private Network" "message": "Private Network"
}, },
"proceedWithTransaction": {
"message": "I want to proceed anyway"
},
"proposedApprovalLimit": { "proposedApprovalLimit": {
"message": "Proposed Approval Limit" "message": "Proposed Approval Limit"
}, },
@ -2407,6 +2415,9 @@
"simulationErrorMessage": { "simulationErrorMessage": {
"message": "This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended." "message": "This transaction is expected to fail. Trying to execute it is expected to be expensive but fail, and is not recommended."
}, },
"simulationErrorMessageV2": {
"message": "We were not able to estimate gas. There might be an error in the contract and this transaction may fail."
},
"skip": { "skip": {
"message": "Skip" "message": "Skip"
}, },

View File

@ -36,10 +36,10 @@ export default class ConfirmPageContainerContent extends Component {
onCancel: PropTypes.func, onCancel: PropTypes.func,
cancelText: PropTypes.string, cancelText: PropTypes.string,
onSubmit: PropTypes.func, onSubmit: PropTypes.func,
onConfirmAnyways: PropTypes.func, setUserAcknowledgedGasMissing: PropTypes.func,
submitText: PropTypes.string, submitText: PropTypes.string,
disabled: PropTypes.bool, disabled: PropTypes.bool,
hideConfirmAnyways: PropTypes.bool, hideUserAcknowledgedGasMissing: PropTypes.bool,
unapprovedTxCount: PropTypes.number, unapprovedTxCount: PropTypes.number,
rejectNText: PropTypes.string, rejectNText: PropTypes.string,
hideTitle: PropTypes.boolean, hideTitle: PropTypes.boolean,
@ -99,15 +99,15 @@ export default class ConfirmPageContainerContent extends Component {
origin, origin,
ethGasPriceWarning, ethGasPriceWarning,
hideTitle, hideTitle,
onConfirmAnyways, setUserAcknowledgedGasMissing,
hideConfirmAnyways, hideUserAcknowledgedGasMissing,
} = this.props; } = this.props;
const primaryAction = hideConfirmAnyways const primaryAction = hideUserAcknowledgedGasMissing
? null ? null
: { : {
label: this.context.t('tryAnywayOption'), label: this.context.t('tryAnywayOption'),
onClick: onConfirmAnyways, onClick: setUserAcknowledgedGasMissing,
}; };
return ( return (

View File

@ -22,7 +22,7 @@ describe('Confirm Page Container Content', () => {
const mockOnCancel = jest.fn(); const mockOnCancel = jest.fn();
const mockOnCancelAll = jest.fn(); const mockOnCancelAll = jest.fn();
const mockOnSubmit = jest.fn(); const mockOnSubmit = jest.fn();
const mockOnConfirmAnyways = jest.fn(); const mockSetUserAcknowledgedGasMissing = jest.fn();
props = { props = {
action: ' Withdraw Stake', action: ' Withdraw Stake',
errorMessage: null, errorMessage: null,
@ -32,7 +32,7 @@ describe('Confirm Page Container Content', () => {
onCancel: mockOnCancel, onCancel: mockOnCancel,
cancelText: 'Reject', cancelText: 'Reject',
onSubmit: mockOnSubmit, onSubmit: mockOnSubmit,
onConfirmAnyways: mockOnConfirmAnyways, setUserAcknowledgedGasMissing: mockSetUserAcknowledgedGasMissing,
submitText: 'Confirm', submitText: 'Confirm',
disabled: true, disabled: true,
origin: 'http://localhost:4200', origin: 'http://localhost:4200',
@ -63,7 +63,7 @@ describe('Confirm Page Container Content', () => {
const iWillTryButton = getByText('I will try anyway'); const iWillTryButton = getByText('I will try anyway');
fireEvent.click(iWillTryButton); fireEvent.click(iWillTryButton);
expect(props.onConfirmAnyways).toHaveBeenCalledTimes(1); expect(props.setUserAcknowledgedGasMissing).toHaveBeenCalledTimes(1);
const cancelButton = getByText('Reject'); const cancelButton = getByText('Reject');
fireEvent.click(cancelButton); fireEvent.click(cancelButton);

View File

@ -1,5 +1,5 @@
.edit-gas-fee-popover { .edit-gas-fee-popover {
height: 540px; height: 500px;
&__wrapper { &__wrapper {
border-top: 1px solid $ui-grey; border-top: 1px solid $ui-grey;

View File

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { toBigNumber } from '../../../../../shared/modules/conversion.utils';
import { COLORS } from '../../../../helpers/constants/design-system'; import { COLORS } from '../../../../helpers/constants/design-system';
import { useGasFeeContext } from '../../../../contexts/gasFee'; import { useGasFeeContext } from '../../../../contexts/gasFee';
import I18nValue from '../../../ui/i18n-value'; import I18nValue from '../../../ui/i18n-value';
@ -10,13 +9,6 @@ import StatusSlider from './status-slider';
const NetworkStatus = () => { const NetworkStatus = () => {
const { gasFeeEstimates } = useGasFeeContext(); const { gasFeeEstimates } = useGasFeeContext();
let estBaseFee = null;
if (gasFeeEstimates?.estimatedBaseFee) {
// estimatedBaseFee is not likely to be below 1, value .01 is used as test networks sometimes
// show have small values for it and more decimal places may cause UI to look broken.
estBaseFee = toBigNumber.dec(gasFeeEstimates?.estimatedBaseFee);
estBaseFee = estBaseFee.lessThan(0.01) ? 0.01 : estBaseFee.toFixed(2);
}
return ( return (
<div className="network-status"> <div className="network-status">
@ -32,7 +24,8 @@ const NetworkStatus = () => {
<div className="network-status__info"> <div className="network-status__info">
<div className="network-status__info__field"> <div className="network-status__info__field">
<span className="network-status__info__field-data"> <span className="network-status__info__field-data">
{estBaseFee !== null && `${estBaseFee} GWEI`} {gasFeeEstimates?.estimatedBaseFee &&
`${gasFeeEstimates?.estimatedBaseFee} GWEI`}
</span> </span>
<span className="network-status__info__field-label">Base fee</span> <span className="network-status__info__field-label">Base fee</span>
</div> </div>

View File

@ -57,21 +57,10 @@ describe('NetworkStatus', () => {
expect(screen.queryByText('Priority fee')).toBeInTheDocument(); expect(screen.queryByText('Priority fee')).toBeInTheDocument();
}); });
it('should renders current base fee value rounded to 2 decimal places', () => { it('should renders current base fee value', () => {
renderComponent(); renderComponent();
expect( expect(
screen.queryByText( screen.queryByText(`${MOCK_FEE_ESTIMATE.estimatedBaseFee} GWEI`),
`${parseFloat(MOCK_FEE_ESTIMATE.estimatedBaseFee).toFixed(2)} GWEI`,
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('should .01 as estimates base fee if estimated base fee is < .01', () => {
renderComponent({
gasFeeEstimates: {
estimatedBaseFee: '0.0012',
},
});
expect(screen.queryByText('0.01 GWEI')).toBeInTheDocument();
});
}); });

View File

@ -36,7 +36,7 @@ export default function TransactionDetailItem({
color={COLORS.BLACK} color={COLORS.BLACK}
fontWeight={FONT_WEIGHT.BOLD} fontWeight={FONT_WEIGHT.BOLD}
variant={TYPOGRAPHY.H6} variant={TYPOGRAPHY.H6}
margin={[1, 1]} margin={[1, 0, 1, 1]}
> >
{detailTotal} {detailTotal}
</Typography> </Typography>

View File

@ -17,7 +17,6 @@
} }
&-edit-V2 { &-edit-V2 {
margin-bottom: 10px;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: flex-end; justify-content: flex-end;
@ -66,4 +65,8 @@
} }
} }
} }
&-rows {
margin-top: 10px;
}
} }

View File

@ -11,13 +11,18 @@ import { COLORS } from '../../../helpers/constants/design-system';
import { PRIORITY_LEVEL_ICON_MAP } from '../../../helpers/constants/gas'; import { PRIORITY_LEVEL_ICON_MAP } from '../../../helpers/constants/gas';
import { useI18nContext } from '../../../hooks/useI18nContext'; import { useI18nContext } from '../../../hooks/useI18nContext';
export default function TransactionDetail({ rows = [], onEdit }) { export default function TransactionDetail({
rows = [],
onEdit,
userAcknowledgedGasMissing,
}) {
// eslint-disable-next-line prefer-destructuring // eslint-disable-next-line prefer-destructuring
const EIP_1559_V2 = process.env.EIP_1559_V2; const EIP_1559_V2 = process.env.EIP_1559_V2;
const t = useI18nContext(); const t = useI18nContext();
const { const {
gasLimit, gasLimit,
hasSimulationError,
estimateUsed, estimateUsed,
maxFeePerGas, maxFeePerGas,
maxPriorityFeePerGas, maxPriorityFeePerGas,
@ -26,6 +31,9 @@ export default function TransactionDetail({ rows = [], onEdit }) {
const { openModal } = useTransactionModalContext(); const { openModal } = useTransactionModalContext();
if (EIP_1559_V2 && estimateUsed) { if (EIP_1559_V2 && estimateUsed) {
const editEnabled = !hasSimulationError || userAcknowledgedGasMissing;
if (!editEnabled) return null;
return ( return (
<div className="transaction-detail"> <div className="transaction-detail">
<div className="transaction-detail-edit-V2"> <div className="transaction-detail-edit-V2">
@ -88,4 +96,5 @@ export default function TransactionDetail({ rows = [], onEdit }) {
TransactionDetail.propTypes = { TransactionDetail.propTypes = {
rows: PropTypes.arrayOf(TransactionDetailItem).isRequired, rows: PropTypes.arrayOf(TransactionDetailItem).isRequired,
onEdit: PropTypes.func, onEdit: PropTypes.func,
userAcknowledgedGasMissing: PropTypes.bool.isRequired,
}; };

View File

@ -16,7 +16,7 @@ jest.mock('../../../store/actions', () => ({
addPollingTokenToAppState: jest.fn(), addPollingTokenToAppState: jest.fn(),
})); }));
const render = (props) => { const render = ({ componentProps, contextProps } = {}) => {
const store = configureStore({ const store = configureStore({
metamask: { metamask: {
nativeCurrency: ETH, nativeCurrency: ETH,
@ -37,13 +37,13 @@ const render = (props) => {
}); });
return renderWithProvider( return renderWithProvider(
<GasFeeContextProvider {...props}> <GasFeeContextProvider {...contextProps}>
<TransactionDetail <TransactionDetail
onEdit={() => { onEdit={() => {
console.log('on edit'); console.log('on edit');
}} }}
rows={[]} rows={[]}
{...props} {...componentProps}
/> />
</GasFeeContextProvider>, </GasFeeContextProvider>,
store, store,
@ -54,41 +54,79 @@ describe('TransactionDetail', () => {
beforeEach(() => { beforeEach(() => {
process.env.EIP_1559_V2 = true; process.env.EIP_1559_V2 = true;
}); });
afterEach(() => { afterEach(() => {
process.env.EIP_1559_V2 = false; process.env.EIP_1559_V2 = false;
}); });
it('should render edit link with text low if low gas estimates are selected', () => { it('should render edit link with text low if low gas estimates are selected', () => {
render({ transaction: { userFeeLevel: 'low' } }); render({ contextProps: { transaction: { userFeeLevel: 'low' } } });
expect(screen.queryByText('🐢')).toBeInTheDocument(); expect(screen.queryByText('🐢')).toBeInTheDocument();
expect(screen.queryByText('Low')).toBeInTheDocument(); expect(screen.queryByText('Low')).toBeInTheDocument();
}); });
it('should render edit link with text markey if medium gas estimates are selected', () => { it('should render edit link with text markey if medium gas estimates are selected', () => {
render({ transaction: { userFeeLevel: 'medium' } }); render({ contextProps: { transaction: { userFeeLevel: 'medium' } } });
expect(screen.queryByText('🦊')).toBeInTheDocument(); expect(screen.queryByText('🦊')).toBeInTheDocument();
expect(screen.queryByText('Market')).toBeInTheDocument(); expect(screen.queryByText('Market')).toBeInTheDocument();
}); });
it('should render edit link with text agressive if high gas estimates are selected', () => { it('should render edit link with text agressive if high gas estimates are selected', () => {
render({ transaction: { userFeeLevel: 'high' } }); render({ contextProps: { transaction: { userFeeLevel: 'high' } } });
expect(screen.queryByText('🦍')).toBeInTheDocument(); expect(screen.queryByText('🦍')).toBeInTheDocument();
expect(screen.queryByText('Aggressive')).toBeInTheDocument(); expect(screen.queryByText('Aggressive')).toBeInTheDocument();
}); });
it('should render edit link with text Site suggested if site suggested estimated are used', () => { it('should render edit link with text Site suggested if site suggested estimated are used', () => {
render({ render({
contextProps: {
transaction: { transaction: {
dappSuggestedGasFees: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 }, dappSuggestedGasFees: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 },
txParams: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 }, txParams: { maxFeePerGas: 1, maxPriorityFeePerGas: 1 },
}, },
},
}); });
expect(screen.queryByText('🌐')).toBeInTheDocument(); expect(screen.queryByText('🌐')).toBeInTheDocument();
expect(screen.queryByText('Site suggested')).toBeInTheDocument(); expect(screen.queryByText('Site suggested')).toBeInTheDocument();
expect(document.getElementsByClassName('info-tooltip')).toHaveLength(1); expect(document.getElementsByClassName('info-tooltip')).toHaveLength(1);
}); });
it('should render edit link with text advance if custom gas estimates are used', () => { it('should render edit link with text advance if custom gas estimates are used', () => {
render({ render({
contextProps: {
defaultEstimateToUse: 'custom', defaultEstimateToUse: 'custom',
},
}); });
expect(screen.queryByText('⚙')).toBeInTheDocument(); expect(screen.queryByText('⚙')).toBeInTheDocument();
expect(screen.queryByText('Advanced')).toBeInTheDocument(); expect(screen.queryByText('Advanced')).toBeInTheDocument();
expect(screen.queryByText('Edit')).toBeInTheDocument(); expect(screen.queryByText('Edit')).toBeInTheDocument();
}); });
it('should not render edit link if transaction has simulation error and prop userAcknowledgedGasMissing is false', () => {
render({
contextProps: {
transaction: {
simulationFails: true,
userFeeLevel: 'low',
},
},
componentProps: { userAcknowledgedGasMissing: false },
});
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByText('Low')).not.toBeInTheDocument();
});
it('should render edit link if userAcknowledgedGasMissing is true even if transaction has simulation error', () => {
render({
contextProps: {
transaction: {
simulationFails: true,
userFeeLevel: 'low',
},
},
componentProps: { userAcknowledgedGasMissing: true },
});
expect(screen.queryByRole('button')).toBeInTheDocument();
expect(screen.queryByText('Low')).toBeInTheDocument();
});
}); });

View File

@ -19,6 +19,7 @@ const typeHash = {
export default function ActionableMessage({ export default function ActionableMessage({
message = '', message = '',
primaryAction = null, primaryAction = null,
primaryActionV2 = null,
secondaryAction = null, secondaryAction = null,
className = '', className = '',
infoTooltipText = '', infoTooltipText = '',
@ -50,6 +51,14 @@ export default function ActionableMessage({
/> />
)} )}
<div className="actionable-message__message">{message}</div> <div className="actionable-message__message">{message}</div>
{primaryActionV2 && (
<button
className="actionable-message__action-v2"
onClick={primaryActionV2.onClick}
>
{primaryActionV2.label}
</button>
)}
{(primaryAction || secondaryAction) && ( {(primaryAction || secondaryAction) && (
<div <div
className={classnames('actionable-message__actions', { className={classnames('actionable-message__actions', {
@ -61,6 +70,7 @@ export default function ActionableMessage({
className={classnames( className={classnames(
'actionable-message__action', 'actionable-message__action',
'actionable-message__action--primary', 'actionable-message__action--primary',
`actionable-message__action-${type}`,
{ {
'actionable-message__action--rounded': roundedButtons, 'actionable-message__action--rounded': roundedButtons,
}, },
@ -75,6 +85,7 @@ export default function ActionableMessage({
className={classnames( className={classnames(
'actionable-message__action', 'actionable-message__action',
'actionable-message__action--secondary', 'actionable-message__action--secondary',
`actionable-message__action-${type}`,
{ {
'actionable-message__action--rounded': roundedButtons, 'actionable-message__action--rounded': roundedButtons,
}, },
@ -96,6 +107,10 @@ ActionableMessage.propTypes = {
label: PropTypes.string, label: PropTypes.string,
onClick: PropTypes.func, onClick: PropTypes.func,
}), }),
primaryActionV2: PropTypes.shape({
label: PropTypes.string,
onClick: PropTypes.func,
}),
secondaryAction: PropTypes.shape({ secondaryAction: PropTypes.shape({
label: PropTypes.string, label: PropTypes.string,
onClick: PropTypes.func, onClick: PropTypes.func,

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { fireEvent } from '@testing-library/react';
import { renderWithProvider } from '../../../../test/jest'; import { renderWithProvider } from '../../../../test/jest';
import ActionableMessage from '.'; import ActionableMessage from '.';
@ -19,4 +20,28 @@ describe('ActionableMessage', () => {
expect(getByText(props.message)).toBeInTheDocument(); expect(getByText(props.message)).toBeInTheDocument();
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it('renders button for primaryActionV2 prop', () => {
const props = createProps();
const { getByRole } = renderWithProvider(
<ActionableMessage
{...props}
primaryActionV2={{ label: 'primary-action-v2' }}
/>,
);
expect(getByRole('button')).toBeInTheDocument();
});
it('renders primaryActionV2.onClick is callen when primaryActionV2 button is clicked', () => {
const props = createProps();
const onClick = jest.fn();
const { getByRole } = renderWithProvider(
<ActionableMessage
{...props}
primaryActionV2={{ label: 'primary-action-v2', onClick }}
/>,
);
fireEvent.click(getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
}); });

View File

@ -54,6 +54,21 @@
&--rounded { &--rounded {
border-radius: 8px; border-radius: 8px;
} }
&-danger {
background: $Red-500;
color: #fff;
}
}
&__action-v2 {
color: $primary-1;
background: none;
border: none;
font-size: 12px;
align-self: baseline;
padding: 0;
margin-top: 12px;
} }
&__info-tooltip-wrapper { &__info-tooltip-wrapper {
@ -86,11 +101,6 @@
color: $Black-100; color: $Black-100;
text-align: left; text-align: left;
} }
button {
background: $Red-500;
color: #fff;
}
} }
&--info { &--info {

View File

@ -262,5 +262,6 @@ export function useGasFeeErrors({
gasWarnings, gasWarnings,
balanceError, balanceError,
estimatesUnavailableWarning, estimatesUnavailableWarning,
hasSimulationError: Boolean(transaction?.simulationFails),
}; };
} }

View File

@ -280,6 +280,21 @@ describe('useGasFeeErrors', () => {
}); });
}); });
describe('Simulation Error', () => {
it('is false if transaction has falsy values for simulationFails', () => {
configureEIP1559();
const { result } = renderUseGasFeeErrorsHook();
expect(result.current.hasSimulationError).toBe(false);
});
it('is true if transaction.simulationFails is true', () => {
configureEIP1559();
const { result } = renderUseGasFeeErrorsHook({
transaction: { simulationFails: true },
});
expect(result.current.hasSimulationError).toBe(true);
});
});
describe('estimatesUnavailableWarning', () => { describe('estimatesUnavailableWarning', () => {
it('is false if supportsEIP1559 and gasEstimateType is fee-market', () => { it('is false if supportsEIP1559 and gasEstimateType is fee-market', () => {
configureEIP1559(); configureEIP1559();

View File

@ -186,6 +186,7 @@ export function useGasFeeInputs(
gasErrors, gasErrors,
gasWarnings, gasWarnings,
hasGasErrors, hasGasErrors,
hasSimulationError,
} = useGasFeeErrors({ } = useGasFeeErrors({
gasEstimateType, gasEstimateType,
gasFeeEstimates, gasFeeEstimates,
@ -301,6 +302,7 @@ export function useGasFeeInputs(
gasErrors, gasErrors,
gasWarnings, gasWarnings,
hasGasErrors, hasGasErrors,
hasSimulationError,
supportsEIP1559, supportsEIP1559,
updateTransactionUsingGasFeeEstimates, updateTransactionUsingGasFeeEstimates,
}; };

View File

@ -145,7 +145,7 @@ export default class ConfirmTransactionBase extends Component {
submitWarning: '', submitWarning: '',
ethGasPriceWarning: '', ethGasPriceWarning: '',
editingGas: false, editingGas: false,
confirmAnyways: false, userAcknowledgedGasMissing: false,
}; };
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@ -290,8 +290,8 @@ export default class ConfirmTransactionBase extends Component {
this.setState({ editingGas: false }); this.setState({ editingGas: false });
} }
handleConfirmAnyways() { setUserAcknowledgedGasMissing() {
this.setState({ confirmAnyways: true }); this.setState({ userAcknowledgedGasMissing: true });
} }
renderDetails() { renderDetails() {
@ -318,15 +318,16 @@ export default class ConfirmTransactionBase extends Component {
nativeCurrency, nativeCurrency,
} = this.props; } = this.props;
const { t } = this.context; const { t } = this.context;
const { userAcknowledgedGasMissing } = this.state;
const { valid } = this.getErrorKey(); const { valid } = this.getErrorKey();
const isDisabled = () => { const isDisabled = () => {
return this.state.confirmAnyways ? false : !valid; return userAcknowledgedGasMissing ? false : !valid;
}; };
const hasSimulationError = Boolean(txData.simulationFails); const hasSimulationError = Boolean(txData.simulationFails);
const renderSimulationFailureWarning = const renderSimulationFailureWarning =
hasSimulationError && !this.state.confirmAnyways; hasSimulationError && !userAcknowledgedGasMissing;
const renderTotalMaxAmount = () => { const renderTotalMaxAmount = () => {
if ( if (
@ -432,6 +433,7 @@ export default class ConfirmTransactionBase extends Component {
useNativeCurrencyAsPrimaryCurrency={ useNativeCurrencyAsPrimaryCurrency={
useNativeCurrencyAsPrimaryCurrency useNativeCurrencyAsPrimaryCurrency
} }
userAcknowledgedGasMissing={userAcknowledgedGasMissing}
/> />
) : ( ) : (
<TransactionDetailItem <TransactionDetailItem
@ -559,7 +561,7 @@ export default class ConfirmTransactionBase extends Component {
type="danger" type="danger"
primaryAction={{ primaryAction={{
label: this.context.t('tryAnywayOption'), label: this.context.t('tryAnywayOption'),
onClick: () => this.handleConfirmAnyways(), onClick: () => this.setUserAcknowledgedGasMissing(),
}} }}
message={this.context.t('simulationErrorMessage')} message={this.context.t('simulationErrorMessage')}
roundedButtons roundedButtons
@ -569,9 +571,17 @@ export default class ConfirmTransactionBase extends Component {
return ( return (
<div className="confirm-page-container-content__details"> <div className="confirm-page-container-content__details">
{EIP_1559_V2 && <TransactionAlerts />} {EIP_1559_V2 && (
<TransactionAlerts
setUserAcknowledgedGasMissing={() =>
this.setUserAcknowledgedGasMissing()
}
userAcknowledgedGasMissing={userAcknowledgedGasMissing}
/>
)}
<TransactionDetail <TransactionDetail
disabled={isDisabled()} disabled={isDisabled()}
userAcknowledgedGasMissing={userAcknowledgedGasMissing}
onEdit={ onEdit={
renderSimulationFailureWarning ? null : () => this.handleEditGas() renderSimulationFailureWarning ? null : () => this.handleEditGas()
} }
@ -946,14 +956,14 @@ export default class ConfirmTransactionBase extends Component {
submitWarning, submitWarning,
ethGasPriceWarning, ethGasPriceWarning,
editingGas, editingGas,
confirmAnyways, userAcknowledgedGasMissing,
} = this.state; } = this.state;
const { name } = methodData; const { name } = methodData;
const { valid, errorKey } = this.getErrorKey(); const { valid, errorKey } = this.getErrorKey();
const hasSimulationError = Boolean(txData.simulationFails); const hasSimulationError = Boolean(txData.simulationFails);
const renderSimulationFailureWarning = const renderSimulationFailureWarning =
hasSimulationError && !confirmAnyways; hasSimulationError && !userAcknowledgedGasMissing;
const { const {
totalTx, totalTx,
positionOfCurrentTx, positionOfCurrentTx,
@ -967,7 +977,7 @@ export default class ConfirmTransactionBase extends Component {
} = this.getNavigateTxData(); } = this.getNavigateTxData();
const isDisabled = () => { const isDisabled = () => {
return confirmAnyways ? false : !valid; return userAcknowledgedGasMissing ? false : !valid;
}; };
let functionType = getMethodName(name); let functionType = getMethodName(name);
@ -1017,7 +1027,7 @@ export default class ConfirmTransactionBase extends Component {
lastTx={lastTx} lastTx={lastTx}
ofText={ofText} ofText={ofText}
requestsWaitingText={requestsWaitingText} requestsWaitingText={requestsWaitingText}
hideConfirmAnyways={!isDisabled()} hideUserAcknowledgedGasMissing={!isDisabled()}
disabled={ disabled={
renderSimulationFailureWarning || renderSimulationFailureWarning ||
!valid || !valid ||
@ -1029,7 +1039,7 @@ export default class ConfirmTransactionBase extends Component {
onCancelAll={() => this.handleCancelAll()} onCancelAll={() => this.handleCancelAll()}
onCancel={() => this.handleCancel()} onCancel={() => this.handleCancel()}
onSubmit={() => this.handleSubmit()} onSubmit={() => this.handleSubmit()}
onConfirmAnyways={() => this.handleConfirmAnyways()} setUserAcknowledgedGasMissing={this.setUserAcknowledgedGasMissing}
hideSenderToRecipient={hideSenderToRecipient} hideSenderToRecipient={hideSenderToRecipient}
origin={txData.origin} origin={txData.origin}
ethGasPriceWarning={ethGasPriceWarning} ethGasPriceWarning={ethGasPriceWarning}

View File

@ -26,11 +26,14 @@ const GasDetailsItem = ({
isMainnet, isMainnet,
maxFeePerGas, maxFeePerGas,
maxPriorityFeePerGas, maxPriorityFeePerGas,
userAcknowledgedGasMissing,
txData, txData,
useNativeCurrencyAsPrimaryCurrency, useNativeCurrencyAsPrimaryCurrency,
}) => { }) => {
const t = useI18nContext(); const t = useI18nContext();
const { estimateUsed } = useGasFeeContext(); const { estimateUsed, hasSimulationError } = useGasFeeContext();
if (hasSimulationError && !userAcknowledgedGasMissing) return null;
return ( return (
<TransactionDetailItem <TransactionDetailItem
@ -138,6 +141,7 @@ GasDetailsItem.propTypes = {
isMainnet: PropTypes.bool, isMainnet: PropTypes.bool,
maxFeePerGas: PropTypes.string, maxFeePerGas: PropTypes.string,
maxPriorityFeePerGas: PropTypes.string, maxPriorityFeePerGas: PropTypes.string,
userAcknowledgedGasMissing: PropTypes.bool.isRequired,
txData: PropTypes.object, txData: PropTypes.object,
useNativeCurrencyAsPrimaryCurrency: PropTypes.bool, useNativeCurrencyAsPrimaryCurrency: PropTypes.bool,
}; };

View File

@ -17,7 +17,7 @@ jest.mock('../../../store/actions', () => ({
getGasFeeTimeEstimate: jest.fn().mockImplementation(() => Promise.resolve()), getGasFeeTimeEstimate: jest.fn().mockImplementation(() => Promise.resolve()),
})); }));
const render = (props) => { const render = ({ componentProps, contextProps } = {}) => {
const store = configureStore({ const store = configureStore({
metamask: { metamask: {
nativeCurrency: ETH, nativeCurrency: ETH,
@ -37,8 +37,12 @@ const render = (props) => {
}); });
return renderWithProvider( return renderWithProvider(
<GasFeeContextProvider {...props}> <GasFeeContextProvider {...contextProps}>
<GasDetailsItem txData={{ txParams: {} }} {...props} /> <GasDetailsItem
txData={{ txParams: {} }}
userAcknowledgedGasMissing={false}
{...componentProps}
/>
</GasFeeContextProvider>, </GasFeeContextProvider>,
store, store,
); );
@ -56,16 +60,47 @@ describe('GasDetailsItem', () => {
}); });
it('should show warning icon if estimates are high', async () => { it('should show warning icon if estimates are high', async () => {
render({ defaultEstimateToUse: 'high' }); render({ contextProps: { defaultEstimateToUse: 'high' } });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('⚠ Max fee:')).toBeInTheDocument(); expect(screen.queryByText('⚠ Max fee:')).toBeInTheDocument();
}); });
}); });
it('should not show warning icon if estimates are not high', async () => { it('should not show warning icon if estimates are not high', async () => {
render({ defaultEstimateToUse: 'low' }); render({ contextProps: { defaultEstimateToUse: 'low' } });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Max fee:')).toBeInTheDocument(); expect(screen.queryByText('Max fee:')).toBeInTheDocument();
}); });
}); });
it('should return null if there is simulationError and user has not acknowledged gasMissing warning', () => {
const { container } = render({
contextProps: {
defaultEstimateToUse: 'low',
transaction: { simulationFails: true },
},
});
expect(container.innerHTML).toHaveLength(0);
});
it('should not return null even if there is simulationError if user acknowledged gasMissing warning', async () => {
render();
await waitFor(() => {
expect(screen.queryByText('Gas')).toBeInTheDocument();
});
});
it('should should render gas fee details', async () => {
render({
componentProps: {
hexMinimumTransactionFee: '0x1ca62a4f7800',
hexMaximumTransactionFee: '0x290ee75e3d900',
},
});
await waitFor(() => {
expect(screen.queryByTitle('0.0000315 ETH')).toBeInTheDocument();
expect(screen.queryByText('ETH')).toBeInTheDocument();
expect(screen.queryByTitle('0.0007223')).toBeInTheDocument();
});
});
}); });

View File

@ -1,26 +1,59 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { PRIORITY_LEVELS } from '../../../../shared/constants/gas';
import { INSUFFICIENT_FUNDS_ERROR_KEY } from '../../../helpers/constants/error-keys'; import { INSUFFICIENT_FUNDS_ERROR_KEY } from '../../../helpers/constants/error-keys';
import { submittedPendingTransactionsSelector } from '../../../selectors/transactions'; import { submittedPendingTransactionsSelector } from '../../../selectors/transactions';
import { useGasFeeContext } from '../../../contexts/gasFee'; import { useGasFeeContext } from '../../../contexts/gasFee';
import { useI18nContext } from '../../../hooks/useI18nContext';
import ActionableMessage from '../../../components/ui/actionable-message/actionable-message'; import ActionableMessage from '../../../components/ui/actionable-message/actionable-message';
import ErrorMessage from '../../../components/ui/error-message'; import ErrorMessage from '../../../components/ui/error-message';
import I18nValue from '../../../components/ui/i18n-value'; import I18nValue from '../../../components/ui/i18n-value';
import Typography from '../../../components/ui/typography';
const TransactionAlerts = () => { const TransactionAlerts = ({
const { balanceError, estimateUsed } = useGasFeeContext(); userAcknowledgedGasMissing,
setUserAcknowledgedGasMissing,
}) => {
const { balanceError, estimateUsed, hasSimulationError } = useGasFeeContext();
const pendingTransactions = useSelector(submittedPendingTransactionsSelector); const pendingTransactions = useSelector(submittedPendingTransactionsSelector);
const t = useI18nContext();
return ( return (
<div className="transaction-alerts"> <div className="transaction-alerts">
{hasSimulationError && (
<ActionableMessage
message={<I18nValue messageKey="simulationErrorMessageV2" />}
useIcon
iconFillColor="#d73a49"
type="danger"
primaryActionV2={
userAcknowledgedGasMissing === true
? undefined
: {
label: t('proceedWithTransaction'),
onClick: setUserAcknowledgedGasMissing,
}
}
/>
)}
{pendingTransactions?.length > 0 && ( {pendingTransactions?.length > 0 && (
<ActionableMessage <ActionableMessage
message={ message={
<div className="transaction-alerts__pending-transactions"> <Typography
className="transaction-alerts__pending-transactions"
align="left"
fontSize="12px"
margin={[0, 0]}
>
<strong> <strong>
<I18nValue <I18nValue
messageKey="pendingTransaction" messageKey={
pendingTransactions?.length === 1
? 'pendingTransactionSingle'
: 'pendingTransactionMultiple'
}
options={[pendingTransactions?.length]} options={[pendingTransactions?.length]}
/> />
</strong>{' '} </strong>{' '}
@ -38,36 +71,33 @@ const TransactionAlerts = () => {
</a>, </a>,
]} ]}
/> />
</div> </Typography>
} }
useIcon useIcon
iconFillColor="#f8c000" iconFillColor="#f8c000"
type="warning" type="warning"
/> />
)} )}
{balanceError && ( {balanceError && <ErrorMessage errorKey={INSUFFICIENT_FUNDS_ERROR_KEY} />}
<> {estimateUsed === PRIORITY_LEVELS.LOW && (
{pendingTransactions?.length > 0 && (
<div className="transaction-alerts--separator" />
)}
<ErrorMessage errorKey={INSUFFICIENT_FUNDS_ERROR_KEY} />
</>
)}
{estimateUsed === 'low' && (
<>
{balanceError && (
<div className="transaction-alerts-message--separator" />
)}
<ActionableMessage <ActionableMessage
message={<I18nValue messageKey="lowPriorityMessage" />} message={
<Typography align="left" fontSize="12px" margin={[0, 0]}>
<I18nValue messageKey="lowPriorityMessage" />
</Typography>
}
useIcon useIcon
iconFillColor="#f8c000" iconFillColor="#f8c000"
type="warning" type="warning"
/> />
</>
)} )}
</div> </div>
); );
}; };
TransactionAlerts.propTypes = {
userAcknowledgedGasMissing: PropTypes.bool,
setUserAcknowledgedGasMissing: PropTypes.func,
};
export default TransactionAlerts; export default TransactionAlerts;

View File

@ -1,7 +1,11 @@
.transaction-alerts { .transaction-alerts {
margin-top: 20px; text-align: left;
&--separator { & > *:first-of-type {
margin-top: 20px;
}
& > *:not(:first-of-type) {
margin-top: 12px; margin-top: 12px;
} }

View File

@ -17,7 +17,7 @@ jest.mock('../../../store/actions', () => ({
addPollingTokenToAppState: jest.fn(), addPollingTokenToAppState: jest.fn(),
})); }));
const render = ({ props, state }) => { const render = ({ props, state } = {}) => {
const store = configureStore({ const store = configureStore({
metamask: { metamask: {
nativeCurrency: ETH, nativeCurrency: ETH,
@ -95,7 +95,7 @@ describe('TransactionAlerts', () => {
}, },
}); });
expect( expect(
screen.queryByText('You have (1) pending transaction(s).'), screen.queryByText('You have (1) pending transaction.'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });