Skip to content
Draft
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
7 changes: 7 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
140 changes: 140 additions & 0 deletions packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down
33 changes: 29 additions & 4 deletions packages/ramps-controller/src/RampsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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.
Expand All @@ -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[];
},
Expand All @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions packages/ramps-controller/src/RampsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, ProviderLimit>>;

/**
* 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<string, ProviderAssetLimits>;

/**
* 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;
};

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/ramps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export type {
ProviderLogos,
ProviderBrowserType,
ProviderLimit,
ProviderAssetPaymentLimit,
ProviderAssetLimits,
ProviderAssetLimitsMap,
ProviderFiatLimits,
ProviderLimits,
ProviderSortOrder,
Expand Down Expand Up @@ -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,
Expand Down
Loading