Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bitcoin-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/bitcoin-wallet-snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/internal-snaps.git"
},
"source": {
"shasum": "Toy0lgo6eUTHc9W2Obj9JZNRm3uEJzkeD2E8NmG+W44=",
"shasum": "3Msbb6pMl2lUUJnwKtW0ONeFu5n1WTvLuNW2Vc2HW0c=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
8 changes: 8 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,14 @@ export type BitcoinAccountRepository = {
*/
getAll(): Promise<BitcoinAccount[]>;

/**
* Get accounts by their ids.
*
* @param ids - Account IDs.
* @returns the accounts that exist, in requested order
*/
getByIds(ids: string[]): Promise<BitcoinAccount[]>;

/**
* Get an account by its derivation path.
*
Expand Down
126 changes: 126 additions & 0 deletions packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BitcoinAccount>({
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',
},
],
});
});
});
});
});
142 changes: 142 additions & 0 deletions packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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<SignProofOfOwnershipBatchResponse> {
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 };
}
}
Loading