From d436ec1e0fdfc1e2b23be162b15c8895f9e28d28 Mon Sep 17 00:00:00 2001 From: Ivan Vershigora Date: Tue, 27 Oct 2026 12:16:03 +0000 Subject: [PATCH] FIX: validate prefix, case, version and keys of silent payment code --- src/index.ts | 48 ++++++++++++++++++++++++------------ tests/silent-payment.test.ts | 31 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4918bab..5bdb835 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,19 +54,12 @@ export class SilentPayment { const silentPaymentGroups: Array = []; for (let i = 0; i < targets.length; i++) { const target = targets[i]; - if (!target.address?.startsWith("sp1")) { + if (!target.address?.toLowerCase().startsWith("sp1")) { ret[i] = target; // passthrough continue; } - const result = bech32m.decode(target.address, 118); - const version = result.words.shift(); - if (version !== 0) { - throw new Error("Unexpected version of silent payment code"); - } - const data = bech32m.fromWords(result.words); - const Bscan = new Uint8Array(data.slice(0, 33)); - const Bm = new Uint8Array(data.slice(33)); + const { Bscan, Bm } = SilentPayment.decodePaymentCode(target.address); // Addresses with the same Bscan key all belong to the same recipient const recipient = silentPaymentGroups.find((group) => compareUint8Arrays(group.Bscan, Bscan) === 0); @@ -283,15 +276,38 @@ export class SilentPayment { return ret; } + /** + * Decodes and validates BIP-352 address, throws if invalid. + * Version 0 is exactly B_scan || B_m, versions 1-30 may append data which is ignored, version 31 is reserved. + */ + static decodePaymentCode(pc: string): { Bscan: Uint8Array; Bm: Uint8Array } { + if (pc !== pc.toLowerCase() && pc !== pc.toUpperCase()) { + throw new Error("Mixed case silent payment code"); + } + const result = bech32m.decode(pc.toLowerCase(), 1023); + if (result.prefix !== "sp") { + throw new Error("Unexpected prefix of silent payment code"); + } + const version = result.words.shift(); + if (version === undefined || version === 31) { + throw new Error("Unexpected version of silent payment code"); + } + const data = new Uint8Array(bech32m.fromWords(result.words)); + if (version === 0 ? data.length !== 66 : data.length < 66) { + throw new Error("Unexpected length of silent payment code"); + } + const Bscan = data.slice(0, 33); + const Bm = data.slice(33, 66); + if (!ecc.isPoint(Bscan) || !ecc.isPoint(Bm)) { + throw new Error("Invalid public key in silent payment code"); + } + return { Bscan, Bm }; + } + static isPaymentCodeValid(pc: string) { try { - const result = bech32m.decode(pc, 118); - const version = result.words.shift(); - if (version !== 0) { - return false; - } - // version 0 payload is B_scan || B_m (33 + 33 compressed pubkeys) - return bech32m.fromWords(result.words).length === 66; + SilentPayment.decodePaymentCode(pc); + return true; } catch (_) { return false; } diff --git a/tests/silent-payment.test.ts b/tests/silent-payment.test.ts index 796bfbe..501f9a9 100644 --- a/tests/silent-payment.test.ts +++ b/tests/silent-payment.test.ts @@ -411,6 +411,37 @@ it("can validate payment code", () => { assert.ok(!SilentPayment.isPaymentCodeValid(longPayload)); }); +it("validates prefix, case, version and keys of payment code", () => { + const valid = "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"; + const { Bscan, Bm } = SilentPayment.decodePaymentCode(valid); + const encode = (prefix: string, version: number, data: Uint8Array) => bech32m.encode(prefix, [version].concat(bech32m.toWords(data)), 1023); + const keys = new Uint8Array([...Bscan, ...Bm]); + const notOnCurve = new Uint8Array([2, ...new Array(32).fill(0xff)]); + + assert.ok(SilentPayment.isPaymentCodeValid(valid.toUpperCase())); + assert.ok(!SilentPayment.isPaymentCodeValid(valid.slice(0, 10).toUpperCase() + valid.slice(10))); // mixed case + assert.ok(!SilentPayment.isPaymentCodeValid(encode("tsp", 0, keys))); + assert.ok(!SilentPayment.isPaymentCodeValid(encode("bc", 0, keys))); + assert.ok(!SilentPayment.isPaymentCodeValid(encode("sp", 0, new Uint8Array([...Bscan, ...notOnCurve])))); + assert.ok(!SilentPayment.isPaymentCodeValid(encode("sp", 0, new Uint8Array([...notOnCurve, ...Bm])))); + + // future versions: first 66 bytes are B_scan || B_m, the rest is ignored + const v1 = encode("sp", 1, new Uint8Array([...keys, 1, 2, 3])); + assert.deepStrictEqual(SilentPayment.decodePaymentCode(v1), { Bscan, Bm }); + assert.ok(!SilentPayment.isPaymentCodeValid(encode("sp", 1, Bscan))); + assert.ok(!SilentPayment.isPaymentCodeValid(encode("sp", 31, keys))); +}); + +it("createTransaction treats uppercase payment code same as lowercase", () => { + const sp = new SilentPayment(); + const pc = "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv"; + const utxos = [{ txid: "a".repeat(64), vout: 0, wif: "L1uyy5qTuGrVXrmrsvHWHgVzW9kKdrp27wBC7Vs6nZDTF2BRUVwy", utxoType: "p2wpkh" as UTXOType }]; + const lower = sp.createTransaction(utxos, [{ address: pc, value: 1000 }]); + const upper = sp.createTransaction(utxos, [{ address: pc.toUpperCase(), value: 1000 }]); + assert.ok(lower[0].address?.startsWith("bc1p")); + assert.deepStrictEqual(upper, lower); +}); + it("can turn pubkey into taproot address", () => { assert.strictEqual(SilentPayment.pubkeyToAddress("40ef293a8a0ebaf8b351a27d89ff4b5b3822a635e4afdca77a30170c363bafa3"), "bc1pgrhjjw52p6a03v635f7cnl6ttvuz9f34ujhaefm6xqtscd3m473szkl92g");