diff --git a/.js.env.example b/.js.env.example index a6a75452422f..ec8e61873c74 100644 --- a/.js.env.example +++ b/.js.env.example @@ -197,6 +197,9 @@ export MM_PERPS_MYX_BROKER_ADDRESS_TESTNET="" export MM_PERPS_MYX_APP_ID_MAINNET="" export MM_PERPS_MYX_API_SECRET_MAINNET="" export MM_PERPS_MYX_BROKER_ADDRESS_MAINNET="" +# HyperLiquid builder fee wallet addresses (empty = uses hardcoded defaults) +export MM_PERPS_HL_BUILDER_ADDRESS_TESTNET="" +export MM_PERPS_HL_BUILDER_ADDRESS_MAINNET="" # HIP-3 Feature Flags (remote override with local fallback) export MM_PERPS_HIP3_ENABLED="true" export MM_PERPS_HIP3_ALLOWLIST_MARKETS="" # Allowlist: Empty = enable all markets. Examples: "xyz:XYZ100,xyz:TSLA" or "xyz:*,abc:TSLA" diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index a7c63e8d443b..9740c90ea71f 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -417,6 +417,15 @@ export const PerpsTransactionSelectorsIDs = { FUNDING_TRANSACTION_VIEW: 'perps-funding-transaction-view', ORDER_TRANSACTION_VIEW: 'perps-order-transaction-view', + // FlashList + FLASH_LIST: 'perps-transactions-flash-list', + + // Fill tags + FILL_TAG_TAKE_PROFIT: 'perps-fill-tag-take-profit', + FILL_TAG_STOP_LOSS: 'perps-fill-tag-stop-loss', + FILL_TAG_LIQUIDATED: 'perps-fill-tag-liquidated', + FILL_TAG_ADL: 'perps-fill-tag-adl', + // Common buttons BLOCK_EXPLORER_BUTTON: 'block-explorer-button', }; diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.styles.ts b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.styles.ts index 61758f7ccaa1..de9f2a783d57 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.styles.ts +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.styles.ts @@ -56,6 +56,11 @@ export const styleSheet = (params: { theme: Theme }) => { fontSize: 14, color: colors.text.alternative, }, + fillTag: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 8, + }, rightContent: { alignItems: 'flex-end' as const, }, diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx index 186ad0df69f5..8abce05f38ee 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx @@ -481,6 +481,7 @@ const PerpsTransactionsView: React.FC = () => { )} ({ @@ -212,3 +215,62 @@ describe('createMobileInfrastructure', () => { }); }); }); + +describe('createMobileClientConfig', () => { + it('returns default config with empty strings and arrays when no env vars are set', () => { + // Arrange — ensure relevant env vars are absent + const envVars = [ + 'MM_PERPS_BLOCKED_REGIONS', + 'MM_PERPS_HIP3_ENABLED', + 'MM_PERPS_HIP3_ALLOWLIST_MARKETS', + 'MM_PERPS_HIP3_BLOCKLIST_MARKETS', + 'MM_PERPS_HL_BUILDER_ADDRESS_TESTNET', + 'MM_PERPS_HL_BUILDER_ADDRESS_MAINNET', + 'MM_PERPS_MYX_PROVIDER_ENABLED', + 'MM_PERPS_MYX_APP_ID_TESTNET', + 'MM_PERPS_MYX_API_SECRET_TESTNET', + 'MM_PERPS_MYX_BROKER_ADDRESS_TESTNET', + 'MM_PERPS_MYX_APP_ID_MAINNET', + 'MM_PERPS_MYX_API_SECRET_MAINNET', + 'MM_PERPS_MYX_BROKER_ADDRESS_MAINNET', + ]; + const saved: Record = {}; + for (const key of envVars) { + saved[key] = process.env[key]; + delete process.env[key]; + } + + // Act + const config = createMobileClientConfig(); + + // Assert + expect(config).toEqual({ + fallbackBlockedRegions: [], + fallbackHip3Enabled: false, + fallbackHip3AllowlistMarkets: [], + fallbackHip3BlocklistMarkets: [], + providerCredentials: { + hyperliquid: { + builderAddressTestnet: '', + builderAddressMainnet: '', + }, + myx: { + enabled: false, + appIdTestnet: '', + apiSecretTestnet: '', + brokerAddressTestnet: '', + appIdMainnet: '', + apiSecretMainnet: '', + brokerAddressMainnet: '', + }, + }, + }); + + // Restore + for (const key of envVars) { + if (saved[key] !== undefined) { + process.env[key] = saved[key]; + } + } + }); +}); diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.ts index 5dbf7f24858d..9bd0eee51987 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.ts @@ -21,7 +21,9 @@ import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; import Engine from '../../../../core/Engine'; import { PERPS_CONSTANTS, + parseCommaSeparatedString, type PerpsPlatformDependencies, + type PerpsControllerConfig, type PerpsMetrics, type PerpsTraceName, type PerpsTraceValue, @@ -155,6 +157,44 @@ function createCacheInvalidatorAdapter() { }; } +/** + * Creates mobile-specific client config from environment variables. + * Centralizes all process.env reads so the Engine init file stays pure wiring. + */ +export function createMobileClientConfig(): PerpsControllerConfig { + return { + fallbackBlockedRegions: parseCommaSeparatedString( + process.env.MM_PERPS_BLOCKED_REGIONS ?? '', + ), + fallbackHip3Enabled: process.env.MM_PERPS_HIP3_ENABLED === 'true', + fallbackHip3AllowlistMarkets: parseCommaSeparatedString( + process.env.MM_PERPS_HIP3_ALLOWLIST_MARKETS ?? '', + ), + fallbackHip3BlocklistMarkets: parseCommaSeparatedString( + process.env.MM_PERPS_HIP3_BLOCKLIST_MARKETS ?? '', + ), + providerCredentials: { + hyperliquid: { + builderAddressTestnet: + process.env.MM_PERPS_HL_BUILDER_ADDRESS_TESTNET ?? '', + builderAddressMainnet: + process.env.MM_PERPS_HL_BUILDER_ADDRESS_MAINNET ?? '', + }, + myx: { + enabled: process.env.MM_PERPS_MYX_PROVIDER_ENABLED === 'true', + appIdTestnet: process.env.MM_PERPS_MYX_APP_ID_TESTNET ?? '', + apiSecretTestnet: process.env.MM_PERPS_MYX_API_SECRET_TESTNET ?? '', + brokerAddressTestnet: + process.env.MM_PERPS_MYX_BROKER_ADDRESS_TESTNET ?? '', + appIdMainnet: process.env.MM_PERPS_MYX_APP_ID_MAINNET ?? '', + apiSecretMainnet: process.env.MM_PERPS_MYX_API_SECRET_MAINNET ?? '', + brokerAddressMainnet: + process.env.MM_PERPS_MYX_BROKER_ADDRESS_MAINNET ?? '', + }, + }, + }; +} + /** * Creates mobile-specific platform dependencies for PerpsController. * Controller access uses messenger pattern (messenger.call()). diff --git a/app/components/UI/Perps/components/PerpsFillTag/PerpsFillTag.tsx b/app/components/UI/Perps/components/PerpsFillTag/PerpsFillTag.tsx index 29a2e47ffc79..d32afc002402 100644 --- a/app/components/UI/Perps/components/PerpsFillTag/PerpsFillTag.tsx +++ b/app/components/UI/Perps/components/PerpsFillTag/PerpsFillTag.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { TouchableOpacity, Linking } from 'react-native'; +import { View, TouchableOpacity, Linking } from 'react-native'; import { useSelector } from 'react-redux'; import Text, { TextColor, @@ -20,6 +20,7 @@ import { PERPS_EVENT_VALUE, } from '@metamask/perps-controller'; import { FillType, PerpsTransaction } from '../../types/transactionHistory'; +import { PerpsTransactionSelectorsIDs } from '../../Perps.testIds'; interface PerpsFillTagProps { transaction: PerpsTransaction; @@ -61,6 +62,7 @@ const PerpsFillTag: React.FC = ({ severity: TagSeverity.Info, textColor: TextColor.Default, includesBorder: false, + testID: PerpsTransactionSelectorsIDs.FILL_TAG_ADL, }, [FillType.Liquidation]: { // Only show if liquidated user is current user @@ -73,18 +75,21 @@ const PerpsFillTag: React.FC = ({ severity: TagSeverity.Danger, textColor: TextColor.Error, includesBorder: false, + testID: PerpsTransactionSelectorsIDs.FILL_TAG_LIQUIDATED, }, [FillType.TakeProfit]: { label: strings('perps.transactions.order.take_profit'), severity: TagSeverity.Default, textColor: TextColor.Alternative, includesBorder: true, + testID: PerpsTransactionSelectorsIDs.FILL_TAG_TAKE_PROFIT, }, [FillType.StopLoss]: { label: strings('perps.transactions.order.stop_loss'), severity: TagSeverity.Default, textColor: TextColor.Alternative, includesBorder: true, + testID: PerpsTransactionSelectorsIDs.FILL_TAG_STOP_LOSS, }, }; @@ -95,15 +100,17 @@ const PerpsFillTag: React.FC = ({ } const tagContent = ( - - - {tagConfig.label} - - + + + + {tagConfig.label} + + + ); // Only wrap in TouchableOpacity for ADL fill type which has an action. diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts index 304bab74e86a..86ad423933ef 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts @@ -976,6 +976,57 @@ describe('usePerpsHomeData', () => { }); }); + it('preserves detailedOrderType from REST fill when WS fill lacks it', async () => { + // Arrange — REST fill has enriched detailedOrderType + const restFill = createMockOrderFill({ + orderId: 'fill-tp-1', + symbol: 'BTC', + timestamp: 1234567800, + detailedOrderType: 'Take Profit Limit', + }); + const mockGetOrderFills = jest.fn().mockResolvedValue([restFill]); + ( + Engine.context.PerpsController.getActiveProviderOrNull as jest.Mock + ).mockReturnValue({ + getOrderFills: mockGetOrderFills, + }); + + // WS fill with same key but no detailedOrderType + const wsFill = createMockOrderFill({ + orderId: 'fill-tp-1', + symbol: 'BTC', + timestamp: 1234567800, + }); + mockUsePerpsLiveFills.mockReturnValue({ + fills: [wsFill], + isInitialLoading: false, + }); + + mockUsePerpsConnection.mockReturnValue({ + isConnected: true, + isInitialized: true, + isConnecting: false, + error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + } as never); + + // Act + const { result } = renderHook(() => usePerpsHomeData()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Assert — recentActivity contains the merged fill with preserved detailedOrderType + // The detailedOrderType from REST is preserved during merge, then + // transformFillsToTransactions converts it to FillType.TakeProfit + expect(result.current.recentActivity).toHaveLength(1); + expect(result.current.recentActivity[0].fill?.fillType).toBe( + FillType.TakeProfit, + ); + }); + it('handles special characters in search query', () => { const { result } = renderHook(() => usePerpsHomeData({ searchQuery: '$BTC*' }), diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.ts index 531716bd08dd..72df7f435e97 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.ts @@ -136,9 +136,20 @@ export const usePerpsHomeData = ({ } // Add live fills (overwrites duplicates from REST - live data is fresher) + // Preserve detailedOrderType from REST fills since WS fills lack it for (const fill of liveFills) { const key = `${fill.orderId}-${fill.timestamp}`; - fillsMap.set(key, fill); + const existing = fillsMap.get(key); + if (existing?.detailedOrderType && !fill.detailedOrderType) { + fillsMap.set(key, { + ...fill, + detailedOrderType: existing.detailedOrderType, + ...(existing.liquidation && + !fill.liquidation && { liquidation: existing.liquidation }), + }); + } else { + fillsMap.set(key, fill); + } } // Convert back to array and sort by timestamp descending (newest first) diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts index 08938264bcc3..76675819661d 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts @@ -435,9 +435,9 @@ describe('usePerpsOrderForm', () => { }); // Assert - // With $2 balance and 3x leverage = $6 max amount, which is less than $10 default - // Should use the max possible amount ($6) instead of the default ($10) - expect(result.current.orderForm.amount).toBe('6'); + // With $2 balance and 3x leverage, max is floor(6 * (1 - 0.5% buffer)) = 5 (less than $10 default) + // Should use the max possible amount (5) instead of the default ($10) + expect(result.current.orderForm.amount).toBe('5'); }); it('should use default amount when available balance times leverage is greater than default amount', () => { @@ -468,14 +468,14 @@ describe('usePerpsOrderForm', () => { describe('useMemo and useEffect behavior', () => { it('should not overwrite user input when dependencies change', async () => { - // Arrange - Start with balance high enough that max >= 999 (e.g. 334 * 3x = 1002) + // Arrange - Start with balance high enough that max >= 999 after 0.5% buffer (e.g. 335 * 3x → floor(1005*0.995) = 999) const mockAccount = { account: { - availableBalance: '334', + availableBalance: '335', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', - totalBalance: '334', + totalBalance: '335', }, isInitialLoading: false, }; @@ -490,7 +490,7 @@ describe('usePerpsOrderForm', () => { TRADING_DEFAULTS.amount.mainnet.toString(), ); - // Act - User changes the amount (within current max) + // Act - User changes the amount (within current max; 335*3*0.995 >= 999) act(() => { result.current.setAmount('999'); }); @@ -515,7 +515,7 @@ describe('usePerpsOrderForm', () => { // Test 1: Low balance scenario mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '2', // $2 balance = $6 max with 3x leverage (less than $10 default) + availableBalance: '2', // $2 balance, 3x leverage: max = floor(6 * 0.995) = 5 (less than $10 default) marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -528,7 +528,7 @@ describe('usePerpsOrderForm', () => { wrapper: createWrapper(), }); - expect(result1.current.orderForm.amount).toBe('6'); // Should use maxPossibleAmount + expect(result1.current.orderForm.amount).toBe('5'); // Should use maxPossibleAmount (with margin buffer) // Test 2: High balance scenario mockUsePerpsLiveAccount.mockReturnValue({ @@ -690,7 +690,8 @@ describe('usePerpsOrderForm', () => { result.current.handleMaxAmount(); }); - expect(result.current.orderForm.amount).toBe('3000'); // 1000 * 3x leverage + // 1000 * 3x leverage with 0.5% margin buffer = floor(3000 * 0.995) = 2985 + expect(result.current.orderForm.amount).toBe('2985'); }); it('should handle min amount for mainnet', () => { @@ -722,6 +723,27 @@ describe('usePerpsOrderForm', () => { ); }); + it('should clamp near-100% amounts to maxPossibleAmount', () => { + const { result } = renderHook(() => usePerpsOrderForm(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handlePercentageAmount(0.999); + }); + + const at999 = Number(result.current.orderForm.amount); + + act(() => { + result.current.handlePercentageAmount(1); + }); + + const at100 = Number(result.current.orderForm.amount); + + expect(at999).toBeLessThanOrEqual(at100); + expect(at100).toBe(result.current.maxPossibleAmount); + }); + it('should not update amount when balance is 0', () => { mockUsePerpsLiveAccount.mockReturnValue({ account: { diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts index 1efff43fb74c..05f021c00aaa 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts @@ -317,26 +317,28 @@ export function usePerpsOrderForm( setOrderForm((prev) => ({ ...prev, type })); }; - // Handle percentage-based amount selection (respects custom token amount when set) + // Handle percentage-based amount selection (respects custom token amount when set). + // Clamp to maxPossibleAmount so near-100% values never exceed the buffered max. const handlePercentageAmount = useCallback( (percentage: number) => { if (balanceForMax === 0) return; - const newAmount = Math.floor( - balanceForMax * orderForm.leverage * percentage, - ).toString(); + const raw = balanceForMax * orderForm.leverage * percentage; + const clamped = Math.min(raw, maxPossibleAmount); + const newAmount = Math.floor(clamped).toString(); setOrderForm((prev) => ({ ...prev, amount: newAmount })); }, - [balanceForMax, orderForm.leverage], + [balanceForMax, orderForm.leverage, maxPossibleAmount], ); - // Handle max amount selection (respects custom token amount when set) + // Handle max amount selection (respects custom token amount when set). + // Uses maxPossibleAmount (includes margin buffer) to avoid "Insufficient margin" rejections. const handleMaxAmount = useCallback(() => { if (balanceForMax === 0) return; setOrderForm((prev) => ({ ...prev, - amount: Math.floor(balanceForMax * prev.leverage).toString(), + amount: Math.floor(maxPossibleAmount).toString(), })); - }, [balanceForMax]); + }, [balanceForMax, maxPossibleAmount]); // Handle min amount selection const handleMinAmount = useCallback(() => { diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts index 6494d03506ad..548acd84f90b 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts @@ -1077,6 +1077,72 @@ describe('usePerpsTransactionHistory', () => { }); }); + describe('WS fill merge preserves fillType from REST', () => { + it('preserves non-standard fillType when WS fill has standard', async () => { + // Arrange — REST returns a trade with stop_loss fillType + const restTrade = { + ...mockTransformedTransactions[0], + id: 'rest-sl-1', + asset: 'BTC', + timestamp: 1641000000000, + fill: { + ...mockTransformedTransactions[0].fill, + fillType: FillType.StopLoss, + }, + }; + // Live fill with same asset+timestamp(seconds) but standard fillType + const wsFill = { + ...mockTransformedTransactions[0], + id: 'ws-sl-1', + asset: 'BTC', + timestamp: 1641000000000, + fill: { + ...mockTransformedTransactions[0].fill, + fillType: FillType.Standard, + }, + }; + + // Call order: (1) useMemo on initial render with liveFills, + // (2) fetchAllTransactions with REST fills (sets state), + // (3) useMemo re-runs with liveFills after state update + mockTransformFillsToTransactions + .mockReturnValueOnce([wsFill]) // initial render: live fills + .mockReturnValueOnce([restTrade]) // fetchAllTransactions: REST fills + .mockReturnValue([wsFill]); // re-render: live fills again + + mockUsePerpsLiveFills.mockReturnValue({ + fills: [ + { + orderId: 'ws-1', + timestamp: 1641000000000, + symbol: 'BTC', + side: 'buy', + size: '0.1', + price: '50000', + pnl: '0', + direction: 'Open Long', + fee: '5', + feeToken: 'USDC', + }, + ], + isInitialLoading: false, + }); + + // Act + const { result } = renderHook(() => usePerpsTransactionHistory()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Assert — merged trade preserves the stop_loss fillType + const trades = result.current.transactions.filter( + (tx) => tx.type === 'trade', + ); + expect(trades).toHaveLength(1); + expect(trades[0].fill?.fillType).toBe(FillType.StopLoss); + }); + }); + describe('connection state transitions', () => { it('triggers fetch when skipInitialFetch transitions from true to false', async () => { // Reset mocks to track calls clearly diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts index 3db153d84f6c..1a1b371e2322 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts @@ -9,7 +9,7 @@ import type { CaipAccountId } from '@metamask/utils'; import { areAddressesEqual } from '../../../../util/address'; import { selectNonReplacedTransactions } from '../../../../selectors/transactionController'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; -import { PerpsTransaction } from '../types/transactionHistory'; +import { FillType, PerpsTransaction } from '../types/transactionHistory'; import { useUserHistory } from './useUserHistory'; import { usePerpsLiveFills } from './stream/usePerpsLiveFills'; import { @@ -288,10 +288,30 @@ export const usePerpsTransactionHistory = ({ } // Add live fills (overwrites REST duplicates - live data is fresher) + // Preserve fillType from REST when WS fill lacks enrichment (TP/SL pills) for (const tx of liveTransactions) { const timestampSeconds = Math.floor(tx.timestamp / 1000); const dedupKey = `${tx.asset}-${timestampSeconds}`; - tradeMap.set(dedupKey, tx); + const existing = tradeMap.get(dedupKey); + if ( + existing?.fill?.fillType && + existing.fill.fillType !== FillType.Standard && + tx.fill?.fillType === FillType.Standard + ) { + tradeMap.set(dedupKey, { + ...tx, + fill: { + ...tx.fill, + fillType: existing.fill.fillType, + ...(existing.fill.liquidation && + !tx.fill.liquidation && { + liquidation: existing.fill.liquidation, + }), + }, + }); + } else { + tradeMap.set(dedupKey, tx); + } } // Combine deduplicated trades with non-trade transactions (including wallet deposits) diff --git a/app/components/UI/Perps/utils/orderCalculations.test.ts b/app/components/UI/Perps/utils/orderCalculations.test.ts index 02d3aa478568..fcec7b020622 100644 --- a/app/components/UI/Perps/utils/orderCalculations.test.ts +++ b/app/components/UI/Perps/utils/orderCalculations.test.ts @@ -355,6 +355,27 @@ describe('orderCalculations', () => { expect(result).toBeGreaterThanOrEqual(0); expect(result).toBeLessThanOrEqual(50); // 10 * 5 leverage }); + + it('should apply margin buffer so result is below theoretical max', () => { + // Arrange - case where theoretical max is 1000 (100 * 10) + const params = { + availableBalance: 100, + assetPrice: 50000, + assetSzDecimals: 6, + leverage: 10, + }; + + // Act + const result = getMaxAllowedAmount(params); + const theoreticalMax = params.availableBalance * params.leverage; + + // Assert - buffer (0.5%) reduces max to avoid "Insufficient margin" rejections + expect(result).toBeGreaterThan(0); + expect(result).toBeLessThanOrEqual(theoreticalMax); + expect(result).toBeLessThanOrEqual( + Math.floor(theoreticalMax * (1 - 0.005)), + ); + }); }); describe('buildOrdersArray', () => { diff --git a/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx index 2af0dd41c571..68cded2bef7a 100644 --- a/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx @@ -140,7 +140,6 @@ const createTestCampaign = ( endDate: '2027-12-31T23:59:59.999Z', termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, @@ -185,10 +184,6 @@ describe('CampaignMechanicsView', () => { campaigns: [ createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'How it works', description: 'Earn rewards', @@ -246,10 +241,6 @@ describe('CampaignMechanicsView', () => { campaigns: [ createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'How it works', description: 'Earn rewards', @@ -272,10 +263,6 @@ describe('CampaignMechanicsView', () => { campaigns: [ createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'How it works', description: 'Earn rewards', @@ -298,10 +285,6 @@ describe('CampaignMechanicsView', () => { campaigns: [ createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'How it works', description: 'Earn rewards', @@ -323,10 +306,6 @@ describe('CampaignMechanicsView', () => { campaigns: [ createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'How it works', description: 'Earn rewards', @@ -349,10 +328,6 @@ describe('CampaignMechanicsView', () => { campaigns: [ createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'How it works', description: 'Earn rewards', diff --git a/app/components/UI/Rewards/Views/CampaignsView.test.tsx b/app/components/UI/Rewards/Views/CampaignsView.test.tsx index 625c5c7360b5..6e37c8fa434a 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.test.tsx @@ -143,7 +143,6 @@ const createTestCampaign = ( endDate: '2027-12-31T23:59:59.999Z', termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx index 101a99d9f746..7d4fcd088e23 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx @@ -206,7 +206,6 @@ const createTestCampaign = ( endDate: nextMonth.toISOString(), termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, @@ -317,10 +316,6 @@ describe('OndoCampaignDetailsView', () => { campaigns: [ createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'How it works', description: 'Description', diff --git a/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.test.tsx index b2be68e48160..5714e553e609 100644 --- a/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.test.tsx @@ -148,7 +148,6 @@ const createTestCampaign = ( endDate: nextMonth.toISOString(), termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx index 4b30daf03c9d..c5a5f1d241a2 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx @@ -165,7 +165,6 @@ const createTestCampaign = ( endDate: '2027-12-31T23:59:59.999Z', termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx index e779437e9100..65bf77634dfb 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx @@ -33,7 +33,6 @@ const createTestCampaign = (overrides = {}): CampaignDto => ({ endDate: '2027-12-31T23:59:59.999Z', termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, @@ -58,12 +57,9 @@ describe('CampaignStatus', () => { it('renders campaign image', () => { const campaign = createTestCampaign({ - details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - howItWorks: { title: '', description: '', phases: [] }, + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', }, }); const { getByTestId } = render(); @@ -89,10 +85,6 @@ describe('CampaignStatus', () => { it('renders howItWorks title when available', () => { const campaign = createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'How it works', description: 'Description', @@ -117,10 +109,6 @@ describe('CampaignStatus', () => { it('does not render howItWorks title when title is empty', () => { const campaign = createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: '', description: '', phases: [] }, }, }); @@ -133,10 +121,6 @@ describe('CampaignStatus', () => { it('renders howItWorks description when available', () => { const campaign = createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'How it works', description: 'Hold ONDO tokens to earn rewards', @@ -161,10 +145,6 @@ describe('CampaignStatus', () => { it('does not render howItWorks description when description is empty', () => { const campaign = createTestCampaign({ details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, howItWorks: { title: 'Title', description: '', phases: [] }, }, }); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx index 2ca2e5ac7c9b..b1fdeaa75af7 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx @@ -37,8 +37,8 @@ const CampaignStatus: React.FC = ({ campaign }) => { const backgroundImageUrl = colorScheme === 'dark' - ? campaign.details?.image?.darkModeUrl - : campaign.details?.image?.lightModeUrl; + ? campaign.image?.darkModeUrl + : campaign.image?.lightModeUrl; const howItWorksTitle = campaign.details?.howItWorks?.title; const howItWorksDescription = campaign.details?.howItWorks?.description; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx index 2b23a5b5e410..541d9db5cc37 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx @@ -88,7 +88,6 @@ const createTestCampaign = (overrides = {}): CampaignDto => ({ endDate: '2027-12-31T23:59:59.999Z', termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, @@ -161,16 +160,9 @@ describe('CampaignTile', () => { it('renders background image via campaign-tile-background testID', () => { const campaign = createTestCampaign({ - details: { - image: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - howItWorks: { - title: '', - description: '', - phases: [], - }, + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', }, }); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx index 4950e938f807..79fd04a03a11 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx @@ -74,8 +74,8 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { const backgroundImageUrl = colorScheme === 'dark' - ? campaign.details?.image?.darkModeUrl - : campaign.details?.image?.lightModeUrl; + ? campaign.image?.darkModeUrl + : campaign.image?.lightModeUrl; const handlePress = () => { if (!isInteractive) return; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts index 663855acaaa8..abec4ec03e07 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts @@ -42,7 +42,6 @@ function buildCampaignDto(overrides: Partial = {}): CampaignDto { endDate: '2025-12-31T23:59:59.999Z', termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx index 267f1c86f0ac..235f30603d84 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx @@ -16,7 +16,6 @@ const createTestCampaign = ( endDate: '2027-12-31T23:59:59.999Z', termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx index 8adae8d1a656..dc1eb911605b 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx @@ -80,7 +80,6 @@ const createTestCampaign = ( endDate: futureDate.toISOString(), termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, diff --git a/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts b/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts index 5cc5d2fe0cf5..7f9ea8bc1b5c 100644 --- a/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts +++ b/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts @@ -81,7 +81,6 @@ const createTestCampaign = ( endDate: '2027-01-01T00:00:00.000Z', termsAndConditions: null, excludedRegions: [], - statusLabel: 'Active', details: null, featured: true, ...overrides, diff --git a/app/components/Views/ManualBackupStep1/__snapshots__/index.test.tsx.snap b/app/components/Views/ManualBackupStep1/__snapshots__/index.test.tsx.snap index 8844e0c4c6e3..fba420e36c64 100644 --- a/app/components/Views/ManualBackupStep1/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/ManualBackupStep1/__snapshots__/index.test.tsx.snap @@ -331,7 +331,8 @@ exports[`ManualBackupStep1 matches snapshot 1`] = ` accessibilityIgnoresInvertColors={true} style={ [ - "absolute top-0 left-0 bottom-0 right-0 h-full rounded-lg flex-1 opacity-50", + "absolute top-0 left-0 bottom-0 right-0 h-full rounded-lg flex-1", + "opacity-50", ] } > @@ -361,11 +362,12 @@ exports[`ManualBackupStep1 matches snapshot 1`] = ` [ [ "flex", - "flex-col", + undefined, + undefined, + false, + undefined, undefined, false, - "items-center", - "justify-center", false, false, false, @@ -378,12 +380,11 @@ exports[`ManualBackupStep1 matches snapshot 1`] = ` false, false, false, - "px-6", false, false, undefined, undefined, - "rounded-lg py-[45px] gap-y-4 h-full flex-1", + "items-center justify-center rounded-lg px-6 py-[45px] gap-y-4 h-full flex-1", ], undefined, ] @@ -885,7 +886,8 @@ exports[`ManualBackupStep1 theme appearance renders with dark theme 1`] = ` accessibilityIgnoresInvertColors={true} style={ [ - "absolute top-0 left-0 bottom-0 right-0 h-full rounded-lg flex-1 opacity-50", + "absolute top-0 left-0 bottom-0 right-0 h-full rounded-lg flex-1", + "opacity-50", ] } > @@ -915,11 +917,12 @@ exports[`ManualBackupStep1 theme appearance renders with dark theme 1`] = ` [ [ "flex", - "flex-col", + undefined, + undefined, + false, + undefined, undefined, false, - "items-center", - "justify-center", false, false, false, @@ -932,12 +935,11 @@ exports[`ManualBackupStep1 theme appearance renders with dark theme 1`] = ` false, false, false, - "px-6", false, false, undefined, undefined, - "rounded-lg py-[45px] gap-y-4 h-full flex-1", + "items-center justify-center rounded-lg px-6 py-[45px] gap-y-4 h-full flex-1", ], undefined, ] @@ -1439,7 +1441,8 @@ exports[`ManualBackupStep1 theme appearance renders with light theme on Android accessibilityIgnoresInvertColors={true} style={ [ - "absolute top-0 left-0 bottom-0 right-0 h-full rounded-lg flex-1 opacity-50", + "absolute top-0 left-0 bottom-0 right-0 h-full rounded-lg flex-1", + "opacity-50", ] } > @@ -1469,11 +1472,12 @@ exports[`ManualBackupStep1 theme appearance renders with light theme on Android [ [ "flex", - "flex-col", + undefined, + undefined, + false, + undefined, undefined, false, - "items-center", - "justify-center", false, false, false, @@ -1486,12 +1490,11 @@ exports[`ManualBackupStep1 theme appearance renders with light theme on Android false, false, false, - "px-6", false, false, undefined, undefined, - "rounded-lg py-[45px] gap-y-4 h-full flex-1", + "items-center justify-center rounded-lg px-6 py-[45px] gap-y-4 h-full flex-1", ], undefined, ] diff --git a/app/components/Views/ManualBackupStep1/index.tsx b/app/components/Views/ManualBackupStep1/index.tsx index 5282d7af51d4..d96ae6530a99 100644 --- a/app/components/Views/ManualBackupStep1/index.tsx +++ b/app/components/Views/ManualBackupStep1/index.tsx @@ -10,7 +10,6 @@ import { KeyboardAvoidingView, FlatList, TouchableOpacity, - ImageBackground, Platform, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -37,7 +36,6 @@ import { TextButton, TextButtonSize, BoxAlignItems, - BoxFlexDirection, BoxJustifyContent, } from '@metamask/design-system-react-native'; import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; @@ -58,8 +56,8 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import type { ITrackingEvent } from '../../../core/Analytics/MetaMetrics.types'; import { Authentication } from '../../../core'; import { ManualBackUpStepsSelectorsIDs } from './ManualBackUpSteps.testIds'; +import SeedPhraseConcealer from '../RevealPrivateCredential/components/SeedPhraseConcealer'; import { saveOnboardingEvent as saveEvent } from '../../../actions/onboarding'; -import { AppThemeKey } from '../../../util/theme/models'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { createTrackFunction, @@ -67,9 +65,6 @@ import { showSeedphraseDefinition, } from '../../../util/onboarding/backupUtils'; import type { ManualBackupStep1RouteProp } from './ManualBackupStep1.types'; -import darkBlurImage from '../../../images/dark-blur.png'; -import lightBlurImage from '../../../images/blur.png'; - /** * View that's shown during the second step of * the backup seed phrase flow @@ -298,50 +293,10 @@ const ManualBackupStep1 = () => { }; const renderSeedPhraseConcealer = () => ( - - - - - - - {strings('manual_backup_step_1.reveal')} - - - {strings('manual_backup_step_1.watching')} - - - - + ); const renderConfirmPassword = () => ( diff --git a/app/components/Views/NftDetails/NftDetails.tsx b/app/components/Views/NftDetails/NftDetails.tsx index f4479c41e84f..26d39d292462 100644 --- a/app/components/Views/NftDetails/NftDetails.tsx +++ b/app/components/Views/NftDetails/NftDetails.tsx @@ -15,7 +15,7 @@ import styleSheet from './NftDetails.styles'; import Routes from '../../../constants/navigation/Routes'; import { NftDetailsParams } from './NftDetails.types'; import { ScrollView } from 'react-native-gesture-handler'; -import StyledButton from '../../../components/UI/StyledButton'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import NftDetailsBox from './NftDetailsBox'; import NftDetailsInformationRow from './NftDetailsInformationRow'; import { renderShortAddress } from '../../../util/address'; @@ -47,9 +47,6 @@ import { renderShortText } from '../../../util/general'; import { prefixUrlWithProtocol } from '../../../util/browser'; import { formatTimestampToYYYYMMDD } from '../../../util/date'; import MAX_TOKEN_ID_LENGTH from './nftDetails.utils'; -import Engine from '../../../core/Engine'; -import { toHex } from '@metamask/controller-utils'; -import { Hex } from '@metamask/utils'; import { InitSendLocation } from '../confirmations/constants/send'; import { useSendNavigation } from '../confirmations/hooks/useSendNavigation'; @@ -170,31 +167,17 @@ const NftDetails = () => { return Math.floor(date.getTime() / 1000); }; - const onSend = useCallback(async () => { - const chainIdHex = toHex(collectible?.chainId as number) as Hex; - if (chainIdHex !== chainId) { - const { NetworkController, MultichainNetworkController } = Engine.context; - const networkConfiguration = - NetworkController.getNetworkConfigurationByChainId(chainIdHex); - - const networkClientId = - networkConfiguration?.rpcEndpoints?.[ - networkConfiguration.defaultRpcEndpointIndex - ]?.networkClientId; - - await MultichainNetworkController.setActiveNetwork( - networkClientId as string, - ); - } + const onSend = useCallback(() => { navigateToSendPage({ location: InitSendLocation.NftDetails, asset: collectible, }); - }, [collectible, chainId, navigateToSendPage]); + }, [collectible, navigateToSendPage]); const isTradable = useCallback( () => - collectible.standard === 'ERC721' && + (collectible.standard === 'ERC721' || + collectible.standard === 'ERC1155') && collectible.isCurrentlyOwned === true, [collectible], ); @@ -665,14 +648,13 @@ const NftDetails = () => { {isTradable() ? ( - {strings('transaction.send')} - + ) : null} diff --git a/app/components/Views/NftDetails/__snapshots__/NftDetails.test.ts.snap b/app/components/Views/NftDetails/__snapshots__/NftDetails.test.ts.snap index 9471a3bf83f3..59830404c0f8 100644 --- a/app/components/Views/NftDetails/__snapshots__/NftDetails.test.ts.snap +++ b/app/components/Views/NftDetails/__snapshots__/NftDetails.test.ts.snap @@ -1315,60 +1315,93 @@ exports[`NftDetails renders correctly 1`] = ` } } > - Send - + diff --git a/app/components/Views/OAuthRehydration/index.test.tsx b/app/components/Views/OAuthRehydration/index.test.tsx index 04ed0b58c6a5..736439227b1a 100644 --- a/app/components/Views/OAuthRehydration/index.test.tsx +++ b/app/components/Views/OAuthRehydration/index.test.tsx @@ -36,6 +36,7 @@ const mockReauthenticate = jest.fn(); const mockRevealSRP = jest.fn(); const mockRevealPrivateKey = jest.fn(); const mockRequestBiometricsAccessControlForIOS = jest.fn(); +const mockUpdateAuthPreference = jest.fn(); jest.mock('../../../core/Authentication/hooks/useAuthentication', () => ({ __esModule: true, @@ -49,6 +50,7 @@ jest.mock('../../../core/Authentication/hooks/useAuthentication', () => ({ revealPrivateKey: mockRevealPrivateKey, requestBiometricsAccessControlForIOS: mockRequestBiometricsAccessControlForIOS, + updateAuthPreference: mockUpdateAuthPreference, }), })); @@ -191,6 +193,7 @@ describe('OAuthRehydration', () => { mockRequestBiometricsAccessControlForIOS.mockResolvedValue( AUTHENTICATION_TYPE.PASSWORD, ); + mockUpdateAuthPreference.mockResolvedValue(undefined); mockUseNetInfo.mockReturnValue({ isConnected: true, isInternetReachable: true, @@ -220,6 +223,9 @@ describe('OAuthRehydration', () => { await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith(Routes.ONBOARDING.HOME_NAV); }); + expect(mockUnlockWallet.mock.invocationCallOrder[0]).toBeLessThan( + mockRequestBiometricsAccessControlForIOS.mock.invocationCallOrder[0], + ); expect(mockRequestBiometricsAccessControlForIOS).toHaveBeenCalledWith( AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, ); @@ -233,6 +239,47 @@ describe('OAuthRehydration', () => { expect(mockTrackOnboarding).toHaveBeenCalled(); }); }); + + it('logs error when post-unlock biometric prompt fails', async () => { + const biometricError = new Error('Biometric prompt failed'); + mockRequestBiometricsAccessControlForIOS.mockRejectedValueOnce( + biometricError, + ); + + const { getByTestId } = renderWithProvider(); + await enterPasswordAndSubmit(getByTestId); + + await waitFor(() => { + expect(mockUnlockWallet).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + biometricError, + 'OAuthRehydration: post-unlock biometric preference', + ); + }); + }); + + it('logs error when updateAuthPreference fails after choosing device auth', async () => { + mockRequestBiometricsAccessControlForIOS.mockResolvedValueOnce( + AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, + ); + const preferenceError = new Error('Keychain preference update failed'); + mockUpdateAuthPreference.mockRejectedValueOnce(preferenceError); + + const { getByTestId } = renderWithProvider(); + await enterPasswordAndSubmit(getByTestId); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + preferenceError, + 'OAuthRehydration: post-unlock biometric preference', + ); + }); + }); }); describe('Password validation', () => { @@ -246,6 +293,18 @@ describe('OAuthRehydration', () => { }); }); + it('does not prompt biometrics when password unlock fails', async () => { + mockUnlockWallet.mockRejectedValue(new Error('Error: Decrypt failed')); + const { getByTestId } = renderWithProvider(); + await enterPasswordAndSubmit(getByTestId, 'wrongPassword'); + + await waitFor(() => { + expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); + }); + expect(mockRequestBiometricsAccessControlForIOS).not.toHaveBeenCalled(); + expect(mockUpdateAuthPreference).not.toHaveBeenCalled(); + }); + it('clears error when user types new password', async () => { mockUnlockWallet.mockRejectedValue(new Error('Error: Decrypt failed')); const { getByTestId } = renderWithProvider(); @@ -858,10 +917,28 @@ describe('OAuthRehydration', () => { expect.objectContaining({ authPreference: expect.objectContaining({ oauth2Login: false, + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, }), }), ); }); + expect(mockUnlockWallet.mock.invocationCallOrder[0]).toBeLessThan( + mockRequestBiometricsAccessControlForIOS.mock.invocationCallOrder[0], + ); + expect(mockRequestBiometricsAccessControlForIOS).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, + ); + }); + + it('does not prompt biometrics before unlock when password is wrong (outdated password flow)', async () => { + mockUnlockWallet.mockRejectedValue(new Error('Error: Decrypt failed')); + const { getByTestId } = renderWithProvider(); + await enterPasswordAndSubmit(getByTestId, 'wrongPassword'); + + await waitFor(() => { + expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); + }); + expect(mockRequestBiometricsAccessControlForIOS).not.toHaveBeenCalled(); }); it('navigates to DELETE_WALLET modal on forgot password press', () => { diff --git a/app/components/Views/OAuthRehydration/index.tsx b/app/components/Views/OAuthRehydration/index.tsx index 28361a07ad7c..b5af10540764 100644 --- a/app/components/Views/OAuthRehydration/index.tsx +++ b/app/components/Views/OAuthRehydration/index.tsx @@ -150,8 +150,38 @@ const OAuthRehydration: React.FC = ({ const passwordLoginAttemptTraceCtxRef = useRef(null); - const { unlockWallet, getAuthType, requestBiometricsAccessControlForIOS } = - useAuthentication(); + const { + unlockWallet, + getAuthType, + requestBiometricsAccessControlForIOS, + updateAuthPreference, + } = useAuthentication(); + + /** + * After a successful password unlock, offer device auth / biometrics for keychain storage. + */ + const upgradeKeychainAuthAfterSuccessfulUnlock = useCallback(async () => { + try { + const upgradeAuthType = await requestBiometricsAccessControlForIOS( + AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, + ); + if (upgradeAuthType !== AUTHENTICATION_TYPE.PASSWORD) { + await updateAuthPreference({ + authType: upgradeAuthType, + password, + fallbackToPassword: true, + }); + } + } catch (postUnlockAuthErr) { + Logger.error( + ensureError( + postUnlockAuthErr, + 'Post-unlock auth preference update failed', + ), + 'OAuthRehydration: post-unlock biometric preference', + ); + } + }, [password, requestBiometricsAccessControlForIOS, updateAuthPreference]); const track = useCallback( ( @@ -485,14 +515,9 @@ const OAuthRehydration: React.FC = ({ setLoading(true); - // Ask user to allow biometrics access control - const authType = await requestBiometricsAccessControlForIOS( - AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, - ); - - // Only set oauth2Login for normal rehydration, not when password is outdated + // Password first: do not prompt biometrics until unlock succeeds const authData: AuthData = { - currentAuthType: authType, + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, oauth2Login: true, }; @@ -506,6 +531,8 @@ const OAuthRehydration: React.FC = ({ }, ); + await upgradeKeychainAuthAfterSuccessfulUnlock(); + // Best-effort post-unlock UX: show biometric cancelled alert if needed. // Failure here must not be treated as a login error — unlock already succeeded. try { @@ -542,7 +569,7 @@ const OAuthRehydration: React.FC = ({ track, promptBiometricFailedAlert, unlockWallet, - requestBiometricsAccessControlForIOS, + upgradeKeychainAuthAfterSuccessfulUnlock, ]); const newGlobalPasswordLogin = useCallback(async () => { @@ -551,14 +578,9 @@ const OAuthRehydration: React.FC = ({ setLoading(true); - // Ask user to allow biometrics access control - const authType = await requestBiometricsAccessControlForIOS( - AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, - ); - - // Only set oauth2Login for normal rehydration, not when password is outdated + // biometrics/passcode preference is applied only after sync succeeds const authData: AuthData = { - currentAuthType: authType, + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, oauth2Login: false, }; @@ -572,6 +594,8 @@ const OAuthRehydration: React.FC = ({ }, ); + await upgradeKeychainAuthAfterSuccessfulUnlock(); + // Best-effort post-unlock UX: show biometric cancelled alert if needed. // Failure here must not be treated as a login error — unlock already succeeded. try { @@ -593,7 +617,7 @@ const OAuthRehydration: React.FC = ({ handleLoginError, promptBiometricFailedAlert, unlockWallet, - requestBiometricsAccessControlForIOS, + upgradeKeychainAuthAfterSuccessfulUnlock, ]); // Cleanup for isMountedRef tracking diff --git a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx index d7a4559d0a5d..ab03dc24c886 100644 --- a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx +++ b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx @@ -18,13 +18,11 @@ import ActionView from '../../UI/ActionView'; import { ScreenshotDeterrent } from '../../UI/ScreenshotDeterrent'; import { SRP_GUIDE_URL } from '../../../constants/urls'; import ClipboardManager from '../../../core/ClipboardManager'; -import { useTheme } from '../../../util/theme'; import { MetaMetricsEvents } from '../../../core/Analytics/MetaMetrics.events'; import { passwordRequirementsMet } from '../../../util/password'; import Device from '../../../util/device'; import { strings } from '../../../../locales/i18n'; import AppConstants from '../../../core/AppConstants'; -import { createStyles } from './styles'; import { RevealSeedViewSelectorsIDs } from './RevealSeedView.testIds'; import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; @@ -48,6 +46,7 @@ import { import { useRevealCredential, useSRPQuiz } from './hooks'; import { IRevealPrivateCredentialProps, RevealSrpStage } from './types'; import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; const RevealPrivateCredential = ({ navigation, @@ -68,11 +67,8 @@ const RevealPrivateCredential = ({ const checkSummedAddress = useSelector( selectSelectedInternalAccountFormattedAddress, ); - - const theme = useTheme(); const { trackEvent, createEventBuilder } = useAnalytics(); - const { colors } = theme; - const styles = createStyles(theme, colors); + const tw = useTailwind(); const selectedAddress = route?.params?.selectedAccount?.address || checkSummedAddress; @@ -281,7 +277,7 @@ const RevealPrivateCredential = ({ {strings('reveal_credential.seed_phrase_warning_explanation')} } - style={styles.warningWrapper} + style={tw.style('text-body-sm mt-6')} /> ); @@ -335,7 +331,6 @@ const RevealPrivateCredential = ({ onRevealSeedPhrase={() => setShowSeedPhrase(!showSeedPhrase)} onCopyToClipboard={copyPrivateCredentialToClipboard} onTabChange={onTabBarChange} - styles={styles} /> ) : ( @@ -345,7 +340,6 @@ const RevealPrivateCredential = ({ warningMessage={warningIncorrectPassword} showPassword={showPassword} onToggleShowPassword={() => setShowPassword(!showPassword)} - styles={styles} /> )} @@ -359,7 +353,6 @@ const RevealPrivateCredential = ({ ); } @@ -372,7 +365,6 @@ const RevealPrivateCredential = ({ onAnswerClick={handleQuestionAnswerClick} onContinueClick={handleAnsweredQuestionClick} onLearnMore={handleLearnMoreClick} - styles={styles} /> ); } diff --git a/app/components/Views/RevealPrivateCredential/components/PasswordEntry.tsx b/app/components/Views/RevealPrivateCredential/components/PasswordEntry.tsx index 5852f054a72c..8b5b6f1486f8 100644 --- a/app/components/Views/RevealPrivateCredential/components/PasswordEntry.tsx +++ b/app/components/Views/RevealPrivateCredential/components/PasswordEntry.tsx @@ -1,13 +1,19 @@ import React from 'react'; -import { ButtonIcon, IconName } from '@metamask/design-system-react-native'; -import Text, { +import { + TextField, + IconName, TextVariant, -} from '../../../../component-library/components/Texts/Text'; -import TextField from '../../../../component-library/components/Form/TextField/TextField'; + Text, + TextFieldSize, + FontWeight, + ButtonIcon, + TextColor, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import { RevealSeedViewSelectorsIDs } from '../RevealSeedView.testIds'; import { useTheme } from '../../../../util/theme'; import { PasswordEntryProps } from '../types'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; const PasswordEntry = ({ onPasswordChange, @@ -15,13 +21,18 @@ const PasswordEntry = ({ warningMessage, showPassword, onToggleShowPassword, - styles, }: PasswordEntryProps) => { + const tw = useTailwind(); const { colors, themeAppearance } = useTheme(); return ( <> - + {strings('reveal_credential.enter_password')} } + size={TextFieldSize.Lg} /> {warningMessage} diff --git a/app/components/Views/RevealPrivateCredential/components/SRPQuizIntroduction.tsx b/app/components/Views/RevealPrivateCredential/components/SRPQuizIntroduction.tsx index e73d7cd02989..4a4ae529c8b3 100644 --- a/app/components/Views/RevealPrivateCredential/components/SRPQuizIntroduction.tsx +++ b/app/components/Views/RevealPrivateCredential/components/SRPQuizIntroduction.tsx @@ -1,74 +1,85 @@ import React from 'react'; import { Image } from 'react-native'; -import { ButtonSize } from '../../../../component-library/components/Buttons/Button'; -import ButtonPrimary from '../../../../component-library/components/Buttons/Button/variants/ButtonPrimary'; -import ButtonLink from '../../../../component-library/components/Buttons/Button/variants/ButtonLink'; -import Text, { +import { + Box, + Text, TextColor, TextVariant, -} from '../../../../component-library/components/Texts/Text'; -import { Box } from '../../../UI/Box/Box'; -import { - AlignItems, - FlexDirection, - JustifyContent, -} from '../../../UI/Box/box.types'; + Button, + ButtonSize, + ButtonVariant, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + TextButton, +} from '@metamask/design-system-react-native'; import SecurityQuizLockImage from '../../../../images/reveal_srp_intro.png'; import { strings } from '../../../../../locales/i18n'; import { ExportCredentialsIds } from '../../MultichainAccounts/AccountDetails/ExportCredentials.testIds'; import { SrpQuizGetStartedSelectorsIDs } from '../../Quiz/SRPQuiz/SrpQuizModal.testIds'; import { SRPQuizIntroductionProps } from '../types'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; const SRPQuizIntroduction = ({ onGetStarted, onLearnMore, - styles, -}: SRPQuizIntroductionProps) => ( - +}: SRPQuizIntroductionProps) => { + const tw = useTailwind(); + + return ( - - - {strings('multichain_accounts.reveal_srp.description')} - - - - - + + + {strings('multichain_accounts.reveal_srp.description')} + + + + + + {strings('multichain_accounts.reveal_srp.learn_more')} + + - -); + ); +}; export default SRPQuizIntroduction; diff --git a/app/components/Views/RevealPrivateCredential/components/SRPSecurityQuiz.tsx b/app/components/Views/RevealPrivateCredential/components/SRPSecurityQuiz.tsx index 0f9aed1d69f3..ecb487882dd9 100644 --- a/app/components/Views/RevealPrivateCredential/components/SRPSecurityQuiz.tsx +++ b/app/components/Views/RevealPrivateCredential/components/SRPSecurityQuiz.tsx @@ -1,23 +1,20 @@ import React from 'react'; -import { ButtonSize } from '../../../../component-library/components/Buttons/Button'; -import ButtonPrimary from '../../../../component-library/components/Buttons/Button/variants/ButtonPrimary'; -import ButtonSecondary from '../../../../component-library/components/Buttons/Button/variants/ButtonSecondary'; -import ButtonLink from '../../../../component-library/components/Buttons/Button/variants/ButtonLink'; -import Text, { +import { + Box, + Text, TextColor, TextVariant, -} from '../../../../component-library/components/Texts/Text'; -import { Box } from '../../../UI/Box/Box'; -import { - AlignItems, - FlexDirection, - JustifyContent, -} from '../../../UI/Box/box.types'; -import { + Button, + ButtonSize, + ButtonVariant, + IconName, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, Icon, IconColor, - IconName, IconSize, + TextButton, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import { ExportCredentialsIds } from '../../MultichainAccounts/AccountDetails/ExportCredentials.testIds'; @@ -34,20 +31,19 @@ const SRPSecurityQuiz = ({ onAnswerClick, onContinueClick, onLearnMore, - styles, }: SRPSecurityQuizProps) => { const renderQuestionResult = () => ( {strings( correctAnswer @@ -69,19 +67,19 @@ const SRPSecurityQuiz = ({ {currentQuestionIndex === 1 && ( - + {strings( correctAnswer ? 'srp_security_quiz.question_one.right_answer_title' : 'srp_security_quiz.question_one.wrong_answer_title', )} - + {strings( correctAnswer ? 'srp_security_quiz.question_one.right_answer_description' @@ -92,19 +90,19 @@ const SRPSecurityQuiz = ({ )} {currentQuestionIndex === 2 && ( - + {strings( correctAnswer ? 'srp_security_quiz.question_two.right_answer_title' : 'srp_security_quiz.question_two.wrong_answer_title', )} - + {strings( correctAnswer ? 'srp_security_quiz.question_two.right_answer_description' @@ -118,64 +116,68 @@ const SRPSecurityQuiz = ({ const renderAnswerButtons = () => ( - onAnswerClick(1)} size={ButtonSize.Lg} - label={strings( - currentQuestionIndex === 1 - ? 'srp_security_quiz.question_one.wrong_answer' - : 'srp_security_quiz.question_two.right_answer', - )} testID={ currentQuestionIndex === 1 ? SrpSecurityQuestionOneSelectorsIDs.WRONG_ANSWER : SrpSecurityQuestionTwoSelectorsIDs.RIGHT_ANSWER } - style={styles.button} - /> - onAnswerClick(2)} - size={ButtonSize.Lg} - label={strings( + twClassName="w-full text-center" + > + {strings( currentQuestionIndex === 1 - ? 'srp_security_quiz.question_one.right_answer' - : 'srp_security_quiz.question_two.wrong_answer', + ? 'srp_security_quiz.question_one.wrong_answer' + : 'srp_security_quiz.question_two.right_answer', )} + + + + twClassName="w-full text-center flex items-center justify-center" + > + {strings('multichain_accounts.reveal_srp.learn_more')} + ); const renderAnsweredButtons = () => ( - - + {strings( + correctAnswer + ? 'srp_security_quiz.continue' + : 'srp_security_quiz.try_again', + )} + + + twClassName="w-full text-center flex items-center justify-center" + > + {strings('multichain_accounts.reveal_srp.learn_more')} + ); return ( {strings('srp_security_quiz.question_step', { step: currentQuestionIndex, @@ -218,9 +230,9 @@ const SRPSecurityQuiz = ({ {!questionAnswered && ( {strings( currentQuestionIndex === 1 diff --git a/app/components/Views/RevealPrivateCredential/components/SRPTabView.tsx b/app/components/Views/RevealPrivateCredential/components/SRPTabView.tsx index efe15d918c93..66d79a7b2e9d 100644 --- a/app/components/Views/RevealPrivateCredential/components/SRPTabView.tsx +++ b/app/components/Views/RevealPrivateCredential/components/SRPTabView.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { Dimensions, ScrollView, View } from 'react-native'; +import { Dimensions, ScrollView, Platform } from 'react-native'; +import { + Box, + BoxJustifyContent, + BoxAlignItems, + BoxFlexDirection, +} from '@metamask/design-system-react-native'; import ScrollableTabView from '@tommasini/react-native-scrollable-tab-view'; import QRCode from 'react-native-qrcode-svg'; import TabBar from '../../../../component-library/components-temp/TabBar/TabBar'; @@ -10,6 +16,7 @@ import logo from '../../../../images/branding/fox.png'; import SeedPhraseDisplay from './SeedPhraseDisplay'; import SeedPhraseConcealer from './SeedPhraseConcealer'; import { SRPTabViewProps } from '../types'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const CustomTabView = ScrollView as any; @@ -21,7 +28,6 @@ const SRPTabView = ({ onRevealSeedPhrase, onCopyToClipboard, onTabChange, - styles, }: SRPTabViewProps) => { const { colors } = useTheme(); const trimmedCredential = clipboardPrivateCredential.trim(); @@ -29,42 +35,44 @@ const SRPTabView = ({ const hasCredential = words.length > 0; const renderTabBar = () => ; + const tw = useTailwind(); return ( - + renderTabBar()} // eslint-disable-next-line @typescript-eslint/no-explicit-any onChangeTab={(event: any) => onTabChange(event)} - style={styles.tabContentContainer} + style={tw.style( + `min-h-[${Platform.OS === 'android' ? 320 : 0}px] flex-grow flex-shrink-0 mb-[${Platform.OS === 'android' ? 20 : 0}px]`, + )} > - + {showSeedPhrase ? ( ) : ( - + )} - + - ) : null} - + - + ); }; diff --git a/app/components/Views/RevealPrivateCredential/components/SeedPhraseConcealer.tsx b/app/components/Views/RevealPrivateCredential/components/SeedPhraseConcealer.tsx index 51a5b832686f..e22a707c9f88 100644 --- a/app/components/Views/RevealPrivateCredential/components/SeedPhraseConcealer.tsx +++ b/app/components/Views/RevealPrivateCredential/components/SeedPhraseConcealer.tsx @@ -1,15 +1,16 @@ import React from 'react'; -import { ImageBackground, TouchableOpacity, View } from 'react-native'; +import { ImageBackground, TouchableOpacity } from 'react-native'; import { Icon, IconColor, IconName, IconSize, -} from '@metamask/design-system-react-native'; -import Text, { + Box, + Text, TextColor, TextVariant, -} from '../../../../component-library/components/Texts/Text'; + FontWeight, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import { RevealSeedViewSelectorsIDs } from '../RevealSeedView.testIds'; import { AppThemeKey } from '../../../../util/theme/models'; @@ -17,42 +18,51 @@ import { useTheme } from '../../../../util/theme'; import blurImage from '../../../../images/blur.png'; import darkBlurImage from '../../../../images/dark-blur.png'; import { SeedPhraseConcealerProps } from '../types'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +const FILL_STYLE = + 'absolute top-0 left-0 bottom-0 right-0 h-full rounded-lg flex-1'; const SeedPhraseConcealer = ({ onReveal, - styles, + testID = RevealSeedViewSelectorsIDs.REVEAL_CREDENTIAL_BUTTON_ID, }: SeedPhraseConcealerProps) => { const { themeAppearance } = useTheme(); + const tw = useTailwind(); return ( - + - + - + {strings('manual_backup_step_1.reveal')} - + {strings('manual_backup_step_1.watching')} - + - + ); }; diff --git a/app/components/Views/RevealPrivateCredential/components/SeedPhraseDisplay.tsx b/app/components/Views/RevealPrivateCredential/components/SeedPhraseDisplay.tsx index d4ec703f5cb7..8f17ff50a56e 100644 --- a/app/components/Views/RevealPrivateCredential/components/SeedPhraseDisplay.tsx +++ b/app/components/Views/RevealPrivateCredential/components/SeedPhraseDisplay.tsx @@ -1,20 +1,20 @@ import React from 'react'; -import { FlatList, View } from 'react-native'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; -import Text, { +import { FlatList } from 'react-native'; +import { + Box, + Text, TextColor, TextVariant, -} from '../../../../component-library/components/Texts/Text'; -import { Box } from '../../../UI/Box/Box'; -import { - AlignItems, - FlexDirection, - JustifyContent, -} from '../../../UI/Box/box.types'; -import { IconName as IconNameLibrary } from '../../../../component-library/components/Icons/Icon'; + TextButton, + TextButtonSize, + IconName, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + BoxBorderColor, + BoxBackgroundColor, + IconSize, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import { ManualBackUpStepsSelectorsIDs } from '../../ManualBackupStep1/ManualBackUpSteps.testIds'; import { RevealSeedViewSelectorsIDs } from '../RevealSeedView.testIds'; @@ -25,58 +25,69 @@ const SeedPhraseDisplay = ({ showSeedPhrase, clipboardEnabled, onCopyToClipboard, - styles, }: SeedPhraseDisplayProps) => ( - + index.toString()} renderItem={({ item, index }) => ( - + {index + 1}. {item} - + )} /> - + {clipboardEnabled ? ( -