Skip to content
Merged
4 changes: 2 additions & 2 deletions packages/snap/snap.manifest.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"version": "1.5.0",
"version": "1.6.0",
"description": "Manage Bitcoin using MetaMask",
"proposedName": "Bitcoin",
"repository": {
"type": "git",
"url": "https://github.com/MetaMask/snap-bitcoin-wallet.git"
},
"source": {
"shasum": "j9W3ulkpAGrL0lkkChX7wavGEY3qq6nCf4lQ0KmmF78=",
"shasum": "TPFaRAwYCn6fMO0uaOcIMxprEPRPjX8aB0JViJv5gWA=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
96 changes: 96 additions & 0 deletions packages/snap/src/handlers/RpcHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,4 +967,100 @@ describe('RpcHandler', () => {
});
});
});

describe('signRewardsMessage', () => {
const mockBitcoinAccount = mock<BitcoinAccount>({
id: validAccountId,
publicAddress: {
toString: () => 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k',
},
network: 'bitcoin',
});

it('successfully signs a valid rewards message', async () => {
const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k';
const timestamp = 1736660000;
const message = btoa(`rewards,${address},${timestamp}`);

mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount);
jest
.mocked(Address.from_string)
.mockReturnValue(mockBitcoinAccount.publicAddress);

const signMessageSpy = jest
.spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases)
.mockResolvedValue('mock-signature-base64' as never);

const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: '1',
method: RpcMethod.SignRewardsMessage,
params: {
accountId: validAccountId,
message,
},
};

const result = await handler.route(origin, request);

expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId);
expect(Address.from_string).toHaveBeenCalledWith(address, 'bitcoin');
expect(signMessageSpy).toHaveBeenCalledWith(
validAccountId,
`rewards,${address},${timestamp}`,
'metamask',
{ skipConfirmation: true },
);
expect(result).toStrictEqual({ signature: 'mock-signature-base64' });
});

it('throws error when address in message does not match account', async () => {
const differentAddress = 'bc1qdifferentaddress123456789abcdefgh';
const timestamp = 1736660000;
const message = btoa(`rewards,${differentAddress},${timestamp}`);

mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount);
jest
.mocked(Address.from_string)
.mockReturnValue(mockBitcoinAccount.publicAddress);

const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: '1',
method: RpcMethod.SignRewardsMessage,
params: {
accountId: validAccountId,
message,
},
};

await expect(handler.route(origin, request)).rejects.toThrow(
'does not match signing account address',
);
});

it('throws error when account is not found', async () => {
const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k';
const timestamp = 1736660000;
const message = btoa(`rewards,${address},${timestamp}`);

mockAccountsUseCases.get.mockResolvedValue(
null as unknown as BitcoinAccount,
);

const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: '1',
method: RpcMethod.SignRewardsMessage,
params: {
accountId: 'non-existent-account',
message,
},
};

await expect(handler.route(origin, request)).rejects.toThrow(
'Account not found',
);
});
});
});
61 changes: 61 additions & 0 deletions packages/snap/src/handlers/RpcHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
validateAddress,
validateAccountBalance,
validateDustLimit,
parseRewardsMessage,
} from './validation';

export const CreateSendFormRequest = object({
Expand Down Expand Up @@ -64,6 +65,11 @@ export const VerifyMessageRequest = object({
signature: string(),
});

export const SignRewardsMessageRequest = object({
accountId: string(),
message: string(),
});

export class RpcHandler {
readonly #logger: Logger;

Expand Down Expand Up @@ -124,6 +130,10 @@ export class RpcHandler {
params.signature,
);
}
case RpcMethod.SignRewardsMessage: {
assert(params, SignRewardsMessageRequest);
return this.#signRewardsMessage(params.accountId, params.message);
}

default:
throw new InexistentMethodError(`Method not found: ${method}`);
Expand Down Expand Up @@ -296,4 +306,55 @@ export class RpcHandler {
throw error;
}
}

/**
* Handles the signing of a rewards message, of format 'rewards,{address},{timestamp}' base64 encoded.
*
* @param accountId - The ID of the account to sign with
* @param message - The base64-encoded rewards message
* @returns The signature
* @throws {ValidationError} If the account is not found or if the address in the message doesn't match the signing account
*/
async #signRewardsMessage(
accountId: string,
message: string,
): Promise<{ signature: string }> {
const { address: messageAddress } = parseRewardsMessage(message);

const account = await this.#accountUseCases.get(accountId);
if (!account) {
throw new ValidationError('Account not found', { accountId });
}

const addressValidation = validateAddress(
messageAddress,
account.network,
this.#logger,
);
if (!addressValidation.valid) {
throw new ValidationError(
`Invalid Bitcoin address in rewards message for network ${account.network}`,
{ messageAddress, network: account.network },
);
}

const accountAddress = account.publicAddress.toString();
if (messageAddress !== accountAddress) {
throw new ValidationError(
`Address in rewards message (${messageAddress}) does not match signing account address (${accountAddress})`,
{ messageAddress, accountAddress },
);
}

const decodedMessage = atob(message);

const signature = await this.#accountUseCases.signMessage(
accountId,
decodedMessage,
'metamask',
{ skipConfirmation: true },
);

