Skip to content

Commit 905777f

Browse files
runway-github[bot]michalconsensyscursoragent
authored
chore(runway): cherry-pick fix(perps): prevent payment token reverting when switching in Pay With modal cp-7.66.0 (#26130)
- fix(perps): prevent payment token reverting when switching in Pay With modal cp-7.66.0 (#26120) ## **Description** When opening the Perps order view, the component syncs the transaction "pay token" from pending config (e.g. last selected payment token). The previous logic re-ran this sync on every effect run, so if the user changed the token in the "Pay With" modal, a later effect could overwrite their choice with the pending token again. **Changes:** - Pending config is now applied **once per load** (or when `initialAsset` changes). A ref tracks which pending token was already applied so we don’t overwrite the user’s in-session token selection. - When `initialAsset` changes (e.g. user switches from BTC to ETH), the "applied" state is reset so pending config is re-applied for the new asset. - When pending config has no selected token, `setSelectedPaymentToken(null)` is only called once, not on every effect run. This keeps the intended "restore from pending config" behavior on first load and on asset change, without undoing the user’s token choice when they’ve already changed it in the same session. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Fixed Perps order view reverting the user’s payment token selection to the pending config after they had changed it in the Pay With modal. ## **Related issues** Jira issue: https://consensyssoftware.atlassian.net/browse/TAT-2572 Fixes: #26118 ## **Manual testing steps** ```gherkin Feature: Perps order view pay token and pending config Scenario: user changes payment token and it is not overwritten by pending config Given user is on Perps order view with a pending payment token (e.g. USDC) And pay token is initially synced from pending config When user opens Pay With modal and selects a different token (e.g. DAI) Then the selected token remains DAI and is not reverted to the pending token (USDC) Scenario: pending config is re-applied when switching asset Given user is on Perps order view for asset BTC with pending token A And pay token was synced to token A When user switches to asset ETH (e.g. different market) and pending config has token B for ETH Then pay token is synced to token B for the new asset ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/f0d32725-e490-4379-a890-cec76f1691d6 ## **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] > **Medium Risk** > Touches Perps order payment-token sync logic that drives transaction parameters; regression could cause incorrect token selection, but change is scoped and covered by new unit tests. > > **Overview** > Prevents the Perps order view from re-applying the *pending trade configuration* payment token on subsequent renders, which previously could revert a user’s in-session selection after using the Pay With modal. > > `PerpsPayRow` now tracks the last pending token applied via a `useRef`, only syncing it once per load and resetting when `initialAsset` changes; it also ensures `setSelectedPaymentToken(null)` is only dispatched once when no pending token exists. Tests were expanded to cover “apply once”, asset-switch re-apply, and no-repeat-null behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 090eb88. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Cursor <cursoragent@cursor.com> [6c5d77a](6c5d77a) Co-authored-by: Michal Szorad <michal.szorad@consensys.net> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6eacf8d commit 905777f

2 files changed

Lines changed: 186 additions & 17 deletions

File tree

app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { fireEvent } from '@testing-library/react-native';
2+
import { act, fireEvent } from '@testing-library/react-native';
33
import { PerpsPayRow } from './PerpsPayRow';
44
import { useNavigation } from '@react-navigation/native';
55
import { useTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useTransactionPayToken';
@@ -300,4 +300,151 @@ describe('PerpsPayRow', () => {
300300
Engine.context.PerpsController?.setSelectedPaymentToken,
301301
).toHaveBeenCalledWith(null);
302302
});
303+
304+
describe('pending config sync (apply once per load)', () => {
305+
it('does not overwrite pay token when user switches token after pending config was applied', () => {
306+
const setPayTokenMock = jest.fn();
307+
const pendingTokenA = {
308+
address: '0xTokenA',
309+
chainId: '0x1',
310+
description: 'Token A',
311+
};
312+
mockUsePerpsSelector.mockReturnValue({
313+
selectedPaymentToken: pendingTokenA,
314+
});
315+
mockUsePerpsPayWithToken.mockReturnValue({
316+
address: pendingTokenA.address,
317+
chainId: pendingTokenA.chainId,
318+
description: pendingTokenA.description,
319+
});
320+
mockUseTransactionPayToken.mockReturnValue({
321+
payToken: { address: '0xOther', chainId: '0xa4b1', symbol: 'Other' },
322+
setPayToken: setPayTokenMock,
323+
} as unknown as ReturnType<typeof useTransactionPayToken>);
324+
325+
const { rerender } = renderWithProvider(
326+
<PerpsPayRow initialAsset="BTC" />,
327+
);
328+
329+
expect(setPayTokenMock).toHaveBeenCalledTimes(1);
330+
expect(setPayTokenMock).toHaveBeenCalledWith({
331+
address: '0xTokenA',
332+
chainId: '0x1',
333+
});
334+
335+
setPayTokenMock.mockClear();
336+
(
337+
Engine.context.PerpsController?.setSelectedPaymentToken as jest.Mock
338+
).mockClear();
339+
340+
const tokenB = {
341+
address: '0xTokenB',
342+
chainId: '0x1',
343+
description: 'Token B',
344+
};
345+
mockUsePerpsPayWithToken.mockReturnValue({
346+
address: tokenB.address,
347+
chainId: tokenB.chainId,
348+
description: tokenB.description,
349+
});
350+
mockUseTransactionPayToken.mockReturnValue({
351+
payToken: {
352+
address: tokenB.address,
353+
chainId: tokenB.chainId,
354+
symbol: 'B',
355+
},
356+
setPayToken: setPayTokenMock,
357+
} as unknown as ReturnType<typeof useTransactionPayToken>);
358+
359+
act(() => {
360+
rerender(<PerpsPayRow initialAsset="BTC" />);
361+
});
362+
363+
expect(setPayTokenMock).not.toHaveBeenCalled();
364+
expect(
365+
Engine.context.PerpsController?.setSelectedPaymentToken,
366+
).not.toHaveBeenCalled();
367+
});
368+
369+
it('re-applies pending config when initialAsset changes', () => {
370+
const setPayTokenMock = jest.fn();
371+
const tokenA = {
372+
address: '0xTokenA',
373+
chainId: '0x1',
374+
description: 'Token A',
375+
};
376+
const tokenB = {
377+
address: '0xTokenB',
378+
chainId: '0x1',
379+
description: 'Token B',
380+
};
381+
mockUsePerpsSelector.mockReturnValue({
382+
selectedPaymentToken: tokenA,
383+
});
384+
mockUsePerpsPayWithToken.mockReturnValue({
385+
address: tokenA.address,
386+
chainId: tokenA.chainId,
387+
description: tokenA.description,
388+
});
389+
mockUseTransactionPayToken.mockReturnValue({
390+
payToken: { address: '0xOther', chainId: '0xa4b1', symbol: 'Other' },
391+
setPayToken: setPayTokenMock,
392+
} as unknown as ReturnType<typeof useTransactionPayToken>);
393+
394+
const { rerender } = renderWithProvider(
395+
<PerpsPayRow initialAsset="BTC" />,
396+
);
397+
398+
expect(setPayTokenMock).toHaveBeenCalledWith({
399+
address: '0xTokenA',
400+
chainId: '0x1',
401+
});
402+
403+
setPayTokenMock.mockClear();
404+
405+
mockUsePerpsSelector.mockReturnValue({
406+
selectedPaymentToken: tokenB,
407+
});
408+
mockUsePerpsPayWithToken.mockReturnValue({
409+
address: tokenB.address,
410+
chainId: tokenB.chainId,
411+
description: tokenB.description,
412+
});
413+
mockUseTransactionPayToken.mockReturnValue({
414+
payToken: { address: '0xOther', chainId: '0xa4b1', symbol: 'Other' },
415+
setPayToken: setPayTokenMock,
416+
} as unknown as ReturnType<typeof useTransactionPayToken>);
417+
418+
act(() => {
419+
rerender(<PerpsPayRow initialAsset="ETH" />);
420+
});
421+
422+
expect(setPayTokenMock).toHaveBeenCalledWith({
423+
address: '0xTokenB',
424+
chainId: '0x1',
425+
});
426+
});
427+
428+
it('does not call setSelectedPaymentToken(null) again when pending has no token and already applied', () => {
429+
mockUsePerpsSelector.mockReturnValue({});
430+
mockUsePerpsPayWithToken.mockReturnValue(null);
431+
432+
const setSelectedPaymentTokenMock = Engine.context.PerpsController
433+
?.setSelectedPaymentToken as jest.Mock;
434+
435+
const { rerender } = renderWithProvider(
436+
<PerpsPayRow initialAsset="BTC" />,
437+
);
438+
439+
expect(setSelectedPaymentTokenMock).toHaveBeenCalledTimes(1);
440+
expect(setSelectedPaymentTokenMock).toHaveBeenCalledWith(null);
441+
442+
setSelectedPaymentTokenMock.mockClear();
443+
act(() => {
444+
rerender(<PerpsPayRow initialAsset="BTC" />);
445+
});
446+
447+
expect(setSelectedPaymentTokenMock).not.toHaveBeenCalled();
448+
});
449+
});
303450
});

