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