diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx
index dc45415b30a..d7cc7d22e43 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 c784d1b4f63..31aed30b061 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 0bbe19e2701..ece1d738cd8 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 09c40a62ed3..7f085d05ba1 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 868229ca7b4..e451134046e 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 0d595714504..3d77d7f691a 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 329a49b0b6a..d9e735bb812 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 7dcc0b4c590..8f177d7b2c0 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 f82f4803390..491c2cb9880 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 6ffec17d1e9..e6948736371 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 a76c824f3fa..b662428fecb 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 efce4d19531..a9ee358c8b7 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 9aac66a2777..19c9d69b8b8 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 1b84f3bceec..f874510b670 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 8bdca5498e7..1bb6218a1bc 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 3317116821f..24bc0ada920 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 56d34f5e0f6..5d0114c3d7a 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 394698f6981..57ed5f68594 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 54c74be3dcc..8e7e023f0a5 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 96aa7ea4ac9..0f1cf2c514d 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 9a3c50d53a2..2e928e0ba9e 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/app/components/Views/AccountStatus/index.test.tsx b/app/components/Views/AccountStatus/index.test.tsx
index 2c70cb9cb51..7b90015f8a3 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 90effc3623f..ba8bd5c1036 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 930042210c3..af2fc849e76 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 bb94764eca8..dfb7b7616b6 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 c3486517cfd..6a757cff1c6 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 5816a186435..adcc97aa10b 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/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx
index 8d9b361b851..f3aab38fe77 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 b22c37426c6..d618002ff2a 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 75979fe67e9..6777b2c5cac 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'),
+ )
+ }
+ />
+ )}
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 efc116f0cad..782e06d52b2 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 214a62a9a23..336e32d86fe 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 299083e5ca6..1246b431d7a 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 8d0b6c71aac..ba352699f4a 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 2f26f4f6437..23240bda4d0 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 81b732585da..7e7df7d2a31 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 0a615933cec..a50ec56442f 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 ace460a7f17..cc4fea5abf3 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 00000000000..c827ab336b6
--- /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 597f0bac32f..836686104a9 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 00000000000..c3ee0349154
--- /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 00000000000..c213497b77c
--- /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(),
+ );
+}
diff --git a/locales/languages/en.json b/locales/languages/en.json
index e707e2f48a1..f7b22a73522 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",
diff --git a/package.json b/package.json
index 5edc676e9b2..1e6ff08329e 100644
--- a/package.json
+++ b/package.json
@@ -310,7 +310,7 @@
"@metamask/native-utils": "^0.8.0",
"@metamask/network-controller": "^31.0.0",
"@metamask/network-enablement-controller": "^5.1.0",
- "@metamask/notification-services-controller": "^24.0.0",
+ "@metamask/notification-services-controller": "24.1.1",
"@metamask/permission-controller": "^13.1.1",
"@metamask/phishing-controller": "^17.1.1",
"@metamask/post-message-stream": "^10.0.0",
diff --git a/scripts/perps/agentic/README.md b/scripts/perps/agentic/README.md
index 6713b0054b7..494ecbb4742 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 00000000000..839288390b5
--- /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 00000000000..338c31f8080
--- /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 00000000000..2c981b23a98
--- /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 26cc5ecb390..c688afe9bd4 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"
diff --git a/tests/api-mocking/mock-responses/defaults/user-storage.ts b/tests/api-mocking/mock-responses/defaults/user-storage.ts
index e47247c2c0f..3544521f156 100644
--- a/tests/api-mocking/mock-responses/defaults/user-storage.ts
+++ b/tests/api-mocking/mock-responses/defaults/user-storage.ts
@@ -28,8 +28,9 @@ const notificationPreferences = {
},
],
},
+ // Feature announcements are controlled by marketing in-app preferences.
marketing: {
- inAppNotificationsEnabled: false,
+ inAppNotificationsEnabled: true,
pushNotificationsEnabled: false,
},
perps: {
diff --git a/tests/component-view/presets/notifications.ts b/tests/component-view/presets/notifications.ts
index a81662a4f32..687f6e37f7a 100644
--- a/tests/component-view/presets/notifications.ts
+++ b/tests/component-view/presets/notifications.ts
@@ -42,6 +42,7 @@ interface NotificationsPresetOptions {
notificationsEnabled?: boolean;
featureAnnouncementsEnabled?: boolean;
pushEnabled?: boolean;
+ socialLeaderboardEnabled?: boolean;
notifications?: typeof MOCK_NOTIFICATIONS;
}
@@ -62,6 +63,7 @@ export function buildNotificationsState(
notificationsEnabled = true,
featureAnnouncementsEnabled = true,
pushEnabled = true,
+ socialLeaderboardEnabled = false,
notifications = MOCK_NOTIFICATIONS,
} = options;
@@ -93,6 +95,10 @@ export function buildNotificationsState(
RemoteFeatureFlagController: {
remoteFeatureFlags: {
assetsNotificationsEnabled: true,
+ aiSocialLeaderboardEnabled: {
+ enabled: socialLeaderboardEnabled,
+ minimumVersion: '0.0.1',
+ },
},
},
AccountsController: {
diff --git a/yarn.lock b/yarn.lock
index adf2d01fdb5..bb19755c0fe 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9275,9 +9275,9 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/notification-services-controller@npm:^24.0.0":
- version: 24.1.0
- resolution: "@metamask/notification-services-controller@npm:24.1.0"
+"@metamask/notification-services-controller@npm:24.1.1":
+ version: 24.1.1
+ resolution: "@metamask/notification-services-controller@npm:24.1.1"
dependencies:
"@contentful/rich-text-html-renderer": "npm:^16.5.2"
"@metamask/authenticated-user-storage": "npm:^2.0.0"
@@ -9293,7 +9293,7 @@ __metadata:
loglevel: "npm:^1.8.1"
semver: "npm:^7.6.3"
uuid: "npm:^8.3.2"
- checksum: 10/c8bcfcc7e9178eee8789ef4417636ac583e597639596c85eac82e5703c5d59fb8e4762e0d1fe71005320bedb11ea5893431ee540f0d268bcecf10bb226d7af33
+ checksum: 10/8f5249dea67dc7168c9254fde1de81e24483f9b054ba1ac282c36849e2090515f0cff0ed7cd4beaf6aefed6382f966277e02d3259f69aea67dee1c84c2ec7400
languageName: node
linkType: hard
@@ -35288,7 +35288,7 @@ __metadata:
"@metamask/native-utils": "npm:^0.8.0"
"@metamask/network-controller": "npm:^31.0.0"
"@metamask/network-enablement-controller": "npm:^5.1.0"
- "@metamask/notification-services-controller": "npm:^24.0.0"
+ "@metamask/notification-services-controller": "npm:24.1.1"
"@metamask/object-multiplex": "npm:^1.1.0"
"@metamask/permission-controller": "npm:^13.1.1"
"@metamask/phishing-controller": "npm:^17.1.1"