@did-btcr2/method 0.36.1 → 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.
Files changed (169) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/browser.js +1149 -126
  3. package/dist/browser.mjs +1149 -126
  4. package/dist/cjs/index.js +1244 -208
  5. package/dist/esm/core/aggregation/beacon-strategy.js +19 -6
  6. package/dist/esm/core/aggregation/beacon-strategy.js.map +1 -1
  7. package/dist/esm/core/aggregation/cohort.js +142 -33
  8. package/dist/esm/core/aggregation/cohort.js.map +1 -1
  9. package/dist/esm/core/aggregation/conditions.js +27 -0
  10. package/dist/esm/core/aggregation/conditions.js.map +1 -1
  11. package/dist/esm/core/aggregation/fallback-spend.js +132 -0
  12. package/dist/esm/core/aggregation/fallback-spend.js.map +1 -0
  13. package/dist/esm/core/aggregation/messages/base.js.map +1 -1
  14. package/dist/esm/core/aggregation/messages/bodies.js +46 -1
  15. package/dist/esm/core/aggregation/messages/bodies.js.map +1 -1
  16. package/dist/esm/core/aggregation/messages/constants.js +11 -0
  17. package/dist/esm/core/aggregation/messages/constants.js.map +1 -1
  18. package/dist/esm/core/aggregation/messages/factories.js +27 -1
  19. package/dist/esm/core/aggregation/messages/factories.js.map +1 -1
  20. package/dist/esm/core/aggregation/messages/guards.js +4 -1
  21. package/dist/esm/core/aggregation/messages/guards.js.map +1 -1
  22. package/dist/esm/core/aggregation/participant.js +210 -21
  23. package/dist/esm/core/aggregation/participant.js.map +1 -1
  24. package/dist/esm/core/aggregation/phases.js +6 -0
  25. package/dist/esm/core/aggregation/phases.js.map +1 -1
  26. package/dist/esm/core/aggregation/recovery-policy.js +156 -0
  27. package/dist/esm/core/aggregation/recovery-policy.js.map +1 -0
  28. package/dist/esm/core/aggregation/recovery-spend.js +106 -0
  29. package/dist/esm/core/aggregation/recovery-spend.js.map +1 -0
  30. package/dist/esm/core/aggregation/runner/aggregation-runner.js +12 -3
  31. package/dist/esm/core/aggregation/runner/aggregation-runner.js.map +1 -1
  32. package/dist/esm/core/aggregation/runner/participant-runner.js +74 -22
  33. package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
  34. package/dist/esm/core/aggregation/runner/service-runner.js +104 -11
  35. package/dist/esm/core/aggregation/runner/service-runner.js.map +1 -1
  36. package/dist/esm/core/aggregation/service.js +221 -11
  37. package/dist/esm/core/aggregation/service.js.map +1 -1
  38. package/dist/esm/core/aggregation/signing-session.js +3 -3
  39. package/dist/esm/core/aggregation/signing-session.js.map +1 -1
  40. package/dist/esm/core/aggregation/transport/http/envelope.js +3 -3
  41. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js +1 -1
  42. package/dist/esm/core/aggregation/transport/http/nonce-cache.js +1 -1
  43. package/dist/esm/core/aggregation/transport/http/rate-limiter.js +1 -1
  44. package/dist/esm/core/aggregation/transport/http/request-auth.js +1 -1
  45. package/dist/esm/core/aggregation/transport/http/server.js +1 -1
  46. package/dist/esm/core/aggregation/transport/http/sse-stream.js +1 -1
  47. package/dist/esm/core/aggregation/transport/http/sse-writer.js +1 -1
  48. package/dist/esm/core/aggregation/transport/in-memory.js +1 -1
  49. package/dist/esm/core/aggregation/transport/nostr.js +6 -6
  50. package/dist/esm/core/beacon/beacon.js +116 -20
  51. package/dist/esm/core/beacon/beacon.js.map +1 -1
  52. package/dist/esm/core/beacon/cas-beacon.js +6 -6
  53. package/dist/esm/core/beacon/cas-beacon.js.map +1 -1
  54. package/dist/esm/core/beacon/fee-estimator.js +8 -0
  55. package/dist/esm/core/beacon/fee-estimator.js.map +1 -1
  56. package/dist/esm/core/beacon/singleton-beacon.js +2 -2
  57. package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
  58. package/dist/esm/core/beacon/smt-beacon.js +3 -3
  59. package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
  60. package/dist/esm/core/resolver.js +8 -8
  61. package/dist/esm/core/resolver.js.map +1 -1
  62. package/dist/esm/core/updater.js +6 -6
  63. package/dist/esm/core/updater.js.map +1 -1
  64. package/dist/esm/did-btcr2.js +2 -2
  65. package/dist/esm/index.js +3 -0
  66. package/dist/esm/index.js.map +1 -1
  67. package/dist/types/core/aggregation/beacon-strategy.d.ts +8 -4
  68. package/dist/types/core/aggregation/beacon-strategy.d.ts.map +1 -1
  69. package/dist/types/core/aggregation/cohort.d.ts +88 -17
  70. package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
  71. package/dist/types/core/aggregation/conditions.d.ts +28 -0
  72. package/dist/types/core/aggregation/conditions.d.ts.map +1 -1
  73. package/dist/types/core/aggregation/fallback-spend.d.ts +94 -0
  74. package/dist/types/core/aggregation/fallback-spend.d.ts.map +1 -0
  75. package/dist/types/core/aggregation/logger.d.ts +1 -1
  76. package/dist/types/core/aggregation/messages/base.d.ts +6 -0
  77. package/dist/types/core/aggregation/messages/base.d.ts.map +1 -1
  78. package/dist/types/core/aggregation/messages/bodies.d.ts +50 -2
  79. package/dist/types/core/aggregation/messages/bodies.d.ts.map +1 -1
  80. package/dist/types/core/aggregation/messages/constants.d.ts +8 -0
  81. package/dist/types/core/aggregation/messages/constants.d.ts.map +1 -1
  82. package/dist/types/core/aggregation/messages/factories.d.ts +40 -0
  83. package/dist/types/core/aggregation/messages/factories.d.ts.map +1 -1
  84. package/dist/types/core/aggregation/messages/guards.d.ts.map +1 -1
  85. package/dist/types/core/aggregation/participant.d.ts +41 -7
  86. package/dist/types/core/aggregation/participant.d.ts.map +1 -1
  87. package/dist/types/core/aggregation/phases.d.ts +8 -2
  88. package/dist/types/core/aggregation/phases.d.ts.map +1 -1
  89. package/dist/types/core/aggregation/recovery-policy.d.ts +132 -0
  90. package/dist/types/core/aggregation/recovery-policy.d.ts.map +1 -0
  91. package/dist/types/core/aggregation/recovery-spend.d.ts +94 -0
  92. package/dist/types/core/aggregation/recovery-spend.d.ts.map +1 -0
  93. package/dist/types/core/aggregation/runner/aggregation-runner.d.ts +19 -3
  94. package/dist/types/core/aggregation/runner/aggregation-runner.d.ts.map +1 -1
  95. package/dist/types/core/aggregation/runner/events.d.ts +28 -7
  96. package/dist/types/core/aggregation/runner/events.d.ts.map +1 -1
  97. package/dist/types/core/aggregation/runner/participant-runner.d.ts +3 -3
  98. package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
  99. package/dist/types/core/aggregation/runner/service-runner.d.ts +47 -5
  100. package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
  101. package/dist/types/core/aggregation/service.d.ts +26 -2
  102. package/dist/types/core/aggregation/service.d.ts.map +1 -1
  103. package/dist/types/core/aggregation/signing-session.d.ts +2 -2
  104. package/dist/types/core/aggregation/signing-session.d.ts.map +1 -1
  105. package/dist/types/core/aggregation/transport/http/envelope.d.ts +3 -3
  106. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts +1 -1
  107. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts +1 -1
  108. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts +1 -1
  109. package/dist/types/core/aggregation/transport/http/request-auth.d.ts +1 -1
  110. package/dist/types/core/aggregation/transport/http/server.d.ts +1 -1
  111. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts +2 -2
  112. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts +1 -1
  113. package/dist/types/core/aggregation/transport/in-memory.d.ts +1 -1
  114. package/dist/types/core/aggregation/transport/nostr.d.ts +4 -4
  115. package/dist/types/core/aggregation/transport/transport.d.ts +4 -4
  116. package/dist/types/core/aggregation/transport/transport.d.ts.map +1 -1
  117. package/dist/types/core/beacon/beacon.d.ts +66 -10
  118. package/dist/types/core/beacon/beacon.d.ts.map +1 -1
  119. package/dist/types/core/beacon/cas-beacon.d.ts +2 -2
  120. package/dist/types/core/beacon/fee-estimator.d.ts +8 -0
  121. package/dist/types/core/beacon/fee-estimator.d.ts.map +1 -1
  122. package/dist/types/core/beacon/interfaces.d.ts +1 -1
  123. package/dist/types/core/resolver.d.ts +3 -3
  124. package/dist/types/core/updater.d.ts +4 -4
  125. package/dist/types/did-btcr2.d.ts +2 -2
  126. package/dist/types/index.d.ts +3 -0
  127. package/dist/types/index.d.ts.map +1 -1
  128. package/package.json +4 -4
  129. package/src/core/aggregation/beacon-strategy.ts +27 -9
  130. package/src/core/aggregation/cohort.ts +171 -35
  131. package/src/core/aggregation/conditions.ts +56 -0
  132. package/src/core/aggregation/fallback-spend.ts +223 -0
  133. package/src/core/aggregation/logger.ts +1 -1
  134. package/src/core/aggregation/messages/base.ts +6 -0
  135. package/src/core/aggregation/messages/bodies.ts +94 -1
  136. package/src/core/aggregation/messages/constants.ts +12 -0
  137. package/src/core/aggregation/messages/factories.ts +57 -0
  138. package/src/core/aggregation/messages/guards.ts +6 -0
  139. package/src/core/aggregation/participant.ts +255 -23
  140. package/src/core/aggregation/phases.ts +9 -0
  141. package/src/core/aggregation/recovery-policy.ts +237 -0
  142. package/src/core/aggregation/recovery-spend.ts +192 -0
  143. package/src/core/aggregation/runner/aggregation-runner.ts +34 -5
  144. package/src/core/aggregation/runner/events.ts +26 -7
  145. package/src/core/aggregation/runner/participant-runner.ts +75 -23
  146. package/src/core/aggregation/runner/service-runner.ts +144 -12
  147. package/src/core/aggregation/service.ts +260 -14
  148. package/src/core/aggregation/signing-session.ts +3 -3
  149. package/src/core/aggregation/transport/http/envelope.ts +3 -3
  150. package/src/core/aggregation/transport/http/inbox-buffer.ts +1 -1
  151. package/src/core/aggregation/transport/http/nonce-cache.ts +1 -1
  152. package/src/core/aggregation/transport/http/rate-limiter.ts +1 -1
  153. package/src/core/aggregation/transport/http/request-auth.ts +1 -1
  154. package/src/core/aggregation/transport/http/server.ts +1 -1
  155. package/src/core/aggregation/transport/http/sse-stream.ts +2 -2
  156. package/src/core/aggregation/transport/http/sse-writer.ts +1 -1
  157. package/src/core/aggregation/transport/in-memory.ts +1 -1
  158. package/src/core/aggregation/transport/nostr.ts +6 -6
  159. package/src/core/aggregation/transport/transport.ts +4 -4
  160. package/src/core/beacon/beacon.ts +149 -25
  161. package/src/core/beacon/cas-beacon.ts +7 -7
  162. package/src/core/beacon/fee-estimator.ts +9 -0
  163. package/src/core/beacon/interfaces.ts +1 -1
  164. package/src/core/beacon/singleton-beacon.ts +2 -2
  165. package/src/core/beacon/smt-beacon.ts +3 -3
  166. package/src/core/resolver.ts +8 -8
  167. package/src/core/updater.ts +6 -6
  168. package/src/did-btcr2.ts +2 -2
  169. package/src/index.ts +3 -0
