@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
@@ -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,
@@ -18,6 +21,8 @@ import {
18
21
  AggregationService
19
22
  } from '../service.js';
20
23
  import type { Transport } from '../transport/transport.js';
24
+ import { DEFAULT_FEE_ESTIMATOR } from '../../beacon/fee-estimator.js';
25
+ import type { FeeEstimator } from '../../beacon/fee-estimator.js';
21
26
  import type { AggregationServiceEvents } from './events.js';
22
27
  import { TypedEventEmitter } from './typed-emitter.js';
23
28
 
@@ -35,6 +40,13 @@ export type OnProvideTxData = (info: {
35
40
  cohortId: string;
36
41
  beaconAddress: string;
37
42
  signalBytes: Uint8Array;
43
+ /**
44
+ * Fee estimator the runner is configured with (the runner's `feeEstimator`
45
+ * option, or a static 5 sat/vB default). Forward it to the beacon transaction
46
+ * builder so a dynamic rate injected at the runner is honored, rather than
47
+ * hard-coding a rate inside this callback (ADR 045).
48
+ */
49
+ feeEstimator: FeeEstimator;
38
50
  }) => Promise<SigningTxData>;
39
51
 
40
52
  export interface AggregationServiceRunnerOptions {
@@ -68,10 +80,19 @@ export interface AggregationServiceRunnerOptions {
68
80
 
69
81
  /**
70
82
  * Provide the Bitcoin transaction data to sign.
71
- * REQUIRED no sensible default.
83
+ * REQUIRED - no sensible default.
72
84
  */
73
85
  onProvideTxData: OnProvideTxData;
74
86
 
87
+ /**
88
+ * Fee estimator passed to {@link OnProvideTxData} so the beacon transaction the
89
+ * callback builds is sized at a chosen rate. Inject a dynamic estimator (a mempool
90
+ * API or Bitcoin Core `estimatesmartfee`) here as the single standard point for
91
+ * fee-rate selection, instead of hard-coding a rate inside the callback (ADR 045).
92
+ * Defaults to a static 5 sat/vB estimator.
93
+ */
94
+ feeEstimator?: FeeEstimator;
95
+
75
96
  /**
76
97
  * Maximum canonicalized byte-length of a signed update body. Submissions
77
98
  * above this cap are rejected and surfaced via the `message-rejected` event.
@@ -98,7 +119,7 @@ export interface AggregationServiceRunnerOptions {
98
119
  /**
99
120
  * Re-publish COHORT_ADVERT on this interval until a cohort's keygen is
100
121
  * finalized. Works around relays that don't backfill historical events to
101
- * late subscribers a republish gives late joiners a window to discover the
122
+ * late subscribers - a republish gives late joiners a window to discover the
102
123
  * advert without protocol changes. The first publish is immediate;
103
124
  * subsequent publishes fire every `advertRepeatIntervalMs` until that
104
125
  * cohort's keygen completes, fails, or is stopped. Defaults to
@@ -106,6 +127,17 @@ export interface AggregationServiceRunnerOptions {
106
127
  * once and never retry.
107
128
  */
108
129
  advertRepeatIntervalMs?: number;
130
+
131
+ /**
132
+ * When a cohort stalls (phase timeout) while the optimistic n-of-n signing
133
+ * round is in flight, fall back to the k-of-n script path instead of failing
134
+ * the cohort (graceful liveness, ADR 042). Off by default: enabling it trades a
135
+ * cheaper/private key-path spend for a larger script-path spend whenever the
136
+ * optimistic round does not complete in time. A stall outside the signing
137
+ * phases still fails the cohort. Operators can also drive the fallback
138
+ * explicitly via {@link AggregationServiceRunner.triggerFallback}.
139
+ */
140
+ autoFallbackOnStall?: boolean;
109
141
  }
110
142
 
111
143
  /** Default cadence for re-publishing COHORT_ADVERT until keygen completes: 60 seconds. */
@@ -139,6 +171,14 @@ interface RunContext {
139
171
  finalizing: boolean;
140
172
  /** Once settled (resolved or rejected), late timers/messages must not re-settle. */
141
173
  settled: boolean;
174
+ /**
175
+ * The spend path this cohort is committed to once signing reaches a decision:
176
+ * `optimistic` (n-of-n key path) or `fallback` (k-of-n script path). A cohort
177
+ * spends its single beacon UTXO exactly once, so this latch (set synchronously
178
+ * before any await) ensures the optimistic completion and the fallback never
179
+ * both finalize and broadcast the same UTXO (the ADR-042 double-spend hazard).
180
+ */
181
+ committedPath?: 'optimistic' | 'fallback';
142
182
  cohortTtlTimer?: ReturnType<typeof setTimeout>;
143
183
  phaseTimer?: ReturnType<typeof setTimeout>;
144
184
  lastObservedPhase?: string;
@@ -155,7 +195,7 @@ interface RunContext {
155
195
  *
156
196
  * A single runner is a long-lived multiplexer: it advertises and drives many
157
197
  * cohorts concurrently over one transport. Each advertised cohort owns an
158
- * independent completion promise and fails in isolation a stalled or failed
198
+ * independent completion promise and fails in isolation - a stalled or failed
159
199
  * cohort never settles its siblings (see ADR 040). Use
160
200
  * {@link AggregationServiceRunner.advertiseCohort} for the multi-cohort path;
161
201
  * {@link AggregationServiceRunner.run} is a thin single-cohort convenience over
@@ -170,8 +210,9 @@ interface RunContext {
170
210
  * transport,
171
211
  * did: serviceDid,
172
212
  * keys: serviceKeys,
173
- * onProvideTxData: async ({ cohortId, beaconAddress, signalBytes }) => {
174
- * return await buildBeaconTransaction(beaconAddress, signalBytes, bitcoin);
213
+ * onProvideTxData: async ({ beaconAddress, signalBytes, feeEstimator }) => {
214
+ * // Forward feeEstimator so a dynamic rate injected at the runner is honored.
215
+ * return await buildBeaconTransaction(beaconAddress, signalBytes, bitcoin, feeEstimator);
175
216
  * },
176
217
  * });
177
218
  *
@@ -203,9 +244,18 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
203
244
  readonly #onOptInReceived: OnOptInReceived;
204
245
  readonly #onReadyToFinalize: OnReadyToFinalize;
205
246
  readonly #onProvideTxData: OnProvideTxData;
247
+ readonly #feeEstimator: FeeEstimator;
206
248
  readonly #cohortTtlMs?: number;
207
249
  readonly #phaseTimeoutMs?: number;
208
250
  readonly #advertRepeatIntervalMs: number;
251
+ readonly #autoFallbackOnStall: boolean;
252
+
253
+ /** Phases during which a stall can be salvaged by the k-of-n fallback (ADR 042). */
254
+ static readonly #SIGNING_PHASES: readonly ServiceCohortPhaseType[] = [
255
+ ServiceCohortPhase.SigningStarted,
256
+ ServiceCohortPhase.NoncesCollected,
257
+ ServiceCohortPhase.AwaitingPartialSigs,
258
+ ];
209
259
 
210
260
  /** Per-cohort run state, keyed by cohortId. */
211
261
  readonly #contexts: Map<string, RunContext> = new Map();
@@ -222,9 +272,11 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
222
272
  finalize : acceptedCount >= minRequired,
223
273
  }));
