From 4cdb1b649d31cc5cf0d7190c2f2adfc1a2ada410 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Tue, 25 Aug 2026 15:20:07 -0500 Subject: [PATCH] feat(ramps-controller): send MetaMask client identity on on-ramp fetches Enable catalog gating by attaching optional product, app version, and RFFC environment headers without overloading the controller query param. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 1 + .../ramps-controller/src/RampsService.test.ts | 33 ++++++++++++ packages/ramps-controller/src/RampsService.ts | 47 ++++++++++++++--- .../src/TransakService.test.ts | 30 +++++++++++ .../ramps-controller/src/TransakService.ts | 26 +++++++++- .../src/client-identity.test.ts | 36 +++++++++++++ .../ramps-controller/src/client-identity.ts | 50 +++++++++++++++++++ packages/ramps-controller/src/index.ts | 7 +++ 8 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 packages/ramps-controller/src/client-identity.test.ts create mode 100644 packages/ramps-controller/src/client-identity.ts diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 4c014b651ee..d0d36dad719 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 optional `clientProduct`, `clientVersion`, and `clientEnvironment` constructor options on `RampsService` and `TransakService`. When set, they are sent on every on-ramp API fetch as `x-metamask-clientproduct`, `x-metamask-clientversion`, and `x-metamask-clientenvironment` (RFFC-aligned build flavor). The `controller` query param remains the ramps-controller package version. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) ### Changed diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index bb59974639f..fdb2de6a81a 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -48,6 +48,39 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('us-tx'); }); + it('sends client identity headers when constructor options are set', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io', { + reqheaders: { + 'x-metamask-clientproduct': 'metamask-mobile', + 'x-metamask-clientversion': '8.9.0', + 'x-metamask-clientenvironment': 'rc', + }, + }) + .get('/geolocation') + .query({ + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, 'us-tx'); + const { rootMessenger } = getService({ + options: { + clientProduct: 'metamask-mobile', + clientVersion: '8.9.0', + clientEnvironment: 'rc', + }, + }); + + const geolocationPromise = rootMessenger.call( + 'RampsService:getGeolocation', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + const geolocationResponse = await geolocationPromise; + + expect(geolocationResponse).toBe('us-tx'); + }); + it('uses the production URL when environment is Production', async () => { nock('https://on-ramp.api.cx.metamask.io') .get('/geolocation') diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 14c6c8b45bd..d3ef8127d4d 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -7,6 +7,8 @@ import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import packageJson from '../package.json'; +import type { RampsClientIdentity } from './client-identity.js'; +import { getRampsClientIdentityHeaders } from './client-identity.js'; import type { RampsServiceMethodActions } from './RampsService-method-action-types.js'; /** @@ -890,6 +892,8 @@ export class RampsService { */ readonly #baseUrlOverride?: string; + readonly #clientIdentity: RampsClientIdentity; + /** * Constructs a new RampsService object. * @@ -904,6 +908,9 @@ export class RampsService { * @param args.policyOptions - Options to pass to `createServicePolicy`, which * is used to wrap each request. See {@link CreateServicePolicyOptions}. * @param args.baseUrlOverride - Optional base URL override for local development. + * @param args.clientProduct - Optional MetaMask product id (`metamask-mobile`). + * @param args.clientVersion - Optional app SemVer (not the ramps-controller package version). + * @param args.clientEnvironment - Optional RFFC-aligned build flavor (`prod`/`rc`/`exp`/`dev`). */ constructor({ messenger, @@ -912,6 +919,9 @@ export class RampsService { fetch: fetchFunction, policyOptions = {}, baseUrlOverride, + clientProduct, + clientVersion, + clientEnvironment, }: { messenger: RampsServiceMessenger; environment?: RampsEnvironment; @@ -919,6 +929,9 @@ export class RampsService { fetch: typeof fetch; policyOptions?: CreateServicePolicyOptions; baseUrlOverride?: string; + clientProduct?: string; + clientVersion?: string; + clientEnvironment?: string; }) { this.name = serviceName; this.#messenger = messenger; @@ -927,6 +940,11 @@ export class RampsService { this.#environment = environment; this.#context = context; this.#baseUrlOverride = baseUrlOverride; + this.#clientIdentity = { + clientProduct, + clientVersion, + clientEnvironment, + }; this.#messenger.registerMethodActionHandlers( this, @@ -986,11 +1004,16 @@ export class RampsService { * * @returns Headers containing the `Authorization: Bearer ` entry. */ + #getClientIdentityHeaders(): Record { + return getRampsClientIdentityHeaders(this.#clientIdentity); + } + async #getRequestHeaders(): Promise> { const bearerToken = await this.#messenger.call( 'AuthenticationController:getBearerToken', ); return { + ...this.#getClientIdentityHeaders(), Authorization: `Bearer ${bearerToken}`, }; } @@ -1087,7 +1110,9 @@ export class RampsService { const url = new URL(path, baseUrl); this.#addCommonParams(url, options.action); - const response = await this.#fetch(url); + const response = await this.#fetch(url, { + headers: this.#getClientIdentityHeaders(), + }); if (!response.ok) { throw new HttpError( response.status, @@ -1188,7 +1213,9 @@ export class RampsService { } const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); + const fetchResponse = await this.#fetch(url, { + headers: this.#getClientIdentityHeaders(), + }); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, @@ -1261,7 +1288,9 @@ export class RampsService { } const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); + const fetchResponse = await this.#fetch(url, { + headers: this.#getClientIdentityHeaders(), + }); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, @@ -1310,7 +1339,9 @@ export class RampsService { url.searchParams.set('provider', options.provider); const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); + const fetchResponse = await this.#fetch(url, { + headers: this.#getClientIdentityHeaders(), + }); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, @@ -1479,7 +1510,9 @@ export class RampsService { } const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); + const fetchResponse = await this.#fetch(url, { + headers: this.#getClientIdentityHeaders(), + }); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, @@ -1526,7 +1559,9 @@ export class RampsService { callbackApiUrl.searchParams.set('url', callbackUrl); const callbackResponse = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(callbackApiUrl); + const fetchResponse = await this.#fetch(callbackApiUrl, { + headers: this.#getClientIdentityHeaders(), + }); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, diff --git a/packages/ramps-controller/src/TransakService.test.ts b/packages/ramps-controller/src/TransakService.test.ts index 3c55e2d20db..7a8bdf406ef 100644 --- a/packages/ramps-controller/src/TransakService.test.ts +++ b/packages/ramps-controller/src/TransakService.test.ts @@ -1666,6 +1666,36 @@ describe('TransakService', () => { expect(result.orderType).toBe('DEPOSIT'); }); + it('sends client identity headers on ramps API order fetches', async () => { + const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; + + nock(STAGING_ORDERS_BASE, { + reqheaders: { + 'x-metamask-clientproduct': 'metamask-mobile', + 'x-metamask-clientversion': '8.9.0', + 'x-metamask-clientenvironment': 'rc', + }, + }) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(200, MOCK_DEPOSIT_ORDER); + + const { service } = getService({ + options: { + clientProduct: 'metamask-mobile', + clientVersion: '8.9.0', + clientEnvironment: 'rc', + }, + }); + + const promise = service.getOrder(depositOrderId, '0x1234'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result.id).toBe(depositOrderId); + }); + it('omits the wallet query parameter when it is undefined', async () => { const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; diff --git a/packages/ramps-controller/src/TransakService.ts b/packages/ramps-controller/src/TransakService.ts index 8223ec0f291..8d568ce848a 100644 --- a/packages/ramps-controller/src/TransakService.ts +++ b/packages/ramps-controller/src/TransakService.ts @@ -7,6 +7,8 @@ import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import packageJson from '../package.json'; +import type { RampsClientIdentity } from './client-identity.js'; +import { getRampsClientIdentityHeaders } from './client-identity.js'; import { RAMPS_SDK_VERSION } from './RampsService.js'; import { TRANSAK_ERROR_CODES } from './transakErrorCodes.js'; import type { TransakServiceMethodActions } from './TransakService-method-action-types.js'; @@ -510,6 +512,8 @@ export class TransakService { */ readonly #referrerDomain: string; + readonly #clientIdentity: RampsClientIdentity; + constructor({ messenger, environment = TransakEnvironment.Staging, @@ -520,6 +524,9 @@ export class TransakService { orderRetryDelayMs = 2000, rampsApiBaseUrlOverride, referrerDomain = TRANSAK_REFERRER_DOMAIN, + clientProduct, + clientVersion, + clientEnvironment, }: { messenger: TransakServiceMessenger; environment?: TransakEnvironment; @@ -530,6 +537,9 @@ export class TransakService { orderRetryDelayMs?: number; rampsApiBaseUrlOverride?: string; referrerDomain?: string; + clientProduct?: string; + clientVersion?: string; + clientEnvironment?: string; }) { this.name = serviceName; this.#messenger = messenger; @@ -545,6 +555,11 @@ export class TransakService { this.#orderRetryDelayMs = orderRetryDelayMs; this.#rampsApiBaseUrlOverride = rampsApiBaseUrlOverride; this.#referrerDomain = referrerDomain; + this.#clientIdentity = { + clientProduct, + clientVersion, + clientEnvironment, + }; this.#messenger.registerMethodActionHandlers( this, @@ -806,7 +821,10 @@ export class TransakService { const response = await this.#policy.execute(async () => { const fetchResponse = await this.#fetch(url.toString(), { method: 'GET', - headers: { Accept: 'application/json' }, + headers: { + Accept: 'application/json', + ...getRampsClientIdentityHeaders(this.#clientIdentity), + }, }); if (!fetchResponse.ok) { throw new HttpError( @@ -1199,6 +1217,7 @@ export class TransakService { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${bearerToken}`, + ...getRampsClientIdentityHeaders(this.#clientIdentity), }; if (this.#accessToken?.accessToken) { headers['x-transak-access-token'] = this.#accessToken.accessToken; @@ -1289,7 +1308,10 @@ export class TransakService { const response = await this.#policy.execute(async () => { const fetchResponse = await this.#fetch(url.toString(), { method: 'GET', - headers: { Accept: 'application/json' }, + headers: { + Accept: 'application/json', + ...getRampsClientIdentityHeaders(this.#clientIdentity), + }, }); if (!fetchResponse.ok) { throw new HttpError( diff --git a/packages/ramps-controller/src/client-identity.test.ts b/packages/ramps-controller/src/client-identity.test.ts new file mode 100644 index 00000000000..794860fa828 --- /dev/null +++ b/packages/ramps-controller/src/client-identity.test.ts @@ -0,0 +1,36 @@ +import { + getRampsClientIdentityHeaders, + RAMPS_CLIENT_ENVIRONMENT_HEADER, + RAMPS_CLIENT_PRODUCT_HEADER, + RAMPS_CLIENT_VERSION_HEADER, +} from './client-identity.js'; + +describe('getRampsClientIdentityHeaders', () => { + it('returns an empty object when no identity is provided', () => { + expect(getRampsClientIdentityHeaders({})).toStrictEqual({}); + }); + + it('omits empty string values', () => { + expect( + getRampsClientIdentityHeaders({ + clientProduct: '', + clientVersion: '', + clientEnvironment: '', + }), + ).toStrictEqual({}); + }); + + it('includes only the fields that are set', () => { + expect( + getRampsClientIdentityHeaders({ + clientProduct: 'metamask-mobile', + clientVersion: '8.9.0', + clientEnvironment: 'rc', + }), + ).toStrictEqual({ + [RAMPS_CLIENT_PRODUCT_HEADER]: 'metamask-mobile', + [RAMPS_CLIENT_VERSION_HEADER]: '8.9.0', + [RAMPS_CLIENT_ENVIRONMENT_HEADER]: 'rc', + }); + }); +}); diff --git a/packages/ramps-controller/src/client-identity.ts b/packages/ramps-controller/src/client-identity.ts new file mode 100644 index 00000000000..cc2dd1e7c19 --- /dev/null +++ b/packages/ramps-controller/src/client-identity.ts @@ -0,0 +1,50 @@ +/** + * Platform-style identity headers sent on every on-ramp API request. + * Do not overload the `controller` query param — that is the ramps-controller + * package version used for JWT/auth minimums, not the MetaMask app version. + */ +export const RAMPS_CLIENT_PRODUCT_HEADER = 'x-metamask-clientproduct'; +export const RAMPS_CLIENT_VERSION_HEADER = 'x-metamask-clientversion'; +export const RAMPS_CLIENT_ENVIRONMENT_HEADER = 'x-metamask-clientenvironment'; + +/** + * Host-supplied MetaMask client identity. All fields are optional so older + * hosts keep compiling; omit them to send no identity headers. + */ +export type RampsClientIdentity = { + /** + * Product id, e.g. `metamask-mobile` or `metamask-extension`. + */ + clientProduct?: string; + /** + * App SemVer, e.g. `8.9.0` from `getBaseSemVerVersion()`. + */ + clientVersion?: string; + /** + * Build flavor aligned with Remote Feature Flag Client Config + * (`prod` / `rc` / `exp` / `dev` / `beta` / `test`). Not an API-host switch. + */ + clientEnvironment?: string; +}; + +/** + * Builds identity headers, omitting empty values. + * + * @param identity - Optional product, version, and environment. + * @returns Headers to merge onto on-ramp fetches. + */ +export function getRampsClientIdentityHeaders( + identity: RampsClientIdentity, +): Record { + const headers: Record = {}; + if (identity.clientProduct) { + headers[RAMPS_CLIENT_PRODUCT_HEADER] = identity.clientProduct; + } + if (identity.clientVersion) { + headers[RAMPS_CLIENT_VERSION_HEADER] = identity.clientVersion; + } + if (identity.clientEnvironment) { + headers[RAMPS_CLIENT_ENVIRONMENT_HEADER] = identity.clientEnvironment; + } + return headers; +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index f1d1dcdbe6d..dcd37ded61f 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -112,6 +112,13 @@ export { RAMPS_SDK_VERSION, getDefaultRedirectCallbackUrl, } from './RampsService.js'; +export type { RampsClientIdentity } from './client-identity.js'; +export { + getRampsClientIdentityHeaders, + RAMPS_CLIENT_PRODUCT_HEADER, + RAMPS_CLIENT_VERSION_HEADER, + RAMPS_CLIENT_ENVIRONMENT_HEADER, +} from './client-identity.js'; export type { RampsServiceGetDefaultRedirectCallbackUrlAction, RampsServiceGetGeolocationAction,