Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/ramps-controller/src/RampsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
17 changes: 17 additions & 0 deletions packages/ramps-controller/src/RampsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -906,6 +908,8 @@ export class RampsService {
*/
readonly #baseUrlOverride?: string;

readonly #clientIdentity: RampsClientIdentity;

/**
* Constructs a new RampsService object.
*
Expand All @@ -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,
Expand All @@ -928,13 +934,17 @@ export class RampsService {
fetch: fetchFunction,
policyOptions = {},
baseUrlOverride,
clientProduct,
clientVersion,
}: {
messenger: RampsServiceMessenger;
environment?: RampsEnvironment;
context: string;
fetch: typeof fetch;
policyOptions?: CreateServicePolicyOptions;
baseUrlOverride?: string;
clientProduct?: string;
clientVersion?: string;
}) {
this.name = serviceName;
this.#messenger = messenger;
Expand All @@ -943,6 +953,10 @@ export class RampsService {
this.#environment = environment;
this.#context = context;
this.#baseUrlOverride = baseUrlOverride;
this.#clientIdentity = {
clientProduct,
clientVersion,
};

this.#messenger.registerMethodActionHandlers(
this,
Expand Down Expand Up @@ -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);
}

/**
Expand Down
27 changes: 27 additions & 0 deletions packages/ramps-controller/src/TransakService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down
18 changes: 18 additions & 0 deletions packages/ramps-controller/src/TransakService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -510,6 +512,8 @@ export class TransakService {
*/
readonly #referrerDomain: string;

readonly #clientIdentity: RampsClientIdentity;

constructor({
messenger,
environment = TransakEnvironment.Staging,
Expand All @@ -520,6 +524,8 @@ export class TransakService {
orderRetryDelayMs = 2000,
rampsApiBaseUrlOverride,
referrerDomain = TRANSAK_REFERRER_DOMAIN,
clientProduct,
clientVersion,
}: {
messenger: TransakServiceMessenger;
environment?: TransakEnvironment;
Expand All @@ -530,6 +536,8 @@ export class TransakService {
orderRetryDelayMs?: number;
rampsApiBaseUrlOverride?: string;
referrerDomain?: string;
clientProduct?: string;
clientVersion?: string;
}) {
this.name = serviceName;
this.#messenger = messenger;
Expand All @@ -545,6 +553,10 @@ export class TransakService {
this.#orderRetryDelayMs = orderRetryDelayMs;
this.#rampsApiBaseUrlOverride = rampsApiBaseUrlOverride;
this.#referrerDomain = referrerDomain;
this.#clientIdentity = {
clientProduct,
clientVersion,
};

this.#messenger.registerMethodActionHandlers(
this,
Expand Down Expand Up @@ -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<ResponseType>(
Expand All @@ -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);
Comment thread
cursor[bot] marked this conversation as resolved.

if (params) {
for (const [key, value] of Object.entries(params)) {
Expand Down
35 changes: 35 additions & 0 deletions packages/ramps-controller/src/client-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
35 changes: 35 additions & 0 deletions packages/ramps-controller/src/client-identity.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
6 changes: 6 additions & 0 deletions packages/ramps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down