224
274
  this.#onProvideTxData = options.onProvideTxData;
275
+ this.#feeEstimator = options.feeEstimator ?? DEFAULT_FEE_ESTIMATOR;
225
276
  this.#cohortTtlMs = options.cohortTtlMs;
226
277
  this.#phaseTimeoutMs = options.phaseTimeoutMs;
227
278
  this.#advertRepeatIntervalMs = options.advertRepeatIntervalMs ?? DEFAULT_ADVERT_REPEAT_INTERVAL_MS;
279
+ this.#autoFallbackOnStall = options.autoFallbackOnStall ?? false;
228
280
 
229
281
  this.session = new AggregationService({
230
282
  // The coordinator never signs, so the state machine receives only the
@@ -269,7 +321,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
269
321
  }
270
322
  this.#registerHandlers();
271
323
  // createCohort validates the conditions and throws on a bad config before
272
- // any context exists fail-fast, nothing to clean up.
324
+ // any context exists - fail-fast, nothing to clean up.
273
325
  const cohortId = this.session.createCohort(config);
274
326
 
275
327
  let resolve!: (result: AggregationResult) => void;
@@ -293,7 +345,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
293
345
  this.#onPhaseMaybeChanged(ctx);
294
346
  this.emit('cohort-advertised', { cohortId });
295
347
  // Publish the advert. If advertRepeatIntervalMs > 0 we republish on that
296
- // cadence until this cohort's keygen-complete / fail / stop works around
348
+ // cadence until this cohort's keygen-complete / fail / stop - works around
297
349
  // relays that don't backfill historical events to late subscribers.
298
350
  // Otherwise fall back to a single send.
299
351
  if(this.#advertRepeatIntervalMs > 0) {
@@ -404,12 +456,53 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
404
456
  ctx.phaseTimer = undefined;
405
457
  if(this.#phaseTimeoutMs === undefined) return;
406
458
  ctx.phaseTimer = setTimeout(() => {
459
+ // A stall during the optimistic signing round can be salvaged by the k-of-n
460
+ // fallback rather than failing the whole cohort (graceful liveness, ADR
461
+ // 042) - but only if enabled and not already committed to a path.
462
+ const phase = this.session.getCohortPhase(ctx.cohortId);
463
+ const inSigning = phase !== undefined && AggregationServiceRunner.#SIGNING_PHASES.includes(phase);
464
+ if(this.#autoFallbackOnStall && inSigning && !ctx.committedPath && !ctx.settled) {
465
+ this.triggerFallback(ctx.cohortId).catch(err => this.#failCohort(ctx, err as Error));
466
+ return;
467
+ }
407
468
  const reason = `Cohort ${ctx.cohortId} stalled in phase ${ctx.lastObservedPhase ?? '?'} for ${this.#phaseTimeoutMs}ms`;
408
469
  this.emit('cohort-failed', { cohortId: ctx.cohortId, reason });
409
470
  this.#failCohort(ctx, new Error(reason));
410
471
  }, this.#phaseTimeoutMs);
411
472
  }
412
473
 
474
+ /**
475
+ * Abandon the optimistic n-of-n key path for a cohort and collect k-of-n
476
+ * fallback (script-path) signatures instead (ADR 042). Idempotent and safe
477
+ * against the optimistic completion: it commits the cohort to the fallback
478
+ * path synchronously (the `committedPath` latch) before sending anything, so a
479
+ * late optimistic signature can no longer complete-and-broadcast a competing
480
+ * spend of the same UTXO. No-op if the cohort is unknown, already settled, or
481
+ * already committed to a path.
482
+ *
483
+ * Wired automatically to the phase-stall timer when `autoFallbackOnStall` is
484
+ * set; otherwise call it from an operator decision (a UI "fall back now"
485
+ * action). Throws only if the underlying state machine rejects the transition
486
+ * (e.g. signing has not started).
487
+ */
488
+ async triggerFallback(cohortId: string): Promise<void> {
489
+ const ctx = this.#contexts.get(cohortId);
490
+ if(!ctx || ctx.settled || ctx.committedPath) return;
491
+ // startFallbackSigning is synchronous and throws if the cohort is not in a
492
+ // signing phase (e.g. a premature operator call). Run it FIRST so a rejected
493
+ // transition cannot poison the latch: only after it commits the state machine
494
+ // to the fallback do we set committedPath. This still happens synchronously
495
+ // before any await, so a concurrent optimistic completion observes the latch
496
+ // and stands down - but a bad-phase call leaves the optimistic path intact.
497
+ const messages = this.session.startFallbackSigning(cohortId);
498
+ ctx.committedPath = 'fallback';
499
+ this.#stopAdvertRepeating(ctx);
500
+ this.#onPhaseMaybeChanged(ctx);
501
+ const sessionId = this.session.getSigningSessionId(cohortId) ?? '';
502
+ this.emit('fallback-started', { cohortId, sessionId });
503
+ await this.#sendAll(messages);
504
+ }
505
+
413
506
  /** Detect a phase change for a cohort since the last observation and reset its phase timer. */
414
507
  #onPhaseMaybeChanged(ctx: RunContext): void {
415
508
  const phase = this.session.getCohortPhase(ctx.cohortId);
@@ -505,14 +598,16 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
505
598
  static readonly #HANDLED_MESSAGE_TYPES: readonly string[] = [
506
599
  COHORT_OPT_IN,
507
600
  SUBMIT_UPDATE,
601
+ SUBMIT_NONINCLUDED,
508
602
  VALIDATION_ACK,
509
603
  NONCE_CONTRIBUTION,
510
604
  SIGNATURE_AUTHORIZATION,
605
+ FALLBACK_SIGNATURE,
511
606
  ];
512
607
 
513
608
  /**
514
609
  * Internal: handler registration with the transport. Idempotent. Handlers
515
- * are DID-scoped and cohort-agnostic one registration serves every cohort
610
+ * are DID-scoped and cohort-agnostic - one registration serves every cohort
516
611
  * this runner drives; demux to the right {@link RunContext} happens in each
517
612
  * handler via the inbound message's cohortId.
518
613
  */
@@ -522,9 +617,14 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
522
617
 
523
618
  this.#transport.registerMessageHandler(this.#did, COHORT_OPT_IN, this.#handleOptIn.bind(this));
524
619
  this.#transport.registerMessageHandler(this.#did, SUBMIT_UPDATE, this.#handleSubmitUpdate.bind(this));
620
+ // A non-inclusion (decline) is an update-phase response handled identically:
621
+ // session.receive() routes by type, and the response gate + distribute
622
+ // trigger are shared with SUBMIT_UPDATE.
623
+ this.#transport.registerMessageHandler(this.#did, SUBMIT_NONINCLUDED, this.#handleSubmitUpdate.bind(this));
525
624
  this.#transport.registerMessageHandler(this.#did, VALIDATION_ACK, this.#handleValidationAck.bind(this));
526
625
  this.#transport.registerMessageHandler(this.#did, NONCE_CONTRIBUTION, this.#handleNonceContribution.bind(this));
527
626
  this.#transport.registerMessageHandler(this.#did, SIGNATURE_AUTHORIZATION, this.#handleSignatureAuthorization.bind(this));
627
+ this.#transport.registerMessageHandler(this.#did, FALLBACK_SIGNATURE, this.#handleFallbackSignature.bind(this));
528
628
  }
529
629
 
530
630
  /** Internal: detach from the transport. Safe to call repeatedly. */
@@ -584,7 +684,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
584
684
 
585
685
  // Check if it's time to finalize. The per-cohort `finalizing` flag is set
586
686
  // synchronously before the first await so concurrent opt-in handlers for
587
- // the same cohort observe it and skip otherwise two handlers could both
687
+ // the same cohort observe it and skip - otherwise two handlers could both
588
688
  // pass the minParticipants check and both call finalizeKeygen, the second
589
689
  // of which would throw (phase mismatch).
590
690
  const cohort = this.session.getCohort(ctx.cohortId)!;
@@ -595,7 +695,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
595
695
  minRequired : ctx.config.minParticipants,
596
696
  });
