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