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"