diff --git a/.github/workflows/performance-test-runner.yml b/.github/workflows/performance-test-runner.yml
index 4f19f89c729..5fde4ba4eb1 100644
--- a/.github/workflows/performance-test-runner.yml
+++ b/.github/workflows/performance-test-runner.yml
@@ -183,7 +183,7 @@ jobs:
SELECTED_SENTRY_DSN=""
SENTRY_ENVIRONMENT="github-actions-performance-e2e"
FEATURE_FLAGS_ENV="${{ inputs.feature_flags_environment }}"
- BROWSERSTACK_GEO_LOCATION="${BROWSERSTACK_GEO_LOCATION:-ES}"
+ BROWSERSTACK_GEO_LOCATION="${BROWSERSTACK_GEO_LOCATION:-SE}"
if [[ "$FEATURE_FLAGS_ENV" != "rc" && "$FEATURE_FLAGS_ENV" != "exp" && "$FEATURE_FLAGS_ENV" != "test" && "$FEATURE_FLAGS_ENV" != "dev" && "$FEATURE_FLAGS_ENV" != "prod" ]]; then
echo "❌ Invalid feature_flags_environment '$FEATURE_FLAGS_ENV'. Expected rc, exp, test, dev, or prod."
@@ -258,7 +258,7 @@ jobs:
echo "QA App Version: ${{ inputs.app_version }}"
echo "BrowserStack Build Name: $BROWSERSTACK_BUILD_NAME"
echo "BrowserStack Local: $BROWSERSTACK_LOCAL"
- echo "BrowserStack Geo Location: ${BROWSERSTACK_GEO_LOCATION:-ES}"
+ echo "BrowserStack Geo Location: ${BROWSERSTACK_GEO_LOCATION:-SE}"
if [ -n "$GREP_TAGS" ]; then
echo "Grep filter: $GREP_TAGS"
fi
diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml
index 08ad6dc85b6..c3f96c0764f 100644
--- a/.github/workflows/run-e2e-workflow.yml
+++ b/.github/workflows/run-e2e-workflow.yml
@@ -197,16 +197,30 @@ jobs:
mkdir -p ${{ steps.determine-target-paths.outputs.apk-target-path }}
mkdir -p ${{ steps.determine-target-paths.outputs.test-apk-target-path }}
- - name: Download Android build artifacts (Namespace)
+ - name: Download Android app APK (Namespace)
if: ${{ inputs.platform == 'android' && inputs.runner_provider == 'namespace' }}
uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1
with:
- path: artifacts/
- - name: Download Android build artifacts (current)
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk
+ path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk
+ - name: Download Android test APK (Namespace)
+ if: ${{ inputs.platform == 'android' && inputs.runner_provider == 'namespace' }}
+ uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1
+ with:
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk
+ path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk
+ - name: Download Android app APK (current)
+ if: ${{ inputs.platform == 'android' && inputs.runner_provider != 'namespace' }}
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk
+ path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk
+ - name: Download Android test APK (current)
if: ${{ inputs.platform == 'android' && inputs.runner_provider != 'namespace' }}
uses: actions/download-artifact@v4
with:
- path: artifacts/
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk
+ path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk
- name: Move Android artifacts to expected locations
if: ${{ inputs.platform == 'android' }}
@@ -229,12 +243,14 @@ jobs:
if: ${{ inputs.platform == 'ios' && inputs.runner_provider == 'namespace' }}
uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1
with:
- path: artifacts/
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
+ path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
- name: Download iOS build artifacts (current)
if: ${{ inputs.platform == 'ios' && inputs.runner_provider != 'namespace' }}
uses: actions/download-artifact@v4
with:
- path: artifacts/
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
+ path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
# actions/upload-artifact strips execute permissions from the ZIP.
# Also fixes any residual case mismatch as a safety net.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 590b4fc2bc9..30e59a5abdf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [7.81.1]
+
+### Added
+
+- Adds Swap/Bridge feature to ARC mainnet. (#31413)
+
+### Fixed
+
+- Fixed the MetaMask Card section on the Money screen so the "Link card" option and funding token placeholders are only shown when the card spending token is enabled in the card feature configuration. (#31611)
+- Removed the trailing slash from the Arc explorer URL defined in app/util/networks/customNetworks.tsx so now the URL is correctly generated. (#31718)
+- Fixed Arc native token support, included patches for MetaMask/core#9006 and MetaMask/core#9007 (#31662)
+
## [7.81.0]
### Added
@@ -84,8 +96,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a new Positions screen for Predict users to view active positions and history (#30718)
- Track explore conversions in swaps (#30720)
-
### Changed
+
- Updated the Sei native token icon to Sei's maroon token mark. (#31038)
- Updated the Sei network and token logo to Sei's new brand mark. (#30892)
- Improve crypto movers section in explore (#30809)
@@ -368,10 +380,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [7.78.1]
-### Fixed
-
-- Fixed a crash caused by CloseEvent dispatch on WebSocket failing instanceof validation (#30612)
-
### Added
- Updated the onboarding interest questionnaire layout to a two-column grid with refreshed option labels. (#30753)
@@ -469,6 +477,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Fixed a crash caused by CloseEvent dispatch on WebSocket failing instanceof validation (#30612)
- Fixed the "Add mUSD" option in the Money account "Add money" sheet so it opens the deposit flow with mUSD pre-selected, and (#30741)
corrected the in-progress / success / failed toast copy for both the
Convert and Add flows.
@@ -12078,8 +12087,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957)
- [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954)
-[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.81.0...HEAD
-[7.81.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.79.1...v7.81.0
+[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.81.1...HEAD
+[7.81.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.81.0...v7.81.1
+[7.81.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.80.0...v7.81.0
+[7.80.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.79.1...v7.80.0
[7.79.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.79.0...v7.79.1
[7.79.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.78.1...v7.79.0
[7.78.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.78.0...v7.78.1
diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx
index 411822e52fd..e53d2a58e74 100644
--- a/app/components/Nav/App/App.test.tsx
+++ b/app/components/Nav/App/App.test.tsx
@@ -940,10 +940,6 @@ describe('App', () => {
expect(Routes.SHEET.SUCCESS_ERROR_SHEET).toBeDefined();
});
- it('has add account route defined', () => {
- expect(Routes.SHEET.ADD_ACCOUNT).toBeDefined();
- });
-
it('has experience enhancer route defined', () => {
expect(Routes.SHEET.EXPERIENCE_ENHANCER).toBeDefined();
});
@@ -1183,10 +1179,6 @@ describe('App', () => {
expect(Routes.SHEET.ADDRESS_SELECTOR).toBeDefined();
});
- it('has add account route defined', () => {
- expect(Routes.SHEET.ADD_ACCOUNT).toBeDefined();
- });
-
it('has account actions route defined', () => {
expect(Routes.SHEET.ACCOUNT_ACTIONS).toBeDefined();
});
diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx
index 75a4a71fa8b..5d5a35e0a2e 100644
--- a/app/components/Nav/App/App.tsx
+++ b/app/components/Nav/App/App.tsx
@@ -126,7 +126,6 @@ import SeedphraseModal from '../../UI/SeedphraseModal';
import SkipAccountSecurityModal from '../../UI/SkipAccountSecurityModal';
import SuccessErrorSheet from '../../Views/SuccessErrorSheet';
import ConfirmTurnOnBackupAndSyncModal from '../../UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal';
-import AddNewAccountBottomSheet from '../../Views/AddNewAccount/AddNewAccountBottomSheet';
import EligibilityFailedModal from '../../UI/Ramp/components/EligibilityFailedModal';
import RampUnsupportedModal from '../../UI/Ramp/components/RampUnsupportedModal';
import RampsBootstrap from '../../UI/Ramp/RampsBootstrap';
@@ -521,10 +520,6 @@ const RootModalFlow = (props: RootModalFlowProps) => (
name={Routes.SHEET.ADDRESS_SELECTOR}
component={AddressSelector}
/>
-
{
({
+ ...fullScreenModalSlideFromBottomNativeOptions,
+ animation: route.params?.animation ?? 'slide_from_right',
+ })}
/>
{
render();
- expect(
- screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP),
- ).toBeOnTheScreen();
expect(
screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM),
).toBeOnTheScreen();
@@ -6339,7 +6336,9 @@ describe('CardHome Component', () => {
render();
expect(
- screen.queryByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP),
+ screen.queryByTestId(
+ CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM,
+ ),
).not.toBeOnTheScreen();
expect(
screen.queryByText(strings('money.metamask_card.link_title')),
@@ -6353,7 +6352,7 @@ describe('CardHome Component', () => {
render();
expect(
- screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP),
+ screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM),
).toBeOnTheScreen();
});
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts
index 0283ba73f04..d7336bf4cce 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts
+++ b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts
@@ -31,6 +31,5 @@ export const CardHomeSelectors = {
FREEZE_CARD_TOGGLE: 'freeze-card-toggle',
VIEW_PIN_BUTTON: 'view-pin-button',
CARD_WALLET_ADDRESS: 'card-wallet-address',
- LINK_MONEY_ACCOUNT_DIVIDER_TOP: 'link-money-account-divider-top',
LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM: 'link-money-account-divider-bottom',
};
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx
index 302162808f5..54c1da289c2 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx
@@ -422,11 +422,7 @@ const CardHome = () => {
{canLinkMoneyAccount && (
<>
-
-
+
{
limitType: 'full' as const,
customLimit: '',
isLoading: false,
+ isUiInteractionLocked: false,
setSelectedToken: mockSetSelectedToken,
handleAccountSelect: mockHandleAccountSelect,
handleOtherSelect: mockHandleOtherSelect,
@@ -769,10 +770,11 @@ describe('SpendingLimit Component', () => {
);
});
- it('blocks navigation when isLoading is true', () => {
+ it('blocks navigation when UI interaction is locked', () => {
mockUseSpendingLimit.mockReturnValue({
...getDefaultUseSpendingLimitMock(),
isLoading: true,
+ isUiInteractionLocked: true,
});
render();
@@ -785,6 +787,42 @@ describe('SpendingLimit Component', () => {
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
+ it('allows navigation when Money Account linkage is processing outside onboarding', () => {
+ mockUseSpendingLimit.mockReturnValue({
+ ...getDefaultUseSpendingLimitMock(),
+ isLoading: true,
+ isMoneyAccountSource: true,
+ isUiInteractionLocked: false,
+ });
+
+ render();
+
+ const mockEvent = { preventDefault: jest.fn() };
+ const beforeRemoveCallback = mockAddListener.mock.calls[0][1];
+
+ beforeRemoveCallback(mockEvent);
+
+ expect(mockEvent.preventDefault).not.toHaveBeenCalled();
+ });
+
+ it('blocks navigation when Money Account linkage is processing during onboarding', () => {
+ mockUseSpendingLimit.mockReturnValue({
+ ...getDefaultUseSpendingLimitMock(),
+ isLoading: true,
+ isMoneyAccountSource: true,
+ isUiInteractionLocked: true,
+ });
+
+ render({ params: { flow: 'onboarding' } });
+
+ const mockEvent = { preventDefault: jest.fn() };
+ const beforeRemoveCallback = mockAddListener.mock.calls[0][1];
+
+ beforeRemoveCallback(mockEvent);
+
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
+ });
+
it('allows navigation when isLoading is false', () => {
render();
@@ -853,12 +891,28 @@ describe('SpendingLimit Component', () => {
...getDefaultUseSpendingLimitMock(),
isLoading: true,
isMoneyAccountSource: true,
+ isUiInteractionLocked: false,
});
render();
expect(screen.queryByTestId('button-loading-indicator')).toBeNull();
});
+
+ it('disables cancel when Money Account linkage is processing outside onboarding', () => {
+ mockUseSpendingLimit.mockReturnValue({
+ ...getDefaultUseSpendingLimitMock(),
+ isLoading: true,
+ isMoneyAccountSource: true,
+ isUiInteractionLocked: false,
+ });
+
+ render();
+
+ const cancelButton = screen.getByText('Cancel');
+
+ expect(cancelButton).toBeDisabled();
+ });
});
describe('Onboarding Flow', () => {
diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
index 46a9b8dec4e..54de6a975e8 100644
--- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
+++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
@@ -108,6 +108,7 @@ const SpendingLimit: React.FC = ({ route }) => {
limitType,
customLimit,
isLoading,
+ isUiInteractionLocked,
handleAccountSelect,
handleOtherSelect,
handleLimitSelect,
@@ -129,14 +130,14 @@ const SpendingLimit: React.FC = ({ route }) => {
routeParams: route?.params as Record | undefined,
});
- const isLoadingRef = useRef(isLoading);
+ const isUiInteractionLockedRef = useRef(isUiInteractionLocked);
useEffect(() => {
- isLoadingRef.current = isLoading;
- }, [isLoading]);
+ isUiInteractionLockedRef.current = isUiInteractionLocked;
+ }, [isUiInteractionLocked]);
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
- if (!isLoadingRef.current) return;
+ if (!isUiInteractionLockedRef.current) return;
e.preventDefault();
});
return unsubscribe;
diff --git a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx
index af988c2aaac..8691e1a58fc 100644
--- a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx
+++ b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx
@@ -9,6 +9,7 @@ import {
} from '@metamask/design-system-react-native';
import { CardMessageBoxType, CardMessageBoxVariant } from '../../types';
import { strings } from '../../../../../../locales/i18n';
+import { FLAT_BANNER_ALERT_STYLE } from '../../../shared/flatBannerAlertStyle';
interface CardMessageBoxProps {
messageType: CardMessageBoxType;
@@ -84,6 +85,7 @@ const CardMessageBox = ({
severity={SEVERITY_MAP[config.variant]}
title={config.title}
description={config.description}
+ style={FLAT_BANNER_ALERT_STYLE}
testID="card-message-box"
>
{(onConfirm || onDismiss) && (
diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
index 8a8b9655343..8cb4ff321d0 100644
--- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
+++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
@@ -278,6 +278,7 @@ describe('useMoneyAccountCardLinkage', () => {
expect(result.current.canLink).toBe(true);
expect(result.current.hasMoneyAccountRequirements).toBe(true);
+ expect(result.current.hasMoneyAccountBaseRequirements).toBe(true);
expect(result.current.isCardAuthenticated).toBe(true);
expect(result.current.isCardLinkedToMoneyAccount).toBe(false);
expect(result.current.moneyAccountCardToken).toBe(MOCK_TOKEN);
@@ -353,9 +354,37 @@ describe('useMoneyAccountCardLinkage', () => {
const { result } = renderLinkageHook();
expect(result.current.canLink).toBe(false);
expect(result.current.hasMoneyAccountRequirements).toBe(false);
+ expect(result.current.hasMoneyAccountBaseRequirements).toBe(true);
expect(result.current.moneyAccountCardToken).toBeNull();
});
+ it('reports hasMoneyAccountBaseRequirements=true when VEDA is not allowlisted but base requirements are met', () => {
+ applySelectorMocks(
+ buildSelectors({
+ cardFeatureFlag: {
+ chains: {
+ 'eip155:143': {
+ enabled: true,
+ tokens: [
+ {
+ address: '0xusdc',
+ symbol: 'USDC',
+ decimals: 6,
+ enabled: true,
+ name: 'USD Coin',
+ },
+ ],
+ },
+ },
+ },
+ }),
+ );
+ const { result } = renderLinkageHook();
+ expect(result.current.hasMoneyAccountBaseRequirements).toBe(true);
+ expect(result.current.hasMoneyAccountRequirements).toBe(false);
+ expect(result.current.canLink).toBe(false);
+ });
+
it('reports canLink=true when VEDA is allowlisted by address under the mUSD display symbol', () => {
applySelectorMocks(
buildSelectors({
@@ -380,6 +409,7 @@ describe('useMoneyAccountCardLinkage', () => {
const { result } = renderLinkageHook();
expect(result.current.canLink).toBe(true);
expect(result.current.hasMoneyAccountRequirements).toBe(true);
+ expect(result.current.hasMoneyAccountBaseRequirements).toBe(true);
expect(result.current.moneyAccountCardToken).toBe(MOCK_TOKEN);
});
@@ -700,6 +730,50 @@ describe('useMoneyAccountCardLinkage', () => {
expect(mockShowToast).not.toHaveBeenCalled();
});
+ it('routes an unauthenticated cardholder to CardAuthentication when base requirements are met but VEDA is not allowlisted', () => {
+ applySelectorMocks(
+ buildSelectors({
+ isCardAuthenticated: false,
+ isCardholder: true,
+ cardFeatureFlag: {
+ chains: {
+ 'eip155:143': {
+ enabled: true,
+ tokens: [
+ {
+ address: '0xusdc',
+ symbol: 'USDC',
+ decimals: 6,
+ enabled: true,
+ name: 'USD Coin',
+ },
+ ],
+ },
+ },
+ },
+ }),
+ );
+ const { result } = renderLinkageHook();
+
+ act(() => {
+ result.current.startLinkFlow(ORIGIN);
+ });
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setPendingMoneyAccountCardLink(CardEntryPoint.MONEY_LINK_CARD_SHEET),
+ );
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.HOME,
+ params: {
+ screen: Routes.CARD.AUTHENTICATION,
+ params: { postAuthRedirect: ORIGIN, showAuthPrompt: true },
+ },
+ });
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
it('stores the origin entrypoint for the post-auth sheet resume', () => {
applySelectorMocks(
buildSelectors({ isCardAuthenticated: false, isCardholder: true }),
@@ -926,6 +1000,74 @@ describe('useMoneyAccountCardLinkage', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});
+ it.each(['idle', 'loading'] as const)(
+ 'keeps the pending flag while VEDA support is unresolved and card home data is still %s',
+ (cardHomeDataStatus) => {
+ applySelectorMocks(
+ buildSelectors({
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ vedaConfig: undefined,
+ cardHomeDataStatus,
+ }),
+ );
+ renderLinkageHook();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ expect(mockShowToast).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each(['success', 'error'] as const)(
+ 'clears the flag silently once card home data has %s but VEDA support is unresolved',
+ (cardHomeDataStatus) => {
+ applySelectorMocks(
+ buildSelectors({
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ vedaConfig: undefined,
+ cardHomeDataStatus,
+ }),
+ );
+ renderLinkageHook();
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setPendingMoneyAccountCardLink(null),
+ );
+ expect(mockNavigate).not.toHaveBeenCalled();
+ expect(mockShowToast).not.toHaveBeenCalled();
+ },
+ );
+
+ it('opens the sheet on rerender once VEDA support resolves after card home data succeeds', () => {
+ applySelectorMocks(
+ buildSelectors({
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ vedaConfig: undefined,
+ cardHomeDataStatus: 'loading',
+ }),
+ );
+ const { rerender } = renderLinkageHook();
+
+ // First render: VEDA config not yet loaded, flag must stay set.
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+
+ applySelectorMocks(
+ buildSelectors({
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ cardHomeDataStatus: 'success',
+ }),
+ );
+ rerender();
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setPendingMoneyAccountCardLink(null),
+ );
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, {
+ ...expectedLinkCardSheetRoute(),
+ });
+ });
+
it('keeps the flag set when the funding token is null but card home data is still loading', () => {
mockResolveMoneyAccountCardToken.mockReturnValue(null);
applySelectorMocks(
diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
index 579b21614d0..118172e1271 100644
--- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
+++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
@@ -80,6 +80,7 @@ export interface LinkFlowOrigin {
export interface UseMoneyAccountCardLinkageReturn {
hasMoneyAccountRequirements: boolean;
+ hasMoneyAccountBaseRequirements: boolean;
isCardAuthenticated: boolean;
isCardVerified: boolean;
isCardLinkedToMoneyAccount: boolean;
@@ -159,12 +160,14 @@ export const useMoneyAccountCardLinkage =
? rawMoneyAccountCardToken
: null;
+ const hasMoneyAccountBaseRequirements = hasMoneyAccountCardRequirements({
+ isMoneyAccountEnabled,
+ vaultConfig,
+ moneyAccountAddress: primaryMoneyAccount?.address,
+ });
+
const hasRequirements =
- hasMoneyAccountCardRequirements({
- isMoneyAccountEnabled,
- vaultConfig,
- moneyAccountAddress: primaryMoneyAccount?.address,
- }) && isMoneyAccountCardSupported;
+ hasMoneyAccountBaseRequirements && isMoneyAccountCardSupported;
const canSubmitDelegation = Boolean(
hasRequirements &&
@@ -323,7 +326,7 @@ export const useMoneyAccountCardLinkage =
if (linkInProgress) {
return;
}
- if (!hasRequirements || !primaryMoneyAccount?.address) {
+ if (!hasMoneyAccountBaseRequirements || !primaryMoneyAccount?.address) {
showErrorToast();
return;
}
@@ -384,7 +387,7 @@ export const useMoneyAccountCardLinkage =
},
[
linkInProgress,
- hasRequirements,
+ hasMoneyAccountBaseRequirements,
moneyAccountCardToken,
primaryMoneyAccount?.address,
isCardAuthenticated,
@@ -414,11 +417,21 @@ export const useMoneyAccountCardLinkage =
return;
}
- if (!hasRequirements || !primaryMoneyAccount?.address) {
+ if (!hasMoneyAccountBaseRequirements || !primaryMoneyAccount?.address) {
dispatch(setPendingMoneyAccountCardLink(null));
return;
}
+ if (!isMoneyAccountCardSupported) {
+ if (
+ cardHomeDataStatus === 'success' ||
+ cardHomeDataStatus === 'error'
+ ) {
+ dispatch(setPendingMoneyAccountCardLink(null));
+ }
+ return;
+ }
+
if (isResidencyBlocked) {
trackMoneyAccountLinkingEvent(
MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED,
@@ -454,7 +467,8 @@ export const useMoneyAccountCardLinkage =
pendingMoneyAccountCardLinkEntryPoint,
isCardAuthenticated,
isCardVerified,
- hasRequirements,
+ hasMoneyAccountBaseRequirements,
+ isMoneyAccountCardSupported,
moneyAccountCardToken,
primaryMoneyAccount?.address,
isAlreadyDelegated,
@@ -598,6 +612,7 @@ export const useMoneyAccountCardLinkage =
return {
hasMoneyAccountRequirements: hasRequirements,
+ hasMoneyAccountBaseRequirements,
isCardAuthenticated,
isCardVerified,
isCardLinkedToMoneyAccount: isAlreadyDelegated,
diff --git a/app/components/UI/Card/hooks/useSpendingLimit.test.ts b/app/components/UI/Card/hooks/useSpendingLimit.test.ts
index 541935e0586..7f56f1f7e5b 100644
--- a/app/components/UI/Card/hooks/useSpendingLimit.test.ts
+++ b/app/components/UI/Card/hooks/useSpendingLimit.test.ts
@@ -258,6 +258,7 @@ describe('useSpendingLimit', () => {
goBack: jest.Mock;
dispatch: jest.Mock;
setParams: jest.Mock;
+ isFocused: jest.Mock;
};
let mockSubmitDelegation: jest.Mock;
let mockTrackEvent: jest.Mock;
@@ -279,6 +280,7 @@ describe('useSpendingLimit', () => {
goBack: jest.fn(),
dispatch: jest.fn(),
setParams: jest.fn(),
+ isFocused: jest.fn().mockReturnValue(true),
};
mockUseNavigation.mockReturnValue(mockNavigation as never);
@@ -985,6 +987,71 @@ describe('useSpendingLimit', () => {
expect(mockNavigation.goBack).not.toHaveBeenCalled();
});
+ it('does not navigate when Money Account linkage is processing', async () => {
+ const MONEY_ACCOUNT_TOKEN: CardFundingToken = {
+ address: '0xMonadUsdc',
+ symbol: 'USDC',
+ name: 'USDC',
+ decimals: 6,
+ caipChainId: 'eip155:143',
+ walletAddress: undefined,
+ fundingStatus: FundingStatus.NotEnabled,
+ spendableBalance: '0',
+ delegationContract: '0xMonadDelegation',
+ priority: undefined,
+ isMoneyAccountEntry: true,
+ };
+
+ mockUseMoneyAccountCardLinkage.mockReturnValue(
+ buildLinkageReturn({
+ hasMoneyAccountRequirements: true,
+ isCardAuthenticated: true,
+ moneyAccountCardToken: MONEY_ACCOUNT_TOKEN,
+ canLink: true,
+ }),
+ );
+ mockUseMoneyAccountBalance.mockReturnValue(
+ buildBalanceReturn({
+ tokenTotal: new BigNumber('12.34'),
+ totalFiatFormatted: '$12.34',
+ }),
+ );
+
+ let resolveLink: (value: boolean) => void = () => undefined;
+ mockConfirmLinkInBackground.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveLink = resolve;
+ }),
+ );
+
+ const priorityToken = createMockToken({
+ ...MONEY_ACCOUNT_TOKEN,
+ isMoneyAccountEntry: true,
+ });
+
+ const { result } = renderHook(() =>
+ useSpendingLimit(
+ createDefaultParams({ flow: 'manage', priorityToken }),
+ ),
+ );
+
+ act(() => {
+ result.current.submit().catch(() => undefined);
+ });
+
+ act(() => {
+ result.current.cancel();
+ });
+
+ expect(mockNavigation.goBack).not.toHaveBeenCalled();
+
+ await act(async () => {
+ resolveLink(true);
+ await jest.runAllTimersAsync();
+ });
+ });
+
it('tracks button click event', () => {
const { result } = renderHook(() =>
useSpendingLimit(createDefaultParams()),
@@ -1623,6 +1690,153 @@ describe('useSpendingLimit', () => {
});
});
+ describe('isUiInteractionLocked', () => {
+ it('returns false when not loading', () => {
+ const { result } = renderHook(() =>
+ useSpendingLimit(createDefaultParams()),
+ );
+
+ expect(result.current.isUiInteractionLocked).toBe(false);
+ });
+
+ it('returns true when delegation is loading for regular funding assets', () => {
+ mockUseCardDelegation.mockReturnValue({
+ submitDelegation: mockSubmitDelegation,
+ isLoading: true,
+ error: null,
+ needsFaucet: false,
+ isFaucetCheckLoading: false,
+ refetchFaucetCheck: jest.fn(),
+ });
+
+ const { result } = renderHook(() =>
+ useSpendingLimit(createDefaultParams()),
+ );
+
+ expect(result.current.isUiInteractionLocked).toBe(true);
+ });
+
+ it('returns false when Money Account linkage is processing outside onboarding', async () => {
+ const MONEY_ACCOUNT_TOKEN: CardFundingToken = {
+ address: '0xMonadUsdc',
+ symbol: 'USDC',
+ name: 'USDC',
+ decimals: 6,
+ caipChainId: 'eip155:143',
+ walletAddress: undefined,
+ fundingStatus: FundingStatus.NotEnabled,
+ spendableBalance: '0',
+ delegationContract: '0xMonadDelegation',
+ priority: undefined,
+ isMoneyAccountEntry: true,
+ };
+
+ mockUseMoneyAccountCardLinkage.mockReturnValue(
+ buildLinkageReturn({
+ hasMoneyAccountRequirements: true,
+ isCardAuthenticated: true,
+ moneyAccountCardToken: MONEY_ACCOUNT_TOKEN,
+ canLink: true,
+ }),
+ );
+ mockUseMoneyAccountBalance.mockReturnValue(
+ buildBalanceReturn({
+ tokenTotal: new BigNumber('12.34'),
+ totalFiatFormatted: '$12.34',
+ }),
+ );
+
+ let resolveLink: (value: boolean) => void = () => undefined;
+ mockConfirmLinkInBackground.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveLink = resolve;
+ }),
+ );
+
+ const priorityToken = createMockToken({
+ ...MONEY_ACCOUNT_TOKEN,
+ isMoneyAccountEntry: true,
+ });
+
+ const { result } = renderHook(() =>
+ useSpendingLimit(
+ createDefaultParams({ flow: 'manage', priorityToken }),
+ ),
+ );
+
+ expect(result.current.isMoneyAccountSource).toBe(true);
+
+ act(() => {
+ result.current.submit().catch(() => undefined);
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.isUiInteractionLocked).toBe(false);
+
+ await act(async () => {
+ resolveLink(true);
+ await jest.runAllTimersAsync();
+ });
+ });
+
+ it('returns true when Money Account linkage is processing during onboarding', async () => {
+ const MONEY_ACCOUNT_TOKEN: CardFundingToken = {
+ address: '0xMonadUsdc',
+ symbol: 'USDC',
+ name: 'USDC',
+ decimals: 6,
+ caipChainId: 'eip155:143',
+ walletAddress: undefined,
+ fundingStatus: FundingStatus.NotEnabled,
+ spendableBalance: '0',
+ delegationContract: '0xMonadDelegation',
+ priority: undefined,
+ };
+
+ mockUseMoneyAccountCardLinkage.mockReturnValue(
+ buildLinkageReturn({
+ hasMoneyAccountRequirements: true,
+ isCardAuthenticated: true,
+ moneyAccountCardToken: MONEY_ACCOUNT_TOKEN,
+ canLink: true,
+ }),
+ );
+ mockUseMoneyAccountBalance.mockReturnValue(
+ buildBalanceReturn({
+ tokenTotal: new BigNumber('12.34'),
+ totalFiatFormatted: '$12.34',
+ }),
+ );
+
+ let resolveLink: (value: boolean) => void = () => undefined;
+ mockConfirmLinkInBackground.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveLink = resolve;
+ }),
+ );
+
+ const { result } = renderHook(() =>
+ useSpendingLimit(createDefaultParams({ flow: 'onboarding' })),
+ );
+
+ expect(result.current.isMoneyAccountSource).toBe(true);
+
+ act(() => {
+ result.current.submit().catch(() => undefined);
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.isUiInteractionLocked).toBe(true);
+
+ await act(async () => {
+ resolveLink(true);
+ await jest.runAllTimersAsync();
+ });
+ });
+ });
+
describe('Money Account source (onboarding flow)', () => {
const MONEY_ACCOUNT_TOKEN: CardFundingToken = {
address: '0xMonadUsdc',
diff --git a/app/components/UI/Card/hooks/useSpendingLimit.ts b/app/components/UI/Card/hooks/useSpendingLimit.ts
index cda3f8664ca..6fe259614b6 100644
--- a/app/components/UI/Card/hooks/useSpendingLimit.ts
+++ b/app/components/UI/Card/hooks/useSpendingLimit.ts
@@ -74,6 +74,7 @@ export interface UseSpendingLimitReturn {
limitType: LimitType;
customLimit: string;
isLoading: boolean;
+ isUiInteractionLocked: boolean;
// Handlers
setSelectedToken: (token: CardFundingToken | null) => void;
@@ -217,6 +218,8 @@ const useSpendingLimit = ({
} = useCardDelegation(selectedToken);
const isLoading = isDelegationLoading || isProcessing;
+ const isUiInteractionLocked =
+ isLoading && (!isMoneyAccountSource || isOnboardingFlow);
// Wallet-only token balances for the currently selected MetaMask account.
// Using this (instead of useAssetBalances) ensures sorting reflects the active
@@ -620,7 +623,7 @@ const useSpendingLimit = ({
setTimeout(() => {
if (isOnboardingFlow) {
navigateToCardHome();
- } else {
+ } else if (navigation.isFocused()) {
navigation.goBack();
}
}, 0);
@@ -742,6 +745,7 @@ const useSpendingLimit = ({
limitType,
customLimit,
isLoading,
+ isUiInteractionLocked,
// Handlers
setSelectedToken,
diff --git a/app/components/UI/Carousel/constants.ts b/app/components/UI/Carousel/constants.ts
index 37c6eb2743c..79dc5ac4cac 100644
--- a/app/components/UI/Carousel/constants.ts
+++ b/app/components/UI/Carousel/constants.ts
@@ -1,34 +1,8 @@
import { strings } from '../../../../locales/i18n';
import { CarouselSlide } from './types';
import { createBuyNavigationDetails } from '../Ramp/Aggregator/routes/utils';
-import Routes from '../../../constants/navigation/Routes';
-///: BEGIN:ONLY_INCLUDE_IF(solana)
-import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient';
-import { SolScope } from '@metamask/keyring-api';
-///: END:ONLY_INCLUDE_IF
export const PREDEFINED_SLIDES: CarouselSlide[] = [
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- {
- id: 'solana',
- title: strings('banner.solana.title'),
- description: strings('banner.solana.subtitle'),
- undismissable: false,
- navigation: {
- type: 'function',
- navigate: () => [
- Routes.MODAL.ROOT_MODAL_FLOW,
- {
- screen: Routes.SHEET.ADD_ACCOUNT,
- params: {
- clientType: WalletClientType.Solana,
- scope: SolScope.Mainnet,
- },
- },
- ],
- },
- },
- ///: END:ONLY_INCLUDE_IF
{
id: 'fund',
title: strings('banner.fund.title'),
diff --git a/app/components/UI/Carousel/index.test.tsx b/app/components/UI/Carousel/index.test.tsx
index 5d697779d6b..ddd8e594089 100644
--- a/app/components/UI/Carousel/index.test.tsx
+++ b/app/components/UI/Carousel/index.test.tsx
@@ -12,16 +12,11 @@ import AppConstants from '../../../core/AppConstants';
import Carousel, { useFetchCarouselSlides } from './';
import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds';
import { backgroundState } from '../../../util/test/initial-root-state';
-import Engine from '../../../core/Engine';
import { fetchCarouselSlidesFromContentful } from './fetchCarouselSlidesFromContentful';
import { CarouselSlide } from './types';
// eslint-disable-next-line import-x/no-namespace
import * as FeatureFlagSelectorsModule from './selectors/featureFlags';
import { RootState } from '../../../reducers';
-import { selectLastSelectedSolanaAccount } from '../../../selectors/accountsController';
-import Routes from '../../../constants/navigation/Routes';
-import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient';
-import { SolScope } from '@metamask/keyring-api';
import { setContentPreviewToken } from '../../../actions/notification/helpers';
import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock';
import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics';
@@ -37,14 +32,6 @@ const makeMockState = () =>
engine: {
backgroundState: {
...backgroundState,
- AccountsController: {
- internalAccounts: {
- selectedAccount: '1',
- accounts: {
- '1': { address: '0xSomeAddress' },
- },
- },
- },
},
},
settings: { showFiatOnTestnets: false },
@@ -62,11 +49,6 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: mockNavigate }),
}));
-jest.mock('../../../core/Engine', () => ({
- setSelectedAddress: jest.fn(),
- context: { PreferencesController: { state: {} } },
-}));
-
jest.mock('../../../components/hooks/useAnalytics/useAnalytics');
jest.mock('../../../core/DeeplinkManager/DeeplinkManager', () => {
@@ -445,56 +427,6 @@ describe('Carousel Slide Dismissal', () => {
});
});
-describe('Carousel Solana Integration', () => {
- const setupSolanaTests = (hasSolanaAccount: boolean = false) => {
- const mockState = makeMockState();
- jest.mocked(useSelector).mockImplementation((selector) => {
- if (selector === selectLastSelectedSolanaAccount) {
- return hasSolanaAccount ? { address: 'SolanaAddress123' } : null;
- }
- return selector(mockState);
- });
- };
-
- const arrangeActTestSolanaCarouselClick = async (
- props = { hasSolanaAccount: true },
- ) => {
- setupSolanaTests(props.hasSolanaAccount);
- const solanaSlide = createMockSlide({
- id: 'solana',
- variableName: 'solana',
- });
- mockFetchCarouselSlides.mockResolvedValue({
- prioritySlides: [],
- regularSlides: [solanaSlide],
- });
-
- const { findByTestId } = render();
- const slide = await findByTestId('carousel-slide-solana');
- expect(slide).toBeVisible();
- fireEvent.press(slide);
- };
-
- it('switches to existing Solana account when clicked', async () => {
- await arrangeActTestSolanaCarouselClick({ hasSolanaAccount: true });
- expect(Engine.setSelectedAddress).toHaveBeenCalledWith('SolanaAddress123');
- });
-
- it('navigates to add account flow when no existing Solana account', async () => {
- await arrangeActTestSolanaCarouselClick({ hasSolanaAccount: false }); // no solana account
-
- // Should navigate to add account flow instead of switching accounts
- expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, {
- screen: Routes.SHEET.ADD_ACCOUNT,
- params: {
- clientType: WalletClientType.Solana,
- scope: SolScope.Mainnet,
- },
- });
- expect(Engine.setSelectedAddress).not.toHaveBeenCalled();
- });
-});
-
describe('Carousel UI Behavior', () => {
it('renders stack-based carousel with current slide', async () => {
const slides = [
diff --git a/app/components/UI/Carousel/index.tsx b/app/components/UI/Carousel/index.tsx
index 617d3e316bc..87e3343fc92 100644
--- a/app/components/UI/Carousel/index.tsx
+++ b/app/components/UI/Carousel/index.tsx
@@ -24,15 +24,6 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics';
import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds';
import { selectDismissedBanners } from '../../../selectors/banner';
-///: BEGIN:ONLY_INCLUDE_IF(solana)
-import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient';
-import {
- selectSelectedInternalAccount,
- selectLastSelectedSolanaAccount,
-} from '../../../selectors/accountsController';
-import { SolAccountType, SolScope } from '@metamask/keyring-api';
-import Engine from '../../../core/Engine';
-///: END:ONLY_INCLUDE_IF
import { selectAddressHasTokenBalances } from '../../../selectors/tokenBalancesController';
import {
fetchCarouselSlidesFromContentful,
@@ -40,7 +31,6 @@ import {
} from './fetchCarouselSlidesFromContentful';
import { selectContentfulCarouselEnabledFlag } from './selectors/featureFlags';
import { createBuyNavigationDetails } from '../Ramp/Aggregator/routes/utils';
-import Routes from '../../../constants/navigation/Routes';
import { subscribeToContentPreviewToken } from '../../../actions/notification/helpers';
import { BANNER_EVENT_DISPLAY } from '../../../constants/engagement';
import SharedDeeplinkManager from '../../../core/DeeplinkManager/DeeplinkManager';
@@ -187,13 +177,6 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => {
const { navigate } = useNavigation();
const tw = useTailwind();
const dismissedBanners = useSelector(selectDismissedBanners);
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- const selectedAccount = useSelector(selectSelectedInternalAccount);
- const lastSelectedSolanaAccount = useSelector(
- selectLastSelectedSolanaAccount,
- );
- ///: END:ONLY_INCLUDE_IF
-
const isZeroBalance = !hasBalance;
const applyLocalNavigation = useCallback(
@@ -210,28 +193,6 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => {
},
};
}
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- // solana → open add-account flow (if we don't already redirect below)
- if (variableName === 'solana') {
- return {
- ...s,
- navigation: {
- type: 'function',
- navigate: () =>
- [
- Routes.MODAL.ROOT_MODAL_FLOW,
- {
- screen: Routes.SHEET.ADD_ACCOUNT,
- params: {
- clientType: WalletClientType.Solana,
- scope: SolScope.Mainnet,
- },
- },
- ] as const,
- },
- };
- }
- ///: END:ONLY_INCLUDE_IF
return s; // keep Contentful linkUrl for everything else
},
[],
@@ -290,15 +251,6 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => {
const active = isActive(slide);
if (!active) return false;
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- if (
- getSlideVariableName(slide) === 'solana' &&
- selectedAccount?.type === SolAccountType.DataAccount
- ) {
- return false;
- }
- ///: END:ONLY_INCLUDE_IF
-
return !dismissedBanners.includes(slide.id);
});
@@ -313,13 +265,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => {
}
return filtered.slice(0, MAX_CAROUSEL_SLIDES);
- }, [
- slidesConfig,
- dismissedBanners,
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- selectedAccount,
- ///: END:ONLY_INCLUDE_IF
- ]);
+ }, [slidesConfig, dismissedBanners]);
// Ensure activeSlideIndex is within bounds after filtering
const safeActiveSlideIndex = Math.min(
@@ -407,16 +353,6 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => {
(slide: CarouselSlide) => {
const slideName = getSlideAnalyticsName(slide);
const { navigation } = slide;
- const extraProperties: Record = {};
-
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- const isSolanaBanner = slideName === 'solana';
- if (isSolanaBanner && lastSelectedSolanaAccount) {
- extraProperties.action = 'redirect-solana-account';
- } else if (isSolanaBanner && !lastSelectedSolanaAccount) {
- extraProperties.action = 'create-solana-account';
- }
- ///: END:ONLY_INCLUDE_IF
trackEvent(
createEventBuilder({
@@ -424,17 +360,10 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => {
})
.addProperties({
name: slideName,
- ...extraProperties,
})
.build(),
);
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- if (isSolanaBanner && lastSelectedSolanaAccount) {
- return Engine.setSelectedAddress(lastSelectedSolanaAccount.address);
- }
- ///: END:ONLY_INCLUDE_IF
-
if (navigation.type === 'url') {
return openUrl(navigation.href)();
}
@@ -447,14 +376,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => {
return navigate(navigation.route);
}
},
- [
- trackEvent,
- createEventBuilder,
- navigate,
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- lastSelectedSolanaAccount,
- ///: END:ONLY_INCLUDE_IF
- ],
+ [trackEvent, createEventBuilder, navigate],
);
const handleTransitionToNextCard = useCallback(
diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
index 240fd847d49..026e06f014d 100644
--- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
+++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { act, fireEvent, within } from '@testing-library/react-native';
import { Linking } from 'react-native';
+import type { ReactTestInstance } from 'react-test-renderer';
import BigNumber from 'bignumber.js';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
import MoneyHomeView from './MoneyHomeView';
@@ -172,6 +173,7 @@ jest.mock('../../../Card/hooks/useMoneyAccountCardLinkage', () => ({
__esModule: true,
useMoneyAccountCardLinkage: jest.fn(() => ({
hasMoneyAccountRequirements: false,
+ hasMoneyAccountBaseRequirements: false,
isCardAuthenticated: false,
isCardVerified: false,
isCardLinkedToMoneyAccount: false,
@@ -319,6 +321,34 @@ jest.mock('../../../../../util/Logger', () => ({
default: { error: jest.fn() },
}));
+const collectTestIdsInTreeOrder = (
+ node: ReactTestInstance,
+ testIds: string[] = [],
+): string[] => {
+ if (typeof node.props?.testID === 'string') {
+ testIds.push(node.props.testID);
+ }
+
+ node.children.forEach((child) => {
+ if (typeof child === 'object' && child !== null && 'props' in child) {
+ collectTestIdsInTreeOrder(child as ReactTestInstance, testIds);
+ }
+ });
+
+ return testIds;
+};
+
+const expectTestIdBefore = (
+ root: ReactTestInstance,
+ earlierTestId: string,
+ laterTestId: string,
+) => {
+ const order = collectTestIdsInTreeOrder(root);
+ expect(order.indexOf(earlierTestId)).toBeGreaterThanOrEqual(0);
+ expect(order.indexOf(laterTestId)).toBeGreaterThanOrEqual(0);
+ expect(order.indexOf(earlierTestId)).toBeLessThan(order.indexOf(laterTestId));
+};
+
describe('MoneyHomeView', () => {
let defaultMoneyAccountBalance: ReturnType;
@@ -343,6 +373,7 @@ describe('MoneyHomeView', () => {
mockStartLinkFlow.mockReset();
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: false,
+ hasMoneyAccountBaseRequirements: false,
isCardAuthenticated: false,
isCardVerified: false,
isCardLinkedToMoneyAccount: false,
@@ -995,6 +1026,7 @@ describe('MoneyHomeView', () => {
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
+ animation: 'slide_from_bottom',
});
});
@@ -1076,6 +1108,7 @@ describe('MoneyHomeView', () => {
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
+ animation: 'slide_from_bottom',
});
});
@@ -1372,6 +1405,7 @@ describe('MoneyHomeView', () => {
// link CTA is offered.
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
isCardAuthenticated: false,
isCardLinkedToMoneyAccount: false,
primaryMoneyAccount: { address: '0xabc' },
@@ -1462,8 +1496,7 @@ describe('MoneyHomeView', () => {
expect(mockStartLinkFlow).toHaveBeenCalledTimes(1);
expect(mockStartLinkFlow).toHaveBeenCalledWith({
- screen: Routes.MONEY.ROOT,
- params: { screen: Routes.MONEY.HOME },
+ ...MONEY_HOME_CARD_ORIGIN,
entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
});
expect(mockNavigate).not.toHaveBeenCalledWith(Routes.CARD.ROOT, {
@@ -1476,6 +1509,7 @@ describe('MoneyHomeView', () => {
beforeEach(() => {
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
isCardAuthenticated: true,
isCardVerified: true,
isCardLinkedToMoneyAccount: false,
@@ -1500,8 +1534,7 @@ describe('MoneyHomeView', () => {
expect(mockStartLinkFlow).toHaveBeenCalledTimes(1);
expect(mockStartLinkFlow).toHaveBeenCalledWith({
- screen: Routes.MONEY.ROOT,
- params: { screen: Routes.MONEY.HOME },
+ ...MONEY_HOME_CARD_ORIGIN,
entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
});
expect(mockNavigate).not.toHaveBeenCalledWith(Routes.CARD.ROOT, {
@@ -1862,6 +1895,7 @@ describe('MoneyHomeView', () => {
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
+ animation: 'slide_from_bottom',
});
});
});
@@ -1871,6 +1905,7 @@ describe('MoneyHomeView', () => {
mockSelectIsCardholder.mockReturnValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: false,
+ hasMoneyAccountBaseRequirements: false,
isCardAuthenticated: false,
isCardLinkedToMoneyAccount: true,
primaryMoneyAccount: undefined,
@@ -1895,6 +1930,7 @@ describe('MoneyHomeView', () => {
mockSelectIsCardholder.mockReturnValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
isCardAuthenticated: false,
isCardLinkedToMoneyAccount: false,
primaryMoneyAccount: { address: '0xabc' },
@@ -1915,10 +1951,40 @@ describe('MoneyHomeView', () => {
).toBeOnTheScreen();
});
+ it('selects mode="link" for unauthenticated cardholder when base requirements are met but VEDA is unavailable', () => {
+ mockSelectIsCardholder.mockReturnValue(true);
+ mockUseMoneyAccountCardLinkage.mockReturnValue({
+ hasMoneyAccountRequirements: false,
+ hasMoneyAccountBaseRequirements: true,
+ isCardAuthenticated: false,
+ isCardVerified: false,
+ isCardLinkedToMoneyAccount: false,
+ primaryMoneyAccount: { address: '0xabc' },
+ moneyAccountCardToken: null,
+ canLink: false,
+ status: 'idle',
+ isLinking: false,
+ error: null,
+ startLinkFlow: mockStartLinkFlow,
+ openLinkCardSheet: mockOpenLinkCardSheet,
+ reset: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByTestId } = renderWithProvider();
+
+ expect(
+ getByTestId(MoneyMetaMaskCardTestIds.LINK_CONTAINER),
+ ).toBeOnTheScreen();
+ expect(
+ getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON),
+ ).toBeOnTheScreen();
+ });
+
it('hides the card section when cardholder but VEDA is not allowlisted (cannot link)', () => {
mockSelectIsCardholder.mockReturnValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: false,
+ hasMoneyAccountBaseRequirements: false,
isCardAuthenticated: true,
isCardLinkedToMoneyAccount: false,
primaryMoneyAccount: { address: '0xabc' },
@@ -2001,6 +2067,7 @@ describe('MoneyHomeView', () => {
mockSelectIsCardholder.mockReturnValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
isCardAuthenticated: false,
isCardLinkedToMoneyAccount: false,
primaryMoneyAccount: { address: '0xabc' },
@@ -2039,10 +2106,62 @@ describe('MoneyHomeView', () => {
).toBeOnTheScreen();
});
+ it('renders upsell MetaMask Card below Activity and Earn on your crypto', () => {
+ mockSelectIsCardholder.mockReturnValue(false);
+
+ const { getByTestId } = renderWithProvider();
+ const scrollRoot = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW);
+
+ expectTestIdBefore(
+ scrollRoot,
+ MoneyActivityListTestIds.CONTAINER,
+ MoneyMetaMaskCardTestIds.CONTAINER,
+ );
+ expectTestIdBefore(
+ scrollRoot,
+ MoneyPotentialEarningsTestIds.CONTAINER,
+ MoneyMetaMaskCardTestIds.CONTAINER,
+ );
+ });
+
+ it('renders link MetaMask Card above Activity and Earn on your crypto', () => {
+ mockSelectIsCardholder.mockReturnValue(true);
+ mockUseMoneyAccountCardLinkage.mockReturnValue({
+ hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
+ isCardAuthenticated: false,
+ isCardLinkedToMoneyAccount: false,
+ primaryMoneyAccount: { address: '0xabc' },
+ moneyAccountCardToken: { symbol: 'veda' },
+ canLink: false,
+ status: 'idle',
+ isLinking: false,
+ error: null,
+ startLinkFlow: mockStartLinkFlow,
+ openLinkCardSheet: mockOpenLinkCardSheet,
+ reset: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByTestId } = renderWithProvider();
+ const scrollRoot = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW);
+
+ expectTestIdBefore(
+ scrollRoot,
+ MoneyMetaMaskCardTestIds.CONTAINER,
+ MoneyActivityListTestIds.CONTAINER,
+ );
+ expectTestIdBefore(
+ scrollRoot,
+ MoneyMetaMaskCardTestIds.CONTAINER,
+ MoneyPotentialEarningsTestIds.CONTAINER,
+ );
+ });
+
it('selects mode="link" when card-authenticated and VERIFIED even if selected wallet is not a cardholder account', () => {
mockSelectIsCardholder.mockReturnValue(false);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
isCardAuthenticated: true,
isCardVerified: true,
isCardLinkedToMoneyAccount: false,
@@ -2064,10 +2183,11 @@ describe('MoneyHomeView', () => {
).toBeOnTheScreen();
});
- it('hides the MetaMask Card section when authenticated but not VERIFIED', () => {
+ it('shows the MetaMask Card section with verification banner when authenticated but not VERIFIED', () => {
mockSelectIsCardholder.mockReturnValue(false);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
isCardAuthenticated: true,
isCardVerified: false,
isCardLinkedToMoneyAccount: false,
@@ -2082,17 +2202,22 @@ describe('MoneyHomeView', () => {
reset: jest.fn(),
} as unknown as ReturnType);
- const { queryByTestId } = renderWithProvider();
+ const { getByTestId, getByText } = renderWithProvider();
+ expect(getByTestId(MoneyMetaMaskCardTestIds.CONTAINER)).toBeOnTheScreen();
expect(
- queryByTestId(MoneyMetaMaskCardTestIds.CONTAINER),
- ).not.toBeOnTheScreen();
+ getByTestId(MoneyMetaMaskCardTestIds.VERIFYING_BANNER),
+ ).toBeOnTheScreen();
+ expect(
+ getByText(strings('money.metamask_card.verification_pending')),
+ ).toBeOnTheScreen();
});
it('disables the link button when linkage is in progress', () => {
mockSelectIsCardholder.mockReturnValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
isCardAuthenticated: false,
isCardLinkedToMoneyAccount: false,
primaryMoneyAccount: { address: '0xabc' },
@@ -2118,6 +2243,7 @@ describe('MoneyHomeView', () => {
mockSelectIsCardholder.mockReturnValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: false,
+ hasMoneyAccountBaseRequirements: false,
isCardAuthenticated: false,
isCardLinkedToMoneyAccount: true,
primaryMoneyAccount: undefined,
@@ -2138,6 +2264,7 @@ describe('MoneyHomeView', () => {
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
+ animation: 'slide_from_bottom',
});
});
});
@@ -2168,6 +2295,7 @@ describe('MoneyHomeView', () => {
mockSelectIsCardholder.mockReturnValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: false,
+ hasMoneyAccountBaseRequirements: false,
isCardAuthenticated: false,
isCardLinkedToMoneyAccount: true,
primaryMoneyAccount: undefined,
@@ -2200,6 +2328,7 @@ describe('MoneyHomeView', () => {
mockSelectIsCardholder.mockReturnValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: false,
+ hasMoneyAccountBaseRequirements: false,
isCardAuthenticated: false,
isCardLinkedToMoneyAccount: true,
primaryMoneyAccount: undefined,
@@ -2230,6 +2359,7 @@ describe('MoneyHomeView', () => {
mockSelectHasMetalCard.mockReturnValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
isCardAuthenticated: false,
isCardLinkedToMoneyAccount: false,
primaryMoneyAccount: { address: '0xabc' },
@@ -2258,6 +2388,7 @@ describe('MoneyHomeView', () => {
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
+ animation: 'slide_from_bottom',
});
});
});
diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx
index 366f37823bc..0378bea16d5 100644
--- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx
+++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx
@@ -38,6 +38,7 @@ import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance';
import { useMoneyAccountTransactions } from '../../hooks/useMoneyAccountTransactions';
import { useMoneyAccountCardTransactions } from '../../hooks/useMoneyAccountCardTransactions';
import { mergeMoneyActivity } from '../../hooks/useMoneyActivityItems';
+import { deriveMoneyMetaMaskCardMode } from '../../utils/moneyMetaMaskCardMode';
import MoneyActivityLoading from '../../components/MoneyActivityLoading/MoneyActivityLoading';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo';
@@ -166,6 +167,7 @@ const MoneyHomeView = () => {
isCardLinkedToMoneyAccount,
isLinking,
hasMoneyAccountRequirements,
+ hasMoneyAccountBaseRequirements,
isResidencyBlocked,
} = useMoneyAccountCardLinkage();
@@ -316,6 +318,7 @@ const MoneyHomeView = () => {
navigation.navigate(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
+ animation: 'slide_from_bottom',
});
}, [navigation]);
@@ -354,8 +357,7 @@ const MoneyHomeView = () => {
const handleLinkCardPress = useCallback(() => {
startLinkFlow({
- screen: Routes.MONEY.ROOT,
- params: { screen: Routes.MONEY.HOME },
+ ...MONEY_HOME_CARD_ORIGIN,
entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
});
}, [startLinkFlow]);
@@ -579,18 +581,15 @@ const MoneyHomeView = () => {
[navigation, trackActivitySurfaceClicked],
);
- let metamaskCardMode: 'upsell' | 'link' | 'manage' | null;
- if (isCardLinkedToMoneyAccount) {
- metamaskCardMode = 'manage';
- } else if (isResidencyBlocked) {
- metamaskCardMode = null;
- } else if (isCardholder || (isCardAuthenticated && isCardVerified)) {
- metamaskCardMode = hasMoneyAccountRequirements ? 'link' : null;
- } else if (isCardAuthenticated) {
- metamaskCardMode = null;
- } else {
- metamaskCardMode = 'upsell';
- }
+ const metamaskCardMode = deriveMoneyMetaMaskCardMode({
+ isCardLinkedToMoneyAccount,
+ isCardholder,
+ isCardAuthenticated,
+ isCardVerified,
+ isResidencyBlocked,
+ hasMoneyAccountBaseRequirements,
+ hasMoneyAccountRequirements,
+ });
const { primaryToken: cardPrimaryToken } = useCardHomeData();
const cardBalance = cardPrimaryToken?.balanceFiat ?? formattedZero;
@@ -602,6 +601,34 @@ const MoneyHomeView = () => {
const isCardAnalyticsReady =
cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error';
+ const metamaskCardSection = metamaskCardMode
+ ? {
+ key: 'metamask-card',
+ node: (
+
+ ),
+ }
+ : null;
+
+ const shouldShowMetaMaskCardEarly =
+ metamaskCardMode !== null && metamaskCardMode !== 'upsell';
+
const contentSections: { key: string; node: React.ReactNode }[] = [];
if (hasBalanceValue && isFunded) {
@@ -649,6 +676,10 @@ const MoneyHomeView = () => {
});
}
+ if (shouldShowMetaMaskCardEarly && metamaskCardSection) {
+ contentSections.push(metamaskCardSection);
+ }
+
if (showCardActivityLoading || activityItems.length >= 1) {
contentSections.push({
key: 'activity',
@@ -684,29 +715,8 @@ const MoneyHomeView = () => {
});
}
- if (metamaskCardMode) {
- contentSections.push({
- key: 'metamask-card',
- node: (
-
- ),
- });
+ if (!shouldShowMetaMaskCardEarly && metamaskCardSection) {
+ contentSections.push(metamaskCardSection);
}
if (isFunded) {
diff --git a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.test.tsx b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.test.tsx
index 4d5b3c91136..8813da2890d 100644
--- a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.test.tsx
+++ b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.test.tsx
@@ -6,6 +6,7 @@ import { MoneyHowItWorksViewTestIds } from './MoneyHowItWorksView.testIds';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics';
import { SCREEN_NAMES } from '../../constants/moneyEvents';
+import { strings } from '../../../../../../locales/i18n';
const mockTrackScreenViewed = jest.fn();
@@ -35,51 +36,6 @@ jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 48, bottom: 34, left: 0, right: 0 }),
}));
-jest.mock('../../../../../../locales/i18n', () => ({
- __esModule: true,
- default: { locale: 'en-US' },
- strings: (key: string) => {
- const map: Record = {
- 'money.how_it_works_page.header_title': 'Money',
- 'money.how_it_works_page.section_title': 'How it works',
- 'money.how_it_works_page.description_1':
- 'Add mUSD into your Money account and earn up to 4% APY (variable) automatically. Funds go into a DeFi vault, administered by Veda with risk monitoring by Steakhouse Financial. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols. No staking, no claiming, no lock-ups.',
- 'money.how_it_works_page.description_2':
- 'Your Money balance is your spending balance. Link your MetaMask Card to spend at 150M+ merchants worldwide. Your money keeps earning until the moment you use it.',
- 'money.how_it_works_page.description_3':
- 'Money account is powered by Monad.',
- 'money.how_it_works_page.faq_title': 'FAQs',
- 'money.how_it_works_page.faq_q1': 'What is a MetaMask Money Account?',
- 'money.how_it_works_page.faq_a1': 'Money Account answer 1',
- 'money.how_it_works_page.faq_q2': 'Is MetaMask Money Account safe?',
- 'money.how_it_works_page.faq_a2': 'Money Account answer 2',
- 'money.how_it_works_page.faq_q3':
- 'Who controls my MetaMask Money Account?',
- 'money.how_it_works_page.faq_a3': 'Money Account answer 3',
- 'money.how_it_works_page.faq_q4': 'What are MetaMask Money Account fees?',
- 'money.how_it_works_page.faq_a4': 'Money Account answer 4',
- 'money.how_it_works_page.faq_q5':
- 'How much can I earn with a MetaMask Money Account?',
- 'money.how_it_works_page.faq_a5': 'Money Account answer 5',
- 'money.how_it_works_page.faq_q6': 'Where does the yield come from?',
- 'money.how_it_works_page.faq_a6': 'Money Account answer 6',
- 'money.how_it_works_page.faq_q7':
- 'Which tokens can I deposit into the Money Account?',
- 'money.how_it_works_page.faq_a7': 'Money Account answer 7',
- 'money.how_it_works_page.faq_q8':
- 'Can I withdraw from the MetaMask Money Account anytime?',
- 'money.how_it_works_page.faq_a8': 'Money Account answer 8',
- 'money.how_it_works_page.faq_q9':
- 'Do I need to complete KYC to use MetaMask Money Account?',
- 'money.how_it_works_page.faq_a9': 'Money Account answer 9',
- 'money.how_it_works_page.faq_q10':
- 'Which countries is MetaMask Money Account available in?',
- 'money.how_it_works_page.faq_a10': 'Money Account answer 10',
- };
- return map[key] ?? key;
- },
-}));
-
describe('MoneyHowItWorksView', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -118,7 +74,9 @@ describe('MoneyHowItWorksView', () => {
expect(
getByTestId(MoneyHowItWorksViewTestIds.DESCRIPTION_3),
).toBeOnTheScreen();
- expect(getByText('Money account is powered by Monad.')).toBeOnTheScreen();
+ expect(
+ getByText(strings('money.how_it_works_page.description_3')),
+ ).toBeOnTheScreen();
});
it('renders the FAQ title', () => {
@@ -140,7 +98,9 @@ describe('MoneyHowItWorksView', () => {
it('renders the "FAQs" FAQ header', () => {
const { getByText } = renderWithProvider();
- expect(getByText('FAQs')).toBeOnTheScreen();
+ expect(
+ getByText(strings('money.how_it_works_page.faq_title')),
+ ).toBeOnTheScreen();
});
it('reveals the matching answer when a FAQ item is expanded', () => {
@@ -148,40 +108,23 @@ describe('MoneyHowItWorksView', () => {
,
);
- expect(queryByText('Money Account answer 1')).toBeNull();
+ expect(queryByText(strings('money.how_it_works_page.faq_a2'))).toBeNull();
- fireEvent.press(getByTestId(MoneyHowItWorksViewTestIds.FAQ_ITEM(1)));
+ fireEvent.press(getByTestId(MoneyHowItWorksViewTestIds.FAQ_ITEM(2)));
- expect(getByText('Money Account answer 1')).toBeOnTheScreen();
+ expect(
+ getByText(strings('money.how_it_works_page.faq_a2')),
+ ).toBeOnTheScreen();
});
it('renders all 10 FAQ questions', () => {
const { getByText } = renderWithProvider();
- expect(getByText('What is a MetaMask Money Account?')).toBeOnTheScreen();
- expect(getByText('Is MetaMask Money Account safe?')).toBeOnTheScreen();
- expect(
- getByText('Who controls my MetaMask Money Account?'),
- ).toBeOnTheScreen();
- expect(
- getByText('What are MetaMask Money Account fees?'),
- ).toBeOnTheScreen();
- expect(
- getByText('How much can I earn with a MetaMask Money Account?'),
- ).toBeOnTheScreen();
- expect(getByText('Where does the yield come from?')).toBeOnTheScreen();
- expect(
- getByText('Which tokens can I deposit into the Money Account?'),
- ).toBeOnTheScreen();
- expect(
- getByText('Can I withdraw from the MetaMask Money Account anytime?'),
- ).toBeOnTheScreen();
- expect(
- getByText('Do I need to complete KYC to use MetaMask Money Account?'),
- ).toBeOnTheScreen();
- expect(
- getByText('Which countries is MetaMask Money Account available in?'),
- ).toBeOnTheScreen();
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach((n) => {
+ expect(
+ getByText(strings(`money.how_it_works_page.faq_q${n}`)),
+ ).toBeOnTheScreen();
+ });
});
it('pressing the back button calls navigation.goBack', () => {
@@ -197,6 +140,22 @@ describe('MoneyHowItWorksView', () => {
expect(queryByText('Sounds good')).toBeNull();
});
+ it('renders the dash placeholder in description_1 when APY is unavailable', () => {
+ (useMoneyAccountBalance as jest.Mock).mockReturnValue({
+ apyPercent: undefined,
+ });
+ const { getByTestId, getByText } = renderWithProvider(
+ ,
+ );
+
+ expect(getByTestId(MoneyHowItWorksViewTestIds.CONTAINER)).toBeOnTheScreen();
+ expect(
+ getByText(
+ strings('money.how_it_works_page.description_1', { percentage: '-' }),
+ ),
+ ).toBeOnTheScreen();
+ });
+
it('toggles a FAQ item when the question row is pressed', () => {
const { getByTestId } = renderWithProvider();
diff --git a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx
index a1447fd167c..5c20138b735 100644
--- a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx
+++ b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx
@@ -61,7 +61,6 @@ const FAQ_KEYS = [
] as const;
const ANIMATION_DURATION = 200;
-const FALLBACK_APY = 4;
const FaqItem = ({
question,
@@ -131,7 +130,6 @@ const MoneyHowItWorksView = () => {
const insets = useSafeAreaInsets();
const { colors: themeColors } = useTheme();
const { apyPercent } = useMoneyAccountBalance();
- const percentage = apyPercent ?? FALLBACK_APY;
const noFeeTokens = resolveNoFeeTokens(useSelector(selectMoneyNoFeeTokens));
const tokenBullets = formatNoFeeTokenBullets(noFeeTokens);
@@ -196,7 +194,9 @@ const MoneyHowItWorksView = () => {
color={TextColor.TextAlternative}
testID={MoneyHowItWorksViewTestIds.DESCRIPTION_1}
>
- {strings('money.how_it_works_page.description_1', { percentage })}
+ {strings('money.how_it_works_page.description_1', {
+ percentage: apyPercent ?? '-',
+ })}
{
{index > 0 && }
{
);
};
-function rejectPendingTransactions(transactions: TransactionMeta[]) {
- const { ApprovalController } = Engine.context;
-
- for (const tx of transactions) {
- if (tx.status !== TransactionStatus.unapproved) {
- continue;
- }
- try {
- ApprovalController.rejectRequest(
- tx.id,
- providerErrors.userRejectedRequest(),
- );
- log('Rejected transaction', tx.type, tx.id);
- } catch {
- log('Failed to reject transaction', tx.type, tx.id);
- }
- }
-}
-
export default MoneyAddMoneySheet;
diff --git a/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.test.tsx b/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.test.tsx
index c68afd81730..71b5bacc704 100644
--- a/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.test.tsx
+++ b/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.test.tsx
@@ -20,6 +20,7 @@ const card: CardTransaction = {
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
+const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.());
let mockRouteParams: { card?: CardTransaction } | undefined;
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }),
@@ -58,11 +59,23 @@ jest.mock('../../../../../util/networks', () => ({
// Heavy presentational deps reduced to passthroughs / stubs.
jest.mock('@metamask/design-system-react-native', () => {
const actual = jest.requireActual('@metamask/design-system-react-native');
+ const ReactActual = jest.requireActual('react');
const { View, Text: RNText } = jest.requireActual('react-native');
return {
...actual,
- BottomSheet: ({ children }: { children: React.ReactNode }) => (
- {children}
+ // Forward a ref exposing `onCloseBottomSheet`, mirroring the real sheet:
+ // it runs the post-close callback once the close animation finishes (here,
+ // synchronously) so navigation-on-close can be asserted.
+ BottomSheet: ReactActual.forwardRef(
+ (
+ { children }: { children: React.ReactNode },
+ ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>,
+ ) => {
+ ReactActual.useImperativeHandle(ref, () => ({
+ onCloseBottomSheet: mockOnCloseBottomSheet,
+ }));
+ return {children};
+ },
),
BottomSheetHeader: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -129,7 +142,7 @@ describe('MoneyCardTransactionDetailsSheet', () => {
).toHaveTextContent('-5.38 mUSD');
});
- it('opens the block explorer for the tx hash on press', () => {
+ it('closes the sheet before opening the block explorer for the tx hash on press', () => {
const { getByTestId } = render();
fireEvent.press(
@@ -141,6 +154,9 @@ describe('MoneyCardTransactionDetailsSheet', () => {
card.hash,
'https://monadscan.com',
);
+ // The sheet must dismiss first — navigating while the transparent modal is
+ // still presented leaves the WebView behind it and strands the overlay.
+ expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(
'Webview',
expect.objectContaining({
diff --git a/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.tsx b/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.tsx
index 366ebdd243b..5a2edfa0cc6 100644
--- a/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.tsx
+++ b/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.tsx
@@ -107,9 +107,17 @@ const CardTransactionDetails = ({ card }: { card: CardTransaction }) => {
if (!url) {
return;
}
- navigation.navigate(Routes.WEBVIEW.MAIN, {
- screen: Routes.WEBVIEW.SIMPLE,
- params: { url, title },
+ // Dismiss the sheet *before* navigating. This sheet is a `transparentModal`
+ // presented over the main stack, and the WebView is a sibling screen on
+ // that same stack. Navigating while the modal is still up pushes the
+ // WebView behind it (so it never appears) and strands the sheet's overlay
+ // on screen. Closing first pops the modal, then the callback pushes the
+ // WebView onto the now-top stack.
+ sheetRef.current?.onCloseBottomSheet(() => {
+ navigation.navigate(Routes.WEBVIEW.MAIN, {
+ screen: Routes.WEBVIEW.SIMPLE,
+ params: { url, title },
+ });
});
}, [card.chainId, card.hash, navigation, networkConfigurations]);
diff --git a/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.test.tsx b/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.test.tsx
index 0fc459cd526..641c2058566 100644
--- a/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.test.tsx
+++ b/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.test.tsx
@@ -201,13 +201,20 @@ describe('MoneyEarnCryptoInfoSheet', () => {
});
});
- it('falls back to a baseline APY when the live value is unavailable', () => {
+ it('renders the body when APY is unavailable', () => {
(useMoneyAccountBalance as jest.Mock).mockReturnValue({
apyPercent: undefined,
});
- const { getByTestId } = renderWithProvider();
+ const { getByTestId, getByText } = renderWithProvider(
+ ,
+ );
expect(getByTestId(MoneyEarnCryptoInfoSheetTestIds.BODY)).toBeOnTheScreen();
+ expect(
+ getByText(
+ strings('money.earn_crypto_info_sheet.body', { percentage: '-' }),
+ ),
+ ).toBeOnTheScreen();
});
describe('analytics', () => {
diff --git a/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.tsx b/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.tsx
index f158b67318d..a963d403fa9 100644
--- a/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.tsx
+++ b/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.tsx
@@ -21,8 +21,6 @@ import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics';
import useMountEffect from '../../hooks/useMountEffect';
import { BOTTOM_SHEET_NAMES } from '../../constants/moneyEvents';
-const FALLBACK_APY = 4;
-
type MoneyEarnCryptoInfoSheetVariant = 'default' | 'deposit';
interface MoneyEarnCryptoInfoSheetParams {
@@ -79,7 +77,7 @@ const MoneyEarnCryptoInfoSheet = () => {
testID={MoneyEarnCryptoInfoSheetTestIds.BODY}
>
{strings('money.earn_crypto_info_sheet.body', {
- percentage: apyPercent ?? FALLBACK_APY,
+ percentage: apyPercent ?? '-',
})}
diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx
index 190530d69a1..c5943ce4afc 100644
--- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx
+++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx
@@ -447,6 +447,49 @@ describe('MoneyMetaMaskCard', () => {
});
});
+ describe('mode="verifying"', () => {
+ it('renders the MetaMask Card title and verification pending banner', () => {
+ const { getByText, getByTestId } = render(
+ ,
+ );
+
+ expect(getByText(strings('money.metamask_card.title'))).toBeOnTheScreen();
+ expect(
+ getByTestId(MoneyMetaMaskCardTestIds.VERIFYING_BANNER),
+ ).toBeOnTheScreen();
+ expect(
+ getByText(strings('money.metamask_card.verification_pending')),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls onHeaderPress when section header is tapped in verifying mode', () => {
+ const mockHeader = jest.fn();
+ const { getByText } = render(
+ ,
+ );
+
+ fireEvent.press(getByText(strings('money.metamask_card.title')));
+ expect(mockHeader).toHaveBeenCalled();
+ });
+
+ it('does not render upsell or link content', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ queryByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW),
+ ).not.toBeOnTheScreen();
+ expect(
+ queryByTestId(MoneyMetaMaskCardTestIds.LINK_CONTAINER),
+ ).not.toBeOnTheScreen();
+ });
+ });
+
describe('upsell mode (default)', () => {
it('renders only the virtual card row regardless of showMetalCard', () => {
const { getByTestId, queryByTestId } = render(
diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.testIds.ts b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.testIds.ts
index 305b4225c1b..f3078d0855a 100644
--- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.testIds.ts
+++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.testIds.ts
@@ -14,4 +14,5 @@ export const MoneyMetaMaskCardTestIds = {
MANAGE_BUTTON: 'money-mm-card-manage-button',
MANAGE_METAL_GET_NOW: 'money-mm-card-manage-metal-get-now',
MANAGE_BALANCE: 'money-mm-card-manage-balance',
+ VERIFYING_BANNER: 'money-mm-card-verifying-banner',
} as const;
diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx
index adf49e23717..34e1c3ab7c1 100644
--- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx
+++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx
@@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Image, ImageSourcePropType } from 'react-native';
import {
+ BannerAlert,
+ BannerAlertSeverity,
Box,
BoxAlignItems,
BoxFlexDirection,
@@ -33,6 +35,7 @@ import {
import mmCardRegular from '../../../../../images/mm_card_regular.png';
import mmCardMetal from '../../../../../images/mm_card_metal.png';
+import { FLAT_BANNER_ALERT_STYLE } from '../../../shared/flatBannerAlertStyle';
interface MoneyMetaMaskCardProps {
/**
@@ -40,7 +43,7 @@ interface MoneyMetaMaskCardProps {
* 'link': card-linking CTA layout.
* 'manage': cardholder management layout with available balance and metal upsell.
*/
- mode?: 'upsell' | 'link' | 'manage';
+ mode?: 'upsell' | 'link' | 'manage' | 'verifying';
onGetNowPress: () => void;
onHeaderPress?: () => void;
/** Called when the "Link card" button is pressed (link mode only). */
@@ -463,6 +466,18 @@ const MoneyMetaMaskCard = ({
showMetalCard={showMetalCard}
/>
);
+ } else if (mode === 'verifying') {
+ content = (
+
+
+
+ );
} else {
content = (
<>
@@ -487,7 +502,7 @@ const MoneyMetaMaskCard = ({
let headerTitleKey: string;
if (mode === 'link') {
headerTitleKey = 'money.metamask_card.link_title';
- } else if (mode === 'manage') {
+ } else if (mode === 'manage' || mode === 'verifying') {
headerTitleKey = 'money.metamask_card.title';
} else {
headerTitleKey = 'money.metamask_card.upsell_title';
diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.test.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.test.tsx
index 7d204bd1f3d..1e7bf4b1171 100644
--- a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.test.tsx
+++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.test.tsx
@@ -19,6 +19,8 @@ jest.mock('../../../../../util/analytics/externalLinkTracking', () => ({
}));
import { trackBlockExplorerLinkClicked } from '../../../../../util/analytics/externalLinkTracking';
const mockNavigate = jest.fn();
+// Mirrors the host sheet: closes first, then runs the deferred navigation.
+const mockCloseSheet = jest.fn((navigate: () => void) => navigate());
jest.mock(
'../../../../Views/confirmations/hooks/activity/useTransactionDetails',
@@ -162,9 +164,12 @@ function render(overrides: Partial = {}) {
...overrides,
} as TransactionMeta,
});
- return renderWithProvider(, {
- state: merge({}, otherControllersMock),
- });
+ return renderWithProvider(
+ ,
+ {
+ state: merge({}, otherControllersMock),
+ },
+ );
}
describe('MoneySentDetails', () => {
@@ -220,9 +225,12 @@ describe('MoneySentDetails', () => {
expect(getByText('$34.54')).toBeOnTheScreen();
});
- it('navigates to the block explorer when the button is pressed', () => {
+ it('closes the sheet before navigating to the block explorer when the button is pressed', () => {
const { getByText } = render();
fireEvent.press(getByText('View on block explorer'));
+ // The sheet must dismiss first — navigating while its transparent modal is
+ // still presented leaves the WebView behind it and strands the overlay.
+ expect(mockCloseSheet).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(
'Webview',
expect.objectContaining({ screen: 'SimpleWebview' }),
diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.tsx
index f1aac328b24..ea4fad4b0e8 100644
--- a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.tsx
+++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.tsx
@@ -79,7 +79,17 @@ function useRecipient(): Hex | undefined {
}, [transactionMeta]);
}
-export function MoneySentDetails() {
+interface MoneySentDetailsProps {
+ /**
+ * Closes the host bottom sheet and runs `navigate` once it has finished
+ * dismissing. Navigating while the sheet's transparent modal is still
+ * presented pushes the WebView behind it and strands the overlay, so the
+ * explorer link must defer navigation until after the sheet is gone.
+ */
+ onCloseSheet: (navigate: () => void) => void;
+}
+
+export function MoneySentDetails({ onCloseSheet }: MoneySentDetailsProps) {
const { styles } = useStyles(styleSheet, {});
const navigation = useNavigation();
const { trackEvent, createEventBuilder } = useAnalytics();
@@ -126,15 +136,18 @@ export function MoneySentDetails() {
text: strings('transaction_details.view_on_block_explorer'),
url,
});
- navigation.navigate(Routes.WEBVIEW.MAIN, {
- screen: Routes.WEBVIEW.SIMPLE,
- params: { url, title },
+ onCloseSheet(() => {
+ navigation.navigate(Routes.WEBVIEW.MAIN, {
+ screen: Routes.WEBVIEW.SIMPLE,
+ params: { url, title },
+ });
});
}, [
chainId,
createEventBuilder,
navigation,
networkConfigurations,
+ onCloseSheet,
trackEvent,
transactionMeta,
]);
diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx
index 9b17abb05c6..5417997bda3 100644
--- a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx
+++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx
@@ -86,6 +86,12 @@ const MoneyTransactionDetailsSheet = () => {
sheetRef.current?.onCloseBottomSheet();
}, []);
+ // Close the sheet first, then navigate — see `MoneySentDetailsProps.onCloseSheet`.
+ const closeAndNavigate = useCallback(
+ (navigate: () => void) => sheetRef.current?.onCloseBottomSheet(navigate),
+ [],
+ );
+
return (
{
{isReceived ? (
) : isSent ? (
-
+
) : (
)}
diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.pendingTransaction.test.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.pendingTransaction.test.tsx
new file mode 100644
index 00000000000..920e701d643
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.pendingTransaction.test.tsx
@@ -0,0 +1,283 @@
+import React, { useEffect, useState } from 'react';
+import { act, fireEvent } from '@testing-library/react-native';
+import {
+ TransactionStatus,
+ type TransactionMeta,
+} from '@metamask/transaction-controller';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import MoneyTransferSheet from './MoneyTransferSheet';
+import { MoneyTransferSheetTestIds } from './MoneyTransferSheet.testIds';
+import Routes from '../../../../../constants/navigation/Routes';
+import Engine from '../../../../../core/Engine';
+import { updateBgState } from '../../../../../core/redux/slices/engine';
+import { addTransactionBatch } from '../../../../../util/transaction-controller';
+import { useMoneyPerpsDeposit } from '../../../../Views/confirmations/hooks/pay/useMoneyPerpsDeposit';
+import { useMoneyPredictDeposit } from '../../../../Views/confirmations/hooks/pay/useMoneyPredictDeposit';
+import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics';
+
+const PENDING_TX_ID = 'pending-tx-from-elsewhere';
+
+const mockNavigate = jest.fn();
+let unmountSheet: () => void = () => undefined;
+const mockGoBack = jest.fn(() => unmountSheet());
+
+const mockVaultConfig = {
+ chainId: '0x8f',
+ boringVault: '0xb4563bcD3B7764CCBf497f515585f70B6C3EA5Ae',
+ tellerAddress: '0x2D49EA58A4C70b62c8B56DE971310d9e999c8117',
+ accountantAddress: '0x7382c5b8B51B8C4f127B3123C1039581BAA5A06B',
+ lensAddress: '0xA816ECd922de94c6879AD23B9A884dB257F20947',
+};
+
+// Stable references so react-redux's useSelector doesn't warn on every render.
+const mockPrimaryMoneyAccount = {
+ address: '0x1111111111111111111111111111111111111111',
+};
+const mockRecipient = '0x2222222222222222222222222222222222222222';
+
+// Mutable mock of Engine.state.TransactionController so the test can simulate
+// the TransactionController removing a transaction after it is rejected.
+const mockEngineState: {
+ TransactionController: { transactions: TransactionMeta[] };
+} = {
+ TransactionController: { transactions: [] },
+};
+
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }),
+ };
+});
+
+jest.mock('@metamask/design-system-react-native', () => {
+ const actual = jest.requireActual('@metamask/design-system-react-native');
+ const { forwardRef, useImperativeHandle } = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ const MockBottomSheet = forwardRef(
+ (
+ {
+ children,
+ testID,
+ goBack,
+ }: {
+ children: React.ReactNode;
+ testID?: string;
+ goBack?: () => void;
+ },
+ ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>,
+ ) => {
+ useImperativeHandle(
+ ref,
+ () => ({
+ onCloseBottomSheet: (cb?: () => void) => {
+ goBack?.();
+ cb?.();
+ },
+ onOpenBottomSheet: jest.fn(),
+ }),
+ [goBack],
+ );
+ return {children};
+ },
+ );
+ const MockBottomSheetHeader = ({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) => {children};
+ return {
+ ...actual,
+ BottomSheet: MockBottomSheet,
+ BottomSheetHeader: MockBottomSheetHeader,
+ };
+});
+
+// Keep the real `useMoneyAccountWithdrawal` + `useConfirmNavigation`; stub only
+// the heavy withdrawal-building internals so `initiateWithdrawal` reaches
+// navigation.
+jest.mock('../../utils/moneyAccountTransactions', () => ({
+ buildMoneyAccountWithdrawBatch: jest.fn().mockResolvedValue({
+ withdrawTx: { to: '0xwithdraw', data: '0x', value: '0x0' },
+ transferTx: { to: '0xtransfer', data: '0x', value: '0x0' },
+ }),
+ getMoneyAccountDepositAssetAddress: jest.fn(() => '0xasset'),
+}));
+
+jest.mock('../../../../../util/notifications/methods/common', () => ({
+ getProviderByChainId: jest.fn(() => ({})),
+}));
+
+jest.mock('../../../../../util/transaction-controller', () => ({
+ addTransactionBatch: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('../../../../../core/Engine', () => ({
+ context: {
+ NetworkController: {
+ findNetworkClientIdByChainId: jest.fn(() => 'network-client-1'),
+ },
+ ApprovalController: { rejectRequest: jest.fn() },
+ },
+
+ get state() {
+ return mockEngineState;
+ },
+}));
+
+jest.mock(
+ '../../../../../selectors/featureFlagController/moneyAccount',
+ () => ({
+ ...jest.requireActual(
+ '../../../../../selectors/featureFlagController/moneyAccount',
+ ),
+ selectMoneyAccountVaultConfig: jest.fn(() => mockVaultConfig),
+ }),
+);
+
+jest.mock('../../../../../selectors/moneyAccountController', () => ({
+ ...jest.requireActual('../../../../../selectors/moneyAccountController'),
+ selectPrimaryMoneyAccount: jest.fn(() => mockPrimaryMoneyAccount),
+}));
+
+jest.mock('../../../../../selectors/accountsController', () => ({
+ ...jest.requireActual('../../../../../selectors/accountsController'),
+ selectEvmAddress: jest.fn(() => mockRecipient),
+}));
+
+// Other transfer options are out of scope here; disable them.
+jest.mock(
+ '../../../../Views/confirmations/hooks/pay/useMoneyPerpsDeposit',
+ () => ({ useMoneyPerpsDeposit: jest.fn() }),
+);
+jest.mock(
+ '../../../../Views/confirmations/hooks/pay/useMoneyPredictDeposit',
+ () => ({ useMoneyPredictDeposit: jest.fn() }),
+);
+jest.mock('../../hooks/useMoneyAnalytics', () => ({
+ useMoneyAnalytics: jest.fn(),
+}));
+
+// Wrapper that unmounts the sheet when `goBack` runs — modelling the modal
+// being popped on close, which is what tears the hook down in production.
+const SheetHarness = () => {
+ const [open, setOpen] = useState(true);
+ useEffect(() => {
+ unmountSheet = () => setOpen(false);
+ return () => {
+ unmountSheet = () => undefined;
+ };
+ }, []);
+ return open ? : null;
+};
+
+function renderHarness(pendingTransactions: TransactionMeta[]) {
+ return renderWithProvider(, {
+ state: {
+ engine: {
+ backgroundState: {
+ TransactionController: { transactions: pendingTransactions },
+ },
+ },
+ },
+ });
+}
+
+const flushAsync = async () => {
+ await act(async () => {
+ await Promise.resolve();
+ });
+};
+
+/**
+ * Regression test for MUSD-975: tapping "Another account" (Between accounts)
+ * while a stale unapproved transaction was pending created a second stuck
+ * pending transaction and never showed the Send flow, because closing the sheet
+ * unmounted the component before the deferred navigation could run.
+ */
+describe('MoneyTransferSheet — Between accounts with a pending transaction', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockEngineState.TransactionController = { transactions: [] };
+ (useMoneyPerpsDeposit as jest.Mock).mockReturnValue({
+ isEnabled: false,
+ initiatePerpsDeposit: jest.fn(),
+ });
+ (useMoneyPredictDeposit as jest.Mock).mockReturnValue({
+ isEnabled: false,
+ initiatePredictDeposit: jest.fn(),
+ });
+ (useMoneyAnalytics as jest.Mock).mockReturnValue({
+ trackBottomSheetViewed: jest.fn(),
+ trackSurfaceClicked: jest.fn(),
+ });
+ });
+
+ it('navigates to the Send flow even when an unconfirmed transaction is already pending', async () => {
+ const pendingTx = {
+ id: PENDING_TX_ID,
+ status: TransactionStatus.unapproved,
+ } as TransactionMeta;
+
+ const { getByTestId, store } = renderHarness([pendingTx]);
+
+ fireEvent.press(
+ getByTestId(MoneyTransferSheetTestIds.BETWEEN_ACCOUNTS_OPTION),
+ );
+ await flushAsync();
+
+ // The pre-existing pending transaction is rejected straight away.
+ expect(
+ jest.mocked(Engine.context.ApprovalController.rejectRequest),
+ ).toHaveBeenCalledWith(PENDING_TX_ID, expect.anything());
+
+ // Closing + navigating is deferred until the rejection clears from state,
+ // so the sheet stays mounted: it has not been closed and nothing has
+ // navigated yet. This is what keeps the navigation alive.
+ expect(mockGoBack).not.toHaveBeenCalled();
+ expect(addTransactionBatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+
+ // Simulate the TransactionController removing the rejected transaction.
+ mockEngineState.TransactionController = { transactions: [] };
+ await act(async () => {
+ store.dispatch(updateBgState({ key: 'TransactionController' }));
+ });
+ await flushAsync();
+
+ // Now that nothing is pending, the sheet closes (modal popped) and the
+ // withdrawal flow runs in one step — the user reaches the Send
+ // confirmation instead of being stranded with a stuck pending transaction.
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ expect(addTransactionBatch).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.MONEY.CONFIRMATIONS_ROOT,
+ expect.objectContaining({
+ screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
+ }),
+ );
+ });
+
+ it('navigates to the Send flow when no transaction is pending (control)', async () => {
+ const { getByTestId } = renderHarness([]);
+
+ fireEvent.press(
+ getByTestId(MoneyTransferSheetTestIds.BETWEEN_ACCOUNTS_OPTION),
+ );
+ await flushAsync();
+
+ // Nothing to reject, so the sheet closes and navigates immediately.
+ expect(
+ jest.mocked(Engine.context.ApprovalController.rejectRequest),
+ ).not.toHaveBeenCalled();
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ expect(addTransactionBatch).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.MONEY.CONFIRMATIONS_ROOT,
+ expect.objectContaining({
+ screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
+ }),
+ );
+ });
+});
diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx
index 9b3b306d6cc..b7a6ea5c49b 100644
--- a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx
+++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx
@@ -21,6 +21,11 @@ jest.mock('../../hooks/useMoneyAnalytics', () => ({
useMoneyAnalytics: jest.fn(),
}));
+jest.mock('../../../../../selectors/transactionController', () => ({
+ ...jest.requireActual('../../../../../selectors/transactionController'),
+ selectTransactions: jest.fn(() => []),
+}));
+
const mockInitiateWithdrawal = jest.fn().mockResolvedValue(undefined);
const mockInitiatePerpsDeposit = jest.fn().mockResolvedValue(undefined);
const mockInitiatePredictDeposit = jest.fn().mockResolvedValue(undefined);
diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx
index 88648169fd8..b11a14454c2 100644
--- a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx
+++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx
@@ -1,6 +1,14 @@
-import React, { useCallback, useRef } from 'react';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { View } from 'react-native';
+import { useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
+import { TransactionStatus } from '@metamask/transaction-controller';
import {
BottomSheet,
BottomSheetHeader,
@@ -11,6 +19,8 @@ import {
} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import { useStyles } from '../../../../../component-library/hooks';
+import { selectTransactions } from '../../../../../selectors/transactionController';
+import { rejectPendingTransactions } from '../../utils/rejectPendingTransactions';
import { useMoneyAccountWithdrawal } from '../../hooks/useMoneyAccount';
import { useMoneyPerpsDeposit } from '../../../../Views/confirmations/hooks/pay/useMoneyPerpsDeposit';
import { useMoneyPredictDeposit } from '../../../../Views/confirmations/hooks/pay/useMoneyPredictDeposit';
@@ -29,6 +39,8 @@ import {
import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics';
import useMountEffect from '../../hooks/useMountEffect';
+type TransferAction = 'withdraw' | 'perps' | 'predict';
+
const MoneyTransferSheet = () => {
const sheetRef = useRef(null);
const navigation = useNavigation();
@@ -46,6 +58,71 @@ const MoneyTransferSheet = () => {
useMountEffect(trackBottomSheetViewed);
+ const transactions = useSelector(selectTransactions);
+
+ const hasPendingTransaction = useMemo(
+ () =>
+ (transactions ?? []).some(
+ (tx) => tx.status === TransactionStatus.unapproved,
+ ),
+ [transactions],
+ );
+
+ const [deferredAction, setDeferredAction] = useState(
+ null,
+ );
+
+ // Close the sheet (which pops the modal) and kick off the transfer in one
+ // atomic step, so the confirmation slides straight over the sheet rather than
+ // flashing back to Money home in between. We resolve the initiator here rather
+ // than capturing it earlier so a deferred run uses the up-to-date navigation
+ // closure (which no longer sees the just-rejected transaction), not a stale
+ // snapshot.
+ const closeAndStart = useCallback(
+ (action: TransferAction) => {
+ let initiate = initiateWithdrawal;
+ if (action === 'perps') {
+ initiate = initiatePerpsDeposit;
+ } else if (action === 'predict') {
+ initiate = initiatePredictDeposit;
+ }
+ sheetRef.current?.onCloseBottomSheet(() => {
+ initiate().catch((error: Error) => {
+ Logger.error(
+ error,
+ '[MoneyTransferSheet] Transfer initiation failed',
+ );
+ });
+ });
+ },
+ [initiateWithdrawal, initiatePerpsDeposit, initiatePredictDeposit],
+ );
+
+ // A leftover unapproved transaction would be picked up by the confirmation
+ // screen, so we reject it before opening a fresh one. Rejection clears from
+ // state asynchronously and closing the sheet unmounts us, so when something is
+ // pending we stay mounted and defer the close+navigate (see effect) instead of
+ // letting it race the unmount.
+ const startAction = useCallback(
+ (action: TransferAction) => {
+ if (hasPendingTransaction) {
+ rejectPendingTransactions(transactions ?? []);
+ setDeferredAction(action);
+ return;
+ }
+ closeAndStart(action);
+ },
+ [hasPendingTransaction, transactions, closeAndStart],
+ );
+
+ useEffect(() => {
+ if (!deferredAction || hasPendingTransaction) {
+ return;
+ }
+ closeAndStart(deferredAction);
+ setDeferredAction(null);
+ }, [deferredAction, hasPendingTransaction, closeAndStart]);
+
const handleGoBack = useCallback(() => {
navigation.goBack();
}, [navigation]);
@@ -57,15 +134,8 @@ const MoneyTransferSheet = () => {
redirect_target: SCREEN_NAMES.MONEY_TRANSFER,
});
- sheetRef.current?.onCloseBottomSheet(() => {
- initiateWithdrawal().catch((error: Error) => {
- Logger.error(
- error,
- '[MoneyTransferSheet] Withdrawal initiation failed',
- );
- });
- });
- }, [initiateWithdrawal, trackSurfaceClicked]);
+ startAction('withdraw');
+ }, [startAction, trackSurfaceClicked]);
const handlePerpsAccount = useCallback(() => {
trackSurfaceClicked({
@@ -73,10 +143,8 @@ const MoneyTransferSheet = () => {
redirect_target: SCREEN_NAMES.MONEY_TRANSFER,
});
- sheetRef.current?.onCloseBottomSheet(() => {
- initiatePerpsDeposit();
- });
- }, [initiatePerpsDeposit, trackSurfaceClicked]);
+ startAction('perps');
+ }, [startAction, trackSurfaceClicked]);
const handlePredictionsAccount = useCallback(() => {
trackSurfaceClicked({
@@ -85,10 +153,8 @@ const MoneyTransferSheet = () => {
redirect_target: SCREEN_NAMES.MONEY_TRANSFER,
});
- sheetRef.current?.onCloseBottomSheet(() => {
- initiatePredictDeposit();
- });
- }, [initiatePredictDeposit, trackSurfaceClicked]);
+ startAction('predict');
+ }, [startAction, trackSurfaceClicked]);
const options: MoneySheetOption[] = [
{
diff --git a/app/components/UI/Money/hooks/useMoneyToasts.test.tsx b/app/components/UI/Money/hooks/useMoneyToasts.test.tsx
index 48b6baa9f63..0ec9d9588c2 100644
--- a/app/components/UI/Money/hooks/useMoneyToasts.test.tsx
+++ b/app/components/UI/Money/hooks/useMoneyToasts.test.tsx
@@ -104,6 +104,11 @@ describe('useMoneyToasts', () => {
).toBeDefined();
expect(result.current.MoneyToastOptions.withdraw.success).toBeDefined();
expect(result.current.MoneyToastOptions.withdraw.failed).toBeDefined();
+
+ expect(result.current.MoneyToastOptions.send).toBeDefined();
+ expect(result.current.MoneyToastOptions.send.inProgress).toBeDefined();
+ expect(result.current.MoneyToastOptions.send.success).toBeDefined();
+ expect(result.current.MoneyToastOptions.send.failed).toBeDefined();
});
});
@@ -286,6 +291,75 @@ describe('useMoneyToasts', () => {
});
});
+ describe('send toasts', () => {
+ it('inProgress mirrors the in-progress configuration', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.send.inProgress();
+
+ expect(toast.variant).toBe(ToastVariants.Icon);
+ expect(toast.iconName).toBe(IconName.Loading);
+ expect(toast.hapticsType).toBe(NotificationMoment.Warning);
+ expect(toast.hasNoTimeout).toBe(true);
+ });
+
+ it('inProgress title/body is "Sending funds" / "This may take a few minutes."', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.send.inProgress();
+
+ expect(toast.labelOptions?.[0].label).toBe('Sending funds');
+ const secondary = toast.labelOptions?.[2].label as React.ReactElement<{
+ children?: React.ReactNode;
+ }>;
+ expect(secondary.props.children).toBe('This may take a few minutes.');
+ });
+
+ it('success title/body interpolates the amount and destination', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.send.success({
+ amountFiat: '$50.00',
+ destination: 'Perps',
+ });
+
+ expect(toast.iconName).toBe(IconName.Confirmation);
+ expect(toast.hapticsType).toBe(NotificationMoment.Success);
+ expect(toast.labelOptions?.[0].label).toBe('Funds sent');
+ const secondary = toast.labelOptions?.[2].label as React.ReactElement<{
+ children?: React.ReactNode;
+ }>;
+ expect(secondary.props.children).toBe('$50.00 is available in Perps.');
+ });
+
+ it('success body falls back to "Available in {{destination}}." when amount is missing', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.send.success({
+ destination: 'Predict',
+ });
+
+ const secondary = toast.labelOptions?.[2].label as React.ReactElement<{
+ children?: React.ReactNode;
+ }>;
+ expect(secondary.props.children).toBe('Available in Predict.');
+ });
+
+ it('failed title/body is "Send failed" / "Unable to send funds. Try again."', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.send.failed();
+
+ expect(toast.iconName).toBe(IconName.CircleX);
+ expect(toast.hapticsType).toBe(NotificationMoment.Error);
+ expect(toast.labelOptions?.[0].label).toBe('Send failed');
+ const secondary = toast.labelOptions?.[2].label as React.ReactElement<{
+ children?: React.ReactNode;
+ }>;
+ expect(secondary.props.children).toBe('Unable to send funds. Try again.');
+ });
+ });
+
describe('closeButtonOptions', () => {
it.each([
['deposit.inProgress', () => ({}), 'inProgress'],
@@ -298,10 +372,17 @@ describe('useMoneyToasts', () => {
'success',
],
['withdraw.failed', () => ({}), 'failed'],
+ ['send.inProgress', () => ({}), 'inProgress'],
+ [
+ 'send.success',
+ () => ({ amountFiat: '$1.00', destination: 'Perps' }),
+ 'success',
+ ],
+ ['send.failed', () => ({}), 'failed'],
])('exposes a Close button on %s', (key, paramsFactory, _builder) => {
const { result } = renderHook(() => useMoneyToasts(), { wrapper });
const [namespace, builder] = key.split('.') as [
- 'deposit' | 'withdraw',
+ 'deposit' | 'withdraw' | 'send',
'inProgress' | 'success' | 'failed',
];
diff --git a/app/components/UI/Money/hooks/useMoneyToasts.tsx b/app/components/UI/Money/hooks/useMoneyToasts.tsx
index 29e751a58ef..960a9f00a69 100644
--- a/app/components/UI/Money/hooks/useMoneyToasts.tsx
+++ b/app/components/UI/Money/hooks/useMoneyToasts.tsx
@@ -56,6 +56,11 @@ export interface WithdrawSuccessParams {
destination: string;
}
+export interface SendSuccessParams {
+ amountFiat?: string;
+ destination: string;
+}
+
export interface MoneyToastOptionsConfig {
deposit: {
inProgress: (params?: DepositInProgressParams) => MoneyToastOptions;
@@ -67,6 +72,11 @@ export interface MoneyToastOptionsConfig {
success: (params: WithdrawSuccessParams) => MoneyToastOptions;
failed: () => MoneyToastOptions;
};
+ send: {
+ inProgress: () => MoneyToastOptions;
+ success: (params: SendSuccessParams) => MoneyToastOptions;
+ failed: () => MoneyToastOptions;
+ };
}
interface MoneyToastLabelOptions {
@@ -174,8 +184,27 @@ const useMoneyToasts = (): {
[toastRef],
);
- const MoneyToastOptions: MoneyToastOptionsConfig = useMemo(
- () => ({
+ const MoneyToastOptions: MoneyToastOptionsConfig = useMemo(() => {
+ const buildSendToast = (
+ base: MoneyToastOptions,
+ primaryKey: string,
+ secondaryKey: string,
+ secondaryParams?: Record,
+ ): MoneyToastOptions => ({
+ ...base,
+ labelOptions: getMoneyToastLabels({
+ primary: strings(primaryKey),
+ primaryIsBold: true,
+ secondary: (
+
+ {strings(secondaryKey, secondaryParams)}
+
+ ),
+ }),
+ closeButtonOptions,
+ });
+
+ return {
deposit: {
inProgress: (params?: DepositInProgressParams) => ({
...moneyBaseToastOptions.inProgress,
@@ -303,14 +332,41 @@ const useMoneyToasts = (): {
closeButtonOptions,
}),
},
- }),
- [
- closeButtonOptions,
- moneyBaseToastOptions.error,
- moneyBaseToastOptions.inProgress,
- moneyBaseToastOptions.success,
- ],
- );
+ send: {
+ inProgress: () =>
+ buildSendToast(
+ moneyBaseToastOptions.inProgress,
+ 'money.toasts.send_in_progress_title',
+ 'money.toasts.in_progress_body',
+ ),
+ success: ({ amountFiat, destination }: SendSuccessParams) =>
+ amountFiat
+ ? buildSendToast(
+ moneyBaseToastOptions.success,
+ 'money.toasts.send_success_title',
+ 'money.toasts.send_success_body',
+ { amount: amountFiat, destination },
+ )
+ : buildSendToast(
+ moneyBaseToastOptions.success,
+ 'money.toasts.send_success_title',
+ 'money.toasts.send_success_body_no_amount',
+ { destination },
+ ),
+ failed: () =>
+ buildSendToast(
+ moneyBaseToastOptions.error,
+ 'money.toasts.send_failed_title',
+ 'money.toasts.send_failed_body',
+ ),
+ },
+ };
+ }, [
+ closeButtonOptions,
+ moneyBaseToastOptions.error,
+ moneyBaseToastOptions.inProgress,
+ moneyBaseToastOptions.success,
+ ]);
return { showToast, MoneyToastOptions };
};
diff --git a/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts b/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts
index 6b4b8ae5fff..6d6ccbb67c0 100644
--- a/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts
+++ b/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts
@@ -1,4 +1,5 @@
import {
+ CHAIN_IDS,
TransactionMeta,
TransactionStatus,
TransactionType,
@@ -203,6 +204,18 @@ describe('useMoneyTransactionStatus', () => {
ReturnType,
Parameters
>(() => baseFailedToast);
+ const sendInProgressFn = jest.fn<
+ ReturnType,
+ Parameters
+ >(() => baseInProgressToast);
+ const sendSuccessFn = jest.fn<
+ ReturnType,
+ Parameters
+ >(() => baseSuccessToast);
+ const sendFailedFn = jest.fn<
+ ReturnType,
+ Parameters
+ >(() => baseFailedToast);
const moneyToastOptions: MoneyToastOptionsConfig = {
deposit: {
@@ -215,6 +228,11 @@ describe('useMoneyTransactionStatus', () => {
success: withdrawSuccessFn,
failed: withdrawFailedFn,
},
+ send: {
+ inProgress: sendInProgressFn,
+ success: sendSuccessFn,
+ failed: sendFailedFn,
+ },
};
beforeEach(() => {
@@ -744,6 +762,129 @@ describe('useMoneyTransactionStatus', () => {
});
});
+ describe('perps/predict send lifecycle', () => {
+ const buildSendTxMeta = (
+ type: TransactionType,
+ overrides: Partial = {},
+ ): TransactionMeta =>
+ buildTxMeta({
+ id: 'send-tx-1',
+ type,
+ metamaskPay: {
+ tokenAddress: MUSD_ADDRESS,
+ chainId: CHAIN_IDS.MONAD,
+ totalFiat: '100',
+ },
+ ...overrides,
+ } as unknown as Partial);
+
+ it('approved → send in-progress toast (after deferral), not deposit/withdraw', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildSendTxMeta(TransactionType.perpsDeposit, {
+ status: TransactionStatus.approved,
+ }),
+ });
+
+ expect(sendInProgressFn).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(sendInProgressFn).toHaveBeenCalledTimes(1);
+ expect(depositInProgressFn).not.toHaveBeenCalled();
+ expect(withdrawInProgressFn).not.toHaveBeenCalled();
+ });
+
+ it('confirmed → send success toast with fiat amount and "Perps" destination', () => {
+ const { confirmedHandler } = renderAndGetHandlers();
+
+ confirmedHandler(
+ buildSendTxMeta(TransactionType.perpsDeposit, {
+ status: TransactionStatus.confirmed,
+ }),
+ );
+
+ expect(sendSuccessFn).toHaveBeenCalledTimes(1);
+ const params = sendSuccessFn.mock.calls[0][0];
+ expect(params.amountFiat).toContain('100');
+ expect(params.destination).toBe('Perps');
+ expect(depositSuccessFn).not.toHaveBeenCalled();
+ expect(withdrawSuccessFn).not.toHaveBeenCalled();
+ });
+
+ it('confirmed predict deposit → "Predict" destination', () => {
+ const { confirmedHandler } = renderAndGetHandlers();
+
+ confirmedHandler(
+ buildSendTxMeta(TransactionType.predictDeposit, {
+ status: TransactionStatus.confirmed,
+ }),
+ );
+
+ expect(sendSuccessFn).toHaveBeenCalledTimes(1);
+ expect(sendSuccessFn.mock.calls[0][0].destination).toBe('Predict');
+ });
+
+ it('confirmed with missing/zero fiat → success without amount', () => {
+ const { confirmedHandler } = renderAndGetHandlers();
+
+ confirmedHandler(
+ buildSendTxMeta(TransactionType.perpsDeposit, {
+ status: TransactionStatus.confirmed,
+ metamaskPay: {
+ tokenAddress: MUSD_ADDRESS,
+ chainId: CHAIN_IDS.MONAD,
+ totalFiat: '0',
+ },
+ } as unknown as Partial),
+ );
+
+ expect(sendSuccessFn).toHaveBeenCalledTimes(1);
+ expect(sendSuccessFn.mock.calls[0][0].amountFiat).toBeUndefined();
+ });
+
+ it.each([
+ ['failed', TransactionStatus.failed],
+ ['dropped', TransactionStatus.dropped],
+ ['cancelled', TransactionStatus.cancelled],
+ ])('statusUpdated with %s → send failed toast', (_label, status) => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildSendTxMeta(TransactionType.perpsDeposit, {
+ status,
+ }),
+ });
+
+ expect(sendFailedFn).toHaveBeenCalledTimes(1);
+ expect(depositFailedFn).not.toHaveBeenCalled();
+ expect(withdrawFailedFn).not.toHaveBeenCalled();
+ expect(clearMoneyAccountDepositIntent).not.toHaveBeenCalled();
+ });
+
+ it('ignores a perps deposit not funded with mUSD on the Money chain', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ id: 'send-tx-other',
+ type: TransactionType.perpsDeposit,
+ status: TransactionStatus.approved,
+ metamaskPay: {
+ tokenAddress: MUSD_ADDRESS,
+ chainId: '0x1',
+ totalFiat: '100',
+ },
+ } as unknown as Partial),
+ });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(sendInProgressFn).not.toHaveBeenCalled();
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+ });
+
describe('dedup + cleanup', () => {
it('does not fire the same status+id toast twice', () => {
const { statusUpdatedHandler } = renderAndGetHandlers();
diff --git a/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts b/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts
index fde71e2ee35..42fc98ebc1b 100644
--- a/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts
+++ b/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts
@@ -34,7 +34,9 @@ import { TELLER_ABI } from '../utils/moneyAccountTransactions';
import {
isMoneyAccountTx,
isMoneyDepositTx,
+ isPerpsPredictMoneyDeposit,
nestedTxWithType,
+ perpsPredictServiceFamily,
} from '../utils/moneyTransactionGuards';
import useMoneyToasts from './useMoneyToasts';
import {
@@ -189,12 +191,15 @@ export const useMoneyTransactionStatus = () => {
};
const showInProgressFor = (transactionMeta: TransactionMeta) => {
- if (!isMoneyAccountTx(transactionMeta)) return;
+ const isSend = isPerpsPredictMoneyDeposit(transactionMeta);
+ if (!isMoneyAccountTx(transactionMeta) && !isSend) return;
if (!reserveToastKey(transactionMeta.id, IN_PROGRESS_KEY)) return;
if (pendingInProgress.has(transactionMeta.id)) return;
const timeoutId = setTimeout(() => {
pendingInProgress.delete(transactionMeta.id);
- if (isMoneyDepositTx(transactionMeta)) {
+ if (isSend) {
+ showToast(MoneyToastOptions.send.inProgress());
+ } else if (isMoneyDepositTx(transactionMeta)) {
const intent = getMoneyAccountDepositIntent(transactionMeta.batchId);
showToast(MoneyToastOptions.deposit.inProgress({ intent }));
} else {
@@ -205,10 +210,13 @@ export const useMoneyTransactionStatus = () => {
};
const showFailedFor = (transactionMeta: TransactionMeta) => {
- if (!isMoneyAccountTx(transactionMeta)) return;
+ const isSend = isPerpsPredictMoneyDeposit(transactionMeta);
+ if (!isMoneyAccountTx(transactionMeta) && !isSend) return;
cancelPendingInProgress(transactionMeta.id);
if (!reserveToastKey(transactionMeta.id, FAILED_KEY)) return;
- if (isMoneyDepositTx(transactionMeta)) {
+ if (isSend) {
+ showToast(MoneyToastOptions.send.failed());
+ } else if (isMoneyDepositTx(transactionMeta)) {
const intent = getMoneyAccountDepositIntent(transactionMeta.batchId);
showToast(MoneyToastOptions.deposit.failed({ intent }));
clearMoneyAccountDepositIntent(transactionMeta.batchId);
@@ -219,10 +227,31 @@ export const useMoneyTransactionStatus = () => {
};
const showConfirmedFor = (transactionMeta: TransactionMeta) => {
- if (!isMoneyAccountTx(transactionMeta)) return;
+ const isSend = isPerpsPredictMoneyDeposit(transactionMeta);
+ if (!isMoneyAccountTx(transactionMeta) && !isSend) return;
cancelPendingInProgress(transactionMeta.id);
if (!reserveToastKey(transactionMeta.id, CONFIRMED_KEY)) return;
+ if (isSend) {
+ const fiat = Number(transactionMeta.metamaskPay?.totalFiat);
+ const amountFiat =
+ !Number.isNaN(fiat) && fiat > 0
+ ? moneyFormatFiat(
+ new BigNumber(fiat),
+ selectCurrentCurrency(store.getState()),
+ )
+ : undefined;
+ const family = perpsPredictServiceFamily(transactionMeta);
+ const destination = strings(
+ family === 'predict'
+ ? 'money.toasts.send_destination_predict'
+ : 'money.toasts.send_destination_perps',
+ );
+ showToast(MoneyToastOptions.send.success({ amountFiat, destination }));
+ scheduleCleanup(transactionMeta.id, CONFIRMED_KEY);
+ return;
+ }
+
const depositNested = nestedTxWithType(
transactionMeta,
TransactionType.moneyAccountDeposit,
@@ -312,5 +341,10 @@ export const useMoneyTransactionStatus = () => {
pendingCleanups.forEach((timeoutId) => clearTimeout(timeoutId));
pendingCleanups.clear();
};
- }, [MoneyToastOptions.deposit, MoneyToastOptions.withdraw, showToast]);
+ }, [
+ MoneyToastOptions.deposit,
+ MoneyToastOptions.withdraw,
+ MoneyToastOptions.send,
+ showToast,
+ ]);
};
diff --git a/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts b/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts
new file mode 100644
index 00000000000..b37cfe772fc
--- /dev/null
+++ b/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts
@@ -0,0 +1,130 @@
+import { deriveMoneyMetaMaskCardMode } from './moneyMetaMaskCardMode';
+
+const baseInput = {
+ isCardLinkedToMoneyAccount: false,
+ isCardholder: false,
+ isCardAuthenticated: false,
+ isCardVerified: false,
+ isResidencyBlocked: false,
+ hasMoneyAccountBaseRequirements: true,
+ hasMoneyAccountRequirements: true,
+};
+
+describe('deriveMoneyMetaMaskCardMode', () => {
+ it('returns manage when the card is linked to the Money account', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardLinkedToMoneyAccount: true,
+ }),
+ ).toBe('manage');
+ });
+
+ it('returns link for an unauthenticated cardholder when base requirements are met', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardholder: true,
+ isCardAuthenticated: false,
+ hasMoneyAccountBaseRequirements: true,
+ }),
+ ).toBe('link');
+ });
+
+ it('returns null for an unauthenticated cardholder when base requirements are not met', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardholder: true,
+ isCardAuthenticated: false,
+ hasMoneyAccountBaseRequirements: false,
+ }),
+ ).toBeNull();
+ });
+
+ it('returns null when residency is blocked', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardholder: true,
+ isCardAuthenticated: true,
+ isCardVerified: true,
+ isResidencyBlocked: true,
+ }),
+ ).toBeNull();
+ });
+
+ it('returns link for a cardholder when full requirements are met', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardholder: true,
+ isCardAuthenticated: true,
+ isCardVerified: true,
+ hasMoneyAccountRequirements: true,
+ }),
+ ).toBe('link');
+ });
+
+ it('returns null for a cardholder when full requirements are not met', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardholder: true,
+ isCardAuthenticated: true,
+ isCardVerified: true,
+ hasMoneyAccountRequirements: false,
+ }),
+ ).toBeNull();
+ });
+
+ it('returns link for a verified authenticated non-cardholder when full requirements are met', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardAuthenticated: true,
+ isCardVerified: true,
+ hasMoneyAccountRequirements: true,
+ }),
+ ).toBe('link');
+ });
+
+ it('returns verifying for an authenticated but unverified non-cardholder', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardAuthenticated: true,
+ isCardVerified: false,
+ }),
+ ).toBe('verifying');
+ });
+
+ it('returns upsell for a brand-new user', () => {
+ expect(deriveMoneyMetaMaskCardMode(baseInput)).toBe('upsell');
+ });
+
+ it('prefers manage over all other states when the card is linked', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardLinkedToMoneyAccount: true,
+ isCardholder: true,
+ isCardAuthenticated: true,
+ isCardVerified: false,
+ isResidencyBlocked: true,
+ }),
+ ).toBe('manage');
+ });
+
+ it('prefers unauthenticated cardholder link over residency block', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardholder: true,
+ isCardAuthenticated: false,
+ isResidencyBlocked: true,
+ hasMoneyAccountBaseRequirements: true,
+ }),
+ ).toBe('link');
+ });
+});
diff --git a/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts b/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts
new file mode 100644
index 00000000000..e38a103b108
--- /dev/null
+++ b/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts
@@ -0,0 +1,43 @@
+export type MoneyMetaMaskCardMode = 'upsell' | 'link' | 'manage' | 'verifying';
+
+export interface MoneyMetaMaskCardModeInput {
+ isCardLinkedToMoneyAccount: boolean;
+ isCardholder: boolean;
+ isCardAuthenticated: boolean;
+ isCardVerified: boolean;
+ isResidencyBlocked: boolean;
+ hasMoneyAccountBaseRequirements: boolean;
+ hasMoneyAccountRequirements: boolean;
+}
+
+export const deriveMoneyMetaMaskCardMode = ({
+ isCardLinkedToMoneyAccount,
+ isCardholder,
+ isCardAuthenticated,
+ isCardVerified,
+ isResidencyBlocked,
+ hasMoneyAccountBaseRequirements,
+ hasMoneyAccountRequirements,
+}: MoneyMetaMaskCardModeInput): MoneyMetaMaskCardMode | null => {
+ // Card already linked to the Money account -> manage it.
+ if (isCardLinkedToMoneyAccount) return 'manage';
+
+ // Cardholder who hasn't authenticated yet -> offer to link (needs base reqs).
+ if (isCardholder && !isCardAuthenticated) {
+ return hasMoneyAccountBaseRequirements ? 'link' : null;
+ }
+
+ // Residency-blocked users can't link.
+ if (isResidencyBlocked) return null;
+
+ // Cardholder or a verified authenticated user -> offer to link (needs full reqs).
+ if (isCardholder || (isCardAuthenticated && isCardVerified)) {
+ return hasMoneyAccountRequirements ? 'link' : null;
+ }
+
+ // Authenticated but identity not yet verified -> verification in progress.
+ if (isCardAuthenticated) return 'verifying';
+
+ // Brand-new user -> upsell.
+ return 'upsell';
+};
diff --git a/app/components/UI/Money/utils/rejectPendingTransactions.test.ts b/app/components/UI/Money/utils/rejectPendingTransactions.test.ts
new file mode 100644
index 00000000000..3d4a600c510
--- /dev/null
+++ b/app/components/UI/Money/utils/rejectPendingTransactions.test.ts
@@ -0,0 +1,76 @@
+import {
+ TransactionStatus,
+ type TransactionMeta,
+} from '@metamask/transaction-controller';
+import { providerErrors } from '@metamask/rpc-errors';
+import { rejectPendingTransactions } from './rejectPendingTransactions';
+import Engine from '../../../../core/Engine';
+import Logger from '../../../../util/Logger';
+
+jest.mock('../../../../core/Engine', () => ({
+ context: {
+ ApprovalController: { rejectRequest: jest.fn() },
+ },
+}));
+
+jest.mock('../../../../util/Logger', () => ({
+ error: jest.fn(),
+}));
+
+const mockRejectRequest = jest.mocked(
+ Engine.context.ApprovalController.rejectRequest,
+);
+
+const tx = (id: string, status: TransactionStatus): TransactionMeta =>
+ ({ id, status }) as TransactionMeta;
+
+describe('rejectPendingTransactions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('rejects only unapproved transactions', () => {
+ rejectPendingTransactions([
+ tx('a', TransactionStatus.unapproved),
+ tx('b', TransactionStatus.confirmed),
+ tx('c', TransactionStatus.submitted),
+ tx('d', TransactionStatus.unapproved),
+ ]);
+
+ expect(mockRejectRequest).toHaveBeenCalledTimes(2);
+ expect(mockRejectRequest).toHaveBeenCalledWith(
+ 'a',
+ providerErrors.userRejectedRequest(),
+ );
+ expect(mockRejectRequest).toHaveBeenCalledWith(
+ 'd',
+ providerErrors.userRejectedRequest(),
+ );
+ });
+
+ it('does nothing when there are no unapproved transactions', () => {
+ rejectPendingTransactions([tx('a', TransactionStatus.confirmed)]);
+
+ expect(mockRejectRequest).not.toHaveBeenCalled();
+ expect(Logger.error).not.toHaveBeenCalled();
+ });
+
+ it('logs and continues when a rejection throws', () => {
+ const error = new Error('boom');
+ mockRejectRequest.mockImplementationOnce(() => {
+ throw error;
+ });
+
+ rejectPendingTransactions([
+ tx('a', TransactionStatus.unapproved),
+ tx('b', TransactionStatus.unapproved),
+ ]);
+
+ expect(Logger.error).toHaveBeenCalledWith(
+ error,
+ 'Failed to reject pending transaction',
+ );
+ // The throw on the first tx does not stop the second from being rejected.
+ expect(mockRejectRequest).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/app/components/UI/Money/utils/rejectPendingTransactions.ts b/app/components/UI/Money/utils/rejectPendingTransactions.ts
new file mode 100644
index 00000000000..2d43c98fe1a
--- /dev/null
+++ b/app/components/UI/Money/utils/rejectPendingTransactions.ts
@@ -0,0 +1,31 @@
+import { providerErrors } from '@metamask/rpc-errors';
+import {
+ TransactionStatus,
+ type TransactionMeta,
+} from '@metamask/transaction-controller';
+import Engine from '../../../../core/Engine';
+import Logger from '../../../../util/Logger';
+
+/**
+ * Reject every unapproved transaction so a stale one is not picked up by the
+ * next confirmation screen. Best-effort: failures are logged, not thrown.
+ *
+ * @param transactions - Transactions to scan; only `unapproved` ones are rejected.
+ */
+export function rejectPendingTransactions(transactions: TransactionMeta[]) {
+ const { ApprovalController } = Engine.context;
+
+ for (const tx of transactions) {
+ if (tx.status !== TransactionStatus.unapproved) {
+ continue;
+ }
+ try {
+ ApprovalController.rejectRequest(
+ tx.id,
+ providerErrors.userRejectedRequest(),
+ );
+ } catch (error) {
+ Logger.error(error as Error, 'Failed to reject pending transaction');
+ }
+ }
+}
diff --git a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx
index 3efafd3ae02..08a62e871cc 100644
--- a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx
+++ b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx
@@ -1212,4 +1212,44 @@ describe('PerpsProgressBar', () => {
}).not.toThrow();
});
});
+
+ describe('withdrawal progress selector stability (TAT-3328)', () => {
+ it('returns a stable fallback reference when withdrawalProgress is null/undefined', () => {
+ renderWithProvider();
+
+ // Capture the selector function passed to usePerpsSelector
+ const selector = mockUsePerpsSelector.mock.calls[0][0] as (
+ state: { withdrawalProgress?: unknown } | undefined,
+ ) => unknown;
+
+ // Simulate Redux evaluating the selector across unrelated dispatches
+ const firstResult = selector({ withdrawalProgress: undefined });
+ const secondResult = selector({ withdrawalProgress: undefined });
+ const thirdResult = selector(undefined);
+
+ // A new object literal on each call would break referential stability and
+ // cause spurious re-renders. The fallback must be the same reference.
+ expect(firstResult).toBe(secondResult);
+ expect(secondResult).toBe(thirdResult);
+ });
+
+ it('returns the actual withdrawalProgress when present, even with progress 0', () => {
+ renderWithProvider();
+
+ const selector = mockUsePerpsSelector.mock.calls[0][0] as (
+ state: { withdrawalProgress?: unknown } | undefined,
+ ) => unknown;
+
+ const validProgress = {
+ progress: 0,
+ lastUpdated: 123,
+ activeWithdrawalId: 'withdrawal1',
+ };
+
+ // A valid object with progress 0 must NOT be replaced by the fallback.
+ expect(selector({ withdrawalProgress: validProgress })).toBe(
+ validProgress,
+ );
+ });
+ });
});
diff --git a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.tsx b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.tsx
index e4d2731f793..025be62da40 100644
--- a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.tsx
+++ b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.tsx
@@ -26,6 +26,12 @@ import {
} from '@metamask/perps-controller';
import { HYPERLIQUID_WITHDRAWAL_PROGRESS_INTERVAL_MS } from '../../constants/perpsUIConfig';
+// Hoisted to module scope so the selector returns a stable reference when
+// withdrawalProgress is null/undefined. An inline object literal would create a
+// new reference on every Redux evaluation, breaking selector referential
+// stability and causing spurious re-renders on unrelated dispatches.
+const EMPTY_WITHDRAWAL_PROGRESS = { progress: 0, lastUpdated: 0 } as const;
+
interface PerpsProgressBarProps {
/**
* Progress amount as a number between 0 and 100
@@ -107,7 +113,7 @@ export const PerpsProgressBar: React.FC = ({
// Get persistent withdrawal progress from controller
const persistentWithdrawalProgress = usePerpsSelector(
- (state) => state?.withdrawalProgress || { progress: 0, lastUpdated: 0 },
+ (state) => state?.withdrawalProgress ?? EMPTY_WITHDRAWAL_PROGRESS,
);
// Track transaction progress
diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts
index 5c8c879d3ef..6775c5e79ce 100644
--- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts
@@ -22,12 +22,17 @@ import {
ARBITRUM_MAINNET_CHAIN_ID_HEX,
} from '@metamask/perps-controller';
import { selectTransactionBridgeQuotesById } from '../../../../core/redux/slices/confirmationMetrics';
+import { isPerpsPredictMoneyDeposit } from '../../Money/utils/moneyTransactionGuards';
// Mock dependencies
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
+jest.mock('../../Money/utils/moneyTransactionGuards', () => ({
+ isPerpsPredictMoneyDeposit: jest.fn(() => false),
+}));
+
jest.mock('./stream/usePerpsLiveAccount');
jest.mock('./usePerpsTrading');
jest.mock('./usePerpsToasts');
@@ -66,6 +71,10 @@ const mockSelectTransactionBridgeQuotesById =
selectTransactionBridgeQuotesById as jest.MockedFunction<
typeof selectTransactionBridgeQuotesById
>;
+const mockIsPerpsPredictMoneyDeposit =
+ isPerpsPredictMoneyDeposit as jest.MockedFunction<
+ typeof isPerpsPredictMoneyDeposit
+ >;
describe('usePerpsDepositStatus', () => {
let mockSubscribe: jest.Mock;
@@ -96,6 +105,8 @@ describe('usePerpsDepositStatus', () => {
// Mock selectTransactionBridgeQuotesById
mockSelectTransactionBridgeQuotesById.mockReturnValue([]);
+ mockIsPerpsPredictMoneyDeposit.mockReturnValue(false);
+
// Default mock for usePerpsLiveAccount
mockUsePerpsLiveAccount.mockReturnValue({
account: {
@@ -329,6 +340,60 @@ describe('usePerpsDepositStatus', () => {
).toHaveBeenCalledWith(60, 'test-tx-id');
});
+ it('should not show any toast when perpsDeposit is funded from the Money account', () => {
+ mockShowToast.mockClear();
+ mockIsPerpsPredictMoneyDeposit.mockReturnValue(true);
+ renderHook(() => usePerpsDepositStatus());
+ const transactionMeta: TransactionMeta = {
+ id: 'test-tx-id',
+ type: TransactionType.perpsDeposit,
+ status: TransactionStatus.approved,
+ } as TransactionMeta;
+
+ act(() => {
+ transactionHandler({ transactionMeta });
+ });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ expect(
+ mockPerpsToastOptions.accountManagement.deposit.inProgress,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('should not set expecting state when perpsDeposit is funded from the Money account', () => {
+ mockShowToast.mockClear();
+ mockIsPerpsPredictMoneyDeposit.mockReturnValue(true);
+ const { rerender } = renderHook(() => usePerpsDepositStatus());
+
+ act(() => {
+ transactionHandler({
+ transactionMeta: {
+ id: 'test-tx-id',
+ type: TransactionType.perpsDeposit,
+ status: TransactionStatus.approved,
+ } as TransactionMeta,
+ });
+ });
+
+ mockUsePerpsLiveAccount.mockReturnValue({
+ account: {
+ spendableBalance: '1500.00',
+ withdrawableBalance: '1500.00',
+ marginUsed: '9000.00',
+ unrealizedPnl: '100.00',
+ returnOnEquity: '0.15',
+ totalBalance: '10600.00',
+ },
+ isInitialLoading: false,
+ });
+
+ rerender({});
+
+ expect(
+ mockPerpsToastOptions.accountManagement.deposit.success,
+ ).not.toHaveBeenCalled();
+ });
+
it('should not show in-progress toast when perpsDeposit transaction is submitted', () => {
renderHook(() => usePerpsDepositStatus());
const transactionMeta: TransactionMeta = {
diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts
index 6aae71a93d6..e90c55d05bc 100644
--- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts
+++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts
@@ -14,6 +14,7 @@ import {
import { usePerpsLiveAccount } from './stream/usePerpsLiveAccount';
import usePerpsToasts from './usePerpsToasts';
import { usePerpsTrading } from './usePerpsTrading';
+import { isPerpsPredictMoneyDeposit } from '../../Money/utils/moneyTransactionGuards';
/**
* Hook to monitor deposit status and show appropriate toasts
@@ -71,6 +72,10 @@ export const usePerpsDepositStatus = () => {
transactionMeta.type === TransactionType.perpsDeposit &&
transactionMeta.status === TransactionStatus.approved
) {
+ if (isPerpsPredictMoneyDeposit(transactionMeta)) {
+ return;
+ }
+
expectingDepositRef.current = true;
prevSpendableBalanceRef.current =
liveAccountRef.current?.spendableBalance || '0';
diff --git a/app/components/UI/Predict/components/PredictChipList/PredictChipList.test.tsx b/app/components/UI/Predict/components/PredictChipList/PredictChipList.test.tsx
index 577c9d12a02..5db9c67f991 100644
--- a/app/components/UI/Predict/components/PredictChipList/PredictChipList.test.tsx
+++ b/app/components/UI/Predict/components/PredictChipList/PredictChipList.test.tsx
@@ -1,7 +1,15 @@
import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
+import {
+ act,
+ render,
+ fireEvent,
+ renderHook,
+ waitFor,
+} from '@testing-library/react-native';
+import type { LayoutChangeEvent, ScrollView } from 'react-native';
import PredictChipList from './PredictChipList';
import { calculateChipScrollX } from './calculateChipScrollX';
+import { useChipScrollList } from './useChipScrollList';
import {
PREDICT_CHIP_LIST_TEST_IDS,
getPredictChipTestId,
@@ -9,6 +17,8 @@ import {
} from './PredictChipList.testIds';
import type { PredictChipItem } from './PredictChipList.types';
+const mockScrollTo = jest.fn();
+
jest.mock('@metamask/design-system-twrnc-preset', () => ({
useTailwind: () => ({
style: (...classes: (string | boolean | undefined)[]) => ({
@@ -210,6 +220,68 @@ describe('PredictChipList', () => {
});
});
+describe('useChipScrollList', () => {
+ const layoutEvent = (x: number, width: number): LayoutChangeEvent =>
+ ({
+ nativeEvent: { layout: { x, y: 0, width, height: 40 } },
+ }) as LayoutChangeEvent;
+
+ beforeEach(() => {
+ mockScrollTo.mockClear();
+ });
+
+ it('scrolls the active chip into view when activeChipIndex changes', async () => {
+ const { result, rerender } = renderHook(
+ ({ activeChipIndex }: { activeChipIndex: number }) =>
+ useChipScrollList(3, { activeChipIndex }),
+ { initialProps: { activeChipIndex: 0 } },
+ );
+
+ act(() => {
+ result.current.scrollViewRef.current = {
+ scrollTo: mockScrollTo,
+ } as unknown as ScrollView;
+ result.current.handleScrollViewLayout(layoutEvent(0, 200));
+ result.current.handleChipLayout(0, layoutEvent(16, 100));
+ result.current.handleChipLayout(1, layoutEvent(124, 100));
+ result.current.handleChipLayout(2, layoutEvent(232, 100));
+ });
+ mockScrollTo.mockClear();
+
+ rerender({ activeChipIndex: 2 });
+
+ await waitFor(() => {
+ expect(mockScrollTo).toHaveBeenCalledWith({ x: 182, animated: true });
+ });
+ });
+
+ it('does not re-scroll on incidental relayouts once the active chip is positioned', () => {
+ const { result } = renderHook(() =>
+ useChipScrollList(3, { activeChipIndex: 2 }),
+ );
+
+ act(() => {
+ result.current.scrollViewRef.current = {
+ scrollTo: mockScrollTo,
+ } as unknown as ScrollView;
+ result.current.handleScrollViewLayout(layoutEvent(0, 200));
+ result.current.handleChipLayout(0, layoutEvent(16, 100));
+ result.current.handleChipLayout(1, layoutEvent(124, 100));
+ result.current.handleChipLayout(2, layoutEvent(232, 100));
+ });
+
+ expect(mockScrollTo).toHaveBeenCalledTimes(1);
+ mockScrollTo.mockClear();
+
+ act(() => {
+ result.current.handleChipLayout(2, layoutEvent(240, 110));
+ result.current.handleScrollViewLayout(layoutEvent(0, 200));
+ });
+
+ expect(mockScrollTo).not.toHaveBeenCalled();
+ });
+});
+
describe('calculateChipScrollX', () => {
const VIEWPORT = 375;
diff --git a/app/components/UI/Predict/components/PredictChipList/PredictChipList.tsx b/app/components/UI/Predict/components/PredictChipList/PredictChipList.tsx
index 0e2b4816321..30e30cd6d45 100644
--- a/app/components/UI/Predict/components/PredictChipList/PredictChipList.tsx
+++ b/app/components/UI/Predict/components/PredictChipList/PredictChipList.tsx
@@ -1,4 +1,4 @@
-import React, { memo, useCallback } from 'react';
+import React, { memo, useCallback, useMemo } from 'react';
import { Image, Pressable, ScrollView } from 'react-native';
import { ScrollView as GestureHandlerScrollView } from 'react-native-gesture-handler';
import {
@@ -35,12 +35,16 @@ const PredictChipList: React.FC = ({
useGestureHandlerScrollView = false,
}) => {
const tw = useTailwind();
+ const activeChipIndex = useMemo(
+ () => chips.findIndex((chip) => chip.key === activeChipKey),
+ [chips, activeChipKey],
+ );
const {
scrollViewRef,
handleScrollViewLayout,
handleChipLayout,
scrollToChipAtIndex,
- } = useChipScrollList(chips.length);
+ } = useChipScrollList(chips.length, { activeChipIndex });
const handlePress = useCallback(
(key: string, index: number) => {
diff --git a/app/components/UI/Predict/components/PredictChipList/useChipScrollList.ts b/app/components/UI/Predict/components/PredictChipList/useChipScrollList.ts
index cfb21b9b319..d6d3d137789 100644
--- a/app/components/UI/Predict/components/PredictChipList/useChipScrollList.ts
+++ b/app/components/UI/Predict/components/PredictChipList/useChipScrollList.ts
@@ -1,32 +1,38 @@
-import { useCallback, useRef } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import type { LayoutChangeEvent, ScrollView } from 'react-native';
import { calculateChipScrollX } from './calculateChipScrollX';
-export function useChipScrollList(chipCount: number) {
+interface UseChipScrollListOptions {
+ activeChipIndex?: number;
+}
+
+interface ScrollToChipOptions {
+ animated?: boolean;
+}
+
+export function useChipScrollList(
+ chipCount: number,
+ options: UseChipScrollListOptions = {},
+) {
+ const { activeChipIndex } = options;
const scrollViewRef = useRef(null);
const chipLayoutsRef = useRef