Skip to content
Merged
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
48 changes: 32 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,12 @@ export class SilentPayment {
const silentPaymentGroups: Array<SilentPaymentGroup> = [];
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);
Expand Down Expand Up @@ -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;
Comment on lines +309 to +310

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads-up for when we bump this in BlueWallet: isPaymentCodeValid("SP1…") now returns true, but BlueWallet still does case-sensitive startsWith('sp1') in SendDetails.tsx (the allowSilentPaymentSend gate) and in AbstractHDElectrumWallet.createTransaction (hasSilentPaymentOutput). So an uppercase code gets past validation, skips the wallet-capability check, never goes through sp.createTransaction, and then fails in psbt with SP1… has no matching Script. It doesn't lose funds, but it's a confusing failure, and wallets that can't send SP don't get the proper error.

This lib is fine as it is. The bump PR needs to lowercase the address (or use .toLowerCase().startsWith('sp1')) in those two places. Alternatively, export a small isSilentPaymentCode(address) helper from here so the prefix check lives in one place.

} catch (_) {
return false;
}
Expand Down
31 changes: 31 additions & 0 deletions tests/silent-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading