@did-btcr2/method 0.35.0 → 0.36.1

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