Skip to content

Commit

Permalink
Show modal when user tries to cancel confirmed transaction (#22943)
Browse files Browse the repository at this point in the history
## **Description**

If user tries to cancel transaction which is already confirmed, we would
like to show them a modal to show a link to block explorer.

## **Related issues**

Fixes: #22314
Related: #22663

## **Manual testing steps**

Please see the recording. 

## **Screenshots/Recordings**


https://github.com/MetaMask/metamask-extension/assets/7644512/861ef616-89a4-4292-8a2b-8a1733f2d88b

## **Pre-merge author checklist**

- [x] I’ve followed [MetaMask Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've clearly explained what problem this PR is solving and how it
is solved.
- [x] I've linked related issues
- [x] I've included manual testing steps
- [x] I've included screenshots/recordings if applicable
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
- [ ] I’ve properly set the pull request status:
  - [ ] In case it's not yet "ready for review", I've set it to "draft".
- [ ] In case it's "ready for review", I've changed it from "draft" to
"non-draft".

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

---------

Co-authored-by: MetaMask Bot <[email protected]>
  • Loading branch information
2 people authored and dbrans committed Feb 27, 2024
1 parent 826d9a1 commit a93967f
Show file tree
Hide file tree
Showing 11 changed files with 329 additions and 3 deletions.
8 changes: 7 additions & 1 deletion app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions ui/components/app/modals/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import EthSignModal from './eth-sign-modal/eth-sign-modal';
import FadeModal from './fade-modal';
import NewAccountModal from './new-account-modal';
import RejectTransactions from './reject-transactions';
import TransactionAlreadyConfirmed from './transaction-already-confirmed';

const modalContainerBaseStyle = {
transform: 'translate3d(-50%, 0, 0px)',
Expand Down Expand Up @@ -264,6 +265,17 @@ const MODALS = {
},
},

TRANSACTION_ALREADY_CONFIRMED: {
disableBackdropClick: true,
contents: <TransactionAlreadyConfirmed />,
mobileModalStyle: {
...modalContainerMobileStyle,
},
laptopModalStyle: {
...modalContainerLaptopStyle,
},
},

QR_SCANNER: {
contents: <QRScanner />,
testId: 'qr-scanner-modal',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Transaction Already Confirmed modal should match snapshot 1`] = `
<body>
<div
id="popover-content"
/>
<div />
<div
class="mm-modal"
>
<div
aria-hidden="true"
class="mm-box mm-modal-overlay mm-box--width-full mm-box--height-full mm-box--background-color-overlay-default"
/>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
data-focus-lock-disabled="false"
>
<div
class="mm-box mm-modal-content mm-box--padding-top-4 mm-box--sm:padding-top-8 mm-box--md:padding-top-12 mm-box--padding-right-4 mm-box--padding-bottom-4 mm-box--sm:padding-bottom-8 mm-box--md:padding-bottom-12 mm-box--padding-left-4 mm-box--display-flex mm-box--justify-content-center mm-box--align-items-flex-start mm-box--width-screen mm-box--height-screen"
>
<section
aria-modal="true"
class="mm-box mm-modal-content__dialog mm-modal-content__dialog--size-sm mm-box--padding-top-4 mm-box--padding-bottom-4 mm-box--display-flex mm-box--flex-direction-column mm-box--width-full mm-box--background-color-background-default mm-box--rounded-lg"
role="dialog"
>
<header
class="mm-box mm-header-base mm-modal-header mm-box--padding-right-4 mm-box--padding-bottom-4 mm-box--padding-left-4 mm-box--display-flex mm-box--justify-content-space-between"
>
<div
class="mm-box mm-box--width-full"
>
<h4
class="mm-box mm-text mm-text--heading-sm mm-text--text-align-center mm-box--color-text-default"
>
Transaction already confirmed
</h4>
</div>
<div
class="mm-box mm-box--display-flex mm-box--justify-content-flex-end"
style="min-width: 0px;"
>
<button
aria-label="Close"
class="mm-box mm-button-icon mm-button-icon--size-sm mm-box--display-inline-flex mm-box--justify-content-center mm-box--align-items-center mm-box--color-icon-default mm-box--background-color-transparent mm-box--rounded-lg"
>
<span
class="mm-box mm-icon mm-icon--size-sm mm-box--display-inline-block mm-box--color-inherit"
style="mask-image: url('./images/icons/close.svg');"
/>
</button>
</div>
</header>
<div
class="mm-box mm-modal-body mm-box--padding-right-4 mm-box--padding-left-4"
>
<p
class="mm-box mm-text mm-text--body-md mm-box--color-text-default"
>
We weren't able to cancel your transaction before it was confirmed on the blockchain.
</p>
</div>
<div
class="mm-box mm-modal-footer mm-box--padding-top-4 mm-box--padding-right-4 mm-box--padding-left-4"
>
<div
class="mm-box mm-container mm-container--max-width-sm mm-box--margin-right-auto mm-box--margin-left-auto mm-box--display-flex mm-box--gap-4 mm-box--flex-direction-column mm-box--flex-wrap-wrap mm-box--align-items-stretch"
>
<button
class="mm-box mm-text mm-button-base mm-button-base--size-lg mm-modal-footer__button mm-button-secondary mm-text--body-md-medium mm-box--padding-0 mm-box--padding-right-4 mm-box--padding-left-4 mm-box--display-inline-flex mm-box--justify-content-center mm-box--align-items-center mm-box--color-primary-default mm-box--background-color-transparent mm-box--rounded-pill mm-box--border-color-primary-default box--border-style-solid box--border-width-1"
>
View on block explorer
</button>
<button
class="mm-box mm-text mm-button-base mm-button-base--size-lg mm-modal-footer__button mm-button-primary mm-text--body-md-medium mm-box--padding-0 mm-box--padding-right-4 mm-box--padding-left-4 mm-box--display-inline-flex mm-box--justify-content-center mm-box--align-items-center mm-box--color-primary-inverse mm-box--background-color-primary-default mm-box--rounded-pill"
>
Got it
</button>
</div>
</div>
</section>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
</body>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './transaction-already-confirmed';
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import mockStore from '../../../../../test/data/mock-state.json';

import {
renderWithProvider,
} from '../../../../../test/jest';
import TransactionAlreadyConfirmed from '.';

const getStoreWithModalData = () => {
return configureMockStore()({
...mockStore,
appState: {
...mockStore.appState,
modal: {
modalState: {
props: {
originalTransactionId: 'test',
},
},
},
},
});
};

describe('Transaction Already Confirmed modal', () => {
it('should match snapshot', async () => {
const { baseElement } = renderWithProvider(
<TransactionAlreadyConfirmed />,
getStoreWithModalData(),
);
expect(baseElement).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getBlockExplorerLink } from '@metamask/etherscan-link';
import { type TransactionMeta } from '@metamask/transaction-controller';
import { type NetworkClientConfiguration } from '@metamask/network-controller';
import {
getRpcPrefsForCurrentProvider,
getTransaction,
} from '../../../../selectors';
import { useModalProps } from '../../../../hooks/useModalProps';

import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
Text,
ModalFooter,
} from '../../../component-library';
import {
AlignItems,
FlexDirection,
} from '../../../../helpers/constants/design-system';
import { I18nContext } from '../../../../contexts/i18n';
import { MetaMaskReduxState } from '../../../../store/store';

export default function TransactionAlreadyConfirmed() {
const {
hideModal,
props: { originalTransactionId },
} = useModalProps();
const t = useContext(I18nContext);
const dispatch = useDispatch();
const transaction: TransactionMeta = useSelector(
(state: MetaMaskReduxState) =>
(getTransaction as any)(state, originalTransactionId),
);
const rpcPrefs: NetworkClientConfiguration = useSelector(
getRpcPrefsForCurrentProvider,
);

const viewTransaction = () => {
// TODO: Fix getBlockExplorerLink arguments compatible with the actual controller types
const blockExplorerLink = getBlockExplorerLink(
transaction as any,
rpcPrefs as any,
);
global.platform.openTab({
url: blockExplorerLink,
});
dispatch(hideModal());
};

return (
<Modal isOpen onClose={hideModal}>
<ModalOverlay />
<ModalContent>
<ModalHeader onClose={hideModal}>
{t('yourTransactionConfirmed')}
</ModalHeader>
<ModalBody>
<Text>{t('yourTransactionJustConfirmed')}</Text>
</ModalBody>
<ModalFooter
onSubmit={hideModal}
onCancel={viewTransaction}
submitButtonProps={{
children: t('gotIt'),
}}
cancelButtonProps={{
children: t('viewOnBlockExplorer'),
}}
containerProps={{
flexDirection: FlexDirection.Column,
alignItems: AlignItems.stretch,
}}
/>
</ModalContent>
</Modal>
);
}
32 changes: 32 additions & 0 deletions ui/hooks/useModalProps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useModalProps } from './useModalProps';

const MOCK_PROPS = {
test: 'test',
};
const MOCK_MM_STATE = {
appState: {
modal: {
modalState: {
props: MOCK_PROPS,
},
},
},
};

jest.mock('react-redux', () => ({
useSelector: (selector: any) => selector(MOCK_MM_STATE),
useDispatch: jest.fn(),
}));

jest.mock('../store/actions', () => ({
hideModal: jest.fn(),
}));

describe('useModalProps', () => {
it('should return modal props and hideModal function', () => {
const { props, hideModal } = useModalProps();

expect(props).toStrictEqual(MOCK_PROPS);
expect(hideModal).toStrictEqual(expect.any(Function));
});
});
18 changes: 18 additions & 0 deletions ui/hooks/useModalProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useSelector, useDispatch } from 'react-redux';
import { hideModal } from '../store/actions';

interface ModalProps {
props: Record<string, any>;
hideModal: () => void;
}

export function useModalProps(): ModalProps {
const modalProps = useSelector((state: any) => {
return state.appState.modal.modalState?.props;
});

const dispatch = useDispatch();
const onHideModal = () => dispatch(hideModal());

return { props: modalProps, hideModal: onHideModal };
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('Creation Successful Onboarding View', () => {

it('should route to pin extension route when "Got it" button is clicked', () => {
const { getByText } = renderWithProvider(<CreationSuccessful />, store);
const gotItButton = getByText('Got it!');
const gotItButton = getByText('Got it');
fireEvent.click(gotItButton);
expect(mockHistoryPush).toHaveBeenCalledWith(
ONBOARDING_PIN_EXTENSION_ROUTE,
Expand Down
33 changes: 33 additions & 0 deletions ui/store/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { GAS_LIMITS } from '../../shared/constants/gas';
import { ORIGIN_METAMASK } from '../../shared/constants/app';
import { MetaMetricsNetworkEventSource } from '../../shared/constants/metametrics';
import * as actions from './actions';
import * as actionConstants from './actionConstants';
import { setBackgroundConnection } from './background-connection';

const middleware = [thunk];
Expand Down Expand Up @@ -2158,4 +2159,36 @@ describe('Actions', () => {
});
});
});

describe('#createCancelTransaction', () => {
it('shows TRANSACTION_ALREADY_CONFIRMED modal if createCancelTransaction throws with an error', async () => {
const store = mockStore();

const createCancelTransactionStub = sinon
.stub()
.callsFake((_1, _2, _3, cb) =>
cb(new Error('Previous transaction is already confirmed')),
);
setBackgroundConnection({
createCancelTransaction: createCancelTransactionStub,
});

const txId = '123-456';

try {
await store.dispatch(actions.createCancelTransaction(txId));
} catch (error) {
/* eslint-disable-next-line jest/no-conditional-expect */
expect(error.message).toBe('Previous transaction is already confirmed');
}

const resultantActions = store.getActions();
const expectedAction = resultantActions.find(
(action) => action.type === actionConstants.MODAL_OPEN,
);

expect(expectedAction.payload.name).toBe('TRANSACTION_ALREADY_CONFIRMED');
expect(expectedAction.payload.originalTransactionId).toBe(txId);
});
});
});
14 changes: 13 additions & 1 deletion ui/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2166,7 +2166,7 @@ export function createCancelTransaction(
customGasSettings: CustomGasSettings,
options: { estimatedBaseFee?: string } = {},
): ThunkAction<void, MetaMaskReduxState, unknown, AnyAction> {
log.debug('background.cancelTransaction');
log.debug('background.createCancelTransaction');
let newTxId: string;

return (dispatch: MetaMaskReduxDispatch) => {
Expand All @@ -2177,6 +2177,18 @@ export function createCancelTransaction(
[txId, customGasSettings, { ...options, actionId }],
(err, newState) => {
if (err) {
if (
err?.message?.includes(
'Previous transaction is already confirmed',
)
) {
dispatch(
showModal({
name: 'TRANSACTION_ALREADY_CONFIRMED',
originalTransactionId: txId,
}),
);
}
dispatch(displayWarning(err));
reject(err);
return;
Expand Down

0 comments on commit a93967f

Please sign in to comment.