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..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 @@ -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, @@ -243,7 +247,7 @@ describe('getFiatQuotes', () => { autoSelectProvider: true, fiat: 'USD', paymentMethods: ['/payments/debit-credit-card'], - restrictToKnownOrNativeProviders: true, + restrictToKnownOrNativeProviders: false, walletAddress: WALLET_ADDRESS, }), ); @@ -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..c6f314b02b 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 []; @@ -173,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, }); @@ -184,12 +203,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;