@@ -7,37 +7,34 @@ 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
- * include three beacon services: P2PKH, P2WPKH, and P2TR (taproot key-path) all
16
+ * include three beacon services: P2PKH, P2WPKH, and P2TR (taproot key-path), all
20
17
  * derived from the genesis secp256k1 public key. The singleton broadcast path must
21
18
  * support signing for all three.
22
19
  */
23
20
  export type SingletonScriptKind = 'p2pkh' | 'p2wpkh' | 'p2tr';
24
21
 
25
22
  /**
26
- * Conservative vsize estimate for a 1-input P2TR key-path 1 P2TR change + 1 OP_RETURN(32) tx.
23
+ * Conservative vsize estimate for a 1-input P2TR key-path to 1 P2TR change + 1 OP_RETURN(32) tx.
27
24
  * Stripped 137 + witness ≈ 68 (marker + flag + stack-count + sig-len + 64 BIP-340 sig).
28
25
  * Weight = 137*4 + 68 = 616, vsize ≈ 154, rounded to 160 for headroom.
29
26
  */
30
27
  export const P2TR_BEACON_TX_VSIZE = 160;
31
28
 
32
29
  /**
33
- * Conservative vsize estimate for a 1-input P2WPKH 1 P2WPKH change + 1 OP_RETURN(32) tx.
30
+ * Conservative vsize estimate for a 1-input P2WPKH to 1 P2WPKH change + 1 OP_RETURN(32) tx.
34
31
  * Stripped 125 + witness ≈ 110 (worst-case DER ECDSA sig 72 + sighash byte + 33 pubkey + framing).
35
32
  * vsize = ceil((125*4 + 110) / 4) ≈ 153, rounded to 155.
36
33
  */
