Skip to content

Commit 685219b

Browse files
feat(ramps-controller): share provider/quote/error helpers with consumers
Expose the provider-availability, quote-classification, and error-normalization logic the controller uses internally as pure, tested helpers so headless-buy consumers (e.g. metamask-mobile) can share it instead of re-deriving it: - providerServesAsset, getProvidersServingAsset, regionHasProviderForAsset, isFiatDepositAvailable - isExternalBrowserQuote, isCustomActionQuote, isInAppOnlyQuote - getErrorMessage, extractExplicitTypedError, normalizeToTypedError (+ TypedError) RampsController now derives its own region asset-matching and quote filtering from these helpers so the shared surface stays behaviourally identical to the controller's selection.
1 parent a168fa1 commit 685219b

9 files changed

Lines changed: 658 additions & 22 deletions

packages/ramps-controller/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- 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))
13+
- 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))
14+
- 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))
15+
16+
### Changed
17+
18+
- `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))
19+
1020
## [15.1.0]
1121

1222
### Added

packages/ramps-controller/src/RampsController.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { Messenger } from '@metamask/messenger';
99
import type { Json } from '@metamask/utils';
1010
import type { Draft } from 'immer';
1111

12+
import { getProvidersServingAsset } from './providerAvailability';
13+
import { isCustomActionQuote, isExternalBrowserQuote } from './quoteClassification';
1214
import type { RampsControllerMethodActions } from './RampsController-method-action-types';
1315
import type { RampsErrorCode } from './rampsErrorCodes';
1416
import { RAMPS_ERROR_CODES } from './rampsErrorCodes';
@@ -2121,14 +2123,9 @@ export class RampsController extends BaseController<
21212123
if (customActionProviderCodes.has(providerCode)) {
21222124
return false;
21232125
}
2124-
// Defensive: the wire may carry an inline `isCustomAction` flag that is
2125-
// absent from the published `Quote` type.
2126-
if (
2127-
(quote.quote as { isCustomAction?: boolean }).isCustomAction === true
2128-
) {
2129-
return false;
2130-
}
2131-
if (quote.quote.buyWidget?.browser === 'IN_APP_OS_BROWSER') {
2126+
// Custom-action and external-browser classification is shared with the
2127+
// consuming client via `quoteClassification` so both filter identically.
2128+
if (isCustomActionQuote(quote) || isExternalBrowserQuote(quote)) {
21322129
return false;
21332130
}
21342131
}
@@ -2199,20 +2196,10 @@ export class RampsController extends BaseController<
21992196
({ providers } = await this.getProviders(normalizedRegion));
22002197
}
22012198

2202-
// EVM CAIP-19 asset IDs may arrive checksummed or lowercased, and the
2203-
// providers API returns both forms, so match case-insensitively on both
2204-
// sides. Only the lowercased forms are compared (the original IDs are never
2205-
// returned), so case-sensitive non-EVM asset IDs are not corrupted.
2206-
const normalizedAssetId = assetId.toLowerCase();
2207-
const supporting = providers.filter((provider) => {
2208-
const map = provider?.supportedCryptoCurrencies;
2209-
if (!map) {
2210-
return false;
2211-
}
2212-
return Object.keys(map).some(
2213-
(key) => key.toLowerCase() === normalizedAssetId,
2214-
);
2215-
});
2199+
// Case-insensitive CAIP-19 matching is shared with headless-buy consumers
2200+
// via `getProvidersServingAsset`, so the controller and the UI region gate
2201+
// cannot disagree about which providers serve the asset.
2202+
const supporting = getProvidersServingAsset(providers, assetId);
22162203

22172204
return { supporting, all: providers };
22182205
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
extractExplicitTypedError,
3+
getErrorMessage,
4+
normalizeToTypedError,
5+
} from './errorNormalization';
6+
7+
type Code = 'NO_QUOTES' | 'QUOTE_FAILED' | 'UNKNOWN';
8+
9+
const isValidCode = (value: unknown): value is Code =>
10+
value === 'NO_QUOTES' || value === 'QUOTE_FAILED' || value === 'UNKNOWN';
11+
12+
describe('getErrorMessage', () => {
13+
it('reads Error.message', () => {
14+
expect(getErrorMessage(new Error('boom'))).toBe('boom');
15+
});
16+
17+
it('reads a record message', () => {
18+
expect(getErrorMessage({ message: 'nope' })).toBe('nope');
19+
});
20+
21+
it('returns a string value directly', () => {
22+
expect(getErrorMessage('raw')).toBe('raw');
23+
});
24+
25+
it('returns undefined when no message can be derived', () => {
26+
expect(getErrorMessage(42)).toBeUndefined();
27+
expect(getErrorMessage(null)).toBeUndefined();
28+
});
29+
});
30+
31+
describe('extractExplicitTypedError', () => {
32+
it('reads a valid code from the default `code` property', () => {
33+
expect(
34+
extractExplicitTypedError(
35+
{ code: 'NO_QUOTES', message: 'none' },
36+
{ isValidCode },
37+
),
38+
).toStrictEqual({ code: 'NO_QUOTES', message: 'none', details: undefined });
39+
});
40+
41+
it('honours codeProperties precedence order', () => {
42+
expect(
43+
extractExplicitTypedError(
44+
{ headlessCode: 'QUOTE_FAILED', code: 'NO_QUOTES' },
45+
{ isValidCode, codeProperties: ['headlessCode', 'code'] },
46+
),
47+
).toStrictEqual({
48+
code: 'QUOTE_FAILED',
49+
message: undefined,
50+
details: undefined,
51+
});
52+
});
53+
54+
it('passes through a record details object', () => {
55+
expect(
56+
extractExplicitTypedError(
57+
{ code: 'NO_QUOTES', details: { providerId: 'moonpay' } },
58+
{ isValidCode },
59+
),
60+
).toStrictEqual({
61+
code: 'NO_QUOTES',
62+
message: undefined,
63+
details: { providerId: 'moonpay' },
64+
});
65+
});
66+
67+
it('returns undefined when no valid code is present', () => {
68+
expect(
69+
extractExplicitTypedError({ code: 'NOT_A_CODE' }, { isValidCode }),
70+
).toBeUndefined();
71+
expect(extractExplicitTypedError('boom', { isValidCode })).toBeUndefined();
72+
});
73+
});
74+
75+
describe('normalizeToTypedError', () => {
76+
it('returns the explicit typed error when present', () => {
77+
expect(
78+
normalizeToTypedError(
79+
{ code: 'QUOTE_FAILED', message: 'x' },
80+
{ isValidCode, fallbackCode: 'UNKNOWN' },
81+
),
82+
).toStrictEqual({
83+
code: 'QUOTE_FAILED',
84+
message: 'x',
85+
details: undefined,
86+
});
87+
});
88+
89+
it('falls back with the derived message when no valid code is present', () => {
90+
expect(
91+
normalizeToTypedError(new Error('boom'), {
92+
isValidCode,
93+
fallbackCode: 'UNKNOWN',
94+
}),
95+
).toStrictEqual({ code: 'UNKNOWN', message: 'boom' });
96+
});
97+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* A typed error surface: a stable code plus an optional human message and
3+
* structured details. Consumers parameterise `Code` with their own error-code
4+
* union (e.g. headless-buy codes) so the taxonomy stays with the consumer while
5+
* the pure extraction below is shared.
6+
*/
7+
export type TypedError<Code extends string> = {
8+
code: Code;
9+
message?: string;
10+
details?: Record<string, unknown>;
11+
};
12+
13+
/**
14+
* Type guard for a non-null object.
15+
*
16+
* @param value - The value to test.
17+
* @returns Whether the value is a record.
18+
*/
19+
function isRecord(value: unknown): value is Record<string, unknown> {
20+
return typeof value === 'object' && value !== null;
21+
}
22+
23+
/**
24+
* Best-effort human-readable message from an arbitrary thrown/native value.
25+
*
26+
* @param error - The caught value.
27+
* @returns The message, or `undefined` when none can be derived.
28+
*/
29+
export function getErrorMessage(error: unknown): string | undefined {
30+
if (error instanceof Error) {
31+
return error.message;
32+
}
33+
if (isRecord(error) && typeof error.message === 'string') {
34+
return error.message;
35+
}
36+
if (typeof error === 'string') {
37+
return error;
38+
}
39+
return undefined;
40+
}
41+
42+
/**
43+
* Extracts a caller-recognised typed error from an arbitrary thrown/native
44+
* value, when the value carries an explicit, valid code on one of the given
45+
* properties. Pure: performs no side effects and applies no fallback, so
46+
* callers keep full control over precedence (e.g. domain-specific special
47+
* cases) and the fallback code.
48+
*
49+
* @param error - The caught value.
50+
* @param options - The options.
51+
* @param options.isValidCode - Type guard identifying a recognised code.
52+
* @param options.codeProperties - Property names to read a code from, in
53+
* precedence order. Defaults to `['code']`.
54+
* @returns The typed error when an explicit valid code is present, else
55+
* `undefined`.
56+
*/
57+
export function extractExplicitTypedError<Code extends string>(
58+
error: unknown,
59+
{
60+
isValidCode,
61+
codeProperties = ['code'],
62+
}: {
63+
isValidCode: (value: unknown) => value is Code;
64+
codeProperties?: string[];
65+
},
66+
): TypedError<Code> | undefined {
67+
if (!isRecord(error)) {
68+
return undefined;
69+
}
70+
for (const property of codeProperties) {
71+
const candidate = error[property];
72+
if (isValidCode(candidate)) {
73+
return {
74+
code: candidate,
75+
message: getErrorMessage(error),
76+
details: isRecord(error.details) ? error.details : undefined,
77+
};
78+
}
79+
}
80+
return undefined;
81+
}
82+
83+
/**
84+
* Normalises an arbitrary thrown/native value into a {@link TypedError}, using
85+
* the caller's recognised codes and falling back to `fallbackCode` when no
86+
* explicit valid code is present. Pure.
87+
*
88+
* @param error - The caught value.
89+
* @param options - The options.
90+
* @param options.isValidCode - Type guard identifying a recognised code.
91+
* @param options.fallbackCode - Code used when no explicit valid code is found.
92+
* @param options.codeProperties - Property names to read a code from, in
93+
* precedence order. Defaults to `['code']`.
94+
* @returns The typed error.
95+
*/
96+
export function normalizeToTypedError<Code extends string>(
97+
error: unknown,
98+
{
99+
isValidCode,
100+
fallbackCode,
101+
codeProperties,
102+
}: {
103+
isValidCode: (value: unknown) => value is Code;
104+
fallbackCode: Code;
105+
codeProperties?: string[];
106+
},
107+
): TypedError<Code> {
108+
return (
109+
extractExplicitTypedError(error, { isValidCode, codeProperties }) ?? {
110+
code: fallbackCode,
111+
message: getErrorMessage(error),
112+
}
113+
);
114+
}

packages/ramps-controller/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,23 @@ export {
142142
export { RAMPS_ERROR_CODES } from './rampsErrorCodes';
143143
export type { RequestSelectorResult } from './selectors';
144144
export { createRequestSelector } from './selectors';
145+
export {
146+
providerServesAsset,
147+
getProvidersServingAsset,
148+
regionHasProviderForAsset,
149+
isFiatDepositAvailable,
150+
} from './providerAvailability';
151+
export {
152+
isExternalBrowserQuote,
153+
isCustomActionQuote,
154+
isInAppOnlyQuote,
155+
} from './quoteClassification';
156+
export type { TypedError } from './errorNormalization';
157+
export {
158+
getErrorMessage,
159+
extractExplicitTypedError,
160+
normalizeToTypedError,
161+
} from './errorNormalization';
145162
export type {
146163
TransakServiceActions,
147164
TransakServiceEvents,

0 commit comments

Comments
 (0)