@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
@@ -8,6 +8,7 @@ import {
8
8
  COHORT_OPT_IN_ACCEPT,
9
9
  COHORT_READY,
10
10
  DISTRIBUTE_AGGREGATED_DATA,
11
+ FALLBACK_AUTHORIZATION_REQUEST,
11
12
  } from '../messages/constants.js';
12
13
  import type {
13
14
  CohortAdvert,
@@ -29,7 +30,7 @@ export type ShouldJoin = (advert: CohortAdvert) => Promise<boolean>;
29
30
  export type OnProvideUpdate = (info: {
30
31
  cohortId: string;
31
32
  beaconAddress: string;
32
- }) => Promise<SignedBTCR2Update>;
33
+ }) => Promise<SignedBTCR2Update | null>;
33
34
 
34
35
  /** Decision callback: approve or reject aggregated data. */
35
36
  export type OnValidateData = (info: PendingValidation) => Promise<{ approved: boolean }>;
@@ -53,7 +54,7 @@ export interface AggregationParticipantRunnerOptions {
53
54
 
54
55
  /**
55
56
  * Provide a signed BTCR2 update for the cohort.
56
- * REQUIRED no sensible default.
57
+ * REQUIRED - no sensible default.
57
58
  */
58
59
  onProvideUpdate: OnProvideUpdate;
59
60
 
@@ -161,6 +162,7 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
161
162
  DISTRIBUTE_AGGREGATED_DATA,
162
163
  AUTHORIZATION_REQUEST,
163
164
  AGGREGATED_NONCE,
165
+ FALLBACK_AUTHORIZATION_REQUEST,
164
166
  ];
165
167
 
166
168
  /** Internal: detach from the transport. Safe to call repeatedly. */
@@ -200,7 +202,7 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
200
202
  *
201
203
  * For an open-ended, long-lived subscriber (no fixed count), construct an
202
204
  * {@link AggregationParticipantRunner} directly, set `shouldJoin`, call
203
- * `start()`, and listen for `cohort-complete` the runner already drives
205
+ * `start()`, and listen for `cohort-complete` - the runner already drives
204
206
  * any number of cohorts concurrently.
205
207
  *
206
208
  * @param options Participant runner options (set `shouldJoin` to select cohorts).
@@ -240,6 +242,7 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
240
242
  this.#transport.registerMessageHandler(this.#did, DISTRIBUTE_AGGREGATED_DATA, this.#handleDistributeData.bind(this));
241
243
  this.#transport.registerMessageHandler(this.#did, AUTHORIZATION_REQUEST, this.#handleAuthorizationRequest.bind(this));
242
244
  this.#transport.registerMessageHandler(this.#did, AGGREGATED_NONCE, this.#handleAggregatedNonce.bind(this));
245
+ this.#transport.registerMessageHandler(this.#did, FALLBACK_AUTHORIZATION_REQUEST, this.#handleFallbackAuthorizationRequest.bind(this));
243
246
  }
244
247
 
245
248
  /**
@@ -302,13 +305,20 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
302
305
  if (!info) return;
303
306
  this.emit('cohort-ready', { cohortId, beaconAddress: info.beaconAddress });
304
307
 
305
- // Construct the signed update via caller callback and submit
308
+ // Construct the signed update via caller callback and submit. A null return
309
+ // means the member has no update this round: it declines (cooperative
310
+ // non-inclusion) but stays in the cohort and still signs.
306
311
  const signedUpdate = await this.#onProvideUpdate({
307
312
  cohortId,
308
313
  beaconAddress : info.beaconAddress,
309
314
  });
310
- await this.#sendAll(this.session.submitUpdate(cohortId, signedUpdate));
311
- this.emit('update-submitted', { cohortId });
315
+ if(signedUpdate === null) {
316
+ await this.#sendAll(this.session.declineUpdate(cohortId));
317
+ this.emit('update-declined', { cohortId });
318
+ } else {
319
+ await this.#sendAll(this.session.submitUpdate(cohortId, signedUpdate));
320
+ this.emit('update-submitted', { cohortId });
321
+ }
312
322
  } catch (err) {
313
323
  this.emit('error', err as Error);
314
324
  }
@@ -398,29 +408,71 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
398
408
  await this.#sendAll(this.session.generatePartialSignature(cohortId));
399
409
 
400
410
  // Check if we've reached completion
401
- if (this.session.getCohortPhase(cohortId) === ParticipantCohortPhase.Complete) {
402
- const info = this.session.joinedCohorts.get(cohortId);
403
- if (info) {
404
- // Surface the sidecar data the participant will need for future resolutions:
405
- // the CAS Announcement map (CAS beacons) or their SMT inclusion proof.
406
- // Read via getValidation (not pendingValidations, which lists only the
407
- // AwaitingValidation phase) so the sidecar is still available now that
408
- // the cohort has reached Complete.
409
- const validation = this.session.getValidation(cohortId);
410
- this.emit('cohort-complete', {
411
- cohortId,
412
- beaconAddress : info.beaconAddress,
413
- beaconType : validation?.beaconType ?? '',
414
- casAnnouncement : validation?.casAnnouncement,
415
- smtProof : validation?.smtProof,
416
- });
417
- }
411
+ this.#emitCohortCompleteIfDone(cohortId);
412
+ } catch (err) {
413
+ this.emit('error', err as Error);
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Internal: handler for fallback authorization requests. The service abandoned
419
+ * the optimistic key path; the member authorizes the k-of-n script-path spend
420
+ * of the same beacon transaction (ADR 042). Reuses `onApproveSigning` to gate
421
+ * the decision, signs the fallback, and completes once it has contributed (the
422
+ * service needs only k members).
423
+ * @param {BaseMessage} msg - The received fallback authorization request message.
424
+ * @returns {Promise<void>} Resolves when processing is complete.
425
+ */
426
+ async #handleFallbackAuthorizationRequest(msg: BaseMessage): Promise<void> {
427
+ if (this.#stopped) return;
428
+ try {
429
+ this.session.receive(msg);
430
+
431
+ const cohortId = msg.body?.cohortId;
432
+ if (!cohortId) return;
433
+
434
+ const req = this.session.pendingFallbackRequests.get(cohortId);
435
+ if (!req) return;
436
+ this.emit('fallback-requested', req);
437
+
438
+ const decision = await this.#onApproveSigning(req);
439
+ if (!decision.approved) {
440
+ this.emit('cohort-failed', { cohortId, reason: 'Fallback signing rejected by participant' });
441
+ return;
418
442
  }
443
+
444
+ await this.#sendAll(this.session.approveFallback(cohortId));
445
+ // The member has contributed its fallback signature and is done from its
446
+ // own perspective, regardless of whether the service has yet collected k.
447
+ this.#emitCohortCompleteIfDone(cohortId);
419
448
  } catch (err) {
420
449
  this.emit('error', err as Error);
421
450
  }
422
451
  }
423
452
 
453
+ /**
454
+ * Internal: emit `cohort-complete` with the participant's retained sidecar once
455
+ * the cohort has reached the Complete phase. Surfaces the CAS Announcement map
456
+ * (CAS beacons) or the SMT proof (SMT beacons) the participant keeps for future
457
+ * DID resolution. Read via getValidation (not pendingValidations, which lists
458
+ * only the AwaitingValidation phase) so the sidecar is still available at
459
+ * Complete. Shared by the optimistic and fallback completion paths.
460
+ */
461
+ #emitCohortCompleteIfDone(cohortId: string): void {
462
+ if (this.session.getCohortPhase(cohortId) !== ParticipantCohortPhase.Complete) return;
463
+ const info = this.session.joinedCohorts.get(cohortId);
464
+ if (!info) return;
465
+ const validation = this.session.getValidation(cohortId);
466
+ this.emit('cohort-complete', {
467
+ cohortId,
468
+ beaconAddress : info.beaconAddress,
469
+ beaconType : validation?.beaconType ?? '',
470
+ included : validation?.included ?? true,
471
+ casAnnouncement : validation?.casAnnouncement,
472
+ smtProof : validation?.smtProof,
473
+ });
474
+ }
475
+
424
476
  /**
425
477
  * Internal: send helper to ensure messages are sent sequentially. This is important for protocol
426
478
  * correctness, as some transports may not guarantee message order if sent in parallel.
@@ -3,12 +3,15 @@ import { AggregationServiceError } from '../errors.js';
3
3
  import type { BaseMessage } from '../messages/base.js';
4
4
  import {
5
5
  COHORT_OPT_IN,
6
+ FALLBACK_SIGNATURE,
6
7
  NONCE_CONTRIBUTION,
7
8
  SIGNATURE_AUTHORIZATION,
9
+ SUBMIT_NONINCLUDED,
8
10
  SUBMIT_UPDATE,
9
11
  VALIDATION_ACK,
10
12
  } from '../messages/constants.js';
11
13
  import { ServiceCohortPhase } from '../phases.js';
14
+ import type { ServiceCohortPhaseType } from '../phases.js';
12
15
  import type {
13
16
  AggregationResult,
14
17
  CohortConfig,
@@ -68,7 +71,7 @@ export interface AggregationServiceRunnerOptions {
68
71
 
69
72
  /**
70
73
  * Provide the Bitcoin transaction data to sign.
71
- * REQUIRED no sensible default.
74
+ * REQUIRED - no sensible default.
72
75
  */
73
76
  onProvideTxData: OnProvideTxData;
74
77
 
@@ -98,7 +101,7 @@ export interface AggregationServiceRunnerOptions {
98
101
  /**
99
102
  * Re-publish COHORT_ADVERT on this interval until a cohort's keygen is
100
103
  * finalized. Works around relays that don't backfill historical events to
101
- * late subscribers a republish gives late joiners a window to discover the
104
+ * late subscribers - a republish gives late joiners a window to discover the
102
105
  * advert without protocol changes. The first publish is immediate;
103
106
  * subsequent publishes fire every `advertRepeatIntervalMs` until that
104
107
  * cohort's keygen completes, fails, or is stopped. Defaults to
@@ -106,6 +109,17 @@ export interface AggregationServiceRunnerOptions {
106
109
  * once and never retry.
107
110
  */
108
111
  advertRepeatIntervalMs?: number;
112
+
113
+ /**
114
+ * When a cohort stalls (phase timeout) while the optimistic n-of-n signing
115
+ * round is in flight, fall back to the k-of-n script path instead of failing
116
+ * the cohort (graceful liveness, ADR 042). Off by default: enabling it trades a
117
+ * cheaper/private key-path spend for a larger script-path spend whenever the
118
+ * optimistic round does not complete in time. A stall outside the signing
119
+ * phases still fails the cohort. Operators can also drive the fallback
120
+ * explicitly via {@link AggregationServiceRunner.triggerFallback}.
121
+ */
122
+ autoFallbackOnStall?: boolean;
109
123
  }
110
124
 
111
125
  /** Default cadence for re-publishing COHORT_ADVERT until keygen completes: 60 seconds. */