597
697
  if(!finalizeDecision.finalize) {
598
- // Operator declined reset the flag so a later opt-in can retry.
698
+ // Operator declined - reset the flag so a later opt-in can retry.
599
699
  ctx.finalizing = false;
600
700
  return;
601
701
  }
@@ -604,7 +704,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
604
704
  // (which can run all the way to signing-complete) would resolve the
605
705
  // cohort's completion promise before this event fires.
606
706
  const readyMsgs = this.session.finalizeKeygen(ctx.cohortId);
607
- // Keygen done stop re-advertising the cohort. New participants
707
+ // Keygen done - stop re-advertising the cohort. New participants
608
708
  // arriving after this point would be rejected anyway.
609
709
  this.#stopAdvertRepeating(ctx);
610
710
  this.emit('keygen-complete', {
@@ -681,6 +781,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
681
781
  cohortId : ctx.cohortId,
682
782
  beaconAddress : cohort.beaconAddress,
683
783
  signalBytes : cohort.signalBytes!,
784
+ feeEstimator : this.#feeEstimator,
684
785
  });
685
786
  const authMsgs = this.session.startSigning(ctx.cohortId, txData);
686
787
  const sessionId = this.session.getSigningSessionId(ctx.cohortId) ?? '';
@@ -733,7 +834,38 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
733
834
  this.#drainRejections(ctx);
