Skip to content

Commit c99e886

Browse files
authored
feat: surface the last-used payment method in the Pay With crypto section (#30051)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** Adds a "Last used" tag to the payment method most recently used in the Pay With bottom sheet's crypto section, giving the user a quick visual cue about which token they last paid with for the same kind of transaction. The tag is inferred from `TransactionController` history per transaction type, with no new persistence layer. The tag uses the design system `Tag` with `severity={TagSeverity.Info}` so its background and text colors come from semantic tokens that match the Figma spec across light and dark themes. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **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: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1361 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/32da593b-5c49-43fe-b0ca-1d66e4288d1d <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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** > Adds new selector/hook logic that derives UI state from transaction history; correctness depends on transaction metadata (type/batches) and excluding the current tx, but changes are contained to confirmations/pay-with UI. > > **Overview** > Adds a new `selectLastUsedPaymentMethod` selector (and shared `findLatestMetaMaskPayToken` helper) to derive the most recent MetaMask Pay token used for a given `TransactionType`, with support for batch/nested transactions and excluding the *current* tx. > > Introduces `useLastUsedPaymentMethod` and wires it into `usePayWithCryptoSection` to set `isLastUsed` on the preferred/selected crypto rows (never on “other assets”), enabling the UI to display a “Last used” tag. > > Updates `PaymentMethodRow` to use the design-system `Tag` API (with `severity={TagSeverity.Info}` and children text), and refactors the Pay With bottom-sheet dev gate into `isPayWithBottomSheetEnabled()` in `transaction-pay.ts`. Comprehensive unit tests are added/expanded for the selector, hook, and row behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6875c0d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9841b7f commit c99e886

10 files changed

Lines changed: 604 additions & 38 deletions

File tree

app/components/Views/confirmations/components/UI/payment-method-row/payment-method-row.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ describe('PaymentMethodRow', () => {
4444
).toBeOnTheScreen();
4545
});
4646

