Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
21 changes: 20 additions & 1 deletion packages/transaction-pay-controller/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand All @@ -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,
},
};
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
TransactionData,
TransactionFiatPayment,
TransactionFiatPaymentCallback,
TransactionFiatQuoteError,
TransactionPayControllerActions,
TransactionPayControllerEvents,
TransactionPayControllerGetStateAction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { TransactionPayStrategy } from '../../constants';
import type {
PayStrategyGetQuotesRequest,
TransactionFiatPayment,
TransactionFiatQuoteError,
TransactionPayQuote,
TransactionPayRequiredToken,
} from '../../types';
Expand Down Expand Up @@ -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<string, unknown>) => {
if (action === 'TransactionPayController:getState') {
Expand Down Expand Up @@ -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;
}

Expand All @@ -178,6 +181,7 @@ function getRequest({

return {
callMock,
capturedFiatPayment,
request: {
accountSupports7702: false,
fiatPaymentMethod,
Expand Down Expand Up @@ -243,7 +247,7 @@ describe('getFiatQuotes', () => {
autoSelectProvider: true,
fiat: 'USD',
paymentMethods: ['/payments/debit-credit-card'],
restrictToKnownOrNativeProviders: true,
restrictToKnownOrNativeProviders: false,
walletAddress: WALLET_ADDRESS,
}),
);
Expand Down Expand Up @@ -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<TransactionFiatQuoteError>(
{
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<TransactionFiatQuoteError>(
{
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<TransactionFiatQuoteError>(
{
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<TransactionFiatQuoteError>(
{
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<TransactionFiatQuoteError>(
{
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<TransactionFiatQuoteError>(
{
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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { projectLogger } from '../../logger';
import type {
PayStrategyGetQuotesRequest,
QuoteRequest,
TransactionFiatPayment,
TransactionFiatQuoteError,
TransactionPayRequiredToken,
TransactionPayQuote,
} from '../../types';
Expand Down Expand Up @@ -127,8 +129,9 @@ export async function getFiatQuotes(
});

messenger.call('TransactionPayController:updateFiatPayment', {
callback: (fiatPayment) => {
callback: (fiatPayment: TransactionFiatPayment) => {
fiatPayment.rampsQuote = fiatQuote;
fiatPayment.quoteError = undefined;
},
transactionId,
});
Expand All @@ -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,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded fetch persists quoteError

High Severity

The getFiatQuotes catch handler always calls updateFiatPayment to set quoteError but never checks request.signal. A superseded in-flight attempt can still write a stale provider error into fiatPayment after a newer refresh already succeeded, so the limit UI can show a rejection while current quotes are valid. The handler also does not clear rampsQuote, so error and quote fields can disagree.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a3d867d. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quoteError set for non-ramps failures

Medium Severity

The shared catch in getFiatQuotes always writes fiatPayment.quoteError, including when getRampsQuote never ran (relay errors, invalid amounts, unsupported multi-token). Those cases get { code: 'QUOTE_FAILED' } with no provider message, which misrepresents a ramps rejection and can drive limit/quote UI from stale or irrelevant state.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 86a299a. Configure here.

}

return [];
Expand Down Expand Up @@ -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,
});

Expand All @@ -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,
Expand Down
Loading
Loading