diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index d6e53ba82d..f9e5efd833 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. ([#9983](https://github.com/MetaMask/core/pull/9983)) + ## [20.1.0] ### Added diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index c6aa14df49..c1a7233780 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -48,6 +48,34 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('us-tx'); }); + 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', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + clientProduct: 'metamask-mobile', + clientVersion: '8.9.0', + }) + .reply(200, 'us-tx'); + const { rootMessenger } = getService({ + options: { + clientProduct: 'metamask-mobile', + clientVersion: '8.9.0', + }, + }); + + 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 748ce8a003..0c5071195c 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 { addRampsClientIdentityParams } 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,8 @@ 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). */ constructor({ messenger, @@ -928,6 +934,8 @@ export class RampsService { fetch: fetchFunction, policyOptions = {}, baseUrlOverride, + clientProduct, + clientVersion, }: { messenger: RampsServiceMessenger; environment?: RampsEnvironment; @@ -935,6 +943,8 @@ export class RampsService { fetch: typeof fetch; policyOptions?: CreateServicePolicyOptions; baseUrlOverride?: string; + clientProduct?: string; + clientVersion?: string; }) { this.name = serviceName; this.#messenger = messenger; @@ -943,6 +953,10 @@ export class RampsService { this.#environment = environment; this.#context = context; this.#baseUrlOverride = baseUrlOverride; + this.#clientIdentity = { + clientProduct, + clientVersion, + }; this.#messenger.registerMethodActionHandlers( this, @@ -1078,6 +1092,9 @@ export class RampsService { url.searchParams.set('sdk', RAMPS_SDK_VERSION); url.searchParams.set('controller', packageJson.version); url.searchParams.set('context', this.#context); + // 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.test.ts b/packages/ramps-controller/src/TransakService.test.ts index 3c55e2d20d..6dbe76f62b 100644 --- a/packages/ramps-controller/src/TransakService.test.ts +++ b/packages/ramps-controller/src/TransakService.test.ts @@ -1666,6 +1666,33 @@ describe('TransakService', () => { expect(result.orderType).toBe('DEPOSIT'); }); + 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) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query( + (query) => + query.clientProduct === 'metamask-mobile' && + query.clientVersion === '8.9.0', + ) + .reply(200, MOCK_DEPOSIT_ORDER); + + const { service } = getService({ + options: { + clientProduct: 'metamask-mobile', + clientVersion: '8.9.0', + }, + }); + + 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 8223ec0f29..0972fe5df6 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 { 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'; @@ -510,6 +512,8 @@ export class TransakService { */ readonly #referrerDomain: string; + readonly #clientIdentity: RampsClientIdentity; + constructor({ messenger, environment = TransakEnvironment.Staging, @@ -520,6 +524,8 @@ export class TransakService { orderRetryDelayMs = 2000, rampsApiBaseUrlOverride, referrerDomain = TRANSAK_REFERRER_DOMAIN, + clientProduct, + clientVersion, }: { messenger: TransakServiceMessenger; environment?: TransakEnvironment; @@ -530,6 +536,8 @@ export class TransakService { orderRetryDelayMs?: number; rampsApiBaseUrlOverride?: string; referrerDomain?: string; + clientProduct?: string; + clientVersion?: string; }) { this.name = serviceName; this.#messenger = messenger; @@ -545,6 +553,10 @@ export class TransakService { this.#orderRetryDelayMs = orderRetryDelayMs; this.#rampsApiBaseUrlOverride = rampsApiBaseUrlOverride; this.#referrerDomain = referrerDomain; + this.#clientIdentity = { + clientProduct, + clientVersion, + }; this.#messenger.registerMethodActionHandlers( this, @@ -782,6 +794,9 @@ export class TransakService { url.searchParams.set('sdk', RAMPS_SDK_VERSION); url.searchParams.set('controller', packageJson.version); url.searchParams.set('context', this.#context); + // In the query string (not headers) so CDN-cached responses vary per + // client product / version. + addRampsClientIdentityParams(url, this.#clientIdentity); } async #ordersApiGet( @@ -794,6 +809,9 @@ export class TransakService { url.searchParams.set('action', 'deposit'); url.searchParams.set('context', this.#context); + // In the query string (not headers) so CDN-cached responses vary per + // client product / version. + 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 new file mode 100644 index 0000000000..e2a1e95a26 --- /dev/null +++ b/packages/ramps-controller/src/client-identity.test.ts @@ -0,0 +1,35 @@ +import { addRampsClientIdentityParams } from './client-identity.js'; + +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', + }); + + expect(url.searchParams.get('clientProduct')).toBe('metamask-mobile'); + expect(url.searchParams.get('clientVersion')).toBe('8.9.0'); + }); + + 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', + }); + + expect(url.searchParams.has('clientProduct')).toBe(false); + expect(url.searchParams.get('clientVersion')).toBe('8.9.0'); + }); +}); diff --git a/packages/ramps-controller/src/client-identity.ts b/packages/ramps-controller/src/client-identity.ts new file mode 100644 index 0000000000..a4df2a553d --- /dev/null +++ b/packages/ramps-controller/src/client-identity.ts @@ -0,0 +1,35 @@ +/** Host-supplied MetaMask client identity. All fields optional. */ +export type RampsClientIdentity = { + /** Product id, e.g. `metamask-mobile` or `metamask-extension`. */ + clientProduct?: string; + /** App SemVer, e.g. `8.9.0` (not the ramps-controller package version). */ + clientVersion?: string; +}; + +/** + * 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'; + +/** + * 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 and version. + */ +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); + } +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index f5b0ecaf42..4c04ed4160 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -116,6 +116,12 @@ export { RAMPS_SDK_VERSION, getDefaultRedirectCallbackUrl, } from './RampsService.js'; +export type { RampsClientIdentity } from './client-identity.js'; +export { + addRampsClientIdentityParams, + RAMPS_CLIENT_PRODUCT_PARAM, + RAMPS_CLIENT_VERSION_PARAM, +} from './client-identity.js'; export type { RampsServiceGetDefaultRedirectCallbackUrlAction, RampsServiceGetGeolocationAction,