Skip to content
1 change: 1 addition & 0 deletions packages/solana-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `signProofOfOwnershipBatch` for signing multiple proof-of-ownership messages in one request. ([#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
Expand Down
2 changes: 1 addition & 1 deletion packages/solana-wallet-snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/internal-snaps.git"
},
"source": {
"shasum": "3XId1IkqlIty92kiBLHbDxIv1RY8AnZiGvOiTOnTaJI=",
"shasum": "Qz6QNLaBjj6RB9FRui24cE1Y8db+mecztdYvWx/rc/M=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ describe('ClientRequestHandler', () => {
// Create mock keyring
mockAccountsService = {
findById: jest.fn(),
findByIds: jest.fn(),
findByAddress: jest.fn(),
} as unknown as jest.Mocked<AccountsService>;

// Create mock wallet service
mockWalletService = {
signAndSendTransaction: jest.fn(),
signMessage: jest.fn(),
signMessages: jest.fn(),
} as unknown as jest.Mocked<WalletService>;

// Create mock logger
Expand Down Expand Up @@ -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 =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,6 +38,8 @@ import {
SignAndSendTransactionResponseStruct,
SignAndSendTransactionWithoutConfirmationRequestStruct,
SignCardMessageRequestStruct,
SignProofOfOwnershipBatchRequestStruct,
SignProofOfOwnershipBatchResponseStruct,
SignProofOfOwnershipRequestStruct,
SignProofOfOwnershipResponseStruct,
SignRewardsMessageRequestStruct,
Expand All @@ -45,6 +48,7 @@ import {
import type {
ComputeFeeResponse,
SignAndSendTransactionResponse,
SignProofOfOwnershipBatchResponse,
SignProofOfOwnershipResponse,
} from './validation';

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -490,16 +496,134 @@ 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 };

assert(result, SignProofOfOwnershipResponseStruct);

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<Json> {
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)),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading