From 2e12bfc238067c0f437e464fe8e3b25368a1fafd Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Tue, 15 Sep 2026 21:36:54 +0100 Subject: [PATCH 1/4] feat: publish per-asset buy limits on providers (TRAM-3994) The v2 regions providers endpoint now publishes token-specific limits (ProviderLimits.assets) so clients can show per-token minimum/maximum purchase amounts instead of the token-agnostic fiat limits, which are wrong for providers whose minimum varies per token (e.g. Coinbase: 2 EUR for ETH, 5 EUR for most other tokens). - Add ProviderAssetLimits / ProviderAssetPaymentLimit / ProviderAssetLimitsMap types mirroring the API wire shape - Add getProviderBuyLimit helper, which resolves the effective buy limit for a fiat currency, payment method, and deposit asset by intersecting the fiat and per-asset limit dimensions the same way the ramps API enforces buy limits server-side --- packages/ramps-controller/CHANGELOG.md | 5 + packages/ramps-controller/src/RampsService.ts | 40 ++ packages/ramps-controller/src/index.ts | 5 + .../src/providerLimits.test.ts | 453 ++++++++++++++++++ .../ramps-controller/src/providerLimits.ts | 180 +++++++ 5 files changed, 683 insertions(+) create mode 100644 packages/ramps-controller/src/providerLimits.test.ts create mode 100644 packages/ramps-controller/src/providerLimits.ts diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index f39ff829978..f88ab909cf1 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add per-asset buy limits support: `ProviderLimits` now carries an optional `assets` map keyed by CAIP-19 asset id (`ProviderAssetLimitsMap`), matching the per-token limits the v2 regions providers endpoint publishes for providers that configure token-specific minimum/maximum purchase amounts +- Add `getProviderBuyLimit` helper, which resolves a provider's effective buy limit for a fiat currency, payment method, and deposit asset, intersecting the token-agnostic fiat limits with the per-asset limits (including their per-payment-method breakdown) the same way the ramps API enforces buy limits server-side + ### Changed - Add an optional fee-exclusion argument to native Transak buy quotes while preserving fee exclusion as the default. ([#9317](https://github.com/MetaMask/core/pull/9317)) diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index ab2ade4a4e1..c904a240900 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -93,16 +93,56 @@ export type ProviderLimit = { feeDynamicRate: number; }; +/** + * Per-payment-method entry of a provider's per-asset limits. + */ +export type ProviderAssetPaymentLimit = ProviderLimit & { + /** + * Payment method id the limit applies to (e.g., "debit-credit-card"). + * Canonicalized to the bare form (no `/payments/` prefix) when the API + * serves canonical ids, but either form may appear. + */ + payment: string; +}; + +/** + * Per-asset (per-token) fiat limits for a provider, as exposed by the + * regions providers endpoint. + */ +export type ProviderAssetLimits = ProviderLimit & { + /** + * Optional per-payment-method breakdown. When present, an entry for the + * payment method is authoritative for it; the asset-level limits apply to + * payment methods without an entry. + */ + payments?: ProviderAssetPaymentLimit[]; +}; + /** * Fiat buy limits keyed by lowercased fiat short code, then payment method id. */ export type ProviderFiatLimits = Record>; +/** + * Per-asset buy limits keyed by CAIP-19 asset id, using the same asset ids + * (and casing) as the provider's `supportedCryptoCurrencies` map. Only + * assets with a meaningful configured limit are published. + */ +export type ProviderAssetLimitsMap = Record; + /** * Provider limits exposed by the regions providers endpoint. */ export type ProviderLimits = { fiat?: ProviderFiatLimits; + /** + * Token-specific limits, which the API publishes in addition to the + * token-agnostic `fiat` limits when the provider configures them (e.g. + * Coinbase: 2 EUR minimum for ETH but 5 EUR for other tokens). Prefer + * `getProviderBuyLimit` for lookups: it combines both dimensions the way + * the ramps API does when enforcing limits server-side. + */ + assets?: ProviderAssetLimitsMap; }; /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 6aeecbcb6cd..0469c507483 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -96,6 +96,9 @@ export type { ProviderLogos, ProviderBrowserType, ProviderLimit, + ProviderAssetPaymentLimit, + ProviderAssetLimits, + ProviderAssetLimitsMap, ProviderFiatLimits, ProviderLimits, ProviderSortOrder, @@ -135,6 +138,8 @@ export { RAMPS_CLIENT_PRODUCT_PARAM, RAMPS_CLIENT_VERSION_PARAM, } from './client-identity.js'; +export type { GetProviderBuyLimitOptions } from './providerLimits.js'; +export { getProviderBuyLimit } from './providerLimits.js'; export type { RampsServiceGetDefaultRedirectCallbackUrlAction, RampsServiceGetGeolocationAction, diff --git a/packages/ramps-controller/src/providerLimits.test.ts b/packages/ramps-controller/src/providerLimits.test.ts new file mode 100644 index 00000000000..b98f75a43e1 --- /dev/null +++ b/packages/ramps-controller/src/providerLimits.test.ts @@ -0,0 +1,453 @@ +import { getProviderBuyLimit } from './providerLimits.js'; +import type { Provider, ProviderLimit } from './RampsService.js'; + +const ETH_ASSET_ID = 'eip155:1/slip44:60'; +const BNB_ASSET_ID = 'eip155:56/slip44:714'; + +const buildLimit = ( + minAmount: number, + maxAmount: number, +): ProviderLimit => ({ + minAmount, + maxAmount, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, +}); + +const buildProvider = (limits?: Provider['limits']): Provider => ({ + id: '/providers/coinbase', + name: 'Coinbase', + environmentType: 'STAGING', + description: '', + hqAddress: '', + links: [], + logos: { light: '', dark: '', height: 24, width: 77 }, + ...(limits ? { limits } : {}), +}); + +describe('getProviderBuyLimit', () => { + it('returns the fiat limit when no per-asset limits are published', () => { + const fiatLimit = buildLimit(2, 6400); + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': fiatLimit } }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'EUR', + paymentMethodId: 'debit-credit-card', + }), + ).toBe(fiatLimit); + }); + + it('lowercases the fiat currency for the lookup', () => { + const fiatLimit = buildLimit(2, 6400); + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': fiatLimit } }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'Eur', + paymentMethodId: 'debit-credit-card', + }), + ).toBe(fiatLimit); + }); + + it('matches payment method ids regardless of /payments/ prefix on either side', () => { + const fiatLimit = buildLimit(2, 6400); + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': fiatLimit } }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: '/payments/debit-credit-card', + }), + ).toBe(fiatLimit); + + const prefixedFiatLimit = buildLimit(2, 6400); + const prefixedProvider = buildProvider({ + fiat: { eur: { '/payments/debit-credit-card': prefixedFiatLimit } }, + }); + + expect( + getProviderBuyLimit({ + provider: prefixedProvider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + }), + ).toBe(prefixedFiatLimit); + }); + + it('intersects the fiat limit with the per-asset limit', () => { + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': buildLimit(2, 6400) } }, + assets: { [BNB_ASSET_ID]: buildLimit(5, 4000) }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toStrictEqual({ + minAmount: 5, + maxAmount: 4000, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + }); + + it('keeps the fiat limit when the per-asset limit is wider', () => { + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': buildLimit(2, 6400) } }, + assets: { [BNB_ASSET_ID]: buildLimit(1, 10000) }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toStrictEqual({ + minAmount: 2, + maxAmount: 6400, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + }); + + it('takes fees from the fiat limit when intersecting', () => { + const provider = buildProvider({ + fiat: { + eur: { + 'debit-credit-card': { ...buildLimit(2, 6400), feeFixedRate: 0.5 }, + }, + }, + assets: { + [BNB_ASSET_ID]: { ...buildLimit(5, 4000), feeFixedRate: 0.9 }, + }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + })?.feeFixedRate, + ).toBe(0.5); + }); + + it('prefers the per-payment breakdown of the asset limit', () => { + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': buildLimit(2, 6400) } }, + assets: { + [BNB_ASSET_ID]: { + ...buildLimit(5, 4000), + payments: [ + { + payment: 'debit-credit-card', + ...buildLimit(10, 2000), + }, + ], + }, + }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toStrictEqual({ + minAmount: 10, + maxAmount: 2000, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + }); + + it('matches the per-payment breakdown with a /payments/ prefix', () => { + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': buildLimit(2, 6400) } }, + assets: { + [BNB_ASSET_ID]: { + ...buildLimit(5, 4000), + payments: [ + { + payment: '/payments/debit-credit-card', + ...buildLimit(10, 2000), + }, + ], + }, + }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: '/payments/debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toStrictEqual({ + minAmount: 10, + maxAmount: 2000, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + }); + + it('ignores the asset-level limit when a non-matching per-payment breakdown exists', () => { + const fiatLimit = buildLimit(2, 6400); + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': fiatLimit } }, + assets: { + [BNB_ASSET_ID]: { + ...buildLimit(5, 4000), + payments: [ + { + payment: 'bank-transfer', + ...buildLimit(10, 2000), + }, + ], + }, + }, + }); + + // Mirrors the backend: a non-empty breakdown is authoritative, so an + // unmatched payment method means no crypto limit applies. + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toBe(fiatLimit); + }); + + it('returns only the per-asset limit when no fiat limit is published for the payment method', () => { + const provider = buildProvider({ + assets: { [BNB_ASSET_ID]: buildLimit(5, 4000) }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toStrictEqual({ + minAmount: 5, + maxAmount: 4000, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + }); + + it('falls back to the fiat limit when the asset has no published limits', () => { + const fiatLimit = buildLimit(2, 6400); + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': fiatLimit } }, + assets: { [ETH_ASSET_ID]: buildLimit(2, 6400) }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: 'eip155:1/erc20:0xusdt', + }), + ).toBe(fiatLimit); + }); + + it('matches EVM asset ids case-insensitively', () => { + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': buildLimit(2, 6400) } }, + assets: { [BNB_ASSET_ID]: buildLimit(5, 4000) }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: 'EIP155:56/SLIP44:714', + }), + ).toStrictEqual({ + minAmount: 5, + maxAmount: 4000, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + }); + + it('keeps non-EVM asset ids case-sensitive', () => { + const solanaAssetId = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const fiatLimit = buildLimit(2, 6400); + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': fiatLimit } }, + assets: { [solanaAssetId]: buildLimit(5, 4000) }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: solanaAssetId.toUpperCase(), + }), + ).toBe(fiatLimit); + }); + + it('returns undefined when the provider has no limits', () => { + expect( + getProviderBuyLimit({ + provider: buildProvider(), + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toBeUndefined(); + }); + + it('returns undefined when the fiat map has no entry for the payment method', () => { + const provider = buildProvider({ + fiat: { eur: { 'bank-transfer': buildLimit(2, 6400) } }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + }), + ).toBeUndefined(); + }); + + it('treats a maxAmount of 0 as unbounded when intersecting', () => { + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': buildLimit(2, 0) } }, + assets: { [BNB_ASSET_ID]: buildLimit(5, 4000) }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toStrictEqual({ + minAmount: 5, + maxAmount: 4000, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + + const unboundedAssetProvider = buildProvider({ + fiat: { eur: { 'debit-credit-card': buildLimit(2, 6400) } }, + assets: { [BNB_ASSET_ID]: buildLimit(5, 0) }, + }); + + expect( + getProviderBuyLimit({ + provider: unboundedAssetProvider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toStrictEqual({ + minAmount: 5, + maxAmount: 6400, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + }); + + it('falls back to the asset-level limit when the per-payment breakdown is empty', () => { + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': buildLimit(2, 6400) } }, + assets: { + [BNB_ASSET_ID]: { + ...buildLimit(5, 4000), + payments: [], + }, + }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toStrictEqual({ + minAmount: 5, + maxAmount: 4000, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + }); + + it('falls back to the fiat limit when the asset id is provided but no per-asset limits are published', () => { + const fiatLimit = buildLimit(2, 6400); + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': fiatLimit } }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toBe(fiatLimit); + }); + + it('returns undefined when required arguments are missing', () => { + const provider = buildProvider({ + fiat: { eur: { 'debit-credit-card': buildLimit(2, 6400) } }, + }); + + expect( + getProviderBuyLimit({ + provider: null, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + }), + ).toBeUndefined(); + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: null, + paymentMethodId: 'debit-credit-card', + }), + ).toBeUndefined(); + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: '', + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/ramps-controller/src/providerLimits.ts b/packages/ramps-controller/src/providerLimits.ts new file mode 100644 index 00000000000..c8d622522fe --- /dev/null +++ b/packages/ramps-controller/src/providerLimits.ts @@ -0,0 +1,180 @@ +import type { + Provider, + ProviderAssetLimits, + ProviderLimit, +} from './RampsService.js'; +import { normalizeRampsAssetId } from './providerAvailability.js'; + +/** + * The canonical payment method id path prefix, which the v2 API strips from + * wire ids but clients may still supply (or receive, when the API serves + * legacy ids). + */ +const PAYMENTS_PREFIX_PATTERN = /^\/payments\//iu; + +/** + * Normalizes a payment method id for limit-key comparison by trimming it and + * stripping the canonical `/payments/` path prefix, so `debit-credit-card` + * and `/payments/debit-credit-card` match each other. The API publishes + * limit keys in either form depending on whether it serves canonical ids. + * + * @param paymentMethodId - A payment method id in either form. + * @returns The normalized id used for limit-key comparison. + */ +function normalizePaymentMethodId(paymentMethodId: string): string { + return paymentMethodId.trim().replace(PAYMENTS_PREFIX_PATTERN, ''); +} + +/** + * Finds the limit entry for a payment method among record keys that may use + * either the prefixed or bare payment method id form. + * + * @param byPaymentMethod - Limit entries keyed by payment method id. + * @param paymentMethodId - The payment method id to look up. + * @returns The matching entry, or `undefined` when there is none. + */ +function findLimitForPaymentMethod( + byPaymentMethod: Record, + paymentMethodId: string, +): LimitEntry | undefined { + const target = normalizePaymentMethodId(paymentMethodId); + for (const [key, entry] of Object.entries(byPaymentMethod)) { + if (normalizePaymentMethodId(key) === target) { + return entry; + } + } + return undefined; +} + +/** + * Finds the per-payment-method limit for a payment method in a provider's + * per-asset limits. Mirrors the ramps API: a non-empty `payments` breakdown + * is authoritative, so a payment method without an entry has no asset + * limit (the asset-level limits are not used as a fallback). + * + * @param assetLimits - The provider's per-asset limits. + * @param paymentMethodId - The payment method id to look up. + * @returns The matching limit, or `undefined` when there is none. + */ +function findAssetLimitForPaymentMethod( + assetLimits: ProviderAssetLimits, + paymentMethodId: string, +): ProviderLimit | undefined { + if (assetLimits.payments?.length) { + const target = normalizePaymentMethodId(paymentMethodId); + return assetLimits.payments.find( + (entry) => normalizePaymentMethodId(entry.payment) === target, + ); + } + return assetLimits; +} + +/** + * Intersects a provider's fiat limit with its per-asset limit the way the + * ramps API does when enforcing buy limits: the effective minimum is the + * higher of the two, the effective maximum is the lower of the two + * (treating a `maxAmount` of 0 as unbounded), and the fiat limit's fees win. + * + * @param fiatLimit - The provider's fiat-level limit, if published. + * @param assetLimit - The provider's per-asset limit, if published. + * @returns The combined limit, or whichever limit exists. + */ +function intersectLimits( + fiatLimit: ProviderLimit | undefined, + assetLimit: ProviderLimit | undefined, +): ProviderLimit | undefined { + if (fiatLimit && assetLimit) { + return { + minAmount: Math.max(fiatLimit.minAmount, assetLimit.minAmount), + maxAmount: Math.min( + fiatLimit.maxAmount || Number.POSITIVE_INFINITY, + assetLimit.maxAmount || Number.POSITIVE_INFINITY, + ), + feeFixedRate: fiatLimit.feeFixedRate, + feeDynamicRate: fiatLimit.feeDynamicRate, + }; + } + return fiatLimit ?? assetLimit; +} + +/** + * Options for {@link getProviderBuyLimit}. + */ +export type GetProviderBuyLimitOptions = { + /** + * The provider to look up limits for. + */ + provider: Provider | null | undefined; + /** + * Fiat currency short code (e.g., "EUR"). Matched case-insensitively. + */ + fiatCurrency: string | null | undefined; + /** + * Payment method id (e.g., "debit-credit-card"), in either the bare or + * `/payments/`-prefixed form. + */ + paymentMethodId: string | null | undefined; + /** + * CAIP-19 asset id of the token being bought (e.g., + * "eip155:56/slip44:714"), used to prefer the provider's per-token limits. + * EVM asset ids are matched case-insensitively. + */ + assetId?: string | null; +}; + +/** + * Resolves a provider's effective buy limit for a fiat currency, payment + * method, and (optionally) deposit asset. + * + * The regions providers endpoint publishes two limit dimensions: a + * token-agnostic fiat map (`limits.fiat[fiat][paymentMethod]`) and, for + * providers that configure token-specific limits, a per-asset map + * (`limits.assets[assetId]`, with an optional per-payment-method breakdown). + * When the provider publishes limits for the asset, the two dimensions are + * intersected (tightest bounds win) exactly as the ramps API does when + * enforcing buy limits server-side; otherwise the fiat limit alone applies. + * Without this, providers whose minimum varies per token (e.g. Coinbase: 2 + * EUR for ETH but 5 EUR for most other tokens) advertise a minimum that is + * wrong for most tokens. + * + * @param options - The options. + * @param options.provider - The provider to look up limits for. + * @param options.fiatCurrency - Fiat currency short code (e.g., "EUR"), + * matched case-insensitively. + * @param options.paymentMethodId - Payment method id (e.g., + * "debit-credit-card"), in either the bare or `/payments/`-prefixed form. + * @param options.assetId - CAIP-19 asset id of the token being bought + * (e.g., "eip155:56/slip44:714"), used to prefer the provider's per-token + * limits. EVM asset ids are matched case-insensitively. + * @returns The effective limit, or `undefined` when the provider publishes + * no usable limit for the combination. + */ +export function getProviderBuyLimit({ + provider, + fiatCurrency, + paymentMethodId, + assetId, +}: GetProviderBuyLimitOptions): ProviderLimit | undefined { + if (!provider?.limits || !fiatCurrency || !paymentMethodId) { + return undefined; + } + + const fiatLimits = provider.limits.fiat?.[fiatCurrency.trim().toLowerCase()]; + const fiatLimit = fiatLimits + ? findLimitForPaymentMethod(fiatLimits, paymentMethodId) + : undefined; + + if (!assetId) { + return fiatLimit; + } + + const targetAssetId = normalizeRampsAssetId(assetId.trim()); + const assetEntry = Object.entries(provider.limits.assets ?? {}).find( + ([key]) => normalizeRampsAssetId(key) === targetAssetId, + )?.[1]; + const assetLimit = assetEntry + ? findAssetLimitForPaymentMethod(assetEntry, paymentMethodId) + : undefined; + + return intersectLimits(fiatLimit, assetLimit); +} From a50dcf0641336baadcc04f0f21b338e4966f69a2 Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Tue, 15 Sep 2026 21:49:43 +0100 Subject: [PATCH 2/4] fix: enforce per-asset buy limits in widened quote pick (TRAM-3994) Apply review feedback: - Use getProviderBuyLimit for the widened quote pick's limit-fit check on buy quotes, so a provider whose limits for the requested asset do not fit the amount is skipped instead of being judged by the token-agnostic fiat limits. Sell quotes keep the fiat-map check, since per-asset limits are a buy dimension. - Return per-asset limits in the uniform ProviderLimit shape (no leaked payment field) when the limit comes from the per-payment breakdown. - Document that the client intersects for every provider publishing asset limits, which can be stricter than API quotes for providers the backend does not enforce per-crypto limits for. --- packages/ramps-controller/CHANGELOG.md | 2 + .../src/RampsController.test.ts | 140 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 33 ++++- .../src/providerLimits.test.ts | 30 ++++ .../ramps-controller/src/providerLimits.ts | 32 +++- 5 files changed, 230 insertions(+), 7 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index f88ab909cf1..014a74c0890 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Enforce per-asset buy limits when picking the single best quote on the widened all-providers `getQuotes` path: for buy quotes, a provider is now skipped when its published limits for the requested asset (fiat ∩ per-asset) do not fit the amount, instead of only checking the token-agnostic fiat limits, so providers whose minimum varies per token are judged correctly + - Add an optional fee-exclusion argument to native Transak buy quotes while preserving fee exclusion as the default. ([#9317](https://github.com/MetaMask/core/pull/9317)) - Bump `@metamask/profile-sync-controller` from `^32.0.0` to `^32.1.1` ([#10184](https://github.com/MetaMask/core/pull/10184), [#10220](https://github.com/MetaMask/core/pull/10220)) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 4f9f56b0939..85592c04b17 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -801,6 +801,146 @@ describe('RampsController', () => { ); }); + it('skips a provider whose per-asset minimum does not fit the amount even when the fiat minimum does', async () => { + const response: QuotesResponse = { + success: [appBrowserQuote(MOONPAY, 90), appBrowserQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + state: scopeState([ + // MoonPay's fiat minimum (2) fits the $100 amount, but its + // minimum for the requested asset (200) does not, so it is + // skipped (TRAM-3994: token-agnostic minimums are wrong for + // most tokens). + buildScopeProvider(MOONPAY, 'aggregator', { + fiat: { + usd: { + [SCOPE_PAYMENT_METHOD]: { + minAmount: 2, + maxAmount: 1000, + feeFixedRate: 0, + feeDynamicRate: 0, + }, + }, + }, + assets: { + [SCOPE_ASSET_ID]: { + minAmount: 200, + maxAmount: 1000, + feeFixedRate: 0, + feeDynamicRate: 0, + }, + }, + }), + // Revolut publishes no limits, so it stays eligible. + buildScopeProvider(REVOLUT, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotes.success[0]?.provider).toBe(REVOLUT); + }, + ); + }); + + it('keeps a provider whose per-asset minimum fits the amount eligible', async () => { + const response: QuotesResponse = { + success: [appBrowserQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + state: scopeState([ + buildScopeProvider(MOONPAY, 'aggregator', { + fiat: { + usd: { + [SCOPE_PAYMENT_METHOD]: { + minAmount: 2, + maxAmount: 1000, + feeFixedRate: 0, + feeDynamicRate: 0, + }, + }, + }, + assets: { + [SCOPE_ASSET_ID]: { + minAmount: 50, + maxAmount: 1000, + feeFixedRate: 0, + feeDynamicRate: 0, + }, + }, + }), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // The $100 amount is above the asset minimum (50), so MoonPay is + // not excluded. + expect(quotes.success[0]?.provider).toBe(MOONPAY); + }, + ); + }); + + it('keeps a quote from a provider absent from the catalog eligible', async () => { + // A stale providers state can make the API return a quote for a + // provider the catalog does not list. With no published limits to + // check, the quote stays eligible. + const UNKNOWN = '/providers/unknown'; + const response: QuotesResponse = { + success: [appBrowserQuote(UNKNOWN, 99), appBrowserQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [UNKNOWN, REVOLUT] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + state: scopeState([ + buildScopeProvider(REVOLUT, 'aggregator', fiatLimit(2, 1000)), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotes.success[0]?.provider).toBe(UNKNOWN); + }, + ); + }); + it('returns an empty success list when no quote fits the published provider limits', async () => { const response: QuotesResponse = { success: [appBrowserQuote(MOONPAY, 90)], diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index e3a64c33fe6..f47c5055ab8 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -59,6 +59,7 @@ import { normalizeRampsAssetId, providerServesAsset, } from './providerAvailability.js'; +import { getProviderBuyLimit } from './providerLimits.js'; import type { RampsControllerMethodActions } from './RampsController-method-action-types.js'; import type { RampsErrorCode } from './rampsErrorCodes.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; @@ -2608,6 +2609,8 @@ export class RampsController extends BaseController< const selectedQuote = this.#pickWidenedQuote(response, { amount: options.amount, fiat: normalizedFiat, + assetId: normalizedAssetId, + action, providers: widenedProviderCatalog, allowlist: providerAllowlist, }); @@ -2638,14 +2641,21 @@ export class RampsController extends BaseController< * Every provider class is eligible (native, in-app WebView aggregator, and * external-browser / custom-action). When the feature flag payload carries a * provider allowlist, candidates from unlisted providers are dropped first. - * Enforces per-provider fiat limits up front, then orders by reliability and - * falls back to price using the server-provided `sorted` order. Returns - * `undefined` when no quote survives. + * Enforces per-provider limits up front — intersecting the fiat limits with + * the per-asset limits for buy quotes, so providers whose minimum varies + * per token are judged by the limit for the requested asset — then orders + * by reliability and falls back to price using the server-provided `sorted` + * order. Returns `undefined` when no quote survives. * * @param response - The multi-provider quotes response. * @param options - Selection inputs. * @param options.amount - Fiat amount, for the limit-fit check. * @param options.fiat - Lowercased fiat short code, for the limit lookup. + * @param options.assetId - CAIP-19 asset id of the requested asset, for the + * per-asset limit lookup. + * @param options.action - The ramp action the quotes were fetched for. Only + * buy quotes are checked against the per-asset limits, which are a buy + * dimension; sell quotes keep the fiat-map check. * @param options.providers - Provider catalog for the limit lookup. * @param options.allowlist - Optional provider ids (either `/providers/x` * or bare form) the pick is restricted to. @@ -2656,11 +2666,15 @@ export class RampsController extends BaseController< { amount, fiat, + assetId, + action, providers, allowlist, }: { amount: number; fiat: string; + assetId: string; + action: RampAction; providers: Provider[]; allowlist?: string[]; }, @@ -2679,7 +2693,18 @@ export class RampsController extends BaseController< const fitsProviderLimits = (quote: Quote): boolean => { const provider = providerByCode.get(quote.provider); - const limit = provider?.limits?.fiat?.[fiat]?.[quote.quote.paymentMethod]; + if (!provider) { + return true; + } + const limit = + action === 'buy' + ? getProviderBuyLimit({ + provider, + fiatCurrency: fiat, + paymentMethodId: quote.quote.paymentMethod, + assetId, + }) + : provider.limits?.fiat?.[fiat]?.[quote.quote.paymentMethod]; if (!limit) { // No published limits for this provider/payment method: treat as // eligible and let the provider enforce limits at checkout. diff --git a/packages/ramps-controller/src/providerLimits.test.ts b/packages/ramps-controller/src/providerLimits.test.ts index b98f75a43e1..1b085e6f03a 100644 --- a/packages/ramps-controller/src/providerLimits.test.ts +++ b/packages/ramps-controller/src/providerLimits.test.ts @@ -259,6 +259,36 @@ describe('getProviderBuyLimit', () => { }); }); + it('returns the per-payment entry without the payment field when no fiat limit is published', () => { + const provider = buildProvider({ + assets: { + [BNB_ASSET_ID]: { + ...buildLimit(5, 4000), + payments: [ + { + payment: 'debit-credit-card', + ...buildLimit(10, 2000), + }, + ], + }, + }, + }); + + expect( + getProviderBuyLimit({ + provider, + fiatCurrency: 'eur', + paymentMethodId: 'debit-credit-card', + assetId: BNB_ASSET_ID, + }), + ).toStrictEqual({ + minAmount: 10, + maxAmount: 2000, + feeFixedRate: 0.1, + feeDynamicRate: 0.2, + }); + }); + it('falls back to the fiat limit when the asset has no published limits', () => { const fiatLimit = buildLimit(2, 6400); const provider = buildProvider({ diff --git a/packages/ramps-controller/src/providerLimits.ts b/packages/ramps-controller/src/providerLimits.ts index c8d622522fe..691911a0c44 100644 --- a/packages/ramps-controller/src/providerLimits.ts +++ b/packages/ramps-controller/src/providerLimits.ts @@ -46,6 +46,23 @@ function findLimitForPaymentMethod( return undefined; } +/** + * Strips the fields that only exist on per-asset limit entries, so a + * per-asset limit is always returned in the uniform {@link ProviderLimit} + * shape regardless of which entry it came from. + * + * @param assetLimits - The per-asset limits entry. + * @returns The entry's limits. + */ +function toProviderLimit(assetLimits: ProviderAssetLimits): ProviderLimit { + return { + minAmount: assetLimits.minAmount, + maxAmount: assetLimits.maxAmount, + feeFixedRate: assetLimits.feeFixedRate, + feeDynamicRate: assetLimits.feeDynamicRate, + }; +} + /** * Finds the per-payment-method limit for a payment method in a provider's * per-asset limits. Mirrors the ramps API: a non-empty `payments` breakdown @@ -62,11 +79,13 @@ function findAssetLimitForPaymentMethod( ): ProviderLimit | undefined { if (assetLimits.payments?.length) { const target = normalizePaymentMethodId(paymentMethodId); - return assetLimits.payments.find( - (entry) => normalizePaymentMethodId(entry.payment) === target, + const entry = assetLimits.payments.find( + (paymentLimit) => + normalizePaymentMethodId(paymentLimit.payment) === target, ); + return entry ? toProviderLimit(entry) : undefined; } - return assetLimits; + return toProviderLimit(assetLimits); } /** @@ -137,6 +156,13 @@ export type GetProviderBuyLimitOptions = { * EUR for ETH but 5 EUR for most other tokens) advertise a minimum that is * wrong for most tokens. * + * The API gates its own per-crypto enforcement on a per-provider list + * (`ENFORCE_CRYPTO_PAYMENT_LIMITS_PROVIDERS`) that is invisible to clients, + * so this helper intersects for every provider that publishes asset limits. + * For a non-enforced provider the result can therefore be stricter than the + * API's quotes — the safe direction, since it matches the limit the + * provider's own checkout enforces. + * * @param options - The options. * @param options.provider - The provider to look up limits for. * @param options.fiatCurrency - Fiat currency short code (e.g., "EUR"), From c3cec16611a3cf237a7ad9769fa20689702a7ef5 Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Tue, 15 Sep 2026 21:52:05 +0100 Subject: [PATCH 3/4] docs(ramps): link changelog entries to PR --- packages/ramps-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 014a74c0890..9c04130b66c 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,12 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add per-asset buy limits support: `ProviderLimits` now carries an optional `assets` map keyed by CAIP-19 asset id (`ProviderAssetLimitsMap`), matching the per-token limits the v2 regions providers endpoint publishes for providers that configure token-specific minimum/maximum purchase amounts -- Add `getProviderBuyLimit` helper, which resolves a provider's effective buy limit for a fiat currency, payment method, and deposit asset, intersecting the token-agnostic fiat limits with the per-asset limits (including their per-payment-method breakdown) the same way the ramps API enforces buy limits server-side +- Add per-asset buy limits support: `ProviderLimits` now carries an optional `assets` map keyed by CAIP-19 asset id (`ProviderAssetLimitsMap`), matching the per-token limits the v2 regions providers endpoint publishes for providers that configure token-specific minimum/maximum purchase amounts ([#10254](https://github.com/MetaMask/core/pull/10254)) +- Add `getProviderBuyLimit` helper, which resolves a provider's effective buy limit for a fiat currency, payment method, and deposit asset, intersecting the token-agnostic fiat limits with the per-asset limits (including their per-payment-method breakdown) the same way the ramps API enforces buy limits server-side ([#10254](https://github.com/MetaMask/core/pull/10254)) ### Changed -- Enforce per-asset buy limits when picking the single best quote on the widened all-providers `getQuotes` path: for buy quotes, a provider is now skipped when its published limits for the requested asset (fiat ∩ per-asset) do not fit the amount, instead of only checking the token-agnostic fiat limits, so providers whose minimum varies per token are judged correctly +- Enforce per-asset buy limits when picking the single best quote on the widened all-providers `getQuotes` path: for buy quotes, a provider is now skipped when its published limits for the requested asset (fiat ∩ per-asset) do not fit the amount, instead of only checking the token-agnostic fiat limits, so providers whose minimum varies per token are judged correctly ([#10254](https://github.com/MetaMask/core/pull/10254)) - Add an optional fee-exclusion argument to native Transak buy quotes while preserving fee exclusion as the default. ([#9317](https://github.com/MetaMask/core/pull/9317)) - Bump `@metamask/profile-sync-controller` from `^32.0.0` to `^32.1.1` ([#10184](https://github.com/MetaMask/core/pull/10184), [#10220](https://github.com/MetaMask/core/pull/10220)) From c2211a3250c8083e370fe5e6ef199e2a907022c6 Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Tue, 15 Sep 2026 22:01:00 +0100 Subject: [PATCH 4/4] style(ramps): format provider limits files with oxfmt --- packages/ramps-controller/src/providerLimits.test.ts | 5 +---- packages/ramps-controller/src/providerLimits.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/ramps-controller/src/providerLimits.test.ts b/packages/ramps-controller/src/providerLimits.test.ts index 1b085e6f03a..88f220cd3b0 100644 --- a/packages/ramps-controller/src/providerLimits.test.ts +++ b/packages/ramps-controller/src/providerLimits.test.ts @@ -4,10 +4,7 @@ import type { Provider, ProviderLimit } from './RampsService.js'; const ETH_ASSET_ID = 'eip155:1/slip44:60'; const BNB_ASSET_ID = 'eip155:56/slip44:714'; -const buildLimit = ( - minAmount: number, - maxAmount: number, -): ProviderLimit => ({ +const buildLimit = (minAmount: number, maxAmount: number): ProviderLimit => ({ minAmount, maxAmount, feeFixedRate: 0.1, diff --git a/packages/ramps-controller/src/providerLimits.ts b/packages/ramps-controller/src/providerLimits.ts index 691911a0c44..4f670d3a88a 100644 --- a/packages/ramps-controller/src/providerLimits.ts +++ b/packages/ramps-controller/src/providerLimits.ts @@ -1,9 +1,9 @@ +import { normalizeRampsAssetId } from './providerAvailability.js'; import type { Provider, ProviderAssetLimits, ProviderLimit, } from './RampsService.js'; -import { normalizeRampsAssetId } from './providerAvailability.js'; /** * The canonical payment method id path prefix, which the v2 API strips from