@did-btcr2/method 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,13 +7,10 @@ import { Address, OutScript, p2pkh, p2tr, p2wpkh, Script, SigHash, Transaction }
7
7
  import type { BeaconProcessResult } from '../resolver.js';
8
8
  import type { SidecarData } from '../types.js';
9
9
  import { BeaconError } from './error.js';
10
- import { StaticFeeEstimator } from './fee-estimator.js';
10
+ import { DEFAULT_FEE_ESTIMATOR } from './fee-estimator.js';
11
11
  import type { FeeEstimator } from './fee-estimator.js';
12
12
  import type { BeaconService, BeaconSignal } from './interfaces.js';
13
13
 
14
- /** Default fee estimator used when none is supplied. ~5 sat/vB static rate. */
15
- const DEFAULT_FEE_ESTIMATOR: FeeEstimator = new StaticFeeEstimator(5);
16
-
17
14
  /**
18
15
  * Singleton beacon script kinds. Per the did:btcr2 spec, deterministic DID documents
19
16
  * include three beacon services: P2PKH, P2WPKH, and P2TR (taproot key-path), all
@@ -51,6 +48,54 @@ export const SINGLETON_BEACON_TX_VSIZE: Readonly<Record<SingletonScriptKind, num
51
48
  p2tr : P2TR_BEACON_TX_VSIZE,
52
49
  };
53
50
 
51
+ /**
52
+ * Serialized size (vbytes) of a single change output, by script kind:
53
+ * 8 (value) + 1 (scriptPubKey length) + scriptPubKey bytes. P2PKH 25, P2WPKH 22,
54
+ * P2TR 34. These are non-witness bytes, so each contributes its full byte count to
55
+ * the transaction vsize. The {@link SINGLETON_BEACON_TX_VSIZE} constants bake in a
56
+ * same-kind change output; {@link beaconTxVsize} uses these deltas to re-size the
57
+ * fee when a caller routes change to an address of a different kind (ADR 044).
58
+ */
59
+ export const CHANGE_OUTPUT_VBYTES: Readonly<Record<SingletonScriptKind, number>> = {
60
+ p2pkh : 34,
61
+ p2wpkh : 31,
62
+ p2tr : 43,
63
+ };
64
+
65
+ /**
66
+ * Dust threshold (sats) below which a change output is not worth creating, by script
67
+ * kind (the standard Bitcoin Core dust relay thresholds at the default 3 sat/vB dust
68
+ * rate). When the change after fees falls below this, the builders omit the change
69
+ * output and let the remainder fall into the fee rather than emit an unspendable,
70
+ * relay-rejected dust output (ADR 044).
71
+ */
72
+ export const DUST_LIMIT_SATS: Readonly<Record<SingletonScriptKind, number>> = {
73
+ p2pkh : 546,
74
+ p2wpkh : 294,
75
+ p2tr : 330,
76
+ };
77
+
78
+ /**
79
+ * vsize (vbytes) for a beacon transaction that spends one input of `beaconKind`
80
+ * and returns change to an output of `changeKind`, plus the OP_RETURN(32) signal.
81
+ *
82
+ * When `changeKind === beaconKind` (the default, change to the beacon address) this
83
+ * returns the per-kind {@link SINGLETON_BEACON_TX_VSIZE} constant unchanged, so the
84
+ * default path and the constants' lock-in tests are byte-identical. A differing
85
+ * `changeKind` swaps the assumed same-kind change output for the actual one, keeping
86
+ * the result a valid upper bound. The aggregation key-path spend is the
87
+ * `beaconKind: 'p2tr'` case (its input is always the cohort's P2TR key path; only the
88
+ * change output varies), the analytical sizing ADR 045 calls for, computed without a
89
+ * secret.
90
+ */
91
+ export function beaconTxVsize(
92
+ beaconKind: SingletonScriptKind,
93
+ changeKind: SingletonScriptKind,
94
+ ): number {
95
+ const base = SINGLETON_BEACON_TX_VSIZE[beaconKind] - CHANGE_OUTPUT_VBYTES[beaconKind];
96
+ return base + CHANGE_OUTPUT_VBYTES[changeKind];
97
+ }
98
+
54
99
  /**
55
100
  * Detect the singleton script kind of a Bitcoin address (P2PKH / P2WPKH / P2TR).
56
101
  * The deterministic-DID document emits all three kinds; the broadcast path needs
@@ -88,12 +133,56 @@ export function deriveSingletonAddress(
88
133
  return p2tr(pubkey.slice(1, 33), undefined, network).address!;
89
134
  }
90
135
 
136
+ /**
137
+ * Resolve the change-output recipient for a beacon transaction. Returns the beacon
138
+ * address when no change address is supplied (preserving the prior behavior of
139
+ * returning change to the spent address), otherwise validates the caller-supplied
140
+ * address against the network and returns it. Validating here fails fast rather than
141
+ * burning a real UTXO on a transaction that breaks at broadcast (ADR 044).
142
+ */
143
+ export function resolveChangeAddress(
144
+ beaconAddress: string,
145
+ network: BTCNetwork,
146
+ changeAddress?: string,
147
+ ): string {
148
+ if(!changeAddress || changeAddress === beaconAddress) return beaconAddress;
149
+ try {
150
+ Address(network).decode(changeAddress);
151
+ } catch {
152
+ throw new BeaconError(
153
+ `Invalid change address "${changeAddress}" for network "${network}".`,
154
+ 'INVALID_CHANGE_ADDRESS',
155
+ { changeAddress, network }
156
+ );
157
+ }
158
+ return changeAddress;
159
+ }
160
+
161
+ /**
162
+ * Detect the change output's script kind for fee sizing. A change address that is not
163
+ * one of the three singleton kinds (for example P2SH or P2WSH) is sized as P2TR, the
164
+ * largest standard change output, so the estimated fee stays a valid upper bound.
165
+ */
166
+ function changeOutputKind(changeAddress: string, network: BTCNetwork): SingletonScriptKind {
167
+ try {
168
+ return detectSingletonScriptKind(changeAddress, network);
169
+ } catch {
170
+ return 'p2tr';
171
+ }
172
+ }
173
+
91
174
  /**
92
175
  * Options accepted by {@link SinglePartyBeacon.buildSignAndBroadcast} and related helpers.
93
176
  */
