From 7da1081e896d840733dbe03f09197785d256df39 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Wed, 2 Sep 2026 16:08:15 +0200 Subject: [PATCH 01/23] feat(solana-wallet-snap): add batch proof-of-ownership signing --- .../solana-wallet-snap/snap.manifest.json | 2 +- .../ClientRequestHandler.test.ts | 122 ++++++++++++++++ .../onClientRequest/ClientRequestHandler.ts | 131 +++++++++++++++++- .../core/handlers/onClientRequest/types.ts | 1 + .../handlers/onClientRequest/validation.ts | 49 +++++++ .../services/accounts/AccountsRepository.ts | 7 + .../core/services/accounts/AccountsService.ts | 4 + .../services/wallet/WalletService.test.ts | 64 +++++++++ .../src/core/services/wallet/WalletService.ts | 127 ++++++++++++++++- .../solana-wallet-snap/src/permissions.ts | 1 + 10 files changed, 495 insertions(+), 13 deletions(-) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index a309f3d0b..771d60a80 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": "RP8K+BdAavCCQ3ZxXRx/CgH4ipA4Y2K/yCmqQTgHB8w=", + "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..4d4c54757 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -37,6 +37,8 @@ import { SignAndSendTransactionResponseStruct, SignAndSendTransactionWithoutConfirmationRequestStruct, SignCardMessageRequestStruct, + SignProofOfOwnershipBatchRequestStruct, + SignProofOfOwnershipBatchResponseStruct, SignProofOfOwnershipRequestStruct, SignProofOfOwnershipResponseStruct, SignRewardsMessageRequestStruct, @@ -45,9 +47,14 @@ import { import type { ComputeFeeResponse, SignAndSendTransactionResponse, + SignProofOfOwnershipBatchResponse, SignProofOfOwnershipResponse, } from './validation'; +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export class ClientRequestHandler { readonly #accountsService: AccountsService; @@ -110,6 +117,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 +499,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 +507,120 @@ 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: getErrorMessage(error), + }; + } + }); + + 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; + } + + #toProofOfOwnershipSignature(base58Signature: string): `0x${string}` { + // Transcode the base58 signature to 0x-prefixed hex for the identity + // auth API; the dApp `signMessage` flow keeps its wallet-standard base58. + 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..64e0c6bf7 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,7 @@ export const ClientRequestMethod = { SignCardMessage: 'signCardMessage', ApproveCardAmount: 'approveCardAmount', SignProofOfOwnership: 'signProofOfOwnership', + SignProofOfOwnershipBatch: 'signProofOfOwnershipBatch', } as const; export type ClientRequestMethod = 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 463962023..1ae4a612c 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts @@ -12,6 +12,7 @@ import { optional, refine, string, + union, } from '@metamask/superstruct'; import { CaipAssetTypeStruct, @@ -520,3 +521,51 @@ export const SignProofOfOwnershipResponseStruct = object({ export type SignProofOfOwnershipResponse = Infer< typeof SignProofOfOwnershipResponseStruct >; + +/** + * signProofOfOwnershipBatch request/response validation. + * + * 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 = object({ + accountId: string(), + message: string(), +}); + +export const SignProofOfOwnershipBatchRequestParamsStruct = object({ + items: array(SignProofOfOwnershipBatchRequestItemStruct), +}); + +export const SignProofOfOwnershipBatchRequestStruct = object({ + jsonrpc: JsonRpcVersionStruct, + id: JsonRpcIdStruct, + method: literal(ClientRequestMethod.SignProofOfOwnershipBatch), + params: SignProofOfOwnershipBatchRequestParamsStruct, +}); + +export const SignProofOfOwnershipBatchSuccessStruct = object({ + accountId: string(), + /** + * 0x-prefixed hex encoding of the 64-byte ed25519 signature. + */ + signature: StrictHexStruct, +}); + +export const SignProofOfOwnershipBatchErrorStruct = object({ + accountId: string(), + error: string(), +}); + +export const SignProofOfOwnershipBatchItemResponseStruct = union([ + SignProofOfOwnershipBatchSuccessStruct, + SignProofOfOwnershipBatchErrorStruct, +]); + +export const SignProofOfOwnershipBatchResponseStruct = object({ + results: array(SignProofOfOwnershipBatchItemResponseStruct), +}); + +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..ee308f0ff 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,13 @@ export class AccountsRepository { return (await this.#state.getKey(`keyringAccounts.${id}`)) ?? null; } + 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..1099ad212 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,10 @@ export class AccountsService { return this.#accountsRepository.findById(id); } + 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..c245ff9db 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -1,3 +1,4 @@ +import { SLIP10Node } from '@metamask/key-tree'; import { SolMethod } from '@metamask/keyring-api'; import type { Logger } from '@metamask/snap-networks-utils'; import type { Infer } from '@metamask/superstruct'; @@ -23,7 +24,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 +55,42 @@ import type { SolanaSignTransactionResponse, } from './structs'; +export type SolanaSignMessageBatchRequest = { + account: SolanaKeyringAccount; + message: string; +}; + +export type SolanaSignMessageBatchResult = + | SolanaSignMessageResponse + | { error: string }; + +const DEFAULT_SOLANA_DERIVATION_PATH_REGEX = /^m\/44'\/501'\/([0-9]+)'\/0'$/u; + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +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 +383,89 @@ 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); + } + + 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: getErrorMessage(error) }; + } + } + } catch (error) { + for (const { index } of sourceRequests) { + results[index] = { error: getErrorMessage(error) }; + } + } + }, + ), + ); + + return results; + } + + 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({ From d0c7d96efb9d0447dce6667092d94637121c7b1c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Wed, 2 Sep 2026 16:20:11 +0200 Subject: [PATCH 02/23] chore(solana-wallet-snap): add changelog entry --- packages/solana-wallet-snap/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 2858f3678..f5a0b41bd 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `signProofOfOwnershipBatch` for signing multiple proof-of-ownership messages in one request. ([#256](https://github.com/MetaMask/internal-snaps/pull/256)) + ### Changed - Migrate `trackError` and `withCatchAndThrowSnapError` to `@metamask/snap-networks-utils` `createSnapErrorHandling`, and add `getSnapProvider` for Snap RPC access From bbcbc1fd1499dc87f92d8c9bc7897b057754ff67 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 3 Sep 2026 15:30:21 +0200 Subject: [PATCH 03/23] chore(solana-wallet-snap): add JSDocs --- .../onClientRequest/ClientRequestHandler.ts | 16 +++++- .../core/handlers/onClientRequest/types.ts | 4 ++ .../handlers/onClientRequest/validation.ts | 23 ++++++++- .../services/accounts/AccountsRepository.ts | 8 +++ .../core/services/accounts/AccountsService.ts | 6 +++ .../src/core/services/wallet/WalletService.ts | 49 +++++++++++++++++++ 6 files changed, 103 insertions(+), 3 deletions(-) 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 4d4c54757..0dd9306a3 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -51,6 +51,12 @@ import type { SignProofOfOwnershipResponse, } from './validation'; +/** + * Converts an unknown thrown value into a JSON-serializable error message. + * + * @param error - The thrown value. + * @returns A string error message. + */ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -616,9 +622,15 @@ export class ClientRequestHandler { 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}` { - // Transcode the base58 signature to 0x-prefixed hex for the identity - // auth API; the dApp `signMessage` flow keeps its wallet-standard base58. 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 64e0c6bf7..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,10 @@ export const ClientRequestMethod = { SignCardMessage: 'signCardMessage', ApproveCardAmount: 'approveCardAmount', SignProofOfOwnership: 'signProofOfOwnership', + /** + * Silently signs multiple proof-of-ownership messages for MetaMask identity + * authentication. + */ SignProofOfOwnershipBatch: 'signProofOfOwnershipBatch', } as const; 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 1ae4a612c..17dfd702c 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts @@ -523,7 +523,7 @@ export type SignProofOfOwnershipResponse = Infer< >; /** - * signProofOfOwnershipBatch request/response validation. + * 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. @@ -533,10 +533,16 @@ export const SignProofOfOwnershipBatchRequestItemStruct = object({ message: string(), }); +/** + * Validates the params object for `signProofOfOwnershipBatch`. + */ export const SignProofOfOwnershipBatchRequestParamsStruct = object({ items: array(SignProofOfOwnershipBatchRequestItemStruct), }); +/** + * Validates a `signProofOfOwnershipBatch` JSON-RPC request. + */ export const SignProofOfOwnershipBatchRequestStruct = object({ jsonrpc: JsonRpcVersionStruct, id: JsonRpcIdStruct, @@ -544,6 +550,9 @@ export const SignProofOfOwnershipBatchRequestStruct = object({ params: SignProofOfOwnershipBatchRequestParamsStruct, }); +/** + * Validates a successful proof-of-ownership batch item response. + */ export const SignProofOfOwnershipBatchSuccessStruct = object({ accountId: string(), /** @@ -552,20 +561,32 @@ export const SignProofOfOwnershipBatchSuccessStruct = object({ signature: StrictHexStruct, }); +/** + * Validates a failed proof-of-ownership batch item response. + */ export const SignProofOfOwnershipBatchErrorStruct = object({ accountId: string(), error: string(), }); +/** + * 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 ee308f0ff..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,14 @@ 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(); 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 1099ad212..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,12 @@ 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); } 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 c245ff9db..b9e406da3 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -55,21 +55,48 @@ 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; +/** + * Converts an unknown thrown value into a JSON-serializable error message. + * + * @param error - The thrown value. + * @returns A string error message. + */ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * 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, @@ -392,6 +419,17 @@ export class WalletService { 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 { @@ -456,6 +494,17 @@ export class WalletService { 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, From d2ecf2b1b49f31645981f6c59b6302b1d6bd01b2 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 4 Sep 2026 15:26:58 +0200 Subject: [PATCH 04/23] refactor(solana-wallet-snap): use poo utils --- .../onClientRequest/validation.test.ts | 10 ++- .../handlers/onClientRequest/validation.ts | 61 ++++++------------- 2 files changed, 29 insertions(+), 42 deletions(-) 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 1c042d2cf..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 { @@ -433,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. @@ -443,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; } /** @@ -530,17 +514,14 @@ export type SignProofOfOwnershipResponse = Infer< * 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 = object({ - accountId: string(), - message: string(), -}); +export const SignProofOfOwnershipBatchRequestItemStruct = + ProofOfOwnershipBatchRequestItemStruct; /** * Validates the params object for `signProofOfOwnershipBatch`. */ -export const SignProofOfOwnershipBatchRequestParamsStruct = object({ - items: array(SignProofOfOwnershipBatchRequestItemStruct), -}); +export const SignProofOfOwnershipBatchRequestParamsStruct = + ProofOfOwnershipBatchRequestParamsStruct; /** * Validates a `signProofOfOwnershipBatch` JSON-RPC request. @@ -566,10 +547,8 @@ export const SignProofOfOwnershipBatchSuccessStruct = object({ /** * Validates a failed proof-of-ownership batch item response. */ -export const SignProofOfOwnershipBatchErrorStruct = object({ - accountId: string(), - error: string(), -}); +export const SignProofOfOwnershipBatchErrorStruct = + ProofOfOwnershipBatchErrorStruct; /** * Validates a proof-of-ownership batch item result. From a29dc8ac37cb7efc152a6c8951e3d862f9c2364c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 4 Sep 2026 15:57:13 +0200 Subject: [PATCH 05/23] refactor(solana-wallet-snap): use shared error normalization --- .../onClientRequest/ClientRequestHandler.ts | 13 ++----------- .../src/core/services/wallet/WalletService.ts | 15 +++------------ 2 files changed, 5 insertions(+), 23 deletions(-) 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 0dd9306a3..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'; @@ -51,16 +52,6 @@ import type { SignProofOfOwnershipResponse, } from './validation'; -/** - * Converts an unknown thrown value into a JSON-serializable error message. - * - * @param error - The thrown value. - * @returns A string error message. - */ -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - export class ClientRequestHandler { readonly #accountsService: AccountsService; @@ -585,7 +576,7 @@ export class ClientRequestHandler { } catch (error) { results[index] = { accountId, - error: getErrorMessage(error), + error: normalizeError(error).message, }; } }); 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 b9e406da3..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,5 +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'; @@ -78,16 +79,6 @@ export type SolanaSignMessageBatchResult = const DEFAULT_SOLANA_DERIVATION_PATH_REGEX = /^m\/44'\/501'\/([0-9]+)'\/0'$/u; -/** - * Converts an unknown thrown value into a JSON-serializable error message. - * - * @param error - The thrown value. - * @returns A string error message. - */ -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - /** * Extracts the account index from the default Solana BIP-44 derivation path. * @@ -479,12 +470,12 @@ export class WalletService { privateKeyBytes, ); } catch (error) { - results[index] = { error: getErrorMessage(error) }; + results[index] = { error: normalizeError(error).message }; } } } catch (error) { for (const { index } of sourceRequests) { - results[index] = { error: getErrorMessage(error) }; + results[index] = { error: normalizeError(error).message }; } } }, From b75f784f451dc1401f828b280d65de6fc7ae71b4 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 8 Sep 2026 14:13:42 +0200 Subject: [PATCH 06/23] fix(solana-wallet-snap): remove batch method from metamaskMethods --- packages/solana-wallet-snap/src/permissions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/solana-wallet-snap/src/permissions.ts b/packages/solana-wallet-snap/src/permissions.ts index 209f406a3..68cbdbeec 100644 --- a/packages/solana-wallet-snap/src/permissions.ts +++ b/packages/solana-wallet-snap/src/permissions.ts @@ -66,7 +66,6 @@ const metamaskMethods = [ // Client methods ClientRequestMethod.SignAndSendTransactionWithoutConfirmation, ClientRequestMethod.SignProofOfOwnership, - ClientRequestMethod.SignProofOfOwnershipBatch, ]; export const originPermissions = createOriginPermissions({ From e07f941167569511a263668faa6fa667d85013e8 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 8 Sep 2026 18:04:58 +0200 Subject: [PATCH 07/23] fix(solana-wallet-snap): fix sonarcloud issues --- .../handlers/onClientRequest/validation.ts | 50 ++++++++++--------- .../src/core/services/wallet/WalletService.ts | 4 +- 2 files changed, 28 insertions(+), 26 deletions(-) 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 8bc0502d7..a84812f30 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts @@ -1,9 +1,8 @@ import { AssetStruct, FeeType } from '@metamask/keyring-api'; import { parseProofOfOwnershipMessage as parseSharedProofOfOwnershipMessage, - ProofOfOwnershipBatchErrorStruct, - ProofOfOwnershipBatchRequestItemStruct, - ProofOfOwnershipBatchRequestParamsStruct, + ProofOfOwnershipBatchErrorStruct as SignProofOfOwnershipBatchErrorStruct, + ProofOfOwnershipBatchRequestParamsStruct as SignProofOfOwnershipBatchRequestParamsStruct, UuidStruct, } from '@metamask/snap-networks-utils'; import type { ProofOfOwnershipMessage } from '@metamask/snap-networks-utils'; @@ -44,6 +43,30 @@ import { } from '../../validation/structs'; import { ClientRequestMethod } from './types'; +/** + * 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 { + ProofOfOwnershipBatchRequestItemStruct as SignProofOfOwnershipBatchRequestItemStruct, +} from '@metamask/snap-networks-utils'; + +/** + * Validates the params object for `signProofOfOwnershipBatch`. + */ +export { + ProofOfOwnershipBatchRequestParamsStruct as SignProofOfOwnershipBatchRequestParamsStruct, +} from '@metamask/snap-networks-utils'; + +/** + * Validates a failed proof-of-ownership batch item response. + */ +export { + ProofOfOwnershipBatchErrorStruct as SignProofOfOwnershipBatchErrorStruct, +} from '@metamask/snap-networks-utils'; + /** * signAndSendTransactionWithoutConfirmation request/response validation. * TODO: Deprecate this method. @@ -508,21 +531,6 @@ 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. */ @@ -544,12 +552,6 @@ export const SignProofOfOwnershipBatchSuccessStruct = object({ signature: StrictHexStruct, }); -/** - * Validates a failed proof-of-ownership batch item response. - */ -export const SignProofOfOwnershipBatchErrorStruct = - ProofOfOwnershipBatchErrorStruct; - /** * Validates a proof-of-ownership batch item result. */ 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 72c122bb5..8a135dc5e 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -77,7 +77,7 @@ export type SolanaSignMessageBatchResult = | SolanaSignMessageResponse | { error: string }; -const DEFAULT_SOLANA_DERIVATION_PATH_REGEX = /^m\/44'\/501'\/([0-9]+)'\/0'$/u; +const DEFAULT_SOLANA_DERIVATION_PATH_REGEX = /^m\/44'\/501'\/([\d]+)'\/0'$/u; /** * Extracts the account index from the default Solana BIP-44 derivation path. @@ -424,7 +424,7 @@ export class WalletService { async signMessages( requests: SolanaSignMessageBatchRequest[], ): Promise { - this.#logger.log('Signing message batch', { count: requests.length }); + this.#logger.log(`Signing message batch for ${requests.length} requests`); const results: SolanaSignMessageBatchResult[] = new Array(requests.length); const requestsByEntropySource = new Map< From 854f66ea865bfe3adf8a2585dc11224c24567cd1 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 8 Sep 2026 18:10:32 +0200 Subject: [PATCH 08/23] fix(solana-wallet-snap): lint fix --- .../src/core/handlers/onClientRequest/validation.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) 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 a84812f30..4b4136870 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/validation.ts @@ -49,23 +49,17 @@ import { ClientRequestMethod } from './types'; * Batch items intentionally validate messages as plain strings so invalid * proof messages can be reported per item instead of failing the whole batch. */ -export { - ProofOfOwnershipBatchRequestItemStruct as SignProofOfOwnershipBatchRequestItemStruct, -} from '@metamask/snap-networks-utils'; +export { ProofOfOwnershipBatchRequestItemStruct as SignProofOfOwnershipBatchRequestItemStruct } from '@metamask/snap-networks-utils'; /** * Validates the params object for `signProofOfOwnershipBatch`. */ -export { - ProofOfOwnershipBatchRequestParamsStruct as SignProofOfOwnershipBatchRequestParamsStruct, -} from '@metamask/snap-networks-utils'; +export { ProofOfOwnershipBatchRequestParamsStruct as SignProofOfOwnershipBatchRequestParamsStruct } from '@metamask/snap-networks-utils'; /** * Validates a failed proof-of-ownership batch item response. */ -export { - ProofOfOwnershipBatchErrorStruct as SignProofOfOwnershipBatchErrorStruct, -} from '@metamask/snap-networks-utils'; +export { ProofOfOwnershipBatchErrorStruct as SignProofOfOwnershipBatchErrorStruct } from '@metamask/snap-networks-utils'; /** * signAndSendTransactionWithoutConfirmation request/response validation. From 8accd388c2c5c4be56753d9440bba47b8eb7244b Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 8 Sep 2026 18:29:24 +0200 Subject: [PATCH 09/23] fix(solana-wallet-snap): fix sonarcloud issues --- .../src/core/services/wallet/WalletService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 8a135dc5e..d92c24a4a 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -77,7 +77,7 @@ export type SolanaSignMessageBatchResult = | SolanaSignMessageResponse | { error: string }; -const DEFAULT_SOLANA_DERIVATION_PATH_REGEX = /^m\/44'\/501'\/([\d]+)'\/0'$/u; +const DEFAULT_SOLANA_DERIVATION_PATH_REGEX = /^m\/44'\/501'\/(\d+)'\/0'$/u; /** * Extracts the account index from the default Solana BIP-44 derivation path. @@ -424,7 +424,7 @@ export class WalletService { async signMessages( requests: SolanaSignMessageBatchRequest[], ): Promise { - this.#logger.log(`Signing message batch for ${requests.length} requests`); + this.#logger.info(`Signing message batch for ${requests.length} requests`); const results: SolanaSignMessageBatchResult[] = new Array(requests.length); const requestsByEntropySource = new Map< From 37be45f1807d2e0e1dce61268816a2a856213faa Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 11 Sep 2026 11:15:47 -0400 Subject: [PATCH 10/23] chore(solana-wallet-snap): add comment explaining cast --- .../src/core/handlers/onClientRequest/ClientRequestHandler.ts | 2 ++ 1 file changed, 2 insertions(+) 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 167860347..d87c5ec0c 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -586,6 +586,8 @@ export class ClientRequestHandler { ); signedMessages.forEach((signedMessage, signingRequestIndex) => { + // Strip `| undefined` away, both `signingRequests` and `signedMessages` have + // the same size, thus, this is safe to not consider `undefined` here. const { index, accountId } = signingRequests[ signingRequestIndex ] as (typeof signingRequests)[number]; From 3a195e44f27e31560009cce5f113668e83d4140c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 11 Sep 2026 11:54:38 -0400 Subject: [PATCH 11/23] refactor(solana-wallet-snap): address pr comments --- .../solana-wallet-snap/snap.manifest.json | 2 +- .../ClientRequestHandler.test.ts | 32 ++++++++++ .../onClientRequest/ClientRequestHandler.ts | 29 ++++++--- .../handlers/onKeyringRequest/Keyring.test.ts | 12 ++-- .../core/handlers/onKeyringRequest/Keyring.ts | 11 +--- .../accounts/AccountsRepository.test.ts | 25 ++++++++ .../services/accounts/AccountsRepository.ts | 4 +- .../services/wallet/WalletService.test.ts | 27 ++++++++- .../src/core/services/wallet/WalletService.ts | 59 ++++++++++++++----- .../core/test/mocks/utils/getBip32Entropy.ts | 12 ++++ .../src/core/utils/getBip32Entropy.ts | 19 ++++++ 11 files changed, 187 insertions(+), 45 deletions(-) create mode 100644 packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.test.ts diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 74420bafc..4737e3480 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": "Qz6QNLaBjj6RB9FRui24cE1Y8db+mecztdYvWx/rc/M=", + "shasum": "vkJwy+8cKdGnjrvtdZ4Be/u5ACfr4uEgs1PJ1Exyy8Q=", "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 55ec1354c..515c81f97 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 @@ -858,6 +858,38 @@ describe('ClientRequestHandler', () => { ], }); }); + + it('matches account IDs case-insensitively while preserving the requested account ID in the response', async () => { + const message = buildProofMessage(nonce, account0.address); + const uppercaseAccountId = account0.id.toUpperCase(); + mockAccountsService.findByIds.mockResolvedValue([account0]); + mockWalletService.signMessages.mockResolvedValue([ + { + signature: base58Signature, + signedMessage: utf8ToBase64(message), + signatureType: 'ed25519', + }, + ]); + + const result = await handler.handle( + createRequest([{ accountId: uppercaseAccountId, message }]), + ); + + expect(mockAccountsService.findByIds).toHaveBeenCalledWith([ + uppercaseAccountId, + ]); + expect(mockWalletService.signMessages).toHaveBeenCalledWith([ + { account: account0, message: utf8ToBase64(message) }, + ]); + expect(result).toStrictEqual({ + results: [ + { + accountId: uppercaseAccountId, + signature: `0x${'01'.repeat(64)}`, + }, + ], + }); + }); }); describe('signCardMessage', () => { 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 d87c5ec0c..295c72bb2 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -20,7 +20,10 @@ import { fromTransactionToBase64String } from '../../sdk-extensions/codecs'; import type { AccountsService, ApproveTokenService } from '../../services'; import type { SendService } from '../../services/send/SendService'; import type { OnAddressInputRequest } from '../../services/send/types'; -import type { WalletService } from '../../services/wallet/WalletService'; +import type { + SolanaSignMessageBatchResult, + WalletService, +} from '../../services/wallet/WalletService'; import { lamportsToSol } from '../../utils/conversion'; import { ClientRequestMethod } from './types'; import { @@ -52,6 +55,18 @@ import type { SignProofOfOwnershipResponse, } from './validation'; +/** + * Checks whether a batch message-signing result is an item-level error. + * + * @param signedMessage - The result returned by wallet batch signing. + * @returns Whether the result is an error response. + */ +function isSignMessageBatchError( + signedMessage: SolanaSignMessageBatchResult, +): signedMessage is Extract { + return Object.hasOwn(signedMessage, 'error'); +} + export class ClientRequestHandler { readonly #accountsService: AccountsService; @@ -528,7 +543,7 @@ export class ClientRequestHandler { ]; const accounts = await this.#accountsService.findByIds(uniqueAccountIds); const accountsById = new Map( - accounts.map((account) => [account.id, account]), + accounts.map((account) => [account.id.toLowerCase(), account]), ); const results: SignProofOfOwnershipBatchResponse['results'] = new Array( items.length, @@ -541,7 +556,7 @@ export class ClientRequestHandler { }[] = []; items.forEach(({ accountId, message }, index) => { - const account = accountsById.get(accountId); + const account = accountsById.get(accountId.toLowerCase()); if (!account) { results[index] = { accountId, @@ -592,19 +607,17 @@ export class ClientRequestHandler { signingRequestIndex ] as (typeof signingRequests)[number]; - const { error } = signedMessage as { error?: string }; - if (error !== undefined) { + if (isSignMessageBatchError(signedMessage)) { results[index] = { accountId, - error, + error: signedMessage.error, }; return; } - const { signature } = signedMessage as { signature: string }; results[index] = { accountId, - signature: this.#toProofOfOwnershipSignature(signature), + signature: this.#toProofOfOwnershipSignature(signedMessage.signature), }; }); diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts index d310ed679..01ef5e7e1 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts @@ -35,17 +35,14 @@ import { MOCK_SOLANA_KEYRING_ACCOUNT_0, MOCK_SOLANA_KEYRING_ACCOUNT_0_PRIVATE_KEY_BYTES, MOCK_SOLANA_KEYRING_ACCOUNT_1, - MOCK_SOLANA_KEYRING_ACCOUNT_2, MOCK_SOLANA_KEYRING_ACCOUNT_3, - MOCK_SOLANA_KEYRING_ACCOUNT_4, - MOCK_SOLANA_KEYRING_ACCOUNT_5, MOCK_SOLANA_KEYRING_ACCOUNTS, - MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0, - MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_1, } from '../../test/mocks/solana-keyring-accounts'; -import { getBip32EntropyMock } from '../../test/mocks/utils/getBip32Entropy'; +import { + getBip32EntropyMock, + getSolanaCoinTypeNodeMock, +} from '../../test/mocks/utils/getBip32Entropy'; import { trackError } from '../../utils/errors'; -import { getBip32Entropy } from '../../utils/getBip32Entropy'; import logger from '../../utils/logger'; import { SolanaKeyring } from './Keyring'; @@ -56,6 +53,7 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ jest.mock('../../utils/getBip32Entropy', () => ({ getBip32Entropy: getBip32EntropyMock, + getSolanaCoinTypeNode: getSolanaCoinTypeNodeMock, })); jest.mock('../../utils/errors', () => ({ diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts index 8ad63f973..07fbf4b0e 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -1,4 +1,3 @@ -import { SLIP10Node } from '@metamask/key-tree'; import { AccountCreationType, assertCreateAccountOptionIsSupported, @@ -65,8 +64,7 @@ import { deriveSolanaKeypairFromCoinTypeNode, } from '../../utils/deriveSolanaKeypair'; import { trackError } from '../../utils/errors'; -import { getBip32Entropy } from '../../utils/getBip32Entropy'; -import { getLowestUnusedIndex } from '../../utils/getLowestUnusedIndex'; +import { getSolanaCoinTypeNode } from '../../utils/getBip32Entropy'; import { endTrace, listEntropySources, @@ -308,12 +306,7 @@ export class SolanaKeyring implements KeyringSnapRpc { } // Get coin-type node once (optimization: 1 snap API call for N accounts) - const coinTypeNodeJson = await getBip32Entropy({ - entropySource, - path: ['m', "44'", "501'"], - curve: 'ed25519', - }); - const coinTypeNode = await SLIP10Node.fromJSON(coinTypeNodeJson); + const coinTypeNode = await getSolanaCoinTypeNode(entropySource); // Create new accounts in memory, then flush all to state in one call let createdCount = 0; diff --git a/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.test.ts b/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.test.ts new file mode 100644 index 000000000..e1722923a --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.test.ts @@ -0,0 +1,25 @@ +import { InMemoryState } from '@metamask/snap-networks-utils'; + +import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +import { DEFAULT_UNENCRYPTED_STATE } from '../state/stateTypes'; +import { AccountsRepository } from './AccountsRepository'; + +describe('AccountsRepository', () => { + describe('findByIds', () => { + it('matches account IDs case-insensitively', async () => { + const state = new InMemoryState({ + ...DEFAULT_UNENCRYPTED_STATE, + keyringAccounts: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: MOCK_SOLANA_KEYRING_ACCOUNT_0, + }, + }); + const repository = new AccountsRepository(state); + + const accounts = await repository.findByIds([ + MOCK_SOLANA_KEYRING_ACCOUNT_0.id.toUpperCase(), + ]); + + expect(accounts).toStrictEqual([MOCK_SOLANA_KEYRING_ACCOUNT_0]); + }); + }); +}); 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 bd7b8d4cb..1f3a1c256 100644 --- a/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts +++ b/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts @@ -32,10 +32,10 @@ export class AccountsRepository { * ordering, not input ordering. */ async findByIds(ids: string[]): Promise { - const idSet = new Set(ids); + const idSet = new Set(ids.map((id) => id.toLowerCase())); const accounts = await this.getAll(); - return accounts.filter((account) => idSet.has(account.id)); + return accounts.filter((account) => idSet.has(account.id.toLowerCase())); } async findByAddress(address: string): Promise { 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 e74c91f0a..fc07b185a 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 @@ -10,7 +10,10 @@ import { 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 { + getBip32EntropyMock, + getSolanaCoinTypeNodeMock, +} from '../../test/mocks/utils/getBip32Entropy'; import logger from '../../utils/logger'; import { createMockConnection } from '../__mocks__/mockConnection'; import type { AnalyticsService } from '../analytics/AnalyticsService'; @@ -31,6 +34,7 @@ import { WalletService } from './WalletService'; jest.mock('../../utils/getBip32Entropy', () => ({ getBip32Entropy: getBip32EntropyMock, + getSolanaCoinTypeNode: getSolanaCoinTypeNodeMock, })); jest.mock('@metamask/keyring-snap-sdk', () => ({ @@ -83,6 +87,7 @@ describe('WalletService', () => { }; getBip32EntropyMock.mockClear(); + getSolanaCoinTypeNodeMock.mockClear(); }); describe('resolveAccountAddress', () => { @@ -530,9 +535,27 @@ describe('WalletService', () => { expect(result).toStrictEqual([ { - error: "Unsupported Solana derivation path: m/44'/501'/0'", + error: 'Unable to derive private key', + }, + ]); + }); + + it('does not expose derivation error details in batch signing results', async () => { + const sensitiveError = 'derived private key bytes: secret'; + getSolanaCoinTypeNodeMock.mockResolvedValueOnce({ + derive: jest.fn().mockRejectedValue(new Error(sensitiveError)), + } as never); + + const result = await service.signMessages([ + { account: MOCK_SOLANA_KEYRING_ACCOUNT_0, message: utf8ToBase64('a') }, + ]); + + expect(result).toStrictEqual([ + { + error: 'Unable to derive private key', }, ]); + expect(JSON.stringify(result)).not.toContain(sensitiveError); }); }); }); 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 d92c24a4a..fd47be039 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,4 @@ -import { SLIP10Node } from '@metamask/key-tree'; +import type { 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'; @@ -15,6 +15,7 @@ import { getBase64Codec, getSignatureFromTransaction, getUtf8Codec, + pipe, sendTransactionWithoutConfirmingFactory, verifySignature, } from '@solana/kit'; @@ -29,7 +30,7 @@ import { deriveSolanaKeypair, deriveSolanaKeypairFromCoinTypeNode, } from '../../utils/deriveSolanaKeypair'; -import { getBip32Entropy } from '../../utils/getBip32Entropy'; +import { getSolanaCoinTypeNode } from '../../utils/getBip32Entropy'; import { getSolanaExplorerUrl } from '../../utils/getSolanaExplorerUrl'; import logger from '../../utils/logger'; import { Base58Struct, Base64Struct } from '../../validation/structs'; @@ -446,22 +447,14 @@ export class WalletService { [...requestsByEntropySource.entries()].map( async ([entropySource, sourceRequests]) => { try { - const coinTypeNodeJson = await getBip32Entropy({ - entropySource, - path: ['m', "44'", "501'"], - curve: 'ed25519', - }); - const coinTypeNode = await SLIP10Node.fromJSON(coinTypeNodeJson); + const coinTypeNode = await getSolanaCoinTypeNode(entropySource); for (const { index, request } of sourceRequests) { try { - const accountIndex = getDefaultSolanaAccountIndex( - request.account, - ); - const { privateKeyBytes } = - await deriveSolanaKeypairFromCoinTypeNode({ + const privateKeyBytes = + await this.#deriveProofSigningPrivateKey({ coinTypeNode, - accountIndex, + account: request.account, }); results[index] = await this.#signMessageWithPrivateKey( @@ -502,8 +495,11 @@ export class WalletService { privateKeyBytes: Uint8Array, ): Promise { const addressAsAddress = asAddress(account.address); - const messageBytes = getBase64Codec().encode(message); - const messageUtf8 = getUtf8Codec().decode(messageBytes); + const messageUtf8 = pipe( + message, + getBase64Codec().encode, + getUtf8Codec().decode, + ); const signableMessage = createSignableMessage(messageUtf8); const signer = @@ -534,6 +530,37 @@ export class WalletService { return result; } + /** + * Derives private key bytes for an account in the batch proof-signing path. + * + * Derivation errors are deliberately collapsed to a generic message so error + * responses cannot include library context around private key material. + * + * @param params - The derivation parameters. + * @param params.coinTypeNode - The Solana coin-type node for the account's entropy source. + * @param params.account - The account to derive private key bytes for. + * @returns Private key bytes for the account. + */ + async #deriveProofSigningPrivateKey({ + coinTypeNode, + account, + }: { + coinTypeNode: SLIP10Node; + account: SolanaKeyringAccount; + }): Promise { + try { + const accountIndex = getDefaultSolanaAccountIndex(account); + const { privateKeyBytes } = await deriveSolanaKeypairFromCoinTypeNode({ + coinTypeNode, + accountIndex, + }); + + return privateKeyBytes; + } catch { + throw new Error('Unable to derive private key'); + } + } + /** * Signs in to the Solana blockchain. Receives a sign in intent object * that contains data like domain, or uri, then converts it into a message diff --git a/packages/solana-wallet-snap/src/core/test/mocks/utils/getBip32Entropy.ts b/packages/solana-wallet-snap/src/core/test/mocks/utils/getBip32Entropy.ts index 01d8a8237..1d62ff761 100644 --- a/packages/solana-wallet-snap/src/core/test/mocks/utils/getBip32Entropy.ts +++ b/packages/solana-wallet-snap/src/core/test/mocks/utils/getBip32Entropy.ts @@ -38,3 +38,15 @@ export const getBip32EntropyMock = jest }); }, ); + +export const getSolanaCoinTypeNodeMock = jest.fn( + async (entropySource?: string) => { + const coinTypeNodeJson = await getBip32EntropyMock({ + entropySource, + path: ['m', "44'", "501'"], + curve: 'ed25519', + }); + + return await SLIP10Node.fromJSON(coinTypeNodeJson); + }, +); diff --git a/packages/solana-wallet-snap/src/core/utils/getBip32Entropy.ts b/packages/solana-wallet-snap/src/core/utils/getBip32Entropy.ts index 64dc0a1fc..a6086da0c 100644 --- a/packages/solana-wallet-snap/src/core/utils/getBip32Entropy.ts +++ b/packages/solana-wallet-snap/src/core/utils/getBip32Entropy.ts @@ -1,3 +1,4 @@ +import { SLIP10Node } from '@metamask/key-tree'; import type { JsonSLIP10Node } from '@metamask/key-tree'; import type { EntropySourceId } from '@metamask/keyring-api'; @@ -30,3 +31,21 @@ export async function getBip32Entropy({ return node; } + +/** + * Retrieves the Solana coin-type node (`m/44'/501'`) for an entropy source. + * + * @param entropySource - The entropy source to use for key derivation. + * @returns A Promise that resolves to the Solana coin-type `SLIP10Node`. + */ +export async function getSolanaCoinTypeNode( + entropySource?: EntropySourceId | undefined, +): Promise { + const coinTypeNodeJson = await getBip32Entropy({ + entropySource, + path: ['m', "44'", "501'"], + curve: 'ed25519', + }); + + return await SLIP10Node.fromJSON(coinTypeNodeJson); +} From 22b3c14309c2ef3e31515695c3e5189a090085af Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 11 Sep 2026 12:03:18 -0400 Subject: [PATCH 12/23] refactor(solana-wallet-snap): use account index directly instead of helper --- .../services/wallet/WalletService.test.ts | 15 ++++---- .../src/core/services/wallet/WalletService.ts | 35 +------------------ 2 files changed, 9 insertions(+), 41 deletions(-) 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 fc07b185a..6c7a81564 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 @@ -522,22 +522,23 @@ describe('WalletService', () => { expect(getBip32EntropyMock).toHaveBeenCalledTimes(2); }); - it('returns an item-level error for unsupported derivation paths', async () => { + it('uses account.index for batch derivation', async () => { + const message = utf8ToBase64('proof message'); + const result = await service.signMessages([ { account: { ...MOCK_SOLANA_KEYRING_ACCOUNT_0, derivationPath: "m/44'/501'/0'", }, - message: utf8ToBase64('a'), + message, }, ]); - expect(result).toStrictEqual([ - { - error: 'Unable to derive private key', - }, - ]); + expect(result[0]).toMatchObject({ + signedMessage: message, + signatureType: 'ed25519', + }); }); it('does not expose derivation error details in batch signing results', async () => { 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 fd47be039..97cfce4bd 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -78,38 +78,6 @@ export type SolanaSignMessageBatchResult = | SolanaSignMessageResponse | { error: string }; -const DEFAULT_SOLANA_DERIVATION_PATH_REGEX = /^m\/44'\/501'\/(\d+)'\/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; @@ -549,10 +517,9 @@ export class WalletService { account: SolanaKeyringAccount; }): Promise { try { - const accountIndex = getDefaultSolanaAccountIndex(account); const { privateKeyBytes } = await deriveSolanaKeypairFromCoinTypeNode({ coinTypeNode, - accountIndex, + accountIndex: account.index, }); return privateKeyBytes; From 235f77114c03ea6f4ad2cd9089e87ec43edecbd6 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 11 Sep 2026 12:18:17 -0400 Subject: [PATCH 13/23] refactor(solana-wallet-snap): avoid duplicate O(n) scan --- .../onClientRequest/ClientRequestHandler.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 295c72bb2..b5e5c9fc8 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -21,6 +21,7 @@ import type { AccountsService, ApproveTokenService } from '../../services'; import type { SendService } from '../../services/send/SendService'; import type { OnAddressInputRequest } from '../../services/send/types'; import type { + SolanaSignMessageBatchRequest, SolanaSignMessageBatchResult, WalletService, } from '../../services/wallet/WalletService'; @@ -555,6 +556,8 @@ export class ClientRequestHandler { message: string; }[] = []; + const batchRequests: SolanaSignMessageBatchRequest[] = []; + items.forEach(({ accountId, message }, index) => { const account = accountsById.get(accountId.toLowerCase()); if (!account) { @@ -582,12 +585,18 @@ export class ClientRequestHandler { getUtf8Codec().encode, getBase64Codec().decode, ); + signingRequests.push({ index, accountId, account, message: base64Message, }); + + batchRequests.push({ + account, + message: base64Message, + }); } catch (error) { results[index] = { accountId, @@ -596,9 +605,8 @@ export class ClientRequestHandler { } }); - const signedMessages = await this.#walletService.signMessages( - signingRequests.map(({ account, message }) => ({ account, message })), - ); + const signedMessages = + await this.#walletService.signMessages(batchRequests); signedMessages.forEach((signedMessage, signingRequestIndex) => { // Strip `| undefined` away, both `signingRequests` and `signedMessages` have From f06cecd07d3cfc4008ddb49293c431701941d830 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 11 Sep 2026 12:56:24 -0400 Subject: [PATCH 14/23] refactor(solana-wallet-snap): rename vars and remove unnecessary props --- .../onClientRequest/ClientRequestHandler.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) 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 b5e5c9fc8..26d4bf8d1 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onClientRequest/ClientRequestHandler.ts @@ -549,14 +549,12 @@ export class ClientRequestHandler { const results: SignProofOfOwnershipBatchResponse['results'] = new Array( items.length, ); - const signingRequests: { + const signingRequestsMetadata: { index: number; accountId: string; - account: (typeof accounts)[number]; - message: string; }[] = []; - const batchRequests: SolanaSignMessageBatchRequest[] = []; + const signingRequests: SolanaSignMessageBatchRequest[] = []; items.forEach(({ accountId, message }, index) => { const account = accountsById.get(accountId.toLowerCase()); @@ -586,14 +584,12 @@ export class ClientRequestHandler { getBase64Codec().decode, ); - signingRequests.push({ + signingRequestsMetadata.push({ index, accountId, - account, - message: base64Message, }); - batchRequests.push({ + signingRequests.push({ account, message: base64Message, }); @@ -606,14 +602,14 @@ export class ClientRequestHandler { }); const signedMessages = - await this.#walletService.signMessages(batchRequests); + await this.#walletService.signMessages(signingRequests); signedMessages.forEach((signedMessage, signingRequestIndex) => { // Strip `| undefined` away, both `signingRequests` and `signedMessages` have // the same size, thus, this is safe to not consider `undefined` here. - const { index, accountId } = signingRequests[ + const { index, accountId } = signingRequestsMetadata[ signingRequestIndex - ] as (typeof signingRequests)[number]; + ] as (typeof signingRequestsMetadata)[number]; if (isSignMessageBatchError(signedMessage)) { results[index] = { From 20ddb05501fa06ab45adb9b2a03d2830d8fba18c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 11 Sep 2026 13:00:09 -0400 Subject: [PATCH 15/23] fix(solana-wallet-snap): lint fix --- eslint-suppressions.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 8026b9d22..031e7934a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -221,9 +221,6 @@ "packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 - }, - "@typescript-eslint/no-unused-vars": { - "count": 6 } }, "packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts": { @@ -231,7 +228,7 @@ "count": 10 }, "@typescript-eslint/no-unused-vars": { - "count": 3 + "count": 2 }, "@typescript-eslint/only-throw-error": { "count": 1 From 4b3d9559ca505f93f157826ffba3fd66761e527d Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 14 Sep 2026 06:58:30 -0400 Subject: [PATCH 16/23] refactor(solana-wallet-snap): apply code review --- .../src/core/services/wallet/WalletService.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 6d5c787cc..e88ad3046 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -420,11 +420,10 @@ export class WalletService { for (const { index, request } of sourceRequests) { try { - const privateKeyBytes = - await this.#deriveProofSigningPrivateKey({ - coinTypeNode, - account: request.account, - }); + const privateKeyBytes = await this.#deriveSigningPrivateKey({ + coinTypeNode, + account: request.account, + }); results[index] = await this.#signMessageWithPrivateKey( request.account, @@ -510,7 +509,7 @@ export class WalletService { * @param params.account - The account to derive private key bytes for. * @returns Private key bytes for the account. */ - async #deriveProofSigningPrivateKey({ + async #deriveSigningPrivateKey({ coinTypeNode, account, }: { From 4d8b528b95878c4e798fa997f8a51b89debbeb48 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 14 Sep 2026 10:35:45 -0400 Subject: [PATCH 17/23] fix(solana-wallet-snap): update jsdoc --- .../src/core/services/wallet/WalletService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e88ad3046..441291fd7 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -499,7 +499,7 @@ export class WalletService { } /** - * Derives private key bytes for an account in the batch proof-signing path. + * Derives private key bytes for signing. * * Derivation errors are deliberately collapsed to a generic message so error * responses cannot include library context around private key material. From 364dc206b6292c21c86cd34fa90c9359e36d63d4 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 14 Sep 2026 11:57:10 -0400 Subject: [PATCH 18/23] test(solana-wallet-snap): add test for when ids are not found in getByIds --- .../accounts/AccountsRepository.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.test.ts b/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.test.ts index e1722923a..84899f2da 100644 --- a/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.test.ts +++ b/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.test.ts @@ -21,5 +21,22 @@ describe('AccountsRepository', () => { expect(accounts).toStrictEqual([MOCK_SOLANA_KEYRING_ACCOUNT_0]); }); + + it('filters accounts that are not found', async () => { + const state = new InMemoryState({ + ...DEFAULT_UNENCRYPTED_STATE, + keyringAccounts: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: MOCK_SOLANA_KEYRING_ACCOUNT_0, + }, + }); + const repository = new AccountsRepository(state); + + const accounts = await repository.findByIds([ + MOCK_SOLANA_KEYRING_ACCOUNT_0.id.toUpperCase(), + 'non-existent-id', + ]); + + expect(accounts).toStrictEqual([MOCK_SOLANA_KEYRING_ACCOUNT_0]); + }); }); }); From b320f4ffcb6b7ce7d2e97d3bc55ce802c5eba309 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 14 Sep 2026 12:30:36 -0400 Subject: [PATCH 19/23] test(solana-wallet-snap): update tests --- .../ClientRequestHandler.test.ts | 10 ++++--- .../services/wallet/WalletService.test.ts | 26 +++++++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) 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 515c81f97..9a218fa81 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 @@ -764,6 +764,8 @@ describe('ClientRequestHandler', () => { params: { items }, }); + const hexSignature = `0x${'01'.repeat(64)}`; + 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); @@ -798,8 +800,8 @@ describe('ClientRequestHandler', () => { ]); expect(result).toStrictEqual({ results: [ - { accountId: account0.id, signature: `0x${'01'.repeat(64)}` }, - { accountId: account1.id, signature: `0x${'01'.repeat(64)}` }, + { accountId: account0.id, signature: hexSignature }, + { accountId: account1.id, signature: hexSignature }, ], }); }); @@ -828,7 +830,7 @@ describe('ClientRequestHandler', () => { expect(mockWalletService.signMessages).toHaveBeenCalledTimes(1); expect(result).toStrictEqual({ results: [ - { accountId: account0.id, signature: `0x${'01'.repeat(64)}` }, + { accountId: account0.id, signature: hexSignature }, { accountId: missingAccountId, error: `Account not found: ${missingAccountId}`, @@ -885,7 +887,7 @@ describe('ClientRequestHandler', () => { results: [ { accountId: uppercaseAccountId, - signature: `0x${'01'.repeat(64)}`, + signature: hexSignature, }, ], }); 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 6c7a81564..11bc3d5c2 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 @@ -7,6 +7,7 @@ import { MOCK_SOLANA_KEYRING_ACCOUNT_2, MOCK_SOLANA_KEYRING_ACCOUNT_3, MOCK_SOLANA_KEYRING_ACCOUNT_4, + MOCK_SOLANA_KEYRING_ACCOUNT_5, MOCK_SOLANA_KEYRING_ACCOUNTS, MOCK_SOLANA_SEED_PHRASE_2_KEYRING_ACCOUNT_0, } from '../../test/mocks/solana-keyring-accounts'; @@ -522,19 +523,34 @@ describe('WalletService', () => { expect(getBip32EntropyMock).toHaveBeenCalledTimes(2); }); - it('uses account.index for batch derivation', async () => { + it('uses account.index instead of derivationPath for batch derivation', async () => { const message = utf8ToBase64('proof message'); + const account = { + ...MOCK_SOLANA_KEYRING_ACCOUNT_0, + derivationPath: MOCK_SOLANA_KEYRING_ACCOUNT_5.derivationPath, + }; + const coinTypeNode = await getSolanaCoinTypeNodeMock( + account.entropySource, + ); + const deriveMock = jest.fn(coinTypeNode.derive.bind(coinTypeNode)); + + getSolanaCoinTypeNodeMock.mockClear(); + getSolanaCoinTypeNodeMock.mockResolvedValueOnce({ + derive: deriveMock, + } as never); const result = await service.signMessages([ { - account: { - ...MOCK_SOLANA_KEYRING_ACCOUNT_0, - derivationPath: "m/44'/501'/0'", - }, + account, message, }, ]); + expect(deriveMock).toHaveBeenCalledWith([ + `slip10:${account.index}'`, + `slip10:0'`, + ]); + expect(deriveMock).not.toHaveBeenCalledWith(["slip10:5'", "slip10:0'"]); expect(result[0]).toMatchObject({ signedMessage: message, signatureType: 'ed25519', From 51d89bbf834ec3b175e63b5f3321e669916567e8 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 15 Sep 2026 12:01:17 -0400 Subject: [PATCH 20/23] fix(solana-wallet-snap): fix lint issues --- .../src/core/handlers/onKeyringRequest/Keyring.test.ts | 1 + .../src/core/handlers/onKeyringRequest/Keyring.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts index b01e537ed..100358d38 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts @@ -43,6 +43,7 @@ import { getSolanaCoinTypeNodeMock, } from '../../test/mocks/utils/getBip32Entropy'; import { trackError } from '../../utils/errors'; +import { getBip32Entropy } from '../../utils/getBip32Entropy'; import logger from '../../utils/logger'; import { SolanaKeyring } from './Keyring'; diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts index a576895f3..e8f00bc15 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -6,6 +6,7 @@ import { SolMethod, SolScope, } from '@metamask/keyring-api'; +import { SLIP10Node } from '@metamask/key-tree'; import type { CreateAccountOptions, EntropySourceId, @@ -64,7 +65,7 @@ import { deriveSolanaKeypairFromCoinTypeNode, } from '../../utils/deriveSolanaKeypair'; import { trackError } from '../../utils/errors'; -import { getSolanaCoinTypeNode } from '../../utils/getBip32Entropy'; +import { getBip32Entropy } from '../../utils/getBip32Entropy'; import { endTrace, listEntropySources, From 7651c147cebae03e37ef2a402085774ff38e1e9d Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 15 Sep 2026 16:06:29 -0400 Subject: [PATCH 21/23] fix(solana-wallet-snap): lint fix --- .../src/core/handlers/onKeyringRequest/Keyring.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts index e8f00bc15..4a0d2e978 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -1,3 +1,4 @@ +import { SLIP10Node } from '@metamask/key-tree'; import { AccountCreationType, assertCreateAccountOptionIsSupported, @@ -6,7 +7,6 @@ import { SolMethod, SolScope, } from '@metamask/keyring-api'; -import { SLIP10Node } from '@metamask/key-tree'; import type { CreateAccountOptions, EntropySourceId, @@ -33,7 +33,7 @@ import { SnapError, UserRejectedRequestError, } from '@metamask/snaps-sdk'; -import { array, assert, integer, is } from '@metamask/superstruct'; +import { array, assert, is } from '@metamask/superstruct'; import type { CaipChainId } from '@metamask/utils'; import type { Signature } from '@solana/kit'; import { address as asAddress, getAddressDecoder } from '@solana/kit'; From f31e72f70e48f2beca3d3a64997655bddcb9cb15 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 15 Sep 2026 16:39:38 -0400 Subject: [PATCH 22/23] fix(solana-wallet-snap): fix suppressions --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 8a30cf697..417a808ec 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -215,7 +215,7 @@ "count": 10 }, "@typescript-eslint/no-unused-vars": { - "count": 2 + "count": 1 }, "@typescript-eslint/only-throw-error": { "count": 1 From 4c31b6a9976c03947ab47391054c4c228d9d9bf7 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Wed, 16 Sep 2026 13:05:10 -0400 Subject: [PATCH 23/23] fix(solana-wallet-snap): use ExtendedKeyringAccount --- .../src/core/services/wallet/WalletService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fb53e11b3..27e0a3628 100644 --- a/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts +++ b/packages/solana-wallet-snap/src/core/services/wallet/WalletService.ts @@ -67,7 +67,7 @@ export type SolanaSignMessageBatchRequest = { /** * Account whose key should sign the message. */ - account: SolanaKeyringAccount; + account: ExtendedKeyringAccount; /** * Base64-encoded message to sign. */