From 7e91887ccba847b3791266fb5cbf30a89f80a59f Mon Sep 17 00:00:00 2001 From: overtorment Date: Mon, 14 Sep 2026 18:30:08 +0100 Subject: [PATCH 1/2] feat: scan consecutive unlabeled k on receive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect every BIP-352 output in a transaction (k=0,1,…) instead of only the first, and keep isolated script checks at k=0. --- src/index.ts | 144 ++++++++++++++++++----------------- tests/silent-payment.test.ts | 66 +++------------- 2 files changed, 87 insertions(+), 123 deletions(-) diff --git a/src/index.ts b/src/index.ts index 462bd21..f356723 100644 --- a/src/index.ts +++ b/src/index.ts @@ -173,6 +173,41 @@ export class SilentPayment { return P_k.length === 33 ? P_k.subarray(1) : P_k; } + /** + * BIP-352 unlabeled scan: consecutive k starting at 0 until a gap or K_MAX. + * `taprootVoutsByPubkey` maps x-only output pubkey hex → vout. + */ + private static _scanUnlabeledOutputs( + sharedSecret: Uint8Array, + Bspend: Uint8Array, + taprootVoutsByPubkey: Map + ): Array<{ t_k: Uint8Array; vout: number }> { + const wallet: Array<{ t_k: Uint8Array; vout: number }> = []; + let k = 0; + + while (k < K_MAX && wallet.length < taprootVoutsByPubkey.size) { + const t_k = SilentPayment._sharedSecretTweakAtK(sharedSecret, k); + if (t_k === null) { + break; + } + + const outputXonly = SilentPayment._expectedOutputXonlyAtK(t_k, Bspend); + if (outputXonly === null) { + break; + } + + const vout = taprootVoutsByPubkey.get(uint8ArrayToHex(outputXonly)); + if (vout === undefined) { + break; + } + + wallet.push({ t_k, vout }); + k += 1; + } + + return wallet; + } + private static _privateMultiply(a: Uint8Array, b: Uint8Array): Uint8Array { if (a.length !== 32 || b.length !== 32) { throw new Error("Expected 32-byte scalars for private multiply"); @@ -408,76 +443,45 @@ export class SilentPayment { const ret: UTXO[] = []; const code = SilentPayment.seedToCode(seed); const sharedSecret = getSharedSecret(code.bscan, hexToUint8Array(tweakHex)); + const found = SilentPayment._scanUnlabeledOutputs(sharedSecret, code.Bspend, SilentPayment._taprootVoutsByPubkey(tx)); + const txid = tx.getId(); - // todo: iterate k (aka label), cause it might be non-zero - const k = 0; - const t_k = SilentPayment._sharedSecretTweakAtK(sharedSecret, k); - if (t_k === null) { - return ret; - } - - const outputXonly = SilentPayment._expectedOutputXonlyAtK(t_k, code.Bspend); - if (outputXonly === null) { - return ret; - } - - const pubkeyHex = uint8ArrayToHex(outputXonly); - - let vout = 0; - for (const o of tx.outs) { - if (uint8ArrayToHex(o.script) === "5120" + pubkeyHex) { - // match, that means this output is spendable by us; - // alternatively, could compare addresses: SilentPayment.pubkeyToAddress(pubkeyHex) === SilentPayment.pubkeyToAddress(o.script) - - // deriving spending privkey for this utxo: d = b_spend + t_k (mod n) - const d = ecc.privateAdd(code.bspend, t_k); - if (!d) { - console.log("SilentPayment: Invalid private‐key tweak addition"); - continue; - } - - const keyPair = ECPair.fromPrivateKey(d); - const wif = keyPair.toWIF(); - - const u: UTXO = { - txid: tx.getId(), - vout, - wif, - utxoType: "p2tr", - }; - - ret.push(u); + for (const { t_k, vout } of found) { + const d = ecc.privateAdd(code.bspend, t_k); + if (!d) { + console.log("SilentPayment: Invalid private‐key tweak addition"); + continue; } - vout++; + + ret.push({ + txid, + vout, + wif: ECPair.fromPrivateKey(d).toWIF(), + utxoType: "p2tr", + }); } return ret; } static isOurUtxoUsingTweakbscanBspendAndOutputScript(outputScriptHex: string, tweakHex: string, bscan: string, Bspend: string) { - const sharedSecret = getSharedSecret(hexToUint8Array(bscan), hexToUint8Array(tweakHex)); - - // todo: iterate k (aka label), cause it might be non-zero - const k = 0; - const t_k = SilentPayment._sharedSecretTweakAtK(sharedSecret, k); - if (t_k === null) { - return false; - } + return SilentPayment.isOurUtxoUsingTweakbscanBspendAndOutputScriptUint8array( + hexToUint8Array(outputScriptHex), + hexToUint8Array(tweakHex), + hexToUint8Array(bscan), + hexToUint8Array(Bspend) + ); + } - const outputXonly = SilentPayment._expectedOutputXonlyAtK(t_k, hexToUint8Array(Bspend)); - if (outputXonly === null) { + static isOurUtxoUsingTweakbscanBspendAndOutputScriptUint8array(outputScript: Uint8Array, tweak: Uint8Array, bscan: Uint8Array, Bspend: Uint8Array) { + if (outputScript.length !== 34 || outputScript[0] !== 0x51 || outputScript[1] !== 0x20) { return false; } - return outputScriptHex === "5120" + uint8ArrayToHex(outputXonly); - } - - static isOurUtxoUsingTweakbscanBspendAndOutputScriptUint8array(outputScript: Uint8Array, tweak: Uint8Array, bscan: Uint8Array, Bspend: Uint8Array) { + // Isolated scripts can only be checked at k=0. k>0 exists only after k=0..k-1 + // in the same transaction; use detectOurUtxos* for that sequential scan. const sharedSecret = getSharedSecret(bscan, tweak); - - // todo: iterate k (aka label), cause it might be non-zero - const k = 0; - const t_k = SilentPayment._sharedSecretTweakAtK(sharedSecret, k); + const t_k = SilentPayment._sharedSecretTweakAtK(sharedSecret, 0); if (t_k === null) { return false; } @@ -491,23 +495,27 @@ export class SilentPayment { } static detectOurUtxosUsingTweakbscanBspend(tx: Transaction, tweakHex: string, bscan: string, Bspend: string) { - const ret: Omit[] = []; + const sharedSecret = getSharedSecret(hexToUint8Array(bscan), hexToUint8Array(tweakHex)); + const found = SilentPayment._scanUnlabeledOutputs(sharedSecret, hexToUint8Array(Bspend), SilentPayment._taprootVoutsByPubkey(tx)); + const txid = tx.getId(); + + return found.map(({ vout }) => ({ + txid, + vout, + utxoType: "p2tr" as const, + })); + } + private static _taprootVoutsByPubkey(tx: Transaction): Map { + const taprootVoutsByPubkey = new Map(); let vout = 0; for (const o of tx.outs) { - if (SilentPayment.isOurUtxoUsingTweakbscanBspendAndOutputScript(uint8ArrayToHex(o.script), tweakHex, bscan, Bspend)) { - const u: Omit = { - txid: tx.getId(), - vout, - utxoType: "p2tr", - }; - - ret.push(u); + if (o.script.length === 34 && o.script[0] === 0x51 && o.script[1] === 0x20) { + taprootVoutsByPubkey.set(uint8ArrayToHex(o.script.subarray(2)), vout); } vout++; } - - return ret; + return taprootVoutsByPubkey; } } diff --git a/tests/silent-payment.test.ts b/tests/silent-payment.test.ts index 6a41761..71a52ce 100644 --- a/tests/silent-payment.test.ts +++ b/tests/silent-payment.test.ts @@ -2,10 +2,9 @@ import { ECPairFactory } from "ecpair"; import assert from "node:assert"; import { expect, it } from "vitest"; import { Transaction } from "bitcoinjs-lib"; -import { G, K_MAX, SilentPayment, UTXOType } from "../src"; +import { K_MAX, SilentPayment, UTXOType } from "../src"; import * as ecc from "tiny-secp256k1"; -import { concatUint8Arrays, hexToUint8Array, uint8ArrayToHex } from "../src/uint8array-extras"; -import { isValidScalar } from "../src/input-pubkeys"; +import { hexToUint8Array, uint8ArrayToHex } from "../src/uint8array-extras"; import { Vin, getUTXOType } from "../tests/utils"; import jsonInput from "./data/send_and_receive_test_vectors.json"; import receivingVectors from "./data/receiving_test_vectors.json"; @@ -130,50 +129,9 @@ function sharedSecretFromTweak(bscan: Uint8Array, tweak: Uint8Array): Uint8Array return new Uint8Array(shared); } -function expectedOutputXonlyAtK(sharedSecret: Uint8Array, Bspend: Uint8Array, k: number): Uint8Array | null { - const t_k = SilentPayment.taggedHash("BIP0352/SharedSecret", concatUint8Arrays([sharedSecret, SilentPayment._ser32(k)])); - if (!isValidScalar(t_k)) { - return null; - } - - const tkG = ecc.pointMultiply(G, t_k); - if (!tkG) { - return null; - } - - const P_k = ecc.pointAdd(tkG, Bspend); - if (!P_k) { - return null; - } - - return P_k.length === 33 ? P_k.subarray(1) : P_k; -} - -/** Unlabeled BIP-352 scan loop (k-loop lives in tests until production supports it). */ -function scanUnlabeledOutputs(tweakHex: string, bscan: string, Bspend: string, outputPubKeys: string[]): string[] { - const sharedSecret = sharedSecretFromTweak(hexToUint8Array(bscan), hexToUint8Array(tweakHex)); - const BspendBytes = hexToUint8Array(Bspend); - const remaining = new Set(outputPubKeys); - const wallet: string[] = []; - let k = 0; - - while (k < K_MAX && remaining.size > 0) { - const outputXonly = expectedOutputXonlyAtK(sharedSecret, BspendBytes, k); - if (outputXonly === null) { - break; - } - - const outputPubKey = uint8ArrayToHex(outputXonly); - if (!remaining.has(outputPubKey)) { - break; - } - - wallet.push(outputPubKey); - remaining.delete(outputPubKey); - k += 1; - } - - return wallet; +function scanUnlabeledOutputs(tx: Transaction, tweakHex: string, bscan: string, Bspend: string): string[] { + const detected = SilentPayment.detectOurUtxosUsingTweakbscanBspend(tx, tweakHex, bscan, Bspend); + return detected.map(({ vout }) => uint8ArrayToHex(tx.outs[vout].script.subarray(2))); } it("smoke test", () => { @@ -279,20 +237,18 @@ tests.forEach((testCase, index) => { const sharedSecret = sharedSecretFromTweak(hexToUint8Array(testCase.bscan), tweak); assert.strictEqual(uint8ArrayToHex(sharedSecret), testCase.expected.shared_secret); - const foundPubKeys = scanUnlabeledOutputs(tweakHex, testCase.bscan, testCase.Bspend, testCase.outputPubKeys); + const foundPubKeys = scanUnlabeledOutputs(tx, tweakHex, testCase.bscan, testCase.Bspend); if (testCase.expected.found_count !== undefined) { assert.strictEqual(foundPubKeys.length, testCase.expected.found_count); } else { assert.deepStrictEqual([...foundPubKeys].sort(), [...testCase.expected.found_pub_keys].sort()); } - // detectOurUtxosUsingTweakbscanBspend only checks k=0; verify it on single-hit cases. - if (testCase.expected.found_count === undefined && testCase.expected.found_pub_keys.length === 1) { - const detected = SilentPayment.detectOurUtxosUsingTweakbscanBspend(tx, tweakHex, testCase.bscan, testCase.Bspend); - assert.strictEqual(detected.length, 1); - const outputPubkey = uint8ArrayToHex(tx.outs[detected[0].vout].script.subarray(2)); - assert.strictEqual(outputPubkey, testCase.expected.found_pub_keys[0]); - } + // A single output script can only be identified at k=0. + const isolatedHits = testCase.txOutputScripts.filter((script) => + SilentPayment.isOurUtxoUsingTweakbscanBspendAndOutputScript(script, tweakHex, testCase.bscan, testCase.Bspend) + ).length; + assert.strictEqual(isolatedHits, foundPubKeys.length > 0 ? 1 : 0); }); }); From a50fa22e4b0d2e11f441b2f9d397374b77e6d9d5 Mon Sep 17 00:00:00 2001 From: overtorment Date: Mon, 14 Sep 2026 18:32:20 +0100 Subject: [PATCH 2/2] style: apply prettier to unlabeled k-scan changes --- src/index.ts | 13 ++----------- tests/silent-payment.test.ts | 4 +--- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index f356723..e85e2a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -177,11 +177,7 @@ export class SilentPayment { * BIP-352 unlabeled scan: consecutive k starting at 0 until a gap or K_MAX. * `taprootVoutsByPubkey` maps x-only output pubkey hex → vout. */ - private static _scanUnlabeledOutputs( - sharedSecret: Uint8Array, - Bspend: Uint8Array, - taprootVoutsByPubkey: Map - ): Array<{ t_k: Uint8Array; vout: number }> { + private static _scanUnlabeledOutputs(sharedSecret: Uint8Array, Bspend: Uint8Array, taprootVoutsByPubkey: Map): Array<{ t_k: Uint8Array; vout: number }> { const wallet: Array<{ t_k: Uint8Array; vout: number }> = []; let k = 0; @@ -465,12 +461,7 @@ export class SilentPayment { } static isOurUtxoUsingTweakbscanBspendAndOutputScript(outputScriptHex: string, tweakHex: string, bscan: string, Bspend: string) { - return SilentPayment.isOurUtxoUsingTweakbscanBspendAndOutputScriptUint8array( - hexToUint8Array(outputScriptHex), - hexToUint8Array(tweakHex), - hexToUint8Array(bscan), - hexToUint8Array(Bspend) - ); + return SilentPayment.isOurUtxoUsingTweakbscanBspendAndOutputScriptUint8array(hexToUint8Array(outputScriptHex), hexToUint8Array(tweakHex), hexToUint8Array(bscan), hexToUint8Array(Bspend)); } static isOurUtxoUsingTweakbscanBspendAndOutputScriptUint8array(outputScript: Uint8Array, tweak: Uint8Array, bscan: Uint8Array, Bspend: Uint8Array) { diff --git a/tests/silent-payment.test.ts b/tests/silent-payment.test.ts index 71a52ce..ac5f789 100644 --- a/tests/silent-payment.test.ts +++ b/tests/silent-payment.test.ts @@ -245,9 +245,7 @@ tests.forEach((testCase, index) => { } // A single output script can only be identified at k=0. - const isolatedHits = testCase.txOutputScripts.filter((script) => - SilentPayment.isOurUtxoUsingTweakbscanBspendAndOutputScript(script, tweakHex, testCase.bscan, testCase.Bspend) - ).length; + const isolatedHits = testCase.txOutputScripts.filter((script) => SilentPayment.isOurUtxoUsingTweakbscanBspendAndOutputScript(script, tweakHex, testCase.bscan, testCase.Bspend)).length; assert.strictEqual(isolatedHits, foundPubKeys.length > 0 ? 1 : 0); }); });