@did-btcr2/method 0.36.0 → 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 +1371 -260
  3. package/dist/browser.mjs +1371 -260
  4. package/dist/cjs/index.js +1464 -343
  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 +218 -18
  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 +106 -19
  33. package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
  34. package/dist/esm/core/aggregation/runner/service-runner.js +409 -195
  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 +47 -4
  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 +48 -16
  94. package/dist/types/core/aggregation/runner/events.d.ts.map +1 -1
  95. package/dist/types/core/aggregation/runner/participant-runner.d.ts +23 -14
  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 +102 -23
  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 +264 -20
  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 +47 -19
  141. package/src/core/aggregation/runner/participant-runner.ts +113 -31
  142. package/src/core/aggregation/runner/service-runner.ts +484 -203
  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
@@ -1,13 +1,17 @@
1
1
  import type { SchnorrKeyPair } from '@did-btcr2/keypair';
2
+ import { AggregationServiceError } from '../errors.js';
2
3
  import type { BaseMessage } from '../messages/base.js';
3
4
  import {
4
5
  COHORT_OPT_IN,
6
+ FALLBACK_SIGNATURE,
5
7
  NONCE_CONTRIBUTION,
6
8
  SIGNATURE_AUTHORIZATION,
9
+ SUBMIT_NONINCLUDED,
7
10
  SUBMIT_UPDATE,
8
11
  VALIDATION_ACK,
9
12
  } from '../messages/constants.js';
10
13
  import { ServiceCohortPhase } from '../phases.js';
