From a0b59b8dd9f0285cdf63a933a28c9e192f0d9332 Mon Sep 17 00:00:00 2001
From: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com>
Date: Fri, 22 May 2026 02:04:21 +0200
Subject: [PATCH 1/4] fix: gate Social AI notification settings & bump
notification-services-controller to 24.1.1 (#30528)
## **Description**
Ensure the project uses the newer
`@metamask/notification-services-controller` version and prevent Social
AI notification controls from showing when the Social Leaderboard
feature is disabled.
## **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**
- [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]
> **Low Risk**
> Low risk: UI visibility is now controlled by an existing remote
feature flag and changes are covered by updated tests; dependency bump
may have minor integration fallout but is limited in scope.
>
> **Overview**
> **Gates the Social AI notification settings section** so it only
appears when the `aiSocialLeaderboardEnabled` remote feature flag is
enabled (via `selectSocialLeaderboardEnabled`).
>
> Updates unit/component-view tests and notification-state presets to
cover both flag states, and adjusts the user-storage mock defaults for
`marketing.inAppNotificationsEnabled`.
>
> Bumps `@metamask/notification-services-controller` to `24.1.1` (and
updates lockfile accordingly).
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
445bca50c42702577f97243691874b69ae51beca. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../NotificationsSettings.view.test.tsx | 13 ++-
.../NotificationsSettings/index.test.tsx | 93 +++++++++++++++----
.../Settings/NotificationsSettings/index.tsx | 32 ++++---
package.json | 2 +-
.../mock-responses/defaults/user-storage.ts | 3 +-
tests/component-view/presets/notifications.ts | 6 ++
yarn.lock | 10 +-
7 files changed, 122 insertions(+), 37 deletions(-)
diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx
index 8d9b361b8518..f3aab38fe77b 100644
--- a/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx
@@ -108,7 +108,7 @@ describeForPlatforms('Notifications settings (toggles + visibility)', () => {
it('renders notification sections when notifications are enabled', async () => {
const { getByTestId, getByText, findAllByText, findByText } =
- renderSettings();
+ renderSettings({ socialLeaderboardEnabled: true });
expect(
getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE),
@@ -122,6 +122,17 @@ describeForPlatforms('Notifications settings (toggles + visibility)', () => {
expect(getByText('Off')).toBeOnTheScreen();
});
+ it('hides social AI section when social leaderboard feature flag is disabled', async () => {
+ const { getByText, queryByText, findAllByText, findByText } =
+ renderSettings({ socialLeaderboardEnabled: false });
+
+ expect(await findByText(SECTION_TITLES.walletActivity)).toBeOnTheScreen();
+ expect(getByText(SECTION_TITLES.perps)).toBeOnTheScreen();
+ expect(queryByText(SECTION_TITLES.socialAI)).toBeNull();
+ expect(getByText(SECTION_TITLES.marketing)).toBeOnTheScreen();
+ expect(await findAllByText('Push, In app')).toHaveLength(2);
+ });
+
it('hides notification sections when main toggle is off', async () => {
const { getByTestId, queryByText } = renderSettings({
notificationsEnabled: false,
diff --git a/app/components/Views/Settings/NotificationsSettings/index.test.tsx b/app/components/Views/Settings/NotificationsSettings/index.test.tsx
index b22c37426c6d..d618002ff2af 100644
--- a/app/components/Views/Settings/NotificationsSettings/index.test.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/index.test.tsx
@@ -6,12 +6,20 @@ import { Props } from './NotificationsSettings.types';
import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils';
import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar';
import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds';
+import { strings } from '../../../../../locales/i18n';
+
+jest.mock('react-native-device-info', () => ({
+ getVersion: jest.fn().mockReturnValue('7.72.0'),
+}));
jest.mock('../../../UI/Perps/selectors/featureFlags', () => ({
selectPerpsEnabledFlag: jest.fn().mockReturnValue(true),
}));
-const mockInitialState = {
+const createMockState = ({
+ notificationsEnabled = false,
+ socialLeaderboardEnabled = false,
+} = {}) => ({
settings: {
avatarAccountType: AvatarAccountType.Maskicon,
},
@@ -19,9 +27,43 @@ const mockInitialState = {
backgroundState: {
...backgroundState,
AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ NotificationServicesController: {
+ ...backgroundState.NotificationServicesController,
+ isNotificationServicesEnabled: notificationsEnabled,
+ },
+ RemoteFeatureFlagController: {
+ ...backgroundState.RemoteFeatureFlagController,
+ remoteFeatureFlags: {
+ ...backgroundState.RemoteFeatureFlagController.remoteFeatureFlags,
+ aiSocialLeaderboardEnabled: {
+ enabled: socialLeaderboardEnabled,
+ minimumVersion: '0.0.1',
+ },
+ },
+ },
},
},
-};
+});
+
+const setOptions = jest.fn();
+
+const renderNotificationsSettings = (
+ state = createMockState(),
+ navigation = {
+ setOptions,
+ goBack: jest.fn(),
+ navigate: jest.fn(),
+ } as unknown as Props['navigation'],
+) =>
+ renderWithProvider(
+ ,
+ {
+ state,
+ },
+ );
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
@@ -76,25 +118,42 @@ jest.mock('./hooks/useNotificationStoragePreferences', () => ({
}),
}));
-const setOptions = jest.fn();
+const socialAISectionTitle = strings(
+ 'app_settings.notifications_opts.social_ai_title',
+);
describe('NotificationsSettings', () => {
- it('renders correctly', () => {
- const { getByTestId } = renderWithProvider(
- ,
- {
- state: mockInitialState,
- },
- );
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders main notifications toggle', () => {
+ const { getByTestId } = renderNotificationsSettings();
+
expect(
getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE),
).toBeOnTheScreen();
});
+
+ it('renders social AI section when social leaderboard feature flag is enabled', () => {
+ const state = createMockState({
+ notificationsEnabled: true,
+ socialLeaderboardEnabled: true,
+ });
+
+ const { getByText } = renderNotificationsSettings(state);
+
+ expect(getByText(socialAISectionTitle)).toBeOnTheScreen();
+ });
+
+ it('hides social AI section when social leaderboard feature flag is disabled', () => {
+ const state = createMockState({
+ notificationsEnabled: true,
+ socialLeaderboardEnabled: false,
+ });
+
+ const { queryByText } = renderNotificationsSettings(state);
+
+ expect(queryByText(socialAISectionTitle)).toBeNull();
+ });
});
diff --git a/app/components/Views/Settings/NotificationsSettings/index.tsx b/app/components/Views/Settings/NotificationsSettings/index.tsx
index 75979fe67e95..6777b2c5cacb 100644
--- a/app/components/Views/Settings/NotificationsSettings/index.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/index.tsx
@@ -12,6 +12,7 @@ import SwitchLoadingModal from '../../../UI/Notification/SwitchLoadingModal';
import { Props } from './NotificationsSettings.types';
import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications';
+import { selectSocialLeaderboardEnabled } from '../../../../selectors/featureFlagController/socialLeaderboard';
import Routes from '../../../../constants/navigation/Routes';
@@ -96,6 +97,9 @@ const NotificationsSettings = ({ navigation }: Props) => {
const isMetamaskNotificationsEnabled = useSelector(
selectIsMetamaskNotificationsEnabled,
);
+ const isSocialLeaderboardEnabled = useSelector(
+ selectSocialLeaderboardEnabled,
+ );
const loadingText = useSwitchNotificationLoadingText();
const { preferences } = useNotificationStoragePreferences();
@@ -155,18 +159,22 @@ const NotificationsSettings = ({ navigation }: Props) => {
}
/>
-
- navigateToSection(
- 'socialAI',
- strings('app_settings.notifications_opts.social_ai_title'),
- strings('app_settings.notifications_opts.social_ai_desc'),
- )
- }
- />
+ {isSocialLeaderboardEnabled && (
+
+ navigateToSection(
+ 'socialAI',
+ strings('app_settings.notifications_opts.social_ai_title'),
+ strings('app_settings.notifications_opts.social_ai_desc'),
+ )
+ }
+ />
+ )}
Date: Fri, 22 May 2026 00:33:25 -0300
Subject: [PATCH 2/4] fix(predict): add extended sports market support for more
leagues cp-7.79.0 (#30559)
## **Description**
Adds Predict live sports and extended-market support for additional
basketball, baseball, hockey, soccer, and tennis leagues. This expands
league parsing and supported flag filtering so newly enabled Polymarket
game events can render as game detail experiences instead of generic
markets.
This also fixes several extended sports details issues found while
validating the new leagues:
- Parses WNBA, MLB, NHL, ATP, WTA, and ITF game slugs and tennis
provider metadata.
- Uses tennis `series` and team metadata when ATP/WTA/ITF events only
include generic tennis tags.
- Keeps extended game charts on the primary moneyline outcome so World
Cup and other draw-capable markets load correctly.
- Opens extended market cards through the bottom-sheet buy flow instead
of the legacy full-screen buy preview.
- Adds loading-only chart height reservation to avoid the game details
footer jumping while the chart loads.
- Adds tennis market labels and separates tennis cards into `Game Lines`
and `1st Set` groups.
- Aligns footer and card outcome labels, order, and team colors for
tennis moneyline and first-set winner markets.
## **Changelog**
CHANGELOG entry: Added support for additional Predict sports leagues and
extended sports market details.
## **Related issues**
Fixes: PRED-925 https://consensyssoftware.atlassian.net/browse/PRED-925
## **Manual testing steps**
```gherkin
Feature: Predict extended sports markets for newly supported leagues
Scenario: user opens supported game detail markets
Given Predict live sports is enabled for WNBA, MLB, NHL, ATP, WTA, and ITF
And Predict extended sports markets is enabled for NBA, WNBA, MLB, NHL, World Cup, UCL, EPL, La Liga, Serie A, Bundesliga, MLS, FIFA Friendlies, ATP, WTA, and ITF
When the user opens a supported game market
Then the market renders as a game details view
And the chart loads from the primary moneyline market
And the footer prices match the primary moneyline outcomes
Scenario: user opens an ATP, WTA, or ITF tennis game
Given the event has generic Tennis and Games tags
And the event has ATP, WTA, or ITF league metadata in series or teams
When the user opens the game details view
Then the event is parsed into the correct tennis league
And the tabs show Game Lines and 1st Set
And tennis market cards show translated labels
And the 1st Set Winner buttons use the same team colors as the footer
Scenario: user selects an extended sports market card
Given the extended sports market cards are visible
When the user taps a card outcome
Then the bottom-sheet buy flow opens for that outcome
And the app does not navigate to the legacy full-screen buy preview
```
## **Automated testing**
- `node .yarn/releases/yarn-4.14.1.cjs jest
app/components/UI/Predict/utils/gameParser.test.ts
app/components/UI/Predict/constants/sports.test.ts
app/components/UI/Predict/providers/polymarket/utils.test.ts
app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx
app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx
app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx
app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx
app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx
app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx`
- 9 test suites passed
- 288 tests passed
- `node .yarn/releases/yarn-4.14.1.cjs lint:tsc`
## **Screenshots/Recordings**
### **Before**
N/A - no recordings attached in this local PR draft.
### **After**
N/A - no recordings attached in this local PR draft.
## **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
- N/A - no Android-specific or performance-sensitive native path
changed.
- [x] I've tested with a power user scenario
- N/A - Predict sports details rendering does not depend on imported
wallet size.
- [x] I've instrumented key operations with Sentry traces for production
performance metrics
- N/A - no new production performance operation was added.
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 sports leagues are
detected/whitelisted and how UI components select outcome tokens
(primary moneyline vs extended markets), which can affect navigation,
pricing subscriptions, and displayed teams across multiple sport
experiences.
>
> **Overview**
> Extends Predict live/extended sports support to additional leagues
(WNBA/MLB/NHL and tennis `atp`/`wta`/`itf`), including updated league
whitelisting/types and more robust league detection from event
`series`/team metadata when tags are missing.
>
> Standardizes **"primary" moneyline selection** via
`getPrimaryMoneylineOutcomes`, and updates the footer buttons, market
sport cards, and game charts to ignore non-moneyline extended outcomes
(especially for draw-capable leagues) and to map tennis/home-away tokens
to the correct team labels/colors.
>
> Improves game details UX by routing outcomes-tab buys through the
shared `onBetPress` bottom-sheet flow (instead of navigation) and
reserving chart height only while loading to avoid layout jump; adds
tennis group ordering/labels (e.g., `first_set`) and corresponding i18n
strings.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c2a0122c96c82ad454438383b67c056987e0b4c9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../PredictActionButtons.test.tsx | 117 +++++++
.../PredictActionButtons.tsx | 86 ++++-
.../PredictGameChart.constants.ts | 3 +
.../PredictGameChart.test.tsx | 30 ++
.../PredictGameChart/PredictGameChart.tsx | 21 +-
.../PredictGameChart.wrapper.test.tsx | 69 ++++
.../PredictGameChartContent.tsx | 14 +-
.../PredictGameDetailsContent.tsx | 1 +
.../PredictGameDetailsTabsContent.test.tsx | 45 +--
.../PredictGameDetailsTabsContent.tsx | 30 +-
.../PredictGameOutcomesTab.test.tsx | 76 ++++
.../PredictGameOutcomesTab.tsx | 21 +-
.../PredictMarketSportCard.test.tsx | 37 ++
.../PredictMarketSportCard.tsx | 12 +-
.../UI/Predict/constants/sports.test.ts | 92 ++++-
app/components/UI/Predict/constants/sports.ts | 19 +
.../Predict/providers/polymarket/constants.ts | 8 +
.../providers/polymarket/utils.test.ts | 328 +++++++++++++++++-
app/components/UI/Predict/types/index.ts | 8 +-
.../UI/Predict/utils/gameParser.test.ts | 140 ++++++++
app/components/UI/Predict/utils/gameParser.ts | 39 ++-
locales/languages/en.json | 9 +-
22 files changed, 1118 insertions(+), 87 deletions(-)
diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx
index dc45415b30a3..d7cc7d22e433 100644
--- a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx
+++ b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx
@@ -316,6 +316,80 @@ describe('PredictActionButtons', () => {
expect(screen.getAllByText('35¢')).toHaveLength(1);
});
+ it('uses token-matched teams when a game moneyline returns home-away tokens', () => {
+ const outcome = createMockOutcome({
+ sportsMarketType: 'moneyline',
+ tokens: [
+ {
+ id: 'token-ivashka',
+ title: 'Ilya Ivashka',
+ shortTitle: 'IVASHKA',
+ price: 0.63,
+ },
+ {
+ id: 'token-stewart',
+ title: 'Hamish Stewart',
+ shortTitle: 'STEWART',
+ price: 0.38,
+ },
+ ],
+ });
+ const market = createMockMarket({
+ outcomes: [outcome],
+ game: {
+ id: 'game-atp-1',
+ startTime: '2026-05-22T07:30:00Z',
+ status: 'scheduled',
+ league: 'atp',
+ elapsed: null,
+ period: null,
+ score: null,
+ awayTeam: {
+ id: 'stewart',
+ name: 'Hamish Stewart',
+ logo: 'https://example.com/stewart.png',
+ abbreviation: 'STEWART',
+ color: TEST_HEX_COLORS.TEAM_SEA,
+ alias: 'H. Stewart',
+ },
+ homeTeam: {
+ id: 'ivashka',
+ name: 'Ilya Ivashka',
+ logo: 'https://example.com/ivashka.png',
+ abbreviation: 'IVASHKA',
+ color: TEST_HEX_COLORS.TEAM_DEN,
+ alias: 'I. Ivashka',
+ },
+ },
+ });
+
+ const mockOnBetPress = jest.fn();
+ const props = createDefaultProps({
+ market,
+ outcome,
+ onBetPress: mockOnBetPress,
+ });
+
+ renderWithProvider();
+
+ expect(screen.getByText('IVASHKA')).toBeOnTheScreen();
+ expect(screen.getByText('STEWART')).toBeOnTheScreen();
+ expect(screen.getAllByText('63¢')).toHaveLength(1);
+ expect(screen.getAllByText('38¢')).toHaveLength(1);
+
+ fireEvent.press(screen.getByTestId('action-buttons-bet-yes'));
+ fireEvent.press(screen.getByTestId('action-buttons-bet-no'));
+
+ expect(mockOnBetPress).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ id: 'token-ivashka' }),
+ );
+ expect(mockOnBetPress).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ id: 'token-stewart' }),
+ );
+ });
+
it('calls onBetPress with correct token for away team', () => {
const mockOnBetPress = jest.fn();
const outcome = createMockOutcome();
@@ -379,6 +453,49 @@ describe('PredictActionButtons', () => {
expect.objectContaining({ id: 'token-draw' }),
);
});
+
+ it('ignores extended non-moneyline outcomes for draw-capable leagues', () => {
+ const market = createMockDrawCapableGameMarket();
+ const [awayOutcome, drawOutcome, homeOutcome] = market.outcomes;
+ const extendedMarket = {
+ ...market,
+ outcomes: [
+ createMockOutcome({
+ id: 'outcome-spread',
+ sportsMarketType: 'spreads',
+ groupItemThreshold: -2.5,
+ tokens: [{ id: 'token-spread', title: 'Spread', price: 0.16 }],
+ }),
+ { ...awayOutcome, sportsMarketType: 'moneyline' },
+ createMockOutcome({
+ id: 'outcome-halftime',
+ sportsMarketType: 'soccer_halftime_result',
+ groupItemThreshold: 1,
+ tokens: [{ id: 'token-halftime', title: 'Draw', price: 0.2 }],
+ }),
+ { ...drawOutcome, sportsMarketType: 'moneyline' },
+ { ...homeOutcome, sportsMarketType: 'moneyline' },
+ ],
+ };
+
+ const props = createDefaultProps({
+ market: extendedMarket,
+ outcome: extendedMarket.outcomes[0],
+ });
+
+ renderWithProvider();
+
+ expect(screen.getByText('ARS')).toBeOnTheScreen();
+ expect(screen.getByText('DRAW')).toBeOnTheScreen();
+ expect(screen.getByText('PSG')).toBeOnTheScreen();
+ expect(screen.getAllByText('42¢')).toHaveLength(1);
+ expect(screen.getAllByText('30¢')).toHaveLength(1);
+ expect(screen.getAllByText('28¢')).toHaveLength(1);
+ expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(
+ ['token-home', 'token-draw', 'token-away'],
+ { enabled: true },
+ );
+ });
});
describe('priority order', () => {
diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx
index c784d1b4f63e..31aed30b0617 100644
--- a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx
+++ b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx
@@ -7,9 +7,16 @@ import {
PredictActionButtonsProps,
PredictBetButtonLayout,
} from './PredictActionButtons.types';
-import { PredictMarketStatus, PredictOutcomeToken } from '../../types';
+import {
+ PredictMarketGame,
+ PredictMarketStatus,
+ PredictOutcomeToken,
+} from '../../types';
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
-import { isDrawCapableLeague } from '../../constants/sports';
+import {
+ getPrimaryMoneylineOutcomes,
+ isDrawCapableLeague,
+} from '../../constants/sports';
import {
BASE_PREDICT_ACTION_BUTTONS_TEST_IDS,
PREDICT_ACTION_BUTTONS_TEST_IDS,
@@ -29,6 +36,38 @@ interface ButtonConfig {
drawToken?: PredictOutcomeToken;
}
+type GameTeam = PredictMarketGame['homeTeam'];
+
+const normalizeLabel = (value?: string): string | undefined =>
+ value?.trim().toLowerCase();
+
+const teamMatchesToken = (
+ team: GameTeam,
+ token: PredictOutcomeToken,
+): boolean => {
+ const tokenLabels = [token.shortTitle, token.title]
+ .map(normalizeLabel)
+ .filter((label): label is string => Boolean(label));
+ const teamLabels = [team.abbreviation, team.name, team.alias]
+ .map(normalizeLabel)
+ .filter((label): label is string => Boolean(label));
+
+ return tokenLabels.some((tokenLabel) => teamLabels.includes(tokenLabel));
+};
+
+const getTokenTeam = (
+ token: PredictOutcomeToken,
+ game: PredictMarketGame,
+): GameTeam | undefined => {
+ if (teamMatchesToken(game.homeTeam, token)) {
+ return game.homeTeam;
+ }
+ if (teamMatchesToken(game.awayTeam, token)) {
+ return game.awayTeam;
+ }
+ return undefined;
+};
+
const PredictActionButtons: React.FC = ({
market,
outcome,
@@ -45,21 +84,33 @@ const PredictActionButtons: React.FC = ({
}) => {
const isGameMarket = Boolean(market.game);
const isMarketOpen = market.status === PredictMarketStatus.OPEN;
+ const moneylineOutcomes = useMemo(
+ () => getPrimaryMoneylineOutcomes(market.outcomes),
+ [market.outcomes],
+ );
+ const hasMainMoneylineOutcomes = moneylineOutcomes.some(
+ (marketOutcome) =>
+ marketOutcome.sportsMarketType?.toLowerCase() === 'moneyline',
+ );
+ const primaryOutcome =
+ hasMainMoneylineOutcomes && !moneylineOutcomes.includes(outcome)
+ ? (moneylineOutcomes[0] ?? outcome)
+ : outcome;
const isDrawCapable =
isGameMarket &&
market.game &&
isDrawCapableLeague(market.game.league) &&
- market.outcomes.length >= 3;
+ moneylineOutcomes.length >= 3;
const sortedOutcomes = useMemo(() => {
if (!isDrawCapable) {
return null;
}
- return [...market.outcomes].sort(
+ return [...moneylineOutcomes].sort(
(a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0),
);
- }, [isDrawCapable, market.outcomes]);
+ }, [isDrawCapable, moneylineOutcomes]);
const tokenIds = useMemo(() => {
if (sortedOutcomes) {
@@ -68,8 +119,8 @@ const PredictActionButtons: React.FC = ({
.filter((tokenId): tokenId is string => Boolean(tokenId));
}
- return outcome.tokens.map((token) => token.id);
- }, [sortedOutcomes, outcome.tokens]);
+ return primaryOutcome.tokens.map((token) => token.id);
+ }, [sortedOutcomes, primaryOutcome.tokens]);
const { getPrice } = useLiveMarketPrices(tokenIds, {
enabled: isMarketOpen && !isLoading,
@@ -110,7 +161,7 @@ const PredictActionButtons: React.FC = ({
};
}
- const tokens = outcome.tokens;
+ const tokens = primaryOutcome.tokens;
if (tokens.length < 2) {
return null;
}
@@ -126,14 +177,17 @@ const PredictActionButtons: React.FC = ({
if (isGameMarket && market.game) {
const { awayTeam, homeTeam } = market.game;
+ const yesTeam = getTokenTeam(yesToken, market.game) ?? awayTeam;
+ const noTeam = getTokenTeam(noToken, market.game) ?? homeTeam;
+
return {
- yesLabel: awayTeam.abbreviation,
+ yesLabel: yesTeam.abbreviation,
yesPrice: Math.round(yesPrice * 100),
- yesTeamColor: awayTeam.color,
+ yesTeamColor: yesTeam.color,
yesToken,
- noLabel: homeTeam.abbreviation,
+ noLabel: noTeam.abbreviation,
noPrice: Math.round(noPrice * 100),
- noTeamColor: homeTeam.color,
+ noTeamColor: noTeam.color,
noToken,
};
}
@@ -148,7 +202,13 @@ const PredictActionButtons: React.FC = ({
noTeamColor: undefined,
noToken,
};
- }, [outcome.tokens, isGameMarket, market.game, sortedOutcomes, getPrice]);
+ }, [
+ primaryOutcome.tokens,
+ isGameMarket,
+ market.game,
+ sortedOutcomes,
+ getPrice,
+ ]);
if (isLoading) {
return (
diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts
index 0bbe19e27017..ece1d738cd8c 100644
--- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts
+++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts
@@ -1,4 +1,7 @@
export const CHART_HEIGHT = 200;
+export const TIMEFRAME_SELECTOR_RESERVED_HEIGHT = 56;
+export const CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT =
+ CHART_HEIGHT + TIMEFRAME_SELECTOR_RESERVED_HEIGHT;
export const FONT_SIZE_LABEL = 14;
export const FONT_SIZE_VALUE = 24;
export const LABEL_HEIGHT = 40;
diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx
index 09c40a62ed38..7f085d05ba17 100644
--- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx
+++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx
@@ -4,6 +4,7 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider';
import PredictGameChartContent from './PredictGameChartContent';
import { GameChartSeries } from './PredictGameChart.types';
import { TEST_HEX_COLORS } from '../../testUtils/mockColors';
+import { CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT } from './PredictGameChart.constants';
jest.mock('react-native-svg-charts', () => {
const { View, Text } = jest.requireActual('react-native');
@@ -313,6 +314,35 @@ describe('PredictGameChartContent (Chart UI)', () => {
expect(getByText('Live')).toBeOnTheScreen();
});
+
+ it('reserves chart height only while loading', () => {
+ const onTimeframeChange = jest.fn();
+
+ const { getByTestId, rerender } = renderWithProvider(
+ ,
+ );
+
+ expect(getByTestId('chart')).toHaveStyle({
+ minHeight: CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT,
+ });
+
+ rerender(
+ ,
+ );
+
+ expect(getByTestId('chart')).not.toHaveStyle({
+ minHeight: CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT,
+ });
+ });
});
describe('Data Processing', () => {
diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx
index 868229ca7b45..e451134046e0 100644
--- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx
+++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx
@@ -8,7 +8,10 @@ import React, {
import { PredictGameStatus, PredictPriceHistoryInterval } from '../../types';
import { usePredictPriceHistory } from '../../hooks/usePredictPriceHistory';
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
-import { isDrawCapableLeague } from '../../constants/sports';
+import {
+ getPrimaryMoneylineOutcomes,
+ isDrawCapableLeague,
+} from '../../constants/sports';
import { useTheme } from '../../../../../util/theme';
import PredictGameChartContent from './PredictGameChartContent';
import {
@@ -72,27 +75,31 @@ const PredictGameChart: React.FC = ({
const gameStatus = game?.status;
const isGameEnded = gameStatus === 'ended';
const isGameOngoing = gameStatus === 'ongoing';
+ const moneylineOutcomes = useMemo(
+ () => getPrimaryMoneylineOutcomes(market.outcomes),
+ [market.outcomes],
+ );
const tokenIds = useMemo(() => {
if (
game?.league &&
isDrawCapableLeague(game.league) &&
- market.outcomes.length >= 3
+ moneylineOutcomes.length >= 3
) {
- return [...market.outcomes]
+ return [...moneylineOutcomes]
.sort(
(a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0),
)
.map((o) => o.tokens[0]?.id)
.filter((id): id is string => Boolean(id));
}
- const tokens = market.outcomes[0]?.tokens ?? [];
+ const tokens = moneylineOutcomes[0]?.tokens ?? [];
return tokens.map((t) => t.id);
- }, [market.outcomes, game?.league]);
+ }, [moneylineOutcomes, game?.league]);
const seriesConfig: GameChartSeriesConfig[] | null = useMemo(() => {
if (!game) return null;
- if (isDrawCapableLeague(game.league) && market.outcomes.length >= 3) {
+ if (isDrawCapableLeague(game.league) && moneylineOutcomes.length >= 3) {
return [
{ label: game.homeTeam.abbreviation, color: game.homeTeam.color },
{ label: 'DRAW', color: colors.icon.muted },
@@ -103,7 +110,7 @@ const PredictGameChart: React.FC = ({
{ label: game.awayTeam.abbreviation, color: game.awayTeam.color },
{ label: game.homeTeam.abbreviation, color: game.homeTeam.color },
];
- }, [game, market.outcomes.length, colors.icon.muted]);
+ }, [game, moneylineOutcomes.length, colors.icon.muted]);
const [timeframe, setTimeframe] = useState(() =>
getDefaultTimeframe(gameStatus),
diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx
index 0d595714504a..3d77d7f691ab 100644
--- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx
+++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx
@@ -9,6 +9,7 @@ import {
PredictMarketStatus,
PredictPriceHistoryInterval,
PredictGameStatus,
+ PredictOutcome,
} from '../../types';
import { POLYMARKET_PROVIDER_ID } from '../../providers/polymarket/constants';
@@ -123,6 +124,20 @@ const createMockMarket = (
...overrides,
}) as PredictMarket;
+const createChartOutcome = (
+ overrides: Partial,
+): PredictOutcome =>
+ ({
+ id: 'outcome',
+ marketId: 'test-market-id',
+ title: 'Outcome',
+ groupItemTitle: 'Outcome',
+ status: 'open',
+ volume: 1000,
+ tokens: [{ id: 'token', title: 'Outcome', price: 0.5 }],
+ ...overrides,
+ }) as PredictOutcome;
+
const defaultTokenIds: [string, string] = ['token-a', 'token-b'];
const defaultMarket = createMockMarket();
@@ -194,6 +209,60 @@ describe('PredictGameChart Wrapper', () => {
}),
);
});
+
+ it('uses only main moneyline tokens for draw-capable extended sports markets', () => {
+ const market = createMockMarket({
+ game: {
+ ...mockBaseGame,
+ league: 'fifwc',
+ },
+ outcomes: [
+ createChartOutcome({
+ id: 'spread',
+ sportsMarketType: 'spreads',
+ groupItemThreshold: -2.5,
+ tokens: [{ id: 'token-spread', title: 'MEX -2.5', price: 0.16 }],
+ }),
+ createChartOutcome({
+ id: 'away',
+ sportsMarketType: 'moneyline',
+ groupItemThreshold: 2,
+ tokens: [{ id: 'token-away', title: 'Ghana', price: 0.12 }],
+ }),
+ createChartOutcome({
+ id: 'halftime',
+ sportsMarketType: 'soccer_halftime_result',
+ groupItemThreshold: 1,
+ tokens: [{ id: 'token-halftime', title: 'Draw', price: 0.2 }],
+ }),
+ createChartOutcome({
+ id: 'draw',
+ sportsMarketType: 'moneyline',
+ groupItemThreshold: 1,
+ tokens: [{ id: 'token-draw', title: 'Draw', price: 0.19 }],
+ }),
+ createChartOutcome({
+ id: 'home',
+ sportsMarketType: 'moneyline',
+ groupItemThreshold: 0,
+ tokens: [{ id: 'token-home', title: 'Mexico', price: 0.7 }],
+ }),
+ ],
+ });
+
+ render();
+
+ expect(mockUsePredictPriceHistory).toHaveBeenCalledWith(
+ expect.objectContaining({
+ marketIds: ['token-home', 'token-draw', 'token-away'],
+ enabled: true,
+ }),
+ );
+ expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(
+ ['token-home', 'token-draw', 'token-away'],
+ { enabled: true },
+ );
+ });
});
describe('Data Transformation', () => {
diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx
index 329a49b0b6a7..d9e735bb812c 100644
--- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx
+++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx
@@ -20,7 +20,10 @@ import { PredictGameChartContentProps } from './PredictGameChart.types';
import TimeframeSelector from './TimeframeSelector';
import ChartTooltip from './ChartTooltip';
import EndpointDots from './EndpointDots';
-import { CHART_HEIGHT } from './PredictGameChart.constants';
+import {
+ CHART_HEIGHT,
+ CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT,
+} from './PredictGameChart.constants';
import { PREDICT_GAME_CHART_CONTENT_TEST_IDS } from './PredictGameChartContent.testIds';
const CHART_CONTENT_INSET = { top: 30, bottom: 20, left: 0, right: 80 };
@@ -41,6 +44,9 @@ const PredictGameChartContent: React.FC = ({
const [activeIndex, setActiveIndex] = useState(-1);
const chartWidthRef = useRef(0);
const primaryDataLengthRef = useRef(0);
+ const loadingContainerMinHeight = onTimeframeChange
+ ? CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT
+ : CHART_HEIGHT;
const seriesToRender = data;
const nonEmptySeries = useMemo(
@@ -139,7 +145,11 @@ const PredictGameChartContent: React.FC = ({
if (isLoading) {
return (
-
+
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx
index 7dcc0b4c5902..8f177d7b2c05 100644
--- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx
@@ -253,6 +253,7 @@ const PredictGameDetailsContent: React.FC = ({
claimablePositions={claimablePositions}
groupMap={groupMap}
activeChipKey={activeChipKey}
+ onBetPress={onBetPress}
/>
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx
index f82f4803390f..491c2cb98802 100644
--- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx
@@ -11,24 +11,6 @@ import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsConte
import { TEST_HEX_COLORS } from '../../testUtils/mockColors';
import type { PredictMarketDetailsTabKey } from '../../Predict.testIds';
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({ navigate: jest.fn() }),
-}));
-
-jest.mock('../../hooks/usePredictActionGuard', () => ({
- usePredictActionGuard: () => ({
- executeGuardedAction: (action: () => void) => action(),
- isEligible: true,
- }),
-}));
-
-const mockNavigateToBuyPreview = jest.fn();
-jest.mock('../../hooks/usePredictNavigation', () => ({
- usePredictNavigation: () => ({
- navigateToBuyPreview: mockNavigateToBuyPreview,
- }),
-}));
-
jest.mock('./PredictGameOutcomesTab', () => {
const { View, Pressable, Text } = jest.requireActual('react-native');
const { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS: IDS } = jest.requireActual(
@@ -148,6 +130,8 @@ const positionsTabs: { label: string; key: PredictMarketDetailsTabKey }[] = [
];
describe('PredictGameDetailsTabs', () => {
+ const mockOnBetPress = jest.fn();
+
beforeEach(() => {
jest.clearAllMocks();
});
@@ -167,6 +151,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -187,6 +172,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -207,6 +193,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -232,6 +219,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -256,6 +244,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -278,6 +267,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -300,6 +290,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -308,7 +299,7 @@ describe('PredictGameDetailsTabs', () => {
).not.toBeOnTheScreen();
});
- it('calls navigateToBuyPreview when buy button is pressed', () => {
+ it('calls onBetPress when buy button is pressed', () => {
const market = createMockMarket();
const { getByTestId } = render(
@@ -322,18 +313,16 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
fireEvent.press(getByTestId('mock-buy-button'));
- expect(mockNavigateToBuyPreview).toHaveBeenCalledWith(
- expect.objectContaining({
- market,
- outcome: { id: 'outcome-1', title: 'Test' },
- outcomeToken: { id: 'token-1', title: 'Yes' },
- }),
- );
+ expect(mockOnBetPress).toHaveBeenCalledWith({
+ id: 'token-1',
+ title: 'Yes',
+ });
});
});
@@ -352,6 +341,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -381,6 +371,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -403,6 +394,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
@@ -428,6 +420,7 @@ describe('PredictGameDetailsTabs', () => {
claimablePositions={[]}
groupMap={emptyGroupMap}
activeChipKey=""
+ onBetPress={mockOnBetPress}
/>,
);
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.tsx
index 6ffec17d1e9e..e69487363717 100644
--- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.tsx
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.tsx
@@ -1,6 +1,5 @@
import React, { memo, useCallback } from 'react';
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
-import { NavigationProp, useNavigation } from '@react-navigation/native';
import { strings } from '../../../../../../locales/i18n';
import type {
PredictMarket,
@@ -9,12 +8,8 @@ import type {
PredictOutcomeToken,
PredictPosition,
} from '../../types';
-import type { PredictNavigationParamList } from '../../types/navigation';
import type { PredictMarketDetailsTabKey } from '../../Predict.testIds';
import PredictPicks from '../PredictPicks/PredictPicks';
-import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
-import { usePredictNavigation } from '../../hooks/usePredictNavigation';
-import { PredictEventValues } from '../../constants/eventNames';
import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsContent.testIds';
import PredictGameOutcomesTab from './PredictGameOutcomesTab';
@@ -28,6 +23,7 @@ interface PredictGameDetailsTabsContentProps {
claimablePositions: PredictPosition[];
groupMap: Map;
activeChipKey: string;
+ onBetPress: (token: PredictOutcomeToken) => void;
}
const PredictGameDetailsTabsContent = memo(
@@ -41,29 +37,13 @@ const PredictGameDetailsTabsContent = memo(
claimablePositions,
groupMap,
activeChipKey,
+ onBetPress,
}: PredictGameDetailsTabsContentProps) => {
- const navigation =
- useNavigation>();
- const { executeGuardedAction } = usePredictActionGuard({ navigation });
- const { navigateToBuyPreview } = usePredictNavigation();
-
const handleBuyPress = useCallback(
- (outcome: PredictOutcome, token: PredictOutcomeToken) => {
- executeGuardedAction(
- () => {
- navigateToBuyPreview({
- market,
- outcome,
- outcomeToken: token,
- entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_MARKET_DETAILS,
- });
- },
- {
- attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT,
- },
- );
+ (_outcome: PredictOutcome, token: PredictOutcomeToken) => {
+ onBetPress(token);
},
- [market, executeGuardedAction, navigateToBuyPreview],
+ [onBetPress],
);
const hasPositions =
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx
index a76c824f3fa6..b662428fecb3 100644
--- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx
@@ -20,6 +20,12 @@ jest.mock('../../../../../../locales/i18n', () => ({
'predict.sports_market_types.spreads': 'Spreads',
'predict.sports_market_types.totals': 'Totals',
'predict.sports_market_types.points': 'Points',
+ 'predict.sports_market_types.tennis_set_totals': 'Total Sets',
+ 'predict.sports_market_types.tennis_match_totals': 'Total Games',
+ 'predict.sports_market_types.tennis_first_set_totals':
+ '1st Set Total Games',
+ 'predict.sports_market_types.tennis_first_set_winner': '1st Set Winner',
+ 'predict.sports_market_types.tennis_completed_match': 'Completed Match',
};
return translations[key] ?? key;
}),
@@ -194,6 +200,15 @@ describe('PredictGameOutcomesTab', () => {
expect(getSportsMarketTypeLabel('moneyline')).toBe('Moneyline');
});
+ it('returns translated label for tennis market types', () => {
+ expect(getSportsMarketTypeLabel('tennis_match_totals')).toBe(
+ 'Total Games',
+ );
+ expect(getSportsMarketTypeLabel('tennis_first_set_winner')).toBe(
+ '1st Set Winner',
+ );
+ });
+
it('returns title-cased fallback for unknown type', () => {
expect(getSportsMarketTypeLabel('unknown_type')).toBe('Unknown Type');
});
@@ -626,6 +641,67 @@ describe('PredictGameOutcomesTab', () => {
);
});
+ it('assigns tennis first set winner team colors from normalized token labels', () => {
+ const tennisGame: PredictMarketGame = {
+ ...mockGame,
+ league: 'atp',
+ homeTeam: {
+ ...mockGame.homeTeam,
+ name: 'Ilya Ivashka',
+ abbreviation: 'ivashka',
+ alias: 'I. Ivashka',
+ color: TEST_HEX_COLORS.PURE_RED,
+ },
+ awayTeam: {
+ ...mockGame.awayTeam,
+ name: 'Hamish Stewart',
+ abbreviation: 'stewart',
+ alias: 'H. Stewart',
+ color: TEST_HEX_COLORS.PURE_BLUE,
+ },
+ };
+ const outcome = createOutcome({
+ sportsMarketType: 'tennis_first_set_winner',
+ tokens: [
+ createToken({ shortTitle: 'IVASHKA' }),
+ createToken({ shortTitle: 'STEWART' }),
+ ],
+ });
+ const subgroups: PredictOutcomeGroup[] = [
+ createGroup({
+ key: 'tennis_first_set_winner',
+ outcomes: [outcome],
+ }),
+ ];
+ const groups = [
+ createGroup({ key: 'first_set', outcomes: [], subgroups }),
+ ];
+
+ render(
+ ,
+ );
+
+ expect(mockCapturedCards[0].buttons[0]).toEqual(
+ expect.objectContaining({
+ label: 'IVASHKA',
+ variant: 'yes',
+ teamColor: TEST_HEX_COLORS.PURE_RED,
+ }),
+ );
+ expect(mockCapturedCards[0].buttons[1]).toEqual(
+ expect.objectContaining({
+ label: 'STEWART',
+ variant: 'no',
+ teamColor: TEST_HEX_COLORS.PURE_BLUE,
+ }),
+ );
+ });
+
it('assigns draw variant and no team colors for non-moneyline types', () => {
const outcome = createOutcome({
sportsMarketType: 'spreads',
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx
index efce4d195319..a9ee358c8b79 100644
--- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx
@@ -51,8 +51,25 @@ const getTeamColor = (
game?: PredictMarketGame,
): string | undefined => {
if (!game) return undefined;
- if (tokenTitle === game.homeTeam.abbreviation) return game.homeTeam.color;
- if (tokenTitle === game.awayTeam.abbreviation) return game.awayTeam.color;
+
+ const normalizedTokenTitle = tokenTitle.trim().toLowerCase();
+ const homeLabels = [
+ game.homeTeam.abbreviation,
+ game.homeTeam.name,
+ game.homeTeam.alias,
+ ]
+ .filter((label): label is string => Boolean(label))
+ .map((label) => label.trim().toLowerCase());
+ const awayLabels = [
+ game.awayTeam.abbreviation,
+ game.awayTeam.name,
+ game.awayTeam.alias,
+ ]
+ .filter((label): label is string => Boolean(label))
+ .map((label) => label.trim().toLowerCase());
+
+ if (homeLabels.includes(normalizedTokenTitle)) return game.homeTeam.color;
+ if (awayLabels.includes(normalizedTokenTitle)) return game.awayTeam.color;
return undefined;
};
diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx
index 9aac66a27777..19c9d69b8b84 100644
--- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx
@@ -158,6 +158,43 @@ describe('PredictMarketSportCard', () => {
expect(getByText('ENG 62¢')).toBeOnTheScreen();
});
+ it('uses the main moneyline outcome when extended sports markets are present', () => {
+ const extendedMarket: PredictMarketType = {
+ ...mockMarket,
+ outcomes: [
+ {
+ id: 'outcome-spread',
+ providerId: 'test-provider',
+ marketId: 'test-market-sport-1',
+ title: 'Spread',
+ description: 'Spread line',
+ image: '',
+ status: 'open',
+ sportsMarketType: 'spreads',
+ tokens: [
+ { id: 'token-spread-home', title: 'Spain -1.5', price: 0.16 },
+ { id: 'token-spread-away', title: 'England +1.5', price: 0.84 },
+ ],
+ volume: 1000000,
+ groupItemTitle: 'Spread',
+ },
+ {
+ ...mockMarket.outcomes[0],
+ sportsMarketType: 'moneyline',
+ },
+ ],
+ };
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialState },
+ );
+
+ expect(getByText('SPA 60¢')).toBeOnTheScreen();
+ expect(getByText('DRAW 15¢')).toBeOnTheScreen();
+ expect(getByText('ENG 62¢')).toBeOnTheScreen();
+ });
+
it('renders compact carousel cards without scheduled score placeholders', () => {
const { getByText, queryByText } = renderWithProvider(
,
diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx
index 1b84f3bceec0..f874510b6701 100644
--- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx
@@ -22,7 +22,10 @@ import I18n from '../../../../../../locales/i18n';
import Routes from '../../../../../constants/navigation/Routes';
import { getIntlDateTimeFormatter } from '../../../../../util/intl';
import { useTheme } from '../../../../../util/theme';
-import { isDrawCapableLeague } from '../../constants/sports';
+import {
+ getPrimaryMoneylineOutcomes,
+ isDrawCapableLeague,
+} from '../../constants/sports';
import { PredictEventValues } from '../../constants/eventNames';
import { getLeagueConfig } from '../../constants/sportLeagueConfigs';
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
@@ -117,9 +120,10 @@ const buildButtonItems = (
game: PredictMarketGame,
showDraw: boolean,
): SportOutcomeButtonItem[] => {
+ const moneylineOutcomes = getPrimaryMoneylineOutcomes(market.outcomes);
const sortedDrawOutcomes =
- showDraw && market.outcomes.length >= 3
- ? [...market.outcomes].sort(
+ showDraw && moneylineOutcomes.length >= 3
+ ? [...moneylineOutcomes].sort(
(a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0),
)
: null;
@@ -165,7 +169,7 @@ const buildButtonItems = (
]);
}
- const outcome = market.outcomes[0];
+ const outcome = moneylineOutcomes[0];
if (!outcome) return [];
const homeToken =
diff --git a/app/components/UI/Predict/constants/sports.test.ts b/app/components/UI/Predict/constants/sports.test.ts
index 8bdca5498e77..1bb6218a1bc9 100644
--- a/app/components/UI/Predict/constants/sports.test.ts
+++ b/app/components/UI/Predict/constants/sports.test.ts
@@ -1,8 +1,13 @@
-import { MONEYLINE_MARKET_TYPES, isMoneylineLikeMarketType } from './sports';
+import {
+ MONEYLINE_MARKET_TYPES,
+ filterSupportedLeagues,
+ getPrimaryMoneylineOutcomes,
+ isMoneylineLikeMarketType,
+} from './sports';
describe('MONEYLINE_MARKET_TYPES', () => {
- it('contains exactly 3 entries', () => {
- expect(MONEYLINE_MARKET_TYPES.size).toBe(3);
+ it('contains exactly 4 entries', () => {
+ expect(MONEYLINE_MARKET_TYPES.size).toBe(4);
});
it('contains moneyline', () => {
@@ -16,6 +21,10 @@ describe('MONEYLINE_MARKET_TYPES', () => {
it('contains soccer_halftime_result', () => {
expect(MONEYLINE_MARKET_TYPES.has('soccer_halftime_result')).toBe(true);
});
+
+ it('contains tennis_first_set_winner', () => {
+ expect(MONEYLINE_MARKET_TYPES.has('tennis_first_set_winner')).toBe(true);
+ });
});
describe('isMoneylineLikeMarketType', () => {
@@ -37,10 +46,17 @@ describe('isMoneylineLikeMarketType', () => {
expect(result).toBe(true);
});
+ it('returns true for tennis_first_set_winner', () => {
+ const result = isMoneylineLikeMarketType('tennis_first_set_winner');
+
+ expect(result).toBe(true);
+ });
+
it('returns true for mixed-case moneyline values', () => {
expect(isMoneylineLikeMarketType('Moneyline')).toBe(true);
expect(isMoneylineLikeMarketType('FIRST_HALF_MONEYLINE')).toBe(true);
expect(isMoneylineLikeMarketType('Soccer_Halftime_Result')).toBe(true);
+ expect(isMoneylineLikeMarketType('Tennis_First_Set_Winner')).toBe(true);
});
it('returns false for spreads', () => {
@@ -55,3 +71,73 @@ describe('isMoneylineLikeMarketType', () => {
expect(result).toBe(false);
});
});
+
+describe('filterSupportedLeagues', () => {
+ it('keeps the extended sports leagues supported by Predict', () => {
+ const result = filterSupportedLeagues([
+ 'nba',
+ 'wnba',
+ 'mlb',
+ 'nhl',
+ 'fifwc',
+ 'ucl',
+ 'epl',
+ 'lal',
+ 'sea',
+ 'bun',
+ 'mls',
+ 'fif',
+ 'atp',
+ 'wta',
+ 'itf',
+ 'fake_league',
+ ]);
+
+ expect(result).toEqual([
+ 'nba',
+ 'wnba',
+ 'mlb',
+ 'nhl',
+ 'fifwc',
+ 'ucl',
+ 'epl',
+ 'lal',
+ 'sea',
+ 'bun',
+ 'mls',
+ 'fif',
+ 'atp',
+ 'wta',
+ 'itf',
+ ]);
+ });
+});
+
+describe('getPrimaryMoneylineOutcomes', () => {
+ it('keeps only main moneyline outcomes when extended sports markets are present', () => {
+ const moneylineOutcome = { id: 'moneyline', sportsMarketType: 'moneyline' };
+ const spreadOutcome = { id: 'spread', sportsMarketType: 'spreads' };
+ const halftimeOutcome = {
+ id: 'halftime',
+ sportsMarketType: 'soccer_halftime_result',
+ };
+
+ const result = getPrimaryMoneylineOutcomes([
+ spreadOutcome,
+ moneylineOutcome,
+ halftimeOutcome,
+ ]);
+
+ expect(result).toEqual([moneylineOutcome]);
+ });
+
+ it('falls back to all outcomes when no main moneyline type is present', () => {
+ const outcomes = [
+ { id: 'legacy-away', sportsMarketType: undefined },
+ { id: 'legacy-draw', sportsMarketType: undefined },
+ { id: 'legacy-home', sportsMarketType: undefined },
+ ];
+
+ expect(getPrimaryMoneylineOutcomes(outcomes)).toBe(outcomes);
+ });
+});
diff --git a/app/components/UI/Predict/constants/sports.ts b/app/components/UI/Predict/constants/sports.ts
index 3317116821f8..24bc0ada920f 100644
--- a/app/components/UI/Predict/constants/sports.ts
+++ b/app/components/UI/Predict/constants/sports.ts
@@ -12,6 +12,9 @@ import { PredictSportsLeague } from '../types';
export const SUPPORTED_SPORTS_LEAGUES: PredictSportsLeague[] = [
'nfl',
'nba',
+ 'wnba',
+ 'mlb',
+ 'nhl',
'ucl',
'fif',
'lal',
@@ -51,6 +54,9 @@ export const SUPPORTED_SPORTS_LEAGUES: PredictSportsLeague[] = [
'dfb',
'cde',
'fifwc',
+ 'atp',
+ 'wta',
+ 'itf',
];
export const filterSupportedLeagues = (
@@ -109,7 +115,20 @@ export const MONEYLINE_MARKET_TYPES: ReadonlySet = new Set([
'moneyline',
'first_half_moneyline',
'soccer_halftime_result',
+ 'tennis_first_set_winner',
]);
export const isMoneylineLikeMarketType = (type?: string): boolean =>
type !== undefined && MONEYLINE_MARKET_TYPES.has(type.toLowerCase());
+
+export const getPrimaryMoneylineOutcomes = <
+ T extends { sportsMarketType?: string },
+>(
+ outcomes: T[],
+): T[] => {
+ const moneylineOutcomes = outcomes.filter(
+ (outcome) => outcome.sportsMarketType?.toLowerCase() === 'moneyline',
+ );
+
+ return moneylineOutcomes.length > 0 ? moneylineOutcomes : outcomes;
+};
diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts
index 56d34f5e0f61..5d0114c3d7a2 100644
--- a/app/components/UI/Predict/providers/polymarket/constants.ts
+++ b/app/components/UI/Predict/providers/polymarket/constants.ts
@@ -113,11 +113,14 @@ export const SPORTS_MARKET_TYPE_TO_GROUP: Record = {
soccer_exact_score: 'exact_score',
soccer_halftime_result: 'halftime',
total_corners: 'corners',
+ tennis_first_set_winner: 'first_set',
+ tennis_first_set_totals: 'first_set',
};
export const GROUP_ORDER: string[] = [
'game_lines',
'first_half',
+ 'first_set',
'team_totals',
'touchdowns',
'rushing',
@@ -135,6 +138,11 @@ export const DEFAULT_GROUP_KEY = 'game_lines';
export const SPORTS_MARKET_TYPE_PRIORITIES: Record = {
moneyline: 0,
+ tennis_first_set_winner: 0,
spreads: 1,
totals: 2,
+ tennis_set_totals: 2,
+ tennis_first_set_totals: 2,
+ tennis_match_totals: 3,
+ tennis_completed_match: 4,
};
diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts
index 394698f6981c..57ed5f685941 100644
--- a/app/components/UI/Predict/providers/polymarket/utils.test.ts
+++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts
@@ -3,14 +3,16 @@ import EthQuery from '@metamask/eth-query';
import { SignTypedDataVersion } from '@metamask/keyring-controller';
import Engine from '../../../../../core/Engine';
import Logger from '../../../../../util/Logger';
-import { Side, type OrderPreview } from '../../types';
+import { Side, type OrderPreview, type PredictOutcome } from '../../types';
import { PREDICT_ERROR_CODES } from '../../constants/errors';
import {
DEFAULT_CLOB_BASE_URL,
MATIC_CONTRACTS_V2,
POLYGON_MAINNET_CHAIN_ID,
+ POLYMARKET_PROVIDER_ID,
} from './constants';
import {
+ buildOutcomeGroups,
calculateConservativeBuyMarketFee,
clearClobMarketInfoCache,
clearClobMarketInfoSessionState,
@@ -119,6 +121,49 @@ describe('polymarket utils', () => {
>);
});
+ it('groups tennis first set markets separately from game lines', () => {
+ const createOutcome = (
+ id: string,
+ sportsMarketType: string,
+ ): PredictOutcome => ({
+ id,
+ providerId: POLYMARKET_PROVIDER_ID,
+ marketId: 'market-1',
+ title: id,
+ description: id,
+ image: 'icon.png',
+ status: 'open',
+ tokens: [{ id: `${id}-token`, title: 'Yes', price: 0.5 }],
+ volume: 100,
+ groupItemTitle: id,
+ sportsMarketType,
+ });
+
+ const groups = buildOutcomeGroups([
+ createOutcome('moneyline', 'moneyline'),
+ createOutcome('set-total', 'tennis_set_totals'),
+ createOutcome('match-total', 'tennis_match_totals'),
+ createOutcome('completed', 'tennis_completed_match'),
+ createOutcome('first-set-winner', 'tennis_first_set_winner'),
+ createOutcome('first-set-total', 'tennis_first_set_totals'),
+ ]);
+
+ expect(groups.map((group) => group.key)).toEqual([
+ 'game_lines',
+ 'first_set',
+ ]);
+ expect(groups[0].subgroups?.map((group) => group.key)).toEqual([
+ 'moneyline',
+ 'tennis_set_totals',
+ 'tennis_match_totals',
+ 'tennis_completed_match',
+ ]);
+ expect(groups[1].subgroups?.map((group) => group.key)).toEqual([
+ 'tennis_first_set_winner',
+ 'tennis_first_set_totals',
+ ]);
+ });
+
it('parses World Cup game events with game metadata when team data is available', () => {
const teamsByAbbreviation: Record = {
usa: {
@@ -216,6 +261,287 @@ describe('polymarket utils', () => {
);
});
+ it('parses ATP game events from provider metadata when league tag is missing', () => {
+ const teamsByAbbreviation: Record = {
+ ivashka: {
+ id: 'team-ivashka',
+ name: 'Ilya Ivashka',
+ logo: 'ivashka.png',
+ abbreviation: 'ivashka',
+ color: 'red',
+ alias: 'I. Ivashka',
+ league: 'atp',
+ },
+ stewart: {
+ id: 'team-stewart',
+ name: 'Hamish Stewart',
+ logo: 'stewart.png',
+ abbreviation: 'stewart',
+ color: 'orange',
+ alias: 'H. Stewart',
+ league: 'atp',
+ },
+ };
+ const event: PolymarketApiEvent = {
+ id: '509179',
+ slug: 'atp-ivashka-stewart-2026-05-22',
+ title: 'Bengaluru 3: Ilya Ivashka vs Hamish Stewart',
+ description: 'ATP match',
+ icon: 'icon.png',
+ closed: false,
+ active: true,
+ series: [
+ {
+ id: '10365',
+ slug: 'atp',
+ title: 'ATP',
+ recurrence: 'daily',
+ },
+ ],
+ markets: [
+ {
+ conditionId: 'condition-1',
+ question: 'Bengaluru 3: Ilya Ivashka vs Hamish Stewart',
+ description: 'Market description',
+ icon: 'icon.png',
+ image: 'image.png',
+ groupItemTitle: '',
+ groupItemThreshold: 0,
+ sportsMarketType: 'moneyline',
+ status: 'open',
+ volumeNum: 100,
+ liquidity: 100,
+ negRisk: false,
+ clobTokenIds: '["token-ivashka","token-stewart"]',
+ outcomes: '["Ilya Ivashka","Hamish Stewart"]',
+ outcomePrices: '["0.625","0.375"]',
+ closed: false,
+ active: true,
+ acceptingOrders: true,
+ resolvedBy: '',
+ orderPriceMinTickSize: 0.01,
+ umaResolutionStatus: '',
+ },
+ ],
+ tags: [
+ { id: 'tennis', label: 'Tennis', slug: 'tennis' },
+ { id: 'games', label: 'Games', slug: 'games' },
+ ],
+ teams: [teamsByAbbreviation.ivashka, teamsByAbbreviation.stewart],
+ liquidity: 100,
+ volume: 100,
+ gameId: '5658375',
+ startTime: '2026-05-22T07:30:00Z',
+ live: false,
+ ended: false,
+ };
+
+ const [market] = parsePolymarketEvents([event], {
+ category: 'hot',
+ teamLookup: (_league, abbreviation) => teamsByAbbreviation[abbreviation],
+ extendedSportsMarketsLeagues: ['atp'],
+ });
+
+ expect(market.game).toEqual(
+ expect.objectContaining({
+ id: '5658375',
+ league: 'atp',
+ startTime: '2026-05-22T07:30:00Z',
+ status: 'scheduled',
+ homeTeam: expect.objectContaining({ abbreviation: 'ivashka' }),
+ awayTeam: expect.objectContaining({ abbreviation: 'stewart' }),
+ }),
+ );
+ });
+
+ it('parses WTA game events from provider metadata when league tag is missing', () => {
+ const teamsByAbbreviation: Record = {
+ sasnovi: {
+ id: 'team-sasnovi',
+ name: 'Aliaksandra Sasnovich',
+ logo: 'sasnovi.png',
+ abbreviation: 'sasnovi',
+ color: 'red',
+ alias: 'A. Sasnovich',
+ league: 'wta',
+ },
+ ribera: {
+ id: 'team-ribera',
+ name: 'Marina Bassols Ribera',
+ logo: 'ribera.png',
+ abbreviation: 'ribera',
+ color: 'orange',
+ alias: 'M. Ribera',
+ league: 'wta',
+ },
+ };
+ const event: PolymarketApiEvent = {
+ id: '506439',
+ slug: 'wta-sasnovi-ribera-2026-05-22',
+ title:
+ 'Roland Garros, Qualification WTA: Aliaksandra Sasnovich vs Marina Bassols Ribera',
+ description: 'WTA match',
+ icon: 'icon.png',
+ closed: false,
+ active: true,
+ series: [
+ {
+ id: '10366',
+ slug: 'wta',
+ title: 'WTA',
+ recurrence: 'daily',
+ },
+ ],
+ markets: [
+ {
+ conditionId: 'condition-1',
+ question:
+ 'Roland Garros, Qualification WTA: Aliaksandra Sasnovich vs Marina Bassols Ribera',
+ description: 'Market description',
+ icon: 'icon.png',
+ image: 'image.png',
+ groupItemTitle: '',
+ groupItemThreshold: 0,
+ sportsMarketType: 'moneyline',
+ status: 'open',
+ volumeNum: 100,
+ liquidity: 100,
+ negRisk: false,
+ clobTokenIds: '["token-sasnovi","token-ribera"]',
+ outcomes: '["Aliaksandra Sasnovich","Marina Bassols Ribera"]',
+ outcomePrices: '["0.735","0.265"]',
+ closed: false,
+ active: true,
+ acceptingOrders: true,
+ resolvedBy: '',
+ orderPriceMinTickSize: 0.01,
+ umaResolutionStatus: '',
+ },
+ ],
+ tags: [
+ { id: 'tennis', label: 'Tennis', slug: 'tennis' },
+ { id: 'games', label: 'Games', slug: 'games' },
+ ],
+ teams: [teamsByAbbreviation.sasnovi, teamsByAbbreviation.ribera],
+ liquidity: 100,
+ volume: 100,
+ gameId: '5655456',
+ startTime: '2026-05-22T09:00:00Z',
+ live: false,
+ ended: false,
+ };
+
+ const [market] = parsePolymarketEvents([event], {
+ category: 'hot',
+ teamLookup: (_league, abbreviation) => teamsByAbbreviation[abbreviation],
+ extendedSportsMarketsLeagues: ['wta'],
+ });
+
+ expect(market.game).toEqual(
+ expect.objectContaining({
+ id: '5655456',
+ league: 'wta',
+ startTime: '2026-05-22T09:00:00Z',
+ status: 'scheduled',
+ homeTeam: expect.objectContaining({ abbreviation: 'sasnovi' }),
+ awayTeam: expect.objectContaining({ abbreviation: 'ribera' }),
+ }),
+ );
+ });
+
+ it('parses ITF game events from provider metadata when league tag is missing', () => {
+ const teamsByAbbreviation: Record = {
+ back: {
+ id: 'team-back',
+ name: 'Dayeon Back',
+ logo: 'back.png',
+ abbreviation: 'back',
+ color: 'red',
+ alias: 'D. Back',
+ league: 'itf',
+ },
+ eunjile: {
+ id: 'team-eunjile',
+ name: 'Eun Ji Lee',
+ logo: 'eunjile.png',
+ abbreviation: 'eunjile',
+ color: 'orange',
+ alias: 'E. Lee',
+ league: 'itf',
+ },
+ };
+ const event: PolymarketApiEvent = {
+ id: '506396',
+ slug: 'itf-back-eunjile-2026-05-21',
+ title: 'ITF Changwon: Dayeon Back vs Eun Ji Lee',
+ description: 'ITF match',
+ icon: 'icon.png',
+ closed: false,
+ active: true,
+ series: [
+ {
+ id: '11634',
+ slug: 'itf',
+ title: 'ITF',
+ recurrence: 'daily',
+ },
+ ],
+ markets: [
+ {
+ conditionId: 'condition-1',
+ question: 'ITF Changwon: Dayeon Back vs Eun Ji Lee',
+ description: 'Market description',
+ icon: 'icon.png',
+ image: 'image.png',
+ groupItemTitle: '',
+ groupItemThreshold: 0,
+ sportsMarketType: 'moneyline',
+ status: 'open',
+ volumeNum: 100,
+ liquidity: 100,
+ negRisk: false,
+ clobTokenIds: '["token-back","token-eunjile"]',
+ outcomes: '["Dayeon Back","Eun Ji Lee"]',
+ outcomePrices: '["0.86","0.14"]',
+ closed: false,
+ active: true,
+ acceptingOrders: true,
+ resolvedBy: '',
+ orderPriceMinTickSize: 0.01,
+ umaResolutionStatus: '',
+ },
+ ],
+ tags: [
+ { id: 'tennis', label: 'Tennis', slug: 'tennis' },
+ { id: 'games', label: 'Games', slug: 'games' },
+ ],
+ teams: [teamsByAbbreviation.back, teamsByAbbreviation.eunjile],
+ liquidity: 100,
+ volume: 100,
+ gameId: '1631097223',
+ startTime: '2026-05-21T01:00:00Z',
+ live: false,
+ ended: false,
+ };
+
+ const [market] = parsePolymarketEvents([event], {
+ category: 'hot',
+ teamLookup: (_league, abbreviation) => teamsByAbbreviation[abbreviation],
+ extendedSportsMarketsLeagues: ['itf'],
+ });
+
+ expect(market.game).toEqual(
+ expect.objectContaining({
+ id: '1631097223',
+ league: 'itf',
+ startTime: '2026-05-21T01:00:00Z',
+ status: 'scheduled',
+ homeTeam: expect.objectContaining({ abbreviation: 'back' }),
+ awayTeam: expect.objectContaining({ abbreviation: 'eunjile' }),
+ }),
+ );
+ });
+
describe('fetchEventsFromPolymarketApi', () => {
beforeEach(() => {
mockFetch.mockResolvedValue({
diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts
index 54c74be3dcc7..8e7e023f0a5f 100644
--- a/app/components/UI/Predict/types/index.ts
+++ b/app/components/UI/Predict/types/index.ts
@@ -152,6 +152,9 @@ export type PredictCategory =
export type PredictSportsLeague =
| 'nfl'
| 'nba'
+ | 'wnba'
+ | 'mlb'
+ | 'nhl'
| 'ucl'
| 'fif'
| 'lal'
@@ -190,7 +193,10 @@ export type PredictSportsLeague =
| 'itc'
| 'dfb'
| 'cde'
- | 'fifwc';
+ | 'fifwc'
+ | 'atp'
+ | 'wta'
+ | 'itf';
// Game status
export type PredictGameStatus = 'scheduled' | 'ongoing' | 'ended';
diff --git a/app/components/UI/Predict/utils/gameParser.test.ts b/app/components/UI/Predict/utils/gameParser.test.ts
index 96aa7ea4ac92..0f1cf2c514dc 100644
--- a/app/components/UI/Predict/utils/gameParser.test.ts
+++ b/app/components/UI/Predict/utils/gameParser.test.ts
@@ -58,6 +58,64 @@ describe('gameParser', () => {
expect(result).toBe('nfl');
});
+ it.each([
+ ['wnba', 'wnba-tor-min-2026-05-21'],
+ ['mlb', 'mlb-cle-det-2026-05-21'],
+ ['nhl', 'nhl-mon-car-2026-05-21'],
+ ['atp', 'atp-darderi-minaur-2026-05-21'],
+ ['wta', 'wta-tan-fruhvir-2026-05-22'],
+ ['itf', 'itf-par-saigo-2026-05-21'],
+ ] as const)(
+ 'returns "%s" for supported league slug and tag',
+ (league, slug) => {
+ const event = createMockEvent({
+ slug,
+ tags: [
+ { id: '1', label: league.toUpperCase(), slug: league },
+ { id: '2', label: 'Games', slug: 'games' },
+ ],
+ });
+
+ const result = getEventLeague(event);
+
+ expect(result).toBe(league);
+ },
+ );
+
+ it('returns tennis league from provider metadata when league tag is missing', () => {
+ const event = createMockEvent({
+ slug: 'wta-sasnovi-ribera-2026-05-22',
+ tags: [
+ { id: '1', label: 'Tennis', slug: 'tennis' },
+ { id: '2', label: 'Games', slug: 'games' },
+ ],
+ series: [
+ {
+ id: 'series-1',
+ slug: 'wta',
+ title: 'WTA',
+ recurrence: 'daily',
+ },
+ ],
+ teams: [
+ createMockApiTeam({
+ id: 'team-1',
+ abbreviation: 'sasnovi',
+ league: 'wta',
+ }),
+ createMockApiTeam({
+ id: 'team-2',
+ abbreviation: 'ribera',
+ league: 'wta',
+ }),
+ ],
+ });
+
+ const result = getEventLeague(event);
+
+ expect(result).toBe('wta');
+ });
+
it('returns null when missing nfl tag', () => {
const event = createMockEvent({
tags: [{ id: '2', label: 'Games', slug: 'games' }],
@@ -208,6 +266,26 @@ describe('gameParser', () => {
});
});
+ it.each([
+ ['wnba', 'wnba-tor-min-2026-05-21', 'tor', 'min'],
+ ['mlb', 'mlb-cle-det-2026-05-21', 'cle', 'det'],
+ ['nhl', 'nhl-mon-car-2026-05-21', 'mon', 'car'],
+ ['atp', 'atp-darderi-minaur-2026-05-21', 'minaur', 'darderi'],
+ ['wta', 'wta-tan-fruhvir-2026-05-22', 'fruhvir', 'tan'],
+ ['itf', 'itf-par-saigo-2026-05-21', 'saigo', 'par'],
+ ] as const)(
+ 'extracts participants from valid %s slug',
+ (league, slug, awayAbbreviation, homeAbbreviation) => {
+ const result = parseGameSlugTeams(slug, league);
+
+ expect(result).toEqual({
+ awayAbbreviation,
+ homeAbbreviation,
+ dateString: slug.slice(-10),
+ });
+ },
+ );
+
it('returns null for non-NFL slug', () => {
const result = parseGameSlugTeams('some-other-event', 'nfl');
@@ -656,6 +734,68 @@ describe('gameParser', () => {
expect(result.get('nba')).toEqual(['lal', 'bos']);
});
+ it('extracts teams from newly supported league events', () => {
+ const events = [
+ ['wnba', 'wnba-tor-min-2026-05-21'],
+ ['mlb', 'mlb-cle-det-2026-05-21'],
+ ['nhl', 'nhl-mon-car-2026-05-21'],
+ ['atp', 'atp-darderi-minaur-2026-05-21'],
+ ['wta', 'wta-tan-fruhvir-2026-05-22'],
+ ['itf', 'itf-par-saigo-2026-05-21'],
+ ].map(([league, slug], index) =>
+ createMockEvent({
+ id: `event-${index}`,
+ slug,
+ tags: [
+ {
+ id: `${index}-league`,
+ label: league.toUpperCase(),
+ slug: league,
+ },
+ { id: `${index}-games`, label: 'Games', slug: 'games' },
+ ],
+ }),
+ );
+
+ const result = extractNeededTeamsFromEvents(events, [
+ 'wnba',
+ 'mlb',
+ 'nhl',
+ 'atp',
+ 'wta',
+ 'itf',
+ ]);
+
+ expect(result.get('wnba')).toEqual(['tor', 'min']);
+ expect(result.get('mlb')).toEqual(['cle', 'det']);
+ expect(result.get('nhl')).toEqual(['mon', 'car']);
+ expect(result.get('atp')).toEqual(['minaur', 'darderi']);
+ expect(result.get('wta')).toEqual(['fruhvir', 'tan']);
+ expect(result.get('itf')).toEqual(['saigo', 'par']);
+ });
+
+ it('extracts tennis teams when provider metadata supplies the league without a league tag', () => {
+ const event = createMockEvent({
+ slug: 'wta-sasnovi-ribera-2026-05-22',
+ tags: [
+ { id: '1', label: 'Tennis', slug: 'tennis' },
+ { id: '2', label: 'Games', slug: 'games' },
+ ],
+ series: [
+ {
+ id: 'series-1',
+ slug: 'wta',
+ title: 'WTA',
+ recurrence: 'daily',
+ },
+ ],
+ });
+
+ const result = extractNeededTeamsFromEvents([event], ['wta']);
+
+ expect(result.get('wta')).toEqual(['ribera', 'sasnovi']);
+ });
+
it('deduplicates team abbreviations across events', () => {
const event1 = createMockEvent({
id: 'event-1',
diff --git a/app/components/UI/Predict/utils/gameParser.ts b/app/components/UI/Predict/utils/gameParser.ts
index 9a3c50d53a21..2e928e0ba9e7 100644
--- a/app/components/UI/Predict/utils/gameParser.ts
+++ b/app/components/UI/Predict/utils/gameParser.ts
@@ -27,6 +27,18 @@ const LEAGUE_SLUG_CONFIGS: Record = {
pattern: /^nba-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/,
teamOrder: 'away-home',
},
+ wnba: {
+ pattern: /^wnba-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/,
+ teamOrder: 'away-home',
+ },
+ mlb: {
+ pattern: /^mlb-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/,
+ teamOrder: 'away-home',
+ },
+ nhl: {
+ pattern: /^nhl-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/,
+ teamOrder: 'away-home',
+ },
ucl: {
pattern: /^ucl-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/,
teamOrder: 'home-away',
@@ -212,6 +224,18 @@ const LEAGUE_SLUG_CONFIGS: Record = {
teamOrder: 'home-away',
tagSlug: 'fifa-world-cup',
},
+ atp: {
+ pattern: /^atp-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/,
+ teamOrder: 'home-away',
+ },
+ wta: {
+ pattern: /^wta-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/,
+ teamOrder: 'home-away',
+ },
+ itf: {
+ pattern: /^itf-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/,
+ teamOrder: 'home-away',
+ },
};
export type TeamLookup = (
@@ -233,6 +257,13 @@ const hasTeamsMatchingLeague = (
return teams.length > 0 && teams.every((team) => team.league === league);
};
+const hasSeriesMatchingLeague = (
+ event: PolymarketApiEvent,
+ league: PredictSportsLeague,
+): boolean =>
+ Array.isArray(event.series) &&
+ event.series.some((series) => series.slug === league);
+
export function getEventLeague(
event: PolymarketApiEvent,
extendedSportsMarketsLeagues: string[] = [],
@@ -248,13 +279,17 @@ export function getEventLeague(
const { pattern, tagSlug } = LEAGUE_SLUG_CONFIGS[league];
const leagueTagSlug = tagSlug ?? league;
const hasLeagueTag = tags.some((tag) => tag.slug === leagueTagSlug);
+ const hasProviderLeagueMetadata =
+ hasLeagueTag ||
+ hasSeriesMatchingLeague(event, league) ||
+ hasTeamsMatchingLeague(event, league);
const hasValidSlug = pattern.test(event.slug);
- if (hasLeagueTag && hasValidSlug) {
+ if (hasProviderLeagueMetadata && hasValidSlug) {
return league;
}
const canInferFromTeams =
- hasLeagueTag &&
+ event.slug.startsWith(`${league}-`) &&
extendedSportsMarketsLeagues.includes(league) &&
hasTeamsMatchingLeague(event, league);
diff --git a/locales/languages/en.json b/locales/languages/en.json
index e707e2f48a1d..f7b22a73522e 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -2366,6 +2366,7 @@
"outcome_groups": {
"game_lines": "Game Lines",
"first_half": "1st Half",
+ "first_set": "1st Set",
"team_totals": "Team Totals",
"touchdowns": "Touchdowns",
"rushing": "Rushing",
@@ -2387,6 +2388,7 @@
"moneyline": "Moneyline",
"spreads": "Spreads",
"totals": "Totals",
+ "nrfi": "Will there be a run in the first inning?",
"both_teams_to_score": "Both Teams to Score",
"first_half_moneyline": "1H Moneyline",
"first_half_spreads": "1H Spreads",
@@ -2406,7 +2408,12 @@
"soccer_anytime_goalscorer": "Goalscorers",
"soccer_exact_score": "Exact Score",
"soccer_halftime_result": "Halftime Result",
- "total_corners": "Corners"
+ "total_corners": "Corners",
+ "tennis_set_totals": "Total Sets",
+ "tennis_match_totals": "Total Games",
+ "tennis_first_set_totals": "1st Set Total Games",
+ "tennis_first_set_winner": "1st Set Winner",
+ "tennis_completed_match": "Completed Match"
},
"sell_position": "Sell position",
"odds": "Odds",
From 52018670ea48b0c1da59fc1cf87f318583ca6010 Mon Sep 17 00:00:00 2001
From: Gaurav Goel
Date: Fri, 22 May 2026 13:39:05 +0530
Subject: [PATCH 3/4] feat: add social login dismiss event and account_type
gaps (#30529)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Social login onboarding analytics had gaps: several events were missing
account_type, and there was no dedicated signal when a user abandoned
the provider auth UI before completing login (especially Telegram and
cross-provider cancel flows)
This PR:
1) Adds Social Login Auth Browser Dismissed
(SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED) with:
* account_type (best-effort via getSocialAccountType)
* surface (onboarding | rehydration)
* elapsed_ms
2) Fills account_type gaps on:
* Account Already Exists Page Viewed / Account Not Found Page Viewed
* Wallet Setup Failure in ChoosePassword for social login flows
3) Improves cancel detection so dismiss tracking works across providers:
* Shared helpers: isOAuthUserCancellationMessage,
isSocialLoginAuthSessionDismissed
* Broader Android Google ACM cancel mapping (canceled, One Tap cancel,
resolved cancel result)
* iOS Apple ERR_REQUEST_CANCELED and additional cancel messages
Jira: https://consensyssoftware.atlassian.net/browse/TO-754
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: Social login onboarding analytics
Scenario: User dismisses Google login on iOS during wallet creation
Given the user is on onboarding and opts in to analytics
And the user taps "Continue with Google" to create a wallet
When the user closes the in-app auth browser before login completes
Then "Social Login Auth Browser Dismissed" is tracked with account_type "metamask_google", surface "onboarding", and elapsed_ms
And "Social Login Failed" is tracked with failure_type "user_cancelled"
Scenario: User dismisses Apple login on iOS during wallet creation
Given the user is on onboarding and opts in to analytics
And the user taps "Continue with Apple" to create a wallet
When the user cancels the native Apple sign-in sheet
Then "Social Login Auth Browser Dismissed" is tracked with account_type "metamask_apple" and surface "onboarding"
Scenario: User dismisses Google login on Android during wallet creation
Given the user is on onboarding on Android and opts in to analytics
And the user taps "Continue with Google" to create a wallet
When the user cancels the Google credential / One Tap UI or dismisses the browser fallback
Then "Social Login Auth Browser Dismissed" is tracked with account_type "metamask_google" and surface "onboarding"
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
#### 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**
> Adds new analytics events and modifies OAuth provider
cancellation/error classification on Android and iOS, which could change
how login failures are surfaced and tracked. Risk is moderate because it
touches cross-platform OAuth handlers and centralized OAuthService error
handling, but does not alter auth token exchange logic.
>
> **Overview**
> Adds a new MetaMetrics event `SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED` and
emits it from `OAuthService` when provider auth is cancelled/dismissed
(with `account_type`, `surface`, and `elapsed_ms`).
>
> Fills `account_type` gaps for `AccountStatus` page-view + wallet
setup/import start events and for `ChoosePassword`
`WALLET_SETUP_FAILURE` tracking during social-login flows.
>
> Refactors cancel detection into shared helpers
(`isOAuthUserCancellationMessage`, `isSocialLoginAuthSessionDismissed`)
and expands Android Google + iOS Apple handlers to classify more
cancel/dismiss outcomes (including resolved cancel results and
additional message/code patterns), with updated/added unit tests.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4a231c8550f70cbe1426e950cc9ddc45f033ee64. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../Views/AccountStatus/index.test.tsx | 73 +++++++--
app/components/Views/AccountStatus/index.tsx | 18 ++-
.../Views/ChoosePassword/index.test.tsx | 3 +
app/components/Views/ChoosePassword/index.tsx | 14 ++
.../Views/Onboarding/index.test.tsx | 64 ++++++++
app/components/Views/Onboarding/index.tsx | 142 +++++++++++++-----
.../SocialLoginErrorSheet.test.tsx | 85 +++++------
.../SocialLoginErrorSheet.tsx | 17 +--
.../Views/WalletCreationError/index.tsx | 18 ++-
app/core/Analytics/MetaMetrics.events.ts | 4 +
.../androidHandlers/google.test.ts | 52 +++++++
.../androidHandlers/google.ts | 24 ++-
.../OAuthLoginHandlers/iosHandlers/apple.ts | 10 +-
app/core/OAuthService/OAuthService.test.ts | 113 ++++++++++++++
app/core/OAuthService/OAuthService.ts | 76 +++++-----
app/core/OAuthService/error.test.ts | 66 ++++++++
app/core/OAuthService/error.ts | 46 ++++++
.../OAuthService/socialLoginAnalytics.test.ts | 113 ++++++++++++++
app/core/OAuthService/socialLoginAnalytics.ts | 68 +++++++++
19 files changed, 839 insertions(+), 167 deletions(-)
create mode 100644 app/core/OAuthService/error.test.ts
create mode 100644 app/core/OAuthService/socialLoginAnalytics.test.ts
create mode 100644 app/core/OAuthService/socialLoginAnalytics.ts
diff --git a/app/components/Views/AccountStatus/index.test.tsx b/app/components/Views/AccountStatus/index.test.tsx
index 2c70cb9cb51f..7b90015f8a3d 100644
--- a/app/components/Views/AccountStatus/index.test.tsx
+++ b/app/components/Views/AccountStatus/index.test.tsx
@@ -11,6 +11,7 @@ import renderWithProvider from '../../../util/test/renderWithProvider';
import Routes from '../../../constants/navigation/Routes';
import { PREVIOUS_SCREEN } from '../../../constants/navigation';
import { AccountStatusSelectorIDs } from './AccountStatus.testIds';
+import { AccountType } from '../../../constants/onboarding';
// Mock navigation
const mockNavigate = jest.fn();
@@ -60,11 +61,25 @@ jest.mock('../../../util/metrics/TrackOnboarding/trackOnboarding', () =>
jest.mock('../../../core/Analytics/MetricsEventBuilder', () => ({
MetricsEventBuilder: {
createEventBuilder: jest.fn(() => ({
+ addProperties: jest.fn().mockReturnThis(),
build: jest.fn(),
})),
},
}));
+const getMockEventBuilder = () => {
+ const mockBuild = jest.fn();
+ const mockAddProperties = jest.fn().mockReturnThis();
+ const mockCreateEventBuilder = jest.fn(() => ({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+ }));
+ (MetricsEventBuilder.createEventBuilder as jest.Mock).mockImplementation(
+ mockCreateEventBuilder,
+ );
+ return { mockBuild, mockAddProperties, mockCreateEventBuilder };
+};
+
describe('AccountStatus', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -154,14 +169,41 @@ describe('AccountStatus', () => {
});
describe('Analytics tracking', () => {
- it('tracks WALLET_IMPORT_STARTED event when type="found"', () => {
- const mockBuild = jest.fn();
- const mockCreateEventBuilder = jest.fn(() => ({ build: mockBuild }));
- (
- MetricsEventBuilder.createEventBuilder as jest.Mock
- ).mockImplementation(mockCreateEventBuilder);
+ it('tracks ACCOUNT_ALREADY_EXISTS_PAGE_VIEWED with account_type on mount when type="found"', () => {
+ const { mockAddProperties, mockCreateEventBuilder } =
+ getMockEventBuilder();
- mockRouteParams = { type: 'found' };
+ mockRouteParams = { type: 'found', provider: 'google' };
+ renderWithProvider();
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.ACCOUNT_ALREADY_EXISTS_PAGE_VIEWED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ account_type: AccountType.ImportedGoogle,
+ });
+ });
+
+ it('tracks ACCOUNT_NOT_FOUND_PAGE_VIEWED with account_type on mount when type="not_exist"', () => {
+ const { mockAddProperties, mockCreateEventBuilder } =
+ getMockEventBuilder();
+
+ mockRouteParams = { type: 'not_exist', provider: 'google' };
+ renderWithProvider();
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.ACCOUNT_NOT_FOUND_PAGE_VIEWED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ account_type: AccountType.MetamaskGoogle,
+ });
+ });
+
+ it('tracks WALLET_IMPORT_STARTED event with account_type when type="found"', () => {
+ const { mockBuild, mockAddProperties, mockCreateEventBuilder } =
+ getMockEventBuilder();
+
+ mockRouteParams = { type: 'found', provider: 'google' };
const { getByTestId } = renderWithProvider();
const primaryButton = getByTestId(
AccountStatusSelectorIDs.ACCOUNT_FOUND_LOGIN_BUTTON,
@@ -172,18 +214,18 @@ describe('AccountStatus', () => {
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
MetaMetricsEvents.WALLET_IMPORT_STARTED,
);
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ account_type: AccountType.ImportedGoogle,
+ });
expect(mockBuild).toHaveBeenCalled();
expect(trackOnboarding).toHaveBeenCalled();
});
- it('tracks WALLET_SETUP_STARTED event when type="not_exist"', () => {
- const mockBuild = jest.fn();
- const mockCreateEventBuilder = jest.fn(() => ({ build: mockBuild }));
- (
- MetricsEventBuilder.createEventBuilder as jest.Mock
- ).mockImplementation(mockCreateEventBuilder);
+ it('tracks WALLET_SETUP_STARTED event with account_type when type="not_exist"', () => {
+ const { mockBuild, mockAddProperties, mockCreateEventBuilder } =
+ getMockEventBuilder();
- mockRouteParams = { type: 'not_exist' };
+ mockRouteParams = { type: 'not_exist', provider: 'google' };
const { getByText } = renderWithProvider();
const primaryButton = getByText('Create a new wallet');
@@ -192,6 +234,9 @@ describe('AccountStatus', () => {
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
MetaMetricsEvents.WALLET_SETUP_STARTED,
);
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ account_type: AccountType.MetamaskGoogle,
+ });
expect(mockBuild).toHaveBeenCalled();
expect(trackOnboarding).toHaveBeenCalled();
});
diff --git a/app/components/Views/AccountStatus/index.tsx b/app/components/Views/AccountStatus/index.tsx
index 90effc3623f6..ba8bd5c1036e 100644
--- a/app/components/Views/AccountStatus/index.tsx
+++ b/app/components/Views/AccountStatus/index.tsx
@@ -27,7 +27,9 @@ import { store } from '../../../store';
import {
IMetaMetricsEvent,
ITrackingEvent,
+ JsonMap,
} from '../../../core/Analytics/MetaMetrics.types';
+import { getSocialAccountType } from '../../../constants/onboarding';
import {
OnboardingActionTypes,
saveOnboardingEvent as saveEvent,
@@ -102,10 +104,18 @@ const AccountStatus = ({ saveOnboardingEvent }: AccountStatusProps) => {
};
}, [windowWidth]);
+ const accountType = useMemo(
+ () =>
+ provider ? getSocialAccountType(provider, type === 'found') : undefined,
+ [provider, type],
+ );
+
const track = useCallback(
- (event: IMetaMetricsEvent) => {
+ (event: IMetaMetricsEvent, properties: JsonMap = {}) => {
trackOnboarding(
- MetricsEventBuilder.createEventBuilder(event).build(),
+ MetricsEventBuilder.createEventBuilder(event)
+ .addProperties(properties)
+ .build(),
saveOnboardingEvent,
);
},
@@ -129,12 +139,13 @@ const AccountStatus = ({ saveOnboardingEvent }: AccountStatusProps) => {
type === 'found'
? MetaMetricsEvents.ACCOUNT_ALREADY_EXISTS_PAGE_VIEWED
: MetaMetricsEvents.ACCOUNT_NOT_FOUND_PAGE_VIEWED,
+ accountType ? { account_type: accountType } : {},
);
return () => {
endTrace({ name: traceName });
};
- }, [onboardingTraceCtx, type, track]);
+ }, [accountType, onboardingTraceCtx, type, track]);
const navigateNextScreen = (
targetRoute: string,
@@ -167,6 +178,7 @@ const AccountStatus = ({ saveOnboardingEvent }: AccountStatusProps) => {
metricFlow === ACCOUNT_STATUS_PRIMARY_FLOW.EXISTING_ACCOUNT_IMPORT
? MetaMetricsEvents.WALLET_IMPORT_STARTED
: MetaMetricsEvents.WALLET_SETUP_STARTED,
+ accountType ? { account_type: accountType } : {},
);
};
diff --git a/app/components/Views/ChoosePassword/index.test.tsx b/app/components/Views/ChoosePassword/index.test.tsx
index 930042210c3e..af2fc849e76c 100644
--- a/app/components/Views/ChoosePassword/index.test.tsx
+++ b/app/components/Views/ChoosePassword/index.test.tsx
@@ -54,6 +54,7 @@ jest.mock('@metamask/key-tree', () => ({
import ChoosePassword from './index.tsx';
import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding';
+import { AccountType } from '../../../constants/onboarding';
import {
TraceName,
TraceOperation,
@@ -1122,6 +1123,7 @@ describe('ChoosePassword', () => {
...mockRoute.params,
[PREVIOUS_SCREEN]: ONBOARDING,
oauthLoginSuccess: true,
+ provider: 'google',
};
const component = renderWithProviders();
@@ -1146,6 +1148,7 @@ describe('ChoosePassword', () => {
params: expect.objectContaining({
metricsEnabled: true,
error: walletError,
+ accountType: AccountType.MetamaskGoogle,
}),
},
],
diff --git a/app/components/Views/ChoosePassword/index.tsx b/app/components/Views/ChoosePassword/index.tsx
index bb94764eca89..dfb7b7616b62 100644
--- a/app/components/Views/ChoosePassword/index.tsx
+++ b/app/components/Views/ChoosePassword/index.tsx
@@ -248,6 +248,10 @@ const ChoosePassword = () => {
const canSubmit = getOauth2LoginSuccess()
? passwordsMatch
: passwordsMatch && isSelected;
+ const oauthProvider = route.params?.provider;
+ const socialAccountType = oauthProvider
+ ? getSocialAccountType(oauthProvider, false)
+ : undefined;
if (loading) return { valid: false, shouldTrack: false };
@@ -261,6 +265,7 @@ const ChoosePassword = () => {
track(MetaMetricsEvents.WALLET_SETUP_FAILURE, {
wallet_setup_type: 'import',
error_type: strings('choose_password.password_dont_match'),
+ ...(socialAccountType && { account_type: socialAccountType }),
});
}
return { valid: false, shouldTrack: false };
@@ -270,6 +275,7 @@ const ChoosePassword = () => {
track(MetaMetricsEvents.WALLET_SETUP_FAILURE, {
wallet_setup_type: 'import',
error_type: strings('choose_password.password_length_error'),
+ ...(socialAccountType && { account_type: socialAccountType }),
});
return { valid: false, shouldTrack: false };
}
@@ -281,6 +287,7 @@ const ChoosePassword = () => {
loading,
isSelected,
getOauth2LoginSuccess,
+ route.params?.provider,
track,
]);
@@ -397,9 +404,15 @@ const ChoosePassword = () => {
dispatch(setLockTimeAction(-1));
setLoading(false);
+ const oauthProvider = route.params?.provider;
+ const socialAccountType = oauthProvider
+ ? getSocialAccountType(oauthProvider, false)
+ : undefined;
+
track(MetaMetricsEvents.WALLET_SETUP_FAILURE, {
wallet_setup_type: 'new',
error_type: caughtError.toString(),
+ ...(socialAccountType && { account_type: socialAccountType }),
});
const onboardingTraceCtx = route.params?.onboardingTraceCtx;
@@ -430,6 +443,7 @@ const ChoosePassword = () => {
params: {
metricsEnabled,
error: caughtError,
+ ...(socialAccountType && { accountType: socialAccountType }),
},
},
],
diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx
index c3486517cfd1..6a757cff1c65 100644
--- a/app/components/Views/Onboarding/index.test.tsx
+++ b/app/components/Views/Onboarding/index.test.tsx
@@ -1829,6 +1829,70 @@ describe('Onboarding', () => {
}),
}),
);
+ expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: MetaMetricsEvents.SOCIAL_LOGIN_FAILED.category,
+ properties: expect.objectContaining({
+ account_type: AccountType.MetamaskGoogle,
+ is_rehydration: 'false',
+ failure_type: 'error',
+ error_category: 'provider_login',
+ }),
+ }),
+ );
+ });
+
+ it('tracks Social Login Failed when createLoginHandler rejects an invalid provider before OAuthService', async () => {
+ Platform.OS = 'ios';
+ (Device.isIos as jest.Mock).mockReturnValue(true);
+ (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true);
+ mockCreateLoginHandler.mockImplementation(() => {
+ throw new OAuthError(
+ 'Invalid provider',
+ OAuthErrorType.InvalidProvider,
+ );
+ });
+ mockAnalytics.trackEvent.mockClear();
+
+ const { getByTestId } = renderScreen(
+ Onboarding,
+ { name: 'Onboarding' },
+ {
+ state: mockInitialState,
+ },
+ );
+
+ const createWalletButton = getByTestId(
+ OnboardingSelectorIDs.NEW_WALLET_BUTTON,
+ );
+ await act(async () => {
+ fireEvent.press(createWalletButton);
+ });
+
+ const navCall = mockNavigate.mock.calls.find(
+ (call) =>
+ call[0] === Routes.MODAL.ROOT_MODAL_FLOW &&
+ call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET,
+ );
+
+ await act(async () => {
+ await navCall[1].params.onPressContinueWithGoogle(true);
+ await flushPromises();
+ await flushPromises();
+ });
+
+ expect(mockOAuthService.handleOAuthLogin).not.toHaveBeenCalled();
+ expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: MetaMetricsEvents.SOCIAL_LOGIN_FAILED.category,
+ properties: expect.objectContaining({
+ account_type: AccountType.MetamaskGoogle,
+ is_rehydration: 'false',
+ failure_type: 'error',
+ error_category: 'provider_login',
+ }),
+ }),
+ );
});
it('blocks Google login on iOS < 17.4 import flow with rehydration sheet when googleLoginIosUnsupportedBlockingEnabled is true', async () => {
diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx
index 5816a1864351..adcc97aa10b5 100644
--- a/app/components/Views/Onboarding/index.tsx
+++ b/app/components/Views/Onboarding/index.tsx
@@ -87,6 +87,10 @@ import { SEEDLESS_ONBOARDING_ENABLED } from '../../../core/OAuthService/OAuthLog
import OAuthLoginService from '../../../core/OAuthService/OAuthService';
import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error';
import { createLoginHandler } from '../../../core/OAuthService/OAuthLoginHandlers';
+import {
+ isPreOAuthSocialLoginFailure,
+ trackSocialLoginFailed,
+} from '../../../core/OAuthService/socialLoginAnalytics';
import { AuthConnection } from '../../../core/OAuthService/OAuthInterface';
import { selectWalletSetupCompletedAttributionAnalyticsProps } from '../../../selectors/attribution';
import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics';
@@ -209,6 +213,16 @@ const Onboarding = () => {
const onboardingTraceCtx = useRef(undefined);
const socialLoginTraceCtx = useRef(undefined);
+ const endSocialLoginAttemptTrace = useCallback((success: boolean) => {
+ if (socialLoginTraceCtx.current) {
+ endTrace({
+ name: TraceName.OnboardingSocialLoginAttempt,
+ data: { success },
+ });
+ socialLoginTraceCtx.current = undefined;
+ }
+ }, []);
+
const mounted = useRef(false);
const hasCheckedVaultBackup = useRef(false);
const warningCallback = useRef<() => boolean>(() => true);
@@ -448,10 +462,7 @@ const Onboarding = () => {
provider: string,
): void => {
const isIOS = Platform.OS === 'ios';
- if (socialLoginTraceCtx.current) {
- endTrace({ name: TraceName.OnboardingSocialLoginAttempt });
- socialLoginTraceCtx.current = undefined;
- }
+ endSocialLoginAttemptTrace(true);
// Error case (result.type !== 'success') is not handled here because
// OAuthService.handleOAuthLogin() throws on failure, and the error is
@@ -542,6 +553,7 @@ const Onboarding = () => {
dispatch,
onboardingVersion,
walletSetupAttributionAnalyticsProps,
+ endSocialLoginAttemptTrace,
],
);
@@ -579,6 +591,17 @@ const Onboarding = () => {
socialConnectionType: string,
createWallet: boolean,
): Promise => {
+ const isRehydration = !createWallet;
+
+ const trackPreOAuthSocialLoginFailure = (failureError: unknown) => {
+ trackSocialLoginFailed({
+ authConnection: socialConnectionType,
+ isRehydration,
+ errorCategory: 'provider_login',
+ error: failureError,
+ });
+ };
+
if (error instanceof OAuthError) {
// For OAuth API failures (excluding user cancellation/dismissal), handle based on analytics consent
if (
@@ -589,6 +612,7 @@ const Onboarding = () => {
error.code === OAuthErrorType.TelegramLoginError
) {
// QA: do not show error sheet if user cancelled
+ endSocialLoginAttemptTrace(false);
return;
} else if (
error.code === OAuthErrorType.GoogleLoginNoCredential ||
@@ -642,6 +666,7 @@ const Onboarding = () => {
(fallbackError.code === OAuthErrorType.UserCancelled ||
fallbackError.code === OAuthErrorType.UserDismissed)
) {
+ endSocialLoginAttemptTrace(false);
return;
}
// Handle both OAuthError and unexpected errors from browser fallback
@@ -661,14 +686,15 @@ const Onboarding = () => {
);
handleOAuthLoginError(wrappedError, socialConnectionType, true);
}
+ endSocialLoginAttemptTrace(false);
return;
}
}
+ endSocialLoginAttemptTrace(false);
return;
}
// Show error sheet for auth server or seedless controller errors
if (
- error.code === OAuthErrorType.InvalidProvider ||
error.code === OAuthErrorType.AuthServerError ||
error.code === OAuthErrorType.LoginError
) {
@@ -683,10 +709,28 @@ const Onboarding = () => {
type: 'error',
},
});
+ endSocialLoginAttemptTrace(false);
+ return;
+ }
+ if (isPreOAuthSocialLoginFailure(error)) {
+ trackPreOAuthSocialLoginFailure(error);
+ handleOAuthLoginError(error, socialConnectionType, false);
+ navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, {
+ screen: Routes.SHEET.SUCCESS_ERROR_SHEET,
+ params: {
+ title: strings('error_sheet.oauth_error_title'),
+ description: strings('error_sheet.oauth_error_description'),
+ descriptionAlign: 'center',
+ buttonLabel: strings('error_sheet.oauth_error_button'),
+ type: 'error',
+ },
+ });
+ endSocialLoginAttemptTrace(false);
return;
}
// unexpected oauth login error
handleOAuthLoginError(error, socialConnectionType, false);
+ endSocialLoginAttemptTrace(false);
return;
}
@@ -700,13 +744,7 @@ const Onboarding = () => {
});
endTrace({ name: TraceName.OnboardingSocialLoginError });
- if (socialLoginTraceCtx.current) {
- endTrace({
- name: TraceName.OnboardingSocialLoginAttempt,
- data: { success: false },
- });
- socialLoginTraceCtx.current = undefined;
- }
+ endSocialLoginAttemptTrace(false);
navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, {
screen: Routes.SHEET.SUCCESS_ERROR_SHEET,
@@ -725,6 +763,7 @@ const Onboarding = () => {
setLoading,
unsetLoading,
handlePostSocialLogin,
+ endSocialLoginAttemptTrace,
],
);
@@ -815,6 +854,15 @@ const Onboarding = () => {
track(MetaMetricsEvents.WALLET_GOOGLE_IOS_ERROR_VIEWED, {
account_type: accountType,
});
+ trackSocialLoginFailed({
+ authConnection: provider,
+ isRehydration: !createWallet,
+ errorCategory: 'provider_login',
+ error: new OAuthError(
+ 'Google login not supported on this iOS version',
+ OAuthErrorType.UnsupportedPlatform,
+ ),
+ });
return;
}
@@ -839,43 +887,57 @@ const Onboarding = () => {
return;
}
- socialLoginTraceCtx.current = trace({
- name: TraceName.OnboardingSocialLoginAttempt,
- op: TraceOperation.OnboardingUserJourney,
- tags: { ...getTraceTags(store.getState()), provider },
- parentContext: onboardingTraceCtx.current,
- });
-
setLoading();
- const loginHandler = createLoginHandler(
- Platform.OS,
- provider,
- false,
- provider === AuthConnection.Telegram
- ? { telegramLoginEnabled: true }
- : undefined,
- );
try {
- const result = await OAuthLoginService.handleOAuthLogin(
- loginHandler,
- !createWallet,
- );
- handlePostSocialLogin(
- result as OAuthLoginResult,
- createWallet,
+ const loginHandler = createLoginHandler(
+ Platform.OS,
provider,
+ false,
+ provider === AuthConnection.Telegram
+ ? { telegramLoginEnabled: true }
+ : undefined,
);
- // Mark metrics opt-in UI as seen since OAuth users auto-consent to metrics.
- // Set AFTER OAuth succeeds to avoid marking as seen if the flow fails.
- await markMetricsOptInUISeen();
+ socialLoginTraceCtx.current = trace({
+ name: TraceName.OnboardingSocialLoginAttempt,
+ op: TraceOperation.OnboardingUserJourney,
+ tags: { ...getTraceTags(store.getState()), provider },
+ parentContext: onboardingTraceCtx.current,
+ });
+
+ try {
+ const result = await OAuthLoginService.handleOAuthLogin(
+ loginHandler,
+ !createWallet,
+ );
+ handlePostSocialLogin(
+ result as OAuthLoginResult,
+ createWallet,
+ provider,
+ );
- // delay unset loading to avoid flash of loading state
- setTimeout(() => {
+ // Mark metrics opt-in UI as seen since OAuth users auto-consent to metrics.
+ // Set AFTER OAuth succeeds to avoid marking as seen if the flow fails.
+ await markMetricsOptInUISeen();
+
+ // delay unset loading to avoid flash of loading state
+ setTimeout(() => {
+ unsetLoading();
+ }, 1000);
+ } catch (error) {
unsetLoading();
- }, 1000);
+ await handleLoginError(error as Error, provider, createWallet);
+ }
} catch (error) {
unsetLoading();
+ if (!(error instanceof OAuthError)) {
+ trackSocialLoginFailed({
+ authConnection: provider,
+ isRehydration: !createWallet,
+ errorCategory: 'provider_login',
+ error,
+ });
+ }
await handleLoginError(error as Error, provider, createWallet);
}
};
diff --git a/app/components/Views/WalletCreationError/SocialLoginErrorSheet.test.tsx b/app/components/Views/WalletCreationError/SocialLoginErrorSheet.test.tsx
index 00bf3484b359..0d06e3306dc1 100644
--- a/app/components/Views/WalletCreationError/SocialLoginErrorSheet.test.tsx
+++ b/app/components/Views/WalletCreationError/SocialLoginErrorSheet.test.tsx
@@ -14,6 +14,8 @@ import { Authentication } from '../../../core';
import AppConstants from '../../../core/AppConstants';
import Routes from '../../../constants/navigation/Routes';
+const defaultAccountType = AccountType.MetamaskGoogle;
+
// Type helper for UNSAFE_getAllByType with mocked string components
const asComponentType = (name: string) => name as unknown as ComponentType;
@@ -70,6 +72,18 @@ describe('SocialLoginErrorSheet', () => {
},
};
+ const renderSheet = (
+ props: { error?: Error; accountType?: AccountType } = {},
+ state = initialState,
+ ) =>
+ renderWithProvider(
+ ,
+ { state },
+ );
+
beforeEach(() => {
jest.clearAllMocks();
mockAddProperties.mockReturnThis();
@@ -86,33 +100,30 @@ describe('SocialLoginErrorSheet', () => {
describe('analytics', () => {
it('tracks screen viewed event on mount', () => {
- renderWithProvider(, {
- state: initialState,
- });
+ renderSheet();
expect(mockCreateEventBuilder).toHaveBeenCalled();
expect(mockTrackEvent).toHaveBeenCalled();
});
- it('tracks screen viewed event with account_type from getSocialAccountType when OAuth provider is unknown', () => {
- renderWithProvider(, {
- state: initialState,
- });
+ it('tracks screen viewed event with explicit account_type prop', () => {
+ renderSheet({ accountType: AccountType.MetamaskGoogle });
expect(mockAddProperties).toHaveBeenCalledWith({
- account_type: AccountType.Metamask,
+ account_type: AccountType.MetamaskGoogle,
error_type: 'Error',
error_message: 'Test social login error',
});
});
- it('tracks screen viewed event with metamask_google when Google OAuth is in seedless state', () => {
- renderWithProvider(, {
- state: stateWithGoogleOAuth,
- });
+ it('uses explicit account_type instead of seedless auth connection state', () => {
+ renderSheet(
+ { accountType: AccountType.MetamaskApple },
+ stateWithGoogleOAuth,
+ );
expect(mockAddProperties).toHaveBeenCalledWith({
- account_type: AccountType.MetamaskGoogle,
+ account_type: AccountType.MetamaskApple,
error_type: 'Error',
error_message: 'Test social login error',
});
@@ -121,10 +132,7 @@ describe('SocialLoginErrorSheet', () => {
it('tracks retry clicked event when Try again is pressed', async () => {
(Authentication.deleteWallet as jest.Mock).mockResolvedValue(undefined);
- const { getByText } = renderWithProvider(
- ,
- { state: initialState },
- );
+ const { getByText } = renderSheet();
mockCreateEventBuilder.mockClear();
mockAddProperties.mockClear();
@@ -138,17 +146,14 @@ describe('SocialLoginErrorSheet', () => {
);
expect(mockAddProperties).toHaveBeenCalledWith({
cta_type: WalletCreationErrorCtaType.Retry,
- account_type: AccountType.Metamask,
+ account_type: AccountType.MetamaskGoogle,
});
expect(mockTrackEvent).toHaveBeenCalled();
});
});
it('tracks support clicked event when MetaMask Support is pressed', () => {
- const { getByText } = renderWithProvider(
- ,
- { state: initialState },
- );
+ const { getByText } = renderSheet();
mockCreateEventBuilder.mockClear();
mockAddProperties.mockClear();
@@ -161,41 +166,33 @@ describe('SocialLoginErrorSheet', () => {
);
expect(mockAddProperties).toHaveBeenCalledWith({
cta_type: WalletCreationErrorCtaType.ContactSupport,
- account_type: AccountType.Metamask,
+ account_type: AccountType.MetamaskGoogle,
});
expect(mockTrackEvent).toHaveBeenCalled();
});
});
it('renders error title', () => {
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
+ const { getByText } = renderSheet({}, initialState);
expect(getByText('Something went wrong')).toBeOnTheScreen();
});
it('renders try again button', () => {
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
+ const { getByText } = renderSheet({}, initialState);
expect(getByText('Try again')).toBeOnTheScreen();
});
it('renders MetaMask Support link', () => {
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
+ const { getByText } = renderSheet({}, initialState);
expect(getByText('MetaMask Support')).toBeOnTheScreen();
});
it('deletes wallet and resets navigation when try again is pressed', async () => {
(Authentication.deleteWallet as jest.Mock).mockResolvedValue(undefined);
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
+ const { getByText } = renderSheet({}, initialState);
const tryAgainButton = getByText('Try again');
fireEvent.press(tryAgainButton);
@@ -211,9 +208,7 @@ describe('SocialLoginErrorSheet', () => {
});
it('opens support URL when MetaMask Support is pressed', () => {
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
+ const { getByText } = renderSheet({}, initialState);
const supportLink = getByText('MetaMask Support');
fireEvent.press(supportLink);
@@ -224,24 +219,14 @@ describe('SocialLoginErrorSheet', () => {
});
it('renders fox logo image', () => {
- const { UNSAFE_getAllByType } = renderWithProvider(
- ,
- {
- state: initialState,
- },
- );
+ const { UNSAFE_getAllByType } = renderSheet({}, initialState);
const images = UNSAFE_getAllByType(Image);
expect(images.length).toBeGreaterThan(0);
});
it('renders danger icon', () => {
- const { UNSAFE_getAllByType } = renderWithProvider(
- ,
- {
- state: initialState,
- },
- );
+ const { UNSAFE_getAllByType } = renderSheet({}, initialState);
const icons = UNSAFE_getAllByType(asComponentType('SvgMock'));
expect(icons.length).toBeGreaterThan(0);
diff --git a/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx b/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx
index efc116f0cadd..782e06d52b2a 100644
--- a/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx
+++ b/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx
@@ -1,8 +1,7 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
+import React, { useCallback, useEffect } from 'react';
import { Image, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
-import { useSelector } from 'react-redux';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
@@ -22,10 +21,9 @@ import {
import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics';
import { MetaMetricsEvents } from '../../../core/Analytics';
import {
- getSocialAccountType,
+ AccountType,
WalletCreationErrorCtaType,
} from '../../../constants/onboarding';
-import { selectSeedlessOnboardingAuthConnection } from '../../../selectors/seedlessOnboardingController';
import { strings } from '../../../../locales/i18n';
import Routes from '../../../constants/navigation/Routes';
@@ -37,17 +35,16 @@ const FOX_LOGO = require('../../../images/branding/fox.png');
interface SocialLoginErrorSheetProps {
error?: Error;
+ accountType: AccountType;
}
-const SocialLoginErrorSheet = ({ error }: SocialLoginErrorSheetProps) => {
+const SocialLoginErrorSheet = ({
+ error,
+ accountType,
+}: SocialLoginErrorSheetProps) => {
const navigation = useNavigation();
const tw = useTailwind();
const { trackEvent, createEventBuilder } = useAnalytics();
- const oauthProvider = useSelector(selectSeedlessOnboardingAuthConnection);
- const accountType = useMemo(
- () => getSocialAccountType(oauthProvider ?? '', false),
- [oauthProvider],
- );
useEffect(() => {
trackEvent(
diff --git a/app/components/Views/WalletCreationError/index.tsx b/app/components/Views/WalletCreationError/index.tsx
index 214a62a9a238..336e32d86fe7 100644
--- a/app/components/Views/WalletCreationError/index.tsx
+++ b/app/components/Views/WalletCreationError/index.tsx
@@ -3,24 +3,36 @@ import { useRoute, RouteProp } from '@react-navigation/native';
import SocialLoginErrorSheet from './SocialLoginErrorSheet';
import SRPErrorScreen from './SRPErrorScreen';
+import { AccountType } from '../../../constants/onboarding';
interface WalletCreationErrorParams {
metricsEnabled: boolean;
error: Error;
+ accountType?: AccountType;
}
const WalletCreationError = () => {
const route =
useRoute>();
- const { metricsEnabled, error } = route.params || {};
+ const { metricsEnabled, error, accountType } = route.params || {};
// Render different UI based on metrics consent status
if (metricsEnabled) {
- return ;
+ return (
+
+ );
}
- return ;
+ return (
+
+ );
};
export default WalletCreationError;
diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts
index 299083e5ca63..1246b431d7a5 100644
--- a/app/core/Analytics/MetaMetrics.events.ts
+++ b/app/core/Analytics/MetaMetrics.events.ts
@@ -183,6 +183,7 @@ enum EVENT_NAME {
WALLET_SETUP_COMPLETED = 'Wallet Setup Completed',
SOCIAL_LOGIN_COMPLETED = 'Social Login Completed',
SOCIAL_LOGIN_FAILED = 'Social Login Failed',
+ SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED = 'Social Login Auth Browser Dismissed',
SOCIAL_LOGIN_IOS_SUCCESS_VIEWED = 'Social Login iOS Success Viewed',
SOCIAL_LOGIN_IOS_SUCCESS_CTA_CLICKED = 'Social Login iOS Success CTA Clicked',
ACCOUNT_ALREADY_EXISTS_PAGE_VIEWED = 'Account Already Exists Page Viewed',
@@ -995,6 +996,9 @@ const events = {
WALLET_SETUP_COMPLETED: generateOpt(EVENT_NAME.WALLET_SETUP_COMPLETED),
SOCIAL_LOGIN_COMPLETED: generateOpt(EVENT_NAME.SOCIAL_LOGIN_COMPLETED),
SOCIAL_LOGIN_FAILED: generateOpt(EVENT_NAME.SOCIAL_LOGIN_FAILED),
+ SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED: generateOpt(
+ EVENT_NAME.SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED,
+ ),
SOCIAL_LOGIN_IOS_SUCCESS_VIEWED: generateOpt(
EVENT_NAME.SOCIAL_LOGIN_IOS_SUCCESS_VIEWED,
),
diff --git a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.test.ts b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.test.ts
index 8d0b6c71aac6..ba352699f4a0 100644
--- a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.test.ts
+++ b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.test.ts
@@ -48,6 +48,58 @@ describe('AndroidGoogleLoginHandler', () => {
expect(mockSignInWithGoogle).toHaveBeenCalledTimes(1);
});
+ it('treats American spelling "canceled" as UserCancelled', async () => {
+ mockSignInWithGoogle.mockRejectedValue(
+ new Error('One Tap was canceled by the user'),
+ );
+
+ await expect(handler.login()).rejects.toMatchObject({
+ code: OAuthErrorType.UserCancelled,
+ });
+ });
+
+ it('treats One Tap failure with cancel wording as UserCancelled', async () => {
+ mockSignInWithGoogle.mockRejectedValue(
+ new Error(
+ 'During begin signin, failure response from one tap: canceled',
+ ),
+ );
+
+ await expect(handler.login()).rejects.toMatchObject({
+ code: OAuthErrorType.UserCancelled,
+ });
+ });
+
+ it('throws GoogleLoginOneTapFailure for One Tap failure without cancel wording', async () => {
+ mockSignInWithGoogle.mockRejectedValue(
+ new Error('During begin signin, failure response from one tap'),
+ );
+
+ await expect(handler.login()).rejects.toMatchObject({
+ code: OAuthErrorType.GoogleLoginOneTapFailure,
+ });
+ });
+
+ it('throws GoogleLoginNoMatchingCredential when One Tap failure includes matching credential', async () => {
+ mockSignInWithGoogle.mockRejectedValue(
+ new Error(
+ 'During begin signin, failure response from one tap. 16: [28433] Cannot find matching credential error',
+ ),
+ );
+
+ await expect(handler.login()).rejects.toMatchObject({
+ code: OAuthErrorType.GoogleLoginNoMatchingCredential,
+ });
+ });
+
+ it('treats resolved cancel result as UserCancelled', async () => {
+ mockSignInWithGoogle.mockResolvedValue({ type: 'cancel' });
+
+ await expect(handler.login()).rejects.toMatchObject({
+ code: OAuthErrorType.UserCancelled,
+ });
+ });
+
it('throws GoogleLoginUserDisabledOneTapFeature when user disabled One Tap', async () => {
mockSignInWithGoogle.mockRejectedValue(
new Error('user disabled the feature'),
diff --git a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts
index 2f26f4f64371..23240bda4d04 100644
--- a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts
+++ b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts
@@ -6,7 +6,11 @@ import {
} from '../../OAuthInterface';
import { signInWithGoogle } from '@metamask/react-native-acm';
import { BaseHandlerOptions, BaseLoginHandler } from '../baseHandler';
-import { OAuthErrorType, OAuthError } from '../../error';
+import {
+ OAuthError,
+ OAuthErrorType,
+ isOAuthUserCancellationMessage,
+} from '../../error';
import Logger from '../../../../util/Logger';
/**
@@ -18,18 +22,17 @@ import Logger from '../../../../util/Logger';
* matches both ONE_TAP_FAILURE and NO_MATCHING_CREDENTIAL.
*
* Current priority order (more specific patterns first):
- * 1. CANCEL / NO_CREDENTIAL - user explicitly cancelled or dismissed the dialog
+ * 1. USER_CANCEL / NO_CREDENTIAL - user explicitly cancelled or dismissed the dialog; isOAuthUserCancellationMessage runs before regex branches, including One Tap cancel text
* 2. USER_DISABLED_FEATURE - user disabled One Tap
* 3. NO_MATCHING_CREDENTIAL - account exists but doesn't match (contains "matching credential")
* 4. ONE_TAP_FAILURE - generic One Tap failure (catch-all for other One Tap issues)
* 5. NO_PROVIDER_DEPENDENCIES - credential provider not available (e.g., missing Google Play Services)
*/
const ACM_ERRORS_REGEX = {
- CANCEL: /user\s+cancel|cancelled|16:\s*\[.*\]\s*cancel/i,
NO_CREDENTIAL: /no credential/i,
- NO_MATCHING_CREDENTIAL: /matching credential/i,
USER_DISABLED_FEATURE: /user disabled the feature/i,
ONE_TAP_FAILURE: /failure response from one tap/i,
+ NO_MATCHING_CREDENTIAL: /matching credential/i,
NO_PROVIDER_DEPENDENCIES:
/no provider dependencies|provider.{0,20}not available|provider.{0,20}configuration/i,
};
@@ -87,6 +90,17 @@ export class AndroidGoogleLoginHandler extends BaseLoginHandler {
};
}
+ if (
+ result?.type === 'cancel' ||
+ result?.type === 'cancelled' ||
+ result?.type === 'dismiss'
+ ) {
+ throw new OAuthError(
+ 'handleGoogleLogin: User cancelled the login process',
+ OAuthErrorType.UserCancelled,
+ );
+ }
+
throw new OAuthError(
'handleGoogleLogin: Unknown error',
OAuthErrorType.UnknownError,
@@ -97,7 +111,7 @@ export class AndroidGoogleLoginHandler extends BaseLoginHandler {
throw error;
} else if (error instanceof Error) {
if (
- ACM_ERRORS_REGEX.CANCEL.test(error.message) ||
+ isOAuthUserCancellationMessage(error.message) ||
ACM_ERRORS_REGEX.NO_CREDENTIAL.test(error.message)
) {
throw new OAuthError(
diff --git a/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/apple.ts b/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/apple.ts
index 81b732585dad..7e7df7d2a31a 100644
--- a/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/apple.ts
+++ b/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/apple.ts
@@ -9,7 +9,11 @@ import {
AppleAuthenticationScope,
} from 'expo-apple-authentication';
import { BaseHandlerOptions, BaseLoginHandler } from '../baseHandler';
-import { OAuthErrorType, OAuthError } from '../../error';
+import {
+ OAuthErrorType,
+ OAuthError,
+ isOAuthUserCancellationMessage,
+} from '../../error';
import Logger from '../../../../util/Logger';
/**
@@ -77,8 +81,10 @@ export class IosAppleLoginHandler extends BaseLoginHandler {
if (error instanceof OAuthError) {
throw error;
} else if (error instanceof Error) {
+ const errorWithCode = error as Error & { code?: string };
if (
- error.message.includes('The user canceled the authorization attempt')
+ errorWithCode.code === 'ERR_REQUEST_CANCELED' ||
+ isOAuthUserCancellationMessage(error.message)
) {
throw new OAuthError(
'handleIosAppleLogin: User canceled the authorization attempt',
diff --git a/app/core/OAuthService/OAuthService.test.ts b/app/core/OAuthService/OAuthService.test.ts
index 0a615933cec0..a50ec56442f7 100644
--- a/app/core/OAuthService/OAuthService.test.ts
+++ b/app/core/OAuthService/OAuthService.test.ts
@@ -434,6 +434,119 @@ describe('OAuth login service', () => {
);
});
+ it('tracks SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED when provider login is dismissed', async () => {
+ const loginHandler = mockCreateLoginHandler();
+ mockLoginHandlerResponse.mockImplementation(() => {
+ throw new OAuthError('Login dismissed', OAuthErrorType.UserDismissed);
+ });
+
+ await expect(
+ OAuthLoginService.handleOAuthLogin(loginHandler, false),
+ ).rejects.toMatchObject({ code: OAuthErrorType.UserDismissed });
+
+ expect(analytics.trackEvent).toHaveBeenCalledTimes(1);
+ expect(analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Social Login Auth Browser Dismissed',
+ properties: expect.objectContaining({
+ auth_connection: AuthConnection.Google,
+ account_type: AccountType.MetamaskGoogle,
+ surface: 'onboarding',
+ elapsed_ms: expect.any(Number),
+ }),
+ }),
+ );
+ expect(analytics.trackEvent).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Social Login Failed',
+ }),
+ );
+ });
+
+ it('tracks SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED for native provider cancel', async () => {
+ const loginHandler = mockCreateLoginHandler();
+ mockLoginHandlerResponse.mockImplementation(() => {
+ throw new OAuthError('Login cancelled', OAuthErrorType.UserCancelled);
+ });
+
+ await expect(
+ OAuthLoginService.handleOAuthLogin(loginHandler, false),
+ ).rejects.toMatchObject({ code: OAuthErrorType.UserCancelled });
+
+ expect(analytics.trackEvent).toHaveBeenCalledTimes(1);
+ expect(analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Social Login Auth Browser Dismissed',
+ properties: expect.objectContaining({
+ auth_connection: AuthConnection.Google,
+ account_type: AccountType.MetamaskGoogle,
+ surface: 'onboarding',
+ elapsed_ms: expect.any(Number),
+ }),
+ }),
+ );
+ expect(analytics.trackEvent).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Social Login Failed',
+ }),
+ );
+ });
+
+ it('tracks rehydration surface on SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED when rehydrating', async () => {
+ const loginHandler = mockCreateLoginHandler();
+ mockLoginHandlerResponse.mockImplementation(() => {
+ throw new OAuthError('Login cancelled', OAuthErrorType.UserCancelled);
+ });
+
+ await expect(
+ OAuthLoginService.handleOAuthLogin(loginHandler, true),
+ ).rejects.toMatchObject({ code: OAuthErrorType.UserCancelled });
+
+ expect(analytics.trackEvent).toHaveBeenCalledTimes(1);
+ expect(analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Social Login Auth Browser Dismissed',
+ properties: expect.objectContaining({
+ auth_connection: AuthConnection.Google,
+ account_type: AccountType.ImportedGoogle,
+ surface: 'rehydration',
+ elapsed_ms: expect.any(Number),
+ }),
+ }),
+ );
+ expect(analytics.trackEvent).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Social Login Failed',
+ }),
+ );
+ });
+
+ it('tracks SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED for provider cancel mapped to AppleLoginError', async () => {
+ const loginHandler = mockCreateLoginHandler();
+ mockLoginHandlerResponse.mockImplementation(() => {
+ throw new OAuthError(
+ 'Apple login error - The user canceled the authorization attempt',
+ OAuthErrorType.AppleLoginError,
+ );
+ });
+
+ await expect(
+ OAuthLoginService.handleOAuthLogin(loginHandler, false),
+ ).rejects.toMatchObject({ code: OAuthErrorType.AppleLoginError });
+
+ expect(analytics.trackEvent).toHaveBeenCalledTimes(1);
+ expect(analytics.trackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Social Login Auth Browser Dismissed',
+ }),
+ );
+ expect(analytics.trackEvent).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Social Login Failed',
+ }),
+ );
+ });
+
// use for loop to test undefine and null cases
for (const value of [undefined, null]) {
it(`throws error when login handler returns ${value}`, async () => {
diff --git a/app/core/OAuthService/OAuthService.ts b/app/core/OAuthService/OAuthService.ts
index ace460a7f17d..cc4fea5abf3c 100644
--- a/app/core/OAuthService/OAuthService.ts
+++ b/app/core/OAuthService/OAuthService.ts
@@ -25,7 +25,11 @@ import {
GoogleWebGID,
} from './OAuthLoginHandlers/constants';
import { QAMockOAuthService } from './QAMockOAuthService';
-import { OAuthError, OAuthErrorType } from './error';
+import {
+ OAuthError,
+ OAuthErrorType,
+ isSocialLoginAuthSessionDismissed,
+} from './error';
import { BaseLoginHandler } from './OAuthLoginHandlers/baseHandler';
import { Platform } from 'react-native';
import { signOut as acmSignOut } from '@metamask/react-native-acm';
@@ -36,6 +40,7 @@ import {
import { analytics } from '../../util/analytics/analytics';
import { AnalyticsEventBuilder } from '../../util/analytics/AnalyticsEventBuilder';
import { MetaMetricsEvents } from '../Analytics/MetaMetrics.events';
+import { trackSocialLoginFailed } from './socialLoginAnalytics';
import ReduxService from '../redux';
import { setSeedlessOnboarding } from '../../actions/onboarding';
import Device from '../../util/device';
@@ -287,8 +292,9 @@ export class OAuthService {
name: TraceName.OnboardingOAuthBYOAServerGetAuthTokensError,
});
- this.#trackSocialLoginFailure({
+ trackSocialLoginFailed({
authConnection,
+ isRehydration: this.localState.userClickedRehydration,
errorCategory: 'get_auth_tokens',
error,
});
@@ -333,8 +339,9 @@ export class OAuthService {
name: TraceName.OnboardingOAuthSeedlessAuthenticateError,
});
- this.#trackSocialLoginFailure({
+ trackSocialLoginFailed({
authConnection,
+ isRehydration: this.localState.userClickedRehydration,
errorCategory: 'seedless_auth',
error,
});
@@ -378,46 +385,26 @@ export class OAuthService {
}
};
- #trackSocialLoginFailure = ({
+ #trackSocialLoginAuthBrowserDismissed = ({
authConnection,
- errorCategory,
- error,
+ elapsedMs,
}: {
authConnection: AuthConnection;
- errorCategory: 'provider_login' | 'get_auth_tokens' | 'seedless_auth';
- error: unknown;
+ elapsedMs: number;
}) => {
- const isUserCancelled =
- error instanceof OAuthError &&
- (error.code === OAuthErrorType.UserCancelled ||
- error.code === OAuthErrorType.UserDismissed);
-
- let userClickedRehydration: 'true' | 'false' | 'unknown' = 'unknown';
- if (this.localState.userClickedRehydration !== undefined) {
- userClickedRehydration = this.localState.userClickedRehydration
- ? 'true'
- : 'false';
- }
-
- const oauthErrorCode =
- error instanceof OAuthError ? String(error.code) : undefined;
+ const isRehydration = this.localState.userClickedRehydration === true;
+ const properties = {
+ auth_connection: authConnection,
+ account_type: getSocialAccountType(authConnection, isRehydration),
+ surface: isRehydration ? 'rehydration' : 'onboarding',
+ elapsed_ms: elapsedMs,
+ };
analytics.trackEvent(
AnalyticsEventBuilder.createEventBuilder(
- MetaMetricsEvents.SOCIAL_LOGIN_FAILED,
+ MetaMetricsEvents.SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED,
)
- .addProperties({
- account_type: getSocialAccountType(
- authConnection,
- this.localState.userClickedRehydration === true,
- ),
- is_rehydration: userClickedRehydration,
- failure_type: isUserCancelled ? 'user_cancelled' : 'error',
- error_category: errorCategory,
- ...(oauthErrorCode !== undefined && {
- oauth_error_code: oauthErrorCode,
- }),
- })
+ .addProperties(properties)
.build(),
);
};
@@ -426,6 +413,7 @@ export class OAuthService {
loginHandler: BaseLoginHandler,
): Promise => {
let providerLoginSuccess = false;
+ const providerLoginStartedAt = Date.now();
try {
trace({
name: TraceName.OnboardingOAuthProviderLogin,
@@ -459,11 +447,19 @@ export class OAuthService {
endTrace({ name: TraceName.OnboardingOAuthProviderLoginError });
}
- this.#trackSocialLoginFailure({
- authConnection: loginHandler.authConnection,
- errorCategory: 'provider_login',
- error,
- });
+ if (isSocialLoginAuthSessionDismissed(error)) {
+ this.#trackSocialLoginAuthBrowserDismissed({
+ authConnection: loginHandler.authConnection,
+ elapsedMs: Date.now() - providerLoginStartedAt,
+ });
+ } else {
+ trackSocialLoginFailed({
+ authConnection: loginHandler.authConnection,
+ isRehydration: this.localState.userClickedRehydration,
+ errorCategory: 'provider_login',
+ error,
+ });
+ }
throw error;
} finally {
diff --git a/app/core/OAuthService/error.test.ts b/app/core/OAuthService/error.test.ts
new file mode 100644
index 000000000000..c827ab336b69
--- /dev/null
+++ b/app/core/OAuthService/error.test.ts
@@ -0,0 +1,66 @@
+import {
+ OAuthError,
+ OAuthErrorType,
+ isOAuthUserCancellationMessage,
+ isSocialLoginAuthSessionDismissed,
+} from './error';
+
+describe('OAuth error helpers', () => {
+ describe('isOAuthUserCancellationMessage', () => {
+ it('detects Apple authorization cancel messages', () => {
+ expect(
+ isOAuthUserCancellationMessage(
+ 'The user canceled the authorization attempt',
+ ),
+ ).toBe(true);
+ });
+
+ it('detects American spelling canceled', () => {
+ expect(isOAuthUserCancellationMessage('One Tap was canceled')).toBe(true);
+ });
+
+ it('detects dismiss wording', () => {
+ expect(
+ isOAuthUserCancellationMessage('User dismissed the login process'),
+ ).toBe(true);
+ });
+
+ it('returns false for unrelated errors', () => {
+ expect(isOAuthUserCancellationMessage('Network request failed')).toBe(
+ false,
+ );
+ });
+ });
+
+ describe('isSocialLoginAuthSessionDismissed', () => {
+ it('returns true for UserCancelled and UserDismissed', () => {
+ expect(
+ isSocialLoginAuthSessionDismissed(
+ new OAuthError('cancel', OAuthErrorType.UserCancelled),
+ ),
+ ).toBe(true);
+ expect(
+ isSocialLoginAuthSessionDismissed(
+ new OAuthError('dismiss', OAuthErrorType.UserDismissed),
+ ),
+ ).toBe(true);
+ });
+
+ it('returns true for provider errors with cancel wording', () => {
+ expect(
+ isSocialLoginAuthSessionDismissed(
+ new OAuthError(
+ 'Apple login error - The user canceled the authorization attempt',
+ OAuthErrorType.AppleLoginError,
+ ),
+ ),
+ ).toBe(true);
+ });
+
+ it('returns false for non-OAuth errors', () => {
+ expect(isSocialLoginAuthSessionDismissed(new Error('cancel'))).toBe(
+ false,
+ );
+ });
+ });
+});
diff --git a/app/core/OAuthService/error.ts b/app/core/OAuthService/error.ts
index 597f0bac32f8..836686104a94 100644
--- a/app/core/OAuthService/error.ts
+++ b/app/core/OAuthService/error.ts
@@ -43,6 +43,24 @@ export const OAuthErrorMessages: Record = {
[OAuthErrorType.TelegramLoginError]: 'Telegram login error',
} as const;
+/**
+ * Returns true when an OAuth provider error message indicates the user aborted
+ * sign-in (cancel, dismiss, close) rather than a server or configuration failure.
+ */
+export function isOAuthUserCancellationMessage(message: string): boolean {
+ const normalized = message.toLowerCase();
+ return (
+ normalized.includes('the user canceled the authorization attempt') ||
+ normalized.includes('authorization error error 1001') ||
+ normalized.includes('err_request_canceled') ||
+ /user\s+cancel/.test(normalized) ||
+ /\bcanceled\b/.test(normalized) ||
+ /\bcancelled\b/.test(normalized) ||
+ /16:\s*\[.*\]\s*cancel/.test(normalized) ||
+ /\bdismiss/.test(normalized)
+ );
+}
+
export class OAuthError extends Error {
public readonly code: OAuthErrorType;
public readonly data: Record;
@@ -64,3 +82,31 @@ export class OAuthError extends Error {
this.data = data || {};
}
}
+
+/**
+ * Returns true when a social login attempt ended because the user closed or
+ * cancelled the provider UI before completing authentication.
+ */
+export function isSocialLoginAuthSessionDismissed(error: unknown): boolean {
+ if (!(error instanceof OAuthError)) {
+ return false;
+ }
+
+ if (
+ error.code === OAuthErrorType.UserCancelled ||
+ error.code === OAuthErrorType.UserDismissed
+ ) {
+ return true;
+ }
+
+ if (
+ error.code === OAuthErrorType.GoogleLoginError ||
+ error.code === OAuthErrorType.AppleLoginError ||
+ error.code === OAuthErrorType.UnknownError ||
+ error.code === OAuthErrorType.GoogleLoginOneTapFailure
+ ) {
+ return isOAuthUserCancellationMessage(error.message);
+ }
+
+ return false;
+}
diff --git a/app/core/OAuthService/socialLoginAnalytics.test.ts b/app/core/OAuthService/socialLoginAnalytics.test.ts
new file mode 100644
index 000000000000..c3ee0349154e
--- /dev/null
+++ b/app/core/OAuthService/socialLoginAnalytics.test.ts
@@ -0,0 +1,113 @@
+import { AccountType } from '../../constants/onboarding';
+import { MetaMetricsEvents } from '../Analytics/MetaMetrics.events';
+import { AuthConnection } from './OAuthInterface';
+import { OAuthError, OAuthErrorType } from './error';
+import {
+ isPreOAuthSocialLoginFailure,
+ trackSocialLoginFailed,
+} from './socialLoginAnalytics';
+
+const mockTrackEvent = jest.fn();
+
+jest.mock('../../util/analytics/analytics', () => ({
+ analytics: {
+ trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
+ },
+}));
+
+describe('socialLoginAnalytics', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('isPreOAuthSocialLoginFailure', () => {
+ it('returns true for invalid provider errors', () => {
+ expect(
+ isPreOAuthSocialLoginFailure(
+ new OAuthError('Invalid provider', OAuthErrorType.InvalidProvider),
+ ),
+ ).toBe(true);
+ });
+
+ it('returns true for unsupported platform errors', () => {
+ expect(
+ isPreOAuthSocialLoginFailure(
+ new OAuthError(
+ 'Unsupported platform',
+ OAuthErrorType.UnsupportedPlatform,
+ ),
+ ),
+ ).toBe(true);
+ });
+
+ it('returns false for provider login errors handled inside OAuthService', () => {
+ expect(
+ isPreOAuthSocialLoginFailure(
+ new OAuthError('Login error', OAuthErrorType.LoginError),
+ ),
+ ).toBe(false);
+ });
+ });
+
+ describe('trackSocialLoginFailed', () => {
+ it('tracks Social Login Failed for onboarding create-wallet flow', () => {
+ trackSocialLoginFailed({
+ authConnection: AuthConnection.Google,
+ isRehydration: false,
+ errorCategory: 'provider_login',
+ error: new OAuthError(
+ 'Invalid provider',
+ OAuthErrorType.InvalidProvider,
+ ),
+ });
+
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: MetaMetricsEvents.SOCIAL_LOGIN_FAILED.category,
+ properties: expect.objectContaining({
+ account_type: AccountType.MetamaskGoogle,
+ is_rehydration: 'false',
+ failure_type: 'error',
+ error_category: 'provider_login',
+ }),
+ }),
+ );
+ });
+
+ it('tracks user_cancelled failure_type for dismiss errors', () => {
+ trackSocialLoginFailed({
+ authConnection: AuthConnection.Apple,
+ isRehydration: true,
+ errorCategory: 'provider_login',
+ error: new OAuthError('User dismissed', OAuthErrorType.UserDismissed),
+ });
+
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ properties: expect.objectContaining({
+ account_type: AccountType.ImportedApple,
+ is_rehydration: 'true',
+ failure_type: 'user_cancelled',
+ }),
+ }),
+ );
+ });
+
+ it('includes oauth_error_code when error is an OAuthError', () => {
+ trackSocialLoginFailed({
+ authConnection: AuthConnection.Google,
+ isRehydration: false,
+ errorCategory: 'provider_login',
+ error: new OAuthError('Login error', OAuthErrorType.LoginError),
+ });
+
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ properties: expect.objectContaining({
+ oauth_error_code: String(OAuthErrorType.LoginError),
+ }),
+ }),
+ );
+ });
+ });
+});
diff --git a/app/core/OAuthService/socialLoginAnalytics.ts b/app/core/OAuthService/socialLoginAnalytics.ts
new file mode 100644
index 000000000000..c213497b77cc
--- /dev/null
+++ b/app/core/OAuthService/socialLoginAnalytics.ts
@@ -0,0 +1,68 @@
+import { getSocialAccountType } from '../../constants/onboarding';
+import { analytics } from '../../util/analytics/analytics';
+import { AnalyticsEventBuilder } from '../../util/analytics/AnalyticsEventBuilder';
+import { MetaMetricsEvents } from '../Analytics/MetaMetrics.events';
+import { OAuthError, OAuthErrorType } from './error';
+
+export type SocialLoginFailureErrorCategory =
+ | 'provider_login'
+ | 'get_auth_tokens'
+ | 'seedless_auth';
+
+/**
+ * OAuth failures that occur in Onboarding before {@link OAuthService.handleOAuthLogin}
+ * is invoked (e.g. invalid/disabled provider from {@link createLoginHandler}).
+ */
+export function isPreOAuthSocialLoginFailure(error: OAuthError): boolean {
+ return (
+ error.code === OAuthErrorType.InvalidProvider ||
+ error.code === OAuthErrorType.UnsupportedPlatform
+ );
+}
+
+/**
+ * Tracks the Social Login Failed analytics event.
+ */
+export function trackSocialLoginFailed({
+ authConnection,
+ isRehydration,
+ errorCategory,
+ error,
+}: {
+ authConnection: string;
+ isRehydration?: boolean;
+ errorCategory: SocialLoginFailureErrorCategory;
+ error: unknown;
+}): void {
+ const isUserCancelled =
+ error instanceof OAuthError &&
+ (error.code === OAuthErrorType.UserCancelled ||
+ error.code === OAuthErrorType.UserDismissed);
+
+ let isRehydrationValue: 'true' | 'false' | 'unknown' = 'unknown';
+ if (isRehydration !== undefined) {
+ isRehydrationValue = isRehydration ? 'true' : 'false';
+ }
+
+ const oauthErrorCode =
+ error instanceof OAuthError ? String(error.code) : undefined;
+
+ analytics.trackEvent(
+ AnalyticsEventBuilder.createEventBuilder(
+ MetaMetricsEvents.SOCIAL_LOGIN_FAILED,
+ )
+ .addProperties({
+ account_type: getSocialAccountType(
+ authConnection,
+ isRehydration === true,
+ ),
+ is_rehydration: isRehydrationValue,
+ failure_type: isUserCancelled ? 'user_cancelled' : 'error',
+ error_category: errorCategory,
+ ...(oauthErrorCode !== undefined && {
+ oauth_error_code: oauthErrorCode,
+ }),
+ })
+ .build(),
+ );
+}
From c06187a24c9ee5f30f92365720f6682bb946245b Mon Sep 17 00:00:00 2001
From: abretonc7s <107169956+abretonc7s@users.noreply.github.com>
Date: Fri, 22 May 2026 18:37:22 +0800
Subject: [PATCH 4/4] feat(perps): fingerprint-gated build cache for agentic
preflight (#30565)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Adds a fingerprint-gated build cache to
`scripts/perps/agentic/preflight.sh` so farmslot dispatches stop paying
for redundant `yarn setup` + `pod install --repo-update` + `xcodebuild`
cycles when the native dep graph hasn't changed.
**Why:** farmslot today hardcodes `--clean --wallet-setup`, forcing
~15–20 min per dispatch even when only TypeScript changed. Investigation
showed `@expo/fingerprint` (already a dep) +
`scripts/generate-fingerprint.js` (already wired) can deterministically
detect "no native change" — but the preflight path didn't use them.
**Approach:**
- New `--mode ` flag on `preflight.sh`.
Legacy `--clean`/`--rebuild` continue to work unchanged.
- Two-tier cache: shared `$MM_BUILD_CACHE_DIR` (default
`~/Library/Caches/mm-mobile-builds` on macOS,
`~/.cache/mm-mobile-builds` on Linux) keyed by fingerprint, plus a
per-worktree `.agent/build-cache//installed.json` sidecar.
- Per-fingerprint `flock` wraps the full **decide + install + build +
store** region, so two peer worktrees at the same fingerprint produce
exactly one xcodebuild/gradle invocation. The second worker installs the
artifact the first published.
- `installed.json` records both fingerprint AND target (sim UDID / adb
serial); the fast-path skip requires both to match, so a recorded build
on one sim won't false-hit on another.
- `pod install --repo-update` is now opt-in (only `--mode clean`); plain
`pod install` runs first with a one-shot `--repo-update` retry on
failure.
- `--mode fast` is strict: missing cache OR failed cache install
hard-fails instead of silently rebuilding.
- `--check-only` stays read-only: a cache-decision that would mutate the
sim/device exits with an explanatory failure instead.
All new helpers live in `scripts/perps/agentic/lib/build-cache.sh`.
Idempotent test suites (unit + real-sim e2e) live next to it.
**Boundary:** change is fully contained under `scripts/perps/agentic/` —
no root `package.json` shortcuts, no perps-out-of-scope files. Callers
invoke `bash scripts/perps/agentic/preflight.sh --mode <…>` directly.
| Scenario | Today | After |
|---|---|---|
| Same worktree, app already installed at this fp on this sim | ~15–20
min | ~30–45 s |
| Peer worktree, shared cache hit | ~15–20 min | ~60–90 s |
| Native diff (Podfile/native module) | ~15–20 min | ~5–8 min (first
worker only; rest cache-install) |
| Cold host / `--mode clean` | ~15–20 min | unchanged (escape hatch) |
Farmslot side (`projects/metamask-mobile-farm/project.json` → `--mode
auto`) is intentionally deferred to a follow-up: the `--mode` flag must
land on `main` first since farmslot clones MM fresh per dispatch.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: N/A (developer tooling improvement; supports the farmslot
dispatch loop)
## **Manual testing steps**
Two idempotent test scripts ship with the change. Both safely stash and
restore any pre-existing `.agent/build-cache`.
```gherkin
Feature: build-cache lib + preflight --mode plumbing
Scenario: unit suite passes
Given a clean checkout of this branch
When I run "bash scripts/perps/agentic/lib/test-build-cache.sh"
Then 26 PASS lines print and exit code is 0
And "ALL TESTS PASSED" is printed
And re-running the command immediately also exits 0 (idempotent)
Scenario: real-simulator cache-hit recognition (Path 1)
Given a booted iOS simulator with MetaMask installed
When I run "bash scripts/perps/agentic/lib/test-preflight-cache-e2e.sh"
Then preflight logs "Cache: installed app matches fingerprint "
And the build branch is skipped (no pod install / xcodebuild)
And MetaMask remains installed on the simulator (no destructive ops)
Scenario: --mode fast is strict
Given a worktree with no cached build for the current fingerprint
And no MetaMask installed on the simulator at the right fingerprint
When I run "bash scripts/perps/agentic/preflight.sh --platform ios --mode fast"
Then preflight exits non-zero with "Mode 'fast' but no cached build for fp "
Scenario: --check-only stays read-only
Given a shared cache hit for the current fingerprint
And no MetaMask installed at that fingerprint
When I run "bash scripts/perps/agentic/preflight.sh --platform ios --mode auto --check-only"
Then preflight exits non-zero with a clear "cache hit available, but --check-only forbids install" message
And the simulator state is unchanged
Scenario: legacy --clean path is unchanged
Given an existing worktree
When I run "bash scripts/perps/agentic/preflight.sh --platform ios --clean --wallet-setup"
Then preflight prints "Mode: clean (yarn setup → pod --repo-update → build)"
And executes the same path as before this PR
```
Direct invocation examples (no yarn shortcuts; perps-scoped):
```bash
bash scripts/perps/agentic/preflight.sh --platform ios --mode auto --wallet-setup # fingerprint-gated reuse
bash scripts/perps/agentic/preflight.sh --platform ios --mode fast --wallet-setup # fail loud if no cached/installed build
bash scripts/perps/agentic/preflight.sh --platform ios --clean --wallet-setup # legacy clean rebuild (unchanged)
```
## **Screenshots/Recordings**
N/A — script-only change, no UI surface.
### **Before**
N/A
### **After**
N/A
## **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 (26 unit + real-sim e2e suite in
`scripts/perps/agentic/lib/`)
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable (shell — header comments + README section)
- [x] I've applied the right labels on the PR
#### Performance checks (if applicable)
- [ ] I've tested on Android — N/A (developer preflight; same code path
executes on both platforms; covered by mode flag + cache lib unit tests;
Android-real-sim e2e left to a separate Linux farmslot host)
- [ ] I've tested with a power user scenario — N/A (no runtime / wallet
path touched)
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics — N/A (developer tooling, never ships to production)
## **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 `preflight.sh` control flow for
iOS/Android build/install decisions and adds cross-worktree caching +
locking; mistakes could cause stale builds to be reused or unexpected
install/build skips.
>
> **Overview**
> Adds a fingerprint-gated shared native build cache to
`scripts/perps/agentic/preflight.sh`, allowing iOS `.app` and Android
`.apk` artifacts to be reused across worktrees when the
`@expo/fingerprint` hash matches, with per-fingerprint locking to
serialize build/store.
>
> Introduces `--mode ` to control cache
usage and rebuild strictness, updates CocoaPods behavior to avoid
`--repo-update` except in `clean` (with a one-shot retry on failure),
and tightens *read-only* semantics for `--check-only` (no installs/adb
reverse/Metro wallet steps; fails if cache would mutate state).
>
> Adds the new `lib/build-cache.sh` helper library (artifact paths,
memoized fingerprinting, installed sidecar tracking, pruning,
flock/mkdir locks), plus smoke/e2e shell tests and README docs
describing the modes and cache semantics.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fc1b85d970614dbc194890a735bd54dae7eb3148. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
scripts/perps/agentic/README.md | 23 ++
scripts/perps/agentic/lib/build-cache.sh | 290 ++++++++++++++++
scripts/perps/agentic/lib/test-build-cache.sh | 244 ++++++++++++++
.../agentic/lib/test-preflight-cache-e2e.sh | 125 +++++++
scripts/perps/agentic/preflight.sh | 317 +++++++++++++++++-
5 files changed, 982 insertions(+), 17 deletions(-)
create mode 100644 scripts/perps/agentic/lib/build-cache.sh
create mode 100644 scripts/perps/agentic/lib/test-build-cache.sh
create mode 100644 scripts/perps/agentic/lib/test-preflight-cache-e2e.sh
diff --git a/scripts/perps/agentic/README.md b/scripts/perps/agentic/README.md
index 6713b0054b77..494ecbb47422 100644
--- a/scripts/perps/agentic/README.md
+++ b/scripts/perps/agentic/README.md
@@ -213,6 +213,29 @@ Used in `assert` blocks on steps and pre-conditions:
Compound: `{ all: [...] }`, `{ any: [...] }`, `{ none: [...] }`.
+## Preflight modes
+
+`preflight.sh` accepts `--mode ` to control how much of the native setup runs. Cuts cold-rebuild waste when the native dep graph hasn't changed.
+
+| Mode | yarn setup | pod install | xcodebuild | Reads shared cache |
+|---|---|---|---|---|
+| `auto` (recommended) | no | only on native rebuild, no `--repo-update` (one-shot `--repo-update` retry on failure) | only on fingerprint miss | yes |
+| `fast` | no | no | no — fail loud if missing | yes |
+| `rebuild-native` | no | yes (no `--repo-update`) | yes | no |
+| `clean` (legacy `--clean`) | yes | yes with `--repo-update` | yes | no (writes only) |
+
+Cache lives in `$MM_BUILD_CACHE_DIR` (default `~/Library/Caches/mm-mobile-builds` on macOS, `~/.cache/mm-mobile-builds` on Linux), keyed by `@expo/fingerprint` hash. Parallel worktrees at the same fingerprint share one artifact through a per-fingerprint mutex: Linux uses `flock(1)` (auto-released by the kernel on process death); macOS, where `flock` is not in base, uses an atomic `mkdir .lock.d` fallback that is released by the script's `EXIT` trap. If a script is killed with `kill -9` between `mkdir` and the trap, the mutex dir can be left behind — delete it manually under `$MM_BUILD_CACHE_DIR//`. Override retention with `BUILD_CACHE_RETAIN=N` (default 5 per platform).
+
+Invoke directly:
+
+```bash
+bash scripts/perps/agentic/preflight.sh --platform ios --mode auto --wallet-setup # fingerprint-gated reuse, build only on miss
+bash scripts/perps/agentic/preflight.sh --platform ios --mode fast --wallet-setup # fail loud if no cached/installed build
+bash scripts/perps/agentic/preflight.sh --platform ios --clean --wallet-setup # legacy clean rebuild (unchanged)
+```
+
+Farmslot dispatch: once this branch lands on `main`, switch `projects/metamask-mobile-farm/project.json` `preflight` hook from `--clean` to `--mode auto`. Keep `--mode clean` as the explicit burn-it-down escape.
+
## CLI
```bash
diff --git a/scripts/perps/agentic/lib/build-cache.sh b/scripts/perps/agentic/lib/build-cache.sh
new file mode 100644
index 000000000000..839288390b50
--- /dev/null
+++ b/scripts/perps/agentic/lib/build-cache.sh
@@ -0,0 +1,290 @@
+#!/bin/bash
+# build-cache.sh — shared helpers for fingerprint-gated native build reuse.
+#
+# Two-tier cache:
+# Tier 1 (shared, one per host): $MM_BUILD_CACHE_DIR (default ~/Library/Caches/mm-mobile-builds)
+# Tier 2 (per-worktree sidecar): .agent/build-cache//installed.json
+#
+# All functions are pure shell so preflight.sh can source this file directly.
+# Callers must `set -euo pipefail` themselves; this file does not.
+
+# Source-time sanitization: drop any inherited claim on the private memo
+# directory. Bash imports exported env vars as shell vars on startup, so a
+# parent process running this lib could otherwise convince us we own a
+# caller-supplied BC_MEMO_DIR and recurse rm -rf into it from cleanup.
+# Only ownership set by bc_memo_init running in this shell, AFTER the unset
+# below, is ever trusted.
+unset BC_MEMO_DIR_OWNED BC_MEMO_DIR
+
+# Resolve shared cache root. Honors override env, defaults per-OS.
+bc_root() {
+ if [ -n "${MM_BUILD_CACHE_DIR:-}" ]; then
+ printf '%s\n' "$MM_BUILD_CACHE_DIR"
+ return
+ fi
+ if [ "$(uname)" = "Darwin" ]; then
+ printf '%s\n' "$HOME/Library/Caches/mm-mobile-builds"
+ else
+ printf '%s\n' "${XDG_CACHE_HOME:-$HOME/.cache}/mm-mobile-builds"
+ fi
+}
+
+bc_plat_dir() {
+ local plat="$1"
+ printf '%s/%s\n' "$(bc_root)" "$plat"
+}
+
+bc_artifact_path() {
+ local plat="$1" fp="$2"
+ local ext
+ [ "$plat" = "ios" ] && ext="app" || ext="apk"
+ printf '%s/%s.%s\n' "$(bc_plat_dir "$plat")" "$fp" "$ext"
+}
+
+bc_meta_path() {
+ local plat="$1" fp="$2"
+ printf '%s/%s.meta.json\n' "$(bc_plat_dir "$plat")" "$fp"
+}
+
+bc_lock_path() {
+ local plat="$1" fp="$2"
+ printf '%s/%s.lock\n' "$(bc_plat_dir "$plat")" "$fp"
+}
+
+bc_installed_json() {
+ local plat="$1"
+ printf '.agent/build-cache/%s/installed.json\n' "$plat"
+}
+
+# Ensure shared + per-worktree dirs exist.
+bc_init_dirs() {
+ local plat="$1"
+ mkdir -p "$(bc_plat_dir "$plat")"
+ mkdir -p ".agent/build-cache/$plat"
+}
+
+# Compute the current native fingerprint. Memoized in $BC_MEMO_DIR/fp so
+# command-substitution callers (`FP=$(bc_fingerprint)`) survive subshell exit.
+# Falls back to per-call compute if BC_MEMO_DIR isn't initialized.
+bc_fingerprint() {
+ local memo=""
+ if [ -n "${BC_MEMO_DIR:-}" ] && [ -d "$BC_MEMO_DIR" ] && [ -w "$BC_MEMO_DIR" ]; then
+ memo="$BC_MEMO_DIR/fp"
+ # Trust the file only if it is a regular file (not a symlink/dir) inside
+ # our private dir; mktemp -d guarantees 0700 + exclusive ownership.
+ if [ -f "$memo" ] && [ ! -L "$memo" ] && [ -s "$memo" ]; then
+ cat "$memo"
+ return 0
+ fi
+ fi
+ local fp
+ fp=$(node scripts/generate-fingerprint.js 2>/dev/null || true)
+ if [ -z "$fp" ]; then
+ return 1
+ fi
+ if [ -n "$memo" ]; then
+ printf '%s' "$fp" > "$memo"
+ fi
+ printf '%s\n' "$fp"
+}
+
+# Create the private memo dir (0700, mktemp -d) and record ownership in a
+# NON-EXPORTED shell variable that child processes cannot inherit.
+# A forgeable on-disk sentinel would not be enough — anyone with write access
+# to a victim dir could pre-create the marker and trick us into rm -rf'ing
+# it. Storing ownership in this shell only means an attacker who controls
+# BC_MEMO_DIR via env cannot also make us think we own the dir.
+bc_memo_init() {
+ if [ "${BC_MEMO_DIR_OWNED:-}" = "1" ] && [ -n "${BC_MEMO_DIR:-}" ] && [ -d "$BC_MEMO_DIR" ]; then
+ return 0 # already created by us in this shell
+ fi
+ local dir
+ dir=$(mktemp -d 2>/dev/null) || return 1
+ chmod 700 "$dir" 2>/dev/null || true
+ export BC_MEMO_DIR="$dir"
+ # Deliberately not exported — child processes that inherit BC_MEMO_DIR
+ # from us will not also inherit the ownership flag.
+ BC_MEMO_DIR_OWNED=1
+}
+
+# Tear down the private memo dir — only if we own it in this shell.
+# Never deletes an inherited / caller-supplied path.
+bc_memo_cleanup() {
+ if [ "${BC_MEMO_DIR_OWNED:-}" = "1" ] \
+ && [ -n "${BC_MEMO_DIR:-}" ] \
+ && [ -d "$BC_MEMO_DIR" ]; then
+ rm -rf "$BC_MEMO_DIR"
+ fi
+ unset BC_MEMO_DIR
+ unset BC_MEMO_DIR_OWNED
+}
+
+# Drop any inherited memo claim (bash imports env vars on startup, so a
+# parent could otherwise convince us we own BC_MEMO_DIR) and create a fresh
+# private dir. Called once at preflight startup.
+bc_fingerprint_reset_memo() {
+ unset BC_MEMO_DIR_OWNED BC_MEMO_DIR
+ bc_memo_init
+}
+
+# True if shared artifact for (plat, fp) exists AND is non-trivially populated.
+# Rejects empty .app dirs (no Info.plist) and zero-byte .apk files to avoid
+# treating a half-written or aborted store as a cache hit.
+bc_has_artifact() {
+ local plat="$1" fp="$2"
+ local p
+ p=$(bc_artifact_path "$plat" "$fp")
+ if [ "$plat" = "ios" ]; then
+ [ -d "$p" ] && [ -f "$p/Info.plist" ]
+ else
+ [ -f "$p" ] && [ -s "$p" ]
+ fi
+}
+
+# Read the per-worktree installed fingerprint (empty if unset).
+bc_installed_fp() {
+ local plat="$1"
+ local f
+ f=$(bc_installed_json "$plat")
+ [ -f "$f" ] || { printf ''; return; }
+ jq -r '.fingerprint // empty' "$f" 2>/dev/null || true
+}
+
+# Read the per-worktree installed target (sim UDID / adb serial). Empty if unset.
+bc_installed_target() {
+ local plat="$1"
+ local f
+ f=$(bc_installed_json "$plat")
+ [ -f "$f" ] || { printf ''; return; }
+ jq -r '.target // empty' "$f" 2>/dev/null || true
+}
+
+# Write per-worktree installed.json after a successful install.
+# Uses jq for JSON escaping so unusual paths/targets don't produce invalid JSON.
+bc_record_install() {
+ local plat="$1" fp="$2" target="$3"
+ local f
+ f=$(bc_installed_json "$plat")
+ mkdir -p "$(dirname "$f")"
+ jq -n --arg fp "$fp" --arg target "$target" --arg installedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ '{fingerprint:$fp, target:$target, installedAt:$installedAt}' > "$f"
+}
+
+# Store a freshly-built artifact into the shared cache (atomic mv).
+# JSON meta is written via jq to escape arbitrary paths.
+bc_store_artifact() {
+ local plat="$1" fp="$2" src="$3"
+ local dst tmp meta
+ dst=$(bc_artifact_path "$plat" "$fp")
+ tmp="${dst}.tmp.$$"
+ meta=$(bc_meta_path "$plat" "$fp")
+ bc_init_dirs "$plat"
+ rm -rf "$tmp" "$dst"
+ cp -R "$src" "$tmp"
+ mv "$tmp" "$dst"
+ jq -n --arg fp "$fp" --arg builtAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg builderWorktree "$(pwd)" \
+ '{fingerprint:$fp, builtAt:$builtAt, builderWorktree:$builderWorktree}' > "$meta"
+}
+
+# Portable mtime extraction. macOS BSD stat uses -f; GNU stat uses -c.
+# Echoes " " per line. Silent on stat errors.
+bc__stat_mtime() {
+ local path="$1"
+ if stat -f '%m %N' "$path" 2>/dev/null; then
+ return 0
+ fi
+ stat -c '%Y %n' "$path" 2>/dev/null || true
+}
+
+# LRU prune of shared cache. Keeps newest N per platform (default 5).
+bc_prune() {
+ local plat="$1" keep="${2:-5}"
+ local d
+ d=$(bc_plat_dir "$plat")
+ [ -d "$d" ] || return 0
+ local ext
+ [ "$plat" = "ios" ] && ext="app" || ext="apk"
+ local entries
+ entries=$(
+ find "$d" -maxdepth 1 -name "*.$ext" 2>/dev/null \
+ | while IFS= read -r p; do bc__stat_mtime "$p"; done \
+ | sort -rn \
+ | awk '{ $1=""; sub(/^ /,""); print }'
+ )
+ local i=0
+ while IFS= read -r path; do
+ [ -z "$path" ] && continue
+ i=$((i + 1))
+ [ "$i" -le "$keep" ] && continue
+ local base="${path%.*}"
+ rm -rf "$path" "${base}.meta.json"
+ done <<< "$entries"
+}
+
+# Persistent-fd lock helpers. Use these when the locked region is too large to
+# wrap in a single function call. Acquire returns 0 on success, 1 on timeout.
+# Release tears down whichever lock mechanism was used.
+#
+# Usage:
+# bc_lock_acquire ios "$FP" || fail "build-cache: lock timeout"
+# trap 'bc_lock_release' EXIT
+# ... locked section ...
+# bc_lock_release
+# trap - EXIT
+bc_lock_acquire() {
+ local plat="$1" fp="$2"
+ local lock
+ lock=$(bc_lock_path "$plat" "$fp")
+ bc_init_dirs "$plat"
+ local timeout="${BUILD_CACHE_LOCK_TIMEOUT:-1800}"
+
+ if command -v flock >/dev/null 2>&1; then
+ exec 9>"$lock"
+ if ! flock -w "$timeout" 9; then
+ exec 9>&-
+ echo "build-cache: timed out waiting for $lock" >&2
+ return 1
+ fi
+ BUILD_CACHE_LOCK_KIND="flock"
+ BUILD_CACHE_LOCK_PATH="$lock"
+ return 0
+ fi
+
+ # macOS fallback: mkdir-based mutex with poll.
+ local lockdir="${lock}.d"
+ local waited=0
+ while ! mkdir "$lockdir" 2>/dev/null; do
+ if [ "$waited" -ge "$timeout" ]; then
+ echo "build-cache: timed out waiting for $lockdir" >&2
+ return 1
+ fi
+ sleep 1
+ waited=$((waited + 1))
+ done
+ BUILD_CACHE_LOCK_KIND="mkdir"
+ BUILD_CACHE_LOCK_PATH="$lockdir"
+ return 0
+}
+
+bc_lock_release() {
+ case "${BUILD_CACHE_LOCK_KIND:-}" in
+ flock)
+ exec 9>&- 2>/dev/null || true
+ ;;
+ mkdir)
+ [ -n "${BUILD_CACHE_LOCK_PATH:-}" ] && rmdir "$BUILD_CACHE_LOCK_PATH" 2>/dev/null || true
+ ;;
+ esac
+ unset BUILD_CACHE_LOCK_KIND BUILD_CACHE_LOCK_PATH
+}
+
+# Function-scoped lock wrapper (kept for callers that have a tight body).
+# For the larger preflight build region, prefer bc_lock_acquire / bc_lock_release.
+bc_with_lock() {
+ local plat="$1" fp="$2"; shift 2
+ bc_lock_acquire "$plat" "$fp" || return 1
+ local rc=0
+ "$@" || rc=$?
+ bc_lock_release
+ return $rc
+}
diff --git a/scripts/perps/agentic/lib/test-build-cache.sh b/scripts/perps/agentic/lib/test-build-cache.sh
new file mode 100644
index 000000000000..338c31f80807
--- /dev/null
+++ b/scripts/perps/agentic/lib/test-build-cache.sh
@@ -0,0 +1,244 @@
+#!/bin/bash
+# Smoke test for scripts/perps/agentic/lib/build-cache.sh + preflight --mode plumbing.
+# Idempotent: uses a throwaway shared-cache dir and restores any pre-existing
+# .agent/build-cache after running. Safe to invoke repeatedly.
+#
+# Usage:
+# bash scripts/perps/agentic/lib/test-build-cache.sh
+set -euo pipefail
+
+# Run from repo root regardless of caller cwd.
+REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
+cd "$REPO_ROOT"
+
+# Use a throwaway shared cache so we never touch the user's real ~/.../mm-mobile-builds.
+export MM_BUILD_CACHE_DIR="/tmp/mm-bc-test-$$"
+rm -rf "$MM_BUILD_CACHE_DIR"
+
+# Stash any real sidecar so the test can scribble on .agent/build-cache.
+SIDE_BACKUP=""
+if [ -d .agent/build-cache ]; then
+ SIDE_BACKUP="/tmp/mm-bc-sidecar-backup-$$"
+ mv .agent/build-cache "$SIDE_BACKUP"
+fi
+cleanup() {
+ rm -rf "$MM_BUILD_CACHE_DIR" .agent/build-cache 2>/dev/null || true
+ # Delegate memo-dir cleanup to the lib helper: it refuses to delete an
+ # inherited / unowned BC_MEMO_DIR. Plain `rm -rf "$BC_MEMO_DIR"` would
+ # nuke a caller-supplied path on early test failure.
+ if type bc_memo_cleanup >/dev/null 2>&1; then
+ bc_memo_cleanup
+ fi
+ if [ -n "$SIDE_BACKUP" ] && [ -d "$SIDE_BACKUP" ]; then
+ mv "$SIDE_BACKUP" .agent/build-cache
+ fi
+}
+trap cleanup EXIT
+
+# shellcheck disable=SC1091
+. scripts/perps/agentic/lib/build-cache.sh
+
+FAILED=0
+pass() { printf " \033[32mPASS\033[0m %s\n" "$1"; }
+fail() { printf " \033[31mFAIL\033[0m %s\n" "$1"; FAILED=1; }
+hdr() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; }
+
+# Portable bounded capture: runs the command and captures combined stdout+stderr,
+# killing it if it exceeds $1 seconds. Avoids the GNU `timeout` binary which is
+# not in base macOS. Echoes whatever the command produced before the watchdog
+# fired.
+_capture_for() {
+ local secs="$1"; shift
+ local out_file
+ out_file=$(mktemp -t mm-bc-capture)
+ "$@" >"$out_file" 2>&1 &
+ local pid=$!
+ ( sleep "$secs" && kill "$pid" 2>/dev/null ) &
+ local watcher=$!
+ wait "$pid" 2>/dev/null
+ kill "$watcher" 2>/dev/null
+ wait "$watcher" 2>/dev/null
+ cat "$out_file"
+ rm -f "$out_file"
+}
+
+# ─── 1. Path helpers ────────────────────────────────────────────────
+hdr "path helpers"
+[ "$(bc_root)" = "$MM_BUILD_CACHE_DIR" ] && pass "bc_root respects MM_BUILD_CACHE_DIR" || fail "bc_root: $(bc_root)"
+[ "$(bc_plat_dir ios)" = "$MM_BUILD_CACHE_DIR/ios" ] && pass "bc_plat_dir ios" || fail "bc_plat_dir ios"
+[ "$(bc_artifact_path ios abc123)" = "$MM_BUILD_CACHE_DIR/ios/abc123.app" ] && pass "bc_artifact_path ios → .app" || fail "ios artifact path"
+[ "$(bc_artifact_path android abc123)" = "$MM_BUILD_CACHE_DIR/android/abc123.apk" ] && pass "bc_artifact_path android → .apk" || fail "android artifact path"
+
+# ─── 2. Init dirs idempotent ────────────────────────────────────────
+hdr "bc_init_dirs"
+bc_init_dirs ios
+bc_init_dirs ios # second call must not error
+[ -d "$MM_BUILD_CACHE_DIR/ios" ] && pass "shared dir created" || fail "shared dir missing"
+[ -d ".agent/build-cache/ios" ] && pass "sidecar dir created" || fail "sidecar dir missing"
+
+# ─── 3. Fingerprint ─────────────────────────────────────────────────
+hdr "bc_fingerprint"
+unset BUILD_CACHE_FP
+FP1=$(bc_fingerprint)
+FP2=$(bc_fingerprint) # should hit memoized value
+if [ -n "$FP1" ] && [ "${#FP1}" -gt 20 ] && [ "$FP1" = "$FP2" ]; then
+ pass "fingerprint stable: ${FP1:0:16}..."
+else
+ fail "fingerprint unstable or empty: [$FP1] vs [$FP2]"
+fi
+
+# ─── 4. Store + lookup round-trip ───────────────────────────────────
+hdr "bc_store_artifact + bc_has_artifact"
+TEST_FP="testfp1234567890"
+SRC="/tmp/mm-bc-fake-app-$$"
+rm -rf "$SRC"
+mkdir -p "$SRC"
+# bc_has_artifact validity check requires Info.plist at the .app root.
+echo "" > "$SRC/Info.plist"
+bc_store_artifact ios "$TEST_FP" "$SRC"
+[ -e "$(bc_artifact_path ios "$TEST_FP")" ] && pass "artifact stored at expected path" || fail "artifact not at expected path"
+bc_has_artifact ios "$TEST_FP" && pass "bc_has_artifact returns true on hit" || fail "bc_has_artifact missed"
+bc_has_artifact ios "nonexistent_fp" && fail "bc_has_artifact wrongly hits" || pass "bc_has_artifact returns false on miss"
+[ -e "$(bc_meta_path ios "$TEST_FP")" ] && pass "meta.json written" || fail "meta.json missing"
+
+# ─── 5. installed.json round-trip ───────────────────────────────────
+hdr "installed.json"
+bc_record_install ios "$TEST_FP" "Simulator-XYZ"
+[ "$(bc_installed_fp ios)" = "$TEST_FP" ] && pass "bc_installed_fp returns recorded fp" || fail "bc_installed_fp mismatch: $(bc_installed_fp ios)"
+
+# ─── 6. Re-store overwrites atomically ──────────────────────────────
+hdr "atomic overwrite"
+echo "v2" > "$SRC/Info.plist"
+bc_store_artifact ios "$TEST_FP" "$SRC"
+GOT=$(cat "$(bc_artifact_path ios "$TEST_FP")/Info.plist")
+echo "$GOT" | grep -q "v2" && pass "re-store overwrites contents" || fail "re-store did not overwrite: got '$GOT'"
+
+# ─── 7. Lock — serialized within one shell ──────────────────────────
+hdr "bc_with_lock (sequential)"
+LOG="/tmp/mm-bc-lock-log-$$"
+: > "$LOG"
+bc_with_lock ios "lockfp1" sh -c "echo A >> $LOG; sleep 0.2; echo B >> $LOG"
+bc_with_lock ios "lockfp1" sh -c "echo C >> $LOG"
+[ "$(tr -d '[:space:]' < "$LOG")" = "ABC" ] && pass "sequential lock acquire/release" || fail "sequential lock order: $(cat "$LOG")"
+rm -f "$LOG"
+
+# ─── 8. Lock — concurrent (one waits for the other) ─────────────────
+hdr "bc_with_lock (concurrent)"
+LOG="/tmp/mm-bc-lock-conc-log-$$"
+: > "$LOG"
+BUILD_CACHE_LOCK_TIMEOUT=10 bc_with_lock ios "lockfp2" sh -c "echo start1 >> $LOG; sleep 1; echo end1 >> $LOG" &
+PID1=$!
+sleep 0.1
+BUILD_CACHE_LOCK_TIMEOUT=10 bc_with_lock ios "lockfp2" sh -c "echo start2 >> $LOG; echo end2 >> $LOG" &
+PID2=$!
+wait $PID1 $PID2
+LINES=$(tr '\n' ' ' < "$LOG")
+case "$LINES" in
+ "start1 end1 start2 end2 ") pass "concurrent: second waited for first" ;;
+ *) fail "concurrent lock ordering wrong: $LINES" ;;
+esac
+rm -f "$LOG"
+
+# ─── 9. Prune keeps N newest ────────────────────────────────────────
+hdr "bc_prune"
+# Clear prior artifacts so only prune-fp-* exist in the cache.
+rm -rf "$MM_BUILD_CACHE_DIR/ios"/*.app "$MM_BUILD_CACHE_DIR/ios"/*.meta.json 2>/dev/null || true
+for i in 1 2 3 4 5 6 7; do
+ FP="prune-fp-$i"
+ D="$(bc_artifact_path ios "$FP")"
+ mkdir -p "$D"
+ echo "x" > "$D/marker"
+ printf '{}' > "$(bc_meta_path ios "$FP")"
+ # YYYYMMDDhhmm — use distinct days so mtimes are unambiguously ordered.
+ touch -t "2024010${i}1200" "$D" "$(bc_meta_path ios "$FP")" 2>/dev/null || true
+done
+bc_prune ios 3
+REMAINING=$(find "$MM_BUILD_CACHE_DIR/ios" -maxdepth 1 -name "prune-fp-*.app" | wc -l | tr -d ' ')
+if [ "$REMAINING" = "3" ]; then
+ pass "bc_prune keeps exactly 3 (got $REMAINING)"
+else
+ fail "bc_prune kept $REMAINING (expected 3)"
+fi
+for keep in 5 6 7; do
+ [ -d "$MM_BUILD_CACHE_DIR/ios/prune-fp-${keep}.app" ] && pass "kept newest: prune-fp-${keep}" || fail "newest dropped: prune-fp-${keep}"
+done
+
+# ─── 10. Preflight --mode plumbing ──────────────────────────────────
+hdr "preflight --mode arg parsing"
+out=$(bash scripts/perps/agentic/preflight.sh --mode invalid --check-only 2>&1 || true)
+echo "$out" | grep -q "unknown --mode 'invalid'" && pass "unknown --mode rejected" || fail "unknown mode not rejected: $out"
+
+out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --mode fast --check-only 2>&1 | head -20 || true)
+echo "$out" | grep -qE "Mode:.*fast.*no build" && pass "fast mode header rendered" || fail "fast mode header missing"
+
+out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --mode auto --check-only 2>&1 | head -20 || true)
+echo "$out" | grep -qE "Mode:.*auto.*fingerprint-gated" && pass "auto mode header rendered" || fail "auto mode header missing"
+
+out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --mode rebuild-native --check-only 2>&1 | head -20 || true)
+echo "$out" | grep -qE "Mode:.*rebuild-native" && pass "rebuild-native mode header rendered" || fail "rebuild-native mode header missing"
+
+out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --mode clean --check-only 2>&1 | head -20 || true)
+echo "$out" | grep -qE "Mode:.*clean.*yarn setup" && pass "clean mode header rendered" || fail "clean mode header missing"
+
+# Legacy --clean still maps to clean mode (back-compat)
+out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --clean --check-only 2>&1 | head -20 || true)
+echo "$out" | grep -qE "Mode:.*clean.*yarn setup" && pass "legacy --clean still maps to clean" || fail "legacy --clean broken"
+
+# ─── 11. Memo cleanup refuses inherited / unowned BC_MEMO_DIR ──────
+# Across R6/R7/R8/R9 codex flagged five attack shapes against the memo
+# directory cleanup. Each scenario sets up a "victim" dir, hands its path
+# to a child shell via env, runs a code path that previously deleted the
+# dir, and asserts the dir + its contents survive.
+hdr "memo cleanup refuses inherited / unowned BC_MEMO_DIR"
+_memo_attack() {
+ local label="$1" extra_env="$2" sentinel="$3" body="$4"
+ local victim
+ victim=$(mktemp -d)
+ echo keep > "$victim/please-keep-me"
+ [ "$sentinel" = "yes" ] && : > "$victim/.bc_memo_owner"
+ env BC_MEMO_DIR="$victim" $extra_env bash -c ". scripts/perps/agentic/lib/build-cache.sh; $body" >/dev/null 2>&1 || true
+ if [ -d "$victim" ] && [ -f "$victim/please-keep-me" ]; then
+ pass "$label"
+ else
+ fail "$label — victim dir was deleted"
+ fi
+ rm -rf "$victim"
+}
+# Five attack shapes — must all preserve the victim:
+_memo_attack "R6: plain inherited dir + reset_memo" "" no "bc_fingerprint_reset_memo"
+_memo_attack "R7: forged on-disk sentinel + reset_memo" "" yes "bc_fingerprint_reset_memo"
+_memo_attack "R8: forged env BC_MEMO_DIR_OWNED=1" "BC_MEMO_DIR_OWNED=1" no "bc_fingerprint_reset_memo"
+_memo_attack "R9A: direct bc_memo_init + bc_memo_cleanup" "BC_MEMO_DIR_OWNED=1" no "bc_memo_init; bc_memo_cleanup"
+_memo_attack "R9B: EXIT cleanup on inherited memo" "" no "cleanup(){ bc_memo_cleanup; }; trap cleanup EXIT; false"
+
+# ─── 12. Fast-mode strictness when fingerprint cannot be computed ───
+# Codex R2 B3: --mode fast must hard-fail if the fingerprint command can't
+# run, instead of silently falling through to the legacy build path.
+hdr "preflight --mode fast / fingerprint failure"
+FP_SCRIPT="scripts/generate-fingerprint.js"
+FP_BACKUP="${FP_SCRIPT}.test-bak-$$"
+mv "$FP_SCRIPT" "$FP_BACKUP"
+restore_fp() { [ -f "$FP_BACKUP" ] && mv "$FP_BACKUP" "$FP_SCRIPT" 2>/dev/null || true; }
+# Augment trap so we restore even on test failure.
+trap '
+ rm -rf "$MM_BUILD_CACHE_DIR" .agent/build-cache 2>/dev/null || true
+ if [ -n "$SIDE_BACKUP" ] && [ -d "$SIDE_BACKUP" ]; then
+ mv "$SIDE_BACKUP" .agent/build-cache
+ fi
+ restore_fp
+' EXIT
+
+out=$(_capture_for 20 bash scripts/perps/agentic/preflight.sh --mode fast --platform ios --no-launch 2>&1 || true)
+restore_fp
+echo "$out" | grep -q "Mode 'fast': could not compute fingerprint" \
+ && pass "--mode fast fails loud when fingerprint cannot be computed" \
+ || fail "--mode fast did not fail loud on fingerprint failure: $(echo "$out" | tail -5)"
+
+echo ""
+if [ "$FAILED" -eq 0 ]; then
+ printf "\033[1;32m=== ALL TESTS PASSED ===\033[0m\n"
+ exit 0
+else
+ printf "\033[1;31m=== TESTS FAILED ===\033[0m\n"
+ exit 1
+fi
diff --git a/scripts/perps/agentic/lib/test-preflight-cache-e2e.sh b/scripts/perps/agentic/lib/test-preflight-cache-e2e.sh
new file mode 100644
index 000000000000..2c981b23a986
--- /dev/null
+++ b/scripts/perps/agentic/lib/test-preflight-cache-e2e.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+# Read-only e2e for preflight --mode auto cache-hit Path 1.
+#
+# Plants a synthetic installed.json claiming the current fingerprint is
+# already installed on the booted simulator, then runs preflight and
+# verifies it logs "Cache: installed app matches fingerprint" and skips
+# the native build branch. Does NOT uninstall, modify, or rebuild anything
+# on the sim — purely tests that the decision branch fires.
+#
+# Idempotent: stashes/restores any pre-existing .agent/build-cache.
+# Requires: a booted iOS simulator with MetaMask already installed.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
+cd "$REPO_ROOT"
+
+# Find a booted simulator that already has MetaMask installed. Iterate all
+# booted sims so the test works regardless of which one .js.env points at.
+BOOTED_UDID=""
+BOOTED_NAME=""
+while IFS= read -r line; do
+ udid=$(echo "$line" | awk -F'[()]' '{print $2}')
+ name=$(echo "$line" | sed -E 's/^[[:space:]]*//; s/ \(.*//')
+ if [ -n "$udid" ] && xcrun simctl listapps "$udid" 2>/dev/null | grep -q "io.metamask.MetaMask"; then
+ BOOTED_UDID="$udid"
+ BOOTED_NAME="$name"
+ break
+ fi
+done < <(xcrun simctl list devices 2>/dev/null | grep "Booted")
+
+if [ -z "$BOOTED_UDID" ]; then
+ echo "SKIP: no booted iOS simulator with MetaMask installed — run 'yarn a:setup:ios' first" >&2
+ exit 0
+fi
+echo "Booted sim: $BOOTED_NAME ($BOOTED_UDID) — MetaMask present"
+
+# Override .js.env sim selection so preflight inspects the right sim.
+export IOS_SIMULATOR="$BOOTED_NAME"
+export SIM_UDID="$BOOTED_UDID"
+
+export MM_BUILD_CACHE_DIR="/tmp/mm-bc-e2e-$$"
+rm -rf "$MM_BUILD_CACHE_DIR"
+
+SIDE_BACKUP=""
+if [ -d .agent/build-cache ]; then
+ SIDE_BACKUP="/tmp/mm-bc-e2e-sidecar-$$"
+ mv .agent/build-cache "$SIDE_BACKUP"
+fi
+PIDFILE_BACKUP=""
+if [ -f .agent/metro.pid ]; then
+ PIDFILE_BACKUP="/tmp/mm-bc-e2e-metropid-$$"
+ cp .agent/metro.pid "$PIDFILE_BACKUP"
+fi
+cleanup() {
+ rm -rf "$MM_BUILD_CACHE_DIR" 2>/dev/null || true
+ rm -rf .agent/build-cache 2>/dev/null || true
+ [ -n "$SIDE_BACKUP" ] && [ -d "$SIDE_BACKUP" ] && mv "$SIDE_BACKUP" .agent/build-cache
+ [ -n "$PIDFILE_BACKUP" ] && [ -f "$PIDFILE_BACKUP" ] && mv "$PIDFILE_BACKUP" .agent/metro.pid
+}
+trap cleanup EXIT
+
+# shellcheck disable=SC1091
+. scripts/perps/agentic/lib/build-cache.sh
+
+FP=$(bc_fingerprint)
+echo "Current fingerprint: ${FP:0:16}..."
+
+FAILED=0
+pass() { printf " \033[32mPASS\033[0m %s\n" "$1"; }
+fail() { printf " \033[31mFAIL\033[0m %s\n" "$1"; FAILED=1; }
+
+printf "\n\033[1m== Path 1: installed.json matches current fingerprint ==\033[0m\n"
+mkdir -p .agent/build-cache/ios
+bc_record_install ios "$FP" "$BOOTED_UDID"
+
+LOG="/tmp/mm-bc-e2e-log-$$"
+set +e
+# Run preflight in the background; watchdog below kills it after 45s OR as
+# soon as the cache-decision marker appears. Avoids the GNU `timeout` binary
+# which is not in base macOS.
+bash scripts/perps/agentic/preflight.sh --mode auto --platform ios --no-launch > "$LOG" 2>&1 &
+PID=$!
+( sleep 45 && kill "$PID" 2>/dev/null ) &
+WATCHDOG=$!
+for _ in $(seq 1 45); do
+ if grep -q "Cache: installed app matches fingerprint" "$LOG" 2>/dev/null; then break; fi
+ if ! kill -0 "$PID" 2>/dev/null; then break; fi
+ sleep 1
+done
+kill "$PID" 2>/dev/null || true
+wait "$PID" 2>/dev/null || true
+kill "$WATCHDOG" 2>/dev/null || true
+wait "$WATCHDOG" 2>/dev/null || true
+set -e
+
+if grep -q "Cache: installed app matches fingerprint ${FP:0:12}" "$LOG"; then
+ pass "preflight recognized installed-app fp match"
+else
+ fail "expected 'Cache: installed app matches fingerprint ${FP:0:12}' in log:"
+ tail -50 "$LOG" | sed 's/^/ /'
+fi
+
+if grep -qE "Running pod install|expo run:ios|Building \+ installing app" "$LOG"; then
+ fail "preflight unexpectedly entered the build branch"
+else
+ pass "build branch was skipped (no pod/xcodebuild)"
+fi
+
+# Verify app is still present (we didn't break the sim).
+POST_COUNT=$(xcrun simctl listapps "$BOOTED_UDID" 2>/dev/null | grep -c "io.metamask.MetaMask" || true)
+if [ "$POST_COUNT" -gt 0 ]; then
+ pass "MetaMask still installed on sim post-test (no destructive ops)"
+else
+ fail "MetaMask vanished from sim — test should have been read-only"
+fi
+rm -f "$LOG"
+
+echo ""
+if [ "$FAILED" -eq 0 ]; then
+ printf "\033[1;32m=== E2E PATH 1 TEST PASSED ===\033[0m\n"
+ exit 0
+else
+ printf "\033[1;31m=== E2E TEST FAILED ===\033[0m\n"
+ exit 1
+fi
diff --git a/scripts/perps/agentic/preflight.sh b/scripts/perps/agentic/preflight.sh
index 26cc5ecb390b..c688afe9bd4d 100755
--- a/scripts/perps/agentic/preflight.sh
+++ b/scripts/perps/agentic/preflight.sh
@@ -77,10 +77,13 @@ DO_WALLET_SETUP=false
WALLET_FIXTURE="${WALLET_FIXTURE:-.agent/wallet-fixture.json}"
WALLET_PW="${MM_WALLET_PASSWORD:-}"
FORCE_PLATFORM=""
+MODE="" # auto | fast | rebuild-native | clean — resolved below
+MODE_EXPLICIT=false
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) FORCE_PLATFORM="$2"; shift 2 ;;
+ --mode) MODE="$2"; MODE_EXPLICIT=true; shift 2 ;;
--rebuild) DO_REBUILD=true; shift ;;
--clean) DO_CLEAN=true; DO_REBUILD=true; shift ;;
--no-launch) DO_LAUNCH=false; shift ;;
@@ -92,6 +95,52 @@ while [[ $# -gt 0 ]]; do
esac
done
+# ── Resolve --mode → existing flag state ─────────────────────────────
+# If --mode not given, fall back to legacy flag mapping for back-compat.
+if ! $MODE_EXPLICIT; then
+ if $DO_CLEAN; then
+ MODE="clean"
+ elif $DO_REBUILD; then
+ MODE="rebuild-native"
+ else
+ MODE="default" # legacy: skip build if app installed, otherwise build
+ fi
+fi
+case "$MODE" in
+ auto)
+ DO_CLEAN=false; DO_REBUILD=false
+ ;;
+ fast)
+ DO_CLEAN=false; DO_REBUILD=false
+ ;;
+ rebuild-native)
+ DO_CLEAN=false; DO_REBUILD=true
+ ;;
+ clean)
+ DO_CLEAN=true; DO_REBUILD=true
+ ;;
+ default)
+ : # keep parsed flag state
+ ;;
+ *)
+ echo "ERROR: unknown --mode '$MODE' (expected: auto|fast|rebuild-native|clean)" >&2
+ exit 2
+ ;;
+esac
+
+# Source the build-cache helpers (no-op if file missing — fall back to legacy).
+BUILD_CACHE_LIB="$(dirname "$0")/lib/build-cache.sh"
+if [ -f "$BUILD_CACHE_LIB" ]; then
+ # shellcheck disable=SC1090
+ . "$BUILD_CACHE_LIB"
+ BUILD_CACHE_ENABLED=true
+ # Allocate private mktemp memo dir, exported so $(bc_fingerprint) subshells
+ # inherit it. No EXIT trap here — lock helpers need EXIT; dir is OS-reaped.
+ bc_fingerprint_reset_memo
+else
+ BUILD_CACHE_ENABLED=false
+fi
+
# ── Platform detection ─────────────────────────────────────────────
detect_platform() {
if [ -n "$FORCE_PLATFORM" ]; then echo "$FORCE_PLATFORM"; return; fi
@@ -296,15 +345,15 @@ step() {
echo ""
echo -e "${BOLD}=== MetaMask Mobile Preflight ===${NC}"
echo -e " Port: $PORT | Platform: $PLAT"
-if $DO_CLEAN; then
- echo -e " Mode: ${YELLOW}clean${NC} (yarn setup → build → Metro → CDP → wallet)"
-elif $DO_REBUILD; then
- echo -e " Mode: ${YELLOW}rebuild${NC} (build → Metro → CDP)"
-elif $CHECK_ONLY; then
- echo -e " Mode: check-only"
-else
- echo -e " Mode: default (Metro → CDP)"
-fi
+case "$MODE" in
+ auto) echo -e " Mode: ${BLUE}auto${NC} (fingerprint-gated reuse, build only if needed)" ;;
+ fast) echo -e " Mode: ${BLUE}fast${NC} (no build — fail loud if app missing)" ;;
+ rebuild-native) echo -e " Mode: ${YELLOW}rebuild-native${NC} (skip yarn setup, force native rebuild)" ;;
+ clean) echo -e " Mode: ${YELLOW}clean${NC} (yarn setup → pod --repo-update → build)" ;;
+ default) $CHECK_ONLY \
+ && echo -e " Mode: check-only" \
+ || echo -e " Mode: default (fingerprint-gated reuse; falls back to native build on cache miss, no fail-loud)" ;;
+esac
# ── Zombie sweep (silent when clean) ─────────────────────────────────
# Detect and clean up orphaned expo/metro processes from previous crashed runs.
@@ -352,6 +401,11 @@ sweep_port "$PORT" "worktree Metro"
[ "$PORT" != "8081" ] && sweep_port 8081 "expo default"
# ── Step: yarn setup (clean only) ────────────────────────────────────
+# --check-only is read-only by contract; refuse a destructive yarn setup
+# combo loudly instead of running it briefly and then early-exiting.
+if $DO_CLEAN && $CHECK_ONLY; then
+ fail "--check-only conflicts with --clean / --mode clean (would mutate node_modules + build artifacts)"
+fi
if $DO_CLEAN; then
if [ "$PLAT" = "ios" ]; then
step "Installing dependencies" "rm ios/build → yarn setup (install deps + patches + pods)"
@@ -410,6 +464,76 @@ if [ "$PLAT" = "ios" ]; then
# ── Step: App build / install ────────────────────────────────────
step "Checking app" "Looking for $BUNDLE_ID on simulator"
APP_INSTALLED=$(xcrun simctl listapps "$SIM_TARGET" 2>/dev/null | grep -c "$BUNDLE_ID" || true)
+ BC_LOCK_HELD=false # set to true once we own the per-fingerprint build lock
+
+ # Cache validation runs in every mode except `clean` / `rebuild-native`,
+ # which intentionally bypass the cache. This is a deliberate behaviour
+ # change vs origin/main: default mode now opts into fingerprint-gated reuse.
+ if $BUILD_CACHE_ENABLED && [ "$MODE" != "clean" ] && [ "$MODE" != "rebuild-native" ]; then
+ FP=$(bc_fingerprint 2>/dev/null || true)
+ if [ -n "$FP" ]; then
+ INSTALLED_FP=$(bc_installed_fp ios)
+ INSTALLED_TGT=$(bc_installed_target ios)
+ if [ "$APP_INSTALLED" -gt 0 ] \
+ && [ "$INSTALLED_FP" = "$FP" ] \
+ && [ "$INSTALLED_TGT" = "$SIM_TARGET" ] \
+ && ! $DO_REBUILD; then
+ ok "Cache: installed app matches fingerprint ${FP:0:12} on $SIM_TARGET — no native action needed"
+ CHECK_ONLY_FP_VERIFIED=true
+ CHECK_ONLY_FP_VALUE="$FP"
+ else
+ if bc_lock_acquire ios "$FP"; then
+ BC_LOCK_HELD=true
+ trap 'bc_lock_release' EXIT
+ if bc_has_artifact ios "$FP"; then
+ if $CHECK_ONLY; then
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ fail "App not at fingerprint ${FP:0:12} on $SIM_TARGET — cache hit available, but --check-only forbids install"
+ fi
+ echo -e " ${GREEN}Cache hit:${NC} fp=${FP:0:12} — installing from shared cache"
+ IOS_ARTIFACT=$(bc_artifact_path ios "$FP")
+ # `simctl install` overwrites the .app bundle in place; it keeps
+ # the existing container data (wallet/app state), so no preemptive
+ # uninstall is needed on the happy path. If install fails we
+ # explicitly reset APP_INSTALLED to force the build branch.
+ if xcrun simctl install "$SIM_TARGET" "$IOS_ARTIFACT"; then
+ bc_record_install ios "$FP" "$SIM_TARGET"
+ APP_INSTALLED=1
+ ok "Installed from cache: $IOS_ARTIFACT"
+ else
+ APP_INSTALLED=0
+ if [ "$MODE" = "fast" ]; then
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ fail "Mode 'fast': cached artifact install failed for fp ${FP:0:12}"
+ fi
+ warn "Cache install failed — falling through to native build"
+ fi
+ elif [ "$MODE" = "fast" ]; then
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ fail "Mode 'fast' but no cached build for fp ${FP:0:12} and app not installed at this fingerprint on $SIM_TARGET"
+ else
+ # Cache miss in auto/default mode. Whatever is installed (if anything)
+ # is at the wrong fingerprint; force the build gate to fire so we
+ # produce + install a fresh artifact instead of running a stale app.
+ APP_INSTALLED=0
+ fi
+ # Lock stays held through native build; post-build store releases it.
+ else
+ if [ "$MODE" = "fast" ]; then
+ fail "Mode 'fast': could not acquire build-cache lock for fp ${FP:0:12}"
+ fi
+ warn "Could not acquire build-cache lock for fp ${FP:0:12} — proceeding without lock"
+ APP_INSTALLED=0 # unknown cache state — treat installed app as untrusted
+ fi
+ fi
+ else
+ if [ "$MODE" = "fast" ]; then
+ fail "Mode 'fast': could not compute fingerprint — cannot validate cache availability"
+ fi
+ warn "Could not compute fingerprint — falling back to legacy build path"
+ fi
+ fi
+
if [ "$APP_INSTALLED" -eq 0 ] || $DO_REBUILD; then
$CHECK_ONLY && fail "App not installed (run with --rebuild)"
echo ""
@@ -417,13 +541,31 @@ if [ "$PLAT" = "ios" ]; then
echo -e " ${DIM}expo run:ios --port \$PORT (bundler killed after build, start-metro.sh takes over)${NC}"
echo ""
+ # Skip --repo-update unless --mode clean: it re-pulls every CocoaPods
+ # spec (~3-5 min) on every dispatch. Plain `pod install` is sufficient
+ # whenever Podfile.lock pods are already present in the local spec repo.
+ if $DO_CLEAN; then
+ POD_CMD="cd ios && bundle exec pod install --repo-update --ansi"
+ else
+ POD_CMD="cd ios && bundle exec pod install --ansi"
+ fi
echo " Running pod install via bundler..."
stage_log "$POD_INSTALL_LOG"
- printf '$ (cd ios && bundle exec pod install --repo-update --ansi)\n' > "$POD_INSTALL_LOG"
- if run_with_live_log "$POD_INSTALL_LOG" "cd ios && bundle exec pod install --repo-update --ansi"; then
+ printf '$ (%s)\n' "$POD_CMD" > "$POD_INSTALL_LOG"
+ if run_with_live_log "$POD_INSTALL_LOG" "$POD_CMD"; then
ok "pod install complete"
else
- warn "pod install had issues — see $POD_INSTALL_LOG"
+ # On non-clean modes, the failure may be a missing spec → retry once with --repo-update.
+ if ! $DO_CLEAN; then
+ warn "pod install failed — retrying with --repo-update"
+ if run_with_live_log "$POD_INSTALL_LOG" "cd ios && bundle exec pod install --repo-update --ansi"; then
+ ok "pod install complete (after --repo-update retry)"
+ else
+ warn "pod install had issues — see $POD_INSTALL_LOG"
+ fi
+ else
+ warn "pod install had issues — see $POD_INSTALL_LOG"
+ fi
fi
# Must pass --port (never --no-bundler): @expo/cli rejects that combo
@@ -558,7 +700,36 @@ if [ "$PLAT" = "ios" ]; then
fail "simctl install succeeded but app not found"
fi
ok "App built and installed"
+
+ # Publish to shared cache. If we hold the lock from the cache-decision
+ # phase, store + release directly; else (clean/rebuild-native) bc_with_lock.
+ if $BUILD_CACHE_ENABLED && [ -n "${APP_PATH:-}" ]; then
+ FP=$(bc_fingerprint 2>/dev/null || true)
+ if [ -n "$FP" ]; then
+ if $BC_LOCK_HELD; then
+ if bc_store_artifact ios "$FP" "$APP_PATH"; then
+ ok "Stored build in shared cache: fp=${FP:0:12}"
+ bc_record_install ios "$FP" "$SIM_TARGET"
+ bc_prune ios "${BUILD_CACHE_RETAIN:-5}" 2>/dev/null || true
+ else
+ warn "Failed to store build in cache"
+ fi
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ else
+ if bc_with_lock ios "$FP" bc_store_artifact ios "$FP" "$APP_PATH"; then
+ ok "Stored build in shared cache: fp=${FP:0:12}"
+ bc_record_install ios "$FP" "$SIM_TARGET"
+ bc_prune ios "${BUILD_CACHE_RETAIN:-5}" 2>/dev/null || true
+ else
+ warn "Could not store build in cache (lock timeout?)"
+ fi
+ fi
+ fi
+ fi
else
+ if $BC_LOCK_HELD; then
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ fi
ok "App already installed"
fi
@@ -603,18 +774,86 @@ else
ok "Device connected: $DEVICE_NAME"
fi
- # Set up adb reverse so device can reach Metro on host
- $ADB_CMD reverse tcp:$PORT tcp:$PORT 2>/dev/null || warn "adb reverse failed — device may not reach Metro"
- ok "adb reverse tcp:$PORT → host"
+ # Set up adb reverse so device can reach Metro on host.
+ # Skipped in --check-only to preserve the read-only contract.
+ if ! $CHECK_ONLY; then
+ $ADB_CMD reverse tcp:$PORT tcp:$PORT 2>/dev/null || warn "adb reverse failed — device may not reach Metro"
+ ok "adb reverse tcp:$PORT → host"
+ fi
# ── Step: App build / install ────────────────────────────────────
step "Checking app" "Looking for $PACKAGE_ID on device"
APP_INSTALLED=$($ADB_CMD shell pm list packages 2>/dev/null | grep -c "$PACKAGE_ID" || true)
+ BC_LOCK_HELD=false # see iOS block for semantics
+
+ # ── Build-cache lookup (auto/fast/default modes only) ────────────
+ if $BUILD_CACHE_ENABLED && [ "$MODE" != "clean" ] && [ "$MODE" != "rebuild-native" ]; then
+ FP=$(bc_fingerprint 2>/dev/null || true)
+ if [ -n "$FP" ]; then
+ INSTALLED_FP=$(bc_installed_fp android)
+ INSTALLED_TGT=$(bc_installed_target android)
+ ADB_DEVICE_ID="${ADB_TARGET:-default}"
+ if [ "$APP_INSTALLED" -gt 0 ] \
+ && [ "$INSTALLED_FP" = "$FP" ] \
+ && [ "$INSTALLED_TGT" = "$ADB_DEVICE_ID" ] \
+ && ! $DO_REBUILD; then
+ ok "Cache: installed app matches fingerprint ${FP:0:12} on $ADB_DEVICE_ID — no native action needed"
+ CHECK_ONLY_FP_VERIFIED=true
+ CHECK_ONLY_FP_VALUE="$FP"
+ else
+ if bc_lock_acquire android "$FP"; then
+ BC_LOCK_HELD=true
+ trap 'bc_lock_release' EXIT
+ if bc_has_artifact android "$FP"; then
+ if $CHECK_ONLY; then
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ fail "App not at fingerprint ${FP:0:12} on $ADB_DEVICE_ID — cache hit available, but --check-only forbids install"
+ fi
+ echo -e " ${GREEN}Cache hit:${NC} fp=${FP:0:12} — installing from shared cache"
+ ANDROID_ARTIFACT=$(bc_artifact_path android "$FP")
+ # `adb install -r` reinstalls keeping data; no preemptive uninstall.
+ if $ADB_CMD install -r "$ANDROID_ARTIFACT" 2>/dev/null; then
+ bc_record_install android "$FP" "$ADB_DEVICE_ID"
+ APP_INSTALLED=1
+ ok "Installed from cache: $ANDROID_ARTIFACT"
+ else
+ APP_INSTALLED=0
+ if [ "$MODE" = "fast" ]; then
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ fail "Mode 'fast': cached artifact install failed for fp ${FP:0:12}"
+ fi
+ warn "Cache install failed — falling through to native build"
+ fi
+ elif [ "$MODE" = "fast" ]; then
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ fail "Mode 'fast' but no cached build for fp ${FP:0:12} and app not installed at this fingerprint on $ADB_DEVICE_ID"
+ else
+ # Cache miss in auto/default mode. Stale app must not pass the build
+ # gate untouched; force a fresh build + install.
+ APP_INSTALLED=0
+ fi
+ else
+ if [ "$MODE" = "fast" ]; then
+ fail "Mode 'fast': could not acquire build-cache lock for fp ${FP:0:12} — refusing to proceed without lock"
+ fi
+ warn "Could not acquire build-cache lock for fp ${FP:0:12} — proceeding without lock"
+ APP_INSTALLED=0
+ fi
+ fi
+ else
+ if [ "$MODE" = "fast" ]; then
+ fail "Mode 'fast': could not compute fingerprint — cannot validate cache availability"
+ fi
+ warn "Could not compute fingerprint — falling back to legacy build path"
+ fi
+ fi
+
if [ "$APP_INSTALLED" -eq 0 ] || $DO_REBUILD; then
$CHECK_ONLY && fail "App not installed (run with --rebuild)"
- # Uninstall first for a clean slate (avoids stale data / vault)
- if ($DO_CLEAN || $DO_WALLET_SETUP) && [ "$APP_INSTALLED" -gt 0 ]; then
+ # Uninstall for a clean slate. Re-query device since cache-miss zeroes
+ # APP_INSTALLED even when the app is still physically present.
+ if ($DO_CLEAN || $DO_WALLET_SETUP) && $ADB_CMD shell pm list packages 2>/dev/null | grep -q "$PACKAGE_ID"; then
echo " Uninstalling previous app..."
$ADB_CMD uninstall "$PACKAGE_ID" 2>/dev/null || true
fi
@@ -660,7 +899,37 @@ else
fail "Build completed but app not found on device"
fi
ok "App built and installed"
+
+ # Publish .apk to shared cache. If we still hold the per-fingerprint lock
+ # from the cache-decision phase, store directly; otherwise (clean/rebuild-native)
+ # acquire-and-release inline via bc_with_lock.
+ if $BUILD_CACHE_ENABLED && [ -n "${APK_PATH:-}" ]; then
+ FP=$(bc_fingerprint 2>/dev/null || true)
+ if [ -n "$FP" ]; then
+ if $BC_LOCK_HELD; then
+ if bc_store_artifact android "$FP" "$APK_PATH"; then
+ ok "Stored build in shared cache: fp=${FP:0:12}"
+ bc_record_install android "$FP" "${ADB_TARGET:-default}"
+ bc_prune android "${BUILD_CACHE_RETAIN:-5}" 2>/dev/null || true
+ else
+ warn "Failed to store build in cache"
+ fi
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ else
+ if bc_with_lock android "$FP" bc_store_artifact android "$FP" "$APK_PATH"; then
+ ok "Stored build in shared cache: fp=${FP:0:12}"
+ bc_record_install android "$FP" "${ADB_TARGET:-default}"
+ bc_prune android "${BUILD_CACHE_RETAIN:-5}" 2>/dev/null || true
+ else
+ warn "Could not store build in cache (lock timeout?)"
+ fi
+ fi
+ fi
+ fi
else
+ if $BC_LOCK_HELD; then
+ bc_lock_release; BC_LOCK_HELD=false; trap - EXIT
+ fi
ok "App already installed"
fi
fi
@@ -669,6 +938,20 @@ fi
# ── Shared steps (both platforms) ────────────────────────────────────
# ══════════════════════════════════════════════════════════════════════
+# --check-only is read-only: probes above fail loud on mismatch; here we
+# must not run Metro / CDP / wallet (all state-changing).
+if $CHECK_ONLY; then
+ TOTAL_ELAPSED=$(elapsed_since $PREFLIGHT_START)
+ echo ""
+ echo -e "${GREEN}${BOLD}=== Preflight check-only passed ===${NC} ${DIM}(${TOTAL_ELAPSED}s)${NC}"
+ if ${CHECK_ONLY_FP_VERIFIED:-false}; then
+ echo -e " Platform ${DIM}$PLAT${NC} | App installed and verified at fingerprint ${DIM}${CHECK_ONLY_FP_VALUE:0:12}${NC}"
+ else
+ echo -e " Platform ${DIM}$PLAT${NC} | App installed (fingerprint not verified — cache disabled or fingerprint compute failed)"
+ fi
+ exit 0
+fi
+
# ── Step: Metro ─────────────────────────────────────────────────────
step "Starting Metro" "Bundler on port $PORT → logs at $LOGFILE"
stage_log "$LOGFILE"