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
10 changes: 10 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Code>` 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
Expand Down
31 changes: 9 additions & 22 deletions packages/ramps-controller/src/RampsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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 };
}
Expand Down
97 changes: 97 additions & 0 deletions packages/ramps-controller/src/errorNormalization.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
114 changes: 114 additions & 0 deletions packages/ramps-controller/src/errorNormalization.ts
Original file line number Diff line number Diff line change
@@ -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 extends string> = {
code: Code;
message?: string;
details?: Record<string, unknown>;
};

/**
* 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<string, unknown> {
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<Code extends string>(
error: unknown,
{
isValidCode,
codeProperties = ['code'],
}: {
isValidCode: (value: unknown) => value is Code;
codeProperties?: string[];
},
): TypedError<Code> | 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<Code extends string>(
error: unknown,
{
isValidCode,
fallbackCode,
codeProperties,
}: {
isValidCode: (value: unknown) => value is Code;
fallbackCode: Code;
codeProperties?: string[];
},
): TypedError<Code> {
return (
extractExplicitTypedError(error, { isValidCode, codeProperties }) ?? {
code: fallbackCode,
message: getErrorMessage(error),
}
);
}
17 changes: 17 additions & 0 deletions packages/ramps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading