@did-btcr2/method 0.36.1 → 0.37.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 (164) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/browser.js +1060 -96
  3. package/dist/browser.mjs +1060 -96
  4. package/dist/cjs/index.js +1153 -179
  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 +11 -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 +97 -9
  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 +15 -10
  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/singleton-beacon.js +2 -2
  55. package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
  56. package/dist/esm/core/beacon/smt-beacon.js +3 -3
  57. package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
  58. package/dist/esm/core/resolver.js +8 -8
  59. package/dist/esm/core/resolver.js.map +1 -1
  60. package/dist/esm/core/updater.js +6 -6
  61. package/dist/esm/core/updater.js.map +1 -1
  62. package/dist/esm/did-btcr2.js +2 -2
  63. package/dist/esm/index.js +3 -0
  64. package/dist/esm/index.js.map +1 -1
  65. package/dist/types/core/aggregation/beacon-strategy.d.ts +8 -4
  66. package/dist/types/core/aggregation/beacon-strategy.d.ts.map +1 -1
  67. package/dist/types/core/aggregation/cohort.d.ts +88 -17
  68. package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
  69. package/dist/types/core/aggregation/conditions.d.ts +28 -0
  70. package/dist/types/core/aggregation/conditions.d.ts.map +1 -1
  71. package/dist/types/core/aggregation/fallback-spend.d.ts +94 -0
  72. package/dist/types/core/aggregation/fallback-spend.d.ts.map +1 -0
  73. package/dist/types/core/aggregation/logger.d.ts +1 -1
  74. package/dist/types/core/aggregation/messages/base.d.ts +6 -0
  75. package/dist/types/core/aggregation/messages/base.d.ts.map +1 -1
  76. package/dist/types/core/aggregation/messages/bodies.d.ts +50 -2
  77. package/dist/types/core/aggregation/messages/bodies.d.ts.map +1 -1
  78. package/dist/types/core/aggregation/messages/constants.d.ts +8 -0
  79. package/dist/types/core/aggregation/messages/constants.d.ts.map +1 -1
  80. package/dist/types/core/aggregation/messages/factories.d.ts +40 -0
  81. package/dist/types/core/aggregation/messages/factories.d.ts.map +1 -1
  82. package/dist/types/core/aggregation/messages/guards.d.ts.map +1 -1
  83. package/dist/types/core/aggregation/participant.d.ts +41 -7
  84. package/dist/types/core/aggregation/participant.d.ts.map +1 -1
  85. package/dist/types/core/aggregation/phases.d.ts +8 -2
  86. package/dist/types/core/aggregation/phases.d.ts.map +1 -1
  87. package/dist/types/core/aggregation/recovery-policy.d.ts +132 -0
  88. package/dist/types/core/aggregation/recovery-policy.d.ts.map +1 -0
  89. package/dist/types/core/aggregation/recovery-spend.d.ts +94 -0
  90. package/dist/types/core/aggregation/recovery-spend.d.ts.map +1 -0
  91. package/dist/types/core/aggregation/runner/aggregation-runner.d.ts +13 -3
  92. package/dist/types/core/aggregation/runner/aggregation-runner.d.ts.map +1 -1
  93. package/dist/types/core/aggregation/runner/events.d.ts +28 -7
  94. package/dist/types/core/aggregation/runner/events.d.ts.map +1 -1
  95. package/dist/types/core/aggregation/runner/participant-runner.d.ts +3 -3
  96. package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
  97. package/dist/types/core/aggregation/runner/service-runner.d.ts +28 -3
  98. package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
  99. package/dist/types/core/aggregation/service.d.ts +26 -2
  100. package/dist/types/core/aggregation/service.d.ts.map +1 -1
  101. package/dist/types/core/aggregation/signing-session.d.ts +2 -2
  102. package/dist/types/core/aggregation/signing-session.d.ts.map +1 -1
  103. package/dist/types/core/aggregation/transport/http/envelope.d.ts +3 -3
  104. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts +1 -1
  105. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts +1 -1
  106. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts +1 -1
  107. package/dist/types/core/aggregation/transport/http/request-auth.d.ts +1 -1
  108. package/dist/types/core/aggregation/transport/http/server.d.ts +1 -1
  109. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts +2 -2
  110. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts +1 -1
  111. package/dist/types/core/aggregation/transport/in-memory.d.ts +1 -1
  112. package/dist/types/core/aggregation/transport/nostr.d.ts +4 -4
  113. package/dist/types/core/aggregation/transport/transport.d.ts +4 -4
  114. package/dist/types/core/aggregation/transport/transport.d.ts.map +1 -1
  115. package/dist/types/core/beacon/beacon.d.ts +7 -7
  116. package/dist/types/core/beacon/beacon.d.ts.map +1 -1
  117. package/dist/types/core/beacon/cas-beacon.d.ts +2 -2
  118. package/dist/types/core/beacon/interfaces.d.ts +1 -1
  119. package/dist/types/core/resolver.d.ts +3 -3
  120. package/dist/types/core/updater.d.ts +4 -4
  121. package/dist/types/did-btcr2.d.ts +2 -2
  122. package/dist/types/index.d.ts +3 -0
  123. package/dist/types/index.d.ts.map +1 -1
  124. package/package.json +4 -4
  125. package/src/core/aggregation/beacon-strategy.ts +27 -9
  126. package/src/core/aggregation/cohort.ts +171 -35
  127. package/src/core/aggregation/conditions.ts +56 -0
  128. package/src/core/aggregation/fallback-spend.ts +223 -0
  129. package/src/core/aggregation/logger.ts +1 -1
  130. package/src/core/aggregation/messages/base.ts +6 -0
  131. package/src/core/aggregation/messages/bodies.ts +94 -1
  132. package/src/core/aggregation/messages/constants.ts +12 -0
  133. package/src/core/aggregation/messages/factories.ts +57 -0
  134. package/src/core/aggregation/messages/guards.ts +6 -0
  135. package/src/core/aggregation/participant.ts +255 -23
  136. package/src/core/aggregation/phases.ts +9 -0
  137. package/src/core/aggregation/recovery-policy.ts +237 -0
  138. package/src/core/aggregation/recovery-spend.ts +192 -0
  139. package/src/core/aggregation/runner/aggregation-runner.ts +27 -5
  140. package/src/core/aggregation/runner/events.ts +26 -7
  141. package/src/core/aggregation/runner/participant-runner.ts +75 -23
  142. package/src/core/aggregation/runner/service-runner.ts +120 -10
  143. package/src/core/aggregation/service.ts +260 -14
  144. package/src/core/aggregation/signing-session.ts +3 -3
  145. package/src/core/aggregation/transport/http/envelope.ts +3 -3
  146. package/src/core/aggregation/transport/http/inbox-buffer.ts +1 -1
  147. package/src/core/aggregation/transport/http/nonce-cache.ts +1 -1
  148. package/src/core/aggregation/transport/http/rate-limiter.ts +1 -1
  149. package/src/core/aggregation/transport/http/request-auth.ts +1 -1
  150. package/src/core/aggregation/transport/http/server.ts +1 -1
  151. package/src/core/aggregation/transport/http/sse-stream.ts +2 -2
  152. package/src/core/aggregation/transport/http/sse-writer.ts +1 -1
  153. package/src/core/aggregation/transport/in-memory.ts +1 -1
  154. package/src/core/aggregation/transport/nostr.ts +6 -6
  155. package/src/core/aggregation/transport/transport.ts +4 -4
  156. package/src/core/beacon/beacon.ts +16 -11
  157. package/src/core/beacon/cas-beacon.ts +7 -7
  158. package/src/core/beacon/interfaces.ts +1 -1
  159. package/src/core/beacon/singleton-beacon.ts +2 -2
  160. package/src/core/beacon/smt-beacon.ts +3 -3
  161. package/src/core/resolver.ts +8 -8
  162. package/src/core/updater.ts +6 -6
  163. package/src/did-btcr2.ts +2 -2
  164. package/src/index.ts +3 -0