14
+ import type { ServiceCohortPhaseType } from '../phases.js';
11
15
  import type {
12
16
  AggregationResult,
13
17
  CohortConfig,
@@ -44,8 +48,13 @@ export interface AggregationServiceRunnerOptions {
44
48
  did: string;
45
49
  keys: SchnorrKeyPair;
46
50
 
47
- /** Cohort configuration. */
48
- config: CohortConfig;
51
+ /**
52
+ * Default cohort configuration for the {@link AggregationServiceRunner.run}
53
+ * convenience path. Optional: omit it when driving the runner with
54
+ * {@link AggregationServiceRunner.advertiseCohort}, which takes a per-cohort
55
+ * config and can be called many times on one runner.
56
+ */
57
+ config?: CohortConfig;
49
58
 
50
59
  /**
51
60
  * Decide whether to accept a participant's opt-in.
@@ -62,7 +71,7 @@ export interface AggregationServiceRunnerOptions {
62
71
 
63
72
  /**
64
73
  * Provide the Bitcoin transaction data to sign.
65
- * REQUIRED no sensible default.
74
+ * REQUIRED - no sensible default.
66
75
  */
67
76
  onProvideTxData: OnProvideTxData;
68
77
 
@@ -74,35 +83,91 @@ export interface AggregationServiceRunnerOptions {
74
83
  maxUpdateSizeBytes?: number;
75
84
 
76
85
  /**
77
- * Overall wall-clock budget for the cohort, from run() to signing-complete.
78
- * On expiry the cohort is dropped, `cohort-failed` is emitted, and run()
79
- * rejects with a timeout error. Leave undefined to disable.
86
+ * Overall wall-clock budget for each cohort, from advertise to
87
+ * signing-complete. On expiry the cohort is dropped, `cohort-failed` is
88
+ * emitted, and that cohort's completion rejects with a timeout error. Other
89
+ * cohorts on the same runner are unaffected. Leave undefined to disable.
80
90
  */
81
91
  cohortTtlMs?: number;
82
92
 
83
93
  /**
84
- * Maximum time allowed between phase transitions. Protects against stalled
85
- * cohorts (e.g. a participant vanishing mid-protocol). Reset automatically
86
- * on every observed phase change. Leave undefined to disable.
94
+ * Maximum time allowed between phase transitions for a cohort. Protects
95
+ * against stalled cohorts (e.g. a participant vanishing mid-protocol). Reset
96
+ * automatically on every observed phase change. Applied per cohort. Leave
97
+ * undefined to disable.
87
98
  */
88
99
  phaseTimeoutMs?: number;
89
100
 
90
101
  /**
91
- * Re-publish COHORT_ADVERT on this interval until keygen is finalized.
92
- * Works around relays that don't backfill historical events to late
93
- * subscribers a republish gives late joiners a window to discover the
102
+ * Re-publish COHORT_ADVERT on this interval until a cohort's keygen is
103
+ * finalized. Works around relays that don't backfill historical events to
104
+ * late subscribers - a republish gives late joiners a window to discover the
94
105
  * advert without protocol changes. The first publish is immediate;
95
- * subsequent publishes fire every `advertRepeatIntervalMs` until
96
- * keygen-complete, fail, or stop(). Defaults to
106
+ * subsequent publishes fire every `advertRepeatIntervalMs` until that
107
+ * cohort's keygen completes, fails, or is stopped. Defaults to
97
108
  * {@link DEFAULT_ADVERT_REPEAT_INTERVAL_MS} (60 s). Set to 0 to publish
98
109
  * once and never retry.
99
110
  */
100
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;
101
123
  }
102
124
 
103
125
  /** Default cadence for re-publishing COHORT_ADVERT until keygen completes: 60 seconds. */
104
126
  export const DEFAULT_ADVERT_REPEAT_INTERVAL_MS = 60_000;
105
127
 
128
+ /**
129
+ * Per-cohort runtime bookkeeping the runner keeps for each advertised cohort.
130
+ * One {@link RunContext} per cohortId lives in the runner's `#contexts` map so
131
+ * many cohorts run concurrently on a single runner, each with its own
132
+ * completion promise, finalize guard, timers, and advert-republish loop. The
133
+ * underlying {@link AggregationService} state machine is already keyed by
134
+ * cohortId; this struct is the runner-layer counterpart (see ADR 040).
135
+ */
136
+ interface RunContext {
137
+ /** The cohort this context drives. */
138
+ cohortId: string;
139
+ /** The conditions this cohort was advertised with. */
140
+ config: CohortConfig;
141
+ /** Resolve this cohort's completion with its aggregation result. */
142
+ resolve: (result: AggregationResult) => void;
143
+ /** Reject this cohort's completion. */
144
+ reject: (err: Error) => void;
145
+ /** The promise handed back from {@link AggregationServiceRunner.advertiseCohort}. */
146
+ completion: Promise<AggregationResult>;
147
+ /**
148
+ * Guard against the async race where two concurrent #handleOptIn invocations
149
+ * for THIS cohort both pass the `participants.length >= minParticipants`
150
+ * check before either mutates the cohort phase. Set synchronously before any
151
+ * `await` so subsequent handlers observe it on their next resumption.
152
+ */
153
+ finalizing: boolean;
154
+ /** Once settled (resolved or rejected), late timers/messages must not re-settle. */
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';
164
+ cohortTtlTimer?: ReturnType<typeof setTimeout>;
165
+ phaseTimer?: ReturnType<typeof setTimeout>;
166
+ lastObservedPhase?: string;
167
+ /** Stop handle for THIS cohort's repeating COHORT_ADVERT publish loop. */
168
+ stopAdvertRepeat?: () => void;
169
+ }
170
+
106
171
  /**
107
172
  * High-level facade for running an Aggregation Service over a Transport.
108
173
  *
@@ -110,6 +175,14 @@ export const DEFAULT_ADVERT_REPEAT_INTERVAL_MS = 60_000;
110
175
  * encapsulating message handler registration, outgoing message dispatch,
111
176
  * and decision callback orchestration.
112
177
  *
178
+ * A single runner is a long-lived multiplexer: it advertises and drives many
179
+ * cohorts concurrently over one transport. Each advertised cohort owns an
180
+ * independent completion promise and fails in isolation - a stalled or failed
181
+ * cohort never settles its siblings (see ADR 040). Use
182
+ * {@link AggregationServiceRunner.advertiseCohort} for the multi-cohort path;
183
+ * {@link AggregationServiceRunner.run} is a thin single-cohort convenience over
184
+ * it.
185
+ *
113
186
  * @example
114
187
  * ```typescript
115
188
  * const transport = new NostrTransport({ relays: [RELAY] });
@@ -119,16 +192,21 @@ export const DEFAULT_ADVERT_REPEAT_INTERVAL_MS = 60_000;
119
192
  * transport,
120
193
  * did: serviceDid,
121
194
  * keys: serviceKeys,
122
- * config: { minParticipants: 2, network: 'mutinynet', beaconType: 'CASBeacon' },
123
- * onProvideTxData: async ({ beaconAddress, signalBytes }) => {
195
+ * onProvideTxData: async ({ cohortId, beaconAddress, signalBytes }) => {
124
196
  * return await buildBeaconTransaction(beaconAddress, signalBytes, bitcoin);
125
197
  * },
126
198
  * });
127
199
  *
128
- * runner.on('keygen-complete', ({ beaconAddress }) => console.log(beaconAddress));
129
- * runner.on('signing-complete', ({ signature }) => console.log('done'));
200
+ * runner.on('keygen-complete', ({ cohortId, beaconAddress }) => console.log(beaconAddress));
201
+ * runner.on('signing-complete', ({ cohortId, signature }) => console.log('done', cohortId));
202
+ *
203
+ * // Multi-cohort: advertise several cohorts; each completion resolves independently.
204
+ * const a = runner.advertiseCohort({ minParticipants: 2, network: 'mutinynet', beaconType: 'CASBeacon' });
205
+ * const b = runner.advertiseCohort({ minParticipants: 3, network: 'mutinynet', beaconType: 'SMTBeacon' });
206
+ * const [ra, rb] = await Promise.all([a.completion, b.completion]);
130
207
  *
131
- * const result = await runner.run();
208
+ * // Single-cohort convenience (requires `config` in the options):
209
+ * // const result = await runner.run();
132
210
  * ```
133
211
  *
134
212
  * For full manual control, drop down to the underlying state machine via
@@ -143,37 +221,32 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
143
221
 
144
222
  readonly #transport: Transport;
145
223
  readonly #did: string;
146
- readonly #config: CohortConfig;
224
+ readonly #defaultConfig?: CohortConfig;
147
225
  readonly #onOptInReceived: OnOptInReceived;
148
226
  readonly #onReadyToFinalize: OnReadyToFinalize;
149
227
  readonly #onProvideTxData: OnProvideTxData;
150
228
  readonly #cohortTtlMs?: number;
151
229
  readonly #phaseTimeoutMs?: number;
152
230
  readonly #advertRepeatIntervalMs: number;
231
+ readonly #autoFallbackOnStall: boolean;
153
232
 
154
- #cohortId?: string;
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
+ ];
239
+
240
+ /** Per-cohort run state, keyed by cohortId. */
241
+ readonly #contexts: Map<string, RunContext> = new Map();
155
242
  #handlersRegistered = false;
156
243
  #stopped = false;
157
- /**
158
- * Guard against the async race where two concurrent #handleOptIn invocations
159
- * both pass the `participants.length >= minParticipants` check before either
160
- * mutates the cohort phase. Set synchronously before any `await` so subsequent
161
- * handlers observe it on their next resumption.
162
- */
163
- #finalizing = false;
164
- #resolveRun?: (result: AggregationResult) => void;
165
- #rejectRun?: (err: Error) => void;
166
- #cohortTtlTimer?: ReturnType<typeof setTimeout>;
167
- #phaseTimer?: ReturnType<typeof setTimeout>;
168
- #lastObservedPhase?: string;
169
- /** Stop handle for the repeating COHORT_ADVERT publish loop. */
170
- #stopAdvertRepeat?: () => void;
171
244
 
172
245
  constructor(options: AggregationServiceRunnerOptions) {
173
246
  super();
174
247
  this.#transport = options.transport;
175
248
  this.#did = options.did;
176
- this.#config = options.config;
249
+ this.#defaultConfig = options.config;
177
250
  this.#onOptInReceived = options.onOptInReceived ?? (async () => ({ accepted: true }));
178
251
  this.#onReadyToFinalize = options.onReadyToFinalize ?? (async ({ acceptedCount, minRequired }) => ({
179
252
  finalize : acceptedCount >= minRequired,
@@ -182,6 +255,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
182
255
  this.#cohortTtlMs = options.cohortTtlMs;
183
256
  this.#phaseTimeoutMs = options.phaseTimeoutMs;
184
257
  this.#advertRepeatIntervalMs = options.advertRepeatIntervalMs ?? DEFAULT_ADVERT_REPEAT_INTERVAL_MS;
258
+ this.#autoFallbackOnStall = options.autoFallbackOnStall ?? false;
185
259
 
186
260
  this.session = new AggregationService({
187
261
  // The coordinator never signs, so the state machine receives only the
@@ -193,144 +267,328 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
193
267
  });
194
268
  }
195
269
 
270
+ /** Resolve the {@link RunContext} an inbound message belongs to, by cohortId. */
271
+ #contextFor(msg: BaseMessage): RunContext | undefined {
272
+ const cohortId = msg.body?.cohortId;
273
+ if(!cohortId) return undefined;
274
+ return this.#contexts.get(cohortId);
275
+ }
276
+
277
+ /**
278
+ * Drain any silent rejections the state machine recorded for a cohort during
279
+ * the most recent receive() and surface them as `message-rejected` events.
280
+ */
281
+ #drainRejections(ctx: RunContext): void {
282
+ for(const r of this.session.drainRejections(ctx.cohortId)) {
283
+ this.emit('message-rejected', { cohortId: ctx.cohortId, ...r });
284
+ }
285
+ }
286
+
196
287
  /**
197
- * Drain any silent rejections the state machine recorded during the most
198
- * recent receive() and surface them as `message-rejected` events. Safe to
199
- * call even before a cohortId is assigned.
288
+ * Advertise a new cohort and begin driving it to completion. Callable many
289
+ * times on one runner; each cohort runs concurrently and independently.
290
+ *
291
+ * @param config Per-cohort conditions + network (see {@link CohortConfig}).
292
+ * @returns The new cohort's id and a `completion` promise that resolves with
293
+ * that cohort's {@link AggregationResult} (or rejects if it fails/stalls).
294
+ * @throws If the runner has been stopped, or the config is invalid
295
+ * (fail-fast via `createCohort`).
200
296
  */
