Skip to content
Merged
55 changes: 55 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,13 @@ export class RpcHandler {
params.signature,
);
}
case RpcMethod.SignRewardsMessage: {
assert(params, SignRewardsMessageRequest);
return this.#signRewardsMessage(
(params as { accountId: string; message: string }).accountId,
(params as { accountId: string; message: string }).message,
);
}

default:
throw new InexistentMethodError(`Method not found: ${method}`);
Expand Down Expand Up @@ -296,4 +309,46 @@ 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 },
);
}

return { signature: '0x' };
}
}
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,
);
},
);
});
});
});
67 changes: 67 additions & 0 deletions packages/snap/src/handlers/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
string,
nonempty,
refine,
is,
} from 'superstruct';

import type { BitcoinAccount, CodifiedError, Logger } from '../entities';
Expand All @@ -24,6 +25,7 @@ export enum RpcMethod {
OnAddressInput = 'onAddressInput',
OnAmountInput = 'onAmountInput',
ConfirmSend = 'confirmSend',
SignRewardsMessage = 'signRewardsMessage',
}

export enum SendErrorCodes {
Expand Down Expand Up @@ -214,3 +216,68 @@ export function validateDustLimit(
}
return NO_ERRORS_RESPONSE;
}

export const PositiveNumberStringStruct = pattern(
string(),
/^(?!0\d)(\d+(\.\d+)?)$/u,
);

/**
* Parses a base64-encoded rewards message in the format 'rewards,{address},{timestamp}'
*
* @param base64Message - The base64-encoded message to parse
* @returns Object containing the parsed address and timestamp
* @throws Error if the message format is invalid
*/
export function parseRewardsMessage(base64Message: string): {
address: string;
timestamp: number;
} {
// Decode the message from base64 to utf8
let decodedMessage: string;
try {
decodedMessage = atob(base64Message);
} catch {
throw new Error('Invalid base64 encoding');
}

// Check if message starts with 'rewards,'
if (!decodedMessage.startsWith('rewards,')) {
throw new Error('Message must start with "rewards,"');
}

// Split the message into parts
const parts = decodedMessage.split(',');
if (parts.length !== 3) {
throw new Error(
'Message must have exactly 3 parts: rewards,{address},{timestamp}',
);
}

const [prefix, addressPart, timestampPart] = parts;

// Validate prefix (already checked above, but being explicit)
if (prefix !== 'rewards') {
throw new Error('Message must start with "rewards"');
}

// Validate timestamp
if (!is(timestampPart, PositiveNumberStringStruct)) {
throw new Error('Invalid timestamp format');
}

// Ensure timestamp is an integer (no decimals)
if (timestampPart.includes('.')) {
throw new Error('Invalid timestamp');
}

const timestamp = parseInt(timestampPart, 10);
if (timestamp <= 0) {
throw new Error('Invalid timestamp');
}

return {
address: addressPart as string,
timestamp,
};
}