@@ -139,6 +153,14 @@ interface RunContext {
139
153
  finalizing: boolean;
140
154
  /** Once settled (resolved or rejected), late timers/messages must not re-settle. */
141
155
  settled: boolean;
156
+ /**
157
+ * The spend path this cohort is committed to once signing reaches a decision:
158
+ * `optimistic` (n-of-n key path) or `fallback` (k-of-n script path). A cohort
159
+ * spends its single beacon UTXO exactly once, so this latch (set synchronously
160
+ * before any await) ensures the optimistic completion and the fallback never
161
+ * both finalize and broadcast the same UTXO (the ADR-042 double-spend hazard).
162
+ */
163
+ committedPath?: 'optimistic' | 'fallback';
142
164
  cohortTtlTimer?: ReturnType<typeof setTimeout>;
143
165
  phaseTimer?: ReturnType<typeof setTimeout>;
144
166
  lastObservedPhase?: string;
@@ -155,7 +177,7 @@ interface RunContext {
155
177
  *
156
178
  * A single runner is a long-lived multiplexer: it advertises and drives many
157
179
  * cohorts concurrently over one transport. Each advertised cohort owns an
158
- * independent completion promise and fails in isolation a stalled or failed
180
+ * independent completion promise and fails in isolation - a stalled or failed
159
181
  * cohort never settles its siblings (see ADR 040). Use
160
182
  * {@link AggregationServiceRunner.advertiseCohort} for the multi-cohort path;
161
183
  * {@link AggregationServiceRunner.run} is a thin single-cohort convenience over
@@ -206,6 +228,14 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
206
228
  readonly #cohortTtlMs?: number;
207
229
  readonly #phaseTimeoutMs?: number;
208
230
  readonly #advertRepeatIntervalMs: number;
231
+ readonly #autoFallbackOnStall: boolean;
232
+
233
+ /** Phases during which a stall can be salvaged by the k-of-n fallback (ADR 042). */
234
+ static readonly #SIGNING_PHASES: readonly ServiceCohortPhaseType[] = [
235
+ ServiceCohortPhase.SigningStarted,
236
+ ServiceCohortPhase.NoncesCollected,
237
+ ServiceCohortPhase.AwaitingPartialSigs,
238
+ ];
209
239
 
210
240
  /** Per-cohort run state, keyed by cohortId. */
211
241
  readonly #contexts: Map<string, RunContext> = new Map();
@@ -225,6 +255,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
225
255
  this.#cohortTtlMs = options.cohortTtlMs;
226
256
  this.#phaseTimeoutMs = options.phaseTimeoutMs;
227
257
  this.#advertRepeatIntervalMs = options.advertRepeatIntervalMs ?? DEFAULT_ADVERT_REPEAT_INTERVAL_MS;
258
+ this.#autoFallbackOnStall = options.autoFallbackOnStall ?? false;
228
259
 
229
260
  this.session = new AggregationService({
230
261
  // The coordinator never signs, so the state machine receives only the
@@ -269,7 +300,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
269
300
  }
270
301
  this.#registerHandlers();
271
302
  // createCohort validates the conditions and throws on a bad config before
272
- // any context exists fail-fast, nothing to clean up.
303
+ // any context exists - fail-fast, nothing to clean up.
273
304
  const cohortId = this.session.createCohort(config);
274
305
 
275
306
  let resolve!: (result: AggregationResult) => void;
@@ -293,7 +324,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
293
324
  this.#onPhaseMaybeChanged(ctx);
294
325
  this.emit('cohort-advertised', { cohortId });
295
326
  // Publish the advert. If advertRepeatIntervalMs > 0 we republish on that
296
- // cadence until this cohort's keygen-complete / fail / stop works around
327
+ // cadence until this cohort's keygen-complete / fail / stop - works around
297
328
  // relays that don't backfill historical events to late subscribers.
298
329
  // Otherwise fall back to a single send.
299
330
  if(this.#advertRepeatIntervalMs > 0) {
@@ -404,12 +435,53 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
404
435
  ctx.phaseTimer = undefined;
405
436
  if(this.#phaseTimeoutMs === undefined) return;
406
437
  ctx.phaseTimer = setTimeout(() => {
438
+ // A stall during the optimistic signing round can be salvaged by the k-of-n
439
+ // fallback rather than failing the whole cohort (graceful liveness, ADR
440
+ // 042) - but only if enabled and not already committed to a path.
441
+ const phase = this.session.getCohortPhase(ctx.cohortId);
442
+ const inSigning = phase !== undefined && AggregationServiceRunner.#SIGNING_PHASES.includes(phase);
443
+ if(this.#autoFallbackOnStall && inSigning && !ctx.committedPath && !ctx.settled) {
444
+ this.triggerFallback(ctx.cohortId).catch(err => this.#failCohort(ctx, err as Error));
445
+ return;
446
+ }
407
447
  const reason = `Cohort ${ctx.cohortId} stalled in phase ${ctx.lastObservedPhase ?? '?'} for ${this.#phaseTimeoutMs}ms`;
408
448
  this.emit('cohort-failed', { cohortId: ctx.cohortId, reason });
409
449
  this.#failCohort(ctx, new Error(reason));
410
450
  }, this.#phaseTimeoutMs);
411
451
  }
412
452
 
453
+ /**
454
+ * Abandon the optimistic n-of-n key path for a cohort and collect k-of-n
455
+ * fallback (script-path) signatures instead (ADR 042). Idempotent and safe
456
+ * against the optimistic completion: it commits the cohort to the fallback
457
+ * path synchronously (the `committedPath` latch) before sending anything, so a
458
+ * late optimistic signature can no longer complete-and-broadcast a competing
459
+ * spend of the same UTXO. No-op if the cohort is unknown, already settled, or
460
+ * already committed to a path.
461
+ *
462
+ * Wired automatically to the phase-stall timer when `autoFallbackOnStall` is
463
+ * set; otherwise call it from an operator decision (a UI "fall back now"
464
+ * action). Throws only if the underlying state machine rejects the transition
465
+ * (e.g. signing has not started).
466
+ */
467
+ async triggerFallback(cohortId: string): Promise<void> {
468
+ const ctx = this.#contexts.get(cohortId);
469
+ if(!ctx || ctx.settled || ctx.committedPath) return;
470
+ // startFallbackSigning is synchronous and throws if the cohort is not in a
471
+ // signing phase (e.g. a premature operator call). Run it FIRST so a rejected
472
+ // transition cannot poison the latch: only after it commits the state machine
473
+ // to the fallback do we set committedPath. This still happens synchronously
474
+ // before any await, so a concurrent optimistic completion observes the latch
475
+ // and stands down - but a bad-phase call leaves the optimistic path intact.
476
+ const messages = this.session.startFallbackSigning(cohortId);
477
+ ctx.committedPath = 'fallback';
478
+ this.#stopAdvertRepeating(ctx);
479
+ this.#onPhaseMaybeChanged(ctx);
480
+ const sessionId = this.session.getSigningSessionId(cohortId) ?? '';
481
+ this.emit('fallback-started', { cohortId, sessionId });
482
+ await this.#sendAll(messages);
483
+ }
484
+
413
485
  /** Detect a phase change for a cohort since the last observation and reset its phase timer. */
414
486
  #onPhaseMaybeChanged(ctx: RunContext): void {
415
487
  const phase = this.session.getCohortPhase(ctx.cohortId);
@@ -505,14 +577,16 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
505
577
  static readonly #HANDLED_MESSAGE_TYPES: readonly string[] = [
506
578
  COHORT_OPT_IN,
507
579
  SUBMIT_UPDATE,
580
+ SUBMIT_NONINCLUDED,
508
581
  VALIDATION_ACK,
509
582
  NONCE_CONTRIBUTION,
510
583
  SIGNATURE_AUTHORIZATION,
584
+ FALLBACK_SIGNATURE,
511
585
  ];
512
586
 
513
587
  /**
514
588
  * Internal: handler registration with the transport. Idempotent. Handlers
515
- * are DID-scoped and cohort-agnostic one registration serves every cohort
589
+ * are DID-scoped and cohort-agnostic - one registration serves every cohort
516
590
  * this runner drives; demux to the right {@link RunContext} happens in each
517
591
  * handler via the inbound message's cohortId.
518
592
  */
@@ -522,9 +596,14 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
522
596
 
523
597
  this.#transport.registerMessageHandler(this.#did, COHORT_OPT_IN, this.#handleOptIn.bind(this));
524
598
  this.#transport.registerMessageHandler(this.#did, SUBMIT_UPDATE, this.#handleSubmitUpdate.bind(this));
599
+ // A non-inclusion (decline) is an update-phase response handled identically:
600
+ // session.receive() routes by type, and the response gate + distribute
601
+ // trigger are shared with SUBMIT_UPDATE.
602
+ this.#transport.registerMessageHandler(this.#did, SUBMIT_NONINCLUDED, this.#handleSubmitUpdate.bind(this));
525
603
  this.#transport.registerMessageHandler(this.#did, VALIDATION_ACK, this.#handleValidationAck.bind(this));
526
604
  this.#transport.registerMessageHandler(this.#did, NONCE_CONTRIBUTION, this.#handleNonceContribution.bind(this));
527
605
  this.#transport.registerMessageHandler(this.#did, SIGNATURE_AUTHORIZATION, this.#handleSignatureAuthorization.bind(this));
606
+ this.#transport.registerMessageHandler(this.#did, FALLBACK_SIGNATURE, this.#handleFallbackSignature.bind(this));
528
607
  }
529
608
 
530
609
  /** Internal: detach from the transport. Safe to call repeatedly. */
@@ -584,7 +663,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
584
663
 
585
664
  // Check if it's time to finalize. The per-cohort `finalizing` flag is set
586
665
  // synchronously before the first await so concurrent opt-in handlers for
587
- // the same cohort observe it and skip otherwise two handlers could both
666
+ // the same cohort observe it and skip - otherwise two handlers could both
588
667
  // pass the minParticipants check and both call finalizeKeygen, the second
589
668
  // of which would throw (phase mismatch).
590
669
  const cohort = this.session.getCohort(ctx.cohortId)!;
@@ -595,7 +674,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
595
674
  minRequired : ctx.config.minParticipants,
596
675
  });
597
676
  if(!finalizeDecision.finalize) {
598
- // Operator declined reset the flag so a later opt-in can retry.
677
+ // Operator declined - reset the flag so a later opt-in can retry.
599
678
  ctx.finalizing = false;
600
679
  return;
601
680
  }
@@ -604,7 +683,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
604
683
  // (which can run all the way to signing-complete) would resolve the
605
684
  // cohort's completion promise before this event fires.
606
685
  const readyMsgs = this.session.finalizeKeygen(ctx.cohortId);
607
- // Keygen done stop re-advertising the cohort. New participants
686
+ // Keygen done - stop re-advertising the cohort. New participants
608
687
  // arriving after this point would be rejected anyway.
609
688
  this.#stopAdvertRepeating(ctx);
610
689
  this.emit('keygen-complete', {
@@ -733,7 +812,38 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
733
812
  this.#drainRejections(ctx);
734
813
  this.#onPhaseMaybeChanged(ctx);
735
814
 
736
- // The state machine auto-completes when all partial sigs received
815
+ // If the cohort already committed to the fallback path, ignore a late
816
+ // optimistic completion: only one path may finalize the single beacon UTXO.
817
+ if(ctx.committedPath === 'fallback') return;
818
+
819
+ // The state machine auto-completes when all partial sigs received.
820
+ const result = this.session.getResult(ctx.cohortId);
821
+ if(result) {
822
+ ctx.committedPath = 'optimistic';
823
+ this.#completeCohort(ctx, result);
824
+ }
825
+ } catch(err) {
826
+ this.#failCohort(ctx, err as Error);
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Handler for receiving fallback (k-of-n script-path) signatures. The state
832
+ * machine assembles and finalizes the fallback spend once k valid signatures
833
+ * are in; the result is then emitted and the cohort's completion resolves. The
834
+ * cohort is already committed to the fallback path (via {@link triggerFallback}).
835
+ * @param {BaseMessage} msg - The incoming FALLBACK_SIGNATURE message.
836
+ * @returns {Promise<void>} Resolves when handling is complete.
837
+ */
838
+ async #handleFallbackSignature(msg: BaseMessage): Promise<void> {
839
+ if(this.#stopped) return;
840
+ const ctx = this.#contextFor(msg);
841
+ if(!ctx) return;
842
+ try {
843
+ this.session.receive(msg);
844
+ this.#drainRejections(ctx);
845
+ this.#onPhaseMaybeChanged(ctx);
846
+
737
847
  const result = this.session.getResult(ctx.cohortId);
738
848
  if(result) {
739
849
  this.#completeCohort(ctx, result);