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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
dist
.idea/
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,6 @@ MIT
- https://github.com/josibake/silent-payments-workshop/blob/main/silent-payments-workshop.ipynb
- https://github.com/bitcoin/bitcoin/pull/27827
- https://medium.com/@ottosch/how-bip47-works-ee641cc14bf3
- https://medium.com/@ottosch/how-silent-payments-work-41bea907d6b0
- https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki
- https://github.com/bitcoin/bips/blob/97012a82064c7247df502a170c03b053825cdd15/bip-0352/reference.py
61 changes: 60 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"license": "MIT",
"dependencies": {
"@noble/secp256k1": "1.6.3",
"bip32": "^5.0.0-rc.0",
"bip39": "^3.1.0",
"bitcoinjs-lib": "^7.0.0-rc.0",
"create-hash": "^1.2.0",
"ecpair": "github:bitcoinjs/ecpair#v3.0.0"
Expand Down
176 changes: 166 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import * as crypto from "crypto";
import { ECPairFactory } from "ecpair";
import { bech32m } from "bech32";
import * as bitcoin from "bitcoinjs-lib";
import { Stack, Transaction, script, address, networks } from "bitcoinjs-lib";
import { BIP32Factory } from 'bip32';
import * as bip39 from 'bip39';

import ecc from "./noble_ecc";
import { compareUint8Arrays, concatUint8Arrays, hexToUint8Array, uint8ArrayToHex } from "./uint8array-extras";

const ECPair = ECPairFactory(ecc);
bitcoin.initEccLib(ecc);
const bip32 = BIP32Factory(ecc);

export type UTXOType = "p2wpkh" | "p2sh-p2wpkh" | "p2pkh" | "p2tr" | "non-eligible";

Expand All @@ -27,14 +32,7 @@ export type SilentPaymentGroup = {
BmValues: Array<[Uint8Array, number | undefined, number]>;
};

function taggedHash(tag: string, data: Uint8Array): Uint8Array {
const hash = crypto.createHash("sha256");
const tagHash = hash.update(tag, "utf-8").digest();
const ss = concatUint8Arrays([tagHash, tagHash, data]);
return crypto.createHash("sha256").update(ss).digest();
}

const G = hexToUint8Array("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798");
export const G = hexToUint8Array("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798");

export class SilentPayment {
/**
Expand Down Expand Up @@ -90,7 +88,7 @@ export class SilentPayment {

let k = 0;
for (const [Bm, amount, i] of group.BmValues) {
const tk = taggedHash("BIP0352/SharedSecret", concatUint8Arrays([ecdh_shared_secret, SilentPayment._ser32(k)]));
const tk = SilentPayment.taggedHash("BIP0352/SharedSecret", concatUint8Arrays([ecdh_shared_secret, SilentPayment._ser32(k)]));

// Let Pmk = tk·G + Bm
const Pmk = new Uint8Array(ecc.pointAdd(ecc.pointMultiply(G, tk) as Uint8Array, Bm) as Uint8Array);
Expand All @@ -106,6 +104,13 @@ export class SilentPayment {
return ret;
}

static taggedHash(tag: "BIP0352/Inputs" | "BIP0352/SharedSecret", data: Uint8Array): Uint8Array {
const hash = crypto.createHash("sha256");
const tagHash = hash.update(tag, "utf-8").digest();
const ss = concatUint8Arrays([tagHash, tagHash, data]);
return crypto.createHash("sha256").update(ss).digest();
}

static _outpointsHash(parameters: UTXO[], A: Uint8Array): Uint8Array {
const outpoints: Array<Uint8Array> = [];
for (const parameter of parameters) {
Expand All @@ -115,7 +120,7 @@ export class SilentPayment {
}
outpoints.sort((a, b) => compareUint8Arrays(a, b));
const smallest_outpoint = outpoints[0];
return taggedHash("BIP0352/Inputs", concatUint8Arrays([smallest_outpoint, A]));
return SilentPayment.taggedHash("BIP0352/Inputs", concatUint8Arrays([smallest_outpoint, A]));
}

/**
Expand Down Expand Up @@ -209,4 +214,155 @@ export class SilentPayment {
static addressToPubkey(address: string): string {
return uint8ArrayToHex(bitcoin.address.toOutputScript(address).subarray(2));
}

static getPubkeysFromTransactionInputs(tx: Transaction): Uint8Array[] {
const result: Uint8Array[] = [];

const stackToPubkeys = (stack: Stack): Uint8Array[] => {
return stack
.filter((elem) => typeof elem !== "number") // filtering out numbers, leaving only Uint8Array
.filter((elem) => ecc.isXOnlyPoint(elem as Uint8Array) || script.isCanonicalPubKey(elem as Uint8Array)) as Uint8Array[];
};

for (const input of tx.ins) {
const inScript = script.decompile(input.script);
if (inScript) {
// push any pubkeys in the scriptSig
result.push(...stackToPubkeys(inScript));
if (inScript.length > 1) {
const lastItem = inScript[inScript.length - 1];
if (typeof lastItem !== "number") {
// If the last item is a buffer, treat as redeemScript and check if we can decompile
// and if it has any pubkeys (it might not)
const redeemScript = script.decompile(lastItem);
if (redeemScript) {
result.push(...stackToPubkeys(redeemScript));
}
}
}
}
// Find any raw pubkeys in the witness stack
result.push(...input.witness.filter(script.isCanonicalPubKey));
for (const item of input.witness) {
const maybeScript = script.decompile(item);
if (maybeScript) {
result.push(...stackToPubkeys(maybeScript));
}
}
}
return result;
}

/**
* takes decoded bitcoin transaction and computes tweak. some transactions must be augmented with prevout data
* so the method can successfully discover all pubkeys from inputs (example: `tx.ins[0].script = txPrevout0.outs[0].script;`)
*/
static computeTweakForTx(tx: Transaction): Uint8Array | null {
// you need the sum of the (eligible) input public keys (call it A), multiplied by the input_hash, i.e,
// hash(A|smallest_outpoint). this is a public key (33bytes) so this 33 bytes per tx is sent to the client.
// that would be a tweak (per tx)
let A = SilentPayment.sumPubKeys(SilentPayment.getPubkeysFromTransactionInputs(tx));

// looking for smallest outpoint:
const outpoints: Array<Uint8Array> = [];
for (const inn of tx.ins) {
const txidBuffer = inn.hash;//.reverse();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Byte Order Mismatch in Tweak Calculation

The computeTweakForTx method uses txids without reversing them, unlike _outpointsHash which explicitly reverses the txid buffer for outpoint hash calculation. This byte order inconsistency can lead to incorrect tweak computations and potential incompatibility with the BIP-352 specification.

Fix in Cursor Fix in Web

const voutBuffer = new Uint8Array(SilentPayment._ser32(inn.index).reverse());
outpoints.push(new Uint8Array([...txidBuffer, ...voutBuffer]));
}
outpoints.sort((a, b) => compareUint8Arrays(a, b));
const smallest_outpoint = outpoints[0];
const input_hash = SilentPayment.taggedHash("BIP0352/Inputs", concatUint8Arrays([smallest_outpoint, A]));

// finally, computing tweak:
return ecc.pointMultiply(A, input_hash);
}

static sumPubKeys(pubkeys: Uint8Array[], compressed: boolean = true): Uint8Array | null {
if (pubkeys.length === 0) return null;

let result = pubkeys[0];
for (let i = 1; i < pubkeys.length; i++) {
const sum = ecc.pointAdd(result, pubkeys[i], compressed);
if (!sum) return null;
result = sum;
}

if (result.length === 32) {
// its x-only...?
return concatUint8Arrays([new Uint8Array([2]), result]); // not sure about this `2`
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Incorrect Public Key Conversion

The sumPubKeys method incorrectly assumes an even y-coordinate when converting 32-byte x-only public keys to compressed format, always prepending 0x02. The correct prefix (0x02 or 0x03) depends on the actual y-coordinate parity, which isn't determined. This can lead to invalid public keys, as the comment "not sure about this 2" suggests.

Fix in Cursor Fix in Web


return result;
}


/**
* takes BIP-39 mnemonic seed and returns shareable static payment code; also: Bscan, bscan, Bspend, bspend
*/
static seedToCode(bip39seed: string, accountNum = 0, passphrase = ''): { address: string; Bscan: Uint8Array; bscan: Uint8Array; Bspend: Uint8Array, bspend: Uint8Array } {
const root = bip32.fromSeed(bip39.mnemonicToSeedSync(bip39seed, passphrase));
const scanXprv = root.derivePath(`m/352'/0'/${accountNum}'/1'/0`);
const spendXprv = root.derivePath(`m/352'/0'/${accountNum}'/0'/0`);
const Bscan = scanXprv.publicKey;
const bscan = scanXprv.privateKey;
const Bspend = spendXprv.publicKey;
const bspend = spendXprv.privateKey;

const bech32Version = 0;
const words = [bech32Version].concat(bech32m.toWords(Buffer.concat([Bscan, Bspend])));
const address = bech32m.encode('sp', words, 1023);
return { address, Bscan, bscan, Bspend, bspend };
}

/**
* takes a decoded transaction (`bitcoinjs.Transaction.fromHex()` will do fine),
* takes computed tweak for this transaction, your mnemonic seed, and gives you UTXOs from this transaction
* that you own. tweak is _not_ calculated here because theoretically it can come from a tweak-indexing backend
* service.
*/
static detectOurUtxos(tx: Transaction, seed: string, tweakHex: string) {
const ret: UTXO[] = [];
const code = SilentPayment.seedToCode(seed);
const sharedSecret = ecc.getSharedSecret(code.bscan, hexToUint8Array(tweakHex));

// todo: iterate k (aka label), cause it might be non-zero
const k = 0;
const t_k = SilentPayment.taggedHash("BIP0352/SharedSecret", concatUint8Arrays([sharedSecret, SilentPayment._ser32(k)]));

// Compute the expected output pubkey
const P_k = ecc.pointAdd(ecc.pointMultiply(G, t_k), code.Bspend);

let pubkeyHex = uint8ArrayToHex(P_k);
if (pubkeyHex.startsWith("02") || pubkeyHex.startsWith("03")) pubkeyHex = pubkeyHex.substring(2);

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);
const keyPair = ECPair.fromPrivateKey(d);
const wif = keyPair.toWIF();

if (!d) {
console.log("SilentPayment: Invalid private‐key tweak addition");
continue;
}

const u: UTXO = {
txid: tx.getId(),
vout,
wif,
utxoType: "p2tr"
}

ret.push(u);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: UTXO Detection and Key Generation Errors

In detectOurUtxos, the vout variable isn't incremented when iterating tx.outs, causing all detected UTXOs to incorrectly show vout: 0. Separately, the null check for the d variable (from ecc.privateAdd) is placed after d is already used to create an ECPair and WIF. This means if d is falsy, an error will occur before the check can catch it.

Fix in Cursor Fix in Web


return ret;
}
}
Loading