diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 362d9d0c14..e603191caf 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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)) +- Add pure error-normalization helpers `getErrorMessage`, `extractExplicitTypedError`, and `normalizeToTypedError` (with a `TypedError` type) so consumers can share error-shape extraction while keeping their own error-code taxonomy ([#9409](https://github.com/MetaMask/core/pull/9409)) + +### Changed + +- `RampsController` now derives its internal region provider-asset matching and quote in-app/custom-action/external filtering from the shared `providerAvailability` and `quoteClassification` helpers, so the exposed helpers stay behaviourally identical to the controller's own selection ([#9409](https://github.com/MetaMask/core/pull/9409)) + ## [15.1.0] ### Added diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 6187c4a497..802bb2f96a 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -9,6 +9,8 @@ import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; +import { getProvidersServingAsset } from './providerAvailability'; +import { isCustomActionQuote, isExternalBrowserQuote } from './quoteClassification'; import type { RampsControllerMethodActions } from './RampsController-method-action-types'; import type { RampsErrorCode } from './rampsErrorCodes'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; @@ -2121,14 +2123,9 @@ export class RampsController extends BaseController< if (customActionProviderCodes.has(providerCode)) { return false; } - // Defensive: the wire may carry an inline `isCustomAction` flag that is - // absent from the published `Quote` type. - if ( - (quote.quote as { isCustomAction?: boolean }).isCustomAction === true - ) { - return false; - } - if (quote.quote.buyWidget?.browser === 'IN_APP_OS_BROWSER') { + // Custom-action and external-browser classification is shared with the + // consuming client via `quoteClassification` so both filter identically. + if (isCustomActionQuote(quote) || isExternalBrowserQuote(quote)) { return false; } } @@ -2199,20 +2196,10 @@ export class RampsController extends BaseController< ({ providers } = await this.getProviders(normalizedRegion)); } - // EVM CAIP-19 asset IDs may arrive checksummed or lowercased, and the - // providers API returns both forms, so match case-insensitively on both - // sides. Only the lowercased forms are compared (the original IDs are never - // returned), so case-sensitive non-EVM asset IDs are not corrupted. - const normalizedAssetId = assetId.toLowerCase(); - const supporting = providers.filter((provider) => { - const map = provider?.supportedCryptoCurrencies; - if (!map) { - return false; - } - return Object.keys(map).some( - (key) => key.toLowerCase() === normalizedAssetId, - ); - }); + // Case-insensitive CAIP-19 matching is shared with headless-buy consumers + // via `getProvidersServingAsset`, so the controller and the UI region gate + // cannot disagree about which providers serve the asset. + const supporting = getProvidersServingAsset(providers, assetId); return { supporting, all: providers }; } diff --git a/packages/ramps-controller/src/errorNormalization.test.ts b/packages/ramps-controller/src/errorNormalization.test.ts new file mode 100644 index 0000000000..f07abd93e4 --- /dev/null +++ b/packages/ramps-controller/src/errorNormalization.test.ts @@ -0,0 +1,97 @@ +import { + extractExplicitTypedError, + getErrorMessage, + normalizeToTypedError, +} from './errorNormalization'; + +type Code = 'NO_QUOTES' | 'QUOTE_FAILED' | 'UNKNOWN'; + +const isValidCode = (value: unknown): value is Code => + value === 'NO_QUOTES' || value === 'QUOTE_FAILED' || value === 'UNKNOWN'; + +describe('getErrorMessage', () => { + it('reads Error.message', () => { + expect(getErrorMessage(new Error('boom'))).toBe('boom'); + }); + + it('reads a record message', () => { + expect(getErrorMessage({ message: 'nope' })).toBe('nope'); + }); + + it('returns a string value directly', () => { + expect(getErrorMessage('raw')).toBe('raw'); + }); + + it('returns undefined when no message can be derived', () => { + expect(getErrorMessage(42)).toBeUndefined(); + expect(getErrorMessage(null)).toBeUndefined(); + }); +}); + +describe('extractExplicitTypedError', () => { + it('reads a valid code from the default `code` property', () => { + expect( + extractExplicitTypedError( + { code: 'NO_QUOTES', message: 'none' }, + { isValidCode }, + ), + ).toStrictEqual({ code: 'NO_QUOTES', message: 'none', details: undefined }); + }); + + it('honours codeProperties precedence order', () => { + expect( + extractExplicitTypedError( + { headlessCode: 'QUOTE_FAILED', code: 'NO_QUOTES' }, + { isValidCode, codeProperties: ['headlessCode', 'code'] }, + ), + ).toStrictEqual({ + code: 'QUOTE_FAILED', + message: undefined, + details: undefined, + }); + }); + + it('passes through a record details object', () => { + expect( + extractExplicitTypedError( + { code: 'NO_QUOTES', details: { providerId: 'moonpay' } }, + { isValidCode }, + ), + ).toStrictEqual({ + code: 'NO_QUOTES', + message: undefined, + details: { providerId: 'moonpay' }, + }); + }); + + it('returns undefined when no valid code is present', () => { + expect( + extractExplicitTypedError({ code: 'NOT_A_CODE' }, { isValidCode }), + ).toBeUndefined(); + expect(extractExplicitTypedError('boom', { isValidCode })).toBeUndefined(); + }); +}); + +describe('normalizeToTypedError', () => { + it('returns the explicit typed error when present', () => { + expect( + normalizeToTypedError( + { code: 'QUOTE_FAILED', message: 'x' }, + { isValidCode, fallbackCode: 'UNKNOWN' }, + ), + ).toStrictEqual({ + code: 'QUOTE_FAILED', + message: 'x', + details: undefined, + }); + }); + + it('falls back with the derived message when no valid code is present', () => { + expect( + normalizeToTypedError(new Error('boom'), { + isValidCode, + fallbackCode: 'UNKNOWN', + }), + ).toStrictEqual({ code: 'UNKNOWN', message: 'boom' }); + }); +}); diff --git a/packages/ramps-controller/src/errorNormalization.ts b/packages/ramps-controller/src/errorNormalization.ts new file mode 100644 index 0000000000..a3d82f3991 --- /dev/null +++ b/packages/ramps-controller/src/errorNormalization.ts @@ -0,0 +1,114 @@ +/** + * A typed error surface: a stable code plus an optional human message and + * structured details. Consumers parameterise `Code` with their own error-code + * union (e.g. headless-buy codes) so the taxonomy stays with the consumer while + * the pure extraction below is shared. + */ +export type TypedError = { + code: Code; + message?: string; + details?: Record; +}; + +/** + * Type guard for a non-null object. + * + * @param value - The value to test. + * @returns Whether the value is a record. + */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Best-effort human-readable message from an arbitrary thrown/native value. + * + * @param error - The caught value. + * @returns The message, or `undefined` when none can be derived. + */ +export function getErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + if (isRecord(error) && typeof error.message === 'string') { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return undefined; +} + +/** + * Extracts a caller-recognised typed error from an arbitrary thrown/native + * value, when the value carries an explicit, valid code on one of the given + * properties. Pure: performs no side effects and applies no fallback, so + * callers keep full control over precedence (e.g. domain-specific special + * cases) and the fallback code. + * + * @param error - The caught value. + * @param options - The options. + * @param options.isValidCode - Type guard identifying a recognised code. + * @param options.codeProperties - Property names to read a code from, in + * precedence order. Defaults to `['code']`. + * @returns The typed error when an explicit valid code is present, else + * `undefined`. + */ +export function extractExplicitTypedError( + error: unknown, + { + isValidCode, + codeProperties = ['code'], + }: { + isValidCode: (value: unknown) => value is Code; + codeProperties?: string[]; + }, +): TypedError | undefined { + if (!isRecord(error)) { + return undefined; + } + for (const property of codeProperties) { + const candidate = error[property]; + if (isValidCode(candidate)) { + return { + code: candidate, + message: getErrorMessage(error), + details: isRecord(error.details) ? error.details : undefined, + }; + } + } + return undefined; +} + +/** + * Normalises an arbitrary thrown/native value into a {@link TypedError}, using + * the caller's recognised codes and falling back to `fallbackCode` when no + * explicit valid code is present. Pure. + * + * @param error - The caught value. + * @param options - The options. + * @param options.isValidCode - Type guard identifying a recognised code. + * @param options.fallbackCode - Code used when no explicit valid code is found. + * @param options.codeProperties - Property names to read a code from, in + * precedence order. Defaults to `['code']`. + * @returns The typed error. + */ +export function normalizeToTypedError( + error: unknown, + { + isValidCode, + fallbackCode, + codeProperties, + }: { + isValidCode: (value: unknown) => value is Code; + fallbackCode: Code; + codeProperties?: string[]; + }, +): TypedError { + return ( + extractExplicitTypedError(error, { isValidCode, codeProperties }) ?? { + code: fallbackCode, + message: getErrorMessage(error), + } + ); +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 372bcdefa9..8c8818dc30 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -142,6 +142,23 @@ export { export { RAMPS_ERROR_CODES } from './rampsErrorCodes'; export type { RequestSelectorResult } from './selectors'; export { createRequestSelector } from './selectors'; +export { + providerServesAsset, + getProvidersServingAsset, + regionHasProviderForAsset, + isFiatDepositAvailable, +} from './providerAvailability'; +export { + isExternalBrowserQuote, + isCustomActionQuote, + isInAppOnlyQuote, +} from './quoteClassification'; +export type { TypedError } from './errorNormalization'; +export { + getErrorMessage, + extractExplicitTypedError, + normalizeToTypedError, +} from './errorNormalization'; export type { TransakServiceActions, TransakServiceEvents, diff --git a/packages/ramps-controller/src/providerAvailability.test.ts b/packages/ramps-controller/src/providerAvailability.test.ts new file mode 100644 index 0000000000..4112beb9cf --- /dev/null +++ b/packages/ramps-controller/src/providerAvailability.test.ts @@ -0,0 +1,175 @@ +import { + getProvidersServingAsset, + isFiatDepositAvailable, + providerServesAsset, + regionHasProviderForAsset, +} from './providerAvailability'; +import type { Provider } from './RampsService'; + +const ASSET_ID = 'eip155:1/erc20:0xtoken'; + +const buildProvider = ( + id: string, + type: 'native' | 'aggregator', + supportedCryptoCurrencies?: Provider['supportedCryptoCurrencies'], +): Provider => ({ + id, + name: id, + type, + environmentType: 'STAGING', + description: '', + hqAddress: '', + links: [], + logos: { light: '', dark: '', height: 24, width: 77 }, + ...(supportedCryptoCurrencies ? { supportedCryptoCurrencies } : {}), +}); + +describe('providerServesAsset', () => { + it('returns true when the provider lists the asset', () => { + const provider = buildProvider('moonpay', 'aggregator', { + [ASSET_ID]: true, + }); + expect(providerServesAsset(provider, ASSET_ID)).toBe(true); + }); + + it('matches case-insensitively on both sides', () => { + const provider = buildProvider('moonpay', 'aggregator', { + 'EIP155:1/ERC20:0xTOKEN': true, + }); + expect(providerServesAsset(provider, ASSET_ID)).toBe(true); + }); + + it('returns false when the provider has no supported map', () => { + const provider = buildProvider('moonpay', 'aggregator'); + expect(providerServesAsset(provider, ASSET_ID)).toBe(false); + }); + + it('returns false when the asset is not listed', () => { + const provider = buildProvider('moonpay', 'aggregator', { + 'eip155:1/slip44:60': true, + }); + expect(providerServesAsset(provider, ASSET_ID)).toBe(false); + }); +}); + +describe('getProvidersServingAsset', () => { + it('keeps only providers serving the asset', () => { + const serving = buildProvider('moonpay', 'aggregator', { + [ASSET_ID]: true, + }); + const other = buildProvider('banxa', 'aggregator', { + 'eip155:1/slip44:60': true, + }); + expect(getProvidersServingAsset([serving, other], ASSET_ID)).toStrictEqual([ + serving, + ]); + }); +}); + +describe('regionHasProviderForAsset', () => { + it('returns false for an empty assetId regardless of scope', () => { + const provider = buildProvider('native', 'native', { '': true }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: '', + scope: 'all', + }), + ).toBe(false); + }); + + it('returns true when a native provider serves the asset, even under scope off', () => { + const provider = buildProvider('native', 'native', { [ASSET_ID]: true }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: ASSET_ID, + scope: 'off', + }), + ).toBe(true); + }); + + it('returns false under scope off when only an aggregator serves the asset', () => { + const provider = buildProvider('moonpay', 'aggregator', { + [ASSET_ID]: true, + }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: ASSET_ID, + scope: 'off', + }), + ).toBe(false); + }); + + it('returns true under scope in-app when an aggregator serves the asset', () => { + const provider = buildProvider('moonpay', 'aggregator', { + [ASSET_ID]: true, + }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: ASSET_ID, + scope: 'in-app', + }), + ).toBe(true); + }); + + it('returns false when no provider serves the asset, even when widened', () => { + const provider = buildProvider('moonpay', 'aggregator', { + 'eip155:1/slip44:60': true, + }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: ASSET_ID, + scope: 'all', + }), + ).toBe(false); + }); +}); + +describe('isFiatDepositAvailable', () => { + it('returns true when a native provider is selected, even under scope off', () => { + const selectedProvider = buildProvider('native', 'native'); + expect( + isFiatDepositAvailable({ + providers: [], + selectedProvider, + scope: 'off', + }), + ).toBe(true); + }); + + it('returns false under scope off when the selected provider is not native', () => { + const selectedProvider = buildProvider('moonpay', 'aggregator'); + expect( + isFiatDepositAvailable({ + providers: [selectedProvider], + selectedProvider, + scope: 'off', + }), + ).toBe(false); + }); + + it('returns true under scope in-app when the region has any provider', () => { + const provider = buildProvider('moonpay', 'aggregator'); + expect( + isFiatDepositAvailable({ + providers: [provider], + selectedProvider: null, + scope: 'in-app', + }), + ).toBe(true); + }); + + it('returns false when widened but the region has no providers', () => { + expect( + isFiatDepositAvailable({ + providers: [], + selectedProvider: null, + scope: 'all', + }), + ).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/providerAvailability.ts b/packages/ramps-controller/src/providerAvailability.ts new file mode 100644 index 0000000000..64caa4cf36 --- /dev/null +++ b/packages/ramps-controller/src/providerAvailability.ts @@ -0,0 +1,120 @@ +import type { ProviderScope } from './RampsController'; +import type { Provider } from './RampsService'; + +/** + * Whether a provider serves the given deposit asset. + * + * Mirrors the region-provider matching the controller performs internally: a + * provider serves the asset when its `supportedCryptoCurrencies` map (keyed by + * CAIP-19 asset id) contains the asset id, compared case-insensitively on both + * sides. EVM CAIP-19 asset ids may arrive checksummed or lowercased and the + * providers API returns both forms, so only the lowercased forms are compared. + * A provider without a `supportedCryptoCurrencies` map is treated as not + * serving the asset. + * + * @param provider - The provider to test. + * @param assetId - CAIP-19 asset id of the deposit asset. + * @returns Whether the provider serves the asset. + */ +export function providerServesAsset( + provider: Provider, + assetId: string, +): boolean { + const map = provider?.supportedCryptoCurrencies; + if (!map) { + return false; + } + const target = assetId.toLowerCase(); + return Object.keys(map).some((key) => key.toLowerCase() === target); +} + +/** + * Filters a provider list down to those serving the given deposit asset. + * + * @param providers - The providers to filter. + * @param assetId - CAIP-19 asset id of the deposit asset. + * @returns The subset of providers serving the asset. + */ +export function getProvidersServingAsset( + providers: Provider[], + assetId: string, +): Provider[] { + return providers.filter((provider) => providerServesAsset(provider, assetId)); +} + +/** + * Whether a region offers a usable on-ramp provider that serves the given + * deposit asset, under the active provider-class scope. This is the pure, + * asset-aware region gate shared between the controller and headless-buy + * consumers so the two never disagree about eligibility. + * + * Scope `off` is native-only: the region must offer a native provider (e.g. + * Transak Native) that serves the asset. Scope `in-app` / `all` treats the + * region as supported when any provider (native or in-app aggregator) serves + * the asset; the controller's scope-aware `getQuotes` performs the precise + * in-app provider selection at quote time. Fails closed: an empty or missing + * `assetId` returns `false`. + * + * @param options - The options. + * @param options.providers - The region's providers (native and aggregator). + * @param options.assetId - CAIP-19 asset id of the deposit asset. + * @param options.scope - The effective provider-class scope. + * @returns Whether the region has a provider serving the asset under scope. + */ +export function regionHasProviderForAsset({ + providers, + assetId, + scope, +}: { + providers: Provider[]; + assetId: string; + scope: ProviderScope; +}): boolean { + if (!assetId) { + return false; + } + const serving = getProvidersServingAsset(providers, assetId); + if (serving.some((provider) => provider.type === 'native')) { + return true; + } + if (scope === 'off') { + return false; + } + return serving.length > 0; +} + +/** + * Whether headless fiat deposit is available for the current region and + * provider-class scope. Scope-aware and independent of which single provider is + * currently selected once widened, so the availability gate cannot disagree + * with the controller's own scope-aware provider pick. + * + * Scope `off` keeps the native-only behaviour: a native provider must be the + * currently selected (preferred) one, since the controller resolves the + * selected provider first and an aggregator preferred provider would otherwise + * run a non-native deposit. Scope `in-app` / `all` make the flow available + * whenever the region has any provider. + * + * @param options - The options. + * @param options.providers - The region's providers. + * @param options.selectedProvider - The currently selected provider, if any. + * @param options.scope - The effective provider-class scope. + * @returns Whether headless fiat deposit is available. + */ +export function isFiatDepositAvailable({ + providers, + selectedProvider, + scope, +}: { + providers: Provider[]; + selectedProvider?: Provider | null; + scope: ProviderScope; +}): boolean { + if (selectedProvider?.type === 'native') { + return true; + } + if (scope === 'off') { + return false; + } + return providers.length > 0; +} diff --git a/packages/ramps-controller/src/quoteClassification.test.ts b/packages/ramps-controller/src/quoteClassification.test.ts new file mode 100644 index 0000000000..55735ae62e --- /dev/null +++ b/packages/ramps-controller/src/quoteClassification.test.ts @@ -0,0 +1,75 @@ +import { + isCustomActionQuote, + isExternalBrowserQuote, + isInAppOnlyQuote, +} from './quoteClassification'; +import type { Quote } from './RampsService'; + +const buildQuote = ( + overrides: { + browser?: string; + isCustomAction?: boolean; + } = {}, +): Quote => + ({ + provider: 'moonpay', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: 'credit_debit_card', + ...(overrides.browser + ? { buyWidget: { url: 'https://widget.example', browser: overrides.browser } } + : {}), + ...(overrides.isCustomAction === undefined + ? {} + : { isCustomAction: overrides.isCustomAction }), + }, + metadata: { reliability: 1 }, + }) as unknown as Quote; + +describe('isExternalBrowserQuote', () => { + it('returns true when the buy widget targets the OS browser', () => { + expect(isExternalBrowserQuote(buildQuote({ browser: 'IN_APP_OS_BROWSER' }))).toBe( + true, + ); + }); + + it('returns false for an in-app widget browser', () => { + expect(isExternalBrowserQuote(buildQuote({ browser: 'APP_BROWSER' }))).toBe( + false, + ); + }); + + it('returns false when no browser hint is present', () => { + expect(isExternalBrowserQuote(buildQuote())).toBe(false); + }); +}); + +describe('isCustomActionQuote', () => { + it('returns true when the inline isCustomAction flag is set', () => { + expect(isCustomActionQuote(buildQuote({ isCustomAction: true }))).toBe(true); + }); + + it('returns false when the flag is false or absent', () => { + expect(isCustomActionQuote(buildQuote({ isCustomAction: false }))).toBe( + false, + ); + expect(isCustomActionQuote(buildQuote())).toBe(false); + }); +}); + +describe('isInAppOnlyQuote', () => { + it('returns true for a plain in-app aggregator quote', () => { + expect(isInAppOnlyQuote(buildQuote({ browser: 'APP_BROWSER' }))).toBe(true); + }); + + it('returns false for an external-browser quote', () => { + expect(isInAppOnlyQuote(buildQuote({ browser: 'IN_APP_OS_BROWSER' }))).toBe( + false, + ); + }); + + it('returns false for a custom-action quote', () => { + expect(isInAppOnlyQuote(buildQuote({ isCustomAction: true }))).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/quoteClassification.ts b/packages/ramps-controller/src/quoteClassification.ts new file mode 100644 index 0000000000..dd0fbcf004 --- /dev/null +++ b/packages/ramps-controller/src/quoteClassification.ts @@ -0,0 +1,41 @@ +import type { Quote } from './RampsService'; + +/** + * Whether a quote's checkout runs in an external / system browser rather than + * an in-app WebView. Decided by the per-quote `buyWidget.browser` hint from the + * quotes API. When the hint is absent the quote is treated as in-app (the + * caller is responsible for the in-app WebView fail-safe). + * + * This is the pure classification only: it deliberately knows nothing about + * host redirect URLs or deeplink schemes, which stay in the consuming client. + * + * @param quote - The quote to classify. + * @returns Whether the quote uses an external browser. + */ +export function isExternalBrowserQuote(quote: Quote): boolean { + return quote.quote?.buyWidget?.browser === 'IN_APP_OS_BROWSER'; +} + +/** + * Whether a quote is a custom-action ("checkout outside of MetaMask") quote, + * e.g. PayPal or Robinhood. Reads the inline `isCustomAction` flag, which the + * wire may carry even though it is absent from the published `Quote` type. + * + * @param quote - The quote to classify. + * @returns Whether the quote is a custom-action quote. + */ +export function isCustomActionQuote(quote: Quote): boolean { + return (quote.quote as { isCustomAction?: boolean })?.isCustomAction === true; +} + +/** + * Whether a quote continues inside an in-app WebView, i.e. it is neither a + * custom-action quote nor an external-browser quote. This is the Phase 1 + * in-app-only inclusion test. + * + * @param quote - The quote to classify. + * @returns Whether the quote is an in-app WebView quote. + */ +export function isInAppOnlyQuote(quote: Quote): boolean { + return !isCustomActionQuote(quote) && !isExternalBrowserQuote(quote); +}