diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index f39ff82997..9c04130b66 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,8 +7,15 @@ 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 ([#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 ([#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)) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 4f9f56b093..85592c04b1 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 e3a64c33fe..f47c5055ab 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/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index ab2ade4a4e..c904a24090 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 6aeecbcb6c..0469c50748 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 0000000000..88f220cd3b --- /dev/null +++ b/packages/ramps-controller/src/providerLimits.test.ts @@ -0,0 +1,480 @@ +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('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({ + 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 0000000000..4f670d3a88 --- /dev/null +++ b/packages/ramps-controller/src/providerLimits.ts @@ -0,0 +1,206 @@ +import { normalizeRampsAssetId } from './providerAvailability.js'; +import type { + Provider, + ProviderAssetLimits, + ProviderLimit, +} from './RampsService.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; +} + +/** + * 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 + * 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); + const entry = assetLimits.payments.find( + (paymentLimit) => + normalizePaymentMethodId(paymentLimit.payment) === target, + ); + return entry ? toProviderLimit(entry) : undefined; + } + return toProviderLimit(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. + * + * 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"), + * 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); +}