Skip to content

Commit a5748b0

Browse files
authored
feat: integrate the 'signRewardsMessage' flow (#566)
* feat: add the parse validation part of the RewardsMessage * feat: use atob for base64 decoding * test: add unit tests related to parseRewardsMessage validation * feat: add validation of address * feat: add signature * chore: remove unused variable * test: add unit test for signMessageDirect * fix: update snap manifest version * feat: refactor using accountUseCases.signMessage * chore: update signMessage skipConfirmation flag to have an option visibility * add metamask as origin for signMessage from signRewardsMessage
1 parent a3a63d4 commit a5748b0

File tree

7 files changed

+419
-7
lines changed

7 files changed

+419
-7
lines changed

packages/snap/snap.manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
2-
"version": "1.5.0",
2+
"version": "1.6.0",
33
"description": "Manage Bitcoin using MetaMask",
44
"proposedName": "Bitcoin",
55
"repository": {
66
"type": "git",
77
"url": "https://github.com/MetaMask/snap-bitcoin-wallet.git"
88
},
99
"source": {
10-
"shasum": "j9W3ulkpAGrL0lkkChX7wavGEY3qq6nCf4lQ0KmmF78=",
10+
"shasum": "TPFaRAwYCn6fMO0uaOcIMxprEPRPjX8aB0JViJv5gWA=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",

packages/snap/src/handlers/RpcHandler.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,4 +967,100 @@ describe('RpcHandler', () => {
967967
});
968968
});
969969
});
970+
971+
describe('signRewardsMessage', () => {
972+
const mockBitcoinAccount = mock<BitcoinAccount>({
973+
id: validAccountId,
974+
publicAddress: {
975+
toString: () => 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k',
976+
},
977+
network: 'bitcoin',
978+
});
979+
980+
it('successfully signs a valid rewards message', async () => {
981+
const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k';
982+
const timestamp = 1736660000;
983+
const message = btoa(`rewards,${address},${timestamp}`);
984+
985+
mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount);
986+
jest
987+
.mocked(Address.from_string)
988+
.mockReturnValue(mockBitcoinAccount.publicAddress);
989+
990+
const signMessageSpy = jest
991+
.spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases)
992+
.mockResolvedValue('mock-signature-base64' as never);
993+
994+
const request: JsonRpcRequest = {
995+
jsonrpc: '2.0',
996+
id: '1',
997+
method: RpcMethod.SignRewardsMessage,
998+
params: {
999+
accountId: validAccountId,
1000+
message,
1001+
},
1002+
};
1003+
1004+
const result = await handler.route(origin, request);
1005+
1006+
expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId);
1007+
expect(Address.from_string).toHaveBeenCalledWith(address, 'bitcoin');
1008+
expect(signMessageSpy).toHaveBeenCalledWith(
1009+
validAccountId,
1010+
`rewards,${address},${timestamp}`,
1011+
'metamask',
1012+
{ skipConfirmation: true },
1013+
);
1014+
expect(result).toStrictEqual({ signature: 'mock-signature-base64' });
1015+
});
1016+
1017+
it('throws error when address in message does not match account', async () => {
1018+
const differentAddress = 'bc1qdifferentaddress123456789abcdefgh';
1019+
const timestamp = 1736660000;
1020+
const message = btoa(`rewards,${differentAddress},${timestamp}`);
1021+
1022+
mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount);
1023+
jest
1024+
.mocked(Address.from_string)
1025+
.mockReturnValue(mockBitcoinAccount.publicAddress);
1026+
1027+
const request: JsonRpcRequest = {
1028+
jsonrpc: '2.0',
1029+
id: '1',
1030+
method: RpcMethod.SignRewardsMessage,
1031+
params: {
1032+
accountId: validAccountId,
1033+
message,
1034+
},
1035+
};
1036+
1037+
await expect(handler.route(origin, request)).rejects.toThrow(
1038+
'does not match signing account address',
1039+
);
1040+
});
1041+
1042+
it('throws error when account is not found', async () => {
1043+
const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k';
1044+
const timestamp = 1736660000;
1045+
const message = btoa(`rewards,${address},${timestamp}`);
1046+
1047+
mockAccountsUseCases.get.mockResolvedValue(
1048+
null as unknown as BitcoinAccount,
1049+
);
1050+
1051+
const request: JsonRpcRequest = {
1052+
jsonrpc: '2.0',
1053+
id: '1',
1054+
method: RpcMethod.SignRewardsMessage,
1055+
params: {
1056+
accountId: 'non-existent-account',
1057+
message,
1058+
},
1059+
};
1060+
1061+
await expect(handler.route(origin, request)).rejects.toThrow(
1062+
'Account not found',
1063+
);
1064+
});
1065+
});
9701066
});

