Skip to content

Commit dadd54b

Browse files
committed
chore(p2sp): Moved functionality to SilentPaymentTransaction
We did not want to drag in inputs in payments so instead created a class that extends Transaction. This makes more sense for detecting (scanning) which UTXOs belong to our keys and as well for creating outputs who depend on the given inputs.
1 parent 797c16d commit dadd54b

File tree

5 files changed

+386
-136
lines changed

5 files changed

+386
-136
lines changed

test/integration/silentpayment.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import {
1212
deriveSilentOutput,
1313
decodeSilentPaymentAddress,
1414
encodeSilentPaymentAddress,
15-
findSmallestOutpoint,
16-
modN32,
17-
scanForSilentPayments,
1815
} from '../../ts_src/payments/p2sp.js';
1916
import { Input } from '../../ts_src/transaction.js';
17+
import {
18+
findSmallestOutpoint,
19+
scanForSilentPayments,
20+
} from '../../ts_src/SilentPaymentTransaction.js';
2021

2122
// ---- init ecc for bitcoinjs (even if we use tiny-secp directly) ----
2223
bitcoin.initEccLib(ecc);

ts_src/SilentPaymentTransaction.ts

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import { Input, Transaction } from './transaction';
2+
import { fromHex, toHex, writeUInt32 } from 'uint8array-tools';
3+
import * as ecc from 'tiny-secp256k1';
4+
import { deriveSilentOutput } from './payments';
5+
import {
6+
calculateInputHashTweak,
7+
calculateSharedSecret,
8+
calculateSumA,
9+
decodeSilentPaymentAddress,
10+
generateLabelAndAddress,
11+
} from './payments/p2sp';
12+
import * as tools from 'uint8array-tools';
13+
import { isZero32 } from './bufferutils';
14+
import { bitcoin } from './networks';
15+
16+
/**
17+
* Serialize output with number little endian
18+
* (used to sort outputs)
19+
* @param txidHexBE - big endian encoded tx
20+
* @param vout - output index
21+
* @returns the serialized little endian encoded output
22+
*/
23+
export const serOutpointLE = (txidHexBE: Uint8Array, vout: number) => {
24+
const out = new Uint8Array(36);
25+
if (txidHexBE.length !== 32) throw new Error('txid must be 32 bytes');
26+
txidHexBE.reverse(); // BE -> LE
27+
out.set(txidHexBE, 0);
28+
writeUInt32(out, 32, vout >>> 0, 'le');
29+
return out;
30+
};
31+
32+
/**
33+
* Smallest outpoint = lexicographic min of (txidLE || voutLE)
34+
* @param inputs an array of inputs you want the first lexicographically sorted result
35+
* @returns the first output after sorting lexicographically
36+
*/
37+
export const findSmallestOutpoint = (inputs: Array<Input>) =>
38+
inputs
39+
.map(v => serOutpointLE(v.hash, v.index))
40+
.sort((a, b) => tools.compare(a, b))[0];
41+
42+
/**
43+
* Scans a transaction's inputs and outputs to find any silent payments for the receiver.
44+
* @param receiverScanPrivkey - b_scan
45+
* @param receiverSpendPrivkey - b_spend
46+
* @param inputHashTweak
47+
* @param summedSenderPubkey - A_sum
48+
* @param outputsToCheck - array of hex xOnly encoded outputs to check
49+
* @param labelNonces
50+
*/
51+
export function scanForSilentPayments(
52+
receiverScanPrivkey: Uint8Array,
53+
receiverSpendPrivkey: Uint8Array,
54+
inputHashTweak: Uint8Array,
55+
summedSenderPubkey: Uint8Array,
56+
outputsToCheck: Set<string>,
57+
labelNonces: Array<number> = Array.from([]),
58+
): SilentOutput[] {
59+
let foundPayments: SilentOutput[] = [];
60+
61+
// G
62+
const baseSpendPubkey: Uint8Array = ecc.pointFromScalar(
63+
receiverSpendPrivkey,
64+
true,
65+
)!;
66+
67+
// Shared secret S = (inputHash * A_sum) * b_scan (order equivalent)
68+
const S = calculateSharedSecret(
69+
inputHashTweak,
70+
summedSenderPubkey,
71+
receiverScanPrivkey,
72+
);
73+
if (!S) return [];
74+
75+
// First, scan for the base (unlabeled) address
76+
foundPayments = foundPayments.concat(
77+
performScan(baseSpendPubkey, S, outputsToCheck, null),
78+
);
79+
80+
// Then, scan for each labeled address
81+
for (const m of labelNonces) {
82+
const { L, Bm } = generateLabelAndAddress(
83+
receiverScanPrivkey,
84+
baseSpendPubkey,
85+
m,
86+
);
87+
88+
const labeledResults = performScan(Bm, S, outputsToCheck, L);
89+
90+
// Add the label nonce to any found payments for identification
91+
labeledResults.forEach(result => {
92+
foundPayments.push({ ...result, labelNonce: m });
93+
});
94+
}
95+
96+
return foundPayments;
97+
}
98+
99+
/**
100+
* The core scanning logic, performed for a specific spend public key (B_spend).
101+
* @param receiverSpendPubkey - G or B_m
102+
* @param S
103+
* @param outputsToCheck - array of hex xOnly encoded outputs to check
104+
* @param labelScalar
105+
= */
106+
function performScan(
107+
receiverSpendPubkey: Uint8Array,
108+
S: Uint8Array,
109+
outputsToCheck: Set<string>,
110+
labelScalar: Uint8Array | null, // L (or null for base)
111+
): SilentOutput[] {
112+
const found: SilentOutput[] = [];
113+
114+
for (let k = 0; k < outputsToCheck.size; k++) {
115+
const derivedOutput = deriveSilentOutput(S, receiverSpendPubkey, k);
116+
if (!derivedOutput.pub_key) break;
117+
const xonlyHex = toHex(derivedOutput.pub_key).toLowerCase();
118+
119+
if (outputsToCheck.size === 0 || outputsToCheck.has(xonlyHex)) {
120+
// priv_key_tweak returned by L + t_k (mod n) for labeled, or t_k for unlabeled
121+
let spendTweak = derivedOutput.tweak_key;
122+
if (labelScalar != null && !isZero32(labelScalar)) {
123+
const sum: Uint8Array | null = ecc.privateAdd(
124+
labelScalar,
125+
derivedOutput.tweak_key,
126+
);
127+
if (!sum) throw new Error('privateAdd(label, t_k) failed');
128+
spendTweak = sum;
129+
}
130+
found.push({
131+
...derivedOutput,
132+
priv_key_tweak: spendTweak,
133+
labelScalar,
134+
});
135+
}
136+
}
137+
return found;
138+
}
139+
140+
/** A Previous Output object, containing the scriptPubKey and value needed to scan an input. */
141+
export interface Prevout {
142+
txid: string; // The transaction id of the previous output.
143+
vout: number; // The output index of the previous output.
144+
script: Uint8Array; // The scriptPubKey of the previous output.
145+
value: number; // The value of the previous output.
146+
}
147+
148+
/** The receiver's private keys needed for scanning. */
149+
interface ReceiverKeys {
150+
scanPrivKey: Uint8Array;
151+
spendPrivKey: Uint8Array;
152+
}
153+
154+
/**
155+
* The result of a successful scan, providing the data needed to spend the output.
156+
* Extends the base SilentOutput from p2sp.ts to include transaction-specific UTXO info.
157+
*/
158+
export interface SilentOutput extends P2SPSilentOutput {
159+
utxo: {
160+
txid: string;
161+
vout: number;
162+
value: number;
163+
};
164+
}
165+
166+
/**
167+
* Extends the bitcoinjs-lib Transaction class to provide methods
168+
* for creating and scanning for BIP-352 Silent Payments.
169+
*/
170+
export class SilentPaymentTransaction extends bitcoin.Transaction {
171+
private _senderPrivateKeys: { key: Uint8Array; isXOnly: boolean }[] = [];
172+
private _silentIntents: {
173+
recipientAddress: string;
174+
value: number;
175+
vout: number; // The index of the placeholder output
176+
}[] = [];
177+
private _outputsDirty: boolean = true;
178+
179+
/**
180+
* Adds an input to the transaction and tracks its private key for SP calculations.
181+
* NOTE: Adding an input invalidates any previously calculated silent payment outputs.
182+
* Call `finalizeSilentOutputs()` after all inputs have been added.
183+
* @param hash Transaction hash of the input.
184+
* @param index Output index of the input.
185+
* @param sequence The sequence number.
186+
* @param scriptSig The script signature (for non-witness inputs).
187+
* @param prevoutScript The scriptPubKey of the UTXO being spent.
188+
* @param senderPrivateKey The private key corresponding to this input.
189+
*/
190+
addSilentInput(
191+
hash: Uint8Array,
192+
index: number,
193+
sequence: number | undefined,
194+
scriptSig: Uint8Array | undefined,
195+
prevoutScript: Uint8Array,
196+
senderPrivateKey: Uint8Array,
197+
): number {
198+
const isP2TR = prevoutScript.length === 34 && prevoutScript[0] === 0x51;
199+
this._senderPrivateKeys.push({ key: senderPrivateKey, isXOnly: isP2TR });
200+
this._outputsDirty = true; // Mark outputs as dirty whenever an input changes.
201+
// The parent class expects Buffers. Casting to `any` to match user's request.
202+
return super.addInput(hash as any, index, sequence, scriptSig as any);
203+
}
204+
205+
/**
206+
* Calculates the sum of the sender's private keys using the helper from p2sp.ts.
207+
* This is the `a_sum` scalar.
208+
*/
209+
private _calculateSummedPrivateKey(): Uint8Array | null {
210+
if (this._senderPrivateKeys.length === 0) {
211+
throw new Error('Cannot create a silent payment without inputs.');
212+
}
213+
return calculateSumA(
214+
this._senderPrivateKeys.map(pk => ({
215+
priv: pk.key,
216+
isXOnly: pk.isXOnly,
217+
})),
218+
);
219+
}
220+
221+
/**
222+
* Adds a placeholder for a silent payment output and stores the intent to calculate it later.
223+
* The actual script will be generated and inserted when `finalizeSilentOutputs()` is called.
224+
* @param recipientAddress The bech32m-encoded silent payment address of the recipient.
225+
* @param value The amount in satoshis to send.
226+
* @returns The vout (output index) of the placeholder output.
227+
*/
228+
addSilentPaymentOutput(recipientAddress: string, value: number): number {
229+
// Add a placeholder output (empty script) to reserve the vout and value.
230+
const placeholderScript = new Uint8Array(0);
231+
const vout = this.addOutput(placeholderScript, value);
232+
233+
// Store the intent to be finalized later.
234+
this._silentIntents.push({
235+
recipientAddress,
236+
value,
237+
vout,
238+
});
239+
240+
return vout;
241+
}
242+
243+
/**
244+
* Finalizes all pending silent payment outputs. This method calculates the correct
245+
* output scripts based on the final set of inputs and updates the transaction.
246+
* It is idempotent and will only perform calculations if inputs have changed.
247+
*
248+
* THIS METHOD MUST BE CALLED after all inputs have been added and before signing.
249+
*/
250+
finalizeSilentOutputs(): void {
251+
if (!this._outputsDirty || this._silentIntents.length === 0) {
252+
return; // Nothing to do or already up-to-date.
253+
}
254+
255+
const a_sum = this._calculateSummedPrivateKey();
256+
if (!a_sum) {
257+
throw new Error(
258+
'Sender private keys sum to zero; cannot create silent payment outputs.',
259+
);
260+
}
261+
262+
const smallestOutpoint = findSmallestOutpoint(this.ins);
263+
const A_sum_point = ecc.pointFromScalar(a_sum, true)!;
264+
const input_hash = calculateInputHashTweak(smallestOutpoint, A_sum_point);
265+
266+
// This map tracks the 'k' value for each recipient (B_scan) during this finalization.
267+
const kValues = new Map<string, number>();
268+
269+
for (const intent of this._silentIntents) {
270+
const { recipientAddress, vout } = intent;
271+
272+
const { B_scan, B_spend } = decodeSilentPaymentAddress(recipientAddress);
273+
const bScanHex = toHex(B_scan);
274+
const k = kValues.get(bScanHex) || 0;
275+
276+
// S = (input_hash * B_scan) * a_sum
277+
const S = calculateSharedSecret(input_hash, B_scan, a_sum);
278+
279+
// { pub_key, tweak_key } = deriveSilentOutput(...)
280+
const { pub_key } = deriveSilentOutput(S, B_spend, k);
281+
282+
const p2tr = bitcoin.payments.p2tr({ pubkey: pub_key });
283+
if (!p2tr.output) {
284+
throw new Error(
285+
`Failed to create P2TR output script for recipient ${recipientAddress}`,
286+
);
287+
}
288+
289+
// Update the placeholder output with the correct script.
290+
this.outs[vout].script = p2tr.output;
291+
292+
// Increment k for the next payment to this same recipient in this batch.
293+
kValues.set(bScanHex, k + 1);
294+
}
295+
296+
// Reset the dirty flag after successful finalization.
297+
this._outputsDirty = false;
298+
}
299+
300+
/**
301+
* Scans a finalized transaction to find any outputs belonging to the receiver.
302+
* @param tx The finalized bitcoinjs-lib Transaction object.
303+
* @param prevouts An array of Previous Outputs being spent by the transaction, used to get prevout scripts.
304+
* @param receiverKeys The receiver's scan and spend private keys.
305+
* @param labelNonces An optional array of label nonces to scan for.
306+
* @returns An array of found silent payment outputs with spending information.
307+
*/
308+
static scan(
309+
tx: Transaction,
310+
prevouts: Prevout[],
311+
receiverKeys: ReceiverKeys,
312+
labelNonces: number[] = [],
313+
): SilentOutput[] {
314+
const validPubkeys = tx.ins
315+
.map(input => {
316+
const prevout = prevouts.find(
317+
p =>
318+
tools.equals(tools.fromHex(p.txid).reverse(), input.hash) &&
319+
p.vout === input.index,
320+
);
321+
if (!prevout) return null;
322+
// The getPublicKeyFromInput function expects Buffers.
323+
// Casting to `any` to match user's request to avoid Buffer.from()
324+
return getPublicKeyFromInput({
325+
prevoutScript: prevout.script as any,
326+
scriptSig: input.script as any,
327+
witness: input.witness as any,
328+
});
329+
})
330+
.filter((pk): pk is Uint8Array => pk !== null);
331+
332+
if (validPubkeys.length === 0) return [];
333+
334+
const A_sum = validPubkeys.reduce((acc, pk) => ecc.pointAdd(acc, pk)!);
335+
if (!A_sum) return [];
336+
337+
const smallestOutpoint = findSmallestOutpoint(this.ins);
338+
const input_hash = calculateInputHashTweak(smallestOutpoint, A_sum);
339+
340+
const txOutputXOnlyPubkeys = tx.outs.map(out => toHex(out.script.slice(2)));
341+
const outputsToCheck = new Set(txOutputXOnlyPubkeys);
342+
343+
// Delegate the core scanning logic to the tested helper function.
344+
const foundP2SPOutputs = scanForSilentPayments(
345+
receiverKeys.scanPrivKey,
346+
receiverKeys.spendPrivKey,
347+
input_hash,
348+
A_sum,
349+
outputsToCheck,
350+
labelNonces,
351+
);
352+
353+
// Map the results from the generic helper to the transaction-specific SilentOutput type.
354+
return foundP2SPOutputs.map(p2spOutput => {
355+
const pubkeyHex = toHex(p2spOutput.pub_key);
356+
const vout = txOutputXOnlyPubkeys.findIndex(hex => hex === pubkeyHex);
357+
358+
if (vout === -1) {
359+
throw new Error(
360+
'Logic error: Found output not present in transaction.',
361+
);
362+
}
363+
364+
return {
365+
...p2spOutput,
366+
utxo: {
367+
txid: tx.getId(),
368+
vout,
369+
value: tx.outs[vout].value,
370+
},
371+
};
372+
});
373+
}
374+
}

0 commit comments

Comments
 (0)