diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index ff603339e3..eee1fe502f 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add proof of ownership API wiring pre-requisites ([#8974](https://github.com/MetaMask/core/pull/8974)) + - Add `ProfileMetricsService:fetchNonces` messenger action wrapping `POST /api/v2/nonce/batch`. + - Add optional `proof` field on accounts submitted via `ProfileMetricsService:submitMetrics` so that the auth API can use it to mark accounts as `verified: true`. + ## [3.1.6] ### Changed diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index f3710e6a32..f2472a07f2 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -60,6 +60,7 @@ "@metamask/messenger": "^1.2.0", "@metamask/polling-controller": "^16.0.6", "@metamask/profile-sync-controller": "^28.1.1", + "@metamask/superstruct": "^3.1.0", "@metamask/transaction-controller": "^66.0.1", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0" diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService-method-action-types.ts b/packages/profile-metrics-controller/src/ProfileMetricsService-method-action-types.ts index 41a9bc8c9a..862d5afce9 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService-method-action-types.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService-method-action-types.ts @@ -5,6 +5,32 @@ import type { ProfileMetricsService } from './ProfileMetricsService'; +/** + * Fetch single-use nonces from the auth API, one per identifier. + * + * Requests larger than {@link MAX_NONCE_BATCH_SIZE} are split into multiple + * `POST /api/v2/nonce/batch` calls fired in parallel; the resulting maps are + * merged into a single record. Each chunk independently goes through the + * service policy (retry, circuit-breaker, degraded). If any chunk ultimately + * fails, the whole call rejects so the caller can soft-degrade the entire + * entropy-source batch consistently. + * + * The returned record is keyed by the auth API's echoed `identifier` field + * (`response[i].identifier -> response[i].nonce`). The call asserts that + * the response identifier set is exactly the requested set; any mismatch + * (missing, extra, or duplicated identifier) causes the chunk to throw so + * the caller never silently proceeds with partial nonces. + * + * @param data - The identifiers to mint nonces for, plus the optional + * entropy source ID used to scope the bearer token. + * @returns A map of identifier -> nonce. + * @throws {RangeError} if no identifiers are provided. + */ +export type ProfileMetricsServiceFetchNoncesAction = { + type: `ProfileMetricsService:fetchNonces`; + handler: ProfileMetricsService['fetchNonces']; +}; + /** * Submit metrics to the API. * @@ -20,4 +46,5 @@ export type ProfileMetricsServiceSubmitMetricsAction = { * Union of all ProfileMetricsService action types. */ export type ProfileMetricsServiceMethodActions = - ProfileMetricsServiceSubmitMetricsAction; + | ProfileMetricsServiceFetchNoncesAction + | ProfileMetricsServiceSubmitMetricsAction; diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts index 410fcdbcf6..ba6b30be88 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts @@ -335,6 +335,481 @@ describe('ProfileMetricsService', () => { expect(submitMetricsResponse).toBeUndefined(); }); + + it('serializes the optional proof field for each account that has one and omits it for those that do not', async () => { + const mockFetch = jest.fn().mockResolvedValue( + // eslint-disable-next-line no-restricted-globals + new Response(JSON.stringify({ data: { success: true } }), { + status: 200, + }), + ); + const { rootMessenger } = getService({ + options: { fetch: mockFetch }, + }); + const proof = { + nonce: 'mock-nonce', + signature: '0xdeadbeef', + }; + + await rootMessenger.call( + 'ProfileMetricsService:submitMetrics', + createMockRequest({ + accounts: [ + { address: '0xAccountWithProof', scopes: ['eip155:1'], proof }, + { address: '0xAccountWithoutProof', scopes: ['eip155:1'] }, + ], + }), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.accounts).toStrictEqual([ + { address: '0xAccountWithProof', scopes: ['eip155:1'], proof }, + { address: '0xAccountWithoutProof', scopes: ['eip155:1'] }, + ]); + expect(body.accounts[1]).not.toHaveProperty('proof'); + }); + }); + + describe('ProfileMetricsService:fetchNonces', () => { + it('returns a map keyed by the echoed identifier field of the response', async () => { + const identifiers = ['0xAddressOne', '0xAddressTwo']; + nock(defaultBaseEndpoint) + .post('/nonce/batch', { identifiers }) + .reply(200, [ + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-for-one', + }, + { + expires_in: 300, + identifier: '0xAddressTwo', + nonce: 'nonce-for-two', + }, + ]); + const { rootMessenger } = getService(); + + const nonces = await rootMessenger.call( + 'ProfileMetricsService:fetchNonces', + { identifiers, entropySourceId: 'mock-entropy-source-id' }, + ); + + expect(nonces).toStrictEqual({ + '0xAddressOne': 'nonce-for-one', + '0xAddressTwo': 'nonce-for-two', + }); + }); + + it('tolerates unknown additive fields in the response (forward-compatible schema)', async () => { + const identifiers = ['0xAddressOne']; + nock(defaultBaseEndpoint) + .post('/nonce/batch', { identifiers }) + .reply(200, [ + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-for-one', + created_at: '2026-06-01T00:00:00Z', + schema_version: 2, + }, + ]); + const { rootMessenger } = getService(); + + const nonces = await rootMessenger.call( + 'ProfileMetricsService:fetchNonces', + { identifiers }, + ); + + expect(nonces).toStrictEqual({ '0xAddressOne': 'nonce-for-one' }); + }); + + it('tolerates the response being out of order relative to the request', async () => { + const identifiers = ['0xAddressOne', '0xAddressTwo']; + nock(defaultBaseEndpoint) + .post('/nonce/batch', { identifiers }) + .reply(200, [ + { + expires_in: 300, + identifier: '0xAddressTwo', + nonce: 'nonce-for-two', + }, + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-for-one', + }, + ]); + const { rootMessenger } = getService(); + + const nonces = await rootMessenger.call( + 'ProfileMetricsService:fetchNonces', + { identifiers }, + ); + + expect(nonces).toStrictEqual({ + '0xAddressOne': 'nonce-for-one', + '0xAddressTwo': 'nonce-for-two', + }); + }); + + it('forwards the entropy source ID to the bearer token resolver and omits credentials', async () => { + const mockFetch = jest.fn().mockResolvedValue( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify([ + { + expires_in: 300, + identifier: '0xAddress', + nonce: 'nonce-value', + }, + ]), + { status: 200 }, + ), + ); + const bearerTokenHandler = jest + .fn, [string | undefined]>() + .mockResolvedValue('mock-bearer-token'); + const { rootMessenger } = getService({ + options: { fetch: mockFetch }, + bearerTokenHandler, + }); + + await rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers: ['0xAddress'], + entropySourceId: 'mock-entropy-source-id', + }); + + expect(bearerTokenHandler).toHaveBeenCalledWith('mock-entropy-source-id'); + const [calledUrl, calledInit] = mockFetch.mock.calls[0]; + expect(calledUrl.toString()).toBe(`${defaultBaseEndpoint}/nonce/batch`); + expect(calledInit).toMatchObject({ + method: 'POST', + credentials: 'omit', + headers: { + Authorization: 'Bearer mock-bearer-token', + 'Content-Type': 'application/json', + }, + }); + }); + + it('omits the entropy source ID when none is provided', async () => { + const bearerTokenHandler = jest + .fn, [string | undefined]>() + .mockResolvedValue('mock-bearer-token'); + const mockFetch = jest.fn().mockResolvedValue( + // eslint-disable-next-line no-restricted-globals + new Response( + JSON.stringify([ + { + expires_in: 300, + identifier: '0xAddress', + nonce: 'nonce-value', + }, + ]), + { status: 200 }, + ), + ); + const { rootMessenger } = getService({ + options: { fetch: mockFetch }, + bearerTokenHandler, + }); + + await rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers: ['0xAddress'], + }); + + expect(bearerTokenHandler).toHaveBeenCalledWith(undefined); + }); + + it('throws a RangeError when no identifiers are provided', async () => { + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers: [], + }), + ).rejects.toThrow( + 'ProfileMetricsService.fetchNonces requires at least 1 identifier.', + ); + }); + + it('chunks requests larger than MAX_NONCE_BATCH_SIZE into multiple HTTP calls and merges the results', async () => { + const identifiers = Array.from( + { length: 120 }, + (_, i) => `0xAddress${i}`, + ); + const scope = nock(defaultBaseEndpoint); + // The chunker slices into 50 + 50 + 20. Order of completion across + // chunks is not guaranteed (Promise.all), so we match every chunk by + // its request body and respond with a one-to-one nonce per identifier. + scope + .post('/nonce/batch') + .times(3) + .reply(200, (_uri, requestBody) => { + const { identifiers: chunkIdentifiers } = requestBody as { + identifiers: string[]; + }; + return chunkIdentifiers.map((identifier) => ({ + expires_in: 300, + identifier, + nonce: `nonce-for-${identifier}`, + })); + }); + const { rootMessenger } = getService(); + + const nonces = await rootMessenger.call( + 'ProfileMetricsService:fetchNonces', + { identifiers }, + ); + + expect(Object.keys(nonces)).toHaveLength(120); + identifiers.forEach((identifier) => { + expect(nonces[identifier]).toBe(`nonce-for-${identifier}`); + }); + expect(scope.pendingMocks()).toHaveLength(0); + }); + + it('throws after exhausting retries when the response is short of identifiers', async () => { + const identifiers = ['0xAddressOne', '0xAddressTwo']; + nock(defaultBaseEndpoint) + .post('/nonce/batch') + .times(4) + .reply(200, [ + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-for-one', + }, + ]); + const { service, rootMessenger } = getService(); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers, + }), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/nonce/batch' returned a response whose identifier set does not match the request`, + ); + }); + + it('throws after exhausting retries when the response returns identifiers we did not request', async () => { + const identifiers = ['0xAddressOne', '0xAddressTwo']; + nock(defaultBaseEndpoint) + .post('/nonce/batch') + .times(4) + .reply(200, [ + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-for-one', + }, + { + expires_in: 300, + identifier: '0xUnexpectedAddress', + nonce: 'nonce-for-impostor', + }, + ]); + const { service, rootMessenger } = getService(); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers, + }), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/nonce/batch' returned a response whose identifier set does not match the request`, + ); + }); + + it('throws after exhausting retries when the response duplicates one identifier in place of another', async () => { + const identifiers = ['0xAddressOne', '0xAddressTwo']; + nock(defaultBaseEndpoint) + .post('/nonce/batch') + .times(4) + .reply(200, [ + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-for-one-a', + }, + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-for-one-b', + }, + ]); + const { service, rootMessenger } = getService(); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers, + }), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/nonce/batch' returned a response whose identifier set does not match the request`, + ); + }); + + it('throws after exhausting retries when the response duplicates an identifier alongside a full set (preventing silent overwrite)', async () => { + const identifiers = ['0xAddressOne', '0xAddressTwo']; + nock(defaultBaseEndpoint) + .post('/nonce/batch') + .times(4) + .reply(200, [ + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-for-one-a', + }, + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-for-one-b', + }, + { + expires_in: 300, + identifier: '0xAddressTwo', + nonce: 'nonce-for-two', + }, + ]); + const { service, rootMessenger } = getService(); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers, + }), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/nonce/batch' returned a response whose identifier set does not match the request`, + ); + }); + + it('throws after exhausting retries when the response body is not an array', async () => { + const identifiers = ['0xAddressOne']; + nock(defaultBaseEndpoint) + .post('/nonce/batch') + .times(4) + .reply(200, { error: 'oops' }); + const { service, rootMessenger } = getService(); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers, + }), + ).rejects.toThrow( + `Malformed response received from '${defaultBaseEndpoint}/nonce/batch'`, + ); + }); + + it('throws after exhausting retries when a response entry is missing the `nonce` field', async () => { + const identifiers = ['0xAddressOne']; + nock(defaultBaseEndpoint) + .post('/nonce/batch') + .times(4) + .reply(200, [{ expires_in: 300, identifier: '0xAddressOne' }]); + const { service, rootMessenger } = getService(); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers, + }), + ).rejects.toThrow( + `Malformed response received from '${defaultBaseEndpoint}/nonce/batch'`, + ); + }); + + it('throws after exhausting retries when a response entry has a non-string `nonce`', async () => { + const identifiers = ['0xAddressOne']; + nock(defaultBaseEndpoint) + .post('/nonce/batch') + .times(4) + .reply(200, [ + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 12345, + }, + ]); + const { service, rootMessenger } = getService(); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers, + }), + ).rejects.toThrow( + `Malformed response received from '${defaultBaseEndpoint}/nonce/batch'`, + ); + }); + + it('attempts a request that responds with non-200 up to 4 times, throwing if it never succeeds', async () => { + nock(defaultBaseEndpoint).post('/nonce/batch').times(4).reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers: ['0xAddressOne'], + }), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/nonce/batch' failed with status '500'`, + ); + }); + + it('attempts a request that responds with 4xx up to 4 times, throwing if it never succeeds', async () => { + nock(defaultBaseEndpoint).post('/nonce/batch').times(4).reply(400); + const { service, rootMessenger } = getService(); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); + + await expect( + rootMessenger.call('ProfileMetricsService:fetchNonces', { + identifiers: ['0xAddressOne'], + }), + ).rejects.toThrow( + `Fetching '${defaultBaseEndpoint}/nonce/batch' failed with status '400'`, + ); + }); + }); + + describe('fetchNonces', () => { + it('does the same thing as the messenger action', async () => { + const identifiers = ['0xAddressOne']; + nock(defaultBaseEndpoint) + .post('/nonce/batch', { identifiers }) + .reply(200, [ + { + expires_in: 300, + identifier: '0xAddressOne', + nonce: 'nonce-value', + }, + ]); + const { service } = getService(); + + const nonces = await service.fetchNonces({ identifiers }); + + expect(nonces).toStrictEqual({ '0xAddressOne': 'nonce-value' }); + }); }); }); @@ -386,12 +861,17 @@ function getMessenger( * @param args.options - The options that the service constructor takes. All are * optional and will be filled in with defaults in as needed (including * `messenger`). + * @param args.bearerTokenHandler - Optional override for the + * `AuthenticationController:getBearerToken` handler. Defaults to a stub that + * always resolves to `'mock-bearer-token'`. * @returns The new service, root messenger, and service messenger. */ function getService({ options = {}, + bearerTokenHandler, }: { options?: Partial[0]>; + bearerTokenHandler?: (entropySourceId: string | undefined) => Promise; } = {}): { service: ProfileMetricsService; rootMessenger: RootMessenger; @@ -400,7 +880,7 @@ function getService({ const rootMessenger = getRootMessenger(); rootMessenger.registerActionHandler( 'AuthenticationController:getBearerToken', - async () => 'mock-bearer-token', + bearerTokenHandler ?? (async (): Promise => 'mock-bearer-token'), ); const messenger = getMessenger(rootMessenger); diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.ts index 3fdacc1af9..e9a7b60852 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.ts @@ -6,10 +6,32 @@ import { createServicePolicy, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import { SDK } from '@metamask/profile-sync-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import { + array, + number, + string, + type as structType, +} from '@metamask/superstruct'; import type { IDisposable } from 'cockatiel'; import type { ProfileMetricsServiceMethodActions } from '.'; +/** + * The shape of an entry in the `POST /api/v2/nonce/batch` response body. + * + * `identifier` echoes the request identifier verbatim, mirroring the + * documented behavior of the single-account `GET /api/v2/nonce` endpoint on + * the same auth service. Defined with `type()` (not `object()`) so the + * client tolerates additive server-side schema changes. + */ +const NonceBatchResponseStruct = array( + structType({ + expires_in: number(), + identifier: string(), + nonce: string(), + }), +); + // === GENERAL === /** @@ -19,11 +41,35 @@ import type { ProfileMetricsServiceMethodActions } from '.'; export const serviceName = 'ProfileMetricsService'; /** - * An account address along with its associated scopes. + * A cryptographic proof that the caller controls the private key of an + * account, as defined by the `PUT /api/v2/profile/accounts` endpoint of the + * auth API. When present, the server verifies the signature against + * `metamask:proof-of-ownership::` and permanently + * marks the account as `verified: true`. + */ +export type AccountOwnershipProof = { + /** + * Single-use nonce obtained from {@link ProfileMetricsService.fetchNonces}. + * Consumed by the server on verification; replay is not possible. + */ + nonce: string; + /** + * Chain-native signature of `metamask:proof-of-ownership::
`, + * always 0x-prefixed. The exact format varies by chain (see the auth API + * spec — EIP-191 for `eip155`, ed25519 for `solana`, TIP-191 for `tron`, + * BIP-322 for `bip122`). + */ + signature: string; +}; + +/** + * An account address along with its associated scopes and an optional + * ownership proof. */ export type AccountWithScopes = { address: string; scopes: `${string}:${string}`[]; + proof?: AccountOwnershipProof; }; /** @@ -35,9 +81,33 @@ export type ProfileMetricsSubmitMetricsRequest = { accounts: AccountWithScopes[]; }; +/** + * The shape of the request object for fetching a batch of single-use nonces. + */ +export type ProfileMetricsFetchNoncesRequest = { + /** + * The identifiers (canonical addresses) to mint a nonce for. The auth API + * accepts between 1 and {@link MAX_NONCE_BATCH_SIZE} identifiers per call. + */ + identifiers: string[]; + /** + * The entropy source ID to use when fetching a bearer token. Pass `null` or + * omit for accounts that do not belong to any entropy source. + */ + entropySourceId?: string | null; +}; + +/** + * Maximum number of identifiers the auth API will mint nonces for in a single + * `POST /api/v2/nonce/batch` request. {@link ProfileMetricsService.fetchNonces} + * uses this as the chunk size when the caller requests more than this many + * nonces at once. + */ +export const MAX_NONCE_BATCH_SIZE = 50; + // === MESSENGER === -const MESSENGER_EXPOSED_METHODS = ['submitMetrics'] as const; +const MESSENGER_EXPOSED_METHODS = ['submitMetrics', 'fetchNonces'] as const; /** * Actions that {@link ProfileMetricsService} exposes to other consumers. @@ -194,6 +264,104 @@ export class ProfileMetricsService { return this.#policy.onDegraded(listener); } + /** + * Fetch single-use nonces from the auth API, one per identifier. + * + * Requests larger than {@link MAX_NONCE_BATCH_SIZE} are split into multiple + * `POST /api/v2/nonce/batch` calls fired in parallel; the resulting maps are + * merged into a single record. Each chunk independently goes through the + * service policy (retry, circuit-breaker, degraded). If any chunk ultimately + * fails, the whole call rejects so the caller can soft-degrade the entire + * entropy-source batch consistently. + * + * The returned record is keyed by the auth API's echoed `identifier` field + * (`response[i].identifier -> response[i].nonce`). The call asserts that + * the response identifier set is exactly the requested set; any mismatch + * (missing, extra, or duplicated identifier) causes the chunk to throw so + * the caller never silently proceeds with partial nonces. + * + * @param data - The identifiers to mint nonces for, plus the optional + * entropy source ID used to scope the bearer token. + * @returns A map of identifier -> nonce. + * @throws {RangeError} if no identifiers are provided. + */ + async fetchNonces( + data: ProfileMetricsFetchNoncesRequest, + ): Promise> { + if (data.identifiers.length === 0) { + throw new RangeError( + 'ProfileMetricsService.fetchNonces requires at least 1 identifier.', + ); + } + const chunks: string[][] = []; + for (let i = 0; i < data.identifiers.length; i += MAX_NONCE_BATCH_SIZE) { + chunks.push(data.identifiers.slice(i, i + MAX_NONCE_BATCH_SIZE)); + } + const chunkResults = await Promise.all( + chunks.map((identifiers) => + this.#fetchNoncesChunk(identifiers, data.entropySourceId), + ), + ); + return Object.assign({}, ...chunkResults); + } + + /** + * Mint nonces for a single ≤ {@link MAX_NONCE_BATCH_SIZE}-sized chunk of + * identifiers. Wrapped in {@link #policy} for retry / degraded / circuit + * semantics consistent with the rest of the service. + * + * @param identifiers - The identifiers in this chunk. Must be 1..MAX_NONCE_BATCH_SIZE. + * @param entropySourceId - The entropy source ID forwarded to the bearer + * token resolver. + * @returns A map of identifier -> nonce for this chunk. + */ + async #fetchNoncesChunk( + identifiers: string[], + entropySourceId: string | null | undefined, + ): Promise> { + return await this.#policy.execute(async () => { + const authToken = await this.#messenger.call( + 'AuthenticationController:getBearerToken', + entropySourceId ?? undefined, + ); + const url = new URL(`${this.#baseURL}/nonce/batch`); + const localResponse = await this.#fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ identifiers }), + credentials: 'omit', + }); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + ); + } + const body: unknown = await localResponse.json(); + if (!NonceBatchResponseStruct.is(body)) { + throw new Error(`Malformed response received from '${url.toString()}'`); + } + const result: Record = {}; + for (const entry of body) { + result[entry.identifier] = entry.nonce; + } + const echoesRequest = + body.length === identifiers.length && + identifiers.every((id) => + Object.prototype.hasOwnProperty.call(result, id), + ); + if (!echoesRequest) { + throw new Error( + `Fetching '${url.toString()}' returned a response whose identifier set does not match the request`, + ); + } + return result; + }); + } + /** * Submit metrics to the API. * diff --git a/packages/profile-metrics-controller/src/utils/canonicalize.test.ts b/packages/profile-metrics-controller/src/utils/canonicalize.test.ts new file mode 100644 index 0000000000..ed779fa489 --- /dev/null +++ b/packages/profile-metrics-controller/src/utils/canonicalize.test.ts @@ -0,0 +1,122 @@ +import { + canonicalizeAddress, + ProofUnsupportedNamespaceError, +} from './canonicalize'; + +describe('canonicalizeAddress', () => { + describe('eip155', () => { + const checksummed = '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'; + + it('returns the EIP-55 checksum of an all-lowercase address', () => { + expect(canonicalizeAddress(checksummed.toLowerCase(), 'eip155')).toBe( + checksummed, + ); + }); + + it('returns the EIP-55 checksum of an all-uppercase address', () => { + expect( + canonicalizeAddress( + `0x${checksummed.slice(2).toUpperCase()}`, + 'eip155', + ), + ).toBe(checksummed); + }); + + it('returns an already-checksummed address unchanged', () => { + expect(canonicalizeAddress(checksummed, 'eip155')).toBe(checksummed); + }); + + it('falls back to controller-utils behaviour for non-hex input (0x-prefixed verbatim, server then rejects)', () => { + // `toChecksumHexAddress` 0x-prefixes its input before validating, and + // returns the prefixed form unchanged when the result is not a valid + // hex string. We rely on the server to reject these with 400 rather + // than throwing client-side. + expect(canonicalizeAddress('not-a-hex-address', 'eip155')).toBe( + '0xnot-a-hex-address', + ); + }); + }); + + describe('solana', () => { + it('returns base58 addresses unchanged', () => { + const solanaAddress = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'; + expect(canonicalizeAddress(solanaAddress, 'solana')).toBe(solanaAddress); + }); + }); + + describe('tron', () => { + it('returns base58check addresses unchanged', () => { + const tronAddress = 'TRX9Yg4yFqyKBcXBSc1nKMpHsfYVgKvN3p'; + expect(canonicalizeAddress(tronAddress, 'tron')).toBe(tronAddress); + }); + }); + + describe('bip122', () => { + it('returns legacy P2PKH addresses (starting with 1) unchanged', () => { + const legacy = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + expect(canonicalizeAddress(legacy, 'bip122')).toBe(legacy); + }); + + it('lowercases bech32 P2WPKH addresses (bc1q…) given in uppercase', () => { + const upper = 'BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4'; + expect(canonicalizeAddress(upper, 'bip122')).toBe(upper.toLowerCase()); + }); + + it('returns lowercase bech32m P2TR addresses (bc1p…) unchanged', () => { + const taproot = + 'bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0'; + expect(canonicalizeAddress(taproot, 'bip122')).toBe(taproot); + }); + + it('lowercases mixed-case bech32 addresses', () => { + const mixed = 'Bc1Qw508D6Qejxtdg4Y5R3Zarvary0C5Xw7Kv8F3T4'; + expect(canonicalizeAddress(mixed, 'bip122')).toBe(mixed.toLowerCase()); + }); + + it('lowercases testnet bech32 P2WPKH addresses (tb1q…)', () => { + const testnet = 'TB1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KXPJZSX'; + expect(canonicalizeAddress(testnet, 'bip122')).toBe( + testnet.toLowerCase(), + ); + }); + + it('lowercases testnet bech32m P2TR addresses (tb1p…)', () => { + const testnetTaproot = + 'TB1P0XLXVLHEMJA6C4DQV22UAPCTQUPFHLXM9H8Z3K2E72Q4K9HCZ7VQ4QPSGD'; + expect(canonicalizeAddress(testnetTaproot, 'bip122')).toBe( + testnetTaproot.toLowerCase(), + ); + }); + + it('lowercases regtest bech32 addresses (bcrt1…)', () => { + const regtest = 'BCRT1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KYGT080'; + expect(canonicalizeAddress(regtest, 'bip122')).toBe( + regtest.toLowerCase(), + ); + }); + + it('returns legacy testnet P2PKH addresses (starting with m/n) unchanged', () => { + const testnetLegacy = 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn'; + expect(canonicalizeAddress(testnetLegacy, 'bip122')).toBe(testnetLegacy); + }); + }); + + describe('unsupported namespaces', () => { + it.each([ + 'cosmos', + 'polkadot', + 'eip155:1', // a full CAIP-2 id is not a namespace + '', + ])("throws ProofUnsupportedNamespaceError for '%s'", (namespace) => { + expect(() => canonicalizeAddress('whatever', namespace)).toThrow( + ProofUnsupportedNamespaceError, + ); + }); + + it('attaches the offending namespace to the error message', () => { + expect(() => canonicalizeAddress('whatever', 'cosmos')).toThrow( + "Proof of ownership is not supported for namespace 'cosmos'.", + ); + }); + }); +}); diff --git a/packages/profile-metrics-controller/src/utils/canonicalize.ts b/packages/profile-metrics-controller/src/utils/canonicalize.ts new file mode 100644 index 0000000000..8fad46f662 --- /dev/null +++ b/packages/profile-metrics-controller/src/utils/canonicalize.ts @@ -0,0 +1,76 @@ +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import { KnownCaipNamespace } from '@metamask/utils'; + +/** + * Bitcoin bech32 / bech32m address prefixes that we lowercase per the auth + * API canonicalization rules. The prefix is the network identifier (`bc`, + * `tb`, `bcrt`) plus the bech32 separator (`1`); matching against this form + * pins the check to actual bech32 addresses and avoids accidentally + * matching legacy base58check addresses that happen to start with the same + * letters. Both segwit (`…q…`) and taproot (`…p…`) variants are subsumed. + * Legacy base58check P2PKH addresses (mainnet `1…`, testnet `m…`/`n…`) are + * case-sensitive and intentionally not in this list. + * + * Today the wallet only creates `BtcScope.Mainnet` accounts, but the + * non-mainnet prefixes are kept here as cheap forward-compat: per the auth + * API spec the lowercase rule is shape-based, not network-based. + */ +const BECH32_BITCOIN_ADDRESS_PREFIXES = ['bc1', 'tb1', 'bcrt1'] as const; + +/** + * Thrown when {@link canonicalizeAddress} is given a namespace it does + * not know how to handle. + * Callers in the polling pipeline use this to fall back to submitting the + * account without a proof rather than blocking the batch. + */ +export class ProofUnsupportedNamespaceError extends Error { + constructor(namespace: string) { + super(`Proof of ownership is not supported for namespace '${namespace}'.`); + this.name = 'ProofUnsupportedNamespaceError'; + } +} + +/** + * Returns the address in the canonical encoding the auth API expects for the + * given CAIP-2 namespace. + * + * Encoding rules (per the `PUT /api/v2/profile/accounts` spec): + * + * - `eip155` — EIP-55 mixed-case hex checksum. + * - `solana`, `tron` — base58 / base58check, single canonical encoding; returned + * as-is (the server rejects malformed inputs with 400). + * - `bip122` — bech32 / bech32m addresses (mainnet `bc1…`, testnet + * `tb1…`, regtest `bcrt1…`) must be all-lowercase; legacy base58check + * P2PKH addresses (`1…`, `m…`, `n…`) are accepted as-is. + * + * @param address - The address to canonicalize. + * @param namespace - The CAIP-2 namespace of the chain the address belongs to. + * @returns The address in its canonical form for `namespace`. + * @throws {ProofUnsupportedNamespaceError} if `namespace` is not one of + * `eip155`, `solana`, `tron`, or `bip122`. + */ +export function canonicalizeAddress( + address: string, + namespace: string, +): string { + switch (namespace) { + case KnownCaipNamespace.Eip155: + return toChecksumHexAddress(address); + case KnownCaipNamespace.Solana: + case KnownCaipNamespace.Tron: + return address; + case KnownCaipNamespace.Bip122: { + const lowercased = address.toLowerCase(); + if ( + BECH32_BITCOIN_ADDRESS_PREFIXES.some((prefix) => + lowercased.startsWith(prefix), + ) + ) { + return lowercased; + } + return address; + } + default: + throw new ProofUnsupportedNamespaceError(namespace); + } +} diff --git a/yarn.lock b/yarn.lock index 5ef0e77490..4d08c1d875 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8018,6 +8018,7 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.6" "@metamask/profile-sync-controller": "npm:^28.1.1" + "@metamask/superstruct": "npm:^3.1.0" "@metamask/transaction-controller": "npm:^66.0.1" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4"