From e9fa45487bedc5f00579a7c01011b505b2a5e086 Mon Sep 17 00:00:00 2001
From: Juanmi <95381763+juanmigdr@users.noreply.github.com>
Date: Wed, 20 May 2026 20:51:34 +0200
Subject: [PATCH 01/12] fix: football prediction markets not loading cp-7.78.0
(#30466)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
**Root cause:** The Polymarket API returns `null` for the
`orderPriceMinTickSize` field on some sports markets. Our code was
calling `.toString()` on that value without a null check, which threw a
runtime error. Since the entire batch of events was parsed in a single
`.map()`, one bad event crashed the whole thing and returned an empty
list.
**Fix:**
1. Changed `market.orderPriceMinTickSize.toString()` to
`market.orderPriceMinTickSize?.toString() ?? '0.01'` to safely handle
the null.
2. Updated the TypeScript type to `number | null` to correctly reflect
what the API actually returns.
3. Changed the event parsing loop from `.map()` to `.flatMap()` with a
per-event `try/catch`, so a single malformed event is skipped and logged
instead of taking down the whole batch.
## **Changelog**
CHANGELOG entry: football prediction markets not loading
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3259
## **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**
https://github.com/user-attachments/assets/3fb77be4-1060-45c3-adb2-cb75cd4f41a8
### **After**
https://github.com/user-attachments/assets/464094d7-94ea-4002-8531-498bb6d173da
## **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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 market parsing defaults and error handling for Polymarket
events; while localized, it can affect tick-size-dependent pricing
behavior and which events appear if parsing failures occur.
>
> **Overview**
> Fixes Polymarket sports markets failing to load when the API returns a
`null` `orderPriceMinTickSize`.
>
> Updates the Polymarket market type to allow `orderPriceMinTickSize:
number | null`, safely derives `tickSize` with a fallback default, and
makes `parsePolymarketEvents` resilient by parsing each event in a
`try/catch` (logging and skipping only the bad event instead of failing
the whole batch).
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1f8de4a56425ebce37ac2180e881b9f96fe0d61a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---------
Co-authored-by: Luis Taniça
---
.../UI/Predict/providers/polymarket/types.ts | 2 +-
.../UI/Predict/providers/polymarket/utils.ts | 68 +++++++++++--------
2 files changed, 39 insertions(+), 31 deletions(-)
diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts
index 8bcc3142d970..2c9d1c8f5ca4 100644
--- a/app/components/UI/Predict/providers/polymarket/types.ts
+++ b/app/components/UI/Predict/providers/polymarket/types.ts
@@ -75,7 +75,7 @@ export interface PolymarketApiMarket {
closed: boolean;
active: boolean;
resolvedBy: string;
- orderPriceMinTickSize: number;
+ orderPriceMinTickSize: number | null;
events?: PolymarketApiEvent[];
umaResolutionStatus: string;
line?: number;
diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts
index 270aada3f51b..a3c5432a94d6 100644
--- a/app/components/UI/Predict/providers/polymarket/utils.ts
+++ b/app/components/UI/Predict/providers/polymarket/utils.ts
@@ -1067,7 +1067,7 @@ export const parsePolymarketMarket = (
sportsMarketType: market.sportsMarketType,
line: market.line,
negRisk: market.negRisk,
- tickSize: market.orderPriceMinTickSize.toString(),
+ tickSize: market.orderPriceMinTickSize?.toString() ?? '0.01',
resolvedBy: market.resolvedBy,
resolutionStatus: market.umaResolutionStatus,
});
@@ -1097,8 +1097,8 @@ export const parsePolymarketEvents = (
const { category, teamLookup, extendedSportsMarketsLeagues } = options;
const sortBy = options.sortMarketsBy ?? sortMarketsBy;
- const parsedMarkets: PredictMarket[] = events.map(
- (event: PolymarketApiEvent) => {
+ return events.flatMap((event: PolymarketApiEvent) => {
+ try {
const tags = Array.isArray(event.tags) ? event.tags : [];
const eventLeague = getEventLeague(event, extendedSportsMarketsLeagues);
@@ -1152,33 +1152,41 @@ export const parsePolymarketEvents = (
? buildOutcomeGroups(outcomes)
: undefined;
- return {
- id: event.id,
- slug: event.slug,
- providerId: POLYMARKET_PROVIDER_ID,
- title: event.title,
- description,
- image: event.icon,
- status: event.closed
- ? PredictMarketStatus.CLOSED
- : PredictMarketStatus.OPEN,
- recurrence: getRecurrence(event.series),
- endDate: event.endDate,
- category,
- tags: tags.map((t) => t.slug),
- outcomes,
- ...(outcomeGroups && { outcomeGroups }),
- liquidity: event.liquidity,
- volume: event.volume,
- game,
- ...(seriesData && { series: seriesData }),
- ...(event.parentEventId !== undefined && {
- parentMarketId: event.parentEventId,
- }),
- };
- },
- );
- return parsedMarkets;
+ return [
+ {
+ id: event.id,
+ slug: event.slug,
+ providerId: POLYMARKET_PROVIDER_ID,
+ title: event.title,
+ description,
+ image: event.icon,
+ status: event.closed
+ ? PredictMarketStatus.CLOSED
+ : PredictMarketStatus.OPEN,
+ recurrence: getRecurrence(event.series),
+ endDate: event.endDate,
+ category,
+ tags: tags.map((t) => t.slug),
+ outcomes,
+ ...(outcomeGroups && { outcomeGroups }),
+ liquidity: event.liquidity,
+ volume: event.volume,
+ game,
+ ...(seriesData && { series: seriesData }),
+ ...(event.parentEventId !== undefined && {
+ parentMarketId: event.parentEventId,
+ }),
+ } as PredictMarket,
+ ];
+ } catch (err) {
+ Logger.error(err instanceof Error ? err : new Error(String(err)), {
+ feature: 'predict',
+ method: 'parsePolymarketEvents',
+ eventId: event.id,
+ });
+ return [];
+ }
+ });
};
/**
From 75e45df9e90b2fd376e5e324b62cf86b8580409e Mon Sep 17 00:00:00 2001
From: Bruno Nascimento
Date: Wed, 20 May 2026 16:02:06 -0300
Subject: [PATCH 02/12] feat(card): link Money Account from Card Home and
refresh linkage UX (#30452)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
**Reason:** Users with a MetaMask Card and a Money Account need clearer
paths to link spending to their Money balance, copy that matches design
(including mUSD cashback and flexible APY), and less noisy linkage
feedback. Card Home previously lacked a dedicated entry point; the link
confirmation sheet used generic art and older copy.
**Solution:**
- **Card Home:** When `useMoneyAccountCardLinkage` reports `canLink`,
render a `MoneyMetaMaskCard` in `link` mode between dividers, with
`hideCardImage` so the hero card image is not duplicated, `apy` from
`useMoneyAccountBalance`, and metal vs virtual cashback via `CardType`.
Tapping the header, subtitle row, or **Link card** calls
`startLinkFlow({ screen: Routes.CARD.HOME })`. CTA stays visible while
`cardHomeDataStatus` is `loading` (stale-while-revalidate) when other
conditions still allow linking.
- **MoneyMetaMaskCard (link mode):** Support `hideCardImage` (vertical
bullets only), and when `apy` is `undefined`, use `link_subtitle_no_apy`
and omit the APY bullet instead of forcing `0%`.
- **MoneyLinkCardSheet:** Replace the mUSD coin with the user’s card art
from `selectCardHomeData` (`mm_card_metal` for `CardType.METAL`, else
`mm_card_regular`), Figma-aligned sizing, updated sheet description plus
`link_card_sheet_description_no_apy` when vault APY is not yet
available.
- **useMoneyAccountCardLinkage:** Single-line pending/success toasts,
updated error copy, pending spinner color `IconDefault`, and error icon
`Error` instead of `Danger`.
- **Spending Limit (from #30320):** Money Account preselection, Spend
and Earn promo UI (`SpendAndEarnPromoCard`, `ShimmerOverlay`), and
related `useSpendingLimit` / view tests.
- **Other (same branch vs `main`):** Bridge insufficient-native reserve
handling and tests; send confirmation alert modal and fee / percentage
amount hook adjustments with tests.
## **Changelog**
CHANGELOG entry: Added a Link Money Account section on Card Home,
redesigned the link-card bottom sheet (card art and copy), improved
Money Account card-linkage toasts, extended Spending Limit with Spend
and Earn / Money preselection, and tightened bridge and send
confirmation edge cases.
## **Related issues**
Fixes:
Refs: #30320
## **Manual testing steps**
```gherkin
Feature: Money Account linking and related card flows
Scenario: Card Home shows link CTA when user can link
Given I am a cardholder with Money Account requirements met, card home data loaded, and I am not already delegated for card spending
When I open Card Home
Then I see the Link MetaMask Card block with dividers and Link card action
Scenario: Link card opens confirmation sheet with correct art and copy
Given I tap Link card or the link header from Card Home (or open the link sheet from the Money link flow)
When the Spend and earn bottom sheet appears
Then I see the title Spend and earn, description mentioning mUSD back and up to my vault APY when available, and the card image matches my card type (metal vs virtual)
Scenario: Confirm link still runs linkage after sheet dismiss
Given the link confirmation sheet is open
When I tap Link card on the sheet
Then the sheet closes and the in-app linkage flow continues (pending then success or error toast)
Scenario: No APY yet uses shorter description
Given vault APY is not yet available to the client
When I open the link confirmation sheet
Then the body copy does not include an APY clause and no raw i18n placeholders appear
Scenario: Spending Limit Spend and Earn promo (Refs #30320)
Given I open Spending Limit under conditions where the Spend and Earn promo applies
When I review the screen
Then I see the promo treatment and Money Account is preselected where configured
```
## **Screenshots/Recordings**
### **Before**
N/A
### **After**
https://github.com/user-attachments/assets/67079894-786e-4806-a6eb-fa4dd8ee4b3d
## **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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**
> Moderate risk: introduces new Money Account linking CTA paths and
conditional rendering on Card Home, plus updates toast/copy logic that
could affect linkage UX and user navigation if conditions are wrong.
>
> **Overview**
> Adds a **Money Account linking entry point on Card Home**: when
`useMoneyAccountCardLinkage().canLink` is true, Card Home renders a
`MoneyMetaMaskCard` “link” section (with dividers), passes live `apy`
from `useMoneyAccountBalance`, detects metal vs virtual via `CardType`,
and routes all presses to `startLinkFlow({ screen: Routes.CARD.HOME })`.
>
> Refines the **linking UX and copy**: `MoneyMetaMaskCard` now supports
`hideCardImage` (Card Home variant) and switches to *no-APY*
subtitle/bullets when APY is unavailable; `MoneyLinkCardSheet` swaps the
illustration to card art based on `selectCardHomeData` and uses no-APY
description when needed. Updates linkage toasts in
`useMoneyAccountCardLinkage` (single-line pending/success, new error
copy/icon/spinner color) and refreshes/enhances tests and i18n strings
accordingly.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3128d05c72355c8803ce89c8f3ffb3525beebb40. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../UI/Card/Views/CardHome/CardHome.test.tsx | 237 +++++++++++++++++-
.../Card/Views/CardHome/CardHome.testIds.ts | 2 +
.../UI/Card/Views/CardHome/CardHome.tsx | 38 +++
.../hooks/useMoneyAccountCardLinkage.test.tsx | 26 +-
.../Card/hooks/useMoneyAccountCardLinkage.tsx | 14 +-
.../MoneyLinkCardSheet.styles.ts | 7 +-
.../MoneyLinkCardSheet.test.tsx | 64 ++++-
.../MoneyLinkCardSheet/MoneyLinkCardSheet.tsx | 26 +-
.../MoneyMetaMaskCard.test.tsx | 89 +++++++
.../MoneyMetaMaskCard/MoneyMetaMaskCard.tsx | 128 ++++++----
locales/languages/en.json | 12 +-
11 files changed, 546 insertions(+), 97 deletions(-)
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
index bec350ef54dc..be42e7d32bd9 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
@@ -63,6 +63,7 @@ jest.mock('@tanstack/react-query', () => ({
}));
import { fireEvent, screen, waitFor } from '@testing-library/react-native';
+import { strings } from '../../../../../../locales/i18n';
import { Alert, Linking } from 'react-native';
import { useSelector } from 'react-redux';
import React from 'react';
@@ -95,6 +96,7 @@ import {
selectIsCardAuthenticated,
selectCardholderAccounts,
selectCardUserLocation,
+ selectCardHomeDataStatus,
} from '../../../../../selectors/cardController';
import { useIsSwapEnabledForPriorityToken } from '../../hooks/useIsSwapEnabledForPriorityToken';
import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts';
@@ -299,6 +301,37 @@ jest.mock('../../hooks/useIsSwapEnabledForPriorityToken', () => ({
useIsSwapEnabledForPriorityToken: jest.fn(),
}));
+const mockStartMoneyAccountLinkFlow = jest.fn();
+const mockUseMoneyAccountCardLinkage = jest.fn(() => ({
+ hasMoneyAccountRequirements: false,
+ isCardAuthenticated: false,
+ primaryMoneyAccount: undefined,
+ moneyAccountCardToken: null,
+ canLink: false,
+ status: 'idle' as const,
+ isLinking: false,
+ error: null,
+ startLinkFlow: mockStartMoneyAccountLinkFlow,
+ openLinkCardSheet: jest.fn(),
+ confirmLinkInBackground: jest.fn(),
+ reset: jest.fn(),
+}));
+
+jest.mock('../../hooks/useMoneyAccountCardLinkage', () => ({
+ __esModule: true,
+ useMoneyAccountCardLinkage: () => mockUseMoneyAccountCardLinkage(),
+ default: () => mockUseMoneyAccountCardLinkage(),
+}));
+
+const mockUseMoneyAccountBalance = jest.fn(() => ({
+ apyPercent: undefined as number | undefined,
+}));
+
+jest.mock('../../../Money/hooks/useMoneyAccountBalance', () => ({
+ __esModule: true,
+ default: () => mockUseMoneyAccountBalance(),
+}));
+
const mockFetchCardDetailsToken = jest.fn();
const mockClearCardDetailsImageUrl = jest.fn();
const mockOnCardDetailsImageLoad = jest.fn();
@@ -512,7 +545,7 @@ const mockIsSolanaChainId = isSolanaChainId as jest.MockedFunction<
>;
jest.mock('../../../../../../locales/i18n', () => ({
- strings: (key: string) => {
+ strings: (key: string, params?: Record) => {
const strings: { [key: string]: string } = {
'card.card_home.spending_with': 'Spending with',
'card.card_home.add_funds': 'Add funds',
@@ -583,8 +616,28 @@ jest.mock('../../../../../../locales/i18n', () => ({
'Earn 1% back on all spending',
'card.card_home.manage_card_options.cashback_description_metal':
'Earn 3% back on all spending',
+ 'money.metamask_card.link_title': 'Link MetaMask Card',
+ 'money.metamask_card.link_card': 'Link card',
+ 'money.metamask_card.link_subtitle_no_apy':
+ 'Spend your Money balance and earn on purchases.',
};
- return strings[key] || key;
+ const value = strings[key];
+ if (value) return value;
+ if (key === 'money.metamask_card.link_subtitle') {
+ const apy = (params as { apy?: number | string } | undefined)?.apy;
+ return `Spend your Money balance and earn on purchases. Plus, up to ${apy}% APY on your balance.`;
+ }
+ if (key === 'money.metamask_card.link_bullet_cashback') {
+ const percentage = (
+ params as { percentage?: number | string } | undefined
+ )?.percentage;
+ return `Get ${percentage}% mUSD back`;
+ }
+ if (key === 'money.metamask_card.link_bullet_apy') {
+ const apy = (params as { apy?: number | string } | undefined)?.apy;
+ return `Earn up to ${apy}% APY`;
+ }
+ return key;
},
}));
@@ -626,6 +679,7 @@ function setupMockSelectors(
isAuthenticated: boolean;
userLocation: 'us' | 'international';
isMetalCardCheckoutEnabled: boolean;
+ cardHomeDataStatus: 'idle' | 'loading' | 'success' | 'error';
}>,
) {
const defaults = {
@@ -638,6 +692,7 @@ function setupMockSelectors(
isAuthenticated: false,
userLocation: 'international' as const,
isMetalCardCheckoutEnabled: true,
+ cardHomeDataStatus: 'success' as const,
};
const config = { ...defaults, ...overrides };
@@ -652,6 +707,7 @@ function setupMockSelectors(
if (selector === selectCardholderAccounts) return config.cardholderAccounts;
if (selector === selectIsCardAuthenticated) return config.isAuthenticated;
if (selector === selectCardUserLocation) return config.userLocation;
+ if (selector === selectCardHomeDataStatus) return config.cardHomeDataStatus;
if (selector === selectMetalCardCheckoutFeatureFlag)
return config.isMetalCardCheckoutEnabled;
@@ -6178,4 +6234,181 @@ describe('CardHome Component', () => {
expect(screen.queryByTestId(CardHomeSelectors.LOGOUT_ITEM)).toBeNull();
});
});
+
+ describe('Link Money Account CTA', () => {
+ const setupLinkageMock = (
+ overrides: Partial<{ canLink: boolean }> = {},
+ ) => {
+ mockUseMoneyAccountCardLinkage.mockReturnValue({
+ hasMoneyAccountRequirements: true,
+ isCardAuthenticated: true,
+ primaryMoneyAccount: undefined,
+ moneyAccountCardToken: null,
+ canLink: true,
+ status: 'idle' as const,
+ isLinking: false,
+ error: null,
+ startLinkFlow: mockStartMoneyAccountLinkFlow,
+ openLinkCardSheet: jest.fn(),
+ confirmLinkInBackground: jest.fn(),
+ reset: jest.fn(),
+ ...overrides,
+ });
+ };
+
+ it('renders the CTA when canLink is true and cardHomeDataStatus is success', () => {
+ setupMockSelectors({ cardHomeDataStatus: 'success' });
+ setupLinkageMock();
+
+ render();
+
+ expect(
+ screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByText(strings('money.metamask_card.link_title')),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByText(strings('money.metamask_card.link_card')),
+ ).toBeOnTheScreen();
+ });
+
+ it('does not render the CTA when canLink is false', () => {
+ setupMockSelectors({ cardHomeDataStatus: 'success' });
+ setupLinkageMock({ canLink: false });
+
+ render();
+
+ expect(
+ screen.queryByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP),
+ ).not.toBeOnTheScreen();
+ expect(
+ screen.queryByText(strings('money.metamask_card.link_title')),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('keeps the CTA visible during a background refresh (stale-while-revalidate)', () => {
+ setupMockSelectors({ cardHomeDataStatus: 'loading' });
+ setupLinkageMock();
+
+ render();
+
+ expect(
+ screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls startLinkFlow with Routes.CARD.HOME when the Link card button is pressed', () => {
+ setupMockSelectors({ cardHomeDataStatus: 'success' });
+ setupLinkageMock();
+
+ render();
+
+ fireEvent.press(
+ screen.getByText(strings('money.metamask_card.link_card')),
+ );
+
+ expect(mockStartMoneyAccountLinkFlow).toHaveBeenCalledWith({
+ screen: Routes.CARD.HOME,
+ });
+ });
+
+ it('calls startLinkFlow when the title row (header) is pressed', () => {
+ setupMockSelectors({ cardHomeDataStatus: 'success' });
+ setupLinkageMock();
+
+ render();
+
+ fireEvent.press(
+ screen.getByText(strings('money.metamask_card.link_title')),
+ );
+
+ expect(mockStartMoneyAccountLinkFlow).toHaveBeenCalledWith({
+ screen: Routes.CARD.HOME,
+ });
+ });
+
+ it('advertises 1% mUSD back for virtual cardholders', () => {
+ setupMockSelectors({ cardHomeDataStatus: 'success' });
+ setupLinkageMock();
+ mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: 4 });
+
+ render();
+
+ expect(screen.getByText('Get 1% mUSD back')).toBeOnTheScreen();
+ expect(screen.queryByText('Get 3% mUSD back')).not.toBeOnTheScreen();
+ });
+
+ it('advertises 3% mUSD back for metal cardholders', () => {
+ setupMockSelectors({ cardHomeDataStatus: 'success' });
+ setupLinkageMock();
+ setupLoadCardDataMock();
+ (useCardHomeData as jest.Mock).mockReturnValue({
+ data: {
+ primaryFundingAsset: mockPrimaryFundingAsset,
+ fundingAssets: [mockPrimaryFundingAsset],
+ availableFundingAssets: [mockPrimaryFundingAsset],
+ card: {
+ id: 'card-123',
+ status: 'ACTIVE',
+ lastFour: '1234',
+ type: CardType.METAL,
+ },
+ account: null,
+ alerts: [],
+ actions: [{ type: 'add_funds', enabled: true }],
+ },
+ primaryToken: mockPrimaryAssetWithBalance,
+ availableTokens: [mockPrimaryAssetWithBalance],
+ fundingTokens: [mockPrimaryAssetWithBalance],
+ balanceMap: createMockAssetBalancesMap({
+ balanceFiat: '$1,000.00',
+ asset: { symbol: 'USDC', image: 'usdc-image-url' },
+ balanceFormatted: '1000.000000 USDC',
+ rawTokenBalance: 1000,
+ rawFiatNumber: 1000,
+ }),
+ isLoading: false,
+ isError: false,
+ refetch: mockRefetchAllData,
+ });
+ mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: 4 });
+
+ render();
+
+ expect(screen.getByText('Get 3% mUSD back')).toBeOnTheScreen();
+ expect(screen.queryByText('Get 1% mUSD back')).not.toBeOnTheScreen();
+ });
+
+ it('renders the no-APY subtitle and omits the APY bullet when apyPercent is undefined', () => {
+ setupMockSelectors({ cardHomeDataStatus: 'success' });
+ setupLinkageMock();
+ mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: undefined });
+
+ render();
+
+ expect(
+ screen.getByText(strings('money.metamask_card.link_subtitle_no_apy')),
+ ).toBeOnTheScreen();
+ expect(screen.queryByText(/Earn up to .* APY/)).not.toBeOnTheScreen();
+ });
+
+ it('interpolates apyPercent into the subtitle and APY bullet when defined', () => {
+ setupMockSelectors({ cardHomeDataStatus: 'success' });
+ setupLinkageMock();
+ mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: 4 });
+
+ render();
+
+ expect(
+ screen.getByText(
+ strings('money.metamask_card.link_subtitle', { apy: 4 }),
+ ),
+ ).toBeOnTheScreen();
+ expect(screen.getByText('Earn up to 4% APY')).toBeOnTheScreen();
+ });
+ });
});
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts
index cd3abde52a58..0283ba73f04f 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts
+++ b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts
@@ -31,4 +31,6 @@ export const CardHomeSelectors = {
FREEZE_CARD_TOGGLE: 'freeze-card-toggle',
VIEW_PIN_BUTTON: 'view-pin-button',
CARD_WALLET_ADDRESS: 'card-wallet-address',
+ LINK_MONEY_ACCOUNT_DIVIDER_TOP: 'link-money-account-divider-top',
+ LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM: 'link-money-account-divider-bottom',
};
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx
index 00891ee02431..42073cdd024c 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx
@@ -45,6 +45,9 @@ import { selectMetalCardCheckoutFeatureFlag } from '../../../../../selectors/fea
import { useIsSwapEnabledForPriorityToken } from '../../hooks/useIsSwapEnabledForPriorityToken';
import { useCardHomeData } from '../../hooks/useCardHomeData';
import { useCardCapabilities } from '../../hooks/useCardCapabilities';
+import { useMoneyAccountCardLinkage } from '../../hooks/useMoneyAccountCardLinkage';
+import useMoneyAccountBalance from '../../../Money/hooks/useMoneyAccountBalance';
+import MoneyMetaMaskCard from '../../../Money/components/MoneyMetaMaskCard';
import {
ToastContext,
ToastVariants,
@@ -55,6 +58,7 @@ import { CardScreenshotDeterrent } from '../../components/CardScreenshotDeterren
import AnimatedSpinner from '../../../AnimatedSpinner';
import Routes from '../../../../../constants/navigation/Routes';
import { TOKEN_RATE_UNDEFINED } from '../../../Tokens/constants';
+import { CardType } from '../../types';
import { isSpendingLimitSupportedToken } from '../../constants';
import { CardHomeSelectors } from './CardHome.testIds';
import CardAlertSection from './components/CardAlertSection';
@@ -110,6 +114,16 @@ const CardHome = () => {
const { initiateProvisioning, isProvisioning, canAddToWallet } =
useCardProvisioning(data);
+ // --- Money Account linkage ---
+ const { canLink: canLinkMoneyAccount, startLinkFlow: startMoneyAccountLink } =
+ useMoneyAccountCardLinkage();
+ const { apyPercent: moneyAccountApyPercent } = useMoneyAccountBalance();
+ const hasMetalCard = data?.card?.type === CardType.METAL;
+ const handleLinkMoneyAccountCard = useCallback(
+ () => startMoneyAccountLink({ screen: Routes.CARD.HOME }),
+ [startMoneyAccountLink],
+ );
+
useCardHomeAnalytics({
data,
isLoading,
@@ -370,6 +384,30 @@ const CardHome = () => {
)}
+ {canLinkMoneyAccount && (
+ <>
+
+
+
+
+
+ >
+ )}
+
{
expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledTimes(1);
expect(mockShowToast.mock.calls[0][0]).toMatchObject({
- labelOptions: [{ label: "Couldn't link card", isBold: true }],
+ labelOptions: [
+ { label: 'Something went wrong linking your card', isBold: true },
+ ],
});
});
@@ -654,7 +656,7 @@ describe('useMoneyAccountCardLinkage', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});
- it('shows the Predict-style pending toast with a Spinner startAccessory before the success toast', async () => {
+ it('shows the single-line pending toast with a Spinner startAccessory before the success toast', async () => {
let resolveLink: () => void = () => undefined;
mockLinkMoneyAccountCard.mockReturnValueOnce(
new Promise((resolve) => {
@@ -672,11 +674,7 @@ describe('useMoneyAccountCardLinkage', () => {
const pendingCall = mockShowToast.mock.calls[0]?.[0];
expect(pendingCall).toMatchObject({
hasNoTimeout: true,
- labelOptions: [
- { label: 'Linking card', isBold: true },
- { label: '\n', isBold: false },
- { label: 'Approving spending limit…', isBold: false },
- ],
+ labelOptions: [{ label: 'Linking your card', isBold: true }],
});
expect(pendingCall?.startAccessory).toBeDefined();
@@ -687,11 +685,7 @@ describe('useMoneyAccountCardLinkage', () => {
const successCall = mockShowToast.mock.calls[1]?.[0];
expect(successCall).toMatchObject({
- labelOptions: [
- { label: 'Card linked successfully', isBold: true },
- { label: '\n', isBold: false },
- { label: 'You can now spend while you earn', isBold: false },
- ],
+ labelOptions: [{ label: 'Your card is ready to use', isBold: true }],
hasNoTimeout: false,
});
});
@@ -747,7 +741,9 @@ describe('useMoneyAccountCardLinkage', () => {
const errorCall = mockShowToast.mock.calls.at(-1)?.[0];
expect(errorCall).toMatchObject({
- labelOptions: [{ label: "Couldn't link card", isBold: true }],
+ labelOptions: [
+ { label: 'Something went wrong linking your card', isBold: true },
+ ],
hasNoTimeout: false,
});
});
@@ -787,7 +783,9 @@ describe('useMoneyAccountCardLinkage', () => {
expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledTimes(1);
expect(mockShowToast.mock.calls[0][0]).toMatchObject({
- labelOptions: [{ label: "Couldn't link card", isBold: true }],
+ labelOptions: [
+ { label: 'Something went wrong linking your card', isBold: true },
+ ],
});
});
});
diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
index 49f33584d7ec..a6c6c8fb6d69 100644
--- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
+++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
@@ -138,18 +138,13 @@ export const useMoneyAccountCardLinkage =
label: strings('money.metamask_card.link_pending_title'),
isBold: true,
},
- { label: '\n', isBold: false },
- {
- label: strings('money.metamask_card.link_pending_description'),
- isBold: false,
- },
],
iconName: IconName.Loading,
hasNoTimeout: true,
startAccessory: (
@@ -165,11 +160,6 @@ export const useMoneyAccountCardLinkage =
label: strings('money.metamask_card.link_success_title'),
isBold: true,
},
- { label: '\n', isBold: false },
- {
- label: strings('money.metamask_card.link_success_description'),
- isBold: false,
- },
],
iconName: IconName.Confirmation,
iconColor: theme.colors.success.default,
@@ -183,7 +173,7 @@ export const useMoneyAccountCardLinkage =
labelOptions: [
{ label: strings('money.metamask_card.link_error'), isBold: true },
],
- iconName: IconName.Danger,
+ iconName: IconName.Error,
iconColor: theme.colors.error.default,
backgroundColor: theme.colors.error.muted,
hasNoTimeout: false,
diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.styles.ts b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.styles.ts
index a5dd4acbcac2..58d6d4363e68 100644
--- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.styles.ts
+++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.styles.ts
@@ -2,9 +2,10 @@ import { StyleSheet } from 'react-native';
const styleSheet = () =>
StyleSheet.create({
- illustration: {
- width: '100%',
- height: '100%',
+ cardImage: {
+ width: 150,
+ height: 95,
+ borderRadius: 5,
},
});
diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx
index cc10f7ed9159..63b3ff30a3e6 100644
--- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx
+++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx
@@ -6,9 +6,11 @@ import { MoneyLinkCardSheetTestIds } from './MoneyLinkCardSheet.testIds';
import { strings } from '../../../../../../locales/i18n';
import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
+import { selectCardHomeData } from '../../../../../selectors/cardController';
+import { CardType } from '../../../Card/types';
+import mmCardRegular from '../../../../../images/mm_card_regular.png';
+import mmCardMetal from '../../../../../images/mm_card_metal.png';
-// The real sheet ref invokes the post-close callback after the dismiss
-// animation. We bypass animation in tests by invoking the callback inline.
const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.());
const mockGoBack = jest.fn();
@@ -31,6 +33,10 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({
default: jest.fn(),
}));
+jest.mock('../../../../../selectors/cardController', () => ({
+ selectCardHomeData: jest.fn(),
+}));
+
jest.mock('@metamask/design-system-react-native', () => {
const actual = jest.requireActual('@metamask/design-system-react-native');
const ReactActual = jest.requireActual('react');
@@ -61,6 +67,7 @@ const mockUseMoneyAccountCardLinkage =
>;
const mockUseMoneyAccountBalance =
useMoneyAccountBalance as jest.MockedFunction;
+const mockSelectCardHomeData = selectCardHomeData as unknown as jest.Mock;
describe('MoneyLinkCardSheet', () => {
let mockConfirmLinkInBackground: jest.Mock;
@@ -74,6 +81,9 @@ describe('MoneyLinkCardSheet', () => {
mockUseMoneyAccountBalance.mockReturnValue({
apyPercent: 4,
} as unknown as ReturnType);
+ mockSelectCardHomeData.mockReturnValue({
+ card: { type: CardType.VIRTUAL },
+ });
});
it('renders the container', () => {
@@ -128,18 +138,62 @@ describe('MoneyLinkCardSheet', () => {
expect(queryByText(/{{apy}}/)).toBeNull();
});
- it('falls back to 0% APY while the vault APY query has not resolved yet', () => {
+ it('falls back to no-APY copy when the vault APY query has not resolved yet', () => {
mockUseMoneyAccountBalance.mockReturnValue({
apyPercent: undefined,
} as unknown as ReturnType);
- const { getByText } = renderWithProvider();
+ const { getByText, queryByText } = renderWithProvider(
+ ,
+ );
expect(
getByText(
- strings('money.metamask_card.link_card_sheet_description', { apy: 0 }),
+ strings('money.metamask_card.link_card_sheet_description_no_apy'),
),
).toBeOnTheScreen();
+ // The APY-bearing copy must not appear when there is no APY.
+ expect(queryByText(/APY/)).toBeNull();
+ });
+
+ describe('card illustration adapts to user card type', () => {
+ const getCardImageSource = (
+ root: ReturnType,
+ ) => {
+ const illustration = root.getByTestId(
+ MoneyLinkCardSheetTestIds.ILLUSTRATION,
+ );
+ const image = illustration.findByProps({ resizeMode: 'contain' });
+ return image.props.source;
+ };
+
+ it('renders the metal card image when the user has a metal card', () => {
+ mockSelectCardHomeData.mockReturnValue({
+ card: { type: CardType.METAL },
+ });
+
+ const root = renderWithProvider();
+
+ expect(getCardImageSource(root)).toBe(mmCardMetal);
+ });
+
+ it('renders the virtual card image when the user has a virtual card', () => {
+ mockSelectCardHomeData.mockReturnValue({
+ card: { type: CardType.VIRTUAL },
+ });
+
+ const root = renderWithProvider();
+
+ expect(getCardImageSource(root)).toBe(mmCardRegular);
+ });
+
+ it('renders the virtual card image when there is no card data available', () => {
+ mockSelectCardHomeData.mockReturnValue(null);
+
+ const root = renderWithProvider();
+
+ expect(getCardImageSource(root)).toBe(mmCardRegular);
+ });
});
it('dismisses the sheet and dispatches confirmLinkInBackground when the CTA is pressed', () => {
diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx
index 01a27b472890..a78923bb2cf0 100644
--- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx
+++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx
@@ -1,6 +1,7 @@
import React, { useCallback, useRef } from 'react';
import { Image } from 'react-native';
import { useNavigation } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
import {
BottomSheet,
BottomSheetFooter,
@@ -15,9 +16,12 @@ import {
} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import { useStyles } from '../../../../../component-library/hooks';
+import { selectCardHomeData } from '../../../../../selectors/cardController';
import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
-import musdCoinImage from '../../../../../images/mm_usd.png';
+import { CardType } from '../../../Card/types';
+import mmCardRegular from '../../../../../images/mm_card_regular.png';
+import mmCardMetal from '../../../../../images/mm_card_metal.png';
import styleSheet from './MoneyLinkCardSheet.styles';
import { MoneyLinkCardSheetTestIds } from './MoneyLinkCardSheet.testIds';
@@ -36,6 +40,8 @@ const MoneyLinkCardSheet = () => {
const { styles } = useStyles(styleSheet, {});
const { confirmLinkInBackground } = useMoneyAccountCardLinkage();
const { apyPercent } = useMoneyAccountBalance();
+ const cardHomeData = useSelector(selectCardHomeData);
+ const isMetalCard = cardHomeData?.card?.type === CardType.METAL;
const handleGoBack = useCallback(() => {
navigation.goBack();
@@ -51,6 +57,13 @@ const MoneyLinkCardSheet = () => {
});
}, [confirmLinkInBackground]);
+ const description =
+ apyPercent === undefined
+ ? strings('money.metamask_card.link_card_sheet_description_no_apy')
+ : strings('money.metamask_card.link_card_sheet_description', {
+ apy: apyPercent,
+ });
+
return (
{
onClose={handleClose}
closeButtonProps={{ testID: MoneyLinkCardSheetTestIds.CLOSE_BUTTON }}
/>
-
+
@@ -88,9 +100,7 @@ const MoneyLinkCardSheet = () => {
twClassName="text-center"
testID={MoneyLinkCardSheetTestIds.DESCRIPTION}
>
- {strings('money.metamask_card.link_card_sheet_description', {
- apy: apyPercent ?? 0,
- })}
+ {description}
diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx
index 2e13aad4a282..00fe8430e4c4 100644
--- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx
+++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx
@@ -217,6 +217,95 @@ describe('MoneyMetaMaskCard', () => {
fireEvent.press(getByText(strings('money.metamask_card.link_title')));
expect(mockHeader).toHaveBeenCalled();
});
+
+ describe('hideCardImage', () => {
+ it('does not render the card image when hideCardImage is true', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ queryByTestId(MoneyMetaMaskCardTestIds.LINK_CARD_IMAGE),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('still renders cashback / APY bullets and the Link card button when hideCardImage is true', () => {
+ const { getByTestId, getByText } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_CASHBACK),
+ ).toBeOnTheScreen();
+ expect(
+ getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY),
+ ).toBeOnTheScreen();
+ expect(getByText('Get 1% mUSD back')).toBeOnTheScreen();
+ expect(getByText('Earn up to 4% APY')).toBeOnTheScreen();
+ expect(
+ getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON),
+ ).toBeOnTheScreen();
+ });
+ });
+
+ describe('apy undefined (no-APY copy)', () => {
+ it('renders link_subtitle_no_apy when apy is undefined', () => {
+ const { getByText, queryByText } = render(
+ ,
+ );
+
+ expect(
+ getByText(strings('money.metamask_card.link_subtitle_no_apy')),
+ ).toBeOnTheScreen();
+ expect(
+ queryByText(strings('money.metamask_card.link_subtitle', { apy: 0 })),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('omits the APY bullet when apy is undefined', () => {
+ const { queryByTestId, getByTestId } = render(
+ ,
+ );
+
+ expect(
+ queryByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY),
+ ).not.toBeOnTheScreen();
+ expect(
+ getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_CASHBACK),
+ ).toBeOnTheScreen();
+ });
+
+ it('combines hideCardImage and apy undefined into the Card Home variant', () => {
+ const { getByText, queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ queryByTestId(MoneyMetaMaskCardTestIds.LINK_CARD_IMAGE),
+ ).not.toBeOnTheScreen();
+ expect(
+ queryByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY),
+ ).not.toBeOnTheScreen();
+ expect(
+ getByText(strings('money.metamask_card.link_subtitle_no_apy')),
+ ).toBeOnTheScreen();
+ expect(getByText('Get 1% mUSD back')).toBeOnTheScreen();
+ });
+ });
});
describe('mode="manage"', () => {
diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx
index bc2a93eccc75..b68fbe9adec8 100644
--- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx
+++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx
@@ -50,9 +50,16 @@ interface MoneyMetaMaskCardProps {
cardBalance?: string;
/**
* Live vault APY used to interpolate the link-mode subtitle and the APY
- * bullet. Falls back to 0 when undefined so the copy stays grammatical.
+ * bullet. When `undefined`, the component falls back to APY-less copy
+ * (drops the APY clause from the subtitle and omits the APY bullet).
*/
apy?: number;
+ /**
+ * Link mode only: when true, the card image is omitted and the bullets are
+ * stacked vertically. Used by Card Home where the card image is already
+ * shown elsewhere on the screen.
+ */
+ hideCardImage?: boolean;
}
const CardRow = ({
@@ -129,55 +136,80 @@ const LinkContent = ({
onLinkPress,
showMetalCard,
apy,
+ hideCardImage,
}: {
onLinkPress: () => void;
showMetalCard: boolean;
- apy: number;
-}) => (
-
-
- {strings('money.metamask_card.link_subtitle', { apy })}
-
-
-
-
-
-
-
+ apy: number | undefined;
+ hideCardImage: boolean;
+}) => {
+ const hasApy = apy !== undefined;
+ const subtitle = hasApy
+ ? strings('money.metamask_card.link_subtitle', { apy })
+ : strings('money.metamask_card.link_subtitle_no_apy');
+ const cashbackBullet = (
+
+ );
+ const apyBullet = hasApy ? (
+
+ ) : null;
+
+ return (
+
+
+ {subtitle}
+
+ {hideCardImage ? (
+
+ {cashbackBullet}
+ {apyBullet}
+
+ ) : (
+
+
+
+ {cashbackBullet}
+ {apyBullet}
+
+
+ )}
+
-
-
-);
+ );
+};
const ManageRow = ({
imageSource,
@@ -288,6 +320,7 @@ const MoneyMetaMaskCard = ({
showMetalCard = false,
cardBalance,
apy,
+ hideCardImage = false,
}: MoneyMetaMaskCardProps) => {
const handleLinkPress = useCallback(() => onLinkPress?.(), [onLinkPress]);
const handleManagePress = useCallback(
@@ -301,7 +334,8 @@ const MoneyMetaMaskCard = ({
);
} else if (mode === 'manage') {
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 0c8974d53982..9454c85af890 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -6745,16 +6745,16 @@
"get_now": "Get now",
"link_title": "Link MetaMask Card",
"link_subtitle": "Spend your Money balance and earn on purchases. Plus, up to {{apy}}% APY on your balance.",
+ "link_subtitle_no_apy": "Spend your Money balance and earn on purchases.",
"link_bullet_cashback": "Get {{percentage}}% mUSD back",
"link_bullet_apy": "Earn up to {{apy}}% APY",
"link_card": "Link card",
- "link_pending_title": "Linking card",
- "link_pending_description": "Approving spending limit…",
- "link_success_title": "Card linked successfully",
- "link_success_description": "You can now spend while you earn",
- "link_error": "Couldn't link card",
+ "link_pending_title": "Linking your card",
+ "link_success_title": "Your card is ready to use",
+ "link_error": "Something went wrong linking your card",
"link_card_sheet_title": "Spend and earn",
- "link_card_sheet_description": "Link your card so you can spend your Money balance while it earns {{apy}}% APY.",
+ "link_card_sheet_description": "Link your card so you can spend your Money balance and earn mUSD back on purchases—all while earning up to {{apy}}% APY.",
+ "link_card_sheet_description_no_apy": "Link your card so you can spend your Money balance and earn mUSD back on purchases.",
"link_card_sheet_cta": "Link card",
"manage_card": "Manage",
"avail_balance": "Avail. balance"
From 4adf4d4927d762ccbc9d191ae4591823eeddcf56 Mon Sep 17 00:00:00 2001
From: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com>
Date: Wed, 20 May 2026 21:18:15 +0200
Subject: [PATCH 03/12] refactor: remove unused FeatureAnnouncementToggle
[GE-244] (#30441)
## **Description**
Removes the Feature Announcements notification setting by deleting
FeatureAnnouncementToggle (and its test) and dropping the corresponding
useFeatureAnnouncementToggle hook.
Replaced by AUS notification preference in
https://github.com/MetaMask/metamask-mobile/pull/30106
## **Changelog**
CHANGELOG entry:
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/GE-244
## **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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**
> Low risk removal of unused UI toggle and associated hook/tests;
primary risk is any missed call sites leading to missing functionality,
but searches indicate no remaining references.
>
> **Overview**
> Removes the **Feature Announcements** notification setting by deleting
`FeatureAnnouncementToggle` (and its test) and dropping the
corresponding `useFeatureAnnouncementToggle` hook.
>
> Cleans up `useSwitchNotifications` and its tests by removing the
feature-announcement toggle flow (action/selector usage and list-refresh
behavior) so only the remaining notification/account toggle hooks are
exercised.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1b5ee038d53bf4fce7ac086a97c2fd58585128c8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../FeatureAnnouncementToggle.test.tsx | 69 -----------------
.../FeatureAnnouncementToggle.tsx | 25 ------
.../hooks/useSwitchNotifications.test.tsx | 77 -------------------
.../hooks/useSwitchNotifications.ts | 27 -------
4 files changed, 198 deletions(-)
delete mode 100644 app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.test.tsx
delete mode 100644 app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.tsx
diff --git a/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.test.tsx b/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.test.tsx
deleted file mode 100644
index 91e7581cc418..000000000000
--- a/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.test.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import React from 'react';
-import {
- render,
- fireEvent,
- waitFor,
- screen,
-} from '@testing-library/react-native';
-import { FeatureAnnouncementToggle } from './FeatureAnnouncementToggle';
-// eslint-disable-next-line import-x/no-namespace
-import * as UseSwitchNotificationsModule from '../../../../util/notifications/hooks/useSwitchNotifications';
-import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds';
-
-const arrangeMockMetrics = () => {
- const mockTrackEvent = jest.fn();
- const mockAddProperties = jest.fn();
- const mockCreateEventBuilder = jest.fn().mockReturnValue({
- addProperties: mockAddProperties.mockReturnThis(),
- build: jest.fn().mockReturnThis(),
- });
-
- return {
- mockTrackEvent,
- mockAddProperties,
- mockCreateEventBuilder,
- };
-};
-
-describe('FeatureAnnouncementToggle', () => {
- const arrangeMocks = () => {
- const mockSwitchFeatureAnnouncements = jest.fn();
- const mockUseFeatureAnnouncementToggle = jest
- .spyOn(UseSwitchNotificationsModule, 'useFeatureAnnouncementToggle')
- .mockReturnValue({
- data: true,
- switchFeatureAnnouncements: mockSwitchFeatureAnnouncements,
- });
-
- return {
- mockSwitchFeatureAnnouncements,
- mockUseFeatureAnnouncementToggle,
- ...arrangeMockMetrics(),
- };
- };
-
- it('renders correctly', () => {
- arrangeMocks();
- render();
- expect(
- screen.getByTestId(
- NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE,
- ),
- ).toBeTruthy();
- });
-
- it('toggles feature announcements', async () => {
- const mocks = arrangeMocks();
- render();
- const toggleSwitch = screen.getByTestId(
- NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE,
- );
-
- fireEvent(toggleSwitch, 'onChange', { nativeEvent: { value: false } });
-
- await waitFor(() => {
- // Assert new switch call
- expect(mocks.mockSwitchFeatureAnnouncements).toHaveBeenCalledWith(false);
- });
- });
-});
diff --git a/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.tsx b/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.tsx
deleted file mode 100644
index 78a2848e4e49..000000000000
--- a/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React, { useCallback } from 'react';
-import { useFeatureAnnouncementToggle } from '../../../../util/notifications/hooks/useSwitchNotifications';
-import CustomNotificationsRow from './CustomNotificationsRow';
-import { strings } from '../../../../../locales/i18n';
-import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds';
-
-export function FeatureAnnouncementToggle() {
- const { data: isEnabled, switchFeatureAnnouncements } =
- useFeatureAnnouncementToggle();
-
- const toggleCustomNotificationsEnabled = useCallback(async () => {
- await switchFeatureAnnouncements(!isEnabled);
- }, [isEnabled, switchFeatureAnnouncements]);
-
- return (
-
- );
-}
diff --git a/app/util/notifications/hooks/useSwitchNotifications.test.tsx b/app/util/notifications/hooks/useSwitchNotifications.test.tsx
index 914e4d0193af..b00c2bbe0873 100644
--- a/app/util/notifications/hooks/useSwitchNotifications.test.tsx
+++ b/app/util/notifications/hooks/useSwitchNotifications.test.tsx
@@ -10,7 +10,6 @@ import { renderHookWithProvider } from '../../test/renderWithProvider';
import * as UseNotificationsModule from './useNotifications';
import {
useAccountNotificationsToggle,
- useFeatureAnnouncementToggle,
useFetchAccountNotifications,
useNotificationsToggle,
useSwitchNotificationLoadingText,
@@ -81,82 +80,6 @@ describe('useSwitchNotifications - useNotificationsToggle', () => {
});
});
-describe('useSwitchNotifications - useFeatureAnnouncementToggle()', () => {
- const arrangeMocks = () => {
- const mockListNotifications = jest.fn();
- const mockUseListNotifications = jest
- .spyOn(UseNotificationsModule, 'useListNotifications')
- .mockReturnValue({
- error: null,
- isLoading: false,
- notificationsData: [],
- listNotifications: mockListNotifications,
- });
-
- const mockSelectIsEnabled = jest
- .spyOn(Selectors, 'selectIsMetamaskNotificationsEnabled')
- .mockReturnValue(true);
- const mockSelectIsFeatureAnnouncementsEnabled = jest
- .spyOn(Selectors, 'selectIsFeatureAnnouncementsEnabled')
- .mockReturnValue(true);
-
- const mockToggleFeatureAnnouncement = jest
- .spyOn(Actions, 'toggleFeatureAnnouncements')
- .mockImplementation(jest.fn());
-
- return {
- mockListNotifications,
- mockUseListNotifications,
- mockSelectIsEnabled,
- mockSelectIsFeatureAnnouncementsEnabled,
- mockToggleFeatureAnnouncement,
- };
- };
-
- type Mocks = ReturnType;
- const arrangeAct = async (val: boolean, mutateMocks?: (m: Mocks) => void) => {
- // Arrange
- const mocks = arrangeMocks();
- mutateMocks?.(mocks);
- const hook = renderHookWithProvider(() => useFeatureAnnouncementToggle());
-
- // Act
- await act(() => hook.result.current.switchFeatureAnnouncements(val));
-
- return { mocks, hook };
- };
-
- it('performs enable flow', async () => {
- const { mocks } = await arrangeAct(true);
- await waitFor(() =>
- expect(mocks.mockToggleFeatureAnnouncement).toHaveBeenCalledWith(true),
- );
- await waitFor(() => expect(mocks.mockListNotifications).toHaveBeenCalled());
- });
-
- it('performs disable flow', async () => {
- const { mocks } = await arrangeAct(false);
- await waitFor(() =>
- expect(mocks.mockToggleFeatureAnnouncement).toHaveBeenCalledWith(false),
- );
- await waitFor(() => expect(mocks.mockListNotifications).toHaveBeenCalled());
- });
-
- it('bails if notifications are not enabled', async () => {
- const { mocks } = await arrangeAct(true, (m) =>
- m.mockSelectIsEnabled.mockReturnValue(false),
- );
- await waitFor(() =>
- expect(mocks.mockToggleFeatureAnnouncement).not.toHaveBeenCalledWith(
- true,
- ),
- );
- await waitFor(() =>
- expect(mocks.mockListNotifications).not.toHaveBeenCalled(),
- );
- });
-});
-
describe('useSwitchNotifications - useFetchAccountNotifications()', () => {
const arrangeMocks = () => {
const mockSelectIsUpdatingMetamaskNotificationsAccount = jest
diff --git a/app/util/notifications/hooks/useSwitchNotifications.ts b/app/util/notifications/hooks/useSwitchNotifications.ts
index e0aea45b74d8..00aa20b480d7 100644
--- a/app/util/notifications/hooks/useSwitchNotifications.ts
+++ b/app/util/notifications/hooks/useSwitchNotifications.ts
@@ -5,12 +5,10 @@ import {
enableAccounts,
disableAccounts,
fetchAccountNotificationSettings,
- toggleFeatureAnnouncements,
} from '../../../actions/notification/helpers';
import { debounce } from 'lodash';
import {
- selectIsFeatureAnnouncementsEnabled,
selectIsMetamaskNotificationsEnabled,
selectIsMetaMaskPushNotificationsLoading,
selectIsUpdatingMetamaskNotifications,
@@ -51,31 +49,6 @@ export function useNotificationsToggle() {
};
}
-export function useFeatureAnnouncementToggle() {
- const { listNotifications } = useListNotifications();
- const isEnabled = useSelector(selectIsMetamaskNotificationsEnabled);
- const data = useSelector(selectIsFeatureAnnouncementsEnabled);
- const switchFeatureAnnouncements = useCallback(
- async (val: boolean) => {
- assertIsFeatureEnabled();
- if (!isEnabled) {
- return;
- }
-
- await toggleFeatureAnnouncements(val);
-
- // Refetch notifications
- debounce(listNotifications)();
- },
- [isEnabled, listNotifications],
- );
-
- return {
- data,
- switchFeatureAnnouncements,
- };
-}
-
export function useFetchAccountNotifications(accounts: string[]) {
const accountsBeingUpdated = useSelector(
selectIsUpdatingMetamaskNotificationsAccount,
From b6dc8ea40401c407af31565443fb5ab561812ce2 Mon Sep 17 00:00:00 2001
From: Salim TOUBAL
Date: Wed, 20 May 2026 21:59:50 +0100
Subject: [PATCH 04/12] feat: gate asset controller polling on unified assets
in hooks (#30477)
Move the feature-flag check from AssetPollingProvider into each polling
hook so polling no-ops with empty input when unified assets state is
off, while keeping hook call order stable for the provider.
## **Description**
## **Changelog**
CHANGELOG entry: gate asset controller polling on unified assets in
hooks
## **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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 when asset-related controllers start/stop polling by moving
unified-assets feature-flag gating into each polling hook; risk is
moderate because it affects background polling behavior and could change
network/load characteristics if the flag state is misinterpreted.
>
> **Overview**
> Moves unified-assets feature-flag gating from `AssetPollingProvider`
into each asset polling hook (`useCurrencyRatePolling`,
`useTokenRatesPolling`, `useTokenDetectionPolling`,
`useTokenBalancesPolling`, `useMultichainAssetsRatePolling`) by
resolving normal inputs and then passing an empty `input` to
`usePolling` when the flag is enabled.
>
> Updates `AssetPollingProvider` to always mount all polling hooks
(stable call order) and adjusts/adds tests to assert that polling does
**not** start when unified assets state is enabled, while provider tests
no longer cover the provider-level feature-flag branch.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ee35a16261d7e36d12f1b8c9671beb471e2436bc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../AssetPollingProvider.test.tsx | 25 +--------
.../AssetPolling/AssetPollingProvider.tsx | 30 ++--------
.../useCurrencyRatePolling.test.ts | 56 +++++++++++++++++++
.../AssetPolling/useCurrencyRatePolling.ts | 8 ++-
.../useMultichainAssetsRatePolling.ts | 10 +++-
.../useMultichanAssetsRatePolling.test.ts | 16 ++++++
.../useTokenBalancesPolling.test.ts | 27 +++++++++
.../AssetPolling/useTokenBalancesPolling.ts | 8 ++-
.../useTokenDetectionPolling.test.ts | 17 ++++++
.../AssetPolling/useTokenDetectionPolling.ts | 8 ++-
.../AssetPolling/useTokenRatesPolling.test.ts | 19 +++++++
.../AssetPolling/useTokenRatesPolling.ts | 9 ++-
12 files changed, 180 insertions(+), 53 deletions(-)
diff --git a/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx b/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx
index ca3bee4be551..6caacfa5d57b 100644
--- a/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx
+++ b/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx
@@ -1,6 +1,5 @@
import React from 'react';
import { render } from '@testing-library/react-native';
-import { useSelector } from 'react-redux';
import useCurrencyRatePolling from './useCurrencyRatePolling';
import useTokenRatesPolling from './useTokenRatesPolling';
import useTokenDetectionPolling from './useTokenDetectionPolling';
@@ -8,7 +7,6 @@ import useTokenBalancesPolling from './useTokenBalancesPolling';
import { AssetPollingProvider } from './AssetPollingProvider';
import useMultichainAssetsRatePolling from './useMultichainAssetsRatePolling';
-import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
import { selectSelectedInternalAccount } from '../../../selectors/accountsController';
jest.mock('react-redux', () => ({
@@ -17,10 +15,6 @@ jest.mock('react-redux', () => ({
),
}));
-jest.mock('../../../selectors/featureFlagController/assetsUnifyState', () => ({
- selectIsAssetsUnifyStateEnabled: jest.fn(),
-}));
-
jest.mock('./useCurrencyRatePolling', () => jest.fn());
jest.mock('./useTokenRatesPolling', () => jest.fn());
jest.mock('./useTokenDetectionPolling', () => jest.fn());
@@ -44,9 +38,6 @@ describe('AssetPollingProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
- (selectIsAssetsUnifyStateEnabled as unknown as jest.Mock).mockReturnValue(
- true,
- );
(selectSelectedInternalAccount as unknown as jest.Mock).mockReturnValue({
id: 'mock-account-id',
address: '0x123',
@@ -54,21 +45,7 @@ describe('AssetPollingProvider', () => {
});
});
- it('does not mount polling hooks when unified assets state is disabled', () => {
- (selectIsAssetsUnifyStateEnabled as unknown as jest.Mock).mockReturnValue(
- false,
- );
-
- render();
-
- expect(mockUseCurrencyRatePolling).not.toHaveBeenCalled();
- expect(mockUseTokenRatesPolling).not.toHaveBeenCalled();
- expect(mockUseTokenDetectionPolling).not.toHaveBeenCalled();
- expect(mockUseTokenBalancesPolling).not.toHaveBeenCalled();
- expect(mockUseMultichainAssetsRatePolling).not.toHaveBeenCalled();
- });
-
- it('calls all polling hooks when unified assets state is enabled', () => {
+ it('calls all polling hooks on render', () => {
render();
expect(mockUseCurrencyRatePolling).toHaveBeenCalledWith(undefined);
diff --git a/app/components/hooks/AssetPolling/AssetPollingProvider.tsx b/app/components/hooks/AssetPolling/AssetPollingProvider.tsx
index 2253914462a5..25ef9e6e01e0 100644
--- a/app/components/hooks/AssetPolling/AssetPollingProvider.tsx
+++ b/app/components/hooks/AssetPolling/AssetPollingProvider.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, memo } from 'react';
+import { useMemo, memo } from 'react';
import { Hex } from '@metamask/utils';
import { useSelector } from 'react-redux';
import useCurrencyRatePolling from './useCurrencyRatePolling';
@@ -7,18 +7,17 @@ import useTokenDetectionPolling from './useTokenDetectionPolling';
import useTokenBalancesPolling from './useTokenBalancesPolling';
import useMultichainAssetsRatePolling from './useMultichainAssetsRatePolling';
import { selectSelectedInternalAccount } from '../../../selectors/accountsController';
-import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
export interface AssetPollingProviderProps {
chainIds?: Hex[];
address?: Hex;
}
-/**
- * Controller polling hooks for the asset stack. Only mounted when unified
- * assets state is enabled so hooks are not called when that path is inactive.
- */
-const AssetPollingEnabledContent = memo((props: AssetPollingProviderProps) => {
+// This provider is a step towards making controller polling fully UI based.
+// Eventually, individual UI components will call the use*Polling hooks to
+// poll and return particular data. This polls globally in the meantime.
+// Each hook no-ops (empty polling input) when unified assets state is enabled.
+export const AssetPollingProvider = memo((props: AssetPollingProviderProps) => {
const { chainIds, address } = props;
const chainParams = useMemo(
@@ -45,21 +44,4 @@ const AssetPollingEnabledContent = memo((props: AssetPollingProviderProps) => {
return null;
});
-AssetPollingEnabledContent.displayName = 'AssetPollingEnabledContent';
-
-// This provider is a step towards making controller polling fully UI based.
-// Eventually, individual UI components will call the use*Polling hooks to
-// poll and return particular data. This polls globally in the meantime.
-export const AssetPollingProvider = memo((props: AssetPollingProviderProps) => {
- const isAssetsUnifyStateEnabled = useSelector(
- selectIsAssetsUnifyStateEnabled,
- );
-
- if (!isAssetsUnifyStateEnabled) {
- return null;
- }
-
- return ;
-});
-
AssetPollingProvider.displayName = 'AssetPollingProvider';
diff --git a/app/components/hooks/AssetPolling/useCurrencyRatePolling.test.ts b/app/components/hooks/AssetPolling/useCurrencyRatePolling.test.ts
index d2a2bc6df4c6..55c470c79d77 100644
--- a/app/components/hooks/AssetPolling/useCurrencyRatePolling.test.ts
+++ b/app/components/hooks/AssetPolling/useCurrencyRatePolling.test.ts
@@ -13,9 +13,16 @@ jest.mock('../../../core/Engine', () => ({
},
}));
+jest.mock('../../../selectors/featureFlagController/assetsUnifyState', () => ({
+ selectIsAssetsUnifyStateEnabled: jest.fn(),
+}));
+
+import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
+
describe('useCurrencyRatePolling', () => {
beforeEach(() => {
jest.clearAllMocks();
+ jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(false);
});
it('Should poll by the native currencies in network state', async () => {
@@ -78,6 +85,55 @@ describe('useCurrencyRatePolling', () => {
).toHaveBeenCalledWith({ nativeCurrencies: ['ETH', 'POL'] });
});
+ it('does not start polling when unified assets state is enabled', () => {
+ jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(true);
+
+ const state = {
+ engine: {
+ backgroundState: {
+ MultichainNetworkController: {
+ isEvmSelected: true,
+ selectedMultichainNetworkChainId: SolScope.Mainnet,
+ multichainNetworkConfigurationsByChainId: {},
+ },
+ NetworkController: {
+ selectedNetworkClientId: 'selectedNetworkClientId',
+ networkConfigurationsByChainId: {
+ '0x1': {
+ chainId: '0x1',
+ nativeCurrency: 'ETH',
+ rpcEndpoints: [
+ {
+ networkClientId: 'selectedNetworkClientId',
+ },
+ ],
+ defaultRpcEndpointIndex: 0,
+ },
+ },
+ },
+ PreferencesController: {
+ tokenNetworkFilter: {
+ '0x1': true,
+ },
+ },
+ NetworkEnablementController: {
+ enabledNetworkMap: {
+ eip155: {
+ '0x1': true,
+ },
+ },
+ },
+ },
+ },
+ } as unknown as RootState;
+
+ renderHookWithProvider(() => useCurrencyRatePolling(), { state });
+
+ expect(
+ jest.mocked(Engine.context.CurrencyRateController.startPolling),
+ ).not.toHaveBeenCalled();
+ });
+
it('should poll only for current network if selected one is not popular', async () => {
const state = {
engine: {
diff --git a/app/components/hooks/AssetPolling/useCurrencyRatePolling.ts b/app/components/hooks/AssetPolling/useCurrencyRatePolling.ts
index bca6f99a5c56..1669677e9a95 100644
--- a/app/components/hooks/AssetPolling/useCurrencyRatePolling.ts
+++ b/app/components/hooks/AssetPolling/useCurrencyRatePolling.ts
@@ -2,11 +2,16 @@ import { useSelector } from 'react-redux';
import { Hex } from '@metamask/utils';
import usePolling from '../usePolling';
import { selectEvmNetworkConfigurationsByChainId } from '../../../selectors/networkController';
+import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
import Engine from '../../../core/Engine';
import { usePollingNetworks } from './use-polling-networks';
// Polls native currency prices across networks.
const useCurrencyRatePolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => {
+ const isAssetsUnifyStateEnabled = useSelector(
+ selectIsAssetsUnifyStateEnabled,
+ );
+
// Selectors to determine polling input
const networkConfigurations = useSelector(
selectEvmNetworkConfigurationsByChainId,
@@ -43,7 +48,8 @@ const useCurrencyRatePolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => {
const { CurrencyRateController } = Engine.context;
- const input = overridePollingInput ?? pollingInput;
+ const resolvedInput = overridePollingInput ?? pollingInput;
+ const input = isAssetsUnifyStateEnabled ? [] : resolvedInput;
usePolling({
startPolling: CurrencyRateController.startPolling.bind(
diff --git a/app/components/hooks/AssetPolling/useMultichainAssetsRatePolling.ts b/app/components/hooks/AssetPolling/useMultichainAssetsRatePolling.ts
index 2db1e9c95298..0b1f14e5fe1f 100644
--- a/app/components/hooks/AssetPolling/useMultichainAssetsRatePolling.ts
+++ b/app/components/hooks/AssetPolling/useMultichainAssetsRatePolling.ts
@@ -1,13 +1,21 @@
+import { useSelector } from 'react-redux';
import usePolling from '../usePolling';
import Engine from '../../../core/Engine';
+import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
const useMultichainAssetsRatePolling = ({
accountId,
}: {
accountId: string;
}) => {
+ const isAssetsUnifyStateEnabled = useSelector(
+ selectIsAssetsUnifyStateEnabled,
+ );
+
const { MultichainAssetsRatesController } = Engine.context;
+ const input = isAssetsUnifyStateEnabled ? [] : [{ accountId }];
+
usePolling({
startPolling: MultichainAssetsRatesController.startPolling.bind(
MultichainAssetsRatesController,
@@ -16,7 +24,7 @@ const useMultichainAssetsRatePolling = ({
MultichainAssetsRatesController.stopPollingByPollingToken.bind(
MultichainAssetsRatesController,
),
- input: [{ accountId }],
+ input,
});
};
diff --git a/app/components/hooks/AssetPolling/useMultichanAssetsRatePolling.test.ts b/app/components/hooks/AssetPolling/useMultichanAssetsRatePolling.test.ts
index 6111a01d2f65..c9320f2ad89e 100644
--- a/app/components/hooks/AssetPolling/useMultichanAssetsRatePolling.test.ts
+++ b/app/components/hooks/AssetPolling/useMultichanAssetsRatePolling.test.ts
@@ -1,7 +1,12 @@
import { renderHook } from '@testing-library/react-hooks';
+import { useSelector } from 'react-redux';
import useMultichainAssetsRatePolling from './useMultichainAssetsRatePolling';
import Engine from '../../../core/Engine';
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
+
// Mock Engine with MultichainAssetsRatesController
jest.mock('../../../core/Engine', () => ({
context: {
@@ -20,6 +25,8 @@ describe('useMultichainAssetsRatePolling', () => {
// Reset all mocks before each test
jest.resetAllMocks();
+ jest.mocked(useSelector).mockReturnValue(false);
+
// Setup mock implementations
mockStartPolling.mockImplementation(() => 'mock-polling-token');
mockStopPollingByPollingToken.mockImplementation(() => undefined);
@@ -44,6 +51,15 @@ describe('useMultichainAssetsRatePolling', () => {
expect(mockStopPollingByPollingToken).not.toHaveBeenCalled();
});
+ it('does not start polling when unified assets state is enabled', () => {
+ jest.mocked(useSelector).mockReturnValue(true);
+ const accountId = 'test-account-id-123';
+
+ renderHook(() => useMultichainAssetsRatePolling({ accountId }));
+
+ expect(mockStartPolling).not.toHaveBeenCalled();
+ });
+
it('stops polling on unmount', () => {
// Arrange
const accountId = 'test-account-id-123';
diff --git a/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts b/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts
index 989ef7442799..9621faca3f5c 100644
--- a/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts
+++ b/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts
@@ -6,6 +6,7 @@ import { usePollingNetworks } from './use-polling-networks';
import { NetworkConfiguration } from '@metamask/network-controller';
import initialRootState from '../../../util/test/initial-root-state';
import { selectSelectedAccountGroupId } from '../../../selectors/multichainAccounts/accountTreeController';
+import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
@@ -51,6 +52,9 @@ const arrangeMocks = () => {
if (selector === selectSelectedAccountGroupId) {
return selector({});
}
+ if (selector === selectIsAssetsUnifyStateEnabled) {
+ return false;
+ }
return selector(initialRootState);
});
@@ -128,6 +132,29 @@ describe('useTokenBalancesPolling', () => {
jest.resetAllMocks();
});
+ describe('unified assets state gating', () => {
+ it('does not start polling when unified assets state is enabled', () => {
+ withNoPollingAssertions({
+ overrideMocks: () => {
+ jest.mocked(useSelector).mockImplementation((selector) => {
+ if (selector === selectIsAssetsUnifyStateEnabled) {
+ return true;
+ }
+ if (selector === selectSelectedAccountGroupId) {
+ return selector({});
+ }
+ return selector(initialRootState);
+ });
+ },
+ testFn: ({ mocks }) => {
+ expect(
+ mocks.mockTokenBalancesController.startPolling,
+ ).not.toHaveBeenCalled();
+ },
+ });
+ });
+ });
+
describe('Basic polling behavior', () => {
it.each([
{
diff --git a/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts b/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts
index 2aadc527ef3b..5d033024e6c8 100644
--- a/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts
+++ b/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts
@@ -4,8 +4,13 @@ import { Hex } from '@metamask/utils';
import { usePollingNetworks } from './use-polling-networks';
import { useSelector } from 'react-redux';
import { selectSelectedAccountGroupId } from '../../../selectors/multichainAccounts/accountTreeController';
+import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
const useTokenBalancesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => {
+ const isAssetsUnifyStateEnabled = useSelector(
+ selectIsAssetsUnifyStateEnabled,
+ );
+
const pollingNetworks = usePollingNetworks();
// Input to force polling to restart when selected account group changes
@@ -36,7 +41,8 @@ const useTokenBalancesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => {
const { TokenBalancesController } = Engine.context;
- const input = overridePollingInput ?? pollingInput;
+ const resolvedInput = overridePollingInput ?? pollingInput;
+ const input = isAssetsUnifyStateEnabled ? [] : resolvedInput;
usePolling({
startPolling: TokenBalancesController.startPolling.bind(
diff --git a/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts b/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts
index 871185fd0cf6..9bed49929b13 100644
--- a/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts
+++ b/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts
@@ -13,9 +13,16 @@ jest.mock('../../../core/Engine', () => ({
},
}));
+jest.mock('../../../selectors/featureFlagController/assetsUnifyState', () => ({
+ selectIsAssetsUnifyStateEnabled: jest.fn(),
+}));
+
+import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
+
describe('useTokenDetectionPolling', () => {
beforeEach(() => {
jest.resetAllMocks();
+ jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(false);
});
const selectedAddress = '0x1234567890abcdef';
@@ -117,6 +124,16 @@ describe('useTokenDetectionPolling', () => {
).toHaveBeenCalledTimes(1);
});
+ it('does not start polling when unified assets state is enabled', () => {
+ jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(true);
+
+ renderHookWithProvider(() => useTokenDetectionPolling(), { state });
+
+ expect(
+ jest.mocked(Engine.context.TokenDetectionController.startPolling),
+ ).not.toHaveBeenCalled();
+ });
+
it('Should not poll when token detection is disabled', async () => {
renderHookWithProvider(
() => useTokenDetectionPolling({ chainIds: ['0x1'] }),
diff --git a/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts b/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts
index 9cd34a89c1fc..20d87dbc58db 100644
--- a/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts
+++ b/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts
@@ -4,12 +4,16 @@ import Engine from '../../../core/Engine';
import { Hex } from '@metamask/utils';
import { selectSelectedInternalAccount } from '../../../selectors/accountsController';
import { selectUseTokenDetection } from '../../../selectors/preferencesController';
+import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
import { usePollingNetworks } from './use-polling-networks';
const useTokenDetectionPolling = ({
chainIds,
address,
}: { chainIds?: Hex[]; address?: Hex } = {}) => {
+ const isAssetsUnifyStateEnabled = useSelector(
+ selectIsAssetsUnifyStateEnabled,
+ );
const selectedAccount = useSelector(selectSelectedInternalAccount);
const useTokenDetection = useSelector(selectUseTokenDetection);
@@ -37,12 +41,14 @@ const useTokenDetectionPolling = ({
const { TokenDetectionController } = Engine.context;
- const input = useTokenDetection
+ const resolvedInput = useTokenDetection
? (overridePollingInput ?? pollingInput).filter(
(i) => i.chainIds && i.address,
)
: [];
+ const input = isAssetsUnifyStateEnabled ? [] : resolvedInput;
+
usePolling({
startPolling: TokenDetectionController.startPolling.bind(
TokenDetectionController,
diff --git a/app/components/hooks/AssetPolling/useTokenRatesPolling.test.ts b/app/components/hooks/AssetPolling/useTokenRatesPolling.test.ts
index c47a322b8788..9039638c5317 100644
--- a/app/components/hooks/AssetPolling/useTokenRatesPolling.test.ts
+++ b/app/components/hooks/AssetPolling/useTokenRatesPolling.test.ts
@@ -13,9 +13,16 @@ jest.mock('../../../core/Engine', () => ({
},
}));
+jest.mock('../../../selectors/featureFlagController/assetsUnifyState', () => ({
+ selectIsAssetsUnifyStateEnabled: jest.fn(),
+}));
+
+import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
+
describe('useTokenRatesPolling', () => {
beforeEach(() => {
jest.resetAllMocks();
+ jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(false);
});
const state = {
@@ -105,6 +112,18 @@ describe('useTokenRatesPolling', () => {
).toHaveBeenCalledTimes(1);
});
+ it('does not start polling when unified assets state is enabled', () => {
+ jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(true);
+
+ renderHookWithProvider(() => useTokenRatesPolling({ chainIds: ['0x1'] }), {
+ state,
+ });
+
+ expect(
+ jest.mocked(Engine.context.TokenRatesController.startPolling),
+ ).not.toHaveBeenCalled();
+ });
+
it('should poll only for current network if selected one is not popular', () => {
const stateToTest = {
engine: {
diff --git a/app/components/hooks/AssetPolling/useTokenRatesPolling.ts b/app/components/hooks/AssetPolling/useTokenRatesPolling.ts
index 5e78014bdb94..2081d0bb3de5 100644
--- a/app/components/hooks/AssetPolling/useTokenRatesPolling.ts
+++ b/app/components/hooks/AssetPolling/useTokenRatesPolling.ts
@@ -1,9 +1,15 @@
+import { useSelector } from 'react-redux';
import usePolling from '../usePolling';
import Engine from '../../../core/Engine';
import { Hex } from '@metamask/utils';
import { usePollingNetworks } from './use-polling-networks';
+import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState';
const useTokenRatesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => {
+ const isAssetsUnifyStateEnabled = useSelector(
+ selectIsAssetsUnifyStateEnabled,
+ );
+
const pollingNetworks = usePollingNetworks();
const pollingInput =
pollingNetworks.length > 0
@@ -17,7 +23,8 @@ const useTokenRatesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => {
const { TokenRatesController } = Engine.context;
- const input = overridePollingInput ?? pollingInput;
+ const resolvedInput = overridePollingInput ?? pollingInput;
+ const input = isAssetsUnifyStateEnabled ? [] : resolvedInput;
usePolling({
startPolling: TokenRatesController.startPolling.bind(TokenRatesController),
From c9cb162ed9b6a25d8c2bf675c539c61b18918ef1 Mon Sep 17 00:00:00 2001
From: Aslau Mario-Daniel
Date: Thu, 21 May 2026 00:43:02 +0300
Subject: [PATCH 05/12] feat(predict): Live Trade Activity Overlay reusable
Hook (#30415)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR delivers two related pieces of work for the Predict crypto
up/down details screen:
1. **A reusable `usePredictOrderbook` hook** that streams live
Polymarket orderbook depth into the existing `LivelineChart` so the
chart shows real bid/ask activity overlaid on the price line. The hook
is plumbed through the existing controller / provider / WebSocket stack
— no new APIs needed at the engine layer.
The data flow:
```mermaid
flowchart LR
Hook["usePredictOrderbook(tokenId)"] -->
Controller["PredictController.subscribeToOrderbook"]
Controller --> Provider["PolymarketProvider.subscribeToOrderbook"]
Provider --> WSManager["WebSocketManager.subscribeToOrderbook"]
Provider --> REST["getOrderBook (utils.ts)"]
REST -->|"seed first snapshot"| WSManager
WS["Polymarket market WS"] --> WSManager
Hook --> Chart["LivelineChart orderbook prop"]
```
The WebSocket manager handles per-token reference counting, a 250 ms
emit throttle to protect the RN↔WebView bridge, `book` event ingestion,
and opportunistic top-of-book pruning on `price_change` events. The REST
bootstrap runs at the provider layer (where the active CLOB protocol is
known) and seeds the cache before the first WS event arrives.
2. **Chart UX/UI polish** to match the Figma reference for `BTC Up or
Down`:
- Removed the oversized green/red price pill (badge) — only the orange
dot remains.
- Removed the 36 px dead space above the chart (`padding.top: 48 → 12`).
- Made the chart line extend closer to the y-axis labels
(`padding.right: 80 → 64`).
- Pushed time-axis labels above the WebView's bottom clip zone so they
actually render (`padding.bottom: 48 → 80`).
- Dropped trailing `.00` from the y-axis price labels per design
(whole-dollar formatter).
- Added a compact 12-hour `h:mm:ss` `formatTime` so the time-axis labels
fit the live 30s window without overlap-culling.
- Extracted `CRYPTO_UP_DOWN_FORMAT_VALUE` and
`CRYPTO_UP_DOWN_FORMAT_TIME` as named exports so the WebView-bridged
formatter bodies can be regression-tested with `it.each`.
The PR also includes a self-review pass that fixed:
- Stale orderbook state surviving WebSocket reconnects (cleared
`orderbookState` + throttle timers in `disconnectMarket`).
- Dead `error` field on the orderbook hook's public type.
- Empty-string `tokenId` slipping past the subscribe guard.
- Misleading comment vs. implementation for `applyTopOfBook` (it prunes,
doesn't splice).
- Vague "with correct props" test name renamed per the repo's
unit-testing guidelines.
- Dead `createMockChartRef` helper + 19 unused `chartRef` locals + 2
orphaned `appendPoint` assertions in `useCryptoUpDownChartData.test.ts`
(the hook signature dropped its `chartRef` arg upstream).
- Magic-number documentation for chart padding and chart-height bounds.
### Deferred to follow-ups (intentional)
- `patch-package` on `liveline` for: (a) orderbook depth fade threshold
(currently fades only in the top 45% — design wants midpoint), (b)
time-axis edge-fade reduction, (c) palette opacity bumps. All require
modifying `node_modules/liveline/dist/index.js` and rebundling via `yarn
build:liveline-webview`; tracked separately because they coordinate with
chart-team work.
- Imperative `setOrderbook` on `LivelineChartRef` to skip a full
`SET_PROPS` round-trip on every depth tick (perf optimization that needs
upstream `liveline` cooperation).
- i18n keys for `Price to beat` / `Current price` / `Target` (no
`predict.crypto_up_down` namespace exists yet — broader Predict-feature
concern).
## **Changelog**
CHANGELOG entry: Added live orderbook depth visualization and refined
the chart layout for Crypto Up/Down markets.
## **Related issues**
Refs:
## **Manual testing steps**
```gherkin
Feature: Crypto Up/Down details screen — live chart with orderbook depth
Scenario: user opens a live Crypto Up/Down market and sees the redesigned chart
Given the user has the Predict feature flag enabled
And the user has navigated to a live "BTC Up or Down" market
When the details screen finishes loading
Then the chart line renders with the orange dot at the current price
And no green or red price pill appears next to the dot
And the y-axis price labels render to the right of the chart without trailing ".00"
And there is no large empty band between the "Price to beat" header and the chart line
And time labels in "h:mm:ss" format render along the bottom of the chart, above the action buttons
And the dashed "Target" reference line renders at the target price
Scenario: user observes live orderbook depth on the chart
Given the user is on a live Crypto Up/Down market with active liquidity
When the user watches the chart for a few seconds
Then small green ($) labels for bids and red ($) labels for asks animate upward from the bottom-left of the chart
And the labels update within ~250 ms of new orderbook events
And on reconnect (e.g. toggle airplane mode briefly) the orderbook re-bootstraps without showing stale prices
Scenario: user navigates away and the chart cleans up
Given the chart is rendering with active orderbook depth
When the user taps the back button
Then the chart unmounts and the orderbook subscription is released
And no further WebSocket traffic for that token is observed in the network log
```
## **Screenshots/Recordings**
### **Before**
### **After**
Uploading upDown.mov…
## **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
- `usePredictOrderbook.test.ts` (new) — subscribe/unsubscribe lifecycle,
snapshot mapping, mount/unmount safety, connection polling.
- `WebSocketManager.test.ts` — extended with positive coverage for
`book` events, REST seed, reference counting, reconnect, throttle,
opportunistic TOB prune.
- `PolymarketProvider.test.ts` — `subscribeToOrderbook` REST bootstrap
success/failure.
- `PredictController.test.ts` — passthrough +
`MESSENGER_EXPOSED_METHODS` registration.
- `PredictCryptoUpDownChart.test.tsx` — orderbook wiring, prop
forwarding, `CRYPTO_UP_DOWN_FORMAT_VALUE` and
`CRYPTO_UP_DOWN_FORMAT_TIME` `it.each` regression suites.
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- JSDoc on `usePredictOrderbook`, `CRYPTO_UP_DOWN_FORMAT_VALUE`,
`CRYPTO_UP_DOWN_FORMAT_TIME`, `CHART_HEIGHT_VIEWPORT_FRACTION`, and the
new WebSocketManager orderbook methods.
- [ ] 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.
- Apply at PR creation time: `team-predict` (or the appropriate Predict
team label), `area-predict`. Add `needs-qa` if the on-device cadence of
orderbook depth needs review.
#### Performance checks (if applicable)
- [x] I've tested on Android
- Pending — primary development was iOS; should be smoke-tested on an
Android mid-range device because the WebView bridge cadence differs.
- [x] I've tested with a power user scenario
- Not applicable to the orderbook layer specifically; the chart is sized
for a single market view.
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- Not added in this PR. The WebSocketManager already logs orderbook
lifecycle events via `DevLogger`. If Sentry traces are desired on
`subscribeToOrderbook` REST + WS handshake timing, can add in a
follow-up.
## **Pre-merge reviewer checklist**
- [x] 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.
---
## Appendix — quality gate
| Check | Result |
|---|---|
| `yarn lint:tsc` | clean (0 errors) |
| `yarn jest` (focused Predict suites: chart, details, hooks,
WebSocketManager) | 197 / 197 tests pass |
| ESLint on changed files | 0 errors. 7 pre-existing warnings
(deprecated controller types, `runAfterInteractions`, an existing
`React` shadow in mocks) all predate this branch — not introduced here.
|
## Appendix — files touched (high level)
- New: `app/components/UI/Predict/hooks/usePredictOrderbook.ts` (+ test)
- Modified: `WebSocketManager.ts` (orderbook subscription map, throttle,
lifecycle), `PolymarketProvider.ts` (passthrough + REST seed),
`PredictController.ts` (passthrough + `MESSENGER_EXPOSED_METHODS`),
`types/index.ts` (`OrderbookLevel`/`Snapshot`/`Callback`),
`providers/types.ts` (optional method in interface).
- Chart UX: `PredictCryptoUpDownChart.tsx` (+ test),
`PredictCryptoUpDownDetails.tsx`, `useCryptoUpDownChartData.ts` (+
test).
---
> [!NOTE]
> **Medium Risk**
> Adds a new real-time orderbook subscription pipeline
(controller/provider/WebSocketManager) and feeds it into a
WebView-backed chart, which can impact live data correctness and
reconnect/unsubscribe behavior.
>
> **Overview**
> **Adds live orderbook depth streaming for Predict crypto Up/Down
charts.** Introduces a reusable `usePredictOrderbook` hook, exposes
`PredictController.subscribeToOrderbook`, and implements
`PolymarketProvider.subscribeToOrderbook` with a REST `getOrderBook`
bootstrap that seeds the WS cache.
>
> **Extends `WebSocketManager` to support `book` events.** Adds
per-token orderbook subscriptions with cached snapshot replay, REST
seeding, and a 250ms emit throttle; updates
unsubscribe/reconnect/cleanup logic and connection status to account for
orderbook subscribers.
>
> **Updates chart wiring and formatting.** `PredictCryptoUpDownChart`
now forwards `orderbook` into `LivelineChart`, adds a compact
`CRYPTO_UP_DOWN_FORMAT_TIME` formatter, and expands test coverage across
the new hook, controller/provider passthroughs, WS behavior, and
formatter regression cases; also tweaks Crypto Up/Down details chart
height calculation constant usage and fixes websocket mock tests to use
OS-assigned ports.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
33c3365f37714769cb14beb0d2a63a7a091b9585. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---------
Co-authored-by: ghgoodreau
Co-authored-by: Cursor
---
.../PredictCryptoUpDownChart.test.tsx | 132 ++++-
.../PredictCryptoUpDownChart.tsx | 44 +-
.../PredictCryptoUpDownDetails.tsx | 11 +-
.../PredictController-method-action-types.ts | 15 +
.../controllers/PredictController.test.ts | 37 ++
.../Predict/controllers/PredictController.ts | 22 +
app/components/UI/Predict/hooks/index.ts | 6 +
.../hooks/useCryptoUpDownChartData.test.ts | 201 ++++++--
.../Predict/hooks/usePredictOrderbook.test.ts | 181 +++++++
.../UI/Predict/hooks/usePredictOrderbook.ts | 74 +++
.../polymarket/PolymarketProvider.test.ts | 135 +++++
.../polymarket/PolymarketProvider.ts | 24 +
.../polymarket/WebSocketManager.test.ts | 481 +++++++++++++++++-
.../providers/polymarket/WebSocketManager.ts | 277 +++++++++-
app/components/UI/Predict/providers/types.ts | 7 +
app/components/UI/Predict/types/index.ts | 13 +
.../websocket/account-activity-mocks.test.ts | 11 +-
17 files changed, 1615 insertions(+), 56 deletions(-)
create mode 100644 app/components/UI/Predict/hooks/usePredictOrderbook.test.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictOrderbook.ts
diff --git a/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.test.tsx b/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.test.tsx
index cb2ae959c6dc..cff1ec16a63b 100644
--- a/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.test.tsx
+++ b/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.test.tsx
@@ -1,9 +1,11 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react-native';
import PredictCryptoUpDownChart, {
+ CRYPTO_UP_DOWN_FORMAT_TIME,
CRYPTO_UP_DOWN_FORMAT_VALUE,
} from './PredictCryptoUpDownChart';
import { useCryptoUpDownChartData } from '../../hooks/useCryptoUpDownChartData';
+import { usePredictOrderbook } from '../../hooks/usePredictOrderbook';
import {
Recurrence,
type PredictMarket,
@@ -14,6 +16,10 @@ jest.mock('../../hooks/useCryptoUpDownChartData', () => ({
useCryptoUpDownChartData: jest.fn(),
}));
+jest.mock('../../hooks/usePredictOrderbook', () => ({
+ usePredictOrderbook: jest.fn(),
+}));
+
jest.mock('../../../Charts/LivelineChart', () => {
const { View } = jest.requireActual('react-native');
const { forwardRef } = jest.requireActual('react');
@@ -61,6 +67,7 @@ const createMockMarket = (): PredictMarket & { series: PredictSeries } =>
describe('PredictCryptoUpDownChart', () => {
const mockUseCryptoUpDownChartData = useCryptoUpDownChartData as jest.Mock;
+ const mockUsePredictOrderbook = usePredictOrderbook as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
@@ -71,6 +78,11 @@ describe('PredictCryptoUpDownChart', () => {
isLive: true,
window: 300,
});
+ mockUsePredictOrderbook.mockReturnValue({
+ orderbook: null,
+ loading: false,
+ isConnected: false,
+ });
});
it('does not render LivelineChart when height is 0', () => {
@@ -81,7 +93,7 @@ describe('PredictCryptoUpDownChart', () => {
expect(screen.queryByTestId('mock-liveline-chart')).not.toBeOnTheScreen();
});
- it('renders LivelineChart with correct props when data is available and height is greater than 0', () => {
+ it('forwards chart configuration props to LivelineChart when chart data is available', () => {
const market = createMockMarket();
render();
@@ -107,6 +119,7 @@ describe('PredictCryptoUpDownChart', () => {
expect(chart.props.badge).toBe(true);
expect(chart.props.padding).toEqual({ top: 8, bottom: 48 });
expect(chart.props.formatValue).toBe(CRYPTO_UP_DOWN_FORMAT_VALUE);
+ expect(chart.props.formatTime).toBe(CRYPTO_UP_DOWN_FORMAT_TIME);
});
it('passes a custom chart color to LivelineChart', () => {
@@ -337,6 +350,92 @@ describe('PredictCryptoUpDownChart', () => {
expect(onCurrentPriceChange).not.toHaveBeenCalled();
});
+ describe('orderbook wiring', () => {
+ const marketWithYesToken = (): PredictMarket & { series: PredictSeries } =>
+ ({
+ ...createMockMarket(),
+ outcomes: [
+ {
+ id: 'outcome-1',
+ providerId: 'polymarket',
+ marketId: 'market-1',
+ title: 'Up',
+ description: '',
+ image: '',
+ status: 'open',
+ tokens: [
+ { id: 'yes-token-id', title: 'Up', price: 0.5, status: 'open' },
+ { id: 'no-token-id', title: 'Down', price: 0.5, status: 'open' },
+ ],
+ volume: 0,
+ groupItemTitle: '',
+ },
+ ],
+ }) as unknown as PredictMarket & { series: PredictSeries };
+
+ it("invokes usePredictOrderbook with the YES outcome token's id", () => {
+ const market = marketWithYesToken();
+
+ render();
+
+ expect(mockUsePredictOrderbook).toHaveBeenCalledWith('yes-token-id');
+ });
+
+ it('invokes usePredictOrderbook with undefined when the market has no outcomes', () => {
+ const market = createMockMarket();
+
+ render();
+
+ expect(mockUsePredictOrderbook).toHaveBeenCalledWith(undefined);
+ });
+
+ it('forwards the orderbook prop to LivelineChart when the hook returns data', () => {
+ const market = marketWithYesToken();
+ const orderbook = {
+ bids: [[0.45, 100] as [number, number]],
+ asks: [[0.55, 100] as [number, number]],
+ };
+ mockUsePredictOrderbook.mockReturnValue({
+ orderbook,
+ loading: false,
+ isConnected: true,
+ });
+
+ render();
+
+ const container = screen.getByTestId(
+ 'predict-crypto-up-down-chart-container',
+ );
+ fireEvent(container, 'layout', {
+ nativeEvent: { layout: { height: 300 } },
+ });
+
+ const chart = screen.getByTestId('mock-liveline-chart');
+ expect(chart.props.orderbook).toBe(orderbook);
+ });
+
+ it('passes orderbook=undefined to LivelineChart when the hook returns null', () => {
+ const market = marketWithYesToken();
+ mockUsePredictOrderbook.mockReturnValue({
+ orderbook: null,
+ loading: true,
+ isConnected: false,
+ });
+
+ render();
+
+ const container = screen.getByTestId(
+ 'predict-crypto-up-down-chart-container',
+ );
+ fireEvent(container, 'layout', {
+ nativeEvent: { layout: { height: 300 } },
+ });
+
+ const chart = screen.getByTestId('mock-liveline-chart');
+ expect(chart.props.orderbook).toBeUndefined();
+ });
+ });
+
describe('CRYPTO_UP_DOWN_FORMAT_VALUE', () => {
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
const formatValue = new Function('v', CRYPTO_UP_DOWN_FORMAT_VALUE) as (
@@ -346,6 +445,7 @@ describe('PredictCryptoUpDownChart', () => {
it.each([
[0, '$0.00'],
[0.05, '$0.05'],
+ [0.5, '$0.50'],
[1, '$1.00'],
[999.5, '$999.50'],
[1000, '$1,000.00'],
@@ -358,4 +458,34 @@ describe('PredictCryptoUpDownChart', () => {
expect(formatValue(input)).toBe(expected);
});
});
+
+ describe('CRYPTO_UP_DOWN_FORMAT_TIME', () => {
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
+ const formatTime = new Function('t', CRYPTO_UP_DOWN_FORMAT_TIME) as (
+ t: number,
+ ) => string;
+
+ // Tests are TZ-agnostic: inputs are constructed from local-time Date
+ // objects so the formatter's `getHours()` (local time) round-trips to
+ // the expected 12-hour `h:mm:ss` output regardless of the test
+ // machine's timezone.
+ const toUnixSeconds = (
+ year: number,
+ month: number,
+ day: number,
+ hours: number,
+ minutes: number,
+ seconds: number,
+ ) => new Date(year, month, day, hours, minutes, seconds).getTime() / 1000;
+
+ it.each([
+ ['midnight local', toUnixSeconds(2024, 0, 1, 0, 0, 0), '12:00:00'],
+ ['noon local', toUnixSeconds(2024, 0, 1, 12, 0, 0), '12:00:00'],
+ ['1:30:45 PM local', toUnixSeconds(2024, 0, 1, 13, 30, 45), '1:30:45'],
+ ['9:05:07 AM local', toUnixSeconds(2024, 0, 1, 9, 5, 7), '9:05:07'],
+ ['11:59:59 PM local', toUnixSeconds(2024, 0, 1, 23, 59, 59), '11:59:59'],
+ ])('formats %s as %p', (_label, input, expected) => {
+ expect(formatTime(input)).toBe(expected);
+ });
+ });
});
diff --git a/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.tsx b/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.tsx
index 20b37f378c24..318dfc21a650 100644
--- a/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.tsx
+++ b/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.tsx
@@ -1,19 +1,19 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { Box } from '@metamask/design-system-react-native';
-import {
- LivelineChart,
- type LivelineChartRef,
-} from '../../../Charts/LivelineChart';
+import { LivelineChart } from '../../../Charts/LivelineChart';
import { useCryptoUpDownChartData } from '../../hooks/useCryptoUpDownChartData';
+import { usePredictOrderbook } from '../../hooks/usePredictOrderbook';
import type { PredictCryptoUpDownChartProps } from './PredictCryptoUpDownChart.types';
/**
* USD currency formatter body for `LivelineChart` axis/tooltip values, e.g.
- * `1234567.89` → `"$1,234,567.89"`. Serialised as a JS function body string
- * because functions cannot cross the RN ↔ WebView JSON bridge — the WebView
- * reconstructs it via `new Function('v', CRYPTO_UP_DOWN_FORMAT_VALUE)`.
- * Exact output is locked by a regression test in
- * `PredictCryptoUpDownChart.test.tsx` since drift only surfaces on device.
+ * `1234567.89` → `"$1,234,567.89"`. Keeps two decimals to match the CTA
+ * price display on the details and feed cards (see PR #30342). Serialised
+ * as a JS function body string because functions cannot cross the RN ↔
+ * WebView JSON bridge — the WebView reconstructs it via
+ * `new Function('v', CRYPTO_UP_DOWN_FORMAT_VALUE)`. Exact output is locked
+ * by a regression test in `PredictCryptoUpDownChart.test.tsx` since drift
+ * only surfaces on device.
*/
export const CRYPTO_UP_DOWN_FORMAT_VALUE =
"const sign = v < 0 ? '-' : ''; " +
@@ -21,6 +21,19 @@ export const CRYPTO_UP_DOWN_FORMAT_VALUE =
"parts[0] = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','); " +
"return sign + '$' + parts.join('.')";
+/**
+ * 12-hour `h:mm:ss` time formatter body for `LivelineChart` time-axis
+ * labels (e.g. `8:48:30`). Compact enough to fit the live 30s window
+ * without label overlap. Same bridge constraint as
+ * `CRYPTO_UP_DOWN_FORMAT_VALUE`.
+ */
+export const CRYPTO_UP_DOWN_FORMAT_TIME =
+ 'const d = new Date(t * 1000); ' +
+ 'const h = d.getHours() % 12 || 12; ' +
+ "const m = String(d.getMinutes()).padStart(2, '0'); " +
+ "const s = String(d.getSeconds()).padStart(2, '0'); " +
+ "return h + ':' + m + ':' + s";
+
const PredictCryptoUpDownChart: React.FC = ({
market,
targetPrice,
@@ -28,7 +41,6 @@ const PredictCryptoUpDownChart: React.FC = ({
color = 'rgb(245, 158, 11)',
height: explicitHeight,
}) => {
- const chartRef = useRef(null);
const [measuredHeight, setMeasuredHeight] = useState(0);
const {
data,
@@ -37,6 +49,9 @@ const PredictCryptoUpDownChart: React.FC = ({
window: chartWindow,
} = useCryptoUpDownChartData(market, targetPrice);
+ const outcomeTokenId = market.outcomes?.[0]?.tokens?.[0]?.id;
+ const { orderbook } = usePredictOrderbook(outcomeTokenId);
+
const chartHeight = explicitHeight ?? measuredHeight;
// Override liveline's momentum so the price badge (and direction arrows) color
@@ -71,7 +86,6 @@ const PredictCryptoUpDownChart: React.FC = ({
>
{chartHeight > 0 && (
= ({
referenceLine={
targetPrice ? { value: targetPrice, label: 'Target' } : undefined
}
+ // Coalesce null → undefined so JSON.stringify in the WebView
+ // bridge omits the key entirely when there is no book yet. null
+ // would otherwise serialize and clobber any prior orderbook in
+ // the WebView.
+ orderbook={orderbook ?? undefined}
formatValue={CRYPTO_UP_DOWN_FORMAT_VALUE}
+ formatTime={CRYPTO_UP_DOWN_FORMAT_TIME}
/>
)}
diff --git a/app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx b/app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx
index 8d1bb5cf119d..a521619852a5 100644
--- a/app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx
+++ b/app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx
@@ -48,8 +48,14 @@ import PredictCryptoUpDownChart from '../PredictCryptoUpDownChart';
import PredictMarketDetailsActions from '../../views/PredictMarketDetails/components/PredictMarketDetailsActions';
import { useOpenOutcomes } from '../../views/PredictMarketDetails/hooks/useOpenOutcomes';
+// Chart sizing tuned for the Figma layout: the chart should occupy roughly
+// the middle half of the viewport so the dot stays centred, the price
+// summary stays visible above, and the action buttons stay visible below.
+// Bounds clamp the chart on very short (e.g. landscape) or very tall
+// (e.g. iPad) viewports.
const CHART_HEIGHT_MIN = 420;
const CHART_HEIGHT_MAX = 560;
+const CHART_HEIGHT_VIEWPORT_FRACTION = 0.55;
const MARKET_ROLLOVER_TIMEOUT_MAX_MS = 2_147_483_647;
const NOOP = () => undefined;
const DEFAULT_CRYPTO_ACCENT_COLOR = 'rgb(245, 158, 11)';
@@ -118,7 +124,10 @@ const PredictCryptoUpDownDetails: React.FC = ({
const { height: windowHeight } = useWindowDimensions();
const chartAreaHeight = Math.min(
CHART_HEIGHT_MAX,
- Math.max(CHART_HEIGHT_MIN, Math.round(windowHeight * 0.55)),
+ Math.max(
+ CHART_HEIGHT_MIN,
+ Math.round(windowHeight * CHART_HEIGHT_VIEWPORT_FRACTION),
+ ),
);
const [selectedMarket, setSelectedMarket] =
useState(market);
diff --git a/app/components/UI/Predict/controllers/PredictController-method-action-types.ts b/app/components/UI/Predict/controllers/PredictController-method-action-types.ts
index 9823b5e9a7fc..2f04135ef7a3 100644
--- a/app/components/UI/Predict/controllers/PredictController-method-action-types.ts
+++ b/app/components/UI/Predict/controllers/PredictController-method-action-types.ts
@@ -171,6 +171,20 @@ export type PredictControllerSubscribeToMarketPricesAction = {
handler: PredictController['subscribeToMarketPrices'];
};
+/**
+ * Subscribes to real-time orderbook (depth) updates for a single outcome
+ * token via WebSocket. The first emission is seeded from a REST snapshot by
+ * the provider so consumers render immediately.
+ *
+ * @param tokenId - The outcome token ID to subscribe to orderbook updates for
+ * @param callback - Function invoked with each OrderbookSnapshot (bids desc, asks asc)
+ * @returns Unsubscribe function to clean up the subscription
+ */
+export type PredictControllerSubscribeToOrderbookAction = {
+ type: `PredictController:subscribeToOrderbook`;
+ handler: PredictController['subscribeToOrderbook'];
+};
+
/**
* Subscribes to real-time crypto price updates via RTDS WebSocket.
*
@@ -314,6 +328,7 @@ export type PredictControllerMethodActions =
| PredictControllerRefreshEligibilityAction
| PredictControllerSubscribeToGameUpdatesAction
| PredictControllerSubscribeToMarketPricesAction
+ | PredictControllerSubscribeToOrderbookAction
| PredictControllerSubscribeToCryptoPricesAction
| PredictControllerGetConnectionStatusAction
| PredictControllerClearOrderErrorAction
diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts
index db0e18d1ba2d..8e78b6a84235 100644
--- a/app/components/UI/Predict/controllers/PredictController.test.ts
+++ b/app/components/UI/Predict/controllers/PredictController.test.ts
@@ -8041,6 +8041,43 @@ describe('PredictController', () => {
});
});
+ describe('subscribeToOrderbook', () => {
+ it('delegates to provider and returns unsubscribe function', () => {
+ withController(({ controller }) => {
+ const mockUnsubscribe = jest.fn();
+ const mockCallback = jest.fn();
+ mockPolymarketProvider.subscribeToOrderbook = jest
+ .fn()
+ .mockReturnValue(mockUnsubscribe);
+
+ const unsubscribe = controller.subscribeToOrderbook(
+ 'token1',
+ mockCallback,
+ );
+
+ expect(
+ mockPolymarketProvider.subscribeToOrderbook,
+ ).toHaveBeenCalledWith('token1', mockCallback);
+ expect(unsubscribe).toBe(mockUnsubscribe);
+ });
+ });
+
+ it('returns no-op function when provider lacks method', () => {
+ withController(({ controller }) => {
+ delete (mockPolymarketProvider as { subscribeToOrderbook?: unknown })
+ .subscribeToOrderbook;
+
+ const unsubscribe = controller.subscribeToOrderbook(
+ 'token1',
+ jest.fn(),
+ );
+
+ expect(unsubscribe).toBeDefined();
+ expect(unsubscribe()).toBeUndefined();
+ });
+ });
+ });
+
describe('subscribeToCryptoPrices', () => {
it('delegates to provider and returns unsubscribe function', () => {
withController(({ controller }) => {
diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts
index 75c01f2891cf..b6c9346a72f6 100644
--- a/app/components/UI/Predict/controllers/PredictController.ts
+++ b/app/components/UI/Predict/controllers/PredictController.ts
@@ -98,6 +98,7 @@ import {
PrepareWithdrawParams,
PreviewOrderParams,
PriceUpdateCallback,
+ OrderbookCallback,
Result,
SearchMarketsParams,
Side,
@@ -385,6 +386,7 @@ const MESSENGER_EXPOSED_METHODS = [
'subscribeToCryptoPrices',
'subscribeToGameUpdates',
'subscribeToMarketPrices',
+ 'subscribeToOrderbook',
'trackActivityViewed',
'trackBannerAction',
'trackBetslipDismissed',
@@ -1747,6 +1749,26 @@ export class PredictController extends BaseController<
return provider.subscribeToMarketPrices(tokenIds, callback);
}
+ /**
+ * Subscribes to real-time orderbook (depth) updates for a single outcome
+ * token via WebSocket. The first emission is seeded from a REST snapshot by
+ * the provider so consumers render immediately.
+ *
+ * @param tokenId - The outcome token ID to subscribe to orderbook updates for
+ * @param callback - Function invoked with each OrderbookSnapshot (bids desc, asks asc)
+ * @returns Unsubscribe function to clean up the subscription
+ */
+ public subscribeToOrderbook(
+ tokenId: string,
+ callback: OrderbookCallback,
+ ): () => void {
+ const provider = this.provider;
+ if (!provider?.subscribeToOrderbook) {
+ return () => undefined;
+ }
+ return provider.subscribeToOrderbook(tokenId, callback);
+ }
+
/**
* Subscribes to real-time crypto price updates via RTDS WebSocket.
*
diff --git a/app/components/UI/Predict/hooks/index.ts b/app/components/UI/Predict/hooks/index.ts
index 96ddc3ac68c9..e406c29deb92 100644
--- a/app/components/UI/Predict/hooks/index.ts
+++ b/app/components/UI/Predict/hooks/index.ts
@@ -17,6 +17,12 @@ export {
type UseLiveMarketPricesResult,
} from './useLiveMarketPrices';
+export {
+ usePredictOrderbook,
+ type UsePredictOrderbookOptions,
+ type UsePredictOrderbookResult,
+} from './usePredictOrderbook';
+
export {
usePredictTabs,
type FeedTab,
diff --git a/app/components/UI/Predict/hooks/useCryptoUpDownChartData.test.ts b/app/components/UI/Predict/hooks/useCryptoUpDownChartData.test.ts
index 90286066a2bb..a82d5c70b90b 100644
--- a/app/components/UI/Predict/hooks/useCryptoUpDownChartData.test.ts
+++ b/app/components/UI/Predict/hooks/useCryptoUpDownChartData.test.ts
@@ -3,10 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCryptoUpDownChartData } from './useCryptoUpDownChartData';
import type { CryptoPriceUpdate, PredictMarket, PredictSeries } from '../types';
-import type {
- LivelineChartRef,
- LivelinePoint,
-} from '../../Charts/LivelineChart/LivelineChart.types';
+import type { LivelinePoint } from '../../Charts/LivelineChart/LivelineChart.types';
const mockCryptoPriceHistoryOptions = jest.fn();
const mockUseLiveCryptoPrices = jest.fn();
@@ -82,13 +79,6 @@ const createMarket = (overrides: Partial = {}): TestMarket => ({
...overrides,
});
-const createMockChartRef = () => ({
- current: {
- appendPoint: jest.fn(),
- clearData: jest.fn(),
- } as LivelineChartRef,
-});
-
describe('useCryptoUpDownChartData', () => {
let liveUpdateHandler: ((update: CryptoPriceUpdate) => void) | undefined;
let historicalData: LivelinePoint[];
@@ -149,7 +139,6 @@ describe('useCryptoUpDownChartData', () => {
it('returns loading true when no live data has arrived', () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
wrapper: Wrapper,
@@ -163,7 +152,6 @@ describe('useCryptoUpDownChartData', () => {
it('adds live data points to the returned chart data', () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [];
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
@@ -183,7 +171,6 @@ describe('useCryptoUpDownChartData', () => {
});
});
- expect(chartRef.current.appendPoint).not.toHaveBeenCalled();
expect(result.current.data).toEqual([
{ time: 100, value: 51000 },
{ time: 110, value: 51500 },
@@ -195,7 +182,6 @@ describe('useCryptoUpDownChartData', () => {
it('preserves second-based live timestamps', () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [];
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
@@ -216,7 +202,6 @@ describe('useCryptoUpDownChartData', () => {
it('converts millisecond-based live timestamps to fractional seconds', () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [];
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
@@ -240,7 +225,6 @@ describe('useCryptoUpDownChartData', () => {
jest.setSystemTime(new Date(1700000000000));
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [];
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
@@ -269,10 +253,32 @@ describe('useCryptoUpDownChartData', () => {
]);
});
+ it('parses millisecond timestamps from live updates', () => {
+ jest.setSystemTime(new Date(1700000000000));
+ const { Wrapper } = createWrapper();
+ const market = createMarket();
+ historicalData = [];
+
+ const { result } = renderHook(() => useCryptoUpDownChartData(market), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ liveUpdateHandler?.({
+ symbol: 'btcusdt',
+ price: 51000,
+ timestamp: 1700000000123,
+ });
+ });
+
+ expect(result.current.data).toEqual([
+ { time: 1700000000.123, value: 51000 },
+ ]);
+ });
+
it('evicts live points outside the 30-second chart retention buffer', () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [];
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
@@ -312,7 +318,6 @@ describe('useCryptoUpDownChartData', () => {
id: 'market-2',
endDate: '2025-12-31T23:59:59.000Z',
});
- const chartRef = createMockChartRef();
historicalData = [];
const { result, rerender } = renderHook(
@@ -343,7 +348,6 @@ describe('useCryptoUpDownChartData', () => {
const market = createMarket({
endDate: '2026-01-01T00:00:30.000Z',
});
- const chartRef = createMockChartRef();
historicalData = [];
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
@@ -409,7 +413,6 @@ describe('useCryptoUpDownChartData', () => {
id: 'market-2',
endDate: '2026-01-01T00:00:30.000Z',
});
- const chartRef = createMockChartRef();
historicalData = [];
const { result, rerender } = renderHook(
@@ -439,7 +442,6 @@ describe('useCryptoUpDownChartData', () => {
it('seeds live mode with historical data before live updates arrive', async () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
wrapper: Wrapper,
@@ -461,7 +463,6 @@ describe('useCryptoUpDownChartData', () => {
recurrence: '4h',
},
});
- const chartRef = createMockChartRef();
const { result } = renderHook(
() =>
@@ -598,10 +599,154 @@ describe('useCryptoUpDownChartData', () => {
});
});
+ it('returns historical recurrence-window data without subscribing when live updates are disabled', async () => {
+ const { Wrapper } = createWrapper();
+ const market = createMarket({
+ series: {
+ id: 'series-4h',
+ slug: 'btc-series-4h',
+ title: 'BTC Series 4h',
+ recurrence: '4h',
+ },
+ });
+ const { result } = renderHook(
+ () =>
+ useCryptoUpDownChartData(market, undefined, {
+ liveUpdatesEnabled: false,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.data).toEqual(historicalData);
+ });
+ expect(result.current.isLive).toBe(false);
+ expect(result.current.window).toBe(14400);
+ expect(mockUseLiveCryptoPrices).toHaveBeenLastCalledWith(
+ '',
+ expect.any(Function),
+ );
+ });
+
+ it('can fetch a historical coin lookback window independent of the live market start', async () => {
+ const { Wrapper } = createWrapper();
+ const market = createMarket({
+ id: 'new-market',
+ endDate: '2026-01-01T00:05:00.000Z',
+ });
+
+ renderHook(
+ () =>
+ useCryptoUpDownChartData(market, undefined, {
+ liveUpdatesEnabled: false,
+ historicalWindow: {
+ startDate: '2025-12-31T23:55:00.000Z',
+ endDate: '2026-01-01T00:00:00.000Z',
+ },
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(mockCryptoPriceHistoryOptions).toHaveBeenCalledWith({
+ symbol: 'BTC',
+ eventStartTime: '2025-12-31T23:55:00.000Z',
+ variant: 'fiveminute',
+ endDate: '2026-01-01T00:00:00.000Z',
+ });
+ });
+
+ it('keeps prior real coin history visible during live market rollover refetch', async () => {
+ const { Wrapper } = createWrapper();
+ const market = createMarket();
+ const nextMarket = createMarket({
+ id: 'next-market',
+ endDate: '2026-01-01T00:05:30.000Z',
+ });
+ const previousCoinHistory = [
+ { time: 100, value: 50000 },
+ { time: 200, value: 51000 },
+ ];
+ let resolveNextQuery: ((value: LivelinePoint[]) => void) | undefined;
+
+ historicalData = previousCoinHistory;
+
+ const { result, rerender } = renderHook(
+ ({
+ activeMarket,
+ historicalWindow,
+ }: {
+ activeMarket: TestMarket;
+ historicalWindow: { startDate: string; endDate: string };
+ }) =>
+ useCryptoUpDownChartData(activeMarket, undefined, {
+ liveUpdatesEnabled: false,
+ historicalWindow,
+ }),
+ {
+ initialProps: {
+ activeMarket: market,
+ historicalWindow: {
+ startDate: '2025-12-31T23:55:00.000Z',
+ endDate: '2026-01-01T00:00:00.000Z',
+ },
+ },
+ wrapper: Wrapper,
+ },
+ );
+
+ await waitFor(() => {
+ expect(result.current.data).toEqual(previousCoinHistory);
+ });
+
+ mockCryptoPriceHistoryOptions.mockImplementationOnce(
+ ({
+ symbol,
+ eventStartTime,
+ variant,
+ endDate,
+ }: {
+ symbol: string;
+ eventStartTime: string;
+ variant: string;
+ endDate?: string;
+ }) => ({
+ queryKey: [
+ 'predict',
+ 'cryptoPriceHistory',
+ symbol,
+ eventStartTime,
+ variant,
+ endDate ?? '',
+ 'pending-rollover',
+ ],
+ queryFn: () =>
+ new Promise((resolve) => {
+ resolveNextQuery = resolve;
+ }),
+ }),
+ );
+
+ rerender({
+ activeMarket: nextMarket,
+ historicalWindow: {
+ startDate: '2026-01-01T00:00:00.000Z',
+ endDate: '2026-01-01T00:05:00.000Z',
+ },
+ });
+
+ expect(result.current.data).toEqual(previousCoinHistory);
+
+ await act(async () => {
+ resolveNextQuery?.([
+ { time: 300, value: 52000 },
+ { time: 400, value: 53000 },
+ ]);
+ });
+ });
+
it('keeps historical data available after live updates arrive', async () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
wrapper: Wrapper,
@@ -629,7 +774,6 @@ describe('useCryptoUpDownChartData', () => {
it('falls back to the target price at event start when history is unavailable', () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [];
mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z');
@@ -656,7 +800,6 @@ describe('useCryptoUpDownChartData', () => {
it('does not draw an assumed target-to-live line when opened late without history', () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [];
mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z');
@@ -687,7 +830,6 @@ describe('useCryptoUpDownChartData', () => {
it('does not draw a target fallback after a pre-start live point', () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [];
mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z');
@@ -710,7 +852,6 @@ describe('useCryptoUpDownChartData', () => {
it('keeps the target price fallback if target price later becomes unavailable', () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [];
mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z');
const initialProps: { targetPrice?: number } = { targetPrice: 50000 };
@@ -743,7 +884,6 @@ describe('useCryptoUpDownChartData', () => {
it('uses target price fallback with partial historical data', async () => {
const { Wrapper } = createWrapper();
const market = createMarket();
- const chartRef = createMockChartRef();
historicalData = [{ time: 105, value: 50100 }];
mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z');
@@ -763,7 +903,6 @@ describe('useCryptoUpDownChartData', () => {
it('records the update that freezes live data once the end date passes', () => {
const { Wrapper } = createWrapper();
const market = createMarket({ endDate: '2026-01-01T00:00:05.000Z' });
- const chartRef = createMockChartRef();
historicalData = [];
const { result } = renderHook(() => useCryptoUpDownChartData(market), {
@@ -792,7 +931,6 @@ describe('useCryptoUpDownChartData', () => {
});
});
- expect(chartRef.current.appendPoint).not.toHaveBeenCalled();
expect(result.current.data).toEqual([
{ time: 100, value: 50000 },
{ time: 110, value: 52000 },
@@ -802,7 +940,6 @@ describe('useCryptoUpDownChartData', () => {
it('keeps live data when the end date passes before the next live update', () => {
const { Wrapper } = createWrapper();
const market = createMarket({ endDate: '2026-01-01T00:00:05.000Z' });
- const chartRef = createMockChartRef();
historicalData = [];
const { result, rerender } = renderHook(
diff --git a/app/components/UI/Predict/hooks/usePredictOrderbook.test.ts b/app/components/UI/Predict/hooks/usePredictOrderbook.test.ts
new file mode 100644
index 000000000000..d232fa409502
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictOrderbook.test.ts
@@ -0,0 +1,181 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { usePredictOrderbook } from './usePredictOrderbook';
+import Engine from '../../../../core/Engine';
+import type { OrderbookCallback, OrderbookSnapshot } from '../types';
+
+jest.mock('../../../../core/Engine', () => ({
+ context: {
+ PredictController: {
+ subscribeToOrderbook: jest.fn(),
+ },
+ },
+}));
+
+const buildSnapshot = (
+ overrides: Partial = {},
+): OrderbookSnapshot => ({
+ tokenId: 'token1',
+ bids: [
+ { price: 0.5, size: 100 },
+ { price: 0.45, size: 50 },
+ ],
+ asks: [
+ { price: 0.55, size: 80 },
+ { price: 0.6, size: 30 },
+ ],
+ timestamp: 1700000000,
+ ...overrides,
+});
+
+describe('usePredictOrderbook', () => {
+ const mockSubscribeToOrderbook = Engine.context.PredictController
+ .subscribeToOrderbook as jest.Mock;
+ const mockUnsubscribe = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSubscribeToOrderbook.mockReturnValue(mockUnsubscribe);
+ });
+
+ describe('subscription management', () => {
+ it('subscribes via the PredictController when a tokenId is provided', () => {
+ renderHook(() => usePredictOrderbook('token1'));
+
+ expect(mockSubscribeToOrderbook).toHaveBeenCalledWith(
+ 'token1',
+ expect.any(Function),
+ );
+ });
+
+ it('does not subscribe when tokenId is undefined', () => {
+ renderHook(() => usePredictOrderbook(undefined));
+
+ expect(mockSubscribeToOrderbook).not.toHaveBeenCalled();
+ });
+
+ it('does not subscribe when enabled is false', () => {
+ renderHook(() => usePredictOrderbook('token1', { enabled: false }));
+
+ expect(mockSubscribeToOrderbook).not.toHaveBeenCalled();
+ });
+
+ it('unsubscribes on unmount', () => {
+ const { unmount } = renderHook(() => usePredictOrderbook('token1'));
+
+ unmount();
+
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('resubscribes exactly once when tokenId changes', () => {
+ const { rerender } = renderHook(
+ ({ tokenId }) => usePredictOrderbook(tokenId),
+ { initialProps: { tokenId: 'token1' } },
+ );
+
+ expect(mockSubscribeToOrderbook).toHaveBeenCalledTimes(1);
+
+ rerender({ tokenId: 'token2' });
+
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
+ expect(mockSubscribeToOrderbook).toHaveBeenCalledTimes(2);
+ expect(mockSubscribeToOrderbook).toHaveBeenLastCalledWith(
+ 'token2',
+ expect.any(Function),
+ );
+ });
+ });
+
+ describe('initial state and skip behavior', () => {
+ it('returns null orderbook and loading=true on first render with a tokenId', () => {
+ // Block snapshot delivery by holding the callback before flushing.
+ mockSubscribeToOrderbook.mockImplementation(() => mockUnsubscribe);
+
+ const { result } = renderHook(() => usePredictOrderbook('token1'));
+
+ expect(result.current.orderbook).toBeNull();
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('marks loading=false when no tokenId is provided', () => {
+ const { result } = renderHook(() => usePredictOrderbook(undefined));
+
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('marks loading=false when enabled is false', () => {
+ const { result } = renderHook(() =>
+ usePredictOrderbook('token1', { enabled: false }),
+ );
+
+ expect(result.current.loading).toBe(false);
+ });
+ });
+
+ describe('snapshot delivery', () => {
+ it('maps OrderbookSnapshot to tuple-shaped OrderbookData preserving sort order', () => {
+ let capturedCallback: OrderbookCallback = jest.fn();
+ mockSubscribeToOrderbook.mockImplementation((_, callback) => {
+ capturedCallback = callback;
+ return mockUnsubscribe;
+ });
+
+ const { result } = renderHook(() => usePredictOrderbook('token1'));
+
+ act(() => {
+ capturedCallback(buildSnapshot());
+ });
+
+ expect(result.current.orderbook).toEqual({
+ bids: [
+ [0.5, 100],
+ [0.45, 50],
+ ],
+ asks: [
+ [0.55, 80],
+ [0.6, 30],
+ ],
+ });
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('resets orderbook to null when tokenId changes to avoid stale data', () => {
+ let capturedCallback: OrderbookCallback = jest.fn();
+ mockSubscribeToOrderbook.mockImplementation((_, callback) => {
+ capturedCallback = callback;
+ return mockUnsubscribe;
+ });
+
+ const { result, rerender } = renderHook(
+ ({ tokenId }) => usePredictOrderbook(tokenId),
+ { initialProps: { tokenId: 'token1' } },
+ );
+
+ act(() => {
+ capturedCallback(buildSnapshot());
+ });
+ expect(result.current.orderbook).not.toBeNull();
+
+ rerender({ tokenId: 'token2' });
+
+ expect(result.current.orderbook).toBeNull();
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('does not call setState when a snapshot arrives after unmount', () => {
+ let capturedCallback: OrderbookCallback = jest.fn();
+ mockSubscribeToOrderbook.mockImplementation((_, callback) => {
+ capturedCallback = callback;
+ return mockUnsubscribe;
+ });
+
+ const { unmount } = renderHook(() => usePredictOrderbook('token1'));
+ unmount();
+
+ // No throw and no warning even though setState would be invalid here.
+ expect(() => {
+ capturedCallback(buildSnapshot());
+ }).not.toThrow();
+ });
+ });
+});
diff --git a/app/components/UI/Predict/hooks/usePredictOrderbook.ts b/app/components/UI/Predict/hooks/usePredictOrderbook.ts
new file mode 100644
index 000000000000..1e14012df04f
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictOrderbook.ts
@@ -0,0 +1,74 @@
+import { useEffect, useRef, useState } from 'react';
+import Engine from '../../../../core/Engine';
+import type { OrderbookData } from '../../Charts/LivelineChart/LivelineChart.types';
+import type { OrderbookSnapshot } from '../types';
+
+export interface UsePredictOrderbookOptions {
+ enabled?: boolean;
+}
+
+export interface UsePredictOrderbookResult {
+ orderbook: OrderbookData | null;
+ loading: boolean;
+}
+
+const toLivelineOrderbook = (snapshot: OrderbookSnapshot): OrderbookData => ({
+ bids: snapshot.bids.map(({ price, size }) => [price, size]),
+ asks: snapshot.asks.map(({ price, size }) => [price, size]),
+});
+
+/**
+ * Hook for subscribing to real-time orderbook (depth) updates for a single
+ * outcome token via the Predict controller. The snapshot received from the
+ * controller is already sorted (bids desc, asks asc) and is mapped to the
+ * tuple shape consumed by `LivelineChart`'s `orderbook` prop.
+ *
+ * @param tokenId - The outcome token ID; pass undefined or empty string to skip subscribing
+ * @param options - Configuration options (enabled: boolean)
+ * @returns Latest orderbook tuple data and loading flag
+ */
+export function usePredictOrderbook(
+ tokenId?: string,
+ options: UsePredictOrderbookOptions = {},
+): UsePredictOrderbookResult {
+ const { enabled = true } = options;
+
+ const [orderbook, setOrderbook] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const isMountedRef = useRef(true);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+
+ // Reset state when token changes to avoid stale data from previous
+ // subscriptions.
+ setOrderbook(null);
+ setLoading(true);
+
+ if (!tokenId || !enabled) {
+ setLoading(false);
+ return;
+ }
+
+ const { PredictController } = Engine.context;
+ const unsubscribe = PredictController.subscribeToOrderbook(
+ tokenId,
+ (snapshot) => {
+ if (!isMountedRef.current) return;
+ setOrderbook(toLivelineOrderbook(snapshot));
+ setLoading(false);
+ },
+ );
+
+ return () => {
+ isMountedRef.current = false;
+ unsubscribe();
+ };
+ }, [tokenId, enabled]);
+
+ return {
+ orderbook,
+ loading,
+ };
+}
diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts
index d40eb5557e12..2ea34b2f4cdf 100644
--- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts
+++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts
@@ -46,6 +46,7 @@ import {
fetchEventsFromPolymarketApi,
getBalance,
getL2Headers,
+ getOrderBook,
getRawBalance,
parsePolymarketEvents,
parsePolymarketPositions,
@@ -106,6 +107,7 @@ jest.mock('./utils', () => {
searchEventsFromPolymarketApi: jest.fn(),
getBalance: jest.fn(),
getL2Headers: jest.fn(),
+ getOrderBook: jest.fn(),
getRawBalance: jest.fn(),
getMarketDetailsFromGammaApi: jest.fn(),
getPolymarketEndpoints: jest.fn(() => ({
@@ -125,6 +127,29 @@ jest.mock('./utils', () => {
};
});
+const mockWebSocketManagerInstance = {
+ subscribeToGame: jest.fn(),
+ subscribeToMarketPrices: jest.fn(),
+ subscribeToOrderbook: jest.fn(),
+ subscribeToCryptoPrices: jest.fn(),
+ seedOrderbookSnapshot: jest.fn(),
+ getConnectionStatus: jest.fn(() => ({
+ sportsConnected: false,
+ marketConnected: false,
+ rtdsConnected: false,
+ gameSubscriptionCount: 0,
+ priceSubscriptionCount: 0,
+ cryptoPriceSubscriptionCount: 0,
+ orderbookSubscriptionCount: 0,
+ })),
+};
+
+jest.mock('./WebSocketManager', () => ({
+ WebSocketManager: {
+ getInstance: jest.fn(() => mockWebSocketManagerInstance),
+ },
+}));
+
jest.mock('./protocol/transport', () => ({
submitProtocolClobOrder: jest.fn(),
}));
@@ -189,6 +214,7 @@ const mockGetDeployProxyWalletTransaction = jest.mocked(
getDeployProxyWalletTransaction,
);
const mockGetL2Headers = jest.mocked(getL2Headers);
+const mockGetOrderBook = jest.mocked(getOrderBook);
const mockGetRawBalance = jest.mocked(getRawBalance);
const mockGetSafeTransferAmount = jest.mocked(getSafeTransferAmount);
const mockGetSafeTransferAmountRaw = jest.mocked(getSafeTransferAmountRaw);
@@ -1491,3 +1517,112 @@ describe('PolymarketProvider', () => {
).rejects.toThrow('Failed to get crypto price history');
});
});
+
+describe('PolymarketProvider.subscribeToOrderbook', () => {
+ const mockBook = {
+ market: 'market-1',
+ asset_id: 'token1',
+ hash: 'hash',
+ timestamp: '2025-01-12T12:00:00Z',
+ bids: [{ price: '0.45', size: '50' }],
+ asks: [{ price: '0.55', size: '50' }],
+ min_order_size: '1',
+ tick_size: '0.01',
+ neg_risk: false,
+ };
+
+ beforeEach(() => {
+ mockWebSocketManagerInstance.subscribeToOrderbook.mockReset();
+ mockWebSocketManagerInstance.seedOrderbookSnapshot.mockReset();
+ mockGetOrderBook.mockReset();
+ });
+
+ it('returns the WebSocketManager unsubscribe function', () => {
+ const wsUnsubscribe = jest.fn();
+ mockWebSocketManagerInstance.subscribeToOrderbook.mockReturnValue(
+ wsUnsubscribe,
+ );
+ mockGetOrderBook.mockResolvedValue(mockBook);
+
+ const provider = createProvider();
+ const callback = jest.fn();
+ const unsubscribe = provider.subscribeToOrderbook('token1', callback);
+
+ expect(
+ mockWebSocketManagerInstance.subscribeToOrderbook,
+ ).toHaveBeenCalledWith('token1', callback);
+ expect(unsubscribe).toBe(wsUnsubscribe);
+ });
+
+ it('bootstraps with getOrderBook and seeds the WebSocketManager on success', async () => {
+ mockWebSocketManagerInstance.subscribeToOrderbook.mockReturnValue(
+ jest.fn(),
+ );
+ mockGetOrderBook.mockResolvedValue(mockBook);
+
+ createProvider().subscribeToOrderbook('token1', jest.fn());
+
+ expect(mockGetOrderBook).toHaveBeenCalledWith({ tokenId: 'token1' });
+
+ // Flush the pending REST promise.
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(
+ mockWebSocketManagerInstance.seedOrderbookSnapshot,
+ ).toHaveBeenCalledWith('token1', mockBook);
+ });
+
+ it('does not seed when getOrderBook rejects, but still returns the WS unsubscribe', async () => {
+ const wsUnsubscribe = jest.fn();
+ mockWebSocketManagerInstance.subscribeToOrderbook.mockReturnValue(
+ wsUnsubscribe,
+ );
+ mockGetOrderBook.mockRejectedValue(new Error('boom'));
+
+ const unsubscribe = createProvider().subscribeToOrderbook(
+ 'token1',
+ jest.fn(),
+ );
+
+ expect(unsubscribe).toBe(wsUnsubscribe);
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(
+ mockWebSocketManagerInstance.seedOrderbookSnapshot,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('subscribes via WS before awaiting REST so a WS book event can populate the cache first', async () => {
+ // Asserts the provider's race-safe ordering: the synchronous WS
+ // subscription happens before the awaited REST bootstrap. Combined
+ // with the WebSocketManager guard (`seedOrderbookSnapshot` no-ops when
+ // the cache is already populated), this prevents a late REST snapshot
+ // from stomping a newer WS-delivered book.
+ const callOrder: string[] = [];
+ mockWebSocketManagerInstance.subscribeToOrderbook.mockImplementation(() => {
+ callOrder.push('ws.subscribe');
+ return jest.fn();
+ });
+ mockGetOrderBook.mockImplementation(() => {
+ callOrder.push('rest.start');
+ return Promise.resolve(mockBook);
+ });
+ mockWebSocketManagerInstance.seedOrderbookSnapshot.mockImplementation(
+ () => {
+ callOrder.push('ws.seed');
+ },
+ );
+
+ createProvider().subscribeToOrderbook('token1', jest.fn());
+
+ expect(callOrder).toEqual(['ws.subscribe', 'rest.start']);
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(callOrder).toEqual(['ws.subscribe', 'rest.start', 'ws.seed']);
+ });
+});
diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
index 3b6de7435ecc..f07ba25d657f 100644
--- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
+++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
@@ -54,6 +54,7 @@ import {
GetMarketsParams,
GetMarketsResult,
GetPositionsParams,
+ OrderbookCallback,
OrderPreview,
OrderResult,
PlaceOrderParams,
@@ -104,6 +105,7 @@ import {
getL2Headers,
fetchChildEventsFromGammaApi,
getMarketDetailsFromGammaApi,
+ getOrderBook,
getPolymarketEndpoints,
getRawBalance,
mergeChildEventsIntoParent,
@@ -3086,6 +3088,28 @@ export class PolymarketProvider implements PredictProvider {
);
}
+ public subscribeToOrderbook(
+ tokenId: string,
+ callback: OrderbookCallback,
+ ): () => void {
+ const ws = WebSocketManager.getInstance();
+ const wsUnsubscribe = ws.subscribeToOrderbook(tokenId, callback);
+
+ // Bootstrap with a REST snapshot so the chart has data before the first
+ // WS `book` event arrives. `getOrderBook` defaults to v1; `previewOrder`
+ // does not currently thread v2 either, so no protocol plumbing is needed.
+ getOrderBook({ tokenId })
+ .then((book) => ws.seedOrderbookSnapshot(tokenId, book))
+ .catch((err) => {
+ DevLogger.log('PolymarketProvider: orderbook bootstrap failed', {
+ err,
+ tokenId,
+ });
+ });
+
+ return wsUnsubscribe;
+ }
+
public subscribeToCryptoPrices(
symbols: string[],
callback: CryptoPriceUpdateCallback,
diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts
index 9afa595e8221..42dbe583b7f2 100644
--- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts
+++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts
@@ -564,7 +564,7 @@ describe('WebSocketManager', () => {
]);
});
- it('ignores non-price_change events', () => {
+ it('does not deliver book events to price subscribers', () => {
const manager = WebSocketManager.getInstance();
const callback = jest.fn();
@@ -573,6 +573,23 @@ describe('WebSocketManager', () => {
mockWebSocketInstances[0].simulateMessage({
event_type: 'book',
market: 'market-1',
+ asset_id: 'token1',
+ bids: [{ price: '0.50', size: '100' }],
+ asks: [{ price: '0.52', size: '100' }],
+ });
+
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('ignores unknown event types', () => {
+ const manager = WebSocketManager.getInstance();
+ const callback = jest.fn();
+
+ manager.subscribeToMarketPrices(['token1'], callback);
+ mockWebSocketInstances[0].simulateOpen();
+ mockWebSocketInstances[0].simulateMessage({
+ event_type: 'something_else',
+ market: 'market-1',
});
expect(callback).not.toHaveBeenCalled();
@@ -683,6 +700,464 @@ describe('WebSocketManager', () => {
});
});
+ describe('orderbook subscriptions', () => {
+ const getMarketInstance = () => {
+ const instance = mockWebSocketInstances.find(
+ (ws) =>
+ ws.url === 'wss://ws-subscriptions-clob.polymarket.com/ws/market',
+ );
+ if (!instance) {
+ throw new Error('Market WebSocket instance was not created');
+ }
+ return instance;
+ };
+
+ it('connects to market WS and subscribes when first orderbook subscriber registers', () => {
+ const manager = WebSocketManager.getInstance();
+
+ manager.subscribeToOrderbook('token1', jest.fn());
+ const market = getMarketInstance();
+ market.simulateOpen();
+
+ expect(market.send).toHaveBeenCalledWith(
+ JSON.stringify({
+ type: 'market',
+ assets_ids: ['token1'],
+ }),
+ );
+ });
+
+ it('emits a sorted snapshot on book events (bids desc, asks asc)', () => {
+ const manager = WebSocketManager.getInstance();
+ const callback = jest.fn();
+
+ manager.subscribeToOrderbook('token1', callback);
+ const market = getMarketInstance();
+ market.simulateOpen();
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'token1',
+ bids: [
+ { price: '0.40', size: '100' },
+ { price: '0.45', size: '200' },
+ { price: '0.50', size: '50' },
+ ],
+ asks: [
+ { price: '0.60', size: '30' },
+ { price: '0.55', size: '80' },
+ ],
+ timestamp: '2025-01-12T12:00:00Z',
+ });
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ const snapshot = callback.mock.calls[0][0];
+ expect(snapshot.tokenId).toBe('token1');
+ expect(snapshot.bids).toEqual([
+ { price: 0.5, size: 50 },
+ { price: 0.45, size: 200 },
+ { price: 0.4, size: 100 },
+ ]);
+ expect(snapshot.asks).toEqual([
+ { price: 0.55, size: 80 },
+ { price: 0.6, size: 30 },
+ ]);
+ expect(typeof snapshot.timestamp).toBe('number');
+ });
+
+ it('ignores book events for tokens with no active subscription', () => {
+ const manager = WebSocketManager.getInstance();
+ const callback = jest.fn();
+
+ manager.subscribeToOrderbook('token1', callback);
+ const market = getMarketInstance();
+ market.simulateOpen();
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'other-token',
+ bids: [{ price: '0.10', size: '1' }],
+ asks: [{ price: '0.90', size: '1' }],
+ });
+
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('seedOrderbookSnapshot emits a sorted snapshot from REST data', () => {
+ const manager = WebSocketManager.getInstance();
+ const callback = jest.fn();
+
+ manager.subscribeToOrderbook('token1', callback);
+
+ manager.seedOrderbookSnapshot('token1', {
+ market: 'market-1',
+ asset_id: 'token1',
+ hash: 'hash',
+ timestamp: '2025-01-12T12:00:00Z',
+ // REST returns asks descending, bids ascending — emit must re-sort.
+ asks: [
+ { price: '0.60', size: '30' },
+ { price: '0.55', size: '80' },
+ ],
+ bids: [
+ { price: '0.40', size: '100' },
+ { price: '0.45', size: '200' },
+ { price: '0.50', size: '50' },
+ ],
+ min_order_size: '1',
+ tick_size: '0.01',
+ neg_risk: false,
+ });
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ const snapshot = callback.mock.calls[0][0];
+ expect(snapshot.bids).toEqual([
+ { price: 0.5, size: 50 },
+ { price: 0.45, size: 200 },
+ { price: 0.4, size: 100 },
+ ]);
+ expect(snapshot.asks).toEqual([
+ { price: 0.55, size: 80 },
+ { price: 0.6, size: 30 },
+ ]);
+ });
+
+ it('seedOrderbookSnapshot is a no-op when no subscriber is registered', () => {
+ const manager = WebSocketManager.getInstance();
+ const callback = jest.fn();
+
+ // No subscription for this token.
+ manager.seedOrderbookSnapshot('orphan-token', {
+ market: 'market-1',
+ asset_id: 'orphan-token',
+ hash: 'hash',
+ timestamp: '2025-01-12T12:00:00Z',
+ asks: [{ price: '0.6', size: '1' }],
+ bids: [{ price: '0.4', size: '1' }],
+ min_order_size: '1',
+ tick_size: '0.01',
+ neg_risk: false,
+ });
+
+ // Subscribe afterwards and confirm the cache wasn't populated.
+ manager.subscribeToOrderbook('orphan-token', callback);
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('seedOrderbookSnapshot does not overwrite an already-cached WS snapshot', () => {
+ // Guards the race where a WS `book` event populates the cache before
+ // the parallel REST bootstrap promise resolves: REST is by definition
+ // older than the WS push, so it must not stomp the fresher state.
+ const manager = WebSocketManager.getInstance();
+ const callback = jest.fn();
+
+ manager.subscribeToOrderbook('token1', callback);
+ const market = getMarketInstance();
+ market.simulateOpen();
+
+ // Fresh WS book event arrives first.
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'token1',
+ bids: [{ price: '0.50', size: '100' }],
+ asks: [{ price: '0.52', size: '100' }],
+ });
+ expect(callback).toHaveBeenCalledTimes(1);
+ const wsSnapshot = callback.mock.calls[0][0];
+ callback.mockClear();
+
+ // Late REST bootstrap with stale data.
+ manager.seedOrderbookSnapshot('token1', {
+ market: 'market-1',
+ asset_id: 'token1',
+ hash: 'hash',
+ timestamp: '2025-01-12T11:59:59Z',
+ bids: [{ price: '0.40', size: '999' }],
+ asks: [{ price: '0.60', size: '999' }],
+ min_order_size: '1',
+ tick_size: '0.01',
+ neg_risk: false,
+ });
+
+ // REST seed is a no-op; cache and subscribers untouched.
+ expect(callback).not.toHaveBeenCalled();
+
+ // The next WS event still emits the post-WS state (not the REST state).
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'token1',
+ bids: [{ price: '0.51', size: '101' }],
+ asks: [{ price: '0.53', size: '101' }],
+ });
+ // Throttle window from the first emit is open; trailing emit fires
+ // after 250ms.
+ jest.advanceTimersByTime(250);
+ const finalSnapshot = callback.mock.calls.at(-1)?.[0];
+ expect(finalSnapshot.bids).toEqual([{ price: 0.51, size: 101 }]);
+ expect(finalSnapshot.asks).toEqual([{ price: 0.53, size: 101 }]);
+ // Sanity: the stale REST values never appeared.
+ expect(wsSnapshot.bids[0].price).toBe(0.5);
+ });
+
+ it('replays the cached snapshot to a late subscriber synchronously', () => {
+ const manager = WebSocketManager.getInstance();
+ const firstCallback = jest.fn();
+
+ manager.subscribeToOrderbook('token1', firstCallback);
+ const market = getMarketInstance();
+ market.simulateOpen();
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'token1',
+ bids: [{ price: '0.45', size: '50' }],
+ asks: [{ price: '0.55', size: '50' }],
+ });
+ firstCallback.mockClear();
+
+ const lateCallback = jest.fn();
+ manager.subscribeToOrderbook('token1', lateCallback);
+
+ expect(lateCallback).toHaveBeenCalledTimes(1);
+ const snapshot = lateCallback.mock.calls[0][0];
+ expect(snapshot.bids).toEqual([{ price: 0.45, size: 50 }]);
+ expect(snapshot.asks).toEqual([{ price: 0.55, size: 50 }]);
+ });
+
+ it('throttles rapid book events to one trailing emit per token window', () => {
+ const manager = WebSocketManager.getInstance();
+ const callback = jest.fn();
+
+ manager.subscribeToOrderbook('token1', callback);
+ const market = getMarketInstance();
+ market.simulateOpen();
+
+ // First book — emitted immediately.
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'token1',
+ bids: [{ price: '0.45', size: '50' }],
+ asks: [{ price: '0.55', size: '50' }],
+ });
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ // Subsequent rapid books within the throttle window — coalesced.
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'token1',
+ bids: [{ price: '0.46', size: '60' }],
+ asks: [{ price: '0.54', size: '70' }],
+ });
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'token1',
+ bids: [{ price: '0.47', size: '70' }],
+ asks: [{ price: '0.53', size: '90' }],
+ });
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ // Advance past the throttle window — trailing emit fires with latest state.
+ jest.advanceTimersByTime(250);
+ expect(callback).toHaveBeenCalledTimes(2);
+ const trailing = callback.mock.calls[1][0];
+ expect(trailing.bids).toEqual([{ price: 0.47, size: 70 }]);
+ expect(trailing.asks).toEqual([{ price: 0.53, size: 90 }]);
+ });
+
+ it('does not emit a stale orderbook on price_change events even with a cached book', () => {
+ // `price_change` only carries `best_bid` / `best_ask`, no per-level
+ // sizes. Emitting on these events can only show a stale, wider-than-
+ // real spread until the next `book` event arrives, so the manager
+ // intentionally suppresses orderbook emits here.
+ const manager = WebSocketManager.getInstance();
+ const callback = jest.fn();
+
+ manager.subscribeToOrderbook('token1', callback);
+ const market = getMarketInstance();
+ market.simulateOpen();
+
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'token1',
+ bids: [
+ { price: '0.45', size: '50' },
+ { price: '0.40', size: '100' },
+ ],
+ asks: [
+ { price: '0.55', size: '50' },
+ { price: '0.60', size: '100' },
+ ],
+ });
+ expect(callback).toHaveBeenCalledTimes(1);
+ callback.mockClear();
+ jest.advanceTimersByTime(250);
+
+ market.simulateMessage({
+ event_type: 'price_change',
+ market: 'market-1',
+ price_changes: [
+ {
+ asset_id: 'token1',
+ price: '0.58',
+ best_bid: '0.56',
+ best_ask: '0.59',
+ },
+ ],
+ timestamp: '2025-01-12T12:00:00Z',
+ });
+ // No emit until the next `book` event repopulates the cache with real
+ // level sizes.
+ jest.advanceTimersByTime(500);
+ expect(callback).not.toHaveBeenCalled();
+
+ // Once a fresh book arrives, the new state is delivered.
+ market.simulateMessage({
+ event_type: 'book',
+ market: 'market-1',
+ asset_id: 'token1',
+ bids: [{ price: '0.56', size: '10' }],
+ asks: [{ price: '0.59', size: '10' }],
+ });
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback.mock.calls[0][0].bids).toEqual([
+ { price: 0.56, size: 10 },
+ ]);
+ expect(callback.mock.calls[0][0].asks).toEqual([
+ { price: 0.59, size: 10 },
+ ]);
+ });
+
+ it('does not emit orderbook updates when there is no cached book', () => {
+ const manager = WebSocketManager.getInstance();
+ const orderbookCallback = jest.fn();
+ const priceCallback = jest.fn();
+
+ manager.subscribeToOrderbook('token1', orderbookCallback);
+ manager.subscribeToMarketPrices(['token1'], priceCallback);
+ const market = getMarketInstance();
+ market.simulateOpen();
+
+ market.simulateMessage({
+ event_type: 'price_change',
+ market: 'market-1',
+ price_changes: [
+ {
+ asset_id: 'token1',
+ price: '0.50',
+ best_bid: '0.48',
+ best_ask: '0.52',
+ },
+ ],
+ timestamp: '2025-01-12T12:00:00Z',
+ });
+
+ expect(priceCallback).toHaveBeenCalledTimes(1);
+ expect(orderbookCallback).not.toHaveBeenCalled();
+ });
+
+ it('does not unsubscribe a token shared with an active price subscription', () => {
+ const manager = WebSocketManager.getInstance();
+
+ manager.subscribeToMarketPrices(['token1'], jest.fn());
+ const unsubscribeOrderbook = manager.subscribeToOrderbook(
+ 'token1',
+ jest.fn(),
+ );
+ const market = getMarketInstance();
+ market.simulateOpen();
+ market.send.mockClear();
+
+ unsubscribeOrderbook();
+
+ expect(market.send).not.toHaveBeenCalledWith(
+ JSON.stringify({
+ operation: 'unsubscribe',
+ assets_ids: ['token1'],
+ }),
+ );
+ });
+
+ it('sends WS unsubscribe when last orderbook subscriber is removed and no price sub references the token', () => {
+ const manager = WebSocketManager.getInstance();
+
+ const unsubscribe = manager.subscribeToOrderbook('token1', jest.fn());
+ const market = getMarketInstance();
+ market.simulateOpen();
+ market.send.mockClear();
+
+ unsubscribe();
+
+ expect(market.send).toHaveBeenCalledWith(
+ JSON.stringify({
+ operation: 'unsubscribe',
+ assets_ids: ['token1'],
+ }),
+ );
+ });
+
+ it('closes the market socket only when both price and orderbook maps are empty', () => {
+ const manager = WebSocketManager.getInstance();
+
+ const unsubscribePrice = manager.subscribeToMarketPrices(
+ ['token1'],
+ jest.fn(),
+ );
+ const unsubscribeOrderbook = manager.subscribeToOrderbook(
+ 'token1',
+ jest.fn(),
+ );
+ const market = getMarketInstance();
+ market.simulateOpen();
+ market.close.mockClear();
+
+ unsubscribePrice();
+ expect(market.close).not.toHaveBeenCalled();
+
+ unsubscribeOrderbook();
+ expect(market.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('resubscribes the union of price and orderbook tokens on reconnect', () => {
+ const manager = WebSocketManager.getInstance();
+
+ manager.subscribeToMarketPrices(['tokenA'], jest.fn());
+ manager.subscribeToOrderbook('tokenB', jest.fn());
+ const market = getMarketInstance();
+ market.simulateOpen();
+ market.send.mockClear();
+
+ market.simulateClose();
+ jest.advanceTimersByTime(3000);
+ const reconnected =
+ mockWebSocketInstances[mockWebSocketInstances.length - 1];
+ reconnected.simulateOpen();
+
+ const subscribeCall = reconnected.send.mock.calls.find(
+ ([msg]: [string]) => {
+ try {
+ return JSON.parse(msg).type === 'market';
+ } catch {
+ return false;
+ }
+ },
+ );
+ if (!subscribeCall) {
+ throw new Error('Expected a market subscribe call after reconnect');
+ }
+ const payload = JSON.parse(subscribeCall[0]);
+ expect(payload.assets_ids).toEqual(
+ expect.arrayContaining(['tokenA', 'tokenB']),
+ );
+ expect(payload.assets_ids).toHaveLength(2);
+ });
+ });
+
describe('crypto price subscriptions', () => {
it('connects to RTDS WS when first subscription is made', () => {
const manager = WebSocketManager.getInstance();
@@ -1605,6 +2080,7 @@ describe('WebSocketManager', () => {
gameSubscriptionCount: 0,
priceSubscriptionCount: 0,
cryptoPriceSubscriptionCount: 0,
+ orderbookSubscriptionCount: 0,
});
manager.subscribeToGame('123', jest.fn());
@@ -1616,6 +2092,8 @@ describe('WebSocketManager', () => {
manager.subscribeToCryptoPrices(['btc/usd'], jest.fn());
mockWebSocketInstances[2].simulateOpen();
+ manager.subscribeToOrderbook('token2', jest.fn());
+
expect(manager.getConnectionStatus()).toEqual({
sportsConnected: true,
marketConnected: true,
@@ -1623,6 +2101,7 @@ describe('WebSocketManager', () => {
gameSubscriptionCount: 1,
priceSubscriptionCount: 1,
cryptoPriceSubscriptionCount: 1,
+ orderbookSubscriptionCount: 1,
});
});
});
diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts
index c1f1f195e63d..36602dbaff84 100644
--- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts
+++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts
@@ -3,6 +3,9 @@ import {
CryptoPriceUpdate,
CryptoPriceUpdateCallback,
GameUpdate,
+ OrderbookCallback,
+ OrderbookLevel,
+ OrderbookSnapshot,
PredictGamePeriod,
PredictGameStatus,
PriceUpdate,
@@ -10,6 +13,7 @@ import {
import { GameCache } from './GameCache';
import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger';
import { trace, endTrace, TraceName } from '../../../../../util/trace';
+import { OrderBook } from './types';
const SPORTS_WS_URL = 'wss://sports-api.polymarket.com/ws';
const MARKET_WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
@@ -22,6 +26,7 @@ const RTDS_WS_URL = 'wss://ws-live-data.polymarket.com';
const RTDS_CRYPTO_PRICES_CHAINLINK_TOPIC = 'crypto_prices_chainlink';
const RTDS_PING_INTERVAL_MS = 5000;
const DEFAULT_THROTTLE_INTERVAL_MS = 16;
+const ORDERBOOK_EMIT_THROTTLE_MS = 250;
type GameUpdateCallback = (update: GameUpdate) => void;
type PriceUpdateCallback = (updates: PriceUpdate[]) => void;
@@ -40,6 +45,9 @@ interface SportsWebSocketEvent {
interface MarketWebSocketEvent {
event_type: string;
market: string;
+ asset_id?: string;
+ bids?: { price: string; size: string }[];
+ asks?: { price: string; size: string }[];
price_changes?: {
asset_id: string;
price: string;
@@ -73,6 +81,19 @@ export class WebSocketManager {
private gameSubscriptions: Map> = new Map();
private priceSubscriptions: Map> = new Map();
+ private orderbookSubscriptions: Map> =
+ new Map();
+ private orderbookState: Map<
+ string,
+ {
+ bids: Map;
+ asks: Map;
+ timestamp: number;
+ }
+ > = new Map();
+ private orderbookEmitTimers: Map> =
+ new Map();
+ private orderbookPendingEmit: Set = new Set();
private sportsReconnectAttempts = 0;
private marketReconnectAttempts = 0;
@@ -314,9 +335,11 @@ export class WebSocketManager {
subscriptionCallbacks.delete(callback);
if (subscriptionCallbacks.size === 0) {
this.priceSubscriptions.delete(subscriptionKey);
- const remainingTokenIds = this.getSubscribedMarketTokenIds();
+ const remainingPriceTokenIds = this.getSubscribedMarketTokenIds();
const tokenIdsToUnsubscribe = tokenIds.filter(
- (tokenId) => !remainingTokenIds.has(tokenId),
+ (tokenId) =>
+ !remainingPriceTokenIds.has(tokenId) &&
+ !this.orderbookSubscriptions.has(tokenId),
);
if (tokenIdsToUnsubscribe.length > 0) {
this.sendMarketUnsubscribe(tokenIdsToUnsubscribe);
@@ -324,12 +347,187 @@ export class WebSocketManager {
}
}
- if (this.priceSubscriptions.size === 0) {
+ if (
+ this.priceSubscriptions.size === 0 &&
+ this.orderbookSubscriptions.size === 0
+ ) {
+ this.disconnectMarket();
+ }
+ };
+ }
+
+ subscribeToOrderbook(
+ tokenId: string,
+ callback: OrderbookCallback,
+ ): () => void {
+ let callbacks = this.orderbookSubscriptions.get(tokenId);
+ if (!callbacks) {
+ callbacks = new Set();
+ this.orderbookSubscriptions.set(tokenId, callbacks);
+ }
+ callbacks.add(callback);
+
+ this.ensureMarketConnection([tokenId]);
+
+ // Replay cached snapshot to late subscribers so they render without waiting
+ // for the next WS book event.
+ const cached = this.orderbookState.get(tokenId);
+ if (cached) {
+ try {
+ callback(this.buildOrderbookSnapshot(tokenId, cached));
+ } catch (error) {
+ DevLogger.log('WebSocketManager: Orderbook subscriber failed', {
+ error,
+ tokenId,
+ });
+ }
+ }
+
+ return () => {
+ const subscriptionCallbacks = this.orderbookSubscriptions.get(tokenId);
+ if (subscriptionCallbacks) {
+ subscriptionCallbacks.delete(callback);
+ if (subscriptionCallbacks.size === 0) {
+ this.orderbookSubscriptions.delete(tokenId);
+ this.orderbookState.delete(tokenId);
+ this.orderbookPendingEmit.delete(tokenId);
+ const pendingTimer = this.orderbookEmitTimers.get(tokenId);
+ if (pendingTimer) {
+ clearTimeout(pendingTimer);
+ this.orderbookEmitTimers.delete(tokenId);
+ }
+ const remainingPriceTokenIds = this.getSubscribedMarketTokenIds();
+ if (!remainingPriceTokenIds.has(tokenId)) {
+ this.sendMarketUnsubscribe([tokenId]);
+ }
+ }
+ }
+
+ if (
+ this.priceSubscriptions.size === 0 &&
+ this.orderbookSubscriptions.size === 0
+ ) {
this.disconnectMarket();
}
};
}
+ /**
+ * Seed the orderbook cache with a REST snapshot before WS book events arrive.
+ * REST returns `bids` ascending and `asks` descending; the cached price/size
+ * maps are unordered, and {@link buildOrderbookSnapshot} re-sorts on emit
+ * (bids desc, asks asc).
+ *
+ * No-ops in two cases: when no subscriber is registered for the token (the
+ * consumer unsubscribed before REST resolved), and when a WS `book` event
+ * has already populated the cache (the WS push is by definition newer than
+ * the REST snapshot that started in parallel, so seeding here would
+ * visually regress the depth chart until the next WS event arrives).
+ */
+ public seedOrderbookSnapshot(tokenId: string, book: OrderBook): void {
+ if (!this.orderbookSubscriptions.has(tokenId)) {
+ return;
+ }
+ if (this.orderbookState.has(tokenId)) {
+ return;
+ }
+
+ const bids = new Map();
+ book.bids?.forEach((level) => {
+ const size = parseFloat(level.size);
+ if (Number.isFinite(size) && size > 0) {
+ bids.set(level.price, size);
+ }
+ });
+ const asks = new Map();
+ book.asks?.forEach((level) => {
+ const size = parseFloat(level.size);
+ if (Number.isFinite(size) && size > 0) {
+ asks.set(level.price, size);
+ }
+ });
+
+ this.orderbookState.set(tokenId, {
+ bids,
+ asks,
+ timestamp: Date.now(),
+ });
+
+ this.emitOrderbookSnapshot(tokenId);
+ }
+
+ private buildOrderbookSnapshot(
+ tokenId: string,
+ cached: {
+ bids: Map;
+ asks: Map;
+ timestamp: number;
+ },
+ ): OrderbookSnapshot {
+ const bids: OrderbookLevel[] = [];
+ cached.bids.forEach((size, price) => {
+ const numericPrice = parseFloat(price);
+ if (Number.isFinite(numericPrice)) {
+ bids.push({ price: numericPrice, size });
+ }
+ });
+ bids.sort((a, b) => b.price - a.price);
+
+ const asks: OrderbookLevel[] = [];
+ cached.asks.forEach((size, price) => {
+ const numericPrice = parseFloat(price);
+ if (Number.isFinite(numericPrice)) {
+ asks.push({ price: numericPrice, size });
+ }
+ });
+ asks.sort((a, b) => a.price - b.price);
+
+ return {
+ tokenId,
+ bids,
+ asks,
+ timestamp: cached.timestamp,
+ };
+ }
+
+ private emitOrderbookSnapshot(tokenId: string): void {
+ const cached = this.orderbookState.get(tokenId);
+ const callbacks = this.orderbookSubscriptions.get(tokenId);
+ if (!cached || !callbacks || callbacks.size === 0) {
+ return;
+ }
+
+ const snapshot = this.buildOrderbookSnapshot(tokenId, cached);
+ callbacks.forEach((callback) => {
+ try {
+ callback(snapshot);
+ } catch (error) {
+ DevLogger.log('WebSocketManager: Orderbook subscriber failed', {
+ error,
+ tokenId,
+ });
+ }
+ });
+ }
+
+ private scheduleOrderbookEmit(tokenId: string): void {
+ // Emit immediately if no timer is active (first emit per window is instant).
+ if (!this.orderbookEmitTimers.has(tokenId)) {
+ this.emitOrderbookSnapshot(tokenId);
+ const timer = setTimeout(() => {
+ this.orderbookEmitTimers.delete(tokenId);
+ if (this.orderbookPendingEmit.delete(tokenId)) {
+ this.emitOrderbookSnapshot(tokenId);
+ }
+ }, ORDERBOOK_EMIT_THROTTLE_MS);
+ this.orderbookEmitTimers.set(tokenId, timer);
+ return;
+ }
+
+ // A timer is already active; mark a trailing emit.
+ this.orderbookPendingEmit.add(tokenId);
+ }
+
subscribeToCryptoPrices(
symbols: string[],
callback: CryptoPriceUpdateCallback,
@@ -415,6 +613,11 @@ export class WebSocketManager {
try {
const data: MarketWebSocketEvent = JSON.parse(event.data);
+ if (data.event_type === 'book' && data.asset_id) {
+ this.handleBookEvent(data);
+ return;
+ }
+
if (data.event_type !== 'price_change' || !data.price_changes) {
return;
}
@@ -436,6 +639,14 @@ export class WebSocketManager {
callbacks.forEach((callback) => callback(relevantUpdates));
}
});
+
+ // Intentionally NOT forwarding `price_change` to orderbook subscribers.
+ // The payload only carries `best_bid` / `best_ask` (no per-level
+ // size), so we have nothing to insert into the cached book — we could
+ // only PRUNE crossed levels, which leaves the chart showing a
+ // wider-than-real spread until the next full `book` event. Waiting
+ // for the next `book` event (typically <1s) is preferable to emitting
+ // a knowingly stale snapshot.
} catch (error) {
DevLogger.log('WebSocketManager: Failed to parse market message', {
error,
@@ -443,6 +654,38 @@ export class WebSocketManager {
}
};
+ private handleBookEvent(data: MarketWebSocketEvent): void {
+ if (!data.asset_id) {
+ return;
+ }
+ if (!this.orderbookSubscriptions.has(data.asset_id)) {
+ return;
+ }
+
+ const bids = new Map();
+ data.bids?.forEach((level) => {
+ const size = parseFloat(level.size);
+ if (Number.isFinite(size) && size > 0) {
+ bids.set(level.price, size);
+ }
+ });
+ const asks = new Map();
+ data.asks?.forEach((level) => {
+ const size = parseFloat(level.size);
+ if (Number.isFinite(size) && size > 0) {
+ asks.set(level.price, size);
+ }
+ });
+
+ this.orderbookState.set(data.asset_id, {
+ bids,
+ asks,
+ timestamp: Date.now(),
+ });
+
+ this.scheduleOrderbookEmit(data.asset_id);
+ }
+
private sendMarketSubscribe(tokenIds: string[]): void {
if (this.marketWs?.readyState !== WebSocket.OPEN) {
return;
@@ -485,6 +728,9 @@ export class WebSocketManager {
private resubscribeAllMarkets(): void {
const allTokenIds = this.getSubscribedMarketTokenIds();
+ this.orderbookSubscriptions.forEach((_, tokenId) => {
+ allTokenIds.add(tokenId);
+ });
if (allTokenIds.size > 0) {
this.sendMarketSubscribe(Array.from(allTokenIds));
@@ -492,7 +738,10 @@ export class WebSocketManager {
}
private scheduleMarketReconnect(): void {
- if (this.priceSubscriptions.size === 0) {
+ if (
+ this.priceSubscriptions.size === 0 &&
+ this.orderbookSubscriptions.size === 0
+ ) {
return;
}
@@ -550,6 +799,14 @@ export class WebSocketManager {
private disconnectMarket(): void {
this.cleanupMarketConnection();
this.marketReconnectAttempts = 0;
+ // Drop cached orderbook state so a future reconnect doesn't replay a
+ // stale snapshot to subscribers. The provider's REST bootstrap and the
+ // next live `book` event will repopulate. Also flush throttle timers so
+ // they don't fire after the socket is closed.
+ this.orderbookState.clear();
+ this.orderbookEmitTimers.forEach((timer) => clearTimeout(timer));
+ this.orderbookEmitTimers.clear();
+ this.orderbookPendingEmit.clear();
}
private ensureRtdsConnection(symbols?: string[]): void {
@@ -844,7 +1101,10 @@ export class WebSocketManager {
if (this.gameSubscriptions.size > 0) {
this.connectSports();
}
- if (this.priceSubscriptions.size > 0) {
+ if (
+ this.priceSubscriptions.size > 0 ||
+ this.orderbookSubscriptions.size > 0
+ ) {
this.connectMarket();
}
if (this.cryptoPriceSubscriptions.size > 0) {
@@ -863,6 +1123,11 @@ export class WebSocketManager {
this.gameSubscriptions.clear();
this.priceSubscriptions.clear();
this.cryptoPriceSubscriptions.clear();
+ this.orderbookSubscriptions.clear();
+ this.orderbookState.clear();
+ this.orderbookPendingEmit.clear();
+ this.orderbookEmitTimers.forEach((timer) => clearTimeout(timer));
+ this.orderbookEmitTimers.clear();
if (this.appStateSubscription) {
this.appStateSubscription.remove();
@@ -877,6 +1142,7 @@ export class WebSocketManager {
gameSubscriptionCount: number;
priceSubscriptionCount: number;
cryptoPriceSubscriptionCount: number;
+ orderbookSubscriptionCount: number;
} {
return {
sportsConnected: this.sportsWs?.readyState === WebSocket.OPEN,
@@ -885,6 +1151,7 @@ export class WebSocketManager {
gameSubscriptionCount: this.gameSubscriptions.size,
priceSubscriptionCount: this.priceSubscriptions.size,
cryptoPriceSubscriptionCount: this.cryptoPriceSubscriptions.size,
+ orderbookSubscriptionCount: this.orderbookSubscriptions.size,
};
}
}
diff --git a/app/components/UI/Predict/providers/types.ts b/app/components/UI/Predict/providers/types.ts
index 2d673c81bc73..e4522567825e 100644
--- a/app/components/UI/Predict/providers/types.ts
+++ b/app/components/UI/Predict/providers/types.ts
@@ -16,6 +16,7 @@ import {
GetPriceParams,
GetPriceResponse,
GetSeriesParams,
+ OrderbookCallback,
OrderPreview,
OrderResult,
PlaceOrderParams,
@@ -47,6 +48,7 @@ export type {
GetMarketsParams,
GetMarketsResult,
GetPositionsParams,
+ OrderbookCallback,
OrderPreview,
OrderResult,
PlaceOrderParams,
@@ -214,6 +216,11 @@ export interface PredictProvider {
callback: PriceUpdateCallback,
): () => void;
+ subscribeToOrderbook?(
+ tokenId: string,
+ callback: OrderbookCallback,
+ ): () => void;
+
subscribeToCryptoPrices?(
symbols: string[],
callback: CryptoPriceUpdateCallback,
diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts
index 08b85ecc2dc5..be1f2f807d93 100644
--- a/app/components/UI/Predict/types/index.ts
+++ b/app/components/UI/Predict/types/index.ts
@@ -267,6 +267,18 @@ export interface CryptoPriceUpdate {
timestamp: number;
}
+export interface OrderbookLevel {
+ price: number;
+ size: number;
+}
+
+export interface OrderbookSnapshot {
+ tokenId: string;
+ bids: OrderbookLevel[];
+ asks: OrderbookLevel[];
+ timestamp: number;
+}
+
export type PredictOutcomeGroup = {
key: string;
outcomes: PredictOutcome[];
@@ -669,6 +681,7 @@ export interface ConnectionStatus {
export type GameUpdateCallback = (update: GameUpdate) => void;
export type PriceUpdateCallback = (updates: PriceUpdate[]) => void;
export type CryptoPriceUpdateCallback = (update: CryptoPriceUpdate) => void;
+export type OrderbookCallback = (snapshot: OrderbookSnapshot) => void;
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PrepareDepositParams {}
diff --git a/tests/websocket/account-activity-mocks.test.ts b/tests/websocket/account-activity-mocks.test.ts
index 348a236c5817..10ea43300d18 100644
--- a/tests/websocket/account-activity-mocks.test.ts
+++ b/tests/websocket/account-activity-mocks.test.ts
@@ -27,10 +27,13 @@ describe('Account Activity WebSocket Mocks', () => {
beforeEach(async () => {
clients = [];
- testPort = 51000 + Math.floor(Math.random() * 9000);
server = new LocalWebSocketServer('test-account-activity');
- server.setServerPort(testPort);
+ // Use port 0 so the OS picks a free port. Picking a random fixed port
+ // can collide with other processes / Jest workers and produce flaky
+ // EADDRINUSE failures.
+ server.setServerPort(0);
await server.start();
+ testPort = server.getServerPort();
await setupAccountActivityMocks(server);
});
@@ -376,10 +379,10 @@ describe('Account Activity WebSocket Mocks', () => {
it('uses custom mocks passed to setup (override behavior)', async () => {
await server.stop();
- const customPort = testPort + 1;
const customServer = new LocalWebSocketServer('test-custom-mocks');
- customServer.setServerPort(customPort);
+ customServer.setServerPort(0);
await customServer.start();
+ const customPort = customServer.getServerPort();
await setupAccountActivityMocks(customServer, [
{
From 15f3c738c160a8b7688eb1819e7bc73e76d85b5f Mon Sep 17 00:00:00 2001
From: Cal Leung
Date: Wed, 20 May 2026 15:06:01 -0700
Subject: [PATCH 06/12] chore: Add a required source branch input to build.yml
(#30482)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Some of our workflows such as `build.yml` relies on the `main` workflow
file being run. This is to allow other branches to build while still
locking down the workflow from the main branch. This adds a source
branch input to the `build.yml` workflow to allow building from source
branches
## **Changelog**
CHANGELOG entry:
## **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**
With source input
## **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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**
> Medium risk because it changes how `build.yml` resolves the git ref
for checkout and build metadata, which can break callers if they don’t
pass `source_branch` or if the value differs from the triggering ref.
>
> **Overview**
> Makes `source_branch` a **required** input for
`.github/workflows/build.yml` and uses it consistently for all
checkouts/metadata (removing fallbacks to `github.ref_name`).
>
> Adds `source_branch` to manual `workflow_dispatch` runs (defaulting to
`main`) and updates `expo-dev-build.yml` to pass the current ref name
into the reusable build workflow.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8bd6417ff6e5e56424464ff0127fd9199572c8d9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.github/workflows/build.yml | 28 +++++++++++++++-------------
.github/workflows/expo-dev-build.yml | 1 +
2 files changed, 16 insertions(+), 13 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c94beae0bc7f..6996d5725389 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -10,12 +10,9 @@ on:
required: true
type: string # android, ios, or both
source_branch:
- description: >-
- Branch, tag, or SHA for prepare, node-modules setup, and build checkout (empty uses the default triggering ref).
- When build_number is set, the bump is applied locally on each build runner after checkout.
- required: false
+ description: 'Branch, tag, or SHA to build'
+ required: true
type: string
- default: ''
build_number:
description: >-
Optional (workflow_call only). From generate-build-version.yml. When non-empty, each matrix
@@ -43,10 +40,10 @@ on:
description: 'platform input (android, ios, or both)'
value: ${{ inputs.platform }}
source_branch_input:
- description: 'source_branch input (empty means default ref was used)'
+ description: 'source_branch input passed to this workflow'
value: ${{ inputs.source_branch }}
checkout_ref:
- description: 'Git ref used for checkout (source_branch or triggering ref)'
+ description: 'Git ref used for checkout (same as source_branch)'
value: ${{ jobs.emit-build-metadata.outputs.checkout_ref }}
built_commit_sha:
description: 'Resolved commit SHA at checkout_ref after build succeeded'
@@ -62,6 +59,11 @@ on:
value: ${{ jobs.emit-build-metadata.outputs.ios_version_code }}
workflow_dispatch:
inputs:
+ source_branch:
+ description: 'Branch, tag, or SHA to build'
+ required: true
+ type: string
+ default: 'main'
build_name:
required: true
type: choice
@@ -113,18 +115,18 @@ jobs:
signing_aws_secret: ${{ steps.config.outputs.signing_aws_secret }}
signing_android_keystore_path: ${{ steps.config.outputs.signing_android_keystore_path }}
script_name: ${{ steps.config.outputs.script_name }}
- checkout_ref_for_setup: ${{ inputs.source_branch || github.ref_name }}
+ checkout_ref_for_setup: ${{ inputs.source_branch }}
steps:
- uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1
if: ${{ inputs.runner_provider == 'namespace' }}
with:
fetch-depth: 1
- ref: ${{ inputs.source_branch || github.ref_name }}
+ ref: ${{ inputs.source_branch }}
- uses: actions/checkout@v4
if: ${{ inputs.runner_provider != 'namespace' }}
with:
fetch-depth: 1
- ref: ${{ inputs.source_branch || github.ref_name }}
+ ref: ${{ inputs.source_branch }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -191,12 +193,12 @@ jobs:
- uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1
if: ${{ inputs.runner_provider == 'namespace' }}
with:
- ref: ${{ inputs.source_branch || github.ref_name }}
+ ref: ${{ inputs.source_branch }}
submodules: recursive
- uses: actions/checkout@v4
if: ${{ inputs.runner_provider != 'namespace' }}
with:
- ref: ${{ inputs.source_branch || github.ref_name }}
+ ref: ${{ inputs.source_branch }}
submodules: recursive
- name: Apply build number locally
@@ -434,7 +436,7 @@ jobs:
# Must match Apply build config / artifact naming so build.sh loads the same
# builds.yml entry (e.g. main-e2e-bs-with-srp), not generic main-e2e (sim-only).
BUILD_CONFIG_NAME: ${{ inputs.build_name }}
- GIT_BRANCH: ${{ inputs.source_branch || github.ref_name }}
+ GIT_BRANCH: ${{ inputs.source_branch }}
# React Native 0.81's ReactAndroid/build.gradle.kts requests CMake 3.30.5
# via `System.getenv("CMAKE_VERSION") ?: "3.30.5"`. The self-hosted runner
# only ships CMake 3.22.1 in /opt/android-sdk/cmake/ and AGP cannot auto-
diff --git a/.github/workflows/expo-dev-build.yml b/.github/workflows/expo-dev-build.yml
index 81c45e98120c..1ace1d5b3fe1 100644
--- a/.github/workflows/expo-dev-build.yml
+++ b/.github/workflows/expo-dev-build.yml
@@ -40,5 +40,6 @@ jobs:
with:
build_name: main-dev-expo
platform: both
+ source_branch: ${{ github.ref_name }}
runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
From 6806af27122b91a98f5972b8486547f7b958ec37 Mon Sep 17 00:00:00 2001
From: Wei Sun
Date: Wed, 20 May 2026 14:44:41 -0700
Subject: [PATCH 07/12] refactor(stake): use native stack navigators for Stake
routes (#30220)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
**note to reviewer:** please view the diff with the link to reduce
noise:
https://github.com/MetaMask/metamask-mobile/pull/30220/changes?w=1
Migrates the Stake screen and modal navigators from
@react-navigation/stack to
@react-navigation/native-stack([POC](https://docs.google.com/document/d/1_oDELkGRLUgaAeMs2NSzG9w1UyjsD5VSKQlW7yW5oZU/edit?tab=t.0#heading=h.gba8s4wq9tzu)),
and replaces getStakingNavbar + useEffect(setOptions(...)) with an
in-screen HeaderStandard on three Stake flows.
**Why**: Setting headers via useEffect + setOptions on native stack
causes a visible flicker on first paint (the default native header
renders, then is replaced); for these screens we now configure
headerShown: false at the route level and render the header in-screen
with HeaderStandard, which eliminates the flicker.
What changed
1. **Stake/routes/index.tsx**:
- createStackNavigator → createNativeStackNavigator for both
StakeScreenStack and StakeModalStack.
- Modal stack switched to clearNativeStackNavigatorOptions +
transparentModalScreenOptions (native-stack equivalents of the prior
preset).
- screenOptions.headerShadowVisible: false.
- headerShown: false on STAKE, STAKE_CONFIRMATION, UNSTAKE_CONFIRMATION,
EARNINGS_HISTORY.
2. StakeConfirmationView, UnstakeConfirmationView,
StakeEarningsHistoryView: replaced legacy header setup with
HeaderStandard; back button preserves the existing analytics events
(STAKE_CONFIRMATION_BACK_CLICKED, UNSTAKE_CONFIRMATION_BACK_CLICKED) via
useAnalytics.
**Note** I am leaving migration EarnWithDrawInputView.tsx out on purpose
because this navbar has more logic so it's better for the team to do the
migration
4. UnstakeConfirmationView.styles.ts: mainContainer height: '100%' →
flex: 1 so the footer (Cancel / Continue) lays out correctly under the
new flex parent + HeaderStandard.
5. EarnInputView: removed the now-redundant useEffect(() =>
navigation.setOptions({ headerShown: false })) (the route owns it).
6. Tests: navigation mocks now include goBack;
StakeEarningsHistoryView.test.tsx was rewritten to assert on the
rendered header title instead of setOptions / getStakingNavbar (which
the screen no longer calls).
Android test build:
https://github.com/MetaMask/metamask-mobile/actions/runs/25891826048
## **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**
**Please note that some of these flows are not representing what app is
behaving right now. I hardcoded some of the screens to see the
navigation behaviours**
### **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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**
> Refactors Stake navigation and header rendering, which can impact
screen transitions/back behavior and modal presentation; mitigated by
updated tests covering back actions and header titles.
>
> **Overview**
> **Stake navigation is migrated to native-stack.** `Stake/routes`
switches from `@react-navigation/stack` to
`@react-navigation/native-stack`, applies native-stack modal presets
(`clearNativeStackNavigatorOptions` + `transparentModalScreenOptions`),
and sets `headerShown: false` on Stake-related screens to avoid native
header flicker.
>
> **Stake headers are now rendered in-screen.** `StakeConfirmationView`,
`UnstakeConfirmationView`, and `StakeEarningsHistoryView` replace
`setOptions(getStakingNavbar(...))` with `HeaderStandard`, including
explicit back handlers that preserve existing MetaMetrics back-click
events; `UnstakeConfirmationView` layout is adjusted (`height: '100%'` →
`flex: 1`) to fit the new wrapper.
>
> **Tests are updated accordingly.** View tests now assert rendered
header titles and verify `goBack` + analytics tracking via
`HeaderStandard` back button test IDs, and `EarnInputView` removes a
redundant `navigation.setOptions({ headerShown: false })` now handled at
the route level.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
0c8c21da37f60dede65df01b23aa20d6376581f7. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../Views/EarnInputView/EarnInputView.tsx | 6 -
.../StakeConfirmationView.test.tsx | 74 ++++++++++++-
.../StakeConfirmationView.tsx | 104 +++++++++---------
.../StakeEarningsHistoryView.test.tsx | 37 +++++--
.../StakeEarningsHistoryView.tsx | 48 ++++----
.../UnstakeConfirmationView.styles.ts | 2 +-
.../UnstakeConfirmationView.test.tsx | 67 ++++++++++-
.../UnstakeConfirmationView.tsx | 92 ++++++++--------
app/components/UI/Stake/routes/index.tsx | 30 +++--
9 files changed, 309 insertions(+), 151 deletions(-)
diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx
index 230950da9736..d1ec397d7dcc 100644
--- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx
+++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx
@@ -900,12 +900,6 @@ const EarnInputView = () => {
navigateToLearnMoreModal,
]);
- useEffect(() => {
- navigation.setOptions({
- headerShown: false,
- });
- }, [navigation]);
-
const headerTitle = useMemo(() => {
const isLending =
earnToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING;
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx
index 0107cc2ac6aa..142b9d8a83ad 100644
--- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx
@@ -1,8 +1,11 @@
import React from 'react';
+import { fireEvent } from '@testing-library/react-native';
import renderWithProvider, {
DeepPartial,
} from '../../../../../util/test/renderWithProvider';
-import StakeConfirmationView from './StakeConfirmationView';
+import StakeConfirmationView, {
+ STAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID,
+} from './StakeConfirmationView';
import { Image, ImageSize } from 'react-native';
import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils';
import { backgroundState } from '../../../../../util/test/initial-root-state';
@@ -12,6 +15,8 @@ import { StakeConfirmationViewRouteParams } from './StakeConfirmationView.types'
import { MOCK_POOL_STAKING_SDK } from '../../__mocks__/stakeMockData';
import { RootState } from '../../../../../reducers';
import { strings } from '../../../../../../locales/i18n';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events';
jest.mock('../../../../hooks/useIpfsGateway', () => jest.fn());
@@ -74,13 +79,18 @@ jest.mock('react-redux', () => ({
.mockImplementation((callback) => callback(mockInitialState)),
}));
+const mockNavigate = jest.fn();
+const mockGoBack = jest.fn();
+const mockSetOptions = jest.fn();
+
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
useNavigation: () => ({
- navigate: jest.fn(),
- setOptions: jest.fn(),
+ navigate: mockNavigate,
+ setOptions: mockSetOptions,
+ goBack: mockGoBack,
}),
useRoute: () => ({
key: '1',
@@ -97,6 +107,24 @@ jest.mock('@react-navigation/native', () => {
};
});
+const mockAddProperties = jest.fn().mockReturnThis();
+const mockBuild = jest.fn().mockReturnValue({
+ name: 'STAKE_CONFIRMATION_BACK_CLICKED',
+});
+const mockEventBuilder = {
+ addProperties: mockAddProperties,
+ build: mockBuild,
+};
+const mockCreateEventBuilder = jest.fn().mockReturnValue(mockEventBuilder);
+const mockTrackEvent = jest.fn();
+
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
jest.mock('../../hooks/usePoolStakedDeposit', () => ({
__esModule: true,
default: () => ({
@@ -117,13 +145,49 @@ jest.mock('../../hooks/usePooledStakes', () => ({
}));
describe('StakeConfirmationView', () => {
- it('renders stake confirmation view', () => {
- const { getByText } = renderWithProvider(
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderView = () =>
+ renderWithProvider(
,
);
+ it('renders stake confirmation view', () => {
+ const { getByText } = renderView();
+
expect(getByText(strings('stake.staking_from'))).toBeOnTheScreen();
});
+
+ it('renders header with the stake title', () => {
+ const { getByText } = renderView();
+
+ expect(getByText(strings('stake.stake'))).toBeOnTheScreen();
+ });
+
+ it('calls navigation.goBack on back press', () => {
+ const { getByTestId } = renderView();
+
+ fireEvent.press(getByTestId(STAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID));
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('tracks STAKE_CONFIRMATION_BACK_CLICKED on back press', () => {
+ const { getByTestId } = renderView();
+
+ fireEvent.press(getByTestId(STAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID));
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.STAKE_CONFIRMATION_BACK_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ selected_provider: EVENT_PROVIDERS.CONSENSYS,
+ location: EVENT_LOCATIONS.STAKE_CONFIRMATION_VIEW,
+ });
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx
index a90d79d8c7e4..23a11d62b272 100644
--- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx
@@ -1,8 +1,9 @@
-import React, { useEffect } from 'react';
+import React, { useCallback } from 'react';
import { View } from 'react-native';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
+import { Box, HeaderStandard } from '@metamask/design-system-react-native';
+import { ScrollView } from 'react-native-gesture-handler';
import { useStyles } from '../../../../hooks/useStyles';
-import { getStakingNavbar } from '../../../Navbar';
import styleSheet from './StakeConfirmationView.styles';
import TokenValueStack from '../../components/StakingConfirmation/TokenValueStack/TokenValueStack';
import AccountCard from '../../components/StakingConfirmation/AccountCard/AccountCard';
@@ -12,13 +13,16 @@ import { StakeConfirmationViewRouteParams } from './StakeConfirmationView.types'
import { strings } from '../../../../../../locales/i18n';
import { FooterButtonGroupActions } from '../../components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.types';
import UnstakingTimeCard from '../../components/StakingConfirmation/UnstakeTimeCard/UnstakeTimeCard';
-import { ScrollView } from 'react-native-gesture-handler';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events';
import { getDecimalChainId } from '../../../../../util/networks';
const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking';
+export const STAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID =
+ 'stake-confirmation-header-back-button';
+
const StakeConfirmationView = () => {
const navigation = useNavigation();
const route =
@@ -26,59 +30,59 @@ const StakeConfirmationView = () => {
RouteProp<{ params: StakeConfirmationViewRouteParams }, 'params'>
>();
- const { styles, theme } = useStyles(styleSheet, {});
+ const { styles } = useStyles(styleSheet, {});
+ const { trackEvent, createEventBuilder } = useAnalytics();
- useEffect(() => {
- navigation.setOptions(
- getStakingNavbar(
- strings('stake.stake'),
- navigation,
- theme.colors,
- {
- backgroundColor: theme.colors.background.default,
- hasCancelButton: false,
- },
- {
- backButtonEvent: {
- event: MetaMetricsEvents.STAKE_CONFIRMATION_BACK_CLICKED,
- properties: {
- selected_provider: EVENT_PROVIDERS.CONSENSYS,
- location: EVENT_LOCATIONS.STAKE_CONFIRMATION_VIEW,
- },
- },
- },
- ),
+ const handleBackPress = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.STAKE_CONFIRMATION_BACK_CLICKED)
+ .addProperties({
+ selected_provider: EVENT_PROVIDERS.CONSENSYS,
+ location: EVENT_LOCATIONS.STAKE_CONFIRMATION_VIEW,
+ })
+ .build(),
);
- }, [navigation, theme.colors]);
+ navigation.goBack();
+ }, [navigation, trackEvent, createEventBuilder]);
return (
-
-
-
-
-
-
+
+
+
+
-
+
+
+
+
+
-
-
-
+
+
+
);
};
diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx
index b5bc3b5d87f9..1b07cb3cff79 100644
--- a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx
+++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx
@@ -1,19 +1,23 @@
import { Hex } from '@metamask/utils';
import React from 'react';
+import { fireEvent } from '@testing-library/react-native';
import { backgroundState } from '../../../../../util/test/initial-root-state';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
import { fireLayoutEvent } from '../../../../../util/testUtils/react-native-svg-charts';
+import { strings } from '../../../../../../locales/i18n';
import useEarningsHistory from '../../../Earn/hooks/useEarningsHistory';
-import { getStakingNavbar } from '../../../Navbar';
import { MOCK_STAKED_ETH_MAINNET_ASSET } from '../../__mocks__/stakeMockData';
-import StakeEarningsHistoryView from './StakeEarningsHistoryView';
+import StakeEarningsHistoryView, {
+ STAKE_EARNINGS_HISTORY_VIEW_BACK_BUTTON_TEST_ID,
+} from './StakeEarningsHistoryView';
-jest.mock('../../../Navbar');
jest.mock('../../../Earn/hooks/useEarningsHistory');
+const mockGoBack = jest.fn();
const mockNavigation = {
navigate: jest.fn(),
setOptions: jest.fn(),
+ goBack: mockGoBack,
};
jest.mock('@react-navigation/native', () => {
@@ -29,7 +33,7 @@ jest.mock('@react-navigation/native', () => {
};
});
jest.mock('react-native-svg-charts', () => {
- const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts'); // Get the actual Grid component
+ const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts');
return {
...reactNativeSvgCharts,
Grid: () => <>>,
@@ -88,20 +92,35 @@ const mockInitialState = {
const earningsHistoryView = ;
describe('StakeEarningsHistoryView', () => {
- it('calls navigation setOptions on render', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the staking earnings history header title with the asset ticker', () => {
+ const expectedTitle = strings('stake.earnings_history_title', {
+ ticker:
+ MOCK_STAKED_ETH_MAINNET_ASSET.ticker ||
+ MOCK_STAKED_ETH_MAINNET_ASSET.symbol,
+ });
+
const renderedView = renderWithProvider(earningsHistoryView, {
state: mockInitialState,
});
fireLayoutEvent(renderedView.root);
- expect(mockNavigation.setOptions).toHaveBeenCalled();
+
+ expect(renderedView.getByText(expectedTitle)).toBeOnTheScreen();
});
- it('calls navigation setOptions to get staking navigation bar', () => {
+ it('calls navigation.goBack when the header back button is pressed', () => {
const renderedView = renderWithProvider(earningsHistoryView, {
state: mockInitialState,
});
fireLayoutEvent(renderedView.root);
- expect(mockNavigation.setOptions).toHaveBeenCalled();
- expect(getStakingNavbar).toHaveBeenCalled();
+
+ fireEvent.press(
+ renderedView.getByTestId(STAKE_EARNINGS_HISTORY_VIEW_BACK_BUTTON_TEST_ID),
+ );
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
});
});
diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx
index e7de4bfeab10..b421d81ad0e9 100644
--- a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx
+++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx
@@ -1,43 +1,41 @@
import { useNavigation, useRoute } from '@react-navigation/native';
-import React, { useEffect } from 'react';
+import React from 'react';
import { View } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
+import { Box, HeaderStandard } from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import { useStyles } from '../../../../hooks/useStyles';
-import { getStakingNavbar } from '../../../Navbar';
import styleSheet from './StakeEarningsHistoryView.styles';
import { StakeEarningsHistoryViewRouteParams } from './StakeEarningsHistoryView.types';
import EarningsHistory from '../../../Earn/components/Earnings/EarningsHistory/EarningsHistory';
+export const STAKE_EARNINGS_HISTORY_VIEW_BACK_BUTTON_TEST_ID =
+ 'stake-earnings-history-header-back-button';
+
const StakeEarningsHistoryView = () => {
const navigation = useNavigation();
const route = useRoute();
- const { styles, theme } = useStyles(styleSheet, {});
+ const { styles } = useStyles(styleSheet, {});
const { asset } = route.params;
- useEffect(() => {
- navigation.setOptions(
- getStakingNavbar(
- strings('stake.earnings_history_title', {
- ticker: asset.ticker || asset.symbol,
- }),
- navigation,
- theme.colors,
- {
- backgroundColor: theme.colors.background.default,
- hasCancelButton: false,
- hasBackButton: true,
- },
- ),
- );
- }, [navigation, theme.colors, asset.ticker, asset.symbol]);
-
return (
-
-
-
-
-
+
+
+
+
+
+
+
+
);
};
diff --git a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.styles.ts b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.styles.ts
index 6cb233624389..09661d88ce18 100644
--- a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.styles.ts
+++ b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.styles.ts
@@ -10,7 +10,7 @@ const styleSheet = (params: { theme: Theme }) => {
paddingTop: 8,
paddingHorizontal: 16,
backgroundColor: colors.background.default,
- height: '100%',
+ flex: 1,
justifyContent: 'space-between',
},
cardsContainer: {
diff --git a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx
index f4515eb166c1..7f0131dc3ad2 100644
--- a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx
+++ b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx
@@ -1,5 +1,8 @@
import React from 'react';
-import UnstakeConfirmationView from './UnstakeConfirmationView';
+import { fireEvent } from '@testing-library/react-native';
+import UnstakeConfirmationView, {
+ UNSTAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID,
+} from './UnstakeConfirmationView';
import renderWithProvider, {
DeepPartial,
} from '../../../../../util/test/renderWithProvider';
@@ -10,6 +13,8 @@ import { UnstakeConfirmationViewRouteParams } from './UnstakeConfirmationView.ty
import { MOCK_POOL_STAKING_SDK } from '../../__mocks__/stakeMockData';
import { RootState } from '../../../../../reducers';
import { strings } from '../../../../../../locales/i18n';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events';
const MOCK_ADDRESS_1 = '0x0';
const MOCK_ADDRESS_2 = '0x1';
@@ -64,6 +69,7 @@ Image.getSize = jest.fn(
);
const mockNavigate = jest.fn();
const mockSetOptions = jest.fn();
+const mockGoBack = jest.fn();
jest.mock('@react-navigation/native', () => {
const actualReactNavigation = jest.requireActual('@react-navigation/native');
@@ -72,6 +78,7 @@ jest.mock('@react-navigation/native', () => {
useNavigation: () => ({
navigate: mockNavigate,
setOptions: mockSetOptions,
+ goBack: mockGoBack,
}),
useRoute: () => ({
key: '1',
@@ -84,6 +91,24 @@ jest.mock('@react-navigation/native', () => {
};
});
+const mockAddProperties = jest.fn().mockReturnThis();
+const mockBuild = jest.fn().mockReturnValue({
+ name: 'UNSTAKE_CONFIRMATION_BACK_CLICKED',
+});
+const mockEventBuilder = {
+ addProperties: mockAddProperties,
+ build: mockBuild,
+};
+const mockCreateEventBuilder = jest.fn().mockReturnValue(mockEventBuilder);
+const mockTrackEvent = jest.fn();
+
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
jest.mock('../../hooks/usePoolStakedDeposit', () => ({
__esModule: true,
default: () => ({
@@ -104,14 +129,50 @@ jest.mock('../../hooks/useStakeContext', () => ({
}));
describe('UnstakeConfirmationView', () => {
- it('renders unstake confirmation view', () => {
- const { getByText } = renderWithProvider(, {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderView = () =>
+ renderWithProvider(, {
state: mockInitialState,
});
+ it('renders unstake confirmation view', () => {
+ const { getByText } = renderView();
+
expect(getByText(strings('stake.unstaking_to'))).toBeOnTheScreen();
expect(getByText(strings('stake.interacting_with'))).toBeOnTheScreen();
expect(getByText('Cancel')).toBeOnTheScreen();
expect(getByText('Continue')).toBeOnTheScreen();
});
+
+ it('renders header with the unstake title', () => {
+ const { getByText } = renderView();
+
+ expect(getByText(strings('stake.unstake'))).toBeOnTheScreen();
+ });
+
+ it('calls navigation.goBack on back press', () => {
+ const { getByTestId } = renderView();
+
+ fireEvent.press(getByTestId(UNSTAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID));
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('tracks UNSTAKE_CONFIRMATION_BACK_CLICKED on back press', () => {
+ const { getByTestId } = renderView();
+
+ fireEvent.press(getByTestId(UNSTAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID));
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.UNSTAKE_CONFIRMATION_BACK_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ selected_provider: EVENT_PROVIDERS.CONSENSYS,
+ location: EVENT_LOCATIONS.UNSTAKE_CONFIRMATION_VIEW,
+ });
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx
index 73ce556b5c78..84c4fa57c588 100644
--- a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx
+++ b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx
@@ -1,9 +1,10 @@
+import React, { useCallback } from 'react';
import { View } from 'react-native';
-import React, { useEffect } from 'react';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
+import { Box, HeaderStandard } from '@metamask/design-system-react-native';
+import { useSelector } from 'react-redux';
import styleSheet from './UnstakeConfirmationView.styles';
import { useStyles } from '../../../../hooks/useStyles';
-import { getStakingNavbar } from '../../../Navbar';
import { strings } from '../../../../../../locales/i18n';
import UnstakingTimeCard from '../../components/StakingConfirmation/UnstakeTimeCard/UnstakeTimeCard';
import { UnstakeConfirmationViewRouteParams } from './UnstakeConfirmationView.types';
@@ -12,68 +13,71 @@ import AccountCard from '../../components/StakingConfirmation/AccountCard/Accoun
import ConfirmationFooter from '../../components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter';
import { FooterButtonGroupActions } from '../../components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.types';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events';
import { getDecimalChainId } from '../../../../../util/networks';
-import { useSelector } from 'react-redux';
import { selectEvmChainId } from '../../../../../selectors/networkController';
const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking';
+export const UNSTAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID =
+ 'unstake-confirmation-header-back-button';
+
const UnstakeConfirmationView = () => {
const route =
useRoute<
RouteProp<{ params: UnstakeConfirmationViewRouteParams }, 'params'>
>();
- const { styles, theme } = useStyles(styleSheet, {});
+ const { styles } = useStyles(styleSheet, {});
const chainId = useSelector(selectEvmChainId);
const navigation = useNavigation();
+ const { trackEvent, createEventBuilder } = useAnalytics();
- useEffect(() => {
- navigation.setOptions(
- getStakingNavbar(
- strings('stake.unstake'),
- navigation,
- theme.colors,
- {
- backgroundColor: theme.colors.background.default,
- hasCancelButton: false,
- },
- {
- backButtonEvent: {
- event: MetaMetricsEvents.UNSTAKE_CONFIRMATION_BACK_CLICKED,
- properties: {
- selected_provider: EVENT_PROVIDERS.CONSENSYS,
- location: EVENT_LOCATIONS.UNSTAKE_CONFIRMATION_VIEW,
- },
- },
- },
- ),
+ const handleBackPress = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.UNSTAKE_CONFIRMATION_BACK_CLICKED)
+ .addProperties({
+ selected_provider: EVENT_PROVIDERS.CONSENSYS,
+ location: EVENT_LOCATIONS.UNSTAKE_CONFIRMATION_VIEW,
+ })
+ .build(),
);
- }, [navigation, theme.colors]);
+ navigation.goBack();
+ }, [navigation, trackEvent, createEventBuilder]);
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
);
};
diff --git a/app/components/UI/Stake/routes/index.tsx b/app/components/UI/Stake/routes/index.tsx
index 51c460bda357..e08f6e25e32d 100644
--- a/app/components/UI/Stake/routes/index.tsx
+++ b/app/components/UI/Stake/routes/index.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { createStackNavigator } from '@react-navigation/stack';
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
import Routes from '../../../../constants/navigation/Routes';
import { Confirm } from '../../../Views/confirmations/components/confirm';
import StakeConfirmationView from '../Views/StakeConfirmationView/StakeConfirmationView';
@@ -16,10 +16,13 @@ import EarnTokenList from '../../Earn/components/EarnTokenList';
import EarnInputView from '../../Earn/Views/EarnInputView/EarnInputView';
import EarnWithdrawInputView from '../../Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView';
import { useEmptyNavHeaderForConfirmations } from '../../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations';
-import { clearStackNavigatorOptions } from '../../../../constants/navigation/clearStackNavigatorOptions';
+import {
+ clearNativeStackNavigatorOptions,
+ transparentModalScreenOptions,
+} from '../../../../constants/navigation/clearStackNavigatorOptions';
-const Stack = createStackNavigator();
-const ModalStack = createStackNavigator();
+const Stack = createNativeStackNavigator();
+const ModalStack = createNativeStackNavigator();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ScreenComponent = React.ComponentType;
@@ -30,8 +33,16 @@ const StakeScreenStack = () => {
return (
-
-
+
+
{
(
Date: Wed, 20 May 2026 18:17:17 -0400
Subject: [PATCH 08/12] refactor: migrate MultichainAccountConnect,
PrivateKeyList, AddressList, and IntroModal to design-system-react-native
(#30183)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR updates `MultichainAccountConnect`, `PrivateKeyList`,
`AddressList`, and `IntroModal`/`LearnMoreBottomSheet` to use the
design-system `Toast`, `Text`, `BottomSheet`, and related types/imports.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1692
## **Manual testing steps**
```gherkin
Feature: Multichain account design system migration
Scenario: user connects multichain accounts to a dapp
Given the app receives a multichain account connection request
When the user reviews the account and network permissions
And the user confirms the connection
Then the connection flow completes successfully
And the permissions updated toast is shown
Scenario: user copies an address from the Address List
Given the user is viewing the Multichain Account Address List
When the user taps the copy button for an address
Then the address is copied to the clipboard
And the copied-to-clipboard toast is shown
Scenario: user reveals and copies a private key
Given the user is viewing the Private Key List
When the user enters the correct password
And taps copy for a private key row
Then the private key is copied with expiration
And the copied toast is shown
Scenario: user opens the Learn More bottom sheet
Given the Multichain Accounts intro modal is visible
When the user opens the Learn More bottom sheet
Then the bottom sheet renders correctly
And the checkbox enables the confirmation button
And the back and close buttons dismiss the sheet
```
## **Screenshots/Recordings**
https://github.com/user-attachments/assets/6ad8efb8-d265-4287-becd-d19c6422e1fd
## **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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**
> Mostly UI/refactor work, but it touches the multichain dapp connect
flow and private key copying screens where regressions could impact key
user journeys (toasts, navigation headers, and permission-updated
feedback). No changes to permission decisions or key material handling
beyond how feedback is displayed.
>
> **Overview**
> Migrates Multichain Accounts UI surfaces (`MultichainAccountConnect`,
`AddressList`, `PrivateKeyList`, and `IntroModal/LearnMoreBottomSheet`)
off the legacy `ToastContext`/component-library primitives onto
`@metamask/design-system-react-native` (`toast`, `Toaster`,
`BottomSheet`, and `Text`). Copy and connect confirmation feedback now
uses design-system `toast()` calls with per-screen ``
mounting, including a favicon accessory for the *permissions updated*
toast in `MultichainAccountConnect`.
>
> Updates `MultichainAddressRow` copy API to make `toastMessage`
optional and only trigger the legacy row-managed toast when **both**
`toastRef` and `toastMessage` are provided, enabling callers to own
toast behavior. Refreshes tests/stories accordingly (new toast mocks and
assertions, added header back-button test), and includes small UI tweaks
like tab bar padding and a reduced network-name max width in
`MultichainPermissionsSummary`.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
903fa372ac8f650c76a8c4f12e8cbe03f60df603. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../MultichainAddressRow.tsx | 4 +-
.../MultichainAddressRow.types.ts | 7 +-
.../AddressList/AddressList.test.tsx | 57 ++++++-
.../AddressList/AddressList.tsx | 21 ++-
.../IntroModal/LearnMoreBottomSheet.test.tsx | 47 +++---
.../IntroModal/LearnMoreBottomSheet.tsx | 11 +-
.../MultichainAccountConnect.stories.tsx | 16 +-
.../MultichainAccountConnect.tsx | 57 +++----
.../MultichainAccountConnectMultiSelector.tsx | 7 +-
.../MultichainPermissionsSummary.styles.ts | 2 +-
.../MultichainPermissionsSummary.test.tsx | 151 ++++++++++++++++--
.../MultichainPermissionsSummary.tsx | 77 +++++----
.../PrivateKeyList/PrivateKeyList.test.tsx | 54 +++++++
.../PrivateKeyList/PrivateKeyList.tsx | 16 +-
14 files changed, 388 insertions(+), 139 deletions(-)
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx
index 38b2b7152237..d525a391f7b8 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx
@@ -69,8 +69,8 @@ const MultichainAddressRow = ({
setIconState('copy');
}, 400);
- // Show toast if ref provided
- if (copyParams.toastRef?.current) {
+ // Show legacy row-managed toast only when both ref and message are provided.
+ if (copyParams.toastRef?.current && copyParams.toastMessage) {
copyParams.toastRef.current.showToast({
variant: ToastVariants.Plain,
labelOptions: [{ label: copyParams.toastMessage }],
diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.types.ts b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.types.ts
index a9834a7f3bcd..3961cfbc1e14 100644
--- a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.types.ts
+++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.types.ts
@@ -28,14 +28,13 @@ export interface CopyParams {
*/
callback: () => Promise;
/**
- * Technically optional to keep types simple but toast ref for showing toast notification
- * should always be present if copyParams is used. This ensures consistent behavior
+ * Optional toast ref for legacy callers that need the row to show a toast.
*/
toastRef?: React.RefObject;
/**
- * Required toast message. Specify what is being copied e.g. "Address copied", "Private key copied", etc
+ * Toast message used when toastRef is provided.
*/
- toastMessage: string;
+ toastMessage?: string;
}
export interface MultichainAddressRowProps {
diff --git a/app/components/Views/MultichainAccounts/AddressList/AddressList.test.tsx b/app/components/Views/MultichainAccounts/AddressList/AddressList.test.tsx
index 5febaf2a16c4..a1f496b30269 100644
--- a/app/components/Views/MultichainAccounts/AddressList/AddressList.test.tsx
+++ b/app/components/Views/MultichainAccounts/AddressList/AddressList.test.tsx
@@ -1,7 +1,8 @@
import React from 'react';
-import { fireEvent } from '@testing-library/react-native';
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
import { AccountGroupId, AccountWalletId } from '@metamask/account-api';
import { SolAccountType, EthScope, SolScope } from '@metamask/keyring-api';
+import { IconName, toast } from '@metamask/design-system-react-native';
import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils';
import renderWithProvider from '../../../../util/test/renderWithProvider';
@@ -10,6 +11,8 @@ import { AddressList } from './AddressList';
import { MULTICHAIN_ADDRESS_ROW_QR_BUTTON_TEST_ID } from '../../../../component-library/components-temp/MultichainAccounts/MultichainAddressRow';
import { toFormattedAddress } from '../../../../util/address';
import { EVENT_NAME } from '../../../../core/Analytics/MetaMetrics.events';
+import { strings } from '../../../../../locales/i18n';
+import { AddressListIds } from './AddressList.testIds';
const ACCOUNT_WALLET_ID = 'entropy:wallet-id-1' as AccountWalletId;
const ACCOUNT_GROUP_ID = 'entropy:wallet-id-1/1' as AccountGroupId;
@@ -55,6 +58,20 @@ jest.mock('../../../../core/ClipboardManager', () => ({
setString: jest.fn(),
}));
+jest.mock('@metamask/design-system-react-native', () => {
+ const actualDesignSystem = jest.requireActual(
+ '@metamask/design-system-react-native',
+ );
+
+ return {
+ ...actualDesignSystem,
+ Toaster: jest.fn(() => null),
+ toast: Object.assign(jest.fn(), {
+ dismiss: jest.fn(),
+ }),
+ };
+});
+
const mockEthEoaAccount = {
...createMockInternalAccount(
'0x4fec2622fb662e892dd0e5060b91fa49ddcfdcb5',
@@ -193,6 +210,27 @@ describe('AddressList', () => {
});
});
+ it('calls navigation.goBack from the header back button', () => {
+ renderWithAddressList();
+
+ const navOptionsWithHeader = mockSetOptions.mock.calls
+ .map(([opts]) => opts)
+ .find(
+ (opts) =>
+ opts &&
+ opts.headerShown === true &&
+ typeof opts.header === 'function',
+ );
+
+ expect(navOptionsWithHeader).toBeDefined();
+
+ const { getByTestId, unmount } = render(navOptionsWithHeader.header());
+ fireEvent.press(getByTestId(AddressListIds.GO_BACK));
+
+ expect(mockGoBack).toHaveBeenCalled();
+ unmount();
+ });
+
it('does not set navigation options when title is not provided', () => {
const { useParams } = jest.requireMock(
'../../../../util/navigation/navUtils',
@@ -307,5 +345,22 @@ describe('AddressList', () => {
expect(addPropertiesCall).toHaveProperty('location', 'address-list');
});
+
+ it('shows the design system copy toast', async () => {
+ const { getAllByTestId } = renderWithAddressList();
+
+ const copyButton = getAllByTestId(
+ 'multichain-address-row-copy-button',
+ )[0];
+ expect(copyButton).toBeDefined();
+ fireEvent.press(copyButton);
+
+ await waitFor(() => {
+ expect(toast).toHaveBeenCalledWith({
+ description: strings('notifications.address_copied_to_clipboard'),
+ hasNoTimeout: false,
+ });
+ });
+ });
});
});
diff --git a/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx b/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx
index 18b112e5b45a..d23796de4193 100644
--- a/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx
+++ b/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useContext, useLayoutEffect } from 'react';
+import React, { useCallback, useLayoutEffect } from 'react';
import { View } from 'react-native';
import { useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
@@ -7,7 +7,7 @@ import { FlashList } from '@shopify/flash-list';
import { useStyles } from '../../../hooks/useStyles';
import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
import { selectInternalAccountListSpreadByScopesByGroupId } from '../../../../selectors/multichainAccounts/accounts';
-import { IconName } from '@metamask/design-system-react-native';
+import { IconName, Toaster, toast } from '@metamask/design-system-react-native';
import MultichainAddressRow, {
MULTICHAIN_ADDRESS_ROW_QR_BUTTON_TEST_ID,
} from '../../../../component-library/components-temp/MultichainAccounts/MultichainAddressRow';
@@ -22,7 +22,6 @@ import styleSheet from './styles';
import type { AddressListProps, AddressItem } from './types';
import ClipboardManager from '../../../../core/ClipboardManager';
import getHeaderCompactStandardNavbarOptions from '../../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions';
-import { ToastContext } from '../../../../component-library/components/Toast';
import { strings } from '../../../../../locales/i18n';
import { EVENT_NAME } from '../../../../core/Analytics/MetaMetrics.events';
@@ -39,7 +38,6 @@ export const createAddressListNavigationDetails =
export const AddressList = () => {
const navigation = useNavigation();
const { styles } = useStyles(styleSheet, {});
- const { toastRef } = useContext(ToastContext);
const { trackEvent, createEventBuilder } = useAnalytics();
const { groupId, title, onLoad } = useParams();
@@ -70,9 +68,15 @@ export const AddressList = () => {
networkName={item.networkName}
address={item.account.address}
copyParams={{
- toastMessage: strings('notifications.address_copied_to_clipboard'),
- callback: copyAddressToClipboard,
- toastRef,
+ callback: async () => {
+ await copyAddressToClipboard();
+ toast({
+ description: strings(
+ 'notifications.address_copied_to_clipboard',
+ ),
+ hasNoTimeout: false,
+ });
+ },
}}
icons={[
{
@@ -98,7 +102,7 @@ export const AddressList = () => {
/>
);
},
- [navigation, groupId, toastRef, trackEvent, createEventBuilder],
+ [navigation, groupId, trackEvent, createEventBuilder],
);
useLayoutEffect(() => {
@@ -123,6 +127,7 @@ export const AddressList = () => {
renderItem={renderAddressItem}
onLoad={onLoad}
/>
+
);
};
diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx
index c33a8dab3c3f..7e01c4bcfdcb 100644
--- a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx
+++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx
@@ -15,31 +15,28 @@ const mockDispatch = jest.fn();
// Mock the BottomSheet component
const mockOnCloseBottomSheet = jest.fn();
// eslint-disable-next-line import-x/no-commonjs
-jest.mock(
- '../../../../component-library/components/BottomSheets/BottomSheet',
- () => {
- // eslint-disable-next-line @typescript-eslint/no-require-imports, import-x/no-commonjs, @typescript-eslint/no-var-requires
- const ReactMock = require('react');
- return {
- __esModule: true,
- default: ReactMock.forwardRef(
- (
- { children }: { children: React.ReactNode },
- ref: React.Ref<{ onCloseBottomSheet: () => void }>,
- ) => {
- ReactMock.useImperativeHandle(ref, () => ({
- onCloseBottomSheet: mockOnCloseBottomSheet,
- }));
- return ReactMock.createElement(
- 'View',
- { testID: 'bottom-sheet' },
- children,
- );
- },
- ),
- };
- },
-);
+jest.mock('@metamask/design-system-react-native', () => {
+ const actualDesignSystem = jest.requireActual(
+ '@metamask/design-system-react-native',
+ );
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, import-x/no-commonjs, @typescript-eslint/no-var-requires
+ const ReactMock = require('react');
+
+ return {
+ ...actualDesignSystem,
+ BottomSheet: ReactMock.forwardRef(
+ (
+ { children, testID }: { children: React.ReactNode; testID?: string },
+ ref: React.Ref<{ onCloseBottomSheet: () => void }>,
+ ) => {
+ ReactMock.useImperativeHandle(ref, () => ({
+ onCloseBottomSheet: mockOnCloseBottomSheet,
+ }));
+ return ReactMock.createElement('View', { testID }, children);
+ },
+ ),
+ };
+});
// Mock React Navigation
jest.mock('@react-navigation/native', () => {
diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx
index ae95fb07b9fc..daad79080bb7 100644
--- a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx
+++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx
@@ -10,10 +10,9 @@ import {
Button,
ButtonVariant,
ButtonBaseSize,
+ BottomSheet,
+ type BottomSheetRef,
} from '@metamask/design-system-react-native';
-import BottomSheet, {
- BottomSheetRef,
-} from '../../../../component-library/components/BottomSheets/BottomSheet';
import { useNavigation, useTheme } from '@react-navigation/native';
import { strings } from '../../../../../locales/i18n';
import { useStyles } from '../../../../component-library/hooks';
@@ -63,7 +62,11 @@ const LearnMoreBottomSheet: React.FC = ({
}, [isCheckboxChecked, navigation, isBasicFunctionalityEnabled, dispatch]);
return (
-
+
(
-
-
-
-
-
-
-
+
+
+
+
+
+
),
],
diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx
index e713298b0080..79df1f7cd17e 100644
--- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx
+++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx
@@ -2,7 +2,6 @@
import { useNavigation } from '@react-navigation/native';
import React, {
useCallback,
- useContext,
useEffect,
useMemo,
useRef,
@@ -15,10 +14,12 @@ import { NON_EVM_TESTNET_IDS } from '@metamask/multichain-network-controller';
// External dependencies.
import { strings } from '../../../../../locales/i18n.js';
import {
- ToastContext,
- ToastVariants,
-} from '../../../../component-library/components/Toast/index.ts';
-import { ToastOptions } from '../../../../component-library/components/Toast/Toast.types.ts';
+ AvatarFavicon,
+ AvatarFaviconSize,
+ Box,
+ Toaster,
+ toast,
+} from '@metamask/design-system-react-native';
import { USER_INTENT } from '../../../../constants/permissions.ts';
import { MetaMetricsEvents } from '../../../../core/Analytics/index.ts';
import Engine from '../../../../core/Engine/index.ts';
@@ -49,12 +50,13 @@ import useFavicon from '../../../hooks/useFavicon/useFavicon.ts';
import {
AccountConnectProps,
AccountConnectScreens,
+ NetworkAvatarProps,
} from '../../AccountConnect/AccountConnect.types.ts';
-import { getNetworkImageSource } from '../../../../util/networks/index.js';
import {
AvatarSize,
AvatarVariant,
-} from '../../../../component-library/components/Avatars/Avatar/index.ts';
+} from '../../../../component-library/components/Avatars/Avatar';
+import { getNetworkImageSource } from '../../../../util/networks/index.js';
import {
EvmAndMultichainNetworkConfigurationsWithCaipChainId,
getSelectedMultichainNetwork,
@@ -97,7 +99,6 @@ import { getPermissions } from '../../../../selectors/snaps/index.ts';
import { useSDKV2Connection } from '../../../hooks/useSDKV2Connection';
import { useAccountGroupsForPermissions } from '../../../hooks/useAccountGroupsForPermissions/useAccountGroupsForPermissions.ts';
import NetworkConnectMultiSelector from '../../NetworkConnect/NetworkConnectMultiSelector/index.ts';
-import { Box } from '@metamask/design-system-react-native';
import { TESTNET_CAIP_IDS } from '../../../../constants/network.js';
import { getCaip25AccountIdsFromAccountGroupAndScope } from '../../../../util/multichain/getCaip25AccountIdsFromAccountGroupAndScope.ts';
import { isSnapId } from '@metamask/snaps-utils';
@@ -124,6 +125,9 @@ const ScreenContainer: React.FC = ({
);
+const NETWORK_AVATAR_SIZE = AvatarSize.Xs;
+const NETWORK_AVATAR_VARIANT = AvatarVariant.Network;
+
const MultichainAccountConnect = (props: AccountConnectProps) => {
const { colors } = useTheme();
const { styles } = useStyles(styleSheet, {});
@@ -397,13 +401,15 @@ const MultichainAccountConnect = (props: AccountConnectProps) => {
.filter(
(selectedChainId) => !NON_EVM_TESTNET_IDS.includes(selectedChainId),
)
- .map((selectedChainId) => ({
- size: AvatarSize.Xs,
- name: networkConfigurations[selectedChainId]?.name || '',
- imageSource: getNetworkImageSource({ chainId: selectedChainId }),
- variant: AvatarVariant.Network,
- caipChainId: selectedChainId,
- })),
+ .map(
+ (selectedChainId): NetworkAvatarProps => ({
+ size: NETWORK_AVATAR_SIZE,
+ name: networkConfigurations[selectedChainId]?.name || '',
+ imageSource: getNetworkImageSource({ chainId: selectedChainId }),
+ variant: NETWORK_AVATAR_VARIANT,
+ caipChainId: selectedChainId,
+ }),
+ ),
[networkConfigurations, selectedChainIds],
);
@@ -471,8 +477,6 @@ const MultichainAccountConnect = (props: AccountConnectProps) => {
const [userIntent, setUserIntent] = useState(USER_INTENT.None);
const isMountedRef = useRef(true);
- const { toastRef } = useContext(ToastContext);
-
const accountsLength = useSelector(selectAccountsLength);
const dappUrl =
@@ -714,15 +718,14 @@ const MultichainAccountConnect = (props: AccountConnectProps) => {
.build(),
);
- const labelOptions: ToastOptions['labelOptions'] =
- connectedAccountLength >= 1
- ? [{ label: strings('toast.permissions_updated') }]
- : [];
-
- toastRef?.current?.showToast({
- variant: ToastVariants.App,
- labelOptions,
- appIconSource: faviconSource,
+ toast({
+ description:
+ connectedAccountLength >= 1
+ ? strings('toast.permissions_updated')
+ : undefined,
+ startAccessory: (
+
+ ),
hasNoTimeout: false,
});
} catch (e) {
@@ -745,7 +748,6 @@ const MultichainAccountConnect = (props: AccountConnectProps) => {
createEventBuilder,
accountsLength,
originSource,
- toastRef,
faviconSource,
referrer,
]);
@@ -996,6 +998,7 @@ const MultichainAccountConnect = (props: AccountConnectProps) => {
)}
{renderPhishingModal()}
+
);
};
diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx
index 7101315a946c..ec2878dfca55 100644
--- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx
+++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx
@@ -9,11 +9,10 @@ import {
Button,
ButtonVariant,
ButtonBaseSize,
+ Text,
+ TextColor,
} from '@metamask/design-system-react-native';
import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader';
-import Text, {
- TextColor,
-} from '../../../../../component-library/components/Texts/Text';
import { useStyles } from '../../../../../component-library/hooks';
import HelpText, {
HelpTextSeverity,
@@ -179,7 +178,7 @@ const MultichainAccountConnectMultiSelector = ({
{connection?.originatorInfo?.apiVersion && (
-
+
{strings('permissions.sdk_connection', {
originator_platform: connection?.originatorInfo?.platform,
api_version: connection?.originatorInfo?.apiVersion,
diff --git a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.styles.ts b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.styles.ts
index 141a83876096..db3bb30b3175 100644
--- a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.styles.ts
+++ b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.styles.ts
@@ -92,7 +92,7 @@ const createStyles = (params: { theme: Theme }) => {
},
permissionRequestNetworkName: {
marginRight: 4,
- maxWidth: '75%',
+ maxWidth: '60%',
},
avatarGroup: {
marginLeft: 2,
diff --git a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.test.tsx b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.test.tsx
index 39237f7db7ac..ce192ca2fd14 100644
--- a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.test.tsx
+++ b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.test.tsx
@@ -8,16 +8,144 @@ import renderWithProvider, {
DeepPartial,
} from '../../../../util/test/renderWithProvider';
import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils';
-import {
- AvatarSize,
- AvatarVariant,
-} from '../../../../component-library/components/Avatars/Avatar/Avatar.types';
+import { NetworkAvatarProps } from '../../AccountConnect/AccountConnect.types';
import { RootState } from '../../../../reducers';
import { CommonSelectorsIDs } from '../../../../util/Common.testIds';
import { ConnectedAccountsSelectorsIDs } from '../../AccountConnect/ConnectedAccountModal.testIds';
import { PermissionSummaryBottomSheetSelectorsIDs } from '../../AccountConnect/PermissionSummaryBottomSheet.testIds';
import { NetworkNonPemittedBottomSheetSelectorsIDs } from '../../NetworkConnect/NetworkNonPemittedBottomSheet.testIds';
+jest.mock('@metamask/design-system-twrnc-preset', () => {
+ const tw = (..._args: unknown[]) => ({});
+ tw.style = jest.fn(() => ({}));
+ return { useTailwind: () => tw };
+});
+
+jest.mock('../../../../component-library/components/Avatars/Avatar', () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () => ,
+ AvatarSize: { Xs: '16', Sm: '24', Md: '32', Lg: '40', Xl: '48' },
+ AvatarVariant: {
+ Account: 'Account',
+ Favicon: 'Favicon',
+ Icon: 'Icon',
+ Network: 'Network',
+ Token: 'Token',
+ },
+ };
+});
+
+jest.mock(
+ '../../../../component-library/components/Avatars/AvatarGroup',
+ () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () => ,
+ };
+ },
+);
+
+jest.mock('../../../../component-library/components/Texts/Text', () => {
+ const { Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: Text,
+ TextColor: {
+ Default: 'Default',
+ Error: 'Error',
+ Alternative: 'Alternative',
+ },
+ TextVariant: {
+ BodyMD: 'sBodyMD',
+ BodyMDMedium: 'sBodyMDMedium',
+ BodySM: 'sBodySM',
+ BodySMMedium: 'sBodySMMedium',
+ HeadingMD: 'sHeadingMD',
+ },
+ };
+});
+
+jest.mock('../../../../component-library/components/Icons/Icon', () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({ name }: { name: string }) => (
+
+ ),
+ IconColor: { Alternative: 'Alternative', Default: 'Default' },
+ IconName: {
+ ArrowLeft: 'ArrowLeft',
+ ArrowRight: 'ArrowRight',
+ Data: 'Data',
+ Info: 'Info',
+ Wallet: 'Wallet',
+ },
+ IconSize: { Sm: 'Sm', Md: 'Md' },
+ };
+});
+
+jest.mock('../../../../component-library/components/Buttons/ButtonIcon', () => {
+ const { Pressable } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({
+ onPress,
+ testID,
+ }: {
+ onPress?: () => void;
+ testID?: string;
+ }) => ,
+ ButtonIconSizes: { Sm: 'Sm' },
+ };
+});
+
+jest.mock(
+ '../../../../component-library/components/Badges/BadgeWrapper',
+ () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ };
+ },
+);
+
+jest.mock('../../../../component-library/components/Badges/Badge', () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () => ,
+ BadgeVariant: { Network: 'Network' },
+ };
+});
+
+jest.mock(
+ '../../../../component-library/components/Avatars/Avatar/variants/AvatarFavicon',
+ () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () => ,
+ };
+ },
+);
+
+jest.mock(
+ '../../../../component-library/components/Avatars/Avatar/variants/AvatarToken',
+ () => {
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () => ,
+ };
+ },
+);
+
const mockOnEdit = jest.fn();
const mockOnEditNetworks = jest.fn();
const mockOnBack = jest.fn();
@@ -41,19 +169,24 @@ const MOCK_CURRENT_PAGE_INFORMATION = {
url: 'https://mock-dapp.example.com/',
};
-const MOCK_NETWORK_AVATARS = [
+/** Legacy pixel sizes used by NetworkAvatarProps until AccountConnect.types migrates. */
+const LEGACY_NETWORK_AVATAR_SIZE = '16' as NetworkAvatarProps['size'];
+const LEGACY_NETWORK_AVATAR_VARIANT =
+ 'Network' as NetworkAvatarProps['variant'];
+
+const MOCK_NETWORK_AVATARS: NetworkAvatarProps[] = [
{
name: 'Ethereum Mainnet',
imageSource: { uri: 'test-network-avatar.png' },
- size: AvatarSize.Xs,
- variant: AvatarVariant.Network,
+ size: LEGACY_NETWORK_AVATAR_SIZE,
+ variant: LEGACY_NETWORK_AVATAR_VARIANT,
caipChainId: 'eip155:1' as CaipChainId,
},
{
name: 'Polygon',
imageSource: { uri: 'test-polygon-avatar.png' },
- size: AvatarSize.Xs,
- variant: AvatarVariant.Network,
+ size: LEGACY_NETWORK_AVATAR_SIZE,
+ variant: LEGACY_NETWORK_AVATAR_VARIANT,
caipChainId: 'eip155:137' as CaipChainId,
},
];
diff --git a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx
index e8115bee65c8..fb11a34204da 100644
--- a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx
+++ b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx
@@ -16,12 +16,11 @@ import Icon, {
IconName,
IconSize,
} from '../../../../component-library/components/Icons/Icon';
-import TextComponent, {
- TextColor,
- TextVariant,
-} from '../../../../component-library/components/Texts/Text';
import AvatarGroup from '../../../../component-library/components/Avatars/AvatarGroup';
import {
+ Text,
+ TextColor,
+ TextVariant,
Button,
ButtonVariant,
ButtonBaseSize,
@@ -68,6 +67,8 @@ import { AccountGroupObject } from '@metamask/account-tree-controller';
import { selectIconSeedAddressesByAccountGroupIds } from '../../../../selectors/multichainAccounts/accounts';
import { RootState } from '../../../../reducers';
+const TAB_BAR_HORIZONTAL_PADDING = 0;
+
export interface MultichainPermissionsSummaryProps {
currentPageInformation: {
currentEnsName: string;
@@ -276,12 +277,9 @@ const MultichainPermissionsSummary = ({
) : (
-
+
{strings('permissions.edit')}
-
+
)}
@@ -373,22 +371,22 @@ const MultichainPermissionsSummary = ({
iconColor={colors.icon.alternative}
/>
-
+
{strings('permissions.see_your_accounts')}
-
+
-
-
+
{strings('permissions.requesting_for')}
-
-
+
+
-
+
{strings('permissions.use_enabled_networks')}
-
+
{(isNetworkSwitch || isNonDappNetworkSwitch) && (
<>
-
-
+
+
{strings('permissions.requesting_for')}
-
-
+
+
{isNonDappNetworkSwitch
? networkName || providerConfig.nickname
: chainName}
-
-
+
+
-
-
+
+
{getNetworkLabel()}
-
-
+
+
) => (
-
+
),
[colors],
);
@@ -577,12 +579,12 @@ const MultichainPermissionsSummary = ({
PermissionSummaryBottomSheetSelectorsIDs.NETWORK_PERMISSIONS_CONTAINER
}
>
-
@@ -593,19 +595,16 @@ const MultichainPermissionsSummary = ({
: strings('permissions.title_dapp_url_has_approval_to', {
dappUrl: hostname,
})}
-
+
{isMaliciousDapp && !isAlreadyConnected && }
-
+
{strings('account_dapp_connections.account_summary_header')}
-
+
{isNonDappNetworkSwitch && (
-
+
{strings('permissions.non_permitted_network_description')}
-
+
)}
{!nonTabView ? (
{renderTabsContent()}
diff --git a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx
index 33d693e2de4c..dd2ebb2083c1 100644
--- a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx
+++ b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx
@@ -3,6 +3,7 @@ import { fireEvent, render, waitFor } from '@testing-library/react-native';
import { Platform } from 'react-native';
import { AccountGroupId, AccountWalletId } from '@metamask/account-api';
import { SolAccountType, EthScope, SolScope } from '@metamask/keyring-api';
+import { toast } from '@metamask/design-system-react-native';
import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils';
import { renderScreen } from '../../../../util/test/renderWithProvider';
@@ -63,6 +64,24 @@ jest.mock('../../../../core/Engine', () => ({
},
}));
+jest.mock('../../../../core/ClipboardManager', () => ({
+ setStringExpire: jest.fn(),
+}));
+
+jest.mock('@metamask/design-system-react-native', () => {
+ const actualDesignSystem = jest.requireActual(
+ '@metamask/design-system-react-native',
+ );
+
+ return {
+ ...actualDesignSystem,
+ Toaster: jest.fn(() => null),
+ toast: Object.assign(jest.fn(), {
+ dismiss: jest.fn(),
+ }),
+ };
+});
+
const mockEthEoaAccount = {
...createMockInternalAccount(
'0x4fec2622fb662e892dd0e5060b91fa49ddcfdcb5',
@@ -239,6 +258,41 @@ describe('PrivateKeyList', () => {
unmount();
});
+ it('copies a private key and shows the design system toast', async () => {
+ const { getByTestId, findByTestId, getAllByTestId } =
+ renderWithPrivateKeyList();
+ const mockClipboardManager = jest.requireMock(
+ '../../../../core/ClipboardManager',
+ ) as { setStringExpire: jest.Mock };
+
+ fireEvent.changeText(
+ getByTestId(PrivateKeyListIds.PASSWORD_INPUT),
+ 'correct-password',
+ );
+ fireEvent.press(getByTestId(PrivateKeyListIds.CONTINUE_BUTTON));
+
+ await findByTestId(PrivateKeyListIds.LIST);
+
+ const copyButton = getAllByTestId(
+ PrivateKeyListIds.COPY_TO_CLIPBOARD_BUTTON,
+ )[0];
+ expect(copyButton).toBeDefined();
+ fireEvent.press(copyButton);
+
+ await waitFor(() => {
+ expect(mockClipboardManager.setStringExpire).toHaveBeenCalledWith(
+ `mock-private-key-for-${mockEthEoaAccount.address}`,
+ );
+ });
+
+ await waitFor(() => {
+ expect(toast).toHaveBeenCalledWith({
+ description: strings('multichain_accounts.private_key_list.copied'),
+ hasNoTimeout: false,
+ });
+ });
+ });
+
it('clears wrong-password error and shows list when correct password is entered after wrong', async () => {
const { getByTestId, findByTestId, queryByTestId } =
renderWithPrivateKeyList();
diff --git a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx
index 0895a47575cf..2ca54c0ae568 100644
--- a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx
+++ b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx
@@ -3,7 +3,6 @@ import React, {
useEffect,
useCallback,
useMemo,
- useContext,
useLayoutEffect,
} from 'react';
import { TextInput, Linking } from 'react-native';
@@ -26,12 +25,13 @@ import {
Button,
ButtonVariant,
ButtonSize,
+ Toaster,
+ toast,
} from '@metamask/design-system-react-native';
import Engine from '../../../../core/Engine';
import ClipboardManager from '../../../../core/ClipboardManager';
import MultichainAddressRow from '../../../../component-library/components-temp/MultichainAccounts/MultichainAddressRow';
import getHeaderCompactStandardNavbarOptions from '../../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions';
-import { ToastContext } from '../../../../component-library/components/Toast';
import { strings } from '../../../../../locales/i18n';
import {
useParams,
@@ -75,7 +75,6 @@ export const PrivateKeyList = () => {
const theme = useTheme();
const { bottom: bottomInset } = useSafeAreaInsets();
- const { toastRef } = useContext(ToastContext);
const [password, setPassword] = useState('');
const [wrongPassword, setWrongPassword] = useState(false);
const [reveal, setReveal] = useState(false);
@@ -184,17 +183,21 @@ export const PrivateKeyList = () => {
networkName={item.networkName}
address={item.account.address}
copyParams={{
- toastMessage: strings('multichain_accounts.private_key_list.copied'),
- toastRef,
callback: async () => {
await ClipboardManager.setStringExpire(
privateKeys[item.account.id],
);
+ toast({
+ description: strings(
+ 'multichain_accounts.private_key_list.copied',
+ ),
+ hasNoTimeout: false,
+ });
},
}}
/>
),
- [privateKeys, toastRef],
+ [privateKeys],
);
const privateKeyBannerDescription = useMemo(
@@ -323,6 +326,7 @@ export const PrivateKeyList = () => {
/>
{reveal ? renderPrivateKeyList() : renderPassword()}
+
);
};
From 22e0934089cb0e0d49fdfc53274e40fc652a12c9 Mon Sep 17 00:00:00 2001
From: Aslau Mario-Daniel
Date: Thu, 21 May 2026 01:22:59 +0300
Subject: [PATCH 09/12] feat(predict): Enable Bottom Sheet via Explore page
cp-7.78.0 (#30481)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Predict market cards on the Explore feed now open the Buy/Sell preview
as an in-place bottom sheet (matching the dedicated Predict feed) when
the `predictBottomSheet` feature flag is on, instead of routing to the
full-page bet slip.
### What changed
1. **Mounted `PredictPreviewSheetProvider` at the `HomeTabs` level** in
`app/components/Nav/Main/MainNavigator.js`, wrapping the
`Tab.Navigator`. The provider was previously only mounted inside
`PredictScreenStack`, so triggering `openBuySheet`/`openSellSheet` from
any other tab fell back to navigation. Mounting at `HomeTabs` makes the
sheet usable from Explore (and any future tab that needs it) while
keeping the existing in-Predict behavior untouched (`PredictScreenStack`
still mounts its own provider; the inner one shadows the outer for
usage).
2. **Why mount at `HomeTabs` and not inside the tab itself?**
`BottomSheet` from `@metamask/design-system-react-native` uses `absolute
inset-0` for its container. If the provider is mounted inside an
individual tab's content area, the sheet's parent is smaller than the
viewport and the sheet gets clipped at the top of the screen and below
by the bottom tab bar. Mounting at `HomeTabs` puts the sheet's parent at
the full-viewport `Home` Stack.Screen card, which is the smallest level
above the tab bar that gives correct dimensions.
3. **Fixed a duplicate-toast and stale-suppression bug** that the new
placement exposes. With both `HomeTabs` and `PredictScreenStack`
providers now mounted simultaneously while the user is inside the
Predict stack, both used to:
- independently fire the state-based "Try again" failure toast on
`activeOrder.error` transitions (no dedup in `ToastService`);
- increment the same `_providerSheetModeCount` counter that gates
`shouldSuppressLegacyOrderFailureToast()`, which then swallowed the
legacy failure toast in tabs/flows where the active provider could not
actually fire its own toast (e.g. Wallet/Trade/Money/Rewards, or
HomepageDiscoveryTabs which mounts in `disableBottomSheet` mode).
Replaced the module-level counter with a registration **stack**
(`_sheetModeProviders`). Each entry holds the provider's id and a
`hasBuyParams()` getter. The topmost (most recently mounted, innermost
in the tree) entry is the only "active" one:
- The state-based toast effect in `PredictPreviewSheetContext.tsx` bails
out unless the current provider is active — so only the innermost
provider fires the Retry toast.
- `shouldSuppressLegacyOrderFailureToast()` now consults the active
entry's `hasBuyParams()`, so the legacy toast is only suppressed when
the active provider will actually surface its own toast.
4. **Test coverage** for the multi-provider scenario in
`PredictPreviewSheetContext.test.tsx`:
- Topmost provider fires the failure toast exactly once when both are
mounted.
- Outer provider becomes active again after the inner unmounts.
- Outer (sheet-mode) provider still fires when the inner provider is
mounted with `disableBottomSheet`.
- `shouldSuppressLegacyOrderFailureToast` correctly tracks the topmost
provider across mount/unmount.
5. Added `PredictPreviewSheetProvider` to the Predict barrel
(`app/components/UI/Predict/index.ts`) for consistency, and a rationale
comment in `MainNavigator.js` explaining why the wrap lives at
`HomeTabs` (so a future maintainer doesn't move it back inside a tab).
### Files touched
- `app/components/Nav/Main/MainNavigator.js`
- `app/components/Nav/Main/MainNavigator.test.tsx` (mock update)
- `app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx`
-
`app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx`
- `app/components/UI/Predict/index.ts`
## **Changelog**
CHANGELOG entry: Added in-place buy/sell bottom sheet to Predict market
cards on the Explore feed when the `predictBottomSheet` feature flag is
enabled.
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: Predict bottom sheet on Explore feed
Scenario: open buy sheet from Explore with feature flag ON
Given the predictBottomSheet feature flag is enabled
And the user is on the Explore tab
And the Explore feed is showing Predict market cards
When the user taps "Yes" on a Predict market card
Then a buy preview bottom sheet opens in place
And the sheet is anchored above the bottom tab bar (no clipping at the top or bottom)
And the user can swipe the sheet down to dismiss it
Scenario: navigation fallback when feature flag is OFF
Given the predictBottomSheet feature flag is disabled
And the user is on the Explore tab
When the user taps "Yes" on a Predict market card
Then the app navigates to the full-page bet slip (legacy behavior, unchanged)
Scenario: Predict tab behavior is unchanged
Given the predictBottomSheet feature flag is enabled
And the user is on the Predict tab
When the user taps an outcome on a market card
Then the buy preview bottom sheet opens in place (existing behavior)
And there is exactly one Retry toast if the order subsequently fails
Scenario: only the topmost provider fires the failure toast
Given the predictBottomSheet feature flag is enabled
And the user opened and dismissed a buy sheet from Explore
And the user navigated to the Predict tab and opened/dismissed another buy sheet
And the user is now on the Predict tab with both sheets dismissed
When the active Predict order transitions to a failed state
Then the user sees exactly one "Try again" toast (not two)
And tapping Retry reopens the most recently used sheet's market context
```
## **Screenshots/Recordings**
### **Before**
### **After**
https://github.com/user-attachments/assets/6bcb24e9-b81c-4c8f-b193-295440cd5805
## **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- [x] I've tested with a power user scenario
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
> _Performance checks N/A: this PR only repositions an existing React
provider higher in the tree and refactors a module-level counter into a
registration stack. No new subscriptions/renders on hot paths; the
failure-toast effect now does strictly less work in non-active
providers._
## **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**
> Touches top-level navigation composition and refactors module-scoped
toast suppression/dedup logic for Predict order failures; regressions
could affect bottom sheet rendering or toast behavior across tabs.
>
> **Overview**
> Enables Predict market cards opened outside the Predict tab (e.g.
Explore) to use the in-place Buy/Sell preview bottom sheet by mounting
`PredictPreviewSheetProvider` above the home `Tab.Navigator`.
>
> Refactors `PredictPreviewSheetContext` to handle multiple
simultaneously-mounted providers via a registration stack: only the
topmost sheet-mode provider can fire the state-driven Retry toast, and
legacy order-failure toast suppression now depends on the active
provider having remembered buy params (reducing stale suppression).
Tests are updated/added to cover multi-provider dedup and the new
suppression behavior, and `PredictPreviewSheetProvider` is exported from
the Predict barrel.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
51b78178bc4543e21070641b11849599b09855f8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
app/components/Nav/Main/MainNavigator.js | 133 ++++++------
.../Nav/Main/MainNavigator.test.tsx | 20 +-
.../PredictPreviewSheetContext.test.tsx | 193 +++++++++++++++++-
.../contexts/PredictPreviewSheetContext.tsx | 86 ++++++--
app/components/UI/Predict/index.ts | 1 +
5 files changed, 348 insertions(+), 85 deletions(-)
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index 7be93d518b86..54dc4d65556f 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -117,6 +117,7 @@ import {
import {
PredictScreenStack,
PredictModalStack,
+ PredictPreviewSheetProvider,
selectPredictEnabledFlag,
} from '../../UI/Predict';
import {
@@ -831,73 +832,87 @@ const HomeTabs = () => {
};
return (
-
- {/* Home Tab */}
-
-
- {/* Explore Tab (w/ hidden browser) */}
- <>
+ /*
+ * PredictPreviewSheetProvider is mounted here (above Tab.Navigator) so its
+ * BottomSheet renders inside the full-viewport Home Stack.Screen card.
+ * BottomSheet uses `absolute inset-0` (see
+ * @metamask/design-system-react-native) and would be clipped by an
+ * individual tab's content area if mounted lower in the tree.
+ *
+ * A nested provider in PredictScreenStack still shadows this one for
+ * usage; the registration stack in PredictPreviewSheetContext keeps only
+ * the innermost (most recently mounted) provider active for state-based
+ * Retry toasts so we don't double-fire when both are mounted.
+ */
+
+
+ {/* Home Tab */}
- [Routes.TRENDING_VIEW, Routes.BROWSER.HOME].includes(
- rootScreenName,
- ),
- }}
- component={ExploreHome}
+ name={Routes.WALLET.HOME}
+ options={options.home}
+ component={WalletTabStackFlow}
/>
- {children}}
- />
- >
- {/* Trade Tab */}
-
+ {/* Explore Tab (w/ hidden browser) */}
+ <>
+
+ [Routes.TRENDING_VIEW, Routes.BROWSER.HOME].includes(
+ rootScreenName,
+ ),
+ }}
+ component={ExploreHome}
+ />
+ {children}}
+ />
+ >
- {/* Activity Tab (replaced by Money when feature flag is on) */}
- {isMoneyHomeScreenEnabled ? (
+ {/* Trade Tab */}
- ) : (
+
+ {/* Activity Tab (replaced by Money when feature flag is on) */}
+ {isMoneyHomeScreenEnabled ? (
+
+ ) : (
+ {children}}
+ />
+ )}
+
+ {/* Rewards Tab */}
{children}}
+ name={Routes.REWARDS_VIEW}
+ options={options.rewards}
+ component={RewardsHome}
+ layout={({ children }) => UnmountOnBlurComponent(children)}
/>
- )}
-
- {/* Rewards Tab */}
- UnmountOnBlurComponent(children)}
- />
-
+
+
);
};
diff --git a/app/components/Nav/Main/MainNavigator.test.tsx b/app/components/Nav/Main/MainNavigator.test.tsx
index 69b2585ba50e..df3ab890a9ae 100644
--- a/app/components/Nav/Main/MainNavigator.test.tsx
+++ b/app/components/Nav/Main/MainNavigator.test.tsx
@@ -36,12 +36,20 @@ jest.mock('../../UI/Perps', () => ({
selectPerpsEnabledFlag: (state: unknown) => mockSelectPerpsEnabledFlag(state),
}));
-jest.mock('../../UI/Predict', () => ({
- PredictScreenStack: () => 'PredictScreenStack',
- PredictModalStack: () => 'PredictModalStack',
- selectPredictEnabledFlag: (state: unknown) =>
- mockSelectPredictEnabledFlag(state),
-}));
+jest.mock('../../UI/Predict', () => {
+ const { Fragment } = jest.requireActual('react');
+ return {
+ PredictScreenStack: () => 'PredictScreenStack',
+ PredictModalStack: () => 'PredictModalStack',
+ PredictPreviewSheetProvider: ({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) => jest.requireActual('react').createElement(Fragment, null, children),
+ selectPredictEnabledFlag: (state: unknown) =>
+ mockSelectPredictEnabledFlag(state),
+ };
+});
jest.mock('../../UI/MarketInsights', () => ({
MarketInsightsView: () => 'MarketInsightsView',
diff --git a/app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx b/app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx
index cd4a0e67aab4..764a59435b8d 100644
--- a/app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx
+++ b/app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx
@@ -616,6 +616,168 @@ describe('PredictPreviewSheetContext', () => {
});
});
+ describe('multi-provider dedup', () => {
+ // Mirrors production reality: HomeTabs mounts a sheet-mode provider above
+ // Tab.Navigator (so the BottomSheet's parent is the full-viewport stack
+ // card), and PredictScreenStack mounts another sheet-mode provider when
+ // the user navigates into the Predict tab. Both stay mounted while inside
+ // Predict. The toast effect must fire from the topmost (most recently
+ // mounted, innermost in the tree) provider only.
+
+ it('fires the failure toast only from the topmost (most recently mounted) sheet-mode provider', () => {
+ const outer = render(
+
+
+ ,
+ );
+ // Outer "remembers" buy params from a prior open + dismiss.
+ fireEvent.press(outer.getByTestId('open-buy'));
+ fireEvent.press(outer.getByTestId('dismiss-sheet'));
+
+ const inner = render(
+
+
+ ,
+ );
+ // Inner also remembers buy params from a prior open + dismiss.
+ fireEvent.press(inner.getByTestId('open-buy'));
+ fireEvent.press(inner.getByTestId('dismiss-sheet'));
+
+ mockActiveOrder = { error: 'order/failed' };
+ outer.rerender(
+
+
+ ,
+ );
+ inner.rerender(
+
+
+ ,
+ );
+
+ expect(mockToastShowToast).toHaveBeenCalledTimes(1);
+
+ outer.unmount();
+ inner.unmount();
+ });
+
+ it('outer provider becomes active after inner unmounts and fires for the next error transition', () => {
+ const outer = render(
+
+
+ ,
+ );
+ fireEvent.press(outer.getByTestId('open-buy'));
+ fireEvent.press(outer.getByTestId('dismiss-sheet'));
+
+ const inner = render(
+
+
+ ,
+ );
+ fireEvent.press(inner.getByTestId('open-buy'));
+ fireEvent.press(inner.getByTestId('dismiss-sheet'));
+
+ // First error transition — only inner (topmost) fires.
+ mockActiveOrder = { error: 'order/failed' };
+ outer.rerender(
+
+
+ ,
+ );
+ inner.rerender(
+
+
+ ,
+ );
+ expect(mockToastShowToast).toHaveBeenCalledTimes(1);
+
+ // Inner unmounts — outer becomes the topmost provider.
+ inner.unmount();
+
+ // Drive a fresh falsy -> truthy transition for the outer provider so
+ // its `previousErrorRef` flips correctly.
+ mockActiveOrder = null;
+ outer.rerender(
+
+
+ ,
+ );
+ mockActiveOrder = { error: 'order/failed-2' };
+ outer.rerender(
+
+
+ ,
+ );
+
+ expect(mockToastShowToast).toHaveBeenCalledTimes(2);
+
+ outer.unmount();
+ });
+
+ it('outer (sheet-mode) provider fires when the inner provider is mounted with disableBottomSheet', () => {
+ const outer = render(
+
+
+ ,
+ );
+ fireEvent.press(outer.getByTestId('open-buy'));
+ fireEvent.press(outer.getByTestId('dismiss-sheet'));
+
+ // Disabled provider mounts but does NOT register, so outer remains
+ // the topmost (and only) sheet-mode provider.
+ const navInner = render(
+
+
+ ,
+ );
+
+ mockActiveOrder = { error: 'order/failed' };
+ outer.rerender(
+
+
+ ,
+ );
+
+ expect(mockToastShowToast).toHaveBeenCalledTimes(1);
+
+ navInner.unmount();
+ outer.unmount();
+ });
+
+ it('shouldSuppressLegacyOrderFailureToast tracks the topmost provider after unmount order', () => {
+ const outer = render(
+
+
+ ,
+ );
+ // Outer alone, no opens — suppression off.
+ expect(shouldSuppressLegacyOrderFailureToast()).toBe(false);
+
+ fireEvent.press(outer.getByTestId('open-buy'));
+ expect(shouldSuppressLegacyOrderFailureToast()).toBe(true);
+
+ // Inner mounts on top of outer — its `lastBuyParamsRef` is null,
+ // so suppression flips back to false until inner has its own open.
+ const inner = render(
+
+
+ ,
+ );
+ expect(shouldSuppressLegacyOrderFailureToast()).toBe(false);
+
+ fireEvent.press(inner.getByTestId('open-buy'));
+ expect(shouldSuppressLegacyOrderFailureToast()).toBe(true);
+
+ // Unmounting inner falls back to outer (still has params).
+ inner.unmount();
+ expect(shouldSuppressLegacyOrderFailureToast()).toBe(true);
+
+ outer.unmount();
+ expect(shouldSuppressLegacyOrderFailureToast()).toBe(false);
+ });
+ });
+
describe('failure toast auto-clear timer', () => {
beforeEach(() => {
jest.useFakeTimers();
@@ -695,13 +857,30 @@ describe('PredictPreviewSheetContext', () => {
expect(shouldSuppressLegacyOrderFailureToast()).toBe(false);
});
- it('returns true while provider is mounted and false after unmount', () => {
+ it('returns false while provider is mounted but no sheet has been opened yet', () => {
+ // Suppression is gated on the topmost provider's `lastBuyParamsRef` so
+ // the legacy toast keeps firing for tabs/flows where the user has not
+ // initiated a sheet (e.g. order failure surfaces from elsewhere).
const { unmount } = render(
,
);
+ expect(shouldSuppressLegacyOrderFailureToast()).toBe(false);
+
+ unmount();
+ });
+
+ it('returns true after openBuySheet is called and false after unmount', () => {
+ const { unmount } = render(
+
+
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('open-buy'));
+
expect(shouldSuppressLegacyOrderFailureToast()).toBe(true);
unmount();
@@ -734,12 +913,16 @@ describe('PredictPreviewSheetContext', () => {
});
it('stays true when disableBottomSheet provider unmounts while sheet-mode provider is still mounted', () => {
- const { unmount: unmountSheet } = render(
+ const sheetRender = render(
,
);
- const { unmount: unmountNav } = render(
+ // Open a buy sheet on the sheet-mode provider so it has remembered
+ // params (`hasBuyParams()` now gates suppression).
+ fireEvent.press(sheetRender.getByTestId('open-buy'));
+
+ const navRender = render(
,
@@ -748,10 +931,10 @@ describe('PredictPreviewSheetContext', () => {
expect(shouldSuppressLegacyOrderFailureToast()).toBe(true);
// Unmounting the navigate-mode provider must not clear the sheet-mode one
- unmountNav();
+ navRender.unmount();
expect(shouldSuppressLegacyOrderFailureToast()).toBe(true);
- unmountSheet();
+ sheetRender.unmount();
expect(shouldSuppressLegacyOrderFailureToast()).toBe(false);
});
});
diff --git a/app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx b/app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx
index 32c15b7220b9..3b4ca17e8a46 100644
--- a/app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx
+++ b/app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx
@@ -51,23 +51,53 @@ import { usePredictActiveOrder } from '../hooks/usePredictActiveOrder';
import { PredictDismissalMethod } from '../constants/eventNames';
import { parseAnalyticsProperties } from '../utils/analytics';
-// Reference counter instead of booleans — multiple providers can be mounted
-// simultaneously (e.g. PredictScreenStack + HomepageDiscoveryTabs), so a
-// single flag would race on mount/unmount.
-let _providerSheetModeCount = 0;
+// Registration stack of sheet-mode providers — multiple providers can be
+// mounted simultaneously (e.g. HomeTabs + PredictScreenStack when the user
+// navigates from Explore into Predict), so a single counter cannot tell us
+// which one is "active". The top of the stack (most recently mounted, i.e.
+// innermost in the tree) is the only provider that should fire its
+// state-based Retry toast — earlier-mounted providers stay silent to avoid
+// duplicate toasts for the same `activeOrder.error` transition.
+interface SheetModeProviderEntry {
+ id: number;
+ hasBuyParams: () => boolean;
+}
+
+let _sheetModeProviders: SheetModeProviderEntry[] = [];
+let _nextSheetModeProviderId = 0;
+
+function registerSheetModeProvider(hasBuyParams: () => boolean): number {
+ const id = ++_nextSheetModeProviderId;
+ _sheetModeProviders = [..._sheetModeProviders, { id, hasBuyParams }];
+ return id;
+}
+
+function unregisterSheetModeProvider(id: number): void {
+ _sheetModeProviders = _sheetModeProviders.filter((entry) => entry.id !== id);
+}
+
+function isActiveSheetModeProvider(id: number): boolean {
+ return _sheetModeProviders[_sheetModeProviders.length - 1]?.id === id;
+}
/**
- * Returns true when at least one `PredictPreviewSheetProvider` in sheet mode
- * is active. Used by `usePredictToastRegistrations` to decide whether to
- * suppress the legacy order-failure toast — when a sheet-mode provider is
- * present, its own state-based Retry toast handles the failure and the plain
- * toast would be a duplicate.
+ * Returns true only when the active (top-of-stack) sheet-mode provider has
+ * remembered buy params and will therefore surface its own Retry toast.
+ * Used by `usePredictToastRegistrations` to decide whether to suppress the
+ * legacy order-failure toast.
*
- * Note: a provider mounted with `disableBottomSheet` does NOT count, because
- * it never shows the Retry sheet and the legacy toast must still fire.
+ * Checking `hasBuyParams()` (rather than just "any provider mounted")
+ * avoids suppressing the legacy toast when no sheet-mode provider is
+ * positioned to fire — e.g. the active provider is HomeTabs but the user
+ * just initiated the order via a `disableBottomSheet` provider that
+ * shadowed it (so the outer never had `openBuySheet` called on it).
+ *
+ * Note: a provider mounted with `disableBottomSheet` does NOT register,
+ * because it never shows the Retry sheet.
*/
export function shouldSuppressLegacyOrderFailureToast(): boolean {
- return _providerSheetModeCount > 0;
+ const top = _sheetModeProviders[_sheetModeProviders.length - 1];
+ return Boolean(top?.hasBuyParams());
}
const SellSheetHeader: React.FC<{ params: PredictSellPreviewParams }> = ({
@@ -224,16 +254,29 @@ export const PredictPreviewSheetProvider: React.FC<
*/
const clearErrorTimerRef = useRef | null>(null);
+ /**
+ * Module-level registration id for this provider instance. Set on mount
+ * (when not disabled) and used to guard the failure-toast effect so only
+ * the topmost (most recently mounted) provider fires.
+ */
+ const providerIdRef = useRef(null);
+ const hasBuyParams = useCallback(() => lastBuyParamsRef.current !== null, []);
+
useEffect(() => {
- if (!disableBottomSheet) _providerSheetModeCount += 1;
+ if (!disableBottomSheet) {
+ providerIdRef.current = registerSheetModeProvider(hasBuyParams);
+ }
return () => {
- if (!disableBottomSheet) _providerSheetModeCount -= 1;
+ if (providerIdRef.current !== null) {
+ unregisterSheetModeProvider(providerIdRef.current);
+ providerIdRef.current = null;
+ }
if (clearErrorTimerRef.current) {
clearTimeout(clearErrorTimerRef.current);
clearErrorTimerRef.current = null;
}
};
- }, [disableBottomSheet]);
+ }, [disableBottomSheet, hasBuyParams]);
const openBuySheet = useCallback(
(params: PredictBuyPreviewParams) => {
@@ -315,6 +358,19 @@ export const PredictPreviewSheetProvider: React.FC<
return;
}
+ // When multiple sheet-mode providers are mounted simultaneously (e.g.
+ // HomeTabs + PredictScreenStack while the user is inside the Predict
+ // stack), only the topmost (most recently mounted, innermost in the
+ // tree) provider should fire the toast — earlier-mounted providers
+ // also hold their own `lastBuyParamsRef` and would otherwise duplicate
+ // the toast (and the `clearOrderError` timer).
+ if (
+ providerIdRef.current === null ||
+ !isActiveSheetModeProvider(providerIdRef.current)
+ ) {
+ return;
+ }
+
const lastParams = lastBuyParamsRef.current;
// Use `closeButtonOptions` (with `ButtonVariants.Link`) rather than
// `linkButtonOptions` so the Retry sits inline on the right of the row
diff --git a/app/components/UI/Predict/index.ts b/app/components/UI/Predict/index.ts
index 6f7b56c29d3d..64cf7842f4b2 100644
--- a/app/components/UI/Predict/index.ts
+++ b/app/components/UI/Predict/index.ts
@@ -5,5 +5,6 @@ export { default as PredictScreenStack } from './routes';
export { selectPredictEnabledFlag } from './selectors/featureFlags';
export { default as PredictSellPreview } from './views/PredictSellPreview/PredictSellPreview';
export { PredictModalStack } from './routes';
+export { PredictPreviewSheetProvider } from './contexts';
export * from './types';
From 3bc2d99b7b4c28c2c8f0e5e1470554b4690afa12 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?=
Date: Wed, 20 May 2026 19:53:29 -0300
Subject: [PATCH 10/12] fix(predict): filter stale markets from discovery feeds
(#30405)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR prevents stale or effectively settled Predict markets from
occupying discovery feed surfaces.
- Adds an isolated `marketStaleness` utility for probability thresholds,
formal market status, daily/game expiry, time penalties, ranking
penalties, and highlighted-market exceptions.
- Applies the shared filter at hook/view-model boundaries for Predict
feed data, search results, featured carousel data, and the World Cup
feed without changing fetch parameters, page size, cursors, or backfill
behavior.
- Preserves highlighted-market metadata from
`PredictController.getMarkets`, keeping open highlighted markets pinned
first while still excluding closed/resolved highlighted markets.
- Removes the old `PredictMarketMultiple` exact `0`/`1` outcome filter
so display components render the outcomes already prepared by the shared
feed model.
Branch review summary:
- Base: `main`
- Branch:
`predict/PRED-747-filter-dead-resolved-markets-from-predict-feed-4-layer-staleness-system`
- Changed files: 16
- Commits: 7 scoped commits covering plan, utility, integration, tests,
and component cleanup
Automated coverage added/updated:
- Pure staleness policy tests for outcome thresholds, invalid
probabilities, outcome groups, formal status, time expiry, ranking
penalties, and highlighted-market behavior.
- Hook integration tests for feed, search, carousel, and World Cup
filtering.
- Controller tests for highlighted-market metadata and closed/resolved
highlight exclusion.
- Component regression coverage confirming `PredictMarketMultiple` no
longer owns price-based stale filtering.
## **Changelog**
CHANGELOG entry: Fixed stale Predict markets appearing in discovery
feeds and search results.
## **Related issues**
Fixes: PRED-747
Related: PRED-707, PRED-533, PRED-744
## **Manual testing steps**
```gherkin
Feature: Predict discovery staleness filtering
Scenario: user views Predict discovery surfaces with stale and live markets
Given Predict discovery data includes live markets, markets where all outcomes are at or beyond the dead probability thresholds, partially stale markets, and highlighted open markets
When user opens the Trending tab, Predict search, featured carousel, or World Cup feed
Then markets with no displayable live outcomes are not shown
And partially stale markets show only live outcomes
And open highlighted markets remain pinned first
And closed or resolved highlighted markets are not shown
And pagination state, cursors, and page sizes remain unchanged
```
## **Screenshots/Recordings**
N/A - this is feed data filtering/ranking behavior covered by automated
tests, with no intended layout or visual styling change.
### **Before** Left / **After** Right
https://github.com/user-attachments/assets/04690e72-6692-4601-9730-150eb6495b6b
https://github.com/user-attachments/assets/df5a33c5-43bf-49f5-bc42-af5603c0cca4
## **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 - N/A, no new public API requiring JSDoc.
- [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. Label application should be
confirmed when creating the GitHub PR.
#### Performance checks (if applicable)
- [x] I've tested on Android - N/A, no native or platform-specific
rendering change; automated JS coverage was added for the affected data
paths.
- Ideally on a mid-range device; emulator is acceptable
- [x] I've tested with a power user scenario - N/A, change is scoped to
Predict feed market filtering and does not touch wallet/account/token
scale paths.
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [x] I've instrumented key operations with Sentry traces for production
performance metrics - N/A, no new network operation or long-running
production operation was introduced.
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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.
## **Verification**
```bash
yarn jest app/components/UI/Predict/utils/marketStaleness.test.ts app/components/UI/Predict/hooks/usePredictMarketData.test.tsx app/components/UI/Predict/hooks/usePredictSearchMarketData.test.tsx app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts app/components/UI/Predict/hooks/usePredictWorldCup.test.ts app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx --runInBand --coverage=false
yarn jest app/components/UI/Predict/controllers/PredictController.test.ts -t "getMarkets with market highlights" --runInBand --coverage=false
```
---
> [!NOTE]
> **Medium Risk**
> Changes Predict discovery data shaping by filtering/reshaping outcomes
and re-ranking markets (including a new `isHighlighted` flag), which can
affect what users see and pagination behavior across multiple surfaces.
>
> **Overview**
> Prevents *stale/settled* Predict markets from appearing in discovery
surfaces by introducing a shared `marketStaleness` policy
(`getVisiblePredictMarkets`) that drops closed/expired markets and
prunes “dead” outcomes (probability near 0/1), while re-ranking
remaining markets.
>
> Applies this visibility filter in `usePredictMarketData`,
`useFeaturedCarouselData`, `usePredictWorldCupMarkets`, and the
empty-query fallback path in `usePredictSearchMarketData`, with tests
asserting pagination/cursors remain unchanged.
>
> Updates `PredictController.getMarkets` to tag fetched highlight
markets with `isHighlighted` (kept pinned and exempt from staleness
filtering), and removes `PredictMarketMultiple`’s old price-based
outcome filtering so the UI renders outcomes as provided by the feed
model.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
443cd36910b20abf7e6583f6c6cf413b1f4bb5f5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../PredictMarketMultiple.test.tsx | 25 ++
.../PredictMarketMultiple.tsx | 13 +-
.../controllers/PredictController.test.ts | 7 +
.../Predict/controllers/PredictController.ts | 9 +-
.../hooks/useFeaturedCarouselData.test.ts | 77 +++-
.../Predict/hooks/useFeaturedCarouselData.ts | 5 +-
.../hooks/usePredictMarketData.test.tsx | 86 ++++-
.../UI/Predict/hooks/usePredictMarketData.tsx | 5 +-
.../hooks/usePredictSearchMarketData.test.tsx | 81 +++-
.../hooks/usePredictSearchMarketData.tsx | 8 +-
.../Predict/hooks/usePredictWorldCup.test.ts | 66 +++-
.../UI/Predict/hooks/usePredictWorldCup.ts | 19 +-
app/components/UI/Predict/types/index.ts | 1 +
.../UI/Predict/utils/marketStaleness.test.ts | 349 ++++++++++++++++++
.../UI/Predict/utils/marketStaleness.ts | 256 +++++++++++++
15 files changed, 966 insertions(+), 41 deletions(-)
create mode 100644 app/components/UI/Predict/utils/marketStaleness.test.ts
create mode 100644 app/components/UI/Predict/utils/marketStaleness.ts
diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx
index 66fbe3ee82f2..22be213b5fd4 100644
--- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx
@@ -238,6 +238,31 @@ describe('PredictMarketMultiple', () => {
expect(getByText('75%')).toBeOnTheScreen();
});
+ it('renders outcomes provided by the feed model without price-based filtering', () => {
+ const pinnedOutcomeMarket: PredictMarket = {
+ ...mockMarket,
+ outcomes: [
+ {
+ ...mockMarket.outcomes[0],
+ id: 'pinned-outcome',
+ groupItemTitle: 'Pinned Outcome',
+ tokens: [
+ { id: 'token-yes', title: 'Yes', price: 1 },
+ { id: 'token-no', title: 'No', price: 0 },
+ ],
+ },
+ ],
+ };
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialState },
+ );
+
+ expect(getByText('Pinned Outcome')).toBeOnTheScreen();
+ expect(getByText('>99%')).toBeOnTheScreen();
+ });
+
it('handle market with recurrence', () => {
const marketWithRecurrence: PredictMarket = {
...mockMarket,
diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
index 3dab46dbe3b8..378ace5f16c3 100644
--- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
+++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
@@ -85,10 +85,7 @@ const PredictMarketMultiple: React.FC = ({
navigation,
});
- // filter resolved outcomes
- const filteredOutcomes = market.outcomes.filter(
- (outcome) => outcome.tokens[0].price !== 0 && outcome.tokens[0].price !== 1,
- );
+ const displayOutcomes = market.outcomes;
const getOutcomePercentage = (
outcomePrices?: number[],
@@ -217,7 +214,7 @@ const PredictMarketMultiple: React.FC = ({
- {filteredOutcomes.slice(0, 3).map((outcome) => {
+ {displayOutcomes.slice(0, 3).map((outcome) => {
const outcomeLabels = outcome.tokens.map((token) => token.title);
return (
= ({
numberOfLines={1}
style={tw.style('flex-shrink min-w-0')}
>
- {filteredOutcomes.length > 3
- ? `+${filteredOutcomes.length - 3} ${
- filteredOutcomes.length - 3 === 1
+ {displayOutcomes.length > 3
+ ? `+${displayOutcomes.length - 3} ${
+ displayOutcomes.length - 3 === 1
? strings('predict.outcomes_singular')
: strings('predict.outcomes_plural')
}`
diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts
index 8e78b6a84235..eec6a10e4297 100644
--- a/app/components/UI/Predict/controllers/PredictController.test.ts
+++ b/app/components/UI/Predict/controllers/PredictController.test.ts
@@ -2198,6 +2198,9 @@ describe('PredictController', () => {
expect(result.markets[1].id).toBe('highlight-2');
expect(result.markets[2].id).toBe('regular-1');
expect(result.markets[3].id).toBe('regular-2');
+ expect(result.markets[0].isHighlighted).toBe(true);
+ expect(result.markets[1].isHighlighted).toBe(true);
+ expect(result.markets[2].isHighlighted).toBeUndefined();
expect(result.nextCursor).toBe('next-cursor');
expect(mockPolymarketProvider.getMarketsByIds).toHaveBeenCalledWith([
'highlight-1',
@@ -2375,6 +2378,7 @@ describe('PredictController', () => {
expect(result.markets[0].id).toBe('duplicate-market');
expect(result.markets[1].id).toBe('regular-1');
expect(result.markets[2].id).toBe('regular-2');
+ expect(result.markets[0].isHighlighted).toBe(true);
},
{
mocks: {
@@ -2642,6 +2646,8 @@ describe('PredictController', () => {
expect(result.markets[0].id).toBe('highlight-2');
expect(result.markets[1].id).toBe('highlight-1');
expect(result.markets[2].id).toBe('regular-1');
+ expect(result.markets[0].isHighlighted).toBe(true);
+ expect(result.markets[1].isHighlighted).toBeUndefined();
},
{
mocks: {
@@ -2690,6 +2696,7 @@ describe('PredictController', () => {
expect(result.markets).toHaveLength(2);
expect(result.markets[0].id).toBe('highlight-2');
expect(result.markets[1].id).toBe('regular-1');
+ expect(result.markets[0].isHighlighted).toBe(true);
expect(
result.markets.find((m) => m.id === 'highlight-1'),
).toBeUndefined();
diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts
index b6c9346a72f6..1fd8feda8fff 100644
--- a/app/components/UI/Predict/controllers/PredictController.ts
+++ b/app/components/UI/Predict/controllers/PredictController.ts
@@ -624,9 +624,12 @@ export class PredictController extends BaseController<
(await this.provider.getMarketsByIds?.(highlightedMarketIds)) ??
[];
- const highlightedMarkets = fetchedHighlightedMarkets.filter(
- (market) => market.status === 'open',
- );
+ const highlightedMarkets = fetchedHighlightedMarkets
+ .filter((market) => market.status === 'open')
+ .map((market) => ({
+ ...market,
+ isHighlighted: true,
+ }));
const highlightedIdSet = new Set(
highlightedMarkets.map((m) => m.id),
diff --git a/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts b/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts
index c294607edd47..d7cc96d662ac 100644
--- a/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts
+++ b/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts
@@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Engine from '../../../../core/Engine';
import { useFeaturedCarouselData } from './useFeaturedCarouselData';
-import { type PredictMarket, Recurrence } from '../types';
+import { type PredictMarket, type PredictOutcome, Recurrence } from '../types';
let mockUpDownEnabled = true;
@@ -26,13 +26,29 @@ const mockGetCarouselMarkets = Engine.context.PredictController
const createWrapper = () => {
const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false } },
+ defaultOptions: { queries: { cacheTime: 0, retry: false } },
});
const Wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return { Wrapper, queryClient };
};
+const createMockOutcome = (
+ overrides: Partial = {},
+): PredictOutcome => ({
+ id: 'outcome-1',
+ providerId: 'polymarket',
+ marketId: 'market-1',
+ title: 'Yes',
+ description: '',
+ image: '',
+ status: 'open',
+ tokens: [{ id: 't1', title: 'Yes', price: 0.65 }],
+ volume: 100000,
+ groupItemTitle: 'Yes',
+ ...overrides,
+});
+
const createMockMarket = (
overrides: Partial = {},
): PredictMarket => ({
@@ -46,20 +62,7 @@ const createMockMarket = (
recurrence: Recurrence.NONE,
category: 'crypto',
tags: [],
- outcomes: [
- {
- id: 'outcome-1',
- providerId: 'polymarket',
- marketId: 'market-1',
- title: 'Yes',
- description: '',
- image: '',
- status: 'open',
- tokens: [{ id: 't1', title: 'Yes', price: 0.65 }],
- volume: 100000,
- groupItemTitle: 'Yes',
- },
- ],
+ outcomes: [createMockOutcome()],
liquidity: 1500000,
volume: 1500000,
...overrides,
@@ -71,9 +74,14 @@ describe('useFeaturedCarouselData', () => {
mockUpDownEnabled = true;
});
- it('returns loading state initially', () => {
+ it('returns loading state initially', async () => {
const { Wrapper } = createWrapper();
- mockGetCarouselMarkets.mockReturnValue(new Promise(() => undefined));
+ let resolveMarkets!: (markets: PredictMarket[]) => void;
+ mockGetCarouselMarkets.mockReturnValue(
+ new Promise((resolve) => {
+ resolveMarkets = resolve;
+ }),
+ );
const { result } = renderHook(() => useFeaturedCarouselData(), {
wrapper: Wrapper,
@@ -81,6 +89,14 @@ describe('useFeaturedCarouselData', () => {
expect(result.current.isLoading).toBe(true);
expect(result.current.markets).toHaveLength(0);
+
+ await act(async () => {
+ resolveMarkets([]);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
});
it('returns parsed markets after fetch', async () => {
@@ -123,6 +139,31 @@ describe('useFeaturedCarouselData', () => {
expect(result.current.markets).toEqual([parentMarket]);
});
+ it('filters stale carousel markets', async () => {
+ const { Wrapper } = createWrapper();
+ const liveMarket = createMockMarket({ id: 'live-market' });
+ const staleMarket = createMockMarket({
+ id: 'stale-market',
+ outcomes: [
+ createMockOutcome({
+ id: 'stale-high',
+ tokens: [{ id: 'stale-high-token', title: 'Yes', price: 0.99 }],
+ }),
+ ],
+ });
+ mockGetCarouselMarkets.mockResolvedValue([staleMarket, liveMarket]);
+
+ const { result } = renderHook(() => useFeaturedCarouselData(), {
+ wrapper: Wrapper,
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.markets).toEqual([liveMarket]);
+ });
+
it('returns error when controller throws', async () => {
const { Wrapper } = createWrapper();
mockGetCarouselMarkets.mockRejectedValue(new Error('Network error'));
diff --git a/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts b/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts
index d742c51a20c0..7bd07e894ef1 100644
--- a/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts
+++ b/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts
@@ -8,6 +8,7 @@ import { selectPredictUpDownEnabledFlag } from '../selectors/featureFlags';
import type { PredictMarket } from '../types';
import { isCryptoUpDown } from '../utils/cryptoUpDown';
import { filterStandaloneMarkets } from '../utils/feed';
+import { getVisiblePredictMarkets } from '../utils/marketStaleness';
import { ensureError } from '../utils/predictErrorHandler';
export interface UseFeaturedCarouselDataResult {
@@ -41,7 +42,9 @@ export const useFeaturedCarouselData = (): UseFeaturedCarouselDataResult => {
}, [query.error]);
const markets = useMemo(() => {
- const data = filterStandaloneMarkets(query.data ?? []);
+ const data = getVisiblePredictMarkets(
+ filterStandaloneMarkets(query.data ?? []),
+ );
if (upDownEnabled) {
return data;
}
diff --git a/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx b/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx
index f508590d63f8..3b665824f9df 100644
--- a/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx
+++ b/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx
@@ -7,7 +7,7 @@ import {
import DevLogger from '../../../../core/SDKConnect/utils/DevLogger';
import Engine from '../../../../core/Engine';
import { usePredictMarketData } from './usePredictMarketData';
-import { PredictMarket, Recurrence } from '../types';
+import { PredictMarket, PredictOutcome, Recurrence } from '../types';
import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants';
// Mock dependencies
@@ -130,6 +130,32 @@ describe('usePredictMarketData', () => {
},
];
+ const createOutcome = (id: string, price: number): PredictOutcome => ({
+ ...mockMarketData[0].outcomes[0],
+ id,
+ title: id,
+ tokens: [
+ {
+ ...mockMarketData[0].outcomes[0].tokens[0],
+ id: `${id}-token`,
+ price,
+ },
+ ],
+ });
+
+ const createMarket = (
+ id: string,
+ outcomes = [createOutcome(`${id}-outcome`, 0.5)],
+ overrides: Partial = {},
+ ): PredictMarket => ({
+ ...mockMarketData[0],
+ id,
+ slug: id,
+ title: id,
+ outcomes,
+ ...overrides,
+ });
+
beforeEach(() => {
jest.clearAllMocks();
@@ -247,6 +273,64 @@ describe('usePredictMarketData', () => {
expect(result.current.hasMore).toBe(true);
});
+ it('filters stale markets and keeps pagination metadata unchanged', async () => {
+ const staleMarket = createMarket('stale-market', [
+ createOutcome('stale-high', 0.99),
+ createOutcome('stale-low', 0.01),
+ ]);
+ const liveMarket = createMarket('live-market');
+ const partialMarket = createMarket('partial-market', [
+ createOutcome('partial-dead', 0.99),
+ createOutcome('partial-live', 0.45),
+ ]);
+ mockGetMarkets.mockResolvedValue({
+ markets: [staleMarket, liveMarket, partialMarket],
+ nextCursor: 'next-cursor',
+ });
+
+ const { result } = renderHook(() => usePredictMarketData({ pageSize: 20 }));
+
+ await waitFor(() => {
+ expect(result.current.isFetching).toBe(false);
+ });
+
+ expect(result.current.marketData.map((market) => market.id)).toEqual([
+ 'live-market',
+ 'partial-market',
+ ]);
+ expect(result.current.marketData[1].outcomes).toEqual([
+ createOutcome('partial-live', 0.45),
+ ]);
+ expect(result.current.hasMore).toBe(true);
+ });
+
+ it('keeps highlighted stale markets pinned before live markets', async () => {
+ const liveMarket = createMarket('live-market');
+ const highlightedMarket = createMarket(
+ 'highlighted-market',
+ [createOutcome('highlighted-dead', 0.99)],
+ { isHighlighted: true },
+ );
+ mockGetMarkets.mockResolvedValue({
+ markets: [liveMarket, highlightedMarket],
+ nextCursor: null,
+ });
+
+ const { result } = renderHook(() => usePredictMarketData());
+
+ await waitFor(() => {
+ expect(result.current.isFetching).toBe(false);
+ });
+
+ expect(result.current.marketData.map((market) => market.id)).toEqual([
+ 'highlighted-market',
+ 'live-market',
+ ]);
+ expect(result.current.marketData[0].outcomes).toEqual([
+ createOutcome('highlighted-dead', 0.99),
+ ]);
+ });
+
it('uses raw page offsets when loading more after child cards are filtered', async () => {
const firstRawPage = Array.from({ length: 20 }, (_, index) => ({
...mockMarketData[0],
diff --git a/app/components/UI/Predict/hooks/usePredictMarketData.tsx b/app/components/UI/Predict/hooks/usePredictMarketData.tsx
index 37e8a8b3f9e5..bae413abdda5 100644
--- a/app/components/UI/Predict/hooks/usePredictMarketData.tsx
+++ b/app/components/UI/Predict/hooks/usePredictMarketData.tsx
@@ -14,6 +14,7 @@ import { PREDICT_CONSTANTS } from '../constants/errors';
import { ensureError } from '../utils/predictErrorHandler';
import { PredictCategory, PredictMarket } from '../types';
import { filterStandaloneMarkets } from '../utils/feed';
+import { getVisiblePredictMarkets } from '../utils/marketStaleness';
export interface UsePredictMarketDataOptions {
category?: PredictCategory;
@@ -141,7 +142,9 @@ export const usePredictMarketData = (
nextCursorRef.current = nextCursor;
setHasMore(Boolean(nextCursor));
- const visibleMarkets = filterStandaloneMarkets(markets);
+ const visibleMarkets = getVisiblePredictMarkets(
+ filterStandaloneMarkets(markets),
+ );
if (isLoadMore) {
setMarketData((prevData) => {
diff --git a/app/components/UI/Predict/hooks/usePredictSearchMarketData.test.tsx b/app/components/UI/Predict/hooks/usePredictSearchMarketData.test.tsx
index cb6d5c6c8065..bd94e92ca4c5 100644
--- a/app/components/UI/Predict/hooks/usePredictSearchMarketData.test.tsx
+++ b/app/components/UI/Predict/hooks/usePredictSearchMarketData.test.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { renderHook, waitFor } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants';
-import { PredictMarket, Recurrence } from '../types';
+import { PredictMarket, PredictOutcome, Recurrence } from '../types';
import { usePredictSearchMarketData } from './usePredictSearchMarketData';
jest.mock('../../../../util/Logger', () => ({
@@ -50,12 +50,49 @@ const mockMarketData: PredictMarket[] = [
recurrence: Recurrence.NONE,
category: 'crypto',
tags: ['trending'],
- outcomes: [],
+ outcomes: [
+ {
+ id: 'outcome-1',
+ providerId: POLYMARKET_PROVIDER_ID,
+ marketId: 'market-1',
+ title: 'Yes',
+ description: 'Bitcoin will reach $100k',
+ image: '',
+ status: 'open',
+ tokens: [{ id: 'token-1', title: 'Yes', price: 0.65 }],
+ volume: 1000000,
+ groupItemTitle: 'Yes',
+ },
+ ],
liquidity: 1000000,
volume: 1000000,
},
];
+const createOutcome = (id: string, price: number): PredictOutcome => ({
+ ...mockMarketData[0].outcomes[0],
+ id,
+ title: id,
+ tokens: [
+ {
+ ...mockMarketData[0].outcomes[0].tokens[0],
+ id: `${id}-token`,
+ price,
+ },
+ ],
+});
+
+const createMarket = (
+ id: string,
+ outcomes = [createOutcome(`${id}-outcome`, 0.5)],
+): PredictMarket => ({
+ ...mockMarketData[0],
+ id,
+ slug: id,
+ title: id,
+ outcomes,
+});
+
describe('usePredictSearchMarketData', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -110,6 +147,46 @@ describe('usePredictSearchMarketData', () => {
expect(result.current.marketData).toEqual(mockMarketData);
});
+ it('filters stale trending fallback results for an empty query', async () => {
+ const staleMarket = createMarket('stale-market', [
+ createOutcome('stale-high', 0.99),
+ createOutcome('stale-low', 0.01),
+ ]);
+ const liveMarket = createMarket('live-market');
+ mockGetMarkets.mockResolvedValue({
+ markets: [staleMarket, liveMarket],
+ nextCursor: null,
+ });
+
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => usePredictSearchMarketData({ q: '' }), {
+ wrapper: Wrapper,
+ });
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ expect(result.current.marketData).toEqual([liveMarket]);
+ });
+
+ it('does not filter stale manual search results before exposing market data', async () => {
+ const staleMarket = createMarket('stale-market', [
+ createOutcome('stale-high', 0.99),
+ createOutcome('stale-low', 0.01),
+ ]);
+ const liveMarket = createMarket('live-market');
+ mockSearchMarkets.mockResolvedValue([staleMarket, liveMarket]);
+
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(
+ () => usePredictSearchMarketData({ q: ' bitcoin ' }),
+ { wrapper: Wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ expect(result.current.marketData).toEqual([staleMarket, liveMarket]);
+ });
+
it('sets error and clears data when search throws', async () => {
mockSearchMarkets.mockRejectedValue(new Error('Search failed'));
diff --git a/app/components/UI/Predict/hooks/usePredictSearchMarketData.tsx b/app/components/UI/Predict/hooks/usePredictSearchMarketData.tsx
index ba518d0fe80d..691a5ae4c1e3 100644
--- a/app/components/UI/Predict/hooks/usePredictSearchMarketData.tsx
+++ b/app/components/UI/Predict/hooks/usePredictSearchMarketData.tsx
@@ -4,6 +4,7 @@ import Engine from '../../../../core/Engine';
import Logger from '../../../../util/Logger';
import { PREDICT_CONSTANTS } from '../constants/errors';
import { PredictMarket } from '../types';
+import { getVisiblePredictMarkets } from '../utils/marketStaleness';
import { ensureError } from '../utils/predictErrorHandler';
export interface UsePredictSearchMarketDataOptions {
@@ -86,8 +87,11 @@ export const usePredictSearchMarketData = ({
}
const markets = query.data ?? [];
- return refine ? refine(markets) : markets;
- }, [enabled, query.data, refine]);
+ const visibleMarkets = trimmedQuery
+ ? markets
+ : getVisiblePredictMarkets(markets);
+ return refine ? refine(visibleMarkets) : visibleMarkets;
+ }, [enabled, query.data, refine, trimmedQuery]);
const queryRefetch = query.refetch;
const refetch = useCallback(async () => {
diff --git a/app/components/UI/Predict/hooks/usePredictWorldCup.test.ts b/app/components/UI/Predict/hooks/usePredictWorldCup.test.ts
index c1b5ac82dd08..c31248704980 100644
--- a/app/components/UI/Predict/hooks/usePredictWorldCup.test.ts
+++ b/app/components/UI/Predict/hooks/usePredictWorldCup.test.ts
@@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Engine from '../../../../core/Engine';
import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../constants/flags';
-import { Recurrence, type PredictMarket } from '../types';
+import { Recurrence, type PredictMarket, type PredictOutcome } from '../types';
import { usePredictWorldCupMarkets } from './usePredictWorldCup';
jest.mock('../../../../core/Engine', () => ({
@@ -16,6 +16,28 @@ jest.mock('../../../../core/Engine', () => ({
const mockGetMarkets = jest.mocked(Engine.context.PredictController.getMarkets);
+const createOutcome = (
+ overrides: Partial = {},
+): PredictOutcome => ({
+ id: 'outcome-1',
+ providerId: 'polymarket',
+ marketId: 'market-1',
+ title: 'Outcome 1',
+ description: 'Outcome description',
+ image: 'outcome.png',
+ status: 'open',
+ tokens: [
+ {
+ id: 'token-1',
+ title: 'Yes',
+ price: 0.5,
+ },
+ ],
+ volume: 0,
+ groupItemTitle: 'Outcome 1',
+ ...overrides,
+});
+
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { cacheTime: Infinity, retry: false } },
@@ -38,7 +60,7 @@ const createMarket = (
recurrence: Recurrence.NONE,
category: 'hot',
tags: [],
- outcomes: [],
+ outcomes: [createOutcome()],
liquidity: 0,
volume: 0,
...overrides,
@@ -79,6 +101,46 @@ describe('usePredictWorldCupMarkets', () => {
expect(result.current.hasMore).toBe(false);
});
+ it('filters stale World Cup markets while preserving pagination state', async () => {
+ const { Wrapper } = createWrapper();
+ const liveMarket = createMarket({ id: 'live-market' });
+ const staleMarket = createMarket({
+ id: 'stale-market',
+ outcomes: [
+ createOutcome({
+ id: 'dead-outcome',
+ title: 'Dead outcome',
+ tokens: [
+ {
+ id: 'dead-token',
+ title: 'Yes',
+ price: 0.99,
+ },
+ ],
+ }),
+ ],
+ });
+ mockGetMarkets.mockResolvedValue({
+ markets: [staleMarket, liveMarket],
+ nextCursor: 'cursor-2',
+ });
+
+ const { result } = renderHook(
+ () =>
+ usePredictWorldCupMarkets({
+ tabKey: 'all',
+ config: DEFAULT_PREDICT_WORLD_CUP_FLAG,
+ pageSize: 30,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ await waitFor(() =>
+ expect(result.current.marketData).toEqual([liveMarket]),
+ );
+ expect(result.current.hasMore).toBe(true);
+ });
+
it('requests Props markets with a cached paginated query', async () => {
const { Wrapper } = createWrapper();
const propsMarket = createMarket({ id: 'props-market' });
diff --git a/app/components/UI/Predict/hooks/usePredictWorldCup.ts b/app/components/UI/Predict/hooks/usePredictWorldCup.ts
index d0f345fb40a6..ce9eb0278f09 100644
--- a/app/components/UI/Predict/hooks/usePredictWorldCup.ts
+++ b/app/components/UI/Predict/hooks/usePredictWorldCup.ts
@@ -28,6 +28,7 @@ import {
import { strings } from '../../../../../locales/i18n';
import type { PredictMarket } from '../types';
import type { PredictWorldCupConfig } from '../types/flags';
+import { getVisiblePredictMarkets } from '../utils/marketStaleness';
import type { UsePredictMarketDataResult } from './usePredictMarketData';
export interface UsePredictWorldCupMarketsOptions {
@@ -232,10 +233,22 @@ export const usePredictWorldCupMarkets = ({
await singleQuery.refetch();
}, [infiniteQuery, marketDataConfig.paginationEnabled, singleQuery]);
+ const infiniteMarketData = useMemo(
+ () => infiniteQuery.data?.pages.flatMap((page) => page.markets) ?? [],
+ [infiniteQuery.data],
+ );
+ const visibleInfiniteMarketData = useMemo(
+ () => getVisiblePredictMarkets(infiniteMarketData),
+ [infiniteMarketData],
+ );
+ const visibleSingleMarketData = useMemo(
+ () => getVisiblePredictMarkets(singleQuery.data ?? []),
+ [singleQuery.data],
+ );
+
if (marketDataConfig.paginationEnabled) {
return {
- marketData:
- infiniteQuery.data?.pages.flatMap((page) => page.markets) ?? [],
+ marketData: visibleInfiniteMarketData,
isFetching: infiniteQuery.isLoading,
isFetchingMore: infiniteQuery.isFetchingNextPage,
error: infiniteQuery.error?.message ?? null,
@@ -246,7 +259,7 @@ export const usePredictWorldCupMarkets = ({
}
return {
- marketData: singleQuery.data ?? [],
+ marketData: visibleSingleMarketData,
isFetching: singleQuery.isLoading,
isFetchingMore: false,
error: singleQuery.error?.message ?? null,
diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts
index be1f2f807d93..05f36cd2b88f 100644
--- a/app/components/UI/Predict/types/index.ts
+++ b/app/components/UI/Predict/types/index.ts
@@ -120,6 +120,7 @@ export type PredictMarket = {
series?: PredictSeries;
parentMarketId?: string | number | null;
childMarketIds?: string[];
+ isHighlighted?: boolean;
};
export type PredictSeries = {
diff --git a/app/components/UI/Predict/utils/marketStaleness.test.ts b/app/components/UI/Predict/utils/marketStaleness.test.ts
new file mode 100644
index 000000000000..f996db9f241b
--- /dev/null
+++ b/app/components/UI/Predict/utils/marketStaleness.test.ts
@@ -0,0 +1,349 @@
+import {
+ PredictMarketStatus,
+ Recurrence,
+ type PredictMarket,
+ type PredictOutcome,
+ type PredictOutcomeGroup,
+} from '../types';
+import {
+ filterVisibleMarketOutcomes,
+ getPredictMarketProbabilityPenalty,
+ getPredictMarketTimePenalty,
+ getVisiblePredictMarket,
+ getVisiblePredictMarkets,
+ isPredictMarketExpiredByTime,
+ isPredictOutcomeDead,
+} from './marketStaleness';
+
+const NOW = new Date('2026-03-18T12:00:00.000Z');
+
+const createOutcome = ({
+ id,
+ price,
+ status = PredictMarketStatus.OPEN,
+}: {
+ id: string;
+ price?: number;
+ status?: PredictMarketStatus;
+}): PredictOutcome => ({
+ id,
+ providerId: 'polymarket',
+ marketId: 'market-1',
+ title: id,
+ description: id,
+ image: '',
+ status,
+ tokens:
+ price === undefined
+ ? []
+ : [
+ { id: `${id}-yes`, title: 'Yes', price },
+ { id: `${id}-no`, title: 'No', price: 1 - price },
+ ],
+ volume: 100,
+ groupItemTitle: id,
+});
+
+const createMarket = ({
+ id,
+ outcomes = [createOutcome({ id: 'outcome-1', price: 0.5 })],
+ status = PredictMarketStatus.OPEN,
+ recurrence = Recurrence.NONE,
+ endDate,
+ isHighlighted,
+ outcomeGroups,
+ game,
+}: {
+ id: string;
+ outcomes?: PredictOutcome[];
+ status?: PredictMarketStatus;
+ recurrence?: Recurrence;
+ endDate?: string;
+ isHighlighted?: boolean;
+ outcomeGroups?: PredictOutcomeGroup[];
+ game?: PredictMarket['game'];
+}): PredictMarket => ({
+ id,
+ providerId: 'polymarket',
+ slug: id,
+ title: id,
+ description: id,
+ image: '',
+ status,
+ recurrence,
+ category: 'trending',
+ tags: [],
+ outcomes,
+ ...(outcomeGroups && { outcomeGroups }),
+ liquidity: 100,
+ volume: 100,
+ ...(endDate && { endDate }),
+ ...(isHighlighted && { isHighlighted }),
+ ...(game && { game }),
+});
+
+const createGame = (
+ status: NonNullable['status'],
+): NonNullable => ({
+ id: 'game-1',
+ startTime: '2026-03-18T10:00:00.000Z',
+ status,
+ league: 'nba',
+ elapsed: null,
+ period: null,
+ score: null,
+ homeTeam: {
+ id: 'home',
+ name: 'Home',
+ logo: '',
+ abbreviation: 'HOME',
+ color: 'black',
+ },
+ awayTeam: {
+ id: 'away',
+ name: 'Away',
+ logo: '',
+ abbreviation: 'AWAY',
+ color: 'white',
+ },
+});
+
+describe('marketStaleness', () => {
+ describe('isPredictOutcomeDead', () => {
+ it('treats probabilities at or beyond the dead thresholds as dead', () => {
+ expect(
+ isPredictOutcomeDead(createOutcome({ id: 'high', price: 0.95 })),
+ ).toBe(true);
+ expect(
+ isPredictOutcomeDead(createOutcome({ id: 'low', price: 0.05 })),
+ ).toBe(true);
+ });
+
+ it('keeps probabilities inside the dead thresholds live', () => {
+ expect(
+ isPredictOutcomeDead(createOutcome({ id: 'high-live', price: 0.949 })),
+ ).toBe(false);
+ expect(
+ isPredictOutcomeDead(createOutcome({ id: 'low-live', price: 0.051 })),
+ ).toBe(false);
+ });
+
+ it('treats missing probability as dead', () => {
+ expect(isPredictOutcomeDead(createOutcome({ id: 'missing' }))).toBe(true);
+ });
+ });
+
+ describe('filterVisibleMarketOutcomes', () => {
+ it('hides a market when all outcomes are dead', () => {
+ const market = createMarket({
+ id: 'all-dead',
+ outcomes: [
+ createOutcome({ id: 'dead-high', price: 0.97 }),
+ createOutcome({ id: 'dead-low', price: 0.03 }),
+ ],
+ });
+
+ expect(filterVisibleMarketOutcomes(market)).toBeNull();
+ });
+
+ it('keeps live outcomes in their original order', () => {
+ const liveOne = createOutcome({ id: 'live-one', price: 0.4 });
+ const liveTwo = createOutcome({ id: 'live-two', price: 0.6 });
+ const market = createMarket({
+ id: 'partial',
+ outcomes: [
+ createOutcome({ id: 'dead-high', price: 0.97 }),
+ liveOne,
+ createOutcome({ id: 'dead-low', price: 0.03 }),
+ liveTwo,
+ ],
+ });
+
+ expect(filterVisibleMarketOutcomes(market)?.outcomes).toEqual([
+ liveOne,
+ liveTwo,
+ ]);
+ });
+
+ it('keeps outcome groups synchronized with visible outcomes', () => {
+ const live = createOutcome({ id: 'live', price: 0.5 });
+ const dead = createOutcome({ id: 'dead', price: 0.99 });
+ const market = createMarket({
+ id: 'grouped',
+ outcomes: [live, dead],
+ outcomeGroups: [
+ {
+ key: 'main',
+ outcomes: [live, dead],
+ subgroups: [
+ { key: 'live-subgroup', outcomes: [live] },
+ { key: 'dead-subgroup', outcomes: [dead] },
+ ],
+ },
+ ],
+ });
+
+ expect(filterVisibleMarketOutcomes(market)?.outcomeGroups).toEqual([
+ {
+ key: 'main',
+ outcomes: [live],
+ subgroups: [{ key: 'live-subgroup', outcomes: [live] }],
+ },
+ ]);
+ });
+
+ it('omits outcomeGroups when all groups are filtered out', () => {
+ const live = createOutcome({ id: 'live', price: 0.5 });
+ const dead = createOutcome({ id: 'dead', price: 0.99 });
+ const market = createMarket({
+ id: 'groups-pruned',
+ outcomes: [live, dead],
+ outcomeGroups: [
+ {
+ key: 'dead-only',
+ outcomes: [dead],
+ },
+ ],
+ });
+
+ expect(filterVisibleMarketOutcomes(market)).toEqual({
+ ...market,
+ outcomes: [live],
+ });
+ });
+ });
+
+ describe('getVisiblePredictMarket', () => {
+ it('hides markets that are not open', () => {
+ const market = createMarket({
+ id: 'resolved',
+ status: PredictMarketStatus.RESOLVED,
+ });
+
+ expect(getVisiblePredictMarket(market, { now: NOW })).toBeNull();
+ });
+
+ it('hides ended games', () => {
+ const market = createMarket({
+ id: 'ended-game',
+ game: createGame('ended'),
+ });
+
+ expect(getVisiblePredictMarket(market, { now: NOW })).toBeNull();
+ });
+
+ it('hides expired daily markets', () => {
+ const market = createMarket({
+ id: 'expired-daily',
+ recurrence: Recurrence.DAILY,
+ endDate: '2026-03-18T11:59:00.000Z',
+ });
+
+ expect(getVisiblePredictMarket(market, { now: NOW })).toBeNull();
+ });
+
+ it('does not hide expired non-daily markets by time alone', () => {
+ const market = createMarket({
+ id: 'expired-none',
+ recurrence: Recurrence.NONE,
+ endDate: '2026-03-18T11:59:00.000Z',
+ });
+
+ expect(getVisiblePredictMarket(market, { now: NOW })).toEqual(market);
+ });
+
+ it('keeps highlighted open markets without staleness filtering', () => {
+ const market = createMarket({
+ id: 'highlight',
+ isHighlighted: true,
+ recurrence: Recurrence.DAILY,
+ endDate: '2026-03-18T11:59:00.000Z',
+ outcomes: [createOutcome({ id: 'dead-high', price: 0.99 })],
+ });
+
+ expect(getVisiblePredictMarket(market, { now: NOW })).toEqual(market);
+ });
+ });
+
+ describe('penalties and ranking', () => {
+ it('applies probability staleness penalty from the original market outcomes', () => {
+ const market = createMarket({
+ id: 'stale-top-outcome',
+ outcomes: [
+ createOutcome({ id: 'dead-high', price: 0.97 }),
+ createOutcome({ id: 'live', price: 0.5 }),
+ ],
+ });
+
+ expect(getPredictMarketProbabilityPenalty(market)).toBeCloseTo(0.8);
+ });
+
+ it('applies last-hour time penalty to daily markets', () => {
+ const market = createMarket({
+ id: 'last-hour',
+ recurrence: Recurrence.DAILY,
+ endDate: '2026-03-18T12:30:00.000Z',
+ });
+
+ expect(getPredictMarketTimePenalty(market, { now: NOW })).toBe(0.5);
+ });
+
+ it('does not apply last-hour time penalty to non-daily non-game markets', () => {
+ const market = createMarket({
+ id: 'last-hour-none',
+ recurrence: Recurrence.NONE,
+ endDate: '2026-03-18T12:30:00.000Z',
+ });
+
+ expect(getPredictMarketTimePenalty(market, { now: NOW })).toBe(1);
+ });
+
+ it('detects time expiry separately from ranking penalties', () => {
+ const market = createMarket({
+ id: 'expired-daily',
+ recurrence: Recurrence.DAILY,
+ endDate: '2026-03-18T12:00:00.000Z',
+ });
+
+ expect(isPredictMarketExpiredByTime(market, { now: NOW })).toBe(true);
+ });
+
+ it('ranks highlighted markets first and stale markets behind live markets', () => {
+ const highlighted = createMarket({
+ id: 'highlighted',
+ isHighlighted: true,
+ outcomes: [createOutcome({ id: 'highlighted-dead', price: 0.99 })],
+ });
+ const stale = createMarket({
+ id: 'stale',
+ recurrence: Recurrence.DAILY,
+ endDate: '2026-03-18T12:30:00.000Z',
+ outcomes: [
+ createOutcome({ id: 'stale-dead', price: 0.99 }),
+ createOutcome({ id: 'stale-live', price: 0.5 }),
+ ],
+ });
+ const live = createMarket({
+ id: 'live',
+ outcomes: [createOutcome({ id: 'live-outcome', price: 0.5 })],
+ });
+
+ expect(
+ getVisiblePredictMarkets([stale, highlighted, live], { now: NOW }).map(
+ (market) => market.id,
+ ),
+ ).toEqual(['highlighted', 'live', 'stale']);
+ });
+
+ it('preserves original order when ranking scores tie', () => {
+ const first = createMarket({ id: 'first' });
+ const second = createMarket({ id: 'second' });
+
+ expect(
+ getVisiblePredictMarkets([first, second], { now: NOW }).map(
+ (market) => market.id,
+ ),
+ ).toEqual(['first', 'second']);
+ });
+ });
+});
diff --git a/app/components/UI/Predict/utils/marketStaleness.ts b/app/components/UI/Predict/utils/marketStaleness.ts
new file mode 100644
index 000000000000..ec47991990c9
--- /dev/null
+++ b/app/components/UI/Predict/utils/marketStaleness.ts
@@ -0,0 +1,256 @@
+import {
+ PredictMarketStatus,
+ Recurrence,
+ type PredictMarket,
+ type PredictOutcome,
+ type PredictOutcomeGroup,
+} from '../types';
+
+export const PREDICT_DEAD_OUTCOME_HIGH_THRESHOLD = 0.95;
+export const PREDICT_DEAD_OUTCOME_LOW_THRESHOLD = 0.05;
+export const PREDICT_MIN_STALENESS_PENALTY = 0.1;
+export const PREDICT_LAST_HOUR_TIME_PENALTY = 0.5;
+
+const HOUR_IN_MS = 60 * 60 * 1000;
+
+export interface PredictMarketStalenessOptions {
+ now?: Date | number;
+}
+
+const getNowMs = (options?: PredictMarketStalenessOptions): number => {
+ if (options?.now instanceof Date) {
+ return options.now.getTime();
+ }
+
+ if (typeof options?.now === 'number') {
+ return options.now;
+ }
+
+ return Date.now();
+};
+
+const getOutcomeProbability = (outcome: PredictOutcome): number | null => {
+ const probability = outcome.tokens?.[0]?.price;
+
+ if (typeof probability !== 'number' || !Number.isFinite(probability)) {
+ return null;
+ }
+
+ return probability;
+};
+
+export const isPredictOutcomeDead = (outcome: PredictOutcome): boolean => {
+ const probability = getOutcomeProbability(outcome);
+
+ if (probability === null) {
+ return true;
+ }
+
+ return (
+ probability >= PREDICT_DEAD_OUTCOME_HIGH_THRESHOLD ||
+ probability <= PREDICT_DEAD_OUTCOME_LOW_THRESHOLD
+ );
+};
+
+const isPredictOutcomeDisplayable = (outcome: PredictOutcome): boolean =>
+ outcome.status === PredictMarketStatus.OPEN && !isPredictOutcomeDead(outcome);
+
+const filterOutcomeGroup = (
+ group: PredictOutcomeGroup,
+ visibleOutcomeIds: Set,
+): PredictOutcomeGroup | null => {
+ const outcomes = group.outcomes.filter((outcome) =>
+ visibleOutcomeIds.has(outcome.id),
+ );
+ const subgroups = group.subgroups
+ ?.map((subgroup) => filterOutcomeGroup(subgroup, visibleOutcomeIds))
+ .filter((subgroup): subgroup is PredictOutcomeGroup => Boolean(subgroup));
+
+ if (outcomes.length === 0 && (!subgroups || subgroups.length === 0)) {
+ return null;
+ }
+
+ return {
+ ...group,
+ outcomes,
+ ...(subgroups && { subgroups }),
+ };
+};
+
+export const filterVisibleMarketOutcomes = (
+ market: PredictMarket,
+): PredictMarket | null => {
+ const outcomes = market.outcomes.filter(isPredictOutcomeDisplayable);
+
+ if (outcomes.length === 0) {
+ return null;
+ }
+
+ const visibleOutcomeIds = new Set(outcomes.map((outcome) => outcome.id));
+ const outcomeGroups = market.outcomeGroups
+ ?.map((group) => filterOutcomeGroup(group, visibleOutcomeIds))
+ .filter((group): group is PredictOutcomeGroup => Boolean(group));
+
+ return {
+ ...market,
+ outcomes,
+ ...(outcomeGroups && outcomeGroups.length > 0 ? { outcomeGroups } : {}),
+ };
+};
+
+const isDailyMarket = (market: PredictMarket): boolean =>
+ market.recurrence === Recurrence.DAILY;
+
+const isGameMarket = (market: PredictMarket): boolean => Boolean(market.game);
+
+const getHoursUntilEndDate = (
+ market: PredictMarket,
+ options?: PredictMarketStalenessOptions,
+): number | null => {
+ if (!market.endDate) {
+ return null;
+ }
+
+ const endDateMs = Date.parse(market.endDate);
+ if (!Number.isFinite(endDateMs)) {
+ return null;
+ }
+
+ return (endDateMs - getNowMs(options)) / HOUR_IN_MS;
+};
+
+export const isPredictMarketExpiredByTime = (
+ market: PredictMarket,
+ options?: PredictMarketStalenessOptions,
+): boolean => {
+ if (market.game?.status === 'ended') {
+ return true;
+ }
+
+ if (!isDailyMarket(market)) {
+ return false;
+ }
+
+ const hoursUntilEndDate = getHoursUntilEndDate(market, options);
+ return hoursUntilEndDate !== null && hoursUntilEndDate <= 0;
+};
+
+export const getPredictMarketTimePenalty = (
+ market: PredictMarket,
+ options?: PredictMarketStalenessOptions,
+): number => {
+ if (!isDailyMarket(market) && !isGameMarket(market)) {
+ return 1;
+ }
+
+ const hoursUntilEndDate = getHoursUntilEndDate(market, options);
+ if (hoursUntilEndDate === null) {
+ return 1;
+ }
+
+ return hoursUntilEndDate > 0 && hoursUntilEndDate <= 1
+ ? PREDICT_LAST_HOUR_TIME_PENALTY
+ : 1;
+};
+
+const getMaxOutcomeProbability = (market: PredictMarket): number | null => {
+ const probabilities = market.outcomes
+ .map(getOutcomeProbability)
+ .filter((probability): probability is number => probability !== null);
+
+ if (probabilities.length === 0) {
+ return null;
+ }
+
+ return Math.max(...probabilities);
+};
+
+export const getPredictMarketProbabilityPenalty = (
+ market: PredictMarket,
+): number => {
+ const maxProbability = getMaxOutcomeProbability(market);
+
+ if (
+ maxProbability === null ||
+ maxProbability <= PREDICT_DEAD_OUTCOME_HIGH_THRESHOLD
+ ) {
+ return 1;
+ }
+
+ return Math.max(
+ PREDICT_MIN_STALENESS_PENALTY,
+ 1 - (maxProbability - PREDICT_DEAD_OUTCOME_HIGH_THRESHOLD) * 10,
+ );
+};
+
+const getPredictMarketStalenessPenalty = (
+ market: PredictMarket,
+ options?: PredictMarketStalenessOptions,
+): number => {
+ if (market.isHighlighted) {
+ return 1;
+ }
+
+ return (
+ getPredictMarketProbabilityPenalty(market) *
+ getPredictMarketTimePenalty(market, options)
+ );
+};
+
+export const getVisiblePredictMarket = (
+ market: PredictMarket,
+ options?: PredictMarketStalenessOptions,
+): PredictMarket | null => {
+ if (market.status !== PredictMarketStatus.OPEN) {
+ return null;
+ }
+
+ if (market.isHighlighted) {
+ return market;
+ }
+
+ if (isPredictMarketExpiredByTime(market, options)) {
+ return null;
+ }
+
+ return filterVisibleMarketOutcomes(market);
+};
+
+export const getVisiblePredictMarkets = (
+ markets: PredictMarket[],
+ options?: PredictMarketStalenessOptions,
+): PredictMarket[] => {
+ const visibleMarketEntries = markets
+ .map((market, index) => ({
+ originalMarket: market,
+ visibleMarket: getVisiblePredictMarket(market, options),
+ index,
+ }))
+ .filter(
+ (
+ entry,
+ ): entry is {
+ originalMarket: PredictMarket;
+ visibleMarket: PredictMarket;
+ index: number;
+ } => Boolean(entry.visibleMarket),
+ );
+
+ const highlightedMarkets = visibleMarketEntries
+ .filter(({ visibleMarket }) => visibleMarket.isHighlighted)
+ .map(({ visibleMarket }) => visibleMarket);
+
+ const rankedMarkets = visibleMarketEntries
+ .filter(({ visibleMarket }) => !visibleMarket.isHighlighted)
+ .map(({ originalMarket, visibleMarket, index }) => ({
+ market: visibleMarket,
+ index,
+ score:
+ (markets.length - index) *
+ getPredictMarketStalenessPenalty(originalMarket, options),
+ }))
+ .sort((a, b) => b.score - a.score || a.index - b.index)
+ .map(({ market }) => market);
+
+ return [...highlightedMarkets, ...rankedMarkets];
+};
From 1919f0fef3480331b279b79402a2e3f2800e73f1 Mon Sep 17 00:00:00 2001
From: abretonc7s <107169956+abretonc7s@users.noreply.github.com>
Date: Thu, 21 May 2026 07:12:14 +0800
Subject: [PATCH 11/12] feat(perps): align PerpsController with core PR #8633
(isInternal flag) (#30413)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Mirrors the change from
[MetaMask/core#8633](https://github.com/MetaMask/core/pull/8633) into
mobile ahead of the full migration to core, so the mobile copy of
`PerpsController` stays in sync with the upstream controller. The single
substantive change forwards `isInternal: true` in the `options` payload
when `PerpsController` submits a transaction via the
`TransactionController` messenger, matching the new core behavior that
flags controller-originated transactions as internal.
This keeps both codebases behaviorally identical until mobile retires
its local `PerpsController` in favor of the core package.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: Perps internal transaction flag parity
Scenario: user submits a perps transaction routed through PerpsController
Given the Perps feature is enabled and the wallet is unlocked
When user initiates a perps action that triggers PerpsController to submit a transaction
Then the resulting TransactionController entry is marked as an internal transaction (isInternal: true)
And the transaction completes with the same UX as before this change
```
## **Screenshots/Recordings**
### **Before**
N/A — behavioral parity change, no user-visible UI difference.
### **After**
N/A — behavioral parity change, no user-visible UI difference.
## **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.
#### Performance checks (if applicable)
- [x] I've tested on Android
- [x] I've tested with a power user scenario
- [x] I've instrumented key operations with Sentry traces for production
performance metrics
## **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 the options passed to `TransactionController:addTransaction`
for perps flows, which could affect how transactions are
classified/handled downstream (e.g., UI filtering or telemetry). Scope
is small and covered by updated unit tests.
>
> **Overview**
> Perps transactions submitted via `PerpsController` now always forward
`isInternal: true` when calling `TransactionController:addTransaction`,
aligning mobile behavior with the upstream core controller.
>
> Tests for `depositWithConfirmation` (including the `perpsDeposit` and
`perpsDepositAndOrder` paths) are updated to assert the new `isInternal`
flag in the messenger call options.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
93a877ed9578053a0f42cd79efe2174d34fbb3dd. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
app/controllers/perps/PerpsController.test.ts | 2 ++
app/controllers/perps/PerpsController.ts | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/controllers/perps/PerpsController.test.ts b/app/controllers/perps/PerpsController.test.ts
index 4b818e0f367f..d1313036d244 100644
--- a/app/controllers/perps/PerpsController.test.ts
+++ b/app/controllers/perps/PerpsController.test.ts
@@ -2715,6 +2715,7 @@ describe('PerpsController', () => {
origin: 'metamask',
type: 'perpsDeposit',
skipInitialGasEstimate: true,
+ isInternal: true,
},
);
});
@@ -3106,6 +3107,7 @@ describe('PerpsController', () => {
origin: 'metamask',
type: 'perpsDepositAndOrder',
skipInitialGasEstimate: true,
+ isInternal: true,
},
);
// Should NOT also call with perpsDeposit type
diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts
index 6962ac719009..92c2d4d2b605 100644
--- a/app/controllers/perps/PerpsController.ts
+++ b/app/controllers/perps/PerpsController.ts
@@ -1365,7 +1365,7 @@ export class PerpsController extends BaseController<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
txParams as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- options as any,
+ { ...(options as any), isInternal: true },
);
}
From 3fb3e97b0caffbe42337afb3b8be96861a07b715 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Luis=20Tani=C3=A7a?=
Date: Wed, 20 May 2026 19:12:29 -0400
Subject: [PATCH 12/12] feat(predict): update World Cup banner (#30480)
## **Description**
Updates the Predict World Cup main feed banner to match the latest Figma
card design. The banner now renders as a muted card with the World Cup
image, title, description, and a filled icon button in the footer.
Also updates the Predict World Cup feature flag image override from a
single URL to a structured `bannerImage` object containing `{ url,
width, height }`, so remote images can preserve their intended aspect
ratio while still falling back to a safe default aspect ratio when
dimensions are missing or invalid.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: PRED-895
## **Manual testing steps**
```gherkin
Feature: Predict World Cup main feed banner
Scenario: user views the Predict feed with World Cup enabled
Given the Predict World Cup feature flag is enabled
And showMainFeedBanner and showWorldCupScreen are enabled
When user opens the Predict feed
Then the World Cup banner is displayed as a card with an image, title, description, and arrow button
Scenario: user taps the World Cup banner
Given the World Cup banner is visible on the Predict feed
When user taps the banner
Then user is navigated to the World Cup Predictions screen
```
## **Screenshots/Recordings**
### **Before**
N/A
### **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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.
## Testing
```bash
yarn jest app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.test.tsx app/components/UI/Predict/schemas/flags.test.ts app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts --runInBand
```
Result: 3 test suites passed, 53 tests passed.
---
> [!NOTE]
> **Medium Risk**
> Moderate risk because it changes the remote feature-flag schema/type
for the World Cup banner image (from `bannerImageUrl` to structured
`bannerImage`) and updates banner sizing logic based on provided
dimensions, which could impact rendering if remote config is malformed.
>
> **Overview**
> Updates the Predict World Cup main-feed banner to a card-style layout
with footer copy (title/description) and a filled arrow `ButtonIcon`,
while keeping the same navigation/tracking behavior.
>
> Reworks the World Cup feature-flag image override from
`bannerImageUrl` to a structured `bannerImage` object (`{ url, width,
height }`) and introduces `getPredictWorldCupBannerImageAspectRatio` to
compute banner height from configured dimensions with a safe default
fallback; associated schema/type and unit tests are updated accordingly,
and the English banner description string is revised.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
580875fd00620c01e0ef8660d46c5ddf4ea47a62. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../assets/world-cup-main-feed-banner.png | Bin 29061 -> 2413797 bytes
.../PredictWorldCupMainFeedBanner.test.tsx | 59 +++++++++-----
.../PredictWorldCupMainFeedBanner.tsx | 73 +++++++++++++++---
.../UI/Predict/schemas/flags.test.ts | 6 +-
app/components/UI/Predict/schemas/flags.ts | 8 +-
app/components/UI/Predict/types/flags.ts | 6 +-
.../utils/resolvePredictFeatureFlags.test.ts | 12 ++-
locales/languages/en.json | 2 +-
8 files changed, 131 insertions(+), 35 deletions(-)
diff --git a/app/components/UI/Predict/assets/world-cup-main-feed-banner.png b/app/components/UI/Predict/assets/world-cup-main-feed-banner.png
index cb408e32646f6d3bfc2ef9e8f8eae34bcc5d15cb..a0ef125f4347d038233f690856860abd7e2cc14d 100644
GIT binary patch
literal 2413797
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%0D||j
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