37
34
  export const P2WPKH_BEACON_TX_VSIZE = 155;
38
35
 
39
36
  /**
40
- * Conservative vsize estimate for a 1-input P2PKH 1 P2PKH change + 1 OP_RETURN(32) tx.
37
+ * Conservative vsize estimate for a 1-input P2PKH to 1 P2PKH change + 1 OP_RETURN(32) tx.
41
38
  * Legacy (non-segwit): scriptSig carries the full sig+pubkey (~108 bytes), no witness
42
39
  * discount. Stripped ≈ 4 nVer + 1 vin-count + (32+4+1+108+4) input + 1 vout-count +
43
40
  * 34 P2PKH-change + 43 OP_RETURN + 4 nLockTime ≈ 236 bytes. vsize = 236, rounded to 240.
@@ -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. */
@@ -169,7 +260,7 @@ async function fetchSpendableUtxo(
169
260
  * signing session consumes (via {@link SigningTxData}).
170
261
  *
171
262
  * This is the reusable counterpart to {@link SinglePartyBeacon.buildSignAndBroadcast}'s internal
172
- * construction step the aggregation path must produce an unsigned tx because the
263
+ * construction step: the aggregation path must produce an unsigned tx because the
173
264
  * signature comes from a MuSig2 round, not a local secret key.
174
265
  *
175
266
  * @param opts Parameters including the cohort's aggregate internal pubkey.
@@ -188,15 +279,31 @@ 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);
194
-
195
- const tapOut = p2tr(opts.internalPubkey, undefined, opts.network);
196
- const witnessScript = tapOut.script;
197
-
198
- // Fee cannot be probe-measured (no secret key for MuSig2 round). Use fixed P2TR vsize.
199
- const feeSats = await feeEstimator.estimateFee(P2TR_BEACON_TX_VSIZE);
292
+ const changeAddress = resolveChangeAddress(opts.beaconAddress, opts.network, opts.changeAddress);
293
+
294
+ // The funded beacon output is a Taproot script-tree output: key path is the
295
+ // MuSig2 aggregate, script path is the k-of-n fallback + CSV recovery leaves
296
+ // (see cohort.ts and ADR 042). Derive the witnessUtxo scriptPubKey from the
297
+ // funded address itself; recomputing a key-path-only p2tr(internalPubkey) here
298
+ // would not match the script-tree UTXO on chain and would invalidate both the
299
+ // key-path sighash and the fallback script-path sighash.
300
+ const witnessScript = OutScript.encode(Address(opts.network).decode(opts.beaconAddress));
301
+
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));
200
307
  if(BigInt(utxo.value) <= feeSats) {
201
308
  throw new BeaconError(
202
309
  `UTXO value (${utxo.value}) insufficient to cover fee (${feeSats}).`,
@@ -216,7 +323,12 @@ export async function buildAggregationBeaconTx(opts: {
216
323
  witnessUtxo : { amount: BigInt(utxo.value), script: witnessScript },
217
324
  tapInternalKey : opts.internalPubkey,
218
325
  });
219
- 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
+ }
220
332
  tx.addOutput({ script: opReturnScript(opts.signalBytes), amount: 0n });
221
333
 
222
334
  return {
@@ -224,6 +336,7 @@ export async function buildAggregationBeaconTx(opts: {
224
336
  prevOutScripts : [witnessScript],
225
337
  prevOutValues : [BigInt(utxo.value)],
226
338
  beaconAddress : opts.beaconAddress,
339
+ changeAddress,
227
340
  utxo,
228
341
  feeSats,
229
342
  scriptKind : 'p2tr',
@@ -258,7 +371,7 @@ async function signSingletonInput(
258
371
  // directly. We need only the sighash bytes so an external Signer can produce
259
372
  // the signature, so we reach through the type system here. If scure ever
260
373
  // renames this method, the P2PKH path tests fail loudly.
261
- // TODO: track https://github.com/paulmillr/scure-btc-signer/issues/142
374
+ // TODO: track https://github.com/paulmillr/scure-btc-signer/issues/142 -
262
375
  // drop the cast once a public preimage (e.g. `preimageP2PKH`) lands upstream.
263
376
  const sighashType = SigHash.ALL;
264
377
  const sighash = (tx as unknown as {
@@ -302,7 +415,7 @@ async function signSingletonInput(
302
415
  // `d' = taprootTweakPrivKey(d, merkleRoot)`; the verifier checks against the
303
416
  // tweaked output internal key `Q = P + tG`. The tweak lives inside the Signer
304
417
  // (it needs the secret key), so we use scheme 'bip341' rather than the raw
305
- // 'bip340' scheme. No script tree on singleton beacons no merkleRoot.
418
+ // 'bip340' scheme. No script tree on singleton beacons, no merkleRoot.
306
419
  const sighash = tx.preimageWitnessV1(inputIdx, [prevOutScript], SigHash.DEFAULT, [amount]);
307
420
  const sig = signer.sign(sighash, 'bip341');
308
421
  tx.updateInput(inputIdx, { tapKeySig: sig });
@@ -377,7 +490,7 @@ export abstract class SinglePartyBeacon {
377
490
  *
378
491
  * Composed from the three extracted phases ({@link buildSinglePartyTx},
379
492
  * {@link signSinglePartyTx}, {@link broadcastRawTx}) so each piece can be exercised
380
- * in isolation. Aggregation beacons use {@link buildAggregationBeaconTx} instead
493
+ * in isolation. Aggregation beacons use {@link buildAggregationBeaconTx} instead:
381
494
  * the multi-party path can't share the signing phase, but the tx-construction
382
495
  * plumbing (UTXO fetch + OP_RETURN output + change output) is shared.
383
496
  *
@@ -399,6 +512,7 @@ export abstract class SinglePartyBeacon {
399
512
  const { utxo, prevTxBytes } = await fetchSpendableUtxo(beaconAddress, bitcoin);
400
513
  const plan = await this.buildSinglePartyTx({
401
514
  signalBytes, beaconAddress, utxo, prevTxBytes, signer, bitcoin, feeEstimator,
515
+ changeAddress : options?.changeAddress,
402
516
  });
403
517
  const signedHex = await this.signSinglePartyTx(plan, signer);
404
518
  return this.broadcastRawTx(bitcoin, signedHex);
@@ -409,10 +523,11 @@ export abstract class SinglePartyBeacon {
409
523
  *
410
524
  * Detects the beacon address script kind (P2PKH / P2WPKH / P2TR) and configures
411
525
  * the input accordingly. Validates that the signer's pubkey produces the beacon
412
- * address under that script kind without this check, a misconfigured caller
526
+ * address under that script kind: without this check, a misconfigured caller
413
527
  * would burn a real UTXO on a tx that fails at broadcast. Fees are computed from
414
- * the per-kind {@link SINGLETON_BEACON_TX_VSIZE} constant, avoiding any probe-sign
415
- * 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.
416
531
  */
417
532
  protected async buildSinglePartyTx(opts: {
418
533
  signalBytes: Uint8Array;
@@ -422,10 +537,12 @@ export abstract class SinglePartyBeacon {
422
537
  signer: Signer;
423
538
  bitcoin: BitcoinConnection;
424
539
  feeEstimator: FeeEstimator;
540
+ changeAddress?: string;
425
541
  }): Promise<BeaconTxPlan> {
426
542
  const network = opts.bitcoin.data;
427
543
  const pubkey = opts.signer.publicKey;
428
544
  const kind = detectSingletonScriptKind(opts.beaconAddress, network);
545
+ const changeAddress = resolveChangeAddress(opts.beaconAddress, network, opts.changeAddress);
429
546
 
430
547
  const derivedAddress = deriveSingletonAddress(kind, pubkey, network);
431
548
  if(derivedAddress !== opts.beaconAddress) {
@@ -436,7 +553,8 @@ export abstract class SinglePartyBeacon {
436
553
  );
437
554
  }
438
555
 
439
- 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));
440
558
  const amount = BigInt(opts.utxo.value);
441
559
  if(amount <= feeSats) {
442
560
  throw new BeaconError(
@@ -482,7 +600,12 @@ export abstract class SinglePartyBeacon {
482
600
  });
483
601
  }
484
602
 
485
- 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
+ }
486
609
  tx.addOutput({ script: opReturnScript(opts.signalBytes), amount: 0n });
487
610
 
488
611
  return {
@@ -490,6 +613,7 @@ export abstract class SinglePartyBeacon {
490
613
  prevOutScripts : [prevOutScript],
491
614
  prevOutValues : [amount],
492
615
  beaconAddress : opts.beaconAddress,
616
+ changeAddress,
493
617
  utxo : opts.utxo,
494
618
  feeSats,
495
619
  scriptKind : kind,
@@ -9,7 +9,7 @@ import { SinglePartyBeacon } from './beacon.js';
9
9
  import type { BeaconService, BeaconSignal, BlockMetadata, CasPublishFn } from './interfaces.js';
10
10
 
11
11
  /**
12
- * CAS-specific broadcast options extends {@link BroadcastOptions} with an optional
12
+ * CAS-specific broadcast options: extends {@link BroadcastOptions} with an optional
13
13
  * `casPublish` callback used to publish the CAS Announcement off-chain after the
14
14
  * OP_RETURN signal is broadcast.
15
15
  */
@@ -21,7 +21,7 @@ export interface CASBroadcastOptions extends BroadcastOptions {
21
21
  * Implements {@link https://dcdpr.github.io/did-btcr2/terminology.html#cas-beacon | CAS Beacon}.
22
22
  *
23
23
  * A CAS (Content-Addressed Store) Beacon aggregates updates for multiple DIDs
24
- * into a single CAS Announcement a mapping of DIDs to their update hashes.
24
+ * into a single CAS Announcement: a mapping of DIDs to their update hashes.
25
25
  * The hash of the CAS Announcement is broadcast on-chain via OP_RETURN.
26
26
  * During resolution, the CAS Announcement is retrieved from the sidecar (or CAS)
27
27
  * and used to look up the individual signed update for the DID being resolved.
@@ -63,14 +63,14 @@ export class CASBeacon extends SinglePartyBeacon {
63
63
  const did = this.service.id.split('#')[0];
64
64
 
65
65
  for(const signal of signals) {
66
- // Signal bytes are hex matches hex-keyed sidecar maps directly
66
+ // Signal bytes are hex, matches hex-keyed sidecar maps directly
67
67
  const announcementHash = signal.signalBytes;
68
68
 
69
69
  // Look up the CAS Announcement in sidecar casMap
70
70
  const casAnnouncement = sidecar.casMap.get(announcementHash);
71
71
 
72
72
  if(!casAnnouncement) {
73
- // CAS Announcement not available emit a need
73
+ // CAS Announcement not available, emit a need
74
74
  needs.push({
75
75
  kind : 'NeedCASAnnouncement',
76
76
  announcementHash,
@@ -80,10 +80,10 @@ export class CASBeacon extends SinglePartyBeacon {
80
80
  }
81
81
 
82
82
  // Look up this DID's update hash in the CAS Announcement
83
- // Announcement values are base64urlnopad per spec convert to hex for map lookup
83
+ // Announcement values are base64urlnopad per spec, convert to hex for map lookup
84
84
  const updateHashEncoded = casAnnouncement[did];
85
85
 
86
- // If no entry for this DID, this announcement doesn't contain an update for us skip
86
+ // If no entry for this DID, this announcement doesn't contain an update for us, skip
87
87
  if(!updateHashEncoded) {
88
88
  continue;
89
89
  }
@@ -94,7 +94,7 @@ export class CASBeacon extends SinglePartyBeacon {
94
94
  const signedUpdate = sidecar.updateMap.get(updateHash);
95
95
 
96
96
  if(!signedUpdate) {
97
- // Signed update not available emit a need
97
+ // Signed update not available, emit a need
98
98
  needs.push({
99
99
  kind : 'NeedSignedUpdate',
100
100
  updateHash,
@@ -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);
@@ -71,6 +71,6 @@ export interface BeaconSignal {
71
71
  * The method package defines this type; the api layer provides the implementation
72
72
  * (e.g., via CasApi.publish backed by IPFS/Helia).
73
73
  *
74
- * @param announcement The CAS Announcement object (DID update hash mapping).
74
+ * @param announcement The CAS Announcement object (DID to update hash mapping).
75
75
  */
76
76
  export type CasPublishFn = (announcement: Record<string, string>) => Promise<void>;
@@ -38,14 +38,14 @@ export class SingletonBeacon extends SinglePartyBeacon {
38
38
  const needs = new Array<DataNeed>();
39
39
 
40
40
  for(const signal of signals) {
41
- // Signal bytes are hex matches hex-keyed sidecar maps directly
41
+ // Signal bytes are hex, matches hex-keyed sidecar maps directly
42
42
  const updateHash = signal.signalBytes;
43
43
 
44
44
  // Look up the signed update in sidecar updateMap
45
45
  const signedUpdate = sidecar.updateMap.get(updateHash);
46
46
 
47
47
  if(!signedUpdate) {
48
- // Data not available emit a need instead of throwing
48
+ // Data not available, emit a need instead of throwing
49
49
  needs.push({
50
50
  kind : 'NeedSignedUpdate',
51
51
  updateHash,
@@ -60,7 +60,7 @@ export class SMTBeacon extends SinglePartyBeacon {
60
60
  const smtProof = sidecar.smtMap.get(signal.signalBytes);
61
61
 
62
62
  if(!smtProof) {
63
- // SMT Proof not available emit a need
63
+ // SMT Proof not available, emit a need
64
64
  needs.push({
65
65
  kind : 'NeedSMTProof',
66
66
  smtRootHash : signal.signalBytes,
@@ -95,7 +95,7 @@ export class SMTBeacon extends SinglePartyBeacon {
95
95
  );
96
96
  }
97
97
 
98
- // Non-inclusion proof verified no update for this DID this epoch, skip.
98
+ // Non-inclusion proof verified: no update for this DID this epoch, skip.
99
99
  if(!smtProof.updateId) {
100
100
  continue;
101
101
  }
@@ -106,7 +106,7 @@ export class SMTBeacon extends SinglePartyBeacon {
106
106
  const signedUpdate = sidecar.updateMap.get(updateHashHex);
107
107
 
108
108
  if(!signedUpdate) {
109
- // Signed update not available emit a need
109
+ // Signed update not available, emit a need
110
110
  needs.push({
111
111
  kind : 'NeedSignedUpdate',
112
112
  updateHash : updateHashHex,
@@ -164,7 +164,7 @@ export class Resolver {
164
164
  #resolvedResponse: DidResolutionResponse | null = null;
165
165
 
166
166
  /**
167
- * @internal Use {@link DidBtcr2.resolve} to create instances.
167
+ * @internal Use {@link DidBtcr2.resolve} to create instances.
168
168
  */
169
169
  constructor(
170
170
  didComponents: DidComponents,
@@ -540,13 +540,13 @@ export class Resolver {
540
540
 
541
541
  /**
542
542
  * Advance the state machine. Returns either:
543
- * - `{ status: 'action-required', needs }` caller must provide data via {@link provide}
544
- * - `{ status: 'resolved', result }` resolution complete
543
+ * - `{ status: 'action-required', needs }` - caller must provide data via {@link provide}
544
+ * - `{ status: 'resolved', result }` - resolution complete
545
545
  *
546
546
  * Analogous to Rust's `Resolver::resolve()`.
547
547
  */
548
548
  resolve(): ResolverState {
549
- // Internal loop keeps advancing through phases until data is needed or done
549
+ // Internal loop: keeps advancing through phases until data is needed or done
550
550
  while(true) {
551
551
  switch(this.#phase) {
552
552
 
@@ -554,7 +554,7 @@ export class Resolver {
554
554
  // Only entered for EXTERNAL (x HRP) identifiers when genesis doc was not in sidecar.
555
555
  case ResolverPhase.GenesisDocument: {
556
556
  if(this.#providedGenesisDocument) {
557
- // Genesis doc was provided establish the current document
557
+ // Genesis doc was provided, establish the current document
558
558
  this.#currentDocument = Resolver.external(
559
559
  this.#didComponents, this.#providedGenesisDocument
560
560
  );
@@ -595,7 +595,7 @@ export class Resolver {
595
595
  };
596
596
  }
597
597
 
598
- // No new beacon services to query move to processing
598
+ // No new beacon services to query, move to processing
599
599
  this.#phase = ResolverPhase.BeaconProcess;
600
600
  continue;
601
601
  }
@@ -614,10 +614,10 @@ export class Resolver {
614
614
  const result = beacon.processSignals(signals, this.#sidecarData);
615
615
 
616
616
  if(result.needs.length > 0) {
617
- // This service has unmet data needs collect them
617
+ // This service has unmet data needs, collect them
618
618
  allNeeds.push(...result.needs);
619
619
  } else {
620
- // All signals for this service resolved collect updates, mark processed
620
+ // All signals for this service resolved, collect updates, mark processed
621
621
  this.#unsortedUpdates.push(...result.updates);
622
622
  this.#processedServices.add(service.id);
623
623
  }
@@ -116,7 +116,7 @@ export interface UpdaterParams {
116
116
  }
117
117
 
118
118
  /**
119
- * Sans-I/O state machine for did:btcr2 updates the counterpart to {@link Resolver}.
119
+ * Sans-I/O state machine for did:btcr2 updates: the counterpart to {@link Resolver}.
120
120
  *
121
121
  * Created by {@link DidBtcr2.update} (the factory). The caller drives the update by
122
122
  * repeatedly calling {@link advance} and {@link provide}:
@@ -168,7 +168,7 @@ export class Updater {
168
168
  readonly #beaconService: BeaconService;
169
169
 
170
170
  /**
171
- * @internal Use {@link DidBtcr2.update} to create instances.
171
+ * @internal Use {@link DidBtcr2.update} to create instances.
172
172
  */
173
173
  constructor(params: UpdaterParams) {
174
174
  this.#sourceDocument = params.sourceDocument;
@@ -216,7 +216,7 @@ export class Updater {
216
216
  // checks W3C conformance but not this equality, so it's enforced explicitly here.
217
217
  if(targetDocument.id !== sourceDocument.id) {
218
218
  throw new UpdateError(
219
- `Patches must not change the DID document id (source "${sourceDocument.id}" target "${targetDocument.id}").`,
219
+ `Patches must not change the DID document id (source "${sourceDocument.id}" to target "${targetDocument.id}").`,
220
220
  INVALID_DID_UPDATE, { sourceId: sourceDocument.id, targetId: targetDocument.id }
221
221
  );
222
222
  }
@@ -315,8 +315,8 @@ export class Updater {
315
315
 
316
316
  /**
317
317
  * Advance the state machine. Returns either:
318
- * - `{ status: 'action-required', needs }` caller must provide data via {@link provide}
319
- * - `{ status: 'complete', result }` update is signed and broadcast
318
+ * - `{ status: 'action-required', needs }` caller must provide data via {@link provide}
319
+ * - `{ status: 'complete', result }` update is signed and broadcast
320
320
  */
321
321
  advance(): UpdaterState {
322
322
  while(true) {
@@ -331,7 +331,7 @@ export class Updater {
331
331
  }
332
332
 
333
333
  // Phase: Sign
334
- // Emit NeedSigningKey the caller supplies the secret key (or a KMS signature).
334
+ // Emit NeedSigningKey: the caller supplies the secret key (or a KMS signature).
335
335
  case 'Sign': {
336
336
  return {
337
337
  status : 'action-required',
package/src/did-btcr2.ts CHANGED
@@ -135,8 +135,8 @@ export class DidBtcr2 implements DidMethod {
135
135
  *
136
136
  * Factory method that validates the update parameters and returns a sans-I/O
137
137
  * {@link Updater} state machine. The caller drives the updater through its
138
- * phases (Construct Sign Broadcast Complete) by calling `advance()` and
139
- * `provide()`. The method package performs **zero I/O** signing key retrieval
138
+ * phases (Construct -> Sign -> Broadcast -> Complete) by calling `advance()` and
139
+ * `provide()`. The method package performs **zero I/O**: signing key retrieval
140
140
  * (or KMS delegation) and the on-chain broadcast are the caller's responsibility.
141
141
  *
142
142
  * For a fully-wired version with Bitcoin broadcast and key handling, see
package/src/index.ts CHANGED
@@ -3,6 +3,9 @@ export * from './core/aggregation/service.js';
3
3
  export * from './core/aggregation/participant.js';
4
4
  export * from './core/aggregation/signer.js';
5
5
  export * from './core/aggregation/conditions.js';
6
+ export * from './core/aggregation/recovery-policy.js';
7
+ export * from './core/aggregation/recovery-spend.js';
8
+ export * from './core/aggregation/fallback-spend.js';
6
9
  export * from './core/aggregation/cohort.js';
7
10
  export * from './core/aggregation/signing-session.js';
8
11
  export * from './core/aggregation/phases.js';