app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CHAIN_IDS } from '@metamask/transaction-controller';
22
import { useNavigation } from '@react-navigation/native';
3-
import React, { useCallback, useEffect, useMemo } from 'react';
3+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
44
import { StyleSheet, TouchableOpacity } from 'react-native';
55
import { strings } from '../../../../../../locales/i18n';
66
import Badge, {
@@ -119,38 +119,60 @@ export const PerpsPayRow = ({
119119

120120
const pendingConfigSelectedPaymentToken = pendingConfig?.selectedPaymentToken;
121121

122+
// Track which pending config we've already applied so we don't overwrite the user's
123+
// in-session token selection. Apply pending config only on initial load or when
124+
// switching asset; otherwise switching tokens in the Pay With modal would flip back.
125+
const appliedPendingTokenRef = useRef<
126+
{ address: string; chainId: string } | null | undefined
127+
>(undefined);
128+
const prevInitialAssetRef = useRef(initialAsset);
129+
if (prevInitialAssetRef.current !== initialAsset) {
130+
prevInitialAssetRef.current = initialAsset;
131+
appliedPendingTokenRef.current = undefined;
132+
}
133+
122134
useEffect(() => {
123-
if (!pendingConfigSelectedPaymentToken) {
124-
Engine.context.PerpsController?.setSelectedPaymentToken?.(null);
125-
}
135+
if (pendingConfigSelectedPaymentToken != null) return;
136+
if (appliedPendingTokenRef.current === null) return;
137+
appliedPendingTokenRef.current = null;
138+
Engine.context.PerpsController?.setSelectedPaymentToken?.(null);
126139
}, [pendingConfigSelectedPaymentToken]);
127140

128141
useEffect(() => {
129-
if (!pendingConfigSelectedPaymentToken || !selectedPaymentToken) {
130-
return;
131-
}
142+
if (!pendingConfigSelectedPaymentToken || !selectedPaymentToken) return;
143+
144+
const pendingAddr = pendingConfigSelectedPaymentToken.address;
145+
const pendingChainId = pendingConfigSelectedPaymentToken.chainId;
146+
const alreadyApplied =
147+
appliedPendingTokenRef.current !== undefined &&
148+
(appliedPendingTokenRef.current === null
149+
? false
150+
: appliedPendingTokenRef.current.address === pendingAddr &&
151+
appliedPendingTokenRef.current.chainId === pendingChainId);
152+
if (alreadyApplied) return;
132153

133154
if (
134-
payToken?.address !== pendingConfigSelectedPaymentToken?.address ||
135-
payToken?.chainId !== pendingConfigSelectedPaymentToken?.chainId
155+
payToken?.address !== pendingAddr ||
156+
payToken?.chainId !== pendingChainId
136157
) {
137158
setPayToken({
138-
address: pendingConfigSelectedPaymentToken.address as Hex,
139-
chainId: pendingConfigSelectedPaymentToken.chainId as Hex,
159+
address: pendingAddr as Hex,
160+
chainId: pendingChainId as Hex,
140161
});
141-
142162
Engine.context.PerpsController?.setSelectedPaymentToken?.({
143163
description: pendingConfigSelectedPaymentToken.description,
144-
address: pendingConfigSelectedPaymentToken.address as Hex,
145-
chainId: pendingConfigSelectedPaymentToken.chainId as Hex,
164+
address: pendingAddr as Hex,
165+
chainId: pendingChainId as Hex,
146166
});
147167
}
168+
appliedPendingTokenRef.current = {
169+
address: pendingAddr,
170+
chainId: pendingChainId,
171+
};
148172
}, [
149173
payToken,
150174
pendingConfigSelectedPaymentToken,
151175
setPayToken,
152-
pendingConfig,
153-
initialAsset,
154176
selectedPaymentToken,
155177
]);
156178

0 commit comments

Comments
 (0)