diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml
index 7019ff20104..35d674bcee5 100644
--- a/.github/workflows/build-android-e2e.yml
+++ b/.github/workflows/build-android-e2e.yml
@@ -9,6 +9,9 @@ on:
apk-uploaded:
description: 'Whether the APK was successfully uploaded'
value: ${{ jobs.build-android-apks.outputs.apk-uploaded }}
+ aab-uploaded:
+ description: 'Whether the AAB was successfully uploaded'
+ value: ${{ jobs.build-android-apks.outputs.aab-uploaded }}
inputs:
build_type:
description: 'The type of build to perform'
@@ -29,15 +32,17 @@ on:
jobs:
build-android-apks:
name: Build Android E2E APKs
- runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg # lg runner: 16 vCPUs, 48GB RAM
+ runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-xl # Bumped from lg to xl to prevent Daemon disappearance issue (Daemon OOM issue in CI)
timeout-minutes: 40
env:
GRADLE_USER_HOME: /home/admin/_work/.gradle
CACHE_GENERATION: v1 # Increment this to bust the cache (v1, v2, v3, etc.)
outputs:
apk-uploaded: ${{ steps.upload-apk.outcome == 'success' }}
+ aab-uploaded: ${{ steps.upload-aab.outcome == 'success' }}
apk-target-path: ${{ steps.determine-target-paths.outputs.apk-target-path }}
test-apk-target-path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }}
+ aab-target-path: ${{ steps.determine-target-paths.outputs.aab-target-path }}
artifact_name: ${{ steps.determine-target-paths.outputs.artifact_name }}
steps:
@@ -83,12 +88,14 @@ jobs:
{
echo "apk-target-path=android/app/build/outputs/apk/flask/release"
echo "test-apk-target-path=android/app/build/outputs/apk/androidTest/flask/release"
+ echo "aab-target-path=android/app/build/outputs/bundle/flaskRelease"
echo "artifact_name=app-flask-release"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ inputs.build_type }}" == "main" ]]; then
{
echo "apk-target-path=android/app/build/outputs/apk/prod/release"
echo "test-apk-target-path=android/app/build/outputs/apk/androidTest/prod/release"
+ echo "aab-target-path=android/app/build/outputs/bundle/prodRelease"
echo "artifact_name=app-prod-release"
} >> "$GITHUB_OUTPUT"
else
@@ -103,6 +110,7 @@ jobs:
path: |
${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk
${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk
+ ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab
# Include Gradle properties in key to force rebuild when properties change
# Keep the `hashFiles` call for Gradle config in-sync with these steps:
# - "Cache Gradle dependencies"
@@ -233,6 +241,7 @@ jobs:
path: |
${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk
${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk
+ ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab
# Keep the `hashFiles` call for Gradle config in-sync with these steps:
# - "Check and restore cached APKs if Fingerprint is found"
# - "Cache Gradle dependencies"
@@ -255,3 +264,13 @@ jobs:
path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk
retention-days: 7
if-no-files-found: error
+
+ - name: Upload Android AAB
+ id: upload-aab
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.aab
+ path: ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab
+ retention-days: 7
+ if-no-files-found: warn
+ continue-on-error: true
diff --git a/.github/workflows/merge-previous-release-branches.yml b/.github/workflows/merge-previous-release-branches.yml
new file mode 100644
index 00000000000..2a2f8f13fce
--- /dev/null
+++ b/.github/workflows/merge-previous-release-branches.yml
@@ -0,0 +1,44 @@
+name: Merge Previous Release Branches
+
+permissions:
+ pull-requests: write
+ contents: write
+ issues: write
+
+on:
+ create:
+ # Trigger when a branch is created, filter for release/* happens in the job
+
+jobs:
+ validate-branch:
+ name: Validate release branch format
+ runs-on: ubuntu-latest
+ # Only run for branch creation (not tags)
+ if: github.event.ref_type == 'branch'
+ outputs:
+ is-valid: ${{ steps.check.outputs.is-valid }}
+ steps:
+ - name: Check branch name format
+ id: check
+ env:
+ BRANCH: ${{ github.event.ref }}
+ run: |
+ if [[ "$BRANCH" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Branch '$BRANCH' matches release/X.Y.Z format"
+ echo "is-valid=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "Branch '$BRANCH' does not match release/X.Y.Z format. Skipping."
+ echo "is-valid=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ merge-previous-releases:
+ name: Merge previous release branches
+ needs: validate-branch
+ if: needs.validate-branch.outputs.is-valid == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Merge previous releases
+ uses: metamask/github-tools/.github/actions/merge-previous-releases@v1.2.0
+ with:
+ new-release-branch: ${{ github.event.ref }}
+ github-token: ${{ secrets.METAMASK_MOBILE_BRANCH_SYNC_TOKEN }}
diff --git a/.github/workflows/release-branch-sync.yml b/.github/workflows/release-branch-sync.yml
new file mode 100644
index 00000000000..b3605beaeee
--- /dev/null
+++ b/.github/workflows/release-branch-sync.yml
@@ -0,0 +1,44 @@
+name: Release Branch Sync
+
+permissions:
+ pull-requests: write
+ contents: write
+
+on:
+ pull_request:
+ types: [closed]
+ branches:
+ - stable
+
+jobs:
+ validate-branch:
+ name: Validate release branch format
+ runs-on: ubuntu-latest
+ if: github.event.pull_request.merged == true
+ outputs:
+ is-valid: ${{ steps.check.outputs.is-valid }}
+ steps:
+ - name: Check branch name format
+ id: check
+ env:
+ BRANCH: ${{ github.event.pull_request.head.ref }}
+ run: |
+ if [[ "$BRANCH" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Branch '$BRANCH' matches release/X.Y.Z format"
+ echo "is-valid=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "Branch '$BRANCH' does not match release/X.Y.Z format. Skipping."
+ echo "is-valid=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ sync-release-branches:
+ name: Sync open release branches with stable
+ needs: validate-branch
+ if: needs.validate-branch.outputs.is-valid == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Sync release branches with stable
+ uses: metamask/github-tools/.github/actions/release-branch-sync@v1.2.0
+ with:
+ merged-release-branch: ${{ github.event.pull_request.head.ref }}
+ github-token: ${{ secrets.STABLE_SYNC_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml
index 6ec79db1012..fcc4a9e05e6 100644
--- a/.github/workflows/run-e2e-workflow.yml
+++ b/.github/workflows/run-e2e-workflow.yml
@@ -56,6 +56,7 @@ jobs:
outputs:
apk-target-path: ${{ steps.determine-target-paths.outputs.apk-target-path }}
test-apk-target-path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }}
+ aab-target-path: ${{ steps.determine-target-paths.outputs.aab-target-path }}
env:
PREBUILT_IOS_APP_PATH: artifacts/MetaMask.app
@@ -130,12 +131,14 @@ jobs:
{
echo "apk-target-path=android/app/build/outputs/apk/flask/release"
echo "test-apk-target-path=android/app/build/outputs/apk/androidTest/flask/release"
+ echo "aab-target-path=android/app/build/outputs/bundle/flaskRelease"
echo "artifact_name=app-flask-release"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ inputs.build_type }}" == "main" ]]; then
{
echo "apk-target-path=android/app/build/outputs/apk/prod/release"
echo "test-apk-target-path=android/app/build/outputs/apk/androidTest/prod/release"
+ echo "aab-target-path=android/app/build/outputs/bundle/prodRelease"
echo "artifact_name=app-prod-release"
} >> "$GITHUB_OUTPUT"
else
@@ -149,6 +152,7 @@ jobs:
echo "π Setting up Android artifacts from build job..."
mkdir -p ${{ steps.determine-target-paths.outputs.apk-target-path }}
mkdir -p ${{ steps.determine-target-paths.outputs.test-apk-target-path }}
+ mkdir -p ${{ steps.determine-target-paths.outputs.aab-target-path }}
- name: Download Android build artifacts
if: ${{ inputs.platform == 'android' }}
diff --git a/android/gradle.properties.github b/android/gradle.properties.github
index f3582e40f7c..768591f0851 100644
--- a/android/gradle.properties.github
+++ b/android/gradle.properties.github
@@ -1,16 +1,16 @@
# GitHub Actions-specific Gradle settings
# Optimized for E2E builds on GitHub Actions runners
-# JVM configuration - tuned for 48GB runner to avoid OOM while maintaining performance
-# Heap: 12GB to leave room for Node.js/Metro and native memory
-# ExitOnOutOfMemoryError: fail-fast on OOM for CI
-org.gradle.jvmargs=-Xmx12g -Xms4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:+UseStringDeduplication -XX:MaxGCPauseMillis=500 -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8
+# JVM configuration - balanced settings to avoid OOM while maintaining performance
+# Using 16GB heap to leave room for parallel workers and native memory
+org.gradle.jvmargs=-Xmx16g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:+UseStringDeduplication -XX:+OptimizeStringConcat
# Enable performance optimizations but limit parallelism to prevent OOM
org.gradle.parallel=true
+org.gradle.configureondemand=true
org.gradle.caching=true
-org.gradle.daemon=false
-org.gradle.workers.max=2
+org.gradle.daemon=true
+org.gradle.workers.max=6
org.gradle.vfs.watch=false
# CI-specific optimizations - enabled for GitHub Actions
@@ -54,4 +54,4 @@ hermesEnabled=true
android.disableResourceValidation=true
# Use legacy packaging to compress native libraries in the resulting APK.
-expo.useLegacyPackaging=false
+expo.useLegacyPackaging=false
\ No newline at end of file
diff --git a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts
index 3e62a8a257d..860adbd5f24 100644
--- a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts
+++ b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts
@@ -33,6 +33,7 @@ const styleSheet = (params: {
cellBase: Object.assign(
{
flexDirection: 'row',
+ alignItems: 'center',
} as ViewStyle,
style,
) as ViewStyle,
diff --git a/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap b/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap
index d42d98111a2..91b81cf6876 100644
--- a/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap
+++ b/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap
@@ -50,6 +50,7 @@ exports[`CellSelectWithMenu should render with default settings correctly 1`] =
{
);
fireEvent.press(getByTestId(`tab-bar-item-${TabBarIconKey.Trending}`));
- expect(navigation.reset).toHaveBeenCalledWith({
- index: 0,
- routes: [{ name: Routes.TRENDING_VIEW }],
- });
+ expect(navigation.navigate).toHaveBeenCalledWith(Routes.TRENDING_VIEW);
});
it('does not navigate to trending when trending feature flag is disabled', () => {
diff --git a/app/component-library/components/Navigation/TabBar/TabBar.tsx b/app/component-library/components/Navigation/TabBar/TabBar.tsx
index 5f4e089180f..e4682f3a54b 100644
--- a/app/component-library/components/Navigation/TabBar/TabBar.tsx
+++ b/app/component-library/components/Navigation/TabBar/TabBar.tsx
@@ -95,10 +95,7 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => {
break;
case Routes.TRENDING_VIEW:
if (isAssetsTrendingTokensEnabled) {
- navigation.reset({
- index: 0,
- routes: [{ name: Routes.TRENDING_VIEW }],
- });
+ navigation.navigate(Routes.TRENDING_VIEW);
}
break;
}
diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx
index 94c39b9a09d..fa0928b5379 100644
--- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx
+++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx
@@ -1,21 +1,14 @@
-import type { Theme } from '@metamask/design-tokens';
import { StyleSheet, TextStyle } from 'react-native';
-const styleSheet = (params: { theme: Theme }) => {
- const { theme } = params;
- const { colors } = theme;
- return StyleSheet.create({
+const styleSheet = () =>
+ StyleSheet.create({
tokenDetailsContainer: {
marginTop: 16,
gap: 24,
},
- contentWrapper: {
- paddingVertical: 4,
- },
title: {
paddingVertical: 8,
} as TextStyle,
- icon: { marginLeft: 4 },
listWrapper: {
paddingTop: 8,
paddingBottom: 8,
@@ -36,15 +29,6 @@ const styleSheet = (params: { theme: Theme }) => {
lastChild: {
paddingBottom: 0,
},
- copyButton: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: colors.primary.muted,
- borderRadius: 20,
- paddingHorizontal: 8,
- marginLeft: 8,
- },
});
-};
export default styleSheet;
diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx
index 931c882e609..7816e44bd75 100644
--- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx
+++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx
@@ -1,19 +1,15 @@
import React from 'react';
import { TouchableOpacity, View } from 'react-native';
import { useDispatch } from 'react-redux';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { Icon, IconName, IconSize } from '@metamask/design-system-react-native';
import { showAlert } from '../../../../../actions/alert';
import { strings } from '../../../../../../locales/i18n';
import { useStyles } from '../../../../../component-library/hooks';
import Text, {
- TextColor,
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
import styleSheet from '../TokenDetails.styles';
-import Icon, {
- IconColor,
- IconName,
- IconSize,
-} from '../../../../../component-library/components/Icons/Icon';
import ClipboardManager from '../../../../../core/ClipboardManager';
import { TokenDetails } from '../TokenDetails';
import TokenDetailsListItem from '../TokenDetailsListItem';
@@ -26,7 +22,8 @@ interface TokenDetailsListProps {
const TokenDetailsList: React.FC = ({
tokenDetails,
}) => {
- const { styles } = useStyles(styleSheet, {});
+ const tw = useTailwind();
+ const { styles } = useStyles(styleSheet);
const dispatch = useDispatch();
const handleShowAlert = (config: {
@@ -49,7 +46,7 @@ const TokenDetailsList: React.FC = ({
return (
-
+
{strings('token.token_details')}
@@ -59,18 +56,13 @@ const TokenDetailsList: React.FC = ({
style={[styles.listItem, styles.firstChild]}
>
-
+
{formatAddress(tokenDetails.contractAddress, 'short')}
-
+
)}
diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap
index 6c6c7cb4993..ab5c86e0d51 100644
--- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap
+++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap
@@ -11,7 +11,8 @@ exports[`TokenDetails should render correctly 1`] = `
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
@@ -58,11 +59,8 @@ exports[`TokenDetails should render correctly 1`] = `
style={
{
"alignItems": "center",
- "backgroundColor": "#4459ff1a",
- "borderRadius": 20,
"flexDirection": "row",
- "marginLeft": 8,
- "paddingHorizontal": 8,
+ "gap": 4,
}
}
>
@@ -70,7 +68,7 @@ exports[`TokenDetails should render correctly 1`] = `
accessibilityRole="text"
style={
{
- "color": "#4459ff",
+ "color": "#121314",
"fontFamily": "Geist-Regular",
"fontSize": 14,
"letterSpacing": 0,
@@ -81,18 +79,18 @@ exports[`TokenDetails should render correctly 1`] = `
0x935E7...05477
diff --git a/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap b/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap
index 2684265ac15..1885ee380c4 100644
--- a/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap
+++ b/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap
@@ -19,7 +19,8 @@ exports[`TokenDetails should render correctly 1`] = `
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
@@ -66,11 +67,8 @@ exports[`TokenDetails should render correctly 1`] = `
style={
{
"alignItems": "center",
- "backgroundColor": "#4459ff1a",
- "borderRadius": 20,
"flexDirection": "row",
- "marginLeft": 8,
- "paddingHorizontal": 8,
+ "gap": 4,
}
}
>
@@ -78,7 +76,7 @@ exports[`TokenDetails should render correctly 1`] = `
accessibilityRole="text"
style={
{
- "color": "#4459ff",
+ "color": "#121314",
"fontFamily": "Geist-Regular",
"fontSize": 14,
"letterSpacing": 0,
@@ -89,18 +87,18 @@ exports[`TokenDetails should render correctly 1`] = `
0x6B175...71d0F
diff --git a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap
index b525ae8e8a3..b7c391893f8 100644
--- a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap
+++ b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap
@@ -854,7 +854,8 @@ exports[`AssetOverview should render native balances when non evm network is sel
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx
index 5487a31a728..6c859302ec9 100644
--- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx
+++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx
@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { TouchableOpacity, Platform, UIManager } from 'react-native';
import { useNavigation } from '@react-navigation/native';
-import I18n, { strings } from '../../../../../../locales/i18n';
+import { strings } from '../../../../../../locales/i18n';
import Text, {
TextColor,
TextVariant,
@@ -30,7 +30,7 @@ import {
selectSourceToken,
} from '../../../../../core/redux/slices/bridge';
import { getNativeSourceToken } from '../../utils/tokenUtils';
-import { getIntlNumberFormatter } from '../../../../../util/intl';
+import { formatMinimumReceived } from '../../utils/currencyUtils';
import { useRewards } from '../../hooks/useRewards';
import RewardsAnimations, {
RewardAnimationState,
@@ -55,11 +55,6 @@ const QuoteDetailsCard: React.FC = () => {
const navigation = useNavigation();
const styles = createStyles(theme);
- const locale = I18n.locale;
- const intlNumberFormatter = getIntlNumberFormatter(locale, {
- maximumSignificantDigits: 8,
- });
-
const {
formattedQuoteData,
activeQuote,
@@ -142,8 +137,8 @@ const QuoteDetailsCard: React.FC = () => {
const gasIncluded7702 = !!activeQuote?.quote.gasIncluded7702;
const isGasless = gasIncluded7702 || gasIncluded;
- const formattedMinToTokenAmount = intlNumberFormatter.format(
- parseFloat(activeQuote?.minToTokenAmount?.amount || '0'),
+ const formattedMinToTokenAmount = formatMinimumReceived(
+ activeQuote?.minToTokenAmount?.amount || '0',
);
return (
diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts
index 27f5157c9b2..8d85b3da3b5 100644
--- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts
+++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts
@@ -47,16 +47,19 @@ export enum SwapBridgeNavigationLocation {
* Returns functions that are used to navigate to the MetaMask Bridge and MetaMask Swaps routes.
* @param location location of navigation call β used for analytics.
* @param sourceToken token object containing address and chainId we want to set as source.
+ * @param destToken optional token object to set as destination. If not provided, or matches source, defaults to computed destination token.
* @returns An object containing functions that can be used to navigate to the existing Bridges page in the browser and the MetaMask Swaps page. If there isn't an existing bridge page, one is created based on the current chain ID and passed token address (if provided).
*/
export const useSwapBridgeNavigation = ({
location,
sourcePage,
- sourceToken: tokenBase,
+ sourceToken: sourceTokenBase,
+ destToken: destTokenBase,
}: {
location: SwapBridgeNavigationLocation;
sourcePage: string;
sourceToken?: BridgeToken;
+ destToken?: BridgeToken;
}) => {
const navigation = useNavigation();
const dispatch = useDispatch();
@@ -68,15 +71,21 @@ export const useSwapBridgeNavigation = ({
// Unified swaps/bridge UI
const goToNativeBridge = useCallback(
- (bridgeViewMode: BridgeViewMode, tokenOverride?: BridgeToken) => {
+ (
+ bridgeViewMode: BridgeViewMode,
+ sourceTokenOverride?: BridgeToken,
+ destTokenOverride?: BridgeToken,
+ ) => {
// Use tokenOverride if provided, otherwise fall back to tokenBase
- const effectiveTokenBase = tokenOverride ?? tokenBase;
+ const effectiveSourceTokenBase = sourceTokenOverride ?? sourceTokenBase;
+ // Use destTokenOverride if provided, otherwise fall back to destTokenBase
+ const effectiveDestTokenBase = destTokenOverride ?? destTokenBase;
// Determine effective chain ID - use home page filter network when no sourceToken provided
- const getEffectiveChainId = (): CaipChainId | Hex => {
- if (effectiveTokenBase) {
+ const getEffectiveSourceChainId = (): CaipChainId | Hex => {
+ if (effectiveSourceTokenBase) {
// If specific token provided, use its chainId
- return effectiveTokenBase.chainId;
+ return effectiveSourceTokenBase.chainId;
}
// No token provided - check home page filter network
@@ -92,12 +101,14 @@ export const useSwapBridgeNavigation = ({
return homePageFilterNetwork.caipChainId as CaipChainId;
};
- const effectiveChainId = getEffectiveChainId();
+ const effectiveSourceChainId = getEffectiveSourceChainId();
let bridgeSourceNativeAsset;
try {
- if (!effectiveTokenBase) {
- bridgeSourceNativeAsset = getNativeAssetForChainId(effectiveChainId);
+ if (!effectiveSourceTokenBase) {
+ bridgeSourceNativeAsset = getNativeAssetForChainId(
+ effectiveSourceChainId,
+ );
}
} catch (error) {
// Suppress error as it's expected when the chain is not supported
@@ -111,15 +122,17 @@ export const useSwapBridgeNavigation = ({
symbol: bridgeSourceNativeAsset.symbol,
image: bridgeSourceNativeAsset.iconUrl ?? '',
decimals: bridgeSourceNativeAsset.decimals,
- chainId: isNonEvmChainId(effectiveChainId)
- ? effectiveChainId
- : formatChainIdToHex(effectiveChainId), // Use hex format for balance fetching compatibility, unless it's a Solana chain
+ chainId: isNonEvmChainId(effectiveSourceChainId)
+ ? effectiveSourceChainId
+ : formatChainIdToHex(effectiveSourceChainId), // Use hex format for balance fetching compatibility, unless it's a Solana chain
}
: undefined;
const candidateSourceToken =
- effectiveTokenBase ?? bridgeNativeSourceTokenFormatted;
- const isBridgeEnabledSource = getIsBridgeEnabledSource(effectiveChainId);
+ effectiveSourceTokenBase ?? bridgeNativeSourceTokenFormatted;
+ const isBridgeEnabledSource = getIsBridgeEnabledSource(
+ effectiveSourceChainId,
+ );
let sourceToken = isBridgeEnabledSource
? candidateSourceToken
: undefined;
@@ -137,18 +150,29 @@ export const useSwapBridgeNavigation = ({
// Pre-populate Redux state before navigation to prevent empty button flash
dispatch(setSourceToken(sourceToken));
- const defaultDestToken = getDefaultDestToken(sourceToken.chainId);
- // Make sure source and dest tokens are different
+ // Use provided destToken if available and different from sourceToken, otherwise compute default
if (
- defaultDestToken &&
- !areAddressesEqual(sourceToken.address, defaultDestToken.address)
+ effectiveDestTokenBase &&
+ !areAddressesEqual(sourceToken.address, effectiveDestTokenBase.address)
) {
- dispatch(setDestToken(defaultDestToken));
+ dispatch(setDestToken(effectiveDestTokenBase));
} else {
- // Fall back to native token if default dest is same as source
- const nativeDestToken = getNativeSourceToken(sourceToken.chainId);
- if (!areAddressesEqual(sourceToken.address, nativeDestToken.address)) {
- dispatch(setDestToken(nativeDestToken));
+ // Either no destToken provided, or it's the same as sourceToken - use default logic
+ const defaultDestToken = getDefaultDestToken(sourceToken.chainId);
+ // Make sure source and dest tokens are different
+ if (
+ defaultDestToken &&
+ !areAddressesEqual(sourceToken.address, defaultDestToken.address)
+ ) {
+ dispatch(setDestToken(defaultDestToken));
+ } else {
+ // Fall back to native token if default dest is same as source
+ const nativeDestToken = getNativeSourceToken(sourceToken.chainId);
+ if (
+ !areAddressesEqual(sourceToken.address, nativeDestToken.address)
+ ) {
+ dispatch(setDestToken(nativeDestToken));
+ }
}
}
@@ -201,7 +225,8 @@ export const useSwapBridgeNavigation = ({
[
navigation,
dispatch,
- tokenBase,
+ sourceTokenBase,
+ destTokenBase,
sourcePage,
trackEvent,
createEventBuilder,
@@ -213,8 +238,12 @@ export const useSwapBridgeNavigation = ({
const { networkModal } = useAddNetwork();
const goToSwaps = useCallback(
- (tokenOverride?: BridgeToken) => {
- goToNativeBridge(BridgeViewMode.Unified, tokenOverride);
+ (tokenOverride?: BridgeToken, destTokenOverride?: BridgeToken) => {
+ goToNativeBridge(
+ BridgeViewMode.Unified,
+ tokenOverride,
+ destTokenOverride,
+ );
},
[goToNativeBridge],
);
diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts
index 5401ed8ed5f..a7d839f7d21 100644
--- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts
+++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts
@@ -39,6 +39,7 @@ jest.mock('../../../../hooks/useMetrics', () => {
const mockGetIsBridgeEnabledSource = jest.fn(() => true);
const mockSetIsDestTokenManuallySet = jest.fn();
+const mockSetDestToken = jest.fn();
jest.mock('../../../../../core/redux/slices/bridge', () => {
const actual = jest.requireActual('../../../../../core/redux/slices/bridge');
return {
@@ -50,6 +51,10 @@ jest.mock('../../../../../core/redux/slices/bridge', () => {
mockSetIsDestTokenManuallySet(...args);
return actual.setIsDestTokenManuallySet(...args);
},
+ setDestToken: (...args: unknown[]) => {
+ mockSetDestToken(...args);
+ return actual.setDestToken(...args);
+ },
};
});
@@ -108,6 +113,17 @@ jest.mock('@metamask/bridge-controller', () => ({
isSolanaChainId: jest.fn(),
}));
+// Mock token utilities
+import {
+ getDefaultDestToken,
+ getNativeSourceToken,
+} from '../../utils/tokenUtils';
+
+jest.mock('../../utils/tokenUtils', () => ({
+ getDefaultDestToken: jest.fn(),
+ getNativeSourceToken: jest.fn(),
+}));
+
describe('useSwapBridgeNavigation', () => {
const mockChainId = '0x1' as Hex;
const mockLocation = SwapBridgeNavigationLocation.TabBar;
@@ -121,6 +137,14 @@ describe('useSwapBridgeNavigation', () => {
image: '',
};
+ const mockSourceToken: BridgeToken = {
+ address: '0x0000000000000000000000000000000000000001',
+ symbol: 'SRC',
+ name: 'Source Token',
+ decimals: 18,
+ chainId: mockChainId,
+ };
+
beforeEach(() => {
jest.clearAllMocks();
@@ -157,6 +181,24 @@ describe('useSwapBridgeNavigation', () => {
// Reset setIsDestTokenManuallySet mock
mockSetIsDestTokenManuallySet.mockClear();
+ mockSetDestToken.mockClear();
+
+ // Setup default mocks for token utilities
+ (getDefaultDestToken as jest.Mock).mockReturnValue({
+ address: '0x6B175474E89094C44Da98b954EesdfDcD0E0e6F',
+ symbol: 'DAI',
+ name: 'Dai Stablecoin',
+ decimals: 18,
+ chainId: mockChainId,
+ });
+ (getNativeSourceToken as jest.Mock).mockReturnValue({
+ address: '0x0000000000000000000000000000000000000000',
+ symbol: 'ETH',
+ name: 'Ether',
+ decimals: 18,
+ chainId: mockChainId,
+ image: '',
+ });
});
it('uses native token when no token is provided', () => {
@@ -457,6 +499,226 @@ describe('useSwapBridgeNavigation', () => {
});
});
+ describe('destToken handling', () => {
+ it('dispatches provided destToken when different from sourceToken', () => {
+ const destToken: BridgeToken = {
+ address: '0x0000000000000000000000000000000000000002',
+ symbol: 'DEST',
+ name: 'Destination Token',
+ decimals: 18,
+ chainId: mockChainId,
+ };
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSwapBridgeNavigation({
+ location: mockLocation,
+ sourcePage: mockSourcePage,
+ sourceToken: mockSourceToken,
+ destToken,
+ }),
+ { state: initialState },
+ );
+
+ result.current.goToSwaps();
+
+ expect(mockSetDestToken).toHaveBeenCalledWith(destToken);
+ });
+
+ it('uses destTokenOverride when passed to goToSwaps', () => {
+ const configuredDestToken: BridgeToken = {
+ address: '0x0000000000000000000000000000000000000002',
+ symbol: 'CONFIGURED',
+ name: 'Configured Dest Token',
+ decimals: 18,
+ chainId: mockChainId,
+ };
+
+ const overrideDestToken: BridgeToken = {
+ address: '0x0000000000000000000000000000000000000003',
+ symbol: 'OVERRIDE',
+ name: 'Override Dest Token',
+ decimals: 18,
+ chainId: mockChainId,
+ };
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSwapBridgeNavigation({
+ location: mockLocation,
+ sourcePage: mockSourcePage,
+ sourceToken: mockSourceToken,
+ destToken: configuredDestToken,
+ }),
+ { state: initialState },
+ );
+
+ result.current.goToSwaps(undefined, overrideDestToken);
+
+ expect(mockSetDestToken).toHaveBeenCalledWith(overrideDestToken);
+ });
+
+ it('falls back to default when destToken same as sourceToken', () => {
+ const sameAsSourceToken: BridgeToken = {
+ ...mockSourceToken,
+ };
+
+ const defaultToken = {
+ address: '0x6B175474E89094C44Da98b954EesdfDcD0E0e6F',
+ symbol: 'DAI',
+ name: 'Dai Stablecoin',
+ decimals: 18,
+ chainId: mockChainId,
+ };
+
+ (getDefaultDestToken as jest.Mock).mockReturnValue(defaultToken);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSwapBridgeNavigation({
+ location: mockLocation,
+ sourcePage: mockSourcePage,
+ sourceToken: mockSourceToken,
+ destToken: sameAsSourceToken,
+ }),
+ { state: initialState },
+ );
+
+ result.current.goToSwaps();
+
+ expect(mockSetDestToken).toHaveBeenCalledWith(defaultToken);
+ });
+
+ it('uses both sourceTokenOverride and destTokenOverride when passed to goToSwaps', () => {
+ const sourceOverride: BridgeToken = {
+ address: '0x0000000000000000000000000000000000000004',
+ symbol: 'SRC_OVERRIDE',
+ name: 'Source Override Token',
+ decimals: 18,
+ chainId: mockChainId,
+ };
+
+ const destOverride: BridgeToken = {
+ address: '0x0000000000000000000000000000000000000005',
+ symbol: 'DEST_OVERRIDE',
+ name: 'Dest Override Token',
+ decimals: 18,
+ chainId: mockChainId,
+ };
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSwapBridgeNavigation({
+ location: mockLocation,
+ sourcePage: mockSourcePage,
+ sourceToken: mockSourceToken,
+ }),
+ { state: initialState },
+ );
+
+ result.current.goToSwaps(sourceOverride, destOverride);
+
+ expect(mockNavigate).toHaveBeenCalledWith('Bridge', {
+ screen: 'BridgeView',
+ params: {
+ sourceToken: sourceOverride,
+ sourcePage: mockSourcePage,
+ bridgeViewMode: BridgeViewMode.Unified,
+ },
+ });
+ expect(mockSetDestToken).toHaveBeenCalledWith(destOverride);
+ });
+
+ it('falls back to native token when default dest same as source', () => {
+ const nativeToken = {
+ address: '0x0000000000000000000000000000000000000000',
+ symbol: 'ETH',
+ name: 'Ether',
+ decimals: 18,
+ chainId: mockChainId,
+ image: '',
+ };
+
+ // Make default token same as source token
+ (getDefaultDestToken as jest.Mock).mockReturnValue({
+ ...mockSourceToken,
+ });
+ (getNativeSourceToken as jest.Mock).mockReturnValue(nativeToken);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSwapBridgeNavigation({
+ location: mockLocation,
+ sourcePage: mockSourcePage,
+ sourceToken: mockSourceToken,
+ }),
+ { state: initialState },
+ );
+
+ result.current.goToSwaps();
+
+ expect(mockSetDestToken).toHaveBeenCalledWith(nativeToken);
+ });
+
+ it('falls back to native token when getDefaultDestToken returns null', () => {
+ const nativeToken = {
+ address: '0x0000000000000000000000000000000000000000',
+ symbol: 'ETH',
+ name: 'Ether',
+ decimals: 18,
+ chainId: mockChainId,
+ image: '',
+ };
+
+ (getDefaultDestToken as jest.Mock).mockReturnValue(null);
+ (getNativeSourceToken as jest.Mock).mockReturnValue(nativeToken);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSwapBridgeNavigation({
+ location: mockLocation,
+ sourcePage: mockSourcePage,
+ sourceToken: mockSourceToken,
+ }),
+ { state: initialState },
+ );
+
+ result.current.goToSwaps();
+
+ expect(mockSetDestToken).toHaveBeenCalledWith(nativeToken);
+ });
+
+ it('does not dispatch destToken when native token same as source', () => {
+ // Source token is the native token
+ const nativeSourceToken: BridgeToken = {
+ address: '0x0000000000000000000000000000000000000000',
+ symbol: 'ETH',
+ name: 'Ether',
+ decimals: 18,
+ chainId: mockChainId,
+ };
+
+ // Default and native both match source
+ (getDefaultDestToken as jest.Mock).mockReturnValue(nativeSourceToken);
+ (getNativeSourceToken as jest.Mock).mockReturnValue(nativeSourceToken);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSwapBridgeNavigation({
+ location: mockLocation,
+ sourcePage: mockSourcePage,
+ sourceToken: nativeSourceToken,
+ }),
+ { state: initialState },
+ );
+
+ result.current.goToSwaps();
+
+ // setDestToken should not be called since all options match source
+ expect(mockSetDestToken).not.toHaveBeenCalled();
+ });
+ });
+
describe('Solana', () => {
it('keeps Solana chain ID in CAIP format for Bridge', () => {
// Mock home page filter network as Solana
@@ -609,6 +871,33 @@ describe('useSwapBridgeNavigation', () => {
},
});
});
+
+ it('dispatches destToken with CAIP chain ID format', () => {
+ const solanaDestToken: BridgeToken = {
+ symbol: 'SOL',
+ name: 'Solana',
+ address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
+ decimals: 9,
+ image:
+ 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44/501.png',
+ chainId: SolScope.Mainnet,
+ };
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useSwapBridgeNavigation({
+ location: mockLocation,
+ sourcePage: mockSourcePage,
+ sourceToken: mockSourceToken,
+ destToken: solanaDestToken,
+ }),
+ { state: initialState },
+ );
+
+ result.current.goToSwaps();
+
+ expect(mockSetDestToken).toHaveBeenCalledWith(solanaDestToken);
+ });
});
describe('Analytics Tracking', () => {
diff --git a/app/components/UI/Bridge/utils/currencyUtils.test.ts b/app/components/UI/Bridge/utils/currencyUtils.test.ts
index f21abcde7d3..3bbbc3b9592 100644
--- a/app/components/UI/Bridge/utils/currencyUtils.test.ts
+++ b/app/components/UI/Bridge/utils/currencyUtils.test.ts
@@ -1,6 +1,6 @@
import I18n from '../../../../../locales/i18n';
import { getIntlNumberFormatter } from '../../../../util/intl';
-import { formatCurrency } from './currencyUtils';
+import { formatCurrency, formatMinimumReceived } from './currencyUtils';
jest.mock('../../../../../locales/i18n', () => ({
locale: 'en-US',
@@ -400,3 +400,57 @@ describe('formatCurrency', () => {
});
});
});
+
+describe('formatMinimumReceived', () => {
+ const mockFormat = jest.fn();
+ const mockGetIntlNumberFormatter =
+ getIntlNumberFormatter as jest.MockedFunction<
+ typeof getIntlNumberFormatter
+ >;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetIntlNumberFormatter.mockReturnValue({
+ format: mockFormat,
+ } as unknown as Intl.NumberFormat);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('floors values down instead of rounding up', () => {
+ mockFormat.mockImplementation((val) => String(val));
+
+ formatMinimumReceived(0.012579999);
+
+ expect(mockFormat).toHaveBeenCalledWith(0.01257999);
+ });
+
+ it('parses string amounts', () => {
+ mockFormat.mockImplementation((val) => String(val));
+
+ formatMinimumReceived('1.2345');
+
+ expect(mockFormat).toHaveBeenCalledWith(1.2345);
+ });
+
+ it('returns "0" for invalid input', () => {
+ const result = formatMinimumReceived('not-a-number');
+
+ expect(result).toBe('0');
+ });
+
+ it('uses locale from I18n', () => {
+ mockFormat.mockImplementation((val) => String(val));
+ (I18n as { locale: string }).locale = 'de-DE';
+
+ formatMinimumReceived(1.234);
+
+ expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('de-DE', {
+ maximumSignificantDigits: 8,
+ });
+
+ (I18n as { locale: string }).locale = 'en-US';
+ });
+});
diff --git a/app/components/UI/Bridge/utils/currencyUtils.ts b/app/components/UI/Bridge/utils/currencyUtils.ts
index 647a5bc53a9..1612d7c075b 100644
--- a/app/components/UI/Bridge/utils/currencyUtils.ts
+++ b/app/components/UI/Bridge/utils/currencyUtils.ts
@@ -1,6 +1,22 @@
import I18n from '../../../../../locales/i18n';
import { getIntlNumberFormatter } from '../../../../util/intl';
+/**
+ * Formats a minimum received amount, always rounding down to ensure users
+ * never see a minimum higher than they might actually receive.
+ */
+export function formatMinimumReceived(amount: number | string): string {
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
+ if (isNaN(numAmount)) return '0';
+
+ // Floor to 8 decimal places before formatting
+ const flooredAmount = Math.floor(numAmount * 1e8) / 1e8;
+
+ return getIntlNumberFormatter(I18n.locale, {
+ maximumSignificantDigits: 8,
+ }).format(flooredAmount);
+}
+
/**
* Formats currency amounts using the device's locale
* @param amount - The amount to format (number or string)
diff --git a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx
index 5311fb82542..665c69c1434 100644
--- a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx
+++ b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx
@@ -600,7 +600,11 @@ const CardAuthentication = () => {
disabled={isLoginDisabled || loading}
/>
navigation.navigate(Routes.CARD.ONBOARDING.ROOT)}
+ onPress={() =>
+ navigation.navigate(Routes.CARD.ONBOARDING.ROOT, {
+ screen: Routes.CARD.ONBOARDING.SIGN_UP,
+ })
+ }
>
{
+ const { width: screenWidth, height: screenHeight } = dimensions;
+
+ const widthScale = screenWidth / BASE_WIDTH;
+ const heightScale = screenHeight / baseHeight;
-// Use more conservative scaling to prevent excessive padding
-const scale = Math.min(widthScale, heightScale);
-const conservativeScale = Math.min(scale, 1.2); // Cap scaling at 120%
+ // Use more conservative scaling to prevent excessive padding
+ const scale = Math.min(widthScale, heightScale);
+ const conservativeScale = Math.min(scale, 1.2); // Cap scaling at 120%
-// Platform-aware responsive scaling functions
-const scaleSize = (size: number) => Math.ceil(size * conservativeScale);
-const scaleFont = (size: number) => Math.ceil(size * conservativeScale);
+ const scaleSize = (size: number) => Math.ceil(size * conservativeScale);
+ const scaleFont = (size: number) => Math.ceil(size * conservativeScale);
-// For vertical spacing, use percentage of available height instead of pure scaling
-const scaleVertical = (size: number) => {
- // Use percentage of screen height for more consistent spacing
- const percentage = size / baseHeight;
- return Math.ceil(screenHeight * percentage);
+ // For vertical spacing, use percentage of available height instead of pure scaling
+ const scaleVertical = (size: number) => {
+ // Use percentage of screen height for more consistent spacing
+ const percentage = size / baseHeight;
+ return Math.ceil(screenHeight * percentage);
+ };
+
+ const scaleHorizontal = (size: number) => Math.ceil(size * widthScale);
+
+ return {
+ screenWidth,
+ screenHeight,
+ scaleSize,
+ scaleFont,
+ scaleVertical,
+ scaleHorizontal,
+ };
};
-const scaleHorizontal = (size: number) => Math.ceil(size * widthScale);
+const createStyles = (theme: Theme, dimensions: WindowDimensions) => {
+ const { screenHeight, scaleSize, scaleFont, scaleVertical, scaleHorizontal } =
+ createScalingFunctions(dimensions);
-const createStyles = (theme: Theme) =>
- StyleSheet.create({
+ return StyleSheet.create({
pageContainer: {
flex: 1,
position: 'relative',
@@ -117,5 +135,6 @@ const createStyles = (theme: Theme) =>
fontSize: scaleFont(16),
},
});
+};
export default createStyles;
diff --git a/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx b/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx
index 22ea8d30001..166b404fa2e 100644
--- a/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx
+++ b/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx
@@ -1,6 +1,6 @@
import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useEffect } from 'react';
-import { Image, View } from 'react-native';
+import { Image, View, useWindowDimensions } from 'react-native';
import { strings } from '../../../../../../locales/i18n';
import Button, {
@@ -28,7 +28,8 @@ const CardWelcome = () => {
const { navigate } = useNavigation();
const hasCardholderAccounts = useSelector(selectHasCardholderAccounts);
const theme = useTheme();
- const styles = createStyles(theme);
+ const dimensions = useWindowDimensions();
+ const styles = createStyles(theme, dimensions);
useEffect(() => {
trackEvent(
diff --git a/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx b/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx
index c7a87b91cc2..6fe5f9e76eb 100644
--- a/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx
+++ b/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx
@@ -146,7 +146,7 @@ const ConfirmEmail = () => {
password,
verificationCode: confirmCode,
contactVerificationId,
- countryOfResidence: selectedCountry,
+ countryOfResidence: selectedCountry?.key || '',
allowMarketing: true,
allowSms: true,
});
@@ -314,6 +314,7 @@ const ConfirmEmail = () => {
onPress={handleContinue}
width={ButtonWidthTypes.Full}
isDisabled={isDisabled}
+ loading={verifyLoading}
testID="confirm-email-continue-button"
/>
);
diff --git a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx
index 1f774b06e47..87111450acd 100644
--- a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx
+++ b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx
@@ -316,6 +316,7 @@ const ConfirmPhoneNumber = () => {
onPress={handleContinue}
width={ButtonWidthTypes.Full}
isDisabled={isDisabled}
+ loading={verifyLoading}
testID="confirm-phone-number-continue-button"
/>
);
diff --git a/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx b/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx
index 600f1bc1a90..0649f616fb5 100644
--- a/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx
+++ b/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx
@@ -87,11 +87,24 @@ jest.mock('@metamask/design-system-react-native', () => {
}: React.PropsWithChildren>) =>
React.createElement(RNText, props, children);
+ const Icon = ({ name, size, ...props }: { name: string; size: string }) =>
+ React.createElement(View, { testID: 'icon', ...props });
+
return {
Box,
Text,
+ Icon,
TextVariant: {
BodySm: 'BodySm',
+ BodyMd: 'BodyMd',
+ },
+ IconName: {
+ ArrowDown: 'arrow-down',
+ },
+ IconSize: {
+ Sm: 'sm',
+ Md: 'md',
+ Lg: 'lg',
},
};
});
@@ -254,41 +267,6 @@ jest.mock('../../../../../component-library/components/Buttons/Button', () => {
};
});
-// Mock SelectComponent
-jest.mock('../../../SelectComponent', () => {
- const React = jest.requireActual('react');
- const { TouchableOpacity, Text } = jest.requireActual('react-native');
-
- return ({
- testID,
- onValueChange,
- options,
- selectedValue,
- defaultValue,
- }: {
- testID?: string;
- onValueChange?: (value: string) => void;
- options?: { key: string; value: string; label: string }[];
- selectedValue?: string;
- defaultValue?: string;
- }) => {
- const handlePress = () => {
- if (options && options.length > 0 && onValueChange) {
- onValueChange(options[0].value);
- }
- };
-
- return React.createElement(
- TouchableOpacity,
- {
- testID,
- onPress: handlePress,
- },
- React.createElement(Text, {}, selectedValue || defaultValue || 'Select'),
- );
- };
-});
-
// Mock utility functions
jest.mock('../../util/cardTokenVault');
jest.mock('../../util/mapCountryToLocation');
@@ -303,6 +281,10 @@ jest.mock('../../../../../constants/navigation/Routes', () => ({
COMPLETE: 'CardOnboardingComplete',
SIGN_UP: 'CardOnboardingSignUp',
},
+ MODALS: {
+ ID: 'CardModals',
+ REGION_SELECTION: 'RegionSelection',
+ },
},
}));
@@ -362,7 +344,12 @@ const createTestStore = (initialState = {}) =>
card: (
state = {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
contactVerificationId: 'contact-id',
user: {
@@ -527,7 +514,12 @@ describe('MailingAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
user: {
id: 'user-id',
@@ -682,6 +674,28 @@ describe('MailingAddress Component', () => {
});
it('enables continue button when all required fields are filled', async () => {
+ // Use non-US user to avoid state requirement
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
const { getByTestId } = render(
@@ -689,9 +703,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '12345');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
await waitFor(() => {
const button = getByTestId('mailing-address-continue-button');
@@ -752,7 +765,12 @@ describe('MailingAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
},
},
@@ -774,7 +792,12 @@ describe('MailingAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'CA',
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
},
},
@@ -812,7 +835,12 @@ describe('MailingAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
onboardingId: 'test-onboarding-id',
},
},
@@ -966,7 +994,12 @@ describe('MailingAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
onboardingId: null,
},
},
@@ -1107,7 +1140,29 @@ describe('MailingAddress Component', () => {
expect(mockRegisterAddress).not.toHaveBeenCalled();
});
- it('calls registerAddress with correct parameters for US users', async () => {
+ it('calls registerAddress with correct parameters for non-US users', async () => {
+ // Use non-US user to avoid state requirement via modal
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
mockUseRegisterMailingAddress.mockReturnValue({
registerAddress: mockRegisterAddress,
isLoading: false,
@@ -1123,7 +1178,7 @@ describe('MailingAddress Component', () => {
user: { id: 'user-123', email: 'test@example.com' },
});
- mockMapCountryToLocation.mockReturnValue('us');
+ mockMapCountryToLocation.mockReturnValue('intl');
mockExtractTokenExpiration.mockReturnValue(3600000);
mockStoreCardBaanxToken.mockResolvedValue({ success: true });
@@ -1135,9 +1190,8 @@ describe('MailingAddress Component', () => {
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
fireEvent.changeText(getByTestId('address-line-2-input'), 'Apt 4B');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1150,9 +1204,9 @@ describe('MailingAddress Component', () => {
onboardingId: 'test-id',
addressLine1: '123 Main St',
addressLine2: 'Apt 4B',
- city: 'San Francisco',
- usState: 'CA',
- zip: '94102',
+ city: 'Toronto',
+ usState: undefined,
+ zip: 'M5H 2N2',
});
});
});
@@ -1163,7 +1217,12 @@ describe('MailingAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'CA',
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
},
},
@@ -1218,6 +1277,28 @@ describe('MailingAddress Component', () => {
});
it('updates user via setUser when registration returns updated user', async () => {
+ // Use non-US user to avoid state requirement
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
const updatedUser = { id: 'user-123', email: 'updated@example.com' };
mockUseRegisterMailingAddress.mockReturnValue({
@@ -1235,7 +1316,7 @@ describe('MailingAddress Component', () => {
user: updatedUser,
});
- mockMapCountryToLocation.mockReturnValue('us');
+ mockMapCountryToLocation.mockReturnValue('intl');
mockExtractTokenExpiration.mockReturnValue(3600000);
mockStoreCardBaanxToken.mockResolvedValue({ success: true });
@@ -1246,9 +1327,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1262,6 +1342,28 @@ describe('MailingAddress Component', () => {
});
it('stores access token and dispatches Redux actions on success', async () => {
+ // Use non-US user to avoid state requirement
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
mockUseRegisterMailingAddress.mockReturnValue({
registerAddress: mockRegisterAddress,
isLoading: false,
@@ -1277,7 +1379,7 @@ describe('MailingAddress Component', () => {
user: { id: 'user-123', email: 'test@example.com' },
});
- mockMapCountryToLocation.mockReturnValue('us');
+ mockMapCountryToLocation.mockReturnValue('intl');
mockExtractTokenExpiration.mockReturnValue(3600000);
mockStoreCardBaanxToken.mockResolvedValue({ success: true });
@@ -1288,9 +1390,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1302,12 +1403,34 @@ describe('MailingAddress Component', () => {
expect(mockStoreCardBaanxToken).toHaveBeenCalledWith({
accessToken: 'test-access-token',
accessTokenExpiresAt: 3600000,
- location: 'us',
+ location: 'intl',
});
});
});
it('navigates to complete screen after successful registration', async () => {
+ // Use non-US user to avoid state requirement
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
mockUseRegisterMailingAddress.mockReturnValue({
registerAddress: mockRegisterAddress,
isLoading: false,
@@ -1323,7 +1446,7 @@ describe('MailingAddress Component', () => {
user: { id: 'user-123', email: 'test@example.com' },
});
- mockMapCountryToLocation.mockReturnValue('us');
+ mockMapCountryToLocation.mockReturnValue('intl');
mockExtractTokenExpiration.mockReturnValue(3600000);
mockStoreCardBaanxToken.mockResolvedValue({ success: true });
@@ -1334,9 +1457,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1357,6 +1479,28 @@ describe('MailingAddress Component', () => {
});
it('navigates to sign up when Onboarding ID not found error occurs', async () => {
+ // Use non-US user to avoid state requirement
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
const { CardError } = jest.requireMock('../../types');
mockUseRegisterMailingAddress.mockReturnValue({
@@ -1380,9 +1524,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1396,6 +1539,28 @@ describe('MailingAddress Component', () => {
});
it('allows error display for general registration errors', async () => {
+ // Use non-US user to avoid state requirement
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
mockUseRegisterMailingAddress.mockReturnValue({
registerAddress: mockRegisterAddress,
isLoading: false,
@@ -1415,9 +1580,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1486,19 +1650,6 @@ describe('MailingAddress Component', () => {
expect(mockResetHandler).toHaveBeenCalled();
});
- it('calls reset when state changes', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- const input = getByTestId('state-select');
- fireEvent.press(input);
-
- expect(mockResetHandler).toHaveBeenCalled();
- });
-
it('calls reset when zip code changes', () => {
const { getByTestId } = render(
@@ -1545,7 +1696,28 @@ describe('MailingAddress Component', () => {
});
it('creates new consent when no existing consent found', async () => {
- // Given: No consent exists and registration will succeed
+ // Given: No consent exists and registration will succeed (non-US user to avoid state requirement)
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue(null);
mockCreateOnboardingConsent.mockResolvedValue('new-consent-123');
mockRegisterAddress.mockResolvedValue({
@@ -1584,9 +1756,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1615,7 +1786,28 @@ describe('MailingAddress Component', () => {
});
it('reuses existing incomplete consent', async () => {
- // Given: Incomplete consent exists
+ // Given: Incomplete consent exists (non-US user to avoid state requirement)
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue({
consentSetId: 'existing-consent-456',
userId: null,
@@ -1657,9 +1849,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1686,7 +1877,28 @@ describe('MailingAddress Component', () => {
});
it('skips consent operations when consent already completed', async () => {
- // Given: Completed consent exists
+ // Given: Completed consent exists (non-US user to avoid state requirement)
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue({
consentSetId: 'completed-consent-789',
userId: 'user-123',
@@ -1728,9 +1940,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1762,13 +1973,18 @@ describe('MailingAddress Component', () => {
});
it('uses existing consent set ID from Redux when available', async () => {
- // Given: Consent ID exists in Redux
+ // Given: Consent ID exists in Redux (non-US user to avoid state requirement)
const { useSelector } = jest.requireMock('react-redux');
useSelector.mockImplementation((selector: any) =>
selector({
card: {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
consentSetId: 'redux-consent-999',
user: {
@@ -1816,9 +2032,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1840,7 +2055,28 @@ describe('MailingAddress Component', () => {
});
it('clears consent set ID from Redux after linking consent', async () => {
- // Given: No consent exists
+ // Given: No consent exists (non-US user to avoid state requirement)
+ const { useSelector } = jest.requireMock('react-redux');
+ useSelector.mockImplementation((selector: any) =>
+ selector({
+ card: {
+ onboarding: {
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
+ onboardingId: 'test-id',
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ },
+ },
+ }),
+ );
+
mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue(null);
mockCreateOnboardingConsent.mockResolvedValue('new-consent-123');
mockRegisterAddress.mockResolvedValue({
@@ -1879,9 +2115,8 @@ describe('MailingAddress Component', () => {
);
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
- fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
- fireEvent.changeText(getByTestId('zip-code-input'), '94102');
- fireEvent.press(getByTestId('state-select'));
+ fireEvent.changeText(getByTestId('city-input'), 'Toronto');
+ fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2');
const button = getByTestId('mailing-address-continue-button');
@@ -1916,7 +2151,12 @@ describe('MailingAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
onboardingId: null,
},
},
@@ -1944,7 +2184,12 @@ describe('MailingAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'CA',
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
},
},
diff --git a/app/components/UI/Card/components/Onboarding/MailingAddress.tsx b/app/components/UI/Card/components/Onboarding/MailingAddress.tsx
index 7ed112b52d1..49753dfc079 100644
--- a/app/components/UI/Card/components/Onboarding/MailingAddress.tsx
+++ b/app/components/UI/Card/components/Onboarding/MailingAddress.tsx
@@ -116,7 +116,7 @@ const MailingAddress = () => {
!onboardingId ||
!addressLine1 ||
!city ||
- (!state && selectedCountry === 'US') ||
+ (!state && selectedCountry?.key === 'US') ||
!zipCode,
[
registerLoading,
@@ -135,7 +135,7 @@ const MailingAddress = () => {
!onboardingId ||
!addressLine1 ||
!city ||
- (!state && selectedCountry === 'US') ||
+ (!state && selectedCountry?.key === 'US') ||
!zipCode
) {
return;
@@ -164,7 +164,7 @@ const MailingAddress = () => {
if (accessToken && updatedUser?.id) {
// Store the access token for immediate authentication
- const location = mapCountryToLocation(selectedCountry);
+ const location = mapCountryToLocation(selectedCountry?.key || null);
const accessTokenExpiresIn = extractTokenExpiration(accessToken);
const storeResult = await storeCardBaanxToken({
diff --git a/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx b/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx
index c0ae90ea1ea..1e0531efe83 100644
--- a/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx
+++ b/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx
@@ -46,6 +46,16 @@ jest.mock('@metamask/design-system-react-native', () => {
HeadingMd: 'HeadingMd',
};
+ const IconName = {
+ ArrowDown: 'arrow-down',
+ };
+
+ const IconSize = {
+ Sm: 'sm',
+ Md: 'md',
+ Lg: 'lg',
+ };
+
return {
Box: ({
children,
@@ -63,7 +73,11 @@ jest.mock('@metamask/design-system-react-native', () => {
children: React.ReactNode;
testID?: string;
}) => React.createElement(Text, { testID, ...props }, children),
+ Icon: ({ name, size, ...props }: { name: string; size: string }) =>
+ React.createElement(View, { testID: 'icon', ...props }),
TextVariant,
+ IconName,
+ IconSize,
};
});
@@ -189,51 +203,40 @@ jest.mock('../../../../../component-library/components/Form/Label', () => {
}) => React.createElement(Text, { testID }, children);
});
-jest.mock('../../../SelectComponent', () => {
- const React = jest.requireActual('react');
- const { View, Text } = jest.requireActual('react-native');
-
- return ({
- testID,
- options,
- selectedValue,
- onValueChange,
- ...props
- }: {
- testID?: string;
- options?: { label: string; value: string }[];
- selectedValue?: string;
- onValueChange?: (value: string) => void;
- }) =>
- React.createElement(
- View,
- { testID, ...props },
- React.createElement(Text, {}, `Selected: ${selectedValue || 'None'}`),
- );
-});
-
jest.mock('../../../Ramp/Deposit/components/DepositDateField', () => {
const React = jest.requireActual('react');
const { TextInput } = jest.requireActual('react-native');
return ({
- testID,
onChangeText,
value,
...props
}: {
- testID?: string;
onChangeText?: (text: string) => void;
value?: string;
}) =>
React.createElement(TextInput, {
- testID,
+ testID: 'personal-details-date-of-birth-input',
onChangeText,
value,
...props,
});
});
+// Mock RegionSelectorModal - setOnValueChange should immediately invoke the callback
+const mockSetOnValueChange = jest.fn(
+ (callback: (region: { key: string }) => void) => {
+ // Immediately invoke with a mock region
+ callback({ key: 'US' });
+ },
+);
+jest.mock('./RegionSelectorModal', () => ({
+ setOnValueChange: (callback: (region: { key: string }) => void) =>
+ mockSetOnValueChange(callback),
+ clearOnValueChange: jest.fn(),
+ createRegionSelectorModalNavigationDetails: jest.fn(() => ['MockRoute', {}]),
+}));
+
jest.mock('../../../../hooks/useDebouncedValue', () => ({
useDebouncedValue: (value: string) => value,
}));
@@ -324,6 +327,7 @@ const mockReset = jest.fn();
const mockDispatch = jest.fn();
const mockRegisterPersonalDetails = jest.fn();
const mockSetUser = jest.fn();
+const mockFetchUserData = jest.fn();
const mockTrackEvent = jest.fn();
const mockCreateEventBuilder = jest.fn(() => ({
addProperties: jest.fn().mockReturnThis(),
@@ -343,7 +347,12 @@ const mockCreateEventBuilder = jest.fn(() => ({
card: {
onboarding: {
onboardingId: 'test-onboarding-id',
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
},
},
};
@@ -361,8 +370,8 @@ const mockCreateEventBuilder = jest.fn(() => ({
(useRegistrationSettings as jest.Mock).mockReturnValue({
data: {
countries: [
- { code: 'US', name: 'United States' },
- { code: 'CA', name: 'Canada' },
+ { iso3166alpha2: 'US', name: 'United States', callingCode: '1' },
+ { iso3166alpha2: 'CA', name: 'Canada', callingCode: '1' },
],
},
});
@@ -372,6 +381,7 @@ const mockCreateEventBuilder = jest.fn(() => ({
isLoading: false,
user: null,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -408,6 +418,12 @@ describe('PersonalDetails Component', () => {
expect(queryByTestId('personal-details-ssn-error')).toBeNull();
expect(queryByTestId('personal-details-error')).toBeNull();
});
+
+ it('calls fetchUserData on mount', () => {
+ render();
+
+ expect(mockFetchUserData).toHaveBeenCalledTimes(1);
+ });
});
describe('Conditional SSN Field Rendering', () => {
@@ -417,7 +433,12 @@ describe('PersonalDetails Component', () => {
card: {
onboarding: {
onboardingId: 'test-onboarding-id',
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
},
},
};
@@ -435,7 +456,12 @@ describe('PersonalDetails Component', () => {
card: {
onboarding: {
onboardingId: 'test-onboarding-id',
- selectedCountry: 'CA',
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
},
},
};
@@ -489,7 +515,12 @@ describe('PersonalDetails Component', () => {
card: {
onboarding: {
onboardingId: 'test-onboarding-id',
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
},
},
};
@@ -558,12 +589,13 @@ describe('PersonalDetails Component', () => {
firstName: 'John',
lastName: 'Doe',
dateOfBirth: '2002-06-07T00:00:00.000Z',
- countryOfResidence: 'US',
+ countryOfNationality: 'US',
ssn: '123456789',
};
(useCardSDK as jest.Mock).mockReturnValue({
user: mockUserData,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -580,12 +612,13 @@ describe('PersonalDetails Component', () => {
firstName: 'Jane',
lastName: 'Smith',
dateOfBirth: '1995-03-15T00:00:00.000Z',
- countryOfResidence: 'CA',
+ countryOfNationality: 'CA',
ssn: '987654321',
};
(useCardSDK as jest.Mock).mockReturnValue({
user: mockUserData,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -603,12 +636,13 @@ describe('PersonalDetails Component', () => {
firstName: 'John',
lastName: 'Doe',
dateOfBirth: null,
- countryOfResidence: 'US',
+ countryOfNationality: 'US',
ssn: '123456789',
};
(useCardSDK as jest.Mock).mockReturnValue({
user: mockUserData,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -623,12 +657,13 @@ describe('PersonalDetails Component', () => {
firstName: 'John',
lastName: 'Doe',
dateOfBirth: '',
- countryOfResidence: 'US',
+ countryOfNationality: 'US',
ssn: '123456789',
};
(useCardSDK as jest.Mock).mockReturnValue({
user: mockUserData,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -643,12 +678,13 @@ describe('PersonalDetails Component', () => {
firstName: 'John',
lastName: 'Doe',
dateOfBirth: 'invalid-date',
- countryOfResidence: 'US',
+ countryOfNationality: 'US',
ssn: '123456789',
};
(useCardSDK as jest.Mock).mockReturnValue({
user: mockUserData,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -663,12 +699,13 @@ describe('PersonalDetails Component', () => {
firstName: 'John',
lastName: 'Doe',
dateOfBirth: 1234567890000,
- countryOfResidence: 'US',
+ countryOfNationality: 'US',
ssn: '123456789',
};
(useCardSDK as jest.Mock).mockReturnValue({
user: mockUserData,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -683,12 +720,13 @@ describe('PersonalDetails Component', () => {
firstName: 'John',
lastName: 'Doe',
dateOfBirth: '1990-12-25T00:00:00.000Z',
- countryOfResidence: 'US',
+ countryOfNationality: 'US',
ssn: '123456789',
};
(useCardSDK as jest.Mock).mockReturnValue({
user: mockUserData,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -703,7 +741,7 @@ describe('PersonalDetails Component', () => {
firstName: 'John',
lastName: 'Doe',
dateOfBirth: '1990-01-01T00:00:00.000Z',
- countryOfResidence: 'US',
+ countryOfNationality: 'US',
ssn: '123456789',
};
(useSelector as jest.Mock).mockImplementation((selector) => {
@@ -711,7 +749,12 @@ describe('PersonalDetails Component', () => {
card: {
onboarding: {
onboardingId: 'test-onboarding-id',
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
},
},
};
@@ -720,6 +763,7 @@ describe('PersonalDetails Component', () => {
(useCardSDK as jest.Mock).mockReturnValue({
user: mockUserData,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -737,7 +781,12 @@ describe('PersonalDetails Component', () => {
card: {
onboarding: {
onboardingId: 'test-onboarding-id',
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
},
},
};
@@ -754,10 +803,18 @@ describe('PersonalDetails Component', () => {
const firstNameInput = getByTestId('personal-details-first-name-input');
const lastNameInput = getByTestId('personal-details-last-name-input');
+ const dateOfBirthInput = getByTestId(
+ 'personal-details-date-of-birth-input',
+ );
+ const nationalitySelect = getByTestId(
+ 'personal-details-nationality-select',
+ );
const ssnInput = getByTestId('personal-details-ssn-input');
fireEvent.changeText(firstNameInput, 'John');
fireEvent.changeText(lastNameInput, 'Doe');
+ fireEvent.changeText(dateOfBirthInput, '631152000000'); // Valid timestamp for 1990-01-01
+ fireEvent.press(nationalitySelect); // Triggers setOnValueChange which sets nationalityKey
fireEvent.changeText(ssnInput, '123456789');
const continueButton = getByTestId('personal-details-continue-button');
@@ -784,10 +841,18 @@ describe('PersonalDetails Component', () => {
const firstNameInput = getByTestId('personal-details-first-name-input');
const lastNameInput = getByTestId('personal-details-last-name-input');
+ const dateOfBirthInput = getByTestId(
+ 'personal-details-date-of-birth-input',
+ );
+ const nationalitySelect = getByTestId(
+ 'personal-details-nationality-select',
+ );
const ssnInput = getByTestId('personal-details-ssn-input');
fireEvent.changeText(firstNameInput, 'John');
fireEvent.changeText(lastNameInput, 'Doe');
+ fireEvent.changeText(dateOfBirthInput, '631152000000'); // Valid timestamp for 1990-01-01
+ fireEvent.press(nationalitySelect); // Triggers setOnValueChange which sets nationalityKey
fireEvent.changeText(ssnInput, '123456789');
const continueButton = getByTestId('personal-details-continue-button');
@@ -805,6 +870,7 @@ describe('PersonalDetails Component', () => {
(useCardSDK as jest.Mock).mockReturnValue({
user: null,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -824,7 +890,12 @@ describe('PersonalDetails Component', () => {
card: {
onboarding: {
onboardingId: null,
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
},
},
};
@@ -871,12 +942,13 @@ describe('PersonalDetails Component', () => {
firstName: 'John',
lastName: 'Doe',
dateOfBirth: '1990-01-01T00:00:00.000Z',
- countryOfResidence: 'US',
+ countryOfNationality: 'US',
ssn: '123456789',
};
(useCardSDK as jest.Mock).mockReturnValue({
user: mockUserData,
setUser: mockSetUser,
+ fetchUserData: mockFetchUserData,
logoutFromProvider: jest.fn(),
});
@@ -922,10 +994,18 @@ describe('PersonalDetails Component', () => {
const firstNameInput = getByTestId('personal-details-first-name-input');
const lastNameInput = getByTestId('personal-details-last-name-input');
+ const dateOfBirthInput = getByTestId(
+ 'personal-details-date-of-birth-input',
+ );
+ const nationalitySelect = getByTestId(
+ 'personal-details-nationality-select',
+ );
const ssnInput = getByTestId('personal-details-ssn-input');
fireEvent.changeText(firstNameInput, 'John');
fireEvent.changeText(lastNameInput, 'Doe');
+ fireEvent.changeText(dateOfBirthInput, '631152000000'); // Valid timestamp for 1990-01-01
+ fireEvent.press(nationalitySelect); // Triggers setOnValueChange which sets nationalityKey
fireEvent.changeText(ssnInput, '123456789');
const continueButton = getByTestId('personal-details-continue-button');
@@ -939,6 +1019,7 @@ describe('PersonalDetails Component', () => {
onboardingId: 'test-onboarding-id',
firstName: 'John',
lastName: 'Doe',
+ dateOfBirth: expect.any(String),
}),
);
});
@@ -949,7 +1030,12 @@ describe('PersonalDetails Component', () => {
card: {
onboarding: {
onboardingId: 'test-onboarding-id',
- selectedCountry: 'CA',
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
},
},
};
diff --git a/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx b/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx
index 441e0d80149..58e679c70b3 100644
--- a/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx
+++ b/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx
@@ -1,6 +1,13 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
-import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
+import {
+ Box,
+ Icon,
+ IconName,
+ IconSize,
+ Text,
+ TextVariant,
+} from '@metamask/design-system-react-native';
import Button, {
ButtonSize,
ButtonVariants,
@@ -21,7 +28,6 @@ import {
selectSelectedCountry,
} from '../../../../../core/redux/slices/card';
import { useDispatch, useSelector } from 'react-redux';
-import SelectComponent from '../../../SelectComponent';
import useRegisterPersonalDetails from '../../hooks/useRegisterPersonalDetails';
import useRegistrationSettings from '../../hooks/useRegistrationSettings';
import {
@@ -32,11 +38,19 @@ import { CardError } from '../../types';
import { useCardSDK } from '../../sdk';
import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
import { CardActions, CardScreens } from '../../util/metrics';
+import { countryCodeToFlag } from '../../util/countryCodeToFlag';
+import {
+ clearOnValueChange,
+ createRegionSelectorModalNavigationDetails,
+ Region,
+ setOnValueChange,
+} from './RegionSelectorModal';
+import { TouchableOpacity } from 'react-native';
const PersonalDetails = () => {
const navigation = useNavigation();
const dispatch = useDispatch();
- const { setUser, user: userData } = useCardSDK();
+ const { setUser, fetchUserData, user: userData } = useCardSDK();
const onboardingId = useSelector(selectOnboardingId);
const selectedCountry = useSelector(selectSelectedCountry);
const { trackEvent, createEventBuilder } = useMetrics();
@@ -44,13 +58,17 @@ const PersonalDetails = () => {
const [lastName, setLastName] = useState('');
const [dateOfBirth, setDateOfBirth] = useState('');
const [dateError, setDateError] = useState('');
- const [nationality, setNationality] = useState('');
+ const [nationalityKey, setNationalityKey] = useState(''); // ISO 3166-1 alpha-2 country code
const [SSN, setSSN] = useState('');
const [isSSNError, setIsSSNError] = useState(false);
// Get registration settings data
const { data: registrationSettings } = useRegistrationSettings();
+ useEffect(() => {
+ fetchUserData();
+ }, [fetchUserData]);
+
// If user data is available, set the state values
useEffect(() => {
if (userData) {
@@ -78,13 +96,12 @@ const PersonalDetails = () => {
} else {
setDateOfBirth('');
}
- setNationality(userData.countryOfResidence || '');
+ setNationalityKey(userData.countryOfNationality || '');
setSSN(userData.ssn || '');
}
}, [userData]);
- // Create select options from registration settings data
- const selectOptions = useMemo(() => {
+ const regions: Region[] = useMemo(() => {
if (!registrationSettings?.countries) {
return [];
}
@@ -92,11 +109,17 @@ const PersonalDetails = () => {
.sort((a, b) => a.name.localeCompare(b.name))
.map((country) => ({
key: country.iso3166alpha2,
- value: country.iso3166alpha2,
- label: country.name,
+ name: country.name,
+ emoji: countryCodeToFlag(country.iso3166alpha2),
+ areaCode: country.callingCode,
}));
}, [registrationSettings]);
+ const nationalityName = useMemo(
+ () => regions.find((region) => region.key === nationalityKey)?.name,
+ [regions, nationalityKey],
+ );
+
const {
registerPersonalDetails,
isLoading: registerLoading,
@@ -105,13 +128,17 @@ const PersonalDetails = () => {
reset: resetRegisterPersonalDetails,
} = useRegisterPersonalDetails();
- const handleNationalitySelect = useCallback(
- (value: string) => {
- resetRegisterPersonalDetails();
- setNationality(value);
- },
- [resetRegisterPersonalDetails],
- );
+ const handleNationalitySelect = useCallback(() => {
+ resetRegisterPersonalDetails();
+ setOnValueChange((region) => {
+ setNationalityKey(region.key);
+ });
+ navigation.navigate(
+ ...createRegionSelectorModalNavigationDetails({
+ regions,
+ }),
+ );
+ }, [navigation, regions, resetRegisterPersonalDetails]);
const handleDateOfBirthChange = useCallback(
(timestamp: string) => {
@@ -165,20 +192,22 @@ const PersonalDetails = () => {
} else setDateError('');
}, [dateOfBirth]);
+ useEffect(() => () => clearOnValueChange(), []);
+
const handleContinue = async () => {
if (
!onboardingId ||
!firstName ||
!lastName ||
!dateOfBirth ||
- !nationality ||
- (!SSN && selectedCountry === 'US')
+ !nationalityKey ||
+ (!SSN && selectedCountry?.key === 'US')
) {
return;
}
// Validate SSN before submitting if it's a US user
- if (selectedCountry === 'US') {
+ if (selectedCountry?.key === 'US') {
const isSSNValid = /^\d{9}$/.test(SSN);
if (!isSSNValid) {
setIsSSNError(true);
@@ -191,7 +220,7 @@ const PersonalDetails = () => {
createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
.addProperties({
action: CardActions.PERSONAL_DETAILS_BUTTON,
- country_of_residence: selectedCountry,
+ country_of_residence: selectedCountry?.key,
})
.build(),
);
@@ -200,7 +229,7 @@ const PersonalDetails = () => {
firstName,
lastName,
dateOfBirth: formatDateOfBirth(dateOfBirth),
- countryOfNationality: nationality,
+ countryOfNationality: nationalityKey,
ssn: SSN,
});
@@ -238,7 +267,7 @@ const PersonalDetails = () => {
const isDisabled = useMemo(() => {
// Check the actual SSN value, not the debounced one
const isSSNValid =
- SSN && selectedCountry === 'US' ? /^\d{9}$/.test(SSN) : true;
+ SSN && selectedCountry?.key === 'US' ? /^\d{9}$/.test(SSN) : true;
return (
registerLoading ||
@@ -246,8 +275,8 @@ const PersonalDetails = () => {
!firstName ||
!lastName ||
!dateOfBirth ||
- !nationality ||
- (!SSN && selectedCountry === 'US') ||
+ !nationalityKey ||
+ (!SSN && selectedCountry?.key === 'US') ||
!isSSNValid ||
!!dateError ||
!onboardingId
@@ -258,7 +287,7 @@ const PersonalDetails = () => {
firstName,
lastName,
dateOfBirth,
- nationality,
+ nationalityKey,
SSN,
selectedCountry,
dateError,
@@ -321,20 +350,22 @@ const PersonalDetails = () => {
{strings('card.card_onboarding.personal_details.nationality_label')}
-
+ >
+
+
+ {nationalityName || nationalityKey}
+
+
+
+
{/* SSN */}
- {selectedCountry === 'US' && (
+ {selectedCountry?.key === 'US' && (
diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx
index 0dd002e4382..e2348d340bc 100644
--- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx
+++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx
@@ -147,11 +147,24 @@ jest.mock('@metamask/design-system-react-native', () => {
}: React.PropsWithChildren>) =>
React.createElement(RNText, props, children);
+ const Icon = ({ name, size, ...props }: { name: string; size: string }) =>
+ React.createElement(View, { testID: 'icon', ...props });
+
return {
Box,
Text,
+ Icon,
TextVariant: {
BodySm: 'BodySm',
+ BodyMd: 'BodyMd',
+ },
+ IconName: {
+ ArrowDown: 'arrow-down',
+ },
+ IconSize: {
+ Sm: 'sm',
+ Md: 'md',
+ Lg: 'lg',
},
};
});
@@ -271,52 +284,6 @@ jest.mock('../../../../../component-library/components/Buttons/Button', () => {
};
});
-// Mock SelectComponent
-jest.mock('../../../SelectComponent', () => {
- // eslint-disable-next-line @typescript-eslint/no-shadow
- const React = jest.requireActual('react');
- const { TouchableOpacity, Text } = jest.requireActual('react-native');
-
- return ({
- testID,
- onValueChange,
- options,
- selectedValue,
- defaultValue,
- disabled,
- }: {
- testID?: string;
- onValueChange?: (value: string) => void;
- options?: { key: string; value: string; label: string }[];
- selectedValue?: string;
- defaultValue?: string;
- disabled?: boolean;
- }) => {
- const handlePress = () => {
- if (!disabled && options && options.length > 0 && onValueChange) {
- onValueChange(options[0].value);
- }
- };
-
- // Find the label for the selected value
- const selectedLabel =
- options?.find((opt) => opt.value === selectedValue)?.label ||
- selectedValue ||
- defaultValue ||
- 'Select';
-
- return React.createElement(
- TouchableOpacity,
- {
- testID,
- onPress: handlePress,
- disabled,
- },
- React.createElement(Text, {}, selectedLabel),
- );
- };
-});
-
// Mock i18n
jest.mock('../../../../../../locales/i18n', () => ({
strings: jest.fn((key: string) => {
@@ -366,7 +333,12 @@ const createTestStore = (initialState = {}) =>
card: (
state = {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
contactVerificationId: 'contact-id',
user: {
@@ -530,7 +502,12 @@ describe('PhysicalAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
user: {
id: 'user-id',
@@ -593,16 +570,6 @@ describe('PhysicalAddress Component', () => {
expect(getByTestId('state-select')).toBeTruthy();
});
- it('renders country field', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- expect(getByTestId('country-select')).toBeTruthy();
- });
-
it('renders continue button', () => {
const { getByTestId } = render(
@@ -694,6 +661,20 @@ describe('PhysicalAddress Component', () => {
});
it('enables continue button when all required fields are filled', async () => {
+ // Mock useCardSDK with user data that includes usState
+ mockUseCardSDK.mockReturnValue({
+ sdk: null,
+ isLoading: false,
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ usState: 'CA',
+ },
+ fetchUserData: jest.fn(),
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ });
+
const { getByTestId } = render(
@@ -704,7 +685,6 @@ describe('PhysicalAddress Component', () => {
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
fireEvent.changeText(getByTestId('zip-code-input'), '12345');
- fireEvent.press(getByTestId('state-select'));
// Check the electronic consent checkbox
fireEvent.press(
getByTestId('physical-address-electronic-consent-checkbox'),
@@ -718,6 +698,19 @@ describe('PhysicalAddress Component', () => {
});
it('requires state for US users', () => {
+ // User has no usState set
+ mockUseCardSDK.mockReturnValue({
+ sdk: null,
+ isLoading: false,
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ },
+ fetchUserData: jest.fn(),
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ });
+
const { getByTestId } = render(
@@ -772,6 +765,20 @@ describe('PhysicalAddress Component', () => {
reset: jest.fn(),
});
+ // Mock useCardSDK with user data that includes usState
+ mockUseCardSDK.mockReturnValue({
+ sdk: null,
+ isLoading: false,
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ usState: 'CA',
+ },
+ fetchUserData: jest.fn(),
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ });
+
const { getByTestId } = render(
@@ -781,7 +788,6 @@ describe('PhysicalAddress Component', () => {
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
fireEvent.changeText(getByTestId('zip-code-input'), '12345');
- fireEvent.press(getByTestId('state-select'));
fireEvent.press(
getByTestId('physical-address-electronic-consent-checkbox'),
);
@@ -859,6 +865,20 @@ describe('PhysicalAddress Component', () => {
reset: jest.fn(),
});
+ // Mock useCardSDK with user data that includes usState
+ mockUseCardSDK.mockReturnValue({
+ sdk: null,
+ isLoading: false,
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ usState: 'CA',
+ },
+ fetchUserData: jest.fn(),
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ });
+
const { getByTestId } = render(
@@ -868,7 +888,6 @@ describe('PhysicalAddress Component', () => {
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
fireEvent.changeText(getByTestId('zip-code-input'), '12345');
- fireEvent.press(getByTestId('state-select'));
fireEvent.press(
getByTestId('physical-address-electronic-consent-checkbox'),
);
@@ -934,6 +953,20 @@ describe('PhysicalAddress Component', () => {
reset: jest.fn(),
});
+ // Mock useCardSDK with user data that includes usState
+ mockUseCardSDK.mockReturnValue({
+ sdk: null,
+ isLoading: false,
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ usState: 'CA',
+ },
+ fetchUserData: jest.fn(),
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ });
+
const { getByTestId } = render(
@@ -943,7 +976,6 @@ describe('PhysicalAddress Component', () => {
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
fireEvent.changeText(getByTestId('zip-code-input'), '12345');
- fireEvent.press(getByTestId('state-select'));
fireEvent.press(
getByTestId('physical-address-electronic-consent-checkbox'),
);
@@ -1014,6 +1046,20 @@ describe('PhysicalAddress Component', () => {
reset: jest.fn(),
});
+ // Mock useCardSDK with user data that includes usState
+ mockUseCardSDK.mockReturnValue({
+ sdk: null,
+ isLoading: false,
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ usState: 'CA',
+ },
+ fetchUserData: jest.fn(),
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ });
+
const { getByTestId } = render(
@@ -1023,7 +1069,6 @@ describe('PhysicalAddress Component', () => {
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
fireEvent.changeText(getByTestId('zip-code-input'), '12345');
- fireEvent.press(getByTestId('state-select'));
fireEvent.press(
getByTestId('physical-address-electronic-consent-checkbox'),
);
@@ -1163,7 +1208,12 @@ describe('PhysicalAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
},
},
@@ -1185,7 +1235,12 @@ describe('PhysicalAddress Component', () => {
selector({
card: {
onboarding: {
- selectedCountry: 'CA',
+ selectedCountry: {
+ key: 'CA',
+ name: 'Canada',
+ emoji: 'π¨π¦',
+ areaCode: '1',
+ },
onboardingId: 'test-id',
},
},
@@ -1200,16 +1255,6 @@ describe('PhysicalAddress Component', () => {
expect(queryByTestId('state-select')).toBeFalsy();
});
-
- it('shows country field for all users', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- expect(getByTestId('country-select')).toBeTruthy();
- });
});
describe('Edge Cases', () => {
@@ -1320,6 +1365,20 @@ describe('PhysicalAddress Component', () => {
});
it('disables continue button when checkbox is unchecked', () => {
+ // Mock useCardSDK with user data that includes usState
+ mockUseCardSDK.mockReturnValue({
+ sdk: null,
+ isLoading: false,
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ usState: 'CA',
+ },
+ fetchUserData: jest.fn(),
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ });
+
const { getByTestId } = render(
@@ -1330,13 +1389,26 @@ describe('PhysicalAddress Component', () => {
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
fireEvent.changeText(getByTestId('zip-code-input'), '12345');
- fireEvent.press(getByTestId('state-select'));
const button = getByTestId('physical-address-continue-button');
expect(button.props.disabled).toBe(true);
});
it('enables continue button when checkbox is checked and all fields filled', async () => {
+ // Mock useCardSDK with user data that includes usState
+ mockUseCardSDK.mockReturnValue({
+ sdk: null,
+ isLoading: false,
+ user: {
+ id: 'user-id',
+ email: 'test@example.com',
+ usState: 'CA',
+ },
+ fetchUserData: jest.fn(),
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ });
+
const { getByTestId } = render(
@@ -1347,7 +1419,6 @@ describe('PhysicalAddress Component', () => {
fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St');
fireEvent.changeText(getByTestId('city-input'), 'San Francisco');
fireEvent.changeText(getByTestId('zip-code-input'), '12345');
- fireEvent.press(getByTestId('state-select'));
// Button should be disabled without checkbox
const buttonBefore = getByTestId('physical-address-continue-button');
diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx
index b63cb1d85c6..2db42d20d63 100644
--- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx
+++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx
@@ -1,6 +1,13 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
-import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
+import {
+ Box,
+ Icon,
+ IconName,
+ IconSize,
+ Text,
+ TextVariant,
+} from '@metamask/design-system-react-native';
import Button, {
ButtonSize,
ButtonVariants,
@@ -22,12 +29,12 @@ import {
selectSelectedCountry,
setConsentSetId,
setIsAuthenticatedCard,
+ setSelectedCountry,
setUserCardLocation,
} from '../../../../../core/redux/slices/card';
import useRegisterUserConsent from '../../hooks/useRegisterUserConsent';
import { CardError } from '../../types';
import useRegistrationSettings from '../../hooks/useRegistrationSettings';
-import SelectComponent from '../../../SelectComponent';
import { storeCardBaanxToken } from '../../util/cardTokenVault';
import { mapCountryToLocation } from '../../util/mapCountryToLocation';
import { extractTokenExpiration } from '../../util/extractTokenExpiration';
@@ -37,9 +44,13 @@ import { CardActions, CardScreens } from '../../util/metrics';
import { Linking, TouchableOpacity } from 'react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import Checkbox from '../../../../../component-library/components/Checkbox';
-
-// No-op function for disabled SelectComponent
-const noop = () => undefined;
+import {
+ clearOnValueChange,
+ createRegionSelectorModalNavigationDetails,
+ Region,
+ setOnValueChange,
+} from './RegionSelectorModal';
+import { countryCodeToFlag } from '../../util/countryCodeToFlag';
export const AddressFields = ({
addressLine1,
@@ -64,10 +75,11 @@ export const AddressFields = ({
zipCode: string;
handleZipCodeChange: (text: string) => void;
}) => {
+ const navigation = useNavigation();
const { data: registrationSettings } = useRegistrationSettings();
const selectedCountry = useSelector(selectSelectedCountry);
- const selectOptions = useMemo(() => {
+ const regions: Region[] = useMemo(() => {
if (!registrationSettings?.usStates) {
return [];
}
@@ -75,21 +87,23 @@ export const AddressFields = ({
.sort((a, b) => a.name.localeCompare(b.name))
.map((usState) => ({
key: usState.postalAbbreviation,
- value: usState.postalAbbreviation,
- label: usState.name,
+ name: usState.name,
+ emoji: countryCodeToFlag('US'),
}));
}, [registrationSettings]);
- const countryOptions = useMemo(() => {
- if (!registrationSettings?.countries) {
- return [];
- }
- return registrationSettings.countries.map((country) => ({
- key: country.iso3166alpha2,
- value: country.iso3166alpha2,
- label: country.name,
- }));
- }, [registrationSettings]);
+ useEffect(() => () => clearOnValueChange(), []);
+
+ const handleStateSelect = useCallback(() => {
+ setOnValueChange((region) => {
+ handleStateChange(region.key);
+ });
+ navigation.navigate(
+ ...createRegionSelectorModalNavigationDetails({
+ regions,
+ }),
+ );
+ }, [handleStateChange, navigation, regions]);
return (
<>
@@ -155,21 +169,18 @@ export const AddressFields = ({
/>
{/* State */}
- {selectedCountry === 'US' && (
+ {selectedCountry?.key === 'US' && (
-
+
+
+ {state}
+
+
+
)}
@@ -192,21 +203,20 @@ export const AddressFields = ({
testID="zip-code-input"
/>
- {/* Country */}
+ {/* Country (read-only) */}
-
+
+
+ {selectedCountry?.name}
+
+
>
@@ -228,9 +238,39 @@ const PhysicalAddress = () => {
const [state, setState] = useState('');
const [zipCode, setZipCode] = useState('');
const [electronicConsent, setElectronicConsent] = useState(false);
-
const { data: registrationSettings } = useRegistrationSettings();
+ const regions: Region[] = useMemo(() => {
+ if (!registrationSettings?.countries) {
+ return [];
+ }
+ return [...registrationSettings.countries]
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((country) => ({
+ key: country.iso3166alpha2,
+ name: country.name,
+ emoji: countryCodeToFlag(country.iso3166alpha2),
+ areaCode: country.callingCode,
+ }));
+ }, [registrationSettings]);
+
+ // If user data is available, set the state values
+ useEffect(() => {
+ if (user) {
+ setAddressLine1(user.addressLine1 || '');
+ setAddressLine2(user.addressLine2 || '');
+ setCity(user.city || '');
+ setState(user.usState || '');
+ setZipCode(user.zip || '');
+ const country = regions.find(
+ (region) => region.key === user.countryOfResidence,
+ );
+ if (country) {
+ dispatch(setSelectedCountry(country));
+ }
+ }
+ }, [dispatch, regions, user]);
+
const eSignConsentDisclosureUSUrl = useMemo(
() => registrationSettings?.links?.us?.eSignConsentDisclosure || '',
[registrationSettings?.links?.us?.eSignConsentDisclosure],
@@ -316,9 +356,9 @@ const PhysicalAddress = () => {
!user?.id ||
!addressLine1 ||
!city ||
- (!state && selectedCountry === 'US') ||
+ (!state && selectedCountry?.key === 'US') ||
!zipCode ||
- !electronicConsent,
+ (!electronicConsent && selectedCountry?.key === 'US'),
[
registerLoading,
registerIsError,
@@ -341,9 +381,9 @@ const PhysicalAddress = () => {
!user?.id ||
!addressLine1 ||
!city ||
- (selectedCountry === 'US' && !state) ||
+ (!state && selectedCountry?.key === 'US') ||
!zipCode ||
- !electronicConsent
+ (!electronicConsent && selectedCountry?.key === 'US')
) {
return;
}
@@ -403,7 +443,7 @@ const PhysicalAddress = () => {
// If registration is complete (accessToken received), link consent to user
if (accessToken && updatedUser?.id) {
// Store the access token for immediate authentication
- const location = mapCountryToLocation(selectedCountry);
+ const location = mapCountryToLocation(selectedCountry?.key || null);
const accessTokenExpiresIn = extractTokenExpiration(accessToken);
const storeResult = await storeCardBaanxToken({
@@ -470,35 +510,38 @@ const PhysicalAddress = () => {
zipCode={zipCode}
handleZipCodeChange={handleZipCodeChange}
/>
-
-
- {strings(
- 'card.card_onboarding.physical_address.electronic_consent_1',
- )}
{strings(
- 'card.card_onboarding.physical_address.electronic_consent_2',
+ 'card.card_onboarding.physical_address.electronic_consent_1',
)}
+
+ {strings(
+ 'card.card_onboarding.physical_address.electronic_consent_2',
+ )}
+
-
-
- }
- style={tw.style('h-auto flex flex-row items-start')}
- testID="physical-address-electronic-consent-checkbox"
- />
+
+ }
+ style={tw.style('h-auto flex flex-row items-start')}
+ testID="physical-address-electronic-consent-checkbox"
+ />
+ )}
>
);
@@ -528,6 +571,7 @@ const PhysicalAddress = () => {
onPress={handleContinue}
width={ButtonWidthTypes.Full}
isDisabled={isDisabled}
+ loading={registerLoading}
testID="physical-address-continue-button"
/>
diff --git a/app/components/UI/Card/components/Onboarding/RegionSelectorModal.test.tsx b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.test.tsx
new file mode 100644
index 00000000000..6b1954c58c2
--- /dev/null
+++ b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.test.tsx
@@ -0,0 +1,735 @@
+import React from 'react';
+import { render, fireEvent, waitFor, act } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import RegionSelectorModal, {
+ setOnValueChange,
+ clearOnValueChange,
+ Region,
+} from './RegionSelectorModal';
+import { OnboardingState } from '../../../../../core/redux/slices/card';
+
+// Mock navigation
+const mockNavigate = jest.fn();
+const mockGoBack = jest.fn();
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ goBack: mockGoBack,
+ }),
+}));
+
+// Mock useParams
+const mockUseParams = jest.fn();
+jest.mock('../../../../../util/navigation/navUtils', () => ({
+ useParams: () => mockUseParams(),
+ createNavigationDetails: jest.fn(
+ (stackId, screenName) => (params?: unknown) => [
+ stackId,
+ { screen: screenName, params },
+ ],
+ ),
+}));
+
+// Mock i18n
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn(
+ (key: string, params?: { [key: string]: string | number }) => {
+ const mockStrings: { [key: string]: string } = {
+ 'card.card_onboarding.region_selector.title': 'Select Region',
+ 'card.card_onboarding.errors.no_region_results':
+ 'No results found for "{searchString}"',
+ };
+
+ let result = mockStrings[key] || key;
+ if (params) {
+ Object.keys(params).forEach((param) => {
+ result = result.replace(`{${param}}`, String(params[param]));
+ });
+ }
+ return result;
+ },
+ ),
+}));
+
+// Mock BottomSheet component
+const mockOnCloseBottomSheet = jest.fn();
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheet',
+ () => {
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+
+ return React.forwardRef(
+ (
+ {
+ children,
+ onClose,
+ testID,
+ }: {
+ children: React.ReactNode;
+ onClose?: () => void;
+ shouldNavigateBack?: boolean;
+ keyboardAvoidingViewEnabled?: boolean;
+ testID?: string;
+ },
+ ref: React.Ref<{ onCloseBottomSheet: () => void }>,
+ ) => {
+ React.useImperativeHandle(ref, () => ({
+ onCloseBottomSheet: () => {
+ mockOnCloseBottomSheet();
+ onClose?.();
+ },
+ }));
+ return React.createElement(
+ View,
+ { testID: testID || 'bottom-sheet' },
+ children,
+ );
+ },
+ );
+ },
+);
+
+// Mock BottomSheetHeader
+jest.mock(
+ '../../../../../component-library/components/BottomSheets/BottomSheetHeader',
+ () => {
+ const React = jest.requireActual('react');
+ const { View, TouchableOpacity } = jest.requireActual('react-native');
+
+ return ({
+ children,
+ onClose,
+ }: {
+ children: React.ReactNode;
+ onClose?: () => void;
+ }) =>
+ React.createElement(
+ View,
+ { testID: 'bottom-sheet-header' },
+ children,
+ onClose &&
+ React.createElement(
+ TouchableOpacity,
+ { testID: 'bottom-sheet-close-button', onPress: onClose },
+ 'Close',
+ ),
+ );
+ },
+);
+
+// Mock ListItemSelect
+jest.mock(
+ '../../../../../component-library/components/List/ListItemSelect',
+ () => {
+ const React = jest.requireActual('react');
+ const { TouchableOpacity } = jest.requireActual('react-native');
+
+ return ({
+ children,
+ onPress,
+ isSelected,
+ testID,
+ }: {
+ children: React.ReactNode;
+ onPress: () => void;
+ isSelected?: boolean;
+ accessibilityRole?: string;
+ accessible?: boolean;
+ testID?: string;
+ }) =>
+ React.createElement(
+ TouchableOpacity,
+ {
+ testID: testID || 'list-item-select',
+ onPress,
+ accessibilityState: { selected: isSelected },
+ },
+ children,
+ );
+ },
+);
+
+// Mock ListItemColumn
+jest.mock(
+ '../../../../../component-library/components/List/ListItemColumn',
+ () => {
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+
+ const MockListItemColumn = ({
+ children,
+ }: {
+ children: React.ReactNode;
+ widthType?: string;
+ }) => React.createElement(View, { testID: 'list-item-column' }, children);
+
+ return {
+ __esModule: true,
+ default: MockListItemColumn,
+ WidthType: {
+ Fill: 'Fill',
+ },
+ };
+ },
+);
+
+// Mock TextFieldSearch
+jest.mock(
+ '../../../../../component-library/components/Form/TextFieldSearch',
+ () => {
+ const React = jest.requireActual('react');
+ const { TextInput, TouchableOpacity, View } =
+ jest.requireActual('react-native');
+
+ return ({
+ value,
+ onChangeText,
+ onPressClearButton,
+ onFocus,
+ showClearButton,
+ testID,
+ }: {
+ value: string;
+ onChangeText: (text: string) => void;
+ onPressClearButton?: () => void;
+ onFocus?: () => void;
+ showClearButton?: boolean;
+ testID?: string;
+ }) =>
+ React.createElement(
+ View,
+ { testID: 'search-field-container' },
+ React.createElement(TextInput, {
+ testID: testID || 'search-input',
+ value,
+ onChangeText,
+ onFocus,
+ }),
+ showClearButton &&
+ React.createElement(
+ TouchableOpacity,
+ {
+ testID: 'search-clear-button',
+ onPress: onPressClearButton,
+ },
+ 'Clear',
+ ),
+ );
+ },
+);
+
+// Mock design system components
+jest.mock('@metamask/design-system-react-native', () => {
+ const React = jest.requireActual('react');
+ const { View, Text } = jest.requireActual('react-native');
+
+ return {
+ Box: ({
+ children,
+ testID,
+ twClassName,
+ ...props
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ twClassName?: string;
+ flexDirection?: string;
+ alignItems?: string;
+ [key: string]: unknown;
+ }) =>
+ React.createElement(
+ View,
+ { testID: testID || 'box', 'data-tw-class': twClassName, ...props },
+ children,
+ ),
+ Text: ({
+ children,
+ testID,
+ variant,
+ ...props
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ variant?: string;
+ [key: string]: unknown;
+ }) =>
+ React.createElement(
+ Text,
+ { testID: testID || 'text', 'data-variant': variant, ...props },
+ children,
+ ),
+ TextVariant: {
+ HeadingMd: 'HeadingMd',
+ BodyLg: 'BodyLg',
+ BodyMd: 'BodyMd',
+ },
+ BoxFlexDirection: {
+ Row: 'row',
+ Column: 'column',
+ },
+ BoxAlignItems: {
+ Center: 'center',
+ },
+ };
+});
+
+// Mock FlatList from react-native-gesture-handler
+jest.mock('react-native-gesture-handler', () => {
+ const RN = jest.requireActual('react-native');
+ return {
+ ...jest.requireActual('react-native-gesture-handler'),
+ FlatList: RN.FlatList,
+ };
+});
+
+// Create test store with correct nested structure
+const createTestStore = (initialState: { onboarding?: OnboardingState } = {}) =>
+ configureStore({
+ reducer: {
+ card: (
+ state = {
+ onboarding: {
+ selectedCountry: null,
+ onboardingId: null,
+ contactVerificationId: null,
+ ...initialState.onboarding,
+ },
+ ...initialState,
+ },
+ ) => state,
+ },
+ });
+
+// Helper to create mock regions
+const createMockRegion = (overrides: Partial = {}): Region => ({
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ ...overrides,
+});
+
+const createMockRegions = (): Region[] => [
+ createMockRegion({
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ }),
+ createMockRegion({ key: 'CA', name: 'Canada', emoji: 'π¨π¦', areaCode: '1' }),
+ createMockRegion({
+ key: 'GB',
+ name: 'United Kingdom',
+ emoji: 'π¬π§',
+ areaCode: '44',
+ }),
+ createMockRegion({ key: 'DE', name: 'Germany', emoji: 'π©πͺ', areaCode: '49' }),
+ createMockRegion({ key: 'FR', name: 'France', emoji: 'π«π·', areaCode: '33' }),
+];
+
+describe('RegionSelectorModal', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ store = createTestStore();
+
+ mockUseParams.mockReturnValue({
+ regions: createMockRegions(),
+ renderAreaCode: false,
+ });
+ });
+
+ afterEach(() => {
+ clearOnValueChange();
+ jest.resetAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('renders bottom sheet with header title', () => {
+ const { getByText, getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('region-selector-modal')).toBeTruthy();
+ expect(getByText('Select Region')).toBeTruthy();
+ });
+
+ it('renders search input field', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('region-selector-search-input')).toBeTruthy();
+ });
+
+ it('renders region list with all regions', () => {
+ const { getAllByTestId } = render(
+
+
+ ,
+ );
+
+ const regionItems = getAllByTestId('region-selector-item');
+ expect(regionItems.length).toBe(5);
+ });
+
+ it('displays region emoji and name', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+
+ expect(getByText('United States')).toBeTruthy();
+ expect(getByText('πΊπΈ')).toBeTruthy();
+ });
+
+ it('displays area code when renderAreaCode is true', () => {
+ mockUseParams.mockReturnValue({
+ regions: createMockRegions(),
+ renderAreaCode: true,
+ });
+
+ const { getAllByText, getByText } = render(
+
+
+ ,
+ );
+
+ // US and Canada both have +1 area code, so we expect multiple matches
+ expect(getAllByText('(+1)').length).toBeGreaterThan(0);
+ expect(getByText('(+44)')).toBeTruthy();
+ });
+
+ it('does not display area code when renderAreaCode is false', () => {
+ mockUseParams.mockReturnValue({
+ regions: createMockRegions(),
+ renderAreaCode: false,
+ });
+
+ const { queryByText } = render(
+
+
+ ,
+ );
+
+ expect(queryByText('(+1)')).toBeNull();
+ expect(queryByText('(+44)')).toBeNull();
+ });
+ });
+
+ describe('Search Functionality', () => {
+ it('filters regions based on search text', async () => {
+ const { getByTestId, queryByText, getByText } = render(
+
+
+ ,
+ );
+
+ const searchInput = getByTestId('region-selector-search-input');
+
+ await act(async () => {
+ fireEvent.changeText(searchInput, 'United');
+ });
+
+ await waitFor(() => {
+ expect(getByText('United States')).toBeTruthy();
+ expect(getByText('United Kingdom')).toBeTruthy();
+ expect(queryByText('Germany')).toBeNull();
+ });
+ });
+
+ it('shows empty list message when no results found', async () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ const searchInput = getByTestId('region-selector-search-input');
+
+ await act(async () => {
+ fireEvent.changeText(searchInput, 'XYZ');
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('region-selector-empty-list')).toBeTruthy();
+ });
+ });
+
+ it('clears search text when clear button is pressed', async () => {
+ const { getByTestId, getAllByTestId } = render(
+
+
+ ,
+ );
+
+ const searchInput = getByTestId('region-selector-search-input');
+
+ await act(async () => {
+ fireEvent.changeText(searchInput, 'Germany');
+ });
+
+ const clearButton = getByTestId('search-clear-button');
+
+ await act(async () => {
+ fireEvent.press(clearButton);
+ });
+
+ await waitFor(() => {
+ const regionItems = getAllByTestId('region-selector-item');
+ expect(regionItems.length).toBe(5);
+ });
+ });
+
+ it('shows clear button only when search text is present', async () => {
+ const { getByTestId, queryByTestId } = render(
+
+
+ ,
+ );
+
+ expect(queryByTestId('search-clear-button')).toBeNull();
+
+ const searchInput = getByTestId('region-selector-search-input');
+
+ await act(async () => {
+ fireEvent.changeText(searchInput, 'Test');
+ });
+
+ expect(getByTestId('search-clear-button')).toBeTruthy();
+ });
+ });
+
+ describe('Region Selection', () => {
+ it('calls onValueChange callback when region is pressed', async () => {
+ const mockCallback = jest.fn();
+ setOnValueChange(mockCallback);
+
+ const { getByText } = render(
+
+
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('Germany'));
+ });
+
+ expect(mockCallback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: 'DE',
+ name: 'Germany',
+ emoji: 'π©πͺ',
+ }),
+ );
+ });
+
+ it('closes bottom sheet when region is selected', async () => {
+ const mockCallback = jest.fn();
+ setOnValueChange(mockCallback);
+
+ const { getByText } = render(
+
+
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('Canada'));
+ });
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ });
+
+ it('handles selection when callback is not set', async () => {
+ clearOnValueChange();
+
+ const { getByText } = render(
+
+
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('France'));
+ });
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ });
+ });
+
+ describe('Header Interactions', () => {
+ it('closes bottom sheet when header close button is pressed', async () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ const closeButton = getByTestId('bottom-sheet-close-button');
+
+ await act(async () => {
+ fireEvent.press(closeButton);
+ });
+
+ expect(mockOnCloseBottomSheet).toHaveBeenCalled();
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('handles empty regions array', () => {
+ mockUseParams.mockReturnValue({
+ regions: [],
+ renderAreaCode: false,
+ });
+
+ const { queryByTestId, getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(queryByTestId('region-selector-item')).toBeNull();
+ expect(getByTestId('region-selector-modal')).toBeTruthy();
+ });
+
+ it('handles null regions parameter', () => {
+ mockUseParams.mockReturnValue({
+ regions: null,
+ renderAreaCode: false,
+ });
+
+ const { queryByTestId } = render(
+
+
+ ,
+ );
+
+ expect(queryByTestId('region-selector-item')).toBeNull();
+ });
+
+ it('handles undefined regions parameter', () => {
+ mockUseParams.mockReturnValue({
+ regions: undefined,
+ renderAreaCode: false,
+ });
+
+ const { queryByTestId } = render(
+
+
+ ,
+ );
+
+ expect(queryByTestId('region-selector-item')).toBeNull();
+ });
+
+ it('handles region with missing emoji', () => {
+ mockUseParams.mockReturnValue({
+ regions: [
+ createMockRegion({
+ key: 'XX',
+ name: 'Test Country',
+ emoji: undefined,
+ }),
+ ],
+ renderAreaCode: false,
+ });
+
+ const { getByText, queryByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByText('Test Country')).toBeTruthy();
+ expect(queryByTestId('region-selector-item')).toBeTruthy();
+ });
+
+ it('does not render area code when areaCode is undefined', () => {
+ mockUseParams.mockReturnValue({
+ regions: [
+ createMockRegion({
+ key: 'XX',
+ name: 'Test Country',
+ areaCode: undefined,
+ }),
+ ],
+ renderAreaCode: true,
+ });
+
+ const { getByText, queryByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByText('Test Country')).toBeTruthy();
+ expect(queryByTestId('region-selector-item-area-code')).toBeNull();
+ });
+ });
+
+ describe('Callback Registry', () => {
+ it('setOnValueChange sets callback correctly', () => {
+ const mockCallback = jest.fn();
+ setOnValueChange(mockCallback);
+
+ const { getByText } = render(
+
+
+ ,
+ );
+
+ fireEvent.press(getByText('United States'));
+
+ expect(mockCallback).toHaveBeenCalled();
+ });
+
+ it('clearOnValueChange removes callback', () => {
+ const mockCallback = jest.fn();
+ setOnValueChange(mockCallback);
+ clearOnValueChange();
+
+ const { getByText } = render(
+
+
+ ,
+ );
+
+ fireEvent.press(getByText('United States'));
+
+ expect(mockCallback).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Sorting', () => {
+ it('sorts regions alphabetically when no search is performed', () => {
+ mockUseParams.mockReturnValue({
+ regions: [
+ createMockRegion({ key: 'ZW', name: 'Zimbabwe' }),
+ createMockRegion({ key: 'AL', name: 'Albania' }),
+ createMockRegion({ key: 'MX', name: 'Mexico' }),
+ ],
+ renderAreaCode: false,
+ });
+
+ const { getAllByTestId } = render(
+
+
+ ,
+ );
+
+ const regionItems = getAllByTestId('region-selector-item-name');
+ expect(regionItems[0]).toHaveTextContent('Albania');
+ expect(regionItems[1]).toHaveTextContent('Mexico');
+ expect(regionItems[2]).toHaveTextContent('Zimbabwe');
+ });
+ });
+});
diff --git a/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx
new file mode 100644
index 00000000000..cb02d2744e3
--- /dev/null
+++ b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx
@@ -0,0 +1,247 @@
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useWindowDimensions } from 'react-native';
+import { FlatList } from 'react-native-gesture-handler';
+import Fuse from 'fuse.js';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader';
+import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect';
+import ListItemColumn, {
+ WidthType,
+} from '../../../../../component-library/components/List/ListItemColumn';
+import TextFieldSearch from '../../../../../component-library/components/Form/TextFieldSearch';
+import {
+ createNavigationDetails,
+ useParams,
+} from '../../../../../util/navigation/navUtils';
+import Routes from '../../../../../constants/navigation/Routes';
+import { strings } from '../../../../../../locales/i18n';
+import { useSelector } from 'react-redux';
+import { selectSelectedCountry } from '../../../../../core/redux/slices/card';
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ Text,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+
+const MAX_REGION_RESULTS = 20;
+
+export interface Region {
+ key: string; // country code
+ name: string;
+ emoji?: string;
+ areaCode?: string;
+}
+
+// Simple callback registry for onValueChange
+let onValueChangeCallback: ((region: Region) => void) | null = null;
+
+export const setOnValueChange = (callback: (region: Region) => void) => {
+ onValueChangeCallback = callback;
+};
+
+export const clearOnValueChange = () => {
+ onValueChangeCallback = null;
+};
+
+interface RegionSelectorModalParams {
+ regions: Region[];
+ renderAreaCode?: boolean;
+}
+
+export const createRegionSelectorModalNavigationDetails =
+ createNavigationDetails(
+ Routes.CARD.MODALS.ID,
+ Routes.CARD.MODALS.REGION_SELECTION,
+ );
+
+function RegionSelectorModal() {
+ const sheetRef = useRef(null);
+ const listRef = useRef>(null);
+ const { regions, renderAreaCode } = useParams();
+ const [searchString, setSearchString] = useState('');
+ const selectedCountry = useSelector(selectSelectedCountry);
+ const [currentData, setCurrentData] = useState(regions || []);
+ const { height: screenHeight } = useWindowDimensions();
+
+ // Sync currentData when regions param changes
+ useEffect(() => {
+ setCurrentData(regions || []);
+ }, [regions]);
+
+ const listStyle = useMemo(
+ () => ({ height: screenHeight * 0.65 }),
+ [screenHeight],
+ );
+
+ const fuseData = useMemo(
+ () =>
+ new Fuse(currentData, {
+ shouldSort: true,
+ threshold: 0.2,
+ location: 0,
+ distance: 100,
+ maxPatternLength: 32,
+ minMatchCharLength: 1,
+ keys: ['name'],
+ }),
+ [currentData],
+ );
+
+ const dataSearchResults = useMemo(() => {
+ if (searchString.length > 0) {
+ const results = fuseData
+ .search(searchString)
+ ?.slice(0, MAX_REGION_RESULTS);
+
+ const mappedResults: Region[] =
+ results
+ ?.map((result) =>
+ typeof result === 'object' && result !== null && 'item' in result
+ ? result.item
+ : result,
+ )
+ .filter((item): item is Region => Boolean(item)) || [];
+
+ return mappedResults;
+ }
+
+ if (!currentData?.length) return [];
+
+ return [...currentData].sort((a, b) => a.name.localeCompare(b.name));
+ }, [searchString, fuseData, currentData]);
+
+ const scrollToTop = useCallback(() => {
+ if (listRef?.current) {
+ listRef.current.scrollToOffset({
+ animated: false,
+ offset: 0,
+ });
+ }
+ }, []);
+
+ const handleOnRegionPressCallback = useCallback((region: Region) => {
+ onValueChangeCallback?.(region);
+ sheetRef.current?.onCloseBottomSheet();
+ }, []);
+
+ const renderRegionItem = useCallback(
+ ({ item: region }: { item: Region }) => {
+ if (!region) return null;
+
+ return (
+ handleOnRegionPressCallback(region)}
+ accessibilityRole="button"
+ accessible
+ testID="region-selector-item"
+ >
+
+
+
+ {region.emoji}
+
+
+ {region.name}
+
+ {renderAreaCode && region.areaCode && (
+
+ (+{region.areaCode})
+
+ )}
+
+
+
+ );
+ },
+ [selectedCountry, renderAreaCode, handleOnRegionPressCallback],
+ );
+
+ const renderEmptyList = useCallback(
+ () => (
+
+
+ {strings('card.card_onboarding.errors.no_region_results', {
+ searchString,
+ })}
+
+
+ ),
+ [searchString],
+ );
+
+ const handleSearchTextChange = useCallback(
+ (text: string) => {
+ setSearchString(text);
+ scrollToTop();
+ },
+ [scrollToTop],
+ );
+
+ const clearSearchText = useCallback(() => {
+ setSearchString('');
+ scrollToTop();
+ }, [scrollToTop]);
+
+ const handleClose = useCallback(() => {
+ sheetRef.current?.onCloseBottomSheet();
+ }, []);
+
+ const onModalHide = useCallback(() => {
+ setCurrentData(regions || []);
+ setSearchString('');
+ }, [regions]);
+
+ return (
+
+
+
+ {strings('card.card_onboarding.region_selector.title')}
+
+
+
+ 0}
+ onPressClearButton={clearSearchText}
+ onFocus={scrollToTop}
+ onChangeText={handleSearchTextChange}
+ testID="region-selector-search-input"
+ />
+
+ `${item?.key}-${item?.areaCode}`}
+ ListEmptyComponent={renderEmptyList}
+ keyboardDismissMode="none"
+ keyboardShouldPersistTaps="always"
+ />
+
+ );
+}
+
+export default RegionSelectorModal;
diff --git a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx
index c3d92897c72..4a9129c7cbc 100644
--- a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx
+++ b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx
@@ -25,42 +25,6 @@ jest.mock('../../hooks/usePhoneVerificationSend');
jest.mock('../../hooks/useRegistrationSettings');
jest.mock('../../../../hooks/useDebouncedValue');
-// Mock SelectComponent with proper interaction simulation
-jest.mock('../../../SelectComponent', () => {
- const React = jest.requireActual('react');
- const { TouchableOpacity, Text } = jest.requireActual('react-native');
-
- return (props: {
- testID?: string;
- onValueChange?: (value: string) => void;
- selectedValue?: string;
- defaultValue?: string;
- options?: { key: string; value: string; label: string }[];
- [key: string]: unknown;
- }) => {
- const handlePress = () => {
- // Simulate selecting the first available option
- if (props.options && props.options.length > 0 && props.onValueChange) {
- props.onValueChange(props.options[0].value);
- }
- };
-
- return React.createElement(
- TouchableOpacity,
- {
- testID: props.testID,
- onPress: handlePress,
- ...props,
- },
- React.createElement(
- Text,
- {},
- props.selectedValue || props.defaultValue || 'Select...',
- ),
- );
- };
-});
-
// Mock OnboardingStep
jest.mock('./OnboardingStep', () => {
const React = jest.requireActual('react');
@@ -95,14 +59,19 @@ jest.mock('./OnboardingStep', () => {
);
});
-// Create test store
+// Create test store with country object format
const createTestStore = (initialState = {}) =>
configureStore({
reducer: {
card: (
state = {
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
contactVerificationId: 'test-verification-id',
},
...initialState,
@@ -143,9 +112,24 @@ describe('SetPhoneNumber Component', () => {
(useRegistrationSettings as jest.Mock).mockReturnValue({
data: {
countries: [
- { iso3166alpha2: 'US', name: 'United States', callingCode: '1' },
- { iso3166alpha2: 'CA', name: 'Canada', callingCode: '1' },
- { iso3166alpha2: 'GB', name: 'United Kingdom', callingCode: '44' },
+ {
+ iso3166alpha2: 'US',
+ name: 'United States',
+ callingCode: '1',
+ canSignUp: true,
+ },
+ {
+ iso3166alpha2: 'CA',
+ name: 'Canada',
+ callingCode: '1',
+ canSignUp: true,
+ },
+ {
+ iso3166alpha2: 'GB',
+ name: 'United Kingdom',
+ callingCode: '44',
+ canSignUp: true,
+ },
],
},
});
@@ -287,7 +271,7 @@ describe('SetPhoneNumber Component', () => {
});
describe('Country Area Code Selection', () => {
- it('allows country area code selection', () => {
+ it('renders country area code selector', () => {
const { getByTestId } = render(
@@ -297,26 +281,10 @@ describe('SetPhoneNumber Component', () => {
const countrySelect = getByTestId(
'set-phone-number-country-area-code-select',
);
- fireEvent.press(countrySelect);
-
expect(countrySelect).toBeTruthy();
});
- it('displays initial area code based on selected country', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- const countrySelect = getByTestId(
- 'set-phone-number-country-area-code-select',
- );
- // Should show country code - area code (initial selected country)
- expect(countrySelect.props.selectedValue).toBe('US-1');
- });
-
- it('updates area code when different country is selected', () => {
+ it('navigates to region selector modal on press', () => {
const { getByTestId } = render(
@@ -326,11 +294,9 @@ describe('SetPhoneNumber Component', () => {
const countrySelect = getByTestId(
'set-phone-number-country-area-code-select',
);
-
- // Mock selecting UK (+44)
fireEvent.press(countrySelect);
- expect(countrySelect).toBeTruthy();
+ expect(mockNavigate).toHaveBeenCalled();
});
});
@@ -588,7 +554,12 @@ describe('SetPhoneNumber Component', () => {
it('handles missing contact verification ID', () => {
const storeWithoutVerificationId = createTestStore({
onboarding: {
- selectedCountry: 'US',
+ selectedCountry: {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+ areaCode: '1',
+ },
contactVerificationId: null,
},
});
@@ -614,30 +585,37 @@ describe('SetPhoneNumber Component', () => {
,
);
+ // Should still render the area code selector
const countrySelect = getByTestId(
'set-phone-number-country-area-code-select',
);
- expect(countrySelect.props.options).toEqual([]);
+ expect(countrySelect).toBeTruthy();
});
- it('handles missing selected country in registration settings', () => {
- const storeWithUnknownCountry = createTestStore({
+ it('handles missing selected country area code', () => {
+ const storeWithNoAreaCode = createTestStore({
onboarding: {
- selectedCountry: 'XX', // Unknown country code
+ selectedCountry: {
+ key: 'XX',
+ name: 'Unknown Country',
+ emoji: 'π³οΈ',
+ // areaCode is undefined
+ },
contactVerificationId: 'test-verification-id',
},
});
const { getByTestId } = render(
-
+
,
);
+ // Should still render the component without errors
const countrySelect = getByTestId(
'set-phone-number-country-area-code-select',
);
- expect(countrySelect.props.selectedValue).toBe('XX-1');
+ expect(countrySelect).toBeTruthy();
});
});
});
diff --git a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx
index 3c41b815d11..4a54405b555 100644
--- a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx
+++ b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
import Button, {
@@ -13,7 +13,6 @@ import Label from '../../../../../component-library/components/Form/Label';
import Routes from '../../../../../constants/navigation/Routes';
import { strings } from '../../../../../../locales/i18n';
import OnboardingStep from './OnboardingStep';
-import SelectComponent from '../../../SelectComponent';
import { useDebouncedValue } from '../../../../hooks/useDebouncedValue';
import usePhoneVerificationSend from '../../hooks/usePhoneVerificationSend';
import useRegistrationSettings from '../../hooks/useRegistrationSettings';
@@ -27,6 +26,13 @@ import { CardError } from '../../types';
import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
import { CardActions, CardScreens } from '../../util/metrics';
import { countryCodeToFlag } from '../../util/countryCodeToFlag';
+import {
+ clearOnValueChange,
+ createRegionSelectorModalNavigationDetails,
+ Region,
+ setOnValueChange,
+} from './RegionSelectorModal';
+import { TouchableOpacity } from 'react-native';
const SetPhoneNumber = () => {
const navigation = useNavigation();
@@ -36,7 +42,7 @@ const SetPhoneNumber = () => {
const { trackEvent, createEventBuilder } = useMetrics();
const { data: registrationSettings } = useRegistrationSettings();
- const selectOptions = useMemo(() => {
+ const regions: Region[] = useMemo(() => {
if (!registrationSettings?.countries) {
return [];
}
@@ -45,27 +51,18 @@ const SetPhoneNumber = () => {
.filter((country) => country.canSignUp)
.map((country) => ({
key: country.iso3166alpha2,
- value: `${country.iso3166alpha2}-${country.callingCode}`,
- label: `${countryCodeToFlag(country.iso3166alpha2)} +${country.callingCode}`,
+ name: country.name,
+ emoji: countryCodeToFlag(country.iso3166alpha2),
+ areaCode: country.callingCode,
}));
}, [registrationSettings]);
- const initialSelectedCountryAreaCode = useMemo(() => {
- if (!registrationSettings?.countries) {
- return '1';
- }
- const selectedCountryWithCallingCode = registrationSettings.countries.find(
- (country) => country.iso3166alpha2 === selectedCountry,
- );
- return selectedCountryWithCallingCode?.callingCode || '1';
- }, [selectedCountry, registrationSettings]);
-
const [phoneNumber, setPhoneNumber] = useState('');
const [isPhoneNumberError, setIsPhoneNumberError] = useState(false);
const [selectedCountryAreaCode, setSelectedCountryAreaCode] =
- useState(initialSelectedCountryAreaCode);
- const [selectedCountryIsoCode, setSelectedCountryIsoCode] = useState(
- selectedCountry || 'US',
+ useState(selectedCountry?.areaCode || '');
+ const [selectedCountryEmoji, setSelectedCountryEmoji] = useState(
+ selectedCountry?.emoji || '',
);
const debouncedPhoneNumber = useDebouncedValue(phoneNumber, 1000);
@@ -131,12 +128,21 @@ const SetPhoneNumber = () => {
}
};
- const handleCountrySelect = (value: string) => {
+ const handleCountrySelect = useCallback(() => {
resetPhoneVerificationSend();
- const [key, areaCode] = value.split('-');
- setSelectedCountryAreaCode(areaCode);
- setSelectedCountryIsoCode(key);
- };
+
+ setOnValueChange((region) => {
+ setSelectedCountryAreaCode(region.areaCode || '');
+ setSelectedCountryEmoji(region.emoji || '');
+ });
+
+ navigation.navigate(
+ ...createRegionSelectorModalNavigationDetails({
+ regions,
+ renderAreaCode: true,
+ }),
+ );
+ }, [navigation, regions, resetPhoneVerificationSend]);
const handlePhoneNumberChange = (text: string) => {
resetPhoneVerificationSend();
@@ -173,6 +179,8 @@ const SetPhoneNumber = () => {
phoneVerificationIsError,
]);
+ useEffect(() => () => clearOnValueChange(), []);
+
const renderFormFields = () => (
{/* Area code selector */}
-
-
-
+
+
+ >
+
+
+ {`${selectedCountryEmoji} +${selectedCountryAreaCode}`}
+
+
+
@@ -242,6 +251,7 @@ const SetPhoneNumber = () => {
onPress={handleContinue}
width={ButtonWidthTypes.Full}
isDisabled={isDisabled}
+ loading={phoneVerificationIsLoading}
testID="set-phone-number-continue-button"
/>
{
- const ReactActual = jest.requireActual('react');
- const { TouchableOpacity, Text } = jest.requireActual('react-native');
-
- return (props: {
- testID?: string;
- onValueChange?: (value: string) => void;
- selectedValue?: string;
- defaultValue?: string;
- options?: { key: string; value: string; label: string }[];
- [key: string]: unknown;
- }) => {
- const handlePress = () => {
- // Simulate selecting the first available option
- if (props.options && props.options.length > 0 && props.onValueChange) {
- props.onValueChange(props.options[0].value);
- }
- };
-
- return ReactActual.createElement(
- TouchableOpacity,
- {
- testID: props.testID,
- onPress: handlePress,
- ...props,
- },
- ReactActual.createElement(
- Text,
- {},
- props.selectedValue || props.defaultValue || 'Select...',
- ),
- );
- };
-});
-
// Mock OnboardingStep
jest.mock('./OnboardingStep', () => {
const ReactActual = jest.requireActual('react');
@@ -283,17 +247,6 @@ describe('SignUp Component', () => {
expect(passwordInput.props.value).toBe('password123');
});
-
- it('has secure text entry enabled', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- const passwordInput = getByTestId('signup-password-input');
- expect(passwordInput.props.secureTextEntry).toBe(true);
- });
});
describe('Confirm Password Input', () => {
@@ -353,7 +306,18 @@ describe('SignUp Component', () => {
});
describe('Country Selection', () => {
- it('allows country selection', () => {
+ it('renders country select touchable', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ const countrySelect = getByTestId('signup-country-select');
+ expect(countrySelect).toBeTruthy();
+ });
+
+ it('navigates to region selector modal on press', () => {
const { getByTestId } = render(
@@ -363,14 +327,24 @@ describe('SignUp Component', () => {
const countrySelect = getByTestId('signup-country-select');
fireEvent.press(countrySelect);
- expect(countrySelect).toBeTruthy();
+ expect(mockNavigate).toHaveBeenCalled();
});
});
describe('Form Validation', () => {
it('enables continue button when all fields are valid', async () => {
+ // Create store with pre-selected country
+ const storeWithCountry = createTestStore({
+ onboarding: {
+ selectedCountry: { key: 'US', name: 'United States' },
+ onboardingId: null,
+ contactVerificationId: null,
+ user: null,
+ },
+ });
+
const { getByTestId } = render(
-
+
,
);
@@ -378,21 +352,13 @@ describe('SignUp Component', () => {
const emailInput = getByTestId('signup-email-input');
const passwordInput = getByTestId('signup-password-input');
const confirmPasswordInput = getByTestId('signup-confirm-password-input');
- const countrySelect = getByTestId('signup-country-select');
const continueButton = getByTestId('signup-continue-button');
// Fill in all form fields
- fireEvent.changeText(emailInput, 'test@example.com');
- fireEvent.changeText(passwordInput, 'Password123!');
- fireEvent.changeText(confirmPasswordInput, 'Password123!');
-
- // Select a country - this should trigger the Redux action
- fireEvent.press(countrySelect);
-
- // Wait for all state updates to complete
- await waitFor(() => {
- const state = store.getState();
- expect(state.card.onboarding.selectedCountry).toBe('CA'); // Canada comes first alphabetically
+ await act(async () => {
+ fireEvent.changeText(emailInput, 'test@example.com');
+ fireEvent.changeText(passwordInput, 'Password123!');
+ fireEvent.changeText(confirmPasswordInput, 'Password123!');
});
// Now check if the continue button is enabled
@@ -406,8 +372,17 @@ describe('SignUp Component', () => {
it('keeps continue button disabled when email is invalid', async () => {
(validateEmail as jest.Mock).mockReturnValue(false);
+ const storeWithCountry = createTestStore({
+ onboarding: {
+ selectedCountry: { key: 'US', name: 'United States' },
+ onboardingId: null,
+ contactVerificationId: null,
+ user: null,
+ },
+ });
+
const { getByTestId } = render(
-
+
,
);
@@ -415,14 +390,12 @@ describe('SignUp Component', () => {
const emailInput = getByTestId('signup-email-input');
const passwordInput = getByTestId('signup-password-input');
const confirmPasswordInput = getByTestId('signup-confirm-password-input');
- const countrySelect = getByTestId('signup-country-select');
const continueButton = getByTestId('signup-continue-button');
await act(async () => {
fireEvent.changeText(emailInput, 'invalid-email');
fireEvent.changeText(passwordInput, 'Password123!');
fireEvent.changeText(confirmPasswordInput, 'Password123!');
- fireEvent.press(countrySelect);
});
await waitFor(() => {
@@ -431,8 +404,17 @@ describe('SignUp Component', () => {
});
it('keeps continue button disabled when passwords do not match', async () => {
+ const storeWithCountry = createTestStore({
+ onboarding: {
+ selectedCountry: { key: 'US', name: 'United States' },
+ onboardingId: null,
+ contactVerificationId: null,
+ user: null,
+ },
+ });
+
const { getByTestId } = render(
-
+
,
);
@@ -440,14 +422,12 @@ describe('SignUp Component', () => {
const emailInput = getByTestId('signup-email-input');
const passwordInput = getByTestId('signup-password-input');
const confirmPasswordInput = getByTestId('signup-confirm-password-input');
- const countrySelect = getByTestId('signup-country-select');
const continueButton = getByTestId('signup-continue-button');
await act(async () => {
fireEvent.changeText(emailInput, 'test@example.com');
fireEvent.changeText(passwordInput, 'Password123!');
fireEvent.changeText(confirmPasswordInput, 'Password321!');
- fireEvent.press(countrySelect);
});
await waitFor(() => {
@@ -456,12 +436,18 @@ describe('SignUp Component', () => {
});
it('keeps continue button disabled when password is invalid', async () => {
- // Create a new store for this test with the invalid password mock
- const testStore = createTestStore();
- (validatePassword as jest.Mock).mockReturnValue(false); // Return false directly, not an object
+ (validatePassword as jest.Mock).mockReturnValue(false);
+ const storeWithCountry = createTestStore({
+ onboarding: {
+ selectedCountry: { key: 'US', name: 'United States' },
+ onboardingId: null,
+ contactVerificationId: null,
+ user: null,
+ },
+ });
const { getByTestId } = render(
-
+
,
);
@@ -469,13 +455,13 @@ describe('SignUp Component', () => {
const emailInput = getByTestId('signup-email-input');
const passwordInput = getByTestId('signup-password-input');
const confirmPasswordInput = getByTestId('signup-confirm-password-input');
- const countrySelect = getByTestId('signup-country-select');
const continueButton = getByTestId('signup-continue-button');
- fireEvent.changeText(emailInput, 'test@example.com');
- fireEvent.changeText(passwordInput, 'weak');
- fireEvent.changeText(confirmPasswordInput, 'weak');
- fireEvent.press(countrySelect);
+ await act(async () => {
+ fireEvent.changeText(emailInput, 'test@example.com');
+ fireEvent.changeText(passwordInput, 'weak');
+ fireEvent.changeText(confirmPasswordInput, 'weak');
+ });
await waitFor(() => {
expect(continueButton.props.disabled).toBe(true);
@@ -509,8 +495,17 @@ describe('SignUp Component', () => {
describe('Form Submission', () => {
it('calls sendEmailVerification when continue button is pressed', async () => {
+ const storeWithCountry = createTestStore({
+ onboarding: {
+ selectedCountry: { key: 'US', name: 'United States' },
+ onboardingId: null,
+ contactVerificationId: null,
+ user: null,
+ },
+ });
+
const { getByTestId } = render(
-
+
,
);
@@ -518,14 +513,12 @@ describe('SignUp Component', () => {
const emailInput = getByTestId('signup-email-input');
const passwordInput = getByTestId('signup-password-input');
const confirmPasswordInput = getByTestId('signup-confirm-password-input');
- const countrySelect = getByTestId('signup-country-select');
const continueButton = getByTestId('signup-continue-button');
await act(async () => {
fireEvent.changeText(emailInput, 'test@example.com');
fireEvent.changeText(passwordInput, 'Password123!');
fireEvent.changeText(confirmPasswordInput, 'Password123!');
- fireEvent.press(countrySelect);
});
await waitFor(() => {
@@ -582,37 +575,6 @@ describe('SignUp Component', () => {
});
});
- describe('Country Selection', () => {
- it('filters selectOptions by country.canSignUp property', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- const countrySelect = getByTestId('signup-country-select');
-
- // The SelectComponent mock should receive only countries where canSignUp is true
- // Based on the mock data, we should have US, Canada, and Germany (3 countries)
- expect(countrySelect.props.options).toHaveLength(3);
-
- // Verify that only countries with canSignUp: true are included
- const optionValues = countrySelect.props.options.map(
- (option: { value: string }) => option.value,
- );
- expect(optionValues).toContain('US');
- expect(optionValues).toContain('CA');
- expect(optionValues).toContain('DE');
- expect(optionValues).not.toContain('GB');
-
- // Verify the options are sorted alphabetically by name
- const optionLabels = countrySelect.props.options.map(
- (option: { label: string }) => option.label,
- );
- expect(optionLabels).toEqual(['Canada', 'Germany', 'United States']);
- });
- });
-
describe('Navigation', () => {
it('navigates to authentication screen when "I already have an account" is pressed', () => {
const { getByTestId } = render(
diff --git a/app/components/UI/Card/components/Onboarding/SignUp.tsx b/app/components/UI/Card/components/Onboarding/SignUp.tsx
index 8ceeb3e9da6..1a5857addd4 100644
--- a/app/components/UI/Card/components/Onboarding/SignUp.tsx
+++ b/app/components/UI/Card/components/Onboarding/SignUp.tsx
@@ -23,7 +23,6 @@ import { strings } from '../../../../../../locales/i18n';
import OnboardingStep from './OnboardingStep';
import { validateEmail } from '../../../Ramp/Deposit/utils';
import { useDebouncedValue } from '../../../../hooks/useDebouncedValue';
-import SelectComponent from '../../../SelectComponent';
import useEmailVerificationSend from '../../hooks/useEmailVerificationSend';
import useRegistrationSettings from '../../hooks/useRegistrationSettings';
import {
@@ -37,12 +36,20 @@ import { validatePassword } from '../../util/validatePassword';
import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
import { CardActions, CardScreens } from '../../util/metrics';
import { TouchableOpacity } from 'react-native';
+import {
+ clearOnValueChange,
+ createRegionSelectorModalNavigationDetails,
+ Region,
+ setOnValueChange,
+} from './RegionSelectorModal';
+import { countryCodeToFlag } from '../../util/countryCodeToFlag';
const SignUp = () => {
const navigation = useNavigation();
const dispatch = useDispatch();
const [email, setEmail] = useState('');
const [isEmailError, setIsEmailError] = useState(false);
+ const [isEmailValid, setIsEmailValid] = useState(false);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isPasswordError, setIsPasswordError] = useState(false);
@@ -75,7 +82,7 @@ const SignUp = () => {
const debouncedPassword = useDebouncedValue(password, 1000);
const debouncedConfirmPassword = useDebouncedValue(confirmPassword, 1000);
- const selectOptions = useMemo(() => {
+ const regions: Region[] = useMemo(() => {
if (!registrationSettings?.countries) {
return [];
}
@@ -84,8 +91,9 @@ const SignUp = () => {
.filter((country) => country.canSignUp)
.map((country) => ({
key: country.iso3166alpha2,
- value: country.iso3166alpha2,
- label: country.name,
+ name: country.name,
+ emoji: countryCodeToFlag(country.iso3166alpha2),
+ areaCode: country.callingCode,
}));
}, [registrationSettings]);
@@ -93,7 +101,9 @@ const SignUp = () => {
if (!debouncedEmail) {
return;
}
- setIsEmailError(!validateEmail(debouncedEmail));
+ const isValid = validateEmail(debouncedEmail);
+ setIsEmailError(!isValid);
+ setIsEmailValid(isValid);
}, [debouncedEmail]);
useEffect(() => {
@@ -114,15 +124,8 @@ const SignUp = () => {
setIsConfirmPasswordValid(isValid);
}, [debouncedConfirmPassword, debouncedPassword]);
- const isDisabled = useMemo(() => {
- // Check the actual values, not the debounced ones
- const isEmailValid = email ? validateEmail(email) : false;
- const isPasswordValid = password ? validatePassword(password) : false;
- const isConfirmPasswordValid = confirmPassword
- ? confirmPassword === password
- : false;
-
- return (
+ const isDisabled = useMemo(
+ () =>
!email ||
!password ||
!confirmPassword ||
@@ -131,16 +134,19 @@ const SignUp = () => {
!isPasswordValid ||
!isConfirmPasswordValid ||
emailVerificationIsError ||
- emailVerificationIsLoading
- );
- }, [
- email,
- password,
- confirmPassword,
- selectedCountry,
- emailVerificationIsError,
- emailVerificationIsLoading,
- ]);
+ emailVerificationIsLoading,
+ [
+ email,
+ password,
+ confirmPassword,
+ selectedCountry,
+ isEmailValid,
+ isPasswordValid,
+ isConfirmPasswordValid,
+ emailVerificationIsError,
+ emailVerificationIsLoading,
+ ],
+ );
const handleEmailChange = useCallback(
(emailText: string) => {
@@ -164,16 +170,19 @@ const SignUp = () => {
return;
}
- // Validate current values before submitting
- const isEmailValid = validateEmail(email);
- const isPasswordValid = validatePassword(password);
- const isConfirmPasswordValid = confirmPassword === password;
+ const isCurrentEmailValid = validateEmail(email);
+ const isCurrentPasswordValid = validatePassword(password);
+ const isCurrentConfirmPasswordValid = confirmPassword === password;
- if (!isEmailValid || !isPasswordValid || !isConfirmPasswordValid) {
+ if (
+ !isCurrentEmailValid ||
+ !isCurrentPasswordValid ||
+ !isCurrentConfirmPasswordValid
+ ) {
// Set error states
- setIsEmailError(!isEmailValid);
- setIsPasswordError(!isPasswordValid);
- setIsConfirmPasswordError(!isConfirmPasswordValid);
+ setIsEmailError(!isCurrentEmailValid);
+ setIsPasswordError(!isCurrentPasswordValid);
+ setIsConfirmPasswordError(!isCurrentConfirmPasswordValid);
return;
}
@@ -202,27 +211,34 @@ const SignUp = () => {
// Allow error message to display
}
}, [
- confirmPassword,
email,
password,
- dispatch,
- navigation,
+ confirmPassword,
selectedCountry,
- sendEmailVerification,
trackEvent,
createEventBuilder,
+ sendEmailVerification,
+ dispatch,
+ navigation,
]);
- const handleCountrySelect = useCallback(
- (countryValue: string) => {
- resetEmailVerificationSend();
- dispatch(setSelectedCountry(countryValue));
+ const handleCountrySelect = useCallback(() => {
+ resetEmailVerificationSend();
+ setOnValueChange((region) => {
+ dispatch(setSelectedCountry(region));
dispatch(
- setUserCardLocation(countryValue === 'US' ? 'us' : 'international'),
+ setUserCardLocation(region.key === 'US' ? 'us' : 'international'),
);
- },
- [dispatch, resetEmailVerificationSend],
- );
+ });
+
+ navigation.navigate(
+ ...createRegionSelectorModalNavigationDetails({
+ regions,
+ }),
+ );
+ }, [dispatch, navigation, regions, resetEmailVerificationSend]);
+
+ useEffect(() => () => clearOnValueChange(), []);
const renderFormFields = () => (
<>
@@ -347,16 +363,15 @@ const SignUp = () => {
-
+ >
+
+ {selectedCountry?.name}
+
+
+
>
@@ -371,6 +386,7 @@ const SignUp = () => {
onPress={handleContinue}
width={ButtonWidthTypes.Full}
isDisabled={isDisabled}
+ loading={emailVerificationIsLoading}
testID="signup-continue-button"
/>
({
@@ -35,6 +36,20 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction<
typeof getErrorMessage
>;
+// Mock Region objects for testing
+const MOCK_REGION_US: Region = {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+};
+const MOCK_REGION_CA: Region = { key: 'CA', name: 'Canada', emoji: 'π¨π¦' };
+const MOCK_REGION_GB: Region = {
+ key: 'GB',
+ name: 'United Kingdom',
+ emoji: 'π¬π§',
+};
+const MOCK_REGION_DE: Region = { key: 'DE', name: 'Germany', emoji: 'π©πͺ' };
+
describe('useRegisterUserConsent', () => {
const mockCreateOnboardingConsent = jest.fn();
const mockLinkUserToConsent = jest.fn();
@@ -62,7 +77,7 @@ describe('useRegisterUserConsent', () => {
sdk: mockSDK,
});
- mockUseSelector.mockReturnValue('US'); // selectedCountry
+ mockUseSelector.mockReturnValue(MOCK_REGION_US); // selectedCountry
mockGetErrorMessage.mockReturnValue('Mocked error message');
mockCreateOnboardingConsent.mockResolvedValue(mockConsentResponse);
@@ -171,7 +186,7 @@ describe('useRegisterUserConsent', () => {
describe('createOnboardingConsent function', () => {
describe('successful consent creation', () => {
it('creates consent record for US users with eSignAct consent', async () => {
- mockUseSelector.mockReturnValue('US');
+ mockUseSelector.mockReturnValue(MOCK_REGION_US);
const { result } = renderHook(() => useRegisterUserConsent());
let returnedConsentSetId = '';
@@ -234,7 +249,7 @@ describe('useRegisterUserConsent', () => {
});
it('creates consent record for international users without eSignAct', async () => {
- mockUseSelector.mockReturnValue('CA');
+ mockUseSelector.mockReturnValue(MOCK_REGION_CA);
const { result } = renderHook(() => useRegisterUserConsent());
await act(async () => {
@@ -716,26 +731,26 @@ describe('useRegisterUserConsent', () => {
describe('country-specific behavior', () => {
const countryTestCases = [
{
- country: 'US',
+ country: MOCK_REGION_US,
expectedPolicy: 'us',
description: 'US users',
},
{
- country: 'CA',
+ country: MOCK_REGION_CA,
expectedPolicy: 'global',
description: 'Canadian users',
},
{
- country: 'GB',
+ country: MOCK_REGION_GB,
expectedPolicy: 'global',
description: 'UK users',
},
{
- country: 'DE',
+ country: MOCK_REGION_DE,
expectedPolicy: 'global',
description: 'German users',
},
- ] as const;
+ ];
it.each(countryTestCases)(
'uses correct policy for $description',
diff --git a/app/components/UI/Card/hooks/useRegisterUserConsent.ts b/app/components/UI/Card/hooks/useRegisterUserConsent.ts
index 328634136fd..46899ac79a7 100644
--- a/app/components/UI/Card/hooks/useRegisterUserConsent.ts
+++ b/app/components/UI/Card/hooks/useRegisterUserConsent.ts
@@ -96,7 +96,7 @@ export const useRegisterUserConsent = (): UseRegisterUserConsentReturn => {
throw new Error('Card SDK not initialized');
}
- const policy = selectedCountry === 'US' ? 'us' : 'global';
+ const policy = selectedCountry?.key === 'US' ? 'us' : 'global';
try {
// Reset state and start loading
diff --git a/app/components/UI/Card/routes/OnboardingNavigator.test.tsx b/app/components/UI/Card/routes/OnboardingNavigator.test.tsx
index c05962c5e2d..5c1b05eadf4 100644
--- a/app/components/UI/Card/routes/OnboardingNavigator.test.tsx
+++ b/app/components/UI/Card/routes/OnboardingNavigator.test.tsx
@@ -280,14 +280,13 @@ describe('OnboardingNavigator', () => {
});
describe('when user verification state is PENDING', () => {
- it('returns VERIFY_IDENTITY route when firstName is missing', () => {
+ it('returns SET_PHONE_NUMBER route when phoneNumber is missing', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
verificationState: 'PENDING',
- countryOfNationality: 'US',
- // firstName is undefined
+ // phoneNumber is undefined
},
isLoading: false,
sdk: null,
@@ -303,18 +302,18 @@ describe('OnboardingNavigator', () => {
const stackNavigator = queryByTestId('stack-navigator');
expect(stackNavigator).not.toBeNull();
expect(stackNavigator?.props.initialRouteName).toBe(
- Routes.CARD.ONBOARDING.VERIFY_IDENTITY,
+ Routes.CARD.ONBOARDING.SET_PHONE_NUMBER,
);
});
- it('returns VERIFY_IDENTITY route when countryOfNationality is missing', () => {
+ it('returns PERSONAL_DETAILS route when phoneNumber exists but firstName is missing', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
verificationState: 'PENDING',
- firstName: 'John',
- // countryOfNationality is undefined
+ phoneNumber: '+1234567890',
+ // firstName is undefined
},
isLoading: false,
sdk: null,
@@ -330,47 +329,21 @@ describe('OnboardingNavigator', () => {
const stackNavigator = queryByTestId('stack-navigator');
expect(stackNavigator).not.toBeNull();
expect(stackNavigator?.props.initialRouteName).toBe(
- Routes.CARD.ONBOARDING.VERIFY_IDENTITY,
+ Routes.CARD.ONBOARDING.PERSONAL_DETAILS,
);
});
- it('returns VALIDATING_KYC route when firstName and countryOfNationality exist', () => {
+ it('returns PERSONAL_DETAILS route when dateOfBirth is missing', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
verificationState: 'PENDING',
+ phoneNumber: '+1234567890',
firstName: 'John',
+ lastName: 'Doe',
countryOfNationality: 'US',
- },
- isLoading: false,
- sdk: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
- fetchUserData: jest.fn(),
- });
-
- const { queryByTestId } = renderWithNavigation(
- ,
- );
-
- const stackNavigator = queryByTestId('stack-navigator');
- expect(stackNavigator).not.toBeNull();
- expect(stackNavigator?.props.initialRouteName).toBe(
- Routes.CARD.ONBOARDING.VALIDATING_KYC,
- );
- });
- });
-
- describe('when user verification state is VERIFIED', () => {
- it('returns PERSONAL_DETAILS route when firstName is missing', () => {
- mockUseSelector.mockReturnValue('onboarding-123');
- mockUseCardSDK.mockReturnValue({
- user: {
- id: 'user-123',
- verificationState: 'VERIFIED',
- countryOfNationality: 'US',
- // firstName is undefined
+ // dateOfBirth is undefined
},
isLoading: false,
sdk: null,
@@ -390,14 +363,17 @@ describe('OnboardingNavigator', () => {
);
});
- it('returns PHYSICAL_ADDRESS route when firstName and countryOfNationality exist but addressLine1 is missing', () => {
+ it('returns PHYSICAL_ADDRESS route when personal details exist but addressLine1 is missing', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
- verificationState: 'VERIFIED',
+ verificationState: 'PENDING',
+ phoneNumber: '+1234567890',
firstName: 'John',
+ lastName: 'Doe',
countryOfNationality: 'US',
+ dateOfBirth: '1990-01-01',
// addressLine1 is undefined
},
isLoading: false,
@@ -418,15 +394,19 @@ describe('OnboardingNavigator', () => {
);
});
- it('returns COMPLETE route when user has all required data', () => {
+ it('returns PHYSICAL_ADDRESS route when city is missing', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
- verificationState: 'VERIFIED',
+ verificationState: 'PENDING',
+ phoneNumber: '+1234567890',
firstName: 'John',
+ lastName: 'Doe',
countryOfNationality: 'US',
+ dateOfBirth: '1990-01-01',
addressLine1: '123 Main St',
+ // city is undefined
},
isLoading: false,
sdk: null,
@@ -442,19 +422,24 @@ describe('OnboardingNavigator', () => {
const stackNavigator = queryByTestId('stack-navigator');
expect(stackNavigator).not.toBeNull();
expect(stackNavigator?.props.initialRouteName).toBe(
- Routes.CARD.ONBOARDING.COMPLETE,
+ Routes.CARD.ONBOARDING.PHYSICAL_ADDRESS,
);
});
- });
- describe('when user verification state is UNVERIFIED', () => {
- it('returns SIGN_UP route when email is missing', () => {
+ it('returns VERIFY_IDENTITY route when all user data is complete', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
- verificationState: 'UNVERIFIED',
- // email is undefined
+ verificationState: 'PENDING',
+ phoneNumber: '+1234567890',
+ firstName: 'John',
+ lastName: 'Doe',
+ countryOfNationality: 'US',
+ dateOfBirth: '1990-01-01',
+ addressLine1: '123 Main St',
+ city: 'New York',
+ zip: '10001',
},
isLoading: false,
sdk: null,
@@ -470,18 +455,18 @@ describe('OnboardingNavigator', () => {
const stackNavigator = queryByTestId('stack-navigator');
expect(stackNavigator).not.toBeNull();
expect(stackNavigator?.props.initialRouteName).toBe(
- Routes.CARD.ONBOARDING.SIGN_UP,
+ Routes.CARD.ONBOARDING.VERIFY_IDENTITY,
);
});
+ });
- it('returns SET_PHONE_NUMBER route when email exists but phoneNumber is missing', () => {
+ describe('when user verification state is VERIFIED', () => {
+ it('returns COMPLETE route regardless of user data completeness', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
- verificationState: 'UNVERIFIED',
- email: 'test@example.com',
- // phoneNumber is undefined
+ verificationState: 'VERIFIED',
},
isLoading: false,
sdk: null,
@@ -497,18 +482,18 @@ describe('OnboardingNavigator', () => {
const stackNavigator = queryByTestId('stack-navigator');
expect(stackNavigator).not.toBeNull();
expect(stackNavigator?.props.initialRouteName).toBe(
- Routes.CARD.ONBOARDING.SET_PHONE_NUMBER,
+ Routes.CARD.ONBOARDING.COMPLETE,
);
});
+ });
- it('returns VERIFY_IDENTITY route when email and phoneNumber exist', () => {
+ describe('when user verification state is UNVERIFIED', () => {
+ it('returns SIGN_UP route regardless of user data', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
verificationState: 'UNVERIFIED',
- email: 'test@example.com',
- phoneNumber: '+1234567890',
},
isLoading: false,
sdk: null,
@@ -524,7 +509,7 @@ describe('OnboardingNavigator', () => {
const stackNavigator = queryByTestId('stack-navigator');
expect(stackNavigator).not.toBeNull();
expect(stackNavigator?.props.initialRouteName).toBe(
- Routes.CARD.ONBOARDING.VERIFY_IDENTITY,
+ Routes.CARD.ONBOARDING.SIGN_UP,
);
});
});
diff --git a/app/components/UI/Card/routes/OnboardingNavigator.tsx b/app/components/UI/Card/routes/OnboardingNavigator.tsx
index bf5db35b823..eafe8b63918 100644
--- a/app/components/UI/Card/routes/OnboardingNavigator.tsx
+++ b/app/components/UI/Card/routes/OnboardingNavigator.tsx
@@ -146,31 +146,31 @@ const OnboardingNavigator: React.FC = () => {
// Priority 2: Use cached user data if available
if (user?.verificationState && onboardingId) {
if (user.verificationState === 'UNVERIFIED') {
- if (!user?.email) {
- return Routes.CARD.ONBOARDING.SIGN_UP;
- }
+ return Routes.CARD.ONBOARDING.SIGN_UP;
+ }
+ if (user.verificationState === 'PENDING') {
if (!user?.phoneNumber) {
return Routes.CARD.ONBOARDING.SET_PHONE_NUMBER;
}
- return Routes.CARD.ONBOARDING.VERIFY_IDENTITY;
- }
+ if (
+ !user.firstName ||
+ !user.lastName ||
+ !user.countryOfNationality ||
+ !user.dateOfBirth
+ ) {
+ return Routes.CARD.ONBOARDING.PERSONAL_DETAILS;
+ }
- if (user.verificationState === 'PENDING') {
- if (!user.firstName || !user.countryOfNationality) {
- return Routes.CARD.ONBOARDING.VERIFY_IDENTITY;
+ if (!user?.addressLine1 || !user?.city || !user?.zip) {
+ return Routes.CARD.ONBOARDING.PHYSICAL_ADDRESS;
}
- return Routes.CARD.ONBOARDING.VALIDATING_KYC;
+ return Routes.CARD.ONBOARDING.VERIFY_IDENTITY;
}
if (user.verificationState === 'VERIFIED') {
- if (!user?.firstName || !user?.countryOfNationality) {
- return Routes.CARD.ONBOARDING.PERSONAL_DETAILS;
- } else if (!user?.addressLine1) {
- return Routes.CARD.ONBOARDING.PHYSICAL_ADDRESS;
- }
return Routes.CARD.ONBOARDING.COMPLETE;
}
}
diff --git a/app/components/UI/Card/routes/index.tsx b/app/components/UI/Card/routes/index.tsx
index 0f90dca9ba7..6f75e8d9bb4 100644
--- a/app/components/UI/Card/routes/index.tsx
+++ b/app/components/UI/Card/routes/index.tsx
@@ -29,6 +29,7 @@ import AddFundsBottomSheet from '../components/AddFundsBottomSheet/AddFundsBotto
import AssetSelectionBottomSheet from '../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet';
import { colors } from '../../../../styles/common';
import VerifyingRegistration from '../components/Onboarding/VerifyingRegistration';
+import RegionSelectorModal from '../components/Onboarding/RegionSelectorModal';
const Stack = createStackNavigator();
const ModalsStack = createStackNavigator();
@@ -156,6 +157,10 @@ const CardModalsRoutes = () => (
name={Routes.CARD.MODALS.ASSET_SELECTION}
component={AssetSelectionBottomSheet}
/>
+
);
diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts
index 8fcd492461d..ae34e588469 100644
--- a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts
+++ b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts
@@ -1,7 +1,14 @@
import { StyleSheet } from 'react-native';
+import { Theme } from '../../../util/theme/models';
-const createStyles = () =>
- StyleSheet.create({
+const createStyles = (params: {
+ theme: Theme;
+ vars: Record;
+}) => {
+ const {
+ theme: { colors },
+ } = params;
+ return StyleSheet.create({
// custom network styles
container: {
flex: 1,
@@ -11,14 +18,22 @@ const createStyles = () =>
paddingBottom: 100,
},
addNetworkButtonContainer: {
- paddingHorizontal: 16,
- paddingVertical: 12,
+ borderRadius: 8,
+ padding: 16,
flexDirection: 'row',
alignItems: 'center',
},
iconContainer: {
- marginRight: 14,
+ backgroundColor: colors.background.muted,
+ borderRadius: 8,
+ padding: 8,
+ marginRight: 16,
+ width: 32,
+ height: 32,
+ alignItems: 'center',
+ justifyContent: 'center',
},
});
+};
export default createStyles;
diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx
index 2855abece5d..ab6dc68309a 100644
--- a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx
+++ b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx
@@ -360,7 +360,7 @@ describe('CustomNetworkSelector', () => {
expect(mockUseSafeAreaInsets).toHaveBeenCalled();
});
- it('calls useStyles with theme colors', () => {
+ it('calls useStyles with createStyles', () => {
renderWithProvider(
{
/>,
);
- expect(mockUseStyles).toHaveBeenCalledWith(expect.any(Function), {
- colors: expect.objectContaining({
- icon: expect.any(Object),
- text: expect.any(Object),
- }),
- });
+ expect(mockUseStyles).toHaveBeenCalledWith(expect.any(Function), {});
});
});
diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx
index 2abbccd3767..a496e0351ab 100644
--- a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx
+++ b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx
@@ -53,7 +53,7 @@ const CustomNetworkSelector = ({
openRpcModal,
}: CustomNetworkSelectorProps) => {
const { colors } = useTheme();
- const { styles } = useStyles(createStyles, { colors });
+ const { styles } = useStyles(createStyles, {});
const { navigate } = useNavigation();
const safeAreaInsets = useSafeAreaInsets();
@@ -126,7 +126,7 @@ const CustomNetworkSelector = ({
variant: AvatarVariant.Network,
name,
imageSource: item.imageSource as ImageSourcePropType,
- size: AvatarSize.Sm,
+ size: AvatarSize.Md,
}}
buttonIcon={IconName.MoreVertical}
buttonProps={{
@@ -149,14 +149,15 @@ const CustomNetworkSelector = ({
style={styles.addNetworkButtonContainer}
onPress={goToNetworkSettings}
>
-
+
+
+
-
+
{strings('app_settings.network_add_custom_network')}
diff --git a/app/components/Views/Asset/__snapshots__/index.test.js.snap b/app/components/Views/Asset/__snapshots__/index.test.js.snap
index 1c44e9dc41f..8f0b45364f1 100644
--- a/app/components/Views/Asset/__snapshots__/index.test.js.snap
+++ b/app/components/Views/Asset/__snapshots__/index.test.js.snap
@@ -1758,7 +1758,8 @@ exports[`Asset Multichain Functionality renders empty state when no multichain t
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
@@ -3377,7 +3378,8 @@ exports[`Asset Multichain Functionality returns empty list for unknown SPL token
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
@@ -3424,11 +3426,8 @@ exports[`Asset Multichain Functionality returns empty list for unknown SPL token
style={
{
"alignItems": "center",
- "backgroundColor": "#4459ff1a",
- "borderRadius": 20,
"flexDirection": "row",
- "marginLeft": 8,
- "paddingHorizontal": 8,
+ "gap": 4,
}
}
>
@@ -3436,7 +3435,7 @@ exports[`Asset Multichain Functionality returns empty list for unknown SPL token
accessibilityRole="text"
style={
{
- "color": "#4459ff",
+ "color": "#121314",
"fontFamily": "Geist-Regular",
"fontSize": 14,
"letterSpacing": 0,
@@ -3447,18 +3446,18 @@ exports[`Asset Multichain Functionality returns empty list for unknown SPL token
Unknown...dress
@@ -5099,7 +5098,8 @@ exports[`Asset Multichain Functionality should exclude mixed token/SOL transacti
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
@@ -6749,7 +6749,8 @@ exports[`Asset Multichain Functionality should exclude transactions with empty a
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
@@ -8368,7 +8369,8 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
@@ -8415,11 +8417,8 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor
style={
{
"alignItems": "center",
- "backgroundColor": "#4459ff1a",
- "borderRadius": 20,
"flexDirection": "row",
- "marginLeft": 8,
- "paddingHorizontal": 8,
+ "gap": 4,
}
}
>
@@ -8427,7 +8426,7 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor
accessibilityRole="text"
style={
{
- "color": "#4459ff",
+ "color": "#121314",
"fontFamily": "Geist-Regular",
"fontSize": 14,
"letterSpacing": 0,
@@ -8438,18 +8437,18 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor
EPjFWdd...TDt1v
@@ -10090,7 +10089,8 @@ exports[`Asset Multichain Functionality should filter native SOL transactions co
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
@@ -12268,7 +12268,8 @@ exports[`Asset Multichain Functionality should render non-EVM assets with Multic
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
@@ -13918,7 +13919,8 @@ exports[`Asset Multichain Functionality should sort filtered transactions by tim
"fontSize": 18,
"letterSpacing": 0,
"lineHeight": 24,
- "paddingVertical": 8,
+ "paddingBottom": 8,
+ "paddingTop": 8,
}
}
>
diff --git a/app/components/Views/AssetOptions/AssetOptions.styles.ts b/app/components/Views/AssetOptions/AssetOptions.styles.ts
index 2593ddd1800..bf1d411aa4d 100644
--- a/app/components/Views/AssetOptions/AssetOptions.styles.ts
+++ b/app/components/Views/AssetOptions/AssetOptions.styles.ts
@@ -1,44 +1,13 @@
-import type { Theme } from '@metamask/design-tokens';
-import { StyleSheet, TextStyle } from 'react-native';
-import {
- getFontFamily,
- TextVariant,
-} from '../../../component-library/components/Texts/Text';
+import { StyleSheet } from 'react-native';
-const styleSheet = (params: { theme: Theme }) => {
- const { theme } = params;
- const { colors, typography } = theme;
- return StyleSheet.create({
- screen: { justifyContent: 'flex-end' },
- sheet: {
- backgroundColor: colors.background.default,
- borderTopLeftRadius: 20,
- borderTopRightRadius: 20,
- },
- notch: {
- width: 48,
- height: 5,
- borderRadius: 4,
- backgroundColor: colors.border.default,
- marginTop: 12,
- alignSelf: 'center',
- marginBottom: 16,
- },
+const styleSheet = () =>
+ StyleSheet.create({
optionButton: {
alignItems: 'center',
flexDirection: 'row',
- padding: 16,
- },
- icon: {
- marginRight: 16,
- color: colors.text.default,
+ padding: 12,
+ gap: 16,
},
- optionLabel: {
- ...typography.sBodyLGMedium,
- fontFamily: getFontFamily(TextVariant.BodyLGMedium),
- color: colors.text.default,
- } as TextStyle,
});
-};
export default styleSheet;
diff --git a/app/components/Views/AssetOptions/AssetOptions.test.tsx b/app/components/Views/AssetOptions/AssetOptions.test.tsx
index 44bd50ca18d..8dbfbceec88 100644
--- a/app/components/Views/AssetOptions/AssetOptions.test.tsx
+++ b/app/components/Views/AssetOptions/AssetOptions.test.tsx
@@ -41,6 +41,7 @@ jest.mock('react-redux', () => ({
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: jest.fn(() => ({ bottom: 10 })),
+ useSafeAreaFrame: jest.fn(() => ({ width: 390, height: 844 })),
}));
// Mock InteractionManager.runAfterInteractions to execute callbacks immediately
diff --git a/app/components/Views/AssetOptions/AssetOptions.tsx b/app/components/Views/AssetOptions/AssetOptions.tsx
index 8afc11d282d..1ad3c3487a5 100644
--- a/app/components/Views/AssetOptions/AssetOptions.tsx
+++ b/app/components/Views/AssetOptions/AssetOptions.tsx
@@ -1,7 +1,12 @@
import { useNavigation } from '@react-navigation/native';
import React, { useMemo, useRef } from 'react';
-import { Text, TouchableOpacity, View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { TouchableOpacity, View } from 'react-native';
+import {
+ Text,
+ TextVariant,
+ Icon,
+ IconName,
+} from '@metamask/design-system-react-native';
import { useSelector } from 'react-redux';
import Engine from '../../../core/Engine';
import NotificationManager from '../../../core/NotificationManager';
@@ -9,11 +14,7 @@ import Routes from '../../../constants/navigation/Routes';
import { useStyles } from '../../../component-library/hooks';
import { useMetrics } from '../../../components/hooks/useMetrics';
import { strings } from '../../../../locales/i18n';
-import Icon, {
- IconName,
-} from '../../../component-library/components/Icons/Icon';
import { selectEvmChainId } from '../../../selectors/networkController';
-import ReusableModal, { ReusableModalRef } from '../../UI/ReusableModal';
import styleSheet from './AssetOptions.styles';
import { selectTokenList } from '../../../selectors/tokenListController';
import Logger from '../../../util/Logger';
@@ -31,6 +32,10 @@ import { removeNonEvmToken } from '../../UI/Tokens/util';
import { toChecksumAddress, areAddressesEqual } from '../../../util/address';
import { selectAssetsBySelectedAccountGroup } from '../../../selectors/assets/assets-list';
import useBlockExplorer from '../../hooks/useBlockExplorer';
+import BottomSheet, {
+ BottomSheetRef,
+} from '../../../component-library/components/BottomSheets/BottomSheet';
+import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader';
// Wrapped SOL token address on Solana
const WRAPPED_SOL_ADDRESS = 'So11111111111111111111111111111111111111111';
@@ -90,10 +95,9 @@ const AssetOptions = (props: Props) => {
chainId: networkId,
asset,
} = props.route.params;
- const { styles } = useStyles(styleSheet, {});
- const safeAreaInsets = useSafeAreaInsets();
+ const { styles } = useStyles(styleSheet);
const navigation = useNavigation();
- const modalRef = useRef(null);
+ const modalRef = useRef(null);
const tokenList = useSelector(selectTokenList);
const chainId = useSelector(selectEvmChainId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -129,7 +133,7 @@ const AssetOptions = (props: Props) => {
const { trackEvent, createEventBuilder } = useMetrics();
const goToBrowserUrl = (url: string, title: string) => {
- modalRef.current?.dismissModal(() => {
+ modalRef.current?.onCloseBottomSheet(() => {
(async () => {
if (await InAppBrowser.isAvailable()) {
await InAppBrowser.open(url);
@@ -168,7 +172,7 @@ const AssetOptions = (props: Props) => {
};
const openTokenDetails = () => {
- modalRef.current?.dismissModal(() => {
+ modalRef.current?.onCloseBottomSheet(() => {
// Extract the actual token address from CAIP format only for non-EVM chains
const tokenAddress = isNonEvmChainId(networkId)
? extractTokenAddressFromCaip(address)
@@ -317,8 +321,8 @@ const AssetOptions = (props: Props) => {
return (
-
- {label}
+
+ {label}
);
@@ -328,12 +332,14 @@ const AssetOptions = (props: Props) => {
};
return (
-
-
-
- {renderOptions()}
-
-
+
+ modalRef.current?.onCloseBottomSheet()}>
+
+ {strings('asset_details.options.title')}
+
+
+ {renderOptions()}
+
);
};
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 975011af3fa..ed79ebf5b30 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -406,6 +406,7 @@ const Routes = {
ID: 'CardModals',
ADD_FUNDS: 'CardAddFundsModal',
ASSET_SELECTION: 'CardAssetSelectionModal',
+ REGION_SELECTION: 'CardRegionSelectionModal',
},
},
SEND: {
diff --git a/app/core/BackgroundBridge/BackgroundBridge.js b/app/core/BackgroundBridge/BackgroundBridge.js
index 7d600f8a758..37cfb114843 100644
--- a/app/core/BackgroundBridge/BackgroundBridge.js
+++ b/app/core/BackgroundBridge/BackgroundBridge.js
@@ -83,7 +83,14 @@ import {
import { createEip5792Middleware } from '../RPCMethods/createEip5792Middleware';
import { createOriginThrottlingMiddleware } from '../RPCMethods/OriginThrottlingMiddleware';
import { getAuthorizedScopes } from '../../selectors/permissions';
-import { SolAccountType, SolScope } from '@metamask/keyring-api';
+import {
+ SolAccountType,
+ SolScope,
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ TrxScope,
+ TrxAccountType,
+ ///: END:ONLY_INCLUDE_IF
+} from '@metamask/keyring-api';
import { parseCaipAccountId } from '@metamask/utils';
import { toFormattedAddress, areAddressesEqual } from '../../util/address';
import { isSameOrigin } from '../../util/url';
@@ -150,6 +157,9 @@ export class BackgroundBridge extends EventEmitter {
this.multichainMiddlewareManager = null;
this.lastSelectedSolanaAccountAddress = null;
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ this.lastSelectedTronAccountAddress = null;
+ ///: END:ONLY_INCLUDE_IF
const networkClientId = Engine.controllerMessenger.call(
'SelectedNetworkController:getNetworkClientIdForDomain',
@@ -512,6 +522,20 @@ export class BackgroundBridge extends EventEmitter {
`${AccountTreeController.name}:selectedAccountGroupChange`,
this.handleSolanaAccountChangedFromSelectedAccountGroupChanges,
);
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ controllerMessenger.unsubscribe(
+ `${PermissionController.name}:stateChange`,
+ this.handleTronAccountChangedFromScopeChanges,
+ );
+ controllerMessenger.unsubscribe(
+ `${AccountsController.name}:selectedAccountChange`,
+ this.handleTronAccountChangedFromSelectedAccountChanges,
+ );
+ controllerMessenger.unsubscribe(
+ `${AccountTreeController.name}:selectedAccountGroupChange`,
+ this.handleTronAccountChangedFromSelectedAccountGroupChanges,
+ );
+ ///: END:ONLY_INCLUDE_IF
}
this.port.emit('disconnect', { name: this.port.name, data: null });
@@ -552,8 +576,11 @@ export class BackgroundBridge extends EventEmitter {
// transport. Unlike externally_connectable's chrome.runtime.connect() API, the
// window.postMessage API allows the inpage provider to setup listeners for
// messages before attempting to establish the connection meaning that it will
- // have listeners ready for this solana accountChanged event below.
+ // have listeners ready for this Solana and Tron accountChanged event below.
this.notifySolanaAccountChangedForCurrentAccount();
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ this.notifyTronAccountChangedForCurrentAccount();
+ ///: END:ONLY_INCLUDE_IF
pump(outStream, providerStream, outStream, (err) => {
// handle any middleware cleanup
@@ -910,7 +937,7 @@ export class BackgroundBridge extends EventEmitter {
context: { AccountsController, PermissionController },
} = Engine;
- // this throws if there is no solana account... perhaps we should handle this better at the controller level
+ // this throws if there is no Solana or Tron account... perhaps we should handle this better at the controller level
try {
this.lastSelectedSolanaAccountAddress =
AccountsController.getSelectedMultichainAccount(
@@ -919,6 +946,16 @@ export class BackgroundBridge extends EventEmitter {
} catch {
// noop
}
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ try {
+ this.lastSelectedTronAccountAddress =
+ AccountsController.getSelectedMultichainAccount(
+ TrxScope.Mainnet,
+ )?.address;
+ } catch {
+ // noop
+ }
+ ///: END:ONLY_INCLUDE_IF
// wallet_sessionChanged and eth_subscription setup/teardown
controllerMessenger.subscribe(
@@ -927,14 +964,14 @@ export class BackgroundBridge extends EventEmitter {
getAuthorizedScopes(this.channelIdOrOrigin),
);
- // wallet_notify for solana accountChanged when permission changes
+ // wallet_notify for Solana accountChanged when permission changes
controllerMessenger.subscribe(
`${PermissionController.name}:stateChange`,
this.handleSolanaAccountChangedFromScopeChanges,
getAuthorizedScopes(this.channelIdOrOrigin),
);
- // wallet_notify for solana accountChanged when selected account changes
+ // wallet_notify for Solana accountChanged when selected account changes
controllerMessenger.subscribe(
`${AccountsController.name}:selectedAccountChange`,
this.handleSolanaAccountChangedFromSelectedAccountChanges,
@@ -944,6 +981,26 @@ export class BackgroundBridge extends EventEmitter {
`${AccountTreeController.name}:selectedAccountGroupChange`,
this.handleSolanaAccountChangedFromSelectedAccountGroupChanges,
);
+
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ // wallet_notify for Tron accountChanged when permission changes
+ controllerMessenger.subscribe(
+ `${PermissionController.name}:stateChange`,
+ this.handleTronAccountChangedFromScopeChanges,
+ getAuthorizedScopes(this.channelIdOrOrigin),
+ );
+
+ // wallet_notify for Tron accountChanged when selected account changes
+ controllerMessenger.subscribe(
+ `${AccountsController.name}:selectedAccountChange`,
+ this.handleTronAccountChangedFromSelectedAccountChanges,
+ );
+
+ controllerMessenger.subscribe(
+ `${AccountTreeController.name}:selectedAccountGroupChange`,
+ this.handleTronAccountChangedFromSelectedAccountGroupChanges,
+ );
+ ///: END:ONLY_INCLUDE_IF
}
/**
@@ -1062,10 +1119,11 @@ export class BackgroundBridge extends EventEmitter {
previousSelectedSolanaAccountAddress !==
currentSelectedSolanaAccountAddress
) {
- this._notifySolanaAccountChange(
+ this._notifyMultichainAccountChange(
currentSelectedSolanaAccountAddress
? [currentSelectedSolanaAccountAddress]
: [],
+ SolScope.Mainnet,
);
}
};
@@ -1111,19 +1169,22 @@ export class BackgroundBridge extends EventEmitter {
});
if (parsedSolanaAddresses.includes(account.address)) {
- this._notifySolanaAccountChange([account.address]);
+ this._notifyMultichainAccountChange(
+ [account.address],
+ SolScope.Mainnet,
+ );
}
}
};
handleSolanaAccountChangedFromSelectedAccountGroupChanges = () => {
- const solanaAccount = this.getNonEvmAccountFromSelectedAccountGroup();
+ const solanaAccount = this.getSolanaAccountFromSelectedAccountGroup();
if (solanaAccount) {
this.handleSolanaAccountChangedFromSelectedAccountChanges(solanaAccount);
}
};
- getNonEvmAccountFromSelectedAccountGroup() {
+ getSolanaAccountFromSelectedAccountGroup() {
const controllerMessenger = Engine.controllerMessenger;
const [solanaAccount] = controllerMessenger.call(
@@ -1133,6 +1194,133 @@ export class BackgroundBridge extends EventEmitter {
return solanaAccount;
}
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ handleTronAccountChangedFromScopeChanges = (currentValue, previousValue) => {
+ const previousTronAccountChangedNotificationsEnabled = Boolean(
+ previousValue?.sessionProperties?.[
+ KnownSessionProperties.TronAccountChangedNotifications
+ ],
+ );
+ const currentTronAccountChangedNotificationsEnabled = Boolean(
+ currentValue?.sessionProperties?.[
+ KnownSessionProperties.TronAccountChangedNotifications
+ ],
+ );
+
+ if (
+ !previousTronAccountChangedNotificationsEnabled &&
+ !currentTronAccountChangedNotificationsEnabled
+ ) {
+ return;
+ }
+
+ const previousTronCaipAccountIds = previousValue
+ ? getPermittedAccountsForScopes(previousValue, [
+ TrxScope.Mainnet,
+ TrxScope.Shasta,
+ TrxScope.Nile,
+ ])
+ : [];
+
+ const [previousSelectedTronAccountId] =
+ sortMultichainAccountsByLastSelected(previousTronCaipAccountIds);
+ const previousSelectedTronAccountAddress = previousSelectedTronAccountId
+ ? parseCaipAccountId(previousSelectedTronAccountId).address
+ : '';
+
+ const currentTronCaipAccountIds = currentValue
+ ? getPermittedAccountsForScopes(currentValue, [
+ TrxScope.Mainnet,
+ TrxScope.Shasta,
+ TrxScope.Nile,
+ ])
+ : [];
+ const [currentSelectedTronAccountId] = sortMultichainAccountsByLastSelected(
+ currentTronCaipAccountIds,
+ );
+ const currentSelectedTronAccountAddress = currentSelectedTronAccountId
+ ? parseCaipAccountId(currentSelectedTronAccountId).address
+ : '';
+
+ if (
+ previousSelectedTronAccountAddress !== currentSelectedTronAccountAddress
+ ) {
+ this._notifyMultichainAccountChange(
+ currentSelectedTronAccountAddress
+ ? [currentSelectedTronAccountAddress]
+ : [],
+ TrxScope.Mainnet,
+ );
+ }
+ };
+
+ handleTronAccountChangedFromSelectedAccountChanges = (account) => {
+ if (
+ account.type === TrxAccountType.Eoa &&
+ !areAddressesEqual(account.address, this.lastSelectedTronAccountAddress)
+ ) {
+ this.lastSelectedTronAccountAddress = account.address;
+
+ let caip25Caveat;
+ try {
+ caip25Caveat = Engine.context.PermissionController.getCaveat(
+ this.channelIdOrOrigin,
+ Caip25EndowmentPermissionName,
+ Caip25CaveatType,
+ );
+ } catch {
+ // noop
+ }
+ if (!caip25Caveat) {
+ return;
+ }
+
+ const shouldNotifyTronAccountChanged =
+ caip25Caveat.value.sessionProperties?.[
+ KnownSessionProperties.TronAccountChangedNotifications
+ ];
+ if (!shouldNotifyTronAccountChanged) {
+ return;
+ }
+
+ const tronAccounts = getPermittedAccountsForScopes(caip25Caveat.value, [
+ TrxScope.Mainnet,
+ TrxScope.Shasta,
+ TrxScope.Nile,
+ ]);
+
+ const parsedTronAddresses = tronAccounts.map((caipAccountId) => {
+ const { address } = parseCaipAccountId(caipAccountId);
+ return address;
+ });
+
+ if (parsedTronAddresses.includes(account.address)) {
+ this._notifyMultichainAccountChange(
+ [account.address],
+ TrxScope.Mainnet,
+ );
+ }
+ }
+ };
+
+ handleTronAccountChangedFromSelectedAccountGroupChanges = () => {
+ const tronAccount = this.getTronAccountFromSelectedAccountGroup();
+ if (tronAccount) {
+ this.handleTronAccountChangedFromSelectedAccountChanges(tronAccount);
+ }
+ };
+
+ getTronAccountFromSelectedAccountGroup() {
+ const controllerMessenger = Engine.controllerMessenger;
+
+ const [tronAccount] = controllerMessenger.call(
+ `AccountTreeController:getAccountsFromSelectedAccountGroup`,
+ { type: TrxAccountType.Eoa },
+ );
+ return tronAccount;
+ }
+ ///: END:ONLY_INCLUDE_IF
+
sendNotificationEip1193(payload) {
DevLogger.log(`BackgroundBridge::sendNotificationEip1193: `, payload);
this.engine && this.engine.emit('notification', payload);
@@ -1224,11 +1412,11 @@ export class BackgroundBridge extends EventEmitter {
}
/**
- * For origins with a solana scope permitted, sends a wallet_notify -> metamask_accountChanged
- * event to fire for the solana scope with the currently selected solana account if any are
+ * For origins with a Solana scope permitted, sends a wallet_notify -> metamask_accountChanged
+ * event to fire for the Solana scope with the currently selected Solana account if any are
* permitted or empty array otherwise.
*
- * @param {string} origin - The origin to notify with the current solana account
+ * @param {string} origin - The origin to notify with the current Solana account
*/
notifySolanaAccountChangedForCurrentAccount() {
let caip25Caveat;
@@ -1265,12 +1453,13 @@ export class BackgroundBridge extends EventEmitter {
if (solanaAccountsChangedNotifications && solanaScope) {
const currentSolanaAccountFromSelectedAccountGroup =
- this.getNonEvmAccountFromSelectedAccountGroup();
+ this.getSolanaAccountFromSelectedAccountGroup();
if (currentSolanaAccountFromSelectedAccountGroup) {
- this._notifySolanaAccountChange([
- currentSolanaAccountFromSelectedAccountGroup.address,
- ]);
+ this._notifyMultichainAccountChange(
+ [currentSolanaAccountFromSelectedAccountGroup.address],
+ SolScope.Mainnet,
+ );
return;
}
@@ -1281,16 +1470,88 @@ export class BackgroundBridge extends EventEmitter {
if (accountIdToEmit) {
const accountAddressToEmit =
parseCaipAccountId(accountIdToEmit).address;
- this._notifySolanaAccountChange([accountAddressToEmit]);
+ this._notifyMultichainAccountChange(
+ [accountAddressToEmit],
+ SolScope.Mainnet,
+ );
+ }
+ }
+ }
+
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ /**
+ * For origins with a Tron scope permitted, sends a wallet_notify -> metamask_accountChanged
+ * event to fire for the Tron scope with the currently selected Tron account if any are
+ * permitted or empty array otherwise.
+ *
+ * @param {string} origin - The origin to notify with the current Tron account
+ */
+ notifyTronAccountChangedForCurrentAccount() {
+ let caip25Caveat;
+ try {
+ caip25Caveat = Engine.context.PermissionController.getCaveat(
+ this.channelIdOrOrigin,
+ Caip25EndowmentPermissionName,
+ Caip25CaveatType,
+ );
+ } catch (err) {
+ if (err instanceof PermissionDoesNotExistError) {
+ // suppress expected error in case that the origin
+ // does not have the target permission yet
+ return;
+ }
+ throw err;
+ }
+ if (!caip25Caveat) {
+ return;
+ }
+ const tronAccountsChangedNotifications =
+ caip25Caveat.value.sessionProperties[
+ KnownSessionProperties.TronAccountChangedNotifications
+ ];
+
+ const sessionScopes = getSessionScopes(caip25Caveat.value, {
+ getNonEvmSupportedMethods: this.getNonEvmSupportedMethods.bind(this),
+ });
+
+ const tronScope =
+ sessionScopes[TrxScope.Mainnet] ||
+ sessionScopes[TrxScope.Shasta] ||
+ sessionScopes[TrxScope.Nile];
+
+ if (tronAccountsChangedNotifications && tronScope) {
+ const currentTronAccountFromSelectedAccountGroup =
+ this.getTronAccountFromSelectedAccountGroup();
+
+ if (currentTronAccountFromSelectedAccountGroup) {
+ this._notifyMultichainAccountChange(
+ [currentTronAccountFromSelectedAccountGroup.address],
+ TrxScope.Mainnet,
+ );
+ return;
+ }
+
+ const { accounts } = tronScope;
+
+ const [accountIdToEmit] = sortMultichainAccountsByLastSelected(accounts);
+
+ if (accountIdToEmit) {
+ const accountAddressToEmit =
+ parseCaipAccountId(accountIdToEmit).address;
+ this._notifyMultichainAccountChange(
+ [accountAddressToEmit],
+ TrxScope.Mainnet,
+ );
}
}
}
+ ///: END:ONLY_INCLUDE_IF
- _notifySolanaAccountChange(value) {
+ _notifyMultichainAccountChange(value, scope) {
this.sendNotificationMultichain({
method: MultichainApiNotifications.walletNotify,
params: {
- scope: SolScope.Mainnet,
+ scope,
notification: {
method: NOTIFICATION_NAMES.accountsChanged,
params: value,
diff --git a/app/core/BackgroundBridge/BackgroundBridge.test.js b/app/core/BackgroundBridge/BackgroundBridge.test.js
index 614fc8cb645..05330ff0bce 100644
--- a/app/core/BackgroundBridge/BackgroundBridge.test.js
+++ b/app/core/BackgroundBridge/BackgroundBridge.test.js
@@ -11,6 +11,8 @@ import {
EthAccountType,
SolAccountType,
SolScope,
+ TrxAccountType,
+ TrxScope,
} from '@metamask/keyring-api';
jest.mock('../Engine', () => ({
@@ -657,7 +659,7 @@ describe('BackgroundBridge', () => {
previousValue,
);
- expect(sendNotificationSpy).not.toHaveBeenCalledWith();
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
});
it('emits nothing if currently and previously selected solana accounts did not change', () => {
@@ -699,7 +701,7 @@ describe('BackgroundBridge', () => {
previousValue,
);
- expect(sendNotificationSpy).not.toHaveBeenCalledWith();
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
});
it('emits the currently selected solana account if the currently selected solana accounts did change', () => {
@@ -1046,4 +1048,608 @@ describe('BackgroundBridge', () => {
expect(handleSolanaAccountSpy).toHaveBeenCalledTimes(1);
});
});
+
+ describe('notifyTronAccountChangedForCurrentAccount', () => {
+ it('emits nothing if there is no CAIP-25 permission', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+
+ bridge.notifyTronAccountChangedForCurrentAccount();
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing if there are no permitted tron scopes and `tron_accountChanged_notifications` session property is set', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ 'eip155:1': {
+ accounts: [],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ },
+ });
+
+ bridge.notifyTronAccountChangedForCurrentAccount();
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing if there are permitted tron accounts, but the `tron_accountChanged_notifications` session property is not set', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:someaddress`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {},
+ },
+ });
+
+ bridge.notifyTronAccountChangedForCurrentAccount();
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing if there are permitted tron scopes but no accounts and the `tron_accountChanged_notifications` session property is set', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ },
+ });
+
+ bridge.notifyTronAccountChangedForCurrentAccount();
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits a tron accountChanged event when there are permitted tron accounts and the `tron_accountChanged_notifications` session property is set', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:someaddress`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ },
+ });
+
+ bridge.notifyTronAccountChangedForCurrentAccount();
+
+ expect(sendNotificationSpy).toHaveBeenCalledWith({
+ method: 'wallet_notify',
+ params: {
+ notification: {
+ method: 'metamask_accountsChanged',
+ params: ['someaddress'],
+ },
+ scope: TrxScope.Mainnet,
+ },
+ });
+ });
+
+ it('prioritizes tron account from selected account group over scope accounts', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+
+ const selectedGroupTronAccount = {
+ type: TrxAccountType.Eoa,
+ address: 'TRXAddressExample123456789',
+ };
+ mockAccountTreeController([selectedGroupTronAccount]);
+
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:TDifferentAddress987654321`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ [KnownSessionProperties.TronAccountChangedNotifications]: true,
+ },
+ },
+ });
+
+ bridge.notifyTronAccountChangedForCurrentAccount();
+
+ expect(sendNotificationSpy).toHaveBeenCalledWith({
+ method: 'wallet_notify',
+ params: {
+ notification: {
+ method: 'metamask_accountsChanged',
+ params: ['TRXAddressExample123456789'],
+ },
+ scope: TrxScope.Mainnet,
+ },
+ });
+ });
+ });
+
+ describe('handleTronAccountChangedFromScopeChanges', () => {
+ it('emits nothing if the current and previous permissions both did not have `tron_accountChanged_notifications` session property set', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+
+ const currentValue = {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:456`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {},
+ };
+
+ const previousValue = {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:123`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {},
+ };
+
+ bridge.handleTronAccountChangedFromScopeChanges(
+ currentValue,
+ previousValue,
+ );
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing if currently and previously selected tron accounts did not change', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+
+ const currentValue = {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:123`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ };
+
+ const previousValue = {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:123`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ };
+
+ bridge.handleTronAccountChangedFromScopeChanges(
+ currentValue,
+ previousValue,
+ );
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits the currently selected tron account if the currently selected tron accounts did change', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+
+ const currentValue = {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:456`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ };
+
+ const previousValue = {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:123`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ };
+
+ bridge.handleTronAccountChangedFromScopeChanges(
+ currentValue,
+ previousValue,
+ );
+
+ expect(sendNotificationSpy).toHaveBeenCalledWith({
+ method: 'wallet_notify',
+ params: {
+ notification: {
+ method: 'metamask_accountsChanged',
+ params: ['456'],
+ },
+ scope: TrxScope.Mainnet,
+ },
+ });
+ });
+ });
+
+ describe('handleTronAccountChangedFromSelectedAccountChanges', () => {
+ it('emits nothing if the selected account is not a tron account', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:someaddress`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ },
+ });
+
+ bridge.handleTronAccountChangedFromSelectedAccountChanges({
+ type: EthAccountType.Eoa,
+ address: 'someaddress',
+ });
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing if the selected account did not change from the last seen tron account', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:someaddress`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ },
+ });
+ bridge.lastSelectedTronAccountAddress = 'someaddress';
+
+ bridge.handleTronAccountChangedFromSelectedAccountChanges({
+ type: TrxAccountType.Eoa,
+ address: 'someaddress',
+ });
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing if there is no CAIP-25 permission', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue();
+
+ bridge.handleTronAccountChangedFromSelectedAccountChanges({
+ type: TrxAccountType.Eoa,
+ address: 'someaddress',
+ });
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing if the `tron_accountChanged_notifications` session property is not set', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:someaddress`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {},
+ },
+ });
+
+ bridge.handleTronAccountChangedFromSelectedAccountChanges({
+ type: TrxAccountType.Eoa,
+ address: 'someaddress',
+ });
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing if the selected account does not match a permitted tron account', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:someaddress`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ },
+ });
+
+ bridge.handleTronAccountChangedFromSelectedAccountChanges({
+ type: TrxAccountType.Eoa,
+ address: 'differentaddress',
+ });
+
+ expect(sendNotificationSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits a tron accountChanged event for the selected account if it does match a permitted tron account', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const sendNotificationSpy = jest.spyOn(
+ bridge,
+ 'sendNotificationMultichain',
+ );
+ PermissionController.getCaveat.mockReturnValue({
+ type: Caip25CaveatType,
+ value: {
+ requiredScopes: {},
+ optionalScopes: {
+ [TrxScope.Mainnet]: {
+ accounts: [`${TrxScope.Mainnet}:someaddress`],
+ },
+ },
+ isMultichainOrigin: true,
+ sessionProperties: {
+ tron_accountChanged_notifications: true,
+ },
+ },
+ });
+
+ bridge.handleTronAccountChangedFromSelectedAccountChanges({
+ type: TrxAccountType.Eoa,
+ address: 'someaddress',
+ });
+
+ expect(sendNotificationSpy).toHaveBeenCalledWith({
+ method: 'wallet_notify',
+ params: {
+ notification: {
+ method: 'metamask_accountsChanged',
+ params: ['someaddress'],
+ },
+ scope: TrxScope.Mainnet,
+ },
+ });
+ });
+ });
+
+ describe('handleTronAccountChangedFromSelectedAccountGroupChanges', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('emits nothing when AccountTreeController returns no accounts', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const handleTronAccountSpy = jest.spyOn(
+ bridge,
+ 'handleTronAccountChangedFromSelectedAccountChanges',
+ );
+
+ mockAccountTreeController([]);
+
+ bridge.handleTronAccountChangedFromSelectedAccountGroupChanges();
+
+ expect(Engine.controllerMessenger.call).toHaveBeenCalledWith(
+ 'AccountTreeController:getAccountsFromSelectedAccountGroup',
+ { type: TrxAccountType.Eoa },
+ );
+ expect(handleTronAccountSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing when AccountTreeController returns only non-Tron accounts', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const handleTronAccountSpy = jest.spyOn(
+ bridge,
+ 'handleTronAccountChangedFromSelectedAccountChanges',
+ );
+
+ const mockAccounts = [
+ { type: EthAccountType.Eoa, address: 'eth-address-1' },
+ { type: EthAccountType.Erc4337, address: 'eth-address-2' },
+ ];
+
+ mockAccountTreeController(mockAccounts);
+
+ bridge.handleTronAccountChangedFromSelectedAccountGroupChanges();
+
+ expect(Engine.controllerMessenger.call).toHaveBeenCalledWith(
+ 'AccountTreeController:getAccountsFromSelectedAccountGroup',
+ { type: TrxAccountType.Eoa },
+ );
+ expect(handleTronAccountSpy).not.toHaveBeenCalled();
+ });
+
+ it('calls handleTronAccountChangedFromSelectedAccountChanges when AccountTreeController returns a Tron account', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const handleTronAccountSpy = jest.spyOn(
+ bridge,
+ 'handleTronAccountChangedFromSelectedAccountChanges',
+ );
+
+ const mockTronAccount = {
+ type: TrxAccountType.Eoa,
+ address: 'tron-address-1',
+ };
+ const mockAccounts = [mockTronAccount];
+
+ mockAccountTreeController(mockAccounts);
+
+ bridge.handleTronAccountChangedFromSelectedAccountGroupChanges();
+
+ expect(Engine.controllerMessenger.call).toHaveBeenCalledWith(
+ 'AccountTreeController:getAccountsFromSelectedAccountGroup',
+ { type: TrxAccountType.Eoa },
+ );
+ expect(handleTronAccountSpy).toHaveBeenCalledWith(mockTronAccount);
+ });
+
+ it('processes only the first Tron account when multiple valid Tron accounts exist', () => {
+ const url = 'https:www.mock.io';
+ const bridge = setupBackgroundBridge(url);
+ const handleTronAccountSpy = jest.spyOn(
+ bridge,
+ 'handleTronAccountChangedFromSelectedAccountChanges',
+ );
+
+ const mockTronAccount1 = {
+ type: TrxAccountType.Eoa,
+ address: 'first-tron-address',
+ };
+ const mockTronAccount2 = {
+ type: TrxAccountType.Eoa,
+ address: 'second-tron-address',
+ };
+ const mockTronAccount3 = {
+ type: TrxAccountType.Eoa,
+ address: 'third-tron-address',
+ };
+
+ mockAccountTreeController([
+ mockTronAccount1,
+ mockTronAccount2,
+ mockTronAccount3,
+ ]);
+
+ bridge.handleTronAccountChangedFromSelectedAccountGroupChanges();
+
+ expect(Engine.controllerMessenger.call).toHaveBeenCalledWith(
+ 'AccountTreeController:getAccountsFromSelectedAccountGroup',
+ { type: TrxAccountType.Eoa },
+ );
+ expect(handleTronAccountSpy).toHaveBeenCalledWith(mockTronAccount1);
+ expect(handleTronAccountSpy).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/app/core/Engine/controllers/token-list-controller-init.ts b/app/core/Engine/controllers/token-list-controller-init.ts
index f805e6152e3..2c4f3bf3d9e 100644
--- a/app/core/Engine/controllers/token-list-controller-init.ts
+++ b/app/core/Engine/controllers/token-list-controller-init.ts
@@ -17,12 +17,13 @@ export const tokenListControllerInit: ControllerInitFunction<
TokenListController,
TokenListControllerMessenger,
TokenListControllerInitMessenger
-> = ({ controllerMessenger, initMessenger, getController }) => {
+> = ({ controllerMessenger, initMessenger, getController, persistedState }) => {
const networkController = getController('NetworkController');
const controller = new TokenListController({
messenger: controllerMessenger,
chainId: getGlobalChainId(networkController),
+ state: persistedState.TokenListController,
onNetworkStateChange: (listener) =>
initMessenger.subscribe(
'NetworkController:stateChange',
diff --git a/app/core/redux/slices/card/index.test.ts b/app/core/redux/slices/card/index.test.ts
index f0a5e9fed4e..cd3565f9e1e 100644
--- a/app/core/redux/slices/card/index.test.ts
+++ b/app/core/redux/slices/card/index.test.ts
@@ -36,11 +36,13 @@ import cardReducer, {
selectSelectedCountry,
selectContactVerificationId,
selectConsentSetId,
+ resetAuthenticatedData,
} from '.';
import {
CardTokenAllowance,
AllowanceState,
} from '../../../../components/UI/Card/types';
+import { Region } from '../../../../components/UI/Card/components/Onboarding/RegionSelectorModal';
// Mock the multichain selectors
jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({
@@ -116,6 +118,22 @@ const MOCK_PRIORITY_TOKEN: CardTokenAllowance = {
const testAddress = '0x1234567890123456789012345678901234567890';
+// Mock Region objects for testing
+const MOCK_REGION_US: Region = {
+ key: 'US',
+ name: 'United States',
+ emoji: 'πΊπΈ',
+};
+const MOCK_REGION_GB: Region = {
+ key: 'GB',
+ name: 'United Kingdom',
+ emoji: 'π¬π§',
+};
+const MOCK_REGION_CA: Region = { key: 'CA', name: 'Canada', emoji: 'π¨π¦' };
+const MOCK_REGION_DE: Region = { key: 'DE', name: 'Germany', emoji: 'π©πͺ' };
+const MOCK_REGION_FR: Region = { key: 'FR', name: 'France', emoji: 'π«π·' };
+const MOCK_REGION_JP: Region = { key: 'JP', name: 'Japan', emoji: 'π―π΅' };
+
const CARD_STATE_MOCK: CardSliceState = {
cardholderAccounts: CARDHOLDER_ACCOUNTS_MOCK,
priorityTokensByAddress: {
@@ -363,7 +381,7 @@ describe('Card Selectors', () => {
});
it('should return the selected country when set', () => {
- const selectedCountry = 'US';
+ const selectedCountry = MOCK_REGION_US;
const stateWithCountry: CardSliceState = {
...initialState,
onboarding: {
@@ -378,20 +396,27 @@ describe('Card Selectors', () => {
});
it('should handle different country codes', () => {
- const countryCodes = ['US', 'GB', 'CA', 'DE', 'FR', 'JP'];
-
- countryCodes.forEach((countryCode) => {
+ const regions: Region[] = [
+ MOCK_REGION_US,
+ MOCK_REGION_GB,
+ MOCK_REGION_CA,
+ MOCK_REGION_DE,
+ MOCK_REGION_FR,
+ MOCK_REGION_JP,
+ ];
+
+ regions.forEach((region) => {
const stateWithCountry: CardSliceState = {
...initialState,
onboarding: {
...initialState.onboarding,
- selectedCountry: countryCode,
+ selectedCountry: region,
},
};
const mockRootState = {
card: stateWithCountry,
} as unknown as RootState;
- expect(selectSelectedCountry(mockRootState)).toBe(countryCode);
+ expect(selectSelectedCountry(mockRootState)).toBe(region);
});
});
});
@@ -630,7 +655,7 @@ describe('Card Reducer', () => {
describe('setSelectedCountry', () => {
it('should set selectedCountry', () => {
- const country = 'US';
+ const country = MOCK_REGION_US;
const state = cardReducer(initialState, setSelectedCountry(country));
expect(state.onboarding.selectedCountry).toBe(country);
// ensure other parts of state untouched
@@ -644,10 +669,10 @@ describe('Card Reducer', () => {
...initialState,
onboarding: {
...initialState.onboarding,
- selectedCountry: 'GB',
+ selectedCountry: MOCK_REGION_GB,
},
};
- const newCountry = 'CA';
+ const newCountry = MOCK_REGION_CA;
const state = cardReducer(current, setSelectedCountry(newCountry));
expect(state.onboarding.selectedCountry).toBe(newCountry);
});
@@ -657,7 +682,7 @@ describe('Card Reducer', () => {
...initialState,
onboarding: {
...initialState.onboarding,
- selectedCountry: 'US',
+ selectedCountry: MOCK_REGION_US,
},
};
const state = cardReducer(current, setSelectedCountry(null));
@@ -756,7 +781,7 @@ describe('Card Reducer', () => {
...initialState,
onboarding: {
onboardingId: 'test-id',
- selectedCountry: 'US',
+ selectedCountry: MOCK_REGION_US,
contactVerificationId: 'verification-123',
consentSetId: 'consent-456',
},
@@ -1019,6 +1044,116 @@ describe('Card Reducer', () => {
});
});
});
+
+ describe('resetAuthenticatedData', () => {
+ it('resets all authenticated-related state to initial values', () => {
+ const currentState: CardSliceState = {
+ ...initialState,
+ authenticatedPriorityToken: MOCK_PRIORITY_TOKEN,
+ authenticatedPriorityTokenLastFetched: new Date(
+ '2025-08-21T10:00:00Z',
+ ),
+ userCardLocation: 'us',
+ isAuthenticated: true,
+ };
+
+ const state = cardReducer(currentState, resetAuthenticatedData());
+
+ expect(state.authenticatedPriorityToken).toBeNull();
+ expect(state.authenticatedPriorityTokenLastFetched).toBeNull();
+ expect(state.userCardLocation).toBe('international');
+ expect(state.isAuthenticated).toBe(false);
+ });
+
+ it('does not affect other state properties', () => {
+ const currentState: CardSliceState = {
+ ...initialState,
+ cardholderAccounts: ['0x123'],
+ geoLocation: 'US',
+ isLoaded: true,
+ hasViewedCardButton: true,
+ alwaysShowCardButton: true,
+ authenticatedPriorityToken: MOCK_PRIORITY_TOKEN,
+ authenticatedPriorityTokenLastFetched: new Date(
+ '2025-08-21T10:00:00Z',
+ ),
+ userCardLocation: 'us',
+ isAuthenticated: true,
+ priorityTokensByAddress: {
+ [testAddress.toLowerCase()]: MOCK_PRIORITY_TOKEN,
+ },
+ lastFetchedByAddress: {
+ [testAddress.toLowerCase()]: new Date('2025-08-21T10:00:00Z'),
+ },
+ onboarding: {
+ onboardingId: 'test-id',
+ selectedCountry: MOCK_REGION_US,
+ contactVerificationId: 'verification-123',
+ consentSetId: 'consent-456',
+ },
+ };
+
+ const state = cardReducer(currentState, resetAuthenticatedData());
+
+ // Authenticated data is reset
+ expect(state.authenticatedPriorityToken).toBeNull();
+ expect(state.authenticatedPriorityTokenLastFetched).toBeNull();
+ expect(state.userCardLocation).toBe('international');
+ expect(state.isAuthenticated).toBe(false);
+
+ // Other state properties remain unchanged
+ expect(state.cardholderAccounts).toEqual(['0x123']);
+ expect(state.geoLocation).toBe('US');
+ expect(state.isLoaded).toBe(true);
+ expect(state.hasViewedCardButton).toBe(true);
+ expect(state.alwaysShowCardButton).toBe(true);
+ expect(
+ state.priorityTokensByAddress[testAddress.toLowerCase()],
+ ).toEqual(MOCK_PRIORITY_TOKEN);
+ expect(state.lastFetchedByAddress[testAddress.toLowerCase()]).toEqual(
+ new Date('2025-08-21T10:00:00Z'),
+ );
+ expect(state.onboarding).toEqual({
+ onboardingId: 'test-id',
+ selectedCountry: MOCK_REGION_US,
+ contactVerificationId: 'verification-123',
+ consentSetId: 'consent-456',
+ });
+ });
+
+ it('works when authenticated data is already at initial values', () => {
+ const state = cardReducer(initialState, resetAuthenticatedData());
+
+ expect(state.authenticatedPriorityToken).toBeNull();
+ expect(state.authenticatedPriorityTokenLastFetched).toBeNull();
+ expect(state.userCardLocation).toBe('international');
+ expect(state.isAuthenticated).toBe(false);
+ });
+
+ it('resets userCardLocation to international from us', () => {
+ const currentState: CardSliceState = {
+ ...initialState,
+ userCardLocation: 'us',
+ };
+
+ const state = cardReducer(currentState, resetAuthenticatedData());
+
+ expect(state.userCardLocation).toBe('international');
+ });
+
+ it('resets string date format for authenticatedPriorityTokenLastFetched', () => {
+ const currentState: CardSliceState = {
+ ...initialState,
+ authenticatedPriorityTokenLastFetched: '2025-08-21T10:00:00Z',
+ isAuthenticated: true,
+ };
+
+ const state = cardReducer(currentState, resetAuthenticatedData());
+
+ expect(state.authenticatedPriorityTokenLastFetched).toBeNull();
+ expect(state.isAuthenticated).toBe(false);
+ });
+ });
});
});
diff --git a/app/core/redux/slices/card/index.ts b/app/core/redux/slices/card/index.ts
index ba821a3ec5b..81316520e86 100644
--- a/app/core/redux/slices/card/index.ts
+++ b/app/core/redux/slices/card/index.ts
@@ -15,10 +15,11 @@ import {
selectDisplayCardButtonFeatureFlag,
} from '../../../../selectors/featureFlagController/card';
import { handleLocalAuthentication } from '../../../../components/UI/Card/util/handleLocalAuthentication';
+import { Region } from '../../../../components/UI/Card/components/Onboarding/RegionSelectorModal';
export interface OnboardingState {
onboardingId: string | null;
- selectedCountry: string | null; // ISO 3166 alpha-2 country code, e.g. 'US'
+ selectedCountry: Region | null;
contactVerificationId: string | null;
consentSetId: string | null;
}
@@ -137,7 +138,7 @@ const slice = createSlice({
setOnboardingId: (state, action: PayloadAction) => {
state.onboarding.onboardingId = action.payload;
},
- setSelectedCountry: (state, action: PayloadAction) => {
+ setSelectedCountry: (state, action: PayloadAction) => {
state.onboarding.selectedCountry = action.payload;
},
setContactVerificationId: (state, action: PayloadAction) => {
diff --git a/locales/languages/en.json b/locales/languages/en.json
index ea7d163e0aa..d8fe169bf99 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -2355,6 +2355,7 @@
"lists": "Token Lists",
"hide_cta": "Hide token",
"options": {
+ "title": "Token options",
"view_on_portfolio": "View on Portfolio",
"view_on_block": "View on block explorer",
"token_details": "Token details",
@@ -6614,7 +6615,11 @@
"confirm_button": "Confirm",
"retry_button": "Try again",
"close_button": "Got it",
+ "region_selector": {
+ "title": "Select your Region"
+ },
"errors": {
+ "no_region_results": "No region matches {{searchString}}",
"email_already_exists": "Email already exists. Please use a different email.",
"invalid_email_format": "Invalid email format. Please check your email and try again.",
"invalid_verification_code": "Invalid verification code. Please check your code and try again.",
diff --git a/package.json b/package.json
index 2fcccd8a286..6ff97854faa 100644
--- a/package.json
+++ b/package.json
@@ -203,7 +203,7 @@
"@metamask/bitcoin-wallet-snap": "^1.8.0",
"@metamask/bridge-controller": "^64.1.0",
"@metamask/bridge-status-controller": "^64.0.1",
- "@metamask/chain-agnostic-permission": "^1.2.2",
+ "@metamask/chain-agnostic-permission": "^1.3.0",
"@metamask/composable-controller": "^12.0.0",
"@metamask/controller-utils": "^11.16.0",
"@metamask/core-backend": "^4.1.0",
@@ -245,8 +245,8 @@
"@metamask/mobile-wallet-protocol-core": "^0.3.1",
"@metamask/mobile-wallet-protocol-wallet-client": "^0.2.1",
"@metamask/multichain-account-service": "^3.0.0",
- "@metamask/multichain-api-client": "^0.7.0",
- "@metamask/multichain-api-middleware": "1.2.4",
+ "@metamask/multichain-api-client": "^0.10.1",
+ "@metamask/multichain-api-middleware": "1.2.5",
"@metamask/multichain-network-controller": "^2.0.0",
"@metamask/multichain-transactions-controller": "^6.0.0",
"@metamask/native-utils": "^0.8.0",
diff --git a/scripts/build.sh b/scripts/build.sh
index 7c0c54a341f..54827d14451 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -547,8 +547,6 @@ generateAndroidBinary() {
local reactNativeArchitecturesArg=""
# Define Test build type arg
local testBuildTypeArg=""
- # Define Gradle debug flags
- local gradleDebugFlags=""
# Check if configuration is valid
if [ "$configuration" != "Debug" ] && [ "$configuration" != "Release" ] ; then
@@ -574,19 +572,14 @@ generateAndroidBinary() {
if [ "$METAMASK_ENVIRONMENT" = "e2e" ] ; then
# Only build for x86_64 for E2E builds
reactNativeArchitecturesArg="-PreactNativeArchitectures=x86_64"
- # Enable Gradle debugging flags for E2E builds to investigate Daemon disappearance issues
- gradleDebugFlags="--stacktrace --info"
- echo "π E2E build: Enabling Gradle debugging flags (--stacktrace --info)"
fi
fi
# Generate Android APKs
echo "Generating Android binary for ($flavor) flavor with ($configuration) configuration"
- ./gradlew $assembleApkTask $assembleTestApkTask $testBuildTypeArg $reactNativeArchitecturesArg $gradleDebugFlags
+ ./gradlew $assembleApkTask $assembleTestApkTask $testBuildTypeArg $reactNativeArchitecturesArg
- # Skip AAB bundle for E2E environments - AAB cannot be installed on emulators
- # and is only needed for Play Store distribution
- if [ "$configuration" = "Release" ] && [ "$METAMASK_ENVIRONMENT" != "e2e" ] ; then
+ if [ "$configuration" = "Release" ] ; then
# Generate AAB bundle (not needed for E2E)
bundleConfiguration="bundle${flavor}Release"
echo "Generating AAB bundle for ($flavor) flavor with ($configuration) configuration"
diff --git a/yarn.lock b/yarn.lock
index feacceff78f..bdf7a705e76 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7409,18 +7409,18 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/chain-agnostic-permission@npm:^1.2.1, @metamask/chain-agnostic-permission@npm:^1.2.2":
- version: 1.2.2
- resolution: "@metamask/chain-agnostic-permission@npm:1.2.2"
+"@metamask/chain-agnostic-permission@npm:^1.2.1, @metamask/chain-agnostic-permission@npm:^1.3.0":
+ version: 1.3.0
+ resolution: "@metamask/chain-agnostic-permission@npm:1.3.0"
dependencies:
"@metamask/api-specs": "npm:^0.14.0"
- "@metamask/controller-utils": "npm:^11.14.1"
- "@metamask/network-controller": "npm:^25.0.0"
- "@metamask/permission-controller": "npm:^12.0.0"
+ "@metamask/controller-utils": "npm:^11.16.0"
+ "@metamask/network-controller": "npm:^27.0.0"
+ "@metamask/permission-controller": "npm:^12.1.1"
"@metamask/rpc-errors": "npm:^7.0.2"
"@metamask/utils": "npm:^11.8.1"
lodash: "npm:^4.17.21"
- checksum: 10/cf647bd5e3d38de912439c52a75a9fde91cf2f47f5cbda49258d8c0a0b16e0cb503be430462d7fb96b76cc7fb52402eb2b2b9f1262c75bf028793963709a402c
+ checksum: 10/0a5cd61a32af9e605b0378697744a6ae964a8ca0ff599b207fa6184176b09b122f4af303c2a70abc909606a10c3eabaf06a87dc9f205f9e74626a7061e6f33d3
languageName: node
linkType: hard
@@ -7664,18 +7664,6 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/eth-block-tracker@npm:^14.0.0":
- version: 14.0.0
- resolution: "@metamask/eth-block-tracker@npm:14.0.0"
- dependencies:
- "@metamask/eth-json-rpc-provider": "npm:^5.0.1"
- "@metamask/safe-event-emitter": "npm:^3.0.0"
- "@metamask/utils": "npm:^11.8.1"
- json-rpc-random-id: "npm:^1.0.1"
- checksum: 10/63515bcc5fad22dba78d824f1fafd989e9a6770b318b91f46ba4a68326fafbeb9929146a82713f92e4920d489dc3a9bb81b5f457946053e8cf89bc4062788b36
- languageName: node
- linkType: hard
-
"@metamask/eth-block-tracker@npm:^15.0.0":
version: 15.0.0
resolution: "@metamask/eth-block-tracker@npm:15.0.0"
@@ -7728,24 +7716,6 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/eth-json-rpc-middleware@npm:^21.0.0":
- version: 21.0.0
- resolution: "@metamask/eth-json-rpc-middleware@npm:21.0.0"
- dependencies:
- "@metamask/eth-block-tracker": "npm:^14.0.0"
- "@metamask/eth-json-rpc-provider": "npm:^5.0.1"
- "@metamask/eth-sig-util": "npm:^8.2.0"
- "@metamask/json-rpc-engine": "npm:^10.1.1"
- "@metamask/rpc-errors": "npm:^7.0.2"
- "@metamask/superstruct": "npm:^3.1.0"
- "@metamask/utils": "npm:^11.8.1"
- klona: "npm:^2.0.6"
- pify: "npm:^5.0.0"
- safe-stable-stringify: "npm:^2.4.3"
- checksum: 10/3859035c7647202160fa73a8b2837153454b0097713bf822b4c22a14e971176895546c548e5899f0e600d1adbc3b79071ad468a8a1df3f06247ff5bd8b867353
- languageName: node
- linkType: hard
-
"@metamask/eth-json-rpc-middleware@npm:^22.0.0":
version: 22.0.0
resolution: "@metamask/eth-json-rpc-middleware@npm:22.0.0"
@@ -7778,7 +7748,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/eth-json-rpc-provider@npm:^5.0.0, @metamask/eth-json-rpc-provider@npm:^5.0.1":
+"@metamask/eth-json-rpc-provider@npm:^5.0.0":
version: 5.0.1
resolution: "@metamask/eth-json-rpc-provider@npm:5.0.1"
dependencies:
@@ -8392,29 +8362,29 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/multichain-api-client@npm:^0.7.0":
- version: 0.7.0
- resolution: "@metamask/multichain-api-client@npm:0.7.0"
- checksum: 10/82c58ba382d651430cc27c15c1881ac9943131aebd66b1e404735795bcc4125cb20b74843504ffd207ba9e8c21272facfcd65a91c6f16846bf1aee4e83ed1fca
+"@metamask/multichain-api-client@npm:^0.10.1":
+ version: 0.10.1
+ resolution: "@metamask/multichain-api-client@npm:0.10.1"
+ checksum: 10/adedb90f433cd619eee54943197dc32eb30e461173e928ea698ec70bf8c6780d9ac0bb9b12a22a17f984170841c071d228aa49e6988d5060e8a30e99c433a8f5
languageName: node
linkType: hard
-"@metamask/multichain-api-middleware@npm:1.2.4":
- version: 1.2.4
- resolution: "@metamask/multichain-api-middleware@npm:1.2.4"
+"@metamask/multichain-api-middleware@npm:1.2.5":
+ version: 1.2.5
+ resolution: "@metamask/multichain-api-middleware@npm:1.2.5"
dependencies:
"@metamask/api-specs": "npm:^0.14.0"
- "@metamask/chain-agnostic-permission": "npm:^1.2.2"
- "@metamask/controller-utils": "npm:^11.14.1"
- "@metamask/json-rpc-engine": "npm:^10.1.1"
- "@metamask/network-controller": "npm:^25.0.0"
- "@metamask/permission-controller": "npm:^12.1.0"
+ "@metamask/chain-agnostic-permission": "npm:^1.3.0"
+ "@metamask/controller-utils": "npm:^11.16.0"
+ "@metamask/json-rpc-engine": "npm:^10.2.0"
+ "@metamask/network-controller": "npm:^27.0.0"
+ "@metamask/permission-controller": "npm:^12.1.1"
"@metamask/rpc-errors": "npm:^7.0.2"
"@metamask/utils": "npm:^11.8.1"
"@open-rpc/meta-schema": "npm:^1.14.6"
"@open-rpc/schema-utils-js": "npm:^2.0.5"
jsonschema: "npm:^1.4.1"
- checksum: 10/0572858dec7bb47a75d4da531e3d030fe0ba67aee46dfd72daab7fe8669da0216d344fa17cee1cd8d0c428c4c1b975e1a2085fd7064114a8ebb5e4ce01b32782
+ checksum: 10/57c90313fbb04c0d8ff2a79ab863930dc4fb0f678c219d3beb3aed55782cf419f9b4042479e0c9a2eb54ab0d0d11a4ec5ae1cd5d00e2882527c820a8b0f83104
languageName: node
linkType: hard
@@ -8492,35 +8462,6 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/network-controller@npm:^25.0.0":
- version: 25.0.0
- resolution: "@metamask/network-controller@npm:25.0.0"
- dependencies:
- "@metamask/base-controller": "npm:^9.0.0"
- "@metamask/controller-utils": "npm:^11.14.1"
- "@metamask/eth-block-tracker": "npm:^14.0.0"
- "@metamask/eth-json-rpc-infura": "npm:^10.3.0"
- "@metamask/eth-json-rpc-middleware": "npm:^21.0.0"
- "@metamask/eth-json-rpc-provider": "npm:^5.0.1"
- "@metamask/eth-query": "npm:^4.0.0"
- "@metamask/json-rpc-engine": "npm:^10.1.1"
- "@metamask/messenger": "npm:^0.3.0"
- "@metamask/rpc-errors": "npm:^7.0.2"
- "@metamask/swappable-obj-proxy": "npm:^2.3.0"
- "@metamask/utils": "npm:^11.8.1"
- async-mutex: "npm:^0.5.0"
- fast-deep-equal: "npm:^3.1.3"
- immer: "npm:^9.0.6"
- loglevel: "npm:^1.8.1"
- reselect: "npm:^5.1.1"
- uri-js: "npm:^4.4.1"
- uuid: "npm:^8.3.2"
- peerDependencies:
- "@metamask/error-reporting-service": ^3.0.0
- checksum: 10/1fcad4f22f4b090b7de64134fdc6e4085f5bb4bb1dea53d7d25122c708f18d90a61caa5e6098a483ecbbb8cd93cdd2a145484cb129c7e8def4a9b726e9b4ee4c
- languageName: node
- linkType: hard
-
"@metamask/network-controller@npm:^27.0.0":
version: 27.0.0
resolution: "@metamask/network-controller@npm:27.0.0"
@@ -34225,7 +34166,7 @@ __metadata:
"@metamask/bridge-status-controller": "npm:^64.0.1"
"@metamask/browser-passworder": "npm:^5.0.0"
"@metamask/build-utils": "npm:^3.0.0"
- "@metamask/chain-agnostic-permission": "npm:^1.2.2"
+ "@metamask/chain-agnostic-permission": "npm:^1.3.0"
"@metamask/composable-controller": "npm:^12.0.0"
"@metamask/controller-utils": "npm:^11.16.0"
"@metamask/core-backend": "npm:^4.1.0"
@@ -34271,8 +34212,8 @@ __metadata:
"@metamask/mobile-wallet-protocol-core": "npm:^0.3.1"
"@metamask/mobile-wallet-protocol-wallet-client": "npm:^0.2.1"
"@metamask/multichain-account-service": "npm:^3.0.0"
- "@metamask/multichain-api-client": "npm:^0.7.0"
- "@metamask/multichain-api-middleware": "npm:1.2.4"
+ "@metamask/multichain-api-client": "npm:^0.10.1"
+ "@metamask/multichain-api-middleware": "npm:1.2.5"
"@metamask/multichain-network-controller": "npm:^2.0.0"
"@metamask/multichain-transactions-controller": "npm:^6.0.0"
"@metamask/native-utils": "npm:^0.8.0"