diff --git a/.github/actions/android-play-store-manifest-check/action.yml b/.github/actions/android-play-store-manifest-check/action.yml
new file mode 100644
index 000000000000..91ee7c37a17b
--- /dev/null
+++ b/.github/actions/android-play-store-manifest-check/action.yml
@@ -0,0 +1,165 @@
+name: 'Android Play Store manifest validation'
+description: >
+ Builds the prodRelease variant used for Play uploads (same manifest merge path), runs Android Lint
+ on that variant, produces a signed release bundle using the repo debug keystore via AGP signing
+ injection, and runs bundletool validate on the AAB.
+
+runs:
+ using: composite
+ steps:
+ - name: Set up JDK 17
+ uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ # Third-party actions such as android-actions/setup-android are not allowlisted for this org;
+ # install SDK components with Google's cmdline-tools + sdkmanager (same packages as android/build.gradle).
+ - name: Cache Gradle
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-android-manifest-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-android-manifest-
+
+ - name: Cache Android SDK
+ id: android-sdk-cache
+ uses: actions/cache@v4
+ with:
+ path: ~/android-sdk
+ key: ${{ runner.os }}-android-sdk-manifest-v2-cmd11076708-api35-ndk26.1-cmake322
+
+ - name: Install Android SDK (cmdline-tools + packages)
+ if: steps.android-sdk-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: |
+ set -euo pipefail
+ CMDLINE_TOOLS_BUILD=11076708
+ SDK_ROOT="${ANDROID_SDK_ROOT:-$HOME/android-sdk}"
+ mkdir -p "$SDK_ROOT"
+ TMPZIP="$(mktemp)"
+ curl -fsSL -o "$TMPZIP" \
+ "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_BUILD}_latest.zip"
+ rm -rf "$SDK_ROOT/cmdline-tools"
+ unzip -q "$TMPZIP" -d "$SDK_ROOT/cmdline-tools-staging"
+ rm -f "$TMPZIP"
+ mkdir -p "$SDK_ROOT/cmdline-tools"
+ mv "$SDK_ROOT/cmdline-tools-staging/cmdline-tools" "$SDK_ROOT/cmdline-tools/latest"
+ rm -rf "$SDK_ROOT/cmdline-tools-staging"
+
+ SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
+ # yes exits with SIGPIPE when sdkmanager closes stdin; with pipefail that fails the step.
+ set +o pipefail
+ yes | "$SDKMANAGER" --sdk_root="$SDK_ROOT" --licenses >/dev/null
+ set -o pipefail
+ "$SDKMANAGER" --sdk_root="$SDK_ROOT" \
+ "platform-tools" \
+ "platforms;android-33" \
+ "platforms;android-34" \
+ "platforms;android-35" \
+ "build-tools;33.0.0" \
+ "build-tools;34.0.0" \
+ "build-tools;35.0.0" \
+ "ndk;26.1.10909125" \
+ "cmake;3.22.1"
+
+ - name: Configure Android SDK environment
+ shell: bash
+ run: |
+ SDK_ROOT="${ANDROID_SDK_ROOT:-$HOME/android-sdk}"
+ echo "ANDROID_SDK_ROOT=${SDK_ROOT}" >> "$GITHUB_ENV"
+ echo "ANDROID_HOME=${SDK_ROOT}" >> "$GITHUB_ENV"
+ echo "${SDK_ROOT}/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
+ echo "${SDK_ROOT}/platform-tools" >> "$GITHUB_PATH"
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: '.nvmrc'
+ cache: yarn
+
+ - name: Restore .metamask folder
+ id: restore-metamask
+ uses: actions/cache@v4
+ with:
+ path: .metamask
+ key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }}
+
+ - name: Install Foundry if cache missed
+ if: steps.restore-metamask.outputs.cache-hit != 'true'
+ shell: bash
+ run: yarn install:foundryup
+
+ - name: Install Yarn dependencies with retry
+ uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
+ with:
+ timeout_minutes: 10
+ max_attempts: 3
+ retry_wait_seconds: 30
+ command: yarn install --immutable
+
+ - name: Project setup for CI (Android tooling path)
+ shell: bash
+ run: yarn setup:github-ci --no-build-ios
+
+ - name: Write google-services.json
+ shell: bash
+ run: |
+ set -euo pipefail
+ # Caller workflow must set GOOGLE_SERVICES_B64_ANDROID (job env from GitHub secret).
+ if [[ -z "${GOOGLE_SERVICES_B64_ANDROID:-}" ]]; then
+ echo "::error::GOOGLE_SERVICES_B64_ANDROID is not set; cannot run Play-shaped Android build."
+ exit 1
+ fi
+ echo -n "$GOOGLE_SERVICES_B64_ANDROID" | base64 -d > android/app/google-services.json
+
+ - name: Configure Gradle for GitHub Actions
+ shell: bash
+ run: cp android/gradle.properties.github android/gradle.properties
+
+ - name: Cache bundletool
+ id: bundletool-cache
+ uses: actions/cache@v4
+ with:
+ path: ${{ runner.temp }}/bundletool-all.jar
+ key: bundletool-1.18.3-jar
+
+ - name: Download bundletool
+ if: steps.bundletool-cache.outputs.cache-hit != 'true'
+ shell: bash
+ env:
+ BUNDLETOOL_URL: https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar
+ run: curl -fsSL -o "${RUNNER_TEMP}/bundletool-all.jar" "$BUNDLETOOL_URL"
+
+ - name: Lint, bundle (debug-signed), validate with bundletool
+ shell: bash
+ env:
+ SENTRY_DISABLE_AUTO_UPLOAD: true
+ run: |
+ set -euo pipefail
+ STORE_FILE="${GITHUB_WORKSPACE}/android/keystores/debug.keystore"
+ cd android
+ chmod +x ./gradlew
+ ./gradlew \
+ :app:lintProdRelease \
+ :app:bundleProdRelease \
+ --no-daemon \
+ --stacktrace \
+ -Pandroid.injected.signing.store.file="$STORE_FILE" \
+ -Pandroid.injected.signing.store.password=android \
+ -Pandroid.injected.signing.key.alias=androiddebugkey \
+ -Pandroid.injected.signing.key.password=android
+
+ # Exactly one AAB for prodRelease (main prod Play track).
+ shopt -s nullglob
+ aabs=( "$GITHUB_WORKSPACE/android/app/build/outputs/bundle/prodRelease/"*.aab )
+ shopt -u nullglob
+ if [[ "${#aabs[@]}" -ne 1 ]]; then
+ echo "::error::Expected exactly one .aab under prodRelease; found ${#aabs[@]}"
+ find "$GITHUB_WORKSPACE/android/app/build/outputs/bundle/prodRelease" -maxdepth 2 -print || true
+ exit 1
+ fi
+ echo "Validating bundle: ${aabs[0]}"
+ java -jar "${RUNNER_TEMP}/bundletool-all.jar" validate --bundle="${aabs[0]}"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6996d5725389..40e27cec6734 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -455,6 +455,47 @@ jobs:
SCRIPT_NAME="build:${{ matrix.platform }}:${SCRIPT_BASE//-/:}"
yarn "$SCRIPT_NAME"
+ # Prod Play–shaped Android: lint merged prodRelease manifest, then validate the AAB produced above (bundletool).
+ # Skips e2e (no AAB) and Debug dev builds. Mirrors former ci.yml android-play-store-manifest-check without a second bundle.
+ - name: Cache bundletool (Android Play bundle validation)
+ id: bundletool-cache-play-check
+ if: >-
+ success() &&
+ matrix.platform == 'android' &&
+ env.CONFIGURATION != 'Debug' &&
+ env.METAMASK_BUILD_TYPE == 'main' &&
+ env.METAMASK_ENVIRONMENT != 'e2e'
+ uses: actions/cache@v4
+ with:
+ path: ${{ runner.temp }}/bundletool-all.jar
+ key: bundletool-1.18.3-jar
+
+ - name: Android Play Store lint and bundle validation (prodRelease, non-blocking)
+ if: >-
+ success() &&
+ matrix.platform == 'android' &&
+ env.CONFIGURATION != 'Debug' &&
+ env.METAMASK_BUILD_TYPE == 'main' &&
+ env.METAMASK_ENVIRONMENT != 'e2e'
+ env:
+ SENTRY_DISABLE_AUTO_UPLOAD: true
+ run: node scripts/android-play-store-check-slack.mjs
+
+ - name: Upload Android Play Store Slack report (main-rc only)
+ if: >-
+ success() &&
+ matrix.platform == 'android' &&
+ inputs.build_name == 'main-rc' &&
+ env.CONFIGURATION != 'Debug' &&
+ env.METAMASK_BUILD_TYPE == 'main' &&
+ env.METAMASK_ENVIRONMENT != 'e2e'
+ uses: actions/upload-artifact@v4
+ with:
+ name: android-play-store-check-slack
+ path: android-play-store-check-slack.md
+ if-no-files-found: warn
+ retention-days: 14
+
# Rename build artifacts (ios_simulator_path / ios_ipa_path / ios_archive_path / android_*_path outputs)
- name: Rename ${{ matrix.platform }} artifacts
if: success()
diff --git a/.github/workflows/slack-rc-notification.yml b/.github/workflows/slack-rc-notification.yml
index 4183005e56df..097cfa66a20e 100644
--- a/.github/workflows/slack-rc-notification.yml
+++ b/.github/workflows/slack-rc-notification.yml
@@ -37,6 +37,10 @@ on:
type: string
default: ''
+permissions:
+ actions: read
+ contents: read
+
jobs:
slack-notification:
name: Post Slack Notification
@@ -46,6 +50,15 @@ jobs:
with:
ref: ${{ inputs.source_branch }}
fetch-depth: 0
+
+ - name: Download Android Play Store check report (optional)
+ id: download-play-store-check
+ continue-on-error: true
+ uses: actions/download-artifact@v4
+ with:
+ name: android-play-store-check-slack
+ path: android-play-store-check-out
+
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
@@ -71,3 +84,4 @@ jobs:
ANDROID_PUBLIC_URL: ${{ secrets.ANDROID_PUBLIC_BUCKET_URL }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
PR_NUMBER: ${{ inputs.pr_number }}
+ ANDROID_PLAY_STORE_CHECK_MRKDWN_FILE: ${{ github.workspace }}/android-play-store-check-out/android-play-store-check-slack.md
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 4647d5eeced6..1ba0ebf1e0cb 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -172,7 +172,9 @@ def reactNativeArchitectures() {
android {
ndkVersion rootProject.ext.ndkVersion
-
+ lint {
+ baseline = file("lint-baseline.xml")
+ }
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml
new file mode 100644
index 000000000000..daad37b1387a
--- /dev/null
+++ b/android/app/lint-baseline.xml
@@ -0,0 +1,693 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/gradle.properties b/android/gradle.properties
index 566ababa6068..52814bdfb292 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -22,6 +22,9 @@ org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.enableJetifier=true
+# byte-buddy 1.17+ ships class files newer than Jetifier's ASM can read (Java 24 / major 68).
+# It is not a support-library artifact and does not need jetification. See b/184622491.
+android.jetifier.ignorelist=.*byte-buddy.*
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
@@ -55,4 +58,4 @@ expo.useLegacyPackaging=false
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=false
reactNativeDir=../node_modules/react-native
-REACT_NATIVE_DIR=../node_modules/react-native
\ No newline at end of file
+REACT_NATIVE_DIR=../node_modules/react-native
diff --git a/android/gradle.properties.github b/android/gradle.properties.github
index 766d08bbc5f7..1c9734cfe58c 100644
--- a/android/gradle.properties.github
+++ b/android/gradle.properties.github
@@ -35,6 +35,9 @@ org.gradle.welcome=NEVER
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.enableJetifier=true
+# byte-buddy 1.17+ ships class files newer than Jetifier's ASM can read (Java 24 / major 68).
+# It is not a support-library artifact and does not need jetification. See b/184622491.
+android.jetifier.ignorelist=.*byte-buddy.*
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.constants.ts b/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.constants.ts
deleted file mode 100644
index 2364089e9241..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.constants.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const CUSTOM_SPEND_CAP_INPUT_TEST_ID = 'custom-spend-cap-input-test-id';
-export const CUSTOM_SPEND_CAP_MAX_TEST_ID = 'custom-spend-cap-max-test-id';
-export const CUSTOM_SPEND_CAP_INPUT_INPUT_ID =
- 'custom-spend-cap-input-input-id';
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.styles.ts b/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.styles.ts
deleted file mode 100644
index 63bb0d923c06..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.styles.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-// Third party dependencies.
-import { StyleSheet, TextStyle } from 'react-native';
-
-import { Theme } from '../../../../util/theme/models';
-import { getFontFamily, TextVariant } from '../../../components/Texts/Text';
-/**
- * Style sheet for Custom Input component.
- *
- * @returns StyleSheet object.
- */
-
-const styleSheet = (params: { theme: Theme }) => {
- const { theme } = params;
- const { colors, typography } = theme;
-
- return StyleSheet.create({
- container: {
- backgroundColor: colors.background.default,
- borderRadius: 8,
- padding: 16,
- flexDirection: 'row',
- justifyContent: 'space-between',
- },
- fixedPadding: {
- padding: 0,
- },
- body: {
- flexDirection: 'row',
- flex: 1,
- alignItems: 'center',
- },
- input: {
- paddingTop: 0,
- paddingBottom: 0,
- flexGrow: 1,
- marginRight: 16,
- color: colors.text.default,
- ...typography.sBodyMD,
- fontFamily: getFontFamily(TextVariant.BodyMD),
- } as TextStyle,
- maxValueText: {
- color: theme.colors.text.alternative,
- },
- warningValue: {
- color: theme.colors.error.default,
- },
- });
-};
-
-export default styleSheet;
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.test.tsx b/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.test.tsx
deleted file mode 100644
index 20ef379ea0ea..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.test.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-// Third party dependencies.
-import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react-native';
-
-// External dependencies.
-import { TICKER } from '../CustomSpendCap.constants';
-// Internal dependencies.
-import CustomInput from './CustomInput';
-import {
- CUSTOM_SPEND_CAP_INPUT_INPUT_ID,
- CUSTOM_SPEND_CAP_MAX_TEST_ID,
-} from './CustomInput.constants';
-import { CustomInputProps } from './CustomInput.types';
-
-describe('CustomInput', () => {
- let props: CustomInputProps;
-
- beforeEach(() => {
- props = {
- ticker: TICKER,
- value: '123',
- isInputGreaterThanBalance: false,
- isEditDisabled: false,
- setMaxSelected: jest.fn(),
- setValue: jest.fn(),
- tokenDecimal: 4,
- };
- });
-
- const renderComponent = () => render();
-
- it('should render correctly', () => {
- const { toJSON } = renderComponent();
- expect(toJSON()).toBeDefined();
- });
-
- it('should call setMaxSelected when max button is pressed', () => {
- renderComponent();
- fireEvent.press(screen.getByTestId(CUSTOM_SPEND_CAP_MAX_TEST_ID));
- expect(props.setMaxSelected).toHaveBeenCalled();
- });
-
- it('should update value if input is integer', () => {
- renderComponent();
- fireEvent.changeText(
- screen.getByTestId(CUSTOM_SPEND_CAP_INPUT_INPUT_ID),
- '123',
- );
- expect(props.setValue).toHaveBeenCalledWith('123');
- });
-
- it('should update value if input is decimal and decimal points are less than or equal to tokenDecimal', () => {
- renderComponent();
- fireEvent.changeText(
- screen.getByTestId(CUSTOM_SPEND_CAP_INPUT_INPUT_ID),
- '123.1234',
- );
- expect(props.setValue).toHaveBeenCalledWith('123.1234');
- });
-
- it('should not update value if input is decimal and decimal points are greater than tokenDecimal', () => {
- renderComponent();
- fireEvent.changeText(
- screen.getByTestId(CUSTOM_SPEND_CAP_INPUT_INPUT_ID),
- '123.1234567',
- );
- expect(props.setValue).not.toHaveBeenCalled();
- });
-});
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.tsx b/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.tsx
deleted file mode 100644
index 164442f15241..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-// Third party dependencies.
-import React from 'react';
-import { TextInput, View } from 'react-native';
-
-import { strings } from '../../../../../locales/i18n';
-import formatNumber from '../../../../util/formatNumber';
-import { dotAndCommaDecimalFormatter } from '../../../../util/number';
-import Text, { TextVariant } from '../../../components/Texts/Text';
-// External dependencies.
-import { useStyles } from '../../../hooks';
-import {
- CUSTOM_SPEND_CAP_INPUT_INPUT_ID,
- CUSTOM_SPEND_CAP_INPUT_TEST_ID,
- CUSTOM_SPEND_CAP_MAX_TEST_ID,
-} from './CustomInput.constants';
-import stylesheet from './CustomInput.styles';
-// Internal dependencies.
-import { CustomInputProps } from './CustomInput.types';
-
-const CustomInput = ({
- ticker,
- value,
- setMaxSelected,
- isInputGreaterThanBalance,
- setValue,
- isEditDisabled,
- tokenDecimal,
-}: CustomInputProps) => {
- const handleUpdate = (text: string) => {
- const decimalIndex = text.indexOf('.');
- const fractionalLength = text.substring(decimalIndex + 1).length;
-
- if (decimalIndex !== -1 && fractionalLength > Number(tokenDecimal)) {
- return;
- }
- setValue(dotAndCommaDecimalFormatter(text));
- };
-
- const handleMaxPress = () => {
- setMaxSelected(true);
- };
-
- const {
- styles,
- theme: { colors },
- } = useStyles(stylesheet, {});
-
- const onChangeValueText = (text: string) => {
- handleUpdate(text);
- setMaxSelected(false);
- };
-
- return (
-
-
- {!isEditDisabled ? (
-
- ) : (
- {`${formatNumber(value)} ${ticker}`}
- )}
-
- {!isEditDisabled && (
-
- {strings('contract_allowance.custom_spend_cap.max')}
-
- )}
-
- );
-};
-
-export default CustomInput;
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.types.ts b/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.types.ts
deleted file mode 100644
index 0a419ae6071b..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.types.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export interface CustomInputProps {
- /**
- * Token native symbol
- * @default 'ETH'
- */
- ticker: string;
- /**
- * Input Value
- */
- value: string;
- /**
- * Function that updates the input value
- */
- setValue: (value: string) => void;
- /**
- * Boolean to determine if input is greater than balance
- * @default false
- */
- isInputGreaterThanBalance: boolean;
- /**
- * Function to update max state
- */
- setMaxSelected: (value: boolean) => void;
- /**
- * Boolean to disable edit
- */
- isEditDisabled: boolean;
- tokenDecimal?: number;
-}
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomInput/index.ts b/app/component-library/components-temp/CustomSpendCap/CustomInput/index.ts
deleted file mode 100644
index c0a26168dd00..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomInput/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './CustomInput';
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.constants.ts b/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.constants.ts
deleted file mode 100644
index 7d068672471a..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.constants.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export const TICKER = 'DAI';
-export const DAPP_PROPOSED_VALUE =
- '115792089237316195423570985008687907853269984665640564039457.584007913129639936';
-export const ACCOUNT_BALANCE = '200.12';
-export const DAPP_DOMAIN = 'Uniswap.org';
-export const CUSTOM_SPEND_CAP_TEST_ID = 'custom-spend-cap';
-export const INPUT_VALUE_CHANGED = (value: string) => {
- /* eslint-disable no-console */
- // do something with value
- console.log(value);
-};
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.styles.ts b/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.styles.ts
deleted file mode 100644
index 44d2371b2a05..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.styles.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-// Third party dependencies.
-import { StyleSheet } from 'react-native';
-
-import { Theme } from '../../../util/theme/models';
-/**
- * Style sheet for CustomSpendCap component.
- *
- * @returns StyleSheet object.
- */
-
-const styleSheet = (params: { theme: Theme }) => {
- const { theme } = params;
- const { colors } = theme;
- return StyleSheet.create({
- container: {
- backgroundColor: colors.background.alternative,
- borderRadius: 8,
- padding: 16,
- },
- header: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- },
- titleContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- title: {
- color: colors.text.default,
- marginRight: 4,
- },
- descriptionContainer: {
- marginTop: 16,
- },
- description: {
- color: theme.colors.text.alternative,
- },
- errorDescription: {
- color: colors.error.default,
- marginTop: 8,
- },
- inputContainer: {
- marginTop: 8,
- },
- modalTitle: {
- color: colors.text.default,
- },
- modalTitleDanger: {
- color: colors.error.default,
- },
- });
-};
-
-export default styleSheet;
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.test.tsx b/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.test.tsx
deleted file mode 100644
index 0a0714cde92e..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.test.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import { fireEvent, screen } from '@testing-library/react-native';
-import React from 'react';
-import renderWithProvider from '../../../util/test/renderWithProvider';
-import CustomSpendCap from './CustomSpendCap';
-import {
- ACCOUNT_BALANCE,
- CUSTOM_SPEND_CAP_TEST_ID,
- DAPP_PROPOSED_VALUE,
- INPUT_VALUE_CHANGED,
- TICKER,
-} from './CustomSpendCap.constants';
-
-function RenderCustomSpendCap(
- tokenSpendValue = '',
- isInputValid: () => boolean = () => true,
- dappProposedValue: string = DAPP_PROPOSED_VALUE,
- accountBalance: string = ACCOUNT_BALANCE,
- unroundedAccountBalance: string = ACCOUNT_BALANCE,
-) {
- return (
- ({})}
- tokenSpendValue={tokenSpendValue}
- isInputValid={isInputValid}
- tokenDecimal={18}
- toggleLearnMoreWebPage={() => undefined}
- />
- );
-}
-
-const isInputValid = jest.fn();
-
-describe('CustomSpendCap', () => {
- it('should render CustomSpendCap', () => {
- renderWithProvider(RenderCustomSpendCap(''));
- expect(screen.getByTestId(CUSTOM_SPEND_CAP_TEST_ID)).toBeTruthy();
- });
-
- it('displays error message when value is not a number', async () => {
- const { findByText } = renderWithProvider(RenderCustomSpendCap('abc'));
-
- expect(await findByText('Error: Enter only numbers')).toBeOnTheScreen();
- });
-
- it('displays caution message when value is 0', async () => {
- const { findByText } = renderWithProvider(RenderCustomSpendCap('0'));
-
- expect(
- await findByText(
- `Only enter a number that you're comfortable with the third party spending now or in the future. You can always increase the spending cap later. Learn more`,
- ),
- ).toBeOnTheScreen();
- });
-
- it('displays spend value in ticker format when value is within account balance', async () => {
- const { toJSON } = renderWithProvider(RenderCustomSpendCap('100'));
-
- expect(JSON.stringify(toJSON())).toMatch(`100 ${TICKER}`);
- });
-
- it('displays over-balance warning when value exceeds account balance', async () => {
- const { findByText } = renderWithProvider(RenderCustomSpendCap('300'));
-
- expect(
- await findByText(
- 'This allows the third party to spend all your token balance until it reaches the cap or you revoke the spending cap. If this is not intended, consider setting a lower spending cap. Learn more',
- ),
- ).toBeOnTheScreen();
- });
-
- it('calls isInputValid with false when value is not a number', () => {
- renderWithProvider(RenderCustomSpendCap('abc', isInputValid));
-
- expect(isInputValid).toHaveBeenCalledWith(false);
- });
-
- it('calls isInputValid with true when value is a number', () => {
- renderWithProvider(RenderCustomSpendCap('100', isInputValid));
-
- expect(isInputValid).toHaveBeenCalledWith(true);
- });
-
- it('displays token spend value when tokenSpendValue is provided', async () => {
- const { findByText } = renderWithProvider(
- RenderCustomSpendCap('100', isInputValid, DAPP_PROPOSED_VALUE),
- );
-
- expect(await findByText(`100 ${TICKER}`)).toBeOnTheScreen();
- });
-
- it('populates input with rounded balance when max is pressed and unrounded balance is empty', async () => {
- const { findByTestId, findByText } = renderWithProvider(
- RenderCustomSpendCap(
- '100',
- isInputValid,
- DAPP_PROPOSED_VALUE,
- '3.14',
- '',
- ),
- );
-
- fireEvent.press(await findByText('Max'));
-
- const input = await findByTestId('custom-spend-cap-input-input-id');
- expect(input.props.value).toEqual('3.14');
- });
-
- it('populates input with unrounded balance when max is pressed and unrounded balance is set', async () => {
- const { findByTestId, findByText } = renderWithProvider(
- RenderCustomSpendCap(
- '100',
- isInputValid,
- DAPP_PROPOSED_VALUE,
- '3.14',
- '3.141592654',
- ),
- );
-
- fireEvent.press(await findByText('Max'));
-
- const input = await findByTestId('custom-spend-cap-input-input-id');
- expect(input.props.value).toEqual('3.141592654');
- });
-});
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.tsx b/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.tsx
deleted file mode 100644
index daef84999913..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-// Third party dependencies.
-import React, { useEffect, useState } from 'react';
-import { Pressable, View } from 'react-native';
-
-import { strings } from '../../../../locales/i18n';
-import InfoModal from '../../../components/Base/InfoModal';
-import { TOKEN_APPROVAL_SPENDING_CAP } from '../../../constants/urls';
-import formatNumber from '../../../util/formatNumber';
-import { isNumber } from '../../../util/number';
-import Button, { ButtonVariants } from '../../components/Buttons/Button';
-import Icon, { IconName, IconSize } from '../../components/Icons/Icon';
-import Text, { TextVariant } from '../../components/Texts/Text';
-// External dependencies.
-import { useStyles } from '../../hooks';
-import CustomInput from './CustomInput';
-// Internal dependencies.
-import { CUSTOM_SPEND_CAP_TEST_ID } from './CustomSpendCap.constants';
-import customSpendCapStyles from './CustomSpendCap.styles';
-import { CustomSpendCapProps } from './CustomSpendCap.types';
-
-const CustomSpendCap = ({
- ticker,
- dappProposedValue,
- accountBalance,
- unroundedAccountBalance,
- onInputChanged,
- isEditDisabled,
- editValue,
- tokenSpendValue,
- isInputValid,
- tokenDecimal,
- toggleLearnMoreWebPage,
-}: CustomSpendCapProps) => {
- const {
- styles,
- theme: { colors },
- } = useStyles(customSpendCapStyles, {});
-
- const [value, setValue] = useState(tokenSpendValue);
- const [inputDisabled, setInputDisabled] = useState(true);
- const [maxSelected, setMaxSelected] = useState(false);
- const [
- inputValueHigherThanAccountBalance,
- setInputValueHigherThanAccountBalance,
- ] = useState(false);
- const [isModalVisible, setIsModalVisible] = useState(false);
- const [inputHasError, setInputHasError] = useState(false);
-
- useEffect(() => {
- if (isNumber(value)) {
- setInputHasError(false);
- } else {
- setInputHasError(true);
- }
-
- onInputChanged(value);
- }, [value, onInputChanged]);
-
- useEffect(() => {
- isInputValid(!inputHasError);
- }, [inputHasError, isInputValid]);
-
- useEffect(() => {
- const spendValue = tokenSpendValue || dappProposedValue;
- setValue(spendValue);
- }, [dappProposedValue, tokenSpendValue]);
-
- const handleDefaultValue = () => {
- setMaxSelected(false);
- setValue(dappProposedValue);
- setInputDisabled(!inputDisabled);
- };
-
- const handlePress = () => {
- isEditDisabled ? editValue() : handleDefaultValue();
- };
-
- useEffect(() => {
- if (maxSelected) setValue(unroundedAccountBalance || accountBalance);
- }, [maxSelected, accountBalance, unroundedAccountBalance]);
-
- useEffect(() => {
- if (Number(value) > Number(accountBalance))
- return setInputValueHigherThanAccountBalance(true);
- return setInputValueHigherThanAccountBalance(false);
- }, [value, accountBalance]);
-
- const MAX_VALUE_SELECTED = (
- <>
- {strings('contract_allowance.custom_spend_cap.this_contract_allows')}
-
- {` ${formatNumber(accountBalance)} ${ticker} `}
-
- {strings('contract_allowance.custom_spend_cap.from_your_balance')}
- >
- );
-
- const NO_SELECTED = strings(
- 'contract_allowance.custom_spend_cap.default_error_message',
- );
-
- const INPUT_VALUE_GREATER_THAN_ACCOUNT_BALANCE = strings(
- 'contract_allowance.custom_spend_cap.amount_greater_than_balance',
- );
-
- const INPUT_VALUE_LOWER_THAN_ACCOUNT_BALANCE = (
- <>
- {strings('contract_allowance.custom_spend_cap.this_contract_allows')}
-
- {` ${formatNumber(tokenSpendValue ?? '0')} ${ticker} `}
-
- {strings('contract_allowance.custom_spend_cap.from_your_balance')}
- >
- );
-
- const toggleModal = () => {
- setIsModalVisible(!isModalVisible);
- };
-
- const infoModalTitle = inputValueHigherThanAccountBalance ? (
- <>
-
-
- {strings('contract_allowance.custom_spend_cap.be_careful')}
- {' '}
- >
- ) : (
-
- {strings('contract_allowance.custom_spend_cap.set_spend_cap')}
-
- );
-
- let message;
-
- if (!value || !Number(value)) {
- message = NO_SELECTED;
- } else if (maxSelected) {
- message = MAX_VALUE_SELECTED;
- } else if (inputValueHigherThanAccountBalance) {
- message = INPUT_VALUE_GREATER_THAN_ACCOUNT_BALANCE;
- } else {
- message = INPUT_VALUE_LOWER_THAN_ACCOUNT_BALANCE;
- }
-
- const openLearnMore = () =>
- toggleLearnMoreWebPage(TOKEN_APPROVAL_SPENDING_CAP);
-
- return (
-
- {isModalVisible ? (
-
- {inputValueHigherThanAccountBalance
- ? strings(
- 'contract_allowance.custom_spend_cap.info_modal_description_default',
- )
- : strings(
- 'contract_allowance.custom_spend_cap.default_error_message',
- )}
-
- }
- toggleModal={toggleModal}
- />
- ) : null}
-
-
-
- {strings('contract_allowance.custom_spend_cap.title')}
-
-
-
-
-
-
-
-
-
-
- {value.length > 0 && inputHasError && (
-
- {strings('contract_allowance.custom_spend_cap.error_enter_number')}
-
- )}
- {!isEditDisabled && (
-
-
- {message}{' '}
-
-
-
- )}
-
- );
-};
-
-export default CustomSpendCap;
diff --git a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.types.ts b/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.types.ts
deleted file mode 100644
index b4d925b00f42..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/CustomSpendCap.types.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-export interface CustomSpendCapProps {
- ticker: string;
- dappProposedValue: string;
- accountBalance: string;
- unroundedAccountBalance: string;
- /**
- * @param value - The value of the input field
- */
- onInputChanged: (value: string) => void;
- /**
- * isEditDisabled - Boolean to disable edit
- * @default false
- */
- isEditDisabled: boolean;
- /**
- * function to return to input field
- */
- editValue: () => void;
- /**
- * token spend value - The value of the input field
- */
- tokenSpendValue: string;
- /**
- * isInputValid - function to check if input is valid and has no errors
- */
- isInputValid: (value: boolean) => boolean;
- /**
- * tokenDecimal - token decimal number
- */
- tokenDecimal?: number;
- /**
- * function to show learn more webpage
- */
- toggleLearnMoreWebPage: (url: string) => void;
-}
diff --git a/app/component-library/components-temp/CustomSpendCap/index.ts b/app/component-library/components-temp/CustomSpendCap/index.ts
deleted file mode 100644
index caddaf081c7a..000000000000
--- a/app/component-library/components-temp/CustomSpendCap/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './CustomSpendCap';
diff --git a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.stories.tsx b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.stories.tsx
index 338b4daeb538..76b0d3425537 100644
--- a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.stories.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.stories.tsx
@@ -6,7 +6,7 @@ import { configureStore } from '@reduxjs/toolkit';
import { Box } from '@metamask/design-system-react-native';
import AccountCell from '.';
import initialBackgroundState from '../../../../util/test/initial-background-state.json';
-import { AvatarAccountType } from '../../../components/Avatars/Avatar/variants/AvatarAccount';
+import { AvatarAccountType } from '../avatarAccountVariant';
interface StoryArgs {
accountGroup: AccountGroupObject;
diff --git a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.test.tsx b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.test.tsx
index ef114ceff866..0f641986cb1a 100644
--- a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.test.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.test.tsx
@@ -9,12 +9,14 @@ import {
createMockState,
createMockWallet,
} from '../test-utils';
-import { AvatarAccountType } from '../../../components/Avatars/Avatar';
-import { Maskicon } from '@metamask/design-system-react-native';
-import JazzIcon from 'react-native-jazzicon';
-import { Image as RNImage } from 'react-native';
+import {
+ Blockies,
+ Jazzicon,
+ Maskicon,
+} from '@metamask/design-system-react-native';
import { AccountCellIds } from './AccountCell.testIds';
import { backgroundState } from '../../../../util/test/initial-root-state';
+import { AvatarAccountType } from '../avatarAccountVariant';
// Configurable mock balance for selector
const mockBalance: { value: number; currency: string } = {
@@ -159,18 +161,18 @@ describe('AccountCell', () => {
expect(UNSAFE_getByType(Maskicon)).toBeTruthy();
});
- it('renders JazzIcon AvatarAccount when avatarAccountType is JazzIcon', () => {
+ it('renders Jazzicon AvatarAccount when avatarAccountType is JazzIcon', () => {
const { UNSAFE_getByType } = renderAccountCell({
avatarAccountType: AvatarAccountType.JazzIcon,
});
- expect(UNSAFE_getByType(JazzIcon)).toBeTruthy();
+ expect(UNSAFE_getByType(Jazzicon)).toBeTruthy();
});
it('renders Blockies AvatarAccount when avatarAccountType is Blockies', () => {
const { UNSAFE_getByType } = renderAccountCell({
avatarAccountType: AvatarAccountType.Blockies,
});
- expect(UNSAFE_getByType(RNImage)).toBeTruthy();
+ expect(UNSAFE_getByType(Blockies)).toBeTruthy();
});
it('calls onSelectAccount when account name is pressed', () => {
diff --git a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.tsx b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.tsx
index 9560ea5811b1..96bc0c19114e 100644
--- a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.tsx
@@ -1,30 +1,36 @@
import { AccountGroupObject } from '@metamask/account-tree-controller';
import React, { useCallback, useMemo } from 'react';
-import { type ImageSourcePropType, TouchableOpacity, View } from 'react-native';
+import { TouchableOpacity, View } from 'react-native';
import { useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
+import {
+ AvatarAccount,
+ AvatarAccountSize,
+ AvatarNetwork,
+ AvatarNetworkSize,
+ FontWeight,
+ Icon,
+ IconColor,
+ IconName,
+ IconSize,
+ SensitiveText,
+ SensitiveTextLength,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
import { useStyles } from '../../../hooks';
import styleSheet from './AccountCell.styles';
-import Text, { TextColor, TextVariant } from '../../../components/Texts/Text';
-import SensitiveText, {
- SensitiveTextLength,
-} from '../../../components/Texts/SensitiveText';
import { Box } from '../../../../components/UI/Box/Box';
import {
AlignItems,
FlexDirection,
JustifyContent,
} from '../../../../components/UI/Box/box.types';
-import Icon, { IconName, IconSize } from '../../../components/Icons/Icon';
import { AccountCellIds } from './AccountCell.testIds';
import { selectBalanceByAccountGroup } from '../../../../selectors/assets/balances';
import { formatWithThreshold } from '../../../../util/assets';
import I18n from '../../../../../locales/i18n';
-import AvatarAccount, {
- AvatarAccountType,
-} from '../../../components/Avatars/Avatar/variants/AvatarAccount';
-import Avatar, { AvatarVariant } from '../../../components/Avatars/Avatar';
-import { AvatarSize } from '../../../components/Avatars/Avatar/Avatar.types';
import {
selectIconSeedAddressByAccountGroupId,
selectInternalAccountByAccountGroupAndScope,
@@ -34,10 +40,14 @@ import { createAccountGroupDetailsNavigationDetails } from '../../../../componen
import { getNetworkImageSource } from '../../../../util/networks';
import { formatChainIdToCaip } from '@metamask/bridge-controller';
import { renderShortAddress } from '../../../../util/address';
+import {
+ type AccountAvatarVariant,
+ getAvatarAccountVariant,
+} from '../avatarAccountVariant';
interface AccountCellProps {
accountGroup: AccountGroupObject;
- avatarAccountType: AvatarAccountType;
+ avatarAccountType: AccountAvatarVariant;
hideMenu?: boolean;
startAccessory?: React.ReactNode;
endContainer?: React.ReactNode;
@@ -49,7 +59,7 @@ type BalanceEndContainerProps = Pick<
AccountCellProps,
'accountGroup' | 'hideMenu' | 'onSelectAccount'
> & {
- networkImageSource?: ImageSourcePropType;
+ networkImageSource?: React.ComponentProps['src'];
};
const BalanceEndContainer = ({
@@ -89,8 +99,9 @@ const BalanceEndContainer = ({
{networkImageSource && (
-
)}
@@ -118,7 +128,7 @@ const BalanceEndContainer = ({
)}
@@ -136,6 +146,7 @@ const AccountCell = ({
onSelectAccount,
}: AccountCellProps) => {
const { styles } = useStyles(styleSheet, {});
+ const avatarAccountVariant = getAvatarAccountVariant(avatarAccountType);
const selectEvmAddress = useMemo(
() => selectIconSeedAddressByAccountGroupId(accountGroup.id),
@@ -170,16 +181,17 @@ const AccountCell = ({
{startAccessory}
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.test.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.test.tsx
index dd99cb5b65ac..e5ca47373794 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.test.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.test.tsx
@@ -8,9 +8,9 @@ import {
createMockState,
createMockWallet,
} from '../../test-utils';
-import { AvatarAccountType } from '../../../../components/Avatars/Avatar';
+import { AvatarAccountType } from '../../avatarAccountVariant';
import { RootState } from '../../../../../reducers';
-import { CHECKBOX_ICON_TESTID } from '../../../../components/Checkbox/Checkbox.constants';
+import { ACCOUNT_LIST_CELL_CHECKBOX_ICON_TEST_ID } from './AccountListCell.testIds';
const mockNavigate = jest.fn();
@@ -169,7 +169,7 @@ describe('AccountListCell', () => {
expect(
getByTestId(`account-list-cell-checkbox-${mockAccountGroup.id}`),
).toBeTruthy();
- expect(getByTestId(CHECKBOX_ICON_TESTID)).toBeTruthy();
+ expect(getByTestId(ACCOUNT_LIST_CELL_CHECKBOX_ICON_TEST_ID)).toBeTruthy();
});
it('renders unchecked checkbox when isSelected is false', () => {
@@ -188,7 +188,9 @@ describe('AccountListCell', () => {
expect(
getByTestId(`account-list-cell-checkbox-${mockAccountGroup.id}`),
).toBeTruthy();
- expect(queryByTestId(CHECKBOX_ICON_TESTID)).toBeFalsy();
+ expect(
+ queryByTestId(ACCOUNT_LIST_CELL_CHECKBOX_ICON_TEST_ID),
+ ).toBeFalsy();
});
it('calls onSelectAccount when checkbox is pressed', () => {
@@ -246,7 +248,7 @@ describe('AccountListCell', () => {
expect(
getByTestId(`account-list-cell-checkbox-${mockAccountGroup.id}`),
).toBeTruthy();
- expect(getByTestId(CHECKBOX_ICON_TESTID)).toBeTruthy();
+ expect(getByTestId(ACCOUNT_LIST_CELL_CHECKBOX_ICON_TEST_ID)).toBeTruthy();
expect(getByText('Test Account')).toBeTruthy();
});
});
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.testIds.ts b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.testIds.ts
index 9f12c7264035..8ada8d85ce7a 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.testIds.ts
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.testIds.ts
@@ -1,3 +1,6 @@
export const ACCOUNT_LIST_CELL_TEST_IDS = {
ACCOUNT_LIST_CELL: 'account-list-cell-checkbox-',
} as const;
+
+export const ACCOUNT_LIST_CELL_CHECKBOX_ICON_TEST_ID =
+ 'account-list-cell-checkbox-icon';
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.tsx
index 13bff7a533b2..951310e06445 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.tsx
@@ -1,12 +1,15 @@
import React, { memo, useCallback } from 'react';
import { View } from 'react-native';
+import { Checkbox } from '@metamask/design-system-react-native';
import { useStyles } from '../../../../hooks';
import AccountCell from '../../AccountCell';
import createStyles from '../MultichainAccountSelectorList.styles';
import { AccountListCellProps } from './AccountListCell.types';
-import Checkbox from '../../../../components/Checkbox';
-import { ACCOUNT_LIST_CELL_TEST_IDS } from './AccountListCell.testIds';
+import {
+ ACCOUNT_LIST_CELL_CHECKBOX_ICON_TEST_ID,
+ ACCOUNT_LIST_CELL_TEST_IDS,
+} from './AccountListCell.testIds';
const AccountListCell = memo(
({
@@ -32,10 +35,17 @@ const AccountListCell = memo(
-
+
+
) : undefined
}
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.types.ts b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.types.ts
index 7d07a74d1a34..3c215f2cfbc7 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.types.ts
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell/AccountListCell.types.ts
@@ -1,9 +1,9 @@
import { AccountGroupObject } from '@metamask/account-tree-controller';
-import { AvatarAccountType } from '../../../../components/Avatars/Avatar/variants/AvatarAccount';
+import type { AccountAvatarVariant } from '../../avatarAccountVariant';
export interface AccountListCellProps {
accountGroup: AccountGroupObject;
- avatarAccountType: AvatarAccountType;
+ avatarAccountType: AccountAvatarVariant;
isSelected: boolean;
onSelectAccount: (accountGroup: AccountGroupObject) => void;
showCheckbox?: boolean;
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListHeader/AccountListHeader.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListHeader/AccountListHeader.tsx
index 4c66899ba392..ce54fd769635 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListHeader/AccountListHeader.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListHeader/AccountListHeader.tsx
@@ -1,11 +1,13 @@
import React, { memo } from 'react';
import { View } from 'react-native';
-
-import { useStyles } from '../../../../hooks';
-import Text, {
+import {
+ FontWeight,
+ Text,
TextColor,
TextVariant,
-} from '../../../../components/Texts/Text';
+} from '@metamask/design-system-react-native';
+
+import { useStyles } from '../../../../hooks';
import createStyles from '../MultichainAccountSelectorList.styles';
import { AccountListHeaderProps } from './AccountListHeader.types';
@@ -16,8 +18,9 @@ const AccountListHeader = memo(
return (
{title}
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.test.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.test.tsx
index c286affe85a2..bc852071d15c 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.test.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.test.tsx
@@ -3,6 +3,7 @@ import { fireEvent } from '@testing-library/react-native';
import ExternalAccountCell from './ExternalAccountCell';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
import { strings } from '../../../../../../locales/i18n';
+import { EXTERNAL_ACCOUNT_CELL_TEST_IDS } from './ExternalAccountCell.testIds';
// Mock the settings selector
jest.mock('../../../../../selectors/settings', () => ({
@@ -75,7 +76,9 @@ describe('ExternalAccountCell', () => {
);
// Network avatar should not be rendered
- expect(queryByTestId('network-avatar-image')).toBeFalsy();
+ expect(
+ queryByTestId(EXTERNAL_ACCOUNT_CELL_TEST_IDS.NETWORK_AVATAR),
+ ).toBeFalsy();
});
it('renders with network avatar when chainId is provided', () => {
@@ -88,7 +91,9 @@ describe('ExternalAccountCell', () => {
);
// Network avatar should be rendered
- expect(getByTestId('network-avatar-image')).toBeTruthy();
+ expect(
+ getByTestId(EXTERNAL_ACCOUNT_CELL_TEST_IDS.NETWORK_AVATAR),
+ ).toBeTruthy();
});
it('renders account avatar', () => {
@@ -205,7 +210,9 @@ describe('ExternalAccountCell', () => {
/>,
);
- expect(getByTestId('network-avatar-image')).toBeTruthy();
+ expect(
+ getByTestId(EXTERNAL_ACCOUNT_CELL_TEST_IDS.NETWORK_AVATAR),
+ ).toBeTruthy();
});
it('renders correctly with Polygon chain ID', () => {
@@ -217,7 +224,9 @@ describe('ExternalAccountCell', () => {
/>,
);
- expect(getByTestId('network-avatar-image')).toBeTruthy();
+ expect(
+ getByTestId(EXTERNAL_ACCOUNT_CELL_TEST_IDS.NETWORK_AVATAR),
+ ).toBeTruthy();
});
it('renders correctly with Optimism chain ID', () => {
@@ -229,7 +238,9 @@ describe('ExternalAccountCell', () => {
/>,
);
- expect(getByTestId('network-avatar-image')).toBeTruthy();
+ expect(
+ getByTestId(EXTERNAL_ACCOUNT_CELL_TEST_IDS.NETWORK_AVATAR),
+ ).toBeTruthy();
});
});
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.testIds.ts b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.testIds.ts
index 1124f88a96d6..d53375a3765f 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.testIds.ts
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.testIds.ts
@@ -1,3 +1,4 @@
export const EXTERNAL_ACCOUNT_CELL_TEST_IDS = {
CONTAINER: 'external-account-cell-touchable',
+ NETWORK_AVATAR: 'network-avatar-image',
} as const;
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.tsx
index 7dd6514b412a..c20108640ebf 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/ExternalAccountCell/ExternalAccountCell.tsx
@@ -1,21 +1,25 @@
import React from 'react';
import { TouchableOpacity, View } from 'react-native';
import { useSelector } from 'react-redux';
-
-import { useStyles } from '../../../../hooks';
-import Text, {
+import {
+ AvatarAccount,
+ AvatarAccountSize,
+ AvatarNetwork,
+ AvatarNetworkSize,
+ FontWeight,
+ Text,
TextColor,
TextVariant,
-} from '../../../../components/Texts/Text';
-import AvatarAccount from '../../../../components/Avatars/Avatar/variants/AvatarAccount';
-import Avatar, { AvatarVariant } from '../../../../components/Avatars/Avatar';
-import { AvatarSize } from '../../../../components/Avatars/Avatar/Avatar.types';
+} from '@metamask/design-system-react-native';
+
+import { useStyles } from '../../../../hooks';
import { formatAddress } from '../../../../../util/address';
import { getNetworkImageSource } from '../../../../../util/networks';
import { selectAvatarAccountType } from '../../../../../selectors/settings';
import { strings } from '../../../../../../locales/i18n';
import createStyles from '../MultichainAccountSelectorList.styles';
import { EXTERNAL_ACCOUNT_CELL_TEST_IDS } from './ExternalAccountCell.testIds';
+import { getAvatarAccountVariant } from '../../avatarAccountVariant';
/**
* ExternalAccountCell Component
@@ -39,6 +43,7 @@ const ExternalAccountCell: React.FC = ({
}) => {
const { styles } = useStyles(createStyles, { isSelected });
const avatarAccountType = useSelector(selectAvatarAccountType);
+ const avatarAccountVariant = getAvatarAccountVariant(avatarAccountType);
const formattedAddress = formatAddress(address, 'short');
// Get network image if chainId is provided
@@ -60,21 +65,24 @@ const ExternalAccountCell: React.FC = ({
testID={EXTERNAL_ACCOUNT_CELL_TEST_IDS.CONTAINER}
>
{strings('bridge.external_account')}
{formattedAddress}
@@ -82,10 +90,10 @@ const ExternalAccountCell: React.FC = ({
{networkImageSource && (
-
)}
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx
index 36681d12b280..d26934b151bc 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx
@@ -48,6 +48,7 @@ import {
createMockInternalAccountsWithAddresses,
} from '../test-utils';
import { AccountCellIds } from '../AccountCell/AccountCell.testIds';
+import { ACCOUNT_LIST_CELL_CHECKBOX_ICON_TEST_ID } from './AccountListCell/AccountListCell.testIds';
jest.mock('../../../../core/Engine', () => ({
context: {
@@ -1286,7 +1287,9 @@ describe('MultichainAccountSelectorList', () => {
expect(account2Checkboxes.length).toEqual(1); // Only container (unselected account, no icon rendered)
// Check that there are no checked checkbox icons (since none are selected)
- expect(queryByTestId('checkbox-icon-component')).toBeFalsy();
+ expect(
+ queryByTestId(ACCOUNT_LIST_CELL_CHECKBOX_ICON_TEST_ID),
+ ).toBeFalsy();
});
it('calls onSelectAccount when checkbox is pressed', () => {
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx
index b24f7ac65746..ce3731285877 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx
@@ -10,10 +10,14 @@ import { ScrollView } from 'react-native-gesture-handler';
import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list';
import { useSelector } from 'react-redux';
import { AccountGroupObject } from '@metamask/account-tree-controller';
+import {
+ Text,
+ TextColor,
+ TextFieldSearch,
+ TextVariant,
+} from '@metamask/design-system-react-native';
import { useStyles } from '../../../hooks';
-import Text, { TextColor, TextVariant } from '../../../components/Texts/Text';
-import TextFieldSearch from '../../../components/Form/TextFieldSearch';
import { selectAccountGroupsByWallet } from '../../../../selectors/multichainAccounts/accountTreeController';
import { selectInternalAccountsById } from '../../../../selectors/accountsController';
import AccountListHeader from './AccountListHeader';
@@ -408,14 +412,16 @@ const MultichainAccountSelectorList = ({
onChangeText={setSearchText}
onPressClearButton={() => setSearchText('')}
placeholder={strings('accounts.search_your_accounts')}
- testID={MULTICHAIN_ACCOUNT_SELECTOR_SEARCH_INPUT_TESTID}
+ inputProps={{
+ testID: MULTICHAIN_ACCOUNT_SELECTOR_SEARCH_INPUT_TESTID,
+ }}
autoFocus={false}
isError={shouldShowInvalidAddressError}
/>
{shouldShowInvalidAddressError ? (
@@ -430,8 +436,8 @@ const MultichainAccountSelectorList = ({
testID={MULTICHAIN_ACCOUNT_SELECTOR_EMPTY_STATE_TESTID}
>
{emptyStateText}
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.test.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.test.tsx
index d403642fc299..760f3f32ea6b 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.test.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
+import { act, render, fireEvent } from '@testing-library/react-native';
import MultichainAddressRow from './MultichainAddressRow';
import {
SAMPLE_MULTICHAIN_ADDRESS_ROW_PROPS,
@@ -24,6 +24,10 @@ jest.mock('../../../../util/networks', () => ({
}));
describe('MultichainAddressRow', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
it('renders MultichainAddressRow correctly', () => {
const { getByTestId } = render(
,
@@ -142,8 +146,9 @@ describe('MultichainAddressRow', () => {
const copyButton = getByTestId(MULTICHAIN_ADDRESS_ROW_COPY_BUTTON_TEST_ID);
- // Simulate pressing the copy button
- fireEvent.press(copyButton);
+ await act(async () => {
+ fireEvent.press(copyButton);
+ });
// Callback should be called
expect(mockCallback).toHaveBeenCalled();
@@ -176,12 +181,14 @@ describe('MultichainAddressRow', () => {
});
it('shows toast when copy button is pressed and toastRef is provided', async () => {
+ jest.useFakeTimers();
const mockCallback = jest.fn();
const mockShowToast = jest.fn();
+ const mockCloseToast = jest.fn();
const mockToastRef = {
current: {
showToast: mockShowToast,
- closeToast: jest.fn(),
+ closeToast: mockCloseToast,
},
};
const copyParams = {
@@ -199,19 +206,31 @@ describe('MultichainAddressRow', () => {
const copyButton = getByTestId(MULTICHAIN_ADDRESS_ROW_COPY_BUTTON_TEST_ID);
- // Simulate pressing the copy button
- fireEvent.press(copyButton);
-
- // Wait for async operations
- await new Promise((resolve) => setTimeout(resolve, 0));
+ await act(async () => {
+ fireEvent.press(copyButton);
+ });
// Toast should be called with Plain variant (no icon)
expect(mockShowToast).toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({
variant: expect.stringContaining('Plain'),
+ labelOptions: [{ label: 'Address copied' }],
+ closeButtonOptions: expect.objectContaining({
+ variant: 'Icon',
+ iconName: IconName.Close,
+ }),
}),
);
+
+ const toastOptions = mockShowToast.mock.calls[0][0];
+ toastOptions.closeButtonOptions.onPress();
+ expect(mockCloseToast).toHaveBeenCalled();
+
+ act(() => {
+ // Flush the fake timer that resets the copy feedback icon from check back to copy.
+ jest.advanceTimersByTime(400);
+ });
});
it('renders truncated address correctly when copyParams is missing', () => {
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx
index d525a391f7b8..e6c083c4a2aa 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx
@@ -18,14 +18,16 @@ import {
IconName,
IconColor,
FontWeight,
+ AvatarNetwork,
+ AvatarNetworkSize,
} from '@metamask/design-system-react-native';
-import Avatar, {
- AvatarSize,
- AvatarVariant,
-} from '../../../components/Avatars/Avatar';
import { formatAddress } from '../../../../util/address';
import { getNetworkImageSource } from '../../../../util/networks';
-import { Icon, MultichainAddressRowProps } from './MultichainAddressRow.types';
+import type {
+ CopyToastOptions,
+ Icon,
+ MultichainAddressRowProps,
+} from './MultichainAddressRow.types';
import {
MULTICHAIN_ADDRESS_ROW_NETWORK_ICON_TEST_ID,
MULTICHAIN_ADDRESS_ROW_NETWORK_NAME_TEST_ID,
@@ -33,8 +35,6 @@ import {
MULTICHAIN_ADDRESS_ROW_TEST_ID,
MULTICHAIN_ADDRESS_ROW_COPY_BUTTON_TEST_ID,
} from './MultichainAddressRow.constants';
-import { ToastVariants, ButtonIconVariant } from '../../../components/Toast';
-import { IconName as ToastIconName } from '../../../components/Icons/Icon';
const MultichainAddressRow = ({
chainId,
@@ -69,18 +69,21 @@ const MultichainAddressRow = ({
setIconState('copy');
}, 400);
- // Show legacy row-managed toast only when both ref and message are provided.
if (copyParams.toastRef?.current && copyParams.toastMessage) {
- copyParams.toastRef.current.showToast({
- variant: ToastVariants.Plain,
- labelOptions: [{ label: copyParams.toastMessage }],
+ const toastOptions: CopyToastOptions = {
+ variant: 'Plain',
+ labelOptions: [{ label: copyParams.toastMessage ?? '' }],
hasNoTimeout: false,
closeButtonOptions: {
- variant: ButtonIconVariant.Icon,
- iconName: ToastIconName.Close,
+ variant: 'Icon',
+ iconName: IconName.Close,
onPress: () => copyParams.toastRef?.current?.closeToast(),
},
- });
+ };
+ const showToast = copyParams.toastRef.current.showToast as (
+ toastOptions: CopyToastOptions,
+ ) => void;
+ showToast(toastOptions);
}
}, [copyParams]);
@@ -118,11 +121,10 @@ const MultichainAddressRow = ({
testID={testID}
{...viewProps}
>
-
void;
+ };
+}
+
+export interface CopyToastRef {
+ // Keep legacy ToastRef structurally assignable without importing deprecated Toast types.
+ showToast(toastOptions: never): void;
+ closeToast(): void;
+}
+
/**
* Parameters for the copy operation.
*/
@@ -30,7 +46,7 @@ export interface CopyParams {
/**
* Optional toast ref for legacy callers that need the row to show a toast.
*/
- toastRef?: React.RefObject;
+ toastRef?: React.RefObject;
/**
* Toast message used when toastRef is provided.
*/
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRowsList/MultichainAddressRowsList.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRowsList/MultichainAddressRowsList.tsx
index 97b1bd1b9ec8..8ae6868f90d2 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRowsList/MultichainAddressRowsList.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRowsList/MultichainAddressRowsList.tsx
@@ -3,11 +3,15 @@ import { View, FlatList, StyleProp, ViewStyle } from 'react-native';
import { useSelector } from 'react-redux';
import { InternalAccount } from '@metamask/keyring-internal-api';
import { CaipChainId, toCaipChainId } from '@metamask/utils';
+import {
+ Text,
+ TextColor,
+ TextFieldSearch,
+ TextVariant,
+} from '@metamask/design-system-react-native';
import { useStyles } from '../../../hooks';
import styleSheet from './MultichainAddressRowsList.styles';
-import Text, { TextVariant, TextColor } from '../../../components/Texts/Text';
-import TextFieldSearch from '../../../components/Form/TextFieldSearch';
import { strings } from '../../../../../locales/i18n';
import MultichainAddressRow, { SAMPLE_ICONS } from '../MultichainAddressRow';
import { selectEvmNetworkConfigurationsByChainId } from '../../../../selectors/networkController';
@@ -122,8 +126,8 @@ const MultichainAddressRowsList: React.FC = ({
return (
{strings(messageKey)}
@@ -142,7 +146,9 @@ const MultichainAddressRowsList: React.FC = ({
value={searchPattern}
onChangeText={handleSearchChange}
onPressClearButton={() => handleSearchChange('')}
- testID={MULTICHAIN_ADDRESS_ROWS_LIST_SEARCH_TEST_ID}
+ inputProps={{
+ testID: MULTICHAIN_ADDRESS_ROWS_LIST_SEARCH_TEST_ID,
+ }}
/>
diff --git a/app/component-library/components-temp/MultichainAccounts/avatarAccountVariant.test.ts b/app/component-library/components-temp/MultichainAccounts/avatarAccountVariant.test.ts
new file mode 100644
index 000000000000..cf703954dc46
--- /dev/null
+++ b/app/component-library/components-temp/MultichainAccounts/avatarAccountVariant.test.ts
@@ -0,0 +1,38 @@
+import { AvatarAccountVariant } from '@metamask/design-system-react-native';
+import {
+ type AccountAvatarVariant,
+ AvatarAccountType,
+ getAvatarAccountVariant,
+} from './avatarAccountVariant';
+
+const avatarVariantCases: [AccountAvatarVariant, AvatarAccountVariant][] = [
+ ['JazzIcon', AvatarAccountVariant.Jazzicon],
+ ['Blockies', AvatarAccountVariant.Blockies],
+ ['Maskicon', AvatarAccountVariant.Maskicon],
+ [AvatarAccountVariant.Jazzicon, AvatarAccountVariant.Jazzicon],
+ [AvatarAccountVariant.Blockies, AvatarAccountVariant.Blockies],
+ [AvatarAccountVariant.Maskicon, AvatarAccountVariant.Maskicon],
+];
+
+describe('avatarAccountVariant', () => {
+ it.each(avatarVariantCases)(
+ 'maps %s to the matching DS avatar variant',
+ (input, expected) => {
+ expect(getAvatarAccountVariant(input)).toBe(expected);
+ },
+ );
+
+ it('defaults to Maskicon for unknown avatar variants', () => {
+ expect(getAvatarAccountVariant('Unknown' as AccountAvatarVariant)).toBe(
+ AvatarAccountVariant.Maskicon,
+ );
+ });
+
+ it('exposes legacy avatar type names with DS variant values', () => {
+ expect(AvatarAccountType).toEqual({
+ JazzIcon: AvatarAccountVariant.Jazzicon,
+ Blockies: AvatarAccountVariant.Blockies,
+ Maskicon: AvatarAccountVariant.Maskicon,
+ });
+ });
+});
diff --git a/app/component-library/components-temp/MultichainAccounts/avatarAccountVariant.ts b/app/component-library/components-temp/MultichainAccounts/avatarAccountVariant.ts
new file mode 100644
index 000000000000..f8d21803d0de
--- /dev/null
+++ b/app/component-library/components-temp/MultichainAccounts/avatarAccountVariant.ts
@@ -0,0 +1,33 @@
+import { AvatarAccountVariant } from '@metamask/design-system-react-native';
+
+export type LegacyAvatarAccountType = 'JazzIcon' | 'Blockies' | 'Maskicon';
+
+export type AccountAvatarVariant =
+ | AvatarAccountVariant
+ | LegacyAvatarAccountType;
+
+export const AvatarAccountType = {
+ JazzIcon: AvatarAccountVariant.Jazzicon,
+ Blockies: AvatarAccountVariant.Blockies,
+ Maskicon: AvatarAccountVariant.Maskicon,
+} as const;
+
+export type AvatarAccountType =
+ (typeof AvatarAccountType)[keyof typeof AvatarAccountType];
+
+export const getAvatarAccountVariant = (
+ avatarAccountType: AccountAvatarVariant,
+): AvatarAccountVariant => {
+ switch (avatarAccountType) {
+ case 'JazzIcon':
+ case AvatarAccountVariant.Jazzicon:
+ return AvatarAccountVariant.Jazzicon;
+ case 'Blockies':
+ case AvatarAccountVariant.Blockies:
+ return AvatarAccountVariant.Blockies;
+ case 'Maskicon':
+ case AvatarAccountVariant.Maskicon:
+ default:
+ return AvatarAccountVariant.Maskicon;
+ }
+};
diff --git a/app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx b/app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx
index 259298a1db24..ec04328b3f56 100644
--- a/app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx
+++ b/app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx
@@ -20,6 +20,22 @@ import {
import Tab from '../Tab';
import { TabsBarProps } from './TabsBar.types';
+interface TabLayout {
+ x: number;
+ width: number;
+}
+
+/** Dense-array check: sparse arrays make Array.every skip holes and report ready too early. */
+const areAllTabLayoutsMeasured = (
+ layouts: (TabLayout | undefined)[],
+ tabCount: number,
+): boolean =>
+ layouts.length === tabCount &&
+ layouts.every((layout) => layout != null && layout.width > 0);
+
+const createEmptyTabLayouts = (tabCount: number): (TabLayout | undefined)[] =>
+ Array.from({ length: tabCount }, () => undefined);
+
const TabsBar: React.FC = ({
tabs,
activeIndex,
@@ -36,11 +52,12 @@ const TabsBar: React.FC = ({
const underlineAnimated = useRef(new Animated.Value(0)).current;
const underlineWidthAnimated = useRef(new Animated.Value(0)).current;
- const tabLayouts = useRef<{ x: number; width: number }[]>([]);
+ const tabLayouts = useRef<(TabLayout | undefined)[]>([]);
const currentAnimation = useRef(null);
const rafCallbackId = useRef(null);
const [isInitialized, setIsInitialized] = useState(false);
const [layoutsReady, setLayoutsReady] = useState(false);
+ const [layoutGeneration, setLayoutGeneration] = useState(0);
const activeIndexRef = useRef(activeIndex);
// State for automatic overflow detection
@@ -74,7 +91,7 @@ const TabsBar: React.FC = ({
// Store current tab keys for next comparison
prevTabKeys.current = tabKeys;
// Reset all layout state
- tabLayouts.current = new Array(tabs.length);
+ tabLayouts.current = createEmptyTabLayouts(tabs.length);
setIsInitialized(false);
setLayoutsReady(false);
setScrollEnabled(false);
@@ -85,9 +102,8 @@ const TabsBar: React.FC = ({
currentAnimation.current = null;
}
- // Force re-measurement by resetting container width temporarily
- // This ensures fresh layout measurements for the new tab structure
- setContainerWidth(0);
+ // Force Tab remount so onLayout fires for every tab after structural changes
+ setLayoutGeneration((generation) => generation + 1);
}
}, [tabKeys, tabs.length]);
@@ -161,32 +177,32 @@ const TabsBar: React.FC = ({
// Animate when activeIndex changes and layouts are ready
useEffect(() => {
- if (activeIndex >= 0 && layoutsReady) {
+ if (
+ activeIndex >= 0 &&
+ layoutsReady &&
+ areAllTabLayoutsMeasured(tabLayouts.current, tabs.length)
+ ) {
animateToTab(activeIndex);
}
- }, [activeIndex, layoutsReady, animateToTab]);
+ }, [activeIndex, layoutsReady, animateToTab, tabKeys, tabs.length]);
// Check if content overflows and update scroll state
useEffect(() => {
- if (containerWidth > 0 && tabLayouts.current.length === tabs.length) {
- // Validate that all tab layouts are defined (prevent sparse array issues)
- const allLayoutsDefined = tabLayouts.current.every(
- (layout) => layout && typeof layout.width === 'number',
+ if (
+ containerWidth > 0 &&
+ areAllTabLayoutsMeasured(tabLayouts.current, tabs.length)
+ ) {
+ // Calculate total content width by summing tab widths + gaps
+ const totalTabsWidth = tabLayouts.current.reduce(
+ (sum, layout) => sum + (layout?.width ?? 0),
+ 0,
);
+ const gapsWidth = (tabs.length - 1) * 24; // Account for gaps between tabs
+ const calculatedContentWidth = totalTabsWidth + gapsWidth;
- if (allLayoutsDefined) {
- // Calculate total content width by summing tab widths + gaps
- const totalTabsWidth = tabLayouts.current.reduce(
- (sum, layout) => sum + layout.width,
- 0,
- );
- const gapsWidth = (tabs.length - 1) * 24; // Account for gaps between tabs
- const calculatedContentWidth = totalTabsWidth + gapsWidth;
-
- // Account for container's px-4 padding (16px * 2 = 32px)
- const shouldScroll = calculatedContentWidth > containerWidth - 32;
- setScrollEnabled(shouldScroll);
- }
+ // Account for container's px-4 padding (16px * 2 = 32px)
+ const shouldScroll = calculatedContentWidth > containerWidth - 32;
+ setScrollEnabled(shouldScroll);
}
}, [containerWidth, tabs.length]);
@@ -201,7 +217,7 @@ const TabsBar: React.FC = ({
const { x, width } = layoutEvent.nativeEvent.layout;
// Validate input
- if (index < 0 || index >= tabs.length || width <= 0) {
+ if (index < 0 || index >= tabs.length || !width || width <= 0) {
return;
}
@@ -215,9 +231,28 @@ const TabsBar: React.FC = ({
// Store layout data
tabLayouts.current[index] = { x, width };
- // Check if all layouts are now available
- const allLayoutsReady = tabLayouts.current.every(
- (layout, i) => i >= tabs.length || (layout && layout.width > 0),
+ const activeIdx = activeIndexRef.current;
+ const activeTabLayout = tabLayouts.current[activeIdx];
+ const activeTabHasLayout =
+ activeIdx >= 0 &&
+ activeIdx < tabs.length &&
+ activeTabLayout != null &&
+ activeTabLayout.width > 0;
+
+ // Position underline as soon as the active tab is measured (e.g. after tab add/remove)
+ if (activeTabHasLayout && (!isInitialized || index === activeIdx)) {
+ if (rafCallbackId.current !== null) {
+ cancelAnimationFrame(rafCallbackId.current);
+ }
+ rafCallbackId.current = requestAnimationFrame(() => {
+ rafCallbackId.current = null;
+ animateToTab(activeIdx);
+ });
+ }
+
+ const allLayoutsReady = areAllTabLayoutsMeasured(
+ tabLayouts.current,
+ tabs.length,
);
if (allLayoutsReady) {
@@ -254,7 +289,7 @@ const TabsBar: React.FC = ({
}
}
},
- [tabs.length, layoutsReady, containerWidth, animateToTab],
+ [tabs.length, layoutsReady, containerWidth, animateToTab, isInitialized],
);
// Cleanup effect
@@ -302,7 +337,7 @@ const TabsBar: React.FC = ({
>
{tabs.map((tab, index) => (
= ({
>
{tabs.map((tab, index) => (
{
});
});
+ describe('Money Account spending source', () => {
+ const moneyAccountPriorityToken = {
+ ...mockPriorityToken,
+ isMoneyAccountEntry: true,
+ } as typeof mockPriorityToken;
+
+ it('passes the Money Account i18n label as the address to the card image when authenticated', () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ priorityToken: moneyAccountPriorityToken,
+ allTokens: [moneyAccountPriorityToken],
+ isAuthenticated: true,
+ });
+
+ render();
+
+ const cardImage = screen.getByTestId(
+ CardHomeSelectors.CARD_WALLET_ADDRESS,
+ );
+ // The SVG `Svg` element receives `address` via `{...props}`; this is
+ // the same prop that drives the rendered SVG `` content. In
+ // the test environment `strings()` returns the i18n key.
+ expect(cardImage.props.address).toBe(
+ 'card.card_spending_limit.money_account_label',
+ );
+ });
+
+ it('passes the truncated wallet hex (not the Money Account label) when the primary token is not a money account entry', () => {
+ setupMockSelectors({ isAuthenticated: true });
+ const walletPriorityToken = {
+ ...mockPriorityToken,
+ isMoneyAccountEntry: false,
+ } as typeof mockPriorityToken;
+ setupLoadCardDataMock({
+ priorityToken: walletPriorityToken,
+ allTokens: [mockPriorityToken],
+ isAuthenticated: true,
+ });
+
+ render();
+
+ const cardImage = screen.getByTestId(
+ CardHomeSelectors.CARD_WALLET_ADDRESS,
+ );
+ // CardImage truncates the hex; what matters here is that the Money
+ // Account label is NOT used when the flag is false.
+ expect(cardImage.props.address).not.toBe(
+ 'card.card_spending_limit.money_account_label',
+ );
+ });
+
+ it('navigates to MoneyAddMoneySheet and skips switchToFundingAccountIfNeeded when add funds is pressed', async () => {
+ setupLoadCardDataMock({
+ priorityToken: moneyAccountPriorityToken,
+ allTokens: [moneyAccountPriorityToken],
+ });
+ mockSetSelectedAddress.mockClear();
+ mockOpenSwaps.mockClear();
+ mockNavigate.mockClear();
+
+ render();
+
+ fireEvent.press(screen.getByTestId(CardHomeSelectors.ADD_FUNDS_BUTTON));
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('MoneyModals', {
+ screen: 'MoneyAddMoneySheet',
+ });
+ });
+ expect(mockNavigate).not.toHaveBeenCalledWith(
+ 'CardModals',
+ expect.anything(),
+ );
+ expect(mockOpenSwaps).not.toHaveBeenCalled();
+ expect(mockSetSelectedAddress).not.toHaveBeenCalled();
+ });
+ });
+
it('calls navigateToTravelPage when travel item is pressed', async () => {
// TRAVEL_ITEM requires isFullySetUp (isAuthenticated + card + no setup actions)
setupMockSelectors({ isAuthenticated: true });
@@ -3501,7 +3579,7 @@ describe('CardHome Component', () => {
expect(mockNavigate).toHaveBeenCalledWith(
Routes.CARD.SPENDING_LIMIT,
expect.objectContaining({
- flow: 'manage',
+ flow: 'enable_card',
}),
);
});
@@ -5804,7 +5882,7 @@ describe('CardHome Component', () => {
expect(mockNavigate).toHaveBeenCalledWith(
Routes.CARD.SPENDING_LIMIT,
expect.objectContaining({
- flow: 'manage',
+ flow: 'enable_card',
}),
);
});
@@ -5860,7 +5938,7 @@ describe('CardHome Component', () => {
expect(mockNavigate).toHaveBeenCalledWith(
Routes.CARD.SPENDING_LIMIT,
expect.objectContaining({
- flow: 'manage',
+ flow: 'enable_card',
}),
);
});
@@ -5916,7 +5994,7 @@ describe('CardHome Component', () => {
expect(mockNavigate).toHaveBeenCalledWith(
Routes.CARD.SPENDING_LIMIT,
expect.objectContaining({
- flow: 'manage',
+ flow: 'enable_card',
}),
);
});
@@ -5972,7 +6050,7 @@ describe('CardHome Component', () => {
expect(mockNavigate).toHaveBeenCalledWith(
Routes.CARD.SPENDING_LIMIT,
expect.objectContaining({
- flow: 'manage',
+ flow: 'enable_card',
}),
);
});
@@ -6017,7 +6095,7 @@ describe('CardHome Component', () => {
expect(mockNavigate).toHaveBeenCalledWith(
Routes.CARD.SPENDING_LIMIT,
expect.objectContaining({
- flow: 'manage',
+ flow: 'enable_card',
}),
);
});
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx
index 42073cdd024c..441c862c998a 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx
@@ -320,7 +320,9 @@ const CardHome = () => {
cardStatus={data?.card?.status}
walletAddress={
isAuthenticated
- ? data?.primaryFundingAsset?.walletAddress
+ ? primaryToken?.isMoneyAccountEntry
+ ? strings('card.card_spending_limit.money_account_label')
+ : data?.primaryFundingAsset?.walletAddress
: undefined
}
/>
@@ -360,6 +362,7 @@ const CardHome = () => {
actions={data?.actions ?? []}
isLoading={isLoading}
isSwapEnabled={isSwapEnabled}
+ isMoneyAccountEntry={!!primaryToken?.isMoneyAccountEntry}
onAddFunds={actions.addFundsAction}
onEnableCard={actions.enableCardAction}
/>
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.view.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.view.test.tsx
index a045deef7afb..68ace55d9168 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.view.test.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.view.test.tsx
@@ -60,7 +60,7 @@ describe('CardHome', () => {
expect(params.screen).toBe(Routes.CARD.MODALS.ASSET_SELECTION);
});
- it('opens Spending Limit screen with flow=enable when Manage Spending Limit button is pressed', async () => {
+ it('opens Spending Limit screen with flow=manage when Manage Spending Limit button is pressed', async () => {
const { getByTestId, findByTestId } = renderCardHomeView({
extraRoutes: [
{
@@ -79,7 +79,7 @@ describe('CardHome', () => {
);
expect(paramsEl).toBeOnTheScreen();
const params = JSON.parse(paramsEl.props.children as string);
- expect(params.flow).toBe('enable');
+ expect(params.flow).toBe('manage');
});
it('opens Cashback screen showing balance and withdrawal button when Cashback button is pressed', async () => {
diff --git a/app/components/UI/Card/Views/CardHome/components/CardActionsButtons.tsx b/app/components/UI/Card/Views/CardHome/components/CardActionsButtons.tsx
index 79e5fce8ec86..349dcdbe5f4e 100644
--- a/app/components/UI/Card/Views/CardHome/components/CardActionsButtons.tsx
+++ b/app/components/UI/Card/Views/CardHome/components/CardActionsButtons.tsx
@@ -14,6 +14,7 @@ interface CardActionsButtonsProps {
actions: CardAction[];
isLoading: boolean;
isSwapEnabled: boolean;
+ isMoneyAccountEntry?: boolean;
onAddFunds: () => void;
onEnableCard: () => void;
}
@@ -22,6 +23,7 @@ const CardActionsButtons = ({
actions,
isLoading,
isSwapEnabled,
+ isMoneyAccountEntry = false,
onAddFunds,
onEnableCard,
}: CardActionsButtonsProps) => {
@@ -64,7 +66,7 @@ const CardActionsButtons = ({
size={ButtonSize.Lg}
onPress={onAddFunds}
isFullWidth
- isDisabled={!isSwapEnabled}
+ isDisabled={!isSwapEnabled && !isMoneyAccountEntry}
testID={CardHomeSelectors.ADD_FUNDS_BUTTON}
>
{strings('card.card_home.add_funds')}
diff --git a/app/components/UI/Card/Views/CardHome/hooks/useCardHomeActions.ts b/app/components/UI/Card/Views/CardHome/hooks/useCardHomeActions.ts
index 21da1c1d2937..c9758b859f57 100644
--- a/app/components/UI/Card/Views/CardHome/hooks/useCardHomeActions.ts
+++ b/app/components/UI/Card/Views/CardHome/hooks/useCardHomeActions.ts
@@ -314,6 +314,13 @@ export function useCardHomeActions({
createEventBuilder(MetaMetricsEvents.CARD_ADD_FUNDS_CLICKED).build(),
);
+ if (primaryToken?.isMoneyAccountEntry) {
+ navigation.navigate(Routes.MONEY.MODALS.ROOT, {
+ screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET,
+ });
+ return;
+ }
+
const isPriorityTokenSupportedDeposit = !!DEPOSIT_SUPPORTED_TOKENS.find(
(t) =>
t.toLowerCase() === data?.primaryFundingAsset?.symbol?.toLowerCase(),
@@ -360,7 +367,7 @@ export function useCardHomeActions({
.build(),
);
navigation.navigate(Routes.CARD.SPENDING_LIMIT, {
- flow: 'manage',
+ flow: 'enable_card',
});
}, [navigation, trackEvent, createEventBuilder]);
@@ -372,7 +379,7 @@ export function useCardHomeActions({
);
if (isAuthenticated) {
navigation.navigate(Routes.CARD.SPENDING_LIMIT, {
- flow: 'enable',
+ flow: 'manage',
});
} else {
navigation.navigate(Routes.CARD.AUTHENTICATION, { showAuthPrompt: true });
diff --git a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx
index 5a620fe2cd66..b16855d70e59 100644
--- a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx
+++ b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx
@@ -196,6 +196,9 @@ function render(cardControllerOverrides = {}) {
PreferencesController: {
isIpfsGatewayEnabled: true,
},
+ MoneyAccountController: {
+ moneyAccounts: {},
+ },
CardController: {
selectedCountry: null,
activeProviderId: 'baanx',
diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx
index 6d807bb18bf9..7d06b15d0b6b 100644
--- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx
+++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx
@@ -277,7 +277,7 @@ jest.spyOn(Logger, 'error').mockImplementation(() => undefined);
interface MockRoute {
params?: {
- flow?: 'manage' | 'enable' | 'onboarding';
+ flow?: 'manage' | 'enable' | 'onboarding' | 'enable_card';
selectedToken?: CardFundingToken;
returnedSelectedToken?: CardFundingToken;
};
@@ -347,6 +347,7 @@ describe('SpendingLimit Component', () => {
needsFaucet: false,
isFaucetCheckLoading: false,
isMoneyAccountSource: false,
+ isMoneyAccountLocked: false,
canShowMoneyAccountCta: false,
selectMoneyAccountAsSource: mockSelectMoneyAccountAsSource,
moneyAccountTotalFiatFormatted: undefined as string | undefined,
@@ -1059,11 +1060,24 @@ describe('SpendingLimit Component', () => {
expect(screen.getByText('Money account')).toBeOnTheScreen();
});
- it('renders the locked token row (no chevron, not pressable) with the mUSD display label and fiat balance', () => {
+ it('renders the account row as a pressable (non-locked) row showing Money Account on onboarding-like flows', () => {
mountWithMoneyAccount();
- expect(screen.getByTestId('token-row-locked')).toBeOnTheScreen();
- expect(screen.queryByTestId('token-row')).not.toBeOnTheScreen();
+ expect(screen.getByTestId('account-row')).toBeOnTheScreen();
+ expect(screen.queryByTestId('account-row-locked')).not.toBeOnTheScreen();
+ expect(screen.getByTestId('account-row-money-account')).toBeOnTheScreen();
+
+ // Tapping the row opens the account picker (exits Money Account mode).
+ mockHandleAccountSelect.mockClear();
+ fireEvent.press(screen.getByTestId('account-row'));
+ expect(mockHandleAccountSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders the token row as pressable (non-locked) with the mUSD display label and fiat balance on onboarding-like flows', () => {
+ mountWithMoneyAccount();
+
+ expect(screen.getByTestId('token-row')).toBeOnTheScreen();
+ expect(screen.queryByTestId('token-row-locked')).not.toBeOnTheScreen();
expect(screen.getByText('mUSD ($12.34)')).toBeOnTheScreen();
expect(screen.queryByText('USDC on Linea')).not.toBeOnTheScreen();
});
@@ -1169,16 +1183,19 @@ describe('SpendingLimit Component', () => {
expect(screen.getByText('mUSD')).toBeOnTheScreen();
});
- it('renders Money Account rows in the manage flow when Money Account is the source', () => {
+ it('locks both Account and Token rows on the manage flow when Money Account is the source', () => {
mockUseSpendingLimit.mockReturnValue({
...getDefaultUseSpendingLimitMock(),
isMoneyAccountSource: true,
+ isMoneyAccountLocked: true,
selectedToken: moneyAccountToken,
moneyAccountTotalFiatFormatted: '$12.34',
});
render({ params: { flow: 'manage' } });
+ expect(screen.getByTestId('account-row-locked')).toBeOnTheScreen();
+ expect(screen.queryByTestId('account-row')).not.toBeOnTheScreen();
expect(screen.getByTestId('account-row-money-account')).toBeOnTheScreen();
expect(screen.getByTestId('token-row-locked')).toBeOnTheScreen();
expect(screen.queryByTestId('token-row')).not.toBeOnTheScreen();
@@ -1188,14 +1205,14 @@ describe('SpendingLimit Component', () => {
).not.toBeOnTheScreen();
});
- it('renders the switch-back CTA in the manage flow when canShowMoneyAccountCta is true', () => {
+ it('renders the Money Account CTA in the enable flow when canShowMoneyAccountCta is true (NotEnabled token + funded)', () => {
mockUseSpendingLimit.mockReturnValue({
...getDefaultUseSpendingLimitMock(),
isMoneyAccountSource: false,
canShowMoneyAccountCta: true,
});
- render({ params: { flow: 'manage' } });
+ render({ params: { flow: 'enable', selectedToken: mockMUSDToken } });
expect(screen.getByTestId('use-money-account-cta')).toBeOnTheScreen();
});
@@ -1255,7 +1272,7 @@ describe('SpendingLimit Component', () => {
expect(screen.getByTestId('account-row')).toBeOnTheScreen();
});
- it('still blocks the manage flow UI on the Money Account balance when linking is possible', () => {
+ it('does NOT block the manage flow UI on the Money Account balance even when linking is possible', () => {
mockUseSpendingLimit.mockReturnValue({
...getDefaultUseSpendingLimitMock(),
isMoneyAccountBalanceLoading: true,
@@ -1264,10 +1281,43 @@ describe('SpendingLimit Component', () => {
render({ params: { flow: 'manage' } });
+ expect(
+ screen.queryByTestId('spending-limit-loading-indicator'),
+ ).not.toBeOnTheScreen();
+ expect(screen.getByTestId('account-row')).toBeOnTheScreen();
+ });
+
+ it('shows the loading state on the enable_card flow while the Money Account balance is still resolving', () => {
+ mockUseSpendingLimit.mockReturnValue({
+ ...getDefaultUseSpendingLimitMock(),
+ isMoneyAccountBalanceLoading: true,
+ canLinkMoneyAccount: true,
+ });
+
+ render({ params: { flow: 'enable_card' } });
+
expect(
screen.getByTestId('spending-limit-loading-indicator'),
).toBeOnTheScreen();
- expect(screen.queryByTestId('account-row')).not.toBeOnTheScreen();
+ });
+
+ it('renders pressable Money Account rows (NOT locked) on the enable_card flow when Money Account is the source', () => {
+ mockUseSpendingLimit.mockReturnValue({
+ ...getDefaultUseSpendingLimitMock(),
+ isMoneyAccountSource: true,
+ isMoneyAccountLocked: false,
+ selectedToken: moneyAccountToken,
+ moneyAccountTotalFiatFormatted: '$12.34',
+ });
+
+ render({ params: { flow: 'enable_card' } });
+
+ expect(screen.getByTestId('account-row')).toBeOnTheScreen();
+ expect(screen.queryByTestId('account-row-locked')).not.toBeOnTheScreen();
+ expect(screen.getByTestId('account-row-money-account')).toBeOnTheScreen();
+ expect(screen.getByTestId('token-row')).toBeOnTheScreen();
+ expect(screen.queryByTestId('token-row-locked')).not.toBeOnTheScreen();
+ expect(screen.getByText('mUSD ($12.34)')).toBeOnTheScreen();
});
});
});
diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.testIds.ts b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.testIds.ts
index 553cbcf0fb3a..6c99d45cd051 100644
--- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.testIds.ts
+++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.testIds.ts
@@ -1,6 +1,7 @@
export const SpendingLimitSelectors = {
LOADING_INDICATOR: 'spending-limit-loading-indicator',
ACCOUNT_ROW: 'account-row',
+ ACCOUNT_ROW_LOCKED: 'account-row-locked',
ACCOUNT_ROW_MONEY_ACCOUNT: 'account-row-money-account',
TOKEN_ROW: 'token-row',
TOKEN_ROW_LOCKED: 'token-row-locked',
diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
index 5671f3165a9e..7d0420bc2d23 100644
--- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
+++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef } from 'react';
-import { ActivityIndicator, Image, TouchableOpacity } from 'react-native';
+import { ActivityIndicator, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
@@ -21,31 +21,21 @@ import { useTheme } from '../../../../../util/theme';
import { strings } from '../../../../../../locales/i18n';
import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController';
import { selectAvatarAccountType } from '../../../../../selectors/settings';
-import AvatarAccount from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount';
-import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
-import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar';
-import BadgeWrapper, {
- BadgePosition,
-} from '../../../../../component-library/components/Badges/BadgeWrapper';
-import Badge, {
- BadgeVariant,
-} from '../../../../../component-library/components/Badges/Badge';
import { useAccountGroupName } from '../../../../hooks/multichainAccounts/useAccountGroupName';
-import { NetworkBadgeSource } from '../../../AssetOverview/Balance/Balance';
import { CardFundingToken } from '../../types';
import useSpendingLimit from '../../hooks/useSpendingLimit';
import { useCardHomeData } from '../../hooks/useCardHomeData';
import useSpendingLimitData from '../../hooks/useSpendingLimitData';
import { buildTokenIconUrl } from '../../util/buildTokenIconUrl';
import { mapCaipChainIdToChainName } from '../../util/mapCaipChainIdToChainName';
-import { safeFormatChainIdToHex } from '../../util/safeFormatChainIdToHex';
import { LINEA_CAIP_CHAIN_ID } from '../../util/buildTokenList';
-import musdAssetIcon from '../../../../../images/musd-icon-2x.png';
+import AccountRow from './components/AccountRow';
+import TokenRow from './components/TokenRow';
import SpendAndEarnPromoCard from './components/SpendAndEarnPromoCard';
import { SpendingLimitSelectors } from './SpendingLimit.testIds';
interface SpendingLimitRouteParams {
- flow?: 'manage' | 'enable' | 'onboarding';
+ flow?: 'manage' | 'enable' | 'onboarding' | 'enable_card';
selectedToken?: CardFundingToken;
}
@@ -69,19 +59,14 @@ const SpendingLimit: React.FC = ({ route }) => {
const avatarAccountType = useSelector(selectAvatarAccountType);
const accountGroupName = useAccountGroupName();
- // Route params carry only intent
const flow = route?.params?.flow || 'manage';
const isOnboardingFlow = flow === 'onboarding';
const selectedTokenFromRoute = route?.params?.selectedToken;
-
- // Read card data from state (not navigation params)
const {
primaryToken,
availableTokens: homeAvailableTokens,
data: cardHomeData,
} = useCardHomeData();
-
- // For onboarding flow when CardHomeData is empty, fetch delegation settings
const {
availableTokens: hookAvailableTokens,
delegationSettings: hookDelegationSettings,
@@ -96,7 +81,6 @@ const SpendingLimit: React.FC = ({ route }) => {
}
}, [isOnboardingFlow, homeAvailableTokens.length, fetchHookData]);
- // Determine data sources: prefer CardHomeData, fall back to hook data for onboarding
const allTokens =
homeAvailableTokens.length > 0
? homeAvailableTokens
@@ -107,7 +91,6 @@ const SpendingLimit: React.FC = ({ route }) => {
cardHomeData?.delegationSettings ??
(isOnboardingFlow ? hookDelegationSettings : null);
- // Spending limit hook
const {
selectedToken,
limitType,
@@ -121,6 +104,7 @@ const SpendingLimit: React.FC = ({ route }) => {
skip,
isValid,
isMoneyAccountSource,
+ isMoneyAccountLocked,
canShowMoneyAccountCta,
selectMoneyAccountAsSource,
moneyAccountTotalFiatFormatted,
@@ -150,7 +134,6 @@ const SpendingLimit: React.FC = ({ route }) => {
return unsubscribe;
}, [navigation]);
- // Derived display values
const tokenLabel = useMemo(() => {
if (!selectedToken) return '';
const chainId = selectedToken.caipChainId ?? LINEA_CAIP_CHAIN_ID;
@@ -184,7 +167,7 @@ const SpendingLimit: React.FC = ({ route }) => {
}, [moneyAccountTotalFiatFormatted]);
const shouldWaitForMoneyAccountBalance =
- flow !== 'enable' && canLinkMoneyAccount;
+ (flow === 'onboarding' || flow === 'enable_card') && canLinkMoneyAccount;
if (
(isOnboardingFlow && isLoadingHookData) ||
(shouldWaitForMoneyAccountBalance && isMoneyAccountBalanceLoading)
@@ -284,150 +267,23 @@ const SpendingLimit: React.FC = ({ route }) => {
{/* Settings card */}
- {/* Account row */}
-
-
-
- {strings('card.card_spending_limit.account_label')}
-
- {isMoneyAccountSource ? (
-
-
-
- {strings('card.card_spending_limit.money_account_label')}
-
-
-
- ) : (
- selectedAccount && (
-
-
-
- {accountGroupName ?? selectedAccount.metadata.name}
-
-
-
- )
- )}
-
-
-
- {/* Token row */}
- {isMoneyAccountSource ? (
-
-
- {strings('card.card_spending_limit.token_label')}
-
-
-
-
- {moneyAccountTokenDisplayLabel}
-
-
-
- ) : (
-
-
-
- {strings('card.card_spending_limit.token_label')}
-
-
- {selectedToken && tokenIconUrl && (
-
- }
- >
-
-
- )}
-
- {tokenLabel}
-
-
-
-
-
- )}
+ />
+
{/* Spending limit row */}
void;
+}
+
+const RowLabel = () => (
+
+ {strings('card.card_spending_limit.account_label')}
+
+);
+
+const Chevron = () => {
+ const tw = useTailwind();
+ return (
+
+ );
+};
+
+const MoneyAccountChip = ({ showChevron }: { showChevron: boolean }) => {
+ const tw = useTailwind();
+ return (
+
+
+
+ {strings('card.card_spending_limit.money_account_label')}
+
+ {showChevron && }
+
+ );
+};
+
+const RegularAccountChip = ({
+ selectedAccount,
+ avatarAccountType,
+ accountGroupName,
+}: Pick<
+ AccountRowProps,
+ 'selectedAccount' | 'avatarAccountType' | 'accountGroupName'
+>) => {
+ if (!selectedAccount) return null;
+ return (
+
+
+
+ {accountGroupName ?? selectedAccount.metadata.name}
+
+
+
+ );
+};
+
+const AccountRow: React.FC = ({
+ isMoneyAccountLocked,
+ isMoneyAccountSource,
+ selectedAccount,
+ avatarAccountType,
+ accountGroupName,
+ onPress,
+}) => {
+ if (isMoneyAccountLocked) {
+ return (
+
+
+
+
+ );
+ }
+ return (
+
+
+
+ {isMoneyAccountSource ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default AccountRow;
diff --git a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx
index 202986d6e3f0..d866e11e1b96 100644
--- a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx
+++ b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx
@@ -93,7 +93,7 @@ const SpendAndEarnPromoCard: React.FC = ({
colors={BUTTON_SHIMMER_COLORS}
widthFraction={0.7}
sweepDurationMs={1200}
- pauseDurationMs={900}
+ pauseDurationMs={6000}
testID={`${testID}-shimmer`}
>
@@ -427,13 +431,13 @@ const AssetSelectionBottomSheet: React.FC = () => {
{/* Balance */}
{item.balanceFiat}
{item.balance} {item.symbol}
@@ -468,7 +472,7 @@ const AssetSelectionBottomSheet: React.FC = () => {
keyboardAvoidingViewEnabled={false}
>
sheetRef.current?.onCloseBottomSheet()}>
-
+
{strings('card.select_asset')}
diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
index 957d2ad1d9a2..ee212404d060 100644
--- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
+++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
@@ -796,6 +796,29 @@ describe('useMoneyAccountCardLinkage', () => {
],
});
});
+
+ it('still submits the delegation when already delegated (Manage Limit update / revoke path)', async () => {
+ applySelectorMocks(buildSelectors({ isAlreadyDelegated: true }));
+ const { result } = renderLinkageHook();
+
+ // canLink is gated on !isAlreadyDelegated, but submit must work so the
+ // user can change the spending cap or revoke it via Manage Limit.
+ expect(result.current.canLink).toBe(false);
+ expect(result.current.canSubmitMoneyAccountDelegation).toBe(true);
+
+ let returned: boolean | undefined;
+ await act(async () => {
+ returned = await result.current.confirmLinkInBackground({
+ delegationAmountHuman: '0',
+ });
+ });
+
+ expect(returned).toBe(true);
+ expect(mockLinkMoneyAccountCard).toHaveBeenCalledTimes(1);
+ expect(mockLinkMoneyAccountCard).toHaveBeenCalledWith(
+ expect.objectContaining({ delegationAmountHuman: '0' }),
+ );
+ });
});
describe('reset', () => {
diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
index e42387641fd6..ff6d0e8dc7ce 100644
--- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
+++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
@@ -66,6 +66,7 @@ export interface UseMoneyAccountCardLinkageReturn {
primaryMoneyAccount: MoneyAccount | undefined;
moneyAccountCardToken: CardFundingToken | null;
canLink: boolean;
+ canSubmitMoneyAccountDelegation: boolean;
status: LinkageStatus;
isLinking: boolean;
@@ -124,12 +125,10 @@ export const useMoneyAccountCardLinkage =
moneyAccountAddress: primaryMoneyAccount?.address,
});
- const canLink = Boolean(
- hasRequirements &&
- isCardAuthenticated &&
- moneyAccountCardToken &&
- !isAlreadyDelegated,
+ const canSubmitMoneyAccountDelegation = Boolean(
+ hasRequirements && isCardAuthenticated && moneyAccountCardToken,
);
+ const canLink = canSubmitMoneyAccountDelegation && !isAlreadyDelegated;
const showPendingToast = useCallback(() => {
toastRef?.current?.showToast({
@@ -285,7 +284,7 @@ export const useMoneyAccountCardLinkage =
async (options?: {
delegationAmountHuman?: string;
}): Promise => {
- if (!canLink || !primaryMoneyAccount?.address) {
+ if (!canSubmitMoneyAccountDelegation || !primaryMoneyAccount?.address) {
showErrorToast();
return false;
}
@@ -330,7 +329,7 @@ export const useMoneyAccountCardLinkage =
}
},
[
- canLink,
+ canSubmitMoneyAccountDelegation,
primaryMoneyAccount?.address,
showErrorToast,
showPendingToast,
@@ -350,6 +349,7 @@ export const useMoneyAccountCardLinkage =
primaryMoneyAccount,
moneyAccountCardToken,
canLink,
+ canSubmitMoneyAccountDelegation,
status,
isLinking: status === 'pending',
diff --git a/app/components/UI/Card/hooks/useSpendingLimit.test.ts b/app/components/UI/Card/hooks/useSpendingLimit.test.ts
index ee79554df7bc..16c8cdc22ed1 100644
--- a/app/components/UI/Card/hooks/useSpendingLimit.test.ts
+++ b/app/components/UI/Card/hooks/useSpendingLimit.test.ts
@@ -1714,11 +1714,56 @@ describe('useSpendingLimit', () => {
expect(result.current.canShowMoneyAccountCta).toBe(false);
});
- it('preselects Money Account in the manage flow when funded + requirements met', () => {
+ it('locks the manage flow to Money Account when the priority token has isMoneyAccountEntry (even with zero balance)', () => {
+ mockUseMoneyAccountCardLinkage.mockReturnValue(
+ buildLinkageReturn({
+ hasMoneyAccountRequirements: true,
+ isCardAuthenticated: true,
+ moneyAccountCardToken: MONEY_ACCOUNT_TOKEN,
+ canLink: true,
+ }),
+ );
+ mockUseMoneyAccountBalance.mockReturnValue(
+ buildBalanceReturn({ tokenTotal: new BigNumber(0) }),
+ );
+ const priorityToken = createMockToken({
+ symbol: 'mUSD',
+ isMoneyAccountEntry: true,
+ });
+
+ const { result } = renderHook(() =>
+ useSpendingLimit(
+ createDefaultParams({ flow: 'manage', priorityToken }),
+ ),
+ );
+
+ expect(result.current.isMoneyAccountSource).toBe(true);
+ expect(result.current.isMoneyAccountLocked).toBe(true);
+ expect(result.current.selectedToken).toEqual(priorityToken);
+ expect(result.current.canShowMoneyAccountCta).toBe(false);
+ });
+
+ it('does NOT lock on onboarding-like flows even when Money Account is preselected as source', () => {
+ setupFunded();
+
+ const { result: onboarding } = renderHook(() =>
+ useSpendingLimit(createDefaultParams({ flow: 'onboarding' })),
+ );
+ expect(onboarding.current.isMoneyAccountSource).toBe(true);
+ expect(onboarding.current.isMoneyAccountLocked).toBe(false);
+
+ const { result: enableCard } = renderHook(() =>
+ useSpendingLimit(createDefaultParams({ flow: 'enable_card' })),
+ );
+ expect(enableCard.current.isMoneyAccountSource).toBe(true);
+ expect(enableCard.current.isMoneyAccountLocked).toBe(false);
+ });
+
+ it('preselects Money Account on the enable_card flow when funded + requirements met', () => {
setupFunded();
const { result } = renderHook(() =>
- useSpendingLimit(createDefaultParams({ flow: 'manage' })),
+ useSpendingLimit(createDefaultParams({ flow: 'enable_card' })),
);
expect(result.current.isMoneyAccountSource).toBe(true);
@@ -1726,17 +1771,81 @@ describe('useSpendingLimit', () => {
expect(result.current.canShowMoneyAccountCta).toBe(false);
});
- it('does NOT preselect Money Account in the enable flow (managing an existing asset)', () => {
+ it('shows the Money Account CTA on the enable_card flow after the user exits Money Account mode', () => {
+ setupFunded();
+
+ const { result, rerender } = renderHook(() =>
+ useSpendingLimit(createDefaultParams({ flow: 'enable_card' })),
+ );
+
+ expect(result.current.isMoneyAccountSource).toBe(true);
+
+ mockUseSelector.mockReturnValue({
+ id: 'account-2',
+ address: '0xaccount2',
+ metadata: { name: 'Account 2' },
+ });
+ rerender();
+
+ expect(result.current.isMoneyAccountSource).toBe(false);
+ expect(result.current.canShowMoneyAccountCta).toBe(true);
+ expect(result.current.isMoneyAccountLocked).toBe(false);
+ });
+
+ it('does NOT lock the manage flow when the priority token is not a money-account entry, even when Money Account is funded', () => {
setupFunded();
+ const priorityToken = createMockToken({ symbol: 'USDC' });
const { result } = renderHook(() =>
- useSpendingLimit(createDefaultParams({ flow: 'enable' })),
+ useSpendingLimit(
+ createDefaultParams({ flow: 'manage', priorityToken }),
+ ),
);
expect(result.current.isMoneyAccountSource).toBe(false);
expect(result.current.canShowMoneyAccountCta).toBe(false);
});
+ it('does NOT preselect Money Account in the enable flow when the initial token is already Enabled', () => {
+ setupFunded();
+ const initialToken = createMockToken({
+ symbol: 'USDC',
+ fundingStatus: FundingStatus.Enabled,
+ });
+
+ const { result } = renderHook(() =>
+ useSpendingLimit(createDefaultParams({ flow: 'enable', initialToken })),
+ );
+
+ expect(result.current.isMoneyAccountSource).toBe(false);
+ expect(result.current.canShowMoneyAccountCta).toBe(false);
+ });
+
+ it('shows the Money Account CTA in the enable flow when the initial token is NotEnabled and Money Account is funded', () => {
+ setupFunded();
+ const initialToken = createMockToken({
+ symbol: 'USDC',
+ fundingStatus: FundingStatus.NotEnabled,
+ });
+
+ const { result } = renderHook(() =>
+ useSpendingLimit(createDefaultParams({ flow: 'enable', initialToken })),
+ );
+
+ expect(result.current.isMoneyAccountSource).toBe(false);
+ expect(result.current.canShowMoneyAccountCta).toBe(true);
+ });
+
+ it('does NOT show the Money Account CTA on the manage flow regardless of funding', () => {
+ setupFunded();
+
+ const { result } = renderHook(() =>
+ useSpendingLimit(createDefaultParams({ flow: 'manage' })),
+ );
+
+ expect(result.current.canShowMoneyAccountCta).toBe(false);
+ });
+
it('respects an explicit initialToken on the manage flow (AssetSelectionBottomSheet path) and does NOT preselect Money Account', () => {
setupFunded();
@@ -1749,11 +1858,17 @@ describe('useSpendingLimit', () => {
expect(result.current.selectedToken).toEqual(initialToken);
});
- it('exits Money Account mode and exposes the switch-back CTA when the user changes account from the picker in the manage flow', () => {
+ it('exits Money Account mode when the user changes account from the picker in the manage flow (CTA stays hidden on manage)', () => {
setupFunded();
+ const priorityToken = createMockToken({
+ symbol: 'mUSD',
+ isMoneyAccountEntry: true,
+ });
const { result, rerender } = renderHook(() =>
- useSpendingLimit(createDefaultParams({ flow: 'manage' })),
+ useSpendingLimit(
+ createDefaultParams({ flow: 'manage', priorityToken }),
+ ),
);
expect(result.current.isMoneyAccountSource).toBe(true);
@@ -1766,13 +1881,16 @@ describe('useSpendingLimit', () => {
rerender();
expect(result.current.isMoneyAccountSource).toBe(false);
- expect(result.current.canShowMoneyAccountCta).toBe(true);
+ expect(result.current.canShowMoneyAccountCta).toBe(false);
});
it('exits Money Account mode when the picker invokes onSelectAccount, even with the same already-selected account', () => {
setupFunded();
- const priorityToken = createMockToken({ symbol: 'USDC' });
+ const priorityToken = createMockToken({
+ symbol: 'mUSD',
+ isMoneyAccountEntry: true,
+ });
const { result } = renderHook(() =>
useSpendingLimit(
createDefaultParams({ flow: 'manage', priorityToken }),
@@ -1780,7 +1898,7 @@ describe('useSpendingLimit', () => {
);
expect(result.current.isMoneyAccountSource).toBe(true);
- expect(result.current.selectedToken).toEqual(MONEY_ACCOUNT_TOKEN);
+ expect(result.current.selectedToken).toEqual(priorityToken);
// Open the picker (this captures the onSelectAccount callback)
act(() => {
@@ -1799,15 +1917,20 @@ describe('useSpendingLimit', () => {
});
expect(result.current.isMoneyAccountSource).toBe(false);
- expect(result.current.canShowMoneyAccountCta).toBe(true);
- expect(result.current.selectedToken).toEqual(priorityToken);
+ expect(result.current.canShowMoneyAccountCta).toBe(false);
});
it('does not re-trigger Money Account auto-preselect after exiting via the picker callback', () => {
setupFunded();
+ const priorityToken = createMockToken({
+ symbol: 'mUSD',
+ isMoneyAccountEntry: true,
+ });
const { result, rerender } = renderHook(() =>
- useSpendingLimit(createDefaultParams({ flow: 'manage' })),
+ useSpendingLimit(
+ createDefaultParams({ flow: 'manage', priorityToken }),
+ ),
);
expect(result.current.isMoneyAccountSource).toBe(true);
@@ -1826,19 +1949,25 @@ describe('useSpendingLimit', () => {
expect(result.current.isMoneyAccountSource).toBe(false);
- // Subsequent re-renders (while Money Account is still funded + linkable)
- // must NOT silently re-select Money Account — the user just opted out.
+ // Subsequent re-renders (priority token still flagged) must NOT silently
+ // re-select Money Account — the user just opted out.
rerender();
expect(result.current.isMoneyAccountSource).toBe(false);
- expect(result.current.canShowMoneyAccountCta).toBe(true);
+ expect(result.current.canShowMoneyAccountCta).toBe(false);
});
it('submit on the manage flow goes back instead of replacing to Card Home (Money Account source path)', async () => {
setupFunded();
+ const priorityToken = createMockToken({
+ symbol: 'mUSD',
+ isMoneyAccountEntry: true,
+ });
const { result } = renderHook(() =>
- useSpendingLimit(createDefaultParams({ flow: 'manage' })),
+ useSpendingLimit(
+ createDefaultParams({ flow: 'manage', priorityToken }),
+ ),
);
await act(async () => {
diff --git a/app/components/UI/Card/hooks/useSpendingLimit.ts b/app/components/UI/Card/hooks/useSpendingLimit.ts
index 298a6fbca562..e3d1d96f6b6c 100644
--- a/app/components/UI/Card/hooks/useSpendingLimit.ts
+++ b/app/components/UI/Card/hooks/useSpendingLimit.ts
@@ -60,7 +60,7 @@ import { CardActions, CardScreens } from '../util/metrics';
export type LimitType = 'full' | 'restricted';
export interface UseSpendingLimitParams {
- flow: 'manage' | 'enable' | 'onboarding';
+ flow: 'manage' | 'enable' | 'onboarding' | 'enable_card';
initialToken?: CardFundingToken | null;
priorityToken?: CardFundingToken | null;
allTokens: CardFundingToken[];
@@ -96,6 +96,7 @@ export interface UseSpendingLimitReturn {
isFaucetCheckLoading: boolean;
isMoneyAccountSource: boolean;
+ isMoneyAccountLocked: boolean;
canShowMoneyAccountCta: boolean;
selectMoneyAccountAsSource: () => void;
moneyAccountTotalFiatFormatted: string | undefined;
@@ -165,7 +166,13 @@ const useSpendingLimit = ({
const [isMoneyAccountSource, setIsMoneyAccountSource] = useState(false);
const isOnboardingFlow = flow === 'onboarding';
- const isMoneyAccountPreselectAllowed = flow !== 'enable';
+ const isEnableCardFlow = flow === 'enable_card';
+ const isEnableFlow = flow === 'enable';
+ const isManageFlow = flow === 'manage';
+ const isOnboardingLikeFlow = isOnboardingFlow || isEnableCardFlow;
+ const isMoneyAccountPreselectAllowed = isOnboardingLikeFlow;
+ const isEnablingNotEnabledToken =
+ isEnableFlow && initialToken?.fundingStatus === FundingStatus.NotEnabled;
const {
moneyAccountCardToken,
@@ -341,6 +348,17 @@ const useSpendingLimit = ({
return;
}
+ if (
+ isManageFlow &&
+ priorityToken?.isMoneyAccountEntry &&
+ !hasUserExitedMoneyAccountSourceRef.current
+ ) {
+ setIsMoneyAccountSource(true);
+ applySelectedToken(priorityToken);
+ setHasInitialized(true);
+ return;
+ }
+
if (
isMoneyAccountPreselectAllowed &&
canLinkMoneyAccount &&
@@ -422,6 +440,7 @@ const useSpendingLimit = ({
delegationSettings,
sdk,
applySelectedToken,
+ isManageFlow,
isMoneyAccountPreselectAllowed,
canLinkMoneyAccount,
isMoneyAccountBalanceLoading,
@@ -533,11 +552,15 @@ const useSpendingLimit = ({
}, [moneyAccountCardToken, applySelectedToken]);
const canShowMoneyAccountCta =
- isMoneyAccountPreselectAllowed &&
+ (isOnboardingLikeFlow || isEnablingNotEnabledToken) &&
!isMoneyAccountSource &&
isMoneyAccountFunded &&
canLinkMoneyAccount;
+ const isMoneyAccountLocked = Boolean(
+ isManageFlow && priorityToken?.isMoneyAccountEntry,
+ );
+
const handleLimitSelect = useCallback(() => {
navigation.navigate(
...createSpendingLimitOptionsNavigationDetails({
@@ -760,6 +783,7 @@ const useSpendingLimit = ({
isFaucetCheckLoading,
isMoneyAccountSource,
+ isMoneyAccountLocked,
canShowMoneyAccountCta,
selectMoneyAccountAsSource,
moneyAccountTotalFiatFormatted,
diff --git a/app/components/UI/Card/types.ts b/app/components/UI/Card/types.ts
index 83952db60909..2774f46f931e 100644
--- a/app/components/UI/Card/types.ts
+++ b/app/components/UI/Card/types.ts
@@ -86,6 +86,7 @@ export type CardFundingToken = {
spendableBalance: string;
spendingCap?: string;
originalSpendingCap?: string;
+ isMoneyAccountEntry?: boolean;
} & CardToken &
AuthenticatedCardFundingTokenData;
diff --git a/app/components/UI/Card/util/isMoneyAccountEntry.test.ts b/app/components/UI/Card/util/isMoneyAccountEntry.test.ts
new file mode 100644
index 000000000000..40c17d9d6e54
--- /dev/null
+++ b/app/components/UI/Card/util/isMoneyAccountEntry.test.ts
@@ -0,0 +1,76 @@
+import type { MoneyAccountControllerState } from '@metamask/money-account-controller';
+import { isMoneyAccountEntry } from './isMoneyAccountEntry';
+
+type MoneyAccountsMap = MoneyAccountControllerState['moneyAccounts'];
+
+const makeMoneyAccounts = (addresses: string[]): MoneyAccountsMap =>
+ addresses.reduce((acc, address, idx) => {
+ acc[`account-${idx}`] = {
+ address,
+ } as MoneyAccountsMap[string];
+ return acc;
+ }, {});
+
+describe('isMoneyAccountEntry', () => {
+ it('returns false when walletAddress is undefined', () => {
+ const moneyAccounts = makeMoneyAccounts(['0xabc']);
+ expect(isMoneyAccountEntry(undefined, moneyAccounts)).toBe(false);
+ });
+
+ it('returns false when moneyAccounts is empty', () => {
+ expect(isMoneyAccountEntry('0xabc', {})).toBe(false);
+ });
+
+ it('returns false when no money account matches walletAddress', () => {
+ const moneyAccounts = makeMoneyAccounts([
+ '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ ]);
+ expect(
+ isMoneyAccountEntry(
+ '0xcccccccccccccccccccccccccccccccccccccccc',
+ moneyAccounts,
+ ),
+ ).toBe(false);
+ });
+
+ it('returns true when a money account matches walletAddress exactly', () => {
+ const target = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+ const moneyAccounts = makeMoneyAccounts([target]);
+ expect(isMoneyAccountEntry(target, moneyAccounts)).toBe(true);
+ });
+
+ it('matches case-insensitively when walletAddress has mixed case', () => {
+ const moneyAccounts = makeMoneyAccounts([
+ '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ ]);
+ expect(
+ isMoneyAccountEntry(
+ '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ moneyAccounts,
+ ),
+ ).toBe(true);
+ });
+
+ it('matches case-insensitively when money account address has mixed case', () => {
+ const moneyAccounts = makeMoneyAccounts([
+ '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa',
+ ]);
+ expect(
+ isMoneyAccountEntry(
+ '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ moneyAccounts,
+ ),
+ ).toBe(true);
+ });
+
+ it('returns true when walletAddress matches any (not only first) money account', () => {
+ const target = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
+ const moneyAccounts = makeMoneyAccounts([
+ '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ target,
+ '0xcccccccccccccccccccccccccccccccccccccccc',
+ ]);
+ expect(isMoneyAccountEntry(target, moneyAccounts)).toBe(true);
+ });
+});
diff --git a/app/components/UI/Card/util/isMoneyAccountEntry.ts b/app/components/UI/Card/util/isMoneyAccountEntry.ts
new file mode 100644
index 000000000000..ccc5f9dc47f3
--- /dev/null
+++ b/app/components/UI/Card/util/isMoneyAccountEntry.ts
@@ -0,0 +1,36 @@
+import type { MoneyAccountControllerState } from '@metamask/money-account-controller';
+
+/**
+ * Returns true when `walletAddress` corresponds to any money account on the
+ * user's device.
+ *
+ * This is the single source of truth for "is this token tied to a money
+ * account?" across Card surfaces (Card Home label, Add Funds redirect,
+ * Manage Limit lock, Asset Selection per-row label).
+ *
+ * INTERIM: today we identify money-account entries by matching the funding
+ * token's `walletAddress` against the set of known money account addresses.
+ * Once Veda vault tokens land — and because only money accounts can delegate
+ * to vault tokens — this predicate should switch to a token-identity check
+ * (e.g. matching the token address / chain). The flag name and consumers
+ * stay unchanged; only this predicate needs to change.
+ *
+ * @param walletAddress - The funding token's wallet address (the account
+ * that holds the delegated allowance for the card).
+ * @param moneyAccounts - The money account record from
+ * `selectMoneyAccounts`.
+ * @returns True when `walletAddress` matches any known money account
+ * address (case-insensitive).
+ */
+export const isMoneyAccountEntry = (
+ walletAddress: string | undefined,
+ moneyAccounts: MoneyAccountControllerState['moneyAccounts'],
+): boolean => {
+ if (!walletAddress) {
+ return false;
+ }
+ const target = walletAddress.toLowerCase();
+ return Object.values(moneyAccounts).some(
+ (account) => account.address.toLowerCase() === target,
+ );
+};
diff --git a/app/components/UI/Card/util/toCardTokenAllowance.test.ts b/app/components/UI/Card/util/toCardTokenAllowance.test.ts
index f721a419ede1..0b4bc00a1f9e 100644
--- a/app/components/UI/Card/util/toCardTokenAllowance.test.ts
+++ b/app/components/UI/Card/util/toCardTokenAllowance.test.ts
@@ -99,6 +99,23 @@ describe('toCardFundingToken', () => {
});
});
+ describe('isMoneyAccountEntry flag', () => {
+ it('defaults to false when no flag is provided', () => {
+ const result = toCardFundingToken(makeAsset());
+ expect(result.isMoneyAccountEntry).toBe(false);
+ });
+
+ it('forwards true when caller passes true', () => {
+ const result = toCardFundingToken(makeAsset(), true);
+ expect(result.isMoneyAccountEntry).toBe(true);
+ });
+
+ it('forwards false when caller passes false explicitly', () => {
+ const result = toCardFundingToken(makeAsset(), false);
+ expect(result.isMoneyAccountEntry).toBe(false);
+ });
+ });
+
describe('field passthrough', () => {
it('maps remaining balance to spendableBalance and total cap to spendingCap', () => {
const asset = makeAsset({
diff --git a/app/components/UI/Card/util/toCardTokenAllowance.ts b/app/components/UI/Card/util/toCardTokenAllowance.ts
index 40a77c90f8fb..eeadfb2aa42a 100644
--- a/app/components/UI/Card/util/toCardTokenAllowance.ts
+++ b/app/components/UI/Card/util/toCardTokenAllowance.ts
@@ -7,7 +7,10 @@ const STATUS_TO_FUNDING_STATUS: Record = {
inactive: FundingStatus.NotEnabled,
};
-export function toCardFundingToken(asset: CardFundingAsset): CardFundingToken {
+export function toCardFundingToken(
+ asset: CardFundingAsset,
+ isMoneyAccountEntry: boolean = false,
+): CardFundingToken {
return {
address: asset.address,
decimals: asset.decimals,
@@ -24,5 +27,6 @@ export function toCardFundingToken(asset: CardFundingAsset): CardFundingToken {
asset.priority >= Number.MAX_SAFE_INTEGER ? undefined : asset.priority,
stagingTokenAddress: asset.stagingTokenAddress ?? null,
delegationContract: asset.delegationContract ?? null,
+ isMoneyAccountEntry,
};
}
diff --git a/app/components/UI/Card/util/truncateAddress.test.ts b/app/components/UI/Card/util/truncateAddress.test.ts
index 1cf1abc8da50..9e618b7b2940 100644
--- a/app/components/UI/Card/util/truncateAddress.test.ts
+++ b/app/components/UI/Card/util/truncateAddress.test.ts
@@ -79,40 +79,36 @@ describe('truncateAddress', () => {
});
});
- describe('non-hex addresses', () => {
- it('truncates non-hex string without checksumming', () => {
- const address = 'some-non-hex-string-1234567890';
+ describe('non-hex inputs (display labels)', () => {
+ it('returns a non-hex string verbatim (so labels like "Money account" are not mangled)', () => {
+ const label = 'Money account';
mockIsHexAddress.mockReturnValue(false);
- const result = truncateAddress(address);
+ const result = truncateAddress(label);
- expect(mockIsHexAddress).toHaveBeenCalledWith(address);
+ expect(mockIsHexAddress).toHaveBeenCalledWith(label);
expect(mockSafeToChecksumAddress).not.toHaveBeenCalled();
- expect(result).toBe('some...7890');
+ expect(result).toBe(label);
});
- it('truncates alphanumeric string without 0x prefix', () => {
- const address = '1234567890abcdef1234567890abcdef12345678';
+ it('returns an alphanumeric string without 0x prefix unchanged', () => {
+ const value = '1234567890abcdef1234567890abcdef12345678';
mockIsHexAddress.mockReturnValue(false);
- const result = truncateAddress(address);
+ const result = truncateAddress(value);
- expect(result).toBe('1234...5678');
+ expect(result).toBe(value);
});
});
describe('edge cases', () => {
it('returns undefined when address is undefined', () => {
- mockIsHexAddress.mockReturnValue(false);
-
const result = truncateAddress(undefined);
expect(result).toBeUndefined();
});
it('returns undefined when address is empty string', () => {
- mockIsHexAddress.mockReturnValue(false);
-
const result = truncateAddress('');
expect(result).toBeUndefined();
@@ -130,13 +126,13 @@ describe('truncateAddress', () => {
expect(result).toBeUndefined();
});
- it('truncates single character address', () => {
- const address = 'x';
+ it('returns a single non-hex character unchanged', () => {
+ const value = 'x';
mockIsHexAddress.mockReturnValue(false);
- const result = truncateAddress(address);
+ const result = truncateAddress(value);
- expect(result).toBe('x...x');
+ expect(result).toBe('x');
});
});
});
diff --git a/app/components/UI/Card/util/truncateAddress.ts b/app/components/UI/Card/util/truncateAddress.ts
index ace83cf65760..6a91dd35fa0d 100644
--- a/app/components/UI/Card/util/truncateAddress.ts
+++ b/app/components/UI/Card/util/truncateAddress.ts
@@ -1,15 +1,29 @@
import { isHexAddress } from '@metamask/utils';
import { safeToChecksumAddress } from '../../../../util/address';
+/**
+ * Truncates a hex wallet address for display (e.g. `0x1234...5678`).
+ *
+ * Non-hex inputs are returned verbatim. This lets callers safely substitute
+ * a human-readable label (e.g. "Money account") through the same prop path
+ * without having it sliced into a meaningless fragment.
+ */
export const truncateAddress = (
address: string | undefined,
chars: number = 4,
) => {
- const addressToTruncate = isHexAddress(address)
- ? safeToChecksumAddress(address)
- : address;
+ if (!address) {
+ return undefined;
+ }
+
+ if (!isHexAddress(address)) {
+ return address;
+ }
- if (addressToTruncate) {
- return `${addressToTruncate.slice(0, chars)}...${addressToTruncate.slice(-chars)}`;
+ const checksummed = safeToChecksumAddress(address);
+ if (!checksummed) {
+ return undefined;
}
+
+ return `${checksummed.slice(0, chars)}...${checksummed.slice(-chars)}`;
};
diff --git a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.test.tsx b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.test.tsx
index 5553a06aec42..91b7bc746246 100644
--- a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.test.tsx
+++ b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.test.tsx
@@ -92,16 +92,16 @@ describe('MoneyBalanceSummary', () => {
).not.toBeOnTheScreen();
});
- it('hides the APY text and tooltip button when apy is zero', () => {
+ it('shows the APY text and tooltip button when apy is zero', () => {
const mockInfoPress = jest.fn();
- const { queryByTestId } = render(
+ const { getByTestId } = render(
,
);
- expect(queryByTestId(MoneyBalanceSummaryTestIds.APY)).not.toBeOnTheScreen();
+ expect(getByTestId(MoneyBalanceSummaryTestIds.APY)).toBeOnTheScreen();
expect(
- queryByTestId(MoneyBalanceSummaryTestIds.APY_INFO_BUTTON),
- ).not.toBeOnTheScreen();
+ getByTestId(MoneyBalanceSummaryTestIds.APY_INFO_BUTTON),
+ ).toBeOnTheScreen();
});
it('hides the APY tooltip button when isLoading is true', () => {
diff --git a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx
index 843b43b6776d..a10c830bc3dc 100644
--- a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx
+++ b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx
@@ -15,7 +15,7 @@ import {
} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import { MoneyBalanceSummaryTestIds } from './MoneyBalanceSummary.testIds';
-import { isPositiveNumber } from '../../utils/number';
+import { isPositiveNumberOrZero } from '../../utils/number';
const DEFAULT_BALANCE = '$0.00';
@@ -76,7 +76,7 @@ const MoneyBalanceSummary = ({
testID={MoneyBalanceSummaryTestIds.APY_SKELETON}
/>
) : (
- isPositiveNumber(apy) && (
+ isPositiveNumberOrZero(apy) && (
)
)}
- {onApyInfoPress && isPositiveNumber(apy) && !isLoading && (
+ {onApyInfoPress && isPositiveNumberOrZero(apy) && !isLoading && (
{
expect(queryByTestId(MoneyHowItWorksTestIds.APY)).not.toBeOnTheScreen();
});
- it('hides the highlighted APY text when apy is zero', () => {
- const { queryByTestId } = render();
+ it('shows the highlighted APY text when apy is zero', () => {
+ const { getByTestId } = render();
- expect(queryByTestId(MoneyHowItWorksTestIds.APY)).not.toBeOnTheScreen();
+ expect(getByTestId(MoneyHowItWorksTestIds.APY)).toBeOnTheScreen();
});
it('hides the highlighted APY text when apy is negative', () => {
diff --git a/app/components/UI/Money/components/MoneyHowItWorks/MoneyHowItWorks.tsx b/app/components/UI/Money/components/MoneyHowItWorks/MoneyHowItWorks.tsx
index 2789621d4add..44ec5925a06c 100644
--- a/app/components/UI/Money/components/MoneyHowItWorks/MoneyHowItWorks.tsx
+++ b/app/components/UI/Money/components/MoneyHowItWorks/MoneyHowItWorks.tsx
@@ -9,7 +9,7 @@ import {
import { strings } from '../../../../../../locales/i18n';
import MoneySectionHeader from '../MoneySectionHeader';
import { MoneyHowItWorksTestIds } from './MoneyHowItWorks.testIds';
-import { isPositiveNumber } from '../../utils/number';
+import { isPositiveNumberOrZero } from '../../utils/number';
interface MoneyHowItWorksProps {
/** APY expressed as a percentage (e.g. 3 for 3%). */
@@ -35,7 +35,7 @@ const MoneyHowItWorks = ({
testID={MoneyHowItWorksTestIds.DESCRIPTION}
>
{strings('money.how_it_works.description_prefix')}
- {!isLoading && isPositiveNumber(apy) && (
+ {!isLoading && isPositiveNumberOrZero(apy) && (
{
).not.toBeOnTheScreen();
});
- it('hides the inline APY text in the auto-earn benefit when apy is zero', () => {
- const { queryByText } = render();
+ it('renders the inline APY text in the auto-earn benefit when apy is zero', () => {
+ const { getByText } = render();
expect(
- queryByText(strings('money.apy_label', { percentage: 0 })),
- ).not.toBeOnTheScreen();
+ getByText(strings('money.apy_label', { percentage: 0 })),
+ ).toBeOnTheScreen();
});
});
diff --git a/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.tsx b/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.tsx
index 79bf54619cb5..27b833e3e936 100644
--- a/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.tsx
+++ b/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.tsx
@@ -17,7 +17,7 @@ import {
import { strings } from '../../../../../../locales/i18n';
import MoneySectionHeader from '../MoneySectionHeader';
import { MoneyWhatYouGetTestIds } from './MoneyWhatYouGet.testIds';
-import { isPositiveNumber } from '../../utils/number';
+import { isPositiveNumberOrZero } from '../../utils/number';
interface MoneyWhatYouGetProps {
/** APY expressed as a percentage (e.g. 3 for 3%). */
@@ -53,7 +53,7 @@ const MoneyWhatYouGet = ({ apy, onLearnMorePress }: MoneyWhatYouGetProps) => (
{`${strings('money.what_you_get.benefit_auto_earn')} `}
- {isPositiveNumber(apy) && (
+ {isPositiveNumberOrZero(apy) && (
{strings('money.apy_label', { percentage: apy })}
diff --git a/app/components/UI/Money/utils/number.test.ts b/app/components/UI/Money/utils/number.test.ts
index 294b22ec80ae..a56e01d6b86a 100644
--- a/app/components/UI/Money/utils/number.test.ts
+++ b/app/components/UI/Money/utils/number.test.ts
@@ -1,4 +1,4 @@
-import { isPositiveNumber } from './number';
+import { isPositiveNumber, isPositiveNumberOrZero } from './number';
describe('isPositiveNumber', () => {
describe('returns true for positive finite numbers', () => {
@@ -52,3 +52,56 @@ describe('isPositiveNumber', () => {
});
});
});
+
+describe('isPositiveNumberOrZero', () => {
+ describe('returns true for positive finite numbers and zero', () => {
+ it.each([
+ ['a positive integer', 1],
+ ['a positive decimal', 0.01],
+ ['zero', 0],
+ ['Number.MAX_SAFE_INTEGER', Number.MAX_SAFE_INTEGER],
+ ])('returns true for %s', (_label, value) => {
+ const result = isPositiveNumberOrZero(value);
+
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('returns false for non-number types', () => {
+ it.each([
+ ['a numeric string', '1'],
+ ['null', null],
+ ['undefined', undefined],
+ ['a boolean', true],
+ ['an array', [1]],
+ ['an object', { value: 1 }],
+ ])('returns false for %s', (_label, value) => {
+ const result = isPositiveNumberOrZero(value);
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('returns false for non-finite numbers', () => {
+ it.each([
+ ['Infinity', Infinity],
+ ['-Infinity', -Infinity],
+ ['NaN', NaN],
+ ])('returns false for %s', (_label, value) => {
+ const result = isPositiveNumberOrZero(value);
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('returns false for negative numbers', () => {
+ it.each([
+ ['a negative integer', -1],
+ ['a negative decimal', -0.01],
+ ])('returns false for %s', (_label, value) => {
+ const result = isPositiveNumberOrZero(value);
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/app/components/UI/Money/utils/number.ts b/app/components/UI/Money/utils/number.ts
index d4ee51ce621e..6a0b441015a4 100644
--- a/app/components/UI/Money/utils/number.ts
+++ b/app/components/UI/Money/utils/number.ts
@@ -7,3 +7,13 @@
*/
export const isPositiveNumber = (value: unknown): value is number =>
typeof value === 'number' && isFinite(value) && value > 0;
+
+/**
+ * Determines if a value is a positive number or zero.
+ * Used to avoid littering Money components with these repeated checks.
+ *
+ * @param value - The value to check.
+ * @returns True if the value is a positive number or zero, false otherwise.
+ */
+export const isPositiveNumberOrZero = (value: unknown): value is number =>
+ typeof value === 'number' && isFinite(value) && value >= 0;
diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts
index 694a99dd6b55..be2fbeb1f45d 100644
--- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts
+++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts
@@ -348,6 +348,7 @@ const defaultFeatureFlags: PredictFeatureFlags = {
fakOrdersEnabled: false,
predictWithAnyTokenEnabled: false,
predictUpDownEnabled: false,
+ predictPortfolioEnabled: false,
predictHomepageDiscoveryNbaChampionEnabled: true,
predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG,
};
diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts
index 8861b53cebda..f2e65ac5176f 100644
--- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts
+++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts
@@ -10,6 +10,7 @@ import {
selectPredictHomeFeaturedVariant,
selectPredictHomepageDiscoveryNbaChampionEnabledFlag,
selectPredictHotTabFlag,
+ selectPredictPortfolioEnabledFlag,
selectPredictUpDownEnabledFlag,
selectPredictWithAnyTokenEnabledFlag,
selectPredictWorldCupConfig,
@@ -1544,6 +1545,126 @@ describe('Predict Feature Flag Selectors', () => {
});
});
+ describe('selectPredictPortfolioEnabledFlag', () => {
+ it('returns false when flag is missing', () => {
+ const result = selectPredictPortfolioEnabledFlag(mockedEmptyFlagsState);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns true when flag is enabled and version requirement is met', () => {
+ mockHasMinimumRequiredVersion.mockReturnValue(true);
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictPortfolioEnabledFlag(state);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false when flag is disabled', () => {
+ mockHasMinimumRequiredVersion.mockReturnValue(true);
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictPortfolioEnabledFlag(state);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when flag is malformed', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ enabled: 'true',
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictPortfolioEnabledFlag(state);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when app version is below minimum required', () => {
+ mockHasMinimumRequiredVersion.mockReturnValue(false);
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ enabled: true,
+ minimumVersion: '99.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictPortfolioEnabledFlag(state);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when minimumVersion is the default empty string', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ enabled: true,
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictPortfolioEnabledFlag(state);
+
+ expect(result).toBe(false);
+ });
+ });
+
describe('selectPredictHomepageDiscoveryNbaChampionEnabledFlag', () => {
it('returns false when the remote flag is disabled', () => {
mockHasMinimumRequiredVersion.mockReturnValue(true);
diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts
index 26126f4ad7b0..7cd3ccedd50c 100644
--- a/app/components/UI/Predict/selectors/featureFlags/index.ts
+++ b/app/components/UI/Predict/selectors/featureFlags/index.ts
@@ -178,6 +178,11 @@ export const selectPredictWorldCupScreenEnabledFlag = createSelector(
(config) => config.enabled && config.showWorldCupScreen,
);
+export const selectPredictPortfolioEnabledFlag = createSelector(
+ selectPredictFeatureFlags,
+ (flags) => flags.predictPortfolioEnabled,
+);
+
export const selectPredictFeaturedCarouselEnabledFlag = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) =>
diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts
index 668073025b39..4490f87cbaac 100644
--- a/app/components/UI/Predict/types/flags.ts
+++ b/app/components/UI/Predict/types/flags.ts
@@ -55,6 +55,7 @@ export interface PredictFeatureFlags {
predictUpDownEnabled: boolean;
predictHomepageDiscoveryNbaChampionEnabled: boolean;
predictWorldCup: PredictWorldCupConfig;
+ predictPortfolioEnabled: boolean;
}
export interface PredictHotTabFlag extends VersionGatedFeatureFlag {
diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts
index 9711b3ee1c19..ff9a93020d23 100644
--- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts
+++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts
@@ -32,6 +32,7 @@ describe('resolvePredictFeatureFlags', () => {
fakOrdersEnabled: false,
predictWithAnyTokenEnabled: false,
predictUpDownEnabled: false,
+ predictPortfolioEnabled: false,
predictHomepageDiscoveryNbaChampionEnabled: true,
predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG,
});
@@ -323,6 +324,134 @@ describe('resolvePredictFeatureFlags', () => {
});
});
+ describe('predictPortfolioEnabled', () => {
+ it('returns false when flag is missing', () => {
+ const result = resolvePredictFeatureFlags({});
+
+ expect(result.predictPortfolioEnabled).toBe(false);
+ });
+
+ it('returns true when enabled and version gate passes', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => {
+ if (
+ flag &&
+ typeof flag === 'object' &&
+ 'minimumVersion' in flag &&
+ !('leagues' in flag) &&
+ !('seriesId' in flag)
+ ) {
+ return true;
+ }
+ return undefined;
+ });
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ });
+
+ expect(result.predictPortfolioEnabled).toBe(true);
+ });
+
+ it('returns false when flag is disabled', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => {
+ if (
+ flag &&
+ typeof flag === 'object' &&
+ 'minimumVersion' in flag &&
+ !('leagues' in flag) &&
+ !('seriesId' in flag)
+ ) {
+ return false;
+ }
+ return undefined;
+ });
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ },
+ });
+
+ expect(result.predictPortfolioEnabled).toBe(false);
+ });
+
+ it('returns false when flag is malformed', () => {
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ enabled: 'true',
+ minimumVersion: '1.0.0',
+ },
+ },
+ });
+
+ expect(result.predictPortfolioEnabled).toBe(false);
+ });
+
+ it('returns false when version gate fails', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => {
+ if (
+ flag &&
+ typeof flag === 'object' &&
+ 'minimumVersion' in flag &&
+ !('leagues' in flag) &&
+ !('seriesId' in flag)
+ ) {
+ return false;
+ }
+ return undefined;
+ });
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ enabled: true,
+ minimumVersion: '99.0.0',
+ },
+ },
+ });
+
+ expect(result.predictPortfolioEnabled).toBe(false);
+ });
+
+ it('unwraps progressive rollout shape', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => {
+ if (
+ flag &&
+ typeof flag === 'object' &&
+ 'minimumVersion' in flag &&
+ !('leagues' in flag) &&
+ !('seriesId' in flag)
+ ) {
+ return true;
+ }
+ return undefined;
+ });
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictPortfolio: {
+ name: 'group-a',
+ value: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ },
+ });
+
+ expect(result.predictPortfolioEnabled).toBe(true);
+ });
+ });
+
describe('extendedSportsMarketsLeagues', () => {
it('returns empty array when flag is missing', () => {
const result = resolvePredictFeatureFlags({});
diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts
index c4bf90e3a083..1c3238edcbc0 100644
--- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts
+++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts
@@ -99,6 +99,9 @@ export function resolvePredictFeatureFlags(
const predictUpDownEnabled = resolveVersionGatedBooleanFlag(
flags.predictUpDown,
);
+ const predictPortfolioEnabled = resolveVersionGatedBooleanFlag(
+ flags.predictPortfolio,
+ );
const predictHomepageDiscoveryNbaChampionEnabled =
resolveVersionGatedBooleanFlag(
flags.predictHomepageDiscoveryNbaChampionEnabled,
@@ -125,6 +128,7 @@ export function resolvePredictFeatureFlags(
fakOrdersEnabled,
predictWithAnyTokenEnabled,
predictUpDownEnabled,
+ predictPortfolioEnabled,
predictHomepageDiscoveryNbaChampionEnabled,
predictWorldCup,
};
diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.test.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.test.tsx
index 77c831bad3e3..4d77e1d71734 100644
--- a/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.test.tsx
+++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountDetails.test.tsx
@@ -9,7 +9,7 @@ import { KeyringTypes } from '@metamask/keyring-controller';
import renderWithProvider from '../../../../util/test/renderWithProvider';
import { InternalAccount } from '@metamask/keyring-internal-api';
import { formatAddress } from '../../../../util/address';
-import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar';
+import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.types';
jest.mock('../../confirmations/hooks/7702/useEIP7702Networks', () => ({
useEIP7702Networks: jest.fn().mockReturnValue({
diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.test.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.test.tsx
index 031a101061ec..59102063928f 100644
--- a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.test.tsx
+++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.test.tsx
@@ -12,7 +12,7 @@ import { AccountDetailsIds } from '../../../AccountDetails.testIds';
import { formatAddress } from '../../../../../../util/address';
import { RootState } from '../../../../../../reducers';
import { backgroundState } from '../../../../../../util/test/initial-root-state';
-import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar';
+import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.types';
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.tsx
index b786aa093800..4efdf8fa80fb 100644
--- a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.tsx
+++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.tsx
@@ -2,12 +2,22 @@ import React, { useCallback } from 'react';
import { ScrollView, TouchableOpacity } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { InternalAccount } from '@metamask/keyring-internal-api';
+import {
+ AvatarAccount,
+ AvatarAccountSize,
+ AvatarAccountVariant,
+ FontWeight,
+ HeaderBase,
+ Icon,
+ IconColor,
+ IconName,
+ IconSize,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../../locales/i18n';
import styleSheet from './styles';
-import Text, {
- TextVariant,
-} from '../../../../../../component-library/components/Texts/Text';
-import ButtonLink from '../../../../../../component-library/components/Buttons/Button/variants/ButtonLink';
import { formatAddress } from '../../../../../../util/address';
import Routes from '../../../../../../constants/navigation/Routes';
import { useNavigation } from '@react-navigation/native';
@@ -17,15 +27,6 @@ import {
JustifyContent,
} from '../../../../../UI/Box/box.types';
import { Box } from '../../../../../UI/Box/Box';
-import Icon, {
- IconName,
- IconSize,
-} from '../../../../../../component-library/components/Icons/Icon';
-import Avatar, {
- AvatarSize,
- AvatarVariant,
-} from '../../../../../../component-library/components/Avatars/Avatar';
-import HeaderBase from '../../../../../../component-library/components/HeaderBase';
import { useStyles } from '../../../../../hooks/useStyles';
import { AccountDetailsIds } from '../../../AccountDetails.testIds';
import { useSelector } from 'react-redux';
@@ -34,6 +35,24 @@ import {
selectAccountToGroupMap,
} from '../../../../../../selectors/multichainAccounts/accountTreeController';
import { selectAvatarAccountType } from '../../../../../../selectors/settings';
+import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.types';
+
+/** Matches legacy {@link HEADERBASE_TITLE_TEST_ID} for stable E2E/unit queries. */
+const HEADER_BASE_TITLE_TEST_ID = 'header-title';
+
+const toAvatarAccountVariant = (
+ type: AvatarAccountType,
+): AvatarAccountVariant => {
+ switch (type) {
+ case AvatarAccountType.JazzIcon:
+ return AvatarAccountVariant.Jazzicon;
+ case AvatarAccountType.Blockies:
+ return AvatarAccountVariant.Blockies;
+ case AvatarAccountType.Maskicon:
+ default:
+ return AvatarAccountVariant.Maskicon;
+ }
+};
interface BaseAccountDetailsProps {
account: InternalAccount;
@@ -45,8 +64,7 @@ export const BaseAccountDetails = ({
children,
}: BaseAccountDetailsProps) => {
const navigation = useNavigation();
- const { styles, theme } = useStyles(styleSheet, {});
- const { colors } = theme;
+ const { styles } = useStyles(styleSheet, {});
const accountAvatarType = useSelector(selectAvatarAccountType);
const selectWallet = useSelector(selectWalletByAccount);
const wallet = selectWallet?.(account.id);
@@ -87,14 +105,12 @@ export const BaseAccountDetails = ({
}
- onPress={() => navigation.goBack()}
- />
- }
+ titleTestID={HEADER_BASE_TITLE_TEST_ID}
+ startButtonIconProps={{
+ testID: AccountDetailsIds.BACK_BUTTON,
+ iconName: IconName.ArrowLeft,
+ onPress: () => navigation.goBack(),
+ }}
>
{account.metadata.name}
@@ -107,11 +123,10 @@ export const BaseAccountDetails = ({
flexDirection={FlexDirection.Row}
justifyContent={JustifyContent.center}
>
-
@@ -120,7 +135,7 @@ export const BaseAccountDetails = ({
testID={AccountDetailsIds.ACCOUNT_NAME_LINK}
onPress={handleEditAccountName}
>
-
+
{strings('multichain_accounts.account_details.account_name')}
-
+
{account.metadata.name}
@@ -143,7 +162,7 @@ export const BaseAccountDetails = ({
testID={AccountDetailsIds.ACCOUNT_ADDRESS_LINK}
onPress={handleShareAddress}
>
-
+
{strings('multichain_accounts.account_details.account_address')}
-
+
{formatAddress(account.address, 'short')}
@@ -166,7 +189,7 @@ export const BaseAccountDetails = ({
testID={AccountDetailsIds.WALLET_NAME_LINK}
onPress={handleWalletClick}
>
-
+
{strings('multichain_accounts.account_details.wallet')}
-
+
{wallet?.metadata.name}
diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/styles.ts b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/styles.ts
index 6d0a50bba4dc..f52898912021 100644
--- a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/styles.ts
+++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/styles.ts
@@ -38,10 +38,6 @@ const styleSheet = (params: { theme: Theme }) => {
header: {
margin: 16,
},
-
- text: {
- color: colors.text.alternative,
- },
});
};
diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/HardwareAccountDetails/HardwareAccountDetails.test.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/HardwareAccountDetails/HardwareAccountDetails.test.tsx
index 121f22b4f314..6b6835b028bb 100644
--- a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/HardwareAccountDetails/HardwareAccountDetails.test.tsx
+++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/HardwareAccountDetails/HardwareAccountDetails.test.tsx
@@ -5,8 +5,9 @@ import { createMockInternalAccount } from '../../../../../../util/test/accountsC
import { EthAccountType } from '@metamask/keyring-api';
import { KeyringTypes } from '@metamask/keyring-controller';
import { AccountDetailsIds } from '../../../AccountDetails.testIds';
-import { HEADERBASE_TITLE_TEST_ID } from '../../../../../../component-library/components/HeaderBase/HeaderBase.constants';
-import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar';
+import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.types';
+
+const HEADER_BASE_TITLE_TEST_ID = 'header-title';
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
@@ -115,7 +116,7 @@ describe('HardwareAccountDetails', () => {
expect(
getByTestId(AccountDetailsIds.ACCOUNT_DETAILS_CONTAINER),
).toBeTruthy();
- expect(getByTestId(HEADERBASE_TITLE_TEST_ID).children).toStrictEqual([
+ expect(getByTestId(HEADER_BASE_TITLE_TEST_ID).children).toStrictEqual([
name,
]);
expect(getByTestId(AccountDetailsIds.REMOVE_ACCOUNT_BUTTON)).toBeTruthy();
diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/HdAccountDetails/HdAccountDetails.test.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/HdAccountDetails/HdAccountDetails.test.tsx
index a78846718941..b1f17e351b93 100644
--- a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/HdAccountDetails/HdAccountDetails.test.tsx
+++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/HdAccountDetails/HdAccountDetails.test.tsx
@@ -7,7 +7,7 @@ import { KeyringTypes } from '@metamask/keyring-controller';
import { AccountDetailsIds } from '../../../AccountDetails.testIds';
import { backgroundState } from '../../../../../../util/test/initial-root-state';
import { ExportCredentialsIds } from '../../ExportCredentials.testIds';
-import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar';
+import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.types';
const mockIsEvmAccountType = jest.fn();
const mockNavigate = jest.fn();
diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/PrivateKeyAccountDetails/PrivateKeyAccountDetails.test.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/PrivateKeyAccountDetails/PrivateKeyAccountDetails.test.tsx
index d187c59e06ff..e10d9165bb50 100644
--- a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/PrivateKeyAccountDetails/PrivateKeyAccountDetails.test.tsx
+++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/PrivateKeyAccountDetails/PrivateKeyAccountDetails.test.tsx
@@ -7,7 +7,7 @@ import { KeyringTypes } from '@metamask/keyring-controller';
import { AccountDetailsIds } from '../../../AccountDetails.testIds';
import { backgroundState } from '../../../../../../util/test/initial-root-state';
import { ExportCredentialsIds } from '../../ExportCredentials.testIds';
-import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar';
+import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.types';
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/SnapAccountDetails/SnapAccountDetails.test.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/SnapAccountDetails/SnapAccountDetails.test.tsx
index 057449e2aa6a..73fcf40c3e91 100644
--- a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/SnapAccountDetails/SnapAccountDetails.test.tsx
+++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/SnapAccountDetails/SnapAccountDetails.test.tsx
@@ -8,7 +8,7 @@ import { AccountDetailsIds } from '../../../AccountDetails.testIds';
import { backgroundState } from '../../../../../../util/test/initial-root-state';
import { ExportCredentialsIds } from '../../ExportCredentials.testIds';
import { InternalAccount } from '@metamask/keyring-internal-api';
-import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar';
+import { AvatarAccountType } from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.types';
const mockIsHDOrFirstPartySnapAccount = jest.fn();
const mockNavigate = jest.fn();
diff --git a/app/components/Views/MultichainAccounts/AccountDetails/components/SmartAccountModal/SmartAccountModal.tsx b/app/components/Views/MultichainAccounts/AccountDetails/components/SmartAccountModal/SmartAccountModal.tsx
index f08f190c6fdd..5668a6db8cb7 100644
--- a/app/components/Views/MultichainAccounts/AccountDetails/components/SmartAccountModal/SmartAccountModal.tsx
+++ b/app/components/Views/MultichainAccounts/AccountDetails/components/SmartAccountModal/SmartAccountModal.tsx
@@ -1,10 +1,14 @@
import React, { useLayoutEffect, useState } from 'react';
import { View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import Text, {
+import {
+ FontWeight,
+ HeaderBase,
+ IconName,
+ Text,
TextColor,
TextVariant,
-} from '../../../../../../component-library/components/Texts/Text';
+} from '@metamask/design-system-react-native';
import { Box } from '../../../../../UI/Box/Box';
import { strings } from '../../../../../../../locales/i18n';
import { InternalAccount } from '@metamask/keyring-internal-api';
@@ -23,14 +27,10 @@ import {
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
import { SwitchAccountModalSelectorIDs } from '../../../../../../components/Views/confirmations/components/modals/switch-account-type-modal/SwitchAccountModal.testIds';
import AppConstants from '../../../../../../core/AppConstants';
-import HeaderBase from '../../../../../../component-library/components/HeaderBase';
-import ButtonLink from '../../../../../../component-library/components/Buttons/Button/variants/ButtonLink';
-import Icon, {
- IconName,
- IconSize,
-} from '../../../../../../component-library/components/Icons/Icon';
import { SMART_ACCOUNT_MODAL_TEST_IDS } from './SmartAccountModal.testIds';
+const HEADER_BASE_TITLE_TEST_ID = 'header-title';
+
interface RootNavigationParamList extends ParamListBase {
SmartAccount: {
account: InternalAccount;
@@ -75,14 +75,12 @@ const SmartAccountModal = () => {
>
}
- onPress={() => navigation.goBack()}
- testID={SwitchAccountModalSelectorIDs.SMART_ACCOUNT_BACK_BUTTON}
- />
- }
+ titleTestID={HEADER_BASE_TITLE_TEST_ID}
+ startButtonIconProps={{
+ testID: SwitchAccountModalSelectorIDs.SMART_ACCOUNT_BACK_BUTTON,
+ iconName: IconName.ArrowLeft,
+ onPress: () => navigation.goBack(),
+ }}
>
{strings('multichain_accounts.account_details.smart_account')}
@@ -92,7 +90,7 @@ const SmartAccountModal = () => {
testID={SMART_ACCOUNT_MODAL_TEST_IDS.CONTAINER}
>
-
+
{strings('multichain_accounts.smart_account.title')}
{
flexDirection={FlexDirection.Row}
alignItems={AlignItems.flexStart}
>
-
+
{strings('multichain_accounts.smart_account.description')}{' '}
-
+
{strings('multichain_accounts.smart_account.learn_more')}
diff --git a/app/components/Views/TokensFullView/TokensFullView.tsx b/app/components/Views/TokensFullView/TokensFullView.tsx
index 3f7fab41bb80..bf53d46658f7 100644
--- a/app/components/Views/TokensFullView/TokensFullView.tsx
+++ b/app/components/Views/TokensFullView/TokensFullView.tsx
@@ -1,5 +1,5 @@
-import React, { useCallback } from 'react';
-import { useFocusEffect, useNavigation } from '@react-navigation/native';
+import React, { useCallback, useEffect } from 'react';
+import { useNavigation } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
@@ -22,17 +22,15 @@ const TokensFullView = () => {
selectHomepageSectionsV1Enabled,
);
- useFocusEffect(
- useCallback(
- () => () => {
- if (isHomepageSectionsV1Enabled) {
- Engine.context.PreferencesController.setTokenSortConfig(
- DEFAULT_TOKEN_SORT_CONFIG,
- );
- }
- },
- [isHomepageSectionsV1Enabled],
- ),
+ useEffect(
+ () => () => {
+ if (isHomepageSectionsV1Enabled) {
+ Engine.context.PreferencesController.setTokenSortConfig(
+ DEFAULT_TOKEN_SORT_CONFIG,
+ );
+ }
+ },
+ [isHomepageSectionsV1Enabled],
);
const handleBackPress = useCallback(() => {
diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.styles.ts b/app/components/Views/confirmations/components/confirm/confirm-component.styles.ts
index bd7e51a53728..879203aed1cd 100644
--- a/app/components/Views/confirmations/components/confirm/confirm-component.styles.ts
+++ b/app/components/Views/confirmations/components/confirm/confirm-component.styles.ts
@@ -28,7 +28,7 @@ const styleSheet = (params: {
paddingHorizontal: vars.disableSafeArea === true ? 0 : 16,
},
scrollViewContent: {
- flex: vars.isFullScreenConfirmation ? 1 : undefined,
+ flexGrow: vars.isFullScreenConfirmation ? 1 : undefined,
},
spinnerContainer: {
backgroundColor: theme.colors.background.default,
diff --git a/app/components/Views/confirmations/components/info/transfer/transfer.test.tsx b/app/components/Views/confirmations/components/info/transfer/transfer.test.tsx
index 813e55d3274f..03372c2f5660 100644
--- a/app/components/Views/confirmations/components/info/transfer/transfer.test.tsx
+++ b/app/components/Views/confirmations/components/info/transfer/transfer.test.tsx
@@ -42,6 +42,9 @@ jest.mock('../../../../../../core/Engine', () => {
NetworkController: {
getNetworkConfigurationByNetworkClientId: jest.fn(),
},
+ PhishingController: {
+ checkAddressPoisoning: jest.fn().mockReturnValue([]),
+ },
GasFeeController: {
startPolling: jest.fn(),
stopPollingByPollingToken: jest.fn(),
diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts
index 24ed49088678..6b3cb5d70288 100644
--- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts
+++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts
@@ -28,6 +28,12 @@ const styleSheet = (params: { theme: Theme }) => {
gap: 4,
marginLeft: -8,
},
+ poisonedBadge: {
+ backgroundColor: theme.colors.error.muted,
+ borderRadius: 8,
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ },
addressRow: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.test.tsx b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.test.tsx
index a7ad78cf61f8..450f9b0118a0 100644
--- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.test.tsx
+++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.test.tsx
@@ -28,6 +28,9 @@ jest.mock('../../../../../../../core/Engine', () => ({
NetworkController: {
getNetworkConfigurationByNetworkClientId: jest.fn(),
},
+ PhishingController: {
+ checkAddressPoisoning: jest.fn().mockReturnValue([]),
+ },
},
}));
diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx
index 4e49b47831eb..226cfa215f97 100644
--- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx
+++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx
@@ -10,6 +10,7 @@ import Text, {
} from '../../../../../../../component-library/components/Texts/Text';
import { NameType } from '../../../../../../UI/Name/Name.types';
import { useTransferRecipient } from '../../../../hooks/transactions/useTransferRecipient';
+import { useAddressPoisoningDetection } from '../../../../hooks/send/useAddressPoisoningDetection';
import { RowAlertKey } from '../../../UI/info-row/alert-row/constants';
import InfoSection from '../../../UI/info-row/info-section';
import AlertRow from '../../../UI/info-row/alert-row';
@@ -28,6 +29,7 @@ interface AddressDisplayProps {
displayText: string;
image?: string;
label: React.ReactNode;
+ isPoisoned?: boolean;
}
const AddressDisplay = ({
@@ -35,6 +37,7 @@ const AddressDisplay = ({
displayText,
image,
label,
+ isPoisoned,
}: AddressDisplayProps) => {
const { styles } = useStyles(styleSheet, {});
@@ -44,6 +47,7 @@ const AddressDisplay = ({
{label}
@@ -63,6 +67,8 @@ const FromToRow = () => {
const { styles } = useStyles(styleSheet, {});
const transactionMetadata = useTransactionMetadataRequest();
const transferRecipient = useTransferRecipient();
+ const { isPoisoningSuspect } =
+ useAddressPoisoningDetection(transferRecipient);
const fromAddress = (transactionMetadata?.txParams?.from as string) ?? '';
const toAddress = transferRecipient ?? '';
@@ -137,12 +143,23 @@ const FromToRow = () => {
address={toAddress as string}
displayText={toDisplayText}
image={toImage}
+ isPoisoned={isPoisoningSuspect}
label={
+ {isPoisoningSuspect && (
+
+
+ {strings('alert_system.address_poisoning.badge')}
+
+
+ )}
}
/>
diff --git a/app/components/Views/confirmations/components/send/address-poisoning-alert-content/address-poisoning-alert-content.tsx b/app/components/Views/confirmations/components/send/address-poisoning-alert-content/address-poisoning-alert-content.tsx
new file mode 100644
index 000000000000..38fc35359c8e
--- /dev/null
+++ b/app/components/Views/confirmations/components/send/address-poisoning-alert-content/address-poisoning-alert-content.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { TextColor, Box } from '@metamask/design-system-react-native';
+import Accordion from '../../../../../../component-library/components/Accordions/Accordion/Accordion';
+import { AccordionHeaderHorizontalAlignment } from '../../../../../../component-library/components/Accordions/Accordion';
+import { DiffHighlightedAddress } from '../diff-highlighted-address/diff-highlighted-address';
+import { strings } from '../../../../../../../locales/i18n';
+
+interface AddressPoisoningAlertContentProps {
+ address: string;
+ knownAddress: string;
+ diffIndices: number[];
+}
+
+export const AddressPoisoningAlertContent = ({
+ address,
+ knownAddress,
+ diffIndices,
+}: AddressPoisoningAlertContentProps) => (
+
+
+
+
+
+
+);
diff --git a/app/components/Views/confirmations/components/send/diff-highlighted-address/diff-highlighted-address.test.tsx b/app/components/Views/confirmations/components/send/diff-highlighted-address/diff-highlighted-address.test.tsx
new file mode 100644
index 000000000000..4fd195cfb8c3
--- /dev/null
+++ b/app/components/Views/confirmations/components/send/diff-highlighted-address/diff-highlighted-address.test.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import { FontWeight, TextColor } from '@metamask/design-system-react-native';
+import { DiffHighlightedAddress } from './diff-highlighted-address';
+
+jest.mock('@metamask/design-system-react-native', () => {
+ const { Text: RNText, View: RNView } = jest.requireActual('react-native');
+
+ return {
+ Box: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) => (
+ {children}
+ ),
+ Text: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) => (
+ {children}
+ ),
+ FontWeight: {
+ Bold: 'bold',
+ Medium: 'medium',
+ },
+ TextColor: {
+ ErrorDefault: 'error-default',
+ SuccessDefault: 'success-default',
+ TextAlternative: 'text-alternative',
+ },
+ TextVariant: {
+ BodySm: 'body-sm',
+ },
+ };
+});
+
+describe('DiffHighlightedAddress', () => {
+ it('renders consecutive address differences with default warning styles', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ const firstDiffSegment = getByText('23');
+ const secondDiffSegment = getByText('6');
+
+ expect(getByText('Entered address')).toBeOnTheScreen();
+ expect(getByText('0x1')).toBeOnTheScreen();
+ expect(getByText('45')).toBeOnTheScreen();
+ expect(firstDiffSegment.props.color).toBe(TextColor.ErrorDefault);
+ expect(firstDiffSegment.props.fontWeight).toBe(FontWeight.Bold);
+ expect(firstDiffSegment.props.twClassName).toBe('bg-error-muted');
+ expect(secondDiffSegment.props.color).toBe(TextColor.ErrorDefault);
+ });
+
+ it('renders custom styles for address differences', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ const diffSegment = getByText('ab');
+
+ expect(getByText('Known address')).toBeOnTheScreen();
+ expect(diffSegment.props.color).toBe(TextColor.SuccessDefault);
+ expect(diffSegment.props.fontWeight).toBe(FontWeight.Bold);
+ expect(diffSegment.props.twClassName).toBe('bg-success-muted');
+ });
+});
diff --git a/app/components/Views/confirmations/components/send/diff-highlighted-address/diff-highlighted-address.tsx b/app/components/Views/confirmations/components/send/diff-highlighted-address/diff-highlighted-address.tsx
new file mode 100644
index 000000000000..ee007702b81f
--- /dev/null
+++ b/app/components/Views/confirmations/components/send/diff-highlighted-address/diff-highlighted-address.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import {
+ Box,
+ FontWeight,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+
+interface DiffHighlightedAddressProps {
+ address: string;
+ diffIndices: number[];
+ label: string;
+ dotTwColor: string;
+ highlightTwColor?: string;
+ diffTextColor?: TextColor;
+}
+
+/**
+ * Renders an address inside a rounded card with differing characters
+ * highlighted. Used in the address poisoning warning to let users
+ * visually compare the entered and known addresses.
+ */
+export const DiffHighlightedAddress = ({
+ address,
+ diffIndices,
+ label,
+ dotTwColor,
+ highlightTwColor = 'bg-error-muted',
+ diffTextColor = TextColor.ErrorDefault,
+}: DiffHighlightedAddressProps) => {
+ const diffSet = new Set(diffIndices);
+
+ // Build segments of consecutive same/diff characters
+ const segments: { text: string; isDiff: boolean }[] = [];
+ let currentSegment = { text: '', isDiff: false };
+
+ for (let i = 0; i < address.length; i++) {
+ const isDiff = diffSet.has(i);
+ if (i === 0) {
+ currentSegment = { text: address[i], isDiff };
+ } else if (isDiff === currentSegment.isDiff) {
+ currentSegment.text += address[i];
+ } else {
+ segments.push(currentSegment);
+ currentSegment = { text: address[i], isDiff };
+ }
+ }
+ if (currentSegment.text.length > 0) {
+ segments.push(currentSegment);
+ }
+
+ return (
+
+
+
+
+ {label}
+
+
+
+ {segments.map((segment, index) => (
+
+ {segment.text}
+
+ ))}
+
+
+ );
+};
diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx
index 752200036133..74ea3e9163b6 100644
--- a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx
+++ b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx
@@ -11,6 +11,7 @@ import { useToAddressValidation } from '../../../hooks/send/useToAddressValidati
import { useRecipientSelectionMetrics } from '../../../hooks/send/metrics/useRecipientSelectionMetrics';
import { useSendActions } from '../../../hooks/send/useSendActions';
import { useSendAlerts } from '../../../hooks/send/alerts/useSendAlerts';
+import { useAddressPoisoningDetection } from '../../../hooks/send/useAddressPoisoningDetection';
import { useSendType } from '../../../hooks/send/useSendType';
import { RecipientType } from '../../UI/recipient';
import { Recipient } from './recipient';
@@ -70,6 +71,10 @@ jest.mock('../../../hooks/send/alerts/useSendAlerts', () => ({
useSendAlerts: jest.fn(),
}));
+jest.mock('../../../hooks/send/useAddressPoisoningDetection', () => ({
+ useAddressPoisoningDetection: jest.fn(),
+}));
+
jest.mock('../../../hooks/send/metrics/useRecipientSelectionMetrics', () => ({
useRecipientSelectionMetrics: jest.fn(),
}));
@@ -199,6 +204,9 @@ const mockUseSendContext = jest.mocked(useSendContext);
const mockUseAccounts = jest.mocked(useAccounts);
const mockUseContacts = jest.mocked(useContacts);
const mockUseToAddressValidation = jest.mocked(useToAddressValidation);
+const mockUseAddressPoisoningDetection = jest.mocked(
+ useAddressPoisoningDetection,
+);
const mockUseRecipientSelectionMetrics = jest.mocked(
useRecipientSelectionMetrics,
);
@@ -255,6 +263,12 @@ describe('Recipient', () => {
isAlertCheckPending: false,
});
+ mockUseAddressPoisoningDetection.mockReturnValue({
+ isPoisoningSuspect: false,
+ bestMatch: null,
+ matches: [],
+ });
+
mockUseRecipientSelectionMetrics.mockReturnValue({
captureRecipientSelected: mockCaptureRecipientSelected,
});
@@ -376,6 +390,9 @@ describe('Recipient', () => {
fireEvent.press(getByTestId(RedesignedSendViewSelectorsIDs.REVIEW_BUTTON));
+ expect(mockUseAddressPoisoningDetection).toHaveBeenCalledWith(
+ 'some_dummy_address',
+ );
expect(mockHandleSubmitPress).toHaveBeenCalledWith('some_dummy_address');
});
@@ -569,6 +586,12 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => {
mockUseRecipientSelectionMetrics.mockReturnValue({
captureRecipientSelected: jest.fn(),
});
+
+ mockUseAddressPoisoningDetection.mockReturnValue({
+ isPoisoningSuspect: false,
+ bestMatch: null,
+ matches: [],
+ });
});
it('does not auto-submit when pastedRecipient does not match toAddressValidated', () => {
@@ -631,6 +654,52 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => {
expect(mockHandleSubmitPressLocal).not.toHaveBeenCalled();
});
+ it('does not auto-submit when the resolved recipient is a poisoning match', () => {
+ const knownAddress = '0x1234567890123456789012345678901234567890';
+ const poisoningMatch = {
+ knownAddress,
+ prefixMatchLength: 4,
+ suffixMatchLength: 4,
+ poisoningScore: 8,
+ diffIndices: [6, 7],
+ };
+
+ mockUseToAddressValidation.mockReturnValue({
+ loading: false,
+ resolvedAddress: '0x1234ffffffffffffffffffffffffffffffff7890',
+ toAddressError: undefined,
+ toAddressValidated: '0xvalid',
+ toAddressWarning: undefined,
+ });
+ mockUseSendContext.mockReturnValue({
+ to: '0xvalid',
+ updateTo: jest.fn(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ asset: {} as any,
+ chainId: '0x1',
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fromAccount: {} as any,
+ from: '',
+ maxValueMode: false,
+ updateAsset: jest.fn(),
+ updateValue: jest.fn(),
+ value: undefined,
+ });
+ mockUseAddressPoisoningDetection.mockReturnValue({
+ isPoisoningSuspect: true,
+ bestMatch: poisoningMatch,
+ matches: [poisoningMatch],
+ });
+
+ const { getByTestId } = renderWithProvider();
+ fireEvent.press(getByTestId('set-pasted'));
+
+ expect(mockUseAddressPoisoningDetection).toHaveBeenCalledWith(
+ '0x1234ffffffffffffffffffffffffffffffff7890',
+ );
+ expect(mockHandleSubmitPressLocal).not.toHaveBeenCalled();
+ });
+
it('handles reviews exits early if asset is missing', () => {
// Given: valid to address, no errors/warnings/loading, but missing asset
mockUseToAddressValidation.mockReturnValue({
@@ -780,6 +849,12 @@ describe('SendAlertModal integration', () => {
...validationOverrides,
});
+ mockUseAddressPoisoningDetection.mockReturnValue({
+ isPoisoningSuspect: false,
+ bestMatch: null,
+ matches: [],
+ });
+
mockUseSendAlerts.mockReturnValue({
alerts: [
{
@@ -923,6 +998,11 @@ describe('SendAlertModal integration', () => {
acknowledgeAlerts: jest.fn(),
isAlertCheckPending: false,
});
+ mockUseAddressPoisoningDetection.mockReturnValue({
+ isPoisoningSuspect: false,
+ bestMatch: null,
+ matches: [],
+ });
mockUseAccounts.mockReturnValue(mockAccounts);
mockUseContacts.mockReturnValue(mockContacts);
diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.tsx
index 178ff99b9aca..f001f979c55a 100644
--- a/app/components/Views/confirmations/components/send/recipient/recipient.tsx
+++ b/app/components/Views/confirmations/components/send/recipient/recipient.tsx
@@ -22,10 +22,12 @@ import { useContacts } from '../../../hooks/send/useContacts';
import { useRecipientPageReset } from '../../../hooks/send/useRecipientPageReset';
import { useRouteParams } from '../../../hooks/send/useRouteParams';
import { useSendActions } from '../../../hooks/send/useSendActions';
+import { useAddressPoisoningDetection } from '../../../hooks/send/useAddressPoisoningDetection';
import { useToAddressValidation } from '../../../hooks/send/useToAddressValidation';
import { RecipientInput } from '../../recipient-input';
import { RecipientList } from '../../recipient-list/recipient-list';
import { RecipientType } from '../../UI/recipient';
+import { AddressPoisoningAlertContent } from '../address-poisoning-alert-content/address-poisoning-alert-content';
import { SendAlertModal } from '../send-alert-modal';
import { styleSheet } from './recipient.styles';
@@ -48,6 +50,12 @@ export const Recipient = () => {
resolvedAddress,
} = useToAddressValidation();
+ const recipientCandidateAddress =
+ !toAddressError && !loading ? resolvedAddress || to : undefined;
+ const { bestMatch: poisoningMatch } = useAddressPoisoningDetection(
+ recipientCandidateAddress,
+ );
+
const {
alerts,
hasUnacknowledgedAlerts,
@@ -133,6 +141,7 @@ export const Recipient = () => {
pastedRecipient === toAddressValidated &&
!toAddressError &&
!toAddressWarning &&
+ !poisoningMatch &&
!loading &&
!isAlertCheckPending &&
!hasUnacknowledgedAlerts
@@ -145,6 +154,7 @@ export const Recipient = () => {
toAddressError,
toAddressValidated,
toAddressWarning,
+ poisoningMatch,
loading,
isAlertCheckPending,
hasUnacknowledgedAlerts,
@@ -228,6 +238,24 @@ export const Recipient = () => {
{(to || '').length > 0 && !isRecipientSelectedFromList && (
+ {poisoningMatch && recipientCandidateAddress && (
+
+
+
+ )}
{toAddressWarning && (
({
+ strings: (key: string) => key,
+}));
+
+jest.mock('../transactions/useTransferRecipient', () => ({
+ useTransferRecipient: jest.fn(),
+}));
+
+jest.mock('../send/useAddressPoisoningDetection', () => ({
+ useAddressPoisoningDetection: jest.fn(),
+}));
+
+const useTransferRecipientMock = jest.mocked(useTransferRecipient);
+const useAddressPoisoningDetectionMock = jest.mocked(
+ useAddressPoisoningDetection,
+);
+
+describe('useAddressPoisoningAlert', () => {
+ const toAddress = '0x1234567890123456789012345678901234567890';
+ const knownAddress = '0x123456789012345678901234567890abcdef1234';
+ const diffIndices = [32, 33, 34];
+ const bestMatch = {
+ knownAddress,
+ prefixMatchLength: 32,
+ suffixMatchLength: 4,
+ poisoningScore: 8,
+ diffIndices,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ useTransferRecipientMock.mockReturnValue(toAddress);
+ useAddressPoisoningDetectionMock.mockReturnValue({
+ isPoisoningSuspect: false,
+ bestMatch: null,
+ matches: [],
+ });
+ });
+
+ it.each([
+ {
+ name: 'recipient address is missing',
+ recipient: undefined,
+ detection: {
+ isPoisoningSuspect: true,
+ bestMatch,
+ matches: [bestMatch],
+ },
+ },
+ {
+ name: 'address is not a poisoning suspect',
+ recipient: toAddress,
+ detection: {
+ isPoisoningSuspect: false,
+ bestMatch,
+ matches: [bestMatch],
+ },
+ },
+ {
+ name: 'best match is missing',
+ recipient: toAddress,
+ detection: {
+ isPoisoningSuspect: true,
+ bestMatch: null,
+ matches: [],
+ },
+ },
+ ])('returns empty array when $name', ({ recipient, detection }) => {
+ useTransferRecipientMock.mockReturnValue(recipient);
+ useAddressPoisoningDetectionMock.mockReturnValue(detection);
+
+ const { result } = renderHook(() => useAddressPoisoningAlert());
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('returns address poisoning alert for suspect recipient', () => {
+ useAddressPoisoningDetectionMock.mockReturnValue({
+ isPoisoningSuspect: true,
+ bestMatch,
+ matches: [bestMatch],
+ });
+
+ const { result } = renderHook(() => useAddressPoisoningAlert());
+
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0]).toMatchObject({
+ key: AlertKeys.AddressPoisoning,
+ severity: Severity.Danger,
+ title: 'alert_system.address_poisoning.title',
+ isBlocking: false,
+ });
+
+ const content = result.current[0].content as React.ReactElement<{
+ children: React.ReactNode;
+ }>;
+ const contentChildren = React.Children.toArray(content.props.children);
+ const message = contentChildren[0] as React.ReactElement<{
+ children: string;
+ }>;
+ const details = contentChildren[1] as React.ReactElement<{
+ address: string;
+ knownAddress: string;
+ diffIndices: number[];
+ }>;
+
+ expect(message.props.children).toBe(
+ 'alert_system.address_poisoning.message',
+ );
+ expect(details.type).toBe(AddressPoisoningAlertContent);
+ expect(details.props).toMatchObject({
+ address: toAddress,
+ knownAddress,
+ diffIndices,
+ });
+ });
+});
diff --git a/app/components/Views/confirmations/hooks/alerts/useAddressPoisoningAlert.tsx b/app/components/Views/confirmations/hooks/alerts/useAddressPoisoningAlert.tsx
new file mode 100644
index 000000000000..31c709079e6b
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/alerts/useAddressPoisoningAlert.tsx
@@ -0,0 +1,44 @@
+import React, { useMemo } from 'react';
+import { Alert, Severity } from '../../types/alerts';
+import { AlertKeys } from '../../constants/alerts';
+import { useTransferRecipient } from '../transactions/useTransferRecipient';
+import { useAddressPoisoningDetection } from '../send/useAddressPoisoningDetection';
+import { strings } from '../../../../../../locales/i18n';
+import { Box } from '@metamask/design-system-react-native';
+import Text, {
+ TextVariant,
+} from '../../../../../component-library/components/Texts/Text';
+import { AddressPoisoningAlertContent } from '../../components/send/address-poisoning-alert-content/address-poisoning-alert-content';
+
+export function useAddressPoisoningAlert(): Alert[] {
+ const toAddress = useTransferRecipient();
+ const { isPoisoningSuspect, bestMatch } =
+ useAddressPoisoningDetection(toAddress);
+
+ return useMemo(() => {
+ if (!isPoisoningSuspect || !bestMatch || !toAddress) {
+ return [];
+ }
+
+ return [
+ {
+ key: AlertKeys.AddressPoisoning,
+ severity: Severity.Danger,
+ title: strings('alert_system.address_poisoning.title'),
+ content: (
+
+
+ {strings('alert_system.address_poisoning.message')}
+
+
+
+ ),
+ isBlocking: false,
+ },
+ ];
+ }, [isPoisoningSuspect, bestMatch, toAddress]);
+}
diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts
index fda2fb966b1a..b0ffd2a31f30 100644
--- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts
+++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts
@@ -27,6 +27,7 @@ import { useGasSponsorshipWarningAlert } from './useGasSponsorshipWarningAlert';
import { useFirstTimeInteractionAlert } from './useFirstTimeInteractionAlert';
import { useHeadlessBuyErrorAlert } from './useHeadlessBuyErrorAlert';
import { useTokenContractAlert } from './useTokenContractAlert';
+import { useAddressPoisoningAlert } from './useAddressPoisoningAlert';
jest.mock('./useBlockaidAlerts');
jest.mock('./useGasEstimateFailedAlert');
@@ -49,6 +50,7 @@ jest.mock('./useOriginTrustSignalAlerts');
jest.mock('./useFirstTimeInteractionAlert');
jest.mock('./useHeadlessBuyErrorAlert');
jest.mock('./useTokenContractAlert');
+jest.mock('./useAddressPoisoningAlert');
describe('useConfirmationAlerts', () => {
const ALERT_MESSAGE_MOCK = 'This is a test alert message.';
@@ -162,6 +164,15 @@ describe('useConfirmationAlerts', () => {
},
];
+ const mockAddressPoisoningAlert: Alert[] = [
+ {
+ key: 'AddressPoisoningAlert',
+ title: 'Test Address Poisoning Alert',
+ message: ALERT_MESSAGE_MOCK,
+ severity: Severity.Danger,
+ },
+ ];
+
const mockOriginTrustSignalAlerts: Alert[] = [
{
key: 'OriginTrustSignalAlert',
@@ -209,6 +220,7 @@ describe('useConfirmationAlerts', () => {
(useFirstTimeInteractionAlert as jest.Mock).mockReturnValue([]);
(useHeadlessBuyErrorAlert as jest.Mock).mockReturnValue([]);
(useTokenContractAlert as jest.Mock).mockReturnValue([]);
+ (useAddressPoisoningAlert as jest.Mock).mockReturnValue([]);
});
it('returns empty array if no alerts', () => {
@@ -277,6 +289,9 @@ describe('useConfirmationAlerts', () => {
(useTokenTrustSignalAlerts as jest.Mock).mockReturnValue(
mockTokenTrustSignalAlerts,
);
+ (useAddressPoisoningAlert as jest.Mock).mockReturnValue(
+ mockAddressPoisoningAlert,
+ );
(useAddressTrustSignalAlerts as jest.Mock).mockReturnValue(
mockAddressTrustSignalAlerts,
);
@@ -301,6 +316,7 @@ describe('useConfirmationAlerts', () => {
...mockInsufficientPredictBalanceAlert,
...mockBurnAddressAlert,
...mockTokenTrustSignalAlerts,
+ ...mockAddressPoisoningAlert,
...mockTokenContractAlert,
...mockUpgradeAccountAlert,
...mockOriginTrustSignalAlerts,
diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts
index 8eec40d01e01..efabea4a6b3e 100644
--- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts
+++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts
@@ -20,6 +20,7 @@ import { useAddressTrustSignalAlerts } from './useAddressTrustSignalAlerts';
import { useOriginTrustSignalAlerts } from './useOriginTrustSignalAlerts';
import { useHeadlessBuyErrorAlert } from './useHeadlessBuyErrorAlert';
import { useFirstTimeInteractionAlert } from './useFirstTimeInteractionAlert';
+import { useAddressPoisoningAlert } from './useAddressPoisoningAlert';
import { useTokenContractAlert } from './useTokenContractAlert';
function useSignatureAlerts(): Alert[] {
@@ -46,6 +47,7 @@ function useTransactionAlerts(): Alert[] {
const headlessBuyErrorAlert = useHeadlessBuyErrorAlert();
const tokenTrustSignalAlerts = useTokenTrustSignalAlerts();
const firstTimeInteractionAlert = useFirstTimeInteractionAlert();
+ const addressPoisoningAlert = useAddressPoisoningAlert();
const tokenContractAlert = useTokenContractAlert();
return useMemo(
@@ -65,6 +67,7 @@ function useTransactionAlerts(): Alert[] {
...headlessBuyErrorAlert,
...tokenTrustSignalAlerts,
...firstTimeInteractionAlert,
+ ...addressPoisoningAlert,
...tokenContractAlert,
],
[
@@ -83,6 +86,7 @@ function useTransactionAlerts(): Alert[] {
headlessBuyErrorAlert,
tokenTrustSignalAlerts,
firstTimeInteractionAlert,
+ addressPoisoningAlert,
tokenContractAlert,
],
);
diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts
index 03cdcd00c252..4fd5fd13f16a 100644
--- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts
+++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts
@@ -106,6 +106,7 @@ function getAlertNames(alerts: Alert[]): string[] {
}
const ALERTS_NAME_METRICS: AlertNameMetrics = {
+ [AlertKeys.AddressPoisoning]: 'address_poisoning',
[AlertKeys.AddressTrustSignalMalicious]: 'address_trust_signal_malicious',
[AlertKeys.AddressTrustSignalWarning]: 'address_trust_signal_warning',
[AlertKeys.BatchedUnusedApprovals]: 'batched_unused_approvals',
diff --git a/app/components/Views/confirmations/hooks/send/useAddressPoisoningDetection.test.ts b/app/components/Views/confirmations/hooks/send/useAddressPoisoningDetection.test.ts
new file mode 100644
index 000000000000..415fc33c9699
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/useAddressPoisoningDetection.test.ts
@@ -0,0 +1,71 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { useAddressPoisoningDetection } from './useAddressPoisoningDetection';
+import Engine from '../../../../../core/Engine';
+
+jest.mock('../../../../../core/Engine', () => ({
+ context: {
+ PhishingController: {
+ checkAddressPoisoning: jest.fn(),
+ },
+ },
+}));
+
+const mockCheckAddressPoisoning = Engine.context.PhishingController
+ .checkAddressPoisoning as jest.MockedFunction<
+ typeof Engine.context.PhishingController.checkAddressPoisoning
+>;
+
+describe('useAddressPoisoningDetection', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns no suspect when toAddress is undefined', () => {
+ const { result } = renderHook(() =>
+ useAddressPoisoningDetection(undefined),
+ );
+
+ expect(result.current.isPoisoningSuspect).toBe(false);
+ expect(result.current.bestMatch).toBeNull();
+ expect(result.current.matches).toHaveLength(0);
+ expect(mockCheckAddressPoisoning).not.toHaveBeenCalled();
+ });
+
+ it('detects poisoning suspect when controller returns matches', () => {
+ const mockMatch = {
+ knownAddress: '0xknown1',
+ prefixMatchLength: 6,
+ suffixMatchLength: 6,
+ poisoningScore: 8,
+ diffIndices: [10, 11, 12],
+ };
+ mockCheckAddressPoisoning.mockReturnValue([mockMatch]);
+
+ const { result } = renderHook(() =>
+ useAddressPoisoningDetection('0xcandidate'),
+ );
+
+ expect(result.current.isPoisoningSuspect).toBe(true);
+ expect(result.current.bestMatch).toEqual(mockMatch);
+ expect(result.current.matches).toHaveLength(1);
+ });
+
+ it('returns no suspect when no similar addresses found', () => {
+ mockCheckAddressPoisoning.mockReturnValue([]);
+
+ const { result } = renderHook(() =>
+ useAddressPoisoningDetection('0xcompletely_different'),
+ );
+
+ expect(result.current.isPoisoningSuspect).toBe(false);
+ expect(result.current.bestMatch).toBeNull();
+ });
+
+ it('calls checkAddressPoisoning with the candidate address', () => {
+ mockCheckAddressPoisoning.mockReturnValue([]);
+
+ renderHook(() => useAddressPoisoningDetection('0xcandidate'));
+
+ expect(mockCheckAddressPoisoning).toHaveBeenCalledWith('0xcandidate');
+ });
+});
diff --git a/app/components/Views/confirmations/hooks/send/useAddressPoisoningDetection.ts b/app/components/Views/confirmations/hooks/send/useAddressPoisoningDetection.ts
new file mode 100644
index 000000000000..b8c93f58190b
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/useAddressPoisoningDetection.ts
@@ -0,0 +1,28 @@
+import { useMemo } from 'react';
+import type { SimilarAddressMatch } from '@metamask/phishing-controller';
+import Engine from '../../../../../core/Engine';
+
+interface AddressPoisoningDetectionResult {
+ isPoisoningSuspect: boolean;
+ bestMatch: SimilarAddressMatch | null;
+ matches: SimilarAddressMatch[];
+}
+
+export function useAddressPoisoningDetection(
+ toAddress: string | undefined,
+): AddressPoisoningDetectionResult {
+ return useMemo(() => {
+ if (!toAddress) {
+ return { isPoisoningSuspect: false, bestMatch: null, matches: [] };
+ }
+
+ const matches =
+ Engine.context.PhishingController.checkAddressPoisoning(toAddress);
+
+ return {
+ isPoisoningSuspect: matches.length > 0,
+ bestMatch: matches[0] ?? null,
+ matches,
+ };
+ }, [toAddress]);
+}
diff --git a/app/core/Engine/Engine.test.ts b/app/core/Engine/Engine.test.ts
index 251471542af8..1f2cfa16e3cd 100644
--- a/app/core/Engine/Engine.test.ts
+++ b/app/core/Engine/Engine.test.ts
@@ -164,6 +164,31 @@ describe('Engine', () => {
expect(engine.context).toHaveProperty('MoneyAccountController');
});
+ it('hydrates address poisoning known recipients from persisted address book state', () => {
+ const knownAddress = '0x111122223333444455556666777788889999aaaa';
+ const candidateAddress = '0x1111ffffffffffffffffffffffffffffffffaaaa';
+
+ const engine = Engine.init(TEST_ANALYTICS_ID, {
+ AddressBookController: {
+ addressBook: {
+ '0x1': {
+ [knownAddress]: {
+ address: knownAddress,
+ chainId: '0x1',
+ isEns: false,
+ memo: '',
+ name: 'Known recipient',
+ },
+ },
+ },
+ },
+ });
+
+ expect(
+ engine.context.PhishingController.checkAddressPoisoning(candidateAddress),
+ ).toEqual([expect.objectContaining({ knownAddress })]);
+ });
+
it('calling Engine.init twice returns the same instance', () => {
const engine = Engine.init(TEST_ANALYTICS_ID, {});
const newEngine = Engine.init(TEST_ANALYTICS_ID, {});
diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts
index bb6603b369ff..2e116aac6053 100644
--- a/app/core/Engine/Engine.ts
+++ b/app/core/Engine/Engine.ts
@@ -393,12 +393,14 @@ export class Engine {
// subscribes to ClientController:stateChange before ClientController can emit.
AssetsController: assetsControllerInit,
ClientController: clientControllerInit,
+ // PhishingController hydrates known recipients from AddressBookController
+ // during construction for address poisoning checks.
+ AddressBookController: addressBookControllerInit,
PhishingController: phishingControllerInit,
PredictController: predictControllerInit,
RewardsController: rewardsControllerInit,
RewardsDataService: rewardsDataServiceInit,
DelegationController: DelegationControllerInit,
- AddressBookController: addressBookControllerInit,
ConnectivityController: connectivityControllerInit,
ProfileMetricsController: profileMetricsControllerInit,
ProfileMetricsService: profileMetricsServiceInit,
diff --git a/app/core/Engine/messengers/phishing-controller-messenger.ts b/app/core/Engine/messengers/phishing-controller-messenger.ts
index 949597031212..785eb22f10d7 100644
--- a/app/core/Engine/messengers/phishing-controller-messenger.ts
+++ b/app/core/Engine/messengers/phishing-controller-messenger.ts
@@ -19,8 +19,14 @@ export function getPhishingControllerMessenger(
parent: rootMessenger,
});
rootMessenger.delegate({
- actions: [],
- events: ['TransactionController:stateChange'],
+ actions: [
+ 'AddressBookController:getState',
+ 'TransactionController:getState',
+ ],
+ events: [
+ 'AddressBookController:stateChange',
+ 'TransactionController:stateChange',
+ ],
messenger,
});
return messenger;
diff --git a/app/core/SDKConnectV2/services/connection-registry.test.ts b/app/core/SDKConnectV2/services/connection-registry.test.ts
index 1698ea78750d..9eb54e404560 100644
--- a/app/core/SDKConnectV2/services/connection-registry.test.ts
+++ b/app/core/SDKConnectV2/services/connection-registry.test.ts
@@ -739,6 +739,63 @@ describe('ConnectionRegistry', () => {
expect(mockStore.save).toHaveBeenCalledTimes(1);
});
+ it('skips creating a connection if one with the same id already exists', async () => {
+ // Given: a registry ready to handle connections
+ registry = new ConnectionRegistry(
+ RELAY_URL,
+ mockKeyManager,
+ mockHostApp,
+ mockStore,
+ );
+
+ // When: handling the same deeplink twice sequentially. After the first
+ // call resolves the in-flight `deeplinks` guard is cleared (see finally
+ // block), so the second call would otherwise proceed — the connection
+ // id duplicate check is what must short-circuit it.
+ await registry.handleConnectDeeplink(validDeeplink);
+ jest.clearAllMocks();
+
+ await registry.handleConnectDeeplink(validDeeplink);
+
+ // Then: the second call must not create a duplicate connection nor
+ // surface a loading state or error to the user.
+ expect(Connection.create).not.toHaveBeenCalled();
+ expect(mockConnection.connect).not.toHaveBeenCalled();
+ expect(mockStore.save).not.toHaveBeenCalled();
+ expect(mockHostApp.showConnectionLoading).not.toHaveBeenCalled();
+ expect(mockHostApp.showConnectionError).not.toHaveBeenCalled();
+ expect(mockHostApp.syncConnectionList).not.toHaveBeenCalled();
+ });
+
+ it('allows retrying the same deeplink URL after a previous attempt fails', async () => {
+ // Given: a registry where the first connect() attempt fails
+ registry = new ConnectionRegistry(
+ RELAY_URL,
+ mockKeyManager,
+ mockHostApp,
+ mockStore,
+ );
+
+ mockConnection.connect.mockRejectedValueOnce(new Error('Connect failed'));
+
+ // First call fails — no connection is registered.
+ await registry.handleConnectDeeplink(validDeeplink);
+ expect(mockHostApp.showConnectionError).toHaveBeenCalledTimes(1);
+
+ jest.clearAllMocks();
+
+ // When: the same URL is submitted again after the failure. The
+ // `deeplinks` in-flight guard must have been cleared in the finally
+ // block, allowing the retry to proceed.
+ await registry.handleConnectDeeplink(validDeeplink);
+
+ // Then: the retry runs the full happy path.
+ expect(Connection.create).toHaveBeenCalledTimes(1);
+ expect(mockConnection.connect).toHaveBeenCalledTimes(1);
+ expect(mockStore.save).toHaveBeenCalledTimes(1);
+ expect(mockHostApp.showConnectionError).not.toHaveBeenCalled();
+ });
+
it('should handle deeplinks with no payload parameter', async () => {
// Given: a registry ready to handle connections
registry = new ConnectionRegistry(
diff --git a/app/core/SDKConnectV2/services/connection-registry.ts b/app/core/SDKConnectV2/services/connection-registry.ts
index 66b3cfd6f709..d64ce5cd3989 100644
--- a/app/core/SDKConnectV2/services/connection-registry.ts
+++ b/app/core/SDKConnectV2/services/connection-registry.ts
@@ -301,9 +301,17 @@ export class ConnectionRegistry {
message: 'External transactions cannot use internal origins',
});
}
- await this.evictIfAtCapacity();
connInfo = this.toConnectionInfo(connReq);
+ if (this.connections.has(connInfo.id)) {
+ logger.debug(
+ 'Already have a connection with this id, skipping',
+ redactUrl(url),
+ );
+ return;
+ }
+
+ await this.evictIfAtCapacity();
this.hostapp.showConnectionLoading(connInfo);
conn = await Connection.create(
@@ -364,6 +372,7 @@ export class ConnectionRegistry {
if (conn) await this.disconnect(conn.id);
} finally {
+ this.deeplinks.delete(url);
// Loading-toast dismissal rules:
// - On failure, always dismiss the loading toast. Otherwise the user
// would briefly see both a "loading" toast and the error toast at
diff --git a/app/images/mm_how_it_works.png b/app/images/mm_how_it_works.png
index d855e607515c..826777720023 100644
Binary files a/app/images/mm_how_it_works.png and b/app/images/mm_how_it_works.png differ
diff --git a/app/selectors/cardController.test.ts b/app/selectors/cardController.test.ts
index d04aeaa439fd..4a320d5a7fdd 100644
--- a/app/selectors/cardController.test.ts
+++ b/app/selectors/cardController.test.ts
@@ -17,7 +17,10 @@ import {
selectCardLineaUsdcToken,
selectIsMoneyAccountDelegatedForCard,
} from './cardController';
-import { selectPrimaryMoneyAccount } from './moneyAccountController';
+import {
+ selectMoneyAccounts,
+ selectPrimaryMoneyAccount,
+} from './moneyAccountController';
import type { CardControllerState } from '../core/Engine/controllers/card-controller/types';
import {
FundingAssetStatus,
@@ -33,6 +36,7 @@ jest.mock('./multichainAccounts/accounts');
jest.mock('../core/Multichain/utils');
jest.mock('./moneyAccountController', () => ({
selectPrimaryMoneyAccount: jest.fn(),
+ selectMoneyAccounts: jest.fn(() => ({})),
}));
const mockSelectSelectedInternalAccountByScope =
@@ -46,6 +50,10 @@ const mockSelectPrimaryMoneyAccount =
selectPrimaryMoneyAccount as unknown as jest.MockedFunction<
() => { address: string } | undefined
>;
+const mockSelectMoneyAccounts =
+ selectMoneyAccounts as unknown as jest.MockedFunction<
+ () => Record
+ >;
const createMockRootState = (
overrides: Partial = {},
@@ -378,6 +386,10 @@ const mockCardHomeData: CardHomeData = {
};
describe('selectCardPrimaryToken', () => {
+ beforeEach(() => {
+ mockSelectMoneyAccounts.mockReturnValue({});
+ });
+
it('returns null when cardHomeData is null', () => {
const state = createMockRootState({ cardHomeData: null });
expect(selectCardPrimaryToken(state)).toBeNull();
@@ -400,6 +412,37 @@ describe('selectCardPrimaryToken', () => {
}),
);
});
+
+ it('sets isMoneyAccountEntry to false when no money accounts exist', () => {
+ mockSelectMoneyAccounts.mockReturnValue({});
+ const state = createMockRootState({
+ cardHomeData:
+ mockCardHomeData as unknown as CardControllerState['cardHomeData'],
+ });
+ expect(selectCardPrimaryToken(state)?.isMoneyAccountEntry).toBe(false);
+ });
+
+ it('sets isMoneyAccountEntry to false when no money account matches the walletAddress', () => {
+ mockSelectMoneyAccounts.mockReturnValue({
+ 'ma-1': { address: '0xnotthewallet000000000000000000000000000001' },
+ });
+ const state = createMockRootState({
+ cardHomeData:
+ mockCardHomeData as unknown as CardControllerState['cardHomeData'],
+ });
+ expect(selectCardPrimaryToken(state)?.isMoneyAccountEntry).toBe(false);
+ });
+
+ it('sets isMoneyAccountEntry to true when the primary funding asset walletAddress matches a money account', () => {
+ mockSelectMoneyAccounts.mockReturnValue({
+ 'ma-1': { address: mockPrimaryAsset.walletAddress },
+ });
+ const state = createMockRootState({
+ cardHomeData:
+ mockCardHomeData as unknown as CardControllerState['cardHomeData'],
+ });
+ expect(selectCardPrimaryToken(state)?.isMoneyAccountEntry).toBe(true);
+ });
});
describe('selectCardAvailableTokens', () => {
@@ -715,9 +758,86 @@ describe('selectCardAvailableTokens', () => {
expect(placeholder?.walletAddress).toBe(WALLET_A);
});
});
+
+ describe('isMoneyAccountEntry flag', () => {
+ beforeEach(() => {
+ mockSelectMoneyAccounts.mockReturnValue({});
+ });
+
+ it('flags real entries whose walletAddress matches a money account', () => {
+ mockSelectMoneyAccounts.mockReturnValue({
+ 'ma-1': { address: WALLET_A },
+ });
+ const assets = [
+ makeAsset({
+ walletAddress: WALLET_A,
+ status: FundingAssetStatus.Active,
+ }),
+ makeAsset({
+ walletAddress: WALLET_B,
+ status: FundingAssetStatus.Active,
+ }),
+ ];
+ const state = stateWithAssets(assets);
+ const tokens = selectCardAvailableTokens(state);
+ const aTokens = tokens.filter(
+ (t) => t.walletAddress?.toLowerCase() === WALLET_A.toLowerCase(),
+ );
+ const bTokens = tokens.filter(
+ (t) => t.walletAddress?.toLowerCase() === WALLET_B.toLowerCase(),
+ );
+ expect(aTokens.every((t) => t.isMoneyAccountEntry === true)).toBe(true);
+ expect(bTokens.every((t) => t.isMoneyAccountEntry === false)).toBe(true);
+ });
+
+ it('flags synthesized placeholders when the current EVM wallet is a money account', () => {
+ mockSelectMoneyAccounts.mockReturnValue({
+ 'ma-1': { address: WALLET_A },
+ });
+ const cardHomeDataWithDelegationToken = {
+ ...mockCardHomeData,
+ fundingAssets: [],
+ delegationSettings: {
+ ...mockCardHomeData.delegationSettings,
+ networks: [
+ {
+ network: 'linea',
+ environment: 'production',
+ chainId: '59144',
+ delegationContract: '0xdeleg000000000000000000000000000000000004',
+ tokens: {
+ USDC: {
+ symbol: 'USDC',
+ decimals: 6,
+ address: '0xusdc000000000000000000000000000000000010',
+ },
+ },
+ },
+ ],
+ },
+ } as unknown as CardHomeData;
+ mockSelectSelectedInternalAccountByScope.mockReturnValue(
+ jest.fn().mockReturnValue({ address: WALLET_A } as InternalAccount),
+ );
+ const state = createMockRootState({
+ cardHomeData:
+ cardHomeDataWithDelegationToken as unknown as CardControllerState['cardHomeData'],
+ });
+ const tokens = selectCardAvailableTokens(state);
+ const placeholder = tokens.find(
+ (t) => t.fundingStatus === FundingStatus.NotEnabled,
+ );
+ expect(placeholder?.walletAddress).toBe(WALLET_A);
+ expect(placeholder?.isMoneyAccountEntry).toBe(true);
+ });
+ });
});
describe('selectCardFundingTokens', () => {
+ beforeEach(() => {
+ mockSelectMoneyAccounts.mockReturnValue({});
+ });
+
it('returns empty array when cardHomeData is null', () => {
const state = createMockRootState({ cardHomeData: null });
expect(selectCardFundingTokens(state)).toStrictEqual([]);
@@ -733,6 +853,32 @@ describe('selectCardFundingTokens', () => {
expect(tokens[0]?.symbol).toBe('USDC');
expect(tokens[1]?.symbol).toBe('USDT');
});
+
+ it('flags tokens whose walletAddress matches a money account and leaves others false', () => {
+ mockSelectMoneyAccounts.mockReturnValue({
+ 'ma-1': { address: mockPrimaryAsset.walletAddress },
+ });
+ const cardHomeData: CardHomeData = {
+ ...mockCardHomeData,
+ fundingAssets: [
+ mockPrimaryAsset,
+ {
+ ...mockPrimaryAsset,
+ symbol: 'USDT',
+ walletAddress: '0xotherwallet0000000000000000000000000000099',
+ address: '0xusdt000000000000000000000000000000000003',
+ },
+ ],
+ };
+ const state = createMockRootState({
+ cardHomeData:
+ cardHomeData as unknown as CardControllerState['cardHomeData'],
+ });
+ const tokens = selectCardFundingTokens(state);
+ expect(tokens).toHaveLength(2);
+ expect(tokens[0]?.isMoneyAccountEntry).toBe(true);
+ expect(tokens[1]?.isMoneyAccountEntry).toBe(false);
+ });
});
describe('selectCardDelegationSettings', () => {
diff --git a/app/selectors/cardController.ts b/app/selectors/cardController.ts
index abb60509c752..d153a26d7595 100644
--- a/app/selectors/cardController.ts
+++ b/app/selectors/cardController.ts
@@ -18,10 +18,14 @@ import {
} from '../components/UI/Card/types';
import { toCardFundingToken } from '../components/UI/Card/util/toCardTokenAllowance';
import { buildDelegationTokenList } from '../components/UI/Card/util/buildTokenList';
+import { isMoneyAccountEntry } from '../components/UI/Card/util/isMoneyAccountEntry';
import { selectSelectedInternalAccountByScope } from './multichainAccounts/accounts';
import { isEthAccount } from '../core/Multichain/utils';
import { isMoneyAccountDelegatedForCard } from '../core/Engine/controllers/card-controller/utils/moneyAccountCardToken';
-import { selectPrimaryMoneyAccount } from './moneyAccountController';
+import {
+ selectMoneyAccounts,
+ selectPrimaryMoneyAccount,
+} from './moneyAccountController';
import { selectCardFeatureFlag } from './featureFlagController/card';
const LINEA_MAINNET_CAIP_CHAIN_ID = 'eip155:59144';
@@ -132,9 +136,16 @@ export const selectCardHomeDataStatus = createSelector(
export const selectCardPrimaryToken = createSelector(
selectCardHomeData,
- (data): CardFundingToken | null =>
+ selectMoneyAccounts,
+ (data, moneyAccounts): CardFundingToken | null =>
data?.primaryFundingAsset
- ? toCardFundingToken(data.primaryFundingAsset)
+ ? toCardFundingToken(
+ data.primaryFundingAsset,
+ isMoneyAccountEntry(
+ data.primaryFundingAsset.walletAddress,
+ moneyAccounts,
+ ),
+ )
: null,
);
@@ -147,7 +158,13 @@ export const selectCardAvailableTokens = createSelector(
selectCardHomeData,
selectSelectedEvmAccount,
selectCardFeatureFlag,
- (data, selectedAccount, cardFeatureFlag): CardFundingToken[] => {
+ selectMoneyAccounts,
+ (
+ data,
+ selectedAccount,
+ cardFeatureFlag,
+ moneyAccounts,
+ ): CardFundingToken[] => {
const currentAddress = selectedAccount?.address;
const currentAddressLower = currentAddress?.toLowerCase();
const fundingAssets = data?.fundingAssets ?? [];
@@ -167,7 +184,12 @@ export const selectCardAvailableTokens = createSelector(
const assetWallet = asset.walletAddress?.toLowerCase();
return !assetWallet || assetWallet === currentAddressLower;
})
- .map(toCardFundingToken);
+ .map((asset) =>
+ toCardFundingToken(
+ asset,
+ isMoneyAccountEntry(asset.walletAddress, moneyAccounts),
+ ),
+ );
if (!currentAddress) return realEntries;
@@ -177,6 +199,11 @@ export const selectCardAvailableTokens = createSelector(
.map((t) => `${t.address?.toLowerCase()}-${t.caipChainId}`),
);
+ const currentAddressIsMoneyAccount = isMoneyAccountEntry(
+ currentAddress,
+ moneyAccounts,
+ );
+
const placeholders = buildDelegationTokenList({
delegationSettings,
getSupportedTokensByChainId: (chainId) =>
@@ -195,6 +222,7 @@ export const selectCardAvailableTokens = createSelector(
.map((placeholder) => ({
...placeholder,
walletAddress: currentAddress,
+ isMoneyAccountEntry: currentAddressIsMoneyAccount,
}));
return sortCardFundingTokens([...realEntries, ...placeholders]);
@@ -203,8 +231,14 @@ export const selectCardAvailableTokens = createSelector(
export const selectCardFundingTokens = createSelector(
selectCardHomeData,
- (data): CardFundingToken[] =>
- (data?.fundingAssets ?? []).map(toCardFundingToken),
+ selectMoneyAccounts,
+ (data, moneyAccounts): CardFundingToken[] =>
+ (data?.fundingAssets ?? []).map((asset) =>
+ toCardFundingToken(
+ asset,
+ isMoneyAccountEntry(asset.walletAddress, moneyAccounts),
+ ),
+ ),
);
export const selectCardDelegationSettings = createSelector(
@@ -226,13 +260,23 @@ export const selectCardLineaUsdcToken = createSelector(
selectCardHomeData,
selectSelectedEvmAccount,
selectCardFeatureFlag,
- (data, selectedAccount, cardFeatureFlag): CardFundingToken | null => {
+ selectMoneyAccounts,
+ (
+ data,
+ selectedAccount,
+ cardFeatureFlag,
+ moneyAccounts,
+ ): CardFundingToken | null => {
const realAsset = (data?.fundingAssets ?? []).find(
(asset) =>
asset.chainId === LINEA_MAINNET_CAIP_CHAIN_ID &&
asset.symbol?.toUpperCase() === CASHBACK_FUNDING_SYMBOL,
);
- if (realAsset) return toCardFundingToken(realAsset);
+ if (realAsset)
+ return toCardFundingToken(
+ realAsset,
+ isMoneyAccountEntry(realAsset.walletAddress, moneyAccounts),
+ );
const placeholder = buildDelegationTokenList({
delegationSettings: data?.delegationSettings ?? null,
@@ -250,9 +294,16 @@ export const selectCardLineaUsdcToken = createSelector(
if (!placeholder) return null;
- return selectedAccount?.address
- ? { ...placeholder, walletAddress: selectedAccount.address }
- : placeholder;
+ if (!selectedAccount?.address) return placeholder;
+
+ return {
+ ...placeholder,
+ walletAddress: selectedAccount.address,
+ isMoneyAccountEntry: isMoneyAccountEntry(
+ selectedAccount.address,
+ moneyAccounts,
+ ),
+ };
},
);
diff --git a/docs/bigint-migration-guide.md b/docs/bigint-migration-guide.md
index 231b29136caf..ede912073006 100644
--- a/docs/bigint-migration-guide.md
+++ b/docs/bigint-migration-guide.md
@@ -88,11 +88,6 @@ The table below maps each burndown path to the GitHub team(s) from [`.github/COD
- `app/components/Views/confirmations/external/staking/hooks/useStakingDetails.ts`
-### @MetaMask/design-system-engineers
-
-- `app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.tsx`
-- `app/component-library/components-temp/CustomSpendCap/CustomSpendCap.tsx`
-
### @MetaMask/earn
Includes Stake UI and Money paths owned via `**/Earn/**`, `**/earn/**`, `**/Money/**`, `**/money/**`, and `app/components/UI/Stake` in `CODEOWNERS`.
diff --git a/locales/languages/en.json b/locales/languages/en.json
index f729bdc1f5be..9816f7fe5fd6 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -128,6 +128,11 @@
"title": "Wallet not supported",
"message": "Hardware wallets aren't supported.\nSwitch wallets to continue."
},
+ "address_poisoning": {
+ "title": "Address poisoning detected",
+ "message": "This address closely resembles one you've used before. It may have been designed to trick you into sending funds to the wrong recipient.",
+ "badge": "Poisoned"
+ },
"burn_address": {
"message": "You're sending your assets to a burn address. If you continue, you'll lose your assets.",
"title": "Sending assets to burn address"
@@ -712,6 +717,9 @@
"could_not_resolve_name": "Couldn't resolve name",
"invalid_address": "Invalid address",
"contractAddressError": "You are sending tokens to the token's contract address. This may result in the loss of these tokens.",
+ "compare_addresses": "Compare addresses",
+ "entered_malicious": "The Address You Entered",
+ "known_safe_address": "Known Address",
"smart_contract_address": "Smart contract address",
"smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.",
"unavailable_network_connection": "Unavailable network connection",
diff --git a/package.json b/package.json
index 271278f8926d..066d68abfcd4 100644
--- a/package.json
+++ b/package.json
@@ -239,7 +239,7 @@
"@metamask/account-api": "^1.0.4",
"@metamask/account-tree-controller": "^7.2.0",
"@metamask/accounts-controller": "^38.0.0",
- "@metamask/address-book-controller": "^7.1.0",
+ "@metamask/address-book-controller": "^7.1.2",
"@metamask/ai-controllers": "0.6.3",
"@metamask/analytics-controller": "^1.0.0",
"@metamask/app-metadata-controller": "^2.0.0",
@@ -312,7 +312,7 @@
"@metamask/network-enablement-controller": "^5.1.0",
"@metamask/notification-services-controller": "24.1.1",
"@metamask/permission-controller": "^13.1.1",
- "@metamask/phishing-controller": "^17.1.1",
+ "@metamask/phishing-controller": "^17.2.0",
"@metamask/post-message-stream": "^10.0.0",
"@metamask/preferences-controller": "^23.0.0",
"@metamask/preinstalled-example-snap": "^0.7.2",
diff --git a/scripts/android-play-store-check-slack.mjs b/scripts/android-play-store-check-slack.mjs
new file mode 100644
index 000000000000..b06500de5054
--- /dev/null
+++ b/scripts/android-play-store-check-slack.mjs
@@ -0,0 +1,202 @@
+#!/usr/bin/env node
+/**
+ * Non-blocking Android Play Store checks for prodRelease: Gradle lint + bundletool validate.
+ * Always exits 0. Writes android-play-store-check-slack.md in GITHUB_WORKSPACE for Slack (mrkdwn).
+ *
+ * @see .github/workflows/build.yml
+ */
+
+import { execFileSync } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+const root = process.env.GITHUB_WORKSPACE || process.cwd();
+const outRel = 'android-play-store-check-slack.md';
+const outFile = path.join(root, outRel);
+const androidDir = path.join(root, 'android');
+const lintXml = path.join(
+ root,
+ 'android/app/build/reports/lint-results-prodRelease.xml',
+);
+const bundleDir = path.join(
+ root,
+ 'android/app/build/outputs/bundle/prodRelease',
+);
+
+/** Slack mrkdwn escaping for user-controlled text in section bodies */
+function escapeMrkdwn(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+/**
+ * @param {string} xml
+ * @returns {{ id: string, message: string }[]}
+ */
+function parseLintErrors(xml) {
+ const chunks = xml.split(' f.endsWith('.aab'))
+ .map((f) => path.join(bundleDir, f));
+}
+
+function runBundletool(jar, aab) {
+ try {
+ execFileSync(
+ 'java',
+ ['-jar', jar, 'validate', `--bundle=${aab}`],
+ {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ );
+ return { ok: true, log: '' };
+ } catch (e) {
+ const log = `${e.stderr || ''}\n${e.stdout || ''}`.trim();
+ return { ok: false, log };
+ }
+}
+
+function main() {
+ const lines = [];
+ let anyFail = false;
+
+ const bundletoolJar = path.join(
+ process.env.RUNNER_TEMP || '/tmp',
+ 'bundletool-all.jar',
+ );
+
+ if (!fs.existsSync(path.join(androidDir, 'gradlew'))) {
+ lines.push('*Gradle* — `android/gradlew` missing.');
+ fs.writeFileSync(
+ outFile,
+ `PLAY_STORE_CHECK_STATUS=fail\n${lines.join('\n')}\n`,
+ );
+ return;
+ }
+
+ lines.push('*Android Play Store check* (`prodRelease`, non-blocking)\n');
+
+ const lintResult = runLint();
+ if (lintResult.ok) {
+ lines.push('*:app:lintProdRelease* — passed.');
+ } else {
+ anyFail = true;
+ lines.push('*:app:lintProdRelease* — _failed._');
+ if (fs.existsSync(lintXml)) {
+ const xml = fs.readFileSync(lintXml, 'utf8');
+ const issues = parseLintErrors(xml);
+ if (issues.length) {
+ lines.push('\n*Lint errors:*');
+ for (const { id, message } of issues.slice(0, 40)) {
+ lines.push(
+ `• \`${escapeMrkdwn(id)}\`: ${escapeMrkdwn(message).slice(0, 500)}`,
+ );
+ }
+ if (issues.length > 40) {
+ lines.push(`\n_…and ${issues.length - 40} more (see CI lint report)._`);
+ }
+ } else {
+ const tail = lintResult.log.split('\n').slice(-40).join('\n');
+ lines.push(
+ `\n\`\`\`\n${escapeMrkdwn(tail || '(no lint report XML)')}\n\`\`\`\n`,
+ );
+ }
+ } else {
+ const tail = lintResult.log.split('\n').slice(-40).join('\n');
+ lines.push(`\n\`\`\`\n${escapeMrkdwn(tail)}\n\`\`\`\n`);
+ }
+ }
+
+ const aabs = findProdReleaseAab();
+ lines.push('');
+ if (aabs.length !== 1) {
+ anyFail = true;
+ lines.push(
+ `*bundletool validate* — skipped (_expected exactly one .aab in prodRelease, found ${aabs.length}._)`,
+ );
+ } else if (!fs.existsSync(bundletoolJar)) {
+ anyFail = true;
+ lines.push(
+ `*bundletool validate* — skipped (_bundletool jar missing at \`${escapeMrkdwn(bundletoolJar)}\`._)`,
+ );
+ } else {
+ const bt = runBundletool(bundletoolJar, aabs[0]);
+ if (bt.ok) {
+ lines.push(`*bundletool validate* — passed (\`${path.basename(aabs[0])}\`).`);
+ } else {
+ anyFail = true;
+ lines.push(
+ `*bundletool validate* — _failed_ (\`${path.basename(aabs[0])}\`).`,
+ );
+ const tail = bt.log.split('\n').slice(-30).join('\n');
+ lines.push(`\n\`\`\`\n${escapeMrkdwn(tail)}\n\`\`\`\n`);
+ }
+ }
+
+ const status = anyFail ? 'fail' : 'pass';
+ const body = lines.join('\n');
+ fs.writeFileSync(outFile, `PLAY_STORE_CHECK_STATUS=${status}\n${body}\n`);
+
+ if (anyFail && process.env.GITHUB_STEP_SUMMARY) {
+ fs.appendFileSync(
+ process.env.GITHUB_STEP_SUMMARY,
+ `\n## Android Play Store check (non-blocking)\n\n${body}\n\n`,
+ );
+ }
+}
+
+try {
+ main();
+} catch (err) {
+ console.error(err);
+ const root = process.env.GITHUB_WORKSPACE || process.cwd();
+ const outFile = path.join(root, 'android-play-store-check-slack.md');
+ const msg = `PLAY_STORE_CHECK_STATUS=fail\n*Android Play Store check script crashed*\n\`\`\`\n${String(err)}\n\`\`\`\n`;
+ try {
+ fs.writeFileSync(outFile, msg);
+ } catch {
+ // ignore
+ }
+}
diff --git a/scripts/slack-rc-notification.mjs b/scripts/slack-rc-notification.mjs
index b89ab105bd93..e8480be0c20e 100644
--- a/scripts/slack-rc-notification.mjs
+++ b/scripts/slack-rc-notification.mjs
@@ -7,13 +7,38 @@
* Required env: SEMVER, SLACK_BOT_TOKEN
* Optional env: IOS_BUILD_NUMBER, ANDROID_BUILD_NUMBER, ANDROID_PUBLIC_URL,
* IOS_PUBLIC_URL, BUILD_PIPELINE_URL, PR_NUMBER, GITHUB_REPOSITORY,
- * SLACK_RC_NOTIFICATION_DRY_RUN
+ * SLACK_RC_NOTIFICATION_DRY_RUN,
+ * ANDROID_PLAY_STORE_CHECK_MRKDWN_FILE (PLAY_STORE_CHECK_STATUS=pass|fail)
*/
+import fs from 'fs';
+
const REPO_URL = process.env.GITHUB_REPOSITORY
? `https://github.com/${process.env.GITHUB_REPOSITORY}`
: 'https://github.com/MetaMask/metamask-mobile';
+/**
+ * Optional Android Play Store lint/bundletool report from CI (see android-play-store-check-slack.mjs).
+ * @returns {string|null} Slack mrkdwn body or null to omit
+ */
+function loadPlayStoreCheckMrkdwn() {
+ const p = process.env.ANDROID_PLAY_STORE_CHECK_MRKDWN_FILE?.trim();
+ if (!p || !fs.existsSync(p)) {
+ return null;
+ }
+ const raw = fs.readFileSync(p, 'utf8').trim();
+ if (!raw) {
+ return null;
+ }
+ const lines = raw.split('\n');
+ const statusLine = lines[0] ?? '';
+ if (statusLine === 'PLAY_STORE_CHECK_STATUS=pass') {
+ return null;
+ }
+ const body = lines.slice(1).join('\n').trim();
+ return body || null;
+}
+
/**
* Check if a URL is valid
* @param {string|undefined} url - The URL to check
@@ -38,6 +63,7 @@ function isValidUrl(url) {
/**
* Build the Slack message payload
* @param {Object} options - Message options
+ * @param {string|null} [options.playStoreCheckMrkdwn] - Optional mrkdwn from Android Play Store check
* @returns {Object} Slack message payload
*/
function buildSlackMessage(options) {
@@ -48,6 +74,7 @@ function buildSlackMessage(options) {
iosUrl,
pipelineUrl,
prNumber,
+ playStoreCheckMrkdwn,
} = options;
const blocks = [
@@ -124,6 +151,25 @@ function buildSlackMessage(options) {
});
}
+ if (playStoreCheckMrkdwn) {
+ const truncated =
+ playStoreCheckMrkdwn.length > 2800
+ ? `${playStoreCheckMrkdwn.slice(0, 2800)}\n_…truncated_`
+ : playStoreCheckMrkdwn;
+ blocks.push(
+ {
+ type: 'divider',
+ },
+ {
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: `*⚠️ Android Play Store check (non-blocking)*\n${truncated}`,
+ },
+ },
+ );
+ }
+
// Add pipeline link
if (pipelineUrl) {
blocks.push(
@@ -227,6 +273,7 @@ async function main() {
const botToken = process.env.SLACK_BOT_TOKEN;
const prNumber = process.env.PR_NUMBER || '';
+ const playStoreCheckMrkdwn = loadPlayStoreCheckMrkdwn();
const expectedChannelName = getSlackChannel(version);
console.log(`\n📣 Preparing Slack notification for RC v${version} (${buildNumber})`);
@@ -249,6 +296,7 @@ async function main() {
iosUrl,
pipelineUrl,
prNumber,
+ playStoreCheckMrkdwn,
});
if (isDryRun) {
diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts
index d2f9c48bcaec..a075983f8a05 100644
--- a/tests/component-view/mocks.ts
+++ b/tests/component-view/mocks.ts
@@ -106,6 +106,9 @@ jest.mock('../../app/core/Engine', () => {
supportsCashback: true,
}),
},
+ PhishingController: {
+ checkAddressPoisoning: jest.fn().mockReturnValue([]),
+ },
TokensController: {
addTokens() {
return undefined;
diff --git a/yarn.lock b/yarn.lock
index 8a708bfec485..b35a2947b1bb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7570,7 +7570,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/address-book-controller@npm:^7.1.0, @metamask/address-book-controller@npm:^7.1.2":
+"@metamask/address-book-controller@npm:^7.1.2":
version: 7.1.2
resolution: "@metamask/address-book-controller@npm:7.1.2"
dependencies:
@@ -9347,20 +9347,21 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/phishing-controller@npm:^17.1.1, @metamask/phishing-controller@npm:^17.1.2":
- version: 17.1.2
- resolution: "@metamask/phishing-controller@npm:17.1.2"
+"@metamask/phishing-controller@npm:^17.1.2, @metamask/phishing-controller@npm:^17.2.0":
+ version: 17.2.0
+ resolution: "@metamask/phishing-controller@npm:17.2.0"
dependencies:
+ "@metamask/address-book-controller": "npm:^7.1.2"
"@metamask/base-controller": "npm:^9.1.0"
- "@metamask/controller-utils": "npm:^12.0.0"
+ "@metamask/controller-utils": "npm:^12.1.0"
"@metamask/messenger": "npm:^1.2.0"
- "@metamask/transaction-controller": "npm:^65.3.0"
+ "@metamask/transaction-controller": "npm:^65.4.0"
"@noble/hashes": "npm:^1.8.0"
"@types/punycode": "npm:^2.1.0"
ethereum-cryptography: "npm:^2.1.2"
fastest-levenshtein: "npm:^1.0.16"
punycode: "npm:^2.1.1"
- checksum: 10/61a50a802aaaded12b452303025205a9145c59e17eb2f56014c94cbf27f71d9b8582b1cb4cf9859e665471863f4738622f80aff2f7a4881d05dff4575f4ef9eb
+ checksum: 10/de271d813043ca535e76cff52084ac9d4e0da762fa02e75425583dfc068c4b4bcb189266cb9cf58baf7ed79ab58f0b003a5643426780bb49192ca30212640b05
languageName: node
linkType: hard
@@ -35223,7 +35224,7 @@ __metadata:
"@metamask/account-api": "npm:^1.0.4"
"@metamask/account-tree-controller": "npm:^7.2.0"
"@metamask/accounts-controller": "npm:^38.0.0"
- "@metamask/address-book-controller": "npm:^7.1.0"
+ "@metamask/address-book-controller": "npm:^7.1.2"
"@metamask/ai-controllers": "npm:0.6.3"
"@metamask/analytics-controller": "npm:^1.0.0"
"@metamask/app-metadata-controller": "npm:^2.0.0"
@@ -35305,7 +35306,7 @@ __metadata:
"@metamask/notification-services-controller": "npm:24.1.1"
"@metamask/object-multiplex": "npm:^1.1.0"
"@metamask/permission-controller": "npm:^13.1.1"
- "@metamask/phishing-controller": "npm:^17.1.1"
+ "@metamask/phishing-controller": "npm:^17.2.0"
"@metamask/post-message-stream": "npm:^10.0.0"
"@metamask/preferences-controller": "npm:^23.0.0"
"@metamask/preinstalled-example-snap": "npm:^0.7.2"