diff --git a/.eslintignore b/.eslintignore index f654ed55c5e..15edab97f27 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,8 @@ /scripts/inpage-bridge /app/core/InpageBridgeWeb3.js +/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js +/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts +/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js /app/util/blockies.js __snapshots__ android diff --git a/.js.env.example b/.js.env.example index a15cddcf5c8..5bb6f3c81cd 100644 --- a/.js.env.example +++ b/.js.env.example @@ -172,6 +172,12 @@ export ENABLE_WHY_DID_YOU_RENDER="false" # Rewards API URL export REWARDS_API_URL="" +## Advanced Charts (TradingView charting library CDN) +# Production: CloudFront distribution URL (trailing slash required) +# Development: local http-server, e.g. http://localhost:8000/ +# Leave empty to use the default S3 origin fallback +export MM_CHARTING_LIBRARY_URL="" + ## Perps export MM_PERPS_ENABLED="true" diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.styles.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChart.styles.ts new file mode 100644 index 00000000000..c918d14a988 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.styles.ts @@ -0,0 +1,44 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../util/theme/models'; + +export const DEFAULT_CHART_HEIGHT = 400; + +const styleSheet = (params: { theme: Theme; vars: { height: number } }) => + StyleSheet.create({ + container: { + width: '100%', + height: params.vars.height, + backgroundColor: params.theme.colors.background.default, + }, + webview: { + flex: 1, + backgroundColor: params.theme.colors.background.default, + }, + loadingContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: params.theme.colors.background.default, + }, + loadingText: { + marginTop: 12, + color: params.theme.colors.text.muted, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + backgroundColor: params.theme.colors.background.default, + }, + errorText: { + color: params.theme.colors.error.default, + textAlign: 'center', + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx new file mode 100644 index 00000000000..54af667d48d --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx @@ -0,0 +1,366 @@ +import React, { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + forwardRef, +} from 'react'; +import { View, ActivityIndicator } from 'react-native'; +import { WebView, WebViewMessageEvent } from '@metamask/react-native-webview'; +import { Text, TextVariant } from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../component-library/hooks'; +import styleSheet, { DEFAULT_CHART_HEIGHT } from './AdvancedChart.styles'; +import { + createAdvancedChartTemplate, + CHARTING_LIBRARY_BASE_URL, +} from './AdvancedChartTemplate'; +import { + ChartType, + DEFAULT_DISABLED_FEATURES, + parseWebViewMessage, + type AdvancedChartProps, + type AdvancedChartRef, + type IndicatorType, + type OHLCVBar, + type RNToWebViewMessage, +} from './AdvancedChart.types'; + +/** + * Generic TradingView Advanced Chart component. + * + * Renders a professional charting widget inside a WebView. + * Designed to be consumed by multiple features (Token Details, Perps, etc.) + * with a composable props API -- each consumer uses only the props it needs. + * + * ATTRIBUTION NOTICE: + * TradingView Advanced Charts (TM) + * Copyright (c) 2025 TradingView, Inc. https://www.tradingview.com/ + */ +const AdvancedChart = forwardRef( + ( + { + ohlcvData, + height = DEFAULT_CHART_HEIGHT, + realtimeBar, + onRequestMoreHistory, + indicators = [], + positionLines, + chartType, + showVolume = false, + enableDrawingTools = false, + disabledFeatures = DEFAULT_DISABLED_FEATURES, + onChartReady, + onError, + onCrosshairMove, + isLoading = false, + }, + ref, + ) => { + const { styles, theme } = useStyles(styleSheet, { + height, + } as { height: number }); + const webViewRef = useRef(null); + const [chartReadyCount, setChartReadyCount] = useState(0); + const isChartReady = chartReadyCount > 0; + const [webViewError, setWebViewError] = useState(null); + + const activeIndicatorsRef = useRef>(new Set()); + const [webViewLoaded, setWebViewLoaded] = useState(false); + const prevPositionLinesRef = useRef(positionLines); + const prevChartTypeRef = useRef(chartType); + const prevShowVolumeRef = useRef(showVolume); + + const htmlContent = useMemo( + () => + createAdvancedChartTemplate(theme, { + enableDrawingTools, + showVolume, + disabledFeatures, + }), + [theme, enableDrawingTools, showVolume, disabledFeatures], + ); + + // Reset all chart state when the WebView reloads due to htmlContent changes + useEffect(() => { + setChartReadyCount(0); + setWebViewLoaded(false); + activeIndicatorsRef.current.clear(); + prevPositionLinesRef.current = undefined; + prevChartTypeRef.current = undefined; + prevShowVolumeRef.current = showVolume; + }, [htmlContent]); // eslint-disable-line react-hooks/exhaustive-deps + + // ---- Helpers ---- + + const postMessage = useCallback((message: RNToWebViewMessage) => { + if (webViewRef.current) { + webViewRef.current.postMessage(JSON.stringify(message)); + } + }, []); + + const sendOHLCVData = useCallback( + (data: OHLCVBar[]) => { + postMessage({ + type: 'SET_OHLCV_DATA', + payload: { data }, + }); + }, + [postMessage], + ); + + const addIndicator = useCallback( + (indicator: IndicatorType, inputs?: Record) => { + if (!isChartReady) return; + postMessage({ + type: 'ADD_INDICATOR', + payload: { name: indicator, inputs }, + }); + }, + [isChartReady, postMessage], + ); + + const removeIndicator = useCallback( + (indicator: IndicatorType) => { + if (!isChartReady) return; + postMessage({ + type: 'REMOVE_INDICATOR', + payload: { name: indicator }, + }); + }, + [isChartReady, postMessage], + ); + + const setChartTypeInternal = useCallback( + (type: ChartType) => { + if (!isChartReady) return; + postMessage({ + type: 'SET_CHART_TYPE', + payload: { type }, + }); + }, + [isChartReady, postMessage], + ); + + // ---- WebView message handling ---- + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + let raw; + try { + raw = JSON.parse(event.nativeEvent.data); + } catch { + return; + } + + const message = parseWebViewMessage(raw); + if (!message) return; + + switch (message.type) { + case 'CHART_READY': + activeIndicatorsRef.current.clear(); + prevPositionLinesRef.current = undefined; + prevChartTypeRef.current = undefined; + prevShowVolumeRef.current = showVolume; + setChartReadyCount((c) => c + 1); + setWebViewError(null); + onChartReady?.(); + break; + + case 'INDICATOR_ADDED': + activeIndicatorsRef.current.add(message.payload.name); + break; + + case 'INDICATOR_REMOVED': + activeIndicatorsRef.current.delete(message.payload.name); + break; + + case 'CROSSHAIR_MOVE': + onCrosshairMove?.(message.payload.data); + break; + + case 'NEED_MORE_HISTORY': + onRequestMoreHistory?.(message.payload); + break; + + case 'ERROR': + if (!isChartReady) { + setWebViewError(message.payload.message); + } + onError?.(message.payload.message); + break; + + case 'DEBUG': + break; + + default: + break; + } + }, + [ + isChartReady, + showVolume, + onChartReady, + onError, + onCrosshairMove, + onRequestMoreHistory, + ], + ); + + const handleWebViewError = useCallback( + (syntheticEvent: { nativeEvent: { description: string } }) => { + const { description } = syntheticEvent.nativeEvent; + setWebViewError(description); + onError?.(description); + }, + [onError], + ); + + const handleLoadEnd = useCallback(() => { + setWebViewLoaded(true); + }, []); + + // ---- Ref API ---- + + useImperativeHandle( + ref, + () => ({ + addIndicator, + removeIndicator, + setChartType: setChartTypeInternal, + reset: () => { + setChartReadyCount(0); + setWebViewLoaded(false); + setWebViewError(null); + activeIndicatorsRef.current.clear(); + prevPositionLinesRef.current = undefined; + prevChartTypeRef.current = undefined; + prevShowVolumeRef.current = showVolume; + webViewRef.current?.reload(); + }, + }), + [addIndicator, removeIndicator, setChartTypeInternal, showVolume], + ); + + // ---- Declarative prop syncing ---- + + useEffect(() => { + if (ohlcvData.length > 0 && webViewLoaded) { + sendOHLCVData(ohlcvData); + } + }, [ohlcvData, webViewLoaded, sendOHLCVData]); + + // Forward real-time bar updates to WebView + useEffect(() => { + if (!isChartReady || !realtimeBar) return; + postMessage({ + type: 'REALTIME_UPDATE', + payload: { bar: realtimeBar }, + }); + }, [realtimeBar, isChartReady, postMessage]); + + // Sync indicators prop (depends on chartReadyCount to re-fire on chart recreation) + useEffect(() => { + if (chartReadyCount === 0) return; + + const currentIndicators = new Set(indicators); + const active = activeIndicatorsRef.current; + + indicators.forEach((indicator) => { + if (!active.has(indicator)) { + addIndicator(indicator); + } + }); + + active.forEach((indicator) => { + if (!currentIndicators.has(indicator)) { + removeIndicator(indicator); + } + }); + }, [indicators, chartReadyCount, addIndicator, removeIndicator]); + + // Sync positionLines prop + useEffect(() => { + if (chartReadyCount === 0) return; + if (positionLines === prevPositionLinesRef.current) return; + prevPositionLinesRef.current = positionLines; + + postMessage({ + type: 'SET_POSITION_LINES', + payload: { position: positionLines ?? null }, + }); + }, [positionLines, chartReadyCount, postMessage]); + + // Sync chartType prop + useEffect(() => { + if (chartReadyCount === 0 || chartType === undefined) return; + if (chartType === prevChartTypeRef.current) return; + prevChartTypeRef.current = chartType; + setChartTypeInternal(chartType); + }, [chartType, chartReadyCount, setChartTypeInternal]); + + // Sync showVolume prop + useEffect(() => { + if (chartReadyCount === 0) return; + if (showVolume === prevShowVolumeRef.current) return; + prevShowVolumeRef.current = showVolume; + + postMessage({ + type: 'TOGGLE_VOLUME', + payload: { visible: showVolume }, + }); + }, [showVolume, chartReadyCount, postMessage]); + + // ---- Render ---- + + if (webViewError) { + return ( + + + Failed to load chart: {webViewError} + + + ); + } + + return ( + + + + {(isLoading || !isChartReady) && ( + + + + Loading chart... + + + )} + + ); + }, +); + +AdvancedChart.displayName = 'AdvancedChart'; + +export default AdvancedChart; diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts new file mode 100644 index 00000000000..8dbfc56bf47 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts @@ -0,0 +1,352 @@ +// ============================================ +// OHLCV data types +// ============================================ + +/** + * OHLCV bar data structure for TradingView Advanced Charts + */ +export interface OHLCVBar { + /** Unix timestamp in milliseconds */ + time: number; + /** Opening price */ + open: number; + /** Highest price */ + high: number; + /** Lowest price */ + low: number; + /** Closing price */ + close: number; + /** Trading volume */ + volume: number; +} + +/** + * Any TradingView study name is accepted. The three presets ('MACD', 'RSI', + * 'MA200') get built-in parameter defaults in chartLogic.js; all other strings + * are forwarded to `createStudy` as-is with optional `inputs` from the payload. + */ +export type IndicatorType = string; + +/** + * TradingView widget features disabled by default. + * + * These defaults are optimized for the Token Details mobile UX: a clean, + * minimal chart with no header chrome, search, or toolbars. Consumers + * needing TradingView's native UI (e.g. Perps with full indicator picker, + * timeframes toolbar, or symbol search) can pass a custom list via the + * `disabledFeatures` prop to opt back in selectively. + */ +export const DEFAULT_DISABLED_FEATURES: string[] = [ + 'use_localstorage_for_settings', + 'header_widget', + 'timeframes_toolbar', + 'edit_buttons_in_legend', + 'control_bar', + 'border_around_the_chart', + 'header_symbol_search', + 'header_settings', + 'header_compare', + 'header_undo_redo', + 'header_screenshot', + 'header_fullscreen_button', + 'legend_context_menu', + 'symbol_search_hot_key', + 'symbol_info', + 'legend_widget', + 'display_market_status', + 'scales_context_menu', + 'pane_context_menu', + 'create_volume_indicator_by_default', + 'main_series_scale_menu', + 'go_to_date', +]; + +/** + * Position side for long/short position shapes + */ +export type PositionSide = 'long' | 'short'; + +/** + * Position lines to render on the chart (Perps-style dashed horizontal lines). + * Only lines with defined values are rendered. + */ +export interface PositionLines { + side: PositionSide; + entryPrice: number; + currentPrice?: number; + takeProfitPrice?: number; + stopLossPrice?: number; + liquidationPrice?: number; +} + +/** + * Crosshair OHLC data forwarded from the WebView when the user + * scrubs over the chart. Mirrors the Perps OhlcData contract. + */ +export interface CrosshairData { + time: number; + open: number; + high: number; + low: number; + close: number; + volume?: number; +} + +/** + * Chart type constants matching TradingView SeriesType. + * Uses as-const object instead of enum to avoid numeric enum pitfalls + * (reverse lookups, runtime code, opaque values in bridge messages). + */ +export const ChartType = { + Candles: 1, + Line: 2, +} as const; + +export type ChartType = (typeof ChartType)[keyof typeof ChartType]; + +// ============================================ +// Message protocol: React Native <-> WebView +// ============================================ + +export type RNToWebViewMessageType = + | 'SET_OHLCV_DATA' + | 'ADD_INDICATOR' + | 'REMOVE_INDICATOR' + | 'SET_CHART_TYPE' + | 'SET_POSITION_LINES' + | 'REALTIME_UPDATE' + | 'TOGGLE_VOLUME'; + +export type WebViewToRNMessageType = + | 'CHART_READY' + | 'INDICATOR_ADDED' + | 'INDICATOR_REMOVED' + | 'CROSSHAIR_MOVE' + | 'NEED_MORE_HISTORY' + | 'ERROR' + | 'DEBUG'; + +export interface SetOHLCVDataPayload { + data: OHLCVBar[]; +} + +export interface AddIndicatorPayload { + name: IndicatorType; + /** Custom TradingView study inputs (e.g. { in_0: 14 }). Used for non-preset studies. */ + inputs?: Record; +} + +export interface RemoveIndicatorPayload { + name: IndicatorType; +} + +export interface SetChartTypePayload { + type: ChartType; +} + +export interface SetPositionLinesPayload { + position: PositionLines | null; +} + +export interface RealtimeUpdatePayload { + bar: OHLCVBar; +} + +export interface ToggleVolumePayload { + visible: boolean; +} + +export type RNToWebViewMessage = + | { type: 'SET_OHLCV_DATA'; payload: SetOHLCVDataPayload } + | { type: 'ADD_INDICATOR'; payload: AddIndicatorPayload } + | { type: 'REMOVE_INDICATOR'; payload: RemoveIndicatorPayload } + | { type: 'SET_CHART_TYPE'; payload: SetChartTypePayload } + | { type: 'SET_POSITION_LINES'; payload: SetPositionLinesPayload } + | { type: 'REALTIME_UPDATE'; payload: RealtimeUpdatePayload } + | { type: 'TOGGLE_VOLUME'; payload: ToggleVolumePayload }; + +export interface IndicatorAddedPayload { + name: IndicatorType; + id: string; +} + +export interface IndicatorRemovedPayload { + name: IndicatorType; +} + +export interface CrosshairMovePayload { + data: CrosshairData | null; +} + +export interface NeedMoreHistoryPayload { + oldestTimestamp: number; +} + +export interface ErrorPayload { + message: string; + code?: string; +} + +export type WebViewToRNMessage = + | { type: 'CHART_READY' } + | { type: 'INDICATOR_ADDED'; payload: IndicatorAddedPayload } + | { type: 'INDICATOR_REMOVED'; payload: IndicatorRemovedPayload } + | { type: 'CROSSHAIR_MOVE'; payload: CrosshairMovePayload } + | { type: 'NEED_MORE_HISTORY'; payload: NeedMoreHistoryPayload } + | { type: 'ERROR'; payload: ErrorPayload } + | { type: 'DEBUG' }; + +// ============================================ +// Message parsing / runtime narrowing +// ============================================ + +function isIndicatorType(value: unknown): value is IndicatorType { + return typeof value === 'string' && value.length > 0; +} + +/** + * Runtime narrower for messages arriving from the WebView over postMessage. + * Returns a typed WebViewToRNMessage if valid, or null for malformed data. + */ +export function parseWebViewMessage(raw: unknown): WebViewToRNMessage | null { + if (typeof raw !== 'object' || raw === null) return null; + + const { type, payload } = raw as { type: unknown; payload: unknown }; + if (typeof type !== 'string') return null; + + const obj = ( + typeof payload === 'object' && payload !== null ? payload : {} + ) as Record; + + switch (type) { + case 'CHART_READY': + case 'DEBUG': + return { type }; + + case 'NEED_MORE_HISTORY': + return { + type, + payload: { + oldestTimestamp: + typeof obj.oldestTimestamp === 'number' ? obj.oldestTimestamp : 0, + }, + }; + + case 'INDICATOR_ADDED': + if (isIndicatorType(obj.name) && typeof obj.id === 'string') { + return { type, payload: { name: obj.name, id: obj.id } }; + } + return null; + + case 'INDICATOR_REMOVED': + if (isIndicatorType(obj.name)) { + return { type, payload: { name: obj.name } }; + } + return null; + + case 'CROSSHAIR_MOVE': + return { + type, + payload: { + data: + typeof obj.data === 'object' && obj.data !== null + ? (obj.data as CrosshairData) + : null, + }, + }; + + case 'ERROR': + if (typeof obj.message === 'string') { + return { + type, + payload: { + message: obj.message, + ...(typeof obj.code === 'string' ? { code: obj.code } : {}), + }, + }; + } + return null; + + default: + return null; + } +} + +// ============================================ +// Component props and ref +// ============================================ + +/** + * Generic AdvancedChart component props. + * + * Composable API: each consumer uses only the props it needs. + * - Token Details: ohlcvData, indicators, chartType + * - Perps: ohlcvData, positionLines, onRequestMoreHistory, onRealtimeUpdate, onCrosshairMove + */ +export interface AdvancedChartProps { + /** OHLCV data to display (required) */ + ohlcvData: OHLCVBar[]; + /** Chart height in pixels */ + height?: number; + + /** Latest bar for real-time streaming (Perps). When this changes the WebView receives a tick. */ + realtimeBar?: OHLCVBar; + /** + * Called when the user scrolls to the left edge and more history is needed. + * Receives the oldest bar timestamp (ms) currently held by the chart so + * consumers can use it directly as the `endTime` for their next fetch, + * without having to independently track their own oldest candle. + */ + onRequestMoreHistory?: (params: { oldestTimestamp: number }) => void; + + /** Active indicators to display (Token Details). Synced declaratively via useEffect. */ + indicators?: IndicatorType[]; + /** Position lines to overlay (Perps). Set to undefined to clear. */ + positionLines?: PositionLines; + + /** Initial chart type */ + chartType?: ChartType; + /** Show volume bars below the chart */ + showVolume?: boolean; + /** Enable left-side drawing toolbar */ + enableDrawingTools?: boolean; + /** + * TradingView widget features to disable. Defaults to DEFAULT_DISABLED_FEATURES + * (a curated mobile-friendly set). Pass a custom array to re-enable native + * TradingView capabilities like header_widget, timeframes_toolbar, etc. + */ + disabledFeatures?: string[]; + + /** Callback when chart is ready */ + onChartReady?: () => void; + /** Callback when an error occurs */ + onError?: (error: string) => void; + /** Crosshair OHLC data callback (for overlay legend) */ + onCrosshairMove?: (data: CrosshairData | null) => void; + + /** External loading state */ + isLoading?: boolean; +} + +/** + * Imperative ref handle for AdvancedChart. + * Use props for declarative control; ref for one-off imperative actions. + */ +export interface AdvancedChartRef { + addIndicator: ( + indicator: IndicatorType, + inputs?: Record, + ) => void; + removeIndicator: (indicator: IndicatorType) => void; + setChartType: (chartType: ChartType) => void; + reset: () => void; +} + +/** + * Props for the IndicatorToggle component + */ +export interface IndicatorToggleProps { + activeIndicators: IndicatorType[]; + onToggle: (indicator: IndicatorType) => void; + disabled?: boolean; +} diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts new file mode 100644 index 00000000000..677b408797e --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts @@ -0,0 +1,136 @@ +import type { Theme } from '../../../../util/theme/models'; +import { chartLogicScript } from './webview'; + +/** + * CDN base URL for the TradingView charting library assets. + * + * Production: set MM_CHARTING_LIBRARY_URL to the CloudFront distribution URL + * (trailing slash required). Defaults to the S3 origin until the CloudFront + * distribution is delivered by DevOps. + * + * Local development: override MM_CHARTING_LIBRARY_URL with a local http-server + * URL (e.g. http://localhost:8000/) and run: + * npx http-server --cors -p 8000 + */ +export const CHARTING_LIBRARY_BASE_URL = + process.env.MM_CHARTING_LIBRARY_URL ?? ''; + +const CHARTING_LIBRARY_URL = `${CHARTING_LIBRARY_BASE_URL}charting_library/`; + +/** + * Scheme + host only (no path) for use in CSP frame-src. + * TradingView's iframe_loading_same_origin feature loads sameorigin.html from + * this origin, so frame-src must allow it explicitly. + * e.g. "https://va-mmcx-terminal.s3.us-east-2.amazonaws.com" + */ +const CHARTING_LIBRARY_ORIGIN = (() => { + try { + const { origin } = new URL(CHARTING_LIBRARY_BASE_URL); + return origin; + } catch { + return CHARTING_LIBRARY_BASE_URL; + } +})(); + +/** + * Strip the alpha channel from a hex color string. + * Design tokens may use 9-char hex (#RRGGBBAA); TradingView expects #RRGGBB. + */ +const stripHexAlpha = (hex: string): string => + hex.length === 9 && hex.startsWith('#') ? hex.slice(0, 7) : hex; + +interface ChartFeatures { + enableDrawingTools?: boolean; + showVolume?: boolean; + disabledFeatures?: string[]; +} + +const createConfigScript = ( + libraryUrl: string, + theme: Theme, + features: ChartFeatures, +): string => ` +window.CONFIG = { + libraryUrl: '${libraryUrl}', + theme: { + backgroundColor: '${theme.colors.background.default}', + borderColor: '${stripHexAlpha(theme.colors.border.muted)}', + textColor: '${theme.colors.text.alternative}', + successColor: '${theme.colors.success.default}', + errorColor: '${theme.colors.error.default}', + primaryColor: '${theme.colors.primary.default}' + }, + features: { + enableDrawingTools: ${features.enableDrawingTools ? 'true' : 'false'}, + showVolume: ${features.showVolume ? 'true' : 'false'}, + disabledFeatures: ${JSON.stringify(features.disabledFeatures ?? [])} + } +}; +`; + +/** + * Creates the HTML template for TradingView Advanced Charts. + * + * @param theme - MetaMask theme for styling + * @param features - Optional feature flags forwarded to the WebView + */ +export const createAdvancedChartTemplate = ( + theme: Theme, + features: ChartFeatures = {}, +): string => ` + + + + + TradingView Advanced Chart + + + + + +
Loading chart...
+
+ + + + + +`; + +export default createAdvancedChartTemplate; diff --git a/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx b/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx new file mode 100644 index 00000000000..ed58875970d --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import { + Box, + Text, + TextVariant, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../component-library/hooks'; +import { Theme } from '../../../../util/theme/models'; + +export type TimeRange = '1H' | '1D' | '1W' | '1M' | 'YTD' | 'ALL'; + +/** Valid Hyperliquid candle interval values */ +export type CandleInterval = '1m' | '15m' | '1h' | '4h' | '1d'; + +export interface TimeRangeConfig { + /** Hyperliquid candle interval */ + hlInterval: CandleInterval; + /** Number of candles to fetch */ + count: number; +} + +const ytdDays = () => { + const now = new Date(); + const startOfYear = Date.UTC(now.getFullYear(), 0, 1); + const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + return Math.round((today - startOfYear) / 86_400_000) + 1; +}; + +export const TIME_RANGE_CONFIGS: Record = { + '1H': { hlInterval: '1m', count: 60 }, + '1D': { hlInterval: '15m', count: 96 }, + '1W': { hlInterval: '1h', count: 168 }, + '1M': { hlInterval: '4h', count: 180 }, + YTD: { hlInterval: '1d', count: Math.min(ytdDays(), 500) }, + ALL: { hlInterval: '1d', count: 500 }, +}; + +const TIME_RANGES: TimeRange[] = ['1H', '1D', '1W', '1M', 'YTD', 'ALL']; + +interface TimeRangeSelectorProps { + selected: TimeRange; + onSelect: (range: TimeRange) => void; + /** Optional subset of ranges to display. Defaults to all. */ + ranges?: TimeRange[]; +} + +const selectorStyleSheet = (params: { theme: Theme }) => { + const { theme } = params; + return StyleSheet.create({ + button: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + buttonSelected: { + backgroundColor: theme.colors.background.muted, + }, + buttonPressed: { + opacity: 0.7, + }, + }); +}; + +const TimeRangeSelector: React.FC = ({ + selected, + onSelect, + ranges = TIME_RANGES, +}) => { + const { styles } = useStyles(selectorStyleSheet, {}); + + return ( + + {ranges.map((range) => { + const isSelected = selected === range; + return ( + [ + styles.button, + isSelected && styles.buttonSelected, + pressed && styles.buttonPressed, + ]} + onPress={() => onSelect(range)} + > + + {range} + + + ); + })} + + ); +}; + +export default TimeRangeSelector; diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx new file mode 100644 index 00000000000..382bf5b65a6 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx @@ -0,0 +1,437 @@ +import React from 'react'; +import { render, act } from '@testing-library/react-native'; +import AdvancedChart from '../AdvancedChart'; +import { + ChartType, + type OHLCVBar, + type AdvancedChartRef, + type PositionLines, +} from '../AdvancedChart.types'; + +const mockPostMessage = jest.fn(); + +jest.mock('@metamask/react-native-webview', () => { + const { View } = jest.requireActual('react-native'); + const { forwardRef, useImperativeHandle } = jest.requireActual('react'); + const MockWebView = forwardRef( + (props: Record, ref: React.Ref) => { + useImperativeHandle(ref, () => ({ + postMessage: mockPostMessage, + reload: jest.fn(), + })); + return ; + }, + ); + MockWebView.displayName = 'MockWebView'; + return { WebView: MockWebView }; +}); + +const MOCK_BARS: OHLCVBar[] = [ + { time: 1000000, open: 10, high: 12, low: 9, close: 11, volume: 100 }, + { time: 1000300, open: 11, high: 13, low: 10, close: 12, volume: 200 }, +]; + +describe('AdvancedChart', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { getByText } = render(); + expect(getByText('Loading chart...')).toBeOnTheScreen(); + }); + + it('shows loading overlay when isLoading is true', () => { + const { getByText } = render( + , + ); + expect(getByText('Loading chart...')).toBeOnTheScreen(); + }); + + it('sends OHLCV data on WebView load end', () => { + const { getByTestId } = render(); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onLoadEnd(); + }); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'SET_OHLCV_DATA', + payload: { data: MOCK_BARS }, + }), + ); + }); + + it('exposes addIndicator via ref', () => { + const ref = React.createRef(); + render(); + + expect(ref.current).toBeTruthy(); + expect(ref.current?.addIndicator).toBeInstanceOf(Function); + expect(ref.current?.removeIndicator).toBeInstanceOf(Function); + expect(ref.current?.setChartType).toBeInstanceOf(Function); + expect(ref.current?.reset).toBeInstanceOf(Function); + }); + + it('calls onChartReady when chart reports ready', () => { + const onChartReady = jest.fn(); + const { getByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + const onMessage = webView.props.onMessage; + + act(() => { + onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(onChartReady).toHaveBeenCalledTimes(1); + }); + + it('calls onError when chart reports an error', () => { + const onError = jest.fn(); + const { getByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'test error' }, + }), + }, + }); + }); + + expect(onError).toHaveBeenCalledWith('test error'); + }); + + it('does not destroy the chart for errors after CHART_READY', () => { + const onError = jest.fn(); + const { getByTestId, queryByText } = render( + , + ); + + const webView = getByTestId('mock-webview'); + + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'Failed to add indicator: timeout' }, + }), + }, + }); + }); + + expect(onError).toHaveBeenCalledWith('Failed to add indicator: timeout'); + expect(queryByText(/Failed to load chart/)).not.toBeOnTheScreen(); + expect(getByTestId('mock-webview')).toBeOnTheScreen(); + }); + + it('calls onCrosshairMove when crosshair data arrives', () => { + const onCrosshairMove = jest.fn(); + const { getByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + const crosshairData = { + time: 1000000, + open: 10, + high: 12, + low: 9, + close: 11, + volume: 100, + }; + + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'CROSSHAIR_MOVE', + payload: { data: crosshairData }, + }), + }, + }); + }); + + expect(onCrosshairMove).toHaveBeenCalledWith(crosshairData); + }); + + it('calls onRequestMoreHistory when WebView requests more data', () => { + const onRequestMoreHistory = jest.fn(); + const { getByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'NEED_MORE_HISTORY', + payload: { oldestTimestamp: 1000000 }, + }), + }, + }); + }); + + expect(onRequestMoreHistory).toHaveBeenCalledTimes(1); + }); + + it('sends SET_POSITION_LINES when positionLines prop changes', () => { + const position: PositionLines = { + side: 'long', + entryPrice: 1991.7, + liquidationPrice: 1357.83, + }; + + const { getByTestId, rerender } = render( + , + ); + + // Simulate chart ready + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + mockPostMessage.mockClear(); + + rerender(); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'SET_POSITION_LINES', + payload: { position }, + }), + ); + }); + + it('sends SET_POSITION_LINES with null when positionLines cleared', () => { + const position: PositionLines = { + side: 'long', + entryPrice: 1991.7, + }; + + const { getByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + mockPostMessage.mockClear(); + + rerender(); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'SET_POSITION_LINES', + payload: { position: null }, + }), + ); + }); + + it('sends REALTIME_UPDATE when realtimeBar changes', () => { + const { getByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + mockPostMessage.mockClear(); + + const newBar: OHLCVBar = { + time: 1000600, + open: 12, + high: 14, + low: 11, + close: 13, + volume: 300, + }; + + rerender(); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'REALTIME_UPDATE', + payload: { bar: newBar }, + }), + ); + }); + + it('sends SET_CHART_TYPE when chartType prop changes', () => { + const { getByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + mockPostMessage.mockClear(); + + rerender( + , + ); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'SET_CHART_TYPE', + payload: { type: ChartType.Line }, + }), + ); + }); + + it('resets chart state when htmlContent changes so sync effects re-fire', () => { + const onChartReady = jest.fn(); + const { getByTestId, getByText, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + + act(() => { + webView.props.onLoadEnd(); + }); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(onChartReady).toHaveBeenCalledTimes(1); + mockPostMessage.mockClear(); + + rerender( + , + ); + + expect(getByText('Loading chart...')).toBeOnTheScreen(); + + act(() => { + webView.props.onLoadEnd(); + }); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(onChartReady).toHaveBeenCalledTimes(2); + + const addIndicatorCall = mockPostMessage.mock.calls.find((call) => { + const parsed = JSON.parse(call[0] as string); + return parsed.type === 'ADD_INDICATOR' && parsed.payload.name === 'RSI'; + }); + expect(addIndicatorCall).toBeDefined(); + }); + + it('displays error screen for errors before CHART_READY', () => { + const { getByTestId, getByText } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'Load failed' }, + }), + }, + }); + }); + + expect(getByText(/Load failed/)).toBeOnTheScreen(); + }); + + it('recovers from error state when reset() is called via ref', () => { + const ref = React.createRef(); + const { getByTestId, getByText, queryByText } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'Load failed' }, + }), + }, + }); + }); + + expect(getByText(/Load failed/)).toBeOnTheScreen(); + + act(() => { + ref.current?.reset(); + }); + + expect(queryByText(/Load failed/)).not.toBeOnTheScreen(); + expect(getByText('Loading chart...')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx b/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx new file mode 100644 index 00000000000..21fa02ee4ba --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import TimeRangeSelector, { + TIME_RANGE_CONFIGS, + type TimeRange, +} from '../TimeRangeSelector'; + +describe('TimeRangeSelector', () => { + const defaultProps = { + selected: '1D' as TimeRange, + onSelect: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all time range buttons by default', () => { + const { getByText } = render(); + + expect(getByText('1H')).toBeOnTheScreen(); + expect(getByText('1D')).toBeOnTheScreen(); + expect(getByText('1W')).toBeOnTheScreen(); + expect(getByText('1M')).toBeOnTheScreen(); + expect(getByText('YTD')).toBeOnTheScreen(); + expect(getByText('ALL')).toBeOnTheScreen(); + }); + + it('renders only specified ranges when ranges prop is provided', () => { + const { getByText, queryByText } = render( + , + ); + + expect(getByText('1H')).toBeOnTheScreen(); + expect(getByText('1D')).toBeOnTheScreen(); + expect(getByText('1W')).toBeOnTheScreen(); + expect(queryByText('1M')).not.toBeOnTheScreen(); + expect(queryByText('YTD')).not.toBeOnTheScreen(); + expect(queryByText('ALL')).not.toBeOnTheScreen(); + }); + + it('calls onSelect with the tapped range', () => { + const onSelect = jest.fn(); + const { getByText } = render( + , + ); + + fireEvent.press(getByText('1W')); + + expect(onSelect).toHaveBeenCalledWith('1W'); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it('calls onSelect when tapping the already selected range', () => { + const onSelect = jest.fn(); + const { getByText } = render( + , + ); + + fireEvent.press(getByText('1D')); + + expect(onSelect).toHaveBeenCalledWith('1D'); + }); + + describe('TIME_RANGE_CONFIGS', () => { + it('has a config for every time range', () => { + const ranges: TimeRange[] = ['1H', '1D', '1W', '1M', 'YTD', 'ALL']; + + ranges.forEach((range) => { + expect(TIME_RANGE_CONFIGS[range]).toBeDefined(); + expect(TIME_RANGE_CONFIGS[range].hlInterval).toBeTruthy(); + expect(TIME_RANGE_CONFIGS[range].count).toBeGreaterThan(0); + }); + }); + + it('maps 1H to 1-minute candles', () => { + expect(TIME_RANGE_CONFIGS['1H'].hlInterval).toBe('1m'); + expect(TIME_RANGE_CONFIGS['1H'].count).toBe(60); + }); + + it('maps 1D to 15-minute candles', () => { + expect(TIME_RANGE_CONFIGS['1D'].hlInterval).toBe('15m'); + expect(TIME_RANGE_CONFIGS['1D'].count).toBe(96); + }); + + it('maps 1W to 1-hour candles', () => { + expect(TIME_RANGE_CONFIGS['1W'].hlInterval).toBe('1h'); + expect(TIME_RANGE_CONFIGS['1W'].count).toBe(168); + }); + + it('maps 1M to 4-hour candles', () => { + expect(TIME_RANGE_CONFIGS['1M'].hlInterval).toBe('4h'); + expect(TIME_RANGE_CONFIGS['1M'].count).toBe(180); + }); + + it('maps ALL to daily candles with 500 count', () => { + expect(TIME_RANGE_CONFIGS.ALL.hlInterval).toBe('1d'); + expect(TIME_RANGE_CONFIGS.ALL.count).toBe(500); + }); + + it('maps YTD to daily candles capped at 500', () => { + expect(TIME_RANGE_CONFIGS.YTD.hlInterval).toBe('1d'); + expect(TIME_RANGE_CONFIGS.YTD.count).toBeGreaterThan(0); + expect(TIME_RANGE_CONFIGS.YTD.count).toBeLessThanOrEqual(500); + }); + }); +}); diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js new file mode 100644 index 00000000000..27fd979b4e7 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js @@ -0,0 +1,875 @@ +/** + * TradingView Chart WebView Logic + * + * Generic charting logic for TradingView Advanced Charts. + * Embedded into the WebView HTML at runtime via chartLogicString.ts. + * + * CONFIG is injected before this script runs and contains: + * - libraryUrl: string + * - theme: { backgroundColor, borderColor, textColor, successColor, errorColor, primaryColor } + */ + +// ============================================ +// Global State +// ============================================ +window.chartWidget = null; +window.ohlcvData = []; +window.currentSymbol = 'ASSET'; +window.activeStudies = {}; +window.positionShapeIds = []; +window.isChartReady = false; +window.pendingMessages = []; +window.libraryLoaded = false; +window.libraryError = null; +window.realtimeCallbacks = {}; +window.pendingGetBarsCallback = null; + +// ============================================ +// Communication with React Native +// ============================================ +function sendToReactNative(type, payload) { + payload = payload || {}; + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage( + JSON.stringify({ type: type, payload: payload }), + ); + } +} + +// ============================================ +// Message Handler +// ============================================ +function handleMessage(event) { + try { + var message = + typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + + if (!window.isChartReady && message.type !== 'SET_OHLCV_DATA') { + window.pendingMessages.push(message); + return; + } + + switch (message.type) { + case 'SET_OHLCV_DATA': + handleSetOHLCVData(message.payload); + break; + case 'ADD_INDICATOR': + handleAddIndicator(message.payload); + break; + case 'REMOVE_INDICATOR': + handleRemoveIndicator(message.payload); + break; + case 'SET_CHART_TYPE': + handleSetChartType(message.payload); + break; + case 'SET_POSITION_LINES': + handleSetPositionLines(message.payload); + break; + case 'REALTIME_UPDATE': + handleRealtimeUpdate(message.payload); + break; + case 'TOGGLE_VOLUME': + handleToggleVolume(message.payload); + break; + } + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +window.addEventListener('message', handleMessage); +document.addEventListener('message', handleMessage); + +// ============================================ +// Data Handlers +// ============================================ +var INTERVAL_MS_TO_TV = { + 60000: '1', + 180000: '3', + 300000: '5', + 900000: '15', + 1800000: '30', + 3600000: '60', + 7200000: '120', + 14400000: '240', + 28800000: '480', + 43200000: '720', + 86400000: '1D', + 259200000: '3D', + 604800000: '1W', + 2592000000: '1M', +}; + +function detectResolution(data) { + if (data.length < 2) return '5'; + // Use median of first few diffs to avoid gaps skewing the result + var diffs = []; + var len = Math.min(data.length - 1, 10); + for (var i = 0; i < len; i++) { + diffs.push(data[i + 1].time - data[i].time); + } + diffs.sort(function (a, b) { + return a - b; + }); + var median = diffs[Math.floor(diffs.length / 2)]; + + // Find closest match + var keys = Object.keys(INTERVAL_MS_TO_TV); + var best = '5'; + var bestDist = Infinity; + for (var k = 0; k < keys.length; k++) { + var d = Math.abs(Number(keys[k]) - median); + if (d < bestDist) { + bestDist = d; + best = INTERVAL_MS_TO_TV[keys[k]]; + } + } + return best; +} + +function handleSetOHLCVData(payload) { + if (!payload || !payload.data || payload.data.length === 0) return; + + window.ohlcvData = payload.data; + + var newResolution = detectResolution(window.ohlcvData); + var hasPending = !!window.pendingGetBarsCallback; + + // TODO: Early return bypasses resolution-change handling at lines 146–170. + // If SET_OHLCV_DATA arrives at a different resolution while a history + // request is pending (if a user switches interval during a pending chart candle history navigation request), + // the widget stays at the old resolution while window.currentResolution + // reflects the new one. Fix when wiring up history pagination for Perps. + if (hasPending) { + var pending = window.pendingGetBarsCallback; + window.pendingGetBarsCallback = null; + window.currentResolution = newResolution; + resolvePendingGetBars(pending); + return; + } + + if (window.chartWidget && window.isChartReady) { + if (window.currentResolution !== newResolution) { + window.currentResolution = newResolution; + try { + window.chartWidget + .activeChart() + .setResolution(newResolution, function () {}); + } catch (e) { + window.chartWidget.remove(); + window.chartWidget = null; + window.isChartReady = false; + window.activeStudies = {}; + window.volumeStudyId = null; + window.positionShapeIds = []; + window.realtimeCallbacks = {}; + window.pendingGetBarsCallback = null; + initChart(); + } + } else { + try { + window.chartWidget.activeChart().resetData(); + } catch (e) { + // resetData can fail if chart is in a transitional state + } + } + } else if (window.chartWidget && !window.isChartReady) { + window.currentResolution = newResolution; + } else if (!window.chartWidget) { + window.currentResolution = newResolution; + libraryLoadAttempts = 0; + initChart(); + } +} + +// ============================================ +// Realtime Update Handler +// ============================================ +function handleRealtimeUpdate(payload) { + if (!payload || !payload.bar) return; + + var bar = payload.bar; + + // Append or update the last bar in the local data store + if (window.ohlcvData.length > 0) { + var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; + if (lastBar.time === bar.time) { + window.ohlcvData[window.ohlcvData.length - 1] = bar; + } else { + window.ohlcvData.push(bar); + } + } else { + window.ohlcvData.push(bar); + } + + // Forward to all active TradingView subscribeBars callbacks + var tick = { + time: bar.time, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }; + var guids = Object.keys(window.realtimeCallbacks); + for (var i = 0; i < guids.length; i++) { + window.realtimeCallbacks[guids[i]](tick); + } +} + +// ============================================ +// Indicator Handlers +// +// Curated subset for Token Details mobile UX. Consumers needing the full +// TradingView study picker can re-enable header_widget via disabledFeatures +// prop, which exposes TradingView's native indicator UI. +// ============================================ +function handleAddIndicator(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload || !payload.name) return; + + var indicatorName = payload.name; + + if (window.activeStudies[indicatorName]) { + return; + } + + try { + var chart = window.chartWidget.activeChart(); + var studyName, inputs; + + switch (indicatorName) { + case 'MACD': + studyName = 'MACD'; + inputs = { in_0: 12, in_1: 26, in_2: 9 }; + break; + case 'RSI': + studyName = 'Relative Strength Index'; + inputs = { in_0: 14 }; + break; + case 'MA200': + studyName = 'Moving Average'; + inputs = { in_0: 200 }; + break; + default: + studyName = indicatorName; + inputs = payload.inputs || {}; + break; + } + + chart + .createStudy(studyName, false, false, inputs) + .then(function (studyId) { + window.activeStudies[indicatorName] = studyId; + sendToReactNative('INDICATOR_ADDED', { + name: indicatorName, + id: String(studyId), + }); + }) + .catch(function (error) { + sendToReactNative('ERROR', { + message: 'Failed to add indicator: ' + error.message, + }); + }); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +function handleRemoveIndicator(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload || !payload.name) return; + + var indicatorName = payload.name; + var studyId = window.activeStudies[indicatorName]; + + if (!studyId) return; + + try { + var chart = window.chartWidget.activeChart(); + chart.removeEntity(studyId); + delete window.activeStudies[indicatorName]; + sendToReactNative('INDICATOR_REMOVED', { name: indicatorName }); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +// ============================================ +// Chart Type Handler +// ============================================ +function handleSetChartType(payload) { + if (!window.chartWidget || !window.isChartReady) return; + + try { + var chart = window.chartWidget.activeChart(); + chart.setChartType(payload.type); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +// ============================================ +// Position Lines (unified SET_POSITION_LINES) +// ============================================ + +function clearPositionLines() { + if (!window.chartWidget || !window.isChartReady) return; + + try { + var chart = window.chartWidget.activeChart(); + for (var i = 0; i < window.positionShapeIds.length; i++) { + try { + chart.removeEntity(window.positionShapeIds[i]); + } catch (e) { + // Shape may already be removed + } + } + window.positionShapeIds = []; + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to clear position lines: ' + error.message, + }); + } +} + +function handleSetPositionLines(payload) { + if (!window.chartWidget || !window.isChartReady) return; + + // Clear existing lines first + clearPositionLines(); + + // null or missing position means "clear only" + if (!payload || !payload.position) return; + + var position = payload.position; + var theme = window.CONFIG.theme; + + try { + var chart = window.chartWidget.activeChart(); + var lines = []; + + if (position.entryPrice) { + lines.push({ + price: position.entryPrice, + text: 'Entry', + color: '#858585', + lineStyle: 2, + }); + } + if (position.takeProfitPrice) { + lines.push({ + price: position.takeProfitPrice, + text: 'TP', + color: theme.successColor, + lineStyle: 2, + }); + } + if (position.stopLossPrice) { + lines.push({ + price: position.stopLossPrice, + text: 'SL', + color: '#858585', + lineStyle: 2, + }); + } + if (position.liquidationPrice) { + lines.push({ + price: position.liquidationPrice, + text: 'Liq', + color: theme.errorColor, + lineStyle: 2, + }); + } + // TODO: currentPrice is defined in PositionLines but not yet rendered here. + // Add a line for position.currentPrice (e.g. a solid line showing live mark + // price) when the Perps integration is ready. + + for (var i = 0; i < lines.length; i++) { + (function (line) { + chart + .createShape( + { price: line.price }, + { + shape: 'horizontal_line', + lock: true, + disableSelection: true, + disableSave: true, + disableUndo: true, + text: line.text, + overrides: { + linecolor: line.color, + linestyle: line.lineStyle, + linewidth: 1, + showLabel: true, + textcolor: line.color, + fontsize: 11, + horzLabelsAlign: 'right', + showPrice: true, + }, + }, + ) + .then(function (entityId) { + if (entityId) { + window.positionShapeIds.push(entityId); + } + }) + .catch(function () { + // Shape creation can fail silently + }); + })(lines[i]); + } + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to add position lines: ' + error.message, + }); + } +} + +// ============================================ +// Volume Helpers +// ============================================ +window.volumeStudyId = null; + +function createVolumeStudy() { + if (!window.chartWidget || !window.isChartReady) return; + if (window.volumeStudyId) return; + + try { + window.chartWidget + .activeChart() + .createStudy('Volume', false, false, {}, { 'volume ma.visible': false }) + .then(function (studyId) { + window.volumeStudyId = studyId; + }) + .catch(function () { + // Volume study creation failed + }); + } catch (e) { + // Not critical + } +} + +function handleToggleVolume(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload) return; + + if (payload.visible && !window.volumeStudyId) { + createVolumeStudy(); + } else if (!payload.visible && window.volumeStudyId) { + try { + window.chartWidget.activeChart().removeEntity(window.volumeStudyId); + } catch (e) { + // Already removed + } + window.volumeStudyId = null; + } +} + +// ============================================ +// Custom Datafeed Implementation +// ============================================ + +/** + * TradingView variable_tick_size string. + * + * Tells TradingView to dynamically adjust pricescale/minmov based on + * the current price level. Format: "tickSize threshold tickSize threshold …" + * where each tickSize applies for prices below the next threshold, and + * the last tickSize applies to all prices above the last threshold. + * + * This replaces a manual pricescale computation and adapts automatically + * as prices change (e.g. meme token pumps from $0.0001 to $1). + */ +var VARIABLE_TICK_SIZE = [ + '0.0000000001', + '0.000001', // prices < $0.000001 → 10 dp + '0.00000001', + '0.0001', // prices < $0.0001 → 8 dp + '0.000001', + '0.01', // prices < $0.01 → 6 dp + '0.0001', + '1', // prices < $1 → 4 dp + '0.01', + '10000', // prices < $10000 → 2 dp + '0.1', // prices ≥ $10000 → 1 dp +].join(' '); + +function filterBarsForRange(fromMs, toMs, countBack) { + var barsInRange = []; + for (var i = 0; i < window.ohlcvData.length; i++) { + var b = window.ohlcvData[i]; + if (b.time >= fromMs && b.time < toMs) { + barsInRange.push({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + }); + } + } + + if (barsInRange.length < countBack) { + var allBeforeTo = []; + for (var j = 0; j < window.ohlcvData.length; j++) { + if (window.ohlcvData[j].time < toMs) { + allBeforeTo.push(window.ohlcvData[j]); + } + } + var startIdx = Math.max(0, allBeforeTo.length - countBack); + barsInRange = []; + for (var k = startIdx; k < allBeforeTo.length; k++) { + var bar = allBeforeTo[k]; + barsInRange.push({ + time: bar.time, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }); + } + } + + return barsInRange; +} + +function resolvePendingGetBars(pending) { + var currentOldest = + window.ohlcvData.length > 0 ? window.ohlcvData[0].time : 0; + + if (currentOldest >= pending.oldestAtDefer) { + pending.onResult([], { noData: true }); + return; + } + + // Return only the newly fetched bars (older than what we had before deferring). + // TradingView already has bars from oldestAtDefer onward. + var bars = []; + for (var i = 0; i < window.ohlcvData.length; i++) { + var b = window.ohlcvData[i]; + if (b.time < pending.oldestAtDefer) { + bars.push({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + }); + } + } + + pending.onResult(bars, { noData: false }); +} + +var customDatafeed = { + onReady: function (callback) { + setTimeout(function () { + callback({ + supported_resolutions: [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '480', + '720', + '1D', + '3D', + '1W', + '1M', + ], + supports_marks: false, + supports_timescale_marks: false, + supports_time: true, + }); + }, 0); + }, + + searchSymbols: function (userInput, exchange, symbolType, onResult) { + onResult([]); + }, + + resolveSymbol: function (symbolName, onResolve) { + setTimeout(function () { + onResolve({ + name: symbolName, + ticker: symbolName, + description: symbolName, + type: 'crypto', + session: '24x7', + timezone: 'Etc/UTC', + exchange: '', + minmov: 1, + pricescale: 100, + variable_tick_size: VARIABLE_TICK_SIZE, + has_intraday: true, + has_daily: true, + has_weekly_and_monthly: true, + supported_resolutions: [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '480', + '720', + '1D', + '3D', + '1W', + '1M', + ], + volume_precision: 0, + data_status: 'streaming', + }); + }, 0); + }, + + getBars: function (symbolInfo, resolution, periodParams, onResult, onError) { + try { + var fromMs = periodParams.from * 1000; + var toMs = periodParams.to * 1000; + var countBack = periodParams.countBack; + var firstRequest = periodParams.firstDataRequest; + + var bars = filterBarsForRange(fromMs, toMs, countBack); + + if (bars.length > 0) { + onResult(bars, { noData: false }); + return; + } + + if (firstRequest || window.ohlcvData.length === 0) { + onResult([], { noData: true }); + return; + } + + var oldestTs = window.ohlcvData[0].time; + + window.pendingGetBarsCallback = { + onResult: onResult, + oldestAtDefer: oldestTs, + }; + + sendToReactNative('NEED_MORE_HISTORY', { oldestTimestamp: oldestTs }); + } catch (error) { + onError(error.message); + } + }, + + subscribeBars: function (symbolInfo, resolution, onTick, listenerGuid) { + window.realtimeCallbacks[listenerGuid] = onTick; + }, + + unsubscribeBars: function (listenerGuid) { + delete window.realtimeCallbacks[listenerGuid]; + }, +}; + +// ============================================ +// Library Loading +// ============================================ +var libraryLoadAttempts = 0; +var maxLibraryLoadAttempts = 50; + +function loadLibrary() { + var scriptUrl = window.CONFIG.libraryUrl + 'charting_library.js'; + + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = scriptUrl; + script.onload = function () { + window.libraryLoaded = true; + if (window.ohlcvData.length > 0) { + initChart(); + } + }; + script.onerror = function () { + window.libraryError = + 'Failed to load TradingView library. URL: ' + scriptUrl; + document.getElementById('loading-overlay').innerHTML = + '
' + + '

Failed to load chart library

' + + '

URL: ' + + scriptUrl + + '

' + + '

Check S3 access or CORS configuration.

' + + '
'; + sendToReactNative('ERROR', { message: window.libraryError }); + }; + document.head.appendChild(script); +} + +// ============================================ +// Chart Initialization +// ============================================ +function initChart() { + if (window.chartWidget) return; + + if (typeof TradingView === 'undefined') { + libraryLoadAttempts++; + if (libraryLoadAttempts >= maxLibraryLoadAttempts) { + var errorMsg = + 'TradingView library failed to initialize after ' + + maxLibraryLoadAttempts * 100 + + 'ms'; + document.getElementById('loading-overlay').textContent = errorMsg; + sendToReactNative('ERROR', { message: errorMsg }); + return; + } + setTimeout(initChart, 100); + return; + } + + if (window.ohlcvData.length === 0) { + return; + } + + try { + var theme = window.CONFIG.theme; + var features = window.CONFIG.features || {}; + + // Disabled features are passed from React Native via CONFIG.features.disabledFeatures. + // Defaults are set in DEFAULT_DISABLED_FEATURES (AdvancedChart.types.ts) and are + // optimized for the Token Details mobile UX. Consumers needing TradingView's + // native UI (e.g. Perps) can override via the disabledFeatures prop. + var disabledFeatures = (features.disabledFeatures || []).slice(); + + if (!features.enableDrawingTools) { + disabledFeatures.push('left_toolbar'); + disabledFeatures.push('context_menus'); + } + + window.chartWidget = new TradingView.widget({ + symbol: window.currentSymbol, + interval: window.currentResolution || '5', + container: 'tv_chart_container', + datafeed: customDatafeed, + library_path: window.CONFIG.libraryUrl, + locale: 'en', + fullscreen: false, + autosize: true, + theme: 'Dark', + + disabled_features: disabledFeatures, + enabled_features: ['study_templates', 'iframe_loading_same_origin'], + + overrides: { + 'paneProperties.background': theme.backgroundColor, + 'paneProperties.backgroundType': 'solid', + 'paneProperties.vertGridProperties.color': theme.borderColor, + 'paneProperties.horzGridProperties.color': theme.borderColor, + 'scalesProperties.textColor': theme.textColor, + 'scalesProperties.lineColor': theme.borderColor, + 'scalesProperties.fontSize': 11, + 'scalesProperties.showStudyLastValue': true, + 'scalesProperties.showSeriesLastValue': true, + 'scalesProperties.showSymbolLabels': true, + 'scalesProperties.showRightScale': true, + 'scalesProperties.showLeftScale': false, + 'paneProperties.bottomMargin': 5, + 'mainSeriesProperties.candleStyle.upColor': theme.successColor, + 'mainSeriesProperties.candleStyle.downColor': theme.errorColor, + 'mainSeriesProperties.candleStyle.borderUpColor': theme.successColor, + 'mainSeriesProperties.candleStyle.borderDownColor': theme.errorColor, + 'mainSeriesProperties.candleStyle.wickUpColor': theme.successColor, + 'mainSeriesProperties.candleStyle.wickDownColor': theme.errorColor, + }, + + loading_screen: { + backgroundColor: theme.backgroundColor, + foregroundColor: theme.primaryColor, + }, + }); + + window.chartWidget.onChartReady(function () { + window.isChartReady = true; + document.getElementById('loading-overlay').classList.add('hidden'); + + try { + var timeScale = window.chartWidget.activeChart().getTimeScale(); + timeScale.defaultRightOffset().setValue(0); + timeScale.setRightOffset(0); + } catch (e) {} + + sendToReactNative('CHART_READY', {}); + + // Set up crosshair move listener for OHLC overlay + try { + window.chartWidget + .activeChart() + .crossHairMoved() + .subscribe(null, function (params) { + if ( + params && + params.price !== undefined && + params.time !== undefined + ) { + // Find the bar closest to the crosshair time + var targetTime = params.time * 1000; + var closestBar = null; + var minDiff = Infinity; + for (var i = 0; i < window.ohlcvData.length; i++) { + var diff = Math.abs(window.ohlcvData[i].time - targetTime); + if (diff < minDiff) { + minDiff = diff; + closestBar = window.ohlcvData[i]; + } + } + if (closestBar) { + sendToReactNative('CROSSHAIR_MOVE', { + data: { + time: closestBar.time, + open: closestBar.open, + high: closestBar.high, + low: closestBar.low, + close: closestBar.close, + volume: closestBar.volume, + }, + }); + } + } else { + sendToReactNative('CROSSHAIR_MOVE', { data: null }); + } + }); + } catch (e) { + // Crosshair subscription not critical + } + + // Auto-add volume study if showVolume is true (no SMA overlay) + if (features.showVolume) { + createVolumeStudy(); + } + + // Process pending messages + window.pendingMessages.forEach(function (msg) { + handleMessage({ data: msg }); + }); + window.pendingMessages = []; + }); + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to initialize chart: ' + error.message, + }); + } +} + +// ============================================ +// Start +// ============================================ +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + loadLibrary(); + }); +} else { + loadLibrary(); +} diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts new file mode 100644 index 00000000000..7017c990069 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts @@ -0,0 +1,880 @@ +/** + * AUTO-GENERATED - DO NOT EDIT DIRECTLY + * + * This file is generated from chartLogic.js by syncChartLogic.js + * Edit chartLogic.js instead, then run: + * node app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js + */ + +// eslint-disable-next-line import/no-default-export +export default `/** + * TradingView Chart WebView Logic + * + * Generic charting logic for TradingView Advanced Charts. + * Embedded into the WebView HTML at runtime via chartLogicString.ts. + * + * CONFIG is injected before this script runs and contains: + * - libraryUrl: string + * - theme: { backgroundColor, borderColor, textColor, successColor, errorColor, primaryColor } + */ + +// ============================================ +// Global State +// ============================================ +window.chartWidget = null; +window.ohlcvData = []; +window.currentSymbol = 'ASSET'; +window.activeStudies = {}; +window.positionShapeIds = []; +window.isChartReady = false; +window.pendingMessages = []; +window.libraryLoaded = false; +window.libraryError = null; +window.realtimeCallbacks = {}; +window.pendingGetBarsCallback = null; + +// ============================================ +// Communication with React Native +// ============================================ +function sendToReactNative(type, payload) { + payload = payload || {}; + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage( + JSON.stringify({ type: type, payload: payload }), + ); + } +} + +// ============================================ +// Message Handler +// ============================================ +function handleMessage(event) { + try { + var message = + typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + + if (!window.isChartReady && message.type !== 'SET_OHLCV_DATA') { + window.pendingMessages.push(message); + return; + } + + switch (message.type) { + case 'SET_OHLCV_DATA': + handleSetOHLCVData(message.payload); + break; + case 'ADD_INDICATOR': + handleAddIndicator(message.payload); + break; + case 'REMOVE_INDICATOR': + handleRemoveIndicator(message.payload); + break; + case 'SET_CHART_TYPE': + handleSetChartType(message.payload); + break; + case 'SET_POSITION_LINES': + handleSetPositionLines(message.payload); + break; + case 'REALTIME_UPDATE': + handleRealtimeUpdate(message.payload); + break; + case 'TOGGLE_VOLUME': + handleToggleVolume(message.payload); + break; + } + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +window.addEventListener('message', handleMessage); +document.addEventListener('message', handleMessage); + +// ============================================ +// Data Handlers +// ============================================ +var INTERVAL_MS_TO_TV = { + 60000: '1', + 180000: '3', + 300000: '5', + 900000: '15', + 1800000: '30', + 3600000: '60', + 7200000: '120', + 14400000: '240', + 28800000: '480', + 43200000: '720', + 86400000: '1D', + 259200000: '3D', + 604800000: '1W', + 2592000000: '1M', +}; + +function detectResolution(data) { + if (data.length < 2) return '5'; + // Use median of first few diffs to avoid gaps skewing the result + var diffs = []; + var len = Math.min(data.length - 1, 10); + for (var i = 0; i < len; i++) { + diffs.push(data[i + 1].time - data[i].time); + } + diffs.sort(function (a, b) { + return a - b; + }); + var median = diffs[Math.floor(diffs.length / 2)]; + + // Find closest match + var keys = Object.keys(INTERVAL_MS_TO_TV); + var best = '5'; + var bestDist = Infinity; + for (var k = 0; k < keys.length; k++) { + var d = Math.abs(Number(keys[k]) - median); + if (d < bestDist) { + bestDist = d; + best = INTERVAL_MS_TO_TV[keys[k]]; + } + } + return best; +} + +function handleSetOHLCVData(payload) { + if (!payload || !payload.data || payload.data.length === 0) return; + + window.ohlcvData = payload.data; + + var newResolution = detectResolution(window.ohlcvData); + var hasPending = !!window.pendingGetBarsCallback; + + if (hasPending) { + var pending = window.pendingGetBarsCallback; + window.pendingGetBarsCallback = null; + window.currentResolution = newResolution; + resolvePendingGetBars(pending); + return; + } + + if (window.chartWidget && window.isChartReady) { + if (window.currentResolution !== newResolution) { + window.currentResolution = newResolution; + try { + window.chartWidget + .activeChart() + .setResolution(newResolution, function () {}); + } catch (e) { + window.chartWidget.remove(); + window.chartWidget = null; + window.isChartReady = false; + window.activeStudies = {}; + window.volumeStudyId = null; + window.positionShapeIds = []; + window.realtimeCallbacks = {}; + window.pendingGetBarsCallback = null; + initChart(); + } + } else { + try { + window.chartWidget.activeChart().resetData(); + } catch (e) { + // resetData can fail if chart is in a transitional state + } + } + } else if (window.chartWidget && !window.isChartReady) { + window.currentResolution = newResolution; + } else if (!window.chartWidget) { + window.currentResolution = newResolution; + libraryLoadAttempts = 0; + initChart(); + } +} + +// ============================================ +// Realtime Update Handler +// ============================================ +function handleRealtimeUpdate(payload) { + if (!payload || !payload.bar) return; + + var bar = payload.bar; + + // Append or update the last bar in the local data store + if (window.ohlcvData.length > 0) { + var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; + if (lastBar.time === bar.time) { + window.ohlcvData[window.ohlcvData.length - 1] = bar; + } else { + window.ohlcvData.push(bar); + } + } else { + window.ohlcvData.push(bar); + } + + // Forward to all active TradingView subscribeBars callbacks + var tick = { + time: bar.time, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }; + var guids = Object.keys(window.realtimeCallbacks); + for (var i = 0; i < guids.length; i++) { + window.realtimeCallbacks[guids[i]](tick); + } +} + +// ============================================ +// Indicator Handlers +// +// Curated subset for Token Details mobile UX. Consumers needing the full +// TradingView study picker can re-enable header_widget via disabledFeatures +// prop, which exposes TradingView's native indicator UI. +// ============================================ +function handleAddIndicator(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload || !payload.name) return; + + var indicatorName = payload.name; + + if (window.activeStudies[indicatorName]) { + return; + } + + try { + var chart = window.chartWidget.activeChart(); + var studyName, inputs; + + switch (indicatorName) { + case 'MACD': + studyName = 'MACD'; + inputs = { in_0: 12, in_1: 26, in_2: 9 }; + break; + case 'RSI': + studyName = 'Relative Strength Index'; + inputs = { in_0: 14 }; + break; + case 'MA200': + studyName = 'Moving Average'; + inputs = { in_0: 200 }; + break; + default: + studyName = indicatorName; + inputs = payload.inputs || {}; + break; + } + + chart + .createStudy(studyName, false, false, inputs) + .then(function (studyId) { + window.activeStudies[indicatorName] = studyId; + sendToReactNative('INDICATOR_ADDED', { + name: indicatorName, + id: String(studyId), + }); + }) + .catch(function (error) { + sendToReactNative('ERROR', { + message: 'Failed to add indicator: ' + error.message, + }); + }); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +function handleRemoveIndicator(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload || !payload.name) return; + + var indicatorName = payload.name; + var studyId = window.activeStudies[indicatorName]; + + if (!studyId) return; + + try { + var chart = window.chartWidget.activeChart(); + chart.removeEntity(studyId); + delete window.activeStudies[indicatorName]; + sendToReactNative('INDICATOR_REMOVED', { name: indicatorName }); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +// ============================================ +// Chart Type Handler +// ============================================ +function handleSetChartType(payload) { + if (!window.chartWidget || !window.isChartReady) return; + + try { + var chart = window.chartWidget.activeChart(); + chart.setChartType(payload.type); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +// ============================================ +// Position Lines (unified SET_POSITION_LINES) +// ============================================ + +function clearPositionLines() { + if (!window.chartWidget || !window.isChartReady) return; + + try { + var chart = window.chartWidget.activeChart(); + for (var i = 0; i < window.positionShapeIds.length; i++) { + try { + chart.removeEntity(window.positionShapeIds[i]); + } catch (e) { + // Shape may already be removed + } + } + window.positionShapeIds = []; + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to clear position lines: ' + error.message, + }); + } +} + +function handleSetPositionLines(payload) { + if (!window.chartWidget || !window.isChartReady) return; + + // Clear existing lines first + clearPositionLines(); + + // null or missing position means "clear only" + if (!payload || !payload.position) return; + + var position = payload.position; + var theme = window.CONFIG.theme; + + try { + var chart = window.chartWidget.activeChart(); + var lines = []; + + if (position.entryPrice) { + lines.push({ + price: position.entryPrice, + text: 'Entry', + color: '#858585', + lineStyle: 2, + }); + } + if (position.takeProfitPrice) { + lines.push({ + price: position.takeProfitPrice, + text: 'TP', + color: theme.successColor, + lineStyle: 2, + }); + } + if (position.stopLossPrice) { + lines.push({ + price: position.stopLossPrice, + text: 'SL', + color: '#858585', + lineStyle: 2, + }); + } + if (position.liquidationPrice) { + lines.push({ + price: position.liquidationPrice, + text: 'Liq', + color: theme.errorColor, + lineStyle: 2, + }); + } + // TODO: currentPrice is defined in PositionLines but not yet rendered here. + // Add a line for position.currentPrice (e.g. a solid line showing live mark + // price) when the Perps integration is ready. + + for (var i = 0; i < lines.length; i++) { + (function (line) { + chart + .createShape( + { price: line.price }, + { + shape: 'horizontal_line', + lock: true, + disableSelection: true, + disableSave: true, + disableUndo: true, + text: line.text, + overrides: { + linecolor: line.color, + linestyle: line.lineStyle, + linewidth: 1, + showLabel: true, + textcolor: line.color, + fontsize: 11, + horzLabelsAlign: 'right', + showPrice: true, + }, + }, + ) + .then(function (entityId) { + if (entityId) { + window.positionShapeIds.push(entityId); + } + }) + .catch(function () { + // Shape creation can fail silently + }); + })(lines[i]); + } + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to add position lines: ' + error.message, + }); + } +} + +// ============================================ +// Volume Helpers +// ============================================ +window.volumeStudyId = null; + +function createVolumeStudy() { + if (!window.chartWidget || !window.isChartReady) return; + if (window.volumeStudyId) return; + + try { + window.chartWidget + .activeChart() + .createStudy('Volume', false, false, {}, { 'volume ma.visible': false }) + .then(function (studyId) { + window.volumeStudyId = studyId; + }) + .catch(function () { + // Volume study creation failed + }); + } catch (e) { + // Not critical + } +} + +function handleToggleVolume(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload) return; + + if (payload.visible && !window.volumeStudyId) { + createVolumeStudy(); + } else if (!payload.visible && window.volumeStudyId) { + try { + window.chartWidget.activeChart().removeEntity(window.volumeStudyId); + } catch (e) { + // Already removed + } + window.volumeStudyId = null; + } +} + +// ============================================ +// Custom Datafeed Implementation +// ============================================ + +/** + * TradingView variable_tick_size string. + * + * Tells TradingView to dynamically adjust pricescale/minmov based on + * the current price level. Format: "tickSize threshold tickSize threshold …" + * where each tickSize applies for prices below the next threshold, and + * the last tickSize applies to all prices above the last threshold. + * + * This replaces a manual pricescale computation and adapts automatically + * as prices change (e.g. meme token pumps from $0.0001 to $1). + */ +var VARIABLE_TICK_SIZE = [ + '0.0000000001', + '0.000001', // prices < $0.000001 → 10 dp + '0.00000001', + '0.0001', // prices < $0.0001 → 8 dp + '0.000001', + '0.01', // prices < $0.01 → 6 dp + '0.0001', + '1', // prices < $1 → 4 dp + '0.01', + '10000', // prices < $10000 → 2 dp + '0.1', // prices ≥ $10000 → 1 dp +].join(' '); + +function filterBarsForRange(fromMs, toMs, countBack) { + var barsInRange = []; + for (var i = 0; i < window.ohlcvData.length; i++) { + var b = window.ohlcvData[i]; + if (b.time >= fromMs && b.time < toMs) { + barsInRange.push({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + }); + } + } + + if (barsInRange.length < countBack) { + var allBeforeTo = []; + for (var j = 0; j < window.ohlcvData.length; j++) { + if (window.ohlcvData[j].time < toMs) { + allBeforeTo.push(window.ohlcvData[j]); + } + } + var startIdx = Math.max(0, allBeforeTo.length - countBack); + barsInRange = []; + for (var k = startIdx; k < allBeforeTo.length; k++) { + var bar = allBeforeTo[k]; + barsInRange.push({ + time: bar.time, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }); + } + } + + return barsInRange; +} + +function resolvePendingGetBars(pending) { + var currentOldest = + window.ohlcvData.length > 0 ? window.ohlcvData[0].time : 0; + + if (currentOldest >= pending.oldestAtDefer) { + pending.onResult([], { noData: true }); + return; + } + + // Return only the newly fetched bars (older than what we had before deferring). + // TradingView already has bars from oldestAtDefer onward. + var bars = []; + for (var i = 0; i < window.ohlcvData.length; i++) { + var b = window.ohlcvData[i]; + if (b.time < pending.oldestAtDefer) { + bars.push({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + }); + } + } + + pending.onResult(bars, { noData: false }); +} + +var customDatafeed = { + onReady: function (callback) { + setTimeout(function () { + callback({ + supported_resolutions: [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '480', + '720', + '1D', + '3D', + '1W', + '1M', + ], + supports_marks: false, + supports_timescale_marks: false, + supports_time: true, + }); + }, 0); + }, + + searchSymbols: function (userInput, exchange, symbolType, onResult) { + onResult([]); + }, + + resolveSymbol: function (symbolName, onResolve) { + setTimeout(function () { + onResolve({ + name: symbolName, + ticker: symbolName, + description: symbolName, + type: 'crypto', + session: '24x7', + timezone: 'Etc/UTC', + exchange: '', + minmov: 1, + pricescale: 100, + variable_tick_size: VARIABLE_TICK_SIZE, + has_intraday: true, + has_daily: true, + has_weekly_and_monthly: true, + supported_resolutions: [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '480', + '720', + '1D', + '3D', + '1W', + '1M', + ], + volume_precision: 0, + data_status: 'streaming', + }); + }, 0); + }, + + getBars: function (symbolInfo, resolution, periodParams, onResult, onError) { + try { + var fromMs = periodParams.from * 1000; + var toMs = periodParams.to * 1000; + var countBack = periodParams.countBack; + var firstRequest = periodParams.firstDataRequest; + + var bars = filterBarsForRange(fromMs, toMs, countBack); + + if (bars.length > 0) { + onResult(bars, { noData: false }); + return; + } + + if (firstRequest || window.ohlcvData.length === 0) { + onResult([], { noData: true }); + return; + } + + var oldestTs = window.ohlcvData[0].time; + + window.pendingGetBarsCallback = { + onResult: onResult, + oldestAtDefer: oldestTs, + }; + + sendToReactNative('NEED_MORE_HISTORY', { oldestTimestamp: oldestTs }); + } catch (error) { + onError(error.message); + } + }, + + subscribeBars: function (symbolInfo, resolution, onTick, listenerGuid) { + window.realtimeCallbacks[listenerGuid] = onTick; + }, + + unsubscribeBars: function (listenerGuid) { + delete window.realtimeCallbacks[listenerGuid]; + }, +}; + +// ============================================ +// Library Loading +// ============================================ +var libraryLoadAttempts = 0; +var maxLibraryLoadAttempts = 50; + +function loadLibrary() { + var scriptUrl = window.CONFIG.libraryUrl + 'charting_library.js'; + + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = scriptUrl; + script.onload = function () { + window.libraryLoaded = true; + if (window.ohlcvData.length > 0) { + initChart(); + } + }; + script.onerror = function () { + window.libraryError = + 'Failed to load TradingView library. URL: ' + scriptUrl; + document.getElementById('loading-overlay').innerHTML = + '
' + + '