201
- #drainRejections(): void {
202
- if(!this.#cohortId) return;
203
- for(const r of this.session.drainRejections(this.#cohortId)) {
204
- this.emit('message-rejected', { cohortId: this.#cohortId, ...r });
297
+ advertiseCohort(config: CohortConfig): { cohortId: string; completion: Promise<AggregationResult> } {
298
+ if(this.#stopped) {
299
+ throw new AggregationServiceError('Cannot advertise on a stopped runner.', 'RUNNER_STOPPED', {});
300
+ }
301
+ this.#registerHandlers();
302
+ // createCohort validates the conditions and throws on a bad config before
303
+ // any context exists - fail-fast, nothing to clean up.
304
+ const cohortId = this.session.createCohort(config);
305
+
306
+ let resolve!: (result: AggregationResult) => void;
307
+ let reject!: (err: Error) => void;
308
+ const completion = new Promise<AggregationResult>((res, rej) => { resolve = res; reject = rej; });
309
+ const ctx: RunContext = {
310
+ cohortId,
311
+ config,
312
+ resolve,
313
+ reject,
314
+ completion,
315
+ finalizing : false,
316
+ settled : false,
317
+ };
318
+ this.#contexts.set(cohortId, ctx);
319
+
320
+ try {
321
+ this.#startTimers(ctx);
322
+ // Emit cohort-advertised BEFORE the send so the event fires before any downstream cascade.
323
+ const advertMsgs = this.session.advertise(cohortId);
324
+ this.#onPhaseMaybeChanged(ctx);
325
+ this.emit('cohort-advertised', { cohortId });
326
+ // Publish the advert. If advertRepeatIntervalMs > 0 we republish on that
327
+ // cadence until this cohort's keygen-complete / fail / stop - works around
328
+ // relays that don't backfill historical events to late subscribers.
329
+ // Otherwise fall back to a single send.
330
+ if(this.#advertRepeatIntervalMs > 0) {
331
+ this.#startAdvertRepeat(ctx, advertMsgs);
332
+ } else {
333
+ this.#sendAll(advertMsgs).catch(err => this.#failCohort(ctx, err as Error));
334
+ }
335
+ } catch(err) {
336
+ this.#failCohort(ctx, err as Error);
205
337
  }
338
+
339
+ return { cohortId, completion };
206
340
  }
207
341
 
208
342
  /**
209
- * Run the protocol to completion. Resolves with the final aggregation result
210
- * (signature + signed transaction) once signing is complete.
343
+ * Run a single cohort to completion using the `config` supplied in the
344
+ * runner options. Thin convenience over {@link advertiseCohort} for the
345
+ * single-cohort case (and the path {@link AggregationRunner.solo} rides).
211
346
  *
212
347
  * @returns {Promise<AggregationResult>} The final result with signature and signed tx.
213
348
  */
214
349
  run(): Promise<AggregationResult> {
215
- return new Promise((resolve, reject) => {
216
- this.#resolveRun = resolve;
217
- this.#rejectRun = reject;
218
-
219
- try {
220
- this.#registerHandlers();
221
- this.#cohortId = this.session.createCohort(this.#config);
222
- this.#startTimers();
223
- // Emit cohort-advertised BEFORE the send so the event fires before any downstream cascade
224
- const advertMsgs = this.session.advertise(this.#cohortId);
225
- this.#onPhaseMaybeChanged();
226
- this.emit('cohort-advertised', { cohortId: this.#cohortId });
227
- // Publish the advert. If advertRepeatIntervalMs > 0 we republish on
228
- // that cadence until keygen-complete / fail / stop — works around
229
- // relays that don't backfill historical events to late subscribers.
230
- // Otherwise fall back to a single send.
231
- if(this.#advertRepeatIntervalMs > 0) {
232
- this.#startAdvertRepeat(advertMsgs);
233
- } else {
234
- this.#sendAll(advertMsgs).catch(err => this.#fail(err));
235
- }
236
- } catch(err) {
237
- this.#fail(err as Error);
350
+ if(!this.#defaultConfig) {
351
+ return Promise.reject(new AggregationServiceError(
352
+ 'run() requires `config` in the runner options; use advertiseCohort(config) to drive cohorts explicitly.',
353
+ 'MISSING_COHORT_CONFIG', {}
354
+ ));
355
+ }
356
+ try {
357
+ return this.advertiseCohort(this.#defaultConfig).completion;
358
+ } catch(err) {
359
+ return Promise.reject(err as Error);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Wait for every currently-outstanding cohort to settle and return the
365
+ * successful results. Dynamic drain: cohorts advertised while this is pending
366
+ * are included, and it resolves only once no cohorts remain. Failed cohorts
367
+ * are surfaced via `error` / `cohort-failed` events and their rejected
368
+ * `completion` promises; they are omitted from the returned array (this
369
+ * method does not throw). Bound long-running cohorts with `cohortTtlMs` /
370
+ * `phaseTimeoutMs` or this may never resolve.
371
+ *
372
+ * @returns {Promise<AggregationResult[]>} Results of the cohorts that completed.
373
+ */
374
+ async runAll(): Promise<AggregationResult[]> {
375
+ const collected = new Map<string, AggregationResult>();
376
+ // Capture every completion, including a cohort that is advertised and
377
+ // finishes entirely within one drain round (so it never appears in a
378
+ // snapshot below).
379
+ const onComplete = (result: AggregationResult): void => { collected.set(result.cohortId, result); };
380
+ this.on('signing-complete', onComplete);
381
+ try {
382
+ // Block until the live set empties; re-snapshot each round to pick up
383
+ // cohorts advertised mid-drain.
384
+ while(this.#contexts.size > 0) {
385
+ await Promise.allSettled([ ...this.#contexts.values() ].map(c => c.completion));
238
386
  }
239
- });
387
+ } finally {
388
+ this.off('signing-complete', onComplete);
389
+ }
390
+ return [ ...collected.values() ];
240
391
  }
241
392
 
242
393
  /**
243
- * Begin publishing the cohort advert immediately and on a repeating interval
244
- * until {@link #stopAdvertRepeating} is called. Each advert is broadcast
245
- * (no recipient) via the transport's `publishRepeating` primitive.
394
+ * Begin publishing a cohort's advert immediately and on a repeating interval
395
+ * until the cohort's advert loop is stopped. Each advert is broadcast (no
396
+ * recipient) via the transport's `publishRepeating` primitive.
246
397
  */
247
- #startAdvertRepeat(advertMsgs: BaseMessage[]): void {
398
+ #startAdvertRepeat(ctx: RunContext, advertMsgs: BaseMessage[]): void {
248
399
  // COHORT_ADVERT is always a single broadcast message in the current
249
400
  // protocol, but iterate for generality.
250
401
  const stops: Array<() => void> = [];
251
402
  for(const msg of advertMsgs) {
252
403
  stops.push(this.#transport.publishRepeating(msg, this.#did, this.#advertRepeatIntervalMs));
253
404
  }
254
- this.#stopAdvertRepeat = () => {
405
+ ctx.stopAdvertRepeat = () => {
255
406
  for(const stop of stops) {
256
407
  try { stop(); } catch { /* ignore */ }
257
408
  }
258
409
  };
259
410
  }
260
411
 
261
- /** Stop the advert republish loop. Idempotent. */
262
- #stopAdvertRepeating(): void {
263
- if(!this.#stopAdvertRepeat) return;
264
- const stop = this.#stopAdvertRepeat;
265
- this.#stopAdvertRepeat = undefined;
412
+ /** Stop a cohort's advert republish loop. Idempotent. */
413
+ #stopAdvertRepeating(ctx: RunContext): void {
414
+ if(!ctx.stopAdvertRepeat) return;
415
+ const stop = ctx.stopAdvertRepeat;
416
+ ctx.stopAdvertRepeat = undefined;
266
417
  stop();
267
418
  }
268
419
 
269
- /** Schedule cohort TTL + phase timeout at the start of a run. */
270
- #startTimers(): void {
420
+ /** Schedule a cohort's TTL + phase timeout when it is advertised. */
421
+ #startTimers(ctx: RunContext): void {
271
422
  if(this.#cohortTtlMs !== undefined) {
272
- this.#cohortTtlTimer = setTimeout(() => {
273
- const reason = `Cohort ${this.#cohortId ?? ''} exceeded TTL of ${this.#cohortTtlMs}ms`;
274
- this.emit('cohort-failed', { cohortId: this.#cohortId ?? '', reason });
275
- this.#fail(new Error(reason));
423
+ ctx.cohortTtlTimer = setTimeout(() => {
424
+ const reason = `Cohort ${ctx.cohortId} exceeded TTL of ${this.#cohortTtlMs}ms`;
425
+ this.emit('cohort-failed', { cohortId: ctx.cohortId, reason });
426
+ this.#failCohort(ctx, new Error(reason));
276
427
  }, this.#cohortTtlMs);
277
428
  }
278
- this.#resetPhaseTimer();
429
+ this.#resetPhaseTimer(ctx);
279
430
  }
280
431
 
281
- /** Reset the per-phase stall timer. Called when a phase transition is observed. */
282
- #resetPhaseTimer(): void {
283
- if(this.#phaseTimer) clearTimeout(this.#phaseTimer);
284
- this.#phaseTimer = undefined;
432
+ /** Reset a cohort's per-phase stall timer. Called when a phase transition is observed. */
433
+ #resetPhaseTimer(ctx: RunContext): void {
434
+ if(ctx.phaseTimer) clearTimeout(ctx.phaseTimer);
435
+ ctx.phaseTimer = undefined;
285
436
  if(this.#phaseTimeoutMs === undefined) return;
286
- this.#phaseTimer = setTimeout(() => {
287
- const reason = `Cohort ${this.#cohortId ?? ''} stalled in phase ${this.#lastObservedPhase ?? '?'} for ${this.#phaseTimeoutMs}ms`;
288
- this.emit('cohort-failed', { cohortId: this.#cohortId ?? '', reason });
289
- this.#fail(new Error(reason));
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
+ }
447
+ const reason = `Cohort ${ctx.cohortId} stalled in phase ${ctx.lastObservedPhase ?? '?'} for ${this.#phaseTimeoutMs}ms`;
448
+ this.emit('cohort-failed', { cohortId: ctx.cohortId, reason });
449
+ this.#failCohort(ctx, new Error(reason));
290
450
  }, this.#phaseTimeoutMs);
291
451
  }
292
452
 
293
- /** Detect a phase change since the last observation and reset the phase timer. */
294
- #onPhaseMaybeChanged(): void {
295
- if(!this.#cohortId) return;
296
- const phase = this.session.getCohortPhase(this.#cohortId);
297
- if(phase !== this.#lastObservedPhase) {
298
- this.#lastObservedPhase = phase;
299
- this.#resetPhaseTimer();
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
+
485
+ /** Detect a phase change for a cohort since the last observation and reset its phase timer. */
486
+ #onPhaseMaybeChanged(ctx: RunContext): void {
487
+ const phase = this.session.getCohortPhase(ctx.cohortId);
488
+ if(phase !== ctx.lastObservedPhase) {
489
+ ctx.lastObservedPhase = phase;
490
+ this.#resetPhaseTimer(ctx);
300
491
  }
301
492
  }
302
493
 
303
- /** Clear both timers. Called on successful completion, stop(), and #fail. */
304
- #clearTimers(): void {
305
- if(this.#cohortTtlTimer) clearTimeout(this.#cohortTtlTimer);
306
- if(this.#phaseTimer) clearTimeout(this.#phaseTimer);
307
- this.#cohortTtlTimer = undefined;
308
- this.#phaseTimer = undefined;
494
+ /** Clear a cohort's timers. Called on completion, stop, and failure. */
495
+ #clearTimers(ctx: RunContext): void {
496
+ if(ctx.cohortTtlTimer) clearTimeout(ctx.cohortTtlTimer);
497
+ if(ctx.phaseTimer) clearTimeout(ctx.phaseTimer);
498
+ ctx.cohortTtlTimer = undefined;
499
+ ctx.phaseTimer = undefined;
309
500
  }
310
501
 
311
502
  /**
312
- * Stop the runner early. Marks the runner stopped and detaches transport
313
- * handlers so a restart or a new runner doesn't inherit stale dispatch.
503
+ * Reclaim one cohort's runner-layer bookkeeping: stop its advert loop, clear
504
+ * its timers, and drop its {@link RunContext}. Does NOT touch sibling cohorts
505
+ * and does NOT detach the shared transport handlers. Leaves the cohort in the
506
+ * state machine; whether that cohort's `session` state is also removed is the
507
+ * caller's choice (see {@link #completeCohort} vs {@link #failCohort}).
508
+ */
509
+ #disposeCohort(ctx: RunContext): void {
510
+ this.#stopAdvertRepeating(ctx);
511
+ this.#clearTimers(ctx);
512
+ this.#contexts.delete(ctx.cohortId);
513
+ }
514
+
515
+ /**
516
+ * Settle one cohort successfully. Reclaims the runner context but leaves the
517
+ * completed cohort in `session` so callers can read its beaconAddress / cohort
518
+ * via `session.getCohort(result.cohortId)`; reclaim it with
519
+ * `session.removeCohort(cohortId)` when done. Idempotent via `ctx.settled`.
520
+ */
521
+ #completeCohort(ctx: RunContext, result: AggregationResult): void {
522
+ if(ctx.settled) return;
523
+ ctx.settled = true;
524
+ this.#disposeCohort(ctx);
525
+ this.emit('signing-complete', result);
526
+ ctx.resolve(result);
527
+ }
528
+
529
+ /**
530
+ * Fail one cohort. Reclaims its runner context, drops its now-dead state from
531
+ * the state machine, and rejects only its completion; siblings keep running
532
+ * and the shared transport handlers stay registered. Idempotent via
533
+ * `ctx.settled`.
534
+ */
535
+ #failCohort(ctx: RunContext, err: Error): void {
536
+ if(ctx.settled) return;
537
+ ctx.settled = true;
538
+ this.#disposeCohort(ctx);
539
+ this.session.removeCohort(ctx.cohortId);
540
+ this.emit('error', err);
541
+ ctx.reject(err);
542
+ }
543
+
544
+ /**
545
+ * Stop a single cohort early without affecting the rest of the runner. Drops
546
+ * the cohort's state machine state; its `completion` promise rejects with a
547
+ * stopped error.
548
+ */
549
+ stopCohort(cohortId: string): void {
550
+ const ctx = this.#contexts.get(cohortId);
551
+ if(!ctx || ctx.settled) return;
552
+ ctx.settled = true;
553
+ this.#disposeCohort(ctx);
554
+ this.session.removeCohort(cohortId);
555
+ ctx.reject(new AggregationServiceError(`Cohort ${cohortId} stopped.`, 'COHORT_STOPPED', { cohortId }));
556
+ }
557
+
558
+ /**
559
+ * Stop the whole runner. Fails every outstanding cohort, then detaches the
560
+ * shared transport handlers so a restart or a new runner doesn't inherit
561
+ * stale dispatch. Safe to call repeatedly.
314
562
  */
315
563
  stop(): void {
316
564
  this.#stopped = true;
317
- this.#stopAdvertRepeating();
318
- this.#clearTimers();
565
+ for(const ctx of [ ...this.#contexts.values() ]) {
566
+ if(ctx.settled) continue;
567
+ ctx.settled = true;
568
+ this.#disposeCohort(ctx);
569
+ this.session.removeCohort(ctx.cohortId);
570
+ ctx.reject(new AggregationServiceError('Service runner stopped.', 'RUNNER_STOPPED', { cohortId: ctx.cohortId }));
571
+ }
572
+ this.#contexts.clear();
319
573
  this.#unregisterHandlers();
320
- if(this.#cohortId) this.session.removeCohort(this.#cohortId);
321
574
  }
322
575
 
323
576
  /** Message types this runner listens for on the transport. */
324
577
  static readonly #HANDLED_MESSAGE_TYPES: readonly string[] = [
325
578
  COHORT_OPT_IN,
326
579
  SUBMIT_UPDATE,
580
+ SUBMIT_NONINCLUDED,
327
581
  VALIDATION_ACK,
328
582
  NONCE_CONTRIBUTION,
329
583
  SIGNATURE_AUTHORIZATION,
584
+ FALLBACK_SIGNATURE,
330
585
  ];
331
586
 
332
587
  /**
333
- * Internal: handler registration with the transport. Idempotent.
588
+ * Internal: handler registration with the transport. Idempotent. Handlers
589
+ * are DID-scoped and cohort-agnostic - one registration serves every cohort
590
+ * this runner drives; demux to the right {@link RunContext} happens in each
591
+ * handler via the inbound message's cohortId.
334
592
  */
335
593
  #registerHandlers(): void {
336
594
  if(this.#handlersRegistered) return;
@@ -338,9 +596,14 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
338
596
 
339
597
  this.#transport.registerMessageHandler(this.#did, COHORT_OPT_IN, this.#handleOptIn.bind(this));
340
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));
341
603
  this.#transport.registerMessageHandler(this.#did, VALIDATION_ACK, this.#handleValidationAck.bind(this));
342
604
  this.#transport.registerMessageHandler(this.#did, NONCE_CONTRIBUTION, this.#handleNonceContribution.bind(this));
343
605
  this.#transport.registerMessageHandler(this.#did, SIGNATURE_AUTHORIZATION, this.#handleSignatureAuthorization.bind(this));
606
+ this.#transport.registerMessageHandler(this.#did, FALLBACK_SIGNATURE, this.#handleFallbackSignature.bind(this));
344
607
  }
345
608
 
346
609
  /** Internal: detach from the transport. Safe to call repeatedly. */
@@ -354,25 +617,28 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
354
617
 
355
618
  /**
356
619
  * Internal: message handlers for each protocol step. Each handler:
357
- * 1) feeds the message into the state machine via session.receive()
358
- * 2) emits a high-level event for external observers
359
- * 3) checks if the new state triggers any automatic next steps, and if so:
620
+ * 1) resolves the cohort the message belongs to (by cohortId); ignores it if unknown
621
+ * 2) feeds the message into the state machine via session.receive()
622
+ * 3) emits a high-level event (carrying cohortId) for external observers
623
+ * 4) checks if the new state triggers any automatic next steps, and if so:
360
624
  * a) calls the appropriate decision callback(s)
361
625
  * b) sends any resulting messages from the state machine
626
+ * Errors fail only the owning cohort. A stopped runner ignores messages.
362
627
  * @param {BaseMessage} msg - The incoming message to handle.
363
628
  * @returns {Promise<void>} Resolves when handling is complete.
364
- * @throws {Error} If any step of handling fails, the error is emitted and the run promise is rejected.
365
- * Note: if the runner has been stopped, handlers will ignore incoming messages.
366
629
  */
367
630
  async #handleOptIn(msg: BaseMessage): Promise<void> {
368
631
  if(this.#stopped) return;
632
+ const ctx = this.#contextFor(msg);
633
+ if(!ctx) return;
369
634
  try {
370
635
  this.session.receive(msg);
371
- this.#drainRejections();
372
- this.#onPhaseMaybeChanged();
636
+ this.#drainRejections(ctx);
637
+ this.#onPhaseMaybeChanged(ctx);
373
638
 
374
- const optIn = this.session.pendingOptIns(this.#cohortId!).get(msg.from);
639
+ const optIn = this.session.pendingOptIns(ctx.cohortId).get(msg.from);
375
640
  if(!optIn) return;
641
+ // PendingOptIn already carries cohortId, so this event is cohort-identified.
376
642
  this.emit('opt-in-received', optIn);
377
643
 
378
644
  // Register peer key for encrypted messaging
@@ -384,49 +650,50 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
384
650
  if(!decision.accepted) return;
385
651
 
386
652
  // Don't accept past the advertised maxParticipants: acceptParticipant
387
- // would throw COHORT_FULL and fail the run. Silently ignore the surplus
653
+ // would throw COHORT_FULL and fail the cohort. Silently ignore the surplus
388
654
  // opt-in (the cohort is full).
389
- const maxParticipants = this.#config.maxParticipants;
390
- const cohortNow = this.session.getCohort(this.#cohortId!);
655
+ const maxParticipants = ctx.config.maxParticipants;
656
+ const cohortNow = this.session.getCohort(ctx.cohortId);
391
657
  if(maxParticipants !== undefined && cohortNow && cohortNow.participants.length >= maxParticipants) {
392
658
  return;
393
659
  }
394
660
 
395
- await this.#sendAll(this.session.acceptParticipant(this.#cohortId!, msg.from));
396
- this.emit('participant-accepted', { participantDid: msg.from });
397
-
398
- // Check if it's time to finalize. The `#finalizing` flag is set synchronously
399
- // before the first await so concurrent opt-in handlers observe it and skip —
400
- // otherwise two handlers could both pass the minParticipants check and both
401
- // call finalizeKeygen, the second of which would throw (phase mismatch).
402
- const cohort = this.session.getCohort(this.#cohortId!)!;
403
- if(cohort.participants.length >= this.#config.minParticipants && !this.#finalizing) {
404
- this.#finalizing = true;
661
+ await this.#sendAll(this.session.acceptParticipant(ctx.cohortId, msg.from));
662
+ this.emit('participant-accepted', { cohortId: ctx.cohortId, participantDid: msg.from });
663
+
664
+ // Check if it's time to finalize. The per-cohort `finalizing` flag is set
665
+ // synchronously before the first await so concurrent opt-in handlers for
666
+ // the same cohort observe it and skip - otherwise two handlers could both
667
+ // pass the minParticipants check and both call finalizeKeygen, the second
668
+ // of which would throw (phase mismatch).
669
+ const cohort = this.session.getCohort(ctx.cohortId)!;
670
+ if(cohort.participants.length >= ctx.config.minParticipants && !ctx.finalizing) {
671
+ ctx.finalizing = true;
405
672
  const finalizeDecision = await this.#onReadyToFinalize({
406
673
  acceptedCount : cohort.participants.length,
407
- minRequired : this.#config.minParticipants,
674
+ minRequired : ctx.config.minParticipants,
408
675
  });
409
676
  if(!finalizeDecision.finalize) {
410
- // Operator declined reset the flag so a later opt-in can retry.
411
- this.#finalizing = false;
677
+ // Operator declined - reset the flag so a later opt-in can retry.
678
+ ctx.finalizing = false;
412
679
  return;
413
680
  }
414
681
  // finalizeKeygen() computes the beacon address synchronously
415
682
  // emit BEFORE awaiting sendAll. Otherwise the downstream cascade
416
683
  // (which can run all the way to signing-complete) would resolve the
417
- // run() promise before this event fires.
418
- const readyMsgs = this.session.finalizeKeygen(this.#cohortId!);
419
- // Keygen done stop re-advertising the cohort. New participants
684
+ // cohort's completion promise before this event fires.
685
+ const readyMsgs = this.session.finalizeKeygen(ctx.cohortId);
686
+ // Keygen done - stop re-advertising the cohort. New participants
420
687
  // arriving after this point would be rejected anyway.
421
- this.#stopAdvertRepeating();
688
+ this.#stopAdvertRepeating(ctx);
422
689
  this.emit('keygen-complete', {
423
- cohortId : this.#cohortId!,
690
+ cohortId : ctx.cohortId,
424
691
  beaconAddress : cohort.beaconAddress,
425
692
  });
426
693
  await this.#sendAll(readyMsgs);
427
694
  }
428
695
  } catch(err) {
429
- this.#fail(err as Error);
696
+ this.#failCohort(ctx, err as Error);
430
697
  }
431
698
  }
432
699
 
@@ -435,25 +702,25 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
435
702
  * and distributes the data for validation.
436
703
  * @param {BaseMessage} msg - The incoming message to handle.
437
704
  * @returns {Promise<void>} Resolves when handling is complete.
438
- * @throws {Error} If any step of handling fails, the error is emitted and the run promise is rejected.
439
- * Note: if the runner has been stopped, handlers will ignore incoming messages.
440
705
  */
441
706
  async #handleSubmitUpdate(msg: BaseMessage): Promise<void> {
442
707
  if(this.#stopped) return;
708
+ const ctx = this.#contextFor(msg);
709
+ if(!ctx) return;
443
710
  try {
444
711
  this.session.receive(msg);
445
- this.#drainRejections();
446
- this.#onPhaseMaybeChanged();
447
- this.emit('update-received', { participantDid: msg.from });
712
+ this.#drainRejections(ctx);
713
+ this.#onPhaseMaybeChanged(ctx);
714
+ this.emit('update-received', { cohortId: ctx.cohortId, participantDid: msg.from });
448
715
 
449
716
  // When all updates collected, build and distribute
450
- if(this.session.getCohortPhase(this.#cohortId!) === ServiceCohortPhase.UpdatesCollected) {
451
- const distributeMsgs = this.session.buildAndDistribute(this.#cohortId!);
452
- this.emit('data-distributed', { cohortId: this.#cohortId! });
717
+ if(this.session.getCohortPhase(ctx.cohortId) === ServiceCohortPhase.UpdatesCollected) {
718
+ const distributeMsgs = this.session.buildAndDistribute(ctx.cohortId);
719
+ this.emit('data-distributed', { cohortId: ctx.cohortId });
453
720
  await this.#sendAll(distributeMsgs);
454
721
  }
455
722
  } catch(err) {
456
- this.#fail(err as Error);
723
+ this.#failCohort(ctx, err as Error);
457
724
  }
458
725
  }
459
726
 
@@ -462,98 +729,127 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
462
729
  * automatically requests tx data and starts signing.
463
730
  * @param {BaseMessage} msg - The incoming message to handle.
464
731
  * @returns {Promise<void>} Resolves when handling is complete.
465
- * @throws {Error} If any step of handling fails, the error is emitted and the run promise is rejected.
466
- * Note: if the runner has been stopped, handlers will ignore incoming messages.
467
732
  */
468
733
  async #handleValidationAck(msg: BaseMessage): Promise<void> {
469
734
  if(this.#stopped) return;
735
+ const ctx = this.#contextFor(msg);
736
+ if(!ctx) return;
470
737
  try {
471
738
  this.session.receive(msg);
472
- this.#drainRejections();
473
- this.#onPhaseMaybeChanged();
739
+ this.#drainRejections(ctx);
740
+ this.#onPhaseMaybeChanged(ctx);
474
741
  const approved = !!msg.body?.approved;
475
- this.emit('validation-received', { participantDid: msg.from, approved });
742
+ this.emit('validation-received', { cohortId: ctx.cohortId, participantDid: msg.from, approved });
476
743
 
477
- const phase = this.session.getCohortPhase(this.#cohortId!);
744
+ const phase = this.session.getCohortPhase(ctx.cohortId);
478
745
 
479
746
  // A participant rejection flips the cohort to Failed. Emit a structured
480
747
  // event so the runner/caller sees the failure instead of the cohort
481
748
  // silently stalling.
482
749
  if(phase === ServiceCohortPhase.Failed) {
483
750
  const reason = `Validation rejected by participant ${msg.from}`;
484
- this.emit('cohort-failed', { cohortId: this.#cohortId!, reason });
485
- this.#fail(new Error(reason));
751
+ this.emit('cohort-failed', { cohortId: ctx.cohortId, reason });
752
+ this.#failCohort(ctx, new Error(reason));
486
753
  return;
487
754
  }
488
755
 
489
756
  // When all validations received, request tx data and start signing
490
757
  if(phase === ServiceCohortPhase.Validated) {
491
- const cohort = this.session.getCohort(this.#cohortId!)!;
758
+ const cohort = this.session.getCohort(ctx.cohortId)!;
492
759
  const txData = await this.#onProvideTxData({
493
- cohortId : this.#cohortId!,
760
+ cohortId : ctx.cohortId,
494
761
  beaconAddress : cohort.beaconAddress,
495
762
  signalBytes : cohort.signalBytes!,
496
763
  });
497
- const authMsgs = this.session.startSigning(this.#cohortId!, txData);
498
- const sessionId = this.session.getSigningSessionId(this.#cohortId!) ?? '';
499
- this.emit('signing-started', { sessionId });
764
+ const authMsgs = this.session.startSigning(ctx.cohortId, txData);
765
+ const sessionId = this.session.getSigningSessionId(ctx.cohortId) ?? '';
766
+ this.emit('signing-started', { cohortId: ctx.cohortId, sessionId });
500
767
  await this.#sendAll(authMsgs);
501
768
  }
502
769
  } catch(err) {
503
- this.#fail(err as Error);
770
+ this.#failCohort(ctx, err as Error);
504
771
  }
505
772
  }
506
773
 
507
774
  /**
508
- * Handler for receiving nonce contributions and signature authorizations. When all nonces or
509
- * signatures are received,
775
+ * Handler for receiving nonce contributions. When all nonces are received, sends the aggregated
776
+ * nonce back to the cohort.
510
777
  * @param {BaseMessage} msg - The incoming message to handle.
511
778
  * @returns {Promise<void>} Resolves when handling is complete.
512
- * @throws {Error} If any step of handling fails, the error is emitted and the run promise is rejected.
513
- * Note: if the runner has been stopped, handlers will ignore incoming messages.
514
779
  */
515
780
  async #handleNonceContribution(msg: BaseMessage): Promise<void> {
516
781
  if(this.#stopped) return;
782
+ const ctx = this.#contextFor(msg);
783
+ if(!ctx) return;
517
784
  try {
518
785
  this.session.receive(msg);
519
- this.#drainRejections();
520
- this.#onPhaseMaybeChanged();
521
- this.emit('nonce-received', { participantDid: msg.from });
786
+ this.#drainRejections(ctx);
787
+ this.#onPhaseMaybeChanged(ctx);
788
+ this.emit('nonce-received', { cohortId: ctx.cohortId, participantDid: msg.from });
522
789
 
523
790
  // When all nonces collected, send aggregated nonce
524
- if(this.session.getCohortPhase(this.#cohortId!) === ServiceCohortPhase.NoncesCollected) {
525
- await this.#sendAll(this.session.sendAggregatedNonce(this.#cohortId!));
791
+ if(this.session.getCohortPhase(ctx.cohortId) === ServiceCohortPhase.NoncesCollected) {
792
+ await this.#sendAll(this.session.sendAggregatedNonce(ctx.cohortId));
526
793
  }
527
794
  } catch(err) {
528
- this.#fail(err as Error);
795
+ this.#failCohort(ctx, err as Error);
529
796
  }
530
797
  }
531
798
 
532
799
  /**
533
800
  * Handler for receiving signature authorizations. When all partial signatures are received, the
534
- * session automatically completes and the final result is emitted and the run() promise is resolved.
801
+ * session automatically completes; the final result is emitted and the cohort's completion
802
+ * promise resolves.
535
803
  * @param {BaseMessage} msg - The incoming message to handle.
536
804
  * @returns {Promise<void>} Resolves when handling is complete.
537
- * @throws {Error} If any step of handling fails, the error is emitted and the run promise is rejected.
538
- * Note: if the runner has been stopped, handlers will ignore incoming messages.
539
805
  */
540
806
  async #handleSignatureAuthorization(msg: BaseMessage): Promise<void> {
541
807
  if(this.#stopped) return;
808
+ const ctx = this.#contextFor(msg);
809
+ if(!ctx) return;
810
+ try {
811
+ this.session.receive(msg);
812
+ this.#drainRejections(ctx);
813
+ this.#onPhaseMaybeChanged(ctx);
814
+
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;
542
842
  try {
543
843
  this.session.receive(msg);
544
- this.#drainRejections();
545
- this.#onPhaseMaybeChanged();
844
+ this.#drainRejections(ctx);
845
+ this.#onPhaseMaybeChanged(ctx);
546
846
 
547
- // The state machine auto-completes when all partial sigs received
548
- const result = this.session.getResult(this.#cohortId!);
847
+ const result = this.session.getResult(ctx.cohortId);
549
848
  if(result) {
550
- this.#clearTimers();
551
- this.#unregisterHandlers();
552
- this.emit('signing-complete', result);
553
- this.#resolveRun?.(result);
849
+ this.#completeCohort(ctx, result);
554
850
  }
555
851
  } catch(err) {
556
- this.#fail(err as Error);
852
+ this.#failCohort(ctx, err as Error);
557
853
  }
558
854
  }
559
855
 
@@ -561,25 +857,10 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
561
857
  * Internal: helper to send all messages sequentially. Catches and propagates errors.
562
858
  * @param {BaseMessage[]} msgs - The messages to send.
563
859
  * @returns {Promise<void>} Resolves when all messages have been sent.
564
- * @throws {Error} If sending any message fails, the error is emitted and the run promise is
565
- * rejected.
566
860
  */
567
861
  async #sendAll(msgs: BaseMessage[]): Promise<void> {
568
862
  for(const m of msgs) {
569
863
  await this.#transport.sendMessage(m, this.#did, m.to);
570
864
  }
571
865
  }
572
-
573
- /**
574
- * Internal: helper to handle errors. Emits an 'error' event and rejects the run promise.
575
- * @param {Error} err - The error to handle.
576
- */
577
- #fail(err: Error): void {
578
- this.#stopAdvertRepeating();
579
- this.#clearTimers();
580
- this.#unregisterHandlers();
581
- if(this.#cohortId) this.session.removeCohort(this.#cohortId);
582
- this.emit('error', err);
583
- this.#rejectRun?.(err);
584
- }
585
866
  }