@@ -2,18 +2,24 @@ import { canonicalize } from '@did-btcr2/common';
2
2
  import type { SignedBTCR2Update } from '@did-btcr2/cryptosuite';
3
3
  import { BIP340Cryptosuite, SchnorrMultikey } from '@did-btcr2/cryptosuite';
4
4
  import type { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
5
- import { bytesToHex } from '@noble/hashes/utils';
5
+ import { schnorr } from '@noble/curves/secp256k1.js';
6
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
6
7
  import type { Transaction } from '@scure/btc-signer';
7
8
  import { getBeaconStrategy } from './beacon-strategy.js';
8
9
  import { AggregationCohort } from './cohort.js';
9
10
  import { validateCohortConditions, type CohortConditions } from './conditions.js';
10
11
  import { AggregationServiceError } from './errors.js';
12
+ import { buildFallbackSpend, fallbackSighash } from './fallback-spend.js';
13
+ import type { FallbackSignature } from './fallback-spend.js';
14
+ import { buildFallbackLeaf } from './recovery-policy.js';
11
15
  import type { BaseMessage } from './messages/base.js';
12
16
  import { AGGREGATION_WIRE_VERSION } from './messages/base.js';
13
17
  import {
14
18
  COHORT_OPT_IN,
19
+ FALLBACK_SIGNATURE,
15
20
  NONCE_CONTRIBUTION,
16
21
  SIGNATURE_AUTHORIZATION,
22
+ SUBMIT_NONINCLUDED,
17
23
  SUBMIT_UPDATE,
18
24
  VALIDATION_ACK,
19
25
  } from './messages/constants.js';
@@ -24,6 +30,7 @@ import {
24
30
  createCohortOptInAcceptMessage,
25
31
  createCohortReadyMessage,
26
32
  createDistributeAggregatedDataMessage,
33
+ createFallbackAuthorizationRequestMessage,
27
34
  } from './messages/factories.js';
28
35
  import type { ServiceCohortPhaseType } from './phases.js';
29
36
  import { ServiceCohortPhase } from './phases.js';
@@ -58,8 +65,19 @@ export interface ValidationProgress {
58
65
  /** Final aggregation result for a cohort. */
59
66
  export interface AggregationResult {
60
67
  cohortId: string;
68
+ /**
69
+ * The 64-byte aggregated MuSig2 signature for the optimistic key path. Empty
70
+ * for a `script-path` result: the k-of-n fallback embeds k separate signatures
71
+ * directly in the witness of {@link signedTx}, with no single aggregate sig.
72
+ */
61
73
  signature: Uint8Array;
62
74
  signedTx: Transaction;
75
+ /**
76
+ * Which Taproot spend path produced {@link signedTx}: `key-path` is the
77
+ * optimistic n-of-n MuSig2 spend, `script-path` is the k-of-n fallback (ADR
78
+ * 042). Absent is treated as `key-path` for backward compatibility.
79
+ */
80
+ path?: 'key-path' | 'script-path';
63
81
  }
64
82
 
65
83
  /** Transaction data needed to start a signing session. */
@@ -86,7 +104,7 @@ export interface Rejection {
86
104
  reason: string;
87
105
  }
88
106
 
89
- /** Per-cohort service state internal. */
107
+ /** Per-cohort service state - internal. */
90
108
  interface ServiceCohortState {
91
109
  phase: ServiceCohortPhaseType;
92
110
  cohort: AggregationCohort;
@@ -95,6 +113,12 @@ interface ServiceCohortState {
95
113
  acceptedParticipants: Set<string>;
96
114
  signingSession?: BeaconSigningSession;
97
115
  result?: AggregationResult;
116
+ /**
117
+ * Collected fallback (k-of-n script-path) signatures, keyed by signer DID.
118
+ * Populated only after {@link AggregationService.startFallbackSigning}. Each is
119
+ * a verified standalone BIP-340 signature over the fallback script-path sighash.
120
+ */
121
+ fallbackSignatures?: Map<string, FallbackSignature>;
98
122
  /** Rejections accumulated since last drain. Runner polls via drainRejections(). */
99
123
  rejections: Array<Rejection>;
100
124
  }
@@ -147,7 +171,7 @@ export class AggregationService {
147
171
 
148
172
  receive(message: BaseMessage): void {
149
173
  // Reject messages whose wire version doesn't match what this build speaks.
150
- // Missing version treat as legacy and drop: bumping the protocol must be
174
+ // Missing version, treat as legacy and drop: bumping the protocol must be
151
175
  // coordinated across all participants.
152
176
  const version = message.version;
153
177
  if(version === undefined || version !== AGGREGATION_WIRE_VERSION) {
@@ -171,6 +195,9 @@ export class AggregationService {
171
195
  case SUBMIT_UPDATE:
172
196
  this.#handleSubmitUpdate(message);
173
197
  break;
198
+ case SUBMIT_NONINCLUDED:
199
+ this.#handleSubmitNonInclusion(message);
200
+ break;
174
201
  case VALIDATION_ACK:
175
202
  this.#handleValidationAck(message);
176
203
  break;
@@ -180,8 +207,11 @@ export class AggregationService {
180
207
  case SIGNATURE_AUTHORIZATION:
181
208
  this.#handleSignatureAuthorization(message);
182
209
  break;
210
+ case FALLBACK_SIGNATURE:
211
+ this.#handleFallbackSignature(message);
212
+ break;
183
213
  default:
184
- // Unknown message type silently ignore
214
+ // Unknown message type - silently ignore
185
215
  break;
186
216
  }
187
217
  }
@@ -202,7 +232,7 @@ export class AggregationService {
202
232
 
203
233
  /**
204
234
  * Create a new cohort with the given config. Returns the cohort ID.
205
- * Cohort starts in `Created` phase call `advertise()` to broadcast.
235
+ * Cohort starts in `Created` phase - call `advertise()` to broadcast.
206
236
  */
207
237
  createCohort(config: CohortConfig): string {
208
238
  // Fail fast on invalid conditions rather than discovering them at finalize.
@@ -214,10 +244,14 @@ export class AggregationService {
214
244
  );
215
245
  }
216
246
  const cohort = new AggregationCohort({
217
- serviceDid : this.did,
218
- minParticipants : config.minParticipants,
219
- network : config.network,
220
- beaconType : config.beaconType,
247
+ serviceDid : this.did,
248
+ minParticipants : config.minParticipants,
249
+ network : config.network,
250
+ beaconType : config.beaconType,
251
+ recoveryKey : hexToBytes(config.recoveryKey),
252
+ recoverySequence : config.recoverySequence,
253
+ fundingModel : config.fundingModel,
254
+ fallbackThreshold : config.fallbackThreshold,
221
255
  });
222
256
  this.#cohortStates.set(cohort.id, {
223
257
  phase : ServiceCohortPhase.Created,
@@ -289,7 +323,7 @@ export class AggregationService {
289
323
 
290
324
  // Reject re-opt-in from already-accepted participants. Without this guard a
291
325
  // participant could send a second opt-in with a different key, overwriting
292
- // pendingOptIns[did] while cohortKeys still holds the original key opening
326
+ // pendingOptIns[did] while cohortKeys still holds the original key - opening
293
327
  // a desync window where #verifySubmittedUpdate accepts updates signed with
294
328
  // a key that is NOT in the MuSig2 cohort.
295
329
  if(state.acceptedParticipants.has(participantDid)) return;
@@ -427,7 +461,7 @@ export class AggregationService {
427
461
 
428
462
  // Cap the canonicalized update size before doing any heavier verification
429
463
  // work. Without this guard, a participant could submit multi-MB payloads
430
- // that the service would canonicalize, hash, and aggregate cheap DoS.
464
+ // that the service would canonicalize, hash, and aggregate - cheap DoS.
431
465
  const canonicalSize = canonicalize(signedUpdate as unknown as Record<string, unknown>).length;
432
466
  if(canonicalSize > this.maxUpdateSizeBytes) {
433
467
  state.rejections.push({
@@ -451,12 +485,81 @@ export class AggregationService {
451
485
  return;
452
486
  }
453
487
 
488
+ // One response per round. A member that already declined cannot also submit,
489
+ // and a member that already submitted cannot resubmit (a silent overwrite
490
+ // would corrupt the aggregated data the member already validated against).
491
+ // Both are dropped as rejections, symmetric with #handleSubmitNonInclusion.
492
+ if(state.cohort.nonIncluded.has(message.from)) {
493
+ state.rejections.push({
494
+ from : message.from,
495
+ code : 'UPDATE_MALFORMED',
496
+ reason : 'Participant already declined this round; cannot also submit an update',
497
+ });
498
+ return;
499
+ }
500
+ if(state.cohort.pendingUpdates.has(message.from)) {
501
+ state.rejections.push({
502
+ from : message.from,
503
+ code : 'UPDATE_MALFORMED',
504
+ reason : 'Participant already submitted an update this round; cannot resubmit',
505
+ });
506
+ return;
507
+ }
508
+
454
509
  state.cohort.addUpdate(message.from, signedUpdate);
455
510
 
456
511
  if(state.phase === ServiceCohortPhase.CohortSet) {
457
512
  state.phase = ServiceCohortPhase.CollectingUpdates;
458
513
  }
459
- if(state.cohort.hasAllUpdates()) {
514
+ if(state.cohort.hasAllResponses()) {
515
+ state.phase = ServiceCohortPhase.UpdatesCollected;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Handle an incoming SUBMIT_NONINCLUDED message: a member declares it has no
521
+ * update this round (cooperative non-inclusion). Membership is proven by the
522
+ * signed transport envelope, so the body carries only the cohortId. The member
523
+ * stays in the cohort and still signs; it is absent from the CAS map and
524
+ * carries a non-inclusion leaf in the SMT.
525
+ */
526
+ #handleSubmitNonInclusion(message: BaseMessage): void {
527
+ const cohortId = message.body?.cohortId;
528
+ if(!cohortId) return;
529
+ const state = this.#cohortStates.get(cohortId);
530
+ if(!state) return;
531
+ if(state.phase !== ServiceCohortPhase.CohortSet && state.phase !== ServiceCohortPhase.CollectingUpdates) return;
532
+
533
+ // Membership is proven by the signed envelope sender. A non-member decline is
534
+ // dropped as a rejection, never thrown: addNonInclusion would otherwise throw
535
+ // UNKNOWN_PARTICIPANT out of receive() and fail the whole cohort, a DoS any
536
+ // non-member could trigger. Mirrors the SUBMIT_UPDATE verification path.
537
+ if(!state.cohort.participants.includes(message.from)) {
538
+ state.rejections.push({
539
+ from : message.from,
540
+ code : 'UPDATE_MALFORMED',
541
+ reason : 'Sender is not a member of this cohort',
542
+ });
543
+ return;
544
+ }
545
+
546
+ // One response per round: already submitted or already declined. Surface the
547
+ // conflict as a rejection, symmetric with the double-submit guard above.
548
+ if(state.cohort.pendingUpdates.has(message.from) || state.cohort.nonIncluded.has(message.from)) {
549
+ state.rejections.push({
550
+ from : message.from,
551
+ code : 'UPDATE_MALFORMED',
552
+ reason : 'Participant already responded this round',
553
+ });
554
+ return;
555
+ }
556
+
557
+ state.cohort.addNonInclusion(message.from);
558
+
559
+ if(state.phase === ServiceCohortPhase.CohortSet) {
560
+ state.phase = ServiceCohortPhase.CollectingUpdates;
561
+ }
562
+ if(state.cohort.hasAllResponses()) {
460
563
  state.phase = ServiceCohortPhase.UpdatesCollected;
461
564
  }
462
565
  }
@@ -588,7 +691,7 @@ export class AggregationService {
588
691
 
589
692
  /**
590
693
  * Start a signing session by creating auth requests for all participants.
591
- * The caller provides the transaction data typically built via
694
+ * The caller provides the transaction data - typically built via
592
695
  * `buildBeaconTransaction()` against a Bitcoin connection.
593
696
  */
594
697
  startSigning(cohortId: string, txData: SigningTxData): BaseMessage[] {
@@ -703,7 +806,7 @@ export class AggregationService {
703
806
  state.signingSession.addPartialSignature(message.from, partialSignature);
704
807
 
705
808
  if(state.signingSession.partialSignatures.size === state.cohort.participants.length) {
706
- // All partial sigs received generate final signature
809
+ // All partial sigs received - generate final signature
707
810
  const signature = state.signingSession.generateFinalSignature();
708
811
 
709
812
  // Set Taproot key-path witness (finalScriptWitness injects the aggregated MuSig2 sig)
@@ -713,12 +816,155 @@ export class AggregationService {
713
816
  cohortId,
714
817
  signature,
715
818
  signedTx : state.signingSession.pendingTx,
819
+ path : 'key-path',
716
820
  };
717
821
  state.phase = ServiceCohortPhase.Complete;
718
822
  }
719
823
  }
720
824
 
721
825
 
826
+ /**
827
+ * Abandon the optimistic n-of-n key path and ask members to authorize the
828
+ * k-of-n fallback (script-path) spend of the SAME beacon transaction (graceful
829
+ * liveness, ADR 042). Reuses the in-flight signing session's transaction and
830
+ * spent output, so the announcement and its outputs are unchanged: only the
831
+ * witness path differs. Returns one FALLBACK_AUTHORIZATION_REQUEST per
832
+ * participant.
833
+ *
834
+ * Callable once optimistic signing has started (the session and its tx exist)
835
+ * and before it completes. A cohort can take exactly one of the two paths: the
836
+ * caller (runner) must commit to fallback and stop driving the optimistic path.
837
+ */
838
+ startFallbackSigning(cohortId: string): BaseMessage[] {
839
+ const state = this.#cohortStates.get(cohortId);
840
+ if(!state) {
841
+ throw new AggregationServiceError(`Cohort ${cohortId} not found.`, 'COHORT_NOT_FOUND', { cohortId });
842
+ }
843
+ if(!state.signingSession) {
844
+ throw new AggregationServiceError(
845
+ `Cannot start fallback for cohort ${cohortId}: no signing session.`,
846
+ 'NO_SIGNING_SESSION', { cohortId }
847
+ );
848
+ }
849
+ const signingPhases: ServiceCohortPhaseType[] = [
850
+ ServiceCohortPhase.SigningStarted,
851
+ ServiceCohortPhase.NoncesCollected,
852
+ ServiceCohortPhase.AwaitingPartialSigs,
853
+ ];
854
+ if(!signingPhases.includes(state.phase)) {
855
+ throw new AggregationServiceError(
856
+ `Cannot start fallback for cohort ${cohortId}: phase is ${state.phase}.`,
857
+ 'INVALID_PHASE', { cohortId, phase: state.phase }
858
+ );
859
+ }
860
+
861
+ const session = state.signingSession;
862
+ const prevOutScript = session.prevOutScripts[0];
863
+ const prevOutValue = session.prevOutValues[0];
864
+ if(!prevOutScript || prevOutValue === undefined) {
865
+ throw new AggregationServiceError(
866
+ `Cannot start fallback for cohort ${cohortId}: signing session missing prevout data.`,
867
+ 'MISSING_PREV_OUT', { cohortId }
868
+ );
869
+ }
870
+
871
+ const fallbackLeaf = buildFallbackLeaf({
872
+ cohortKeys : state.cohort.cohortKeys,
873
+ fallbackThreshold : state.cohort.effectiveFallbackThreshold,
874
+ });
875
+
876
+ state.fallbackSignatures = new Map();
877
+ state.phase = ServiceCohortPhase.FallbackRequested;
878
+
879
+ const messages: BaseMessage[] = [];
880
+ for(const participantDid of state.cohort.participants) {
881
+ messages.push(createFallbackAuthorizationRequestMessage({
882
+ from : this.did,
883
+ to : participantDid,
884
+ cohortId,
885
+ sessionId : session.id,
886
+ pendingTx : session.pendingTx.hex,
887
+ prevOutScriptHex : bytesToHex(prevOutScript),
888
+ prevOutValue : prevOutValue.toString(),
889
+ fallbackLeafScriptHex : bytesToHex(fallbackLeaf),
890
+ }));
891
+ }
892
+ return messages;
893
+ }
894
+
895
+ /**
896
+ * Handle an incoming FALLBACK_SIGNATURE: a member's standalone BIP-340
897
+ * signature over the fallback script-path sighash. The signature is
898
+ * authenticated to the sender (its `signerPk` must be the sender's own cohort
899
+ * key and the signature must verify against the sighash) and collected. Once k
900
+ * valid signatures are in, the k-of-n fallback spend is assembled and the
901
+ * cohort completes via the script path.
902
+ */
903
+ #handleFallbackSignature(message: BaseMessage): void {
904
+ const cohortId = message.body?.cohortId;
905
+ if(!cohortId) return;
906
+ const state = this.#cohortStates.get(cohortId);
907
+ if(!state || !state.signingSession || !state.fallbackSignatures) return;
908
+ if(state.phase !== ServiceCohortPhase.FallbackRequested) return;
909
+
910
+ const sessionId = message.body?.sessionId;
911
+ if(sessionId !== state.signingSession.id) return;
912
+
913
+ const signerPk = message.body?.signerPk as Uint8Array | undefined;
914
+ const fallbackSignature = message.body?.fallbackSignature as Uint8Array | undefined;
915
+ if(!signerPk || !fallbackSignature) return;
916
+
917
+ const prevOutScript = state.signingSession.prevOutScripts[0];
918
+ const prevOutValue = state.signingSession.prevOutValues[0];
919
+ if(!prevOutScript || prevOutValue === undefined) return;
920
+
921
+ // Authenticate the signature to the sender: signerPk must be the sender's own
922
+ // cohort key (x-only). A non-member or a mismatched key is dropped as a
923
+ // rejection, never thrown, so one bad contribution cannot stall the fallback.
924
+ const memberKey = state.cohort.participantKeys.get(message.from);
925
+ if(!memberKey) {
926
+ state.rejections.push({ from: message.from, code: 'UPDATE_MALFORMED', reason: 'Fallback signature from a non-member' });
927
+ return;
928
+ }
929
+ const memberXOnly = memberKey.slice(1);
930
+ if(signerPk.length !== 32 || !memberXOnly.every((b, i) => b === signerPk[i])) {
931
+ state.rejections.push({ from: message.from, code: 'UPDATE_MALFORMED', reason: 'Fallback signerPk does not match the sender cohort key' });
932
+ return;
933
+ }
934
+
935
+ const fallbackLeaf = buildFallbackLeaf({
936
+ cohortKeys : state.cohort.cohortKeys,
937
+ fallbackThreshold : state.cohort.effectiveFallbackThreshold,
938
+ });
939
+ const sighash = fallbackSighash(state.signingSession.pendingTx, 0, prevOutScript, prevOutValue, fallbackLeaf);
940
+ let valid = false;
941
+ try { valid = fallbackSignature.length === 64 && schnorr.verify(fallbackSignature, sighash, signerPk); } catch { valid = false; }
942
+ if(!valid) {
943
+ state.rejections.push({ from: message.from, code: 'UPDATE_VERIFICATION_FAILED', reason: 'Fallback signature failed verification' });
944
+ return;
945
+ }
946
+
947
+ state.fallbackSignatures.set(message.from, { pubKey: signerPk, signature: fallbackSignature });
948
+
949
+ if(state.fallbackSignatures.size >= state.cohort.effectiveFallbackThreshold) {
950
+ const signedTx = buildFallbackSpend({
951
+ pendingTx : state.signingSession.pendingTx,
952
+ cohortKeys : state.cohort.cohortKeys,
953
+ fallbackThreshold : state.cohort.effectiveFallbackThreshold,
954
+ recoveryKey : state.cohort.recoveryKey!,
955
+ recoverySequence : state.cohort.recoverySequence!,
956
+ fundingModel : state.cohort.fundingModel,
957
+ network : state.cohort.network,
958
+ prevOutScript,
959
+ prevOutValue,
960
+ signatures : [ ...state.fallbackSignatures.values() ],
961
+ });
962
+ state.result = { cohortId, signature: new Uint8Array(), signedTx, path: 'script-path' };
963
+ state.phase = ServiceCohortPhase.Complete;
964
+ }
965
+ }
966
+
967
+
722
968
  getResult(cohortId: string): AggregationResult | undefined {
723
969
  return this.#cohortStates.get(cohortId)?.result;
724
970
  }
@@ -44,13 +44,13 @@ export class BeaconSigningSession {
44
44
  /** Previous output values for Taproot sighash computation. */
45
45
  public prevOutValues: bigint[];
46
46
 
47
- /** Map of participant publicKey-hex public nonce contribution. */
47
+ /** Map of participant publicKey-hex to public nonce contribution. */
48
48
  public nonceContributions: Map<PublicKeyHex, Nonce> = new Map();
49
49
 
50
50
  /** Aggregated MuSig2 nonce (66 bytes). */
51
51
  public aggregatedNonce?: Uint8Array;
52
52
 
53
- /** Map of participant DID partial signature. */
53
+ /** Map of participant DID to partial signature. */
54
54
  public partialSignatures: Map<string, Uint8Array> = new Map();
55
55
 
56
56
  /** Final 64-byte Schnorr signature. */
@@ -251,7 +251,7 @@ export class BeaconSigningSession {
251
251
  throw new SigningSessionError('Aggregated nonce not available.', 'MISSING_AGGREGATED_NONCE');
252
252
  }
253
253
  if(!this.#secretNonce) {
254
- throw new SigningSessionError('Secret nonce not available generateNonceContribution() must be called first.', 'MISSING_SECRET_NONCE');
254
+ throw new SigningSessionError('Secret nonce not available - generateNonceContribution() must be called first.', 'MISSING_SECRET_NONCE');
255
255
  }
256
256
  const session = new musig2.Session(
257
257
  this.aggregatedNonce,
@@ -39,7 +39,7 @@ export interface VerifyEnvelopeOptions {
39
39
  /**
40
40
  * Build a {@link SignedEnvelope} around `message`.
41
41
  *
42
- * Pure function no I/O beyond `randomBytes` for nonce generation (which
42
+ * Pure function - no I/O beyond `randomBytes` for nonce generation (which
43
43
  * uses the platform's cryptographic RNG: `crypto.getRandomValues` in browsers,
44
44
  * `node:crypto` in Node). Deterministic when both `nonce` and `timestamp` are
45
45
  * supplied via {@link SignEnvelopeOptions}.
@@ -73,7 +73,7 @@ export function signEnvelope(
73
73
  * communication public key. Throws {@link HttpTransportError} on any failure;
74
74
  * returns normally on success.
75
75
  *
76
- * Does NOT check nonce uniqueness replay protection is the caller's
76
+ * Does NOT check nonce uniqueness - replay protection is the caller's
77
77
  * responsibility (the server-side transport maintains an LRU cache).
78
78
  */
79
79
  export function verifyEnvelope(
@@ -161,7 +161,7 @@ function normalizeMessage(message: EnvelopeMessage): Record<string, unknown> {
161
161
  *
162
162
  * Without this, `JSON.stringify` serializes a `Uint8Array` as an index-keyed
163
163
  * object (`{"0":1,"1":2,...}`), which `canonicalize` then re-parses into a
164
- * plain object the receiver cannot reconstruct the original bytes even
164
+ * plain object - the receiver cannot reconstruct the original bytes even
165
165
  * though the signature still verifies.
166
166
  */
167
167
  export function normalizeForWire(value: unknown): unknown {
@@ -13,7 +13,7 @@ export interface StoredEvent {
13
13
  * When a subscriber (re)connects with a `Last-Event-ID` header, the server
14
14
  * uses {@link since} to replay everything that arrived while the subscriber
15
15
  * was disconnected. Events older than the replay window (evicted from the
16
- * ring) are unrecoverable callers should choose `capacity` based on
16
+ * ring) are unrecoverable - callers should choose `capacity` based on
17
17
  * expected message rate × acceptable reconnect window.
18
18
  */
19
19
  export class InboxBuffer {
@@ -6,7 +6,7 @@ export interface NonceCacheConfig {
6
6
  /**
7
7
  * Bounded anti-replay cache for `(did, nonce)` pairs.
8
8
  *
9
- * Replay windowing is the caller's responsibility this cache only detects
9
+ * Replay windowing is the caller's responsibility - this cache only detects
10
10
  * duplicates. Callers are expected to reject envelopes/headers whose timestamp
11
11
  * is outside the clock-skew window *before* consulting the cache, so entries
12
12
  * here are always within the protocol's acceptable window.
@@ -39,7 +39,7 @@ export interface RateLimiterConfig {
39
39
  * sender DID). Tokens refill linearly at `rps` up to `burst`. Each `consume`
40
40
  * call atomically debits one token or returns `false` to reject.
41
41
  *
42
- * The limiter is synchronous and deterministic given `nowMs` tests can
42
+ * The limiter is synchronous and deterministic given `nowMs` - tests can
43
43
  * drive it with a fixed clock to exercise exact boundaries.
44
44
  */
45
45
  export class RateLimiter {
@@ -61,7 +61,7 @@ export function buildRequestAuth(
61
61
 
62
62
  /**
63
63
  * Parse a `BTCR2-Sig` auth header value into its structured fields. Does NOT
64
- * verify the signature call {@link verifyRequestAuth} for that.
64
+ * verify the signature: call {@link verifyRequestAuth} for that.
65
65
  */
66
66
  export function parseRequestAuth(headerValue: string): ParsedRequestAuth {
67
67
  const prefix = `${REQUEST_AUTH_SCHEME} `;
@@ -114,7 +114,7 @@ const DEFAULT_ADVERT_TTL_MS = 5 * 60 * 1000;
114
114
  const DEFAULT_HEARTBEAT_MS = 20_000;
115
115
 
116
116
  /**
117
- * Server-side HTTP transport. Sans-I/O the caller mounts
117
+ * Server-side HTTP transport. Sans-I/O - the caller mounts
118
118
  * {@link handleRequest} and {@link handleSse} under their HTTP framework of
119
119
  * choice; the transport owns only in-memory state (actors, inboxes, advert
120
120
  * cache, replay / rate-limit policies).
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Parsed Server-Sent Events record.
3
3
  *
4
- * Events without a `data` field are never yielded (per the SSE spec only a
4
+ * Events without a `data` field are never yielded (per the SSE spec - only a
5
5
  * blank line that follows at least one `data:` line dispatches an event).
6
6
  */
7
7
  export interface SseEvent {
@@ -24,7 +24,7 @@ export interface SseEvent {
24
24
  * CR-only line terminators are not supported (every mainstream SSE
25
25
  * implementation emits LF or CRLF).
26
26
  *
27
- * Pure, runtime-agnostic works anywhere `ReadableStream<Uint8Array>` and
27
+ * Pure, runtime-agnostic - works anywhere `ReadableStream<Uint8Array>` and
28
28
  * `TextDecoder` exist (browsers and Node 22+).
29
29
  *
30
30
  * The caller owns stream lifecycle: cancellation should be effected via an
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Format an SSE event frame. Pairs with {@link parseSseStream}.
3
3
  *
4
- * Multi-line `data` is split across multiple `data:` lines per the SSE spec
4
+ * Multi-line `data` is split across multiple `data:` lines per the SSE spec -
5
5
  * each embedded `\n` becomes its own line, and the parser rejoins them.
6
6
  *
7
7
  * The returned string includes a trailing blank line (the dispatch marker).
@@ -13,7 +13,7 @@ interface ActorEntry {
13
13
  /**
14
14
  * In-process message bus connecting one or more {@link InMemoryTransport}
15
15
  * instances. Routes broadcasts to every registered actor and directed messages
16
- * to the actor that owns the recipient DID with no relay, server, or network.
16
+ * to the actor that owns the recipient DID, with no relay, server, or network.
17
17
  *
18
18
  * Each delivery does a JSON round-trip (Uint8Array preserved as `__bytes` hex)
19
19
  * so handlers receive an isolated, serialization-faithful copy, exactly as a
@@ -64,9 +64,9 @@ interface ActorEntry {
64
64
  * sending or receiving.
65
65
  *
66
66
  * Message routing:
67
- * - Keygen messages (COHORT_ADVERT, COHORT_OPT_IN, COHORT_OPT_IN_ACCEPT, COHORT_READY) kind 1 (plaintext)
68
- * - Update messages (SUBMIT_UPDATE, DISTRIBUTE_AGGREGATED_DATA, VALIDATION_ACK) kind 1059 (NIP-44 encrypted)
69
- * - Sign messages kind 1059 (NIP-44 encrypted)
67
+ * - Keygen messages (COHORT_ADVERT, COHORT_OPT_IN, COHORT_OPT_IN_ACCEPT, COHORT_READY) to kind 1 (plaintext)
68
+ * - Update messages (SUBMIT_UPDATE, DISTRIBUTE_AGGREGATED_DATA, VALIDATION_ACK) to kind 1059 (NIP-44 encrypted)
69
+ * - Sign messages to kind 1059 (NIP-44 encrypted)
70
70
  *
71
71
  * @class NostrTransport
72
72
  * @implements {Transport}
@@ -354,13 +354,13 @@ export class NostrTransport implements Transport {
354
354
  * backfill historical events to late subscribers: republishing gives late
355
355
  * joiners a window to discover the message without requiring protocol
356
356
  * changes. Relay rate-limit / publish failures inside the interval are
357
- * caught and logged rather than propagated the caller should stop the
357
+ * caught and logged rather than propagated - the caller should stop the
358
358
  * repeater once the protocol condition is satisfied.
359
359
  */
360
360
  publishRepeating(message: BaseMessage, sender: Did, intervalMs: number, recipient?: Did): () => void {
361
361
  let stopped = false;
362
362
  // Fire the first publish eagerly; any error surfaces as a rejected
363
- // promise that we swallow to avoid unhandled rejections the caller can
363
+ // promise that we swallow to avoid unhandled rejections - the caller can
364
364
  // observe delivery via receive-side handlers.
365
365
  void this.sendMessage(message, sender, recipient).catch((err) => {
366
366
  this.#logger.debug('publishRepeating first send failed:', err);
@@ -418,7 +418,7 @@ export class NostrTransport implements Transport {
418
418
  // Relay self-echo: sendMessage() adds the sender's own pubkey to the
419
419
  // event's `p` tags (so recipients can reply). The directed subscription
420
420
  // filter `{'#p': [actor_pk]}` therefore matches every event this actor
421
- // publishes. Skip we don't need to process our own outgoing events,
421
+ // publishes. Skip - we don't need to process our own outgoing events,
422
422
  // and attempting to NIP-44-decrypt them fails with "invalid MAC" because
423
423
  // the content was encrypted for the recipient, not self.
424
424
  if(event.pubkey === bytesToHex(actor.keys.publicKey.x)) return;
@@ -14,7 +14,7 @@ export type TransportType = 'nostr' | 'didcomm' | 'http';
14
14
  * shared by all registered actors. Each actor registers its own DID and keys;
15
15
  * the transport resolves the correct identity when sending or receiving messages.
16
16
  *
17
- * The transport is a pure passthrough it knows nothing about the aggregation
17
+ * The transport is a pure passthrough: it knows nothing about the aggregation
18
18
  * protocol. It only signs/encrypts outgoing messages with the sender's keys and
19
19
  * dispatches incoming messages to the correct actor's registered handler.
20
20
  *
@@ -23,7 +23,7 @@ export type TransportType = 'nostr' | 'didcomm' | 'http';
23
23
  export interface Transport {
24
24
  name: string;
25
25
 
26
- /** Start the underlying transport (idempotent only starts once). */
26
+ /** Start the underlying transport (idempotent, only starts once). */
27
27
  start(): void;
28
28
 
29
29
  /** Register an actor (service or participant) with this transport. */
@@ -61,14 +61,14 @@ export interface Transport {
61
61
  * message is satisfied).
62
62
  *
63
63
  * Useful for broadcasts on transports that don't reliably backfill
64
- * historical events to late subscribers (many Nostr relays) republishing
64
+ * historical events to late subscribers (many Nostr relays): republishing
65
65
  * gives late joiners a window in which to discover the message. The first
66
66
  * publish is synchronous-ish (fired before the method returns).
67
67
  *
68
68
  * Callers specify `recipient` only for directed messages; for broadcasts
69
69
  * it is omitted.
70
70
  *
71
- * @returns A stop function. Idempotent safe to call more than once.
71
+ * @returns A stop function. Idempotent, safe to call more than once.
72
72
  */
73
73
  publishRepeating(
74
74
  message: BaseMessage,