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
1 change: 1 addition & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add 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))
Expand Down
2 changes: 2 additions & 0 deletions packages/ramps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions packages/ramps-controller/src/providerAvailability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
});
});
68 changes: 68 additions & 0 deletions packages/ramps-controller/src/providerAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading