diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index f9ec7633d..e57a0214a 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-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. ([#256](https://github.com/MetaMask/internal-snaps/pull/256)) - Add back the `endowment:assets` permission for the Solana 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/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 21ada2cbf..74420bafc 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "3XId1IkqlIty92kiBLHbDxIv1RY8AnZiGvOiTOnTaJI=", + "shasum": "Qz6QNLaBjj6RB9FRui24cE1Y8db+mecztdYvWx/rc/M=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts index 1a2b5f3b8..55ec1354c 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.test.ts @@ -48,6 +48,7 @@ describe('ClientRequestHandler', () => { // Create mock keyring mockAccountsService = { findById: jest.fn(), + findByIds: jest.fn(), findByAddress: jest.fn(), } as unknown as jest.Mocked; @@ -55,6 +56,7 @@ describe('ClientRequestHandler', () => { mockWalletService = { signAndSendTransaction: jest.fn(), signMessage: jest.fn(), + signMessages: jest.fn(), } as unknown as jest.Mocked; // Create mock logger @@ -738,6 +740,126 @@ describe('ClientRequestHandler', () => { }); }); + describe('signProofOfOwnershipBatch', () => { + const utf8ToBase64 = (utf8: string): string => + pipe(utf8, getUtf8Codec().encode, getBase64Codec().decode); + + const base58Signature = + '2AXDGYSE4f2sz7tvMMzyHvUfcoJmxudvdhBcmiUSo6ijwfYmfZYsKRxboQMPh3R4kUhXRVdtSXFXMheka4Rc4P2'; + const nonce = 'a1b2c3d4e5f6789012345678'; + const account0 = MOCK_SOLANA_KEYRING_ACCOUNT_0; + const account1 = MOCK_SOLANA_KEYRING_ACCOUNT_1; + + const buildProofMessage = ( + proofNonce: string, + proofAddress: string, + ): string => `metamask:proof-of-ownership:${proofNonce}:${proofAddress}`; + + const createRequest = ( + items: { accountId: string; message: string }[], + ): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.SignProofOfOwnershipBatch, + params: { items }, + }); + + it('signs a batch and returns 0x-prefixed hex signatures in input order', async () => { + const message0 = buildProofMessage(nonce, account0.address); + const message1 = buildProofMessage(nonce, account1.address); + mockAccountsService.findByIds.mockResolvedValue([account1, account0]); + mockWalletService.signMessages.mockResolvedValue([ + { + signature: base58Signature, + signedMessage: utf8ToBase64(message0), + signatureType: 'ed25519', + }, + { + signature: base58Signature, + signedMessage: utf8ToBase64(message1), + signatureType: 'ed25519', + }, + ]); + + const result = await handler.handle( + createRequest([ + { accountId: account0.id, message: message0 }, + { accountId: account1.id, message: message1 }, + ]), + ); + + expect(mockAccountsService.findByIds).toHaveBeenCalledWith([ + account0.id, + account1.id, + ]); + expect(mockWalletService.signMessages).toHaveBeenCalledWith([ + { account: account0, message: utf8ToBase64(message0) }, + { account: account1, message: utf8ToBase64(message1) }, + ]); + expect(result).toStrictEqual({ + results: [ + { accountId: account0.id, signature: `0x${'01'.repeat(64)}` }, + { accountId: account1.id, signature: `0x${'01'.repeat(64)}` }, + ], + }); + }); + + it('returns item-level errors for missing accounts and address mismatches', async () => { + const missingAccountId = '123e4567-e89b-42d3-a456-426614174099'; + const validMessage = buildProofMessage(nonce, account0.address); + const mismatchedMessage = buildProofMessage(nonce, account1.address); + mockAccountsService.findByIds.mockResolvedValue([account0]); + mockWalletService.signMessages.mockResolvedValue([ + { + signature: base58Signature, + signedMessage: utf8ToBase64(validMessage), + signatureType: 'ed25519', + }, + ]); + + const result = await handler.handle( + createRequest([ + { accountId: account0.id, message: validMessage }, + { accountId: missingAccountId, message: validMessage }, + { accountId: account0.id, message: mismatchedMessage }, + ]), + ); + + expect(mockWalletService.signMessages).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ + results: [ + { accountId: account0.id, signature: `0x${'01'.repeat(64)}` }, + { + accountId: missingAccountId, + error: `Account not found: ${missingAccountId}`, + }, + { + accountId: account0.id, + error: `Address in proof-of-ownership message (${account1.address}) does not match signing account address (${account0.address})`, + }, + ], + }); + }); + + it('returns item-level errors from wallet batch signing', async () => { + const message = buildProofMessage(nonce, account0.address); + mockAccountsService.findByIds.mockResolvedValue([account0]); + mockWalletService.signMessages.mockResolvedValue([ + { error: 'Unable to derive private key' }, + ]); + + const result = await handler.handle( + createRequest([{ accountId: account0.id, message }]), + ); + + expect(result).toStrictEqual({ + results: [ + { accountId: account0.id, error: 'Unable to derive private key' }, + ], + }); + }); + }); + describe('signCardMessage', () => { // Helper function to convert a utf8 string to base64 const utf8ToBase64 = (utf8: string): string => diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts index e26bf05bd..167860347 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -1,4 +1,5 @@ import { FeeType } from '@metamask/keyring-api'; +import { normalizeError } from '@metamask/snap-networks-utils'; import type { Logger } from '@metamask/snap-networks-utils'; import { InvalidParamsError, MethodNotFoundError } from '@metamask/snaps-sdk'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; @@ -37,6 +38,8 @@ import { SignAndSendTransactionResponseStruct, SignAndSendTransactionWithoutConfirmationRequestStruct, SignCardMessageRequestStruct, + SignProofOfOwnershipBatchRequestStruct, + SignProofOfOwnershipBatchResponseStruct, SignProofOfOwnershipRequestStruct, SignProofOfOwnershipResponseStruct, SignRewardsMessageRequestStruct, @@ -45,6 +48,7 @@ import { import type { ComputeFeeResponse, SignAndSendTransactionResponse, + SignProofOfOwnershipBatchResponse, SignProofOfOwnershipResponse, } from './validation'; @@ -110,6 +114,8 @@ export class ClientRequestHandler { return this.#handleApproveCardAmount(request); case ClientRequestMethod.SignProofOfOwnership: return this.#handleSignProofOfOwnership(request); + case ClientRequestMethod.SignProofOfOwnershipBatch: + return this.#handleSignProofOfOwnershipBatch(request); default: throw new MethodNotFoundError() as Error; } @@ -490,11 +496,7 @@ export class ClientRequestHandler { const { signature: base58Signature } = await this.#walletService.signMessage(account, base64Message); - // Transcode the base58 signature to 0x-prefixed hex for the identity - // auth API; the dApp `signMessage` flow keeps its wallet-standard base58. - const signature = bytesToHex( - Uint8Array.from(getBase58Codec().encode(base58Signature)), - ); + const signature = this.#toProofOfOwnershipSignature(base58Signature); const result: SignProofOfOwnershipResponse = { signature }; @@ -502,4 +504,126 @@ export class ClientRequestHandler { return result; } + + /** + * Handles silent batch signing of proof-of-ownership messages. + * + * Valid items are signed together so the wallet service can group key + * derivation by entropy source. Invalid items return per-item errors instead + * of failing the whole batch. + * + * @param request - The JSON-RPC request containing the batch items. + * @returns The response to the JSON-RPC request. + */ + async #handleSignProofOfOwnershipBatch( + request: JsonRpcRequest, + ): Promise { + assert(request, SignProofOfOwnershipBatchRequestStruct); + + const { + params: { items }, + } = request; + const uniqueAccountIds = [ + ...new Set(items.map(({ accountId }) => accountId)), + ]; + const accounts = await this.#accountsService.findByIds(uniqueAccountIds); + const accountsById = new Map( + accounts.map((account) => [account.id, account]), + ); + const results: SignProofOfOwnershipBatchResponse['results'] = new Array( + items.length, + ); + const signingRequests: { + index: number; + accountId: string; + account: (typeof accounts)[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); + + if (messageAddress !== account.address) { + results[index] = { + accountId, + error: `Address in proof-of-ownership message (${messageAddress}) does not match signing account address (${account.address})`, + }; + return; + } + + const base64Message = pipe( + message, + getUtf8Codec().encode, + getBase64Codec().decode, + ); + signingRequests.push({ + index, + accountId, + account, + message: base64Message, + }); + } catch (error) { + results[index] = { + accountId, + error: normalizeError(error).message, + }; + } + }); + + const signedMessages = await this.#walletService.signMessages( + 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: this.#toProofOfOwnershipSignature(signature), + }; + }); + + const result: SignProofOfOwnershipBatchResponse = { results }; + + assert(result, SignProofOfOwnershipBatchResponseStruct); + + return result; + } + + /** + * Converts a wallet-standard base58 ed25519 signature into the strict hex + * format expected by the identity auth proof-of-ownership API. + * + * @param base58Signature - The base58-encoded signature returned by Solana + * wallet signing. + * @returns The same signature encoded as 0x-prefixed hex. + */ + #toProofOfOwnershipSignature(base58Signature: string): `0x${string}` { + return bytesToHex( + Uint8Array.from(getBase58Codec().encode(base58Signature)), + ); + } } diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts index ede29019a..3904ad5ec 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/types.ts @@ -10,6 +10,11 @@ export const ClientRequestMethod = { SignCardMessage: 'signCardMessage', ApproveCardAmount: 'approveCardAmount', SignProofOfOwnership: 'signProofOfOwnership', + /** + * Silently signs multiple proof-of-ownership messages for MetaMask identity + * authentication. + */ + SignProofOfOwnershipBatch: 'signProofOfOwnershipBatch', } as const; export type ClientRequestMethod = diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts index ebf66a2b0..deab97efb 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.test.ts @@ -366,8 +366,16 @@ describe('validation', () => { ); }); + it('rejects messages with an empty address', () => { + expect(() => + assert( + `metamask:proof-of-ownership:${nonce}:`, + ProofOfOwnershipMessageStruct, + ), + ).toThrow('non-empty address'); + }); + it.each([ - `metamask:proof-of-ownership:${nonce}:`, `metamask:proof-of-ownership:${nonce}:not-a-solana-address`, `metamask:proof-of-ownership:${nonce}:0x1234567890abcdef1234567890abcdef12345678`, ])('rejects invalid Solana addresses: "%s"', (message) => { diff --git a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts index 5f585c6b3..8bc0502d7 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts @@ -1,5 +1,12 @@ import { AssetStruct, FeeType } from '@metamask/keyring-api'; -import { UuidStruct } from '@metamask/snap-networks-utils'; +import { + parseProofOfOwnershipMessage as parseSharedProofOfOwnershipMessage, + ProofOfOwnershipBatchErrorStruct, + ProofOfOwnershipBatchRequestItemStruct, + ProofOfOwnershipBatchRequestParamsStruct, + UuidStruct, +} from '@metamask/snap-networks-utils'; +import type { ProofOfOwnershipMessage } from '@metamask/snap-networks-utils'; import { literal } from '@metamask/snaps-sdk'; import type { Infer } from '@metamask/superstruct'; import { @@ -12,6 +19,7 @@ import { optional, refine, string, + union, } from '@metamask/superstruct'; import { CaipAssetTypeStruct, @@ -432,8 +440,6 @@ export const ComputeFeeResponseStruct = array( export type ComputeFeeResponse = Infer; -export const PROOF_OF_OWNERSHIP_MESSAGE_PREFIX = 'metamask:proof-of-ownership:'; - /** * Utility function to parse a proof-of-ownership message, of format `'metamask:proof-of-ownership:{nonce}:{address}'`. * Returns the parsed components or throws an error if invalid. @@ -442,38 +448,17 @@ export const PROOF_OF_OWNERSHIP_MESSAGE_PREFIX = 'metamask:proof-of-ownership:'; * @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', - ); - } +export function parseProofOfOwnershipMessage( + message: string, +): ProofOfOwnershipMessage { + const proofMessage = parseSharedProofOfOwnershipMessage(message); + const { address } = proofMessage; if (!is(address, SolanaAddressStruct)) { throw new Error('Invalid Solana address in proof-of-ownership message'); } - return { nonce, address }; + return proofMessage; } /** @@ -522,3 +507,67 @@ export const SignProofOfOwnershipResponseStruct = object({ export type SignProofOfOwnershipResponse = Infer< typeof SignProofOfOwnershipResponseStruct >; + +/** + * 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 SignProofOfOwnershipBatchRequestItemStruct = + ProofOfOwnershipBatchRequestItemStruct; + +/** + * Validates the params object for `signProofOfOwnershipBatch`. + */ +export const SignProofOfOwnershipBatchRequestParamsStruct = + ProofOfOwnershipBatchRequestParamsStruct; + +/** + * Validates a `signProofOfOwnershipBatch` JSON-RPC request. + */ +export const SignProofOfOwnershipBatchRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.SignProofOfOwnershipBatch), + params: SignProofOfOwnershipBatchRequestParamsStruct, +}); + +/** + * Validates a successful proof-of-ownership batch item response. + */ +export const SignProofOfOwnershipBatchSuccessStruct = object({ + accountId: string(), + /** + * 0x-prefixed hex encoding of the 64-byte ed25519 signature. + */ + signature: StrictHexStruct, +}); + +/** + * Validates a failed proof-of-ownership batch item response. + */ +export const SignProofOfOwnershipBatchErrorStruct = + ProofOfOwnershipBatchErrorStruct; + +/** + * Validates a proof-of-ownership batch item result. + */ +export const SignProofOfOwnershipBatchItemResponseStruct = union([ + SignProofOfOwnershipBatchSuccessStruct, + SignProofOfOwnershipBatchErrorStruct, +]); + +/** + * Validates a `signProofOfOwnershipBatch` response. + */ +export const SignProofOfOwnershipBatchResponseStruct = object({ + results: array(SignProofOfOwnershipBatchItemResponseStruct), +}); + +/** + * Response returned by `signProofOfOwnershipBatch`. + */ +export type SignProofOfOwnershipBatchResponse = Infer< + typeof SignProofOfOwnershipBatchResponseStruct +>; diff --git a/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts b/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts index c658e579e..c9300b927 100644 --- a/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts +++ b/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts @@ -22,6 +22,21 @@ export class AccountsRepository { return (await this.#state.getKey(`keyringAccounts.${id}`)) ?? null; } + /** + * Finds multiple Solana keyring accounts with a single full account-state + * read. + * + * @param ids - Account IDs to resolve. + * @returns The matching accounts. Result ordering follows stored account + * ordering, not input ordering. + */ + async findByIds(ids: string[]): Promise { + const idSet = new Set(ids); + const accounts = await this.getAll(); + + return accounts.filter((account) => idSet.has(account.id)); + } + async findByAddress(address: string): Promise { const accounts = await this.getAll(); diff --git a/packages/solana-wallet-snap/src/core/services/accounts/AccountsService.ts b/packages/solana-wallet-snap/src/core/services/accounts/AccountsService.ts index 3b78a6c95..00fa7937b 100644 --- a/packages/solana-wallet-snap/src/core/services/accounts/AccountsService.ts +++ b/packages/solana-wallet-snap/src/core/services/accounts/AccountsService.ts @@ -29,6 +29,16 @@ export class AccountsService { return this.#accountsRepository.findById(id); } + /** + * Finds multiple Solana keyring accounts. + * + * @param ids - Account IDs to resolve. + * @returns The matching accounts. + */ + async findByIds(ids: string[]): Promise { + return this.#accountsRepository.findByIds(ids); + } + async findByAddress(address: string): Promise { return this.#accountsRepository.findByAddress(address); } diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts index a7e07412a..e74c91f0a 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.test.ts @@ -8,6 +8,7 @@ import { MOCK_SOLANA_KEYRING_ACCOUNT_3, MOCK_SOLANA_KEYRING_ACCOUNT_4, MOCK_SOLANA_KEYRING_ACCOUNTS, + MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0, } from '../../test/mocks/solana-keyring-accounts'; import { getBip32EntropyMock } from '../../test/mocks/utils/getBip32Entropy'; import logger from '../../utils/logger'; @@ -80,6 +81,8 @@ describe('WalletService', () => { (globalThis as any).snap = { request: jest.fn(), }; + + getBip32EntropyMock.mockClear(); }); describe('resolveAccountAddress', () => { @@ -471,4 +474,65 @@ describe('WalletService', () => { }); }, ); + + describe('signMessages', () => { + const utf8ToBase64 = (utf8: string): string => + Buffer.from(utf8, 'utf8').toString('base64'); + + it('signs messages with one entropy fetch for accounts sharing an entropy source', async () => { + const message0 = utf8ToBase64('proof message 0'); + const message1 = utf8ToBase64('proof message 1'); + + const results = await service.signMessages([ + { account: MOCK_SOLANA_KEYRING_ACCOUNT_0, message: message0 }, + { account: MOCK_SOLANA_KEYRING_ACCOUNT_1, message: message1 }, + ]); + + expect(getBip32EntropyMock).toHaveBeenCalledTimes(1); + expect(getBip32EntropyMock).toHaveBeenCalledWith({ + entropySource: MOCK_SOLANA_KEYRING_ACCOUNT_0.entropySource, + path: ['m', "44'", "501'"], + curve: 'ed25519', + }); + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + signedMessage: message0, + signatureType: 'ed25519', + }); + expect(results[1]).toMatchObject({ + signedMessage: message1, + signatureType: 'ed25519', + }); + }); + + it('fetches entropy once per entropy source', async () => { + await service.signMessages([ + { account: MOCK_SOLANA_KEYRING_ACCOUNT_0, message: utf8ToBase64('a') }, + { + account: MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0, + message: utf8ToBase64('b'), + }, + ]); + + expect(getBip32EntropyMock).toHaveBeenCalledTimes(2); + }); + + it('returns an item-level error for unsupported derivation paths', async () => { + const result = await service.signMessages([ + { + account: { + ...MOCK_SOLANA_KEYRING_ACCOUNT_0, + derivationPath: "m/44'/501'/0'", + }, + message: utf8ToBase64('a'), + }, + ]); + + expect(result).toStrictEqual([ + { + error: "Unsupported Solana derivation path: m/44'/501'/0'", + }, + ]); + }); + }); }); diff --git a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts index 3acc9c944..72c122bb5 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -1,4 +1,6 @@ +import { SLIP10Node } from '@metamask/key-tree'; import { SolMethod } from '@metamask/keyring-api'; +import { normalizeError } from '@metamask/snap-networks-utils'; import type { Logger } from '@metamask/snap-networks-utils'; import type { Infer } from '@metamask/superstruct'; import { assert, instance, object } from '@metamask/superstruct'; @@ -23,7 +25,11 @@ import type { Caip10Address, Network } from '../../constants/solana'; import type { DecompileTransactionMessageFetchingLookupTablesConfig } from '../../sdk-extensions/codecs'; import { fromTransactionToBase64String } from '../../sdk-extensions/codecs'; import { addressToCaip10 } from '../../utils/addressToCaip10'; -import { deriveSolanaKeypair } from '../../utils/deriveSolanaKeypair'; +import { + deriveSolanaKeypair, + deriveSolanaKeypairFromCoinTypeNode, +} from '../../utils/deriveSolanaKeypair'; +import { getBip32Entropy } from '../../utils/getBip32Entropy'; import { getSolanaExplorerUrl } from '../../utils/getSolanaExplorerUrl'; import logger from '../../utils/logger'; import { Base58Struct, Base64Struct } from '../../validation/structs'; @@ -50,6 +56,59 @@ import type { SolanaSignTransactionResponse, } from './structs'; +/** + * One message-signing request for the internal Solana batch signing path. + */ +export type SolanaSignMessageBatchRequest = { + /** + * Account whose key should sign the message. + */ + account: SolanaKeyringAccount; + /** + * Base64-encoded message to sign. + */ + message: string; +}; + +/** + * Result for one message in the internal Solana batch signing path. + */ +export type SolanaSignMessageBatchResult = + | SolanaSignMessageResponse + | { error: string }; + +const DEFAULT_SOLANA_DERIVATION_PATH_REGEX = /^m\/44'\/501'\/([0-9]+)'\/0'$/u; + +/** + * Extracts the account index from the default Solana BIP-44 derivation path. + * + * Batch signing derives children from the coin-type node (`m/44'/501'`), so it + * only supports the snap's default `m/44'/501'/index'/0'` path shape. + * + * @param account - The Solana account whose derivation path should be parsed. + * @returns The hardened BIP-44 account index. + */ +function getDefaultSolanaAccountIndex(account: SolanaKeyringAccount): number { + const match = DEFAULT_SOLANA_DERIVATION_PATH_REGEX.exec( + account.derivationPath, + ); + + if (!match?.[1]) { + throw new Error( + `Unsupported Solana derivation path: ${account.derivationPath}`, + ); + } + + const accountIndex = Number(match[1]); + if (!Number.isSafeInteger(accountIndex) || accountIndex !== account.index) { + throw new Error( + `Solana derivation path index (${accountIndex}) does not match account index (${account.index})`, + ); + } + + return accountIndex; +} + export class WalletService { readonly #connection: SolanaConnection; @@ -342,17 +401,111 @@ export class WalletService { ): Promise { this.#logger.log('Signing message', account, message); - const { address, entropySource, derivationPath } = account; - const addressAsAddress = asAddress(address); - const messageBytes = getBase64Codec().encode(message); - const messageUtf8 = getUtf8Codec().decode(messageBytes); - const signableMessage = createSignableMessage(messageUtf8); - + const { entropySource, derivationPath } = account; const { privateKeyBytes } = await deriveSolanaKeypair({ entropySource, derivationPath, }); + return this.#signMessageWithPrivateKey(account, message, privateKeyBytes); + } + + /** + * Signs multiple base64-encoded messages using Solana accounts. + * + * Requests are grouped by entropy source so the coin-type node is fetched + * once per source and account keys are derived locally. Results are returned + * in input order, with per-item errors for invalid derivation paths or + * signing failures. + * + * @param requests - Message signing requests. + * @returns One signing result per request, in input order. + */ + async signMessages( + requests: SolanaSignMessageBatchRequest[], + ): Promise { + this.#logger.log('Signing message batch', { count: requests.length }); + + const results: SolanaSignMessageBatchResult[] = new Array(requests.length); + const requestsByEntropySource = new Map< + string, + { index: number; request: SolanaSignMessageBatchRequest }[] + >(); + + requests.forEach((request, index) => { + const sourceRequests = + requestsByEntropySource.get(request.account.entropySource) ?? []; + sourceRequests.push({ index, request }); + requestsByEntropySource.set( + request.account.entropySource, + sourceRequests, + ); + }); + + await Promise.all( + [...requestsByEntropySource.entries()].map( + async ([entropySource, sourceRequests]) => { + try { + const coinTypeNodeJson = await getBip32Entropy({ + entropySource, + path: ['m', "44'", "501'"], + curve: 'ed25519', + }); + const coinTypeNode = await SLIP10Node.fromJSON(coinTypeNodeJson); + + for (const { index, request } of sourceRequests) { + try { + const accountIndex = getDefaultSolanaAccountIndex( + request.account, + ); + const { privateKeyBytes } = + await deriveSolanaKeypairFromCoinTypeNode({ + coinTypeNode, + accountIndex, + }); + + results[index] = await this.#signMessageWithPrivateKey( + request.account, + request.message, + privateKeyBytes, + ); + } catch (error) { + results[index] = { error: normalizeError(error).message }; + } + } + } catch (error) { + for (const { index } of sourceRequests) { + results[index] = { error: normalizeError(error).message }; + } + } + }, + ), + ); + + return results; + } + + /** + * Signs a base64-encoded message with an already-derived private key. + * + * This keeps the single-message and batch-message code paths using the same + * message encoding and signature response validation. + * + * @param account - Account whose address should own the signature. + * @param message - Base64-encoded message to sign. + * @param privateKeyBytes - Private key bytes for the account. + * @returns The wallet-standard signed message response. + */ + async #signMessageWithPrivateKey( + account: SolanaKeyringAccount, + message: string, + privateKeyBytes: Uint8Array, + ): Promise { + const addressAsAddress = asAddress(account.address); + const messageBytes = getBase64Codec().encode(message); + const messageUtf8 = getUtf8Codec().decode(messageBytes); + const signableMessage = createSignableMessage(messageUtf8); + const signer = await createKeyPairSignerFromPrivateKeyBytes(privateKeyBytes); diff --git a/packages/solana-wallet-snap/src/permissions.ts b/packages/solana-wallet-snap/src/permissions.ts index 68cbdbeec..209f406a3 100644 --- a/packages/solana-wallet-snap/src/permissions.ts +++ b/packages/solana-wallet-snap/src/permissions.ts @@ -66,6 +66,7 @@ const metamaskMethods = [ // Client methods ClientRequestMethod.SignAndSendTransactionWithoutConfirmation, ClientRequestMethod.SignProofOfOwnership, + ClientRequestMethod.SignProofOfOwnershipBatch, ]; export const originPermissions = createOriginPermissions({