return { signature };
}
}
174 changes: 174 additions & 0 deletions packages/snap/src/handlers/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { parseRewardsMessage } from './validation';

/* eslint-disable @typescript-eslint/naming-convention */
jest.mock('@metamask/bitcoindevkit', () => ({
Address: {
from_string: jest.fn(),
},
Amount: {
from_btc: jest.fn(),
},
}));

describe('validation', () => {
describe('parseRewardsMessage', () => {
const validBitcoinMainnetAddress =
'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq';
const currentTimestamp = Math.floor(Date.now() / 1000);

const toBase64 = (utf8: string): string => btoa(utf8);

describe('valid parsing', () => {
it('correctly extracts address and timestamp from valid message', () => {
const expectedAddress = validBitcoinMainnetAddress;
const expectedTimestamp = 1736660000;
const utf8Message = `rewards,${expectedAddress},${expectedTimestamp}`;
const base64Message = toBase64(utf8Message);

const result = parseRewardsMessage(base64Message);

expect(result.address).toBe(expectedAddress);
expect(result.timestamp).toBe(expectedTimestamp);
});
});

describe('invalid messages', () => {
it('rejects string that decodes but not rewards format', () => {
const base64Message = 'hello world';
expect(() => parseRewardsMessage(base64Message)).toThrow(
'Message must start with "rewards,"',
);
});

it('rejects empty string', () => {
const base64Message = '';
expect(() => parseRewardsMessage(base64Message)).toThrow(
'Message must start with "rewards,"',
);
});

it('rejects invalid base64 with special characters', () => {
const base64Message = '!!!@@@###';
expect(() => parseRewardsMessage(base64Message)).toThrow(
'Invalid base64 encoding',
);
});
});

describe('invalid message prefix', () => {
it.each([
{
message: `reward,${validBitcoinMainnetAddress},${currentTimestamp}`,
description: "missing 's'",
},
{
message: `Rewards,${validBitcoinMainnetAddress},${currentTimestamp}`,
description: 'wrong case (capitalized)',
},
{
message: `bonus,${validBitcoinMainnetAddress},${currentTimestamp}`,
description: 'wrong prefix',
},
{
message: `${validBitcoinMainnetAddress},${currentTimestamp}`,
description: 'no prefix',
},
{
message: `REWARDS,${validBitcoinMainnetAddress},${currentTimestamp}`,
description: 'all caps',
},
])(
'rejects message that does not start with "rewards,": $description',
({ message: utf8Message }) => {
const base64Message = toBase64(utf8Message);
expect(() => parseRewardsMessage(base64Message)).toThrow(
'Message must start with "rewards,"',
);
},
);
});

describe('invalid message structure', () => {
it('rejects message with only prefix', () => {
const utf8Message = 'rewards,';
const base64Message = toBase64(utf8Message);
expect(() => parseRewardsMessage(base64Message)).toThrow(
'Message must have exactly 3 parts',
);
});

it('rejects message missing timestamp', () => {
const utf8Message = `rewards,${validBitcoinMainnetAddress}`;
const base64Message = toBase64(utf8Message);
expect(() => parseRewardsMessage(base64Message)).toThrow(
'Message must have exactly 3 parts',
);
});

it('rejects message with too many parts', () => {
const utf8Message = `rewards,${validBitcoinMainnetAddress},${currentTimestamp},extra`;
const base64Message = toBase64(utf8Message);
expect(() => parseRewardsMessage(base64Message)).toThrow(
'Message must have exactly 3 parts',
);
});

it('rejects message with empty parts', () => {
const utf8Message = 'rewards,,,';
const base64Message = toBase64(utf8Message);
expect(() => parseRewardsMessage(base64Message)).toThrow(/timestamp/iu);
});

it('rejects message with only prefix without comma', () => {
const utf8Message = 'rewards';
const base64Message = toBase64(utf8Message);
expect(() => parseRewardsMessage(base64Message)).toThrow(
'Message must start with "rewards,"',
);
});
});

describe('invalid timestamps', () => {
it.each([
{
timestamp: 'invalid',
description: 'non-numeric',
},
{
timestamp: '',
description: 'empty timestamp',
},
{
timestamp: '-1',
description: 'negative timestamp',
},
{
timestamp: '0',
description: 'zero timestamp',
},
{
timestamp: '123.456',
description: 'decimal timestamp',
},
{
timestamp: '1.0',
description: 'decimal with .0',
},
{
timestamp: 'abc123',
description: 'alphanumeric',
},
])(
'rejects message with invalid timestamp: $description',
({ timestamp }) => {
const utf8Message = `rewards,${validBitcoinMainnetAddress},${timestamp}`;
const base64Message = toBase64(utf8Message);

expect(() => parseRewardsMessage(base64Message)).toThrow(
/timestamp/iu,
);
},
);
});
});
});
Loading