Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b5a92b1
fix(ramp): localize circuit breaker ramp errors
saustrie-consensys Apr 29, 2026
f699df0
Refactor ramps provider error selection
saustrie-consensys Apr 29, 2026
4fae07d
Preserve ramps quote error metadata
saustrie-consensys Apr 29, 2026
10bb077
Guard quote error rendering in BuildQuote
saustrie-consensys Apr 29, 2026
eaf2edb
Refine ramps circuit breaker user message
saustrie-consensys Apr 29, 2026
33f1ba9
chore: bump @metamask/ramps-controller from ^13.2.0 to ^13.3.0
saustrie-consensys May 6, 2026
c85c6a4
chore: bump @metamask/messenger to ^1.2.0 and dedupe lockfile
saustrie-consensys May 6, 2026
85fcf49
Merge remote-tracking branch 'origin/main' into saustrie-consensys/TR…
saustrie-consensys May 6, 2026
4b7b83e
chore: yarn dedupe ramps-controller lockfile entries
saustrie-consensys May 6, 2026
567d82c
Merge remote-tracking branch 'origin/main' into saustrie-consensys/TR…
saustrie-consensys May 7, 2026
ff26f8c
test: fix ramp ci coverage and websocket port race
saustrie-consensys May 8, 2026
4201772
Merge remote-tracking branch 'origin/main' into saustrie-consensys/TR…
saustrie-consensys May 8, 2026
460f637
chore: align iOS generated files
saustrie-consensys May 8, 2026
37acc45
Merge remote-tracking branch 'origin/main' into saustrie-consensys/TR…
saustrie-consensys May 8, 2026
32b321f
chore: update E2E default fixture
metamaskbot May 8, 2026
0f0d3da
chore: trigger CLA re-run on head
saustrie-consensys May 11, 2026
d322f17
Merge branch 'main' into saustrie-consensys/TRAM-3475-circuit-breaker…
amitabh94 May 11, 2026
44d4a7b
chore: fix yarn.lock for ramps-controller descriptor
amitabh94 May 11, 2026
95908b5
[skip ci] Bump version number to 4910
metamaskbot May 11, 2026
ecbc24d
Revert "[skip ci] Bump version number to 4910"
amitabh94 May 11, 2026
ee203fc
Merge branch 'main' into saustrie-consensys/TRAM-3475-circuit-breaker…
amitabh94 May 12, 2026
39fbde7
Merge branch 'main' into saustrie-consensys/TRAM-3475-circuit-breaker…
amitabh94 May 12, 2026
206e81f
Merge branch 'main' into saustrie-consensys/TRAM-3475-circuit-breaker…
amitabh94 May 12, 2026
5ff0b72
Merge branch 'main' into saustrie-consensys/TRAM-3475-circuit-breaker…
saustrie-consensys May 13, 2026
89c5d56
Merge branch 'main' into saustrie-consensys/TRAM-3475-circuit-breaker…
amitabh94 May 14, 2026
2d67f0f
Merge branch 'main' into saustrie-consensys/TRAM-3475-circuit-breaker…
Copilot May 15, 2026
bcfc471
fix(ramp): update ramps controller dependency
amitabh94 May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(<BuildQuote />, {
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(<BuildQuote />, {
state: initialRootState,
});

expect(queryByText('Quote fetch failed')).toBeOnTheScreen();

quotesHookResult = {
data: { success: [WIDGET_PROVIDER_QUOTE] },
loading: false,
error: null,
};
rerender(<BuildQuote />);

expect(queryByText('Quote fetch failed')).not.toBeOnTheScreen();
});
});

describe('provider quote error (out-of-bounds / limits)', () => {
Expand Down
12 changes: 7 additions & 5 deletions app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,14 +458,15 @@ function BuildQuote() {
loading: selectedQuoteLoading,
error: quoteFetchError,
} = useRampsQuotes(quoteFetchEnabled ? quoteFetchParams : null);
const hasQuoteFetchError = quoteFetchError !== null;

/*
* Tracks RAMPS_QUOTE_ERROR
*/
const lastTrackedQuoteErrorRef = useRef<unknown>(null);
useEffect(() => {
if (
quoteFetchError &&
hasQuoteFetchError &&
quoteFetchError !== lastTrackedQuoteErrorRef.current
) {
lastTrackedQuoteErrorRef.current = quoteFetchError;
Expand All @@ -487,10 +488,11 @@ function BuildQuote() {
.build(),
);
}
if (!quoteFetchError) {
if (!hasQuoteFetchError) {
lastTrackedQuoteErrorRef.current = null;
}
}, [
hasQuoteFetchError,
quoteFetchError,
amountAsNumber,
currency,
Expand Down Expand Up @@ -676,7 +678,7 @@ function BuildQuote() {
hasAmount &&
hasSettledQuoteAmount &&
!selectedQuoteLoading &&
!quoteFetchError &&
!hasQuoteFetchError &&
Comment thread
cursor[bot] marked this conversation as resolved.
quotesResponse !== null &&
selectedQuote === null;

Expand Down Expand Up @@ -834,15 +836,15 @@ function BuildQuote() {
</View>
</View>

{quoteFetchError && (
{hasQuoteFetchError ? (
<BannerAlert
severity={BannerAlertSeverity.Error}
description={parseUserFacingError(
quoteFetchError,
strings('deposit.buildQuote.quoteFetchError'),
)}
/>
)}
) : null}

<View style={styles.actionSection}>
{hasAmount ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions app/components/UI/Ramp/hooks/useRampsCountries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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');
});
});
});
12 changes: 10 additions & 2 deletions app/components/UI/Ramp/hooks/useRampsCountries.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
};
}

Expand Down
29 changes: 29 additions & 0 deletions app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 },
Expand Down
9 changes: 7 additions & 2 deletions app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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,
};
}
Expand Down
47 changes: 40 additions & 7 deletions app/components/UI/Ramp/hooks/useRampsProviders.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: {
Expand All @@ -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(
Expand Down Expand Up @@ -133,8 +137,6 @@ describe('useRampsProviders', () => {
});

describe('providers query', () => {
const mockUseQuery = useQuery as jest.MockedFunction<typeof useQuery>;

it('triggers providers query when regionCode is available', () => {
const store = createMockStore();
renderHook(() => useRampsProviders(), {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading