diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index bd9e08abde..26c948ac71 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add a pure `resolveFiatDepositRoute` helper (and `FiatDepositRoute` type) that picks the on-ramp asset a region should buy for a fiat deposit: the preferred target asset (e.g. mUSD) when a provider serves it, otherwise the first convertible fallback asset (e.g. native ETH) with a serving provider, or `undefined` when none is purchasable. It reuses `regionHasProviderForAsset` so it obeys the same scope semantics (an aggregator-only fallback is unreachable under scope `off`), enabling the "buy ETH, convert to mUSD" deposit route in regions without a direct mUSD provider ([#9428](https://github.com/MetaMask/core/pull/9428)) - Add pure quote-selection helpers `getSmartSelectedQuote` and `fitsProviderLimits`, the amount validator `validateBuyAmount`, and a `BuyAmountValidation` type so headless-buy consumers can share the controller's provider-agnostic quote ranking (`preferredProviderIds` then reliability then price then first surviving candidate), scope-aware in-app filtering, and per-provider fiat-limit enforcement instead of re-deriving them ([#9414](https://github.com/MetaMask/core/pull/9414)) - Add pure provider-availability helpers `providerServesAsset`, `getProvidersServingAsset`, `regionHasProviderForAsset`, and `isFiatDepositAvailable` so headless-buy consumers can share the controller's case-insensitive CAIP-19 asset matching and scope-aware region/availability gating instead of re-deriving it, keeping the two from disagreeing ([#9409](https://github.com/MetaMask/core/pull/9409)) - Add pure quote-classification helpers `isExternalBrowserQuote`, `isCustomActionQuote`, and `isInAppOnlyQuote` so consumers can share the controller's in-app-vs-external browser-mode classification without owning host redirect/deeplink concerns ([#9409](https://github.com/MetaMask/core/pull/9409)) diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 636757607a..e31f760ba3 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -142,11 +142,13 @@ export { export { RAMPS_ERROR_CODES } from './rampsErrorCodes'; export type { RequestSelectorResult } from './selectors'; export { createRequestSelector } from './selectors'; +export type { FiatDepositRoute } from './providerAvailability'; export { providerServesAsset, getProvidersServingAsset, regionHasProviderForAsset, isFiatDepositAvailable, + resolveFiatDepositRoute, } from './providerAvailability'; export { isExternalBrowserQuote, diff --git a/packages/ramps-controller/src/providerAvailability.test.ts b/packages/ramps-controller/src/providerAvailability.test.ts index 4112beb9cf..df50a129b6 100644 --- a/packages/ramps-controller/src/providerAvailability.test.ts +++ b/packages/ramps-controller/src/providerAvailability.test.ts @@ -3,10 +3,13 @@ import { isFiatDepositAvailable, providerServesAsset, regionHasProviderForAsset, + resolveFiatDepositRoute, } from './providerAvailability'; import type { Provider } from './RampsService'; const ASSET_ID = 'eip155:1/erc20:0xtoken'; +const MUSD_ASSET_ID = 'eip155:143/erc20:0xmusd'; +const ETH_ASSET_ID = 'eip155:1/slip44:60'; const buildProvider = ( id: string, @@ -173,3 +176,93 @@ describe('isFiatDepositAvailable', () => { ).toBe(false); }); }); + +describe('resolveFiatDepositRoute', () => { + it('buys the preferred asset directly when a provider serves it', () => { + const musdProvider = buildProvider('moonpay', 'aggregator', { + [MUSD_ASSET_ID]: true, + }); + const ethProvider = buildProvider('banxa', 'aggregator', { + [ETH_ASSET_ID]: true, + }); + expect( + resolveFiatDepositRoute({ + providers: [musdProvider, ethProvider], + preferredAssetId: MUSD_ASSET_ID, + fallbackAssetIds: [ETH_ASSET_ID], + scope: 'in-app', + }), + ).toStrictEqual({ assetId: MUSD_ASSET_ID, isFallback: false }); + }); + + it('falls back to a convertible asset when the preferred asset has no provider', () => { + const ethProvider = buildProvider('banxa', 'aggregator', { + [ETH_ASSET_ID]: true, + }); + expect( + resolveFiatDepositRoute({ + providers: [ethProvider], + preferredAssetId: MUSD_ASSET_ID, + fallbackAssetIds: [ETH_ASSET_ID], + scope: 'in-app', + }), + ).toStrictEqual({ assetId: ETH_ASSET_ID, isFallback: true }); + }); + + it('tries fallback assets in order and returns the first with a provider', () => { + const secondFallback = 'eip155:1/erc20:0xusdc'; + const usdcProvider = buildProvider('banxa', 'aggregator', { + [secondFallback]: true, + }); + expect( + resolveFiatDepositRoute({ + providers: [usdcProvider], + preferredAssetId: MUSD_ASSET_ID, + fallbackAssetIds: [ETH_ASSET_ID, secondFallback], + scope: 'all', + }), + ).toStrictEqual({ assetId: secondFallback, isFallback: true }); + }); + + it('returns undefined when neither preferred nor fallback assets have a provider', () => { + const otherProvider = buildProvider('banxa', 'aggregator', { + 'eip155:1/erc20:0xother': true, + }); + expect( + resolveFiatDepositRoute({ + providers: [otherProvider], + preferredAssetId: MUSD_ASSET_ID, + fallbackAssetIds: [ETH_ASSET_ID], + scope: 'all', + }), + ).toBeUndefined(); + }); + + it('does not reach an aggregator-only fallback under scope off (native-only)', () => { + const ethProvider = buildProvider('banxa', 'aggregator', { + [ETH_ASSET_ID]: true, + }); + expect( + resolveFiatDepositRoute({ + providers: [ethProvider], + preferredAssetId: MUSD_ASSET_ID, + fallbackAssetIds: [ETH_ASSET_ID], + scope: 'off', + }), + ).toBeUndefined(); + }); + + it('reaches a native fallback provider under scope off', () => { + const ethNativeProvider = buildProvider('native', 'native', { + [ETH_ASSET_ID]: true, + }); + expect( + resolveFiatDepositRoute({ + providers: [ethNativeProvider], + preferredAssetId: MUSD_ASSET_ID, + fallbackAssetIds: [ETH_ASSET_ID], + scope: 'off', + }), + ).toStrictEqual({ assetId: ETH_ASSET_ID, isFallback: true }); + }); +}); diff --git a/packages/ramps-controller/src/providerAvailability.ts b/packages/ramps-controller/src/providerAvailability.ts index 64caa4cf36..1ccda2b8d9 100644 --- a/packages/ramps-controller/src/providerAvailability.ts +++ b/packages/ramps-controller/src/providerAvailability.ts @@ -118,3 +118,71 @@ export function isFiatDepositAvailable({ } return providers.length > 0; } + +/** + * The resolved on-ramp asset a region should buy for a fiat deposit whose end + * goal is a single target asset (e.g. mUSD). + */ +export type FiatDepositRoute = { + /** CAIP-19 asset id to actually purchase from an on-ramp provider. */ + assetId: string; + /** + * `false` when the region can buy the preferred (target) asset directly; + * `true` when it falls back to a convertible asset (e.g. native ETH that a + * downstream step swaps/bridges into the target). + */ + isFallback: boolean; +}; + +/** + * Resolves which asset a region should buy for a fiat deposit, preferring the + * target asset and otherwise falling back to a convertible one. + * + * The intent (from the "buy ETH, convert to mUSD" flow): when a region has an + * on-ramp provider serving the target deposit asset (`preferredAssetId`, e.g. + * mUSD), buy it directly. When it does not but a provider serves a fallback + * asset (e.g. native ETH), buy that instead and let a downstream step convert + * it into the target. The conversion itself is out of scope here; this only + * picks the purchasable asset. + * + * Reuses `regionHasProviderForAsset`, so it obeys the same scope semantics: + * under scope `off` only a native provider counts, so an aggregator-only + * fallback asset is not reachable until scope is widened to `in-app`/`all`. + * `fallbackAssetIds` is tried in order; the first with a serving provider under + * scope wins. Returns `undefined` when neither the preferred nor any fallback + * asset has a serving provider (the caller should show no providers). + * + * @param options - The options. + * @param options.providers - The region's providers (native and aggregator). + * @param options.preferredAssetId - CAIP-19 id of the target deposit asset. + * @param options.fallbackAssetIds - Ordered CAIP-19 ids of convertible assets + * to try when the preferred asset has no provider. + * @param options.scope - The effective provider-class scope. + * @returns The resolved route, or `undefined` when no asset is purchasable. + */ +export function resolveFiatDepositRoute({ + providers, + preferredAssetId, + fallbackAssetIds, + scope, +}: { + providers: Provider[]; + preferredAssetId: string; + fallbackAssetIds: string[]; + scope: ProviderScope; +}): FiatDepositRoute | undefined { + if ( + preferredAssetId && + regionHasProviderForAsset({ providers, assetId: preferredAssetId, scope }) + ) { + return { assetId: preferredAssetId, isFallback: false }; + } + + for (const assetId of fallbackAssetIds) { + if (regionHasProviderForAsset({ providers, assetId, scope })) { + return { assetId, isFallback: true }; + } + } + + return undefined; +}