Failed to load chart library

' + + '

URL: ' + + scriptUrl + + '

' + + '

Check S3 access or CORS configuration.

' + + '
'; + sendToReactNative('ERROR', { message: window.libraryError }); + }; + document.head.appendChild(script); +} + +// ============================================ +// Chart Initialization +// ============================================ +function initChart() { + if (window.chartWidget) return; + + if (typeof TradingView === 'undefined') { + libraryLoadAttempts++; + if (libraryLoadAttempts >= maxLibraryLoadAttempts) { + var errorMsg = + 'TradingView library failed to initialize after ' + + maxLibraryLoadAttempts * 100 + + 'ms'; + document.getElementById('loading-overlay').textContent = errorMsg; + sendToReactNative('ERROR', { message: errorMsg }); + return; + } + setTimeout(initChart, 100); + return; + } + + if (window.ohlcvData.length === 0) { + return; + } + + try { + var theme = window.CONFIG.theme; + var features = window.CONFIG.features || {}; + + // Disabled features are passed from React Native via CONFIG.features.disabledFeatures. + // Defaults are set in DEFAULT_DISABLED_FEATURES (AdvancedChart.types.ts) and are + // optimized for the Token Details mobile UX. Consumers needing TradingView's + // native UI (e.g. Perps) can override via the disabledFeatures prop. + var disabledFeatures = (features.disabledFeatures || []).slice(); + + if (!features.enableDrawingTools) { + disabledFeatures.push('left_toolbar'); + disabledFeatures.push('context_menus'); + } + + window.chartWidget = new TradingView.widget({ + symbol: window.currentSymbol, + interval: window.currentResolution || '5', + container: 'tv_chart_container', + datafeed: customDatafeed, + library_path: window.CONFIG.libraryUrl, + locale: 'en', + fullscreen: false, + autosize: true, + theme: 'Dark', + + disabled_features: disabledFeatures, + enabled_features: ['study_templates', 'iframe_loading_same_origin'], + + overrides: { + 'paneProperties.background': theme.backgroundColor, + 'paneProperties.backgroundType': 'solid', + 'paneProperties.vertGridProperties.color': theme.borderColor, + 'paneProperties.horzGridProperties.color': theme.borderColor, + 'scalesProperties.textColor': theme.textColor, + 'scalesProperties.lineColor': theme.borderColor, + 'scalesProperties.fontSize': 11, + 'scalesProperties.showStudyLastValue': true, + 'scalesProperties.showSeriesLastValue': true, + 'scalesProperties.showSymbolLabels': true, + 'scalesProperties.showRightScale': true, + 'scalesProperties.showLeftScale': false, + 'paneProperties.bottomMargin': 5, + 'mainSeriesProperties.candleStyle.upColor': theme.successColor, + 'mainSeriesProperties.candleStyle.downColor': theme.errorColor, + 'mainSeriesProperties.candleStyle.borderUpColor': theme.successColor, + 'mainSeriesProperties.candleStyle.borderDownColor': theme.errorColor, + 'mainSeriesProperties.candleStyle.wickUpColor': theme.successColor, + 'mainSeriesProperties.candleStyle.wickDownColor': theme.errorColor, + }, + + loading_screen: { + backgroundColor: theme.backgroundColor, + foregroundColor: theme.primaryColor, + }, + }); + + window.chartWidget.onChartReady(function () { + window.isChartReady = true; + document.getElementById('loading-overlay').classList.add('hidden'); + + try { + var timeScale = window.chartWidget.activeChart().getTimeScale(); + timeScale.defaultRightOffset().setValue(0); + timeScale.setRightOffset(0); + } catch (e) {} + + sendToReactNative('CHART_READY', {}); + + // Set up crosshair move listener for OHLC overlay + try { + window.chartWidget + .activeChart() + .crossHairMoved() + .subscribe(null, function (params) { + if ( + params && + params.price !== undefined && + params.time !== undefined + ) { + // Find the bar closest to the crosshair time + var targetTime = params.time * 1000; + var closestBar = null; + var minDiff = Infinity; + for (var i = 0; i < window.ohlcvData.length; i++) { + var diff = Math.abs(window.ohlcvData[i].time - targetTime); + if (diff < minDiff) { + minDiff = diff; + closestBar = window.ohlcvData[i]; + } + } + if (closestBar) { + sendToReactNative('CROSSHAIR_MOVE', { + data: { + time: closestBar.time, + open: closestBar.open, + high: closestBar.high, + low: closestBar.low, + close: closestBar.close, + volume: closestBar.volume, + }, + }); + } + } else { + sendToReactNative('CROSSHAIR_MOVE', { data: null }); + } + }); + } catch (e) { + // Crosshair subscription not critical + } + + // Auto-add volume study if showVolume is true (no SMA overlay) + if (features.showVolume) { + createVolumeStudy(); + } + + // Process pending messages + window.pendingMessages.forEach(function (msg) { + handleMessage({ data: msg }); + }); + window.pendingMessages = []; + }); + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to initialize chart: ' + error.message, + }); + } +} + +// ============================================ +// Start +// ============================================ +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + loadLibrary(); + }); +} else { + loadLibrary(); +} +`; diff --git a/app/components/UI/Charts/AdvancedChart/webview/index.ts b/app/components/UI/Charts/AdvancedChart/webview/index.ts new file mode 100644 index 00000000000..4bc83b6113b --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/webview/index.ts @@ -0,0 +1 @@ +export { default as chartLogicScript } from './chartLogicString'; diff --git a/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js b/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js new file mode 100644 index 00000000000..78ff071c1b8 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/* eslint-disable import/no-commonjs, import/no-nodejs-modules, no-console */ +/** + * Sync script that reads chartLogic.js and exports it as a string in chartLogicString.ts + * + * Run this after editing chartLogic.js: + * node app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js + */ + +const fs = require('fs'); +const path = require('path'); + +const sourceFile = path.join(__dirname, 'chartLogic.js'); +const targetFile = path.join(__dirname, 'chartLogicString.ts'); + +const jsContent = fs.readFileSync(sourceFile, 'utf8'); + +const tsContent = `/** + * AUTO-GENERATED - DO NOT EDIT DIRECTLY + * + * This file is generated from chartLogic.js by syncChartLogic.js + * Edit chartLogic.js instead, then run: + * node app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js + */ + +// eslint-disable-next-line import/no-default-export +export default \`${jsContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\${/g, '\\${')}\`; +`; + +fs.writeFileSync(targetFile, tsContent); +console.log('✓ Synced chartLogic.js → chartLogicString.ts'); diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index 4f3ae7a04d9..b867b137520 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -519,7 +519,7 @@ const MarketInsightsView: React.FC = () => { variant={TextVariant.BodySm} color={TextColor.TextAlternative} > - {strings('market_insights.fixed_footer_disclaimer')} + {strings('market_insights.footer_disclaimer')} diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx index 459531d0214..c2147dec37d 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx @@ -127,7 +127,7 @@ const MarketInsightsEntryCard: React.FC = ({ variant={TextVariant.BodySm} color={TextColor.TextAlternative} > - {strings('market_insights.disclaimer')} + {strings('market_insights.footer_disclaimer')} { priceValid: jest.fn(), createApiKey: jest.fn(), submitClobOrder: jest.fn(), + refreshBalanceAllowance: jest.fn(), getMarketPositions: jest.fn(), getBalance: jest.fn(), previewOrder: jest.fn(), @@ -212,6 +214,7 @@ const mockParsePolymarketPositions = parsePolymarketPositions as jest.Mock; const mockPriceValid = priceValid as jest.Mock; const mockCreateApiKey = createApiKey as jest.Mock; const mockSubmitClobOrder = submitClobOrder as jest.Mock; +const mockRefreshBalanceAllowance = refreshBalanceAllowance as jest.Mock; const mockEncodeClaim = encodeClaim as jest.Mock; const mockComputeProxyAddress = computeProxyAddress as jest.Mock; const mockCreatePermit2FeeAuthorization = @@ -1603,6 +1606,97 @@ describe('PolymarketProvider', () => { }); }); + describe('placeOrder balance/allowance refresh workaround', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRefreshBalanceAllowance.mockResolvedValue(undefined); + }); + + it('calls refreshBalanceAllowance with COLLATERAL before submitting a BUY order', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ side: Side.BUY }); + + // Act + await provider.placeOrder({ signer: mockSigner, preview }); + + // Assert + expect(mockRefreshBalanceAllowance).toHaveBeenCalledWith({ + address: mockSigner.address, + apiKey: expect.objectContaining({ apiKey: 'test-api-key' }), + side: Side.BUY, + outcomeTokenId: preview.outcomeTokenId, + }); + }); + + it('calls refreshBalanceAllowance with CONDITIONAL before submitting a SELL order', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ side: Side.SELL }); + + // Act + await provider.placeOrder({ signer: mockSigner, preview }); + + // Assert + expect(mockRefreshBalanceAllowance).toHaveBeenCalledWith({ + address: mockSigner.address, + apiKey: expect.objectContaining({ apiKey: 'test-api-key' }), + side: Side.SELL, + outcomeTokenId: preview.outcomeTokenId, + }); + }); + + it('calls refreshBalanceAllowance before submitClobOrder', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + const callOrder: string[] = []; + mockRefreshBalanceAllowance.mockImplementation(async () => { + callOrder.push('refresh'); + }); + mockSubmitClobOrder.mockImplementation(async () => { + callOrder.push('submit'); + return { + success: true, + response: { + success: true, + makingAmount: '1000000', + orderID: 'order-123', + status: 'success', + takingAmount: '0', + transactionsHashes: [], + }, + error: undefined, + }; + }); + const preview = createMockOrderPreview({ side: Side.BUY }); + + // Act + await provider.placeOrder({ signer: mockSigner, preview }); + + // Assert + expect(callOrder).toEqual(['refresh', 'submit']); + }); + + it('proceeds with order submission when refreshBalanceAllowance fails', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + mockRefreshBalanceAllowance.mockRejectedValue( + new Error('Network timeout'), + ); + const preview = createMockOrderPreview({ side: Side.BUY }); + + // Act + const result = await provider.placeOrder({ + signer: mockSigner, + preview, + }); + + // Assert - order still submitted despite refresh failure + expect(mockSubmitClobOrder).toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + }); + describe('placeOrder with Safe fee authorization', () => { it('computes Safe address before creating order', async () => { const { provider, mockSigner } = setupPlaceOrderTest(); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 3a88fbb1a8f..fb1b02de3d3 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -102,6 +102,7 @@ import { parsePolymarketEvents, parsePolymarketPositions, previewOrder, + refreshBalanceAllowance, roundOrderAmount, submitClobOrder, } from './utils'; @@ -1306,6 +1307,23 @@ export class PolymarketProvider implements PredictProvider { apiKey: signerApiKey, }); + // TEMPORARY WORKAROUND: Refresh balance/allowance on Polymarket's CLOB + // before submitting the order. See refreshBalanceAllowance docs for details. + try { + await refreshBalanceAllowance({ + address: signer.address, + apiKey: signerApiKey, + side, + outcomeTokenId, + }); + } catch (refreshError) { + // Best-effort — don't block order submission if the refresh fails + DevLogger.log( + 'PolymarketProvider: Pre-order balance/allowance refresh failed', + refreshError, + ); + } + const { success, response, error } = await submitClobOrder({ headers, clobOrder, diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index cc79f15b55c..caf72518f44 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -60,6 +60,7 @@ import { parsePolymarketPositions, parsePolymarketActivity, priceValid, + refreshBalanceAllowance, submitClobOrder, decimalPlaces, roundNormal, @@ -701,6 +702,100 @@ describe('polymarket utils', () => { }); }); + describe('refreshBalanceAllowance', () => { + beforeEach(() => { + mockFetch.mockReset(); + (global.crypto as any).createHmac.mockReturnValue({ + update: jest.fn().mockReturnThis(), + digest: jest.fn().mockReturnValue('mock-digest-base64'), + }); + }); + + it('sends COLLATERAL asset_type for BUY orders', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.BUY, + outcomeTokenId: 'token-123', + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/balance-allowance/update?'), + expect.objectContaining({ method: 'GET' }), + ); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('asset_type=COLLATERAL'); + expect(calledUrl).toContain('signature_type=2'); + expect(calledUrl).not.toContain('token_id='); + }); + + it('sends CONDITIONAL asset_type with token_id for SELL orders', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.SELL, + outcomeTokenId: 'token-456', + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('asset_type=CONDITIONAL'); + expect(calledUrl).toContain('token_id=token-456'); + expect(calledUrl).toContain('signature_type=2'); + }); + + it('calls CLOB_ENDPOINT with L2 auth headers', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.BUY, + outcomeTokenId: 'token-123', + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toMatch(/^https:\/\/clob\.polymarket\.com/); + const calledOptions = mockFetch.mock.calls[0][1] as RequestInit; + expect(calledOptions.headers).toEqual( + expect.objectContaining({ + POLY_ADDRESS: mockAddress, + }), + ); + }); + + it('does not throw when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + await expect( + refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.BUY, + outcomeTokenId: 'token-123', + }), + ).resolves.toBeUndefined(); + }); + + it('uses custom signatureType when provided', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.BUY, + outcomeTokenId: 'token-123', + signatureType: SignatureType.EOA, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('signature_type=0'); + }); + }); + describe('submitClobOrder', () => { const mockHeaders: ClobHeaders = { POLY_ADDRESS: mockAddress, diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 2d95c877648..1c6dcc71c87 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -61,6 +61,7 @@ import { PolymarketApiMarket, PolymarketApiTeam, PolymarketPosition, + SignatureType, TickSize, OrderBook, } from './types'; @@ -187,6 +188,76 @@ export const getL2Headers = async ({ return headers; }; +/** + * TEMPORARY WORKAROUND for Polymarket infrastructure issue. + * + * Polymarket's CLOB infrastructure intermittently returns 400 errors with + * "not enough balance / allowance" when placing orders at high request rates. + * Calling this endpoint before each order refreshes the balance/allowance state + * on their end and prevents most of these spurious failures. + * + * For BUY orders: refreshes COLLATERAL (USDC) balance/allowance. + * For SELL orders: refreshes CONDITIONAL token balance/allowance. + * + * TODO: Remove this workaround once Polymarket resolves the underlying + * infrastructure issue. Track removal in a follow-up ticket. + */ +export const refreshBalanceAllowance = async ({ + address, + apiKey, + side, + outcomeTokenId, + signatureType = SignatureType.POLY_GNOSIS_SAFE, +}: { + address: string; + apiKey: ApiKeyCreds; + side: Side; + outcomeTokenId: string; + signatureType?: SignatureType; +}): Promise => { + const { CLOB_ENDPOINT } = getPolymarketEndpoints(); + + const queryParams = new URLSearchParams({ + signature_type: String(signatureType), + }); + + if (side === Side.BUY) { + queryParams.set('asset_type', 'COLLATERAL'); + } else { + queryParams.set('asset_type', 'CONDITIONAL'); + queryParams.set('token_id', outcomeTokenId); + } + + const requestPath = `/balance-allowance/update`; + + const headers = await getL2Headers({ + l2HeaderArgs: { + method: 'GET', + requestPath, + }, + address, + apiKey, + }); + + const response = await fetch( + `${CLOB_ENDPOINT}${requestPath}?${queryParams.toString()}`, + { + method: 'GET', + headers, + }, + ); + + if (!response.ok) { + DevLogger.log( + 'refreshBalanceAllowance: Pre-order balance/allowance refresh failed', + { + status: response.status, + side, + }, + ); + } +}; + export const deriveApiKey = async ({ address }: { address: string }) => { const { CLOB_ENDPOINT } = getPolymarketEndpoints(); const headers = await getL1Headers({ address }); diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx index 889d1d627bf..3758215fb12 100644 --- a/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx +++ b/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, waitFor, act } from '@testing-library/react-native'; +import { AppState, AppStateStatus, Linking } from 'react-native'; import AnimatedQRScannerModal from './AnimatedQRScanner'; import { QrScanRequestType } from '@metamask/eth-qr-keyring'; import { URRegistryDecoder } from '@keystonehq/ur-decoder'; @@ -601,25 +602,91 @@ describe('AnimatedQRScannerModal - Metrics', () => { }); describe('Camera Permission Error', () => { - it('calls onScanError only after requestPermission resolves with denial', async () => { + it('re-requests camera permission when app returns to foreground', async () => { const mockUseCameraPermission = jest.requireMock( 'react-native-vision-camera', ).useCameraPermission; - const mockRequestPermission = jest.fn().mockResolvedValue(false); mockUseCameraPermission.mockReturnValue({ hasPermission: false, requestPermission: mockRequestPermission, }); + let appStateChangeHandler: + | ((nextAppState: AppStateStatus) => void) + | null = null; + const addEventListenerSpy = jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((eventType, listener) => { + if (eventType === 'change') { + appStateChangeHandler = listener; + } + return { remove: jest.fn() }; + }); + render(); + await waitFor(() => { + expect(mockRequestPermission).toHaveBeenCalledTimes(1); + }); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + expect(appStateChangeHandler).not.toBeNull(); + + act(() => { + appStateChangeHandler?.('background'); + }); + + await waitFor(() => { + expect(mockRequestPermission).toHaveBeenCalledTimes(1); + }); + + act(() => { + appStateChangeHandler?.('active'); + }); + + await waitFor(() => { + expect(mockRequestPermission).toHaveBeenCalledTimes(2); + }); + + addEventListenerSpy.mockRestore(); + }); + + it('keeps modal open with settings button when permission is denied', async () => { + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + const openSettingsSpy = jest + .spyOn(Linking, 'openSettings') + .mockResolvedValue(); + + const mockRequestPermission = jest.fn().mockResolvedValue(false); + mockUseCameraPermission.mockReturnValue({ + hasPermission: false, + requestPermission: mockRequestPermission, + }); + + const { getByTestId, getByText } = render( + , + ); + await waitFor(() => { expect(mockRequestPermission).toHaveBeenCalled(); - expect(mockOnScanError).toHaveBeenCalledWith( - 'transaction.no_camera_permission', - ); }); + + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(getByText('transaction.no_camera_permission')).toBeOnTheScreen(); + expect(getByTestId('open-settings-button')).toBeOnTheScreen(); + + await act(async () => { + getByTestId('open-settings-button').props.onPress(); + }); + expect(openSettingsSpy).toHaveBeenCalledTimes(1); + + openSettingsSpy.mockRestore(); }); it('does not call onScanError when requestPermission is granted', async () => { @@ -662,7 +729,7 @@ describe('AnimatedQRScannerModal - Metrics', () => { expect(mockOnScanError).not.toHaveBeenCalled(); }); - it('does not call onScanError when modal is not visible', async () => { + it('does not request permission when modal is not visible', async () => { const mockUseCameraPermission = jest.requireMock( 'react-native-vision-camera', ).useCameraPermission; @@ -681,7 +748,6 @@ describe('AnimatedQRScannerModal - Metrics', () => { expect(mockOnScanError).not.toHaveBeenCalled(); }); - // requestPermission should not have been called since modal is not visible expect(mockRequestPermission).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.tsx index ecbe2309621..a049512eb1a 100644 --- a/app/components/UI/QRHardware/AnimatedQRScanner.tsx +++ b/app/components/UI/QRHardware/AnimatedQRScanner.tsx @@ -2,8 +2,23 @@ /* eslint @typescript-eslint/no-require-imports: "off" */ 'use strict'; -import React, { useCallback, useState, useEffect, useMemo } from 'react'; -import { Image, Text, TouchableOpacity, View, StyleSheet } from 'react-native'; +import React, { + useCallback, + useState, + useEffect, + useMemo, + useRef, +} from 'react'; +import { + AppState, + AppStateStatus, + Image, + Linking, + Text, + TouchableOpacity, + View, + StyleSheet, +} from 'react-native'; import { Camera, useCameraDevice, @@ -110,6 +125,21 @@ const createStyles = (theme: Theme) => flex: 1, justifyContent: 'center', alignItems: 'center', + paddingHorizontal: 24, + }, + openSettingsButton: { + marginTop: 24, + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 40, + borderWidth: 1, + borderColor: theme.brandColors.white, + }, + openSettingsText: { + color: theme.brandColors.white, + fontSize: 16, + textAlign: 'center', + ...fontStyles.normal, }, }); @@ -142,6 +172,7 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { const cameraDevice = useCameraDevice('back'); const { hasPermission, requestPermission } = useCameraPermission(); + const appState = useRef(AppState.currentState); let expectedURTypes: string[]; if (purpose === QrScanRequestType.PAIR) { @@ -153,19 +184,42 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { expectedURTypes = [SUPPORTED_UR_TYPE.ETH_SIGNATURE]; } + const refreshCameraPermission = useCallback(() => { + if (!visible || hasPermission) { + return; + } + + requestPermission(); + }, [hasPermission, requestPermission, visible]); + useEffect(() => { - let cancelled = false; - if (!hasPermission && visible) { - requestPermission().then((granted) => { - if (!cancelled && !granted) { - onScanError(strings('transaction.no_camera_permission')); - } - }); + refreshCameraPermission(); + }, [refreshCameraPermission]); + + useEffect(() => { + if (!visible) { + return undefined; } + + const subscription = AppState.addEventListener( + 'change', + (nextAppState: AppStateStatus) => { + const hasReturnedToForeground = + /inactive|background/.test(appState.current) && + nextAppState === 'active'; + + appState.current = nextAppState; + + if (hasReturnedToForeground) { + refreshCameraPermission(); + } + }, + ); + return () => { - cancelled = true; + subscription?.remove?.(); }; - }, [hasPermission, requestPermission, visible, onScanError]); + }, [refreshCameraPermission, visible]); const reset = useCallback(() => { setURDecoder(new URRegistryDecoder()); @@ -336,6 +390,15 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { {strings('transaction.no_camera_permission')} + Linking.openSettings()} + testID="open-settings-button" + > + + {strings('qr_scanner.open_settings')} + +
)} diff --git a/app/components/UI/QRHardware/QRSigningDetails.tsx b/app/components/UI/QRHardware/QRSigningDetails.tsx index b38a92d91f3..894fea8e442 100644 --- a/app/components/UI/QRHardware/QRSigningDetails.tsx +++ b/app/components/UI/QRHardware/QRSigningDetails.tsx @@ -1,16 +1,6 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react'; import Engine from '../../../core/Engine'; -import { - StyleSheet, - Text, - View, - ScrollView, - // eslint-disable-next-line react-native/split-platform-components - PermissionsAndroid, - Linking, - AppState, - AppStateStatus, -} from 'react-native'; +import { StyleSheet, Text, View, ScrollView } from 'react-native'; import { strings } from '../../../../locales/i18n'; import AnimatedQRCode from './AnimatedQRCode'; import AnimatedQRScannerModal from './AnimatedQRScanner'; @@ -25,7 +15,6 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import { useNavigation } from '@react-navigation/native'; import { useTheme } from '../../../util/theme'; -import Device from '../../../util/device'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { QrScanRequest, QrScanRequestType } from '@metamask/eth-qr-keyring'; @@ -39,7 +28,6 @@ interface IQRSigningDetails { tighten?: boolean; showHint?: boolean; shouldStartAnimated?: boolean; - bypassAndroidCameraAccessCheck?: boolean; fromAddress: string; } @@ -120,7 +108,6 @@ const QRSigningDetails = ({ tighten = false, showHint = true, shouldStartAnimated = true, - bypassAndroidCameraAccessCheck = true, fromAddress, }: IQRSigningDetails) => { const { colors } = useTheme(); @@ -130,50 +117,6 @@ const QRSigningDetails = ({ const [scannerVisible, setScannerVisible] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [shouldPause, setShouldPause] = useState(false); - const [cameraError, setCameraError] = useState(''); - - // ios handled camera perfectly in this situation, we just need to check permission with android. - const [hasCameraPermission, setCameraPermission] = useState( - Device.isIos() || bypassAndroidCameraAccessCheck, - ); - - const checkAndroidCamera = useCallback(() => { - if (Device.isAndroid() && !hasCameraPermission) { - PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA).then( - (_hasPermission) => { - setCameraPermission(_hasPermission); - if (!_hasPermission) { - setCameraError(strings('transaction.no_camera_permission_android')); - } else { - setCameraError(''); - } - }, - ); - } - }, [hasCameraPermission]); - - const handleAppState = useCallback( - (appState: AppStateStatus) => { - if (appState === 'active') { - checkAndroidCamera(); - } - }, - [checkAndroidCamera], - ); - - useEffect(() => { - checkAndroidCamera(); - }, [checkAndroidCamera]); - - useEffect(() => { - const appStateListener = AppState.addEventListener( - 'change', - handleAppState, - ); - return () => { - appStateListener.remove(); - }; - }, [handleAppState]); const [hasSentOrCanceled, setSentOrCanceled] = useState(false); @@ -269,23 +212,12 @@ const QRSigningDetails = ({ ); - const renderCameraAlert = () => - cameraError !== '' && ( - - {cameraError} - - ); - return ( {pendingScanRequest?.request && ( {renderAlert()} - {renderCameraAlert()} diff --git a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx index 22ae09f8820..44a1af937e3 100644 --- a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx +++ b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx @@ -232,9 +232,6 @@ describe('QRSigningTransactionModal', () => { expect(qrSigningDetailsElement.props.tighten).toBe(true); expect(qrSigningDetailsElement.props.showHint).toBe(true); expect(qrSigningDetailsElement.props.shouldStartAnimated).toBe(true); - expect(qrSigningDetailsElement.props.bypassAndroidCameraAccessCheck).toBe( - false, - ); expect(qrSigningDetailsElement.props.fromAddress).toBe( mockSelectedAccount.address, ); diff --git a/app/components/UI/QRHardware/QRSigningTransactionModal.tsx b/app/components/UI/QRHardware/QRSigningTransactionModal.tsx index 41260f46286..0d9dc6c28ae 100644 --- a/app/components/UI/QRHardware/QRSigningTransactionModal.tsx +++ b/app/components/UI/QRHardware/QRSigningTransactionModal.tsx @@ -110,7 +110,6 @@ const QRSigningTransactionModal = () => { }} cancelCallback={onRejection} failureCallback={onRejection} - bypassAndroidCameraAccessCheck={false} fromAddress={selectedAccount?.address ?? ''} /> )} diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.ts index 0d7d12d1c3c..d49e4b2bf65 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.ts @@ -176,10 +176,11 @@ export const useTokenActions = ({ location: ActionLocation.ASSET_DETAILS, }); - const accountForChain = - isNonEvmToken && token.chainId - ? getAccountByScope(token.chainId as CaipChainId) - : selectedInternalAccount; + const accountForChain = token.chainId + ? (getAccountByScope( + formatChainIdToCaip(token.chainId as Hex) as CaipChainId, + ) ?? selectedInternalAccount) + : selectedInternalAccount; const addressForChain = accountForChain?.address; diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx index cd041afcae1..5b425fb8303 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx @@ -353,7 +353,7 @@ describe('PerpsSection', () => { expect(roeElements.length).toBeGreaterThanOrEqual(2); }); - it('navigates to perps home on title press', () => { + it('navigates to perps home on title press with home_section source', () => { renderWithProvider(); fireEvent.press(screen.getByText('Perpetuals')); @@ -715,7 +715,7 @@ describe('PerpsSection', () => { expect(screen.getByText('View more')).toBeOnTheScreen(); }); - it('navigates to perps home when "View more" card is pressed', () => { + it('navigates to perps home with home_screen source when "View more" card is pressed', () => { usePerpsMarkets.mockReturnValue({ markets: [ makeTrendingMarket({ symbol: 'BTC', volumeNumber: 5000000000 }), diff --git a/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx b/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx index 70e2e01f412..1fdf9d5b125 100644 --- a/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx +++ b/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx @@ -87,8 +87,8 @@ describe('QRHardwareContext', () => { .mockReturnValue(mockedValues); }; - it('should pass correct value of needsCameraPermission to child components', () => { - createCameraSpy({ cameraError: undefined, hasCameraPermission: false }); + it('does not disable confirm button for camera permission since scanner handles it', () => { + createCameraSpy({ cameraError: undefined, hasCameraPermission: true }); createQRHardwareAwarenessSpy({ isSigningQRObject: true, pendingScanRequest: mockPendingScanRequest, @@ -103,7 +103,7 @@ describe('QRHardwareContext', () => { ); expect( getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON).props.disabled, - ).toBe(true); + ).toBe(false); }); it('does not invoke rejectPendingScan when request is cancelled id QR signing is not in progress', async () => { diff --git a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.test.ts b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.test.ts index 2073286c1bc..8f605c83bc3 100644 --- a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.test.ts +++ b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.test.ts @@ -1,32 +1,17 @@ import { renderHook } from '@testing-library/react-native'; -import Device from '../../../../../util/device'; import { useCamera } from './useCamera'; -jest.mock('../../../../../util/device', () => ({ - isIos: () => false, - isAndroid: () => true, -})); - describe('useCamera', () => { - it('returns correct initial values if parameter isSigningQRObject is false', () => { + it('always returns hasCameraPermission as true', () => { const { result } = renderHook(() => useCamera(false)); expect(result.current).toMatchObject({ cameraError: undefined, - hasCameraPermission: false, - }); - }); - - it('returns correct initial values if parameter isSigningQRObject is true', () => { - const { result } = renderHook(() => useCamera(true)); - expect(result.current).toMatchObject({ - cameraError: undefined, - hasCameraPermission: false, + hasCameraPermission: true, }); }); - it('returns correct initial values if device is IOS', () => { - jest.spyOn(Device, 'isIos').mockReturnValue(true); + it('always returns hasCameraPermission as true when signing QR object', () => { const { result } = renderHook(() => useCamera(true)); expect(result.current).toMatchObject({ cameraError: undefined, diff --git a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts index 5f367b3a452..6f33c0b6d8e 100644 --- a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts +++ b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts @@ -1,103 +1,17 @@ -/* eslint-disable react-native/split-platform-components */ -import { useState, useCallback, useEffect } from 'react'; -import { PermissionsAndroid, AppStateStatus, AppState } from 'react-native'; - -import { strings } from '../../../../../../locales/i18n'; -import Device from '../../../../../util/device'; -import { MetaMetricsEvents } from '../../../../../core/Analytics'; -import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; -import { HardwareDeviceTypes } from '../../../../../constants/keyringTypes'; -import { - PERMISSION_RESULT, - PERMISSION_TYPE, -} from '../../../../../core/Analytics/MetaMetrics.events'; - -export const useCamera = (isSigningQRObject: boolean) => { - const { trackEvent, createEventBuilder } = useAnalytics(); - // todo: integrate with alert system - const [cameraError, setCameraError] = useState(); - - // ios handled camera perfectly in this situation, we just need to check permission with android. - const [hasCameraPermission, setCameraPermission] = useState(Device.isIos()); - - const checkAndroidCamera = useCallback(() => { - if (Device.isAndroid() && !hasCameraPermission) { - PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA).then( - (_hasPermission) => { - trackEvent( - createEventBuilder( - MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, - ) - .addProperties({ - permission: PERMISSION_TYPE.CAMERA, - result: _hasPermission - ? PERMISSION_RESULT.GRANTED - : PERMISSION_RESULT.DENIED, - device_type: HardwareDeviceTypes.QR, - }) - .build(), - ); - setCameraPermission(_hasPermission); - if (!_hasPermission) { - trackEvent( - createEventBuilder( - MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, - ) - .addProperties({ - permission: PERMISSION_TYPE.CAMERA, - result: PERMISSION_RESULT.LIMITED, - device_type: HardwareDeviceTypes.QR, - }) - .build(), - ); - setCameraError(strings('transaction.no_camera_permission_android')); - } else { - trackEvent( - createEventBuilder( - MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, - ) - .addProperties({ - permission: PERMISSION_TYPE.CAMERA, - result: PERMISSION_RESULT.UNAVAILABLE, - device_type: HardwareDeviceTypes.QR, - }) - .build(), - ); - setCameraError(undefined); - } - }, - ); - } - }, [hasCameraPermission, trackEvent, createEventBuilder]); - - const handleAppState = useCallback( - (appState: AppStateStatus) => { - if (appState === 'active') { - checkAndroidCamera(); - } - }, - [checkAndroidCamera], - ); - - useEffect(() => { - if (!isSigningQRObject) { - return; - } - checkAndroidCamera(); - }, [checkAndroidCamera, isSigningQRObject]); - - useEffect(() => { - if (!isSigningQRObject) { - return; - } - const appStateListener = AppState.addEventListener( - 'change', - handleAppState, - ); - return () => { - appStateListener.remove(); - }; - }, [handleAppState, isSigningQRObject]); - - return { cameraError, hasCameraPermission }; -}; +/** + * Camera permission is fully handled by AnimatedQRScannerModal via + * react-native-vision-camera's useCameraPermission / requestPermission(). + * + * This hook no longer pre-checks or pre-requests Android camera permission + * because PermissionsAndroid and react-native-vision-camera maintain separate + * permission state, and calling PermissionsAndroid.request() can conflict with + * vision-camera's camera initialization pipeline (see #26115). + * + * hasCameraPermission is always true so the "Get signature" button is never + * disabled for permission reasons. The scanner modal will prompt the user + * when it opens. + */ +export const useCamera = (_isSigningQRObject: boolean) => ({ + cameraError: undefined as string | undefined, + hasCameraPermission: true, +}); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 698fd02a73e..078318e5edb 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -2459,11 +2459,6 @@ describe('RewardsController', () => { await expect(controller.getPointsEvents(mockRequest)).rejects.toThrow( 'API error', ); - - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to get points events:', - 'API error', - ); }); describe('balance updated event emission', () => { @@ -4997,9 +4992,10 @@ describe('RewardsController', () => { it('reauthenticates and retries after 403 error', async () => { // Arrange + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); jest.spyOn(Date, 'now').mockImplementation(() => 1000000); const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const mockAccount = { id: 'test-account-id', @@ -5127,17 +5123,14 @@ describe('RewardsController', () => { }), ); expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Successfully fetched season status after reauth', + 'RewardsController: Attempting reauth with active account after 403', ); }); it('handles 403 error, reauth, and re-throw error if reauth failed', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const mockReauthError = new Error('Reauth failed'); const mockAccount = { @@ -5198,12 +5191,10 @@ describe('RewardsController', () => { state.pointsEvents = {}; }); - // Mock the messenger calls - let getSeasonStatusCallCount = 0; + // Mock the messenger calls — login rejects to simulate reauth failure localMockMessenger.call.mockImplementation( async (method, ..._args): Promise => { if (method === 'RewardsDataService:getSeasonStatus') { - getSeasonStatusCallCount++; return Promise.reject(mock403Error); } if (method === 'AccountsController:getSelectedMultichainAccount') { @@ -5219,22 +5210,14 @@ describe('RewardsController', () => { }, ); - const invalidateSubscriptionCacheSpy = jest.spyOn( - testableController, - 'invalidateSubscriptionCache' as any, - ); - const invalidateSubscriptionAndAccountsSpy = jest.spyOn( - testableController, - 'invalidateSubscriptionAndAccounts' as any, - ); - - // Act & Assert + // Act & Assert — reauth login fails (silently absorbed by performSilentAuth), + // performReauthForSubscription throws, caches are invalidated, and the + // reauth error propagates (retry is never reached) await expect( testableController.getSeasonStatus(mockSubscriptionId, mockSeasonId), - ).rejects.toThrow(mock403Error); + ).rejects.toThrow(`Reauth failed for subscription ${mockSubscriptionId}`); - // Verify reauth was attempted and status was fetched again - expect(getSeasonStatusCallCount).toBe(2); + // Verify reauth was attempted expect(localMockMessenger.call).toHaveBeenCalledWith( 'AccountsController:getSelectedMultichainAccount', ); @@ -5245,25 +5228,16 @@ describe('RewardsController', () => { }), ); expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to reauth with a valid account after 403 error', - mock403Error.message, - ); - expect(invalidateSubscriptionCacheSpy).toHaveBeenCalledWith( - mockSubscriptionId, - ); - expect(invalidateSubscriptionAndAccountsSpy).toHaveBeenCalledWith( - mockSubscriptionId, + 'RewardsController: Attempting reauth with active account after 403', ); }); it('reauthenticates with active account and retries after 403 error', async () => { // Arrange + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); jest.spyOn(Date, 'now').mockImplementation(() => 1000000); const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const mockAccount = { id: 'test-account-id', @@ -5403,18 +5377,16 @@ describe('RewardsController', () => { }), ); expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Successfully fetched season status after reauth', + 'RewardsController: Attempting reauth with active account after 403', ); }); it('reauthenticates with non-active account and retries after 403 error', async () => { // Arrange + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); jest.spyOn(Date, 'now').mockImplementation(() => 1000000); const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const mockAccount = { id: 'test-account-id-2', @@ -5562,17 +5534,14 @@ describe('RewardsController', () => { }), ); expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Successfully fetched season status after reauth', + 'RewardsController: Attempting reauth with active account after 403', ); }); it('throws authorization error when account for subscription not found after 403', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const testSubscriptionId = 'test-sub-id'; @@ -5664,7 +5633,7 @@ describe('RewardsController', () => { it('throws authorization error when converted account not found after 403', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const testSubscriptionId = 'test-sub-id'; const mockAccount = { @@ -5772,7 +5741,7 @@ describe('RewardsController', () => { it('throws authorization error when accounts state is undefined after 403', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const localMockMessenger = { @@ -5852,7 +5821,7 @@ describe('RewardsController', () => { it('throws authorization error when accounts state is empty after 403', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const localMockMessenger = { @@ -6878,13 +6847,9 @@ describe('RewardsController', () => { await expect( controller.getReferralDetails(mockSubscriptionId, mockSeasonId), ).rejects.toThrow('API connection failed'); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to get referral details:', - 'API connection failed', - ); }); - it('logs and rethrows non-Error objects when API call fails', async () => { + it('rethrows non-Error objects when API call fails', async () => { controller = new RewardsController({ messenger: mockMessenger, state: { @@ -6913,10 +6878,6 @@ describe('RewardsController', () => { await expect( controller.getReferralDetails(mockSubscriptionId, mockSeasonId), ).rejects.toEqual(404); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to get referral details:', - '404', - ); }); }); @@ -14799,6 +14760,134 @@ describe('RewardsController', () => { 'boost-B-Y', ); }); + + it('reauthenticates and retries after 403 error on active boosts', async () => { + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); + const seasonId = 'season-123'; + const subscriptionId = 'sub-456'; + const mock403Error = new AuthorizationFailedError( + 'Authorization failed: 403', + ); + const mockAccount = { + id: 'test-account-id', + address: '0x123', + name: 'Test Account', + type: 'eip155:eoa', + options: {}, + metadata: {}, + }; + const mockLoginResponse = { + subscription: { id: subscriptionId }, + }; + const mockBoosts = { + boosts: [ + { + id: 'boost-1', + name: 'Test Boost', + icon: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + boostBips: 500, + seasonLong: false, + backgroundColor: '#00FF00', + }, + ], + }; + + const localMockMessenger = { + subscribe: jest.fn(), + call: jest.fn(), + registerActionHandler: jest.fn(), + unregisterActionHandler: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), + registerInitialEventPayload: jest.fn(), + unsubscribe: jest.fn(), + } as unknown as jest.Mocked; + + const testableController = new TestableRewardsController({ + messenger: localMockMessenger, + isDisabled: () => false, + }); + testableController.testUpdate((state) => { + state.activeAccount = { + subscriptionId, + account: 'eip155:1:0x123', + hasOptedIn: true, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + }; + state.accounts = {}; + state.subscriptions = { + [subscriptionId]: { + id: subscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }; + }); + + let getBoostsCallCount = 0; + localMockMessenger.call.mockImplementation( + async (method, ..._args): Promise => { + if (method === 'RewardsDataService:getActivePointsBoosts') { + getBoostsCallCount++; + if (getBoostsCallCount === 1) { + return Promise.reject(mock403Error); + } + return mockBoosts; + } + if (method === 'AccountsController:getSelectedMultichainAccount') { + return mockAccount; + } + if (method === 'KeyringController:signPersonalMessage') { + return 'mock-signature'; + } + if (method === 'RewardsDataService:login') { + return mockLoginResponse; + } + return undefined; + }, + ); + + // Act + const result = await testableController.getActivePointsBoosts( + seasonId, + subscriptionId, + ); + + // Assert — retried successfully after reauth + expect(getBoostsCallCount).toBe(2); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('boost-1'); + expect(localMockMessenger.call).toHaveBeenCalledWith( + 'AccountsController:getSelectedMultichainAccount', + ); + expect(localMockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:login', + expect.objectContaining({ + account: mockAccount.address, + }), + ); + }); + + it('does not retry on non-403 errors', async () => { + const seasonId = 'season-123'; + const subscriptionId = 'sub-456'; + const genericError = new Error('Network error'); + + mockMessenger.call.mockRejectedValue(genericError); + + await expect( + controller.getActivePointsBoosts(seasonId, subscriptionId), + ).rejects.toThrow('Network error'); + + // Should NOT attempt reauth for non-403 errors + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'AccountsController:getSelectedMultichainAccount', + ); + }); }); describe('getUnlockedRewards', () => { @@ -18619,11 +18708,6 @@ describe('RewardsController', () => { await expect( controller.getSnapshots(mockSeasonId, mockSubscriptionId), ).rejects.toThrow('Network timeout'); - - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to get snapshots:', - 'Network timeout', - ); }); it('logs when fetching fresh snapshots data', async () => { diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index d4c8afac526..9c26aad66b7 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -292,6 +292,7 @@ export class RewardsController extends BaseController< #isBitcoinOptinEnabled: () => boolean; #isTronOptinEnabled: () => boolean; #isSnapshotsEnabled: () => boolean; + #reauthPromises: Map> = new Map(); /** * Calculate tier status and next tier information @@ -823,6 +824,113 @@ export class RewardsController extends BaseController< }); } + /** + * Re-authenticate the account linked to a given subscription. + * Resolves the matching InternalAccount and calls performSilentAuth. + */ + async #performReauthForSubscription(subscriptionId: string): Promise { + await removeSubscriptionToken(subscriptionId); + + if (this.state.activeAccount?.subscriptionId === subscriptionId) { + const account = await this.messenger.call( + 'AccountsController:getSelectedMultichainAccount', + ); + if (this.isOptInSupported(account as InternalAccount)) { + Logger.log( + 'RewardsController: Attempting reauth with active account after 403', + ); + const result = await this.performSilentAuth(account, false, false); + if (!result) { + throw new Error(`Reauth failed for subscription ${subscriptionId}`); + } + return; + } + } + + // Active account can't sign (e.g. hardware wallet) or doesn't match — + // find any software account linked to this subscription. + if (this.state.accounts && Object.values(this.state.accounts).length > 0) { + const allLinkedAccounts = Object.values(this.state.accounts).filter( + (acc) => acc.subscriptionId === subscriptionId, + ); + if (allLinkedAccounts.length > 0) { + const accounts = await this.messenger.call( + 'AccountsController:listMultichainAccounts', + ); + for (const linkedAccount of allLinkedAccounts) { + const intAccount = accounts.find((acc: InternalAccount) => { + const accCaipId = this.convertInternalAccountToCaipAccountId(acc); + return accCaipId === linkedAccount.account; + }); + if (intAccount && this.isOptInSupported(intAccount)) { + Logger.log( + 'RewardsController: Attempting reauth with linked account after 403', + ); + const result = await this.performSilentAuth( + intAccount as InternalAccount, + false, + false, + ); + if (!result) { + throw new Error( + `Reauth failed for subscription ${subscriptionId}`, + ); + } + return; + } + } + } + } + + throw new Error( + `No signable account found for subscription ${subscriptionId} to reauth`, + ); + } + + /** + * Wrap an authenticated async call with automatic 403 retry. + * On AuthorizationFailedError, coalesces concurrent reauths into a single + * performSilentAuth call, then retries the original function once. + */ + async #withAuthRetry( + fn: () => Promise, + subscriptionId: string, + ): Promise { + try { + return await fn(); + } catch (error) { + if (!(error instanceof AuthorizationFailedError)) throw error; + + if (!this.#reauthPromises.has(subscriptionId)) { + Logger.log( + 'RewardsController: 403 detected, initiating reauth for subscription', + subscriptionId, + ); + const promise = this.#performReauthForSubscription( + subscriptionId, + ).finally(() => { + this.#reauthPromises.delete(subscriptionId); + }); + this.#reauthPromises.set(subscriptionId, promise); + } else { + Logger.log( + 'RewardsController: 403 detected, reauth already in progress for subscription', + subscriptionId, + ); + } + + try { + await this.#reauthPromises.get(subscriptionId); + } catch (reauthError) { + this.invalidateSubscriptionCache(subscriptionId); + await this.invalidateSubscriptionAndAccounts(subscriptionId); + throw reauthError; + } + + return await fn(); + } + } + /** * Handle authentication triggers (account changes, keyring unlock) */ @@ -1589,16 +1697,19 @@ export class RewardsController extends BaseController< // If cursor is provided, always fetch fresh and do not touch cache if (params.cursor) { - const dto = await this.messenger.call( - 'RewardsDataService:getPointsEvents', - params, + const dto = await this.#withAuthRetry( + () => this.messenger.call('RewardsDataService:getPointsEvents', params), + params.subscriptionId, ); this.triggerBalanceUpdateIfNeeded(dto, params); return dto; } if (params.forceFresh) { - const dto = await this.getPointsEventsIfChanged(params); + const dto = await this.#withAuthRetry( + () => this.getPointsEventsIfChanged(params), + params.subscriptionId, + ); this.triggerBalanceUpdateIfNeeded(dto, params); return dto; } @@ -1625,8 +1736,8 @@ export class RewardsController extends BaseController< } : undefined; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh points events data via API call for seasonId & subscriptionId & type & page cursor', { @@ -1639,14 +1750,7 @@ export class RewardsController extends BaseController< const pointsEvents = await this.getPointsEventsIfChanged(params); this.triggerBalanceUpdateIfNeeded(pointsEvents, params); return pointsEvents; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get points events:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, params.subscriptionId), writeCache: (key, pointsEventsDto) => { this.update((state: RewardsControllerState) => { state.pointsEvents[key] = @@ -1720,11 +1824,14 @@ export class RewardsController extends BaseController< 'RewardsController: Getting fresh points events last updated for seasonId & subscriptionId', params, ); - const result = await this.messenger.call( - 'RewardsDataService:getPointsEventsLastUpdated', - params, + return this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:getPointsEventsLastUpdated', + params, + ), + params.subscriptionId, ); - return result; } /** @@ -2045,110 +2152,40 @@ export class RewardsController extends BaseController< if (!cached) return; return { payload: cached, lastFetched: cached.lastFetched }; }, - fetchFresh: async () => { - try { - Logger.log( - 'RewardsController: Fetching fresh season status data via API call for subscriptionId & seasonId', - subscriptionId, - seasonId, - ); - - // Now fetch season status (balance, currentTierId, etc.) - const seasonState = await this.messenger.call( - 'RewardsDataService:getSeasonStatus', - seasonId, - subscriptionId, - ); - - // Combine all data into SeasonStatusDto - const seasonStatus = this.convertToSeasonStatusDto( - season, - seasonState, - ); - return this.#convertSeasonStatusToSubscriptionState(seasonStatus); - } catch (error) { - if (error instanceof AuthorizationFailedError) { - // Attempt to reauth with a valid account. - try { - if (this.state.activeAccount?.subscriptionId === subscriptionId) { - const account = await this.messenger.call( - 'AccountsController:getSelectedMultichainAccount', - ); - Logger.log( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - await this.performSilentAuth(account, false, false); // try and auth. - } else if ( - this.state.accounts && - Object.values(this.state.accounts).length > 0 - ) { - const accountForSub = Object.values(this.state.accounts).find( - (acc) => acc.subscriptionId === subscriptionId, - ); - if (accountForSub) { - const accounts = await this.messenger.call( - 'AccountsController:listMultichainAccounts', - ); - const convertInternalAccountToCaipAccountId = - this.convertInternalAccountToCaipAccountId; - const intAccountForSub = accounts.find( - (acc: InternalAccount) => { - const accCaipId = - convertInternalAccountToCaipAccountId(acc); - return accCaipId === accountForSub.account; - }, - ); - if (intAccountForSub) { - Logger.log( - 'RewardsController: Attempting to reauth with any valid account after 403 error', - ); - await this.performSilentAuth( - intAccountForSub as InternalAccount, - false, - false, - ); - } - } - } - // Now fetch season status (balance, currentTierId, etc.) - const seasonState = await this.messenger.call( - 'RewardsDataService:getSeasonStatus', - season.id, - subscriptionId, - ); + fetchFresh: async () => + this.#withAuthRetry(async () => { + try { + Logger.log( + 'RewardsController: Fetching fresh season status data via API call for subscriptionId & seasonId', + subscriptionId, + seasonId, + ); - // Combine all data into SeasonStatusDto - const seasonStatus = this.convertToSeasonStatusDto( - season, - seasonState, - ); + const seasonState = await this.messenger.call( + 'RewardsDataService:getSeasonStatus', + seasonId, + subscriptionId, + ); - Logger.log( - 'RewardsController: Successfully fetched season status after reauth', - ); - return this.#convertSeasonStatusToSubscriptionState(seasonStatus); - } catch { - Logger.log( - 'RewardsController: Failed to reauth with a valid account after 403 error', - error instanceof Error ? error.message : String(error), - ); - this.invalidateSubscriptionCache(subscriptionId); - await this.invalidateSubscriptionAndAccounts(subscriptionId); + const seasonStatus = this.convertToSeasonStatusDto( + season, + seasonState, + ); + return this.#convertSeasonStatusToSubscriptionState(seasonStatus); + } catch (error) { + if (error instanceof SeasonNotFoundError) { + this.update((state: RewardsControllerState) => { + state.seasons = {}; + }); throw error; } - } else if (error instanceof SeasonNotFoundError) { - this.update((state: RewardsControllerState) => { - state.seasons = {}; - }); + Logger.log( + 'RewardsController: Failed to get season status:', + error instanceof Error ? error.message : String(error), + ); throw error; } - Logger.log( - 'RewardsController: Failed to get season status:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, subscriptionSeasonStatus) => { this.update((state: RewardsControllerState) => { // Update season status with composite key @@ -2234,8 +2271,8 @@ export class RewardsController extends BaseController< if (!cached) return; return { payload: cached, lastFetched: cached.lastFetched }; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh referral details data via API call for', { subscriptionId, seasonId }, @@ -2252,14 +2289,7 @@ export class RewardsController extends BaseController< referredByCode: referralDetails.referredByCode, lastFetched: Date.now(), }; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get referral details:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, payload) => { this.update((state: RewardsControllerState) => { state.subscriptionReferralDetails[key] = payload; @@ -2701,9 +2731,13 @@ export class RewardsController extends BaseController< } try { - const response = await this.messenger.call( - 'RewardsDataService:validateBonusCode', - code, + const response = await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:validateBonusCode', + code, + subscriptionId, + ), subscriptionId, ); return response.valid; @@ -3081,8 +3115,8 @@ export class RewardsController extends BaseController< } // Call the opt-out endpoint - const result = await this.messenger.call( - 'RewardsDataService:optOut', + const result = await this.#withAuthRetry( + () => this.messenger.call('RewardsDataService:optOut', subscriptionId), subscriptionId, ); @@ -3135,8 +3169,8 @@ export class RewardsController extends BaseController< lastFetched: cachedActiveBoosts.lastFetched, }; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh active boosts data via API call for subscriptionId & seasonId', subscriptionId, @@ -3148,14 +3182,7 @@ export class RewardsController extends BaseController< subscriptionId, ); return response.boosts; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get active points boosts:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, payload) => { this.update((state: RewardsControllerState) => { state.activeBoosts[key] = { @@ -3195,8 +3222,8 @@ export class RewardsController extends BaseController< lastFetched: cachedUnlockedRewards.lastFetched, }; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh unlocked rewards data via API call for subscriptionId & seasonId', subscriptionId, @@ -3208,14 +3235,7 @@ export class RewardsController extends BaseController< subscriptionId, )) as RewardDto[]; return response || []; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get unlocked rewards:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, payload) => { this.update((state: RewardsControllerState) => { state.unlockedRewards[key] = { @@ -3257,8 +3277,8 @@ export class RewardsController extends BaseController< lastFetched: cachedSnapshots.lastFetched, }; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh snapshots data via API call for seasonId', seasonId, @@ -3269,14 +3289,7 @@ export class RewardsController extends BaseController< subscriptionId, )) as SnapshotDto[]; return response || []; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get snapshots:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, payload) => { this.update((state: RewardsControllerState) => { state.snapshots[key] = { @@ -3306,11 +3319,15 @@ export class RewardsController extends BaseController< throw new Error('Rewards are not enabled'); } try { - await this.messenger.call( - 'RewardsDataService:claimReward', - rewardId, + await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:claimReward', + rewardId, + subscriptionId, + dto, + ), subscriptionId, - dto, ); // Invalidate cache for the active subscription @@ -3349,8 +3366,12 @@ export class RewardsController extends BaseController< } try { - const result = await this.messenger.call( - 'RewardsDataService:getSeasonOneLineaRewardTokens', + const result = await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:getSeasonOneLineaRewardTokens', + subscriptionId, + ), subscriptionId, ); return result; @@ -3380,9 +3401,13 @@ export class RewardsController extends BaseController< } try { - await this.messenger.call( - 'RewardsDataService:applyReferralCode', - { referralCode }, + await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:applyReferralCode', + { referralCode }, + subscriptionId, + ), subscriptionId, ); @@ -3419,9 +3444,13 @@ export class RewardsController extends BaseController< } try { - await this.messenger.call( - 'RewardsDataService:applyBonusCode', - { bonusCode }, + await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:applyBonusCode', + { bonusCode }, + subscriptionId, + ), subscriptionId, ); diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index db5758761e5..b197e6666e3 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -1138,6 +1138,121 @@ describe('RewardsDataService', () => { }); }); + describe('centralized 403 detection in makeRequest', () => { + it('throws AuthorizationFailedError for any endpoint returning 403', async () => { + const mockResponse = { + ok: false, + status: 403, + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getActivePointsBoosts('season-1', 'sub-1'), + ).rejects.toBeInstanceOf(AuthorizationFailedError); + }); + + it('throws AuthorizationFailedError with status in message', async () => { + const mockResponse = { + ok: false, + status: 403, + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getActivePointsBoosts('season-1', 'sub-1'), + ).rejects.toThrow('Authorization failed: 403'); + }); + + it('does not throw AuthorizationFailedError for non-403 errors', async () => { + const mockResponse = { + ok: false, + status: 401, + json: jest.fn().mockResolvedValue({ message: 'Unauthorized' }), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getReferralDetails('season-1', 'sub-1'), + ).rejects.toThrow('Get referral details failed: 401'); + }); + + it('throws AuthorizationFailedError for 403 on different endpoints', async () => { + const mockResponse = { + ok: false, + status: 403, + } as unknown as Response; + + mockFetch.mockResolvedValue(mockResponse); + await expect( + service.getUnlockedRewards('season-1', 'sub-1'), + ).rejects.toBeInstanceOf(AuthorizationFailedError); + + mockFetch.mockResolvedValue(mockResponse); + await expect( + service.getSnapshots('season-1', 'sub-1'), + ).rejects.toBeInstanceOf(AuthorizationFailedError); + + mockFetch.mockResolvedValue(mockResponse); + await expect(service.optOut('sub-1')).rejects.toBeInstanceOf( + AuthorizationFailedError, + ); + }); + + it('does not throw AuthorizationFailedError for 403 on unauthenticated endpoints', async () => { + const mockResponse = { + ok: false, + status: 403, + json: jest.fn().mockResolvedValue({ message: 'Forbidden' }), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.estimatePoints({ + activityType: 'SWAP', + account: 'eip155:1:0x123', + activityContext: { + swapContext: { + srcAsset: { + id: 'eip155:1/slip44:60', + amount: '1000000000000000000', + }, + destAsset: { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + amount: '4500000000', + }, + feeAsset: { + id: 'eip155:1/slip44:60', + amount: '5000000000000000', + }, + }, + }, + }), + ).rejects.toThrow('Points estimation failed: 403'); + await expect( + service.estimatePoints({ + activityType: 'SWAP', + account: 'eip155:1:0x123', + activityContext: { + swapContext: { + srcAsset: { + id: 'eip155:1/slip44:60', + amount: '1000000000000000000', + }, + destAsset: { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + amount: '4500000000', + }, + feeAsset: { + id: 'eip155:1/slip44:60', + amount: '5000000000000000', + }, + }, + }, + }), + ).rejects.not.toBeInstanceOf(AuthorizationFailedError); + }); + }); + const mockSeasonStateResponse: SeasonStateDto = { balance: 1000, currentTierId: 'tier-gold', @@ -1224,10 +1339,10 @@ describe('RewardsDataService', () => { ).rejects.toThrow('Get season state failed: 404'); }); - it('throws AuthorizationFailedError when rewards authorization fails', async () => { + it('throws AuthorizationFailedError when server returns 403', async () => { const mockResponse = { ok: false, - status: 401, + status: 403, json: jest.fn().mockResolvedValue({ message: 'Rewards authorization failed', }), @@ -1244,25 +1359,7 @@ describe('RewardsDataService', () => { expect(caughtError).toBeInstanceOf(AuthorizationFailedError); const authError = caughtError as AuthorizationFailedError; expect(authError.name).toBe('AuthorizationFailedError'); - expect(authError.message).toBe( - 'Rewards authorization failed. Please login and try again.', - ); - }); - - it('detects authorization failure when message contains the phrase', async () => { - const mockResponse = { - ok: false, - status: 403, - json: jest.fn().mockResolvedValue({ - message: - 'Some other error: Rewards authorization failed due to expiry', - }), - } as unknown as Response; - mockFetch.mockResolvedValue(mockResponse); - - await expect( - service.getSeasonStatus(mockSeasonId, mockSubscriptionId), - ).rejects.toBeInstanceOf(AuthorizationFailedError); + expect(authError.message).toBe('Authorization failed: 403'); }); it('throws SeasonNotFoundError when season is not found', async () => { diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 2468209ca25..90397c9fc52 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -526,6 +526,13 @@ export class RewardsDataService { }); clearTimeout(timeoutId); + + if (response.status === 403 && subscriptionId) { + throw new AuthorizationFailedError( + `Authorization failed: ${response.status}`, + ); + } + return response; } catch (error) { clearTimeout(timeoutId); @@ -787,11 +794,6 @@ export class RewardsDataService { if (!response.ok) { const errorData = await response.json(); - if (errorData?.message?.includes('Rewards authorization failed')) { - throw new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', - ); - } if (errorData?.message?.includes('Season not found')) { throw new SeasonNotFoundError( diff --git a/locales/languages/en.json b/locales/languages/en.json index bf55d80fb82..5b1e1b3ff44 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2071,10 +2071,9 @@ }, "market_insights": { "title": "Market insights", - "disclaimer": "AI insights. Not financial advice.", "a_closer_look": "A closer look", "whats_being_said": "What's being said", - "fixed_footer_disclaimer": "AI summary for information only", + "footer_disclaimer": "AI summary for information only", "trade_button": "Trade", "sources_count": "+{{count}} sources", "sources_title": "News sources", diff --git a/locales/languages/hi-in.json b/locales/languages/hi-in.json index cb54eff68e0..4a420c7381d 100644 --- a/locales/languages/hi-in.json +++ b/locales/languages/hi-in.json @@ -582,6 +582,7 @@ "ok": "ठीक है", "cancel": "रद्द करें", "error": "त्रुटि", + "open_settings": "सेटिंग", "attempting_to_scan_with_wallet_locked": "ऐसा लगता है कि आप QR कोड स्कैन करने का प्रयास कर रहे हैं, इसका उपयोग करने में सक्षम होने के लिए आपको अपने वॉलेट को अनलॉक करना होगा।", "attempting_sync_from_wallet_error": "लगता है कि आप एक्सटेंशन के साथ सिंक करने का प्रयास कर रहे हैं। ऐसा करने के लिए, आपको अपने वर्तमान वॉलेट को मिटाना होगा। \n\nजब आप ऐप को मिटा देते हैं या उसके नए संस्करण को फिर से इंस्टॉल करते हैं, तो \"MetaMask एक्सटेंशन के साथ सिंक करें\" विकल्प का चयन करें। महत्वपूर्ण! अपना वॉलेट मिटाने से पहले, सुनिश्चित करें कि आपने अपने गुप्त रिकवरी फ्रेज़ का बैकअप ले लिया है।" }, diff --git a/locales/languages/id-id.json b/locales/languages/id-id.json index 244b8266fd9..cc0190af6a9 100644 --- a/locales/languages/id-id.json +++ b/locales/languages/id-id.json @@ -582,6 +582,7 @@ "ok": "Oke", "cancel": "Batal", "error": "Kesalahan", + "open_settings": "Pengaturan", "attempting_to_scan_with_wallet_locked": "Sepertinya Anda mencoba memindai kode QR, Anda perlu membuka dompet agar dapat menggunakannya.", "attempting_sync_from_wallet_error": "Sepertinya Anda mencoba menyinkronkan dengan ekstensi. Untuk melakukannya, Anda akan perlu menghapus dompet Anda saat ini. \n\nSetelah Anda menghapus atau menginstal ulang versi baru aplikasi, pilih opsi untuk \"Menyinkronkan dengan Ekstensi MetaMask\". Penting! Sebelum menghapus dompet, pastikan Anda telah mencadangkan Frasa Pemulihan Rahasia." }, diff --git a/locales/languages/ja-jp.json b/locales/languages/ja-jp.json index af6f4fd0e7e..ab099a12fef 100644 --- a/locales/languages/ja-jp.json +++ b/locales/languages/ja-jp.json @@ -582,6 +582,7 @@ "ok": "OK", "cancel": "キャンセル", "error": "エラー", + "open_settings": "設定", "attempting_to_scan_with_wallet_locked": "QRコードを読み取ろうとしていますが、ウォレットのロックを解除する必要があります。", "attempting_sync_from_wallet_error": "拡張機能と同期しようとしています。実行するには、現在のウォレットを消去する必要があります。\n\n新しいバージョンのアプリを消去または再インストールした後、[Sync with MetaMask Extension]というオプションを選択してください。重要!ウォレットを消去する前に、シークレットリカバリーフレーズのバックアップを取っておくことをお勧めします。" }, diff --git a/locales/languages/ko-kr.json b/locales/languages/ko-kr.json index 2313fe9662d..1ee186bc10b 100644 --- a/locales/languages/ko-kr.json +++ b/locales/languages/ko-kr.json @@ -582,6 +582,7 @@ "ok": "확인", "cancel": "취소", "error": "오류", + "open_settings": "설정", "attempting_to_scan_with_wallet_locked": "QR 코드 스캔을 시도 중이십니까? 이를 사용하려면 지갑을 잠금 해제해야 합니다.", "attempting_sync_from_wallet_error": "확장 프로그램과 동기화를 시도 중인 것 같습니다. 이를 위해서는 기존 지갑을 지워야 합니다. \n\n지우기를 완료한 후 또는 최신 버전의 앱을 재설치한 후 \"MetaMask 확장 프로그램과 동기화\" 옵션을 선택하십시오. 중요! 지갑을 지우기 전에 계정 시드 구문을 백업했는지 확인하십시오." }, diff --git a/locales/languages/pt-br.json b/locales/languages/pt-br.json index 7ad0976e19b..3922b24b723 100644 --- a/locales/languages/pt-br.json +++ b/locales/languages/pt-br.json @@ -582,6 +582,7 @@ "ok": "OK", "cancel": "Cancelar", "error": "Erro", + "open_settings": "Configurações", "attempting_to_scan_with_wallet_locked": "Parece que você está tentando escanear um código QR. Você deve destravar sua carteira para conseguir usá-la.", "attempting_sync_from_wallet_error": "Parece que você está tentando sincronizar com uma extensão. Para fazê-lo, você precisará excluir sua carteira atual. \n\nApós ter excluído ou reinstalado uma versão mais recente do app, selecione a opção para \"Sincronizar com a Extensão MetaMask\". Importante! Antes de excluir a sua carteira, certifique-se de ter feito uma cópia de segurança da sua Frase de Recuperação Secreta." }, diff --git a/locales/languages/ru-ru.json b/locales/languages/ru-ru.json index f064b3e261d..3ba2d8bacef 100644 --- a/locales/languages/ru-ru.json +++ b/locales/languages/ru-ru.json @@ -582,6 +582,7 @@ "ok": "ОК", "cancel": "Отмена", "error": "Ошибка", + "open_settings": "Настройки", "attempting_to_scan_with_wallet_locked": "Похоже, вы пытаетесь отсканировать QR-код. Чтобы использовать его, нужно разблокировать кошелек.", "attempting_sync_from_wallet_error": "Похоже, вы пытаетесь выполнить синхронизацию с расширением. Сначала нужно удалить текущий кошелек. \n\nКогда вы удалите кошелек или установите новую версию приложения, выберите параметр «Синхронизировать с расширением MetaMask». Важно! Прежде чем удалять кошелек, создайте резервную копию секретной фразы восстановления." }, diff --git a/locales/languages/vi-vn.json b/locales/languages/vi-vn.json index 48b55622c1b..25830b2b098 100644 --- a/locales/languages/vi-vn.json +++ b/locales/languages/vi-vn.json @@ -582,6 +582,7 @@ "ok": "Ok", "cancel": "Hủy", "error": "Lỗi", + "open_settings": "Cài đặt", "attempting_to_scan_with_wallet_locked": "Có vẻ như bạn đang cố gắng quét một mã QR. Bạn cần mở khóa ví của mình trước thì mới có thể sử dụng mã đó.", "attempting_sync_from_wallet_error": "Có vẻ như bạn đang cố gắng đồng bộ hóa với tiện ích. Để có thể làm như vậy, bạn sẽ cần xóa ví hiện tại của mình. \n\nSau khi bạn xóa hoặc cài đặt lại một phiên bản mới của ứng dụng, hãy chọn tùy chọn để \"Đồng bộ hóa với tiện ích MetaMask\". Quan trọng! Trước khi xóa ví, hãy đảm bảo bạn đã sao lưu Cụm mật khẩu khôi phục bí mật." }, diff --git a/locales/languages/zh-cn.json b/locales/languages/zh-cn.json index 01d0f9177bc..a14182f276b 100644 --- a/locales/languages/zh-cn.json +++ b/locales/languages/zh-cn.json @@ -562,6 +562,7 @@ "ok": "确定", "cancel": "取消", "error": "错误", + "open_settings": "设置", "attempting_sync_from_wallet_error": "好像您正尝试与扩展程序同步。要进行同步,请转至“设置”>“高级”>“与 MetaMask 扩展程序同步”" }, "action_view": { diff --git a/tests/framework/UNIFIED_E2E_ARCHIITECTURE.md b/tests/docs/UNIFIED_E2E_ARCHITECTURE.md similarity index 100% rename from tests/framework/UNIFIED_E2E_ARCHIITECTURE.md rename to tests/docs/UNIFIED_E2E_ARCHITECTURE.md diff --git a/tests/docs/UNIFIED_GESTURES_MIGRATION.md b/tests/docs/UNIFIED_GESTURES_MIGRATION.md new file mode 100644 index 00000000000..25c7b4e5149 --- /dev/null +++ b/tests/docs/UNIFIED_GESTURES_MIGRATION.md @@ -0,0 +1,205 @@ +# Unified Gestures Migration Guide + +## Overview + +`UnifiedGestures` is a static facade that lets page objects execute gestures without knowing whether Detox or Appium/WebdriverIO is running. It uses the **Strategy pattern**: a single `GestureStrategy` interface with two implementations (`DetoxGestureStrategy` and `AppiumGestureStrategy`), selected once at startup. + +``` +Page Objects → UnifiedGestures (static) → GestureStrategy (interface) + ├── DetoxGestureStrategy → Gestures (existing) + └── AppiumGestureStrategy → PlaywrightElement / PlaywrightGestures +``` + +This is the **action** counterpart to `encapsulated()` (which handles element locators). Together they let you write fully framework-agnostic page objects. + +## Available Methods + +| Method | Description | +| -------------------------------------------- | ------------------------------------------------------------------------------ | +| `tap(elem, opts?)` | Tap an element | +| `waitAndTap(elem, opts?)` | Wait for visibility then tap | +| `typeText(elem, text, opts?)` | Clear field and type text | +| `replaceText(elem, text, opts?)` | Replace existing text | +| `swipe(elem, direction, opts?)` | Swipe in a direction | +| `scrollToElement(target, scrollView, opts?)` | Scroll until target is visible | +| `longPress(elem, opts?)` | Long press an element | +| `dblTap(elem, opts?)` | Double tap an element | +| `tapAtPoint(elem, point, opts?)` | Tap at specific {x, y} coordinates on an element | +| `tapAtIndex(elem, index, opts?)` | Tap the nth matching element (accepts single element or `PlaywrightElement[]`) | + +All methods accept optional `UnifiedGestureOptions`: + +```typescript +interface UnifiedGestureOptions { + timeout?: number; // Max wait time in ms + description?: string; // For logging / error messages +} +``` + +## Migration Steps + +### 1. Update element getters to use `encapsulated()` + +If your page object still uses Detox-only matchers, convert them first: + +```typescript +// Before +get passwordInput(): DetoxElement { + return Matchers.getElementByID(LoginViewSelectors.PASSWORD_INPUT); +} + +// After +get passwordInput(): EncapsulatedElementType { + return encapsulated({ + detox: () => Matchers.getElementByID(LoginViewSelectors.PASSWORD_INPUT), + appium: () => PlaywrightMatchers.getElementById(LoginViewSelectors.PASSWORD_INPUT), + }); +} +``` + +### 2. Replace `Gestures.*` calls with `UnifiedGestures.*` + +```typescript +// Before +import { Gestures } from '../../framework'; + +async enterPassword(password: string) { + await Gestures.typeText(this.passwordInput, password, { + hideKeyboard: true, + elemDescription: 'password field', + }); +} + +// After +import { UnifiedGestures } from '../../framework'; + +async enterPassword(password: string) { + await UnifiedGestures.typeText(this.passwordInput, password, { + description: 'password field', + }); +} +``` + +Framework-specific options like `hideKeyboard`, `checkStability`, and `clearFirst` are handled internally by each strategy with sensible defaults: + +- **Detox**: `hideKeyboard: true`, `clearFirst: true`, retry + stability checks +- **Appium**: Direct `PlaywrightElement` / `PlaywrightGestures` calls (e.g. `fill()`, `click()`) + +### 3. Handle edge cases with `encapsulatedAction()` + +For the rare ~3% of methods where Detox and Appium need structurally different flows: + +```typescript +import { encapsulatedAction } from '../../framework'; + +async dismissOnboarding() { + await encapsulatedAction({ + detox: async () => { + await Gestures.swipe(this.overlay, 'up'); + await Gestures.tap(this.dismissButton); + }, + appium: async () => { + const btn = await asPlaywrightElement(this.dismissButton); + await btn.click(); + }, + }); +} +``` + +## Escape Hatch + +When the unified approach becomes overly complex for a specific case, you can always use `FrameworkDetector` or `PlatformDetector` directly for custom conditional logic: + +```typescript +import { FrameworkDetector } from '../../framework'; + +async complexSpecialCase() { + if (FrameworkDetector.isDetox()) { + // Detox-specific multi-step flow + } else { + // Appium-specific multi-step flow + } +} +``` + +This should be the last resort. Prefer `UnifiedGestures` > `encapsulatedAction()` > direct `FrameworkDetector` checks, in that order. + +## Full Before/After Example + +### Before (Detox-only) + +```typescript +import { Gestures, Matchers } from '../../framework'; +import { LoginViewSelectors } from '../../selectors/LoginView.selectors'; + +class LoginView { + get passwordInput(): DetoxElement { + return Matchers.getElementByID(LoginViewSelectors.PASSWORD_INPUT); + } + + get loginButton(): DetoxElement { + return Matchers.getElementByID(LoginViewSelectors.LOGIN_BUTTON); + } + + async login(password: string) { + await Gestures.typeText(this.passwordInput, password, { + hideKeyboard: true, + elemDescription: 'password input', + }); + await Gestures.waitAndTap(this.loginButton, { + elemDescription: 'login button', + }); + } +} +``` + +### After (Unified) + +```typescript +import { UnifiedGestures } from '../../framework'; +import { + encapsulated, + EncapsulatedElementType, + Matchers, + PlaywrightMatchers, +} from '../../framework'; +import { LoginViewSelectors } from '../../selectors/LoginView.selectors'; + +class LoginView { + get passwordInput(): EncapsulatedElementType { + return encapsulated({ + detox: () => Matchers.getElementByID(LoginViewSelectors.PASSWORD_INPUT), + appium: () => + PlaywrightMatchers.getElementById(LoginViewSelectors.PASSWORD_INPUT), + }); + } + + get loginButton(): EncapsulatedElementType { + return encapsulated({ + detox: () => Matchers.getElementByID(LoginViewSelectors.LOGIN_BUTTON), + appium: () => + PlaywrightMatchers.getElementById(LoginViewSelectors.LOGIN_BUTTON), + }); + } + + async login(password: string) { + await UnifiedGestures.typeText(this.passwordInput, password, { + description: 'password input', + }); + await UnifiedGestures.waitAndTap(this.loginButton, { + description: 'login button', + }); + } +} +``` + +## FAQ + +**Q: Does this change existing Detox test behavior?** +No. `DetoxGestureStrategy` wraps the existing `Gestures` class — all retry logic, stability checks, and platform-specific scroll behavior are preserved. + +**Q: What if I need a Detox-specific option like `checkStability`?** +Use `encapsulatedAction()` and call `Gestures` directly in the Detox branch. + +**Q: Can I still use `Gestures` directly?** +Yes. The `Gestures` class is not removed or modified. For Detox-only page objects that haven't been migrated, `Gestures` works exactly as before. diff --git a/tests/framework/AppiumGestureStrategy.test.ts b/tests/framework/AppiumGestureStrategy.test.ts new file mode 100644 index 00000000000..3939f6145ce --- /dev/null +++ b/tests/framework/AppiumGestureStrategy.test.ts @@ -0,0 +1,88 @@ +jest.mock('./PlaywrightGestures.ts', () => ({ + __esModule: true, + default: { + dblTap: jest.fn(), + }, +})); + +jest.mock('./EncapsulatedElement.ts', () => ({ + asPlaywrightElement: jest.fn(), +})); + +import PlaywrightGestures from './PlaywrightGestures.ts'; +import { asPlaywrightElement } from './EncapsulatedElement.ts'; +import { AppiumGestureStrategy } from './GestureStrategy.ts'; +import { PlaywrightElement } from './PlaywrightAdapter.ts'; + +describe('AppiumGestureStrategy.dblTap', () => { + const strategy = new AppiumGestureStrategy(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to PlaywrightGestures.dblTap with resolved element', async () => { + const elem = Promise.resolve({}) as never; + const playwrightElement = { unwrap: jest.fn() } as never; + (asPlaywrightElement as jest.Mock).mockResolvedValue(playwrightElement); + + await strategy.dblTap(elem); + + expect(asPlaywrightElement).toHaveBeenCalledWith(elem); + expect(PlaywrightGestures.dblTap).toHaveBeenCalledWith(playwrightElement); + }); +}); + +describe('AppiumGestureStrategy.tapAtIndex', () => { + const strategy = new AppiumGestureStrategy(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createPlaywrightElement = (): PlaywrightElement => + ({ + click: jest.fn(), + unwrap: jest.fn(), + }) as unknown as PlaywrightElement; + + it('clicks indexed element when PlaywrightElement array is provided', async () => { + const first = createPlaywrightElement(); + const second = createPlaywrightElement(); + const third = createPlaywrightElement(); + + await strategy.tapAtIndex([first, second, third], 2); + + expect(third.click).toHaveBeenCalledTimes(1); + expect(second.click).not.toHaveBeenCalled(); + expect(first.click).not.toHaveBeenCalled(); + expect(asPlaywrightElement).not.toHaveBeenCalled(); + }); + + it('throws when array index is out of bounds', async () => { + const only = createPlaywrightElement(); + + await expect(strategy.tapAtIndex([only], 2)).rejects.toThrow( + 'tapAtIndex: index 2 is out of bounds (1 elements)', + ); + }); + + it('throws for single element when index is greater than zero', async () => { + const elem = Promise.resolve({}) as never; + + await expect(strategy.tapAtIndex(elem, 2)).rejects.toThrow( + 'tapAtIndex: Appium requires a PlaywrightElement[] array for index > 0.', + ); + }); + + it('uses single element pass-through when index is zero', async () => { + const elem = Promise.resolve({}) as never; + const playwrightElement = createPlaywrightElement(); + (asPlaywrightElement as jest.Mock).mockResolvedValue(playwrightElement); + + await strategy.tapAtIndex(elem, 0); + + expect(asPlaywrightElement).toHaveBeenCalledWith(elem); + expect(playwrightElement.click).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/framework/GestureStrategy.test.ts b/tests/framework/GestureStrategy.test.ts new file mode 100644 index 00000000000..4d488a61b38 --- /dev/null +++ b/tests/framework/GestureStrategy.test.ts @@ -0,0 +1,63 @@ +jest.mock('./Gestures.ts', () => ({ + __esModule: true, + default: { + scrollToElement: jest.fn(), + }, +})); + +import Gestures from './Gestures.ts'; +import { DetoxGestureStrategy } from './GestureStrategy.ts'; + +describe('DetoxGestureStrategy.scrollToElement', () => { + const strategy = new DetoxGestureStrategy(); + + const createDetoxElement = (): DetoxElement => + ({ tap: jest.fn() }) as unknown as DetoxElement; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards matcher scrollView to Gestures.scrollToElement', async () => { + const target = createDetoxElement(); + const matcher = { + type: 'id', + value: 'scroll-view', + } as unknown as Detox.NativeMatcher; + const scrollView = Promise.resolve(matcher); + + await strategy.scrollToElement(target, scrollView, { + timeout: 1000, + description: 'scroll to token', + }); + + expect(Gestures.scrollToElement).toHaveBeenCalledTimes(1); + + const [forwardedTarget, forwardedScrollView, forwardedOpts] = ( + Gestures.scrollToElement as jest.Mock + ).mock.calls[0]; + + expect(forwardedTarget).toBe(target); + await expect(forwardedScrollView).resolves.toBe(matcher); + expect(forwardedOpts).toEqual( + expect.objectContaining({ + timeout: 1000, + elemDescription: 'scroll to token', + }), + ); + }); + + it('rejects DetoxElement passed as scrollView', async () => { + const target = createDetoxElement(); + const invalidScrollView = createDetoxElement(); + + await expect( + strategy.scrollToElement( + target, + invalidScrollView as unknown as Promise, + ), + ).rejects.toThrow( + 'DetoxGestureStrategy.scrollToElement requires a Detox NativeMatcher', + ); + }); +}); diff --git a/tests/framework/GestureStrategy.ts b/tests/framework/GestureStrategy.ts new file mode 100644 index 00000000000..3e4c5c78c50 --- /dev/null +++ b/tests/framework/GestureStrategy.ts @@ -0,0 +1,442 @@ +import Gestures from './Gestures.ts'; +import PlaywrightGestures from './PlaywrightGestures.ts'; +import { PlaywrightElement } from './PlaywrightAdapter.ts'; +import { + EncapsulatedElementType, + asDetoxElement, + asPlaywrightElement, +} from './EncapsulatedElement.ts'; + +/** + * Unified options for gesture methods. + * Framework-specific options (e.g. Detox's checkStability, hideKeyboard) are + * handled internally by each strategy — page objects only deal with these + * universal options. + */ +export interface UnifiedGestureOptions { + /** Maximum time (ms) to wait for the element before timing out */ + timeout?: number; + /** Human-readable description for logging and error messages */ + description?: string; +} + +/** + * Element input for tapAtIndex — either a single element (Detox uses .atIndex()) + * or an array of elements (Appium selects by array index). + */ +export type TapAtIndexElement = EncapsulatedElementType | PlaywrightElement[]; +export type ScrollViewMatcher = Promise; + +/** + * Strategy interface for framework-agnostic gesture execution. + * + * Each method accepts an `EncapsulatedElementType` (either DetoxElement or + * Promise) so page objects never need to know which + * framework is running. + */ +export interface GestureStrategy { + tap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise; + + waitAndTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise; + + typeText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise; + + replaceText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise; + + swipe( + elem: EncapsulatedElementType, + direction: 'up' | 'down' | 'left' | 'right', + opts?: UnifiedGestureOptions, + ): Promise; + + scrollToElement( + target: EncapsulatedElementType, + scrollView: ScrollViewMatcher, + opts?: UnifiedGestureOptions, + ): Promise; + + longPress( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise; + + dblTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise; + + tapAtPoint( + elem: EncapsulatedElementType, + point: { x: number; y: number }, + opts?: UnifiedGestureOptions, + ): Promise; + + tapAtIndex( + elem: TapAtIndexElement, + index: number, + opts?: UnifiedGestureOptions, + ): Promise; +} + +/** + * Detox implementation of GestureStrategy. + * + * Wraps the existing `Gestures` class, preserving all retry logic, stability + * checks, and platform-specific scroll behaviour. `UnifiedGestureOptions` are + * mapped to Detox-specific option shapes internally. + */ +export class DetoxGestureStrategy implements GestureStrategy { + /** + * Tap an element + * @param elem - The element to tap + * @param opts - The options for the tap + * @returns A promise that resolves when the tap is complete + */ + async tap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.tap(asDetoxElement(elem), { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Wait for an element to be visible and then tap it + * @param elem - The element to wait and tap + * @param opts - The options for the wait and tap + * @returns A promise that resolves when the wait and tap is complete + */ + async waitAndTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.waitAndTap(asDetoxElement(elem), { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Type text into an element + * @param elem - The element to type text into + * @param text - The text to type + * @param opts - The options for the type text + * @returns A promise that resolves when the type text is complete + */ + async typeText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.typeText(asDetoxElement(elem), text, { + hideKeyboard: true, + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Replace text in an element + * @param elem - The element to replace text in + * @param text - The text to replace + * @param opts - The options for the replace text + * @returns A promise that resolves when the replace text is complete + */ + async replaceText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.replaceText(asDetoxElement(elem), text, { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Swipe an element + * @param elem - The element to swipe + * @param direction - The direction to swipe + * @param opts - The options for the swipe + * @returns A promise that resolves when the swipe is complete + */ + async swipe( + elem: EncapsulatedElementType, + direction: 'up' | 'down' | 'left' | 'right', + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.swipe(asDetoxElement(elem), direction, { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Scroll to an element + * @param target - The element to scroll to + * @param scrollView - The scroll view to scroll to + * @param opts - The options for the scroll to element + * @returns A promise that resolves when the scroll to element is complete + */ + async scrollToElement( + target: EncapsulatedElementType, + scrollView: ScrollViewMatcher, + opts?: UnifiedGestureOptions, + ): Promise { + const resolvedScrollView = await scrollView; + + if (this.isLikelyDetoxElement(resolvedScrollView)) { + throw new Error( + 'DetoxGestureStrategy.scrollToElement requires a Detox NativeMatcher ' + + '(e.g. Matchers.getIdentifier(...) or by.id(...)), not a DetoxElement.', + ); + } + + await Gestures.scrollToElement( + asDetoxElement(target), + Promise.resolve(resolvedScrollView), + { + timeout: opts?.timeout, + elemDescription: opts?.description, + }, + ); + } + + private isLikelyDetoxElement(value: unknown): value is DetoxElement { + return ( + typeof value === 'object' && + value !== null && + 'tap' in value && + typeof (value as { tap?: unknown }).tap === 'function' + ); + } + + /** + * Long press an element + * @param elem - The element to long press + * @param opts - The options for the long press + * @returns A promise that resolves when the long press is complete + */ + async longPress( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.longPress(asDetoxElement(elem), { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Double tap an element + * @param elem - The element to double tap + * @param opts - The options for the double tap + * @returns A promise that resolves when the double tap is complete + */ + async dblTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.dblTap(asDetoxElement(elem), { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Tap at a point on an element + * @param elem - The element to tap at a point on + * @param point - The point to tap at + * @param opts - The options for the tap at point + * @returns A promise that resolves when the tap at point is complete + */ + async tapAtPoint( + elem: EncapsulatedElementType, + point: { x: number; y: number }, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.tapAtPoint(asDetoxElement(elem), point, { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Tap at an index on an element + * @param elem - The element to tap at an index on + * @param index - The index to tap at + * @param opts - The options for the tap at index + * @returns A promise that resolves when the tap at index is complete + */ + async tapAtIndex( + elem: EncapsulatedElementType, + index: number, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.tapAtIndex(asDetoxElement(elem), index, { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } +} + +/** + * Appium/WebdriverIO implementation of GestureStrategy. + * + * Wraps `PlaywrightElement` and `PlaywrightGestures`. + */ +export class AppiumGestureStrategy implements GestureStrategy { + /** + * Tap an element + * @param elem - The element to tap + * @returns A promise that resolves when the tap is complete + */ + async tap(elem: EncapsulatedElementType): Promise { + const el = await asPlaywrightElement(elem); + await el.click(); + } + + /** + * Wait for an element to be visible and then tap it + * @param elem - The element to wait and tap + * @returns A promise that resolves when the wait and tap is complete + */ + async waitAndTap(elem: EncapsulatedElementType): Promise { + const el = await asPlaywrightElement(elem); + await el.click(); + } + + /** + * Type text into an element + * @param elem - The element to type text into + * @param text - The text to type + * @returns A promise that resolves when the type text is complete + */ + async typeText(elem: EncapsulatedElementType, text: string): Promise { + const el = await asPlaywrightElement(elem); + await el.fill(text); + } + + /** + * Replace text in an element + * @param elem - The element to replace text in + * @param text - The text to replace + * @returns A promise that resolves when the replace text is complete + */ + async replaceText( + elem: EncapsulatedElementType, + text: string, + ): Promise { + const el = await asPlaywrightElement(elem); + await el.clear(); + await el.fill(text); + } + + /** + * Swipe an element + * @param elem - The element to swipe + * @param direction - The direction to swipe + * @returns A promise that resolves when the swipe is complete + */ + async swipe( + elem: EncapsulatedElementType, + direction: 'up' | 'down' | 'left' | 'right', + ): Promise { + const el = await asPlaywrightElement(elem); + await PlaywrightGestures.swipe(el, direction); + } + + /** + * Scroll to an element + * @param target - The element to scroll to + * @param scrollView - The scroll view to scroll to + * @returns A promise that resolves when the scroll to element is complete + */ + async scrollToElement( + target: EncapsulatedElementType, + _scrollView: ScrollViewMatcher, + ): Promise { + const el = await asPlaywrightElement(target); + await PlaywrightGestures.scrollIntoView(el); + } + + /** + * Long press an element + * @param elem - The element to long press + * @returns A promise that resolves when the long press is complete + */ + async longPress(elem: EncapsulatedElementType): Promise { + const el = await asPlaywrightElement(elem); + await PlaywrightGestures.longPress(el); + } + + /** + * Double tap an element + * @param elem - The element to double tap + * @returns A promise that resolves when the double tap is complete + */ + async dblTap(elem: EncapsulatedElementType): Promise { + const el = await asPlaywrightElement(elem); + await PlaywrightGestures.dblTap(el); + } + + /** + * Tap at a point on an element + * @param elem - The element to tap at a point on + * @param point - The point to tap at + * @returns A promise that resolves when the tap at point is complete + */ + async tapAtPoint( + elem: EncapsulatedElementType, + point: { x: number; y: number }, + ): Promise { + const el = await asPlaywrightElement(elem); + await el.tapOnCoordinates(point); + } + + /** + * Tap at an index on an element + * @param elem - The element to tap at an index on + * @param index - The index to tap at + * @returns A promise that resolves when the tap at index is complete + */ + async tapAtIndex(elem: TapAtIndexElement, index: number): Promise { + // If an array of PlaywrightElements is provided, tap the one at `index` + if (Array.isArray(elem)) { + const elements = elem as PlaywrightElement[]; + if (index < 0 || index >= elements.length) { + throw new Error( + `tapAtIndex: index ${index} is out of bounds (${elements.length} elements)`, + ); + } + await elements[index].click(); + return; + } + + // Single element: allow index 0 as a pass-through, reject anything else + if (index !== 0) { + throw new Error( + `tapAtIndex: Appium requires a PlaywrightElement[] array for index > 0. ` + + `Received single element with index ${index}.`, + ); + } + const el = await asPlaywrightElement(elem); + await el.click(); + } +} diff --git a/tests/framework/PlaywrightGestures.ts b/tests/framework/PlaywrightGestures.ts index 441d8ee3c5d..07fd17abd6a 100644 --- a/tests/framework/PlaywrightGestures.ts +++ b/tests/framework/PlaywrightGestures.ts @@ -82,6 +82,31 @@ export default class PlaywrightGestures { ]); } + /** + * Double tap an element using native touch actions. + * + * Using explicit touchAction avoids relying on desktop-oriented click + * semantics and keeps both taps within a mobile-appropriate interval. + */ + @boxedStep + static async dblTap(elem: PlaywrightElement, intervalMs = 60): Promise { + const location = await elem.unwrap().getLocation(); + const size = await elem.unwrap().getSize(); + + const x = location.x + size.width / 2; + const y = location.y + size.height / 2; + + await elem + .unwrap() + .touchAction([ + { action: 'press', x, y }, + 'release', + { action: 'wait', ms: intervalMs }, + { action: 'press', x, y }, + 'release', + ]); + } + /** * Scroll element into view */ diff --git a/tests/framework/UnifiedGestures.ts b/tests/framework/UnifiedGestures.ts new file mode 100644 index 00000000000..381b2b033e9 --- /dev/null +++ b/tests/framework/UnifiedGestures.ts @@ -0,0 +1,127 @@ +import { FrameworkDetector } from './FrameworkDetector.ts'; +import { EncapsulatedElementType } from './EncapsulatedElement.ts'; +import { + GestureStrategy, + UnifiedGestureOptions, + TapAtIndexElement, + ScrollViewMatcher, + DetoxGestureStrategy, + AppiumGestureStrategy, +} from './GestureStrategy.ts'; + +/** + * UnifiedGestures — Static facade for framework-agnostic gesture execution. + * + * The framework strategy is resolved **once** on first use and cached for the + * lifetime of the test run. Page objects call these static methods directly + * and never need to know whether Detox or Appium is running. + * + * @example + * ```typescript + * import { UnifiedGestures } from '../framework'; + * + * class LoginView { + * get passwordInput(): EncapsulatedElementType { ... } + * + * async enterPassword(password: string) { + * await UnifiedGestures.typeText(this.passwordInput, password); + * } + * } + * ``` + */ +export default class UnifiedGestures { + private static _strategy: GestureStrategy | null = null; + + /** Lazily resolve and cache the active strategy */ + private static get strategy(): GestureStrategy { + if (!this._strategy) { + this._strategy = FrameworkDetector.isDetox() + ? new DetoxGestureStrategy() + : new AppiumGestureStrategy(); + } + return this._strategy; + } + + /** Reset the cached strategy (useful in tests) */ + static resetStrategy(): void { + this._strategy = null; + } + + // ── Gesture Methods ───────────────────────────────────────── + + static async tap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.tap(elem, opts); + } + + static async waitAndTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.waitAndTap(elem, opts); + } + + static async typeText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.typeText(elem, text, opts); + } + + static async replaceText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.replaceText(elem, text, opts); + } + + static async swipe( + elem: EncapsulatedElementType, + direction: 'up' | 'down' | 'left' | 'right', + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.swipe(elem, direction, opts); + } + + static async scrollToElement( + target: EncapsulatedElementType, + scrollView: ScrollViewMatcher, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.scrollToElement(target, scrollView, opts); + } + + static async longPress( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.longPress(elem, opts); + } + + static async dblTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.dblTap(elem, opts); + } + + static async tapAtPoint( + elem: EncapsulatedElementType, + point: { x: number; y: number }, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.tapAtPoint(elem, point, opts); + } + + static async tapAtIndex( + elem: TapAtIndexElement, + index: number, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.tapAtIndex(elem, index, opts); + } +} diff --git a/tests/framework/encapsulatedAction.ts b/tests/framework/encapsulatedAction.ts new file mode 100644 index 00000000000..7a5e1d8d59f --- /dev/null +++ b/tests/framework/encapsulatedAction.ts @@ -0,0 +1,36 @@ +import { FrameworkDetector } from './FrameworkDetector.ts'; + +/** + * Escape hatch for page-object methods that need entirely different + * control flow per framework (~3% of cases). + * + * Use `UnifiedGestures` for the common case. Reach for `encapsulatedAction` + * only when the Detox and Appium flows differ structurally (e.g. different + * sequences of taps, waits, or scrolls). + * + * @example + * ```typescript + * async dismissOnboarding() { + * await encapsulatedAction({ + * detox: async () => { + * await Gestures.swipe(this.overlay, 'up'); + * await Gestures.tap(this.dismissButton); + * }, + * appium: async () => { + * const btn = await asPlaywrightElement(this.dismissButton); + * await btn.click(); + * }, + * }); + * } + * ``` + */ +export async function encapsulatedAction(config: { + detox: () => Promise; + appium: () => Promise; +}): Promise { + if (FrameworkDetector.isDetox()) { + await config.detox(); + } else { + await config.appium(); + } +} diff --git a/tests/framework/index.ts b/tests/framework/index.ts index 665982796ed..a70108f209d 100644 --- a/tests/framework/index.ts +++ b/tests/framework/index.ts @@ -38,3 +38,13 @@ export { export { FrameworkDetector, TestFramework } from './FrameworkDetector.ts'; export { PlatformDetector } from './PlatformLocator.ts'; +export { default as UnifiedGestures } from './UnifiedGestures.ts'; +export { encapsulatedAction } from './encapsulatedAction.ts'; +export { + DetoxGestureStrategy, + AppiumGestureStrategy, + type GestureStrategy, + type UnifiedGestureOptions, + type TapAtIndexElement, + type ScrollViewMatcher, +} from './GestureStrategy.ts'; diff --git a/tests/page-objects/Send/RedesignedSendView.ts b/tests/page-objects/Send/RedesignedSendView.ts index 36dcb74e268..567a0daec68 100644 --- a/tests/page-objects/Send/RedesignedSendView.ts +++ b/tests/page-objects/Send/RedesignedSendView.ts @@ -127,6 +127,7 @@ class SendView { await Utilities.waitForElementToBeEnabled(this.reviewButton); await Gestures.waitAndTap(this.reviewButton, { elemDescription: 'Review button', + checkStability: true, }); } diff --git a/tests/page-objects/Send/TransactionConfirmView.ts b/tests/page-objects/Send/TransactionConfirmView.ts index ab17d40a402..9fb8b53aa45 100644 --- a/tests/page-objects/Send/TransactionConfirmView.ts +++ b/tests/page-objects/Send/TransactionConfirmView.ts @@ -164,6 +164,12 @@ class TransactionConfirmationView { elemDescription: 'Gas Fee Token Pill in Confirmation View', }); } + + async tapAdvancedDetails(): Promise { + await Gestures.waitAndTap(RowComponents.AdvancedDetails, { + elemDescription: 'Advanced details in Confirmation View', + }); + } } export default new TransactionConfirmationView(); diff --git a/tests/selectors/Browser/TestSnaps.selectors.ts b/tests/selectors/Browser/TestSnaps.selectors.ts index c73980ba39e..ab786a90d3c 100644 --- a/tests/selectors/Browser/TestSnaps.selectors.ts +++ b/tests/selectors/Browser/TestSnaps.selectors.ts @@ -21,6 +21,7 @@ export const TestSnapViewSelectorWebIDS = { connectInteractiveButton: 'connectinteractive-ui', connectManageStateButton: 'connectmanage-state', connectMultichainProviderButton: 'connectmultichain-provider', + connectNameLookupButton: 'connectname-lookup', connectNetworkAccessButton: 'connectnetwork-access', connectEthereumProviderButton: 'connectethereum-provider', connectStateButton: 'connectstate', diff --git a/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts b/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts index 3c29e8929ae..98ab85f07b1 100644 --- a/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts +++ b/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts @@ -147,13 +147,24 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { await navigateToBrowserView(); await Browser.navigateToTestDApp(); + await Assertions.expectElementToBeVisible(TestDApp.testDappPageTitle, { + description: 'Test dapp page title should be visible', + }); + await Assertions.expectElementToBeVisible(TestDApp.sendEIP1559Button, { + description: 'Send EIP1559 button should be visible', + }); await TestDApp.tapSendEIP1559Button(); // Check all expected elements are visible await Assertions.expectElementToBeVisible( ConfirmationUITypes.ModalConfirmationContainer, + { + description: 'Transaction confirmation modal should be visible', + }, ); - await Assertions.expectElementToBeVisible(RowComponents.TokenHero); + await Assertions.expectElementToBeVisible(RowComponents.TokenHero, { + description: 'Token hero row should be visible', + }); await Assertions.expectTextDisplayed('0 ETH'); await Assertions.expectElementToBeVisible(RowComponents.FromTo); await Assertions.expectElementToBeVisible( @@ -169,11 +180,34 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { // Scroll to Advanced Details section on Android if (device.getPlatform() === 'android') { - await Gestures.swipe(RowComponents.GasFeesDetails, 'up'); + await Gestures.swipe( + ConfirmationUITypes.ModalConfirmationContainer, + 'up', + { + elemDescription: 'Scroll transaction confirmation content', + }, + ); } + await Assertions.expectElementToBeVisible( + RowComponents.SimulationDetails, + { + description: 'Simulation details row should be visible', + timeout: 30000, + }, + ); + await Assertions.expectElementToBeVisible( + RowComponents.GasFeesDetails, + { + description: 'Gas fees details row should be visible', + timeout: 30000, + }, + ); await Assertions.expectElementToBeVisible( RowComponents.AdvancedDetails, + { + description: 'Advanced details row should be visible', + }, ); // Accept confirmation diff --git a/tests/smoke/snaps/test-snap-ethereum-provider.spec.ts b/tests/smoke/snaps/test-snap-ethereum-provider.spec.ts index 97d18f50a4a..48484acfb1e 100644 --- a/tests/smoke/snaps/test-snap-ethereum-provider.spec.ts +++ b/tests/smoke/snaps/test-snap-ethereum-provider.spec.ts @@ -9,10 +9,7 @@ import ConnectBottomSheet from '../../page-objects/Browser/ConnectBottomSheet'; import RequestTypes from '../../page-objects/Browser/Confirmations/RequestTypes'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; -import { - confirmationFeatureFlags, - remoteFeatureMultichainAccountsAccountDetailsV2, -} from '../../api-mocking/mock-responses/feature-flags-mocks'; +import { confirmationFeatureFlags } from '../../api-mocking/mock-responses/feature-flags-mocks'; import { mockGenesisBlocks } from './mocks'; jest.setTimeout(150_000); @@ -25,11 +22,10 @@ describe(FlaskBuildTests('Ethereum Provider Snap Tests'), () => { restartDevice: true, skipReactNativeReload: true, testSpecificMock: async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - ...Object.assign({}, ...confirmationFeatureFlags), - ...remoteFeatureMultichainAccountsAccountDetailsV2(false), - }); - + await setupRemoteFeatureFlagsMock( + mockServer, + Object.assign({}, ...confirmationFeatureFlags), + ); await mockGenesisBlocks(mockServer); }, }, diff --git a/tests/smoke/snaps/test-snap-name-lookup.spec.ts b/tests/smoke/snaps/test-snap-name-lookup.spec.ts new file mode 100644 index 00000000000..e2d2a36307d --- /dev/null +++ b/tests/smoke/snaps/test-snap-name-lookup.spec.ts @@ -0,0 +1,77 @@ +import { FlaskBuildTests } from '../../tags'; +import { loginToApp } from '../../flows/wallet.flow'; +import { navigateToBrowserView } from '../../flows/browser.flow'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import TestSnaps from '../../page-objects/Browser/TestSnaps'; +import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; +import WalletView from '../../page-objects/wallet/WalletView'; +import RedesignedSendView from '../../page-objects/Send/RedesignedSendView'; +import { Assertions, Gestures, LocalNode, Matchers } from '../../framework'; +import BrowserView from '../../page-objects/Browser/BrowserView'; +import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../seeder/anvil-manager'; +import TransactionConfirmView from '../../page-objects/Send/TransactionConfirmView'; +import TokenOverview from '../../page-objects/wallet/TokenOverview'; + +jest.setTimeout(150_000); + +const TOKEN = 'Ethereum'; + +describe(FlaskBuildTests('Name Lookup Snap Tests'), () => { + it('displays the resolved recipient address in the send flow', async () => { + await withFixtures( + { + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + + return new FixtureBuilder() + .withNetworkController({ + providerConfig: { + chainId: '0x1', + rpcUrl: `http://localhost:${node.getPort() ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', + }, + }) + .build(); + }, + restartDevice: true, + skipReactNativeReload: true, + }, + async () => { + await loginToApp(); + await navigateToBrowserView(); + await TestSnaps.navigateToTestSnap(); + + await TestSnaps.installSnap('connectNameLookupButton'); + + await BrowserView.tapCloseBrowserButton(); + await TabBarComponent.tapHome(); + await device.disableSynchronization(); + await WalletView.waitForTokenToBeReady(TOKEN); + await WalletView.tapOnToken(TOKEN); + await TokenOverview.tapSendButton(); + + const domain = 'metamask.domain'; + await RedesignedSendView.enterZeroAmount(); + await RedesignedSendView.pressContinueButton(); + await RedesignedSendView.inputRecipientAddress(domain); + await RedesignedSendView.pressReviewButton(); + await TransactionConfirmView.tapAdvancedDetails(); + + await Gestures.waitAndTap( + Matchers.getElementByText( + domain, + device.getPlatform() === 'ios' ? 1 : 0, + ), + ); + + await Assertions.expectTextDisplayed( + '0xc0ffee254729296a45a3885639ac7e10f9d54979', + ); + }, + ); + }); +});