From 036ea4ba022b0256cd621c82fd2554b694ca9bdf Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 5 Jun 2026 13:01:32 +0200 Subject: [PATCH 1/6] feat(profile-metrics-service): add proof signing system --- eslint-suppressions.json | 7 +- .../profile-metrics-controller/CHANGELOG.md | 9 + .../profile-metrics-controller/package.json | 6 +- .../src/ProfileMetricsController.ts | 4 +- .../src/ProfileMetricsService.ts | 2 +- ...fOfOwnershipService-method-action-types.ts | 32 ++ .../src/ProofOfOwnershipService.test.ts | 431 ++++++++++++++++++ .../src/ProofOfOwnershipService.ts | 281 ++++++++++++ .../profile-metrics-controller/src/index.ts | 18 +- yarn.lock | 127 ++++++ 10 files changed, 902 insertions(+), 15 deletions(-) create mode 100644 packages/profile-metrics-controller/src/ProofOfOwnershipService-method-action-types.ts create mode 100644 packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts create mode 100644 packages/profile-metrics-controller/src/ProofOfOwnershipService.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index eedb60a53b..e6d2a3e6ed 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1669,11 +1669,6 @@ "count": 1 } }, - "packages/profile-metrics-controller/src/index.ts": { - "no-restricted-syntax": { - "count": 2 - } - }, "packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 5 @@ -2330,4 +2325,4 @@ "count": 10 } } -} +} \ No newline at end of file diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index eee1fe502f..8cccf5cd8d 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -9,10 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `ProofOfOwnershipService` for signing chain-native proofs of account ownership. + - Exposes `ProofOfOwnershipService:sign({ account, nonce })`, dispatching by the CAIP-2 namespace of the account's first scope. + - EVM accounts are signed via `KeyringController:signPersonalMessage` (EIP-191); Solana, Tron, and Bitcoin accounts are signed via `SnapController:handleRequest` with the `onClientRequest` handler against the snap declared in `account.metadata.snap.id`, which keeps the request silent and client-internal. + - The non-EVM snap is expected to implement a `signProofOfOwnership` JSON-RPC method that validates the message prefix `metamask:proof-of-ownership:` before signing. - 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`. +### Changed + +- The package-level `serviceName` export is now exported as `profileMetricsServiceName` to disambiguate it from the new `proofOfOwnershipServiceName` export. +- The `*MethodActions` aggregate types (`ProfileMetricsServiceMethodActions`, `ProfileMetricsControllerMethodActions`) are no longer re-exported from the package index. Consumers should depend on the `*Actions` umbrella types instead, which expose the same set of actions. + ## [3.1.6] ### Changed diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index f2472a07f2..ce065e38bb 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -60,16 +60,20 @@ "@metamask/messenger": "^1.2.0", "@metamask/polling-controller": "^16.0.6", "@metamask/profile-sync-controller": "^28.1.1", + "@metamask/snaps-controllers": "^20.0.6", + "@metamask/snaps-sdk": "^11.1.1", "@metamask/superstruct": "^3.1.0", "@metamask/transaction-controller": "^66.0.1", "@metamask/utils": "^11.9.0", - "async-mutex": "^0.5.0" + "async-mutex": "^0.5.0", + "uuid": "^14.0.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@metamask/keyring-internal-api": "^11.0.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", + "@types/uuid": "^11.0.0", "deepmerge": "^4.2.2", "jest": "^29.7.0", "nock": "^13.3.1", diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.ts index fb3527f57f..c18872284b 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.ts @@ -19,9 +19,9 @@ import { TransactionControllerTransactionSubmittedEvent } from '@metamask/transa import { Duration, inMilliseconds } from '@metamask/utils'; import { Mutex } from 'async-mutex'; -import type { ProfileMetricsServiceMethodActions } from '.'; -import type { ProfileMetricsControllerMethodActions } from '.'; +import type { ProfileMetricsControllerMethodActions } from './ProfileMetricsController-method-action-types'; import type { AccountWithScopes } from './ProfileMetricsService'; +import type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; /** * The name of the {@link ProfileMetricsController}, used to namespace the diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.ts index e9a7b60852..677a0a4f7d 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.ts @@ -14,7 +14,7 @@ import { } from '@metamask/superstruct'; import type { IDisposable } from 'cockatiel'; -import type { ProfileMetricsServiceMethodActions } from '.'; +import type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; /** * The shape of an entry in the `POST /api/v2/nonce/batch` response body. diff --git a/packages/profile-metrics-controller/src/ProofOfOwnershipService-method-action-types.ts b/packages/profile-metrics-controller/src/ProofOfOwnershipService-method-action-types.ts new file mode 100644 index 0000000000..28436e645f --- /dev/null +++ b/packages/profile-metrics-controller/src/ProofOfOwnershipService-method-action-types.ts @@ -0,0 +1,32 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ProofOfOwnershipService } from './ProofOfOwnershipService'; + +/** + * Sign a proof of ownership for the given account and server-issued nonce. + * + * The returned proof is shaped to drop directly into + * `AccountWithScopes.proof` for `ProfileMetricsService:submitMetrics`. + * + * @param data - The account to prove ownership of and the nonce to bind + * the proof to. + * @returns The proof of ownership (nonce echo + signature). + * @throws {ProofUnsupportedNamespaceError} if the account's first scope + * carries a namespace this service does not know how to sign for, or if + * the account has no scopes. + * @throws if the underlying signer (keyring or snap) rejects, or if the + * snap returns a malformed response. + */ +export type ProofOfOwnershipServiceSignAction = { + type: `ProofOfOwnershipService:sign`; + handler: ProofOfOwnershipService['sign']; +}; + +/** + * Union of all ProofOfOwnershipService action types. + */ +export type ProofOfOwnershipServiceMethodActions = + ProofOfOwnershipServiceSignAction; diff --git a/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts b/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts new file mode 100644 index 0000000000..f98c950313 --- /dev/null +++ b/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts @@ -0,0 +1,431 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { ProofOfOwnershipService } from '.'; +import type { ProofOfOwnershipServiceMessenger } from '.'; +import { SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD } from './ProofOfOwnershipService'; +import { ProofUnsupportedNamespaceError } from './utils/canonicalize'; + +/** + * Creates a mock InternalAccount with the given scopes. EVM accounts are + * built with no `metadata.snap`; non-EVM accounts get a snap entry so the + * service can route to `SnapController:handleRequest`. + * + * @param overrides - Partial overrides for the account fields. Provide + * `scopes` to control namespace dispatch, `address` to control the canonical + * address used in the signed message, and `metadata.snap.id` to override + * the default `'npm:@metamask/test-wallet-snap'`. + * @returns A mock InternalAccount. + */ +function createMockAccount( + overrides: Partial = {}, +): InternalAccount { + const scopes = overrides.scopes ?? ['eip155:1']; + const isEvm = scopes[0]?.startsWith('eip155'); + const baseMetadata = { + keyring: { type: 'Test Keyring' }, + name: 'Mock Account', + importTime: 0, + }; + const metadata = isEvm + ? baseMetadata + : { + ...baseMetadata, + snap: { + id: 'npm:@metamask/test-wallet-snap', + name: 'Test Wallet Snap', + enabled: true, + }, + }; + + return { + id: 'mock-account-id', + address: '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed', + type: 'eip155:eoa', + options: {}, + methods: [], + scopes, + ...overrides, + // When the caller passes `metadata`, take it verbatim (full replacement) + // so tests can express "no snap" / "snap-with-no-id" / etc. without + // having to fight a merge. + metadata: overrides.metadata ?? metadata, + } as InternalAccount; +} + +describe('ProofOfOwnershipService', () => { + describe('constructor', () => { + it('registers the sign messenger action on construction', async () => { + const { rootMessenger } = getService(); + + // The service is reachable only if the constructor registered the + // `sign` action on its messenger; if it hadn't, this call would + // throw "Action handler not found" before any of the test stubs ran. + const proof = await rootMessenger.call('ProofOfOwnershipService:sign', { + account: createMockAccount(), + nonce: 'n0', + }); + + expect(proof).toStrictEqual({ + nonce: 'n0', + signature: '0xdefaultsig', + }); + }); + }); + + describe('eip155 dispatch', () => { + it('routes EVM accounts through KeyringController:signPersonalMessage with the canonical message', async () => { + const signPersonalMessage = jest + .fn, [{ data: string; from: string }]>() + .mockResolvedValue('0xevmsignature'); + const { rootMessenger } = getService({ signPersonalMessage }); + + // Lowercase address on input — service must canonicalize to EIP-55 in + // the signed message even when the keyring is asked to sign as the + // raw account address. + const account = createMockAccount({ + address: '0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed', + scopes: ['eip155:1'], + }); + + const proof = await rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'abc123', + }); + + expect(signPersonalMessage).toHaveBeenCalledTimes(1); + expect(signPersonalMessage).toHaveBeenCalledWith({ + data: 'metamask:proof-of-ownership:abc123:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed', + from: '0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed', + }); + expect(proof).toStrictEqual({ + nonce: 'abc123', + signature: '0xevmsignature', + }); + }); + + it('surfaces errors thrown by the keyring without wrapping them', async () => { + const signPersonalMessage = jest + .fn, [{ data: string; from: string }]>() + .mockRejectedValue(new Error('keyring locked')); + const { rootMessenger } = getService({ signPersonalMessage }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account: createMockAccount(), + nonce: 'n', + }), + ).rejects.toThrow('keyring locked'); + }); + }); + + describe('snap dispatch', () => { + it.each([ + { + namespace: 'solana', + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }, + { + namespace: 'tron', + scope: 'tron:0x2b6653dc', + address: 'TRX9Yg4yFqyKBcXBSc1nKMpHsfYVgKvN3p', + }, + { + namespace: 'bip122', + scope: 'bip122:000000000019d6689c085ae165831e93', + address: 'BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4', + }, + ])( + 'routes $namespace accounts through SnapController:handleRequest with the canonical message', + async ({ address, scope }) => { + const snapHandle = jest + .fn, [unknown]>() + .mockResolvedValue({ signature: '0xsnapsig' }); + const { rootMessenger } = getService({ + snapHandle, + }); + const account = createMockAccount({ + id: 'snap-account-id', + address, + scopes: [scope as `${string}:${string}`], + }); + + const proof = await rootMessenger.call( + 'ProofOfOwnershipService:sign', + { account, nonce: 'n42' }, + ); + + expect(snapHandle).toHaveBeenCalledTimes(1); + const [request] = snapHandle.mock.calls[0] as [ + { + snapId: string; + origin: string; + handler: string; + request: { + jsonrpc: string; + method: string; + params: { accountId: string; message: string }; + }; + }, + ]; + expect(request.snapId).toBe('npm:@metamask/test-wallet-snap'); + expect(request.origin).toBe('metamask'); + expect(request.handler).toBe('onClientRequest'); + expect(request.request.jsonrpc).toBe('2.0'); + expect(request.request.method).toBe( + SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD, + ); + expect(request.request.params.accountId).toBe('snap-account-id'); + // The message payload uses the canonical address — bech32 lowercased + // for BIP-122, base58 verbatim for Solana/Tron. + expect(request.request.params.message).toMatch( + /^metamask:proof-of-ownership:n42:/u, + ); + expect(proof).toStrictEqual({ nonce: 'n42', signature: '0xsnapsig' }); + }, + ); + + it('throws a descriptive error when the account has no snap metadata', async () => { + const { rootMessenger } = getService(); + const account = createMockAccount({ + id: 'orphan', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + metadata: { + keyring: { type: 'Test Keyring' }, + name: 'Orphan', + importTime: 0, + }, + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow( + "ProofOfOwnershipService: account 'orphan' has no snap to sign a proof of ownership.", + ); + }); + + it('throws when the snap returns a malformed response (missing signature)', async () => { + const snapHandle = jest + .fn, [unknown]>() + .mockResolvedValue({ notSignature: true }); + const { rootMessenger } = getService({ snapHandle }); + const account = createMockAccount({ + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow( + `ProofOfOwnershipService: snap 'npm:@metamask/test-wallet-snap' returned a malformed response to '${SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD}'.`, + ); + }); + + it('throws when the snap returns a non-string signature', async () => { + const snapHandle = jest + .fn, [unknown]>() + .mockResolvedValue({ signature: 12345 }); + const { rootMessenger } = getService({ snapHandle }); + const account = createMockAccount({ + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow(/returned a malformed response/u); + }); + + it('tolerates additional fields in the snap response (forward-compatible schema)', async () => { + const snapHandle = jest.fn, [unknown]>().mockResolvedValue({ + signature: '0xsnapsig', + publicKey: '0xpub', + algorithm: 'ed25519', + }); + const { rootMessenger } = getService({ snapHandle }); + const account = createMockAccount({ + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }); + + const proof = await rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }); + + expect(proof.signature).toBe('0xsnapsig'); + }); + + it('surfaces errors thrown by the snap without wrapping them', async () => { + const snapHandle = jest + .fn, [unknown]>() + .mockRejectedValue(new Error('snap unavailable')); + const { rootMessenger } = getService({ snapHandle }); + const account = createMockAccount({ + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + address: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow('snap unavailable'); + }); + }); + + describe('namespace handling', () => { + it('throws ProofUnsupportedNamespaceError for unrecognized namespaces', async () => { + const { rootMessenger } = getService(); + const account = createMockAccount({ + scopes: ['cosmos:cosmoshub-4'], + address: 'cosmos1abc', + }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow(ProofUnsupportedNamespaceError); + }); + + it('throws ProofUnsupportedNamespaceError when the account has no scopes', async () => { + const { rootMessenger } = getService(); + const account = createMockAccount({ scopes: [] }); + + await expect( + rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }), + ).rejects.toThrow(ProofUnsupportedNamespaceError); + }); + + it('dispatches based on the first scope when an account carries multiple scopes from the same namespace', async () => { + const signPersonalMessage = jest + .fn, [{ data: string; from: string }]>() + .mockResolvedValue('0xsig'); + const { rootMessenger } = getService({ signPersonalMessage }); + + // Realistic case: multi-chain EVM account scoped to mainnet + polygon. + const account = createMockAccount({ + scopes: ['eip155:1', 'eip155:137'], + }); + + await rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n', + }); + + expect(signPersonalMessage).toHaveBeenCalledTimes(1); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the messenger populated with all external actions and events + * required by the service under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test, delegating the two + * external actions it needs. + * + * @param rootMessenger - The root messenger, with all external actions and + * events registered. + * @returns The service-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): ProofOfOwnershipServiceMessenger { + const serviceMessenger: ProofOfOwnershipServiceMessenger = new Messenger({ + namespace: 'ProofOfOwnershipService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger: serviceMessenger, + actions: [ + 'KeyringController:signPersonalMessage', + 'SnapController:handleRequest', + ], + }); + return serviceMessenger; +} + +/** + * Constructs the service under test with the two external handlers wired up + * (defaulting to inert stubs that can be overridden per test). + * + * @param args - Optional handler overrides. + * @param args.signPersonalMessage - Stub for + * `KeyringController:signPersonalMessage`. Defaults to a stub that resolves + * to `'0xdefaultsig'`. + * @param args.snapHandle - Stub for `SnapController:handleRequest`. Defaults + * to a stub that resolves to `{ signature: '0xdefaultsnapsig' }`. + * @returns The new service, the root messenger, and the service messenger. + */ +function getService({ + signPersonalMessage, + snapHandle, +}: { + signPersonalMessage?: (args: { + data: string; + from: string; + }) => Promise; + snapHandle?: (args: unknown) => Promise; +} = {}): { + service: ProofOfOwnershipService; + rootMessenger: RootMessenger; + messenger: ProofOfOwnershipServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + signPersonalMessage ?? (async (): Promise => '0xdefaultsig'), + ); + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + (snapHandle ?? + (async (): Promise<{ signature: string }> => ({ + signature: '0xdefaultsnapsig', + }))) as never, + ); + + const messenger = getMessenger(rootMessenger); + const service = new ProofOfOwnershipService({ messenger }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts b/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts new file mode 100644 index 0000000000..9f5ce09136 --- /dev/null +++ b/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts @@ -0,0 +1,281 @@ +import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { string, type as structType } from '@metamask/superstruct'; +import { KnownCaipNamespace, parseCaipChainId } from '@metamask/utils'; +import { v4 as uuid } from 'uuid'; + +import type { AccountOwnershipProof } from './ProfileMetricsService'; +import type { ProofOfOwnershipServiceMethodActions } from './ProofOfOwnershipService-method-action-types'; +import { + canonicalizeAddress, + ProofUnsupportedNamespaceError, +} from './utils/canonicalize'; + +// === GENERAL === + +/** + * The name of the {@link ProofOfOwnershipService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'ProofOfOwnershipService'; + +/** + * The shape of the request object for signing a proof of ownership for a + * single account. + */ +export type ProofOfOwnershipSignRequest = { + /** + * The account to sign the proof for. Provides the address, the scopes used + * to infer the signing namespace, and (for non-EVM) the snap ID + account + * ID needed by `SnapController:handleRequest`. + */ + account: InternalAccount; + /** + * A single-use nonce minted by the auth API (see + * `ProfileMetricsService:fetchNonces`). Consumed server-side on + * verification; replay is not possible. + */ + nonce: string; +}; + +/** + * The JSON-RPC method name exposed by non-EVM wallet snaps for silent + * proof-of-ownership signing. Each supported snap (Bitcoin, Solana, Tron) + * implements this method under the `onClientRequest` handler and is expected + * to validate that the signed message begins with + * `metamask:proof-of-ownership:`. + */ +export const SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD = 'signProofOfOwnership'; + +/** + * The shape of a successful response from a non-EVM wallet snap's + * {@link SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD} handler. Validated at runtime; + * declared with `type()` (not `object()`) so additive snap-side schema + * changes do not break the client. + */ +const SnapSignProofResponseStruct = structType({ + signature: string(), +}); + +/** + * Builds the canonical message string that all chains sign for a proof of + * ownership: `metamask:proof-of-ownership::`. + * Defined per the auth API spec — kept inline so the contract is visible + * next to the dispatch logic that uses it. + * + * @param nonce - The single-use nonce from the auth API. + * @param canonicalAddress - The account address in its CAIP-namespace + * canonical form (see {@link canonicalizeAddress}). + * @returns The message string to sign. + */ +function buildProofMessage(nonce: string, canonicalAddress: string): string { + return `metamask:proof-of-ownership:${nonce}:${canonicalAddress}`; +} + +/** + * Extracts the CAIP-2 namespace from the first scope of an account. All + * scopes on a single account are expected to share a namespace (e.g. an + * `eip155` account may carry many `eip155:` scopes but never a + * `solana:<…>` one). + * + * @param account - The internal account to inspect. + * @returns The namespace portion of the first scope. + * @throws {ProofUnsupportedNamespaceError} if the account has no scopes. + */ +function getAccountNamespace(account: InternalAccount): string { + const [firstScope] = account.scopes; + if (!firstScope) { + throw new ProofUnsupportedNamespaceError(''); + } + // `parseCaipChainId` validates the `:` shape. + return parseCaipChainId(firstScope).namespace; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = ['sign'] as const; + +/** + * Actions that {@link ProofOfOwnershipService} exposes to other consumers. + */ +export type ProofOfOwnershipServiceActions = + ProofOfOwnershipServiceMethodActions; + +/** + * Actions from other messengers that {@link ProofOfOwnershipService} calls. + */ +type AllowedActions = + | KeyringControllerSignPersonalMessageAction + | SnapControllerHandleRequestAction; + +/** + * Events that {@link ProofOfOwnershipService} exposes to other consumers. + */ +export type ProofOfOwnershipServiceEvents = never; + +/** + * Events from other messengers that {@link ProofOfOwnershipService} + * subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link ProofOfOwnershipService}. + */ +export type ProofOfOwnershipServiceMessenger = Messenger< + typeof serviceName, + ProofOfOwnershipServiceActions | AllowedActions, + ProofOfOwnershipServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * A service that produces chain-native cryptographic proofs that the wallet + * controls a given account address. + * + * Dispatches by the CAIP-2 namespace of the account's first scope: + * + * - `eip155` → `KeyringController:signPersonalMessage` (EIP-191). + * - `solana`, `tron`, `bip122` → `SnapController:handleRequest` with the + * `onClientRequest` handler against the wallet snap declared in + * `account.metadata.snap.id`. Routed this way (rather than through the + * keyring) so the request is silent and client-internal: no user prompt, + * no dapp-facing exposure. The snap is expected to implement + * {@link SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD} and to validate the message + * prefix before signing. + * + * Any other namespace yields {@link ProofUnsupportedNamespaceError}, which + * callers in the polling pipeline catch to submit the account without a + * proof (the auth API treats `proof` as optional). + */ +export class ProofOfOwnershipService { + /** + * The name of the service. + */ + readonly name: typeof serviceName; + + /** + * The messenger suited for this service. + */ + readonly #messenger: ProofOfOwnershipServiceMessenger; + + /** + * Constructs a new ProofOfOwnershipService object. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + */ + constructor({ messenger }: { messenger: ProofOfOwnershipServiceMessenger }) { + this.name = serviceName; + this.#messenger = messenger; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Sign a proof of ownership for the given account and server-issued nonce. + * + * The returned proof is shaped to drop directly into + * `AccountWithScopes.proof` for `ProfileMetricsService:submitMetrics`. + * + * @param data - The account to prove ownership of and the nonce to bind + * the proof to. + * @returns The proof of ownership (nonce echo + signature). + * @throws {ProofUnsupportedNamespaceError} if the account's first scope + * carries a namespace this service does not know how to sign for, or if + * the account has no scopes. + * @throws if the underlying signer (keyring or snap) rejects, or if the + * snap returns a malformed response. + */ + async sign(data: ProofOfOwnershipSignRequest): Promise { + const { account, nonce } = data; + const namespace = getAccountNamespace(account); + const canonicalAddress = canonicalizeAddress(account.address, namespace); + const message = buildProofMessage(nonce, canonicalAddress); + + let signature: string; + if (namespace === KnownCaipNamespace.Eip155) { + signature = await this.#signEvm(account.address, message); + } else { + // canonicalizeAddress() already threw for any unsupported namespace, + // so anything reaching here is one of solana / tron / bip122. + signature = await this.#signViaSnap(account, message); + } + + return { nonce, signature }; + } + + /** + * Sign an EIP-191 personal message via the keyring controller. + * + * @param address - The EVM address to sign with (must belong to an + * unlocked keyring). + * @param message - The proof message to sign. + * @returns The 0x-prefixed signature. + */ + async #signEvm(address: string, message: string): Promise { + return await this.#messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + ); + } + + /** + * Sign a proof message via the wallet snap declared on the account's + * metadata. Uses the `onClientRequest` handler with `origin: 'metamask'`, + * which is what makes the request silent (no user prompt, no + * dapp-facing exposure). + * + * @param account - The internal account; must carry a non-empty + * `metadata.snap.id`. + * @param message - The proof message to sign. + * @returns The signature string as returned by the snap. + * @throws if the snap response does not match + * {@link SnapSignProofResponseStruct}. + */ + async #signViaSnap( + account: InternalAccount, + message: string, + ): Promise { + const snapId = account.metadata.snap?.id; + if (!snapId) { + throw new Error( + `ProofOfOwnershipService: account '${account.id}' has no snap to sign a proof of ownership.`, + ); + } + + const response: unknown = await this.#messenger.call( + 'SnapController:handleRequest', + { + snapId: snapId as SnapId, + origin: 'metamask', + handler: 'onClientRequest' as never, + request: { + id: uuid(), + jsonrpc: '2.0', + method: SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD, + params: { + accountId: account.id, + message, + }, + }, + }, + ); + + if (!SnapSignProofResponseStruct.is(response)) { + throw new Error( + `ProofOfOwnershipService: snap '${snapId}' returned a malformed response to '${SNAP_SIGN_PROOF_OF_OWNERSHIP_METHOD}'.`, + ); + } + + return response.signature; + } +} diff --git a/packages/profile-metrics-controller/src/index.ts b/packages/profile-metrics-controller/src/index.ts index a8a928733b..099fdc36fd 100644 --- a/packages/profile-metrics-controller/src/index.ts +++ b/packages/profile-metrics-controller/src/index.ts @@ -16,9 +16,17 @@ export type { ProfileMetricsServiceMessenger, ProfileMetricsSubmitMetricsRequest, } from './ProfileMetricsService'; -export { ProfileMetricsService, serviceName } from './ProfileMetricsService'; -export type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; +export { + ProfileMetricsService, + serviceName as profileMetricsServiceName, +} from './ProfileMetricsService'; export type { - ProfileMetricsControllerMethodActions, - ProfileMetricsControllerSkipInitialDelayAction, -} from './ProfileMetricsController-method-action-types'; + ProofOfOwnershipServiceActions, + ProofOfOwnershipServiceEvents, + ProofOfOwnershipServiceMessenger, +} from './ProofOfOwnershipService'; +export { + ProofOfOwnershipService, + serviceName as proofOfOwnershipServiceName, +} from './ProofOfOwnershipService'; +export type { ProfileMetricsControllerSkipInitialDelayAction } from './ProfileMetricsController-method-action-types'; diff --git a/yarn.lock b/yarn.lock index 4d08c1d875..eefc7d154b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8018,11 +8018,14 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.6" "@metamask/profile-sync-controller": "npm:^28.1.1" + "@metamask/snaps-controllers": "npm:^20.0.6" + "@metamask/snaps-sdk": "npm:^11.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" "@types/jest": "npm:^29.5.14" + "@types/uuid": "npm:^11.0.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" @@ -8032,6 +8035,7 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + uuid: "npm:^14.0.0" languageName: unknown linkType: soft @@ -8443,6 +8447,49 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-controllers@npm:^20.0.6": + version: 20.0.6 + resolution: "@metamask/snaps-controllers@npm:20.0.6" + dependencies: + "@metamask/approval-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/json-rpc-engine": "npm:^10.5.0" + "@metamask/json-rpc-middleware-stream": "npm:^8.0.8" + "@metamask/key-tree": "npm:^10.1.1" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/object-multiplex": "npm:^2.1.0" + "@metamask/permission-controller": "npm:^13.1.1" + "@metamask/post-message-stream": "npm:^10.0.0" + "@metamask/rpc-errors": "npm:^7.0.3" + "@metamask/snaps-registry": "npm:^4.0.0" + "@metamask/snaps-rpc-methods": "npm:^17.0.0" + "@metamask/snaps-sdk": "npm:^11.1.1" + "@metamask/snaps-utils": "npm:^12.2.1" + "@metamask/storage-service": "npm:^1.0.1" + "@metamask/superstruct": "npm:^3.2.1" + "@metamask/utils": "npm:^11.11.0" + "@xstate/fsm": "npm:^2.0.0" + async-mutex: "npm:^0.5.0" + concat-stream: "npm:^2.0.0" + cron-parser: "npm:^4.5.0" + fast-deep-equal: "npm:^3.1.3" + get-npm-tarball-url: "npm:^2.0.3" + immer: "npm:^9.0.21" + luxon: "npm:^3.5.0" + nanoid: "npm:^3.3.10" + readable-stream: "npm:^3.6.2" + readable-web-to-node-stream: "npm:^3.0.2" + semver: "npm:^7.5.4" + tar-stream: "npm:^3.1.7" + peerDependencies: + "@metamask/snaps-execution-environments": ^11.1.1 + peerDependenciesMeta: + "@metamask/snaps-execution-environments": + optional: true + checksum: 10/245e7c935be8934a4e590fc48e781965195592d587b1114c075e3b68820cc762c2a60881c30bfa74d1d5040c4e29a591eaaec3d63f0443473694f83831cdfe7a + languageName: node + linkType: hard + "@metamask/snaps-registry@npm:^4.0.0": version: 4.0.0 resolution: "@metamask/snaps-registry@npm:4.0.0" @@ -8472,6 +8519,23 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-rpc-methods@npm:^17.0.0": + version: 17.0.0 + resolution: "@metamask/snaps-rpc-methods@npm:17.0.0" + dependencies: + "@metamask/key-tree": "npm:^10.1.1" + "@metamask/permission-controller": "npm:^13.1.1" + "@metamask/rpc-errors": "npm:^7.0.3" + "@metamask/snaps-sdk": "npm:^11.1.1" + "@metamask/snaps-utils": "npm:^12.2.1" + "@metamask/superstruct": "npm:^3.2.1" + "@metamask/utils": "npm:^11.11.0" + "@noble/hashes": "npm:^1.7.1" + async-mutex: "npm:^0.5.0" + checksum: 10/731696bf6118a7ae1d75776fcb6948a6ee1b3659a1871dced2b78dbedd349c2bba6a3f767ad53b2af3e7bf04ec46798392a4463d316a68d30766c90129410480 + languageName: node + linkType: hard + "@metamask/snaps-sdk@npm:^11.0.0, @metamask/snaps-sdk@npm:^11.1.0": version: 11.1.0 resolution: "@metamask/snaps-sdk@npm:11.1.0" @@ -8486,6 +8550,20 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-sdk@npm:^11.1.1": + version: 11.1.1 + resolution: "@metamask/snaps-sdk@npm:11.1.1" + dependencies: + "@metamask/key-tree": "npm:^10.1.1" + "@metamask/providers": "npm:^22.1.1" + "@metamask/rpc-errors": "npm:^7.0.3" + "@metamask/superstruct": "npm:^3.2.1" + "@metamask/utils": "npm:^11.11.0" + luxon: "npm:^3.5.0" + checksum: 10/8419625775b83045d98c4ed920002ad884471e2303f6650104c97457c238f095ff35030663ddf111ebb6ce8f4cd915f57b7b9a5597d00288093701dbb79e787c + languageName: node + linkType: hard + "@metamask/snaps-utils@npm:^12.1.2, @metamask/snaps-utils@npm:^12.1.3, @metamask/snaps-utils@npm:^12.2.0": version: 12.2.0 resolution: "@metamask/snaps-utils@npm:12.2.0" @@ -8517,6 +8595,37 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-utils@npm:^12.2.1": + version: 12.2.1 + resolution: "@metamask/snaps-utils@npm:12.2.1" + dependencies: + "@babel/core": "npm:^7.23.2" + "@babel/types": "npm:^7.23.0" + "@metamask/key-tree": "npm:^10.1.1" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/permission-controller": "npm:^13.1.1" + "@metamask/rpc-errors": "npm:^7.0.3" + "@metamask/slip44": "npm:^4.4.0" + "@metamask/snaps-registry": "npm:^4.0.0" + "@metamask/snaps-sdk": "npm:^11.1.1" + "@metamask/superstruct": "npm:^3.2.1" + "@metamask/utils": "npm:^11.11.0" + "@scure/base": "npm:^1.1.1" + chalk: "npm:^4.1.2" + cron-parser: "npm:^4.5.0" + fast-deep-equal: "npm:^3.1.3" + fast-json-stable-stringify: "npm:^2.1.0" + fast-xml-parser: "npm:^5.5.6" + luxon: "npm:^3.5.0" + marked: "npm:^12.0.1" + rfdc: "npm:^1.3.0" + semver: "npm:^7.5.4" + ses: "npm:^1.15.0" + validate-npm-package-name: "npm:^5.0.0" + checksum: 10/fda8caefa414d9755a2305b345b5bbbf30ee614215a49070449f314d5d906c645457cf0d7f4881a82f69d398aff5ad2fe9c648bbb2a5003c29c2d01e2f42f199 + languageName: node + linkType: hard + "@metamask/social-controllers@workspace:packages/social-controllers": version: 0.0.0-use.local resolution: "@metamask/social-controllers@workspace:packages/social-controllers" @@ -11043,6 +11152,15 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^11.0.0": + version: 11.0.0 + resolution: "@types/uuid@npm:11.0.0" + dependencies: + uuid: "npm:*" + checksum: 10/9f94bd34e5d220c53cc58ea9f48a0061d3bc343e29bc33a17edc705f5e21fedda21553318151f2bc227c2b2b03727bbb536da2b82a61f84d2e1ca38abc5e5c3f + languageName: node + linkType: hard + "@types/uuid@npm:^8.3.0": version: 8.3.4 resolution: "@types/uuid@npm:8.3.4" @@ -24769,6 +24887,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:*, uuid@npm:^14.0.0": + version: 14.0.0 + resolution: "uuid@npm:14.0.0" + bin: + uuid: dist-node/bin/uuid + checksum: 10/8ee9b98f9650e25555515f7a28d3c3ae9364e72f7bb19b9e08b681bc135338beba5509b2830f6ae1cfaba4d45401da0d16d4d109b977097bc3d6ba0c5583341b + languageName: node + linkType: hard + "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" From 2d6dec6b7e48b4569a16d2e2b11207b7619a0608 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 5 Jun 2026 13:13:56 +0200 Subject: [PATCH 2/6] fix: update CHANGELOG --- packages/profile-metrics-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index 8cccf5cd8d..da14193382 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -9,10 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `ProofOfOwnershipService` for signing chain-native proofs of account ownership. +- Add `ProofOfOwnershipService` for signing chain-native proofs of account ownership ([#9016](https://github.com/MetaMask/core/pull/9016)) - Exposes `ProofOfOwnershipService:sign({ account, nonce })`, dispatching by the CAIP-2 namespace of the account's first scope. - EVM accounts are signed via `KeyringController:signPersonalMessage` (EIP-191); Solana, Tron, and Bitcoin accounts are signed via `SnapController:handleRequest` with the `onClientRequest` handler against the snap declared in `account.metadata.snap.id`, which keeps the request silent and client-internal. - - The non-EVM snap is expected to implement a `signProofOfOwnership` JSON-RPC method that validates the message prefix `metamask:proof-of-ownership:` before signing. + - The non-EVM snaps are expected to implement a `signProofOfOwnership` JSON-RPC method that validates the message prefix `metamask:proof-of-ownership:` before signing. - 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`. From 23aa686a287fdecd3224bd916157432743aa2e13 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 5 Jun 2026 14:05:41 +0200 Subject: [PATCH 3/6] fix: CI issues --- .../profile-metrics-controller/package.json | 8 +- .../src/ProofOfOwnershipService.ts | 3 +- yarn.lock | 133 +----------------- 3 files changed, 11 insertions(+), 133 deletions(-) diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index ce065e38bb..11626b6699 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -60,20 +60,20 @@ "@metamask/messenger": "^1.2.0", "@metamask/polling-controller": "^16.0.6", "@metamask/profile-sync-controller": "^28.1.1", - "@metamask/snaps-controllers": "^20.0.6", - "@metamask/snaps-sdk": "^11.1.1", + "@metamask/snaps-controllers": "^19.0.0", + "@metamask/snaps-sdk": "^11.0.0", + "@metamask/snaps-utils": "^12.1.2", "@metamask/superstruct": "^3.1.0", "@metamask/transaction-controller": "^66.0.1", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0", - "uuid": "^14.0.0" + "uuid": "^8.3.2" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@metamask/keyring-internal-api": "^11.0.1", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", - "@types/uuid": "^11.0.0", "deepmerge": "^4.2.2", "jest": "^29.7.0", "nock": "^13.3.1", diff --git a/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts b/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts index 9f5ce09136..0e27deb01d 100644 --- a/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts +++ b/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts @@ -3,6 +3,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; import { string, type as structType } from '@metamask/superstruct'; import { KnownCaipNamespace, parseCaipChainId } from '@metamask/utils'; import { v4 as uuid } from 'uuid'; @@ -257,7 +258,7 @@ export class ProofOfOwnershipService { { snapId: snapId as SnapId, origin: 'metamask', - handler: 'onClientRequest' as never, + handler: HandlerType.OnClientRequest, request: { id: uuid(), jsonrpc: '2.0', diff --git a/yarn.lock b/yarn.lock index eefc7d154b..7fee06a2de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8018,14 +8018,14 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.6" "@metamask/profile-sync-controller": "npm:^28.1.1" - "@metamask/snaps-controllers": "npm:^20.0.6" - "@metamask/snaps-sdk": "npm:^11.1.1" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-sdk": "npm:^11.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" "@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" "@types/jest": "npm:^29.5.14" - "@types/uuid": "npm:^11.0.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" @@ -8035,7 +8035,7 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - uuid: "npm:^14.0.0" + uuid: "npm:^8.3.2" languageName: unknown linkType: soft @@ -8447,49 +8447,6 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^20.0.6": - version: 20.0.6 - resolution: "@metamask/snaps-controllers@npm:20.0.6" - dependencies: - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/json-rpc-engine": "npm:^10.5.0" - "@metamask/json-rpc-middleware-stream": "npm:^8.0.8" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/messenger": "npm:^1.2.0" - "@metamask/object-multiplex": "npm:^2.1.0" - "@metamask/permission-controller": "npm:^13.1.1" - "@metamask/post-message-stream": "npm:^10.0.0" - "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-registry": "npm:^4.0.0" - "@metamask/snaps-rpc-methods": "npm:^17.0.0" - "@metamask/snaps-sdk": "npm:^11.1.1" - "@metamask/snaps-utils": "npm:^12.2.1" - "@metamask/storage-service": "npm:^1.0.1" - "@metamask/superstruct": "npm:^3.2.1" - "@metamask/utils": "npm:^11.11.0" - "@xstate/fsm": "npm:^2.0.0" - async-mutex: "npm:^0.5.0" - concat-stream: "npm:^2.0.0" - cron-parser: "npm:^4.5.0" - fast-deep-equal: "npm:^3.1.3" - get-npm-tarball-url: "npm:^2.0.3" - immer: "npm:^9.0.21" - luxon: "npm:^3.5.0" - nanoid: "npm:^3.3.10" - readable-stream: "npm:^3.6.2" - readable-web-to-node-stream: "npm:^3.0.2" - semver: "npm:^7.5.4" - tar-stream: "npm:^3.1.7" - peerDependencies: - "@metamask/snaps-execution-environments": ^11.1.1 - peerDependenciesMeta: - "@metamask/snaps-execution-environments": - optional: true - checksum: 10/245e7c935be8934a4e590fc48e781965195592d587b1114c075e3b68820cc762c2a60881c30bfa74d1d5040c4e29a591eaaec3d63f0443473694f83831cdfe7a - languageName: node - linkType: hard - "@metamask/snaps-registry@npm:^4.0.0": version: 4.0.0 resolution: "@metamask/snaps-registry@npm:4.0.0" @@ -8519,38 +8476,7 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-rpc-methods@npm:^17.0.0": - version: 17.0.0 - resolution: "@metamask/snaps-rpc-methods@npm:17.0.0" - dependencies: - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/permission-controller": "npm:^13.1.1" - "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-sdk": "npm:^11.1.1" - "@metamask/snaps-utils": "npm:^12.2.1" - "@metamask/superstruct": "npm:^3.2.1" - "@metamask/utils": "npm:^11.11.0" - "@noble/hashes": "npm:^1.7.1" - async-mutex: "npm:^0.5.0" - checksum: 10/731696bf6118a7ae1d75776fcb6948a6ee1b3659a1871dced2b78dbedd349c2bba6a3f767ad53b2af3e7bf04ec46798392a4463d316a68d30766c90129410480 - languageName: node - linkType: hard - -"@metamask/snaps-sdk@npm:^11.0.0, @metamask/snaps-sdk@npm:^11.1.0": - version: 11.1.0 - resolution: "@metamask/snaps-sdk@npm:11.1.0" - dependencies: - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/providers": "npm:^22.1.1" - "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/superstruct": "npm:^3.2.1" - "@metamask/utils": "npm:^11.11.0" - luxon: "npm:^3.5.0" - checksum: 10/138c616584d537b9976ae48123090ab5731848d79d5d1f4e979c797dfdfe061329cbf18a5e84d8bd068fe36d5b9d169337f6d74efab0736f30c31ddf4088f70b - languageName: node - linkType: hard - -"@metamask/snaps-sdk@npm:^11.1.1": +"@metamask/snaps-sdk@npm:^11.0.0, @metamask/snaps-sdk@npm:^11.1.0, @metamask/snaps-sdk@npm:^11.1.1": version: 11.1.1 resolution: "@metamask/snaps-sdk@npm:11.1.1" dependencies: @@ -8565,37 +8491,6 @@ __metadata: linkType: hard "@metamask/snaps-utils@npm:^12.1.2, @metamask/snaps-utils@npm:^12.1.3, @metamask/snaps-utils@npm:^12.2.0": - version: 12.2.0 - resolution: "@metamask/snaps-utils@npm:12.2.0" - dependencies: - "@babel/core": "npm:^7.23.2" - "@babel/types": "npm:^7.23.0" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/permission-controller": "npm:^12.3.0" - "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/slip44": "npm:^4.4.0" - "@metamask/snaps-registry": "npm:^4.0.0" - "@metamask/snaps-sdk": "npm:^11.1.0" - "@metamask/superstruct": "npm:^3.2.1" - "@metamask/utils": "npm:^11.11.0" - "@scure/base": "npm:^1.1.1" - chalk: "npm:^4.1.2" - cron-parser: "npm:^4.5.0" - fast-deep-equal: "npm:^3.1.3" - fast-json-stable-stringify: "npm:^2.1.0" - fast-xml-parser: "npm:^5.5.6" - luxon: "npm:^3.5.0" - marked: "npm:^12.0.1" - rfdc: "npm:^1.3.0" - semver: "npm:^7.5.4" - ses: "npm:^1.15.0" - validate-npm-package-name: "npm:^5.0.0" - checksum: 10/0e7cb5a4deebad3dc98404486b7767be049e9f86173195e9b1ae577f197e67cdd392f1e05ebb146a199ffc3cd52013636637a4d1ac7f9fec164f3189be97df55 - languageName: node - linkType: hard - -"@metamask/snaps-utils@npm:^12.2.1": version: 12.2.1 resolution: "@metamask/snaps-utils@npm:12.2.1" dependencies: @@ -11152,15 +11047,6 @@ __metadata: languageName: node linkType: hard -"@types/uuid@npm:^11.0.0": - version: 11.0.0 - resolution: "@types/uuid@npm:11.0.0" - dependencies: - uuid: "npm:*" - checksum: 10/9f94bd34e5d220c53cc58ea9f48a0061d3bc343e29bc33a17edc705f5e21fedda21553318151f2bc227c2b2b03727bbb536da2b82a61f84d2e1ca38abc5e5c3f - languageName: node - linkType: hard - "@types/uuid@npm:^8.3.0": version: 8.3.4 resolution: "@types/uuid@npm:8.3.4" @@ -24887,15 +24773,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:*, uuid@npm:^14.0.0": - version: 14.0.0 - resolution: "uuid@npm:14.0.0" - bin: - uuid: dist-node/bin/uuid - checksum: 10/8ee9b98f9650e25555515f7a28d3c3ae9364e72f7bb19b9e08b681bc135338beba5509b2830f6ae1cfaba4d45401da0d16d4d109b977097bc3d6ba0c5583341b - languageName: node - linkType: hard - "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" From 22843f2b7c3f97412e3b1ae13344ea2606d93371 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 5 Jun 2026 14:10:58 +0200 Subject: [PATCH 4/6] fix: lint --- .../src/ProofOfOwnershipService.test.ts | 20 ++++++++++--------- .../src/ProofOfOwnershipService.ts | 12 ++++++----- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts b/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts index f98c950313..27bbb11018 100644 --- a/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts +++ b/packages/profile-metrics-controller/src/ProofOfOwnershipService.test.ts @@ -156,10 +156,10 @@ describe('ProofOfOwnershipService', () => { scopes: [scope as `${string}:${string}`], }); - const proof = await rootMessenger.call( - 'ProofOfOwnershipService:sign', - { account, nonce: 'n42' }, - ); + const proof = await rootMessenger.call('ProofOfOwnershipService:sign', { + account, + nonce: 'n42', + }); expect(snapHandle).toHaveBeenCalledTimes(1); const [request] = snapHandle.mock.calls[0] as [ @@ -253,11 +253,13 @@ describe('ProofOfOwnershipService', () => { }); it('tolerates additional fields in the snap response (forward-compatible schema)', async () => { - const snapHandle = jest.fn, [unknown]>().mockResolvedValue({ - signature: '0xsnapsig', - publicKey: '0xpub', - algorithm: 'ed25519', - }); + const snapHandle = jest + .fn, [unknown]>() + .mockResolvedValue({ + signature: '0xsnapsig', + publicKey: '0xpub', + algorithm: 'ed25519', + }); const { rootMessenger } = getService({ snapHandle }); const account = createMockAccount({ scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], diff --git a/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts b/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts index 0e27deb01d..f36f9a0075 100644 --- a/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts +++ b/packages/profile-metrics-controller/src/ProofOfOwnershipService.ts @@ -196,7 +196,9 @@ export class ProofOfOwnershipService { * @throws if the underlying signer (keyring or snap) rejects, or if the * snap returns a malformed response. */ - async sign(data: ProofOfOwnershipSignRequest): Promise { + async sign( + data: ProofOfOwnershipSignRequest, + ): Promise { const { account, nonce } = data; const namespace = getAccountNamespace(account); const canonicalAddress = canonicalizeAddress(account.address, namespace); @@ -223,10 +225,10 @@ export class ProofOfOwnershipService { * @returns The 0x-prefixed signature. */ async #signEvm(address: string, message: string): Promise { - return await this.#messenger.call( - 'KeyringController:signPersonalMessage', - { data: message, from: address }, - ); + return await this.#messenger.call('KeyringController:signPersonalMessage', { + data: message, + from: address, + }); } /** From 4d4f6ce85fcc69342012afc5194e410eb548181b Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 5 Jun 2026 14:53:06 +0200 Subject: [PATCH 5/6] fix: formatting --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c523b8e9e7..dde3e608dc 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2320,4 +2320,4 @@ "count": 10 } } -} \ No newline at end of file +} From e376a1c6364311635972697038feeda40ad211bd Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 5 Jun 2026 16:22:07 +0200 Subject: [PATCH 6/6] fix: remove breaking change --- eslint-suppressions.json | 5 +++++ packages/profile-metrics-controller/CHANGELOG.md | 3 +-- packages/profile-metrics-controller/src/index.ts | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index dde3e608dc..4b9a2704a4 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1669,6 +1669,11 @@ "count": 1 } }, + "packages/profile-metrics-controller/src/index.ts": { + "no-restricted-syntax": { + "count": 2 + } + }, "packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 5 diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index 91b201808b..df961af760 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -13,14 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exposes `ProofOfOwnershipService:sign({ account, nonce })`, dispatching by the CAIP-2 namespace of the account's first scope. - EVM accounts are signed via `KeyringController:signPersonalMessage` (EIP-191); Solana, Tron, and Bitcoin accounts are signed via `SnapController:handleRequest` with the `onClientRequest` handler against the snap declared in `account.metadata.snap.id`, which keeps the request silent and client-internal. - The non-EVM snaps are expected to implement a `signProofOfOwnership` JSON-RPC method that validates the message prefix `metamask:proof-of-ownership:` before signing. + - Add `profileMetricsServiceName` alias for the existing `serviceName` export, to disambiguate it from the new `proofOfOwnershipServiceName`. The original `serviceName` export is unchanged. - 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`. ### Changed -- The package-level `serviceName` export is now exported as `profileMetricsServiceName` to disambiguate it from the new `proofOfOwnershipServiceName` export. -- The `*MethodActions` aggregate types (`ProfileMetricsServiceMethodActions`, `ProfileMetricsControllerMethodActions`) are no longer re-exported from the package index. Consumers should depend on the `*Actions` umbrella types instead, which expose the same set of actions. - Bump `@metamask/transaction-controller` from `^66.0.1` to `^67.0.0` ([#9021](https://github.com/MetaMask/core/pull/9021)) ## [3.1.6] diff --git a/packages/profile-metrics-controller/src/index.ts b/packages/profile-metrics-controller/src/index.ts index 099fdc36fd..81c38273fa 100644 --- a/packages/profile-metrics-controller/src/index.ts +++ b/packages/profile-metrics-controller/src/index.ts @@ -18,8 +18,10 @@ export type { } from './ProfileMetricsService'; export { ProfileMetricsService, + serviceName, serviceName as profileMetricsServiceName, } from './ProfileMetricsService'; +export type { ProfileMetricsServiceMethodActions } from './ProfileMetricsService-method-action-types'; export type { ProofOfOwnershipServiceActions, ProofOfOwnershipServiceEvents, @@ -29,4 +31,7 @@ export { ProofOfOwnershipService, serviceName as proofOfOwnershipServiceName, } from './ProofOfOwnershipService'; -export type { ProfileMetricsControllerSkipInitialDelayAction } from './ProfileMetricsController-method-action-types'; +export type { + ProfileMetricsControllerMethodActions, + ProfileMetricsControllerSkipInitialDelayAction, +} from './ProfileMetricsController-method-action-types';