From a3d867de5f31c062bf1129e0e7a0adf92043be29 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Sat, 6 Jun 2026 00:44:16 -0500 Subject: [PATCH 1/2] feat(transaction-pay-controller): surface fiat ramps quote error as fiatPayment.quoteError Add `TransactionFiatQuoteError` type and `quoteError` field to `TransactionFiatPayment` so the mobile UI can display provider-specific rejection messages (e.g. "Minimum purchase is $X") for providers without client-side structured limits. The fiat quote flow classifies the first error entry from `quotes.error` as `LIMIT_EXCEEDED` when the message matches limit keywords, or `QUOTE_FAILED` otherwise; the field is cleared on success. Co-Authored-By: Claude Sonnet 4.6 --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../transaction-pay-controller/jest.config.js | 21 ++- .../transaction-pay-controller/src/index.ts | 1 + .../src/strategy/fiat/fiat-quotes.test.ts | 178 +++++++++++++++++- .../src/strategy/fiat/fiat-quotes.ts | 65 ++++++- .../transaction-pay-controller/src/types.ts | 19 ++ 6 files changed, 280 insertions(+), 5 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 3d2e2263ba..95417963c7 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `quoteError` field to `TransactionFiatPayment` and export `TransactionFiatQuoteError` type; the fiat quote flow now surfaces the provider's rejection reason (classified as `LIMIT_EXCEEDED` or `QUOTE_FAILED`) in state so the mobile UI can display provider-specific messages (e.g. "Minimum purchase is $X") - Adding processing for postQuote transactions with paymentOverride defined ([#8967](https://github.com/MetaMask/core/pull/8967)) - Add optional `getAmountData` callback and `TransactionPayController:getAmountData` messenger action for client-side nested calldata re-encoding ([#8987](https://github.com/MetaMask/core/pull/8987)) - Add `@metamask/keyring-controller` `^26.0.0` as a dependency ([#8972](https://github.com/MetaMask/core/pull/8972)) diff --git a/packages/transaction-pay-controller/jest.config.js b/packages/transaction-pay-controller/jest.config.js index ca08413339..53babb1ef6 100644 --- a/packages/transaction-pay-controller/jest.config.js +++ b/packages/transaction-pay-controller/jest.config.js @@ -10,7 +10,7 @@ const baseConfig = require('../../jest.config.packages'); const displayName = path.basename(__dirname); -module.exports = merge(baseConfig, { +const merged = merge(baseConfig, { // The display name when running multiple projects displayName, @@ -24,3 +24,22 @@ module.exports = merge(baseConfig, { }, }, }); + +// Prepend a specific mapping for @metamask/eth-hd-keyring/v2 before the +// generic @metamask/(.+) catch-all so that Jest picks it up first. +// deepmerge appends keys, but Jest evaluates moduleNameMapper in insertion +// order, so the generic pattern would win otherwise. +module.exports = { + ...merged, + moduleNameMapper: { + '^@metamask/eth-hd-keyring/v2$': path.resolve( + __dirname, + '../../node_modules/@metamask/eth-hd-keyring/dist/hd-keyring-v2.cjs', + ), + '^@metamask/eth-simple-keyring/v2$': path.resolve( + __dirname, + '../../node_modules/@metamask/eth-simple-keyring/dist/simple-keyring-v2.cjs', + ), + ...merged.moduleNameMapper, + }, +}; diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index fe2679a011..da7e2de4b6 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -9,6 +9,7 @@ export type { TransactionData, TransactionFiatPayment, TransactionFiatPaymentCallback, + TransactionFiatQuoteError, TransactionPayControllerActions, TransactionPayControllerEvents, TransactionPayControllerGetStateAction, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts index 748e4c712a..2ee18f2f9c 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts @@ -10,6 +10,7 @@ import { TransactionPayStrategy } from '../../constants'; import type { PayStrategyGetQuotesRequest, TransactionFiatPayment, + TransactionFiatQuoteError, TransactionPayQuote, TransactionPayRequiredToken, } from '../../types'; @@ -137,8 +138,11 @@ function getRequest({ throwsOnRampsQuotes?: Error; } = {}): { callMock: jest.Mock; + capturedFiatPayment: TransactionFiatPayment; request: PayStrategyGetQuotesRequest; } { + const capturedFiatPayment: TransactionFiatPayment = {}; + const callMock = jest.fn( (action: string, requestArg?: Record) => { if (action === 'TransactionPayController:getState') { @@ -167,8 +171,7 @@ function getRequest({ const { callback } = requestArg as unknown as { callback: (fiatPayment: TransactionFiatPayment) => void; }; - const fiatPayment: TransactionFiatPayment = {}; - callback(fiatPayment); + callback(capturedFiatPayment); return undefined; } @@ -178,6 +181,7 @@ function getRequest({ return { callMock, + capturedFiatPayment, request: { accountSupports7702: false, fiatPaymentMethod, @@ -582,4 +586,174 @@ describe('getFiatQuotes', () => { // provider = relay(1) + ramps(0) = 1 expect(result[0].fees.provider).toStrictEqual({ fiat: '1', usd: '1' }); }); + + describe('quoteError surfacing', () => { + it('sets quoteError with LIMIT_EXCEEDED code when provider error message contains "minimum"', async () => { + const { capturedFiatPayment, request } = getRequest({ + rampsQuotes: { + customActions: [], + error: [ + { + provider: '/providers/transak-native-staging', + error: 'Minimum purchase is $20', + }, + ], + sorted: [], + success: [], + }, + }); + + await getFiatQuotes(request); + + expect(capturedFiatPayment.quoteError).toStrictEqual( + { + code: 'LIMIT_EXCEEDED', + message: 'Minimum purchase is $20', + }, + ); + }); + + it('sets quoteError with LIMIT_EXCEEDED code when provider error message contains "maximum"', async () => { + const { capturedFiatPayment, request } = getRequest({ + rampsQuotes: { + customActions: [], + error: [ + { + provider: '/providers/transak-native-staging', + error: 'Maximum purchase limit exceeded', + }, + ], + sorted: [], + success: [], + }, + }); + + await getFiatQuotes(request); + + expect(capturedFiatPayment.quoteError).toStrictEqual( + { + code: 'LIMIT_EXCEEDED', + message: 'Maximum purchase limit exceeded', + }, + ); + }); + + it('sets quoteError with LIMIT_EXCEEDED code when provider error message contains "limit"', async () => { + const { capturedFiatPayment, request } = getRequest({ + rampsQuotes: { + customActions: [], + error: [ + { + provider: '/providers/transak-native-staging', + error: 'Transaction limit reached for today', + }, + ], + sorted: [], + success: [], + }, + }); + + await getFiatQuotes(request); + + expect(capturedFiatPayment.quoteError).toStrictEqual( + { + code: 'LIMIT_EXCEEDED', + message: 'Transaction limit reached for today', + }, + ); + }); + + it('sets quoteError with QUOTE_FAILED when provider error message does not match limit keywords', async () => { + const { capturedFiatPayment, request } = getRequest({ + rampsQuotes: { + customActions: [], + error: [ + { + provider: '/providers/transak-native-staging', + error: 'Provider is temporarily unavailable', + }, + ], + sorted: [], + success: [], + }, + }); + + await getFiatQuotes(request); + + expect(capturedFiatPayment.quoteError).toStrictEqual( + { + code: 'QUOTE_FAILED', + message: 'Provider is temporarily unavailable', + }, + ); + }); + + it('sets quoteError with QUOTE_FAILED and no message when error array is empty', async () => { + const { capturedFiatPayment, request } = getRequest({ + rampsQuotes: { + customActions: [], + error: [], + sorted: [], + success: [], + }, + }); + + await getFiatQuotes(request); + + expect(capturedFiatPayment.quoteError).toStrictEqual( + { + code: 'QUOTE_FAILED', + message: undefined, + }, + ); + }); + + it('sets quoteError with QUOTE_FAILED and no message when first error entry has no error field', async () => { + const { capturedFiatPayment, request } = getRequest({ + rampsQuotes: { + customActions: [], + error: [{ provider: '/providers/transak-native-staging' }], + sorted: [], + success: [], + }, + }); + + await getFiatQuotes(request); + + expect(capturedFiatPayment.quoteError).toStrictEqual( + { + code: 'QUOTE_FAILED', + message: undefined, + }, + ); + }); + + it('clears quoteError on the fiat payment state when quote succeeds', async () => { + const { capturedFiatPayment, request } = getRequest(); + + await getFiatQuotes(request); + + expect(capturedFiatPayment.quoteError).toBeUndefined(); + }); + + it('does not treat rate-related messages as LIMIT_EXCEEDED', async () => { + const { capturedFiatPayment, request } = getRequest({ + rampsQuotes: { + customActions: [], + error: [ + { + provider: '/providers/transak-native-staging', + error: 'Exchange rate request failed', + }, + ], + sorted: [], + success: [], + }, + }); + + await getFiatQuotes(request); + + expect(capturedFiatPayment.quoteError?.code).toBe('QUOTE_FAILED'); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts index 668dca896a..19291282cd 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -8,6 +8,8 @@ import { projectLogger } from '../../logger'; import type { PayStrategyGetQuotesRequest, QuoteRequest, + TransactionFiatPayment, + TransactionFiatQuoteError, TransactionPayRequiredToken, TransactionPayQuote, } from '../../types'; @@ -127,8 +129,9 @@ export async function getFiatQuotes( }); messenger.call('TransactionPayController:updateFiatPayment', { - callback: (fiatPayment) => { + callback: (fiatPayment: TransactionFiatPayment) => { fiatPayment.rampsQuote = fiatQuote; + fiatPayment.quoteError = undefined; }, transactionId, }); @@ -143,6 +146,17 @@ export async function getFiatQuotes( ]; } catch (error) { log('Failed to fetch fiat quotes', { error, transactionId }); + + const quoteError = isRampsQuoteError(error) + ? error.quoteError + : { code: 'QUOTE_FAILED' as const }; + + messenger.call('TransactionPayController:updateFiatPayment', { + callback: (fiatPayment: TransactionFiatPayment) => { + fiatPayment.quoteError = quoteError; + }, + transactionId, + }); } return []; @@ -184,12 +198,59 @@ async function getRampsQuote({ const quote = quotes.success?.[0]; if (!quote) { - throw new Error('No matching ramps quote found for selected provider'); + const errorEntry = quotes.error?.[0]; + const message = errorEntry?.error; + const code = classifyRampsError(message); + + const err = new Error('No matching ramps quote found for selected provider') as Error & { + quoteError: TransactionFiatQuoteError; + }; + err.quoteError = { code, message }; + throw err; } return quote; } +/** + * Returns true if the thrown value is an Error with an attached `quoteError` + * property produced by {@link getRampsQuote}. + * + * @param error - The caught value to inspect. + * @returns Whether the error carries structured ramps quote error metadata. + */ +function isRampsQuoteError( + error: unknown, +): error is Error & { quoteError: TransactionFiatQuoteError } { + return ( + error instanceof Error && + 'quoteError' in error && + error.quoteError !== null && + typeof error.quoteError === 'object' + ); +} + +/** + * Classifies a provider error message into a structured error code. + * Messages that mention purchase/transaction limits are classified as + * `LIMIT_EXCEEDED`; all other failures fall back to `QUOTE_FAILED`. + * + * @param message - The raw error string from the provider, if any. + * @returns The error code to surface in `TransactionFiatPayment.quoteError`. + */ +function classifyRampsError( + message: string | undefined, +): TransactionFiatQuoteError['code'] { + if ( + message && + /\b(minimum|maximum|limit)\b/iu.test(message) && + !/\b(rate|request)\b/iu.test(message) + ) { + return 'LIMIT_EXCEEDED'; + } + return 'QUOTE_FAILED'; +} + function buildRelayRequestFromAmountFiat({ amountFiat, fiatAsset, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index eb170afa46..01f2b3b7a5 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -342,6 +342,19 @@ export type TransactionData = { totals?: TransactionPayTotals; }; +/** + * Structured error surfaced when a ramps quote attempt fails. + * `LIMIT_EXCEEDED` is used when the provider message indicates a minimum or + * maximum purchase limit; `QUOTE_FAILED` covers all other failures. + */ +export type TransactionFiatQuoteError = { + /** Broad classification of the failure reason. */ + code: 'LIMIT_EXCEEDED' | 'QUOTE_FAILED'; + + /** Human-readable message returned by the provider, if available. */ + message?: string; +}; + /** Fiat payment state stored per transaction. */ export type TransactionFiatPayment = { /** Entered fiat amount for the selected payment method. */ @@ -353,6 +366,12 @@ export type TransactionFiatPayment = { /** Order identifier in normalized format (/providers/{provider}/orders/{id}). */ orderId?: string; + /** + * Structured error from the last failed ramps quote attempt. + * Present when the most recent quote fetch failed; cleared on success. + */ + quoteError?: TransactionFiatQuoteError; + /** The ramps quote received from the ramps provider. */ rampsQuote?: RampsQuote; From 86a299a60ee68fe1c5cd915b86f4aadd90fb9ae2 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Sat, 6 Jun 2026 03:16:07 -0500 Subject: [PATCH 2/2] fix(transaction-pay-controller): allow aggregator providers in fiat quote fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fiat strategy was calling RampsController:getQuotes with restrictToKnownOrNativeProviders: true, which silently drops aggregator providers (type = 'aggregator', e.g. onramp.money) and returns an empty quote list when no native provider (e.g. Transak) is available in the user's region. This makes quotes impossible in regions served only by aggregators — India with onramp.money + ETH is a concrete example: the eligibility gate (getBestProviderForAsset) correctly returns onramp.money, but the subsequent quote call refused to use it, causing an infinite spinner. The restriction was originally added for headless UB2 to enforce Transak- native-only quotes. The fiat strategy (moneyAccountDeposit path) does not have that constraint — the gate already verified that a supporting provider exists, so the quote call should trust it and use whatever provider the cascade selects. Co-Authored-By: Claude Opus 4.8 --- .../src/strategy/fiat/fiat-quotes.test.ts | 2 +- .../src/strategy/fiat/fiat-quotes.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts index 2ee18f2f9c..10ed84915f 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts @@ -247,7 +247,7 @@ describe('getFiatQuotes', () => { autoSelectProvider: true, fiat: 'USD', paymentMethods: ['/payments/debit-credit-card'], - restrictToKnownOrNativeProviders: true, + restrictToKnownOrNativeProviders: false, walletAddress: WALLET_ADDRESS, }), ); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts index 19291282cd..c6f314b02b 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -187,7 +187,12 @@ async function getRampsQuote({ autoSelectProvider: true, fiat: DEFAULT_FIAT_CURRENCY, paymentMethods: [fiatPaymentMethod], - restrictToKnownOrNativeProviders: true, + // Do not restrict to native-only providers — the fiat strategy is used for + // moneyAccountDeposit which may be served by aggregator providers (e.g. + // onramp.money in regions where Transak is unavailable). The gate + // (RampsController:getBestProviderForAsset) already verified that a + // supporting provider exists for the asset in the user's region. + restrictToKnownOrNativeProviders: false, walletAddress, });