Skip to content

Commit 0828e98

Browse files
authored
fix(card): cp-7.58.3 physical address consent issue + undefined balances (#22676)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- 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? --> This PR hardens several Card flows: - Mailing-address consent now recreates or reuses onboarding consent defensively, mirroring the logic in the physical-address step. - useWrapWithCache, CardHome, and their test suites now treat cache errors and expired-card tokens consistently; the home screen shows a dedicated spinner while auth cleanup runs and only processes each auth error once. - Onboarding/Complete now calls navigation.dispatch(StackActions.replace(...)) (with updated tests) so we don’t stack duplicate routes after successful onboarding. - Card login path surfaces the new ACCOUNT_DISABLED error type with the correct localized messaging. - These fixes resolve missing-consent crashes, inconsistent priority-token balances, brittle token-expiration UX, and the lingering navigation issue after onboarding. ## **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: Improved MetaMask Card onboarding and home flows (defensive consent creation, consistent balance caching, robust expired-token handling, and navigation fixes). ## **Related issues** Fixes: ## **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** <!-- 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** - [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] > Defensively manage consent across onboarding, improve auth-error cleanup and navigation, refine balance fiat/caching logic, and add ACCOUNT_DISABLED handling with new consent lookup API. > > - **Onboarding**: > - **Mailing/Physical Address**: Add defensive consent flow (reuse/create via `getOnboardingConsentSetByOnboardingId`, link user, clear `consentSetId`); maintain validations and success paths. Navigation uses replace where appropriate. > - **Complete screen**: Switch to `navigation.dispatch(StackActions.replace(...))` and reset onboarding state before redirect. > - **Card Home**: > - Add robust auth-error handling (single-run cleanup, token removal, Redux reset/cache clear, `StackActions.replace` to welcome, loading spinner during cleanup) and initial authenticated data load. > - Preserve/compute balances and spending limit UI as before; minor UX tweaks. > - **Hooks**: > - `useAssetBalances`: Rework fiat formatting and fallbacks (handle `tokenRateUndefined`/loading strings, raw fiat parsing, proportional fiat, currency detection), keep Solana/EVM paths; expose consistent map. > - `useWrapWithCache`: Return real `Error`, avoid auto-retries on error, prevent refetch while loading, skip caching null/undefined; same API. > - `useCardDetails`: Surface `error` object (not enum), keep polling, warnings. > - `useGetCardExternalWalletDetails`: Guard auto-fetch on error; unchanged API. > - `useGetDelegationSettings`/`useRegistrationSettings`: Align to `Error` semantics. > - **SDK/Types**: > - `CardSDK.login`: detect and surface `ACCOUNT_DISABLED` error; OTP flows unchanged. > - Add `getConsentSetByOnboardingId` endpoint support; new `ConsentSet`/`GetOnboardingConsentResponse` types. > - **Tests**: Extensive updates/new cases across components and hooks to cover consent recovery, navigation replace, auth cleanup order, cache/error behavior, and fiat formatting. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9e3b88d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e700d86 commit 0828e98

26 files changed

+2818
-308
lines changed

app/components/UI/Card/Views/CardHome/CardHome.test.tsx

Lines changed: 402 additions & 2 deletions
Large diffs are not rendered by default.

app/components/UI/Card/Views/CardHome/CardHome.tsx

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, {
77
useState,
88
} from 'react';
99
import {
10+
ActivityIndicator,
1011
Alert,
1112
RefreshControl,
1213
ScrollView,
@@ -22,7 +23,7 @@ import Icon, {
2223
import Text, {
2324
TextVariant,
2425
} from '../../../../../component-library/components/Texts/Text';
25-
import { useNavigation } from '@react-navigation/native';
26+
import { StackActions, useNavigation } from '@react-navigation/native';
2627
import { useDispatch, useSelector } from 'react-redux';
2728
import SensitiveText, {
2829
SensitiveTextLength,
@@ -58,11 +59,8 @@ import {
5859
import { useCardSDK } from '../../sdk';
5960
import Routes from '../../../../../constants/navigation/Routes';
6061
import {
61-
setIsAuthenticatedCard,
62-
setAuthenticatedPriorityToken,
63-
setAuthenticatedPriorityTokenLastFetched,
64-
setUserCardLocation,
6562
clearAllCache,
63+
resetAuthenticatedData,
6664
} from '../../../../../core/redux/slices/card';
6765
import { useCardProvision } from '../../hooks/useCardProvision';
6866
import CardWarningBox from '../../components/CardWarningBox/CardWarningBox';
@@ -97,8 +95,13 @@ const CardHome = () => {
9795
const { PreferencesController } = Engine.context;
9896
const [retries, setRetries] = useState(0);
9997
const [isRefreshing, setIsRefreshing] = useState(false);
98+
const [isHandlingAuthError, setIsHandlingAuthError] = useState(false);
10099
const { toastRef } = useContext(ToastContext);
101100
const { logoutFromProvider, isLoading: isSDKLoading } = useCardSDK();
101+
const hasTrackedCardHomeView = useRef(false);
102+
const hasLoadedCardHomeView = useRef(false);
103+
const hasHandledAuthErrorRef = useRef(false);
104+
const isComponentUnmountedRef = useRef(false);
102105
const [
103106
isCloseSpendingLimitWarningShown,
104107
setIsCloseSpendingLimitWarningShown,
@@ -190,9 +193,6 @@ const CardHome = () => {
190193
}
191194
}, [fetchAllData]);
192195

193-
// Track event only once after priorityToken and balances are loaded
194-
const hasTrackedCardHomeView = useRef(false);
195-
196196
useEffect(() => {
197197
// Early return if already tracked to prevent any possibility of duplicate tracking
198198
if (hasTrackedCardHomeView.current) {
@@ -497,36 +497,75 @@ const CardHome = () => {
497497
styles,
498498
]);
499499

500+
useEffect(
501+
() => () => {
502+
isComponentUnmountedRef.current = true;
503+
},
504+
[],
505+
);
506+
500507
// Handle authentication errors (expired token, invalid credentials, etc.)
501508
useEffect(() => {
502509
const handleAuthenticationError = async () => {
503-
if (!cardError) {
510+
const isAuthError =
511+
Boolean(cardError) &&
512+
isAuthenticated &&
513+
isAuthenticationError(cardError);
514+
515+
if (!isAuthError) {
516+
hasHandledAuthErrorRef.current = false;
504517
return;
505518
}
506519

507-
// Check if the error is authentication-related
508-
if (isAuthenticated && isAuthenticationError(cardError)) {
509-
Logger.log(
510-
'CardHome: Authentication error detected, clearing auth state and redirecting',
511-
);
520+
if (hasHandledAuthErrorRef.current) {
521+
return;
522+
}
523+
524+
hasHandledAuthErrorRef.current = true;
525+
setIsHandlingAuthError(true);
526+
527+
Logger.log(
528+
'CardHome: Authentication error detected, clearing auth state and redirecting',
529+
);
512530

513-
// Clear authentication state
531+
try {
514532
await removeCardBaanxToken();
515-
dispatch(setIsAuthenticatedCard(false));
516-
dispatch(setAuthenticatedPriorityToken(null));
517-
dispatch(setAuthenticatedPriorityTokenLastFetched(null));
518-
dispatch(setUserCardLocation(null));
519533

520-
// Clear all cached data
534+
if (isComponentUnmountedRef.current) {
535+
return;
536+
}
537+
538+
dispatch(resetAuthenticatedData());
521539
dispatch(clearAllCache());
522540

523-
// Redirect to welcome screen for re-authentication
524-
navigation.navigate(Routes.CARD.WELCOME);
541+
navigation.dispatch(StackActions.replace(Routes.CARD.WELCOME));
542+
} catch (error) {
543+
Logger.log('CardHome: Failed to handle authentication error', error);
544+
545+
if (!isComponentUnmountedRef.current) {
546+
navigation.dispatch(StackActions.replace(Routes.CARD.WELCOME));
547+
}
548+
} finally {
549+
if (!isComponentUnmountedRef.current) {
550+
setIsHandlingAuthError(false);
551+
}
525552
}
526553
};
527554

528555
handleAuthenticationError();
529-
}, [cardError, isAuthenticated, dispatch, navigation]);
556+
}, [cardError, dispatch, isAuthenticated, navigation]);
557+
558+
// Load Card Data once CardHome opens
559+
useEffect(() => {
560+
const loadCardData = async () => {
561+
await fetchAllData();
562+
hasLoadedCardHomeView.current = true;
563+
};
564+
565+
if (!hasLoadedCardHomeView.current && isAuthenticated) {
566+
loadCardData();
567+
}
568+
}, [fetchAllData, isAuthenticated]);
530569

531570
/**
532571
* Check if the current token supports the spending limit progress bar feature.
@@ -565,6 +604,16 @@ const CardHome = () => {
565604
}, [isAuthenticated, isSpendingLimitSupported, priorityToken]);
566605

567606
if (cardError) {
607+
const isAuthError = isAuthenticated && isAuthenticationError(cardError);
608+
609+
if (isHandlingAuthError || isAuthError) {
610+
return (
611+
<View style={styles.loadingContainer}>
612+
<ActivityIndicator size="large" />
613+
</View>
614+
);
615+
}
616+
568617
return (
569618
<View style={styles.errorContainer}>
570619
<Icon

app/components/UI/Card/components/Onboarding/Complete.test.tsx

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
import React from 'react';
22
import { render, fireEvent, waitFor } from '@testing-library/react-native';
3-
import { useNavigation } from '@react-navigation/native';
3+
import { StackActions, useNavigation } from '@react-navigation/native';
44
import { useDispatch } from 'react-redux';
55
import Complete from './Complete';
6+
import Routes from '../../../../../constants/navigation/Routes';
67

78
// Mock dependencies
9+
const mockNavigationDispatch = jest.fn();
10+
const mockStackReplace = jest.fn((routeName: string) => ({
11+
type: 'REPLACE',
12+
routeName,
13+
}));
14+
815
jest.mock('@react-navigation/native', () => ({
916
useNavigation: jest.fn(),
17+
StackActions: {
18+
replace: jest.fn((routeName: string) => ({
19+
type: 'REPLACE',
20+
routeName,
21+
})),
22+
},
1023
}));
1124

1225
jest.mock('react-redux', () => ({
@@ -147,16 +160,18 @@ jest.mock('../../../../../../locales/i18n', () => ({
147160
}));
148161

149162
describe('Complete Component', () => {
150-
const mockNavigate = jest.fn();
151163
const mockDispatch = jest.fn();
152164
const mockTrackEvent = jest.fn();
153165
const mockCreateEventBuilder = jest.fn();
154166

155167
beforeEach(() => {
156168
jest.clearAllMocks();
169+
mockNavigationDispatch.mockClear();
170+
mockStackReplace.mockClear();
171+
(StackActions.replace as jest.Mock).mockImplementation(mockStackReplace);
157172

158173
(useNavigation as jest.Mock).mockReturnValue({
159-
navigate: mockNavigate,
174+
dispatch: mockNavigationDispatch,
160175
});
161176

162177
(useDispatch as jest.Mock).mockReturnValue(mockDispatch);
@@ -228,32 +243,56 @@ describe('Complete Component', () => {
228243
expect(button.props.disabled).toBeFalsy();
229244
});
230245

231-
it('navigates to card home when pressed', async () => {
246+
it('dispatches replace action to card home when pressed', async () => {
232247
const { getByTestId } = render(<Complete />);
233248

234249
const button = getByTestId('complete-confirm-button');
235250
fireEvent.press(button);
236251

237252
await waitFor(() => {
238-
expect(mockNavigate).toHaveBeenCalledWith('CardHome');
253+
expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME);
254+
expect(mockNavigationDispatch).toHaveBeenCalledWith(
255+
expect.objectContaining({ routeName: Routes.CARD.HOME }),
256+
);
239257
});
240258
});
241259

242-
it('calls navigate only once per button press', async () => {
260+
it('dispatches replace action only once per button press', async () => {
243261
const { getByTestId } = render(<Complete />);
244262

245263
const button = getByTestId('complete-confirm-button');
246264
fireEvent.press(button);
247265

248266
await waitFor(() => {
249-
expect(mockNavigate).toHaveBeenCalledTimes(1);
267+
expect(mockNavigationDispatch).toHaveBeenCalledTimes(1);
250268
});
251269

252270
fireEvent.press(button);
253271

254272
await waitFor(() => {
255-
expect(mockNavigate).toHaveBeenCalledTimes(2);
256-
expect(mockNavigate).toHaveBeenCalledWith('CardHome');
273+
expect(mockNavigationDispatch).toHaveBeenCalledTimes(2);
274+
expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME);
275+
});
276+
});
277+
278+
it('falls back to authentication flow when token is missing', async () => {
279+
const { getCardBaanxToken } = jest.requireMock(
280+
'../../util/cardTokenVault',
281+
);
282+
getCardBaanxToken.mockResolvedValueOnce({
283+
success: false,
284+
});
285+
286+
const { getByTestId } = render(<Complete />);
287+
fireEvent.press(getByTestId('complete-confirm-button'));
288+
289+
await waitFor(() => {
290+
expect(mockStackReplace).toHaveBeenCalledWith(
291+
Routes.CARD.AUTHENTICATION,
292+
);
293+
expect(mockNavigationDispatch).toHaveBeenCalledWith(
294+
expect.objectContaining({ routeName: Routes.CARD.AUTHENTICATION }),
295+
);
257296
});
258297
});
259298
});
@@ -265,14 +304,17 @@ describe('Complete Component', () => {
265304
expect(useNavigation).toHaveBeenCalledTimes(1);
266305
});
267306

268-
it('navigates to correct route on continue', async () => {
307+
it('dispatches replace action to correct route on continue', async () => {
269308
const { getByTestId } = render(<Complete />);
270309

271310
const button = getByTestId('complete-confirm-button');
272311
fireEvent.press(button);
273312

274313
await waitFor(() => {
275-
expect(mockNavigate).toHaveBeenCalledWith('CardHome');
314+
expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME);
315+
expect(mockNavigationDispatch).toHaveBeenCalledWith(
316+
expect.objectContaining({ routeName: Routes.CARD.HOME }),
317+
);
276318
});
277319
});
278320
});
@@ -403,7 +445,10 @@ describe('Complete Component', () => {
403445

404446
// Verify navigation to final destination
405447
await waitFor(() => {
406-
expect(mockNavigate).toHaveBeenCalledWith('CardHome');
448+
expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME);
449+
expect(mockNavigationDispatch).toHaveBeenCalledWith(
450+
expect.objectContaining({ routeName: Routes.CARD.HOME }),
451+
);
407452
});
408453
});
409454

@@ -418,7 +463,10 @@ describe('Complete Component', () => {
418463
fireEvent.press(button);
419464

420465
await waitFor(() => {
421-
expect(mockNavigate).toHaveBeenCalledWith('CardHome');
466+
expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME);
467+
expect(mockNavigationDispatch).toHaveBeenCalledWith(
468+
expect.objectContaining({ routeName: Routes.CARD.HOME }),
469+
);
422470
});
423471
});
424472
});

app/components/UI/Card/components/Onboarding/Complete.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useEffect, useState } from 'react';
2-
import { useNavigation } from '@react-navigation/native';
2+
import { StackActions, useNavigation } from '@react-navigation/native';
33
import OnboardingStep from './OnboardingStep';
44
import { strings } from '../../../../../../locales/i18n';
55
import Button, {
@@ -44,11 +44,12 @@ const Complete = () => {
4444
try {
4545
const token = await getCardBaanxToken();
4646
if (token.success && token.tokenData?.accessToken) {
47-
navigation.navigate(Routes.CARD.HOME);
47+
dispatch(resetOnboardingState());
48+
navigation.dispatch(StackActions.replace(Routes.CARD.HOME));
4849
} else {
49-
navigation.navigate(Routes.CARD.AUTHENTICATION);
50+
dispatch(resetOnboardingState());
51+
navigation.dispatch(StackActions.replace(Routes.CARD.AUTHENTICATION));
5052
}
51-
dispatch(resetOnboardingState());
5253
} catch (error) {
5354
Logger.log('Complete::handleContinue error', error);
5455
} finally {

0 commit comments

Comments
 (0)