packages/snap/src/handlers/RpcHandler.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
validateAddress,
3535
validateAccountBalance,
3636
validateDustLimit,
37+
parseRewardsMessage,
3738
} from './validation';
3839

3940
export const CreateSendFormRequest = object({
@@ -64,6 +65,11 @@ export const VerifyMessageRequest = object({
6465
signature: string(),
6566
});
6667

68+
export const SignRewardsMessageRequest = object({
69+
accountId: string(),
70+
message: string(),
71+
});
72+
6773
export class RpcHandler {
6874
readonly #logger: Logger;
6975

@@ -124,6 +130,10 @@ export class RpcHandler {
124130
params.signature,
125131
);
126132
}
133+
case RpcMethod.SignRewardsMessage: {
134+
assert(params, SignRewardsMessageRequest);
135+
return this.#signRewardsMessage(params.accountId, params.message);
136+
}
127137

128138
default:
129139
throw new InexistentMethodError(`Method not found: ${method}`);
@@ -296,4 +306,55 @@ export class RpcHandler {
296306
throw error;
297307
}
298308
}
309+
310+
/**
311+
* Handles the signing of a rewards message, of format 'rewards,{address},{timestamp}' base64 encoded.
312+
*
313+
* @param accountId - The ID of the account to sign with
314+
* @param message - The base64-encoded rewards message
315+
* @returns The signature
316+
* @throws {ValidationError} If the account is not found or if the address in the message doesn't match the signing account
317+
*/
318+
async #signRewardsMessage(
319+
accountId: string,
320+
message: string,
321+
): Promise<{ signature: string }> {
322+
const { address: messageAddress } = parseRewardsMessage(message);
323+
324+
const account = await this.#accountUseCases.get(accountId);
325+
if (!account) {
326+
throw new ValidationError('Account not found', { accountId });
327+
}
328+
329+
const addressValidation = validateAddress(
330+
messageAddress,
331+
account.network,
332+
this.#logger,
333+
);
334+
if (!addressValidation.valid) {
335+
throw new ValidationError(
336+
`Invalid Bitcoin address in rewards message for network ${account.network}`,
337+
{ messageAddress, network: account.network },
338+
);
339+
}
340+
341+
const accountAddress = account.publicAddress.toString();
342+
if (messageAddress !== accountAddress) {
343+
throw new ValidationError(
344+
`Address in rewards message (${messageAddress}) does not match signing account address (${accountAddress})`,
345+
{ messageAddress, accountAddress },
346+
);
347+
}
348+
349+
const decodedMessage = atob(message);
350+
351+
const signature = await this.#accountUseCases.signMessage(
352+
accountId,
353+
decodedMessage,
354+
'metamask',
355+
{ skipConfirmation: true },
356+
);
357+
358+
return { signature };
359+
}
299360
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { parseRewardsMessage } from './validation';
2+
3+
/* eslint-disable @typescript-eslint/naming-convention */
4+
jest.mock('@metamask/bitcoindevkit', () => ({
5+
Address: {
6+
from_string: jest.fn(),
7+
},
8+
Amount: {
9+
from_btc: jest.fn(),
10+
},
11+
}));
12+
13+
describe('validation', () => {
14+
describe('parseRewardsMessage', () => {
15+
const validBitcoinMainnetAddress =
16+
'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq';
17+
const currentTimestamp = Math.floor(Date.now() / 1000);
18+
19+
const toBase64 = (utf8: string): string => btoa(utf8);
20+
21+
describe('valid parsing', () => {
22+
it('correctly extracts address and timestamp from valid message', () => {
23+
const expectedAddress = validBitcoinMainnetAddress;
24+
const expectedTimestamp = 1736660000;
25+
const utf8Message = `rewards,${expectedAddress},${expectedTimestamp}`;
26+
const base64Message = toBase64(utf8Message);
27+
28+
const result = parseRewardsMessage(base64Message);
29+
30+
expect(result.address).toBe(expectedAddress);
31+
expect(result.timestamp).toBe(expectedTimestamp);
32+
});
33+
});
34+
35+
describe('invalid messages', () => {
36+
it('rejects string that decodes but not rewards format', () => {
37+
const base64Message = 'hello world';
38+
expect(() => parseRewardsMessage(base64Message)).toThrow(
39+
'Message must start with "rewards,"',
40+
);
41+
});
42+
43+
it('rejects empty string', () => {
44+
const base64Message = '';
45+
expect(() => parseRewardsMessage(base64Message)).toThrow(
46+
'Message must start with "rewards,"',
47+
);
48+
});
49+
50+
it('rejects invalid base64 with special characters', () => {
51+
const base64Message = '!!!@@@###';
52+
expect(() => parseRewardsMessage(base64Message)).toThrow(
53+
'Invalid base64 encoding',
54+
);
55+
});
56+
});
57+
58+
describe('invalid message prefix', () => {
59+
it.each([
60+
{
61+
message: `reward,${validBitcoinMainnetAddress},${currentTimestamp}`,
62+
description: "missing 's'",
63+
},
64+
{
65+
message: `Rewards,${validBitcoinMainnetAddress},${currentTimestamp}`,
66+
description: 'wrong case (capitalized)',
67+
},
68+
{
69+
message: `bonus,${validBitcoinMainnetAddress},${currentTimestamp}`,
70+
description: 'wrong prefix',
71+
},
72+
{
73+
message: `${validBitcoinMainnetAddress},${currentTimestamp}`,
74+
description: 'no prefix',
75+
},
76+
{
77+
message: `REWARDS,${validBitcoinMainnetAddress},${currentTimestamp}`,
78+
description: 'all caps',
79+
},
80+
])(
81+
'rejects message that does not start with "rewards,": $description',
82+
({ message: utf8Message }) => {
83+
const base64Message = toBase64(utf8Message);
84+
expect(() => parseRewardsMessage(base64Message)).toThrow(
85+
'Message must start with "rewards,"',
86+
);
87+
},
88+
);
89+
});
90+
91+
describe('invalid message structure', () => {
92+
it('rejects message with only prefix', () => {
93+
const utf8Message = 'rewards,';
94+
const base64Message = toBase64(utf8Message);
95+
expect(() => parseRewardsMessage(base64Message)).toThrow(
96+
'Message must have exactly 3 parts',
97+
);
98+
});
99+
100+
it('rejects message missing timestamp', () => {
101+
const utf8Message = `rewards,${validBitcoinMainnetAddress}`;
102+
const base64Message = toBase64(utf8Message);
103+
expect(() => parseRewardsMessage(base64Message)).toThrow(
104+
'Message must have exactly 3 parts',
105+
);
106+
});
107+
108+
it('rejects message with too many parts', () => {
109+
const utf8Message = `rewards,${validBitcoinMainnetAddress},${currentTimestamp},extra`;
110+
const base64Message = toBase64(utf8Message);
111+
expect(() => parseRewardsMessage(base64Message)).toThrow(
112+
'Message must have exactly 3 parts',
113+
);
114+
});
115+
116+
it('rejects message with empty parts', () => {
117+
const utf8Message = 'rewards,,,';
118+
const base64Message = toBase64(utf8Message);
119+
expect(() => parseRewardsMessage(base64Message)).toThrow(/timestamp/iu);
120+
});
121+
122+
it('rejects message with only prefix without comma', () => {
123+
const utf8Message = 'rewards';
124+
const base64Message = toBase64(utf8Message);
125+
expect(() => parseRewardsMessage(base64Message)).toThrow(
126+
'Message must start with "rewards,"',
127+
);
128+
});
129+
});
130+
131+
describe('invalid timestamps', () => {
132+
it.each([
133+
{
134+
timestamp: 'invalid',
135+
description: 'non-numeric',
136+
},
137+
{
138+
timestamp: '',
139+
description: 'empty timestamp',
140+
},
141+
{
142+
timestamp: '-1',
143+
description: 'negative timestamp',
144+
},
145+
{
146+
timestamp: '0',
147+
description: 'zero timestamp',
148+
},
149+
{
150+
timestamp: '123.456',
151+
description: 'decimal timestamp',
152+
},
153+
{
154+
timestamp: '1.0',
155+
description: 'decimal with .0',
156+
},
157+
{
158+
timestamp: 'abc123',
159+
description: 'alphanumeric',
160+
},
161+
])(
162+
'rejects message with invalid timestamp: $description',
163+
({ timestamp }) => {
164+
const utf8Message = `rewards,${validBitcoinMainnetAddress},${timestamp}`;
165+
const base64Message = toBase64(utf8Message);
166+
167+
expect(() => parseRewardsMessage(base64Message)).toThrow(
168+
/timestamp/iu,
169+
);
170+
},
171+
);
172+
});
173+
});
174+
});

0 commit comments

Comments
 (0)