From c4d30a4a60760bfd18ed61d040a04cb3c7d092be Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 5 Feb 2026 13:31:36 +0100 Subject: [PATCH 01/33] feat: bump bitcoin 1.10.0 (#25625) ## **Description** Bumps snap Bitcoin 1.10.0. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Dependency-only change, but it upgrades the Bitcoin Snap used by the app, which can affect Bitcoin balance/transaction event behavior at runtime. Risk is mainly from upstream snap changes rather than local code modifications. > > **Overview** > Upgrades `@metamask/bitcoin-wallet-snap` from `^1.9.0` to `^1.10.0` and updates `yarn.lock` accordingly, pulling in the new snap release (notably including upstream changes around balance/transaction event emission). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f6a98eafa76768706bca67dfe4df9b09d6351bbe. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index bb2a87d6c4d..e85cde0ff23 100644 --- a/package.json +++ b/package.json @@ -208,7 +208,7 @@ "@metamask/approval-controller": "^8.0.0", "@metamask/assets-controllers": "^99.0.0", "@metamask/base-controller": "^9.0.0", - "@metamask/bitcoin-wallet-snap": "^1.9.0", + "@metamask/bitcoin-wallet-snap": "^1.10.0", "@metamask/bridge-controller": "^64.8.0", "@metamask/bridge-status-controller": "^64.4.5", "@metamask/chain-agnostic-permission": "^1.3.0", diff --git a/yarn.lock b/yarn.lock index 448dd5e3a03..ae38b866183 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7597,10 +7597,10 @@ __metadata: languageName: node linkType: hard -"@metamask/bitcoin-wallet-snap@npm:^1.9.0": - version: 1.9.0 - resolution: "@metamask/bitcoin-wallet-snap@npm:1.9.0" - checksum: 10/cfcd2da23ccccddeb981d2e81576995e6ee9ad4e16023d238219c29feecf9e5ac9a49baeadc79013d2001b6aa20170897f082ae9c18c91ae124e17688aa4f973 +"@metamask/bitcoin-wallet-snap@npm:^1.10.0": + version: 1.10.0 + resolution: "@metamask/bitcoin-wallet-snap@npm:1.10.0" + checksum: 10/43bc519f0d6f243658c32ddce3418277f2c7b745a37713ad2eb7927ff6fcc038f7c23dc6fb1933b56bc7ffb5e6dacf403470ee215cf5e0a138e765cd0b14ada5 languageName: node linkType: hard @@ -34671,7 +34671,7 @@ __metadata: "@metamask/assets-controllers": "npm:^99.0.0" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bitcoin-wallet-snap": "npm:^1.9.0" + "@metamask/bitcoin-wallet-snap": "npm:^1.10.0" "@metamask/bridge-controller": "npm:^64.8.0" "@metamask/bridge-status-controller": "npm:^64.4.5" "@metamask/browser-passworder": "npm:^5.0.0" From f49598ec07550995da44b486c9cf5fb77fc3666c Mon Sep 17 00:00:00 2001 From: javiergarciavera <76975121+javiergarciavera@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:08:03 +0100 Subject: [PATCH 02/33] test: added missing permissions to call other workflows (#25697) ## **Description** Adding missing permissions to the workflow ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMQA-1360 Follow up PR of https://github.com/MetaMask/metamask-mobile/pull/25694 ## **Manual testing steps** Trigger workflow Performance E2E Tests for Experimental Builds and builds are correctly created. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk workflow-only change; it broadens GitHub Actions permissions (notably `actions: write` and `id-token: write`), which could impact security if misused. > > **Overview** > Adds an explicit `permissions` block to `run-performance-e2e-experimental.yml` to allow the workflow to invoke other workflows and use OIDC as needed. > > No test logic or triggers change; this is solely an update to GitHub Actions permission scopes (`contents: read`, `id-token: write`, `actions: write`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6c863a4c1a8ed9994a3f8e9994f3398202411b8c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/run-performance-e2e-experimental.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/run-performance-e2e-experimental.yml b/.github/workflows/run-performance-e2e-experimental.yml index 7e6dc967ddf..a5793626bb0 100644 --- a/.github/workflows/run-performance-e2e-experimental.yml +++ b/.github/workflows/run-performance-e2e-experimental.yml @@ -9,6 +9,11 @@ on: branches: - main +permissions: + contents: read + id-token: write + actions: write + concurrency: group: performance-e2e-experimental-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: false From 73390d0ffe530f9800bbe33cf32eafba9db727d7 Mon Sep 17 00:00:00 2001 From: Nico MASSART Date: Thu, 5 Feb 2026 15:08:30 +0100 Subject: [PATCH 03/33] refactor(analytics): migrate Batch 1-4: predict (#25650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR migrates analytics tracking in the Predict feature from the legacy `MetaMetrics` singleton pattern to the new standardized `analytics` API as part of Batch 1-4 analytics migration. **Changes:** - Updated `PredictController` to use `analytics.trackEvent()` with `AnalyticsEventBuilder` instead of `MetaMetrics.getInstance().trackEvent()` with `MetricsEventBuilder` - Migrated `PolymarketProvider` user trait tracking from `addTraitsToUser()` to `analytics.identify()` ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [MCWP-297](https://consensyssoftware.atlassian.net/browse/MCWP-297) ## **Manual testing steps** ```gherkin Feature: Predict feature analytics tracking Scenario: user interacts with Predict feature Given the app is running with analytics enabled And the user opted-in for analytics When user navigates to Predict feed, views markets, positions, and performs trades Then analytics events are tracked: - "Predict Trade Transaction" - "Predict Market Details Opened" - "Predict Position Viewed" - "Predict Activity Viewed" - "Geo Blocked Triggered" - "Predict Feed Viewed" - "Share Action" And user trait "created_polymarket_account_via_mm" "true" is identified in Mixpanel when creating Polymarket account ``` ## **Screenshots/Recordings** ### **Before** NA ### **After** NA ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [MCWP-297]: https://consensyssoftware.atlassian.net/browse/MCWP-297?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Changes swap out the analytics implementation for Predict events and user identification, which could silently affect event/trait delivery or schema if the new builder/API differs; functional business logic is otherwise unchanged. > > **Overview** > Migrates Predict analytics from the legacy `MetaMetrics` singleton to the new `analytics` API, replacing `MetricsEventBuilder` usage with `AnalyticsEventBuilder` across Predict event tracking (trade transaction, market details opened, position/activity viewed, geoblock triggered, feed viewed, share action). > > Updates Polymarket trait capture to use `analytics.identify()` for the "created Polymarket account via MetaMask" user property, and adds/updates unit tests to mock `analytics` and assert `trackEvent` is (or isn’t) invoked for the controller’s tracking methods. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c753df186ea62dbaed0c81f6f21ca5763b9dc4ba. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../controllers/PredictController.test.ts | 90 +++++++++++++++++++ .../Predict/controllers/PredictController.ts | 33 +++---- .../polymarket/PolymarketProvider.ts | 25 +----- 3 files changed, 111 insertions(+), 37 deletions(-) diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 03a41eb07c6..96a33f071e6 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -32,6 +32,7 @@ import { PredictControllerMessenger, type PredictControllerState, } from './PredictController'; +import { analytics } from '../../../../util/analytics/analytics'; // Mock the PolymarketProvider and its dependencies jest.mock('../providers/polymarket/PolymarketProvider'); @@ -88,6 +89,13 @@ jest.mock('@metamask/controller-utils', () => { }; }); +// Mock analytics module +jest.mock('../../../../util/analytics/analytics', () => ({ + analytics: { + trackEvent: jest.fn(), + }, +})); + type AllPredictControllerMessengerActions = MessengerActions; @@ -5783,4 +5791,86 @@ describe('PredictController', () => { }); }); }); + + describe('Analytics Tracking', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls analytics.trackEvent for trackPredictOrderEvent', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'succeeded', + analyticsProperties: { marketId: 'test' }, + providerId: 'polymarket', + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('does not call analytics.trackEvent when analyticsProperties is missing for trackPredictOrderEvent', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'succeeded', + providerId: 'polymarket', + }); + expect(analytics.trackEvent).not.toHaveBeenCalled(); + }); + }); + + it('calls analytics.trackEvent for trackMarketDetailsOpened', () => { + withController(({ controller }) => { + controller.trackMarketDetailsOpened({ + marketId: 'test', + marketTitle: 'test', + entryPoint: 'test', + marketDetailsViewed: 'test', + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackPositionViewed', () => { + withController(({ controller }) => { + controller.trackPositionViewed({ openPositionsCount: 5 }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackActivityViewed', () => { + withController(({ controller }) => { + controller.trackActivityViewed({ activityType: 'all' }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackGeoBlockTriggered', () => { + withController(({ controller }) => { + controller.trackGeoBlockTriggered({ + providerId: 'polymarket', + attemptedAction: 'deposit', + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackFeedViewed', () => { + withController(({ controller }) => { + controller.trackFeedViewed({ + sessionId: 'test', + feedTab: 'test', + numPagesViewed: 1, + sessionTime: 1000, + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('calls analytics.trackEvent for trackShareAction', () => { + withController(({ controller }) => { + controller.trackShareAction({ status: 'success' }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 4e9c5f23a3a..6923ec653f1 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -36,8 +36,9 @@ import { } from '@metamask/remote-feature-flag-controller'; import { Hex, hexToNumber, numberToHex } from '@metamask/utils'; import performance from 'react-native-performance'; -import { MetaMetrics, MetaMetricsEvents } from '../../../../core/Analytics'; -import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { analytics } from '../../../../util/analytics/analytics'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Logger, { type LoggerErrorOptions } from '../../../../util/Logger'; import { @@ -1210,8 +1211,8 @@ export class PredictController extends BaseController< sensitiveProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_TRADE_TRANSACTION, ) .addProperties(regularProperties) @@ -1287,8 +1288,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_MARKET_DETAILS_OPENED, ) .addProperties(analyticsProperties) @@ -1313,8 +1314,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_POSITION_VIEWED, ) .addProperties(analyticsProperties) @@ -1335,8 +1336,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_ACTIVITY_VIEWED, ) .addProperties(analyticsProperties) @@ -1364,8 +1365,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_GEO_BLOCKED_TRIGGERED, ) .addProperties(analyticsProperties) @@ -1413,8 +1414,8 @@ export class PredictController extends BaseController< isSessionEnd, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder( + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.PREDICT_FEED_VIEWED, ) .addProperties(analyticsProperties) @@ -1449,8 +1450,8 @@ export class PredictController extends BaseController< analyticsProperties, }); - MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder(MetaMetricsEvents.SHARE_ACTION) + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder(MetaMetricsEvents.SHARE_ACTION) .addProperties(analyticsProperties) .build(), ); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 9e4120b31a9..228b36941c9 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -7,7 +7,7 @@ import { Hex, numberToHex } from '@metamask/utils'; import { parseUnits } from 'ethers/lib/utils'; import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; import Logger, { type LoggerErrorOptions } from '../../../../../util/Logger'; -import { MetaMetrics } from '../../../../../core/Analytics'; +import { analytics } from '../../../../../util/analytics/analytics'; import { UserProfileProperty } from '../../../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; import { generateTransferData, @@ -1403,28 +1403,11 @@ export class PolymarketProvider implements PredictProvider { /** * Set user trait for Polymarket account creation via MetaMask - * Fire-and-forget operation that logs errors but doesn't fail */ private setPolymarketAccountCreatedTrait(): void { - MetaMetrics.getInstance() - .addTraitsToUser({ - [UserProfileProperty.CREATED_POLYMARKET_ACCOUNT_VIA_MM]: true, - }) - .catch((error) => { - // Log error but don't fail the deposit preparation - Logger.error(error as Error, { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - provider: 'polymarket', - }, - context: { - name: 'PolymarketProvider', - data: { - method: 'setPolymarketAccountCreatedTrait', - }, - }, - }); - }); + analytics.identify({ + [UserProfileProperty.CREATED_POLYMARKET_ACCOUNT_VIA_MM]: true, + }); } public async prepareDeposit( From d270ce5229a3fc5a123fb1e6de19657438f7508c Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 5 Feb 2026 15:58:06 +0100 Subject: [PATCH 04/33] fix(perps): clear confirmation on order view unmount cp-7.64.0 (#25708) ## **Description** Clear transaction confirmation when user leaves the perps order screen ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/25439 ## **Manual testing steps** - Go to Perps - Start a new order - Go back to home page - Start Send flow - Select any token from any EVM network (on non-EVM networks the error does not trigger, but the flow is broken) - Input amount - Select recipient - NO error should be shown ## **Screenshots/Recordings** ### **Before** no visible change ### **After** no visible change ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches shared confirmation state by force-rejecting on unmount; a mistake here could prematurely dismiss legitimate confirmations or change navigation/UX around confirmations. > > **Overview** > Clears any active confirmations when the Perps order screen is left by replacing `useClearConfirmationOnBackSwipe()` with an explicit unmount cleanup that calls `useConfirmActions().onReject(undefined, true)`. > > This prevents stale confirmation state from leaking into other flows (e.g., Send) after backing out of Perps. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 616455e2bbcc75fac19f7d8978b03dc2bae638ed. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Cursor --- .../UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 3baf906ac42..59771c76f1f 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -58,7 +58,6 @@ import { useAddToken } from '../../../../Views/confirmations/hooks/tokens/useAdd import { useAutomaticTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useAutomaticTransactionPayToken'; import { useTransactionPayMetrics } from '../../../../Views/confirmations/hooks/pay/useTransactionPayMetrics'; import { useTransactionMetadataRequest } from '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; -import useClearConfirmationOnBackSwipe from '../../../../Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe'; import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount'; import RewardsAnimations, { RewardAnimationState, @@ -147,6 +146,7 @@ import { useTransactionConfirm } from '../../../../Views/confirmations/hooks/tra import { useTransactionCustomAmount } from '../../../../Views/confirmations/hooks/transactions/useTransactionCustomAmount'; import { useUpdateTokenAmount } from '../../../../Views/confirmations/hooks/transactions/useUpdateTokenAmount'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; +import { useConfirmActions } from '../../../../Views/confirmations/hooks/useConfirmActions'; // Navigation params interface interface OrderRouteParams { @@ -202,7 +202,15 @@ const PerpsOrderViewContentBase: React.FC = ({ tokenAddress: ARBITRUM_USDC.address, }); - useClearConfirmationOnBackSwipe(); + // Clear confirmation when leaving the order view + const { onReject } = useConfirmActions(); + useEffect( + () => () => { + onReject(undefined, true); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); // Disable automatic token selection - we want to show "Perps balance" by default // User can explicitly select a token from the modal From c9d5adcc8226b83057116210509f4617a79e8237 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:26:25 -0500 Subject: [PATCH 05/33] feat: MUSD-280 Make mUSD Conversion Primary CTA text clickable (#25676) ## **Description** This PR makes the "Earn a 3% bonus" text in the mUSD conversion CTA clickable. ## **Changelog** CHANGELOG entry: updates the "Earn a 3% bonus" text in the mUSD conversion CTA to be clickable. ## **Related issues** Fixes: [MUSD-280: mUSD Conversion Primary CTA isn't clickable](https://consensyssoftware.atlassian.net/browse/MUSD-280) ## **Manual testing steps** ```gherkin Feature: mUSD conversion CTA link text Scenario: user taps the bonus text CTA Given user is viewing the mUSD conversion CTA on the token list When user taps the "Earn a % bonus" text Then the conversion CTA action is initiated And an analytics event is tracked with cta_text set to the bonus text ``` ## **Screenshots/Recordings** ### **Before** N/A - Text isn't clickable ### **After** https://github.com/user-attachments/assets/dc1246c9-0659-4562-93bd-f5854c50395a ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > UI interaction and analytics payload changes scoped to the mUSD conversion CTA; no auth/security or persistent data changes, but tracking fields and click targets could regress if event expectations are wrong. > > **Overview** > Makes the mUSD conversion promo line ("Earn a X% bonus") tappable by wrapping it in a `TouchableOpacity` and routing it through the same CTA handler as the button. > > Updates MetaMetrics tracking to distinguish *button vs text* clicks (`cta_text` now reflects the actual pressed element), and changes network analytics fields to use `selectedChainId`/`useNetworkName` when available with a fallback to `wallet.popular_networks`. > > Expands/rewrites unit tests to cover clicking the bonus text, the new event properties (`cta_text`, `redirects_to`, `network_name` fallback), and `useNetworkName` behavior when no chain is selected. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1ddd5fdbf1f7c5e3c1b0e488d685d7d53b8f1fb5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). Cursor Bugbot reviewed your changes and found no issues for commit 1ddd5fd --- .../MusdConversionAssetListCta.test.tsx | 303 +++++++++++++----- .../Musd/MusdConversionAssetListCta/index.tsx | 42 +-- 2 files changed, 243 insertions(+), 102 deletions(-) diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx index 27277857b6e..94ddb606cd8 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx @@ -77,7 +77,9 @@ describe('MusdConversionAssetListCta', () => { ( useNetworkName as jest.MockedFunction - ).mockReturnValue('Ethereum Mainnet'); + ).mockImplementation((chainId) => + chainId ? 'Ethereum Mainnet' : undefined, + ); ( useRampNavigation as jest.MockedFunction @@ -278,6 +280,23 @@ describe('MusdConversionAssetListCta', () => { }); }); + it('calls goToBuy when earn percentage text is pressed', () => { + const { getByText } = renderWithProvider(, { + state: initialRootState, + }); + + const bonusText = strings('earn.earn_a_percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }); + + const bonusTextElement = getByText(bonusText); + fireEvent.press(bonusTextElement.parent as never); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + }); + }); + it('does not call initiateConversion when wallet is empty', () => { const { getByText } = renderWithProvider(, { state: initialRootState, @@ -645,25 +664,42 @@ describe('MusdConversionAssetListCta', () => { }); }); - describe('MetaMetrics', () => { + describe('event tracking (MetaMetrics)', () => { const { EVENT_LOCATIONS, MUSD_CTA_TYPES } = MUSD_EVENTS_CONSTANTS; - it('tracks mUSD conversion CTA clicked event when Buy mUSD is pressed', () => { - // Arrange + interface ArrangeOptions { + isEmptyWallet: boolean; + selectedChainId: Hex | null; + hasSeenConversionEducationScreen: boolean; + } + + const arrange = ({ + isEmptyWallet, + selectedChainId, + hasSeenConversionEducationScreen, + }: ArrangeOptions) => { + ( + useMusdConversion as jest.MockedFunction + ).mockReturnValue({ + initiateConversion: mockInitiateConversion, + error: null, + hasSeenConversionEducationScreen, + }); + ( useMusdConversionFlowData as jest.MockedFunction< typeof useMusdConversionFlowData > ).mockReturnValue({ - isEmptyWallet: true, + isEmptyWallet, getPaymentTokenForSelectedNetwork: mockGetPreferredPaymentToken, getChainIdForBuyFlow: mockGetChainIdForBuyFlow, isPopularNetworksFilterActive: false, - selectedChainId: null, - selectedChains: [], + selectedChainId, + selectedChains: selectedChainId ? [selectedChainId] : [], isGeoEligible: true, - hasConvertibleTokens: false, - conversionTokens: [], + hasConvertibleTokens: !isEmptyWallet, + conversionTokens: isEmptyWallet ? [] : [mockConversionToken], isMusdBuyableOnChain: {}, isMusdBuyableOnAnyChain: false, isMusdBuyable: false, @@ -675,117 +711,216 @@ describe('MusdConversionAssetListCta', () => { shouldShowBuyGetMusdCta: jest.fn().mockReturnValue({ shouldShowCta: true, showNetworkIcon: false, - selectedChainId: null, - isEmptyWallet: true, + selectedChainId, + isEmptyWallet, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), }); - const { getByText } = renderWithProvider(, { + return renderWithProvider(, { state: initialRootState, }); + }; - // Act - fireEvent.press(getByText(strings('earn.musd_conversion.buy_musd'))); - - // Assert + const expectTrackedEventProps = ( + expectedProps: Record, + ) => { expect(mockCreateEventBuilder).toHaveBeenCalledTimes(1); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED, ); expect(mockAddProperties).toHaveBeenCalledTimes(1); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: EVENT_LOCATIONS.HOME_SCREEN, - redirects_to: EVENT_LOCATIONS.BUY_SCREEN, - cta_type: MUSD_CTA_TYPES.PRIMARY, - cta_text: strings('earn.musd_conversion.buy_musd'), - network_chain_id: MUSD_CONVERSION_DEFAULT_CHAIN_ID, - network_name: 'Ethereum Mainnet', - }); + expect(mockAddProperties).toHaveBeenCalledWith(expectedProps); expect(mockTrackEvent).toHaveBeenCalledTimes(1); expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); - }); + }; - // TODO: Missing test case: tracks mUSD conversion CTA clicked event when Get mUSD is pressed and education screen already seen - it('tracks mUSD conversion CTA clicked event when Get mUSD is pressed and education screen has not been seen', async () => { - // Arrange - ( - useMusdConversion as jest.MockedFunction - ).mockReturnValue({ - initiateConversion: mockInitiateConversion, - error: null, - hasSeenConversionEducationScreen: false, - }); + type GetByText = ReturnType['getByText']; - const { getByText } = renderWithProvider(, { - state: initialRootState, + const pressCtaButton = (getByText: GetByText, isEmptyWallet: boolean) => { + const buttonLabel = strings( + isEmptyWallet + ? 'earn.musd_conversion.buy_musd' + : 'earn.musd_conversion.get_musd', + ); + fireEvent.press(getByText(buttonLabel)); + return buttonLabel; + }; + + const pressEarnBonusText = (getByText: GetByText) => { + const bonusText = strings('earn.earn_a_percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }); + const bonusTextElement = getByText(bonusText); + fireEvent.press(bonusTextElement.parent as never); + return bonusText; + }; + + describe('network_name', () => { + it('uses network name when selectedChainId is defined', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: CHAIN_IDS.MAINNET, + hasSeenConversionEducationScreen: true, + }); + + const ctaText = pressCtaButton(getByText, true); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + network_chain_id: CHAIN_IDS.MAINNET, + network_name: 'Ethereum Mainnet', + }); }); - // Act - await act(async () => { - fireEvent.press(getByText(strings('earn.musd_conversion.get_musd'))); + it('falls back to "popular networks" when selectedChainId is undefined', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); + + const ctaText = pressCtaButton(getByText, true); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); + }); - // Assert - expect(mockCreateEventBuilder).toHaveBeenCalledTimes(1); - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED, - ); + describe('cta_text', () => { + it('tracks "Buy mUSD" when the CTA button is clicked and wallet is empty', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); - expect(mockAddProperties).toHaveBeenCalledTimes(1); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: EVENT_LOCATIONS.HOME_SCREEN, - redirects_to: EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN, - cta_type: MUSD_CTA_TYPES.PRIMARY, - cta_text: strings('earn.musd_conversion.get_musd'), - network_chain_id: MUSD_CONVERSION_DEFAULT_CHAIN_ID, - network_name: 'Ethereum Mainnet', + const ctaText = pressCtaButton(getByText, true); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); - expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); - }); + it('tracks "Get mUSD" when the CTA button is clicked and wallet has tokens', async () => { + const { getByText } = arrange({ + isEmptyWallet: false, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); - it('tracks mUSD conversion CTA clicked event when Get mUSD is pressed and education screen has been seen', async () => { - // Arrange - ( - useMusdConversion as jest.MockedFunction - ).mockReturnValue({ - initiateConversion: mockInitiateConversion, - error: null, - hasSeenConversionEducationScreen: true, + await act(async () => { + pressCtaButton(getByText, false); + }); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: strings('earn.musd_conversion.get_musd'), + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); - const { getByText } = renderWithProvider(, { - state: initialRootState, + it('tracks "Earn a X% bonus" when the earn percentage text is clicked', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); + + const ctaText = pressEarnBonusText(getByText); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); + }); - // Act - await act(async () => { - fireEvent.press(getByText(strings('earn.musd_conversion.get_musd'))); + describe('redirects_to', () => { + it('tracks BUY_SCREEN when wallet is empty', () => { + const { getByText } = arrange({ + isEmptyWallet: true, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); + + const ctaText = pressCtaButton(getByText, true); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.BUY_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: ctaText, + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); - // Assert - expect(mockCreateEventBuilder).toHaveBeenCalledTimes(1); - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED, - ); + it('tracks CONVERSION_EDUCATION_SCREEN when wallet has tokens and education has not been seen', async () => { + const { getByText } = arrange({ + isEmptyWallet: false, + selectedChainId: null, + hasSeenConversionEducationScreen: false, + }); - expect(mockAddProperties).toHaveBeenCalledTimes(1); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: EVENT_LOCATIONS.HOME_SCREEN, - redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, - cta_type: MUSD_CTA_TYPES.PRIMARY, - cta_text: strings('earn.musd_conversion.get_musd'), - network_chain_id: MUSD_CONVERSION_DEFAULT_CHAIN_ID, - network_name: 'Ethereum Mainnet', + await act(async () => { + pressCtaButton(getByText, false); + }); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: strings('earn.musd_conversion.get_musd'), + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); }); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); - expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + it('tracks CUSTOM_AMOUNT_SCREEN when wallet has tokens and education has been seen', async () => { + const { getByText } = arrange({ + isEmptyWallet: false, + selectedChainId: null, + hasSeenConversionEducationScreen: true, + }); + + await act(async () => { + pressCtaButton(getByText, false); + }); + + expectTrackedEventProps({ + location: EVENT_LOCATIONS.HOME_SCREEN, + redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + cta_type: MUSD_CTA_TYPES.PRIMARY, + cta_text: strings('earn.musd_conversion.get_musd'), + network_chain_id: null, + network_name: strings('wallet.popular_networks'), + }); + }); }); }); }); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx index 909bde78af0..9012c4a50ab 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View } from 'react-native'; +import { TouchableOpacity, View } from 'react-native'; import styleSheet from './MusdConversionAssetListCta.styles'; import Text, { TextVariant, @@ -12,7 +12,6 @@ import { } from '@metamask/design-system-react-native'; import { MUSD_CONVERSION_APY, - MUSD_CONVERSION_DEFAULT_CHAIN_ID, MUSD_TOKEN, MUSD_TOKEN_ASSET_ID_BY_CHAIN, } from '../../../constants/musd'; @@ -59,15 +58,13 @@ const MusdConversionAssetListCta = () => { const { shouldShowCta, showNetworkIcon, selectedChainId } = shouldShowBuyGetMusdCta(); - const networkName = useNetworkName( - selectedChainId ?? MUSD_CONVERSION_DEFAULT_CHAIN_ID, - ); + const networkName = useNetworkName(selectedChainId ?? undefined); - const ctaText = isEmptyWallet + const buttonText = isEmptyWallet ? strings('earn.musd_conversion.buy_musd') : strings('earn.musd_conversion.get_musd'); - const submitCtaPressedEvent = () => { + const submitCtaPressedEvent = (source: 'cta_button' | 'cta_text') => { const { MUSD_CTA_TYPES, EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; const getRedirectLocation = () => { @@ -80,6 +77,13 @@ const MusdConversionAssetListCta = () => { : EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN; }; + const ctaText = + source === 'cta_button' + ? buttonText + : strings('earn.earn_a_percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + }); + trackEvent( createEventBuilder(MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED) .addProperties({ @@ -87,15 +91,15 @@ const MusdConversionAssetListCta = () => { redirects_to: getRedirectLocation(), cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: ctaText, - network_chain_id: selectedChainId || MUSD_CONVERSION_DEFAULT_CHAIN_ID, - network_name: networkName, + network_chain_id: selectedChainId, + network_name: networkName ?? strings('wallet.popular_networks'), }) .build(), ); }; - const handlePress = async () => { - submitCtaPressedEvent(); + const handlePress = async (source: 'cta_button' | 'cta_text') => { + submitCtaPressedEvent(source); if (isEmptyWallet) { const chainId = getChainIdForBuyFlow(); @@ -169,21 +173,23 @@ const MusdConversionAssetListCta = () => { MetaMask USD - - {strings('earn.earn_a_percentage_bonus', { - percentage: MUSD_CONVERSION_APY, - })} - + handlePress('cta_text')}> + + {strings('earn.earn_a_percentage_bonus', { + percentage: MUSD_CONVERSION_APY, + })} + + From a2afc1698b3f2a87b7aed260cdad8a581e3e7276 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 5 Feb 2026 09:28:11 -0600 Subject: [PATCH 06/33] fix: transaction activity for main and token detail lists as well as detailed modal summary (#25635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes transaction activity display for mUSD conversion transactions. Previously, mUSD conversion related/duplicated transactions were not being properly hidden and displayed in the main activity list, token details activity list, and the transaction detail modal summary. Added support for finding and displaying the mUSD receive transaction hash in the activity summary. On mainnet, the send and receive operations can be in separate transactions, so we need to find and link to the correct receive transaction for the block explorer. Changes: Transaction Activity Lists: - Added TransactionType.musdConversion to POSITIVE_TRANSFER_TRANSACTION_TYPES for proper balance display - Added decoding support for musdConversion transaction type in decodeTransaction - Added navigation route for TRANSACTION_DETAILS in AssetStackFlow to enable tapping on transactions from token details Transaction Detail Modal Summary: - Created new MusdConversionSummary component with dedicated logic for mUSD conversion transactions - Added findMusdSendTransaction utility to find the relay deposit transaction - Added findMusdReceiveTransaction utility to find the mUSD receive transaction by matching transferInformation.contractAddress against the mUSD token address - Handles fallback to transactionMeta.hash when send/receive are bundled in the same transaction - Handles edge case where hash is 0x0 (shows no block explorer link) ## **Changelog** CHANGELOG entry: n/a ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-267 Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-277 Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-287 ## **Manual testing steps** ```gherkin Feature: mUSD conversion transaction activity Scenario: View mUSD conversion in main activity list Given the user has completed an mUSD conversion When viewing the main wallet activity tab Then the mUSD conversion transaction appears with correct title and amount Scenario: View mUSD conversion in token details Given the user has completed an mUSD conversion When viewing the mUSD token details activity list Then the mUSD conversion transaction appears And tapping it opens the transaction details modal Scenario: View mUSD conversion transaction details Given the user has completed an mUSD conversion on mainnet When viewing the transaction details modal Then the Summary section shows two lines: - "Sent {stablecoin} from {chain}" with block explorer link - "Receive mUSD on {chain}" with block explorer link ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-02-05 at 6 28 26 AM Screenshot 2026-02-05 at 6 28 39 AM https://consensys.slack.com/files/U06EZC2Q81X/F0AD6AH8552/simulator_screen_recording_-_iphone_17_pro_-_2026-02-04_at_17.52.37.mp4 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches transaction rendering/decoding and navigation for transaction details; mistakes could cause missing/duplicated activity rows or incorrect block-explorer links for related transaction types. > > **Overview** > Fixes how `mUSD conversion` transactions appear across activity lists and the transaction details summary. > > `decodeTransaction` now treats `TransactionType.musdConversion` as a positive transfer and decodes it via the transfer path (keyed off `transactions.tx_review_musd_conversion`) so amounts/titles render consistently. The transaction details summary logic is adjusted to render *exactly two lines* for mUSD conversion: a send line sourced from the `relayDeposit` tx, and a receive line sourced from the `musdConversion` tx (using its hash when present, suppressing the link when hash is `0x0`), while skipping any other required mUSD-related transactions to avoid duplicates. > > Adds `Routes.TRANSACTION_DETAILS` to the asset/token details stack so tapping a token’s activity item can open the transaction details screen. Updates/expands unit tests accordingly, and bumps `@metamask/transaction-controller`/`@metamask/transaction-pay-controller` (and related lockfile resolutions). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d4e98fe2a20899af30589039d203ee3e780127cb. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Nav/Main/MainNavigator.js | 5 + app/components/UI/TransactionElement/utils.js | 2 + .../UI/TransactionElement/utils.test.js | 4 + .../transaction-details-summary.test.tsx | 317 +++++++++++++++--- .../transaction-details-summary.tsx | 69 +++- package.json | 6 +- yarn.lock | 131 +++++--- 7 files changed, 417 insertions(+), 117 deletions(-) diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 265e71d211c..48ec9dbba7f 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -184,6 +184,11 @@ const AssetStackFlow = (props) => ( component={AssetDetails} initialParams={{ address: props.route.params?.address }} /> + ); diff --git a/app/components/UI/TransactionElement/utils.js b/app/components/UI/TransactionElement/utils.js index a3215d75003..934122da2b0 100644 --- a/app/components/UI/TransactionElement/utils.js +++ b/app/components/UI/TransactionElement/utils.js @@ -37,6 +37,7 @@ import { hasTransactionType } from '../../Views/confirmations/utils/transaction' import { BigNumber } from 'bignumber.js'; const POSITIVE_TRANSFER_TRANSACTION_TYPES = [ + TransactionType.musdConversion, TransactionType.perpsDeposit, TransactionType.perpsDepositAndOrder, TransactionType.predictDeposit, @@ -861,6 +862,7 @@ export default async function decodeTransaction(args) { }); } else { switch (actionKey) { + case strings('transactions.tx_review_musd_conversion'): case strings('transactions.tx_review_perps_deposit'): case strings('transactions.tx_review_predict_deposit'): case strings('transactions.tx_review_predict_withdraw'): diff --git a/app/components/UI/TransactionElement/utils.test.js b/app/components/UI/TransactionElement/utils.test.js index 4bb42e3d514..cc8e2585187 100644 --- a/app/components/UI/TransactionElement/utils.test.js +++ b/app/components/UI/TransactionElement/utils.test.js @@ -385,6 +385,10 @@ describe('Transaction Element Utils', () => { TransactionType.predictWithdraw, strings('transactions.tx_review_predict_withdraw'), ], + [ + TransactionType.musdConversion, + strings('transactions.tx_review_musd_conversion'), + ], ])('if %s', async (transactionType, title) => { const args = { tx: { diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx index 5c599f24b70..2554ef3f30b 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx @@ -157,55 +157,6 @@ describe('TransactionDetailsSummary', () => { ); }); - it('renders mUSD conversion send line title for child transaction', () => { - useTokenWithBalanceMock.mockReturnValue({ - symbol: SYMBOL_MOCK, - } as ReturnType); - - useTransactionDetailsMock.mockReturnValue({ - transactionMeta: { - id: transactionIdMock, - type: TransactionType.musdConversion, - metamaskPay: { - chainId: SOURCE_CHAIN_ID_MOCK, - tokenAddress: '0x123', - }, - } as unknown as TransactionMeta, - }); - - const { getByText } = render({ - transactions: [ - { ...TRANSACTION_META_MOCK, type: TransactionType.bridge }, - ], - }); - - expect( - getByText( - strings('transaction_details.summary_title.musd_convert_send', { - sourceSymbol: SYMBOL_MOCK, - sourceChain: SOURCE_NETWORK_NAME_MOCK, - }), - ), - ).toBeDefined(); - }); - - it('renders mUSD conversion receive line title', () => { - const { getByText } = render({ - transactions: [ - { ...TRANSACTION_META_MOCK, type: TransactionType.musdConversion }, - ], - }); - - expect( - getByText( - strings('transaction_details.summary_title.bridge_receive', { - targetSymbol: 'mUSD', - targetChain: SOURCE_NETWORK_NAME_MOCK, - }), - ), - ).toBeDefined(); - }); - it('renders perps deposit line title', () => { const { getByText } = render({ transactions: [ @@ -542,4 +493,272 @@ describe('TransactionDetailsSummary', () => { txHash: RECEIVE_HASH_MOCK, }); }); + + describe('mUSD Conversion', () => { + const MUSD_SEND_TX_ID = 'musd-send-tx-id'; + const MUSD_RECEIVE_TX_ID = 'musd-receive-tx-id'; + const MUSD_RECEIVE_HASH = '0x789abc' as Hex; + const SEND_HASH = '0x456def' as Hex; + + beforeEach(() => { + useTokenWithBalanceMock.mockReturnValue({ + symbol: SYMBOL_MOCK, + } as ReturnType); + + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + type: TransactionType.musdConversion, + requiredTransactionIds: [MUSD_SEND_TX_ID, MUSD_RECEIVE_TX_ID], + metamaskPay: { + chainId: SOURCE_CHAIN_ID_MOCK, + tokenAddress: '0x123', + }, + } as unknown as TransactionMeta, + }); + }); + + it('renders exactly 2 lines for mUSD conversion', () => { + const { getByText } = render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: MUSD_RECEIVE_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: MUSD_RECEIVE_HASH, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + transferInformation: { + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + symbol: 'MUSD', + }, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // Sent line + expect( + getByText( + strings('transaction_details.summary_title.musd_convert_send', { + sourceSymbol: SYMBOL_MOCK, + sourceChain: SOURCE_NETWORK_NAME_MOCK, + }), + ), + ).toBeDefined(); + + // Receive line + expect( + getByText( + strings('transaction_details.summary_title.bridge_receive', { + targetSymbol: 'mUSD', + targetChain: SOURCE_NETWORK_NAME_MOCK, + }), + ), + ).toBeDefined(); + }); + + it('renders sent line with loading state when source token is not available', () => { + useTokenWithBalanceMock.mockReturnValue( + {} as ReturnType, + ); + + const { getByText } = render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + expect( + getByText( + strings('transaction_details.summary_title.bridge_send_loading'), + ), + ).toBeDefined(); + }); + + it('skips mUSD receive transactions and uses musdConversion hash for receive line', () => { + render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: MUSD_RECEIVE_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: MUSD_RECEIVE_HASH, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + transferInformation: { + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + symbol: 'MUSD', + }, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // mUSD receive transaction is skipped, receive line uses musdConversion tx hash (undefined here) + expect(useMultichainBlockExplorerTxUrlMock).not.toHaveBeenCalledWith({ + chainId: Number(SOURCE_CHAIN_ID_MOCK), + txHash: MUSD_RECEIVE_HASH, + }); + }); + + it('falls back to transactionMeta.hash when no separate receive transaction exists', () => { + const PARENT_TX_HASH = '0xparenthash' as Hex; + + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + type: TransactionType.musdConversion, + requiredTransactionIds: [MUSD_SEND_TX_ID], + hash: PARENT_TX_HASH, + metamaskPay: { + chainId: SOURCE_CHAIN_ID_MOCK, + tokenAddress: '0x123', + }, + } as unknown as TransactionMeta, + }); + + render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: PARENT_TX_HASH, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // Falls back to transactionMeta.hash when no mUSD receive transaction exists + expect(useMultichainBlockExplorerTxUrlMock).toHaveBeenCalledWith({ + chainId: Number(SOURCE_CHAIN_ID_MOCK), + txHash: PARENT_TX_HASH, + }); + }); + + it('renders receive line without block explorer link when hash is 0x0', () => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + type: TransactionType.musdConversion, + requiredTransactionIds: [MUSD_SEND_TX_ID], + hash: '0x0', + metamaskPay: { + chainId: SOURCE_CHAIN_ID_MOCK, + tokenAddress: '0x123', + }, + } as unknown as TransactionMeta, + }); + + render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: '0x0', + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // No fallback when hash is 0x0 + expect(useMultichainBlockExplorerTxUrlMock).toHaveBeenCalledWith({ + chainId: Number(SOURCE_CHAIN_ID_MOCK), + txHash: undefined, + }); + }); + + it('uses send transaction hash for sent line block explorer link', () => { + render({ + transactions: [ + { + id: MUSD_SEND_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: SEND_HASH, + type: TransactionType.relayDeposit, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + { + id: MUSD_RECEIVE_TX_ID, + chainId: SOURCE_CHAIN_ID_MOCK, + hash: MUSD_RECEIVE_HASH, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + transferInformation: { + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + symbol: 'MUSD', + }, + }, + { + id: transactionIdMock, + chainId: SOURCE_CHAIN_ID_MOCK, + type: TransactionType.musdConversion, + submittedTime: TRANSACTION_META_MOCK.submittedTime, + }, + ], + }); + + // Check block explorer URL is called with send hash + expect(useMultichainBlockExplorerTxUrlMock).toHaveBeenCalledWith({ + chainId: Number(SOURCE_CHAIN_ID_MOCK), + txHash: SEND_HASH, + }); + }); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx index 4f6e5673375..314f713d0d3 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx @@ -98,6 +98,7 @@ function TransactionSummary({ chainId: receiveChainId, hash: receiveHash, isReceiveOnly, + skip, sourceNetworkName, sourceSymbol, targetNetworkName, @@ -107,6 +108,11 @@ function TransactionSummary({ const allBridgeHistory = useSelector(selectBridgeHistoryForAccount); + // Skip rendering for transactions handled elsewhere (e.g., mUSD receive) + if (skip) { + return null; + } + const approvalBridgeHistory = Object.values(allBridgeHistory).find( (h) => h.approvalTxId === transaction.id, ); @@ -274,7 +280,6 @@ function getLineTitle({ transactionMeta: TransactionMeta; }): string | undefined { const { type } = transactionMeta; - const { type: parentType } = parentTransaction ?? {}; const approveSymbol = approvalBridgeHistory?.quote?.srcAsset?.symbol; if (isReceive) { @@ -286,13 +291,21 @@ function getLineTitle({ : strings('transaction_details.summary_title.bridge_receive_loading'); } + // mUSD conversion: use specific string for send line + if ( + parentTransaction && + hasTransactionType(parentTransaction, [TransactionType.musdConversion]) && + hasTransactionType(transactionMeta, [TransactionType.relayDeposit]) + ) { + return symbol && networkName + ? strings('transaction_details.summary_title.musd_convert_send', { + sourceSymbol: symbol, + sourceChain: networkName, + }) + : strings('transaction_details.summary_title.bridge_send_loading'); + } + if (symbol && networkName) { - if (parentType === TransactionType.musdConversion) { - return strings('transaction_details.summary_title.musd_convert_send', { - sourceSymbol: symbol, - sourceChain: networkName, - }); - } return strings('transaction_details.summary_title.bridge_send', { sourceSymbol: symbol, sourceChain: networkName, @@ -329,6 +342,7 @@ function useBridgeReceiveData( chainId?: Hex; hash?: Hex; isReceiveOnly?: boolean; + skip?: boolean; sourceNetworkName?: string; sourceSymbol?: string; targetNetworkName?: string; @@ -356,15 +370,6 @@ function useBridgeReceiveData( const sourceNetworkName = useNetworkName(transaction.chainId); const targetNetworkName = useNetworkName(chainId); - if (hasTransactionType(transaction, [TransactionType.musdConversion])) { - return { - chainId: transaction.chainId, - isReceiveOnly: true, - targetNetworkName: sourceNetworkName, - targetSymbol: 'mUSD', - }; - } - if (hasTransactionType(transaction, [TransactionType.perpsDeposit])) { return { chainId: CHAIN_IDS.ARBITRUM, @@ -383,9 +388,27 @@ function useBridgeReceiveData( }; } + // mUSD conversion: main transaction renders receive line only + if (hasTransactionType(transaction, [TransactionType.musdConversion])) { + const receiveData = { + chainId: transaction.chainId, + hash: undefined as Hex | undefined, + isReceiveOnly: true, + targetNetworkName: sourceNetworkName, + targetSymbol: 'mUSD', + }; + + if (!transaction.hash || transaction.hash === '0x0') { + return receiveData; + } + + receiveData.hash = transaction.hash as Hex; + + return receiveData; + } + if ( hasTransactionType(parentTransaction, [ - TransactionType.musdConversion, TransactionType.perpsDeposit, TransactionType.predictDeposit, ]) @@ -396,6 +419,18 @@ function useBridgeReceiveData( }; } + // mUSD conversion: relayDeposit renders send line only + if (hasTransactionType(parentTransaction, [TransactionType.musdConversion])) { + if (hasTransactionType(transaction, [TransactionType.relayDeposit])) { + return { + sourceNetworkName, + sourceSymbol: sourceToken?.symbol, + }; + } + // Skip other mUSD transactions for send line show relay deposit only + return { skip: true }; + } + return { chainId, hash, diff --git a/package.json b/package.json index e85cde0ff23..9be510e125b 100644 --- a/package.json +++ b/package.json @@ -184,7 +184,7 @@ "@playwright/test": "^1.57.0", "@metamask/transaction-controller@npm:^61.0.0": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch", "@metamask/transaction-controller@npm:^62.9.2": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch", - "@metamask/transaction-controller@npm:^62.11.0": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch" + "@metamask/transaction-controller@npm:^62.14.0": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -297,8 +297,8 @@ "@metamask/storage-service": "^1.0.0", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch", - "@metamask/transaction-pay-controller": "^12.0.2", + "@metamask/transaction-controller": "^62.14.0", + "@metamask/transaction-pay-controller": "^12.1.0", "@metamask/tron-wallet-snap": "^1.19.2", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", diff --git a/yarn.lock b/yarn.lock index ae38b866183..4c24a42e793 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7479,9 +7479,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^99.0.0, @metamask/assets-controllers@npm:^99.1.0": - version: 99.1.0 - resolution: "@metamask/assets-controllers@npm:99.1.0" +"@metamask/assets-controllers@npm:^99.0.0, @metamask/assets-controllers@npm:^99.2.0": + version: 99.2.0 + resolution: "@metamask/assets-controllers@npm:99.2.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7508,14 +7508,14 @@ __metadata: "@metamask/permission-controller": "npm:^12.2.0" "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/polling-controller": "npm:^16.0.2" - "@metamask/preferences-controller": "npm:^22.0.0" + "@metamask/preferences-controller": "npm:^22.1.0" "@metamask/profile-sync-controller": "npm:^27.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" - "@metamask/storage-service": "npm:^0.0.1" - "@metamask/transaction-controller": "npm:^62.11.0" + "@metamask/storage-service": "npm:^1.0.0" + "@metamask/transaction-controller": "npm:^62.13.0" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7531,7 +7531,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/01e9e33f50d5207817264c969ee37ad534399756d580ff1ebb965018cba0c669a181ba70273ba6901e5c71c2f5acd7506b2ff12432c9b218cfa778e3d12c59b7 + checksum: 10/948f4f15aeee304d370eb8ac2c29048de4075329e8f8285e5c1677c13bd6be3729f762e91623fed1c4687d5d6b75eac93519b5cefd1fc2d62525ee965b06f6d0 languageName: node linkType: hard @@ -7635,9 +7635,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^65.1.0": - version: 65.1.0 - resolution: "@metamask/bridge-controller@npm:65.1.0" +"@metamask/bridge-controller@npm:^65.1.0, @metamask/bridge-controller@npm:^65.2.0": + version: 65.3.0 + resolution: "@metamask/bridge-controller@npm:65.3.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7645,7 +7645,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^35.0.2" - "@metamask/assets-controllers": "npm:^99.0.0" + "@metamask/assets-controllers": "npm:^99.2.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/gas-fee-controller": "npm:^26.0.2" @@ -7657,12 +7657,12 @@ __metadata: "@metamask/polling-controller": "npm:^16.0.2" "@metamask/remote-feature-flag-controller": "npm:^4.0.0" "@metamask/snaps-controllers": "npm:^17.2.0" - "@metamask/transaction-controller": "npm:^62.11.0" + "@metamask/transaction-controller": "npm:^62.14.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/778c219ecd44f808f936ebb6f5ffe1dd8689e3b160522482d861f7be37d7fed6488561f4d5d4adbcdf63a57f1cedb0f50ff65e06098af5ecf20abfb2ecbbcabb + checksum: 10/204030f6754dc62f220db7c90ca9e248ab00da20759c9eb895f9b0c00f342895a918434bf10650c5c156cdb92bce3ff2263bd1b08ddd60691e0f6cd016c3a770 languageName: node linkType: hard @@ -7789,19 +7789,19 @@ __metadata: languageName: node linkType: hard -"@metamask/core-backend@npm:^5.0.0": - version: 5.0.0 - resolution: "@metamask/core-backend@npm:5.0.0" +"@metamask/core-backend@npm:^5.0.0, @metamask/core-backend@npm:^5.1.0": + version: 5.1.0 + resolution: "@metamask/core-backend@npm:5.1.0" dependencies: - "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/profile-sync-controller": "npm:^27.0.0" - "@metamask/utils": "npm:^11.8.1" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^5.62.16" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/accounts-controller": ^35.0.0 - "@metamask/keyring-controller": ^25.0.0 - checksum: 10/c3c8d527ccbc9d56f6ddb5579cc8c58af971e9b81ece48ea7107c48e496ec2574283119cd4b258cc6c733f15d1432632a4e975d7616809147e2d4510dba59219 + checksum: 10/8d966656b33a5582772ab6c29d04d7d7f9d47ffb52ad18a2d06f9e134a9bc6939b835452224230fc269631ee394b6642473dec8b309fead1e1f324b6e26f286e languageName: node linkType: hard @@ -8966,16 +8966,15 @@ __metadata: languageName: node linkType: hard -"@metamask/preferences-controller@npm:^22.0.0": - version: 22.0.0 - resolution: "@metamask/preferences-controller@npm:22.0.0" +"@metamask/preferences-controller@npm:^22.0.0, @metamask/preferences-controller@npm:^22.1.0": + version: 22.1.0 + resolution: "@metamask/preferences-controller@npm:22.1.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" - peerDependencies: - "@metamask/keyring-controller": ^25.0.0 - checksum: 10/c0b11266efaacbf612652ccacbe013bff03333004107a95422491755641075b52c8ff073a8abea802faeb922be4147cb7ce8feac3305b1ce2e4e070195b4b414 + checksum: 10/bdb6a727cd88313b29b66dfd10308accc8c8bf52830bd7950a5afa7c832554958b94d207ed46ba4b7c8645cd05697e4601ee18194f988416d8dec0bbd2977516 languageName: node linkType: hard @@ -9542,16 +9541,6 @@ __metadata: languageName: node linkType: hard -"@metamask/storage-service@npm:^0.0.1": - version: 0.0.1 - resolution: "@metamask/storage-service@npm:0.0.1" - dependencies: - "@metamask/messenger": "npm:^0.3.0" - "@metamask/utils": "npm:^11.8.1" - checksum: 10/ba2443e4bab7a4ef64bf3e0a10403ef94d50225e5ae5931a6fd32a21bfa8e276916ab6dc377d2db30c15c50acaeaee74a3c0b418a563311da9f98b9172fba300 - languageName: node - linkType: hard - "@metamask/storage-service@npm:^1.0.0": version: 1.0.0 resolution: "@metamask/storage-service@npm:1.0.0" @@ -9689,6 +9678,45 @@ __metadata: languageName: node linkType: hard +"@metamask/transaction-controller@npm:^62.11.0, @metamask/transaction-controller@npm:^62.13.0": + version: 62.14.0 + resolution: "@metamask/transaction-controller@npm:62.14.0" + dependencies: + "@ethereumjs/common": "npm:^4.4.0" + "@ethereumjs/tx": "npm:^5.4.0" + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@ethersproject/wallet": "npm:^5.7.0" + "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/approval-controller": "npm:^8.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/core-backend": "npm:^5.1.0" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/gas-fee-controller": "npm:^26.0.2" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^29.0.0" + "@metamask/nonce-tracker": "npm:^6.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.0.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.9.0" + async-mutex: "npm:^0.5.0" + bignumber.js: "npm:^9.1.2" + bn.js: "npm:^5.2.1" + eth-method-registry: "npm:^4.0.0" + fast-json-patch: "npm:^3.1.1" + lodash: "npm:^4.17.21" + uuid: "npm:^8.3.2" + peerDependencies: + "@babel/runtime": ^7.0.0 + "@metamask/eth-block-tracker": ">=9" + checksum: 10/8ddc99d447990b997becc67617c8a5f4345d6412a6da53314f369bf0f12eff8be142a907e712f82aed6fe428ce08fa386797103d3d55a745b64f85ff5b19dbb0 + languageName: node + linkType: hard + "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch": version: 62.10.0 resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch::version=62.10.0&hash=dae606" @@ -9728,15 +9756,15 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^12.0.2": - version: 12.0.2 - resolution: "@metamask/transaction-pay-controller@npm:12.0.2" +"@metamask/transaction-pay-controller@npm:^12.1.0": + version: 12.1.0 + resolution: "@metamask/transaction-pay-controller@npm:12.1.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" - "@metamask/assets-controllers": "npm:^99.1.0" + "@metamask/assets-controllers": "npm:^99.2.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^65.1.0" + "@metamask/bridge-controller": "npm:^65.2.0" "@metamask/bridge-status-controller": "npm:^65.0.1" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/gas-fee-controller": "npm:^26.0.2" @@ -9744,13 +9772,13 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.0.0" - "@metamask/transaction-controller": "npm:^62.11.0" + "@metamask/transaction-controller": "npm:^62.14.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/e22613a2dce4670bd00d09e698666bf633737bef99125ebcc186dceb151c629c3386ef6f3373c436e9970b09fc1c52bd1c9ffc4e26140bb6a8a3f7cf75ca4299 + checksum: 10/4464536eb852afa32a74bef18f41fc6d6c605b51c5bebf95f1e6a7d2cb71a5d1f5c8a6b0034c56154e8724e36b42e8a862216609a9fc3869bd6abbd3e39c67f6 languageName: node linkType: hard @@ -16949,6 +16977,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:^5.62.16": + version: 5.90.20 + resolution: "@tanstack/query-core@npm:5.90.20" + checksum: 10/25e38f4382442bc15e0f6cce8d787e9df8d8822c61d3f3e9427e89e01b1e2506f848292e086dae29aeb55f8ce71b097c34221f3c5eda37fb4a688b5ceca5d1b3 + languageName: node + linkType: hard + "@testim/chrome-version@npm:^1.1.4": version: 1.1.4 resolution: "@testim/chrome-version@npm:1.1.4" @@ -34771,8 +34806,8 @@ __metadata: "@metamask/test-dapp": "npm:9.5.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch" - "@metamask/transaction-pay-controller": "npm:^12.0.2" + "@metamask/transaction-controller": "npm:^62.14.0" + "@metamask/transaction-pay-controller": "npm:^12.1.0" "@metamask/tron-wallet-snap": "npm:^1.19.2" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6" From 20383bd11063e7b2f7ccd3ec646849f2a10145e4 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Thu, 5 Feb 2026 16:31:07 +0100 Subject: [PATCH 07/33] chore: bump `@metamask/profile-sync-controller` to `^27.1.0` (#25707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** No manual testing steps ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Dependency-only change, but it upgrades profile syncing’s transitive `keyring`/`snaps`/`utils` packages which can subtly affect account, snap, or sync behavior at runtime. > > **Overview** > Updates dependencies to use `@metamask/profile-sync-controller@^27.1.0` and `@metamask/address-book-controller@^7.0.1`. > > The lockfile is refreshed to reflect new transitive requirements for the updated profile sync controller (notably newer `@metamask/keyring-controller`, `@metamask/snaps-*`, and `@metamask/utils` versions). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 95b0fa86c9af54dc765c93942094b1cc7e2ede33. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 4 ++-- yarn.lock | 34 +++++++++++++++++----------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 9be510e125b..ae9dc6893a2 100644 --- a/package.json +++ b/package.json @@ -202,7 +202,7 @@ "@metamask/account-api": "^0.12.0", "@metamask/account-tree-controller": "^3.0.0", "@metamask/accounts-controller": "^34.0.0", - "@metamask/address-book-controller": "^7.0.0", + "@metamask/address-book-controller": "^7.0.1", "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^8.0.0", @@ -267,7 +267,7 @@ "@metamask/preferences-controller": "^21.0.0", "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^2.0.0", - "@metamask/profile-sync-controller": "^27.0.0", + "@metamask/profile-sync-controller": "^27.1.0", "@metamask/ramps-controller": "^6.0.0", "@metamask/react-native-acm": "^1.0.1", "@metamask/react-native-actionsheet": "2.4.2", diff --git a/yarn.lock b/yarn.lock index 4c24a42e793..a7f83979b4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7371,15 +7371,15 @@ __metadata: languageName: node linkType: hard -"@metamask/address-book-controller@npm:^7.0.0": - version: 7.0.0 - resolution: "@metamask/address-book-controller@npm:7.0.0" +"@metamask/address-book-controller@npm:^7.0.1": + version: 7.0.1 + resolution: "@metamask/address-book-controller@npm:7.0.1" dependencies: "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/controller-utils": "npm:^11.16.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.8.1" - checksum: 10/fdd229af695d0b2d9b7683e54a4f5f7121e9fcdcfd5cd7cf60f010726bc7627f5bd67b61665152c75e79d491712150e2c4383d4da39460535bf8eb5da49763bf + checksum: 10/5ada2568b7093e33b93f9caad64e3e72dd7b581ee2758cee5ea8b261b2efedbfd355659f2d2917080399e4ca782bfaa0c12be5383f2db017302d98d2902a480d languageName: node linkType: hard @@ -9004,27 +9004,27 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^27.0.0": - version: 27.0.0 - resolution: "@metamask/profile-sync-controller@npm:27.0.0" +"@metamask/profile-sync-controller@npm:^27.0.0, @metamask/profile-sync-controller@npm:^27.1.0": + version: 27.1.0 + resolution: "@metamask/profile-sync-controller@npm:27.1.0" dependencies: + "@metamask/address-book-controller": "npm:^7.0.1" "@metamask/base-controller": "npm:^9.0.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/snaps-sdk": "npm:^9.0.0" - "@metamask/snaps-utils": "npm:^11.0.0" - "@metamask/utils": "npm:^11.8.1" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-sdk": "npm:^10.3.0" + "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/utils": "npm:^11.9.0" "@noble/ciphers": "npm:^1.3.0" "@noble/hashes": "npm:^1.8.0" immer: "npm:^9.0.6" loglevel: "npm:^1.8.1" siwe: "npm:^2.3.2" peerDependencies: - "@metamask/address-book-controller": ^7.0.1 - "@metamask/keyring-controller": ^25.0.0 "@metamask/providers": ^22.0.0 - "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/3bfa8f483a1dc6c0cc5301d3d02570afadea698c71910c0b53d9a840d172bcaeeaca0d8f2f93c57f4c00b7fd65ef38a62357ec45caf05adc43128c748254f6fa + checksum: 10/8f07889ab3ca5d235fd6331e412fa051430d8762853f696da71386bb2509058a53ba5ab7bc227c348a16f2855440792daa43147cea0ef9d83df345968a0ed2f5 languageName: node linkType: hard @@ -34699,7 +34699,7 @@ __metadata: "@metamask/account-api": "npm:^0.12.0" "@metamask/account-tree-controller": "npm:^3.0.0" "@metamask/accounts-controller": "npm:^34.0.0" - "@metamask/address-book-controller": "npm:^7.0.0" + "@metamask/address-book-controller": "npm:^7.0.1" "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^8.0.0" @@ -34772,7 +34772,7 @@ __metadata: "@metamask/preferences-controller": "npm:^21.0.0" "@metamask/preinstalled-example-snap": "npm:^0.7.2" "@metamask/profile-metrics-controller": "npm:^2.0.0" - "@metamask/profile-sync-controller": "npm:^27.0.0" + "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/providers": "npm:^18.3.1" "@metamask/ramps-controller": "npm:^6.0.0" "@metamask/react-native-acm": "npm:^1.0.1" From 800ebb637f760d2688c1a0e8083521e71c4cf546 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 5 Feb 2026 09:38:56 -0600 Subject: [PATCH 08/33] chore: disallow hiding of mUSD (#25640) ## **Description** This PR stops allowing the hiding of mUSD if the user has the token in their token list. It blocks it in the three places that a user can hide their token ## **Changelog** CHANGELOG entry: Updates to disable hiding of mUSD token similar to network tokens but does not display by default ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-276 ## **Manual testing steps** ```gherkin Feature: Disallow hiding mUSD token Scenario: User cannot hide mUSD from token list via long press Given user has mUSD token in their wallet When user long-presses on mUSD token in the token list Then no hide/remove menu appears Scenario: User cannot hide mUSD from asset options menu Given user is viewing mUSD token details When user opens the asset options menu (3-dot menu) Then "Remove Token" option is not displayed Scenario: User cannot hide mUSD from asset details page Given user is viewing mUSD asset details page When user scrolls to the bottom of the page Then "Hide Token" button is not displayed Scenario: User can still hide other ERC-20 tokens Given user has a non-native, non-mUSD token in their wallet When user long-presses on that token Then hide/remove menu appears as expected ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/1301055c-7b0c-439f-aab6-1377c6488b8a ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Small UI/UX guardrail change gated by a simple address check; low risk aside from potentially mis-identifying mUSD if the address list changes or differs by chain in the future. > > **Overview** > Prevents users from hiding/removing the `mUSD` token by disabling the removal affordances across the UI. > > Adds a shared `isMusdToken` helper (case-insensitive address match) and uses it to: (1) suppress the long-press remove menu in `TokenListItem`, (2) hide the **Hide token** CTA in `AssetDetails`, and (3) omit the **Remove token** option in `AssetOptions`. Updates and adds tests to cover the new `mUSD` detection and the blocked removal flows. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 50fddefe17813f6de640115ef40320f44673a28a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/Earn/constants/musd.test.ts | 46 ++++++++++++ app/components/UI/Earn/constants/musd.ts | 10 +++ .../TokenListItem/TokenListItem.test.tsx | 72 +++++++++++++++++++ .../TokenList/TokenListItem/TokenListItem.tsx | 6 +- .../Views/AssetDetails/AssetsDetails.test.tsx | 23 ++++++ app/components/Views/AssetDetails/index.tsx | 3 +- .../Views/AssetOptions/AssetOptions.test.tsx | 34 +++++++++ .../Views/AssetOptions/AssetOptions.tsx | 2 + 8 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 app/components/UI/Earn/constants/musd.test.ts diff --git a/app/components/UI/Earn/constants/musd.test.ts b/app/components/UI/Earn/constants/musd.test.ts new file mode 100644 index 00000000000..6c44ed7bcdb --- /dev/null +++ b/app/components/UI/Earn/constants/musd.test.ts @@ -0,0 +1,46 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { isMusdToken, MUSD_TOKEN_ADDRESS_BY_CHAIN } from './musd'; + +describe('isMusdToken', () => { + const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + + it('returns true for mUSD token address in lowercase', () => { + const result = isMusdToken(MUSD_ADDRESS); + + expect(result).toBe(true); + }); + + it('returns true for mUSD token address in uppercase', () => { + const result = isMusdToken(MUSD_ADDRESS.toUpperCase()); + + expect(result).toBe(true); + }); + + it('returns true for mUSD token address with mixed case', () => { + const mixedCaseAddress = '0xAcA92E438df0B2401fF60dA7E4337B687a2435DA'; + + const result = isMusdToken(mixedCaseAddress); + + expect(result).toBe(true); + }); + + it('returns false for non-mUSD token address', () => { + const otherAddress = '0x1234567890123456789012345678901234567890'; + + const result = isMusdToken(otherAddress); + + expect(result).toBe(false); + }); + + it('returns false for undefined address', () => { + const result = isMusdToken(undefined); + + expect(result).toBe(false); + }); + + it('returns false for empty string address', () => { + const result = isMusdToken(''); + + expect(result).toBe(false); + }); +}); diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index 0cd980bb9fe..4bf87b92b10 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -21,6 +21,16 @@ export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record = { [CHAIN_IDS.BSC]: '0xaca92e438df0b2401ff60da7e4337b687a2435da', }; +/** + * Check if the given token address is mUSD. + * mUSD has the same address on all supported chains. + */ +export const isMusdToken = (address?: string): boolean => { + if (!address) return false; + const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + return address.toLowerCase() === musdAddress.toLowerCase(); +}; + /** * Chains where mUSD CTA should show (buy routes available). * BSC is excluded as buy routes are not yet available. diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index e85b76d6f46..8023ee16e8c 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -1082,4 +1082,76 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { expect(queryByText('+1.23%')).toBeNull(); }); }); + + describe('mUSD Token Long Press', () => { + const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + const musdAsset = { + ...defaultAsset, + address: musdAddress, + symbol: 'mUSD', + name: 'MetaMask USD', + isNative: false, + }; + + const musdAssetKey: FlashListAssetKey = { + address: musdAddress, + chainId: '0x1', + isStaked: false, + }; + + it('does not call showRemoveMenu on long press for mUSD token', () => { + prepareMocks({ + asset: musdAsset, + }); + + const mockShowRemoveMenu = jest.fn(); + const { getByText } = renderWithProvider( + , + ); + + const tokenElement = getByText('MetaMask USD'); + fireEvent(tokenElement, 'longPress'); + + expect(mockShowRemoveMenu).not.toHaveBeenCalled(); + }); + + it('calls showRemoveMenu on long press for non-mUSD token', () => { + prepareMocks({ + asset: defaultAsset, + }); + + const mockShowRemoveMenu = jest.fn(); + const assetKey: FlashListAssetKey = { + address: '0x456', + chainId: '0x1', + isStaked: false, + }; + + const { getByText } = renderWithProvider( + , + ); + + const tokenElement = getByText('Test Token'); + fireEvent(tokenElement, 'longPress'); + + expect(mockShowRemoveMenu).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0x456', + symbol: 'TEST', + }), + ); + }); + }); }); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index e9cab29dc67..57147128855 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -50,7 +50,7 @@ import { toHex } from '@metamask/controller-utils'; import Logger from '../../../../../util/Logger'; import { useNetworkName } from '../../../../Views/confirmations/hooks/useNetworkName'; import { MUSD_EVENTS_CONSTANTS } from '../../../Earn/constants/events'; -import { MUSD_CONVERSION_APY } from '../../../Earn/constants/musd'; +import { MUSD_CONVERSION_APY, isMusdToken } from '../../../Earn/constants/musd'; import { useMerklRewards, isEligibleForMerklRewards, @@ -379,7 +379,9 @@ export const TokenListItem = React.memo( return ( { runAfterInteractionsSpy.mockRestore(); }); + it('hides the Hide token button for mUSD tokens', () => { + const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + + const { queryByText } = render( + + + , + ); + + expect(queryByText('Hide token')).toBeNull(); + }); + it('renders warning banner if balance is undefined', () => { const mockEmptyState = { ...initialState, diff --git a/app/components/Views/AssetDetails/index.tsx b/app/components/Views/AssetDetails/index.tsx index efa4f29e05b..3a26f316391 100644 --- a/app/components/Views/AssetDetails/index.tsx +++ b/app/components/Views/AssetDetails/index.tsx @@ -58,6 +58,7 @@ import { Colors } from '../../../util/theme/models'; import { Hex } from '@metamask/utils'; import { selectLastSelectedEvmAccount } from '../../../selectors/accountsController'; import { TokenI } from '../../UI/Tokens/types'; +import { isMusdToken } from '../../UI/Earn/constants/musd'; import { areAddressesEqual } from '../../../util/address'; // Perps Discovery Banner imports import { selectPerpsEnabledFlag } from '../../UI/Perps'; @@ -465,7 +466,7 @@ const AssetDetails = (props: InnerProps) => { {renderSectionDescription(aggregators.join(', '))} )} - {renderHideButton()} + {!isMusdToken(address) && renderHideButton()} ); diff --git a/app/components/Views/AssetOptions/AssetOptions.test.tsx b/app/components/Views/AssetOptions/AssetOptions.test.tsx index 8dbfbceec88..fd065bfbbdf 100644 --- a/app/components/Views/AssetOptions/AssetOptions.test.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.test.tsx @@ -625,6 +625,40 @@ describe('AssetOptions Component', () => { expect(queryByText('Remove token')).not.toBeOnTheScreen(); }); + + it('hides Remove token option for mUSD token', () => { + const musdAddress = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectAssetsBySelectedAccountGroup) + return { + '0x1': [ + { + assetId: musdAddress, + chainId: '0x1', + }, + ], + }; + if (selector.name === 'selectEvmChainId') return '0x1'; + if (selector.name === 'selectTokenList') return {}; + return {}; + }); + + const { queryByText } = render( + , + ); + + expect(queryByText('Remove token')).not.toBeOnTheScreen(); + }); }); describe('Token removal', () => { diff --git a/app/components/Views/AssetOptions/AssetOptions.tsx b/app/components/Views/AssetOptions/AssetOptions.tsx index 1ad3c3487a5..47727c52a55 100644 --- a/app/components/Views/AssetOptions/AssetOptions.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.tsx @@ -36,6 +36,7 @@ import BottomSheet, { BottomSheetRef, } from '../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { isMusdToken } from '../../UI/Earn/constants/musd'; // Wrapped SOL token address on Solana const WRAPPED_SOL_ADDRESS = 'So11111111111111111111111111111111111111111'; @@ -308,6 +309,7 @@ const AssetOptions = (props: Props) => { icon: IconName.DocumentCode, }); !isNativeToken && + !isMusdToken(address) && tokenExistsInState && options.push({ label: strings('asset_details.options.remove_token'), From 7a4bd9d0255ac4541316751322d06674931906a5 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Thu, 5 Feb 2026 16:47:50 +0100 Subject: [PATCH 09/33] fix(perps): tx history initial fetch (#25695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes an intermittent bug where the Perps tab in Activity screen sometimes shows as empty when accessed from perps home or perps market detail screens via the "See all" button. ### Root Cause Race condition in `usePerpsTransactionHistory` hook: 1. User taps "See all" → navigates to Activity screen → Perps tab becomes active 2. `PerpsTransactionsView` mounts with `skipInitialFetch: true` (because WebSocket connection isn't established yet) 3. When connection becomes available, the hook didn't trigger a fetch because the `initialFetchDone` guard prevented it ### Solution Modified the effect in `usePerpsTransactionHistory` to detect when `skipInitialFetch` transitions from `true` to `false` (i.e., when connection is established) and trigger a fetch regardless of the `initialFetchDone` guard. ## **Changelog** CHANGELOG entry: Fixed Perps activity tab sometimes showing empty when accessed from perps home or market detail screens ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2335 ## **Manual testing steps** ```gherkin Feature: Perps Activity View Scenario: user views perps activity from perps home Given user is on the perps home screen And user has perps transaction history When user taps "See all" in the recent activity section Then user should see the Activity screen with Perps tab selected And perps transactions should be displayed (not empty) Scenario: user views perps activity from market detail Given user is on a perps market detail screen And user has perps transaction history When user taps "See all" in the trades section Then user should see the Activity screen with Perps tab selected And perps transactions should be displayed (not empty) ``` ## **Screenshots/Recordings** ### **Before** Activity tab sometimes shows empty on first load when navigating from perps screens ### **After** Activity tab reliably shows transaction history when connection is established ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Small hook logic change gated on `skipInitialFetch` with added unit tests; main risk is triggering extra network fetches on connection flaps. > > **Overview** > Fixes an intermittent empty Perps Activity history by making `usePerpsTransactionHistory` trigger its initial `refetch()` when `skipInitialFetch` transitions from `true` to `false` (i.e., connection becomes available), rather than being blocked by the `initialFetchDone` guard. > > Adds focused tests covering connection state transitions to ensure a fetch occurs on true→false changes, does not duplicate on initial `false`, and only re-fetches once per reconnection. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 89d67fdcbe7c3c24fcb86c664cf74e35540fb03c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/usePerpsTransactionHistory.test.ts | 110 ++++++++++++++++++ .../Perps/hooks/usePerpsTransactionHistory.ts | 17 ++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts index de025ad912a..7384297fdb6 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts @@ -928,4 +928,114 @@ describe('usePerpsTransactionHistory', () => { ]); }); }); + + describe('connection state transitions', () => { + it('triggers fetch when skipInitialFetch transitions from true to false', async () => { + // Reset mocks to track calls clearly + mockProvider.getOrderFills.mockClear(); + mockProvider.getOrders.mockClear(); + mockProvider.getFunding.mockClear(); + + // Start with skipInitialFetch: true (simulating not connected state) + const { rerender } = renderHook( + ({ skipInitialFetch }) => + usePerpsTransactionHistory({ skipInitialFetch }), + { initialProps: { skipInitialFetch: true } }, + ); + + // Verify no fetch was made while skipInitialFetch is true + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(mockProvider.getOrderFills).not.toHaveBeenCalled(); + + // Clear mocks to track only the new calls + mockProvider.getOrderFills.mockClear(); + mockProvider.getOrders.mockClear(); + mockProvider.getFunding.mockClear(); + + // Transition to skipInitialFetch: false (simulating connection established) + rerender({ skipInitialFetch: false }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Verify fetch was triggered after the transition + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + expect(mockProvider.getOrders).toHaveBeenCalledTimes(1); + expect(mockProvider.getFunding).toHaveBeenCalledTimes(1); + }); + + it('does not duplicate fetch when skipInitialFetch starts as false', async () => { + // Reset mocks to track calls clearly + mockProvider.getOrderFills.mockClear(); + mockProvider.getOrders.mockClear(); + mockProvider.getFunding.mockClear(); + + // Start with skipInitialFetch: false (already connected) + renderHook(() => usePerpsTransactionHistory({ skipInitialFetch: false })); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Verify fetch was made exactly once (no duplicate) + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + expect(mockProvider.getOrders).toHaveBeenCalledTimes(1); + expect(mockProvider.getFunding).toHaveBeenCalledTimes(1); + }); + + it('fetches once per true-to-false transition during rapid state changes', async () => { + // Reset mocks to track calls clearly + mockProvider.getOrderFills.mockClear(); + mockProvider.getOrders.mockClear(); + mockProvider.getFunding.mockClear(); + + // Start with skipInitialFetch: true + const { rerender } = renderHook( + ({ skipInitialFetch }) => + usePerpsTransactionHistory({ skipInitialFetch }), + { initialProps: { skipInitialFetch: true } }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // No fetch yet + expect(mockProvider.getOrderFills).not.toHaveBeenCalled(); + + // Transition to connected + rerender({ skipInitialFetch: false }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // First fetch + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + + // Clear and transition back to disconnected + mockProvider.getOrderFills.mockClear(); + rerender({ skipInitialFetch: true }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // No additional fetch while disconnected + expect(mockProvider.getOrderFills).not.toHaveBeenCalled(); + + // Reconnect - should fetch again + rerender({ skipInitialFetch: false }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Should fetch on reconnection + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts index 01189c69591..aad2523b431 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import usePrevious from '../../../hooks/usePrevious'; import { BigNumber } from 'bignumber.js'; import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; @@ -58,6 +59,8 @@ export const usePerpsTransactionHistory = ({ const userHistoryRef = useRef(userHistory); // Track if initial fetch has been done to prevent duplicate fetches const initialFetchDone = useRef(false); + // Track previous skipInitialFetch value to detect connection state transitions + const prevSkipInitialFetch = usePrevious(skipInitialFetch); useEffect(() => { userHistoryRef.current = userHistory; }, [userHistory]); @@ -168,11 +171,21 @@ export const usePerpsTransactionHistory = ({ }, [fetchAllTransactions, refetchUserHistory]); useEffect(() => { - if (!skipInitialFetch && !initialFetchDone.current) { + // Detect transition from skipping (not connected) to not skipping (connected) + // This fixes the case where the component mounts before connection is established + const justBecameConnected = prevSkipInitialFetch && !skipInitialFetch; + + // Trigger fetch if: + // 1. Not skipping AND haven't fetched yet (normal initial fetch) + // 2. Connection just became available (transition from disconnected to connected) + if ( + !skipInitialFetch && + (!initialFetchDone.current || justBecameConnected) + ) { initialFetchDone.current = true; refetch(); } - }, [skipInitialFetch, refetch]); + }, [skipInitialFetch, prevSkipInitialFetch, refetch]); // Combine loading states const combinedIsLoading = useMemo( From f2f15792d4aa996f7598aae1bcda0f521085adad Mon Sep 17 00:00:00 2001 From: George Gkasdrogkas Date: Thu, 5 Feb 2026 18:02:15 +0200 Subject: [PATCH 10/33] fix: exclude gas fees from swap quotes insufficientBal calculation (#25637) ## **Description** Prevents infinite quote loading on some chains like tron ## **Changelog** CHANGELOG entry: exclude gas fees from swap quotes insufficientBal calculation ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-3933 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes balance/insufficient-funds logic used in quote requests; while scoped and guarded by a flag, it can affect when quotes are requested/filtered and should be validated across native-token and gasless/sponsored scenarios. > > **Overview** > Prevents infinite bridge/swap quote loading by decoupling the quote-request `insufficientBal` computation from gas-fee data. > > `useIsInsufficientBalance` now accepts `ignoreGasFees`; when enabled (used by `useBridgeQuoteRequest`), it skips reading gas-related fields from the recommended quote and performs a token-only balance check, while leaving the UI path to continue using full gas-aware validation. Tests were updated to assert the new `ignoreGasFees: true` behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3c15084feb999aa0de4f237a982a4d4d7cf3372b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/useBridgeQuoteRequest/index.ts | 5 +++++ .../useBridgeQuoteRequest.test.ts | 1 + .../hooks/useInsufficientBalance/index.ts | 22 ++++++++++++++----- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts index 5b3a9f326b8..2cf981b4621 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts @@ -44,10 +44,15 @@ export const useBridgeQuoteRequest = () => { chainId: sourceToken?.chainId, }); + // Use simple balance check (ignoring gas fees) for quote requests to avoid circular dependencies. + // The full balance check with gas fees is used separately within the BridgeView to block user from executing + // the swap in insufficient balance. + // This prevents the infinite loop: quote request → gas data changes → insufficientBal changes → new quote request const insufficientBal = useIsInsufficientBalance({ amount: sourceAmount, token: sourceToken, latestAtomicBalance: latestSourceBalance?.atomicBalance, + ignoreGasFees: true, }); const { gasIncluded, gasIncluded7702 } = useSelector( diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts index 364013fbb9b..70d2b012204 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts @@ -555,6 +555,7 @@ describe('useBridgeQuoteRequest', () => { amount: '5.5', token: testState.bridge.sourceToken, latestAtomicBalance: BigNumber.from('10000000000000000000'), + ignoreGasFees: true, }); }); diff --git a/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts b/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts index d62d2515cfe..091667be6ad 100644 --- a/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts +++ b/app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts @@ -13,6 +13,12 @@ interface UseIsInsufficientBalanceParams { amount: string | undefined; token: BridgeToken | undefined; latestAtomicBalance: BigNumber | undefined; + /** + * If true, performs a simple balance check without considering gas fees. + * Used for quote requests to avoid circular dependencies. + * If false (default), includes gas fees in the calculation for UI display. + */ + ignoreGasFees?: boolean; } const normalizeAmount = (value: string, decimals: number): string => { @@ -47,17 +53,23 @@ const useIsInsufficientBalance = ({ amount, token, latestAtomicBalance, + ignoreGasFees = false, }: UseIsInsufficientBalanceParams): boolean => { const quotes = useSelector(selectBridgeQuotes); const minSolBalance = useSelector(selectMinSolBalance); // Extract only the required data from quote to prevent - // uneccessary rerenders that can use infinite loops. + // unnecessary rerenders that can cause infinite loops. + // When ignoreGasFees is true, we skip gas data to avoid circular dependencies. const bestQuote = quotes?.recommendedQuote; - const gasIncluded = bestQuote?.quote?.gasIncluded; - const gasIncluded7702 = bestQuote?.quote?.gasIncluded7702; - const gasSponsored = bestQuote?.quote?.gasSponsored; - const gasAmount = bestQuote?.gasFee?.effective?.amount; + const gasIncluded = ignoreGasFees ? false : bestQuote?.quote?.gasIncluded; + const gasIncluded7702 = ignoreGasFees + ? false + : bestQuote?.quote?.gasIncluded7702; + const gasSponsored = ignoreGasFees ? false : bestQuote?.quote?.gasSponsored; + const gasAmount = ignoreGasFees + ? undefined + : bestQuote?.gasFee?.effective?.amount; return useMemo(() => { const isValidAmount = From 4860b4693a760b99b45f3644650a8886e16a7844 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 5 Feb 2026 17:08:06 +0100 Subject: [PATCH 11/33] feat(perps): pay with any token and Perps balance with info tooltip (#25626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds **Pay with any token / Perps balance** support in the Perps order flow and related UX. ## **Changelog** CHANGELOG entry: Added Perps “Pay with” option (Perps balance or other tokens) and info tooltip on the order view. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-1030 ## **Manual testing steps** ```gherkin Feature: Perps pay with any token and pay-with info tooltip Scenario: User selects Perps balance or another token to pay for a Perps order Given user is on Perps order view for a market (e.g. BTC) When user taps "Pay with" row Then pay-with modal opens with Perps balance and other tokens; Perps balance shows custom icon When user selects a token and confirms Then selection is reflected on the pay-with row and used for the order ``` ## **Screenshots/Recordings** ### **Before** ### **After** simulator_screenshot_A119DE05-A515-4BF7-A95D-D44C13DCAAEB ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches Perps order placement UX and fee/balance calculations and adds new controller state for selected payment token; regressions could miscompute max order size/fees or block/allow orders incorrectly when paying with non-Perps balances. > > **Overview** > Adds a **Perps “Pay with”** capability to the order flow, letting users choose between *Perps balance* (default) and a wallet token via the existing `PayWithModal`, with a new tooltip (`pay_with`) and updated `PerpsPayRow` UI. > > Updates order sizing/validation to respect the selected token’s USD balance (new `effectiveAvailableBalance`/`balanceForValidation` plumbing), clamps amounts when the pay balance drops, and shows an insufficient-funds warning when the chosen pay token can’t cover required margin. > > Reworks fee display to include bridge/deposit fees from transaction-pay when using a custom token (with loading skeleton + button disabled while fee quotes load), extends the fees tooltip to show bridge fees, and refactors deposit tracking/toasts (adds “taking longer” + cancel flow, removes stream-manager deposit-handler coordination, and skips notifications for `perpsDepositAndOrder`). > > Introduces `selectedPaymentToken` state in `PerpsController` with new `setSelectedPaymentToken`/`resetSelectedPaymentToken` actions, updates account subscriptions to accept `null` and to persist account updates into controller state so pay-token lists can display live Perps balance outside the stream provider, and adjusts tests/snapshots accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit edb793fc4304c89ec640b55a60034640fb2e78ea. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Cursor --- .../PerpsOrderView/PerpsOrderView.styles.ts | 7 + .../PerpsOrderView/PerpsOrderView.test.tsx | 25 ++ .../Views/PerpsOrderView/PerpsOrderView.tsx | 198 ++++++---- .../Views/PerpsOrderView/PerpsPayRow.test.tsx | 168 ++++++++ .../Views/PerpsOrderView/PerpsPayRow.tsx | 360 ++++++++---------- .../UI/Perps/__mocks__/serviceMocks.ts | 1 + .../PerpsBottomSheetTooltip.test.tsx | 11 +- .../PerpsBottomSheetTooltip.types.ts | 3 +- .../content/FeesTooltipContent.tsx | 13 + .../content/contentRegistry.test.ts | 2 + .../content/contentRegistry.ts | 1 + .../UI/Perps/constants/perpsConfig.ts | 15 + .../Perps/contexts/PerpsOrderContext.test.tsx | 7 +- .../UI/Perps/contexts/PerpsOrderContext.tsx | 4 + .../Perps/controllers/PerpsController.test.ts | 107 +++++- .../UI/Perps/controllers/PerpsController.ts | 103 ++++- .../SubscriptionMultiplexer.test.ts | 28 +- .../aggregation/SubscriptionMultiplexer.ts | 13 +- .../UI/Perps/controllers/types/index.ts | 2 +- app/components/UI/Perps/hooks/index.ts | 6 + .../hooks/useIsPerpsBalanceSelected.test.ts | 33 ++ .../Perps/hooks/useIsPerpsBalanceSelected.ts | 10 + .../hooks/usePerpsBalanceTokenFilter.test.ts | 206 ++++++++++ .../Perps/hooks/usePerpsBalanceTokenFilter.ts | 93 +++++ .../Perps/hooks/usePerpsDepositStatus.test.ts | 47 +-- .../UI/Perps/hooks/usePerpsDepositStatus.ts | 10 +- .../hooks/usePerpsOrderDepositTracking.ts | 50 +-- .../UI/Perps/hooks/usePerpsOrderForm.test.ts | 19 +- .../UI/Perps/hooks/usePerpsOrderForm.ts | 51 ++- .../UI/Perps/hooks/usePerpsPaymentToken.ts | 27 ++ .../UI/Perps/hooks/usePerpsToasts.tsx | 49 +++ app/components/UI/Perps/index.ts | 2 + .../providers/PerpsStreamManager.test.tsx | 54 ++- .../UI/Perps/providers/PerpsStreamManager.tsx | 23 +- .../Perps/selectors/perpsController/index.ts | 9 + .../UI/Perps/utils/hyperLiquidAdapter.test.ts | 9 + .../components/info/external/perps/index.ts | 1 - .../perps/perps-deposit-fees.test.tsx | 191 ---------- .../external/perps/perps-deposit-fees.tsx | 54 --- .../pay-with-modal/pay-with-modal.test.tsx | 61 +++ .../modals/pay-with-modal/pay-with-modal.tsx | 38 +- .../perps-controller/index.test.ts | 1 + app/core/NotificationManager.js | 1 + .../redux/slices/cronjobController/index.ts | 1 - app/images/perps-pay-token-icon.png | Bin 0 -> 9337 bytes .../logs/__snapshots__/index.test.ts.snap | 2 + app/util/test/initial-background-state.json | 3 +- app/util/test/testSetup.js | 7 + locales/languages/en.json | 12 +- 49 files changed, 1473 insertions(+), 665 deletions(-) create mode 100644 app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx create mode 100644 app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.test.ts create mode 100644 app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsPaymentToken.ts delete mode 100644 app/components/Views/confirmations/components/info/external/perps/index.ts delete mode 100644 app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.test.tsx delete mode 100644 app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.tsx create mode 100644 app/images/perps-pay-token-icon.png diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts index 28a7d92441f..66d7543cd42 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.styles.ts @@ -84,6 +84,13 @@ const createStyles = (colors: Colors) => validationContainer: { marginBottom: 12, }, + insufficientPayTokenWarning: { + backgroundColor: colors.warning.muted, + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 8, + marginTop: 12, + }, bottomSection: { paddingVertical: 24, }, diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 6229df5fdab..350bfc54d28 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -230,6 +230,7 @@ jest.mock('../../hooks', () => ({ handleMaxAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: 11, positionSize: 0.0037, @@ -737,6 +738,7 @@ const defaultMockHooks = { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, }, }; @@ -924,6 +926,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -1020,6 +1023,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -1227,6 +1231,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -1273,6 +1278,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -1585,6 +1591,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1637,6 +1644,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1689,6 +1697,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1744,6 +1753,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1799,6 +1809,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1853,6 +1864,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '10', positionSize: '0.033', @@ -1932,6 +1944,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -1962,6 +1975,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -2071,6 +2085,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -2101,6 +2116,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -2448,6 +2464,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '0', positionSize: '0', @@ -2488,6 +2505,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '33.33', positionSize: '0.0333', @@ -2608,6 +2626,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '20.00', // Truthy value - triggers formatPrice path positionSize: '0.02', @@ -2647,6 +2666,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '', // Falsy value - triggers fallback path positionSize: '', @@ -2686,6 +2706,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '16.67', positionSize: '0.0167', @@ -2728,6 +2749,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '0', positionSize: '0', @@ -2780,6 +2802,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '6.25', positionSize: '0.0083', @@ -2911,6 +2934,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0037', @@ -3020,6 +3044,7 @@ describe('PerpsOrderView', () => { handleMinAmount: jest.fn(), optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, calculations: { marginRequired: '11', positionSize: '0.0002', diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 59771c76f1f..82efdc312bb 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -40,12 +40,14 @@ import ListItem from '../../../../../component-library/components/List/ListItem' import ListItemColumn, { WidthType, } from '../../../../../component-library/components/List/ListItemColumn'; +import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; import Text, { TextColor, TextVariant, } from '../../../../../component-library/components/Texts/Text'; import useTooltipModal from '../../../../../components/hooks/useTooltipModal'; import Routes from '../../../../../constants/navigation/Routes'; +import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { useTheme } from '../../../../../util/theme'; import { TraceName } from '../../../../../util/trace'; import Keypad from '../../../../Base/Keypad'; @@ -54,9 +56,15 @@ import { ARBITRUM_USDC, PERPS_CURRENCY, } from '../../../../Views/confirmations/constants/perps'; -import { useAddToken } from '../../../../Views/confirmations/hooks/tokens/useAddToken'; -import { useAutomaticTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useAutomaticTransactionPayToken'; +import { + useIsTransactionPayQuoteLoading, + useTransactionPayTotals, +} from '../../../../Views/confirmations/hooks/pay/useTransactionPayData'; import { useTransactionPayMetrics } from '../../../../Views/confirmations/hooks/pay/useTransactionPayMetrics'; +import { useTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; +import { useAddToken } from '../../../../Views/confirmations/hooks/tokens/useAddToken'; +import { useTransactionConfirm } from '../../../../Views/confirmations/hooks/transactions/useTransactionConfirm'; +import { useTransactionCustomAmount } from '../../../../Views/confirmations/hooks/transactions/useTransactionCustomAmount'; import { useTransactionMetadataRequest } from '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount'; import RewardsAnimations, { @@ -112,16 +120,15 @@ import { usePerpsLivePrices, usePerpsTopOfBook, } from '../../hooks/stream'; +import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsOICap } from '../../hooks/usePerpsOICap'; -import { usePerpsPaymentTokens } from '../../hooks/usePerpsPaymentTokens'; import { usePerpsSavePendingConfig } from '../../hooks/usePerpsSavePendingConfig'; import { selectPerpsButtonColorTestVariant, selectPerpsTradeWithAnyTokenEnabledFlag, } from '../../selectors/featureFlags'; -import type { PerpsToken } from '../../types/perps-types'; import { BUTTON_COLOR_TEST } from '../../utils/abTesting/tests'; import { usePerpsABTest } from '../../utils/abTesting/usePerpsABTest'; import { @@ -139,14 +146,11 @@ import { calculateRoEForPrice, isStopLossSafeFromLiquidation, } from '../../utils/tpslValidation'; -import { PerpsDepositFees } from '../../../../Views/confirmations/components/info/external/perps'; import createStyles from './PerpsOrderView.styles'; import { PerpsPayRow } from './PerpsPayRow'; -import { useTransactionConfirm } from '../../../../Views/confirmations/hooks/transactions/useTransactionConfirm'; -import { useTransactionCustomAmount } from '../../../../Views/confirmations/hooks/transactions/useTransactionCustomAmount'; import { useUpdateTokenAmount } from '../../../../Views/confirmations/hooks/transactions/useUpdateTokenAmount'; -import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { useConfirmActions } from '../../../../Views/confirmations/hooks/useConfirmActions'; +import Engine from '../../../../../core/Engine'; // Navigation params interface interface OrderRouteParams { @@ -212,12 +216,14 @@ const PerpsOrderViewContentBase: React.FC = ({ [], ); - // Disable automatic token selection - we want to show "Perps balance" by default - // User can explicitly select a token from the modal - useAutomaticTransactionPayToken({ - disable: true, // Always disable auto-selection to show "Perps balance" by default - preferredToken: undefined, - }); + // Reset selected payment token to Perps balance when leaving the order view + useEffect( + () => () => { + Engine.context.PerpsController?.resetSelectedPaymentToken?.(); + }, + [], + ); + useTransactionPayMetrics(); const styles = createStyles(colors); @@ -243,12 +249,9 @@ const PerpsOrderViewContentBase: React.FC = ({ const [selectedTooltip, setSelectedTooltip] = useState(null); - // Track if user selected a custom token (not Perps balance) - const [hasCustomTokenSelected, setHasCustomTokenSelected] = useState(false); - - const handleCustomTokenSelected = useCallback(() => { - setHasCustomTokenSelected(true); - }, []); + const { payToken } = useTransactionPayToken(); + const isPayTokenPerpsBalance = useIsPerpsBalanceSelected(); + const hasCustomTokenSelected = !isPayTokenPerpsBalance; const { track } = usePerpsEventTracking(); const { openTooltipModal } = useTooltipModal(); @@ -271,12 +274,7 @@ const PerpsOrderViewContentBase: React.FC = ({ const { account, isInitialLoading: isLoadingAccount } = usePerpsLiveAccount(); - // Get real HyperLiquid USDC balance - const availableBalance = parseFloat( - account?.availableBalance?.toString() || '0', - ); - - // Get order form state from context instead of hook + // Get order form state from context; balanceForValidation respects custom token amount when set const { orderForm, setAmount, @@ -288,6 +286,7 @@ const PerpsOrderViewContentBase: React.FC = ({ handlePercentageAmount, handleMaxAmount, maxPossibleAmount, + balanceForValidation: availableBalance, // existingPosition is available in context but not used in this component } = usePerpsOrderContext(); @@ -360,18 +359,15 @@ const PerpsOrderViewContentBase: React.FC = ({ const [isOrderTypeVisible, setIsOrderTypeVisible] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false); const [shouldOpenLimitPrice, setShouldOpenLimitPrice] = useState(false); - const [selectedToken, setSelectedToken] = useState( - undefined, - ); + const [depositAmount, setDepositAmount] = useState(''); - // Get available payment tokens and set default if none selected - const paymentTokens = usePerpsPaymentTokens(); - useEffect(() => { - if (!selectedToken && paymentTokens.length > 0) { - setSelectedToken(paymentTokens[0]); - } - }, [paymentTokens, selectedToken]); + const isPayRowVisible = Boolean( + isTradeWithAnyTokenEnabled && + depositAmount && + depositAmount.trim() !== '' && + activeTransactionMeta, + ); // Handle opening limit price modal after order type modal closes useEffect(() => { @@ -448,6 +444,28 @@ const PerpsOrderViewContentBase: React.FC = ({ const estimatedFees = feeResults.totalFee; + // Deposit/bridge fees from transaction pay (when paying with custom token) + const payTotals = useTransactionPayTotals(); + const isPayTotalsLoading = useIsTransactionPayQuoteLoading(); + const depositFeeUsd = useMemo(() => { + if (!hasCustomTokenSelected || !payTotals?.fees) return 0; + const { provider, sourceNetwork, targetNetwork } = payTotals.fees; + return new BigNumber(provider?.usd ?? 0) + .plus(sourceNetwork?.estimate?.usd ?? 0) + .plus(targetNetwork?.usd ?? 0) + .toNumber(); + }, [hasCustomTokenSelected, payTotals]); + + const combinedFees = useMemo( + () => estimatedFees + depositFeeUsd, + [estimatedFees, depositFeeUsd], + ); + + const feesToDisplay = hasCustomTokenSelected ? combinedFees : estimatedFees; + const isFeesLoading = + feeResults.isLoadingMetamaskFee || + (hasCustomTokenSelected && isPayTotalsLoading); + // Simple boolean calculation - no need for expensive memoization const hasValidAmount = parseFloat(orderForm.amount) > 0; @@ -516,6 +534,15 @@ const PerpsOrderViewContentBase: React.FC = ({ positionSize, ]); + const hasInsufficientPayTokenBalance = useMemo(() => { + if (marginRequired == null || !payToken || !hasCustomTokenSelected) { + return false; + } + const requiredUsd = Number(marginRequired); + const balanceUsd = Number(payToken.balanceUsd); + return requiredUsd > balanceUsd; + }, [hasCustomTokenSelected, marginRequired, payToken]); + const { updatePositionTPSL } = usePerpsTrading(); // Order execution using new hook @@ -782,7 +809,7 @@ const PerpsOrderViewContentBase: React.FC = ({ }; // Clamp amount to the maximum allowed once the keypad/input is dismissed - // Mirrors the PerpsClosePositionView behavior where values are normalized to valid limits + // maxPossibleAmount from context respects selected token amount in USD when paying with custom token useEffect(() => { if (!isInputFocused) { // Only clamp if input was from keypad (not from percentage/slider/max) @@ -833,6 +860,14 @@ const PerpsOrderViewContentBase: React.FC = ({ // Show deposit toast and set up tracking before confirming handleDepositConfirm(activeTransactionMeta, () => { + hasShownSubmittedToastRef.current = true; + showToast( + PerpsToastOptions.orderManagement[orderForm.type].submitted( + orderForm.direction, + positionSize, + orderForm.asset, + ), + ); handlePlaceOrder(true); }); @@ -1040,6 +1075,7 @@ const PerpsOrderViewContentBase: React.FC = ({ executeOrder, showToast, PerpsToastOptions.formValidation.orderForm, + PerpsToastOptions.orderManagement, PerpsToastOptions.positionManagement.tpsl, updatePositionTPSL, marginRequired, @@ -1197,9 +1233,9 @@ const PerpsOrderViewContentBase: React.FC = ({ = ({ {/* Combined TP/SL row - Hidden when modifying existing position */} {!hideTPSL && ( - + = ({ )} + {/* Pay with row - directly below TP/SL, same stacked box styling */} + {isPayRowVisible && ( + + handleTooltipPress('pay_with')} + /> + + )} + {hasInsufficientPayTokenBalance && ( + + + {strings( + 'perps.order.validation.insufficient_funds_to_cover_trade', + )} + + + )} {!hideTPSL && doesStopLossRiskLiquidation && ( @@ -1413,30 +1472,22 @@ const PerpsOrderViewContentBase: React.FC = ({ /> - - - - {isTradeWithAnyTokenEnabled && - depositAmount && - depositAmount.trim() !== '' && - activeTransactionMeta && ( - - - {hasCustomTokenSelected ? : null} - + {isFeesLoading ? ( + + ) : ( + )} + {/* Rewards Points Estimation */} {rewardsState.shouldShowRewardsRow && @@ -1568,7 +1619,8 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || - isAtOICap + isAtOICap || + isFeesLoading } loading={isPlacingOrder} testID={PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON} @@ -1587,7 +1639,8 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || - isAtOICap + isAtOICap || + isFeesLoading } isLoading={isPlacingOrder} testID={PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON} @@ -1710,6 +1763,12 @@ const PerpsOrderViewContentBase: React.FC = ({ protocolFeeRate: feeResults.protocolFeeRate, originalMetamaskFeeRate: feeResults.originalMetamaskFeeRate, feeDiscountPercentage: feeResults.feeDiscountPercentage, + ...(hasCustomTokenSelected && + depositFeeUsd > 0 && { + bridgeFeeFormatted: formatPerpsFiat(depositFeeUsd, { + ranges: PRICE_RANGES_MINIMAL_VIEW, + }), + }), } : undefined } @@ -1736,6 +1795,8 @@ PerpsOrderViewContent.displayName = 'PerpsOrderViewContent'; // Main component that wraps content with context providers const PerpsOrderView: React.FC = () => { const route = useRoute>(); + const { payToken } = useTransactionPayToken(); + const hasCustomTokenSelected = !useIsPerpsBalanceSelected(); // Get navigation params to pass to context provider const { @@ -1747,6 +1808,12 @@ const PerpsOrderView: React.FC = () => { hideTPSL = false, } = route.params || {}; + const effectiveAvailableBalance = useMemo(() => { + if (!hasCustomTokenSelected) return undefined; + const amount = payToken?.balanceUsd; + return amount !== undefined ? Number(amount) : undefined; + }, [hasCustomTokenSelected, payToken?.balanceUsd]); + return ( { initialAmount={paramAmount} initialLeverage={paramLeverage} existingPosition={existingPosition} + effectiveAvailableBalance={effectiveAvailableBalance} > diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx new file mode 100644 index 00000000000..243cd1960c5 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import { PerpsPayRow } from './PerpsPayRow'; +import { useNavigation } from '@react-navigation/native'; +import { useTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; +import { useTransactionMetadataRequest } from '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; +import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; +import { useTokenWithBalance } from '../../../../Views/confirmations/hooks/tokens/useTokenWithBalance'; +import { useConfirmationMetricEvents } from '../../../../Views/confirmations/hooks/metrics/useConfirmationMetricEvents'; +import { isHardwareAccount } from '../../../../../util/address'; +import Routes from '../../../../../constants/navigation/Routes'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { + ConfirmationRowComponentIDs, + TransactionPayComponentIDs, +} from '../../../../Views/confirmations/ConfirmationView.testIds'; + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +jest.mock('../../../../Views/confirmations/hooks/pay/useTransactionPayToken'); +jest.mock( + '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest', +); +jest.mock('../../hooks/useIsPerpsBalanceSelected'); +jest.mock('../../../../Views/confirmations/hooks/tokens/useTokenWithBalance'); +jest.mock( + '../../../../Views/confirmations/hooks/metrics/useConfirmationMetricEvents', +); +jest.mock('../../../../../util/address'); +jest.mock('../../../../Base/TokenIcon', () => jest.fn(() => null)); +jest.mock('../../../../../util/networks', () => ({ + getNetworkImageSource: jest.fn(() => ({ uri: 'network-icon.png' })), +})); + +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockUseTransactionPayToken = + useTransactionPayToken as jest.MockedFunction; +const mockUseTransactionMetadataRequest = + useTransactionMetadataRequest as jest.MockedFunction< + typeof useTransactionMetadataRequest + >; +const mockUseIsPerpsBalanceSelected = + useIsPerpsBalanceSelected as jest.MockedFunction< + typeof useIsPerpsBalanceSelected + >; +const mockUseTokenWithBalance = useTokenWithBalance as jest.MockedFunction< + typeof useTokenWithBalance +>; +const mockUseConfirmationMetricEvents = + useConfirmationMetricEvents as jest.MockedFunction< + typeof useConfirmationMetricEvents + >; +const mockIsHardwareAccount = isHardwareAccount as jest.MockedFunction< + typeof isHardwareAccount +>; + +describe('PerpsPayRow', () => { + const navigateMock = jest.fn(); + const setConfirmationMetricMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue({ + navigate: navigateMock, + } as unknown as ReturnType); + mockUseTransactionMetadataRequest.mockReturnValue({ + txParams: { from: '0x123' }, + } as ReturnType); + mockUseTransactionPayToken.mockReturnValue({ + payToken: { + address: '0xusdc', + chainId: '0xa4b1', + symbol: 'USDC', + }, + setPayToken: jest.fn(), + } as unknown as ReturnType); + mockUseIsPerpsBalanceSelected.mockReturnValue(false); + mockUseTokenWithBalance.mockReturnValue({ + address: '0xusdc', + symbol: 'USDC', + image: 'https://example.com/usdc.png', + } as unknown as ReturnType); + mockUseConfirmationMetricEvents.mockReturnValue({ + setConfirmationMetric: setConfirmationMetricMock, + } as unknown as ReturnType); + mockIsHardwareAccount.mockReturnValue(false); + }); + + it('renders pay with label', () => { + const { getByText } = renderWithProvider(); + + expect(getByText('confirm.label.pay_with')).toBeOnTheScreen(); + }); + + it('renders perps balance label when perps balance is selected', () => { + mockUseIsPerpsBalanceSelected.mockReturnValue(true); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(TransactionPayComponentIDs.PAY_WITH_SYMBOL), + ).toHaveTextContent('perps.adjust_margin.perps_balance'); + }); + + it('renders pay token symbol when perps balance is not selected', () => { + mockUseIsPerpsBalanceSelected.mockReturnValue(false); + mockUseTransactionPayToken.mockReturnValue({ + payToken: { address: '0xusdc', chainId: '0xa4b1', symbol: 'USDC' }, + setPayToken: jest.fn(), + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(TransactionPayComponentIDs.PAY_WITH_SYMBOL), + ).toHaveTextContent('USDC'); + }); + + it('navigates to pay with modal when row is pressed and not hardware account', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)); + + expect(navigateMock).toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_MODAL, + ); + expect(setConfirmationMetricMock).toHaveBeenCalledWith({ + properties: { mm_pay_token_list_opened: true }, + }); + }); + + it('does not navigate when hardware account', () => { + mockIsHardwareAccount.mockReturnValue(true); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)); + + expect(navigateMock).not.toHaveBeenCalled(); + expect(setConfirmationMetricMock).not.toHaveBeenCalled(); + }); + + it('calls onPayWithInfoPress when info icon is pressed', () => { + const onPayWithInfoPress = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('perps-pay-row-info')); + + expect(onPayWithInfoPress).toHaveBeenCalledTimes(1); + }); + + it('renders with embedded style when embeddedInStack is true', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx index 31460c03c16..31b55f3149f 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx @@ -1,204 +1,139 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, TouchableOpacity } from 'react-native'; -import { BigNumber } from 'bignumber.js'; import { strings } from '../../../../../../locales/i18n'; -import Routes from '../../../../../constants/navigation/Routes'; -import { Box } from '../../../../UI/Box/Box'; -import { - AlignItems, - FlexDirection, - JustifyContent, -} from '../../../../UI/Box/box.types'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; +import Badge, { + BadgeVariant, +} from '../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../component-library/components/Badges/BadgeWrapper'; import Icon, { IconColor, IconName, IconSize, } from '../../../../../component-library/components/Icons/Icon'; -import { TokenIcon } from '../../../../Views/confirmations/components/token-icon'; -import { useStyles } from '../../../../hooks/useStyles'; -import styleSheet from '../../../../Views/confirmations/components/rows/pay-with-row/pay-with-row.styles'; -import useFiatFormatter from '../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; -import { usePerpsLiveAccount } from '../../hooks/stream/usePerpsLiveAccount'; -import { usePerpsNetwork } from '../../hooks/usePerpsNetwork'; -import { - ARBITRUM_MAINNET_CHAIN_ID_HEX, - ARBITRUM_SEPOLIA_CHAIN_ID, - HYPERLIQUID_MAINNET_CHAIN_ID, - HYPERLIQUID_TESTNET_CHAIN_ID, - USDC_ARBITRUM_MAINNET_ADDRESS, - USDC_ARBITRUM_TESTNET_ADDRESS, -} from '../../constants/hyperLiquidConfig'; -import BaseTokenIcon from '../../../../Base/TokenIcon'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../component-library/components/Badges/BadgeWrapper'; -import Badge, { - BadgeVariant, -} from '../../../../../component-library/components/Badges/Badge'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import Routes from '../../../../../constants/navigation/Routes'; +import { isHardwareAccount } from '../../../../../util/address'; import { getNetworkImageSource } from '../../../../../util/networks'; -import { useTokenWithBalance } from '../../../../Views/confirmations/hooks/tokens/useTokenWithBalance'; +import { useTheme } from '../../../../../util/theme'; +import BaseTokenIcon from '../../../../Base/TokenIcon'; +import { Box } from '../../../../UI/Box/Box'; +import { AlignItems, FlexDirection } from '../../../../UI/Box/box.types'; import { ConfirmationRowComponentIDs, TransactionPayComponentIDs, } from '../../../../Views/confirmations/ConfirmationView.testIds'; import { useConfirmationMetricEvents } from '../../../../Views/confirmations/hooks/metrics/useConfirmationMetricEvents'; -import { isHardwareAccount } from '../../../../../util/address'; -import { useTransactionMetadataRequest } from '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { useTransactionPayToken } from '../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; -import type { Hex } from '@metamask/utils'; +import { useTokenWithBalance } from '../../../../Views/confirmations/hooks/tokens/useTokenWithBalance'; +import { useTransactionMetadataRequest } from '../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; +import { + PERPS_BALANCE_CHAIN_ID, + PERPS_BALANCE_PLACEHOLDER_ADDRESS, +} from '../../constants/perpsConfig'; +import { PERPS_BALANCE_ICON_URI } from '../../hooks/usePerpsBalanceTokenFilter'; +import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; +import { Hex } from '@metamask/utils'; const tokenIconStyles = StyleSheet.create({ - icon: { - width: 32, - height: 32, - borderRadius: 16, + iconSmall: { + width: 24, + height: 24, + borderRadius: 12, }, }); -interface PerpsPayRowProps { - onCustomTokenSelected?: () => void; +const createPayRowStyles = (colors: { background: { section: string } }) => + StyleSheet.create({ + payRowSection: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: colors.background.section, + borderRadius: 8, + padding: 12, + marginBottom: 12, + }, + /** When embedded below another box (e.g. TP/SL), parent provides background and radius */ + payRowEmbedded: { + borderRadius: 0, + marginBottom: 0, + }, + payRowLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + infoIcon: { + marginLeft: 0, + padding: 10, + marginRight: -6, + marginTop: -10, + marginBottom: -10, + }, + }); + +export interface PerpsPayRowProps { + /** Optional callback when the info (i) icon is pressed, e.g. for tooltip */ + onPayWithInfoPress?: () => void; + /** When true, row is stacked below another box (e.g. TP/SL); parent provides background and border radius */ + embeddedInStack?: boolean; } export const PerpsPayRow = ({ - onCustomTokenSelected, -}: PerpsPayRowProps = {}) => { + onPayWithInfoPress, + embeddedInStack = false, +}: PerpsPayRowProps) => { const navigation = useNavigation(); - const formatFiat = useFiatFormatter({ currency: 'usd' }); - const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); + const styles = createPayRowStyles(colors); const { setConfirmationMetric } = useConfirmationMetricEvents(); - const currentNetwork = usePerpsNetwork(); const { payToken } = useTransactionPayToken(); - - // Track if user has explicitly interacted with token selection - const [hasUserInteracted, setHasUserInteracted] = useState(false); - const initialPayTokenRef = useRef(null); - - // Get Perps balance from live account - const { account: perpsAccount } = usePerpsLiveAccount({ throttleMs: 1000 }); - const availableBalance = perpsAccount?.availableBalance || '0'; + const transactionMeta = useTransactionMetadataRequest(); + const matchesPerpsBalance = useIsPerpsBalanceSelected(); const { txParams: { from }, - } = useTransactionMetadataRequest() ?? { txParams: {} }; + } = transactionMeta ?? { txParams: {} }; const canEdit = !isHardwareAccount(from ?? ''); - // Determine HyperLiquid chain ID and USDC address based on network - const hyperliquidChainId = useMemo( - () => - currentNetwork === 'testnet' - ? HYPERLIQUID_TESTNET_CHAIN_ID - : HYPERLIQUID_MAINNET_CHAIN_ID, - [currentNetwork], - ); - - const usdcAddress = useMemo( - () => - currentNetwork === 'testnet' - ? USDC_ARBITRUM_TESTNET_ADDRESS - : USDC_ARBITRUM_MAINNET_ADDRESS, - [currentNetwork], - ); - - // Store initial payToken on mount - useEffect(() => { - if (payToken && initialPayTokenRef.current === null) { - initialPayTokenRef.current = `${payToken.chainId}-${payToken.address}`; - } - }, [payToken]); - - // Detect when payToken changes from initial value (user selected a different token) - useEffect(() => { - if (payToken && initialPayTokenRef.current !== null) { - const currentTokenKey = `${payToken.chainId}-${payToken.address}`; - if (currentTokenKey !== initialPayTokenRef.current) { - setHasUserInteracted(true); - onCustomTokenSelected?.(); - } - } - }, [payToken, onCustomTokenSelected]); - const handleClick = useCallback(() => { if (!canEdit) return; - // Mark that user has interacted when they open the modal - setHasUserInteracted(true); - onCustomTokenSelected?.(); setConfirmationMetric({ properties: { mm_pay_token_list_opened: true, }, }); navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); - }, [canEdit, navigation, setConfirmationMetric, onCustomTokenSelected]); - - // Determine Arbitrum chain ID for token lookup (USDC is stored under Arbitrum chain ID) - const arbitrumChainId = useMemo( - () => - currentNetwork === 'testnet' - ? ARBITRUM_SEPOLIA_CHAIN_ID - : ARBITRUM_MAINNET_CHAIN_ID_HEX, - [currentNetwork], - ); - - // Determine what to display based on user interaction - const displayToken = useMemo(() => { - // If user hasn't interacted, always show Perps balance - if (!hasUserInteracted) { - return { - address: usdcAddress as Hex, - tokenLookupChainId: arbitrumChainId as Hex, // Use Arbitrum to find token - networkBadgeChainId: hyperliquidChainId as Hex, // Use HyperLiquid for network badge - label: strings('perps.adjust_margin.perps_balance'), - balance: availableBalance, - }; - } - - // Show the selected token - if (!payToken) { - // Fallback to Perps balance if no token (shouldn't happen) - return { - address: usdcAddress as Hex, - tokenLookupChainId: arbitrumChainId as Hex, - networkBadgeChainId: hyperliquidChainId as Hex, - label: strings('perps.adjust_margin.perps_balance'), - balance: availableBalance, + }, [canEdit, navigation, setConfirmationMetric]); + + // Display data: use local state (defaults to Perps balance) so UI always shows "Perps balance" by default + const displayToken = matchesPerpsBalance + ? { + address: PERPS_BALANCE_PLACEHOLDER_ADDRESS, + tokenLookupChainId: PERPS_BALANCE_CHAIN_ID, + networkBadgeChainId: PERPS_BALANCE_CHAIN_ID, + symbol: strings('perps.adjust_margin.perps_balance'), + } + : { + address: payToken?.address ?? PERPS_BALANCE_PLACEHOLDER_ADDRESS, + tokenLookupChainId: payToken?.chainId ?? CHAIN_IDS.MAINNET, + networkBadgeChainId: payToken?.chainId ?? CHAIN_IDS.MAINNET, + symbol: payToken?.symbol ?? '', }; - } - return { - address: payToken.address as Hex, - tokenLookupChainId: payToken.chainId as Hex, - networkBadgeChainId: payToken.chainId as Hex, // Use same chainId for both - label: `${strings('confirm.label.pay_with')} ${payToken.symbol}`, - balance: payToken.balanceUsd ?? '0', - }; - }, [ - hasUserInteracted, - payToken, - usdcAddress, - arbitrumChainId, - hyperliquidChainId, - availableBalance, - ]); - - // Get token for icon (use tokenLookupChainId to find the token) const token = useTokenWithBalance( - displayToken.address, + displayToken.address as unknown as Hex, displayToken.tokenLookupChainId, ); - // Get network badge image source (use networkBadgeChainId for the badge) const networkImageSource = useMemo( () => getNetworkImageSource({ @@ -207,75 +142,84 @@ export const PerpsPayRow = ({ [displayToken.networkBadgeChainId], ); - const balanceUsdFormatted = useMemo( - () => formatFiat(new BigNumber(displayToken.balance)), - [formatFiat, displayToken.balance], - ); - return ( - {token ? ( - - } - > - - - ) : ( - - )} - - {displayToken.label} + + {strings('confirm.label.pay_with')} - onPayWithInfoPress?.()} + style={styles.infoIcon} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + testID="perps-pay-row-info" > - {balanceUsdFormatted} - - {canEdit && from && ( + + + + {matchesPerpsBalance ? ( + <> + + + {strings('perps.adjust_margin.perps_balance')} + + + ) : ( + <> + {token ? ( + + } + > + + + ) : null} + + {displayToken.symbol} + + )} diff --git a/app/components/UI/Perps/__mocks__/serviceMocks.ts b/app/components/UI/Perps/__mocks__/serviceMocks.ts index c79b082a4d5..526a6123718 100644 --- a/app/components/UI/Perps/__mocks__/serviceMocks.ts +++ b/app/components/UI/Perps/__mocks__/serviceMocks.ts @@ -108,6 +108,7 @@ export const createMockPerpsControllerState = ( lastError: null, lastUpdateTimestamp: Date.now(), hip3ConfigVersion: 0, + selectedPaymentToken: null, ...overrides, }); diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.test.tsx b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.test.tsx index b44b45b000d..7f95125d0c9 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.test.tsx +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.test.tsx @@ -210,10 +210,13 @@ describe('PerpsBottomSheetTooltip', () => { getByTestId(PerpsBottomSheetTooltipSelectorsIDs.GOT_IT_BUTTON), ); - await waitFor(() => { - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - }); + await waitFor( + () => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }, + { timeout: 10000 }, + ); + }, 15000); it('renders different content for different contentKey (Margin Tooltip)', () => { const { getByText } = renderBottomSheetTooltip({ diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts index 441f69b7f3a..a45cf5433e1 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts @@ -55,4 +55,5 @@ export type PerpsTooltipContentKey = | 'market_hours' | 'after_hours_trading' | 'oracle_price' - | 'spread'; + | 'spread' + | 'pay_with'; diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/FeesTooltipContent.tsx b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/FeesTooltipContent.tsx index 6241e9906f2..8631dd314b4 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/FeesTooltipContent.tsx +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/FeesTooltipContent.tsx @@ -17,6 +17,7 @@ interface FeesTooltipContentProps extends TooltipContentProps { protocolFeeRate?: number; originalMetamaskFeeRate?: number; feeDiscountPercentage?: number; + bridgeFeeFormatted?: string; }; } @@ -76,6 +77,18 @@ const FeesTooltipContent = ({ testID, data }: FeesTooltipContentProps) => { {providerFee} + + {/* Bridge Fee Row (when paying with custom token) */} + {data?.bridgeFeeFormatted ? ( + + + {strings('perps.tooltips.fees.bridge_fee')} + + + {data.bridgeFeeFormatted} + + + ) : null} ); }; diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.test.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.test.ts index 8ff6c598866..757a90fcf83 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.test.ts +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.test.ts @@ -46,6 +46,7 @@ describe('tooltipContentRegistry', () => { 'tp_sl', 'close_position_you_receive', 'points', + 'pay_with', ]; undefinedKeys.forEach((key) => { @@ -76,6 +77,7 @@ describe('tooltipContentRegistry', () => { 'close_position_you_receive', 'tpsl_count_warning', 'points', + 'pay_with', ]; expectedKeys.forEach((key) => { diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts index 2b8abf1d97a..fc4f9d9c057 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts @@ -37,4 +37,5 @@ export const tooltipContentRegistry: ContentRegistry = { after_hours_trading: MarketHoursContent, oracle_price: undefined, spread: undefined, + pay_with: undefined, }; diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index bde6a6931b5..6edc50e026f 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -1,11 +1,23 @@ +import type { Hex } from '@metamask/utils'; import { TokenI } from '../../Tokens/types'; +/** Address used to represent "Perps balance" as the payment token (synthetic option). */ +export const PERPS_BALANCE_PLACEHOLDER_ADDRESS = + '0x0000000000000000000000000000000000000000' as Hex; + +/** Chain id used for the "Perps balance" payment option. */ +export { ARBITRUM_CHAIN_ID as PERPS_BALANCE_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; + /** * Perps feature constants */ export const PERPS_CONSTANTS = { FeatureFlagKey: 'perpsEnabled', FeatureName: 'perps', // Constant for Sentry error filtering - enables "feature:perps" dashboard queries + /** Token description used to identify the synthetic "Perps balance" option in pay-with token lists */ + PerpsBalanceTokenDescription: 'perps-balance', + /** Symbol displayed for the synthetic "Perps balance" token in pay-with token lists */ + PerpsBalanceTokenSymbol: 'USD', WebsocketTimeout: 5000, // 5 seconds WebsocketCleanupDelay: 1000, // 1 second BackgroundDisconnectDelay: 20_000, // 20 seconds delay before disconnecting when app is backgrounded or when user exits perps UX @@ -25,6 +37,9 @@ export const PERPS_CONSTANTS = { BalanceUpdateThrottleMs: 15000, // Update at most every 15 seconds to reduce state updates in PerpsConnectionManager InitialDataDelayMs: 100, // Delay to allow initial data to load after connection establishment + // Deposit toast timing + DepositTakingLongerToastDelayMs: 15_000, // Delay before showing "Deposit taking longer than usual" toast + DefaultAssetPreviewLimit: 5, DefaultMaxLeverage: 3 as number, // Default fallback max leverage when market data is unavailable - conservative default FallbackPriceDisplay: '$---', // Display when price data is unavailable diff --git a/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx b/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx index d8601bc37b4..77431e8caf6 100644 --- a/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx +++ b/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx @@ -43,6 +43,7 @@ describe('PerpsOrderContext', () => { handleMaxAmount: jest.fn(), handleMinAmount: jest.fn(), maxPossibleAmount: 1000, + balanceForValidation: 1000, }; beforeEach(() => { @@ -92,6 +93,7 @@ describe('PerpsOrderContext', () => { initialAmount: undefined, initialLeverage: undefined, initialType: undefined, + effectiveAvailableBalance: undefined, }); }); @@ -110,7 +112,10 @@ describe('PerpsOrderContext', () => { , ); - expect(mockUsePerpsOrderForm).toHaveBeenCalledWith(initialProps); + expect(mockUsePerpsOrderForm).toHaveBeenCalledWith({ + ...initialProps, + effectiveAvailableBalance: undefined, + }); }); it('provides the order form state to context', () => { diff --git a/app/components/UI/Perps/contexts/PerpsOrderContext.tsx b/app/components/UI/Perps/contexts/PerpsOrderContext.tsx index 15673e8f677..da9e05dc5e1 100644 --- a/app/components/UI/Perps/contexts/PerpsOrderContext.tsx +++ b/app/components/UI/Perps/contexts/PerpsOrderContext.tsx @@ -19,6 +19,8 @@ interface PerpsOrderProviderProps { initialLeverage?: number; initialType?: OrderType; existingPosition?: Position; + /** When paying with a custom token, the selected token amount in USD; caps maxPossibleAmount and amount handlers */ + effectiveAvailableBalance?: number; } export const PerpsOrderProvider = ({ @@ -29,6 +31,7 @@ export const PerpsOrderProvider = ({ initialLeverage, initialType, existingPosition, + effectiveAvailableBalance, }: PerpsOrderProviderProps) => { const orderFormState = usePerpsOrderForm({ initialAsset, @@ -36,6 +39,7 @@ export const PerpsOrderProvider = ({ initialAmount, initialLeverage: initialLeverage ?? existingPosition?.leverage?.value, initialType, + effectiveAvailableBalance, }); return ( diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index c94e9919e13..88fff778145 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -17,9 +17,11 @@ import { GasFeeEstimateType, } from '@metamask/transaction-controller'; import type { + AccountState, PerpsProvider, PerpsPlatformDependencies, PerpsProviderType, + SubscribeAccountParams, } from './types'; import { HyperLiquidProvider } from './providers/HyperLiquidProvider'; import { createMockHyperLiquidProvider } from '../__mocks__/providerMocks'; @@ -1871,7 +1873,50 @@ describe('PerpsController', () => { const unsubscribe = controller.subscribeToAccount(params); expect(unsubscribe).toBe(mockUnsubscribe); - expect(mockProvider.subscribeToAccount).toHaveBeenCalledWith(params); + // Controller wraps callback to update state, so expect a function rather than exact params + expect(mockProvider.subscribeToAccount).toHaveBeenCalledWith( + expect.objectContaining({ callback: expect.any(Function) }), + ); + }); + + it('updates accountState when subscribeToAccount callback receives non-null account', () => { + const originalCallback = jest.fn(); + let wrappedCallback: (account: AccountState | null) => void = () => { + /* assigned by mock */ + }; + mockProvider.subscribeToAccount.mockImplementation( + (p: SubscribeAccountParams) => { + wrappedCallback = p.callback; + return jest.fn(); + }, + ); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.subscribeToAccount({ callback: originalCallback }); + + const accountState = { + availableBalance: '5000', + totalBalance: '5000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + wrappedCallback(accountState); + + expect(controller.state.accountState).toMatchObject(accountState); + expect(originalCallback).toHaveBeenCalledWith(accountState); + }); + + it('returns no-op unsub and does not throw when subscribeToAccount called before init', () => { + const params = { callback: jest.fn() }; + + const unsubscribe = controller.subscribeToAccount(params); + + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + expect(mockProvider.subscribeToAccount).not.toHaveBeenCalled(); }); }); @@ -3700,4 +3745,64 @@ describe('PerpsController', () => { expect(savedGrouping).toBe(100); }); }); + + describe('setSelectedPaymentToken', () => { + it('sets selectedPaymentToken to null when passed null', () => { + controller.testUpdate((state) => { + state.selectedPaymentToken = { + description: 'USDC', + address: '0xa0b8', + chainId: '0x1', + } as PerpsControllerState['selectedPaymentToken']; + }); + + controller.setSelectedPaymentToken(null); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + + it('sets selectedPaymentToken to null when token has PerpsBalanceTokenDescription', () => { + controller.setSelectedPaymentToken({ + description: 'perps-balance', + address: '0x0', + chainId: '0x1', + } as Parameters[0]); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + + it('stores description, address and chainId when passed a normal token', () => { + const token = { + description: 'USDC', + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const, + chainId: '0x1' as const, + }; + + controller.setSelectedPaymentToken( + token as Parameters[0], + ); + + expect(controller.state.selectedPaymentToken).toMatchObject({ + description: 'USDC', + address: token.address, + chainId: token.chainId, + }); + }); + }); + + describe('resetSelectedPaymentToken', () => { + it('sets selectedPaymentToken to null', () => { + controller.testUpdate((state) => { + state.selectedPaymentToken = { + description: 'USDC', + address: '0xa0b8', + chainId: '0x1', + } as PerpsControllerState['selectedPaymentToken']; + }); + + controller.resetSelectedPaymentToken(); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + }); }); diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 495ac18d2d8..2aad3dbe21e 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -120,9 +120,21 @@ import type { RemoteFeatureFlagControllerStateChangeEvent, RemoteFeatureFlagControllerGetStateAction, } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; import { wait } from '../utils/wait'; import { getSelectedEvmAccount } from '../utils/accountUtils'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import type { AssetType } from '../../../Views/confirmations/types/token'; + +/** + * Minimal payment token stored in PerpsController state. + * Only required fields for identification and Perps balance detection. + */ +export interface SelectedPaymentTokenSnapshot { + description?: string; + address: string; + chainId: string; +} // Re-export error codes from separate file to avoid circular dependencies export { PERPS_ERROR_CODES, type PerpsErrorCode } from './perpsErrorCodes'; @@ -286,6 +298,9 @@ export type PerpsControllerState = { // HIP-3 Configuration Version (incremented when HIP-3 remote flags change) // Used to trigger reconnection and cache invalidation in ConnectionManager hip3ConfigVersion: number; + + // Selected payment token for Perps order/deposit flow (null = Perps balance). Stored as Json (minimal shape: description, address, chainId). + selectedPaymentToken: Json | null; }; /** @@ -340,6 +355,7 @@ export const getDefaultPerpsControllerState = (): PerpsControllerState => ({ direction: MARKET_SORTING_CONFIG.DefaultDirection, }, hip3ConfigVersion: 0, + selectedPaymentToken: null, }); /** @@ -490,6 +506,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, + selectedPaymentToken: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, }; /** @@ -632,6 +654,14 @@ export type PerpsControllerActions = | { type: 'PerpsController:saveOrderBookGrouping'; handler: PerpsController['saveOrderBookGrouping']; + } + | { + type: 'PerpsController:setSelectedPaymentToken'; + handler: PerpsController['setSelectedPaymentToken']; + } + | { + type: 'PerpsController:resetSelectedPaymentToken'; + handler: PerpsController['resetSelectedPaymentToken']; }; /** @@ -1279,6 +1309,14 @@ export class PerpsController extends BaseController< }; } + /** + * Returns current controller state as PerpsControllerState. + * Used by createServiceContext to avoid deep type instantiation when building stateManager. + */ + private getControllerState(): PerpsControllerState { + return this.state as unknown as PerpsControllerState; + } + /** * Create a ServiceContext for dependency injection into services * Provides all orchestration dependencies (tracing, analytics, state management) @@ -1301,11 +1339,13 @@ export class PerpsController extends BaseController< method, }, stateManager: { - update: (updater) => this.update(updater), - getState: () => this.state, + update: (updater: (state: PerpsControllerState) => void) => + // @ts-expect-error TS2589 - excessively deep instantiation when inferring stateManager from BaseController + this.update(updater), + getState: (): PerpsControllerState => this.getControllerState(), }, ...additionalContext, - }; + } as ServiceContext; } /** @@ -2406,12 +2446,27 @@ export class PerpsController extends BaseController< } /** - * Subscribe to live account updates + * Subscribe to live account updates. + * Updates controller state (Redux) when new account data arrives so consumers + * like usePerpsBalanceTokenFilter (PayWithModal) see the latest balance. */ subscribeToAccount(params: SubscribeAccountParams): () => void { try { const provider = this.getActiveProvider(); - return provider.subscribeToAccount(params); + const originalCallback = params.callback; + return provider.subscribeToAccount({ + ...params, + callback: (account: AccountState | null) => { + if (account) { + this.update((state) => { + state.accountState = account; + state.lastUpdateTimestamp = Date.now(); + state.lastError = null; + }); + } + originalCallback(account); + }, + }); } catch (error) { this.logError( ensureError(error), @@ -2934,6 +2989,44 @@ export class PerpsController extends BaseController< }); } + /** + * Set the selected payment token for the Perps order/deposit flow. + * Pass null or a token with description PERPS_CONSTANTS.PerpsBalanceTokenDescription to select Perps balance. + * Only required fields (description, address, chainId) are stored in state. + */ + setSelectedPaymentToken(token: AssetType | null): void { + let normalized: AssetType | null = null; + if ( + token != null && + token.description !== PERPS_CONSTANTS.PerpsBalanceTokenDescription + ) { + normalized = token; + } + + let snapshot: Json | null = null; + if (normalized !== null) { + snapshot = { + description: normalized.description, + address: normalized.address, + chainId: normalized.chainId, + } as unknown as Json; + } + + this.update((state) => { + state.selectedPaymentToken = snapshot; + }); + } + + /** + * Reset the selected payment token to Perps balance (null). + * Call when leaving the Perps order view so the next visit defaults to Perps balance. + */ + resetSelectedPaymentToken(): void { + this.update((state) => { + state.selectedPaymentToken = null; + }); + } + /** * Get saved order book grouping for a market * @param symbol - Market symbol diff --git a/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.test.ts b/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.test.ts index f8e68e30cbf..294bf20d2fc 100644 --- a/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.test.ts +++ b/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.test.ts @@ -20,7 +20,7 @@ interface MockProviderWithEmit extends jest.Mocked> { _emitPositions: (positions: Position[]) => void; _emitOrders: (orders: Order[]) => void; _emitFills: (fills: OrderFill[], isSnapshot?: boolean) => void; - _emitAccount: (account: AccountState) => void; + _emitAccount: (account: AccountState | null) => void; } // Mock provider factory @@ -30,7 +30,7 @@ const createMockProvider = (providerId: string): MockProviderWithEmit => { const orderCallbacks: ((orders: Order[]) => void)[] = []; const fillCallbacks: ((fills: OrderFill[], isSnapshot?: boolean) => void)[] = []; - const accountCallbacks: ((account: AccountState) => void)[] = []; + const accountCallbacks: ((account: AccountState | null) => void)[] = []; return { protocolId: providerId, @@ -82,7 +82,7 @@ const createMockProvider = (providerId: string): MockProviderWithEmit => { _emitFills: (fills: OrderFill[], isSnapshot?: boolean) => { fillCallbacks.forEach((cb) => cb(fills, isSnapshot)); }, - _emitAccount: (account: AccountState) => { + _emitAccount: (account: AccountState | null) => { accountCallbacks.forEach((cb) => cb(account)); }, } as MockProviderWithEmit; @@ -479,6 +479,28 @@ describe('SubscriptionMultiplexer', () => { }), ); }); + + it('removes provider from cache and invokes callback when provider emits null', () => { + const callback = jest.fn(); + + mux.subscribeToAccount({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitAccount(createMockAccount('10000')); + expect(callback).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ providerId: 'hyperliquid' }), + ]), + ); + + mockHLProvider._emitAccount(null); + + expect(callback).toHaveBeenLastCalledWith([]); + }); }); describe('cache operations', () => { diff --git a/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.ts b/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.ts index b50d03f0eb7..ae96a2afc17 100644 --- a/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.ts +++ b/app/components/UI/Perps/controllers/aggregation/SubscriptionMultiplexer.ts @@ -426,9 +426,16 @@ export class SubscriptionMultiplexer { try { const subscribeParams: SubscribeAccountParams = { callback: (account) => { - // Tag account with providerId and cache - const taggedAccount: AccountState = { ...account, providerId }; - this.accountCache.set(providerId, taggedAccount); + if (account === null) { + this.accountCache.delete(providerId); + } else { + // Tag account with providerId and cache + const taggedAccount: AccountState = { + ...account, + providerId, + }; + this.accountCache.set(providerId, taggedAccount); + } // Emit all cached account states const allAccounts = Array.from(this.accountCache.values()); diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index 72b934c6878..f48b820d46b 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -708,7 +708,7 @@ export interface SubscribeOrdersParams { } export interface SubscribeAccountParams { - callback: (account: AccountState) => void; + callback: (account: AccountState | null) => void; accountId?: CaipAccountId; // Optional: defaults to selected account } diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index 0b2edb47f32..b578c595419 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -43,6 +43,12 @@ export { useWithdrawValidation } from './useWithdrawValidation'; // Payment tokens hook export { usePerpsPaymentTokens } from './usePerpsPaymentTokens'; +export { usePerpsPaymentToken } from './usePerpsPaymentToken'; +export { + PERPS_BALANCE_CHAIN_ID, + PERPS_BALANCE_PLACEHOLDER_ADDRESS, +} from '../constants/perpsConfig'; +export { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; // Margin adjustment hook export { usePerpsAdjustMarginData } from './usePerpsAdjustMarginData'; diff --git a/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.test.ts b/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.test.ts new file mode 100644 index 00000000000..d8ef34b29ea --- /dev/null +++ b/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.test.ts @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('useIsPerpsBalanceSelected', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when selector returns true', () => { + mockUseSelector.mockReturnValue(true); + + const { result } = renderHook(() => useIsPerpsBalanceSelected()); + + expect(result.current).toBe(true); + expect(mockUseSelector).toHaveBeenCalledTimes(1); + }); + + it('returns false when selector returns false', () => { + mockUseSelector.mockReturnValue(false); + + const { result } = renderHook(() => useIsPerpsBalanceSelected()); + + expect(result.current).toBe(false); + expect(mockUseSelector).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.ts b/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.ts new file mode 100644 index 00000000000..e956b72a294 --- /dev/null +++ b/app/components/UI/Perps/hooks/useIsPerpsBalanceSelected.ts @@ -0,0 +1,10 @@ +import { useSelector } from 'react-redux'; +import { selectIsPerpsBalanceSelected } from '../selectors/perpsController'; + +/** + * Returns whether the user selected the synthetic "Perps balance" option. + * Reads from PerpsController Redux state: selectedPaymentToken === null means Perps balance selected. + */ +export function useIsPerpsBalanceSelected(): boolean { + return useSelector(selectIsPerpsBalanceSelected); +} diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts new file mode 100644 index 00000000000..4b456e2a748 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts @@ -0,0 +1,206 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { usePerpsBalanceTokenFilter } from './usePerpsBalanceTokenFilter'; +import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; +import { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; +import { + PERPS_BALANCE_PLACEHOLDER_ADDRESS, + PERPS_CONSTANTS, +} from '../constants/perpsConfig'; +import type { AssetType } from '../../../Views/confirmations/types/token'; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock( + '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest', +); +jest.mock('./useIsPerpsBalanceSelected'); +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('images/perps-pay-token-icon.png', () => ({ + uri: 'perps-pay-token-icon-uri', +})); + +jest.mock('../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter', () => + jest.fn( + () => (value: { toNumber: () => number }) => + `$${value.toNumber().toFixed(2)}`, + ), +); + +const mockUseTransactionMetadataRequest = + useTransactionMetadataRequest as jest.MockedFunction< + typeof useTransactionMetadataRequest + >; +const mockUseIsPerpsBalanceSelected = + useIsPerpsBalanceSelected as jest.MockedFunction< + typeof useIsPerpsBalanceSelected + >; +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('usePerpsBalanceTokenFilter', () => { + const chainId = '0xa4b1'; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + mockUseIsPerpsBalanceSelected.mockReturnValue(false); + mockUseSelector.mockImplementation((selector) => { + if (selector.name === 'selectPerpsAccountState') { + return { availableBalance: '1500.00' }; + } + return undefined; + }); + }); + + describe('when transaction is not perpsDepositAndOrder', () => { + it('returns tokens unchanged', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + } as ReturnType); + const inputTokens: AssetType[] = [ + { + address: '0xabc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '100', + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const filter = result.current; + const output = filter(inputTokens); + + expect(output).toBe(inputTokens); + expect(output).toHaveLength(1); + expect(output[0].address).toBe('0xabc'); + }); + + it('returns tokens unchanged when transaction meta is undefined', () => { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + const inputTokens: AssetType[] = [ + { address: '0xdef', chainId, symbol: 'ETH' } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toEqual(inputTokens); + }); + }); + + describe('when transaction is perpsDepositAndOrder', () => { + beforeEach(() => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + } as ReturnType); + }); + + it('prepends perps balance token with correct shape', () => { + mockUseSelector.mockReturnValue({ + availableBalance: '2000.50', + }); + mockUseIsPerpsBalanceSelected.mockReturnValue(true); + const inputTokens: AssetType[] = [ + { + address: '0xusdc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '500', + isSelected: false, + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(2); + const perpsToken = output[0]; + expect(perpsToken.address).toBe(PERPS_BALANCE_PLACEHOLDER_ADDRESS); + expect(perpsToken.tokenId).toBe(PERPS_BALANCE_PLACEHOLDER_ADDRESS); + expect(perpsToken.name).toBe('perps.adjust_margin.perps_balance'); + expect(perpsToken.symbol).toBe('USD'); + expect(perpsToken.balance).toBe('2000.50'); + expect(perpsToken.balanceInSelectedCurrency).toBe('$2000.50'); + expect(perpsToken.decimals).toBe(2); + expect(perpsToken.isETH).toBe(false); + expect(perpsToken.isNative).toBe(false); + expect(perpsToken.isSelected).toBe(true); + expect(perpsToken.description).toBe( + PERPS_CONSTANTS.PerpsBalanceTokenDescription, + ); + }); + + it('uses availableBalance from perps account', () => { + mockUseSelector.mockReturnValue({ + availableBalance: '999.99', + }); + const inputTokens: AssetType[] = []; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output[0].balance).toBe('999.99'); + expect(output[0].balanceInSelectedCurrency).toBe('$999.99'); + }); + + it('uses zero balance when perps account is null', () => { + mockUseSelector.mockReturnValue(null); + const inputTokens: AssetType[] = []; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output[0].balance).toBe('0'); + expect(output[0].balanceInSelectedCurrency).toBe('$0.00'); + }); + + it('clears isSelected on other tokens when perps balance is selected', () => { + mockUseIsPerpsBalanceSelected.mockReturnValue(true); + const inputTokens: AssetType[] = [ + { + address: '0xa', + chainId, + symbol: 'USDC', + isSelected: true, + } as AssetType, + { + address: '0xb', + chainId, + symbol: 'DAI', + isSelected: false, + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output[1].isSelected).toBe(false); + expect(output[2].isSelected).toBe(false); + }); + + it('keeps token isSelected when perps balance is not selected', () => { + mockUseIsPerpsBalanceSelected.mockReturnValue(false); + const inputTokens: AssetType[] = [ + { + address: '0xa', + chainId, + symbol: 'USDC', + isSelected: true, + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output[1].isSelected).toBe(true); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts new file mode 100644 index 00000000000..ab2ac4e1d67 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts @@ -0,0 +1,93 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; +import { useCallback } from 'react'; +import { Image } from 'react-native'; +import { useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; +import useFiatFormatter from '../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; +import { AssetType } from '../../../Views/confirmations/types/token'; +import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; +import perpsPayTokenIcon from 'images/perps-pay-token-icon.png'; +import { + PERPS_BALANCE_CHAIN_ID, + PERPS_BALANCE_PLACEHOLDER_ADDRESS, + PERPS_CONSTANTS, +} from '../constants/perpsConfig'; +import { selectPerpsAccountState } from '../selectors/perpsController'; +import { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; + +/** URI for the perps balance token icon, shared with PerpsPayRow and pay-with modal. */ +const resolvedPerpsIcon = Image.resolveAssetSource(perpsPayTokenIcon); +export const PERPS_BALANCE_ICON_URI = resolvedPerpsIcon?.uri ?? ''; + +/** + * Returns a filter that prepends a synthetic "Perps balance" token to the list + * when the transaction type is perpsDepositAndOrder. The token shows the perps + * account balance, USDC icon, and label "Perps balance". + * + * Uses PerpsController state (Redux) so it works in any screen, including + * PayWithModal and confirmations where PerpsStreamProvider is not mounted. + */ +export function usePerpsBalanceTokenFilter(): ( + tokens: AssetType[], +) => AssetType[] { + const transactionMeta = useTransactionMetadataRequest(); + const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); + const perpsAccount = useSelector(selectPerpsAccountState); + const formatFiat = useFiatFormatter({ currency: 'usd' }); + + const filterAllowedTokens = useCallback( + (tokens: AssetType[]): AssetType[] => { + if ( + !hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]) + ) { + return tokens; + } + + const chainId = PERPS_BALANCE_CHAIN_ID; + + const availableBalance = perpsAccount?.availableBalance || '0'; + const balanceInSelectedCurrency = formatFiat( + new BigNumber(availableBalance), + ); + + const perpsBalanceName = strings('perps.adjust_margin.perps_balance'); + + const perpsBalanceToken: AssetType = { + address: PERPS_BALANCE_PLACEHOLDER_ADDRESS, + chainId, + tokenId: PERPS_BALANCE_PLACEHOLDER_ADDRESS, + name: perpsBalanceName, + symbol: PERPS_CONSTANTS.PerpsBalanceTokenSymbol, + balance: availableBalance, + balanceInSelectedCurrency, + image: PERPS_BALANCE_ICON_URI, + logo: PERPS_BALANCE_ICON_URI, + decimals: 2, + isETH: false, + isNative: false, + isSelected: isPerpsBalanceSelected, + description: PERPS_CONSTANTS.PerpsBalanceTokenDescription, + }; + + const mappedTokens = tokens.map((token) => ({ + ...token, + isSelected: + token.isSelected && isPerpsBalanceSelected ? false : token.isSelected, + })); + + return [perpsBalanceToken, ...mappedTokens]; + }, + [ + transactionMeta, + isPerpsBalanceSelected, + perpsAccount?.availableBalance, + formatFiat, + ], + ); + + return filterAllowedTokens; +} diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts index d13c4c34ce8..7ff1dc82669 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts @@ -47,13 +47,8 @@ jest.mock('../../../../core/redux/slices/confirmationMetrics', () => ({ selectTransactionBridgeQuotesById: jest.fn(), })); -// Mock stream manager -const mockStreamManager = { - hasActiveDepositHandler: jest.fn(), -}; - jest.mock('../providers/PerpsStreamManager', () => ({ - getStreamManagerInstance: jest.fn(() => mockStreamManager), + getStreamManagerInstance: jest.fn(() => ({})), })); const mockUseSelector = useSelector as jest.MockedFunction; @@ -87,7 +82,6 @@ describe('usePerpsDepositStatus', () => { mockUnsubscribe = jest.fn(); mockShowToast = jest.fn(); mockClearDepositResult = jest.fn(); - mockStreamManager.hasActiveDepositHandler.mockReturnValue(false); mockEngine.controllerMessenger.subscribe = mockSubscribe; mockEngine.controllerMessenger.unsubscribe = mockUnsubscribe; @@ -148,6 +142,26 @@ describe('usePerpsDepositStatus', () => { ], hapticsType: NotificationFeedbackType.Success, })), + takingLonger: { + variant: ToastVariants.Icon, + iconName: IconName.Warning, + hasNoTimeout: true, + labelOptions: [ + { label: 'Deposit taking longer', isBold: true }, + { label: 'Your deposit is still processing' }, + ], + hapticsType: NotificationFeedbackType.Warning, + } as PerpsToastOptions, + tradeCanceled: { + variant: ToastVariants.Icon, + iconName: IconName.Warning, + hasNoTimeout: false, + labelOptions: [ + { label: 'Trade canceled', isBold: true }, + { label: 'Funds returned to account' }, + ], + hapticsType: NotificationFeedbackType.Warning, + } as PerpsToastOptions, }, oneClickTrade: { txCreationFailed: {} as PerpsToastOptions, @@ -424,25 +438,6 @@ describe('usePerpsDepositStatus', () => { mockPerpsToastOptions.accountManagement.deposit.inProgress, ).toHaveBeenCalledWith(60, 'test-tx-id'); // 60 seconds for other tokens }); - - it('skips showing toast when active deposit handler exists', () => { - mockStreamManager.hasActiveDepositHandler.mockReturnValue(true); - mockShowToast.mockClear(); - - 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(mockStreamManager.hasActiveDepositHandler).toHaveBeenCalled(); - }); }); describe('Balance Monitoring', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts index 917c1466fb7..2d4894093ba 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts @@ -12,7 +12,6 @@ import { ARBITRUM_MAINNET_CHAIN_ID_HEX, USDC_ARBITRUM_MAINNET_ADDRESS, } from '../constants/hyperLiquidConfig'; -import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; import { usePerpsLiveAccount } from './stream/usePerpsLiveAccount'; import usePerpsToasts from './usePerpsToasts'; import { usePerpsTrading } from './usePerpsTrading'; @@ -76,16 +75,9 @@ export const usePerpsDepositStatus = () => { metamaskPay?.tokenAddress === USDC_ARBITRUM_MAINNET_ADDRESS; if ( - (transactionMeta.type === TransactionType.perpsDeposit || - transactionMeta.type === TransactionType.perpsDepositAndOrder) && + transactionMeta.type === TransactionType.perpsDeposit && transactionMeta.status === TransactionStatus.approved ) { - // Skip showing toast if a component is actively handling deposit toasts - // (e.g., PerpsOrderView handles its own deposit toasts) - if (getStreamManagerInstance().hasActiveDepositHandler()) { - return; - } - expectingDepositRef.current = true; prevAvailableBalanceRef.current = liveAccount?.availableBalance || '0'; diff --git a/app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.ts b/app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.ts index 6cb5c5cad3d..996dbcb20d4 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderDepositTracking.ts @@ -7,8 +7,7 @@ import { useCallback, useContext } from 'react'; import Engine from '../../../../core/Engine'; import { ToastContext } from '../../../../component-library/components/Toast'; import { strings } from '../../../../../locales/i18n'; -import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; -import { usePerpsLiveAccount } from './stream/usePerpsLiveAccount'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import usePerpsToasts from './usePerpsToasts'; /** @@ -24,7 +23,6 @@ import usePerpsToasts from './usePerpsToasts'; * This ensures the order is placed automatically after the deposit completes. */ export const usePerpsOrderDepositTracking = () => { - const { account } = usePerpsLiveAccount(); const { showToast, PerpsToastOptions } = usePerpsToasts(); const { toastRef } = useContext(ToastContext); @@ -50,16 +48,30 @@ export const usePerpsOrderDepositTracking = () => { // Callback to show toast when user confirms the deposit const handleDepositConfirm = useCallback( (transactionMeta: TransactionMeta, callback: () => void) => { - if ( - transactionMeta.type !== TransactionType.perpsDeposit && - transactionMeta.type !== TransactionType.perpsDepositAndOrder - ) { + if (transactionMeta.type !== TransactionType.perpsDepositAndOrder) { return; } - getStreamManagerInstance().setActiveDepositHandler(true); const transactionId = transactionMeta.id; + let cancelTradeRequested = false; showProgressToast(transactionId); + const takingLongerToastOptions = + PerpsToastOptions.accountManagement.deposit.takingLonger; + const cancelTradeOnPress = () => { + cancelTradeRequested = true; + // Replace current toast with "Trade canceled" (don't close first to avoid race) + showToast(PerpsToastOptions.accountManagement.deposit.tradeCanceled); + }; + const depositLongerTimeoutId = setTimeout(() => { + const baseClose = takingLongerToastOptions.closeButtonOptions; + showToast({ + ...takingLongerToastOptions, + closeButtonOptions: baseClose + ? { ...baseClose, onPress: cancelTradeOnPress } + : undefined, + } as Parameters[0]); + }, PERPS_CONSTANTS.DepositTakingLongerToastDelayMs); + // Handle failed transactions const handleTransactionFailed = ({ transactionMeta: failedTransactionMeta, @@ -67,13 +79,10 @@ export const usePerpsOrderDepositTracking = () => { transactionMeta: TransactionMeta; }) => { if ( - failedTransactionMeta?.type === TransactionType.perpsDeposit || failedTransactionMeta?.type === TransactionType.perpsDepositAndOrder ) { if (failedTransactionMeta.id === transactionId) { - // Unmark active handler so usePerpsDepositStatus can handle it if needed - getStreamManagerInstance().setActiveDepositHandler(false); - // Close the depositing toast + clearTimeout(depositLongerTimeoutId); toastRef?.current?.closeToast(); showToast(PerpsToastOptions.accountManagement.deposit.error); } @@ -89,19 +98,11 @@ export const usePerpsOrderDepositTracking = () => { updatedTransactionMeta.id === transactionId && updatedTransactionMeta.status === TransactionStatus.confirmed ) { - // Unmark active handler so usePerpsDepositStatus can handle it if needed - + clearTimeout(depositLongerTimeoutId); toastRef?.current?.closeToast(); - showToast( - PerpsToastOptions.accountManagement.deposit.success( - account?.availableBalance?.toString() || '0', - ), - ); - setTimeout( - () => getStreamManagerInstance().setActiveDepositHandler(false), - 1000, - ); - callback?.(); + if (!cancelTradeRequested) { + callback?.(); + } } }; @@ -117,7 +118,6 @@ export const usePerpsOrderDepositTracking = () => { [ showToast, toastRef, - account?.availableBalance, showProgressToast, PerpsToastOptions.accountManagement.deposit, ], diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts index dd8ac5bbd2f..6c9ab6d4371 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts @@ -469,14 +469,14 @@ describe('usePerpsOrderForm', () => { describe('useMemo and useEffect behavior', () => { it('should not overwrite user input when dependencies change', async () => { - // Arrange - Start with sufficient balance + // Arrange - Start with balance high enough that max >= 999 (e.g. 334 * 3x = 1002) const mockAccount = { account: { - availableBalance: '10', // $10 balance = $30 max with 3x leverage + availableBalance: '334', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', - totalBalance: '10', + totalBalance: '334', }, isInitialLoading: false, }; @@ -491,19 +491,22 @@ describe('usePerpsOrderForm', () => { TRADING_DEFAULTS.amount.mainnet.toString(), ); - // Act - User changes the amount + // Act - User changes the amount (within current max) act(() => { result.current.setAmount('999'); }); expect(result.current.orderForm.amount).toBe('999'); - // Act - Change the available balance to trigger useMemo recalculation - mockAccount.account.availableBalance = '1'; // This would normally trigger a different initialAmountValue + // Act - Change the available balance so the new max is below user's amount + mockAccount.account.availableBalance = '1'; // $1 balance → max order size drops below 999 mockUsePerpsLiveAccount.mockReturnValue(mockAccount); rerender({}); - // Assert - Amount should not be overwritten due to hasSetInitialAmount ref - expect(result.current.orderForm.amount).toBe('999'); + // Assert - Amount should be clamped to the new max when effective balance drops (payment token change or balance update) + expect(Number(result.current.orderForm.amount)).toBeLessThanOrEqual( + result.current.maxPossibleAmount, + ); + expect(result.current.orderForm.amount).not.toBe('999'); }); it('should use useMemo for initialAmountValue calculation', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts index c8d9fa5c2c8..25c23695220 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts @@ -23,6 +23,8 @@ interface UsePerpsOrderFormParams { initialAmount?: string; initialLeverage?: number; initialType?: OrderType; + /** When paying with a custom token, the selected token amount in USD; used to cap maxPossibleAmount and handlers */ + effectiveAvailableBalance?: number; } export interface UsePerpsOrderFormReturn { @@ -40,6 +42,8 @@ export interface UsePerpsOrderFormReturn { handleMaxAmount: () => void; handleMinAmount: () => void; maxPossibleAmount: number; + /** Balance to use for validation and UI (Perps balance or selected token amount in USD when paying with custom token) */ + balanceForValidation: number; } /** @@ -55,6 +59,7 @@ export function usePerpsOrderForm( initialAmount, initialLeverage, initialType = 'market', + effectiveAvailableBalance: effectiveAvailableBalanceParam, } = params; const currentNetwork = usePerpsNetwork(); @@ -84,11 +89,15 @@ export function usePerpsOrderForm( selectPendingTradeConfiguration(state, initialAsset), ); - // Get available balance from live account data const availableBalance = Number.parseFloat( - account?.availableBalance?.toString() || '0', + effectiveAvailableBalanceParam != null + ? effectiveAvailableBalanceParam.toString() + : (account?.availableBalance?.toString() ?? '0'), ); + // When paying with a custom token, use selected token amount in USD (including 0); otherwise use Perps balance + const balanceForMax = effectiveAvailableBalanceParam ?? availableBalance; + // Determine default amount based on network const defaultAmount = currentNetwork === 'mainnet' @@ -121,7 +130,7 @@ export function usePerpsOrderForm( } const tempMaxAmount = getMaxAllowedAmount({ - availableBalance, + availableBalance: balanceForMax, assetPrice: Number.parseFloat(currentPrice.price), assetSzDecimals: marketData?.szDecimals ?? 6, leverage: defaultLeverage, // Use default leverage for initial calculation @@ -138,7 +147,7 @@ export function usePerpsOrderForm( }, [ initialAmount, pendingConfig?.amount, - availableBalance, + balanceForMax, defaultAmount, currentPrice?.price, marketData?.szDecimals, @@ -169,17 +178,17 @@ export function usePerpsOrderForm( type: defaultOrderType, }); - // Calculate the maximum possible amount based on available balance and current leverage + // Calculate the maximum possible amount; when paying with custom token, capped by selected token amount in USD const maxPossibleAmount = useMemo( () => getMaxAllowedAmount({ - availableBalance, + availableBalance: balanceForMax, assetPrice: Number.parseFloat(currentPrice?.price) || 0, assetSzDecimals: marketData?.szDecimals ?? 6, leverage: orderForm.leverage, // Use current leverage instead of default }), [ - availableBalance, + balanceForMax, currentPrice?.price, marketData?.szDecimals, orderForm.leverage, // Include current leverage in dependencies @@ -239,6 +248,17 @@ export function usePerpsOrderForm( } }, [existingPositionLeverage, initialLeverage, orderForm.leverage]); + // When user changes payment token (or effective balance drops), reset amount to MAX if current amount exceeds new max + useEffect(() => { + const current = Number.parseFloat(orderForm.amount || '0'); + if (maxPossibleAmount >= 0 && current > maxPossibleAmount) { + setOrderForm((prev) => ({ + ...prev, + amount: String(Math.floor(maxPossibleAmount)), + })); + } + }, [balanceForMax, maxPossibleAmount, orderForm.amount]); + // Update entire form const updateOrderForm = (updates: Partial) => { setOrderForm((prev) => ({ ...prev, ...updates })); @@ -293,26 +313,26 @@ export function usePerpsOrderForm( setOrderForm((prev) => ({ ...prev, type })); }; - // Handle percentage-based amount selection + // Handle percentage-based amount selection (respects custom token amount when set) const handlePercentageAmount = useCallback( (percentage: number) => { - if (availableBalance === 0) return; + if (balanceForMax === 0) return; const newAmount = Math.floor( - availableBalance * orderForm.leverage * percentage, + balanceForMax * orderForm.leverage * percentage, ).toString(); setOrderForm((prev) => ({ ...prev, amount: newAmount })); }, - [availableBalance, orderForm.leverage], + [balanceForMax, orderForm.leverage], ); - // Handle max amount selection + // Handle max amount selection (respects custom token amount when set) const handleMaxAmount = useCallback(() => { - if (availableBalance === 0) return; + if (balanceForMax === 0) return; setOrderForm((prev) => ({ ...prev, - amount: Math.floor(availableBalance * prev.leverage).toString(), + amount: Math.floor(balanceForMax * prev.leverage).toString(), })); - }, [availableBalance]); + }, [balanceForMax]); // Handle min amount selection const handleMinAmount = useCallback(() => { @@ -341,5 +361,6 @@ export function usePerpsOrderForm( handleMaxAmount, handleMinAmount, maxPossibleAmount, + balanceForValidation: balanceForMax, }; } diff --git a/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts new file mode 100644 index 00000000000..c17ce4582b7 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts @@ -0,0 +1,27 @@ +import { Hex } from '@metamask/utils'; +import { useCallback } from 'react'; +import { AssetType } from '../../../Views/confirmations/types/token'; +import { useTransactionPayToken } from '../../../Views/confirmations/hooks/pay/useTransactionPayToken'; +import Engine from '../../../../core/Engine'; + +export interface UsePerpsPaymentTokenResult { + onPaymentTokenChange: (token: AssetType | null) => void; +} + +export function usePerpsPaymentToken(): UsePerpsPaymentTokenResult { + const { setPayToken } = useTransactionPayToken(); + + const onPaymentTokenChange = useCallback( + (token: AssetType | null) => { + Engine.context.PerpsController?.setSelectedPaymentToken?.(token); + if (token) { + setPayToken({ + address: token.address as Hex, + chainId: token.chainId as Hex, + }); + } + }, + [setPayToken], + ); + return { onPaymentTokenChange }; +} diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.tsx b/app/components/UI/Perps/hooks/usePerpsToasts.tsx index 3025e73fdb6..6033469478d 100644 --- a/app/components/UI/Perps/hooks/usePerpsToasts.tsx +++ b/app/components/UI/Perps/hooks/usePerpsToasts.tsx @@ -45,6 +45,8 @@ export interface PerpsToastOptionsConfig { processingTimeInSeconds: number | undefined, transactionId: string, ) => PerpsToastOptions; + takingLonger: PerpsToastOptions; + tradeCanceled: PerpsToastOptions; error: PerpsToastOptions; }; oneClickTrade: { @@ -271,6 +273,14 @@ const usePerpsToasts = (): { backgroundColor: theme.colors.accent01.light, hapticsType: NotificationFeedbackType.Error, }, + warning: { + ...(PERPS_TOASTS_DEFAULT_OPTIONS as PerpsToastOptions), + variant: ToastVariants.Icon, + iconName: IconName.Warning, + iconColor: theme.colors.warning.default, + backgroundColor: theme.colors.warning.muted, + hapticsType: NotificationFeedbackType.Warning, + }, }), [theme], ); @@ -394,6 +404,42 @@ const usePerpsToasts = (): { closeButtonOptions, }; }, + takingLonger: { + ...perpsBaseToastOptions.warning, + labelOptions: getPerpsToastLabels( + strings('perps.deposit.deposit_taking_longer'), + ), + hasNoTimeout: true, + closeButtonOptions: { + label: ( + + {strings('perps.deposit.cancel_trade')} + + ), + variant: ButtonVariants.Secondary, + style: { backgroundColor: theme.colors.background.muted }, + onPress: () => { + /* no-op */ + }, + }, + }, + tradeCanceled: { + ...(PERPS_TOASTS_DEFAULT_OPTIONS as PerpsToastOptions), + variant: ToastVariants.Icon, + iconName: IconName.Warning, + iconColor: theme.colors.error.default, + backgroundColor: theme.colors.error.muted, + hapticsType: NotificationFeedbackType.Warning, + labelOptions: getPerpsToastLabels( + strings('perps.deposit.trade_canceled'), + ), + descriptionOptions: { + description: strings('perps.deposit.funds_returned_to_account'), + }, + }, error: { ...perpsBaseToastOptions.error, labelOptions: getPerpsToastLabels( @@ -929,8 +975,11 @@ const usePerpsToasts = (): { perpsBaseToastOptions.inProgress, perpsBaseToastOptions.info, perpsBaseToastOptions.success, + perpsBaseToastOptions.warning, perpsToastButtonOptions, + theme.colors.background.muted, theme.colors.error.default, + theme.colors.error.muted, theme.colors.success.default, ], ); diff --git a/app/components/UI/Perps/index.ts b/app/components/UI/Perps/index.ts index 11428d89745..ec7b367fb29 100644 --- a/app/components/UI/Perps/index.ts +++ b/app/components/UI/Perps/index.ts @@ -8,4 +8,6 @@ export { } from './selectors/featureFlags'; export { PERPS_CONSTANTS } from './constants/perpsConfig'; +export { usePerpsPaymentToken } from './hooks/usePerpsPaymentToken'; + export * from './types/perps-types'; diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 5d6bdd70783..e69e9522290 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -9,7 +9,12 @@ import { import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Logger from '../../../../util/Logger'; -import type { PriceUpdate, PerpsMarketData, Order } from '../controllers/types'; +import type { + PriceUpdate, + PerpsMarketData, + Order, + AccountState, +} from '../controllers/types'; import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; jest.mock('../../../../core/Engine'); @@ -764,6 +769,37 @@ describe('PerpsStreamManager', () => { cleanupPrewarmSpy.mockRestore(); }); + it('notifies subscriber with null when account subscription callback receives null', async () => { + let accountCallback: ((account: AccountState | null) => void) | null = + null; + mockSubscribeToAccount.mockImplementation( + (params: { callback: (account: AccountState | null) => void }) => { + accountCallback = params.callback; + return jest.fn(); + }, + ); + + const subscriberCallback = jest.fn(); + const unsubscribe = testStreamManager.account.subscribe({ + callback: subscriberCallback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockSubscribeToAccount).toHaveBeenCalled(); + }); + + act(() => { + accountCallback?.(null); + }); + + expect(subscriberCallback).toHaveBeenCalledTimes(1); + expect(subscriberCallback).toHaveBeenCalledWith(null); + expect(mockLogger.error).not.toHaveBeenCalled(); + + unsubscribe(); + }); + it('should reset all prewarm state when clearing price cache', async () => { // Mock market data to populate allMarketSymbols const mockGetMarketDataWithPrices = jest.fn(); @@ -2921,20 +2957,4 @@ describe('PerpsStreamManager', () => { pricesDisconnect.mockRestore(); }); }); - - describe('Deposit Handler Management', () => { - it('sets active deposit handler state', () => { - expect(testStreamManager.hasActiveDepositHandler()).toBe(false); - - testStreamManager.setActiveDepositHandler(true); - expect(testStreamManager.hasActiveDepositHandler()).toBe(true); - - testStreamManager.setActiveDepositHandler(false); - expect(testStreamManager.hasActiveDepositHandler()).toBe(false); - }); - - it('returns false by default when no active deposit handler is set', () => { - expect(testStreamManager.hasActiveDepositHandler()).toBe(false); - }); - }); }); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index eaec6098be5..c877bf14240 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -915,7 +915,7 @@ class AccountStreamChannel extends StreamChannel { this.wsConnectionStartTime = performance.now(); this.wsSubscription = Engine.context.PerpsController.subscribeToAccount({ - callback: (account: AccountState) => { + callback: (account: AccountState | null) => { // Validate account context const currentAccount = getEvmAccountFromSelectedAccountGroup()?.address || null; @@ -1387,27 +1387,6 @@ export class PerpsStreamManager { // public readonly funding = new FundingStreamChannel(); // public readonly trades = new TradeStreamChannel(); - // UI coordination: Track if a component is actively handling deposit toasts - // This prevents duplicate toasts between usePerpsDepositStatus and usePerpsOrderDepositTracking - private activeDepositHandler = false; - - /** - * Set whether a component is actively handling deposit toasts - * Used by PerpsOrderView to prevent duplicate toasts from usePerpsDepositStatus - * @param isActive - Whether a component is actively handling deposit toasts - */ - public setActiveDepositHandler(isActive: boolean): void { - this.activeDepositHandler = isActive; - } - - /** - * Check if a component is actively handling deposit toasts - * @returns true if a component is actively handling deposit toasts - */ - public hasActiveDepositHandler(): boolean { - return this.activeDepositHandler; - } - /** * Force reconnection of all stream channels after WebSocket reconnection * Disconnects all channels and reconnects those with active subscribers diff --git a/app/components/UI/Perps/selectors/perpsController/index.ts b/app/components/UI/Perps/selectors/perpsController/index.ts index 27465462e87..99bfb474a42 100644 --- a/app/components/UI/Perps/selectors/perpsController/index.ts +++ b/app/components/UI/Perps/selectors/perpsController/index.ts @@ -71,6 +71,14 @@ const selectPerpsMarketFilterPreferences = createSelector( (perpsControllerState) => selectMarketFilterPreferences(perpsControllerState), ); +/** + * True when the user selected the synthetic "Perps balance" option (selectedPaymentToken === null). + */ +const selectIsPerpsBalanceSelected = createSelector( + selectPerpsControllerState, + (perpsControllerState) => perpsControllerState?.selectedPaymentToken == null, +); + /** * Selects the current initialization state of the Perps controller. * Used by UI components to determine if operations can be performed. @@ -105,4 +113,5 @@ export { selectPerpsWatchlistMarkets, selectPerpsMarketFilterPreferences, selectPerpsInitializationState, + selectIsPerpsBalanceSelected, }; diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts index e4e1690dcaa..c70b4f4de13 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts @@ -2,6 +2,15 @@ * Unit tests for HyperLiquid SDK adapter utilities */ +// Avoid loading @metamask/swaps-controller (and thus controller-utils logger) in tests +jest.mock('../constants/perpsConfig', () => ({ + DECIMAL_PRECISION_CONFIG: { + MaxPriceDecimals: 6, + MaxSignificantFigures: 5, + FallbackSizeDecimals: 6, + }, +})); + import { adaptOrderToSDK, adaptOrderFromSDK, diff --git a/app/components/Views/confirmations/components/info/external/perps/index.ts b/app/components/Views/confirmations/components/info/external/perps/index.ts deleted file mode 100644 index 19e58867c43..00000000000 --- a/app/components/Views/confirmations/components/info/external/perps/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PerpsDepositFees } from './perps-deposit-fees'; diff --git a/app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.test.tsx b/app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.test.tsx deleted file mode 100644 index eafb441eb93..00000000000 --- a/app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.test.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import React from 'react'; -import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; -import { merge } from 'lodash'; -import { simpleSendTransactionControllerMock } from '../../../../__mocks__/controllers/transaction-controller-mock'; -import { transactionApprovalControllerMock } from '../../../../__mocks__/controllers/approval-controller-mock'; -import { otherControllersMock } from '../../../../__mocks__/controllers/other-controllers-mock'; -import { PerpsDepositFees } from './perps-deposit-fees'; -import { - useIsTransactionPayLoading, - useTransactionPayQuotes, - useTransactionPayRequiredTokens, - useTransactionPaySourceAmounts, - useTransactionPayTotals, -} from '../../../../hooks/pay/useTransactionPayData'; -import { - TransactionPayQuote, - TransactionPayRequiredToken, - TransactionPaySourceAmount, - TransactionPayTotals, -} from '@metamask/transaction-pay-controller'; -import { Json } from '@metamask/utils'; - -jest.mock('../../../../hooks/pay/useTransactionPayData'); -jest.mock('../../../../hooks/metrics/useConfirmationAlertMetrics', () => ({ - useConfirmationAlertMetrics: () => ({ - trackInlineAlertClicked: jest.fn(), - trackAlertActionClicked: jest.fn(), - trackAlertRendered: jest.fn(), - }), -})); - -function render() { - const state = merge( - {}, - simpleSendTransactionControllerMock, - transactionApprovalControllerMock, - otherControllersMock, - ); - - return renderWithProvider(, { state }); -} - -describe('PerpsDepositFees', () => { - const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes); - const useIsTransactionPayLoadingMock = jest.mocked( - useIsTransactionPayLoading, - ); - const useTransactionPayRequiredTokensMock = jest.mocked( - useTransactionPayRequiredTokens, - ); - const useTransactionPaySourceAmountsMock = jest.mocked( - useTransactionPaySourceAmounts, - ); - const useTransactionPayTotalsMock = jest.mocked(useTransactionPayTotals); - - beforeEach(() => { - jest.resetAllMocks(); - - useTransactionPayQuotesMock.mockReturnValue([ - {} as TransactionPayQuote, - ]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPayRequiredTokensMock.mockReturnValue([]); - useTransactionPaySourceAmountsMock.mockReturnValue([]); - useTransactionPayTotalsMock.mockReturnValue({ - fees: { - provider: { usd: '1.00' }, - sourceNetwork: { estimate: { usd: '0.20' } }, - targetNetwork: { usd: '0.03' }, - }, - total: { usd: '123.456' }, - } as TransactionPayTotals); - }); - - it('renders fee rows when result is ready', () => { - const { getByTestId } = render(); - - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - expect(getByTestId('total-row')).toBeOnTheScreen(); - }); - - it('renders fee rows when quotes are loading', () => { - useIsTransactionPayLoadingMock.mockReturnValue(true); - - const { getByTestId } = render(); - - // When loading, isResultReady is true, so fee rows container should be shown - // But BridgeFeeRow itself shows skeleton when loading, so we check for skeleton - expect(getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); - }); - - it('renders fee rows when no quotes and no source amounts', () => { - useTransactionPayQuotesMock.mockReturnValue([]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPaySourceAmountsMock.mockReturnValue([]); - - const { getByTestId } = render(); - - // When no source amounts, isResultReady is true (!hasSourceAmount), so fee rows should be shown - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - }); - - it('renders fee rows when quotes exist', () => { - useTransactionPayQuotesMock.mockReturnValue([ - {} as TransactionPayQuote, - ]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPaySourceAmountsMock.mockReturnValue([ - { - targetTokenAddress: '0x123', - amount: '100', - } as unknown as TransactionPaySourceAmount, - ]); - - const { getByTestId } = render(); - - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - }); - - it('renders fee rows when required tokens exist but no matching source amounts', () => { - useTransactionPayQuotesMock.mockReturnValue([]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPayRequiredTokensMock.mockReturnValue([ - { - address: '0x123', - skipIfBalance: false, - } as unknown as TransactionPayRequiredToken, - ]); - useTransactionPaySourceAmountsMock.mockReturnValue([ - { - targetTokenAddress: '0x456', // Different address - no match - amount: '100', - } as unknown as TransactionPaySourceAmount, - ]); - - const { getByTestId } = render(); - - // When hasSourceAmount is false (no match), isResultReady is true, so fee rows should be shown - // But actually, hasSourceAmount checks if sourceAmounts match requiredTokens - // Since addresses don't match, hasSourceAmount is false, so !hasSourceAmount is true - // So isResultReady is true, and fee rows should be shown - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - }); - - it('renders skeletons when required tokens match source amounts', () => { - useTransactionPayQuotesMock.mockReturnValue([]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPayRequiredTokensMock.mockReturnValue([ - { - address: '0x123', - skipIfBalance: false, - } as unknown as TransactionPayRequiredToken, - ]); - useTransactionPaySourceAmountsMock.mockReturnValue([ - { - targetTokenAddress: '0x123', // Matching address - amount: '100', - } as unknown as TransactionPaySourceAmount, - ]); - - const { queryByTestId } = render(); - - // When hasSourceAmount is true (match found), isResultReady is false - // (because !hasSourceAmount is false), so skeletons should be shown - expect(queryByTestId('bridge-fee-row')).toBeNull(); - }); - - it('renders fee rows when required token has skipIfBalance true', () => { - useTransactionPayQuotesMock.mockReturnValue([]); - useIsTransactionPayLoadingMock.mockReturnValue(false); - useTransactionPayRequiredTokensMock.mockReturnValue([ - { - address: '0x123', - skipIfBalance: true, // Should be skipped in hasSourceAmount check - } as unknown as TransactionPayRequiredToken, - ]); - useTransactionPaySourceAmountsMock.mockReturnValue([ - { - targetTokenAddress: '0x123', - amount: '100', - } as unknown as TransactionPaySourceAmount, - ]); - - const { getByTestId } = render(); - - // When skipIfBalance is true, that token is not considered in hasSourceAmount - // So hasSourceAmount is false, !hasSourceAmount is true, isResultReady is true - // So fee rows should be shown - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); - }); -}); diff --git a/app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.tsx b/app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.tsx deleted file mode 100644 index a858cbe390d..00000000000 --- a/app/components/Views/confirmations/components/info/external/perps/perps-deposit-fees.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { Box } from '../../../../../../UI/Box/Box'; -import { BridgeFeeRow } from '../../../rows/bridge-fee-row'; -import { BridgeTimeRow } from '../../../rows/bridge-time-row'; -import { PercentageRow } from '../../../rows/percentage-row'; -import { TotalRow } from '../../../rows/total-row'; -import { InfoRowSkeleton } from '../../../UI/info-row/info-row'; -import { - useIsTransactionPayLoading, - useTransactionPayQuotes, - useTransactionPayRequiredTokens, - useTransactionPaySourceAmounts, -} from '../../../../hooks/pay/useTransactionPayData'; - -export const PerpsDepositFees = () => { - const isResultReady = useIsResultReady(); - - if (!isResultReady) { - return ( - - - - - - - ); - } - - return ( - - - - - - - ); -}; - -function useIsResultReady() { - const quotes = useTransactionPayQuotes(); - const isQuotesLoading = useIsTransactionPayLoading(); - const requiredTokens = useTransactionPayRequiredTokens(); - const sourceAmounts = useTransactionPaySourceAmounts(); - - const hasSourceAmount = sourceAmounts?.some((a) => - requiredTokens.some( - (rt) => - rt.address.toLowerCase() === a.targetTokenAddress.toLowerCase() && - !rt.skipIfBalance, - ), - ); - - return isQuotesLoading || Boolean(quotes?.length) || !hasSourceAmount; -} diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx index 4b53e5d3c1b..7c6dcb595db 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx @@ -25,11 +25,15 @@ import { Hex } from '@metamask/utils'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { EMPTY_ADDRESS } from '../../../../../../constants/transaction'; import { getAvailableTokens } from '../../../utils/transaction-pay'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePerpsBalanceTokenFilter } from '../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'; jest.mock('../../../hooks/pay/useTransactionPayToken'); jest.mock('../../../hooks/pay/useTransactionPayData'); jest.mock('../../../hooks/transactions/useTransactionMetadataRequest'); jest.mock('../../../utils/transaction-pay'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'); jest.mock('../../../hooks/send/useAccountTokens', () => ({ useAccountTokens: () => [], @@ -158,6 +162,7 @@ function render({ minimumFiatBalance }: { minimumFiatBalance?: number } = {}) { describe('PayWithModal', () => { const setPayTokenMock = jest.fn(); + const onPerpsPaymentTokenChangeMock = jest.fn(); const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); const getAvailableTokensMock = jest.mocked(getAvailableTokens); const useTransactionPayRequiredTokensMock = jest.mocked( @@ -166,6 +171,10 @@ describe('PayWithModal', () => { const useTransactionMetadataRequestMock = jest.mocked( useTransactionMetadataRequest, ); + const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); + const usePerpsBalanceTokenFilterMock = jest.mocked( + usePerpsBalanceTokenFilter, + ); beforeEach(() => { jest.resetAllMocks(); @@ -189,6 +198,14 @@ describe('PayWithModal', () => { }, type: TransactionType.simpleSend, } as unknown as ReturnType); + + usePerpsPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPerpsPaymentTokenChangeMock, + } as unknown as ReturnType); + + usePerpsBalanceTokenFilterMock.mockReturnValue( + jest.fn((tokens: AssetType[]) => tokens), + ); }); it('renders tokens', async () => { @@ -216,5 +233,49 @@ describe('PayWithModal', () => { chainId: TOKENS_MOCK[1].chainId, }); }); + + it('calls onPerpsPaymentTokenChange via close callback when type is perpsDepositAndOrder', async () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: transactionIdMock, + chainId: CHAIN_ID_1_MOCK, + networkClientId: '', + status: TransactionStatus.unapproved, + time: 0, + txParams: { from: EMPTY_ADDRESS }, + type: TransactionType.perpsDepositAndOrder, + } as unknown as ReturnType); + + const { getByText } = render(); + + await waitFor(() => { + fireEvent.press(getByText('Test Token 1')); + }); + + expect(onPerpsPaymentTokenChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + address: TOKENS_MOCK[1].address, + chainId: TOKENS_MOCK[1].chainId, + }), + ); + }); + }); + + it('uses perpsBalanceTokenFilter when transaction type is perpsDepositAndOrder', () => { + const perpsFilterFn = jest.fn((tokens: AssetType[]) => tokens); + usePerpsBalanceTokenFilterMock.mockReturnValue(perpsFilterFn); + + useTransactionMetadataRequestMock.mockReturnValue({ + id: transactionIdMock, + chainId: CHAIN_ID_1_MOCK, + networkClientId: '', + status: TransactionStatus.unapproved, + time: 0, + txParams: { from: EMPTY_ADDRESS }, + type: TransactionType.perpsDepositAndOrder, + } as unknown as ReturnType); + + render(); + + expect(perpsFilterFn).toHaveBeenCalledWith(TOKENS_MOCK); }); }); diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx index 5e74760c0d3..f50bc03948f 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx @@ -16,6 +16,8 @@ import { hasTransactionType } from '../../../utils/transaction'; import { useMusdConversionTokens } from '../../../../../UI/Earn/hooks/useMusdConversionTokens'; import { HIDE_NETWORK_FILTER_TYPES } from '../../../constants/confirmations'; import { useMusdPaymentToken } from '../../../../../UI/Earn/hooks/useMusdPaymentToken'; +import { usePerpsBalanceTokenFilter } from '../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; export function PayWithModal() { const transactionMeta = useTransactionMetadataRequest(); @@ -29,6 +31,9 @@ export function PayWithModal() { const { filterAllowedTokens: musdTokenFilter } = useMusdConversionTokens(); const { onPaymentTokenChange: onMusdPaymentTokenChange } = useMusdPaymentToken(); + const { onPaymentTokenChange: onPerpsPaymentTokenChange } = + usePerpsPaymentToken(); + const perpsBalanceTokenFilter = usePerpsBalanceTokenFilter(); const close = useCallback((onClosed?: () => void) => { // Called after the bottom sheet's closing animation completes. @@ -44,6 +49,15 @@ export function PayWithModal() { return; } + if ( + hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]) + ) { + close(() => onPerpsPaymentTokenChange(token)); + return; + } + close(() => { setPayToken({ address: token.address as Hex, @@ -51,7 +65,13 @@ export function PayWithModal() { }); }); }, - [close, onMusdPaymentTokenChange, setPayToken, transactionMeta], + [ + close, + onMusdPaymentTokenChange, + onPerpsPaymentTokenChange, + setPayToken, + transactionMeta, + ], ); const tokenFilter = useCallback( @@ -68,9 +88,23 @@ export function PayWithModal() { return musdTokenFilter(availableTokens); } + if ( + hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]) + ) { + return perpsBalanceTokenFilter(availableTokens); + } + return availableTokens; }, - [musdTokenFilter, payToken, requiredTokens, transactionMeta], + [ + musdTokenFilter, + payToken, + requiredTokens, + transactionMeta, + perpsBalanceTokenFilter, + ], ); return ( diff --git a/app/core/Engine/controllers/perps-controller/index.test.ts b/app/core/Engine/controllers/perps-controller/index.test.ts index db102d63f79..f31b39e39f5 100644 --- a/app/core/Engine/controllers/perps-controller/index.test.ts +++ b/app/core/Engine/controllers/perps-controller/index.test.ts @@ -121,6 +121,7 @@ describe('perps controller init', () => { initializationState: InitializationState.Uninitialized, initializationError: null, initializationAttempts: 0, + selectedPaymentToken: null, }; initRequestMock.persistedState = { diff --git a/app/core/NotificationManager.js b/app/core/NotificationManager.js index 211a4522691..57bd339e504 100644 --- a/app/core/NotificationManager.js +++ b/app/core/NotificationManager.js @@ -27,6 +27,7 @@ export const SKIP_NOTIFICATION_TRANSACTION_TYPES = [ TransactionType.predictClaim, TransactionType.predictWithdraw, TransactionType.musdConversion, + TransactionType.perpsDepositAndOrder, ]; export const IN_PROGRESS_SKIP_STATUS = [ diff --git a/app/core/redux/slices/cronjobController/index.ts b/app/core/redux/slices/cronjobController/index.ts index 03c7c46fae4..c8f526b966b 100644 --- a/app/core/redux/slices/cronjobController/index.ts +++ b/app/core/redux/slices/cronjobController/index.ts @@ -21,7 +21,6 @@ const slice = createSlice({ state, action: PayloadAction, ) => { - // @ts-expect-error - Extensively deep merge. state.storage = action.payload; }, }, diff --git a/app/images/perps-pay-token-icon.png b/app/images/perps-pay-token-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..577843507553d5d74708f1298ce941d2353e5cfc GIT binary patch literal 9337 zcmZvibzD^6^YHI3yRbAXEG>-^(vlL3q)3+_DXnxUNG{!tgwh}>NJxi^v>+i!2;xdE zp&;Eni=XfBzvr*LuY2dr&Y4p)?>W)hnkuA3^h5vvkUmgV)CB+#?hypQ@Nqw;9%Xg_ zKmj~Zl-KtGZRHRT-!*vI?@d@JZ$&YV5!KLplP(rT%EA(%^|*Y-fl3b{*h}=iNAa0Z z!&4=Uf|DM@-}FrVE;aUiXGmo^mp=b5o+PygZ#A6NW6Kn|=^sY3L9aeuj2IvO@ILyH zCAl^%X}|i?GxunCBzw>Fa@3apo=Dqt&t-)&rnaAH&cZ7S3mJb}=p8G2r&ZDGEtYy* zT=9zUG#DGCB!{^8I3S*G+%MKAm@YXSPSB1abhTk2&N_QUn#(O$3)YsW;CgsOeUnG( z!3PtsEbNQ%E7c3;m`kO7&hhZVJtg}nP3jI)uVSp__?{v`%pv7(_Q(4&L+`8^tZ9cB zS3lvI?}Uj9Cxs6)7ly*j#t46m!Of1+NgTu3QA@;++)X#Qvku`MM*ES4u}U1@N5ei% zD`mutPKy0~eq#we_NzY)>0CWk>xm*8D}kg%+P9mEnG^MZD`3W=J`RV()rtFJjf707TD_+epKJjD4gRw zB`6$HvX7BiHWbH3#LvWhF?Gf4gYKA%IaCshju0Wq6LbohITjaZ*Cwy>6wNt(Golpi zpBK4U0@RO!<6&{=jeMP=uc?0xfg7MIWMBgJcj0@ro+Cw5>3cdCootSkaDYJ&T)7>b z10%_+_ie5sRYDUXxk8Bd5bw^SXZ!+`DnIg_MqwqA8(d(ho3w!@&4EggMt{BW_%~l( zvtwx90X`gHU+As1eVSOP#bE7`l=;xqA{$U*IrJ#ezut11gbm{e&1a8N^Q~UEsy%g$ zg0AhG{%+jvgeouM_gQ`s#d9(IL~!VQUuCQc_yEx)3~I(wzV7>Wc2v0*LHB%0m<2dI zM!8bTvVLUDm5;1YdJJ;F%0j1kl^#7ib}pPyn)-Yn$eWKyRD6HuVME;qFa{Yd;X_Bu*<89WK149G))~@(kGCd{ zwY{F1RUDO8AyAy}5O<`9KXq*7eZA#kYhYhx1=;ykx;*_pR8hOn+Mhmi9wWCppzx_?jEeVR$yl2!CE8+Cs!bCB>R6qsK!a({jM(5>s z(Mg9NHC+fmGKTj&dvi}X`uU4FUxj>9aLdbc^sk7J>LGpfZ=-t*vUr3u>VT#z5M! zUo<}@t$p-6IMBG!n?AnV~bZod4JU!UJBy9dT<=7=h=+ynP4YQ%i87^r^m5L&?l zfR7z!Rdzj7Bd*=o%Yszjtp-_rpnWUGt*Zgj-uRhiu8B3i-Dn{JLA6MPA{t z`~Y}o6oQFVnj#6Nhacz1{<_mD4u9+fqHwbxLLFV(Ey}(l+q>KHmR{QiL}4l2+tj8% zrY@k6&k0^@&UBIFyVzMILZPfiM?KHitHcQ-kU_9^m0Gj;WTXxh3Am5A`dwazeC0TZ z1ysO2J|5*7P%1qj?cDTtljmhgZoPX08(K8js`DmSA;5KVlKZpzaGL&WiMI*l_ysD{ z^`5}#@fH+0IC?YiP$tN0n!fuJ93vaNt7;H^Zc>4+FN53T(rUB&GRtb z+TY9O#3+(wXzn5X`$qG+zt-}o>DXlUj@daA+&JyguV@|GWf2NPHWV?b{EvLcJDb2P zjv^+b^rlm44hng~R!4qHW#8$_{rCNJKRH3BUbg<7r#BT`-Hn zv5e?hZ!Z5rt)#Uw%7TT>JH;yL1I}IK>4(gexDJv8AHw{HSG3QvNbNcJfUm!b*hZdC z{`-&)OS-qLJ@oWT6*BhZI2xdsj}iLlh^gnh3Ngk7(Ri zg3}v2Ck`}8sTP0C`#lm7r{N8(%jTyw9tPRkOy0v^1t|+?+Hr! z;C;OoB9MU)sWq!oaDI$uh12TBN8B{QSxOhIV5Zq<@s3;Fhu_#K$z#)VbJd3C!YhxWokR zdd;d00>^tc=zzu+u8?#At^a9EM6dJPi$D8b21E^Q$NuY!9prJ8Yg)YqG5XD8SYyGp zdCbAel5MpBR(sL5F8t@Qp8XdJK?sjL#xkSrUIp%h!y&_ePLzd+t^P@XA`xL30lB7m zKT2v@1*IWUMqYJ8zZ&$s?*Ok4e8(3Gbv|rXx4C%aI2`@?L5Y1U5U_5OTr#n4BlSZe zP-aP5$eRjjd-q11%)$aeXP)NKv7J=T$i-^^R}q@eWr05n2hBWus^&GInCFi6WR`ec z03127z9jGYr)5oZ1X1gpOs#RaNw+q*$2ybW`?qnP6qH!spT4m4yXhoQC&XgyTG4CG z(t5Dm4d&mO-Hya4vYWZ=$V3{_3OFsKi>;QwBpJeRH|NhRJKRBAolapjT#ovpTX@sz zdt6Zdx|i0kez{uPo=I`k^k4prM|DOfz}-{P!mhiF(m8+J#S$V;g%gmWW1x+PF3ui1 zgo9+YBnMCMJa))EN+)Dg2m%J2+1cH;zMC$T1MO1U4)BQw&FfxL_lifWDCF@ADAP>x zbeB_69Lzl)E*(4LUQ#(Zx0a_kQ_dS&+Uo#pQm_S+B!9}z9VZwEn8@Bv$C97`3n}=S zNgR9xm#AsSvoRL?RkZF!5ss0_n3!`jH3sf(cqkQlj7a*`g14-L2J$c*QSUzNc{sbiud*IqWy^r1HH5IJjtZ=nop-#Q`%oic2ooU z2lF4vc}`U=4@U5RJcx_Ib7Hrf!G(gY?rsHmT;NyVYlKUi5I_#E+xAsw_~J(mzL0X= zNT>R61cWyF({2)s`NZ_l&3*7eK=eQDtMZQqxk{F}#IO@0CEJFuoNvsOGegvNQzE$6B0m5(2GQRn=lf zKe{cg9$9)5FU4sG5?(7sC##@eJg$R(SHNyw<)87kjPQoeAZJ}&LHt!J;$C+&-pd|z z1-eDCiog^3BxMBvDe)N8Cxo2LwFMx$3QXJN_WW*leRcydT`55_Zf3ADed!d z$-mhzs|Bc03Y5goy}8S`(Iy(t1T&Fr`EG%szq%BXQa_QsH%egH6JGCNs`V4>);i`D z+qMln<}#?oXLm5RTOHjq%|ifmqeFP)gYPD(|3c8|2)Hb|{(b&q{R}|i&thxl5M;*d z`>0vyfYp0t<_Y}K*f0WK!M)AXjpPS$J?Z@;cf>}KfsxJGdBa4g%!}5t%ye*#sr-sO zXASf;Y~g+Ek5aU=j!H`IvV|#)C!Ny{Wq^98v(H;DFb2P2yeP7^KKlrYdE`eiW289D zqb|5YR-x2A&RG(W+tf4>l<-5(^i=h7#@G9o3LLFvwp#rLFL1Mw@BQ}r-kr4o(DAF> zFc&IH1}?@29!^yAO91$e9UMjTqC^;!$HI;X@+qDC7`xD;KFltb0HAAQ`J-2dJr-zC z7MdKzq9!2)2gRb4YQz;vqSgq|PRKOdHmgX>Yovqv(^!E|do1=M&XK4G4|z3xlwh9jsK)g!FlEoW@eCOQg5jMcYP$L?UfW z3p;2+>5?wPUUHilEnuf_!~YtU83Z#zX{`sZd{NNaKgfG?IQ+zA;;(bRduL)?*jAZ#%&mtfPgn~KknL5j_F62{~rs;)7WEo$y+%$ml~b5P$#Fbb)WZISHok^F&eC}mG1MjnNJ^CZzBJALU!6CC{xJVE>Aa$ zcKUU6Xb*TpKfS(q6$P1 zB^VSa9Kf=)lK5zX4L0xF+!=-Xr3AeUcsGh`^VXhHR>+E^y}|p&OsEwV^5!w$~4O>)^EL zV7bx<<9%?M)k`dOxeDqLY02Vrh4v!J-WhkSJYRZ7Q*@T z&vS{N%nG}H{tDItcMm(5Ko@#kz!_9t6>rq6l`>Pg$Sc6bUkiGuaz7d<;&rd!@P_xZSh< z41v`f7|QEWK|T$mT1M_MD* zIo8iqFTsbptb&|54D^0b%QH$Mw88_O@91`bLd?NvVRdpaQH#lMtF~Cq+K{QDXcEgx zgbo#bqbWT3F399ZZH7$H+)IuA)N{r|@tbn1?#G25XUxUGxkM?vph=GmO&I@cP3)t#$LxTh6JVLRsJQ7ctH<#o!b+SBn!IBY_>JP ztOGC&lru$U7wi%&#x)39G84AGKw|KH=3ZSQDekE!!q0B@@N2$Af!(eaUo#FB;KFYh zIz>)J(Ix%C_`LM9i>R@aP06P39&%93Syc%KEu;0eLLrAXAMeAFBRzbgc5BOzWE}g< zg%ayXfA@sdp*%gs{PY~`_o-$hj}2fcg6>A3Cyi4hrkXp4o)3jEbjmX6^DC8OW zvtfmYURnT`BH(UI>RL@a?XV8@btM)%%O`una3>)lvsLB_xzjG|EY}Bl(X9J%Z}rrI zK1{l&?{xy_Dno;V5&e+1Wz7Ai%troukOl5gzVu5h;d|SQhbO}c9O?Ws^s$d1vj2{W zpX`EhpF;BEpC>3DOuZZlla296&ZN-i4h1b>XdnD=cCy+`?0k#EEBN=C=$LER-fBse ziT<;#3NO?~&k8S`{UM>C7i+U_aQ{i5(>e8Xjkj`2z(Q=D2j3F2S3%-iyu;o_MD*yt zGjLi}y{Klk!ST_4h7Jamt%S0Sxv4*7nR?z9_G9-nGf$72M(}e$k8&KQG&8e{m(yiu z8(E)?JNJV}V&%6t&>XXARHzjmuxuG@hYFRvAHX5k%FcT*7ov(3R4}&)b4_}PJLw5W zXQc5?eyn%|3iAR9vpLlQh3sSbGoLXebaLzLXkGkQ{m{5mwLpPX*#`CV$;iYSs3eEn z<)Wwhrv$2d^~jrREuBWdrDW36{dMk8lKx^I2;>F`TFZEowtwvQ$Fmu~$RM=PD@&i! zUAR>0@s0{vo;Bqqx8RA8XYOV!6hqdSrd$r{Ie~z>fbbcuy3~D&A=Y-WFG{XA+$NeQ z=l+tn7N_A|&B%r@`|E9xigBM1?BuSUT7jBnP*3X0^~YtwIhH6G$&E}aAbB9SJ^5cm zxxH39f{Xi#>>qT6^JCLKn~&1aCH2#9bl>+b4*7lce(Lfb)Ghqu2eWk|l-yV9DK`;M@LqC!_mu z!L-ag_3Qr2!73AfWfI#Au7lIsJ+R=}-K_bo(?ux&IF^8@;AJ&cP)e1n2|jMyAjK+N3*8+>o_r7aNh!Qh2>hX-qAkEQUhetJ4hOD zn2L1so<@#f&67Xp7;sYa)*g^$EP0wVQ2|Yd^wTuBy%Tg?_=-e30iqVrZ-(-XFCpuS z(1^GZ+Ot^V9`5w>IvqjFAquyYMFA!PCq|=L;6`bRj2gR5t!-4`Cw{9MILwyhd-@zM zyCH*Dr&k*X2^sA&h%=(tRbTg|5;3V3T55IiywD|;dty>L7_PxKwFAGtNVkWcy12T-S+ADtEVh_U-A9F43AlXV%%JH;HR}d3h zViz1>{aCKP&%Vx@{$_;NZT@q%xiR2XjUEkmZnHAV*KsspL#y%qH-FowaL^a6Vu@r$ zsfp0!jJ=OdB&uJb$7Pz6kBx!(veCBAjRY3nw-gkr5UL)JZ)jzGCT4jKMyllJw+*q# z;e5T@_$wNZ-wfSDpm;ym7qux%P?o0nOEkZSD3XPlMy>oYB=Nf?8?r+_nbB|z`6Sl^ zliKHOyDjPH`O+CQ#@gMX`-f*dpFW|lYhe~bPc@-xgAPhIqS5P1dijev$|h`woNB`E zycWxxZcL-6%-UY<(bBG)(*Q-sU*4N0Z)6{10mrkTU}9Pj=m!{(M>CjcTfMf_BrsCH z;sV1k4_I&hEwKo%w^;M*lRqZd^*=v!d0dreFq3}M(?<_<{4VZSc)>=*Pk$tZQ!b1) zD*tcKn$oUymYjdu?qgUVldJDG!8U{M$C0DB&`jc$hWtnC%lwkkyr%r*vM6c20^f8H$PM@bcH5n*Oz9~!GogimG2rCp_UD=nvUHMS_YsPAJL>JM+9F_@=6T{e)@DFr zeXVS5I_y6-+5<~+TsE!A{sLlRFm%0lsEN< zLqi(eS&|0Tn2tE73!@SZfo!p$4c|>1QDh+n>v-OI1{%`=Xdt7S)=loB5amSlEPsC? z2!}=UUr{YrMw=Lm3FA~dz~(nRc`7mL=HF3fh5$yBoUm0#>H-u6Z0K^d=pBY@25Aqec5j)?>e8f4MF6+=e{V2T|BnG*qu&29 z;0n|863iU^hE+F7Fb@S*XRxV-QSFxSQxMXT4y{2PA5?~tRK`sc!IS2poMLSb0l+~( z;Szaq^Pg{P=z{N=jyN&VNfc&)F<$<$zW>LQMPW(o%b#C=aes7V2ir$uLF34RxBm{V+!-ZnR~(X-y>6~e0`B1$)QY1nn!Kfit4q-qv7zr9r!k7&2%g%O6LZIUU)^+{5Ch!%YLP9?iG^C zI>6@`BK4leuSj<9mcet}pe#_ZtS(^xS1dCF0EI=1ww9k2<$O4=X>&;xMq7_#ZQ3ve>BCY=ftSi9!d%kaKMi>HiyHIxfr;*t!v zV39J*la#(D*w_ekjB(H8I)9&`Q?=)}d?J89DfF1{6V0;|dR#e-1s~k=mDtRw7xRcD z5m!h|dR6p?`l2K~)*t-P*SL}(xIUV5{KrAryy=8IHQ?Hr-RB2Qn6h{Is*Tg_5C;f( zeuypTOMS*5fd!*PmeD4PDobZ^Ol2>7E1$83J0{4gvIJt}XqgQ0+0G~$vn3T_w|u519b>#f*y za{F}3TCmiKx!@|YYi+oS?2V#3fR8a*koLYNpXXz@7GK9DTus`u`AW05D|BJM_MnIl zSQz(^Erq`Sw)QB>h02&g419cy*gbPp3T=KP&|_9405C~IyI-*_h`QBH#mfqYb&b)> z%Fr>#Rx`eKH~U=+t_jNySrUeqLS4U>v?)$)Cx+=3{9ESJ zRk8Z$3lUO*nJcrCYmq60{qD;BE-_4yliC!YKa#Sm+cFh9sX;~CasUh`q-5)=V@y$ztAErh5vX)e_1A>$FQn^c>;tH{j$_MPnWDw;zqp z*?x;}4F(yxXMz)v zC>F7h(dF>T&Wg3SfBHj3F(am#FI~aKC9_2(Z+6K8k)vpO&Uf$sbGOEy^XTq+C_gsL z1shfEZy}Iu+f?g#s#p?T(A^#6W CSnGoT literal 0 HcmV?d00001 diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 28f2fa4fc8f..5a8bc558175 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -514,6 +514,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "optionId": "volume", }, "perpsBalances": {}, + "selectedPaymentToken": null, "tradeConfigurations": { "mainnet": {}, "testnet": {}, @@ -1332,6 +1333,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "optionId": "volume", }, "perpsBalances": {}, + "selectedPaymentToken": null, "tradeConfigurations": { "mainnet": {}, "testnet": {}, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index db459cd2eb3..00ff96ddaab 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -507,7 +507,8 @@ "direction": "desc" }, "hip3ConfigVersion": 0, - "perpsBalances": {} + "perpsBalances": {}, + "selectedPaymentToken": null }, "RemoteFeatureFlagController": { "cacheTimestamp": 0, diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index 9606a74681b..4b9b7d962ec 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -746,6 +746,13 @@ jest.mock('../../core/Analytics/MetaMetricsTestUtils', () => { }; }); +// Mock whenEngineReady to prevent async Engine access after Jest teardown. +// Components that trigger analytics (trackView/trackEvent) cause the queue to call +// whenEngineReady(), which uses setTimeout and can run after tests finish. +jest.mock('../../core/Analytics/whenEngineReady', () => ({ + whenEngineReady: jest.fn().mockResolvedValue(undefined), +})); + jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => { const originalModule = jest.requireActual( 'react-native/Libraries/TurboModule/TurboModuleRegistry', diff --git a/locales/languages/en.json b/locales/languages/en.json index 005a1fecfeb..f1ca2c2f057 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1091,7 +1091,11 @@ "error_toast": "Transaction failed", "error_generic": "Funds have been returned to you", "in_progress": "Adding funds to Perps", - "depositing_your_funds": "Depositing your funds", + "depositing_your_funds": "Setting up your trade", + "deposit_taking_longer": "Deposit taking longer than usual", + "cancel_trade": "Cancel trade", + "trade_canceled": "Trade canceled", + "funds_returned_to_account": "Funds returned to your account", "your_funds_have_arrived": "Your funds have arrived", "estimated_processing_time": "Est. {{time}}", "funds_available_momentarily": "Funds will be available momentarily", @@ -1178,6 +1182,7 @@ "amount_required": "Order amount must be greater than 0", "minimum_amount": "Minimum order size is ${{amount}}", "insufficient_funds": "Insufficient funds", + "insufficient_funds_to_cover_trade": "Insufficient funds to cover the trade", "insufficient_balance": "Insufficient balance. Required: ${{required}}, Available: ${{available}}", "invalid_leverage": "Leverage must be between {{min}}x and {{max}}x", "leverage_below_position": "Leverage must be at least {{required}}x to match your existing position (current: {{provided}}x)", @@ -1660,6 +1665,7 @@ "content": "Trading fees are charged when you open or close a position.", "metamask_fee": "MetaMask fee", "provider_fee": "Provider fee", + "bridge_fee": "Bridge fee", "total": "Total fees", "discount_message": "You're saving {{percentage}}% with MetaMask Rewards." }, @@ -1741,6 +1747,10 @@ "spread": { "title": "Spread", "content": "The spread is the difference between the best bid (highest buy price) and best ask (lowest sell price). A smaller spread indicates higher liquidity." + }, + "pay_with": { + "title": "Pay with", + "content": "Choose which token or balance to use to pay for this trade. You can pay with your Perps balance or select another token from your wallet." } }, "connection": { From 4fda86e17bb9e0014bf7934edc87acc367236a1c Mon Sep 17 00:00:00 2001 From: VGR Date: Thu, 5 Feb 2026 17:12:54 +0100 Subject: [PATCH 12/33] fix(rewards): end of season scrollable rewards (#25639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes the issue in the end of season summary where users couldn't scroll down properly if they had a small resolution and/or a more reward rows rendered. ## **Changelog** CHANGELOG entry: fix rewards end of season scroll issue ## **Screenshots/Recordings** ### **After** ![scroll-eos-rewards](https://github.com/user-attachments/assets/325a19e3-b3a4-41a3-b050-16b665027845) --- > [!NOTE] > **Low Risk** > Low risk UI/layout change confined to end-of-season rewards rendering; main risk is unintended spacing/scroll behavior differences on some devices. > > **Overview** > Fixes end-of-season (previous season) rewards overflow/scroll issues by **constraining the container height** and rendering rewards via a `FlatList` (with nested scrolling and extra bottom padding) instead of mapping inline. > > Updates tests to cover OTHERSIDE rewards without a claim URL (locked state + navigation payload) and to assert the correct “no rewards earned” message when `currentTier.pointsNeeded` is `0` and unlocked rewards are empty. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e8731b83625d7c00d3dfb0014413eea69cb78ec2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: sophieqgu --- .../PreviousSeasonUnlockedRewards.test.tsx | 74 +++++++++++++++++++ .../PreviousSeasonUnlockedRewards.tsx | 59 +++++++++------ 2 files changed, 110 insertions(+), 23 deletions(-) diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx index b290c9957e7..06c06753940 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.test.tsx @@ -461,6 +461,12 @@ describe('PreviousSeasonUnlockedRewards', () => { }, }; + const mockOthersideUnlockedRewardWithoutUrl: RewardDto = { + id: 'reward-otherside-without-url', + seasonRewardId: 'season-reward-otherside', + claimStatus: RewardClaimStatus.UNCLAIMED, + }; + const mockSeasonTiers = [ { id: 'tier-1', @@ -1033,4 +1039,72 @@ describe('PreviousSeasonUnlockedRewards', () => { // OTHERSIDE requires manual claim, so isLocked is false expect(rewardItem.props.accessibilityLabel).toContain('isLocked:false'); }); + + it('passes isLocked=true for OTHERSIDE reward without URL', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectUnlockedRewards) + return [mockOthersideUnlockedRewardWithoutUrl]; + if (selector === selectUnlockedRewardLoading) return false; + if (selector === selectUnlockedRewardError) return false; + if (selector === selectSeasonTiers) return mockSeasonTiers; + if (selector === selectCurrentTier) return { pointsNeeded: 100 }; + return undefined; + }); + + const { getByTestId } = render(); + + const rewardItem = getByTestId('reward-item-reward-otherside-without-url'); + // OTHERSIDE without URL is locked since there's no URL to claim and it's not a redeemable reward + expect(rewardItem.props.accessibilityLabel).toContain('isLocked:true'); + }); + + it('navigates to end of season claim sheet when OTHERSIDE reward without URL is pressed', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectUnlockedRewards) + return [mockOthersideUnlockedRewardWithoutUrl]; + if (selector === selectUnlockedRewardLoading) return false; + if (selector === selectUnlockedRewardError) return false; + if (selector === selectSeasonTiers) return mockSeasonTiers; + if (selector === selectCurrentTier) return { pointsNeeded: 100 }; + return undefined; + }); + + const { getByTestId } = render(); + + const rewardItem = getByTestId('reward-item-reward-otherside-without-url'); + rewardItem.props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith('EndOfSeasonClaimBottomSheet', { + rewardId: mockOthersideUnlockedRewardWithoutUrl.id, + seasonRewardId: 'season-reward-otherside', + title: 'Otherside Reward', + description: 'Otherside long unlocked', + url: undefined, + rewardType: SeasonRewardType.OTHERSIDE, + }); + }); + + it('shows no rewards message when currentTier has no pointsNeeded and empty unlocked rewards', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectUnlockedRewards) return []; + if (selector === selectUnlockedRewardLoading) return false; + if (selector === selectUnlockedRewardError) return false; + if (selector === selectSeasonTiers) return mockSeasonTiers; + if (selector === selectCurrentTier) return { pointsNeeded: 0 }; + return undefined; + }); + + const { getByTestId, getByText } = render( + , + ); + + expect( + getByTestId('rewards-season-ended-no-unlocked-rewards-image'), + ).toBeOnTheScreen(); + expect( + getByText( + "You didn't earn rewards this season, but there's always next time.", + ), + ).toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx index 8c1c35e834b..be27b673012 100644 --- a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonUnlockedRewards.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo } from 'react'; +import { FlatList } from 'react-native'; import { Box, BoxFlexDirection, @@ -144,7 +145,10 @@ const PreviousSeasonUnlockedRewards = () => { } return ( - + { flexDirection={BoxFlexDirection.Column} twClassName="gap-4 w-full" > - - {endOfSeasonRewards?.map((unlockedReward: RewardDto) => { + item.id} + showsVerticalScrollIndicator={false} + nestedScrollEnabled + style={tw.style('w-full')} + contentContainerStyle={tw.style('gap-4 pb-60')} + renderItem={({ item: unlockedReward, index }) => { const seasonReward = seasonTiers ?.flatMap((tier) => tier.rewards) ?.find( @@ -190,28 +200,31 @@ const PreviousSeasonUnlockedRewards = () => { | undefined )?.url; + const isLast = index === endOfSeasonRewards.length - 1; + return ( - + + + ); - })} - + }} + /> ) : ( <> From 8efb4f6ac2f607827810170593b62af3e8a53770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:16:12 +0100 Subject: [PATCH 13/33] feat: implement ClaimOnLineaBottomSheet for claiming bonuses (#25516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR improves the mUSD claiming experience by adding an educational bottom sheet modal and auto-scrolling to the claimed token. **What changed:** 1. **New ClaimOnLineaBottomSheet:** When users tap "Claim" on mUSD rewards, a bottom sheet now appears explaining that bonuses will be issued on Linea, separate from their Ethereum mUSD balance. This helps set proper expectations before the claim transaction. 2. **Auto-scroll to Linea mUSD:** After a successful claim, the app navigates to the home page and automatically scrolls the token list to highlight the Linea mUSD token where the bonus was received. 3. **Consistent reward formatting:** Fixed display of claimable rewards to always show 2 decimal places (e.g., "0.9" → "0.90", "1" → "1.00") for currency consistency. 4. **ConditionalScrollView ref forwarding:** Added forwardRef support to enable parent components to programmatically scroll. **Why:** Users were confused about where their mUSD bonuses would appear after claiming. This educational modal and auto-scroll feature provides clarity and improves the post-claim experience. **Designs:** https://www.figma.com/design/VoEucFy6VdE4dCcmzE6Kw8/mUSD?node-id=7046-23577&t=JZw5lFoq0ZcYbdRi-0 ## **Changelog** CHANGELOG entry: Added educational bottom sheet explaining that mUSD bonuses are claimed on Linea, and auto-scroll to the resulting token after successful claim ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-269 ## **Manual testing steps** ```gherkin Feature: mUSD Claim on Linea Educational Flow Scenario: User sees educational bottom sheet before claiming mUSD bonus Given user has mUSD with claimable Merkl rewards And user is viewing the mUSD asset overview When user taps the "Claim" button Then a bottom sheet appears with title "Claim bonuses on Linea" And shows mUSD icon and explanation that bonus will be issued on Linea And displays a "Terms apply" link and "Continue" button Scenario: User proceeds with claim after reading educational content Given user is viewing the ClaimOnLineaBottomSheet When user taps "Continue" Then the bottom sheet closes And the claim transaction is submitted And the Claim button shows loading state Scenario: User is navigated to Linea mUSD token after successful claim Given user has tapped "Continue" on the educational bottom sheet And the claim transaction was submitted successfully When the transaction is submitted Then user is navigated to the wallet home page And the token list scrolls to the Linea mUSD token Scenario: User can access terms of use Given user is viewing the ClaimOnLineaBottomSheet When user taps "Terms apply" link Then the mUSD conversion bonus terms URL opens in browser Scenario: User can dismiss the bottom sheet without claiming Given user is viewing the ClaimOnLineaBottomSheet When user taps the close button or swipes down Then the bottom sheet closes And no claim transaction is initiated ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/f432842d-2c1c-4519-a602-f4a0109102d9 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Introduces new modal navigation and cross-component scrolling via `DeviceEventEmitter`, which can be timing- and listener-lifecycle-sensitive. Also changes claim error handling and displayed messaging, which may affect user feedback paths. > > **Overview** > Adds a new `ClaimOnLineaBottomSheet` modal to educate users that mUSD bonuses are issued on Linea, with a Terms link and a Continue action that triggers the claim and immediately closes the sheet. > > Updates `ClaimMerklRewards` to open this modal on Claim, navigate back to Wallet on successful submission, and emit a `scrollToToken` event to focus the Linea mUSD token; token list and wallet home now listen for these events to scroll appropriately in both FlashList and homepage redesign (.map/ScrollView) modes. Also forwards refs through `ConditionalScrollView`, standardizes Merkl reward display to always show 2 decimals, and avoids surfacing errors for user-rejected transactions (EIP-1193 `code: 4001`) while showing a generic claim error message. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a6da0077df873bac87d3b86106fd5e5a6c96c0ef. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../ConditionalScrollView.tsx | 20 +- app/components/Nav/App/App.tsx | 5 + .../MerklRewards/ClaimMerklRewards.test.tsx | 144 +++++++----- .../MerklRewards/ClaimMerklRewards.tsx | 58 ++++- .../ClaimOnLineaBottomSheet.test.tsx | 214 +++++++++++++++++ .../ClaimOnLineaBottomSheet.tsx | 114 +++++++++ .../ClaimOnLineaBottomSheet/index.ts | 1 + .../MerklRewards/hooks/useMerklClaim.test.ts | 52 +++++ .../MerklRewards/hooks/useMerklClaim.ts | 13 +- .../hooks/useMerklRewards.test.ts | 93 ++++++++ .../MerklRewards/hooks/useMerklRewards.ts | 12 +- .../UI/Tokens/TokenList/TokenList.test.tsx | 218 +++++++++++++++++- .../UI/Tokens/TokenList/TokenList.tsx | 71 +++++- app/components/UI/Tokens/constants.ts | 5 + app/components/Views/Wallet/index.tsx | 25 ++ app/constants/navigation/Routes.ts | 1 + locales/languages/en.json | 6 +- 17 files changed, 954 insertions(+), 98 deletions(-) create mode 100644 app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/ClaimOnLineaBottomSheet.test.tsx create mode 100644 app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/ClaimOnLineaBottomSheet.tsx create mode 100644 app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/index.ts diff --git a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx index c6ab4878ca1..8ad68ae3820 100644 --- a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx +++ b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { ScrollView } from 'react-native'; import { ConditionalScrollViewProps } from './ConditionalScrollView.types'; @@ -6,15 +6,19 @@ import { ConditionalScrollViewProps } from './ConditionalScrollView.types'; * ConditionalScrollView renders either a ScrollView or content directly based on isScrollEnabled prop. * This is useful for homepage redesign where we want to remove nested scroll views in favor of a global scroll container. */ -const ConditionalScrollView: React.FC = ({ - children, - isScrollEnabled, - scrollViewProps, -}) => +const ConditionalScrollView = forwardRef< + ScrollView, + ConditionalScrollViewProps +>(({ children, isScrollEnabled, scrollViewProps }, ref) => isScrollEnabled ? ( - {children} + + {children} + ) : ( <>{children} - ); + ), +); + +ConditionalScrollView.displayName = 'ConditionalScrollView'; export default ConditionalScrollView; diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index e56cbdafcfc..0b053043028 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -68,6 +68,7 @@ import AccountActions from '../../../components/Views/AccountActions'; import FiatOnTestnetsFriction from '../../../components/Views/Settings/AdvancedSettings/FiatOnTestnetsFriction'; import WalletActions from '../../Views/WalletActions'; import FundActionMenu from '../../UI/FundActionMenu'; +import ClaimOnLineaBottomSheet from '../../UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet'; import MoreTokenActionsMenu from '../../UI/TokenDetails/components/MoreTokenActionsMenu'; import NetworkSelector from '../../../components/Views/NetworkSelector'; import ReturnToAppNotification from '../../Views/ReturnToAppNotification'; @@ -369,6 +370,10 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.MODAL.FUND_ACTION_MENU} component={FundActionMenu} /> + ({ })); jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), useSelector: jest.fn(() => ({ name: 'Ethereum Mainnet' })), })); -jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string) => { - const mockStrings: Record = { - 'asset_overview.merkl_rewards.claim': 'Claim', - }; - return mockStrings[key] || key; - }, -})); - jest.mock('./hooks/useMerklClaim', () => ({ useMerklClaim: jest.fn(), })); @@ -43,20 +35,22 @@ jest.mock('./hooks/usePendingMerklClaim', () => ({ usePendingMerklClaim: jest.fn(), })); -const mockNavigate = jest.fn(); -jest.mock('../../../../../core/NavigationService', () => ({ - __esModule: true, - default: { - navigation: { - navigate: (route: string) => mockNavigate(route), - }, - }, -})); - jest.mock('../../../../../constants/navigation/Routes', () => ({ WALLET: { HOME: 'WalletTabHome', + TOKENS_FULL_VIEW: 'TokensFullView', }, + MODAL: { + ROOT_MODAL_FLOW: 'RootModalFlow', + CLAIM_ON_LINEA: 'ClaimOnLineaModal', + }, +})); + +const mockNavigateToModal = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigateToModal, + }), })); jest.mock('@metamask/design-system-react-native', () => { @@ -168,17 +162,20 @@ describe('ClaimMerklRewards', () => { expect(getByText('Claim')).toBeTruthy(); }); - it('calls claimRewards when button is pressed', async () => { - mockClaimRewards.mockResolvedValue({ txHash: '0x123abc' }); - + it('navigates to bottom sheet modal when claim button is pressed', () => { const { getByText } = render(); - const claimButton = getByText('Claim'); - fireEvent.press(claimButton); - - await waitFor(() => { - expect(mockClaimRewards).toHaveBeenCalledTimes(1); - }); + fireEvent.press(getByText('Claim')); + + expect(mockNavigateToModal).toHaveBeenCalledWith( + 'RootModalFlow', + expect.objectContaining({ + screen: 'ClaimOnLineaModal', + params: expect.objectContaining({ + onContinue: expect.any(Function), + }), + }), + ); }); it('disables button when isClaiming is true', () => { @@ -194,17 +191,20 @@ describe('ClaimMerklRewards', () => { expect(buttonElement.props.disabled).toBe(true); }); - it('displays error message when error is present', () => { - const errorMessage = 'Failed to claim rewards'; + it('displays generic error message when error is present', () => { mockUseMerklClaim.mockReturnValue({ claimRewards: mockClaimRewards, isClaiming: false, - error: errorMessage, + error: 'Some internal error details', }); - const { getByText } = render(); + const { getByText, queryByText } = render( + , + ); - expect(getByText(errorMessage)).toBeTruthy(); + // Should display generic error message, not the actual error + expect(getByText('Unexpected error. Please try again.')).toBeTruthy(); + expect(queryByText('Some internal error details')).toBeNull(); }); it('does not display error message when error is null', () => { @@ -216,46 +216,52 @@ describe('ClaimMerklRewards', () => { const { queryByText } = render(); - expect(queryByText('Failed')).toBeNull(); + expect(queryByText('Unexpected error. Please try again.')).toBeNull(); }); - it('tracks analytics event when claim button is clicked', async () => { - mockClaimRewards.mockResolvedValue(undefined); - + it('tracks analytics event when claim button is clicked', () => { const { getByText } = render(); const claimButton = getByText('Claim'); fireEvent.press(claimButton); - await waitFor(() => { - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_CLAIM_BONUS_BUTTON_CLICKED, - ); - expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ - location: 'asset_overview', - action_type: 'claim_bonus', - button_text: 'Claim', - network_chain_id: mockAsset.chainId, - network_name: 'Ethereum Mainnet', - asset_symbol: mockAsset.symbol, - }); - expect(mockEventBuilder.build).toHaveBeenCalled(); - expect(mockTrackEvent).toHaveBeenCalledWith({ event: 'mock-event' }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_CLAIM_BONUS_BUTTON_CLICKED, + ); + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ + location: 'asset_overview', + action_type: 'claim_bonus', + button_text: 'Claim', + network_chain_id: mockAsset.chainId, + network_name: 'Ethereum Mainnet', + asset_symbol: mockAsset.symbol, }); + expect(mockEventBuilder.build).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith({ event: 'mock-event' }); }); - it('navigates to home page after successful claim submission', async () => { + it('navigates to home page after successful claim via onContinue callback', async () => { mockClaimRewards.mockResolvedValue({ txHash: '0x123abc' }); const { getByText } = render(); - const claimButton = getByText('Claim'); - fireEvent.press(claimButton); + // Press claim button + fireEvent.press(getByText('Claim')); + + // Get the onContinue callback that was passed to the modal + const navigateCall = mockNavigateToModal.mock.calls[0]; + const onContinue = navigateCall[1].params.onContinue; + + // Call the onContinue callback (simulating Continue button press in modal) + await onContinue(); await waitFor(() => { expect(mockClaimRewards).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith('WalletTabHome'); + expect(mockNavigateToModal).toHaveBeenCalledWith('WalletTabHome'); }); + + // Note: After navigation, a scroll event is also emitted via DeviceEventEmitter + // to scroll the token list to the Linea mUSD token (tested via integration tests) }); it('does not navigate to home page when claim submission fails', async () => { @@ -263,13 +269,21 @@ describe('ClaimMerklRewards', () => { mockClaimRewards.mockRejectedValue(error); const { getByText } = render(); - const claimButton = getByText('Claim'); - fireEvent.press(claimButton); + // Press claim button + fireEvent.press(getByText('Claim')); + + // Get the onContinue callback + const navigateCall = mockNavigateToModal.mock.calls[0]; + const onContinue = navigateCall[1].params.onContinue; + + // Call onContinue (simulating Continue press) + await onContinue(); await waitFor(() => { expect(mockClaimRewards).toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); + // mockNavigateToModal was called once for opening the modal, but not again for home navigation + expect(mockNavigateToModal).toHaveBeenCalledTimes(1); }); }); @@ -277,13 +291,21 @@ describe('ClaimMerklRewards', () => { mockClaimRewards.mockResolvedValue(undefined); const { getByText } = render(); - const claimButton = getByText('Claim'); - fireEvent.press(claimButton); + // Press claim button + fireEvent.press(getByText('Claim')); + + // Get the onContinue callback + const navigateCall = mockNavigateToModal.mock.calls[0]; + const onContinue = navigateCall[1].params.onContinue; + + // Call onContinue + await onContinue(); await waitFor(() => { expect(mockClaimRewards).toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); + // mockNavigateToModal was called once for opening the modal, but not again for home navigation + expect(mockNavigateToModal).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/UI/Earn/components/MerklRewards/ClaimMerklRewards.tsx b/app/components/UI/Earn/components/MerklRewards/ClaimMerklRewards.tsx index 877389c704f..0b3544a4c07 100644 --- a/app/components/UI/Earn/components/MerklRewards/ClaimMerklRewards.tsx +++ b/app/components/UI/Earn/components/MerklRewards/ClaimMerklRewards.tsx @@ -1,6 +1,7 @@ -import React from 'react'; -import { View } from 'react-native'; +import React, { useCallback } from 'react'; +import { DeviceEventEmitter, View } from 'react-native'; import { useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; import { Button, ButtonSize, @@ -8,6 +9,7 @@ import { Text, TextVariant, } from '@metamask/design-system-react-native'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import { strings } from '../../../../../../locales/i18n'; import { useMerklClaim } from './hooks/useMerklClaim'; @@ -19,8 +21,10 @@ import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { selectNetworkConfigurationByChainId } from '../../../../../selectors/networkController'; import { RootState } from '../../../../../reducers'; import { MUSD_EVENTS_CONSTANTS } from '../../constants/events/musdEvents'; -import NavigationService from '../../../../../core/NavigationService'; import Routes from '../../../../../constants/navigation/Routes'; +import { ClaimOnLineaBottomSheetParams } from './ClaimOnLineaBottomSheet/ClaimOnLineaBottomSheet'; +import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../../constants/musd'; +import { SCROLL_TO_TOKEN_EVENT } from '../../../Tokens/constants'; interface ClaimMerklRewardsProps { asset: TokenI; @@ -32,6 +36,7 @@ interface ClaimMerklRewardsProps { const ClaimMerklRewards: React.FC = ({ asset }) => { const { styles } = useStyles(styleSheet, {}); const { trackEvent, createEventBuilder } = useMetrics(); + const navigation = useNavigation(); const network = useSelector((state: RootState) => selectNetworkConfigurationByChainId(state, asset.chainId as Hex), ); @@ -43,7 +48,7 @@ const ClaimMerklRewards: React.FC = ({ asset }) => { // (e.g., user navigated away and came back while tx is still processing) const isLoading = isClaiming || hasPendingClaim; - const handleClaim = async () => { + const trackClaimButtonClicked = useCallback(() => { const buttonText = strings('asset_overview.merkl_rewards.claim'); trackEvent( @@ -58,18 +63,51 @@ const ClaimMerklRewards: React.FC = ({ asset }) => { }) .build(), ); + }, [ + trackEvent, + createEventBuilder, + asset.chainId, + asset.symbol, + network?.name, + ]); + const handleContinueClaim = useCallback(async () => { try { const result = await claimRewards(); - // Transaction submitted successfully - navigate to home page + // Transaction submitted successfully // Toast notifications and balance refresh are handled globally by useMerklClaimStatus if (result?.txHash) { - NavigationService.navigation.navigate(Routes.WALLET.HOME); + // Navigate to home page + navigation.navigate(Routes.WALLET.HOME); + + // Emit event to scroll to Linea mUSD token in the token list + // Use a delay to allow navigation and list rendering to complete + // Note: Using plain setTimeout (not stored in ref) intentionally - + // this must execute even after component unmounts during navigation + setTimeout(() => { + DeviceEventEmitter?.emit?.(SCROLL_TO_TOKEN_EVENT, { + address: MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET], + chainId: CHAIN_IDS.LINEA_MAINNET, + }); + }, 700); } - } catch (error) { + } catch { // Error is handled by useMerklClaim hook and displayed via claimError } - }; + }, [claimRewards, navigation]); + + const handleClaimPress = useCallback(() => { + trackClaimButtonClicked(); + + const params: ClaimOnLineaBottomSheetParams = { + onContinue: handleContinueClaim, + }; + + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.CLAIM_ON_LINEA, + params, + }); + }, [trackClaimButtonClicked, handleContinueClaim, navigation]); return ( @@ -78,7 +116,7 @@ const ClaimMerklRewards: React.FC = ({ asset }) => { variant={ButtonVariant.Secondary} size={ButtonSize.Lg} twClassName="w-full" - onPress={handleClaim} + onPress={handleClaimPress} isDisabled={isLoading} isLoading={isLoading} > @@ -89,7 +127,7 @@ const ClaimMerklRewards: React.FC = ({ asset }) => { variant={TextVariant.BodySm} twClassName="text-error-default mt-2" > - {claimError} + {strings('asset_overview.merkl_rewards.unexpected_error')} )} diff --git a/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/ClaimOnLineaBottomSheet.test.tsx b/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/ClaimOnLineaBottomSheet.test.tsx new file mode 100644 index 00000000000..a36073ebf11 --- /dev/null +++ b/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/ClaimOnLineaBottomSheet.test.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { Linking } from 'react-native'; +import ClaimOnLineaBottomSheet from './ClaimOnLineaBottomSheet'; +import AppConstants from '../../../../../../core/AppConstants'; + +const mockOnContinue = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: jest.fn(), + }), + useRoute: () => ({ + params: { + onContinue: mockOnContinue, + }, + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), + useSafeAreaFrame: () => ({ y: 0 }), +})); + +const mockOnCloseBottomSheet = jest.fn(); + +jest.mock( + '../../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: React.forwardRef( + ( + { children }: { children: React.ReactNode }, + ref: React.Ref, + ) => { + React.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: (callback?: () => void) => { + mockOnCloseBottomSheet(); + callback?.(); + }, + })); + return {children}; + }, + ), + }; + }, +); + +jest.mock( + '../../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const React = jest.requireActual('react'); + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + onClose, + children, + }: { + onClose?: () => void; + children?: React.ReactNode; + }) => ( + + {children} + {onClose && ( + + Close + + )} + + ), + }; + }, +); + +jest.mock( + '../../../../../../images/musd-icon-no-background-2x.png', + () => 'mock-musd-icon', +); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ + style: jest.fn(() => ({ width: 120, height: 120 })), + }), +})); + +jest.mock('../../../../../../core/AppConstants', () => ({ + URLS: { + MUSD_CONVERSION_BONUS_TERMS_OF_USE: 'https://metamask.io/musd-terms', + }, +})); + +jest.mock('@metamask/design-system-react-native', () => { + const React = jest.requireActual('react'); + const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); + return { + Box: ({ + children, + testID, + ...props + }: { + children?: React.ReactNode; + testID?: string; + [key: string]: unknown; + }) => React.createElement(View, { testID, ...props }, children), + BoxAlignItems: { Center: 'center' }, + Button: ({ + children, + onPress, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + testID?: string; + }) => + React.createElement( + TouchableOpacity, + { onPress, testID }, + React.createElement(Text, {}, children), + ), + ButtonSize: { Lg: 'lg' }, + ButtonVariant: { Primary: 'primary' }, + Text: ({ + children, + onPress, + testID, + ...props + }: { + children?: React.ReactNode; + onPress?: () => void; + testID?: string; + [key: string]: unknown; + }) => { + if (onPress) { + return React.createElement( + TouchableOpacity, + { onPress, testID }, + React.createElement(Text, props, children), + ); + } + return React.createElement(Text, { testID, ...props }, children); + }, + TextVariant: { + HeadingLg: 'HeadingLg', + BodyMd: 'BodyMd', + }, + }; +}); + +describe('ClaimOnLineaBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const { getByTestId, getByText } = render(); + + expect(getByTestId('claim-on-linea-bottom-sheet')).toBeTruthy(); + expect(getByText('Claim bonuses on Linea')).toBeTruthy(); + expect(getByTestId('claim-on-linea-description')).toBeTruthy(); + expect(getByText('Terms apply.')).toBeTruthy(); + expect(getByText('Continue')).toBeTruthy(); + }); + + it('renders mUSD education image', () => { + const { getByTestId } = render(); + + expect(getByTestId('claim-on-linea-musd-image')).toBeTruthy(); + }); + + it('calls onContinue when Continue button is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('claim-on-linea-continue-button')); + + expect(mockOnContinue).toHaveBeenCalled(); + }); + + it('closes bottom sheet immediately when Continue is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('claim-on-linea-continue-button')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalled(); + }); + + it('opens terms URL when Terms apply link is pressed', () => { + const openURLSpy = jest + .spyOn(Linking, 'openURL') + .mockResolvedValueOnce(undefined); + + const { getByTestId } = render(); + + fireEvent.press(getByTestId('claim-on-linea-terms-link')); + + expect(openURLSpy).toHaveBeenCalledWith( + AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + ); + }); + + it('closes bottom sheet when close button is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('bottom-sheet-close-button')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/ClaimOnLineaBottomSheet.tsx b/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/ClaimOnLineaBottomSheet.tsx new file mode 100644 index 00000000000..1c28341bcea --- /dev/null +++ b/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/ClaimOnLineaBottomSheet.tsx @@ -0,0 +1,114 @@ +import React, { useRef, useCallback } from 'react'; +import { Linking, Image } from 'react-native'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + Button, + ButtonSize, + ButtonVariant, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import AppConstants from '../../../../../../core/AppConstants'; +import Logger from '../../../../../../util/Logger'; +import musdIcon from '../../../../../../images/musd-icon-no-background-2x.png'; + +export interface ClaimOnLineaBottomSheetParams { + onContinue: () => Promise; +} + +type ClaimOnLineaRouteProp = RouteProp< + { ClaimOnLineaModal: ClaimOnLineaBottomSheetParams }, + 'ClaimOnLineaModal' +>; + +const ClaimOnLineaBottomSheet: React.FC = () => { + const bottomSheetRef = useRef(null); + const route = useRoute(); + const { onContinue } = route.params; + const tw = useTailwind(); + + const handleClose = useCallback(() => { + bottomSheetRef.current?.onCloseBottomSheet(); + }, []); + + const handleTermsPress = useCallback(() => { + Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE).catch( + (error: Error) => { + Logger.error(error, 'Error opening terms of use URL'); + }, + ); + }, []); + + const handleContinue = useCallback(() => { + // Fire-and-forget: start claim in background and close sheet immediately + // The Claim button shows loading state while transaction processes + onContinue(); + bottomSheetRef.current?.onCloseBottomSheet(); + }, [onContinue]); + + return ( + + + + + + + + + {strings('asset_overview.merkl_rewards.claim_on_linea_title')} + + + + {strings('asset_overview.merkl_rewards.claim_on_linea_description')}{' '} + + {strings('asset_overview.merkl_rewards.terms_apply')} + + + + + + + + + ); +}; + +export default ClaimOnLineaBottomSheet; diff --git a/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/index.ts b/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/index.ts new file mode 100644 index 00000000000..b07d3f849ce --- /dev/null +++ b/app/components/UI/Earn/components/MerklRewards/ClaimOnLineaBottomSheet/index.ts @@ -0,0 +1 @@ +export { default } from './ClaimOnLineaBottomSheet'; diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.test.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.test.ts index 4f0a79558b2..13288b6255d 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.test.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.test.ts @@ -492,4 +492,56 @@ describe('useMerklClaim', () => { // isClaiming stays true - component will unmount and useMerklClaimStatus handles the rest expect(result.current.isClaiming).toBe(true); }); + + it('does not set error when user rejects the transaction (EIP-1193 code 4001)', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => createMockRewardData(), + }); + + // Create error with EIP-1193 user rejection code + const userRejectionError = Object.assign( + new Error('User rejected the request'), + { code: 4001 }, + ); + mockAddTransaction.mockRejectedValueOnce(userRejectionError); + + const { result } = renderHook(() => useMerklClaim(mockAsset)); + + await act(async () => { + try { + await result.current.claimRewards(); + } catch { + // Expected to throw + } + }); + + // Error should NOT be set for user rejection (code 4001) + expect(result.current.error).toBe(null); + expect(result.current.isClaiming).toBe(false); + }); + + it('sets error for non-user-rejection errors (no code 4001)', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => createMockRewardData(), + }); + + // Error without code 4001 should set error state + mockAddTransaction.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useMerklClaim(mockAsset)); + + await act(async () => { + try { + await result.current.claimRewards(); + } catch { + // Expected to throw + } + }); + + // Error SHOULD be set for non-user-rejection errors + expect(result.current.error).toBe('Network error'); + expect(result.current.isClaiming).toBe(false); + }); }); diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.ts index 0d18b625c86..b4816c81800 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaim.ts @@ -128,12 +128,19 @@ export const useMerklClaim = (asset: TokenI) => { return { txHash, transactionMeta }; } catch (e) { + const error = e as Error & { code?: number }; + // Ignore AbortError - component unmounted or request was cancelled - if ((e as Error).name === 'AbortError') { + if (error.name === 'AbortError') { return undefined; } - const errorMessage = (e as Error).message; - setError(errorMessage); + + // Don't show error if user rejected/cancelled the transaction (EIP-1193 code 4001) + const isUserRejection = error.code === 4001; + + if (!isUserRejection) { + setError(error.message); + } setIsClaiming(false); throw e; } diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts index 5793081d71f..069bd225748 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts @@ -483,6 +483,99 @@ describe('useMerklRewards', () => { expect(result.current.claimableReward).toBe(null); }); + it('formats single decimal values to 2 decimal places (0.9 -> 0.90)', async () => { + const mockRewardData = { + token: { + address: AGLAMERKL_ADDRESS_MAINNET, + chainId: 1, + symbol: 'aglaMerkl', + decimals: 18, + price: null, + }, + accumulated: '0', + unclaimed: '900000000000000000', // 0.9 tokens + pending: '0', + proofs: [], + amount: '900000000000000000', + claimed: '0', + recipient: mockSelectedAddress, + }; + + mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); + mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); + // Simulate renderFromTokenMinimalUnit returning a value without trailing zero + mockRenderFromTokenMinimalUnit.mockReturnValueOnce('0.9'); + + const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); + + await waitFor(() => { + // Should format to 2 decimal places + expect(result.current.claimableReward).toBe('0.90'); + }); + }); + + it('formats whole numbers to 2 decimal places (1 -> 1.00)', async () => { + const mockRewardData = { + token: { + address: AGLAMERKL_ADDRESS_MAINNET, + chainId: 1, + symbol: 'aglaMerkl', + decimals: 18, + price: null, + }, + accumulated: '0', + unclaimed: '1000000000000000000', // 1 token + pending: '0', + proofs: [], + amount: '1000000000000000000', + claimed: '0', + recipient: mockSelectedAddress, + }; + + mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); + mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); + // Simulate renderFromTokenMinimalUnit returning a whole number + mockRenderFromTokenMinimalUnit.mockReturnValueOnce('1'); + + const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); + + await waitFor(() => { + // Should format to 2 decimal places + expect(result.current.claimableReward).toBe('1.00'); + }); + }); + + it('formats values like 12.5 to 12.50', async () => { + const mockRewardData = { + token: { + address: AGLAMERKL_ADDRESS_MAINNET, + chainId: 1, + symbol: 'aglaMerkl', + decimals: 18, + price: null, + }, + accumulated: '0', + unclaimed: '12500000000000000000', // 12.5 tokens + pending: '0', + proofs: [], + amount: '12500000000000000000', + claimed: '0', + recipient: mockSelectedAddress, + }; + + mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); + mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); + // Simulate renderFromTokenMinimalUnit returning single decimal + mockRenderFromTokenMinimalUnit.mockReturnValueOnce('12.5'); + + const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); + + await waitFor(() => { + // Should format to 2 decimal places + expect(result.current.claimableReward).toBe('12.50'); + }); + }); + it('converts "< 0.00001" to "< 0.01" for small amounts', async () => { const mockRewardData = { token: { diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts index 2221f8d3def..bd2b51865f4 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts @@ -147,9 +147,15 @@ export const useMerklRewards = ({ ); // Handle the "< 0.00001" case from renderFromTokenMinimalUnit // by showing "< 0.01" for consistency with 2 decimal places - const displayAmount = unclaimedAmount.startsWith('<') - ? '< 0.01' - : unclaimedAmount; + // Also ensure we always show exactly 2 decimal places for currency display + let displayAmount: string; + if (unclaimedAmount.startsWith('<')) { + displayAmount = '< 0.01'; + } else { + // Ensure exactly 2 decimal places (e.g., "0.9" -> "0.90") + const numValue = parseFloat(unclaimedAmount); + displayAmount = numValue.toFixed(2); + } // Double-check that the rendered amount is not '0' or '0.00' // This handles edge cases where very small amounts round to zero if ( diff --git a/app/components/UI/Tokens/TokenList/TokenList.test.tsx b/app/components/UI/Tokens/TokenList/TokenList.test.tsx index bb3c4431508..e37ce837840 100644 --- a/app/components/UI/Tokens/TokenList/TokenList.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import { DeviceEventEmitter } from 'react-native'; import { Provider, useSelector } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import { TokenList } from './TokenList'; @@ -7,6 +8,7 @@ import { useNavigation } from '@react-navigation/native'; import { WalletViewSelectorsIDs } from '../../../Views/Wallet/WalletView.testIds'; import { useMetrics } from '../../../hooks/useMetrics'; import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; +import { SCROLL_TO_TOKEN_EVENT } from '../constants'; // Mock external dependencies jest.mock('@react-navigation/native', () => ({ @@ -122,6 +124,7 @@ jest.mock('@metamask/design-system-react-native', () => ({ })); // Mock FlashList +const mockScrollToIndex = jest.fn(); jest.mock('@shopify/flash-list', () => { const React = jest.requireActual('react'); const { FlatList } = jest.requireActual('react-native'); @@ -130,6 +133,7 @@ jest.mock('@shopify/flash-list', () => { (props: Record, ref: React.Ref) => { React.useImperativeHandle(ref, () => ({ recomputeViewableItems: jest.fn(), + scrollToIndex: mockScrollToIndex, })); return React.createElement(FlatList, { ...props, ref }); }, @@ -482,4 +486,216 @@ describe('TokenList', () => { expect(queryByTestId('token-item-0x456')).toBeOnTheScreen(); }); }); + + describe('Scroll to Token Event', () => { + beforeEach(() => { + mockScrollToIndex.mockClear(); + // Reset selector mocks + mockUseSelector.mockReset(); + }); + + afterEach(() => { + // Clean up any event listeners + DeviceEventEmitter.removeAllListeners(SCROLL_TO_TOKEN_EVENT); + DeviceEventEmitter.removeAllListeners('scrollToTokenIndex'); + }); + + it('scrolls to token using FlashList scrollToIndex when token is found and using FlashList mode', () => { + // Set up for FlashList mode (homepage redesign disabled) + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + // Emit scroll-to-token event + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x123', + chainId: '0x1', + }); + }); + + expect(mockScrollToIndex).toHaveBeenCalledWith({ + index: 0, + animated: true, + viewPosition: 0.5, + }); + }); + + it('scrolls to correct index when token is not first in list', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x456', + chainId: '0x1', + }); + }); + + expect(mockScrollToIndex).toHaveBeenCalledWith({ + index: 1, + animated: true, + viewPosition: 0.5, + }); + }); + + it('does not scroll when token is not found in the list', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0xnonexistent', + chainId: '0x1', + }); + }); + + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('does not scroll when chainId does not match', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x123', + chainId: '0x5', // Different chainId + }); + }); + + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('emits scrollToTokenIndex event in .map() mode (homepage redesign enabled, not full view)', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return true; + } + return selector({}); + }); + + const scrollToTokenIndexHandler = jest.fn(); + DeviceEventEmitter.addListener( + 'scrollToTokenIndex', + scrollToTokenIndexHandler, + ); + + renderComponent({ isFullView: false }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x123', + chainId: '0x1', + }); + }); + + expect(scrollToTokenIndexHandler).toHaveBeenCalledWith({ + index: 0, + offset: 0, // 0 * 72 (TOKEN_ROW_HEIGHT) + }); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('calculates correct offset based on token index in .map() mode', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return true; + } + return selector({}); + }); + + const scrollToTokenIndexHandler = jest.fn(); + DeviceEventEmitter.addListener( + 'scrollToTokenIndex', + scrollToTokenIndexHandler, + ); + + renderComponent({ isFullView: false }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x456', + chainId: '0x1', + }); + }); + + expect(scrollToTokenIndexHandler).toHaveBeenCalledWith({ + index: 1, + offset: 72, // 1 * 72 (TOKEN_ROW_HEIGHT) + }); + }); + + it('matches token address case-insensitively', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + renderComponent({ isFullView: true }); + + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0X123', // Uppercase + chainId: '0x1', + }); + }); + + expect(mockScrollToIndex).toHaveBeenCalledWith({ + index: 0, + animated: true, + viewPosition: 0.5, + }); + }); + + it('cleans up event listener on unmount', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector.toString().includes('selectHomepageRedesignV1Enabled')) { + return false; + } + return selector({}); + }); + + const { unmount } = renderComponent({ isFullView: true }); + + // Unmount the component + unmount(); + + // Emit event after unmount + act(() => { + DeviceEventEmitter.emit(SCROLL_TO_TOKEN_EVENT, { + address: '0x123', + chainId: '0x1', + }); + }); + + // Should not scroll because listener was removed + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Tokens/TokenList/TokenList.tsx b/app/components/UI/Tokens/TokenList/TokenList.tsx index e637ef6b821..acf7ff680d7 100644 --- a/app/components/UI/Tokens/TokenList/TokenList.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.tsx @@ -1,5 +1,11 @@ -import React, { useCallback, useLayoutEffect, useRef, useMemo } from 'react'; -import { RefreshControl } from 'react-native'; +import React, { + useCallback, + useLayoutEffect, + useRef, + useMemo, + useEffect, +} from 'react'; +import { DeviceEventEmitter, RefreshControl } from 'react-native'; import { FlashList, FlashListRef } from '@shopify/flash-list'; import { useSelector } from 'react-redux'; import { useTheme } from '../../../../util/theme'; @@ -23,6 +29,7 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; import { useMusdCtaVisibility } from '../../Earn/hooks/useMusdCtaVisibility'; +import { SCROLL_TO_TOKEN_EVENT } from '../constants'; export interface FlashListAssetKey { address: string; @@ -72,15 +79,6 @@ const TokenListComponent = ({ listRef.current?.recomputeViewableItems(); }, [isTokenNetworkFilterEqualCurrentNetwork]); - const handleViewAllTokens = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.VIEW_ALL_ASSETS_CLICKED) - .addProperties({ asset_type: 'Token' }) - .build(), - ); - navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW); - }, [navigation, trackEvent, createEventBuilder]); - // Apply maxItems limit if specified const displayTokenKeys = useMemo( () => (tokenKeys || []).slice(0, maxItems || undefined), @@ -93,6 +91,57 @@ const TokenListComponent = ({ [maxItems, tokenKeys], ); + // Listen for scroll-to-token events (e.g., after claiming mUSD rewards) + useEffect(() => { + const subscription = DeviceEventEmitter.addListener( + SCROLL_TO_TOKEN_EVENT, + ({ address, chainId }: { address: string; chainId: string }) => { + // Find the index of the token in the display list + const tokenIndex = displayTokenKeys.findIndex( + (item) => + item.address?.toLowerCase() === address?.toLowerCase() && + item.chainId === chainId, + ); + + if (tokenIndex === -1) { + return; + } + + // For FlashList mode, use scrollToIndex + if (!isHomepageRedesignV1Enabled || isFullView) { + if (listRef.current) { + listRef.current.scrollToIndex({ + index: tokenIndex, + animated: true, + viewPosition: 0.5, // Center the item in the viewport + }); + } + } else { + // For .map() mode, emit event with index for parent ScrollView to handle + // Approximate token row height is ~72px + const TOKEN_ROW_HEIGHT = 72; + DeviceEventEmitter.emit('scrollToTokenIndex', { + index: tokenIndex, + offset: tokenIndex * TOKEN_ROW_HEIGHT, + }); + } + }, + ); + + return () => { + subscription.remove(); + }; + }, [displayTokenKeys, isHomepageRedesignV1Enabled, isFullView]); + + const handleViewAllTokens = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.VIEW_ALL_ASSETS_CLICKED) + .addProperties({ asset_type: 'Token' }) + .build(), + ); + navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW); + }, [navigation, trackEvent, createEventBuilder]); + const renderTokenListItem = useCallback( ({ item }: { item: FlashListAssetKey }) => ( >(); const walletRef = useRef(null); const walletTokensTabViewRef = useRef(null); + const scrollViewRef = useRef(null); const isMountedRef = useRef(true); const refreshInProgressRef = useRef(false); const [refreshing, setRefreshing] = useState(false); @@ -845,6 +848,27 @@ const Wallet = ({ }; }, []); + // Listen for scroll-to-token events (e.g., after claiming mUSD rewards) + // This handles scrolling in the homepage .map() mode where TokenList can't scroll directly + useEffect(() => { + const subscription = DeviceEventEmitter.addListener( + 'scrollToTokenIndex', + ({ offset }: { index: number; offset: number }) => { + // Add offset for content above tokens (balance, carousel, etc.) + // Approximate: AccountGroupBalance (~200px) + Carousel (~150px) + padding + const CONTENT_OFFSET_ABOVE_TOKENS = 400; + scrollViewRef.current?.scrollTo({ + y: CONTENT_OFFSET_ABOVE_TOKENS + offset, + animated: true, + }); + }, + ); + + return () => { + subscription.remove(); + }; + }, []); + useEffect(() => { // do not prompt for social login flow if ( @@ -1398,6 +1422,7 @@ const Wallet = ({ testID={WalletViewSelectorsIDs.WALLET_CONTAINER} > Date: Thu, 5 Feb 2026 17:39:57 +0100 Subject: [PATCH 14/33] fix: background color for Perps deposit cp-7.64.0 (#25567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes background color for Perps deposit. It was set to white, now it's set back to gray. ## **Changelog** CHANGELOG entry: Fixes background color for Perps deposit ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to Perps deposit, background is gray now, as it was before ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Small, localized UI change limited to Perps confirmation screen styling; minimal behavioral risk beyond potential visual regressions. > > **Overview** > Perps confirmations no longer pass a theme-derived `fullscreenStyle` background override into `Confirm`; they now rely on `Confirm`’s default container styling. > > The Perps route wrapper still conditionally disables safe-area insets based on `showPerpsHeader`, but drops the `useTheme` dependency that was forcing a white/incorrect background. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 239fb4b2ffd0643612bdf3b6b9ec57d36161c083. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- app/components/UI/Perps/routes/index.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index bde1f15ba05..fb199d53ab6 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -36,7 +36,6 @@ import ActivityView from '../../../Views/ActivityView'; import PerpsStreamBridge from '../components/PerpsStreamBridge'; import { HIP3DebugView } from '../Debug'; import PerpsCrossMarginWarningBottomSheet from '../components/PerpsCrossMarginWarningBottomSheet'; -import { useTheme } from '../../../../util/theme'; import { RouteProp, useRoute } from '@react-navigation/native'; import { CONFIRMATION_HEADER_CONFIG } from '../constants/perpsConfig'; @@ -66,22 +65,13 @@ const PerpsConfirmScreen = ( route: RouteProp; }, ) => { - const theme = useTheme(); const params = useRoute>(); const showPerpsHeader = params?.params?.showPerpsHeader ?? CONFIRMATION_HEADER_CONFIG.DefaultShowPerpsHeader; - return ( - - ); + return ; }; const PerpsModalStack = () => { From 0aeff3d246b7071f0b0caa788f27406f53f15ad1 Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Thu, 5 Feb 2026 10:44:43 -0600 Subject: [PATCH 15/33] fix: update TransactionDetails component header to have left arrow back button (#25642) ## **Description** Fixed the navigation header on the transaction details page (used by musdConversion, perpsDeposit, predictDeposit) to show a back arrow on the top left instead of a close ("X") button on the right. The issue was that getNavigationOptionsTitle was being called with isFullScreenModal=true, which shows a close button on the right. Changed to false to display the standard back arrow navigation pattern. ## **Changelog** CHANGELOG entry: n/a ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-274 ## **Manual testing steps** ```gherkin Feature: Transaction details navigation Scenario: user views transaction details page Given user has a completed musdConversion, perpsDeposit, or predictDeposit transaction When user navigates to the transaction details page Then a back arrow appears on the top left of the header And no close button ("X") appears on the right side of the header ``` ## **Screenshots/Recordings** ### **Before** ### **After** simulator_screenshot_5866955C-CB6A-470E-9842-FA582CADCFFC ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Small, isolated UI/navigation-option change with a targeted regression test; no changes to transaction logic or data handling. > > **Overview** > Updates `TransactionDetails` header configuration to use standard stack navigation (left back arrow) rather than fullscreen-modal behavior (right-side close button) by changing the `isFullScreenModal` flag passed to `getNavigationOptionsTitle`. > > Adds a unit test asserting the navigation options now render `headerLeft` and omit `headerRight`, preventing regressions in the transaction details header UI. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7f777ba1188d65604d3818b7d856f41113f0285c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../transaction-details/transaction-details.test.tsx | 10 ++++++++++ .../transaction-details/transaction-details.tsx | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx index 3a604cb2778..d2aa5e45848 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.test.tsx @@ -234,6 +234,16 @@ describe('TransactionDetails', () => { }); }); + describe('navigation options', () => { + it('configures navigation with back arrow on left instead of close button on right', () => { + render(); + + const navOptions = mockSetOptions.mock.calls[0][0]; + expect(navOptions.headerLeft()).not.toBeNull(); + expect(navOptions.headerRight()).toBeNull(); + }); + }); + describe('SUMMARY_SECTION_TYPES', () => { it.each([ TransactionType.musdConversion, diff --git a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx index 8021c078de6..336305dd319 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details/transaction-details.tsx @@ -42,7 +42,7 @@ export function TransactionDetails() { useEffect(() => { navigation.setOptions( - getNavigationOptionsTitle(title, navigation, true, colors), + getNavigationOptionsTitle(title, navigation, false, colors), ); }, [colors, navigation, theme, title]); From 889d42110f6950986e51c0a3978a1ce50dc39de4 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:45:07 -0500 Subject: [PATCH 16/33] fix: MUSD-285 get musd cta displayed for account with no stablecoins clicking on the cta does nothing (#25613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR separates the conditional rendering state of the "Buy mUSD" and "Get mUSD" CTAs within `useMusdCtaVisibility`. The "Get mUSD" wins a tiebreaker if both should be displayed. Also adds a `variant` property to the `shouldShowBuyGetMusdCta` so consumers know which will be rendered. ## **Changelog** CHANGELOG entry: refactored ## **Related issues** Fixes: - [MUSD-285: Get mUSD CTA  is displayed on an imported account with no stable coin balance on Ethereum Mainnet. Clicking on the CTA does nothing. The account holds stable coin on Linea network](https://consensyssoftware.atlassian.net/browse/MUSD-285) - [MUSD-284: After switching from a non-EVM network to all networks, the get mUSD CTA is displayed, even if the account holds mUSD on both Ethereum Mainnet and Linea](https://consensyssoftware.atlassian.net/browse/MUSD-284) ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches CTA decision logic and balance/token selection paths that affect navigation into buy/convert flows; behavior changes are well-covered by tests but could alter CTA visibility across network/account combinations. > > **Overview** > Fixes the primary mUSD CTA so it resolves to a single **explicit variant** (`BUY` vs `GET`) returned by `useMusdCtaVisibility`, favoring **Get** when both could apply and preventing cases where a CTA is shown but clicking it does nothing. > > Improves chain-awareness across the flow: `useMusdConversionTokens` now exposes `hasConvertibleTokensByChainId` and compares tokens using `safeFormatChainIdToHex`, `useMusdConversionFlowData` selects/returns the payment token chainId safely in hex, and `useMusdBalance` now reads balances from `selectTokensBalances` keyed by the currently selected EVM account. The asset list CTA now uses the new `variant` for label/action and adds `cta_click_target` to MetaMetrics events; tests are updated accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 08294e7e178ed682adc7527dbde161ebb7980280. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../EarnBalance/EarnBalance.test.tsx | 2 + .../EarnLendingBalance.test.tsx | 6 + .../MusdConversionAssetListCta.test.tsx | 60 +++++- .../Musd/MusdConversionAssetListCta/index.tsx | 43 ++-- .../UI/Earn/hooks/useMusdBalance.test.ts | 186 +++++++++++----- .../UI/Earn/hooks/useMusdBalance.ts | 20 +- .../Earn/hooks/useMusdConversionFlowData.ts | 15 +- .../UI/Earn/hooks/useMusdConversionTokens.ts | 31 ++- .../Earn/hooks/useMusdCtaVisibility.test.ts | 180 +++++++++++++++- .../UI/Earn/hooks/useMusdCtaVisibility.ts | 204 ++++++++++++++---- .../TokenListItem/TokenListItem.test.tsx | 1 + 11 files changed, 625 insertions(+), 123 deletions(-) diff --git a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx index 41218c70c20..9a0a44097a3 100644 --- a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx @@ -293,6 +293,7 @@ describe('EarnBalance', () => { mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(true), filterAllowedTokens: jest.fn(), tokens: [], isMusdSupportedOnChain: jest.fn().mockReturnValue(true), @@ -322,6 +323,7 @@ describe('EarnBalance', () => { mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(true), filterAllowedTokens: jest.fn(), tokens: [], isMusdSupportedOnChain: jest.fn().mockReturnValue(true), diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx index 513015b87f5..26848ee7239 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.test.tsx @@ -437,6 +437,7 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(false), tokens: [], @@ -594,6 +595,7 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], @@ -622,6 +624,7 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], @@ -656,6 +659,7 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], @@ -693,6 +697,7 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], @@ -751,6 +756,7 @@ describe('EarnLendingBalance', () => { > ).mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(true), + hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), filterAllowedTokens: jest.fn().mockReturnValue([]), isMusdSupportedOnChain: jest.fn().mockReturnValue(true), tokens: [], diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx index 94ddb606cd8..c0aa7915c12 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.test.tsx @@ -13,7 +13,10 @@ import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import MusdConversionAssetListCta from '.'; import { useMusdConversionFlowData } from '../../../hooks/useMusdConversionFlowData'; import { useMusdConversion } from '../../../hooks/useMusdConversion'; -import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility'; +import { + BUY_GET_MUSD_CTA_VARIANT, + useMusdCtaVisibility, +} from '../../../hooks/useMusdCtaVisibility'; import { useRampNavigation } from '../../../../Ramp/hooks/useRampNavigation'; import { MUSD_CONVERSION_APY, @@ -134,6 +137,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: false, + variant: BUY_GET_MUSD_CTA_VARIANT.GET, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -183,7 +187,7 @@ describe('MusdConversionAssetListCta', () => { }); describe('CTA button text', () => { - it('displays "Buy mUSD" when hook returns isEmptyWallet true', () => { + it('displays "Buy mUSD" when CTA variant is BUY', () => { ( useMusdConversionFlowData as jest.MockedFunction< typeof useMusdConversionFlowData @@ -203,6 +207,20 @@ describe('MusdConversionAssetListCta', () => { isMusdBuyable: false, }); + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowBuyGetMusdCta: jest.fn().mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, + }), + shouldShowTokenListItemCta: jest.fn(), + shouldShowAssetOverviewCta: jest.fn(), + }); + const { getByText } = renderWithProvider(, { state: initialRootState, }); @@ -227,6 +245,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: false, + variant: null, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -266,6 +285,20 @@ describe('MusdConversionAssetListCta', () => { isMusdBuyableOnAnyChain: false, isMusdBuyable: false, }); + + ( + useMusdCtaVisibility as jest.MockedFunction + ).mockReturnValue({ + shouldShowBuyGetMusdCta: jest.fn().mockReturnValue({ + shouldShowCta: true, + showNetworkIcon: false, + selectedChainId: null, + isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, + }), + shouldShowTokenListItemCta: jest.fn(), + shouldShowAssetOverviewCta: jest.fn(), + }); }); it('calls goToBuy with correct ramp intent', () => { @@ -363,6 +396,8 @@ describe('MusdConversionAssetListCta', () => { shouldShowCta: true, showNetworkIcon: false, selectedChainId: CHAIN_IDS.LINEA_MAINNET, + isEmptyWallet: false, + variant: BUY_GET_MUSD_CTA_VARIANT.GET, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -418,6 +453,8 @@ describe('MusdConversionAssetListCta', () => { shouldShowCta: true, showNetworkIcon: false, selectedChainId: CHAIN_IDS.LINEA_MAINNET, + isEmptyWallet: false, + variant: BUY_GET_MUSD_CTA_VARIANT.GET, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -524,6 +561,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: false, + variant: null, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -548,6 +586,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -574,6 +613,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: false, selectedChainId: null, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -600,6 +640,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: true, selectedChainId: CHAIN_IDS.MAINNET, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -624,6 +665,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: true, selectedChainId: CHAIN_IDS.LINEA_MAINNET, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -648,6 +690,7 @@ describe('MusdConversionAssetListCta', () => { showNetworkIcon: true, selectedChainId: CHAIN_IDS.BSC, isEmptyWallet: true, + variant: BUY_GET_MUSD_CTA_VARIANT.BUY, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -710,9 +753,12 @@ describe('MusdConversionAssetListCta', () => { ).mockReturnValue({ shouldShowBuyGetMusdCta: jest.fn().mockReturnValue({ shouldShowCta: true, - showNetworkIcon: false, + showNetworkIcon: Boolean(selectedChainId), selectedChainId, isEmptyWallet, + variant: isEmptyWallet + ? BUY_GET_MUSD_CTA_VARIANT.BUY + : BUY_GET_MUSD_CTA_VARIANT.GET, }), shouldShowTokenListItemCta: jest.fn(), shouldShowAssetOverviewCta: jest.fn(), @@ -774,6 +820,7 @@ describe('MusdConversionAssetListCta', () => { redirects_to: EVENT_LOCATIONS.BUY_SCREEN, cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: ctaText, + cta_click_target: 'cta_button', network_chain_id: CHAIN_IDS.MAINNET, network_name: 'Ethereum Mainnet', }); @@ -793,6 +840,7 @@ describe('MusdConversionAssetListCta', () => { redirects_to: EVENT_LOCATIONS.BUY_SCREEN, cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: ctaText, + cta_click_target: 'cta_button', network_chain_id: null, network_name: strings('wallet.popular_networks'), }); @@ -814,6 +862,7 @@ describe('MusdConversionAssetListCta', () => { redirects_to: EVENT_LOCATIONS.BUY_SCREEN, cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: ctaText, + cta_click_target: 'cta_button', network_chain_id: null, network_name: strings('wallet.popular_networks'), }); @@ -835,6 +884,7 @@ describe('MusdConversionAssetListCta', () => { redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: strings('earn.musd_conversion.get_musd'), + cta_click_target: 'cta_button', network_chain_id: null, network_name: strings('wallet.popular_networks'), }); @@ -854,6 +904,7 @@ describe('MusdConversionAssetListCta', () => { redirects_to: EVENT_LOCATIONS.BUY_SCREEN, cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: ctaText, + cta_click_target: 'cta_text_link', network_chain_id: null, network_name: strings('wallet.popular_networks'), }); @@ -875,6 +926,7 @@ describe('MusdConversionAssetListCta', () => { redirects_to: EVENT_LOCATIONS.BUY_SCREEN, cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: ctaText, + cta_click_target: 'cta_button', network_chain_id: null, network_name: strings('wallet.popular_networks'), }); @@ -896,6 +948,7 @@ describe('MusdConversionAssetListCta', () => { redirects_to: EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN, cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: strings('earn.musd_conversion.get_musd'), + cta_click_target: 'cta_button', network_chain_id: null, network_name: strings('wallet.popular_networks'), }); @@ -917,6 +970,7 @@ describe('MusdConversionAssetListCta', () => { redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: strings('earn.musd_conversion.get_musd'), + cta_click_target: 'cta_button', network_chain_id: null, network_name: strings('wallet.popular_networks'), }); diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx index 9012c4a50ab..69b464d8c38 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/index.tsx @@ -22,7 +22,10 @@ import { EARN_TEST_IDS } from '../../../constants/testIds'; import Logger from '../../../../../../util/Logger'; import { useStyles } from '../../../../../hooks/useStyles'; import { useMusdConversion } from '../../../hooks/useMusdConversion'; -import { useMusdCtaVisibility } from '../../../hooks/useMusdCtaVisibility'; +import { + BUY_GET_MUSD_CTA_VARIANT, + useMusdCtaVisibility, +} from '../../../hooks/useMusdCtaVisibility'; import { useMusdConversionFlowData } from '../../../hooks/useMusdConversionFlowData'; import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; @@ -37,16 +40,18 @@ import { MetaMetricsEvents, useMetrics } from '../../../../../hooks/useMetrics'; import { MUSD_EVENTS_CONSTANTS } from '../../../constants/events'; import { useNetworkName } from '../../../../../Views/confirmations/hooks/useNetworkName'; +enum CTA_CLICK_TARGET { + CTA_BUTTON = 'cta_button', + CTA_TEXT_LINK = 'cta_text_link', +} + const MusdConversionAssetListCta = () => { const { styles } = useStyles(styleSheet, {}); const { goToBuy } = useRampNavigation(); - const { - isEmptyWallet, - getPaymentTokenForSelectedNetwork, - getChainIdForBuyFlow, - } = useMusdConversionFlowData(); + const { getPaymentTokenForSelectedNetwork, getChainIdForBuyFlow } = + useMusdConversionFlowData(); const { initiateConversion, hasSeenConversionEducationScreen } = useMusdConversion(); @@ -55,20 +60,21 @@ const MusdConversionAssetListCta = () => { const { trackEvent, createEventBuilder } = useMetrics(); - const { shouldShowCta, showNetworkIcon, selectedChainId } = + const { shouldShowCta, showNetworkIcon, selectedChainId, variant } = shouldShowBuyGetMusdCta(); const networkName = useNetworkName(selectedChainId ?? undefined); - const buttonText = isEmptyWallet - ? strings('earn.musd_conversion.buy_musd') - : strings('earn.musd_conversion.get_musd'); + const buttonText = + variant === BUY_GET_MUSD_CTA_VARIANT.BUY + ? strings('earn.musd_conversion.buy_musd') + : strings('earn.musd_conversion.get_musd'); - const submitCtaPressedEvent = (source: 'cta_button' | 'cta_text') => { + const submitCtaPressedEvent = (source: CTA_CLICK_TARGET) => { const { MUSD_CTA_TYPES, EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; const getRedirectLocation = () => { - if (isEmptyWallet) { + if (variant === BUY_GET_MUSD_CTA_VARIANT.BUY) { return EVENT_LOCATIONS.BUY_SCREEN; } @@ -78,7 +84,7 @@ const MusdConversionAssetListCta = () => { }; const ctaText = - source === 'cta_button' + source === CTA_CLICK_TARGET.CTA_BUTTON ? buttonText : strings('earn.earn_a_percentage_bonus', { percentage: MUSD_CONVERSION_APY, @@ -91,6 +97,7 @@ const MusdConversionAssetListCta = () => { redirects_to: getRedirectLocation(), cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: ctaText, + cta_click_target: source, network_chain_id: selectedChainId, network_name: networkName ?? strings('wallet.popular_networks'), }) @@ -98,10 +105,10 @@ const MusdConversionAssetListCta = () => { ); }; - const handlePress = async (source: 'cta_button' | 'cta_text') => { + const handlePress = async (source: CTA_CLICK_TARGET) => { submitCtaPressedEvent(source); - if (isEmptyWallet) { + if (variant === BUY_GET_MUSD_CTA_VARIANT.BUY) { const chainId = getChainIdForBuyFlow(); const rampIntent: RampIntent = { assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId], @@ -173,7 +180,9 @@ const MusdConversionAssetListCta = () => { MetaMask USD - handlePress('cta_text')}> + handlePress(CTA_CLICK_TARGET.CTA_TEXT_LINK)} + > {strings('earn.earn_a_percentage_bonus', { percentage: MUSD_CONVERSION_APY, @@ -185,7 +194,7 @@ const MusdConversionAssetListCta = () => {