From adc45adb1b541d9eafc2c004fbbd00c997143fe8 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Sun, 31 May 2026 11:39:15 +0200 Subject: [PATCH 1/8] feat: proof of ownership preliminary work --- .../profile-metrics-controller/CHANGELOG.md | 5 + .../profile-metrics-controller/package.json | 1 + ...ofileMetricsService-method-action-types.ts | 29 +- .../src/ProfileMetricsService.test.ts | 424 +++++++++++++++++- .../src/ProfileMetricsService.ts | 166 ++++++- .../src/utils/canonicalize.test.ts | 92 ++++ .../src/utils/canonicalize.ts | 60 +++ yarn.lock | 1 + 8 files changed, 774 insertions(+), 4 deletions(-) create mode 100644 packages/profile-metrics-controller/src/utils/canonicalize.test.ts create mode 100644 packages/profile-metrics-controller/src/utils/canonicalize.ts diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index 0e63d3adaf..766112b7d8 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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`. + ### Changed - Bump `@metamask/accounts-controller` from `^38.1.0` to `^38.1.1` ([#8774](https://github.com/MetaMask/core/pull/8774)) diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index 770b6f2da8..6c8d5836fa 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.0", + "@metamask/superstruct": "^3.1.0", "@metamask/transaction-controller": "^66.0.0", "@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..4cf713b010 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts @@ -335,6 +335,421 @@ 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 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 contains a duplicate identifier', 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 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 +801,19 @@ 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 +822,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..91ffefd0f6 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.ts @@ -6,10 +6,26 @@ 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, object, string } 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. + */ +const NonceBatchResponseStruct = array( + object({ + expires_in: number(), + identifier: string(), + nonce: string(), + }), +); + // === GENERAL === /** @@ -19,11 +35,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 +75,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 +258,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 = + Object.keys(result).length === identifiers.length && + identifiers.every((id) => Object.hasOwn(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..a03f5ba929 --- /dev/null +++ b/packages/profile-metrics-controller/src/utils/canonicalize.test.ts @@ -0,0 +1,92 @@ +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()); + }); + }); + + 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..13cea69f7e --- /dev/null +++ b/packages/profile-metrics-controller/src/utils/canonicalize.ts @@ -0,0 +1,60 @@ +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import { KnownCaipNamespace } from '@metamask/utils'; + +/** + * Bech32 / bech32m human-readable parts that we lowercase per the auth API + * canonicalization rules. Only Bitcoin mainnet (`bc1…`) is in scope; other + * networks are intentionally unhandled until the wallet supports them. + */ +const BECH32_BITCOIN_MAINNET_PREFIX = 'bc1'; + +/** + * 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 (`bc1…`) must be all-lowercase; + * legacy P2PKH addresses (starting with `1`) 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: + if (address.toLowerCase().startsWith(BECH32_BITCOIN_MAINNET_PREFIX)) { + return address.toLowerCase(); + } + return address; + default: + throw new ProofUnsupportedNamespaceError(namespace); + } +} diff --git a/yarn.lock b/yarn.lock index d0a699c938..cf8ff41c51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5110,6 +5110,7 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.6" "@metamask/profile-sync-controller": "npm:^28.1.0" + "@metamask/superstruct": "npm:^3.1.0" "@metamask/transaction-controller": "npm:^66.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" From 5bc668cbbebc9af1f69020c727219e3ccc055bd5 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 2 Jun 2026 16:39:35 +0200 Subject: [PATCH 2/8] fix: update CHANGELOG --- packages/profile-metrics-controller/CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index eef04d979a..a74ae19589 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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`. +- 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.5] From 313ca627567b40600c4188f6efc311c19929f7c3 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 2 Jun 2026 16:40:14 +0200 Subject: [PATCH 3/8] fix: lint --- .../src/ProfileMetricsService.test.ts | 31 +++++++++---------- .../src/ProfileMetricsService.ts | 4 +-- .../src/utils/canonicalize.test.ts | 5 ++- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts index 4cf713b010..4743a4c793 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts @@ -456,9 +456,7 @@ describe('ProfileMetricsService', () => { entropySourceId: 'mock-entropy-source-id', }); - expect(bearerTokenHandler).toHaveBeenCalledWith( - '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({ @@ -521,16 +519,19 @@ describe('ProfileMetricsService', () => { // 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}`, - })); - }); + 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( @@ -811,9 +812,7 @@ function getService({ bearerTokenHandler, }: { options?: Partial[0]>; - bearerTokenHandler?: ( - entropySourceId: string | undefined, - ) => Promise; + bearerTokenHandler?: (entropySourceId: string | undefined) => Promise; } = {}): { service: ProfileMetricsService; rootMessenger: RootMessenger; diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.ts index 91ffefd0f6..9035c1b534 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.ts @@ -336,9 +336,7 @@ export class ProfileMetricsService { } const body: unknown = await localResponse.json(); if (!NonceBatchResponseStruct.is(body)) { - throw new Error( - `Malformed response received from '${url.toString()}'`, - ); + throw new Error(`Malformed response received from '${url.toString()}'`); } const result: Record = {}; for (const entry of body) { diff --git a/packages/profile-metrics-controller/src/utils/canonicalize.test.ts b/packages/profile-metrics-controller/src/utils/canonicalize.test.ts index a03f5ba929..1b12dc2365 100644 --- a/packages/profile-metrics-controller/src/utils/canonicalize.test.ts +++ b/packages/profile-metrics-controller/src/utils/canonicalize.test.ts @@ -15,7 +15,10 @@ describe('canonicalizeAddress', () => { it('returns the EIP-55 checksum of an all-uppercase address', () => { expect( - canonicalizeAddress(`0x${checksummed.slice(2).toUpperCase()}`, 'eip155'), + canonicalizeAddress( + `0x${checksummed.slice(2).toUpperCase()}`, + 'eip155', + ), ).toBe(checksummed); }); From 2dcaea78e6bd496acfae1aae7ba6e006f812ba8f Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 2 Jun 2026 16:46:28 +0200 Subject: [PATCH 4/8] fix: use hasOwnProperty instead of hasOwn --- .../profile-metrics-controller/src/ProfileMetricsService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.ts index 9035c1b534..65df18ee2b 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.ts @@ -344,7 +344,9 @@ export class ProfileMetricsService { } const echoesRequest = Object.keys(result).length === identifiers.length && - identifiers.every((id) => Object.hasOwn(result, id)); + 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`, From a8b33010af7c1385aba1eba32a6ff642143a0b7d Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 2 Jun 2026 16:58:48 +0200 Subject: [PATCH 5/8] fix: relax API response validation --- .../src/ProfileMetricsService.test.ts | 23 +++++++++++++++++++ .../src/ProfileMetricsService.ts | 12 +++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts index 4743a4c793..cd76e78223 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts @@ -400,6 +400,29 @@ describe('ProfileMetricsService', () => { }); }); + 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) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.ts index 65df18ee2b..16898e9007 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.ts @@ -6,7 +6,12 @@ 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, object, string } from '@metamask/superstruct'; +import { + array, + number, + string, + type as structType, +} from '@metamask/superstruct'; import type { IDisposable } from 'cockatiel'; import type { ProfileMetricsServiceMethodActions } from '.'; @@ -16,10 +21,11 @@ import type { ProfileMetricsServiceMethodActions } from '.'; * * `identifier` echoes the request identifier verbatim, mirroring the * documented behavior of the single-account `GET /api/v2/nonce` endpoint on - * the same auth service. + * the same auth service. Defined with `type()` (not `object()`) so the + * client tolerates additive server-side schema changes. */ const NonceBatchResponseStruct = array( - object({ + structType({ expires_in: number(), identifier: string(), nonce: string(), From a3cb24b1954f2b32d9cbfae4fcdc3374297f875a Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 2 Jun 2026 17:19:47 +0200 Subject: [PATCH 6/8] fix: address cursor feedbacks --- .../src/ProfileMetricsService.test.ts | 38 ++++++++++++++++++- .../src/ProfileMetricsService.ts | 2 +- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts index cd76e78223..ba6b30be88 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts @@ -626,7 +626,7 @@ describe('ProfileMetricsService', () => { ); }); - it('throws after exhausting retries when the response contains a duplicate identifier', async () => { + 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') @@ -657,6 +657,42 @@ describe('ProfileMetricsService', () => { ); }); + 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) diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.ts index 16898e9007..e9a7b60852 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.ts @@ -349,7 +349,7 @@ export class ProfileMetricsService { result[entry.identifier] = entry.nonce; } const echoesRequest = - Object.keys(result).length === identifiers.length && + body.length === identifiers.length && identifiers.every((id) => Object.prototype.hasOwnProperty.call(result, id), ); From 6585975ebe418157e16186774b8550faa8d27cc0 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 3 Jun 2026 23:09:22 +0200 Subject: [PATCH 7/8] fix: do not lowercase twice --- .../profile-metrics-controller/src/utils/canonicalize.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/profile-metrics-controller/src/utils/canonicalize.ts b/packages/profile-metrics-controller/src/utils/canonicalize.ts index 13cea69f7e..85eeed1a14 100644 --- a/packages/profile-metrics-controller/src/utils/canonicalize.ts +++ b/packages/profile-metrics-controller/src/utils/canonicalize.ts @@ -49,11 +49,13 @@ export function canonicalizeAddress( case KnownCaipNamespace.Solana: case KnownCaipNamespace.Tron: return address; - case KnownCaipNamespace.Bip122: - if (address.toLowerCase().startsWith(BECH32_BITCOIN_MAINNET_PREFIX)) { - return address.toLowerCase(); + case KnownCaipNamespace.Bip122: { + const lowercased = address.toLowerCase(); + if (lowercased.startsWith(BECH32_BITCOIN_MAINNET_PREFIX)) { + return lowercased; } return address; + } default: throw new ProofUnsupportedNamespaceError(namespace); } From bc970efdb00f6bcb2bed2e64a79a001a76460b15 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Thu, 4 Jun 2026 12:07:53 +0200 Subject: [PATCH 8/8] fix: btc addresses coverage for lowercasing --- .../src/utils/canonicalize.test.ts | 27 ++++++++++++++++++ .../src/utils/canonicalize.ts | 28 ++++++++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/profile-metrics-controller/src/utils/canonicalize.test.ts b/packages/profile-metrics-controller/src/utils/canonicalize.test.ts index 1b12dc2365..ed779fa489 100644 --- a/packages/profile-metrics-controller/src/utils/canonicalize.test.ts +++ b/packages/profile-metrics-controller/src/utils/canonicalize.test.ts @@ -72,6 +72,33 @@ describe('canonicalizeAddress', () => { 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', () => { diff --git a/packages/profile-metrics-controller/src/utils/canonicalize.ts b/packages/profile-metrics-controller/src/utils/canonicalize.ts index 85eeed1a14..8fad46f662 100644 --- a/packages/profile-metrics-controller/src/utils/canonicalize.ts +++ b/packages/profile-metrics-controller/src/utils/canonicalize.ts @@ -2,11 +2,20 @@ import { toChecksumHexAddress } from '@metamask/controller-utils'; import { KnownCaipNamespace } from '@metamask/utils'; /** - * Bech32 / bech32m human-readable parts that we lowercase per the auth API - * canonicalization rules. Only Bitcoin mainnet (`bc1…`) is in scope; other - * networks are intentionally unhandled until the wallet supports them. + * 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_MAINNET_PREFIX = 'bc1'; +const BECH32_BITCOIN_ADDRESS_PREFIXES = ['bc1', 'tb1', 'bcrt1'] as const; /** * Thrown when {@link canonicalizeAddress} is given a namespace it does @@ -30,8 +39,9 @@ export class ProofUnsupportedNamespaceError extends Error { * - `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 (`bc1…`) must be all-lowercase; - * legacy P2PKH addresses (starting with `1`) are accepted as-is. + * - `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. @@ -51,7 +61,11 @@ export function canonicalizeAddress( return address; case KnownCaipNamespace.Bip122: { const lowercased = address.toLowerCase(); - if (lowercased.startsWith(BECH32_BITCOIN_MAINNET_PREFIX)) { + if ( + BECH32_BITCOIN_ADDRESS_PREFIXES.some((prefix) => + lowercased.startsWith(prefix), + ) + ) { return lowercased; } return address;