From 4b375253bef7567c820df27cd479a9d7bae7f961 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Wed, 26 Aug 2026 23:46:07 -0500 Subject: [PATCH 1/7] feat(ramps-controller): send client identity metadata headers on on-ramp API requests Adds optional clientProduct / clientVersion / clientEnvironment constructor options to RampsService and TransakService. When provided by the host, every on-ramp API fetch carries x-metamask-clientproduct, x-metamask-clientversion, and x-metamask-clientenvironment so the API can evaluate version- and environment-gated feature flags per client. All fields are optional: older hosts send no headers and the API fails closed. 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 5a93f2351cd..2183994eebf 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) so the API can evaluate version- and environment-gated feature flags per client. Also exports the `RampsClientIdentity` type, the `getRampsClientIdentityHeaders` helper, and the header-name constants. - 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 c6aa14df495..79013810478 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 748ce8a0032..8b469b73166 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'; /** @@ -906,6 +908,8 @@ export class RampsService { */ readonly #baseUrlOverride?: string; + readonly #clientIdentity: RampsClientIdentity; + /** * Constructs a new RampsService object. * @@ -920,6 +924,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, @@ -928,6 +935,9 @@ export class RampsService { fetch: fetchFunction, policyOptions = {}, baseUrlOverride, + clientProduct, + clientVersion, + clientEnvironment, }: { messenger: RampsServiceMessenger; environment?: RampsEnvironment; @@ -935,6 +945,9 @@ export class RampsService { fetch: typeof fetch; policyOptions?: CreateServicePolicyOptions; baseUrlOverride?: string; + clientProduct?: string; + clientVersion?: string; + clientEnvironment?: string; }) { this.name = serviceName; this.#messenger = messenger; @@ -943,6 +956,11 @@ export class RampsService { this.#environment = environment; this.#context = context; this.#baseUrlOverride = baseUrlOverride; + this.#clientIdentity = { + clientProduct, + clientVersion, + clientEnvironment, + }; this.#messenger.registerMethodActionHandlers( this, @@ -1002,11 +1020,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}`, }; } @@ -1103,7 +1126,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, @@ -1204,7 +1229,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, @@ -1277,7 +1304,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, @@ -1329,7 +1358,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, @@ -1498,7 +1529,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, @@ -1545,7 +1578,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 739204a1487..5f84bf9c88b 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -114,6 +114,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, From f42a46164ea6266606d38916c13049691a3f7030 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 27 Aug 2026 00:13:10 -0500 Subject: [PATCH 2/7] feat(ramps-controller): send client identity as query params for CDN cache correctness The on-ramp API sits behind a CDN whose cache key is the URL; headers alone would serve one client's cached response to another. Cacheable GETs now carry clientProduct / clientVersion / clientEnvironment in the query string as well. The API reads params first and falls back to the headers. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 2 +- .../ramps-controller/src/RampsService.test.ts | 5 ++- packages/ramps-controller/src/RampsService.ts | 8 +++- .../src/TransakService.test.ts | 9 ++++- .../ramps-controller/src/TransakService.ts | 11 +++++- .../src/client-identity.test.ts | 39 +++++++++++++++++++ .../ramps-controller/src/client-identity.ts | 35 +++++++++++++++++ packages/ramps-controller/src/index.ts | 4 ++ 8 files changed, 107 insertions(+), 6 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 2183994eebf..007dca5c450 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,7 +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) so the API can evaluate version- and environment-gated feature flags per client. Also exports the `RampsClientIdentity` type, the `getRampsClientIdentityHeaders` helper, and the header-name constants. +- Add optional `clientProduct`, `clientVersion`, and `clientEnvironment` constructor options on `RampsService` and `TransakService`. When set, they are sent on every on-ramp API fetch as the `x-metamask-clientproduct`, `x-metamask-clientversion`, and `x-metamask-clientenvironment` headers **and** as `clientProduct` / `clientVersion` / `clientEnvironment` query params (the on-ramp API sits behind a CDN whose cache key is the URL, so cacheable GETs must vary by client in the query string). This lets the API evaluate version- and environment-gated feature flags per client. Also exports the `RampsClientIdentity` type, the `getRampsClientIdentityHeaders` / `addRampsClientIdentityParams` helpers, and the header/param name constants. - 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 79013810478..88182e51e87 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -48,7 +48,7 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('us-tx'); }); - it('sends client identity headers when constructor options are set', async () => { + it('sends client identity headers and query params when constructor options are set', async () => { nock('https://on-ramp.uat-api.cx.metamask.io', { reqheaders: { 'x-metamask-clientproduct': 'metamask-mobile', @@ -61,6 +61,9 @@ describe('RampsService', () => { sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', + clientProduct: 'metamask-mobile', + clientVersion: '8.9.0', + clientEnvironment: 'rc', }) .reply(200, 'us-tx'); const { rootMessenger } = getService({ diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 8b469b73166..cd2b8a188c0 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -8,7 +8,10 @@ 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 { + addRampsClientIdentityParams, + getRampsClientIdentityHeaders, +} from './client-identity.js'; import type { RampsServiceMethodActions } from './RampsService-method-action-types.js'; /** @@ -1101,6 +1104,9 @@ export class RampsService { url.searchParams.set('sdk', RAMPS_SDK_VERSION); url.searchParams.set('controller', packageJson.version); url.searchParams.set('context', this.#context); + // Also in the query string (not only headers) so CDN-cached responses + // vary per client product / version / environment. + addRampsClientIdentityParams(url, this.#clientIdentity); } /** diff --git a/packages/ramps-controller/src/TransakService.test.ts b/packages/ramps-controller/src/TransakService.test.ts index 7a8bdf406ef..27b1f4b6a9e 100644 --- a/packages/ramps-controller/src/TransakService.test.ts +++ b/packages/ramps-controller/src/TransakService.test.ts @@ -1666,7 +1666,7 @@ describe('TransakService', () => { expect(result.orderType).toBe('DEPOSIT'); }); - it('sends client identity headers on ramps API order fetches', async () => { + it('sends client identity headers and query params on ramps API order fetches', async () => { const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; nock(STAGING_ORDERS_BASE, { @@ -1677,7 +1677,12 @@ describe('TransakService', () => { }, }) .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) - .query(true) + .query( + (query) => + query.clientProduct === 'metamask-mobile' && + query.clientVersion === '8.9.0' && + query.clientEnvironment === 'rc', + ) .reply(200, MOCK_DEPOSIT_ORDER); const { service } = getService({ diff --git a/packages/ramps-controller/src/TransakService.ts b/packages/ramps-controller/src/TransakService.ts index 8d568ce848a..84c0c32a3b9 100644 --- a/packages/ramps-controller/src/TransakService.ts +++ b/packages/ramps-controller/src/TransakService.ts @@ -8,7 +8,10 @@ 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 { + addRampsClientIdentityParams, + 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'; @@ -797,6 +800,9 @@ export class TransakService { url.searchParams.set('sdk', RAMPS_SDK_VERSION); url.searchParams.set('controller', packageJson.version); url.searchParams.set('context', this.#context); + // Also in the query string (not only headers) so CDN-cached responses + // vary per client product / version / environment. + addRampsClientIdentityParams(url, this.#clientIdentity); } async #ordersApiGet( @@ -809,6 +815,9 @@ export class TransakService { url.searchParams.set('action', 'deposit'); url.searchParams.set('context', this.#context); + // Also in the query string (not only headers) so CDN-cached responses + // vary per client product / version / environment. + addRampsClientIdentityParams(url, this.#clientIdentity); if (params) { for (const [key, value] of Object.entries(params)) { diff --git a/packages/ramps-controller/src/client-identity.test.ts b/packages/ramps-controller/src/client-identity.test.ts index 794860fa828..573232d586a 100644 --- a/packages/ramps-controller/src/client-identity.test.ts +++ b/packages/ramps-controller/src/client-identity.test.ts @@ -1,4 +1,5 @@ import { + addRampsClientIdentityParams, getRampsClientIdentityHeaders, RAMPS_CLIENT_ENVIRONMENT_HEADER, RAMPS_CLIENT_PRODUCT_HEADER, @@ -34,3 +35,41 @@ describe('getRampsClientIdentityHeaders', () => { }); }); }); + +describe('addRampsClientIdentityParams', () => { + it('appends all identity fields as query params', () => { + const url = new URL('https://on-ramp.api.cx.metamask.io/regions/countries'); + + addRampsClientIdentityParams(url, { + clientProduct: 'metamask-mobile', + clientVersion: '8.9.0', + clientEnvironment: 'rc', + }); + + expect(url.searchParams.get('clientProduct')).toBe('metamask-mobile'); + expect(url.searchParams.get('clientVersion')).toBe('8.9.0'); + expect(url.searchParams.get('clientEnvironment')).toBe('rc'); + }); + + it('leaves the URL untouched when no identity is provided', () => { + const url = new URL('https://on-ramp.api.cx.metamask.io/regions/countries'); + + addRampsClientIdentityParams(url, {}); + + expect(url.search).toBe(''); + }); + + it('omits empty string values', () => { + const url = new URL('https://on-ramp.api.cx.metamask.io/regions/countries'); + + addRampsClientIdentityParams(url, { + clientProduct: '', + clientVersion: '8.9.0', + clientEnvironment: '', + }); + + expect(url.searchParams.has('clientProduct')).toBe(false); + expect(url.searchParams.get('clientVersion')).toBe('8.9.0'); + expect(url.searchParams.has('clientEnvironment')).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/client-identity.ts b/packages/ramps-controller/src/client-identity.ts index cc2dd1e7c19..6fdd42b2b3c 100644 --- a/packages/ramps-controller/src/client-identity.ts +++ b/packages/ramps-controller/src/client-identity.ts @@ -27,6 +27,16 @@ export type RampsClientIdentity = { clientEnvironment?: string; }; +/** + * Query-param names mirroring the identity headers. The on-ramp API sits + * behind a CDN whose cache key is the URL, so cacheable GETs must carry the + * identity in the query string; headers alone would poison the cache across + * clients. The API reads params first and falls back to headers. + */ +export const RAMPS_CLIENT_PRODUCT_PARAM = 'clientProduct'; +export const RAMPS_CLIENT_VERSION_PARAM = 'clientVersion'; +export const RAMPS_CLIENT_ENVIRONMENT_PARAM = 'clientEnvironment'; + /** * Builds identity headers, omitting empty values. * @@ -48,3 +58,28 @@ export function getRampsClientIdentityHeaders( } return headers; } + +/** + * Appends the identity as query params (CDN cache-key friendly), omitting + * empty values. See {@link RAMPS_CLIENT_PRODUCT_PARAM}. + * + * @param url - URL to mutate. + * @param identity - Optional product, version, and environment. + */ +export function addRampsClientIdentityParams( + url: URL, + identity: RampsClientIdentity, +): void { + if (identity.clientProduct) { + url.searchParams.set(RAMPS_CLIENT_PRODUCT_PARAM, identity.clientProduct); + } + if (identity.clientVersion) { + url.searchParams.set(RAMPS_CLIENT_VERSION_PARAM, identity.clientVersion); + } + if (identity.clientEnvironment) { + url.searchParams.set( + RAMPS_CLIENT_ENVIRONMENT_PARAM, + identity.clientEnvironment, + ); + } +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 5f84bf9c88b..8595fa5dd94 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -117,9 +117,13 @@ export { export type { RampsClientIdentity } from './client-identity.js'; export { getRampsClientIdentityHeaders, + addRampsClientIdentityParams, RAMPS_CLIENT_PRODUCT_HEADER, RAMPS_CLIENT_VERSION_HEADER, RAMPS_CLIENT_ENVIRONMENT_HEADER, + RAMPS_CLIENT_PRODUCT_PARAM, + RAMPS_CLIENT_VERSION_PARAM, + RAMPS_CLIENT_ENVIRONMENT_PARAM, } from './client-identity.js'; export type { RampsServiceGetDefaultRedirectCallbackUrlAction, From ad42c530fe11ed3cbca0339f8c10eb82aeebd815 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 27 Aug 2026 00:31:52 -0500 Subject: [PATCH 3/7] refactor(ramps-controller): drop clientEnvironment from client identity Version-only gating: RC-first rollout is expressed via minimumVersion (RC builds carry the next release version before the store rollout), so the environment dimension is unnecessary. Matches the product+version convention used by core-backend and the bridge status API. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 2 +- .../ramps-controller/src/RampsService.test.ts | 3 -- packages/ramps-controller/src/RampsService.ts | 4 -- .../src/TransakService.test.ts | 5 +-- .../ramps-controller/src/TransakService.ts | 3 -- .../src/client-identity.test.ts | 8 ---- .../ramps-controller/src/client-identity.ts | 45 +++++-------------- packages/ramps-controller/src/index.ts | 2 - 8 files changed, 12 insertions(+), 60 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 007dca5c450..bc2ea5955f4 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,7 +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 the `x-metamask-clientproduct`, `x-metamask-clientversion`, and `x-metamask-clientenvironment` headers **and** as `clientProduct` / `clientVersion` / `clientEnvironment` query params (the on-ramp API sits behind a CDN whose cache key is the URL, so cacheable GETs must vary by client in the query string). This lets the API evaluate version- and environment-gated feature flags per client. Also exports the `RampsClientIdentity` type, the `getRampsClientIdentityHeaders` / `addRampsClientIdentityParams` helpers, and the header/param name constants. +- Add optional `clientProduct` and `clientVersion` constructor options on `RampsService` and `TransakService`, sent on every on-ramp API fetch as `x-metamask-clientproduct` / `x-metamask-clientversion` headers and as `clientProduct` / `clientVersion` query params, so the API can evaluate version-gated feature flags per client. Query params are required because the on-ramp CDN cache key is the URL. Also exports the `RampsClientIdentity` type, the `getRampsClientIdentityHeaders` / `addRampsClientIdentityParams` helpers, and the header/param name constants. - 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 88182e51e87..978c3553101 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -53,7 +53,6 @@ describe('RampsService', () => { reqheaders: { 'x-metamask-clientproduct': 'metamask-mobile', 'x-metamask-clientversion': '8.9.0', - 'x-metamask-clientenvironment': 'rc', }, }) .get('/geolocation') @@ -63,14 +62,12 @@ describe('RampsService', () => { context: 'mobile-ios', clientProduct: 'metamask-mobile', clientVersion: '8.9.0', - clientEnvironment: 'rc', }) .reply(200, 'us-tx'); const { rootMessenger } = getService({ options: { clientProduct: 'metamask-mobile', clientVersion: '8.9.0', - clientEnvironment: 'rc', }, }); diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index cd2b8a188c0..a7cdd406226 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -929,7 +929,6 @@ export class RampsService { * @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, @@ -940,7 +939,6 @@ export class RampsService { baseUrlOverride, clientProduct, clientVersion, - clientEnvironment, }: { messenger: RampsServiceMessenger; environment?: RampsEnvironment; @@ -950,7 +948,6 @@ export class RampsService { baseUrlOverride?: string; clientProduct?: string; clientVersion?: string; - clientEnvironment?: string; }) { this.name = serviceName; this.#messenger = messenger; @@ -962,7 +959,6 @@ export class RampsService { this.#clientIdentity = { clientProduct, clientVersion, - clientEnvironment, }; this.#messenger.registerMethodActionHandlers( diff --git a/packages/ramps-controller/src/TransakService.test.ts b/packages/ramps-controller/src/TransakService.test.ts index 27b1f4b6a9e..232572797d7 100644 --- a/packages/ramps-controller/src/TransakService.test.ts +++ b/packages/ramps-controller/src/TransakService.test.ts @@ -1673,15 +1673,13 @@ describe('TransakService', () => { 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( (query) => query.clientProduct === 'metamask-mobile' && - query.clientVersion === '8.9.0' && - query.clientEnvironment === 'rc', + query.clientVersion === '8.9.0', ) .reply(200, MOCK_DEPOSIT_ORDER); @@ -1689,7 +1687,6 @@ describe('TransakService', () => { options: { clientProduct: 'metamask-mobile', clientVersion: '8.9.0', - clientEnvironment: 'rc', }, }); diff --git a/packages/ramps-controller/src/TransakService.ts b/packages/ramps-controller/src/TransakService.ts index 84c0c32a3b9..fd2450a6f12 100644 --- a/packages/ramps-controller/src/TransakService.ts +++ b/packages/ramps-controller/src/TransakService.ts @@ -529,7 +529,6 @@ export class TransakService { referrerDomain = TRANSAK_REFERRER_DOMAIN, clientProduct, clientVersion, - clientEnvironment, }: { messenger: TransakServiceMessenger; environment?: TransakEnvironment; @@ -542,7 +541,6 @@ export class TransakService { referrerDomain?: string; clientProduct?: string; clientVersion?: string; - clientEnvironment?: string; }) { this.name = serviceName; this.#messenger = messenger; @@ -561,7 +559,6 @@ export class TransakService { this.#clientIdentity = { clientProduct, clientVersion, - clientEnvironment, }; this.#messenger.registerMethodActionHandlers( diff --git a/packages/ramps-controller/src/client-identity.test.ts b/packages/ramps-controller/src/client-identity.test.ts index 573232d586a..a0ad0dd28be 100644 --- a/packages/ramps-controller/src/client-identity.test.ts +++ b/packages/ramps-controller/src/client-identity.test.ts @@ -1,7 +1,6 @@ import { addRampsClientIdentityParams, getRampsClientIdentityHeaders, - RAMPS_CLIENT_ENVIRONMENT_HEADER, RAMPS_CLIENT_PRODUCT_HEADER, RAMPS_CLIENT_VERSION_HEADER, } from './client-identity.js'; @@ -16,7 +15,6 @@ describe('getRampsClientIdentityHeaders', () => { getRampsClientIdentityHeaders({ clientProduct: '', clientVersion: '', - clientEnvironment: '', }), ).toStrictEqual({}); }); @@ -26,12 +24,10 @@ describe('getRampsClientIdentityHeaders', () => { 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', }); }); }); @@ -43,12 +39,10 @@ describe('addRampsClientIdentityParams', () => { addRampsClientIdentityParams(url, { clientProduct: 'metamask-mobile', clientVersion: '8.9.0', - clientEnvironment: 'rc', }); expect(url.searchParams.get('clientProduct')).toBe('metamask-mobile'); expect(url.searchParams.get('clientVersion')).toBe('8.9.0'); - expect(url.searchParams.get('clientEnvironment')).toBe('rc'); }); it('leaves the URL untouched when no identity is provided', () => { @@ -65,11 +59,9 @@ describe('addRampsClientIdentityParams', () => { addRampsClientIdentityParams(url, { clientProduct: '', clientVersion: '8.9.0', - clientEnvironment: '', }); expect(url.searchParams.has('clientProduct')).toBe(false); expect(url.searchParams.get('clientVersion')).toBe('8.9.0'); - expect(url.searchParams.has('clientEnvironment')).toBe(false); }); }); diff --git a/packages/ramps-controller/src/client-identity.ts b/packages/ramps-controller/src/client-identity.ts index 6fdd42b2b3c..b0842dbae2c 100644 --- a/packages/ramps-controller/src/client-identity.ts +++ b/packages/ramps-controller/src/client-identity.ts @@ -1,46 +1,30 @@ /** - * 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. + * Client identity headers sent on every on-ramp API request, matching the + * convention used by `@metamask/core-backend` and the bridge status API. */ 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. - */ +/** Host-supplied MetaMask client identity. All fields optional. */ export type RampsClientIdentity = { - /** - * Product id, e.g. `metamask-mobile` or `metamask-extension`. - */ + /** Product id, e.g. `metamask-mobile` or `metamask-extension`. */ clientProduct?: string; - /** - * App SemVer, e.g. `8.9.0` from `getBaseSemVerVersion()`. - */ + /** App SemVer, e.g. `8.9.0` (not the ramps-controller package version). */ clientVersion?: string; - /** - * Build flavor aligned with Remote Feature Flag Client Config - * (`prod` / `rc` / `exp` / `dev` / `beta` / `test`). Not an API-host switch. - */ - clientEnvironment?: string; }; /** - * Query-param names mirroring the identity headers. The on-ramp API sits - * behind a CDN whose cache key is the URL, so cacheable GETs must carry the - * identity in the query string; headers alone would poison the cache across - * clients. The API reads params first and falls back to headers. + * Query-param names mirroring the identity headers. The on-ramp CDN cache + * key is the URL, so cacheable GETs must carry the identity in the query + * string. The API reads params first and falls back to headers. */ export const RAMPS_CLIENT_PRODUCT_PARAM = 'clientProduct'; export const RAMPS_CLIENT_VERSION_PARAM = 'clientVersion'; -export const RAMPS_CLIENT_ENVIRONMENT_PARAM = 'clientEnvironment'; /** * Builds identity headers, omitting empty values. * - * @param identity - Optional product, version, and environment. + * @param identity - Optional product and version. * @returns Headers to merge onto on-ramp fetches. */ export function getRampsClientIdentityHeaders( @@ -53,9 +37,6 @@ export function getRampsClientIdentityHeaders( if (identity.clientVersion) { headers[RAMPS_CLIENT_VERSION_HEADER] = identity.clientVersion; } - if (identity.clientEnvironment) { - headers[RAMPS_CLIENT_ENVIRONMENT_HEADER] = identity.clientEnvironment; - } return headers; } @@ -64,7 +45,7 @@ export function getRampsClientIdentityHeaders( * empty values. See {@link RAMPS_CLIENT_PRODUCT_PARAM}. * * @param url - URL to mutate. - * @param identity - Optional product, version, and environment. + * @param identity - Optional product and version. */ export function addRampsClientIdentityParams( url: URL, @@ -76,10 +57,4 @@ export function addRampsClientIdentityParams( if (identity.clientVersion) { url.searchParams.set(RAMPS_CLIENT_VERSION_PARAM, identity.clientVersion); } - if (identity.clientEnvironment) { - url.searchParams.set( - RAMPS_CLIENT_ENVIRONMENT_PARAM, - identity.clientEnvironment, - ); - } } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 8595fa5dd94..505fae67062 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -120,10 +120,8 @@ export { addRampsClientIdentityParams, RAMPS_CLIENT_PRODUCT_HEADER, RAMPS_CLIENT_VERSION_HEADER, - RAMPS_CLIENT_ENVIRONMENT_HEADER, RAMPS_CLIENT_PRODUCT_PARAM, RAMPS_CLIENT_VERSION_PARAM, - RAMPS_CLIENT_ENVIRONMENT_PARAM, } from './client-identity.js'; export type { RampsServiceGetDefaultRedirectCallbackUrlAction, From 619d07f559b43620c6ce285f5a0d6afc19079fef Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 27 Aug 2026 16:58:34 -0500 Subject: [PATCH 4/7] refactor(ramps-controller): send client identity as query params only Headers duplicated the query params and risked server-side decisions keyed off values the CDN cache key cannot see. Identity now travels solely in the URL. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 2 +- .../ramps-controller/src/RampsService.test.ts | 9 ++--- packages/ramps-controller/src/RampsService.ts | 34 ++++--------------- .../src/TransakService.test.ts | 9 ++--- .../ramps-controller/src/TransakService.ts | 12 ++----- .../src/client-identity.test.ts | 34 +------------------ .../ramps-controller/src/client-identity.ts | 33 +++--------------- packages/ramps-controller/src/index.ts | 3 -- 8 files changed, 20 insertions(+), 116 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index bc2ea5955f4..47725ad1886 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `clientProduct` and `clientVersion` constructor options on `RampsService` and `TransakService`, sent on every on-ramp API fetch as `x-metamask-clientproduct` / `x-metamask-clientversion` headers and as `clientProduct` / `clientVersion` query params, so the API can evaluate version-gated feature flags per client. Query params are required because the on-ramp CDN cache key is the URL. Also exports the `RampsClientIdentity` type, the `getRampsClientIdentityHeaders` / `addRampsClientIdentityParams` helpers, and the header/param name constants. +- Add optional `clientProduct` and `clientVersion` constructor options on `RampsService` and `TransakService`, sent on every on-ramp API fetch as `clientProduct` / `clientVersion` query params so the API can evaluate version-gated feature flags per client. Identity travels in the URL (not headers) because the on-ramp CDN cache key is the URL. Also exports the `RampsClientIdentity` type, the `addRampsClientIdentityParams` helper, and the param name constants. - 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 978c3553101..c1a72337806 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -48,13 +48,8 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('us-tx'); }); - it('sends client identity headers and query params 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', - }, - }) + it('sends client identity query params when constructor options are set', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') .query({ sdk: '2.1.6', diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index a7cdd406226..a41fd2b780a 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -8,10 +8,7 @@ import type { AuthenticationController } from '@metamask/profile-sync-controller import packageJson from '../package.json'; import type { RampsClientIdentity } from './client-identity.js'; -import { - addRampsClientIdentityParams, - getRampsClientIdentityHeaders, -} from './client-identity.js'; +import { addRampsClientIdentityParams } from './client-identity.js'; import type { RampsServiceMethodActions } from './RampsService-method-action-types.js'; /** @@ -1019,16 +1016,11 @@ 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}`, }; } @@ -1128,9 +1120,7 @@ export class RampsService { const url = new URL(path, baseUrl); this.#addCommonParams(url, options.action); - const response = await this.#fetch(url, { - headers: this.#getClientIdentityHeaders(), - }); + const response = await this.#fetch(url); if (!response.ok) { throw new HttpError( response.status, @@ -1231,9 +1221,7 @@ export class RampsService { } const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url, { - headers: this.#getClientIdentityHeaders(), - }); + const fetchResponse = await this.#fetch(url); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, @@ -1306,9 +1294,7 @@ export class RampsService { } const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url, { - headers: this.#getClientIdentityHeaders(), - }); + const fetchResponse = await this.#fetch(url); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, @@ -1360,9 +1346,7 @@ export class RampsService { url.searchParams.set('provider', options.provider); const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url, { - headers: this.#getClientIdentityHeaders(), - }); + const fetchResponse = await this.#fetch(url); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, @@ -1531,9 +1515,7 @@ export class RampsService { } const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url, { - headers: this.#getClientIdentityHeaders(), - }); + const fetchResponse = await this.#fetch(url); if (!fetchResponse.ok) { throw new HttpError( fetchResponse.status, @@ -1580,9 +1562,7 @@ export class RampsService { callbackApiUrl.searchParams.set('url', callbackUrl); const callbackResponse = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(callbackApiUrl, { - headers: this.#getClientIdentityHeaders(), - }); + const fetchResponse = await this.#fetch(callbackApiUrl); 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 232572797d7..6dbe76f62b6 100644 --- a/packages/ramps-controller/src/TransakService.test.ts +++ b/packages/ramps-controller/src/TransakService.test.ts @@ -1666,15 +1666,10 @@ describe('TransakService', () => { expect(result.orderType).toBe('DEPOSIT'); }); - it('sends client identity headers and query params on ramps API order fetches', async () => { + it('sends client identity query params 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', - }, - }) + nock(STAGING_ORDERS_BASE) .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) .query( (query) => diff --git a/packages/ramps-controller/src/TransakService.ts b/packages/ramps-controller/src/TransakService.ts index fd2450a6f12..870ee5c5816 100644 --- a/packages/ramps-controller/src/TransakService.ts +++ b/packages/ramps-controller/src/TransakService.ts @@ -8,10 +8,7 @@ import type { AuthenticationController } from '@metamask/profile-sync-controller import packageJson from '../package.json'; import type { RampsClientIdentity } from './client-identity.js'; -import { - addRampsClientIdentityParams, - getRampsClientIdentityHeaders, -} from './client-identity.js'; +import { addRampsClientIdentityParams } 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'; @@ -829,8 +826,7 @@ export class TransakService { method: 'GET', headers: { Accept: 'application/json', - ...getRampsClientIdentityHeaders(this.#clientIdentity), - }, + }, }); if (!fetchResponse.ok) { throw new HttpError( @@ -1223,7 +1219,6 @@ 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; @@ -1316,8 +1311,7 @@ export class TransakService { method: 'GET', 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 index a0ad0dd28be..e2a1e95a268 100644 --- a/packages/ramps-controller/src/client-identity.test.ts +++ b/packages/ramps-controller/src/client-identity.test.ts @@ -1,36 +1,4 @@ -import { - addRampsClientIdentityParams, - getRampsClientIdentityHeaders, - 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: '', - }), - ).toStrictEqual({}); - }); - - it('includes only the fields that are set', () => { - expect( - getRampsClientIdentityHeaders({ - clientProduct: 'metamask-mobile', - clientVersion: '8.9.0', - }), - ).toStrictEqual({ - [RAMPS_CLIENT_PRODUCT_HEADER]: 'metamask-mobile', - [RAMPS_CLIENT_VERSION_HEADER]: '8.9.0', - }); - }); -}); +import { addRampsClientIdentityParams } from './client-identity.js'; describe('addRampsClientIdentityParams', () => { it('appends all identity fields as query params', () => { diff --git a/packages/ramps-controller/src/client-identity.ts b/packages/ramps-controller/src/client-identity.ts index b0842dbae2c..a4df2a553de 100644 --- a/packages/ramps-controller/src/client-identity.ts +++ b/packages/ramps-controller/src/client-identity.ts @@ -1,10 +1,3 @@ -/** - * Client identity headers sent on every on-ramp API request, matching the - * convention used by `@metamask/core-backend` and the bridge status API. - */ -export const RAMPS_CLIENT_PRODUCT_HEADER = 'x-metamask-clientproduct'; -export const RAMPS_CLIENT_VERSION_HEADER = 'x-metamask-clientversion'; - /** Host-supplied MetaMask client identity. All fields optional. */ export type RampsClientIdentity = { /** Product id, e.g. `metamask-mobile` or `metamask-extension`. */ @@ -14,32 +7,14 @@ export type RampsClientIdentity = { }; /** - * Query-param names mirroring the identity headers. The on-ramp CDN cache - * key is the URL, so cacheable GETs must carry the identity in the query - * string. The API reads params first and falls back to headers. + * Query-param names for the client identity, sent on every on-ramp API + * request. Identity travels in the URL (not headers) because the on-ramp CDN + * cache key is the URL — the API's version-gated feature flags evaluate these + * params so cached responses always match the requesting cohort. */ export const RAMPS_CLIENT_PRODUCT_PARAM = 'clientProduct'; export const RAMPS_CLIENT_VERSION_PARAM = 'clientVersion'; -/** - * Builds identity headers, omitting empty values. - * - * @param identity - Optional product and version. - * @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; - } - return headers; -} - /** * Appends the identity as query params (CDN cache-key friendly), omitting * empty values. See {@link RAMPS_CLIENT_PRODUCT_PARAM}. diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 505fae67062..aafe369b3c4 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -116,10 +116,7 @@ export { } from './RampsService.js'; export type { RampsClientIdentity } from './client-identity.js'; export { - getRampsClientIdentityHeaders, addRampsClientIdentityParams, - RAMPS_CLIENT_PRODUCT_HEADER, - RAMPS_CLIENT_VERSION_HEADER, RAMPS_CLIENT_PRODUCT_PARAM, RAMPS_CLIENT_VERSION_PARAM, } from './client-identity.js'; From 37dad9c58d5e4943b5af0ad7524919459bfdd83f Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Fri, 28 Aug 2026 01:10:52 -0500 Subject: [PATCH 5/7] style: fix prettier indentation in TransakService Co-authored-by: Cursor --- packages/ramps-controller/src/TransakService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/src/TransakService.ts b/packages/ramps-controller/src/TransakService.ts index 870ee5c5816..6828a96688a 100644 --- a/packages/ramps-controller/src/TransakService.ts +++ b/packages/ramps-controller/src/TransakService.ts @@ -826,7 +826,7 @@ export class TransakService { method: 'GET', headers: { Accept: 'application/json', - }, + }, }); if (!fetchResponse.ok) { throw new HttpError( @@ -1311,7 +1311,7 @@ export class TransakService { method: 'GET', headers: { Accept: 'application/json', - }, + }, }); if (!fetchResponse.ok) { throw new HttpError( From f83881d8baee3e0a3690a26d0ba7eb00173e4580 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Fri, 28 Aug 2026 01:20:26 -0500 Subject: [PATCH 6/7] docs(ramps-controller): link changelog entry to PR Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 47725ad1886..90dec14759e 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `clientProduct` and `clientVersion` constructor options on `RampsService` and `TransakService`, sent on every on-ramp API fetch as `clientProduct` / `clientVersion` query params so the API can evaluate version-gated feature flags per client. Identity travels in the URL (not headers) because the on-ramp CDN cache key is the URL. Also exports the `RampsClientIdentity` type, the `addRampsClientIdentityParams` helper, and the param name constants. +- Add optional `clientProduct` and `clientVersion` constructor options on `RampsService` and `TransakService`, sent on every on-ramp API fetch as `clientProduct` / `clientVersion` query params so the API can evaluate version-gated feature flags per client. Identity travels in the URL (not headers) because the on-ramp CDN cache key is the URL. Also exports the `RampsClientIdentity` type, the `addRampsClientIdentityParams` helper, and the param name constants. ([#9983](https://github.com/MetaMask/core/pull/9983)) - 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 From a13c1e291a7067dd7010860f226860274e2c9652 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Fri, 28 Aug 2026 01:31:04 -0500 Subject: [PATCH 7/7] refactor(ramps-controller): fix stale comments referencing headers and client environment Co-authored-by: Cursor --- packages/ramps-controller/src/RampsService.ts | 4 ++-- packages/ramps-controller/src/TransakService.ts | 16 ++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index a41fd2b780a..0c5071195cf 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -1092,8 +1092,8 @@ export class RampsService { url.searchParams.set('sdk', RAMPS_SDK_VERSION); url.searchParams.set('controller', packageJson.version); url.searchParams.set('context', this.#context); - // Also in the query string (not only headers) so CDN-cached responses - // vary per client product / version / environment. + // In the query string (not headers) so CDN-cached responses vary per + // client product / version. addRampsClientIdentityParams(url, this.#clientIdentity); } diff --git a/packages/ramps-controller/src/TransakService.ts b/packages/ramps-controller/src/TransakService.ts index 6828a96688a..0972fe5df6c 100644 --- a/packages/ramps-controller/src/TransakService.ts +++ b/packages/ramps-controller/src/TransakService.ts @@ -794,8 +794,8 @@ export class TransakService { url.searchParams.set('sdk', RAMPS_SDK_VERSION); url.searchParams.set('controller', packageJson.version); url.searchParams.set('context', this.#context); - // Also in the query string (not only headers) so CDN-cached responses - // vary per client product / version / environment. + // In the query string (not headers) so CDN-cached responses vary per + // client product / version. addRampsClientIdentityParams(url, this.#clientIdentity); } @@ -809,8 +809,8 @@ export class TransakService { url.searchParams.set('action', 'deposit'); url.searchParams.set('context', this.#context); - // Also in the query string (not only headers) so CDN-cached responses - // vary per client product / version / environment. + // In the query string (not headers) so CDN-cached responses vary per + // client product / version. addRampsClientIdentityParams(url, this.#clientIdentity); if (params) { @@ -824,9 +824,7 @@ 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' }, }); if (!fetchResponse.ok) { throw new HttpError( @@ -1309,9 +1307,7 @@ 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' }, }); if (!fetchResponse.ok) { throw new HttpError(