94
177
  export interface BroadcastOptions {
95
178
  /** Fee estimator for computing the transaction fee. Defaults to {@link DEFAULT_FEE_ESTIMATOR}. */
96
179
  feeEstimator?: FeeEstimator;
180
+ /**
181
+ * Address to send change to. Defaults to the beacon address (reuses the spent
182
+ * address, the prior behavior). Supply a fresh address the controller owns to
183
+ * stop linking the beacon's announcements into one on-chain chain (ADR 044).
184
+ */
185
+ changeAddress?: string;
97
186
  }
98
187
 
99
188
  /**
@@ -107,8 +196,10 @@ export interface BeaconTxPlan {
107
196
  prevOutScripts: Uint8Array[];
108
197
  /** Amounts (sats) of the consumed previous outputs. */
109
198
  prevOutValues: bigint[];
110
- /** Address change was sent back to (same as the beacon address). */
199
+ /** The beacon address this tx spends from. */
111
200
  beaconAddress: string;
201
+ /** Address the change output was sent to (the beacon address unless a change address was supplied). */
202
+ changeAddress: string;
112
203
  /** The UTXO this tx consumes. */
113
204
  utxo: AddressUtxo;
114
205
  /** The fee (sats) already deducted from the change output. */
@@ -188,9 +279,17 @@ export async function buildAggregationBeaconTx(opts: {
188
279
  network: BTCNetwork;
189
280
  /** Optional fee estimator (defaults to 5 sat/vB). */
190
281
  feeEstimator?: FeeEstimator;
282
+ /**
283
+ * Address to send change to. Defaults to the beacon (cohort) address. Supply the
284
+ * funder's address (an operator-funded cohort's funding wallet) to stop reusing the
285
+ * cohort address for change (ADR 044). Change ownership is the funder's call, which
286
+ * the cohort-condition model leaves to the caller (ADR 039).
287
+ */
288
+ changeAddress?: string;
191
289
  }): Promise<BeaconTxPlan> {
192
290
  const feeEstimator = opts.feeEstimator ?? DEFAULT_FEE_ESTIMATOR;
193
291
  const { utxo, prevTxBytes } = await fetchSpendableUtxo(opts.beaconAddress, opts.bitcoin);
292
+ const changeAddress = resolveChangeAddress(opts.beaconAddress, opts.network, opts.changeAddress);
194
293
 
195
294
  // The funded beacon output is a Taproot script-tree output: key path is the
196
295
  // MuSig2 aggregate, script path is the k-of-n fallback + CSV recovery leaves
@@ -200,8 +299,11 @@ export async function buildAggregationBeaconTx(opts: {
200
299
  // key-path sighash and the fallback script-path sighash.
201
300
  const witnessScript = OutScript.encode(Address(opts.network).decode(opts.beaconAddress));
202
301
 
203
- // Fee cannot be probe-measured (no secret key for MuSig2 round). Use fixed P2TR vsize.
204
- const feeSats = await feeEstimator.estimateFee(P2TR_BEACON_TX_VSIZE);
302
+ // The fee cannot be probe-measured (no secret key until the downstream MuSig2
303
+ // round), so size it analytically. The input is the cohort's P2TR key path; only
304
+ // the change output's kind varies, so the vsize follows the change address (ADR 045).
305
+ const changeKind = changeOutputKind(changeAddress, opts.network);
306
+ const feeSats = await feeEstimator.estimateFee(beaconTxVsize('p2tr', changeKind));
205
307
  if(BigInt(utxo.value) <= feeSats) {
206
308
  throw new BeaconError(
207
309
  `UTXO value (${utxo.value}) insufficient to cover fee (${feeSats}).`,
@@ -221,7 +323,12 @@ export async function buildAggregationBeaconTx(opts: {
221
323
  witnessUtxo : { amount: BigInt(utxo.value), script: witnessScript },
222
324
  tapInternalKey : opts.internalPubkey,
223
325
  });
224
- tx.addOutputAddress(opts.beaconAddress, BigInt(utxo.value) - feeSats, opts.network);
326
+ // Change first (omitted when it would be dust, sweeping the remainder into the
327
+ // fee), then the OP_RETURN signal, which the spec requires to be the last output.
328
+ const changeValue = BigInt(utxo.value) - feeSats;
329
+ if(changeValue >= BigInt(DUST_LIMIT_SATS[changeKind])) {
330
+ tx.addOutputAddress(changeAddress, changeValue, opts.network);
331
+ }
225
332
  tx.addOutput({ script: opReturnScript(opts.signalBytes), amount: 0n });
226
333
 
227
334
  return {
@@ -229,6 +336,7 @@ export async function buildAggregationBeaconTx(opts: {
229
336
  prevOutScripts : [witnessScript],
230
337
  prevOutValues : [BigInt(utxo.value)],
231
338
  beaconAddress : opts.beaconAddress,
339
+ changeAddress,
232
340
  utxo,
233
341
  feeSats,
234
342
  scriptKind : 'p2tr',
@@ -404,6 +512,7 @@ export abstract class SinglePartyBeacon {
404
512
  const { utxo, prevTxBytes } = await fetchSpendableUtxo(beaconAddress, bitcoin);
405
513
  const plan = await this.buildSinglePartyTx({
406
514
  signalBytes, beaconAddress, utxo, prevTxBytes, signer, bitcoin, feeEstimator,
515
+ changeAddress : options?.changeAddress,
407
516
  });
408
517
  const signedHex = await this.signSinglePartyTx(plan, signer);
409
518
  return this.broadcastRawTx(bitcoin, signedHex);
@@ -416,8 +525,9 @@ export abstract class SinglePartyBeacon {
416
525
  * the input accordingly. Validates that the signer's pubkey produces the beacon
417
526
  * address under that script kind: without this check, a misconfigured caller
418
527
  * would burn a real UTXO on a tx that fails at broadcast. Fees are computed from
419
- * the per-kind {@link SINGLETON_BEACON_TX_VSIZE} constant, avoiding any probe-sign
420
- * round-trip.
528
+ * the per-kind {@link SINGLETON_BEACON_TX_VSIZE} constant (via {@link beaconTxVsize}),
529
+ * avoiding any probe-sign round-trip; a change address of a different kind re-sizes
530
+ * the fee by the change output's size delta so it stays a valid upper bound.
421
531
  */
422
532
  protected async buildSinglePartyTx(opts: {
423
533
  signalBytes: Uint8Array;
@@ -427,10 +537,12 @@ export abstract class SinglePartyBeacon {
427
537
  signer: Signer;
428
538
  bitcoin: BitcoinConnection;
429
539
  feeEstimator: FeeEstimator;
540
+ changeAddress?: string;
430
541
  }): Promise<BeaconTxPlan> {
431
542
  const network = opts.bitcoin.data;
432
543
  const pubkey = opts.signer.publicKey;
433
544
  const kind = detectSingletonScriptKind(opts.beaconAddress, network);
545
+ const changeAddress = resolveChangeAddress(opts.beaconAddress, network, opts.changeAddress);
434
546
 
435
547
  const derivedAddress = deriveSingletonAddress(kind, pubkey, network);
436
548
  if(derivedAddress !== opts.beaconAddress) {
@@ -441,7 +553,8 @@ export abstract class SinglePartyBeacon {
441
553
  );
442
554
  }
443
555
 
444
- const feeSats = await opts.feeEstimator.estimateFee(SINGLETON_BEACON_TX_VSIZE[kind]);
556
+ const changeKind = changeOutputKind(changeAddress, network);
557
+ const feeSats = await opts.feeEstimator.estimateFee(beaconTxVsize(kind, changeKind));
445
558
  const amount = BigInt(opts.utxo.value);
446
559
  if(amount <= feeSats) {
447
560
  throw new BeaconError(
@@ -487,7 +600,12 @@ export abstract class SinglePartyBeacon {
487
600
  });
488
601
  }
489
602
 
490
- tx.addOutputAddress(opts.beaconAddress, amount - feeSats, network);
603
+ // Change first (omitted when it would be dust, sweeping the remainder into the
604
+ // fee), then the OP_RETURN signal, which the spec requires to be the last output.
605
+ const changeValue = amount - feeSats;
606
+ if(changeValue >= BigInt(DUST_LIMIT_SATS[changeKind])) {
607
+ tx.addOutputAddress(changeAddress, changeValue, network);
608
+ }
491
609
  tx.addOutput({ script: opReturnScript(opts.signalBytes), amount: 0n });
492
610
 
493
611
  return {
@@ -495,6 +613,7 @@ export abstract class SinglePartyBeacon {
495
613
  prevOutScripts : [prevOutScript],
496
614
  prevOutValues : [amount],
497
615
  beaconAddress : opts.beaconAddress,
616
+ changeAddress,
498
617
  utxo : opts.utxo,
499
618
  feeSats,
500
619
  scriptKind : kind,
@@ -50,3 +50,12 @@ export class StaticFeeEstimator implements FeeEstimator {
50
50
  return BigInt(Math.ceil(vsize * this.satsPerVbyte));
51
51
  }
52
52
  }
53
+
54
+ /**
55
+ * Default fee estimator used when a caller supplies none: a static 5 sat/vB rate.
56
+ * Suitable for tests and regtest. Production callers should inject a dynamic
57
+ * estimator (a mempool API, or Bitcoin Core `estimatesmartfee`) at the point the
58
+ * beacon transaction is built (single-party broadcast options, or the aggregation
59
+ * service runner's fee estimator).
60
+ */
61
+ export const DEFAULT_FEE_ESTIMATOR: FeeEstimator = new StaticFeeEstimator(5);