diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index 204205b5365..ee57bf31fec 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -246,6 +246,9 @@ const USER_REGION = { regionCode: 'us-ca', }; +const CIRCUIT_BREAKER_MESSAGE = + 'This service is temporarily unavailable. Please try again in about 30 minutes.'; + const buildProviderWithLimits = (limits: { minAmount: number; maxAmount: number; @@ -982,6 +985,61 @@ describe('BuildQuote', () => { getByTestId(BuildQuoteSelectors.CONTINUE_BUTTON), ).toBeOnTheScreen(); }); + + it('shows the localized circuit breaker fallback for quote fetch errors', () => { + const circuitBreakerError = Object.assign( + new Error('Execution prevented because the circuit breaker is open'), + { errorKey: 'CIRCUIT_BREAKER_OPEN' }, + ); + mockUseRampsQuotes.mockReturnValue({ + data: null, + loading: false, + error: circuitBreakerError, + }); + + const { getByText, queryByText } = renderWithProvider(, { + state: initialRootState, + }); + + expect(getByText(CIRCUIT_BREAKER_MESSAGE)).toBeOnTheScreen(); + expect( + queryByText('Execution prevented because the circuit breaker is open'), + ).not.toBeOnTheScreen(); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_message: CIRCUIT_BREAKER_MESSAGE, + }), + ); + }); + + it('removes the quote fetch banner after quote fetching recovers', () => { + const quoteFetchError = new Error('Quote fetch failed'); + let quotesHookResult: { + data: { success: (typeof WIDGET_PROVIDER_QUOTE)[] } | null; + loading: boolean; + error: Error | null; + } = { + data: null, + loading: false, + error: quoteFetchError, + }; + mockUseRampsQuotes.mockImplementation(() => quotesHookResult); + + const { queryByText, rerender } = renderWithProvider(, { + state: initialRootState, + }); + + expect(queryByText('Quote fetch failed')).toBeOnTheScreen(); + + quotesHookResult = { + data: { success: [WIDGET_PROVIDER_QUOTE] }, + loading: false, + error: null, + }; + rerender(); + + expect(queryByText('Quote fetch failed')).not.toBeOnTheScreen(); + }); }); describe('provider quote error (out-of-bounds / limits)', () => { diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index e6767edc9b9..896db4f8844 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -458,6 +458,7 @@ function BuildQuote() { loading: selectedQuoteLoading, error: quoteFetchError, } = useRampsQuotes(quoteFetchEnabled ? quoteFetchParams : null); + const hasQuoteFetchError = quoteFetchError !== null; /* * Tracks RAMPS_QUOTE_ERROR @@ -465,7 +466,7 @@ function BuildQuote() { const lastTrackedQuoteErrorRef = useRef(null); useEffect(() => { if ( - quoteFetchError && + hasQuoteFetchError && quoteFetchError !== lastTrackedQuoteErrorRef.current ) { lastTrackedQuoteErrorRef.current = quoteFetchError; @@ -487,10 +488,11 @@ function BuildQuote() { .build(), ); } - if (!quoteFetchError) { + if (!hasQuoteFetchError) { lastTrackedQuoteErrorRef.current = null; } }, [ + hasQuoteFetchError, quoteFetchError, amountAsNumber, currency, @@ -676,7 +678,7 @@ function BuildQuote() { hasAmount && hasSettledQuoteAmount && !selectedQuoteLoading && - !quoteFetchError && + !hasQuoteFetchError && quotesResponse !== null && selectedQuote === null; @@ -834,7 +836,7 @@ function BuildQuote() { - {quoteFetchError && ( + {hasQuoteFetchError ? ( - )} + ) : null} {hasAmount ? ( diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx index e2d2ce791b0..2969a8f2f7d 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx @@ -22,10 +22,12 @@ import { getOrdersProviders } from '../../../../../../reducers/fiatOrders'; import { selectRampsOrdersForSelectedAccountGroup } from '../../../../../../selectors/rampsController'; import { completedOrdersFromRampsOrders } from '../../../utils/determinePreferredProvider'; import { providerSupportsAsset } from '../../../utils/providerSupportsAsset'; +import { parseUserFacingError } from '../../../utils/parseUserFacingError'; import { useStyles } from '../../../../../hooks/useStyles'; import styleSheet from './ProviderSelectionModal.styles'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { strings } from '../../../../../../../locales/i18n'; export interface ProviderSelectionModalParams { amount?: number; @@ -184,7 +186,14 @@ function ProviderSelectionModal() { providers={displayProviders} quotes={quotes} quotesLoading={quotesLoading} - quotesError={quotesError} + quotesError={ + quotesError + ? parseUserFacingError( + quotesError, + strings('fiat_on_ramp.no_quotes_available'), + ) + : null + } showQuotes={!skipQuotes && amount > 0 && !!selectedPaymentMethod} showBackButton={hasPaymentModalInStack} ordersProviders={ordersProviders.filter( diff --git a/app/components/UI/Ramp/hooks/useRampsCountries.test.ts b/app/components/UI/Ramp/hooks/useRampsCountries.test.ts index 3df3e85be3d..b821cfc947f 100644 --- a/app/components/UI/Ramp/hooks/useRampsCountries.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsCountries.test.ts @@ -5,6 +5,11 @@ import React from 'react'; import { useRampsCountries } from './useRampsCountries'; import { type Country } from '@metamask/ramps-controller'; +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, + locale: 'en', +})); + const mockCountries: Country[] = [ { isoCode: 'US', @@ -123,5 +128,17 @@ describe('useRampsCountries', () => { }); expect(result.current.error).toBe('Network error'); }); + + it('returns localized fallback when the countries state carries a circuit breaker errorKey', () => { + const store = createMockStore({ + error: 'Execution prevented because the circuit breaker is open', + errorKey: 'CIRCUIT_BREAKER_OPEN', + }); + const { result } = renderHook(() => useRampsCountries(), { + wrapper: wrapper(store), + }); + + expect(result.current.error).toBe('fiat_on_ramp.circuit_breaker_open'); + }); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsCountries.ts b/app/components/UI/Ramp/hooks/useRampsCountries.ts index 071a8ee7b57..f82f538105f 100644 --- a/app/components/UI/Ramp/hooks/useRampsCountries.ts +++ b/app/components/UI/Ramp/hooks/useRampsCountries.ts @@ -1,6 +1,8 @@ import { useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; import { selectCountries } from '../../../../selectors/rampsController'; import { type Country } from '@metamask/ramps-controller'; +import { parseUserFacingError } from '../utils/parseUserFacingError'; /** * Result returned by the useRampsCountries hook. @@ -27,12 +29,18 @@ export interface UseRampsCountriesResult { * @returns Countries state. */ export function useRampsCountries(): UseRampsCountriesResult { - const { data: countries, isLoading, error } = useSelector(selectCountries); + const countriesState = useSelector(selectCountries); + const { data: countries, isLoading, error } = countriesState; return { countries, isLoading, - error, + error: error + ? parseUserFacingError( + countriesState, + strings('fiat_on_ramp.payment_error'), + ) + : null, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts index 827959c0177..2bb5df8a334 100644 --- a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts @@ -7,6 +7,11 @@ import { useRampsPaymentMethods } from './useRampsPaymentMethods'; import { type PaymentMethod } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, + locale: 'en', +})); + jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { @@ -225,6 +230,30 @@ describe('useRampsPaymentMethods', () => { expect(result.current.paymentMethods).toEqual([]); }); + it('returns localized fallback when the payment methods query trips the circuit breaker', async () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + + ( + Engine.context.RampsController.getPaymentMethods as jest.Mock + ).mockRejectedValue( + Object.assign( + new Error('Execution prevented because the circuit breaker is open'), + { errorKey: 'CIRCUIT_BREAKER_OPEN' }, + ), + ); + + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.status).toBe('error'); + }); + + expect(result.current.error).toBe('fiat_on_ramp.circuit_breaker_open'); + }); + it('calls Engine.context.RampsController.setSelectedPaymentMethod with full payment method object', () => { const store = createMockStore({ providers: { ...baseRampsState.providers, selected: null }, diff --git a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts index de16bf2c4de..4bc1f76760c 100644 --- a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts +++ b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; import { selectPaymentMethods, selectProviders, @@ -10,6 +11,7 @@ import { import { type PaymentMethod } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; import { rampsQueries } from '../queries'; +import { parseUserFacingError } from '../utils/parseUserFacingError'; import { normalizeAssetIdForApi } from '../utils/normalizeAssetIdForApi'; export type RampsQueryStatus = 'idle' | 'loading' | 'success' | 'error'; @@ -153,8 +155,11 @@ export function useRampsPaymentMethods(): UseRampsPaymentMethodsResult { status, isSuccess: status === 'success', error: - paymentMethodsQuery.error instanceof Error - ? paymentMethodsQuery.error.message + paymentMethodsQuery.error != null + ? parseUserFacingError( + paymentMethodsQuery.error, + strings('fiat_on_ramp.payment_error'), + ) : null, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts index 5dbf9b7818d..aa3381cb126 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts @@ -1,10 +1,6 @@ import { renderHook, act } from '@testing-library/react-native'; import { Provider } from 'react-redux'; -import { - QueryClient, - QueryClientProvider, - useQuery, -} from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; import { useRampsProviders } from './useRampsProviders'; @@ -13,6 +9,11 @@ import Engine from '../../../../core/Engine'; import { determinePreferredProvider } from '../utils/determinePreferredProvider'; import { getOrders, type FiatOrder } from '../../../../reducers/fiatOrders'; +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, + locale: 'en', +})); + jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { @@ -27,6 +28,9 @@ jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn(), })); +const mockUseQuery = jest.requireMock('@tanstack/react-query') + .useQuery as jest.Mock; + const mockSelectedAccountGroupAddresses: string[] = []; jest.mock( @@ -133,8 +137,6 @@ describe('useRampsProviders', () => { }); describe('providers query', () => { - const mockUseQuery = useQuery as jest.MockedFunction; - it('triggers providers query when regionCode is available', () => { const store = createMockStore(); renderHook(() => useRampsProviders(), { @@ -205,6 +207,37 @@ describe('useRampsProviders', () => { }); expect(result.current.error).toBe('Network error'); }); + + it('returns localized fallback when the provider state carries a circuit breaker errorKey', () => { + const store = createMockStore({ + error: 'Execution prevented because the circuit breaker is open', + errorKey: 'CIRCUIT_BREAKER_OPEN', + }); + const { result } = renderHook(() => useRampsProviders(), { + wrapper: wrapper(store), + }); + + expect(result.current.error).toBe('fiat_on_ramp.circuit_breaker_open'); + }); + + it('returns localized fallback when the providers query carries a circuit breaker errorKey', () => { + const circuitBreakerError = Object.assign( + new Error('Execution prevented because the circuit breaker is open'), + { errorKey: 'CIRCUIT_BREAKER_OPEN' }, + ); + mockUseQuery.mockReturnValueOnce({ + data: undefined, + error: circuitBreakerError, + isLoading: false, + } as never); + + const store = createMockStore(); + const { result } = renderHook(() => useRampsProviders(), { + wrapper: wrapper(store), + }); + + expect(result.current.error).toBe('fiat_on_ramp.circuit_breaker_open'); + }); }); describe('selectedProvider state', () => { diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.ts b/app/components/UI/Ramp/hooks/useRampsProviders.ts index 50e7ffea912..4c1b0d69287 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { strings } from '../../../../../locales/i18n'; import { selectProviders, selectUserRegion, @@ -13,6 +14,7 @@ import { completedOrdersFromFiatOrders, completedOrdersFromRampsOrders, } from '../utils/determinePreferredProvider'; +import { parseUserFacingError } from '../utils/parseUserFacingError'; import { getOrders } from '../../../../reducers/fiatOrders'; import { rampsQueries } from '../queries'; @@ -65,12 +67,13 @@ export function useRampsProviders(options?: { enableSideEffects?: boolean; }): UseRampsProvidersResult { const enableSideEffects = options?.enableSideEffects ?? false; + const providersState = useSelector(selectProviders); const { data: providersStateData, selected: selectedProvider, isLoading: providersStateIsLoading, error: providersStateError, - } = useSelector(selectProviders); + } = providersState; const userRegion = useSelector(selectUserRegion); const regionCode = userRegion?.regionCode ?? ''; @@ -155,15 +158,26 @@ export function useRampsProviders(options?: { } }, [enableSideEffects, providers, selectedProvider, completedOrders]); + let error: string | null = null; + + if (providersQuery?.error != null) { + error = parseUserFacingError( + providersQuery.error, + strings('fiat_on_ramp.payment_error'), + ); + } else if (providersStateError) { + error = parseUserFacingError( + providersState, + strings('fiat_on_ramp.payment_error'), + ); + } + return { providers, selectedProvider, setSelectedProvider, isLoading: providersQuery?.isLoading ?? providersStateIsLoading, - error: - providersQuery?.error instanceof Error - ? providersQuery.error.message - : providersStateError, + error, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts index 09e4f07d78e..d511b36af39 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts @@ -188,6 +188,7 @@ describe('useRampsQuotes', () => { expect(result.current.loading).toBe(false); expect(result.current.data).toEqual(mockQuotesResponse); expect(result.current.isSuccess).toBe(true); + expect(result.current.error).toBeNull(); expect(Engine.context.RampsController.getQuotes).toHaveBeenCalledWith( expect.objectContaining({ amount: 100, @@ -202,8 +203,33 @@ describe('useRampsQuotes', () => { it('returns error when the request rejects', async () => { const store = createMockStore(); const { Wrapper } = createWrapper(store); + const networkError = new Error('Network error'); + (Engine.context.RampsController.getQuotes as jest.Mock).mockRejectedValue( + networkError, + ); + + const { result } = renderHook(() => useRampsQuotes(options), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.status).toBe('error'); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(networkError); + expect(result.current.data).toBeNull(); + }); + + it('preserves enriched error metadata when the request rejects', async () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + const circuitBreakerError = Object.assign( + new Error('Execution prevented because the circuit breaker is open'), + { errorKey: 'CIRCUIT_BREAKER_OPEN' }, + ); (Engine.context.RampsController.getQuotes as jest.Mock).mockRejectedValue( - new Error('Network error'), + circuitBreakerError, ); const { result } = renderHook(() => useRampsQuotes(options), { @@ -215,7 +241,7 @@ describe('useRampsQuotes', () => { }); expect(result.current.loading).toBe(false); - expect(result.current.error).toBe('Network error'); + expect(result.current.error).toBe(circuitBreakerError); expect(result.current.data).toBeNull(); }); diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.ts index 00168a16405..6326b7e5fd5 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.ts @@ -26,7 +26,7 @@ export interface UseRampsQuotesResult { loading: boolean; status: RampsQueryStatus; isSuccess: boolean; - error: string | null; + error: unknown | null; } export function useRampsQuotes( @@ -83,8 +83,7 @@ export function useRampsQuotes( loading: status === 'loading', status, isSuccess: status === 'success', - error: - quotesQuery.error instanceof Error ? quotesQuery.error.message : null, + error: quotesQuery.error ?? null, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsTokens.test.ts b/app/components/UI/Ramp/hooks/useRampsTokens.test.ts index df8527039bc..73c9a780f2a 100644 --- a/app/components/UI/Ramp/hooks/useRampsTokens.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsTokens.test.ts @@ -5,6 +5,11 @@ import React from 'react'; import { useRampsTokens } from './useRampsTokens'; import Engine from '../../../../core/Engine'; +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, + locale: 'en', +})); + jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { @@ -120,6 +125,18 @@ describe('useRampsTokens', () => { }); expect(result.current.error).toBe('Network error'); }); + + it('returns localized fallback when the token state carries a circuit breaker errorKey', () => { + const store = createMockStore({ + error: 'Execution prevented because the circuit breaker is open', + errorKey: 'CIRCUIT_BREAKER_OPEN', + }); + const { result } = renderHook(() => useRampsTokens(), { + wrapper: wrapper(store), + }); + + expect(result.current.error).toBe('fiat_on_ramp.circuit_breaker_open'); + }); }); describe('selectedToken state', () => { diff --git a/app/components/UI/Ramp/hooks/useRampsTokens.ts b/app/components/UI/Ramp/hooks/useRampsTokens.ts index 74c3bb2ed64..794e773c19c 100644 --- a/app/components/UI/Ramp/hooks/useRampsTokens.ts +++ b/app/components/UI/Ramp/hooks/useRampsTokens.ts @@ -1,11 +1,13 @@ import { useCallback } from 'react'; import { useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; import { selectTokens } from '../../../../selectors/rampsController'; import { type RampsToken, type TokensResponse, } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; +import { parseUserFacingError } from '../utils/parseUserFacingError'; /** * Result returned by the useRampsTokens hook. @@ -41,12 +43,13 @@ export interface UseRampsTokensResult { * @returns Tokens state. */ export function useRampsTokens(): UseRampsTokensResult { + const tokensState = useSelector(selectTokens); const { data: tokens, selected: selectedToken, isLoading, error, - } = useSelector(selectTokens); + } = tokensState; const setSelectedToken = useCallback( (assetId: string) => @@ -59,7 +62,9 @@ export function useRampsTokens(): UseRampsTokensResult { selectedToken, setSelectedToken, isLoading, - error, + error: error + ? parseUserFacingError(tokensState, strings('fiat_on_ramp.payment_error')) + : null, }; } diff --git a/app/components/UI/Ramp/utils/parseUserFacingError.test.ts b/app/components/UI/Ramp/utils/parseUserFacingError.test.ts index 2bb3ce20dca..c2f3cf82394 100644 --- a/app/components/UI/Ramp/utils/parseUserFacingError.test.ts +++ b/app/components/UI/Ramp/utils/parseUserFacingError.test.ts @@ -1,3 +1,10 @@ +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => + key === 'fiat_on_ramp.circuit_breaker_open' + ? 'This service is temporarily unavailable. Please try again in about 30 minutes.' + : key, +})); + import { parseUserFacingError } from './parseUserFacingError'; const FALLBACK = 'Something went wrong'; @@ -73,6 +80,37 @@ describe('parseUserFacingError', () => { expect(parseUserFacingError(new Error(''), FALLBACK)).toBe(FALLBACK); }); + it('returns fallback for circuit breaker errors with errorKey metadata', () => { + const circuitBreakerError = Object.assign( + new Error('Execution prevented because the circuit breaker is open'), + { errorKey: 'CIRCUIT_BREAKER_OPEN' }, + ); + + expect(parseUserFacingError(circuitBreakerError, FALLBACK)).toBe( + 'This service is temporarily unavailable. Please try again in about 30 minutes.', + ); + }); + + it('returns fallback for resource-like circuit breaker errors', () => { + expect( + parseUserFacingError( + { + error: 'Execution prevented because the circuit breaker is open', + errorKey: 'CIRCUIT_BREAKER_OPEN', + }, + FALLBACK, + ), + ).toBe( + 'This service is temporarily unavailable. Please try again in about 30 minutes.', + ); + }); + + it('returns fallback for resource-like errors with non-string error values', () => { + expect( + parseUserFacingError({ error: { message: 'Nested object' } }, FALLBACK), + ).toBe(FALLBACK); + }); + it('handles JSON body where error.message is empty', () => { const httpError = new Error( `Fetching https://api.example.com/x failed with status '400': {"error":{"statusCode":400,"message":""}}`, diff --git a/app/components/UI/Ramp/utils/parseUserFacingError.ts b/app/components/UI/Ramp/utils/parseUserFacingError.ts index 88ec4fe12c9..dcd2a4c55a8 100644 --- a/app/components/UI/Ramp/utils/parseUserFacingError.ts +++ b/app/components/UI/Ramp/utils/parseUserFacingError.ts @@ -1,3 +1,34 @@ +import { strings } from '../../../../../locales/i18n'; + +const CIRCUIT_BREAKER_OPEN_ERROR_KEY = 'CIRCUIT_BREAKER_OPEN'; +const CIRCUIT_BREAKER_OPEN_MESSAGE_KEY = 'fiat_on_ramp.circuit_breaker_open'; + +function getErrorKey(error: unknown): string | null { + if (typeof error !== 'object' || error === null || !('errorKey' in error)) { + return null; + } + + const errorKey = (error as { errorKey?: unknown }).errorKey; + return typeof errorKey === 'string' && errorKey.trim() ? errorKey : null; +} + +function getRawMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (typeof error === 'object' && error !== null && 'error' in error) { + const resourceError = (error as { error?: unknown }).error; + return typeof resourceError === 'string' ? resourceError : ''; + } + + return ''; +} + /** * Extracts a user-friendly error message from API errors. * @@ -11,12 +42,11 @@ * @returns A user-facing error message string */ export function parseUserFacingError(error: unknown, fallback: string): string { - const rawMessage = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : ''; + if (getErrorKey(error) === CIRCUIT_BREAKER_OPEN_ERROR_KEY) { + return strings(CIRCUIT_BREAKER_OPEN_MESSAGE_KEY); + } + + const rawMessage = getRawMessage(error); if (!rawMessage) { return fallback; diff --git a/locales/languages/en.json b/locales/languages/en.json index ecda4658fc9..42bf7280a76 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5361,6 +5361,7 @@ "pay_with": "Pay with", "buying_via": "Buying via {{providerName}}.", "change_provider": "Change provider.", + "circuit_breaker_open": "This service is temporarily unavailable. Please try again in about 30 minutes.", "payment_error": "Something went wrong. Please try again.", "no_payment_methods_available": "No payment methods are available.", "error_fetching_quotes": "Something went wrong. Please try again.", diff --git a/package.json b/package.json index fa29e7b4fcd..ae3589a9bd8 100644 --- a/package.json +++ b/package.json @@ -207,7 +207,7 @@ "bn.js@npm:5.2.1": "5.2.3", "expo-web-browser@npm:~15.0.10": "patch:expo-web-browser@patch%3Aexpo-web-browser@npm%253A15.0.10%23~/.yarn/patches/expo-web-browser-npm-15.0.10-9bc8443879.patch%3A%3Aversion=15.0.10&hash=f5d37c#~/.yarn/patches/expo-web-browser-patch-901cbe9795.patch", "@metamask/messenger@^0.3.0": "^1.0.0", - "@metamask/messenger": "^1.1.0", + "@metamask/messenger": "^1.2.0", "@metamask/keyring-internal-api": "^11.0.1", "@metamask/accounts-controller": "^38.0.0", "@metamask/profile-sync-controller": "^28.0.2", @@ -294,7 +294,7 @@ "@metamask/keyring-utils": "^3.2.0", "@metamask/logging-controller": "^8.0.0", "@metamask/message-signing-snap": "^1.1.2", - "@metamask/messenger": "^1.1.0", + "@metamask/messenger": "^1.2.0", "@metamask/metamask-eth-abis": "3.1.1", "@metamask/mobile-wallet-protocol-core": "^0.4.0", "@metamask/mobile-wallet-protocol-wallet-client": "^0.3.0", @@ -317,7 +317,7 @@ "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^3.1.0", "@metamask/profile-sync-controller": "^28.0.2", - "@metamask/ramps-controller": "^13.2.0", + "@metamask/ramps-controller": "^13.3.1", "@metamask/react-data-query": "^0.2.0", "@metamask/react-native-acm": "patch:@metamask/react-native-acm@npm%3A1.2.0#~/.yarn/patches/@metamask-react-native-acm-npm-1.2.0-944bf863eb.patch", "@metamask/react-native-actionsheet": "2.4.2", diff --git a/tests/framework/fixtures/json/default-fixture.json b/tests/framework/fixtures/json/default-fixture.json index fb416ee5359..16789153721 100644 --- a/tests/framework/fixtures/json/default-fixture.json +++ b/tests/framework/fixtures/json/default-fixture.json @@ -127,11 +127,30 @@ }, "CardController": { "activeProviderId": "baanx", + "cardHomeData": { + "account": null, + "actions": [ + { + "enabled": true, + "type": "add_funds" + } + ], + "alerts": [], + "availableFundingAssets": [], + "card": null, + "delegationSettings": null, + "fundingAssets": [], + "primaryFundingAsset": null + }, + "cardHomeDataStatus": "success", "cardholderAccounts": [], "isAuthenticated": false, "providerData": {}, "selectedCountry": null }, + "ClientController": { + "isUiOpen": false + }, "ComplianceController": { "lastCheckedAt": null, "walletComplianceStatusMap": {} @@ -176,6 +195,7 @@ "MoneyAccountController": { "moneyAccounts": {} }, + "MoneyAccountUpgradeController": {}, "MultichainAssetsController": { "accountsAssets": {}, "assetsMetadata": {} @@ -352,12 +372,12 @@ }, "PerpsController": { "accountState": { - "spendableBalance": "8000.00", - "withdrawableBalance": "8000.00", "marginUsed": "2000.00", "returnOnEquity": "0", + "spendableBalance": "8000.00", "totalBalance": "10000.00", - "unrealizedPnl": "150.00" + "unrealizedPnl": "150.00", + "withdrawableBalance": "8000.00" }, "activeProvider": "hyperliquid", "cachedMarketDataByProvider": {}, @@ -556,6 +576,11 @@ } }, "SnapController": {}, + "SocialController": { + "followingAddresses": [], + "followingProfileIds": [], + "leaderboardEntries": [] + }, "TokenBalancesController": { "tokenBalances": {} }, @@ -585,6 +610,7 @@ "getStartedAgg": false, "getStartedDeposit": false, "getStartedSell": false, + "hasAgreedTransakNativePolicy": false, "networks": [ { "active": true, @@ -672,6 +698,7 @@ "accountType": "metamask", "completedOnboarding": true, "events": [], + "iosGoogleWarningSheetLastDismissedAt": null, "onboardingVersion": "7.74.0 (4138)", "pendingSocialLoginMarketingConsentBackfill": null }, @@ -706,6 +733,9 @@ "balanceRefereePortion": 0, "balanceTotal": 0, "balanceUpdatedAt": null, + "benefits": [], + "benefitsError": false, + "benefitsLoading": false, "bulkLink": { "failedAccounts": 0, "initialSubscriptionId": null, @@ -730,6 +760,9 @@ "onboardingActiveStep": "INTRO", "onboardingReferralCode": null, "ondoCampaignActivity": {}, + "ondoCampaignDeposits": null, + "ondoCampaignDepositsError": false, + "ondoCampaignDepositsLoading": false, "ondoCampaignLeaderboard": null, "ondoCampaignLeaderboardError": false, "ondoCampaignLeaderboardLoading": false, @@ -739,6 +772,14 @@ "optinAllowedForGeo": null, "optinAllowedForGeoError": false, "optinAllowedForGeoLoading": false, + "pendingDeeplink": null, + "perpsTradingCampaignLeaderboard": null, + "perpsTradingCampaignLeaderboardError": false, + "perpsTradingCampaignLeaderboardLoading": false, + "perpsTradingCampaignLeaderboardPositions": {}, + "perpsTradingCampaignVolume": null, + "perpsTradingCampaignVolumeError": false, + "perpsTradingCampaignVolumeLoading": false, "pointsEvents": null, "refereeCount": 0, "referralCode": null, @@ -787,6 +828,7 @@ "basicFunctionalityEnabled": true, "deepLinkModalDisabled": false, "deviceNotificationEnabled": false, + "hapticsEnabled": true, "hideZeroBalanceTokens": false, "lockTime": 30000, "perpsChartPreferences": { diff --git a/tests/websocket/server.test.ts b/tests/websocket/server.test.ts index 74e3147fbf3..9b8fcf5e525 100644 --- a/tests/websocket/server.test.ts +++ b/tests/websocket/server.test.ts @@ -30,7 +30,7 @@ describe('LocalWebSocketServer', () => { // Guard against nock.disableNetConnect() leaking from other test suites // that share the same Jest worker process. nock.enableNetConnect('localhost'); - testPort = 50000 + Math.floor(Math.random() * 10000); + testPort = 0; jest.clearAllMocks(); }); @@ -61,10 +61,19 @@ describe('LocalWebSocketServer', () => { async function connectClient(port: number): Promise { const client = createClient(port); - await new Promise((resolve) => client.on('open', resolve)); + await new Promise((resolve, reject) => { + client.once('open', resolve); + client.once('error', reject); + }); return client; } + function getStartedPort(): number { + const port = server.getServerPort(); + expect(port).toBeGreaterThan(0); + return port; + } + describe('Resource interface', () => { it('reports STOPPED before start', () => { server = createServer('test-status', testPort); @@ -107,7 +116,7 @@ describe('LocalWebSocketServer', () => { await server.start(); - const client = await connectClient(testPort); + const client = await connectClient(getStartedPort()); expect(client.readyState).toBe(WebSocket.OPEN); }); @@ -145,7 +154,7 @@ describe('LocalWebSocketServer', () => { server = createServer('test-connect', testPort); await server.start(); - const client = await connectClient(testPort); + const client = await connectClient(getStartedPort()); expect(client.readyState).toBe(WebSocket.OPEN); }); @@ -165,8 +174,9 @@ describe('LocalWebSocketServer', () => { server = createServer('test-count-track', testPort); await server.start(); - await connectClient(testPort); - await connectClient(testPort); + const startedPort = getStartedPort(); + await connectClient(startedPort); + await connectClient(startedPort); const count = server.getWebsocketConnectionCount(); expect(count).toBe(2); @@ -176,8 +186,9 @@ describe('LocalWebSocketServer', () => { server = createServer('test-count-disconnect', testPort); await server.start(); - const client1 = await connectClient(testPort); - await connectClient(testPort); + const startedPort = getStartedPort(); + const client1 = await connectClient(startedPort); + await connectClient(startedPort); expect(server.getWebsocketConnectionCount()).toBe(2); client1.close(); @@ -193,8 +204,9 @@ describe('LocalWebSocketServer', () => { server = createServer('test-broadcast', testPort); await server.start(); - const client1 = await connectClient(testPort); - const client2 = await connectClient(testPort); + const startedPort = getStartedPort(); + const client1 = await connectClient(startedPort); + const client2 = await connectClient(startedPort); const received1 = new Promise((resolve) => client1.on('message', (data) => resolve(data.toString())), @@ -222,7 +234,7 @@ describe('LocalWebSocketServer', () => { server = createServer('test-stop', testPort); await server.start(); - const client = await connectClient(testPort); + const client = await connectClient(getStartedPort()); expect(client.readyState).toBe(WebSocket.OPEN); await server.stop(); diff --git a/tests/websocket/server.ts b/tests/websocket/server.ts index 1c6068daf4c..d2ee70f846b 100644 --- a/tests/websocket/server.ts +++ b/tests/websocket/server.ts @@ -73,9 +73,10 @@ class LocalWebSocketServer implements Resource { return; } - this.server = new WebSocketServer({ port: this.port }); + const wsServer = new WebSocketServer({ port: this.port }); + this.server = wsServer; - this.server.on('connection', (socket: WebSocket) => { + wsServer.on('connection', (socket: WebSocket) => { logger.info( `[${this.name}] Client connected to ws://localhost:${this.port}`, ); @@ -92,6 +93,27 @@ class LocalWebSocketServer implements Resource { }); }); + await new Promise((resolve, reject) => { + const handleError = (error: Error) => { + wsServer.removeAllListeners('listening'); + this.server = null; + this.status = ServerStatus.STOPPED; + reject(error); + }; + + const handleListening = () => { + wsServer.off('error', handleError); + const address = wsServer.address(); + if (address && typeof address === 'object') { + this.port = address.port; + } + resolve(); + }; + + wsServer.once('listening', handleListening); + wsServer.once('error', handleError); + }); + this.status = ServerStatus.STARTED; logger.info( `[${this.name}] WebSocket server running on ws://localhost:${this.port}`, diff --git a/yarn.lock b/yarn.lock index e8f13feb95a..460259f43e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9103,7 +9103,7 @@ __metadata: languageName: node linkType: hard -"@metamask/messenger@npm:^1.0.0, @metamask/messenger@npm:^1.1.0": +"@metamask/messenger@npm:^1.0.0, @metamask/messenger@npm:^1.2.0": version: 1.2.0 resolution: "@metamask/messenger@npm:1.2.0" dependencies: @@ -9620,7 +9620,7 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^13.2.0, @metamask/ramps-controller@npm:^13.3.1": +"@metamask/ramps-controller@npm:^13.3.1": version: 13.3.1 resolution: "@metamask/ramps-controller@npm:13.3.1" dependencies: @@ -35382,7 +35382,7 @@ __metadata: "@metamask/keyring-utils": "npm:^3.2.0" "@metamask/logging-controller": "npm:^8.0.0" "@metamask/message-signing-snap": "npm:^1.1.2" - "@metamask/messenger": "npm:^1.1.0" + "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:3.1.1" "@metamask/mobile-provider": "npm:^3.0.0" "@metamask/mobile-wallet-protocol-core": "npm:^0.4.0" @@ -35408,7 +35408,7 @@ __metadata: "@metamask/profile-metrics-controller": "npm:^3.1.0" "@metamask/profile-sync-controller": "npm:^28.0.2" "@metamask/providers": "npm:^18.3.1" - "@metamask/ramps-controller": "npm:^13.2.0" + "@metamask/ramps-controller": "npm:^13.3.1" "@metamask/react-data-query": "npm:^0.2.0" "@metamask/react-native-acm": "patch:@metamask/react-native-acm@npm%3A1.2.0#~/.yarn/patches/@metamask-react-native-acm-npm-1.2.0-944bf863eb.patch" "@metamask/react-native-actionsheet": "npm:2.4.2"