Skip to content

Commit 7b75d21

Browse files
feat: cp-7.60.0 reject duplicate metamask pay transactions (#22836)
## **Description** Reject any existing unapproved Perps and Predict deposit transactions when navigating to the confirmation, and using the custom loader. Also remove the back button in the Perps deposit confirmation while loading. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: #22715 ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Reject unapproved transactions when navigating to confirmation with CustomAmount loader and adjust Perps confirmation header (show header, no back); add supporting tests/mocks. > > - **Confirmations**: > - Reject unapproved `transactions` via `ApprovalController.reject` when navigating with `loader: CustomAmount`, then proceed to navigate once cleared. > - Read pending txs from Redux (`selectTransactions`); manage deferred navigation with local state/effect; add logs. > - Default `loader` to `CustomAmount` when `stack === Routes.PERPS.ROOT`; honor `headerShown === false` to use `NO_HEADER` route. > - **UI (Perps)**: > - `routes/index.tsx`: For `FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS`, set `headerShown: true` and `headerLeft: () => null` (no back button). > - **Tests**: > - Add `useConfirmNavigation` tests covering stack/no-header and rejection of pending txs. > - Update `PredictTabView.test.tsx` to mock `useConfirmNavigation`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1c0ef84. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 85fdd78 commit 7b75d21

File tree

4 files changed

+137
-4
lines changed

4 files changed

+137
-4
lines changed

app/components/UI/Perps/routes/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ const PerpsScreenStack = () => (
261261
name={Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS}
262262
component={Confirm}
263263
options={{
264+
headerLeft: () => null,
265+
headerShown: true,
264266
title: '',
265267
}}
266268
/>

app/components/UI/Predict/views/PredictTabView/PredictTabView.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,12 @@ jest.mock('@shopify/flash-list', () => {
331331
};
332332
});
333333

334+
jest.mock('../../../../Views/confirmations/hooks/useConfirmNavigation', () => ({
335+
useConfirmNavigation: () => ({
336+
navigateToConfirmation: jest.fn(),
337+
}),
338+
}));
339+
334340
import PredictTabView from './PredictTabView';
335341
import { useSelector } from 'react-redux';
336342

app/components/Views/confirmations/hooks/useConfirmNavigation.test.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import {
2+
TransactionMeta,
3+
TransactionStatus,
4+
} from '@metamask/transaction-controller';
15
import Routes from '../../../../constants/navigation/Routes';
26
import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
37
import { ConfirmationLoader } from '../components/confirm/confirm-component';
48
import { useConfirmNavigation } from './useConfirmNavigation';
9+
import { act } from '@testing-library/react-native';
10+
import Engine from '../../../../core/Engine';
511

612
const mockNavigate = jest.fn();
713

@@ -12,10 +18,29 @@ jest.mock('@react-navigation/native', () => ({
1218
}),
1319
}));
1420

21+
jest.mock('../../../../core/Engine', () => ({
22+
context: {
23+
ApprovalController: {
24+
reject: jest.fn(),
25+
},
26+
},
27+
}));
28+
1529
const STACK_MOCK = 'SomeStack';
30+
const TRANSACTION_ID_MOCK = '123-456';
1631

17-
function runHook() {
18-
return renderHookWithProvider(useConfirmNavigation, { state: {} });
32+
function runHook({ transactions }: { transactions?: TransactionMeta[] } = {}) {
33+
return renderHookWithProvider(useConfirmNavigation, {
34+
state: {
35+
engine: {
36+
backgroundState: {
37+
TransactionController: {
38+
transactions: transactions ?? [],
39+
},
40+
},
41+
},
42+
},
43+
});
1944
}
2045

2146
describe('useConfirmNavigation', () => {
@@ -62,4 +87,34 @@ describe('useConfirmNavigation', () => {
6287
{},
6388
);
6489
});
90+
91+
it('rejects pending transactions before navigating if custom amount loader', async () => {
92+
const { result } = runHook({
93+
transactions: [
94+
{
95+
id: TRANSACTION_ID_MOCK,
96+
status: TransactionStatus.unapproved,
97+
} as TransactionMeta,
98+
],
99+
});
100+
101+
const { navigateToConfirmation } = result.current;
102+
103+
await act(async () => {
104+
navigateToConfirmation({
105+
headerShown: false,
106+
loader: ConfirmationLoader.CustomAmount,
107+
});
108+
});
109+
110+
const approvalControllerMock = jest.mocked(
111+
Engine.context.ApprovalController,
112+
);
113+
114+
expect(approvalControllerMock.reject).toHaveBeenCalledTimes(1);
115+
expect(approvalControllerMock.reject).toHaveBeenCalledWith(
116+
TRANSACTION_ID_MOCK,
117+
expect.anything(),
118+
);
119+
});
65120
});

