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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/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": "Szf70/ghTVTeyeEcPUPnHGKxE+tWaZBEZJuR4Kh+gks=",
"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