1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
|
// Silent Payments (BIP-352) — sending side only.
//
// A silent payment address (sp1…) is a static, reusable address the receiver
// publishes once. The sender derives a unique one-time taproot output for each
// payment from the receiver's address plus the sender's own input keys (ECDH),
// so there's no on-chain link between payments and no address reuse — without
// any interaction with the receiver.
//
// This module only does the SENDER half (deriving the output). Receiving needs
// chain scanning (a per-tx ECDH tweak), which a light client can't do without a
// dedicated index server — out of scope here.
//
// Validated against the official BIP-352 send test vectors. We only spend
// compressed P2WPKH inputs, which keeps the eligible-input rules trivial: each
// input contributes its private key as-is (no taproot even-Y negation, no
// uncompressed-key or P2SH edge cases).
import { secp256k1 } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha256';
import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils';
import { bech32m } from '@scure/base';
const Point = secp256k1.ProjectivePoint;
const G = Point.BASE;
const N = secp256k1.CURVE.n;
const modN = (x) => ((x % N) + N) % N;
const toInt = (bytes) => BigInt('0x' + bytesToHex(bytes));
// tagged hash: sha256(sha256(tag) || sha256(tag) || msg)
const tagged = (tag, msg) => {
const t = sha256(new TextEncoder().encode(tag));
return sha256(concatBytes(t, t, msg));
};
const le32 = (n) => {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n >>> 0, true);
return b;
};
const be32 = (n) => {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n >>> 0, false);
return b;
};
// A taproot scriptPubKey for a 32-byte x-only key: OP_1 (0x51) PUSH32 (0x20) key.
const taprootScript = (xonly) => concatBytes(Uint8Array.of(0x51, 0x20), xonly);
export function isSilentPaymentAddress(addr) {
if (typeof addr !== 'string') return false;
const a = addr.toLowerCase();
if (!a.startsWith('sp1') && !a.startsWith('tsp1')) return false;
try {
decodeSilentPaymentAddress(addr);
return true;
} catch {
return false;
}
}
// Decode sp1…/tsp1… → { scan, spend } (33-byte compressed pubkeys each) + net.
export function decodeSilentPaymentAddress(addr) {
const { prefix, words } = bech32m.decode(addr.toLowerCase(), 1023);
if (prefix !== 'sp' && prefix !== 'tsp') throw new Error('Not a silent payment address.');
if (words[0] !== 0) throw new Error('Unsupported silent payment version.');
const data = bech32m.fromWords(words.slice(1));
if (data.length !== 66) throw new Error('Malformed silent payment address.');
return { scan: data.slice(0, 33), spend: data.slice(33, 66), testnet: prefix === 'tsp' };
}
// Derive the one-time taproot output scripts for a set of silent-payment outputs
// that this transaction's inputs are funding.
// inputs: [{ txid (display hex), vout, priv (Uint8Array 32) }] — ALL eligible
// (P2WPKH) inputs of the tx; the derivation commits to every one.
// outputs: [{ scan, spend }] — one entry per desired output (repeat the same
// recipient to pay it multiple times; the k counter is handled here).
// Returns a taproot scriptPubKey (Uint8Array) for each entry, in order.
export function silentPaymentScripts(inputs, outputs) {
if (!inputs.length) throw new Error('Silent payment needs at least one input.');
// a = Σ input private keys (mod n); A = a·G
let a = 0n;
for (const i of inputs) a = modN(a + toInt(i.priv));
if (a === 0n) throw new Error('Silent payment input key sum is zero.');
const A = G.multiply(a);
// input_hash = H(smallest_outpoint || A); outpoint = txid(wire order) || vout(LE)
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, A.toRawBytes(true)))));
const counters = new Map(); // recipient → next k
return outputs.map(({ scan, spend }) => {
const rkey = bytesToHex(scan) + bytesToHex(spend);
const k = counters.get(rkey) || 0;
counters.set(rkey, k + 1);
// ecdh = input_hash·a·B_scan ; t_k = H(ecdh || ser32(k)) ; P = B_spend + t_k·G
const ecdh = Point.fromHex(scan).multiply(modN(inputHash * a)).toRawBytes(true);
const tk = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(k)))));
const Pout = Point.fromHex(spend).add(G.multiply(tk));
return taprootScript(Pout.toRawBytes(true).slice(1)); // x-only
});
}
// A unique, valid placeholder taproot scriptPubKey used to size a silent-payment
// output during coin selection, before the real script can be derived (which
// needs the final input set). Swapped out for the real one afterward.
export function silentPaymentPlaceholder(index) {
return taprootScript(sha256(concatBytes(new TextEncoder().encode('halsp-placeholder'), be32(index))));
}
|