app/components/Views/confirmations/hooks/useConfirmNavigation.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import { useNavigation } from '@react-navigation/native';
2-
import { useCallback } from 'react';
2+
import { useCallback, useEffect, useMemo, useState } from 'react';
33
import Routes from '../../../../constants/navigation/Routes';
44
import {
55
ConfirmationLoader,
66
ConfirmationParams,
77
} from '../components/confirm/confirm-component';
8+
import {
9+
TransactionMeta,
10+
TransactionStatus,
11+
} from '@metamask/transaction-controller';
12+
import Engine from '../../../../core/Engine';
13+
import { providerErrors } from '@metamask/rpc-errors';
14+
import { createProjectLogger } from '@metamask/utils';
15+
import { useSelector } from 'react-redux';
16+
import { selectTransactions } from '../../../../selectors/transactionController';
17+
18+
const log = createProjectLogger('confirm-navigation');
819

920
const ROUTE = Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS;
1021
const ROUTE_NO_HEADER = Routes.FULL_SCREEN_CONFIRMATIONS.NO_HEADER;
@@ -17,6 +28,17 @@ export type ConfirmNavigateOptions = {
1728

1829
export function useConfirmNavigation() {
1930
const { navigate } = useNavigation();
31+
const transactions = useSelector(selectTransactions);
32+
const [pendingParams, setPendingParams] = useState<ConfirmNavigateOptions>();
33+
const [transactionsToRemove, setTransactionsToRemove] = useState<string[]>();
34+
35+
const pendingTransactions = useMemo(
36+
() =>
37+
(transactions ?? []).filter(
38+
(tx) => tx.status === TransactionStatus.unapproved,
39+
),
40+
[transactions],
41+
);
2042

2143
const navigateToConfirmation = useCallback(
2244
(options: ConfirmNavigateOptions) => {
@@ -27,17 +49,65 @@ export function useConfirmNavigation() {
2749
params.loader = ConfirmationLoader.CustomAmount;
2850
}
2951

52+
if (
53+
params.loader === ConfirmationLoader.CustomAmount &&
54+
pendingTransactions.length &&
55+
!pendingParams
56+
) {
57+
log('Rejecting pending transactions before navigating');
58+
59+
setPendingParams(options);
60+
setTransactionsToRemove(pendingTransactions.map((tx) => tx.id));
61+
rejectTransactions(pendingTransactions);
62+
return;
63+
}
64+
3065
const route = headerShown === false ? ROUTE_NO_HEADER : ROUTE;
3166

67+
log('Navigating', { route, params, stack });
68+
3269
if (stack) {
3370
navigate(stack, { screen: route, params });
3471
return;
3572
}
3673

3774
navigate(route, params);
3875
},
39-
[navigate],
76+
[navigate, pendingParams, pendingTransactions],
4077
);
4178

79+
useEffect(() => {
80+
if (
81+
!pendingParams ||
82+
pendingTransactions.some((tx) => transactionsToRemove?.includes(tx.id))
83+
) {
84+
return;
85+
}
86+
87+
log('Navigating after rejecting pending transactions');
88+
89+
navigateToConfirmation(pendingParams);
90+
setPendingParams(undefined);
91+
setTransactionsToRemove(undefined);
92+
}, [
93+
pendingTransactions,
94+
pendingParams,
95+
navigateToConfirmation,
96+
transactionsToRemove,
97+
]);
98+
4299
return { navigateToConfirmation };
43100
}
101+
102+
function rejectTransactions(transactions: TransactionMeta[]) {
103+
const { ApprovalController } = Engine.context;
104+
105+
for (const tx of transactions) {
106+
try {
107+
ApprovalController.reject(tx.id, providerErrors.userRejectedRequest());
108+
log('Rejected transaction', tx.type, tx.id);
109+
} catch {
110+
log('Failed to reject transaction', tx.type, tx.id);
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)