734
835
  this.#onPhaseMaybeChanged(ctx);
735
836
 
736
- // The state machine auto-completes when all partial sigs received
837
+ // If the cohort already committed to the fallback path, ignore a late
838
+ // optimistic completion: only one path may finalize the single beacon UTXO.
839
+ if(ctx.committedPath === 'fallback') return;
840
+
841
+ // The state machine auto-completes when all partial sigs received.
842
+ const result = this.session.getResult(ctx.cohortId);
843
+ if(result) {
844
+ ctx.committedPath = 'optimistic';
845
+ this.#completeCohort(ctx, result);
846
+ }
847
+ } catch(err) {
848
+ this.#failCohort(ctx, err as Error);
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Handler for receiving fallback (k-of-n script-path) signatures. The state
854
+ * machine assembles and finalizes the fallback spend once k valid signatures
855
+ * are in; the result is then emitted and the cohort's completion resolves. The
856
+ * cohort is already committed to the fallback path (via {@link triggerFallback}).
857
+ * @param {BaseMessage} msg - The incoming FALLBACK_SIGNATURE message.
858
+ * @returns {Promise<void>} Resolves when handling is complete.
859
+ */
860
+ async #handleFallbackSignature(msg: BaseMessage): Promise<void> {
861
+ if(this.#stopped) return;
862
+ const ctx = this.#contextFor(msg);
863
+ if(!ctx) return;
864
+ try {
865
+ this.session.receive(msg);
866
+ this.#drainRejections(ctx);
867
+ this.#onPhaseMaybeChanged(ctx);
868
+
737
869
  const result = this.session.getResult(ctx.cohortId);
738
870
  if(result) {
739
871
  this.#completeCohort(ctx, result);