@did-btcr2/method 0.36.1 → 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 +1060 -96
- package/dist/browser.mjs +1060 -96
- package/dist/cjs/index.js +1153 -179
- 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 +210 -21
- 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 +74 -22
- package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
- package/dist/esm/core/aggregation/runner/service-runner.js +97 -9
- 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 +41 -7
- 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 +28 -7
- package/dist/types/core/aggregation/runner/events.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/participant-runner.d.ts +3 -3
- package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/service-runner.d.ts +28 -3
- 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 +255 -23
- 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 +26 -7
- package/src/core/aggregation/runner/participant-runner.ts +75 -23
- package/src/core/aggregation/runner/service-runner.ts +120 -10
- 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
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
COHORT_OPT_IN_ACCEPT,
|
|
9
9
|
COHORT_READY,
|
|
10
10
|
DISTRIBUTE_AGGREGATED_DATA,
|
|
11
|
+
FALLBACK_AUTHORIZATION_REQUEST,
|
|
11
12
|
} from '../messages/constants.js';
|
|
12
13
|
import type {
|
|
13
14
|
CohortAdvert,
|
|
@@ -29,7 +30,7 @@ export type ShouldJoin = (advert: CohortAdvert) => Promise<boolean>;
|
|
|
29
30
|
export type OnProvideUpdate = (info: {
|
|
30
31
|
cohortId: string;
|
|
31
32
|
beaconAddress: string;
|
|
32
|
-
}) => Promise<SignedBTCR2Update>;
|
|
33
|
+
}) => Promise<SignedBTCR2Update | null>;
|
|
33
34
|
|
|
34
35
|
/** Decision callback: approve or reject aggregated data. */
|
|
35
36
|
export type OnValidateData = (info: PendingValidation) => Promise<{ approved: boolean }>;
|
|
@@ -53,7 +54,7 @@ export interface AggregationParticipantRunnerOptions {
|
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
56
|
* Provide a signed BTCR2 update for the cohort.
|
|
56
|
-
* REQUIRED
|
|
57
|
+
* REQUIRED - no sensible default.
|
|
57
58
|
*/
|
|
58
59
|
onProvideUpdate: OnProvideUpdate;
|
|
59
60
|
|
|
@@ -161,6 +162,7 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
|
|
|
161
162
|
DISTRIBUTE_AGGREGATED_DATA,
|
|
162
163
|
AUTHORIZATION_REQUEST,
|
|
163
164
|
AGGREGATED_NONCE,
|
|
165
|
+
FALLBACK_AUTHORIZATION_REQUEST,
|
|
164
166
|
];
|
|
165
167
|
|
|
166
168
|
/** Internal: detach from the transport. Safe to call repeatedly. */
|
|
@@ -200,7 +202,7 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
|
|
|
200
202
|
*
|
|
201
203
|
* For an open-ended, long-lived subscriber (no fixed count), construct an
|
|
202
204
|
* {@link AggregationParticipantRunner} directly, set `shouldJoin`, call
|
|
203
|
-
* `start()`, and listen for `cohort-complete`
|
|
205
|
+
* `start()`, and listen for `cohort-complete` - the runner already drives
|
|
204
206
|
* any number of cohorts concurrently.
|
|
205
207
|
*
|
|
206
208
|
* @param options Participant runner options (set `shouldJoin` to select cohorts).
|
|
@@ -240,6 +242,7 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
|
|
|
240
242
|
this.#transport.registerMessageHandler(this.#did, DISTRIBUTE_AGGREGATED_DATA, this.#handleDistributeData.bind(this));
|
|
241
243
|
this.#transport.registerMessageHandler(this.#did, AUTHORIZATION_REQUEST, this.#handleAuthorizationRequest.bind(this));
|
|
242
244
|
this.#transport.registerMessageHandler(this.#did, AGGREGATED_NONCE, this.#handleAggregatedNonce.bind(this));
|
|
245
|
+
this.#transport.registerMessageHandler(this.#did, FALLBACK_AUTHORIZATION_REQUEST, this.#handleFallbackAuthorizationRequest.bind(this));
|
|
243
246
|
}
|
|
244
247
|
|
|
245
248
|
/**
|
|
@@ -302,13 +305,20 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
|
|
|
302
305
|
if (!info) return;
|
|
303
306
|
this.emit('cohort-ready', { cohortId, beaconAddress: info.beaconAddress });
|
|
304
307
|
|
|
305
|
-
// Construct the signed update via caller callback and submit
|
|
308
|
+
// Construct the signed update via caller callback and submit. A null return
|
|
309
|
+
// means the member has no update this round: it declines (cooperative
|
|
310
|
+
// non-inclusion) but stays in the cohort and still signs.
|
|
306
311
|
const signedUpdate = await this.#onProvideUpdate({
|
|
307
312
|
cohortId,
|
|
308
313
|
beaconAddress : info.beaconAddress,
|
|
309
314
|
});
|
|
310
|
-
|
|
311
|
-
|
|
315
|
+
if(signedUpdate === null) {
|
|
316
|
+
await this.#sendAll(this.session.declineUpdate(cohortId));
|
|
317
|
+
this.emit('update-declined', { cohortId });
|
|
318
|
+
} else {
|
|
319
|
+
await this.#sendAll(this.session.submitUpdate(cohortId, signedUpdate));
|
|
320
|
+
this.emit('update-submitted', { cohortId });
|
|
321
|
+
}
|
|
312
322
|
} catch (err) {
|
|
313
323
|
this.emit('error', err as Error);
|
|
314
324
|
}
|
|
@@ -398,29 +408,71 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
|
|
|
398
408
|
await this.#sendAll(this.session.generatePartialSignature(cohortId));
|
|
399
409
|
|
|
400
410
|
// Check if we've reached completion
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
411
|
+
this.#emitCohortCompleteIfDone(cohortId);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
this.emit('error', err as Error);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Internal: handler for fallback authorization requests. The service abandoned
|
|
419
|
+
* the optimistic key path; the member authorizes the k-of-n script-path spend
|
|
420
|
+
* of the same beacon transaction (ADR 042). Reuses `onApproveSigning` to gate
|
|
421
|
+
* the decision, signs the fallback, and completes once it has contributed (the
|
|
422
|
+
* service needs only k members).
|
|
423
|
+
* @param {BaseMessage} msg - The received fallback authorization request message.
|
|
424
|
+
* @returns {Promise<void>} Resolves when processing is complete.
|
|
425
|
+
*/
|
|
426
|
+
async #handleFallbackAuthorizationRequest(msg: BaseMessage): Promise<void> {
|
|
427
|
+
if (this.#stopped) return;
|
|
428
|
+
try {
|
|
429
|
+
this.session.receive(msg);
|
|
430
|
+
|
|
431
|
+
const cohortId = msg.body?.cohortId;
|
|
432
|
+
if (!cohortId) return;
|
|
433
|
+
|
|
434
|
+
const req = this.session.pendingFallbackRequests.get(cohortId);
|
|
435
|
+
if (!req) return;
|
|
436
|
+
this.emit('fallback-requested', req);
|
|
437
|
+
|
|
438
|
+
const decision = await this.#onApproveSigning(req);
|
|
439
|
+
if (!decision.approved) {
|
|
440
|
+
this.emit('cohort-failed', { cohortId, reason: 'Fallback signing rejected by participant' });
|
|
441
|
+
return;
|
|
418
442
|
}
|
|
443
|
+
|
|
444
|
+
await this.#sendAll(this.session.approveFallback(cohortId));
|
|
445
|
+
// The member has contributed its fallback signature and is done from its
|
|
446
|
+
// own perspective, regardless of whether the service has yet collected k.
|
|
447
|
+
this.#emitCohortCompleteIfDone(cohortId);
|
|
419
448
|
} catch (err) {
|
|
420
449
|
this.emit('error', err as Error);
|
|
421
450
|
}
|
|
422
451
|
}
|
|
423
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Internal: emit `cohort-complete` with the participant's retained sidecar once
|
|
455
|
+
* the cohort has reached the Complete phase. Surfaces the CAS Announcement map
|
|
456
|
+
* (CAS beacons) or the SMT proof (SMT beacons) the participant keeps for future
|
|
457
|
+
* DID resolution. Read via getValidation (not pendingValidations, which lists
|
|
458
|
+
* only the AwaitingValidation phase) so the sidecar is still available at
|
|
459
|
+
* Complete. Shared by the optimistic and fallback completion paths.
|
|
460
|
+
*/
|
|
461
|
+
#emitCohortCompleteIfDone(cohortId: string): void {
|
|
462
|
+
if (this.session.getCohortPhase(cohortId) !== ParticipantCohortPhase.Complete) return;
|
|
463
|
+
const info = this.session.joinedCohorts.get(cohortId);
|
|
464
|
+
if (!info) return;
|
|
465
|
+
const validation = this.session.getValidation(cohortId);
|
|
466
|
+
this.emit('cohort-complete', {
|
|
467
|
+
cohortId,
|
|
468
|
+
beaconAddress : info.beaconAddress,
|
|
469
|
+
beaconType : validation?.beaconType ?? '',
|
|
470
|
+
included : validation?.included ?? true,
|
|
471
|
+
casAnnouncement : validation?.casAnnouncement,
|
|
472
|
+
smtProof : validation?.smtProof,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
424
476
|
/**
|
|
425
477
|
* Internal: send helper to ensure messages are sent sequentially. This is important for protocol
|
|
426
478
|
* correctness, as some transports may not guarantee message order if sent in parallel.
|
|
@@ -3,12 +3,15 @@ import { AggregationServiceError } from '../errors.js';
|
|
|
3
3
|
import type { BaseMessage } from '../messages/base.js';
|
|
4
4
|
import {
|
|
5
5
|
COHORT_OPT_IN,
|
|
6
|
+
FALLBACK_SIGNATURE,
|
|
6
7
|
NONCE_CONTRIBUTION,
|
|
7
8
|
SIGNATURE_AUTHORIZATION,
|
|
9
|
+
SUBMIT_NONINCLUDED,
|
|
8
10
|
SUBMIT_UPDATE,
|
|
9
11
|
VALIDATION_ACK,
|
|
10
12
|
} from '../messages/constants.js';
|
|
11
13
|
import { ServiceCohortPhase } from '../phases.js';
|
|
14
|
+
import type { ServiceCohortPhaseType } from '../phases.js';
|
|
12
15
|
import type {
|
|
13
16
|
AggregationResult,
|
|
14
17
|
CohortConfig,
|
|
@@ -68,7 +71,7 @@ export interface AggregationServiceRunnerOptions {
|
|
|
68
71
|
|
|
69
72
|
/**
|
|
70
73
|
* Provide the Bitcoin transaction data to sign.
|
|
71
|
-
* REQUIRED
|
|
74
|
+
* REQUIRED - no sensible default.
|
|
72
75
|
*/
|
|
73
76
|
onProvideTxData: OnProvideTxData;
|
|
74
77
|
|
|
@@ -98,7 +101,7 @@ export interface AggregationServiceRunnerOptions {
|
|
|
98
101
|
/**
|
|
99
102
|
* Re-publish COHORT_ADVERT on this interval until a cohort's keygen is
|
|
100
103
|
* finalized. Works around relays that don't backfill historical events to
|
|
101
|
-
* late subscribers
|
|
104
|
+
* late subscribers - a republish gives late joiners a window to discover the
|
|
102
105
|
* advert without protocol changes. The first publish is immediate;
|
|
103
106
|
* subsequent publishes fire every `advertRepeatIntervalMs` until that
|
|
104
107
|
* cohort's keygen completes, fails, or is stopped. Defaults to
|
|
@@ -106,6 +109,17 @@ export interface AggregationServiceRunnerOptions {
|
|
|
106
109
|
* once and never retry.
|
|
107
110
|
*/
|
|
108
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;
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
/** Default cadence for re-publishing COHORT_ADVERT until keygen completes: 60 seconds. */
|
|
@@ -139,6 +153,14 @@ interface RunContext {
|
|
|
139
153
|
finalizing: boolean;
|
|
140
154
|
/** Once settled (resolved or rejected), late timers/messages must not re-settle. */
|
|
141
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';
|
|
142
164
|
cohortTtlTimer?: ReturnType<typeof setTimeout>;
|
|
143
165
|
phaseTimer?: ReturnType<typeof setTimeout>;
|
|
144
166
|
lastObservedPhase?: string;
|
|
@@ -155,7 +177,7 @@ interface RunContext {
|
|
|
155
177
|
*
|
|
156
178
|
* A single runner is a long-lived multiplexer: it advertises and drives many
|
|
157
179
|
* cohorts concurrently over one transport. Each advertised cohort owns an
|
|
158
|
-
* independent completion promise and fails in isolation
|
|
180
|
+
* independent completion promise and fails in isolation - a stalled or failed
|
|
159
181
|
* cohort never settles its siblings (see ADR 040). Use
|
|
160
182
|
* {@link AggregationServiceRunner.advertiseCohort} for the multi-cohort path;
|
|
161
183
|
* {@link AggregationServiceRunner.run} is a thin single-cohort convenience over
|
|
@@ -206,6 +228,14 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
206
228
|
readonly #cohortTtlMs?: number;
|
|
207
229
|
readonly #phaseTimeoutMs?: number;
|
|
208
230
|
readonly #advertRepeatIntervalMs: number;
|
|
231
|
+
readonly #autoFallbackOnStall: boolean;
|
|
232
|
+
|
|
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
|
+
];
|
|
209
239
|
|
|
210
240
|
/** Per-cohort run state, keyed by cohortId. */
|
|
211
241
|
readonly #contexts: Map<string, RunContext> = new Map();
|
|
@@ -225,6 +255,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
225
255
|
this.#cohortTtlMs = options.cohortTtlMs;
|
|
226
256
|
this.#phaseTimeoutMs = options.phaseTimeoutMs;
|
|
227
257
|
this.#advertRepeatIntervalMs = options.advertRepeatIntervalMs ?? DEFAULT_ADVERT_REPEAT_INTERVAL_MS;
|
|
258
|
+
this.#autoFallbackOnStall = options.autoFallbackOnStall ?? false;
|
|
228
259
|
|
|
229
260
|
this.session = new AggregationService({
|
|
230
261
|
// The coordinator never signs, so the state machine receives only the
|
|
@@ -269,7 +300,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
269
300
|
}
|
|
270
301
|
this.#registerHandlers();
|
|
271
302
|
// createCohort validates the conditions and throws on a bad config before
|
|
272
|
-
// any context exists
|
|
303
|
+
// any context exists - fail-fast, nothing to clean up.
|
|
273
304
|
const cohortId = this.session.createCohort(config);
|
|
274
305
|
|
|
275
306
|
let resolve!: (result: AggregationResult) => void;
|
|
@@ -293,7 +324,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
293
324
|
this.#onPhaseMaybeChanged(ctx);
|
|
294
325
|
this.emit('cohort-advertised', { cohortId });
|
|
295
326
|
// Publish the advert. If advertRepeatIntervalMs > 0 we republish on that
|
|
296
|
-
// cadence until this cohort's keygen-complete / fail / stop
|
|
327
|
+
// cadence until this cohort's keygen-complete / fail / stop - works around
|
|
297
328
|
// relays that don't backfill historical events to late subscribers.
|
|
298
329
|
// Otherwise fall back to a single send.
|
|
299
330
|
if(this.#advertRepeatIntervalMs > 0) {
|
|
@@ -404,12 +435,53 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
404
435
|
ctx.phaseTimer = undefined;
|
|
405
436
|
if(this.#phaseTimeoutMs === undefined) return;
|
|
406
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
|
+
}
|
|
407
447
|
const reason = `Cohort ${ctx.cohortId} stalled in phase ${ctx.lastObservedPhase ?? '?'} for ${this.#phaseTimeoutMs}ms`;
|
|
408
448
|
this.emit('cohort-failed', { cohortId: ctx.cohortId, reason });
|
|
409
449
|
this.#failCohort(ctx, new Error(reason));
|
|
410
450
|
}, this.#phaseTimeoutMs);
|
|
411
451
|
}
|
|
412
452
|
|
|
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
|
+
|
|
413
485
|
/** Detect a phase change for a cohort since the last observation and reset its phase timer. */
|
|
414
486
|
#onPhaseMaybeChanged(ctx: RunContext): void {
|
|
415
487
|
const phase = this.session.getCohortPhase(ctx.cohortId);
|
|
@@ -505,14 +577,16 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
505
577
|
static readonly #HANDLED_MESSAGE_TYPES: readonly string[] = [
|
|
506
578
|
COHORT_OPT_IN,
|
|
507
579
|
SUBMIT_UPDATE,
|
|
580
|
+
SUBMIT_NONINCLUDED,
|
|
508
581
|
VALIDATION_ACK,
|
|
509
582
|
NONCE_CONTRIBUTION,
|
|
510
583
|
SIGNATURE_AUTHORIZATION,
|
|
584
|
+
FALLBACK_SIGNATURE,
|
|
511
585
|
];
|
|
512
586
|
|
|
513
587
|
/**
|
|
514
588
|
* Internal: handler registration with the transport. Idempotent. Handlers
|
|
515
|
-
* are DID-scoped and cohort-agnostic
|
|
589
|
+
* are DID-scoped and cohort-agnostic - one registration serves every cohort
|
|
516
590
|
* this runner drives; demux to the right {@link RunContext} happens in each
|
|
517
591
|
* handler via the inbound message's cohortId.
|
|
518
592
|
*/
|
|
@@ -522,9 +596,14 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
522
596
|
|
|
523
597
|
this.#transport.registerMessageHandler(this.#did, COHORT_OPT_IN, this.#handleOptIn.bind(this));
|
|
524
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));
|
|
525
603
|
this.#transport.registerMessageHandler(this.#did, VALIDATION_ACK, this.#handleValidationAck.bind(this));
|
|
526
604
|
this.#transport.registerMessageHandler(this.#did, NONCE_CONTRIBUTION, this.#handleNonceContribution.bind(this));
|
|
527
605
|
this.#transport.registerMessageHandler(this.#did, SIGNATURE_AUTHORIZATION, this.#handleSignatureAuthorization.bind(this));
|
|
606
|
+
this.#transport.registerMessageHandler(this.#did, FALLBACK_SIGNATURE, this.#handleFallbackSignature.bind(this));
|
|
528
607
|
}
|
|
529
608
|
|
|
530
609
|
/** Internal: detach from the transport. Safe to call repeatedly. */
|
|
@@ -584,7 +663,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
584
663
|
|
|
585
664
|
// Check if it's time to finalize. The per-cohort `finalizing` flag is set
|
|
586
665
|
// synchronously before the first await so concurrent opt-in handlers for
|
|
587
|
-
// the same cohort observe it and skip
|
|
666
|
+
// the same cohort observe it and skip - otherwise two handlers could both
|
|
588
667
|
// pass the minParticipants check and both call finalizeKeygen, the second
|
|
589
668
|
// of which would throw (phase mismatch).
|
|
590
669
|
const cohort = this.session.getCohort(ctx.cohortId)!;
|
|
@@ -595,7 +674,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
595
674
|
minRequired : ctx.config.minParticipants,
|
|
596
675
|
});
|
|
597
676
|
if(!finalizeDecision.finalize) {
|
|
598
|
-
// Operator declined
|
|
677
|
+
// Operator declined - reset the flag so a later opt-in can retry.
|
|
599
678
|
ctx.finalizing = false;
|
|
600
679
|
return;
|
|
601
680
|
}
|
|
@@ -604,7 +683,7 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
604
683
|
// (which can run all the way to signing-complete) would resolve the
|
|
605
684
|
// cohort's completion promise before this event fires.
|
|
606
685
|
const readyMsgs = this.session.finalizeKeygen(ctx.cohortId);
|
|
607
|
-
// Keygen done
|
|
686
|
+
// Keygen done - stop re-advertising the cohort. New participants
|
|
608
687
|
// arriving after this point would be rejected anyway.
|
|
609
688
|
this.#stopAdvertRepeating(ctx);
|
|
610
689
|
this.emit('keygen-complete', {
|
|
@@ -733,7 +812,38 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
|
|
|
733
812
|
this.#drainRejections(ctx);
|
|
734
813
|
this.#onPhaseMaybeChanged(ctx);
|
|
735
814
|
|
|
736
|
-
//
|
|
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;
|
|
842
|
+
try {
|
|
843
|
+
this.session.receive(msg);
|
|
844
|
+
this.#drainRejections(ctx);
|
|
845
|
+
this.#onPhaseMaybeChanged(ctx);
|
|
846
|
+
|
|
737
847
|
const result = this.session.getResult(ctx.cohortId);
|
|
738
848
|
if(result) {
|
|
739
849
|
this.#completeCohort(ctx, result);
|