47+
it('renders the Last used label inside the tag with the localised string', () => {
48+
const { getByTestId } = render(
49+
<PaymentMethodRow {...baseProps} isLastUsed />,
50+
);
51+
52+
expect(
53+
getByTestId('payment-method-row-usdc-last-used-tag'),
54+
).toHaveTextContent('confirm.pay_with_bottom_sheet.last_used');
55+
});
56+
4757
it('does not render Last used tag when isLastUsed is false', () => {
4858
const { queryByTestId } = render(<PaymentMethodRow {...baseProps} />);
4959

app/components/Views/confirmations/components/UI/payment-method-row/payment-method-row.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import {
1010
IconColor,
1111
IconName,
1212
IconSize,
13+
Tag,
14+
TagSeverity,
1315
Text,
1416
TextColor,
1517
TextVariant,
1618
} from '@metamask/design-system-react-native';
1719
import { useTailwind } from '@metamask/design-system-twrnc-preset';
18-
import Tag from '../../../../../../component-library/components/Tags/Tag';
1920
import { strings } from '../../../../../../../locales/i18n';
2021
import { PayWithRowConfig } from '../../modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types';
2122

@@ -96,9 +97,11 @@ const PaymentMethodRow = ({
9697
</Text>
9798
{isLastUsed ? (
9899
<Tag
99-
label={strings('confirm.pay_with_bottom_sheet.last_used')}
100+
severity={TagSeverity.Info}
100101
testID={`${resolvedTestID}-last-used-tag`}
101-
/>
102+
>
103+
{strings('confirm.pay_with_bottom_sheet.last_used')}
104+
</Tag>
102105
) : null}
103106
</Box>
104107
{subtitle ? (

app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { useParams } from '../../../../../../util/navigation/navUtils';
4444
import { SetPayTokenRequest } from '../../../hooks/pay/useAutomaticTransactionPayToken';
4545
import { useConfirmationContext } from '../../../context/confirmation-context';
4646
import { useTheme } from '../../../../../../util/theme';
47+
import { isPayWithBottomSheetEnabled } from '../../../utils/transaction-pay';
4748

4849
interface PayWithRouteParams {
4950
preferredPaymentToken?: SetPayTokenRequest;
@@ -60,8 +61,6 @@ export function PayWithRow() {
6061
const { styles } = useStyles(styleSheet, {});
6162
const { setConfirmationMetric } = useConfirmationMetricEvents();
6263
const { preferredPaymentToken } = useParams<PayWithRouteParams>({});
63-
const isPayWithBottomSheetEnabled =
64-
process.env.MM_DEV_PAY_WITH_BOTTOM_SHEET === 'true';
6564

6665
const {
6766
txParams: { from },
@@ -79,21 +78,15 @@ export function PayWithRow() {
7978
mm_pay_token_list_opened: true,
8079
},
8180
});
82-
if (isPayWithBottomSheetEnabled) {
81+
if (isPayWithBottomSheetEnabled()) {
8382
navigation.navigate(Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET, {
8483
preferredPaymentToken,
8584
});
8685
return;
8786
}
8887

8988
navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL);
90-
}, [
91-
isDisabled,
92-
isPayWithBottomSheetEnabled,
93-
navigation,
94-
preferredPaymentToken,
95-
setConfirmationMetric,
96-
]);
89+
}, [isDisabled, navigation, preferredPaymentToken, setConfirmationMetric]);
9790

9891
const label = isWithdraw
9992
? strings('confirm.label.receive_as')

app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/us
99
import { TokenIcon, TokenIconVariant } from '../../../components/token-icon';
1010
import { MUSD_TOKEN_ADDRESS } from '../../../../../UI/Earn/constants/musd';
1111
import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest';
12+
import { useLastUsedPaymentMethod } from '../useLastUsedPaymentMethod';
1213
import { usePayWithPreferredToken } from '../usePayWithPreferredToken';
1314
import { usePayWithSelectedToken } from '../usePayWithSelectedToken';
1415
import { usePayWithCryptoSection } from './usePayWithCryptoSection';
@@ -34,6 +35,7 @@ jest.mock('../../../../../../../locales/i18n', () => ({
3435
jest.mock('../../../../../../util/navigation/navUtils');
3536
jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter');
3637
jest.mock('../../transactions/useTransactionMetadataRequest');
38+
jest.mock('../useLastUsedPaymentMethod');
3739
jest.mock('../usePayWithPreferredToken');
3840
jest.mock('../usePayWithSelectedToken');
3941

@@ -64,9 +66,11 @@ describe('usePayWithCryptoSection', () => {
6466
);
6567
const usePayWithPreferredTokenMock = jest.mocked(usePayWithPreferredToken);
6668
const usePayWithSelectedTokenMock = jest.mocked(usePayWithSelectedToken);
69+
const useLastUsedPaymentMethodMock = jest.mocked(useLastUsedPaymentMethod);
6770
const navigateMock = jest.fn();
6871
const goBackMock = jest.fn();
6972
const selectTokenMock = jest.fn();
73+
const isLastUsedMock = jest.fn().mockReturnValue(false);
7074

7175
beforeEach(() => {
7276
jest.resetAllMocks();
@@ -93,6 +97,11 @@ describe('usePayWithCryptoSection', () => {
9397
},
9498
selectToken: selectTokenMock,
9599
});
100+
isLastUsedMock.mockReturnValue(false);
101+
useLastUsedPaymentMethodMock.mockReturnValue({
102+
lastUsedToken: undefined,
103+
isLastUsed: isLastUsedMock,
104+
});
96105
});
97106

98107
it('passes route preferred payment token params to both pay-with hooks', () => {
@@ -430,4 +439,78 @@ describe('usePayWithCryptoSection', () => {
430439
Routes.CONFIRMATION_PAY_WITH_MODAL,
431440
);
432441
});
442+
443+
it('marks the preferred row as last used when the last-used token matches it', () => {
444+
isLastUsedMock.mockImplementation(
445+
(address, chainId) =>
446+
address === TOKEN_MOCK.address && chainId === TOKEN_MOCK.chainId,
447+
);
448+
449+
const { result } = renderHook(() => usePayWithCryptoSection());
450+
451+
expect(result.current?.rows[0]).toEqual(
452+
expect.objectContaining({
453+
id: 'crypto-preferred-token',
454+
isLastUsed: true,
455+
}),
456+
);
457+
});
458+
459+
it('does not mark any row as last used when the last-used token does not match', () => {
460+
isLastUsedMock.mockReturnValue(false);
461+
462+
const { result } = renderHook(() => usePayWithCryptoSection());
463+
464+
for (const row of result.current?.rows ?? []) {
465+
expect(row.isLastUsed ?? false).toBe(false);
466+
}
467+
});
468+
469+
it('marks the user-selected row as last used when the last-used token matches the selected token', () => {
470+
const distinctSelectedToken = {
471+
...TOKEN_MOCK,
472+
address: SELECTED_TOKEN_MOCK.address,
473+
symbol: SELECTED_TOKEN_MOCK.symbol,
474+
balanceUsd: SELECTED_TOKEN_MOCK.balanceUsd,
475+
};
476+
usePayWithPreferredTokenMock.mockReturnValue({
477+
hasTokens: true,
478+
preferredToken: TOKEN_MOCK,
479+
selectedToken: distinctSelectedToken,
480+
});
481+
usePayWithSelectedTokenMock.mockReturnValue({
482+
isSelectedDistinctFromAutomatic: true,
483+
selectedToken: SELECTED_TOKEN_MOCK,
484+
selectToken: selectTokenMock,
485+
});
486+
isLastUsedMock.mockImplementation(
487+
(address, chainId) =>
488+
address === SELECTED_TOKEN_MOCK.address &&
489+
chainId === SELECTED_TOKEN_MOCK.chainId,
490+
);
491+
492+
const { result } = renderHook(() => usePayWithCryptoSection());
493+
494+
const preferredRow = result.current?.rows.find(
495+
(row) => row.id === 'crypto-preferred-token',
496+
);
497+
const selectedRow = result.current?.rows.find(
498+
(row) => row.id === 'crypto-selected-token',
499+
);
500+
501+
expect(preferredRow?.isLastUsed ?? false).toBe(false);
502+
expect(selectedRow).toEqual(expect.objectContaining({ isLastUsed: true }));
503+
});
504+
505+
it('never marks the other-assets row as last used', () => {
506+
isLastUsedMock.mockReturnValue(true);
507+
508+
const { result } = renderHook(() => usePayWithCryptoSection());
509+
510+
const otherAssetsRow = result.current?.rows.find(
511+
(row) => row.id === 'crypto-other-assets',
512+
);
513+
514+
expect(otherAssetsRow?.isLastUsed ?? false).toBe(false);
515+
});
433516
});

app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
resolvePreferredPayToken,
2222
} from '../../../utils/transaction-pay';
2323
import { SetPayTokenRequest } from '../useAutomaticTransactionPayToken';
24+
import { useLastUsedPaymentMethod } from '../useLastUsedPaymentMethod';
2425
import { usePayWithPreferredToken } from '../usePayWithPreferredToken';
2526
import { usePayWithSelectedToken } from '../usePayWithSelectedToken';
2627
import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest';
@@ -60,6 +61,7 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null {
6061
selectedToken: selectedTokenDisplay,
6162
selectToken,
6263
} = usePayWithSelectedToken({ preferredToken: resolvedPreferredToken });
64+
const { isLastUsed } = useLastUsedPaymentMethod();
6365

6466
const handleOtherAssetsPress = useCallback(() => {
6567
navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL);
@@ -111,6 +113,7 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null {
111113
balance: preferredTokenBalance,
112114
}),
113115
isSelected: isPreferredTokenSelected,
116+
isLastUsed: isLastUsed(preferredToken.address, preferredToken.chainId),
114117
trailingElement: isPreferredTokenSelected ? 'checkmark' : 'none',
115118
onPress: handlePreferredTokenPress,
116119
testID: PAY_WITH_CRYPTO_PREFERRED_TOKEN_ROW_TEST_ID,
@@ -130,6 +133,10 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null {
130133
balance: selectedTokenBalance,
131134
}),
132135
isSelected: true,
136+
isLastUsed: isLastUsed(
137+
selectedTokenDisplay.address,
138+
selectedTokenDisplay.chainId,
139+
),
133140
trailingElement: 'checkmark',
134141
testID: PAY_WITH_CRYPTO_SELECTED_TOKEN_ROW_TEST_ID,
135142
});
@@ -161,6 +168,7 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null {
161168
handleOtherAssetsPress,
162169
handlePreferredTokenPress,
163170
hasTokens,
171+
isLastUsed,
164172
isSelectedDistinctFromAutomatic,
165173
preferredToken,
166174
preferredTokenBalance,
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import { TransactionType } from '@metamask/transaction-controller';
3+
import { Hex } from '@metamask/utils';
4+
import { useSelector } from 'react-redux';
5+
import { selectLastUsedPaymentMethod } from '../../../../../selectors/transactionController';
6+
import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest';
7+
import { useLastUsedPaymentMethod } from './useLastUsedPaymentMethod';
8+
9+
jest.mock('react-redux', () => ({
10+
useSelector: jest.fn(),
11+
}));
12+
jest.mock('../transactions/useTransactionMetadataRequest');
13+
jest.mock('../../../../../selectors/transactionController', () => ({
14+
selectLastUsedPaymentMethod: jest.fn(),
15+
}));
16+
17+
const TOKEN_ADDRESS_MOCK = '0x1234567890abcdef1234567890abcdef12345678' as Hex;
18+
const CHAIN_ID_MOCK = '0x1' as Hex;
19+
const OTHER_TOKEN_ADDRESS_MOCK =
20+
'0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Hex;
21+
const OTHER_CHAIN_ID_MOCK = '0x89' as Hex;
22+
23+
describe('useLastUsedPaymentMethod', () => {
24+
const useSelectorMock = jest.mocked(useSelector);
25+
const useTransactionMetadataRequestMock = jest.mocked(
26+
useTransactionMetadataRequest,
27+
);
28+
29+
beforeEach(() => {
30+
jest.resetAllMocks();
31+
32+
useTransactionMetadataRequestMock.mockReturnValue({
33+
id: 'tx-1',
34+
type: TransactionType.perpsDeposit,
35+
txParams: {},
36+
} as never);
37+
38+
useSelectorMock.mockReturnValue({
39+
address: TOKEN_ADDRESS_MOCK,
40+
chainId: CHAIN_ID_MOCK,
41+
});
42+
});
43+
44+
it('returns the last used token from the selector keyed by transaction type', () => {
45+
const { result } = renderHook(() => useLastUsedPaymentMethod());
46+
47+
expect(result.current.lastUsedToken).toStrictEqual({
48+
address: TOKEN_ADDRESS_MOCK,
49+
chainId: CHAIN_ID_MOCK,
50+
});
51+
});
52+
53+
it('returns undefined when no last used token exists', () => {
54+
useSelectorMock.mockReturnValue(undefined);
55+
56+
const { result } = renderHook(() => useLastUsedPaymentMethod());
57+
58+
expect(result.current.lastUsedToken).toBeUndefined();
59+
});
60+
61+
it('isLastUsed returns true when address and chainId match the last used token (case-insensitive)', () => {
62+
const { result } = renderHook(() => useLastUsedPaymentMethod());
63+
64+
expect(
65+
result.current.isLastUsed(
66+
TOKEN_ADDRESS_MOCK.toUpperCase() as Hex,
67+
CHAIN_ID_MOCK,
68+
),
69+
).toBe(true);
70+
});
71+
72+
it('isLastUsed returns false when address does not match', () => {
73+
const { result } = renderHook(() => useLastUsedPaymentMethod());
74+
75+
expect(
76+
result.current.isLastUsed(OTHER_TOKEN_ADDRESS_MOCK, CHAIN_ID_MOCK),
77+
).toBe(false);
78+
});
79+
80+
it('isLastUsed returns false when chainId does not match', () => {
81+
const { result } = renderHook(() => useLastUsedPaymentMethod());
82+
83+
expect(
84+
result.current.isLastUsed(TOKEN_ADDRESS_MOCK, OTHER_CHAIN_ID_MOCK),
85+
).toBe(false);
86+
});
87+
88+
it('isLastUsed returns false when the last used token is undefined', () => {
89+
useSelectorMock.mockReturnValue(undefined);
90+
91+
const { result } = renderHook(() => useLastUsedPaymentMethod());
92+
93+
expect(result.current.isLastUsed(TOKEN_ADDRESS_MOCK, CHAIN_ID_MOCK)).toBe(
94+
false,
95+
);
96+
});
97+
98+
it('invokes selectLastUsedPaymentMethod with the current transaction type and id so the current transaction is excluded', () => {
99+
renderHook(() => useLastUsedPaymentMethod());
100+
101+
expect(useSelectorMock).toHaveBeenCalled();
102+
const selectorFn = useSelectorMock.mock.calls[0][0];
103+
selectorFn({} as never);
104+
expect(selectLastUsedPaymentMethod).toHaveBeenCalledWith(
105+
{},
106+
TransactionType.perpsDeposit,
107+
'tx-1',
108+
);
109+
});
110+
111+
it('passes undefined transactionType and id when no transaction metadata is available', () => {
112+
useTransactionMetadataRequestMock.mockReturnValue(undefined);
113+
114+
renderHook(() => useLastUsedPaymentMethod());
115+
116+
const selectorFn = useSelectorMock.mock.calls[0][0];
117+
selectorFn({} as never);
118+
expect(selectLastUsedPaymentMethod).toHaveBeenCalledWith(
119+
{},
120+
undefined,
121+
undefined,
122+
);
123+
});
124+
125+
it('keeps the result reference stable across renders when nothing changes', () => {
126+
const { result, rerender } = renderHook(() => useLastUsedPaymentMethod());
127+
const firstResult = result.current;
128+
129+
rerender();
130+
131+
expect(result.current).toBe(firstResult);
132+
});
133+
});

0 commit comments

Comments
 (0)