diff --git a/packages/bitcoin-wallet-snap/CHANGELOG.md b/packages/bitcoin-wallet-snap/CHANGELOG.md index e12265e2..1d77e5a4 100644 --- a/packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/packages/bitcoin-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `signProofOfOwnershipBatch` for signing multiple proof-of-ownership messages in one request. ([#266](https://github.com/MetaMask/internal-snaps/pull/266)) - Add back the `endowment:assets` permission for the Bitcoin scopes to the snap manifest, with no-op `onAssetsLookup`, `onAssetsConversion`, `onAssetHistoricalPrice`, and `onAssetsMarketData` entry points required to keep the permission ([#274](https://github.com/MetaMask/internal-snaps/pull/274)) ### Changed diff --git a/packages/bitcoin-wallet-snap/snap.manifest.json b/packages/bitcoin-wallet-snap/snap.manifest.json index 0bc152ed..e2e913fa 100644 --- a/packages/bitcoin-wallet-snap/snap.manifest.json +++ b/packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "Toy0lgo6eUTHc9W2Obj9JZNRm3uEJzkeD2E8NmG+W44=", + "shasum": "3Msbb6pMl2lUUJnwKtW0ONeFu5n1WTvLuNW2Vc2HW0c=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/bitcoin-wallet-snap/src/entities/account.ts b/packages/bitcoin-wallet-snap/src/entities/account.ts index 3d68b4cd..d7d3c31c 100644 --- a/packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/packages/bitcoin-wallet-snap/src/entities/account.ts @@ -274,6 +274,14 @@ export type BitcoinAccountRepository = { */ getAll(): Promise; + /** + * Get accounts by their ids. + * + * @param ids - Account IDs. + * @returns the accounts that exist, in requested order + */ + getByIds(ids: string[]): Promise; + /** * Get an account by its derivation path. * diff --git a/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 46af2613..160cd564 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1252,5 +1252,131 @@ describe('RpcHandler', () => { handler.route(origin, buildRequest(message)), ).rejects.toThrow('signer unavailable'); }); + + describe('signProofOfOwnershipBatch', () => { + const secondAccountId = '8eb1f949-c0cc-4f7d-b0ca-880b17f442b3'; + const secondAccountAddress = 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8'; + const secondBitcoinAccount = mock({ + id: secondAccountId, + publicAddress: { + toString: () => secondAccountAddress, + } as never, + network: 'bitcoin', + }); + + const buildBatchRequest = ( + items: { accountId: string; message: string }[], + ): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: '1', + method: RpcMethod.SignProofOfOwnershipBatch, + params: { items }, + }); + + it('signs a batch and returns signatures in input order', async () => { + const message1 = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + const message2 = `metamask:proof-of-ownership:${nonce}:${secondAccountAddress}`; + mockAccountsUseCases.getByIds.mockResolvedValue([ + mockBitcoinAccount, + secondBitcoinAccount, + ]); + mockAccountsUseCases.signProofOfOwnershipMessages.mockResolvedValue([ + { signature: 'mock-bip322-signature-1' }, + { signature: 'mock-bip322-signature-2' }, + ]); + + const result = await handler.route( + origin, + buildBatchRequest([ + { accountId: validAccountId, message: message1 }, + { accountId: secondAccountId, message: message2 }, + ]), + ); + + expect(mockAccountsUseCases.getByIds).toHaveBeenCalledWith([ + validAccountId, + secondAccountId, + ]); + expect( + mockAccountsUseCases.signProofOfOwnershipMessages, + ).toHaveBeenCalledWith([ + { account: mockBitcoinAccount, message: message1 }, + { account: secondBitcoinAccount, message: message2 }, + ]); + expect(result).toStrictEqual({ + results: [ + { + accountId: validAccountId, + signature: 'mock-bip322-signature-1', + }, + { + accountId: secondAccountId, + signature: 'mock-bip322-signature-2', + }, + ], + }); + }); + + it('returns item-level errors for missing accounts and address mismatches', async () => { + const missingAccountId = '6b3df9d2-07fc-4e08-baf9-769254ab3fc8'; + const validMessage = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + const mismatchedMessage = `metamask:proof-of-ownership:${nonce}:${secondAccountAddress}`; + mockAccountsUseCases.getByIds.mockResolvedValue([mockBitcoinAccount]); + mockAccountsUseCases.signProofOfOwnershipMessages.mockResolvedValue([ + { signature: 'mock-bip322-signature' }, + ]); + + const result = await handler.route( + origin, + buildBatchRequest([ + { accountId: validAccountId, message: validMessage }, + { accountId: missingAccountId, message: validMessage }, + { accountId: validAccountId, message: mismatchedMessage }, + ]), + ); + + expect( + mockAccountsUseCases.signProofOfOwnershipMessages, + ).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ + results: [ + { + accountId: validAccountId, + signature: 'mock-bip322-signature', + }, + { + accountId: missingAccountId, + error: `Account not found: ${missingAccountId}`, + }, + { + accountId: validAccountId, + error: `Address in proof-of-ownership message (${secondAccountAddress}) does not match signing account address (${accountAddress})`, + }, + ], + }); + }); + + it('returns item-level errors from batch signing', async () => { + const message = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + mockAccountsUseCases.getByIds.mockResolvedValue([mockBitcoinAccount]); + mockAccountsUseCases.signProofOfOwnershipMessages.mockResolvedValue([ + { error: 'Failed to get private entropy' }, + ]); + + const result = await handler.route( + origin, + buildBatchRequest([{ accountId: validAccountId, message }]), + ); + + expect(result).toStrictEqual({ + results: [ + { + accountId: validAccountId, + error: 'Failed to get private entropy', + }, + ], + }); + }); + }); }); }); diff --git a/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index dd545ad1..355f5853 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,8 +1,14 @@ import { BtcScope } from '@metamask/keyring-api'; +import { normalizeError } from '@metamask/snap-networks-utils'; +import type { + ProofOfOwnershipBatchRequestItem, + ProofOfOwnershipBatchResponse, +} from '@metamask/snap-networks-utils'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { Verifier } from 'bip322-js'; import { assert, + array, enums, object, optional, @@ -88,6 +94,26 @@ export const SignProofOfOwnershipRequest = object({ message: string(), }); +/** + * Validates one proof-of-ownership batch request item. + * + * Batch items intentionally validate messages as plain strings so invalid + * proof messages can be reported per item instead of failing the whole batch. + */ +export const SignProofOfOwnershipBatchRequestItem = object({ + accountId: string(), + message: string(), +}); + +/** + * Validates `signProofOfOwnershipBatch` request params. + */ +export const SignProofOfOwnershipBatchRequest = object({ + items: array(SignProofOfOwnershipBatchRequestItem), +}); + +export type SignProofOfOwnershipBatchResponse = ProofOfOwnershipBatchResponse; + export class RpcHandler { readonly #logger: Logger; @@ -156,6 +182,10 @@ export class RpcHandler { assert(params, SignProofOfOwnershipRequest); return this.#signProofOfOwnership(params.accountId, params.message); } + case RpcMethod.SignProofOfOwnershipBatch: { + assert(params, SignProofOfOwnershipBatchRequest); + return this.#signProofOfOwnershipBatch(params.items); + } default: throw new InexistentMethodError(`Method not found: ${method}`); @@ -454,4 +484,116 @@ export class RpcHandler { return { signature }; } + + /** + * Handles batch signing of proof-of-ownership messages. + * + * Valid items are signed together so key derivation can be grouped by parent + * path. Invalid items return per-item errors instead of failing the whole + * batch. + * + * @param items - Batch request items. + * @returns One result per item, in input order. + */ + async #signProofOfOwnershipBatch( + items: ProofOfOwnershipBatchRequestItem[], + ): Promise { + const uniqueAccountIds = [ + ...new Set(items.map(({ accountId }) => accountId)), + ]; + const allAccounts = await this.#accountUseCases.getByIds(uniqueAccountIds); + const accountsById = new Map( + allAccounts.map((account) => [account.id, account]), + ); + const results: SignProofOfOwnershipBatchResponse['results'] = new Array( + items.length, + ); + const signingRequests: { + index: number; + accountId: string; + account: (typeof allAccounts)[number]; + message: string; + }[] = []; + + items.forEach(({ accountId, message }, index) => { + const account = accountsById.get(accountId); + if (!account) { + results[index] = { + accountId, + error: `Account not found: ${accountId}`, + }; + return; + } + + try { + const { address: messageAddress } = + parseProofOfOwnershipMessage(message); + + const canonicalMessageAddress = + canonicalizeBitcoinAddress(messageAddress); + const canonicalAccountAddress = canonicalizeBitcoinAddress( + account.publicAddress.toString(), + ); + + const addressValidation = validateAddress( + canonicalMessageAddress, + account.network, + this.#logger, + ); + if (!addressValidation.valid) { + results[index] = { + accountId, + error: `Invalid Bitcoin address in proof-of-ownership message for network ${account.network}`, + }; + return; + } + + if (canonicalMessageAddress !== canonicalAccountAddress) { + results[index] = { + accountId, + error: `Address in proof-of-ownership message (${messageAddress}) does not match signing account address (${canonicalAccountAddress})`, + }; + return; + } + + signingRequests.push({ + index, + accountId, + account, + message, + }); + } catch (error) { + results[index] = { + accountId, + error: normalizeError(error).message, + }; + } + }); + + if (signingRequests.length === 0) { + return { results }; + } + + const signedMessages = + await this.#accountUseCases.signProofOfOwnershipMessages( + signingRequests.map(({ account, message }) => ({ account, message })), + ); + + signedMessages.forEach((signedMessage, signingRequestIndex) => { + const { index, accountId } = signingRequests[ + signingRequestIndex + ] as (typeof signingRequests)[number]; + const { error } = signedMessage as { error?: string }; + + if (error !== undefined) { + results[index] = { accountId, error }; + return; + } + + const { signature } = signedMessage as { signature: string }; + results[index] = { accountId, signature }; + }); + + return { results }; + } } diff --git a/packages/bitcoin-wallet-snap/src/handlers/validation.ts b/packages/bitcoin-wallet-snap/src/handlers/validation.ts index 48163637..1f5bf9e1 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -1,7 +1,11 @@ import type { Network, AddressType } from '@metamask/bitcoindevkit'; import { Address, Amount } from '@metamask/bitcoindevkit'; import { BtcMethod } from '@metamask/keyring-api'; -import { UuidStruct } from '@metamask/snap-networks-utils'; +import { + parseProofOfOwnershipMessage as parseSharedProofOfOwnershipMessage, + UuidStruct, +} from '@metamask/snap-networks-utils'; +import type { ProofOfOwnershipMessage } from '@metamask/snap-networks-utils'; import { CaipAssetTypeStruct } from '@metamask/utils'; import type { Infer } from 'superstruct'; import { @@ -33,6 +37,11 @@ export const RpcMethod = { ConfirmSend: 'confirmSend', SignRewardsMessage: 'signRewardsMessage', SignProofOfOwnership: 'signProofOfOwnership', + /** + * Sign multiple proof-of-ownership messages for MetaMask identity + * authentication. + */ + SignProofOfOwnershipBatch: 'signProofOfOwnershipBatch', } as const; export type RpcMethod = (typeof RpcMethod)[keyof typeof RpcMethod]; @@ -401,8 +410,6 @@ export function parseRewardsMessage(base64Message: string): { }; } -export const PROOF_OF_OWNERSHIP_MESSAGE_PREFIX = 'metamask:proof-of-ownership:'; - // bech32/bech32m HRPs for Bitcoin mainnet, testnet, and regtest. Addresses // starting with one of these are case-insensitive but only canonical in // lowercase. @@ -433,38 +440,8 @@ export function canonicalizeBitcoinAddress(address: string): string { * @returns Object containing the parsed nonce and address * @throws Error if the message format is invalid */ -export function parseProofOfOwnershipMessage(message: string): { - nonce: string; - address: string; -} { - if (!message.startsWith(PROOF_OF_OWNERSHIP_MESSAGE_PREFIX)) { - throw new Error( - `Message must start with "${PROOF_OF_OWNERSHIP_MESSAGE_PREFIX}"`, - ); - } - - const remainder = message.slice(PROOF_OF_OWNERSHIP_MESSAGE_PREFIX.length); - const separatorIdx = remainder.lastIndexOf(':'); - if (separatorIdx === -1) { - throw new Error( - 'Message must follow the format "metamask:proof-of-ownership:{nonce}:{address}"', - ); - } - - const nonce = remainder.slice(0, separatorIdx); - const address = remainder.slice(separatorIdx + 1); - - if (nonce === '') { - throw new Error( - 'Proof-of-ownership message must contain a non-empty nonce', - ); - } - - if (address === '') { - throw new Error( - 'Proof-of-ownership message must contain a non-empty address', - ); - } - - return { nonce, address }; +export function parseProofOfOwnershipMessage( + message: string, +): ProofOfOwnershipMessage { + return parseSharedProofOfOwnershipMessage(message); } diff --git a/packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index aeaa8b7a..7e79ca84 100644 --- a/packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -179,6 +179,96 @@ describe('BdkAccountRepository', () => { }); }); + describe('getByIds', () => { + it('returns empty array if no account IDs are provided', async () => { + const result = await repo.getByIds([]); + + expect(mockSnapClient.getState).not.toHaveBeenCalled(); + expect(result).toStrictEqual([]); + }); + + it('returns requested accounts in requested order', async () => { + const id1 = 'some-id-1'; + const id2 = 'some-id-2'; + const state = { + [id1]: { ...mockAccountState, id: id1 }, + [id2]: { ...mockAccountState, id: id2 }, + }; + const mockAccount1 = { ...mockAccount, id: id1 }; + const mockAccount2 = { ...mockAccount, id: id2 }; + + mockSnapClient.getState.mockResolvedValue(state); + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount2) + .mockReturnValueOnce(mockAccount1); + + const result = await repo.getByIds([id2, 'missing-id', id1]); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts'); + expect(BdkAccountAdapter.load).toHaveBeenCalledTimes(2); + expect(result).toStrictEqual([mockAccount2, mockAccount1]); + }); + + it('uses cached account metadata without loading BDK wallets', async () => { + const id1 = 'some-id-1'; + const id2 = 'some-id-2'; + const accountState1: AccountState = { + ...mockAccountState, + metadata: { + address: 'bc1qcached1...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'cached-public-descriptor-1', + }, + }; + const accountState2: AccountState = { + ...mockAccountState, + metadata: { + address: 'bc1qcached2...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'cached-public-descriptor-2', + }, + }; + mockSnapClient.getState.mockResolvedValue({ + [id1]: accountState1, + [id2]: accountState2, + }); + (BdkAccountAdapter.load as jest.Mock).mockClear(); + (ChangeSet.from_json as jest.Mock).mockClear(); + + const result = await repo.getByIds([id2, 'missing-id', id1]); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts'); + expect(result).toHaveLength(2); + expect(result[0]?.id).toBe(id2); + expect(result[0]?.publicAddress.toString()).toBe('bc1qaddress...'); + expect(result[0]?.publicDescriptor).toBe('cached-public-descriptor-2'); + expect(result[1]?.id).toBe(id1); + expect(result[1]?.publicAddress.toString()).toBe('bc1qaddress...'); + expect(result[1]?.publicDescriptor).toBe('cached-public-descriptor-1'); + expect(jest.mocked(Address.from_string)).toHaveBeenCalledWith( + 'bc1qcached2...', + 'bitcoin', + ); + expect(jest.mocked(Address.from_string)).toHaveBeenCalledWith( + 'bc1qcached1...', + 'bitcoin', + ); + expect(ChangeSet.from_json).not.toHaveBeenCalled(); + expect(BdkAccountAdapter.load).not.toHaveBeenCalled(); + }); + + it('returns empty array if no accounts are found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.getByIds(['some-id']); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts'); + expect(result).toStrictEqual([]); + }); + }); + describe('getByDerivationPath', () => { it('returns null if account not found', async () => { mockSnapClient.getState.mockResolvedValue(null); diff --git a/packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index efae5c57..8ce9be24 100644 --- a/packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -107,6 +107,24 @@ export class BdkAccountRepository implements BitcoinAccountRepository { ); } + async getByIds(ids: string[]): Promise { + if (ids.length === 0) { + return []; + } + + const accounts = (await this.#snapClient.getState('accounts')) as + | SnapState['accounts'] + | null; + if (!accounts) { + return []; + } + + return ids.flatMap((id) => { + const account = accounts[id]; + return account ? [this.#loadPersistedAccount(id, account)] : []; + }); + } + async getByDerivationPath( derivationPath: string[], ): Promise { diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index b9a26f19..af892083 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -11,7 +11,12 @@ import type { Psbt, Address, } from '@metamask/bitcoindevkit'; -import type { JsonSLIP10Node } from '@metamask/key-tree'; +import type { BIP32Node, BIP39Node, JsonSLIP10Node } from '@metamask/key-tree'; +import { + mnemonicPhraseToBytes, + SLIP10Node as RealSlip10Node, +} from '@metamask/key-tree'; +import { Signer } from 'bip322-js'; import { mock } from 'jest-mock-extended'; import type { @@ -116,6 +121,28 @@ describe('AccountUseCases', () => { }); }); + describe('getByIds', () => { + it('returns accounts by id', async () => { + const mockAccount = mock(); + + mockRepository.getByIds.mockResolvedValue([mockAccount]); + + const result = await useCases.getByIds(['some-id']); + + expect(mockRepository.getByIds).toHaveBeenCalledWith(['some-id']); + expect(result).toStrictEqual([mockAccount]); + }); + + it('propagates an error if the repository getByIds fails', async () => { + const error = new Error('Get failed'); + mockRepository.getByIds.mockRejectedValue(error); + + await expect(useCases.getByIds(['some-id'])).rejects.toBe(error); + + expect(mockRepository.getByIds).toHaveBeenCalledWith(['some-id']); + }); + }); + describe('createMany', () => { const createParams: CreateAccountParams = { network: 'bitcoin', @@ -2173,4 +2200,83 @@ describe('AccountUseCases', () => { ).not.toHaveBeenCalled(); }); }); + + describe('signProofOfOwnershipMessages', () => { + const mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + const parentPath = ['entropy-1', "84'", "1'"]; + const mockMessage = 'metamask:proof-of-ownership:nonce:bcrt1qaddress'; + + /** + * Derives the real SLIP-10 node for a path from the fixture mnemonic. + * + * @param segments - Hardened path segments below the master node. + * @returns The derived node. + */ + async function deriveFixtureNode( + segments: string[], + ): Promise { + const derivationPath: [BIP39Node, ...BIP32Node[]] = [ + mnemonicPhraseToBytes(mnemonic) as BIP39Node, + ...segments.map((segment) => `bip32:${segment}` as BIP32Node), + ]; + + return RealSlip10Node.fromDerivationPath({ + derivationPath, + curve: 'secp256k1', + }); + } + + const createAccount = (index: number): BitcoinAccount => + mock({ + id: `account-${index}`, + publicAddress: mock
({ + toString: () => 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', + }), + capabilities: [AccountCapability.SignMessage], + derivationPath: [...parentPath, `${index}'`], + network: 'regtest', + }); + + beforeEach(async () => { + const parentNode = await deriveFixtureNode(["84'", "1'"]); + mockSnapClient.getPrivateEntropy.mockResolvedValue(parentNode.toJSON()); + }); + + it('signs messages with one private entropy fetch for accounts sharing a parent path', async () => { + jest + .spyOn(Signer, 'sign') + .mockReturnValueOnce('mock-bip322-signature-0') + .mockReturnValueOnce('mock-bip322-signature-1'); + + const result = await useCases.signProofOfOwnershipMessages([ + { account: createAccount(0), message: mockMessage }, + { account: createAccount(1), message: mockMessage }, + ]); + + expect(mockSnapClient.getPrivateEntropy).toHaveBeenCalledTimes(1); + expect(mockSnapClient.getPrivateEntropy).toHaveBeenCalledWith(parentPath); + expect( + mockConfirmationRepository.insertSignMessage, + ).not.toHaveBeenCalled(); + expect(result).toStrictEqual([ + { signature: 'mock-bip322-signature-0' }, + { signature: 'mock-bip322-signature-1' }, + ]); + }); + + it('returns an item-level error when an account cannot sign messages', async () => { + const account = createAccount(0); + account.capabilities = []; + + const result = await useCases.signProofOfOwnershipMessages([ + { account, message: mockMessage }, + ]); + + expect(mockSnapClient.getPrivateEntropy).not.toHaveBeenCalled(); + expect(result).toStrictEqual([ + { error: 'Account missing given capability' }, + ]); + }); + }); }); diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index e0c186fb..3a26f26f 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -7,7 +7,10 @@ import type { Txid, WalletTx, } from '@metamask/bitcoindevkit'; +import type { BIP32Node } from '@metamask/key-tree'; +import { SLIP10Node } from '@metamask/key-tree'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; +import { normalizeError } from '@metamask/snap-networks-utils'; import { Signer } from 'bip322-js'; import { encode } from 'wif'; @@ -48,6 +51,27 @@ export type CreateAccountParams = DiscoverAccountParams & { accountName?: string; }; +/** + * One proof-of-ownership message signing request. + */ +export type SignProofOfOwnershipMessageBatchRequest = { + /** + * Account whose address should own the BIP-322 signature. + */ + account: BitcoinAccount; + /** + * Plaintext proof-of-ownership message to sign. + */ + message: string; +}; + +/** + * Result for one proof-of-ownership batch signing request. + */ +export type SignProofOfOwnershipMessageBatchResult = + | { signature: string } + | { error: string }; + /** * @param req - Account creation or discovery request. * @returns The BIP-44 account derivation path. @@ -69,6 +93,47 @@ function getDerivationPathKey(derivationPath: string[]): string { return derivationPath.join('/'); } +/** + * Converts split derivation path segments into key-tree BIP-32 path nodes. + * + * @param segments - Split derivation path segments below a parent node. + * @returns BIP-32 path nodes accepted by key-tree. + */ +function toBip32Path(segments: string[]): BIP32Node[] { + return segments.map((segment) => `bip32:${segment}` as BIP32Node); +} + +/** + * Returns the parent derivation path used for grouped proof signing. + * + * @param account - The account whose account-level derivation path is used. + * @returns The parent path one level above the account index. + */ +function getProofSigningParentPath(account: BitcoinAccount): string[] { + if (account.derivationPath.length === 0) { + throw new Error('Missing account derivation path'); + } + + return account.derivationPath.slice(0, -1); +} + +/** + * Returns the child path from the grouped parent node to the receive address at + * index 0, which is the account public address used for BIP-322 signing. + * + * @param account - The account whose address-0 signing path should be built. + * @returns The child path from parent node to receive address 0. + */ +function getProofSigningChildPath(account: BitcoinAccount): string[] { + const accountIndexSegment = account.derivationPath.at(-1); + + if (!accountIndexSegment) { + throw new Error('Missing account derivation path'); + } + + return [accountIndexSegment, '0', '0']; +} + /** * Result of broadcasting a Bitcoin transaction. * @@ -140,6 +205,24 @@ export class AccountUseCases { return accounts; } + /** + * Gets accounts by id using one account-map read. + * + * @param ids - Account IDs. + * @returns Existing accounts in requested order. + */ + async getByIds(ids: string[]): Promise { + this.#logger.debug('Fetching accounts: %o', ids); + + const accounts = await this.#repository.getByIds(ids); + + this.#logger.debug( + 'Accounts found: %o', + accounts.map(({ id }) => id), + ); + return accounts; + } + async get(id: string): Promise { this.#logger.debug('Fetching account: %s', id); @@ -690,6 +773,128 @@ export class AccountUseCases { } } + /** + * Signs multiple proof-of-ownership messages without user confirmation. + * + * Requests are grouped by the account-level parent path so the private parent + * node is fetched once per distinct parent and address-0 keys are derived + * locally. Results are returned in input order, with per-item errors for + * accounts that cannot sign or fail derivation/signing. + * + * @param requests - Proof-of-ownership message signing requests. + * @returns One signing result per request, in input order. + */ + async signProofOfOwnershipMessages( + requests: SignProofOfOwnershipMessageBatchRequest[], + ): Promise { + const results: SignProofOfOwnershipMessageBatchResult[] = new Array( + requests.length, + ); + const requestsByParentPath = new Map< + string, + { + index: number; + request: SignProofOfOwnershipMessageBatchRequest; + parentPath: string[]; + }[] + >(); + + requests.forEach((request, index) => { + try { + this.#checkCapability(request.account, AccountCapability.SignMessage); + + const parentPath = getProofSigningParentPath(request.account); + const parentKey = getDerivationPathKey(parentPath); + const parentRequests = requestsByParentPath.get(parentKey) ?? []; + parentRequests.push({ index, request, parentPath }); + requestsByParentPath.set(parentKey, parentRequests); + } catch (error) { + results[index] = { error: normalizeError(error).message }; + } + }); + + await Promise.all( + [...requestsByParentPath.values()].map(async (parentRequests) => { + const { parentPath } = + parentRequests[0] as (typeof parentRequests)[number]; + + try { + const parentJson = + await this.#snapClient.getPrivateEntropy(parentPath); + const parentNode = await SLIP10Node.fromJSON(parentJson); + + for (const { index, request } of parentRequests) { + try { + const entropy = await parentNode.derive( + toBip32Path(getProofSigningChildPath(request.account)), + ); + + if (!entropy.privateKey) { + throw new AssertionError('Failed to get private entropy', { + id: request.account.id, + }); + } + + results[index] = { + signature: this.#signProofOfOwnershipMessage( + request.account, + request.message, + entropy.privateKey, + ), + }; + } catch (error) { + results[index] = { error: normalizeError(error).message }; + } + } + } catch (error) { + for (const { index } of parentRequests) { + results[index] = { error: normalizeError(error).message }; + } + } + }), + ); + + return results; + } + + /** + * Signs one proof-of-ownership message using private key entropy. + * + * @param account - Account whose public address should own the signature. + * @param message - Plaintext proof-of-ownership message. + * @param privateKey - 0x-prefixed private key hex string. + * @returns The BIP-322 signature. + */ + #signProofOfOwnershipMessage( + account: BitcoinAccount, + message: string, + privateKey: string, + ): string { + try { + const wifPrivateKey = encode({ + version: account.network === 'bitcoin' ? 128 : 239, + // eslint-disable-next-line no-restricted-globals + privateKey: Buffer.from(privateKey.slice(2), 'hex'), + compressed: true, + }); + + return Signer.sign( + wifPrivateKey, + account.publicAddress.toString(), + message, + ); + } catch (error) { + throw new WalletError( + 'Failed to sign message', + { + id: account.id, + message, + }, + error, + ); + } + } + async getFrozenUTXOs(accountId: string): Promise { return this.#repository.getFrozenUTXOs(accountId); }