@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/browser.js +460 -223
- package/dist/browser.mjs +460 -223
- package/dist/cjs/index.js +461 -223
- package/dist/esm/core/aggregation/cohort.js +3 -1
- package/dist/esm/core/aggregation/cohort.js.map +1 -1
- package/dist/esm/core/aggregation/conditions.js +75 -0
- package/dist/esm/core/aggregation/conditions.js.map +1 -0
- package/dist/esm/core/aggregation/messages/base.js.map +1 -1
- package/dist/esm/core/aggregation/messages/bodies.js +16 -2
- package/dist/esm/core/aggregation/messages/bodies.js.map +1 -1
- package/dist/esm/core/aggregation/messages/factories.js.map +1 -1
- package/dist/esm/core/aggregation/participant.js +20 -7
- package/dist/esm/core/aggregation/participant.js.map +1 -1
- package/dist/esm/core/aggregation/runner/participant-runner.js +37 -2
- package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
- package/dist/esm/core/aggregation/runner/service-runner.js +323 -189
- package/dist/esm/core/aggregation/runner/service-runner.js.map +1 -1
- package/dist/esm/core/aggregation/service.js +23 -3
- package/dist/esm/core/aggregation/service.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
- package/dist/types/core/aggregation/conditions.d.ts +58 -0
- package/dist/types/core/aggregation/conditions.d.ts.map +1 -0
- package/dist/types/core/aggregation/messages/base.d.ts +2 -3
- package/dist/types/core/aggregation/messages/base.d.ts.map +1 -1
- package/dist/types/core/aggregation/messages/bodies.d.ts +2 -3
- package/dist/types/core/aggregation/messages/bodies.d.ts.map +1 -1
- package/dist/types/core/aggregation/messages/factories.d.ts +2 -3
- package/dist/types/core/aggregation/messages/factories.d.ts.map +1 -1
- package/dist/types/core/aggregation/participant.d.ts +16 -4
- package/dist/types/core/aggregation/participant.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/events.d.ts +22 -11
- package/dist/types/core/aggregation/runner/events.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/participant-runner.d.ts +21 -12
- package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/service-runner.d.ts +76 -22
- package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
- package/dist/types/core/aggregation/service.d.ts +8 -4
- package/dist/types/core/aggregation/service.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/core/aggregation/cohort.ts +3 -1
- package/src/core/aggregation/conditions.ts +116 -0
- package/src/core/aggregation/messages/base.ts +6 -3
- package/src/core/aggregation/messages/bodies.ts +18 -6
- package/src/core/aggregation/messages/factories.ts +2 -3
- package/src/core/aggregation/participant.ts +28 -11
- package/src/core/aggregation/runner/events.ts +23 -14
- package/src/core/aggregation/runner/participant-runner.ts +43 -13
- package/src/core/aggregation/runner/service-runner.ts +375 -195
- package/src/core/aggregation/service.ts +39 -7
- package/src/index.ts +1 -0
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
#
|
|
61
|
+
#defaultConfig;
|
|
48
62
|
#onOptInReceived;
|
|
49
63
|
#onReadyToFinalize;
|
|
50
64
|
#onProvideTxData;
|
|
51
65
|
#cohortTtlMs;
|
|
52
66
|
#phaseTimeoutMs;
|
|
53
67
|
#advertRepeatIntervalMs;
|
|
54
|
-
|
|
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.#
|
|
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
|
|
95
|
-
* recent receive() and surface them as `message-rejected` events.
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
#drainRejections(ctx) {
|
|
106
|
+
for (const r of this.session.drainRejections(ctx.cohortId)) {
|
|
107
|
+
this.emit('message-rejected', { cohortId: ctx.cohortId, ...r });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
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
|
+
}
|
|
103
157
|
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
this.#failCohort(ctx, err);
|
|
160
|
+
}
|
|
161
|
+
return { cohortId, completion };
|
|
104
162
|
}
|
|
105
163
|
/**
|
|
106
|
-
* Run
|
|
107
|
-
*
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
141
|
-
* until
|
|
142
|
-
*
|
|
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
|
-
|
|
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
|
|
161
|
-
#stopAdvertRepeating() {
|
|
162
|
-
if (!
|
|
232
|
+
/** Stop a cohort's advert republish loop. Idempotent. */
|
|
233
|
+
#stopAdvertRepeating(ctx) {
|
|
234
|
+
if (!ctx.stopAdvertRepeat)
|
|
163
235
|
return;
|
|
164
|
-
const stop =
|
|
165
|
-
|
|
236
|
+
const stop = ctx.stopAdvertRepeat;
|
|
237
|
+
ctx.stopAdvertRepeat = undefined;
|
|
166
238
|
stop();
|
|
167
239
|
}
|
|
168
|
-
/** Schedule cohort TTL + phase timeout
|
|
169
|
-
#startTimers() {
|
|
240
|
+
/** Schedule a cohort's TTL + phase timeout when it is advertised. */
|
|
241
|
+
#startTimers(ctx) {
|
|
170
242
|
if (this.#cohortTtlMs !== undefined) {
|
|
171
|
-
|
|
172
|
-
const reason = `Cohort ${
|
|
173
|
-
this.emit('cohort-failed', { cohortId:
|
|
174
|
-
this.#
|
|
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
|
|
180
|
-
#resetPhaseTimer() {
|
|
181
|
-
if (
|
|
182
|
-
clearTimeout(
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
const reason = `Cohort ${
|
|
188
|
-
this.emit('cohort-failed', { cohortId:
|
|
189
|
-
this.#
|
|
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
|
|
193
|
-
#onPhaseMaybeChanged() {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
203
|
-
#clearTimers() {
|
|
204
|
-
if (
|
|
205
|
-
clearTimeout(
|
|
206
|
-
if (
|
|
207
|
-
clearTimeout(
|
|
208
|
-
|
|
209
|
-
|
|
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);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
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 }));
|
|
210
335
|
}
|
|
211
336
|
/**
|
|
212
|
-
* Stop the runner
|
|
213
|
-
* handlers so a restart or a new runner doesn't inherit
|
|
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.#
|
|
218
|
-
|
|
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)
|
|
256
|
-
* 2)
|
|
257
|
-
* 3)
|
|
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(
|
|
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) {
|
|
@@ -280,41 +418,50 @@ export class AggregationServiceRunner extends TypedEventEmitter {
|
|
|
280
418
|
const decision = await this.#onOptInReceived(optIn);
|
|
281
419
|
if (!decision.accepted)
|
|
282
420
|
return;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
421
|
+
// Don't accept past the advertised maxParticipants: acceptParticipant
|
|
422
|
+
// would throw COHORT_FULL and fail the cohort. Silently ignore the surplus
|
|
423
|
+
// opt-in (the cohort is full).
|
|
424
|
+
const maxParticipants = ctx.config.maxParticipants;
|
|
425
|
+
const cohortNow = this.session.getCohort(ctx.cohortId);
|
|
426
|
+
if (maxParticipants !== undefined && cohortNow && cohortNow.participants.length >= maxParticipants) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
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;
|
|
292
439
|
const finalizeDecision = await this.#onReadyToFinalize({
|
|
293
440
|
acceptedCount: cohort.participants.length,
|
|
294
|
-
minRequired:
|
|
441
|
+
minRequired: ctx.config.minParticipants,
|
|
295
442
|
});
|
|
296
443
|
if (!finalizeDecision.finalize) {
|
|
297
444
|
// Operator declined — reset the flag so a later opt-in can retry.
|
|
298
|
-
|
|
445
|
+
ctx.finalizing = false;
|
|
299
446
|
return;
|
|
300
447
|
}
|
|
301
448
|
// finalizeKeygen() computes the beacon address synchronously
|
|
302
449
|
// emit BEFORE awaiting sendAll. Otherwise the downstream cascade
|
|
303
450
|
// (which can run all the way to signing-complete) would resolve the
|
|
304
|
-
//
|
|
305
|
-
const readyMsgs = this.session.finalizeKeygen(
|
|
451
|
+
// cohort's completion promise before this event fires.
|
|
452
|
+
const readyMsgs = this.session.finalizeKeygen(ctx.cohortId);
|
|
306
453
|
// Keygen done — stop re-advertising the cohort. New participants
|
|
307
454
|
// arriving after this point would be rejected anyway.
|
|
308
|
-
this.#stopAdvertRepeating();
|
|
455
|
+
this.#stopAdvertRepeating(ctx);
|
|
309
456
|
this.emit('keygen-complete', {
|
|
310
|
-
cohortId:
|
|
457
|
+
cohortId: ctx.cohortId,
|
|
311
458
|
beaconAddress: cohort.beaconAddress,
|
|
312
459
|
});
|
|
313
460
|
await this.#sendAll(readyMsgs);
|
|
314
461
|
}
|
|
315
462
|
}
|
|
316
463
|
catch (err) {
|
|
317
|
-
this.#
|
|
464
|
+
this.#failCohort(ctx, err);
|
|
318
465
|
}
|
|
319
466
|
}
|
|
320
467
|
/**
|
|
@@ -322,26 +469,27 @@ export class AggregationServiceRunner extends TypedEventEmitter {
|
|
|
322
469
|
* and distributes the data for validation.
|
|
323
470
|
* @param {BaseMessage} msg - The incoming message to handle.
|
|
324
471
|
* @returns {Promise<void>} Resolves when handling is complete.
|
|
325
|
-
* @throws {Error} If any step of handling fails, the error is emitted and the run promise is rejected.
|
|
326
|
-
* Note: if the runner has been stopped, handlers will ignore incoming messages.
|
|
327
472
|
*/
|
|
328
473
|
async #handleSubmitUpdate(msg) {
|
|
329
474
|
if (this.#stopped)
|
|
330
475
|
return;
|
|
476
|
+
const ctx = this.#contextFor(msg);
|
|
477
|
+
if (!ctx)
|
|
478
|
+
return;
|
|
331
479
|
try {
|
|
332
480
|
this.session.receive(msg);
|
|
333
|
-
this.#drainRejections();
|
|
334
|
-
this.#onPhaseMaybeChanged();
|
|
335
|
-
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 });
|
|
336
484
|
// When all updates collected, build and distribute
|
|
337
|
-
if (this.session.getCohortPhase(
|
|
338
|
-
const distributeMsgs = this.session.buildAndDistribute(
|
|
339
|
-
this.emit('data-distributed', { 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 });
|
|
340
488
|
await this.#sendAll(distributeMsgs);
|
|
341
489
|
}
|
|
342
490
|
}
|
|
343
491
|
catch (err) {
|
|
344
|
-
this.#
|
|
492
|
+
this.#failCohort(ctx, err);
|
|
345
493
|
}
|
|
346
494
|
}
|
|
347
495
|
/**
|
|
@@ -349,124 +497,110 @@ export class AggregationServiceRunner extends TypedEventEmitter {
|
|
|
349
497
|
* automatically requests tx data and starts signing.
|
|
350
498
|
* @param {BaseMessage} msg - The incoming message to handle.
|
|
351
499
|
* @returns {Promise<void>} Resolves when handling is complete.
|
|
352
|
-
* @throws {Error} If any step of handling fails, the error is emitted and the run promise is rejected.
|
|
353
|
-
* Note: if the runner has been stopped, handlers will ignore incoming messages.
|
|
354
500
|
*/
|
|
355
501
|
async #handleValidationAck(msg) {
|
|
356
502
|
if (this.#stopped)
|
|
357
503
|
return;
|
|
504
|
+
const ctx = this.#contextFor(msg);
|
|
505
|
+
if (!ctx)
|
|
506
|
+
return;
|
|
358
507
|
try {
|
|
359
508
|
this.session.receive(msg);
|
|
360
|
-
this.#drainRejections();
|
|
361
|
-
this.#onPhaseMaybeChanged();
|
|
509
|
+
this.#drainRejections(ctx);
|
|
510
|
+
this.#onPhaseMaybeChanged(ctx);
|
|
362
511
|
const approved = !!msg.body?.approved;
|
|
363
|
-
this.emit('validation-received', { participantDid: msg.from, approved });
|
|
364
|
-
const phase = this.session.getCohortPhase(
|
|
512
|
+
this.emit('validation-received', { cohortId: ctx.cohortId, participantDid: msg.from, approved });
|
|
513
|
+
const phase = this.session.getCohortPhase(ctx.cohortId);
|
|
365
514
|
// A participant rejection flips the cohort to Failed. Emit a structured
|
|
366
515
|
// event so the runner/caller sees the failure instead of the cohort
|
|
367
516
|
// silently stalling.
|
|
368
517
|
if (phase === ServiceCohortPhase.Failed) {
|
|
369
518
|
const reason = `Validation rejected by participant ${msg.from}`;
|
|
370
|
-
this.emit('cohort-failed', { cohortId:
|
|
371
|
-
this.#
|
|
519
|
+
this.emit('cohort-failed', { cohortId: ctx.cohortId, reason });
|
|
520
|
+
this.#failCohort(ctx, new Error(reason));
|
|
372
521
|
return;
|
|
373
522
|
}
|
|
374
523
|
// When all validations received, request tx data and start signing
|
|
375
524
|
if (phase === ServiceCohortPhase.Validated) {
|
|
376
|
-
const cohort = this.session.getCohort(
|
|
525
|
+
const cohort = this.session.getCohort(ctx.cohortId);
|
|
377
526
|
const txData = await this.#onProvideTxData({
|
|
378
|
-
cohortId:
|
|
527
|
+
cohortId: ctx.cohortId,
|
|
379
528
|
beaconAddress: cohort.beaconAddress,
|
|
380
529
|
signalBytes: cohort.signalBytes,
|
|
381
530
|
});
|
|
382
|
-
const authMsgs = this.session.startSigning(
|
|
383
|
-
const sessionId = this.session.getSigningSessionId(
|
|
384
|
-
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 });
|
|
385
534
|
await this.#sendAll(authMsgs);
|
|
386
535
|
}
|
|
387
536
|
}
|
|
388
537
|
catch (err) {
|
|
389
|
-
this.#
|
|
538
|
+
this.#failCohort(ctx, err);
|
|
390
539
|
}
|
|
391
540
|
}
|
|
392
541
|
/**
|
|
393
|
-
* Handler for receiving nonce contributions
|
|
394
|
-
*
|
|
542
|
+
* Handler for receiving nonce contributions. When all nonces are received, sends the aggregated
|
|
543
|
+
* nonce back to the cohort.
|
|
395
544
|
* @param {BaseMessage} msg - The incoming message to handle.
|
|
396
545
|
* @returns {Promise<void>} Resolves when handling is complete.
|
|
397
|
-
* @throws {Error} If any step of handling fails, the error is emitted and the run promise is rejected.
|
|
398
|
-
* Note: if the runner has been stopped, handlers will ignore incoming messages.
|
|
399
546
|
*/
|
|
400
547
|
async #handleNonceContribution(msg) {
|
|
401
548
|
if (this.#stopped)
|
|
402
549
|
return;
|
|
550
|
+
const ctx = this.#contextFor(msg);
|
|
551
|
+
if (!ctx)
|
|
552
|
+
return;
|
|
403
553
|
try {
|
|
404
554
|
this.session.receive(msg);
|
|
405
|
-
this.#drainRejections();
|
|
406
|
-
this.#onPhaseMaybeChanged();
|
|
407
|
-
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 });
|
|
408
558
|
// When all nonces collected, send aggregated nonce
|
|
409
|
-
if (this.session.getCohortPhase(
|
|
410
|
-
await this.#sendAll(this.session.sendAggregatedNonce(
|
|
559
|
+
if (this.session.getCohortPhase(ctx.cohortId) === ServiceCohortPhase.NoncesCollected) {
|
|
560
|
+
await this.#sendAll(this.session.sendAggregatedNonce(ctx.cohortId));
|
|
411
561
|
}
|
|
412
562
|
}
|
|
413
563
|
catch (err) {
|
|
414
|
-
this.#
|
|
564
|
+
this.#failCohort(ctx, err);
|
|
415
565
|
}
|
|
416
566
|
}
|
|
417
567
|
/**
|
|
418
568
|
* Handler for receiving signature authorizations. When all partial signatures are received, the
|
|
419
|
-
* session automatically completes
|
|
569
|
+
* session automatically completes; the final result is emitted and the cohort's completion
|
|
570
|
+
* promise resolves.
|
|
420
571
|
* @param {BaseMessage} msg - The incoming message to handle.
|
|
421
572
|
* @returns {Promise<void>} Resolves when handling is complete.
|
|
422
|
-
* @throws {Error} If any step of handling fails, the error is emitted and the run promise is rejected.
|
|
423
|
-
* Note: if the runner has been stopped, handlers will ignore incoming messages.
|
|
424
573
|
*/
|
|
425
574
|
async #handleSignatureAuthorization(msg) {
|
|
426
575
|
if (this.#stopped)
|
|
427
576
|
return;
|
|
577
|
+
const ctx = this.#contextFor(msg);
|
|
578
|
+
if (!ctx)
|
|
579
|
+
return;
|
|
428
580
|
try {
|
|
429
581
|
this.session.receive(msg);
|
|
430
|
-
this.#drainRejections();
|
|
431
|
-
this.#onPhaseMaybeChanged();
|
|
582
|
+
this.#drainRejections(ctx);
|
|
583
|
+
this.#onPhaseMaybeChanged(ctx);
|
|
432
584
|
// The state machine auto-completes when all partial sigs received
|
|
433
|
-
const result = this.session.getResult(
|
|
585
|
+
const result = this.session.getResult(ctx.cohortId);
|
|
434
586
|
if (result) {
|
|
435
|
-
this.#
|
|
436
|
-
this.#unregisterHandlers();
|
|
437
|
-
this.emit('signing-complete', result);
|
|
438
|
-
this.#resolveRun?.(result);
|
|
587
|
+
this.#completeCohort(ctx, result);
|
|
439
588
|
}
|
|
440
589
|
}
|
|
441
590
|
catch (err) {
|
|
442
|
-
this.#
|
|
591
|
+
this.#failCohort(ctx, err);
|
|
443
592
|
}
|
|
444
593
|
}
|
|
445
594
|
/**
|
|
446
595
|
* Internal: helper to send all messages sequentially. Catches and propagates errors.
|
|
447
596
|
* @param {BaseMessage[]} msgs - The messages to send.
|
|
448
597
|
* @returns {Promise<void>} Resolves when all messages have been sent.
|
|
449
|
-
* @throws {Error} If sending any message fails, the error is emitted and the run promise is
|
|
450
|
-
* rejected.
|
|
451
598
|
*/
|
|
452
599
|
async #sendAll(msgs) {
|
|
453
600
|
for (const m of msgs) {
|
|
454
601
|
await this.#transport.sendMessage(m, this.#did, m.to);
|
|
455
602
|
}
|
|
456
603
|
}
|
|
457
|
-
/**
|
|
458
|
-
* Internal: helper to handle errors. Emits an 'error' event and rejects the run promise.
|
|
459
|
-
* @param {Error} err - The error to handle.
|
|
460
|
-
*/
|
|
461
|
-
#fail(err) {
|
|
462
|
-
this.#stopAdvertRepeating();
|
|
463
|
-
this.#clearTimers();
|
|
464
|
-
this.#unregisterHandlers();
|
|
465
|
-
if (this.#cohortId)
|
|
466
|
-
this.session.removeCohort(this.#cohortId);
|
|
467
|
-
this.emit('error', err);
|
|
468
|
-
this.#rejectRun?.(err);
|
|
469
|
-
}
|
|
470
604
|
}
|
|
471
605
|
_a = AggregationServiceRunner;
|
|
472
606
|
//# sourceMappingURL=service-runner.js.map
|