diff options
| author | Adam Soltys <[email protected]> | 2026-06-27 14:34:59 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-27 14:34:59 -0700 |
| commit | 6c817756c5cca849864119405be9fa3f9e968057 (patch) | |
| tree | 0ceec5f40a6090bba0b54e54dba9641aecb9eec9 /src | |
| parent | 342987a97dfa031e43e370b593bc9b57012dcc71 (diff) | |
| download | coinosv3-6c817756c5cca849864119405be9fa3f9e968057.tar.gz coinosv3-6c817756c5cca849864119405be9fa3f9e968057.zip | |
Add BIP-352 receiving crypto (scan, tweak, keys, address)
silentpay.js gains the receiving half of silent payments, validated against all
official BIP-352 vectors (29/29 receiving sub-cases):
- inputPubKey(): recover the eligible input pubkey from P2PKH/P2WPKH/P2SH-P2WPKH/
P2TR inputs (skips uncompressed keys, excludes NUMS script-path, hash160-matches
malleated P2PKH scriptSigs)
- silentPaymentTweak(): the oracle tweak input_hash*A (sum over eligible inputs,
input hash over all outpoints, point-at-infinity guarded) — used by the indexer
- silentPaymentScan(): ecdh = scan_priv*tweak, derive P_k = B_spend + t_k*G, match
the tx's taproot outputs, return per-output spend tweaks
- silentPaymentOutputPrivKey(), deriveSilentPaymentKeys() (m/352' paths),
encodeSilentPaymentAddress()
Labels not yet implemented (wallet uses a single no-label address).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/silentpay.js | 177 |
1 files changed, 177 insertions, 0 deletions
diff --git a/src/silentpay.js b/src/silentpay.js index 0700999..1a58a41 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -17,9 +17,12 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils'; import { bech32m } from '@scure/base'; +const hash160 = (b) => ripemd160(sha256(b)); + const Point = secp256k1.ProjectivePoint; const G = Point.BASE; const N = secp256k1.CURVE.n; @@ -109,3 +112,177 @@ export function silentPaymentScripts(inputs, outputs) { export function silentPaymentPlaceholder(index) { return taprootScript(sha256(concatBytes(new TextEncoder().encode('halsp-placeholder'), be32(index)))); } + +// =========================================================================== +// Receiving (BIP-352). +// +// A browser light client can't scan the chain itself, so an indexing server (an +// SP "oracle") provides, per block, the tweaks — one 33-byte point (input_hash·A, +// the sum of a tx's eligible input pubkeys folded with its input hash) per +// eligible transaction — plus the taproot UTXOs created in that block. The +// receiver then does the private part on-device: ecdh = b_scan · tweak, then for +// k = 0,1,… derives P_k = B_spend + t_k·G and matches it against the block's +// outputs. The scan key never leaves the device. +// +// The first half here (input pubkey extraction + tweak) is what the indexer +// computes from raw blocks; the second half (scan, output key, key derivation, +// address) is what the wallet uses. They share one implementation so the math +// is validated once, against the official BIP-352 vectors. +// =========================================================================== + +const numTo32 = (x) => hexToBytes(x.toString(16).padStart(64, '0')); + +// secp256k1 NUMS point H (BIP-341): a taproot input whose script-path reveals +// this as the internal key is provably unspendable and NOT an eligible input. +const NUMS_H = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'; + +// Parse a serialized witness (hex): compact-size item count, then each item as +// compact-size length + bytes. +function parseWitness(hex) { + if (!hex) return []; + const b = hexToBytes(hex); + let o = 0; + const rd = () => { + let n = b[o++]; + if (n === 0xfd) { n = b[o] | (b[o + 1] << 8); o += 2; } + else if (n === 0xfe) { n = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; } + return n; + }; + const count = rd(); + const items = []; + for (let i = 0; i < count; i++) { const len = rd(); items.push(b.slice(o, o + len)); o += len; } + return items; +} + +// Pull the data pushes out of a scriptSig (hex) — enough to recover the pubkey +// from P2PKH or the redeemScript from P2SH (push opcodes only). +function scriptPushes(hex) { + if (!hex) return []; + const b = hexToBytes(hex); + let o = 0; + const pushes = []; + while (o < b.length) { + const op = b[o++]; + let len = -1; + if (op >= 0x01 && op <= 0x4b) len = op; + else if (op === 0x4c) len = b[o++]; + else if (op === 0x4d) { len = b[o] | (b[o + 1] << 8); o += 2; } + else if (op === 0x4e) { len = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; } + else continue; // non-push opcode + if (len < 0 || o + len > b.length) break; + pushes.push(b.slice(o, o + len)); o += len; + } + return pushes; +} + +// Only compressed pubkeys are eligible — uncompressed-key inputs are skipped. +const looksLikePubKey = (b) => !!b && b.length === 33 && (b[0] === 0x02 || b[0] === 0x03); + +// Recover the BIP-352-eligible input public key (33-byte compressed) from a tx +// input, or null if the input type isn't eligible. Handles P2PKH, P2WPKH, +// P2SH-P2WPKH and P2TR (key-path) inputs. +export function inputPubKey({ scriptSig = '', witness = '', scriptPubKey = '' }) { + try { + const spk = hexToBytes(scriptPubKey); + // P2TR: OP_1 PUSH32 <x-only> + if (spk.length === 34 && spk[0] === 0x51 && spk[1] === 0x20) { + let w = parseWitness(witness); + if (w.length > 1 && w[w.length - 1][0] === 0x50) w = w.slice(0, -1); // strip annex + if (w.length > 1 && bytesToHex(w[w.length - 1].slice(1, 33)) === NUMS_H) return null; // NUMS script-path + return Point.fromHex(concatBytes(Uint8Array.of(0x02), spk.slice(2, 34))).toRawBytes(true); // even-Y lift + } + // P2WPKH: OP_0 PUSH20 + if (spk.length === 22 && spk[0] === 0x00 && spk[1] === 0x14) { + const w = parseWitness(witness); + const pk = w[w.length - 1]; + return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; + } + // P2SH-P2WPKH: scriptSig pushes redeemScript 0014<20>; witness = [sig, pubkey] + if (spk.length === 23 && spk[0] === 0xa9 && spk[1] === 0x14 && spk[22] === 0x87) { + const rs = scriptPushes(scriptSig)[0]; + if (!rs || rs.length !== 22 || rs[0] !== 0x00 || rs[1] !== 0x14) return null; + const w = parseWitness(witness); + const pk = w[w.length - 1]; + return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null; + } + // P2PKH: OP_DUP OP_HASH160 PUSH20 <hash> ... — the eligible pubkey is the + // scriptSig push whose hash160 matches <hash> (handles malleated scriptSigs). + if (spk.length === 25 && spk[0] === 0x76 && spk[1] === 0xa9 && spk[2] === 0x14) { + const target = bytesToHex(spk.slice(3, 23)); + const pushes = scriptPushes(scriptSig); + for (let i = pushes.length - 1; i >= 0; i--) { + const pk = pushes[i]; + if (looksLikePubKey(pk) && bytesToHex(hash160(pk)) === target) return Point.fromHex(pk).toRawBytes(true); + } + return null; + } + } catch {} + return null; +} + +// The oracle tweak for a transaction: 33-byte point input_hash·A, where A is the +// sum of the eligible input pubkeys and input_hash = H_BIP0352/Inputs(outpoint_L +// || A) with outpoint_L the smallest outpoint among ALL the tx's inputs. +// inputs: [{ txid (display hex), vout, pubkey? (Uint8Array 33) }] — every input +// of the tx; `pubkey` present only for BIP-352-eligible inputs. +// Returns null if the tx has no eligible inputs (so it can't be a silent payment). +export function silentPaymentTweak(inputs) { + let A = null; + for (const i of inputs) if (i.pubkey) A = A ? A.add(Point.fromHex(i.pubkey)) : Point.fromHex(i.pubkey); + if (!A) return null; + let Abytes; + try { Abytes = A.toRawBytes(true); } catch { return null; } // A is the point at infinity → not a silent payment + let smallest = null; + for (const i of inputs) { + const op = concatBytes(hexToBytes(i.txid).reverse(), le32(i.vout)); + if (!smallest || bytesToHex(op) < bytesToHex(smallest)) smallest = op; + } + const inputHash = modN(toInt(tagged('BIP0352/Inputs', concatBytes(smallest, Abytes)))); + return A.multiply(inputHash).toRawBytes(true); +} + +// Scan a transaction's taproot outputs for payments to us. +// scanPriv: Uint8Array(32) our scan private key (b_scan) +// spendPub: Uint8Array(33) our spend public key (B_spend) +// tweak: Uint8Array(33) the oracle tweak for this tx (input_hash·A) +// outputs: array of 32-byte x-only output pubkeys (hex) in the tx +// Returns the matched outputs: [{ output (x-only hex), k, tweak (t_k hex 32) }]; +// `tweak` (t_k) is added to the spend private key to spend the coin. +export function silentPaymentScan({ scanPriv, spendPub, tweak, outputs, maxK = 30 }) { + const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true); + const B = Point.fromHex(spendPub); + const remaining = new Set(outputs.map((o) => o.toLowerCase())); + const found = []; + for (let k = 0; k < maxK && remaining.size; k++) { + const tk = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(k))))); + const xonly = bytesToHex(B.add(G.multiply(tk)).toRawBytes(true).slice(1)); + if (!remaining.has(xonly)) break; // no output at this k → done with this tx + found.push({ output: xonly, k, tweak: bytesToHex(numTo32(tk)) }); + remaining.delete(xonly); + } + return found; +} + +// Spend private key for a found SP output: d = (spend_priv + t_k) mod n. Used as +// the taproot key-path key (the signer negates for odd-Y per BIP-340). +export function silentPaymentOutputPrivKey(spendPriv, tweakK) { + return numTo32(modN(toInt(spendPriv) + toInt(tweakK))); +} + +// Derive the BIP-352 scan + spend keys from a BIP32 master node. +// spend: m/352'/coin'/account'/0'/0 scan: m/352'/coin'/account'/1'/0 +export function deriveSilentPaymentKeys(master, coin, account = 0) { + const spend = master.derive(`m/352'/${coin}'/${account}'/0'/0`); + const scan = master.derive(`m/352'/${coin}'/${account}'/1'/0`); + return { + scanPriv: scan.privateKey, scanPub: scan.publicKey, + spendPriv: spend.privateKey, spendPub: spend.publicKey, + }; +} + +// Encode an sp1…/tsp1… address from scan + spend public keys (33 bytes each). +export function encodeSilentPaymentAddress(scanPub, spendPub, { testnet = false } = {}) { + const hrp = testnet ? 'tsp' : 'sp'; + const words = [0, ...bech32m.toWords(concatBytes(scanPub, spendPub))]; + return bech32m.encode(hrp, words, 1023); +} |
