@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.
Files changed (164) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/browser.js +1371 -260
  3. package/dist/browser.mjs +1371 -260
  4. package/dist/cjs/index.js +1464 -343
  5. package/dist/esm/core/aggregation/beacon-strategy.js +19 -6
  6. package/dist/esm/core/aggregation/beacon-strategy.js.map +1 -1
  7. package/dist/esm/core/aggregation/cohort.js +142 -33
  8. package/dist/esm/core/aggregation/cohort.js.map +1 -1
  9. package/dist/esm/core/aggregation/conditions.js +27 -0
  10. package/dist/esm/core/aggregation/conditions.js.map +1 -1
  11. package/dist/esm/core/aggregation/fallback-spend.js +132 -0
  12. package/dist/esm/core/aggregation/fallback-spend.js.map +1 -0
  13. package/dist/esm/core/aggregation/messages/base.js.map +1 -1
  14. package/dist/esm/core/aggregation/messages/bodies.js +46 -1
  15. package/dist/esm/core/aggregation/messages/bodies.js.map +1 -1
  16. package/dist/esm/core/aggregation/messages/constants.js +11 -0
  17. package/dist/esm/core/aggregation/messages/constants.js.map +1 -1
  18. package/dist/esm/core/aggregation/messages/factories.js +27 -1
  19. package/dist/esm/core/aggregation/messages/factories.js.map +1 -1
  20. package/dist/esm/core/aggregation/messages/guards.js +4 -1
  21. package/dist/esm/core/aggregation/messages/guards.js.map +1 -1
  22. package/dist/esm/core/aggregation/participant.js +218 -18
  23. package/dist/esm/core/aggregation/participant.js.map +1 -1
  24. package/dist/esm/core/aggregation/phases.js +6 -0
  25. package/dist/esm/core/aggregation/phases.js.map +1 -1
  26. package/dist/esm/core/aggregation/recovery-policy.js +156 -0
  27. package/dist/esm/core/aggregation/recovery-policy.js.map +1 -0
  28. package/dist/esm/core/aggregation/recovery-spend.js +106 -0
  29. package/dist/esm/core/aggregation/recovery-spend.js.map +1 -0
  30. package/dist/esm/core/aggregation/runner/aggregation-runner.js +11 -3
  31. package/dist/esm/core/aggregation/runner/aggregation-runner.js.map +1 -1
  32. package/dist/esm/core/aggregation/runner/participant-runner.js +106 -19
  33. package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
  34. package/dist/esm/core/aggregation/runner/service-runner.js +409 -195
  35. package/dist/esm/core/aggregation/runner/service-runner.js.map +1 -1
  36. package/dist/esm/core/aggregation/service.js +221 -11
  37. package/dist/esm/core/aggregation/service.js.map +1 -1
  38. package/dist/esm/core/aggregation/signing-session.js +3 -3
  39. package/dist/esm/core/aggregation/signing-session.js.map +1 -1
  40. package/dist/esm/core/aggregation/transport/http/envelope.js +3 -3
  41. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js +1 -1
  42. package/dist/esm/core/aggregation/transport/http/nonce-cache.js +1 -1
  43. package/dist/esm/core/aggregation/transport/http/rate-limiter.js +1 -1
  44. package/dist/esm/core/aggregation/transport/http/request-auth.js +1 -1
  45. package/dist/esm/core/aggregation/transport/http/server.js +1 -1
  46. package/dist/esm/core/aggregation/transport/http/sse-stream.js +1 -1
  47. package/dist/esm/core/aggregation/transport/http/sse-writer.js +1 -1
  48. package/dist/esm/core/aggregation/transport/in-memory.js +1 -1
  49. package/dist/esm/core/aggregation/transport/nostr.js +6 -6
  50. package/dist/esm/core/beacon/beacon.js +15 -10
  51. package/dist/esm/core/beacon/beacon.js.map +1 -1
  52. package/dist/esm/core/beacon/cas-beacon.js +6 -6
  53. package/dist/esm/core/beacon/cas-beacon.js.map +1 -1
  54. package/dist/esm/core/beacon/singleton-beacon.js +2 -2
  55. package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
  56. package/dist/esm/core/beacon/smt-beacon.js +3 -3
  57. package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
  58. package/dist/esm/core/resolver.js +8 -8
  59. package/dist/esm/core/resolver.js.map +1 -1
  60. package/dist/esm/core/updater.js +6 -6
  61. package/dist/esm/core/updater.js.map +1 -1
  62. package/dist/esm/did-btcr2.js +2 -2
  63. package/dist/esm/index.js +3 -0
  64. package/dist/esm/index.js.map +1 -1
  65. package/dist/types/core/aggregation/beacon-strategy.d.ts +8 -4
  66. package/dist/types/core/aggregation/beacon-strategy.d.ts.map +1 -1
  67. package/dist/types/core/aggregation/cohort.d.ts +88 -17
  68. package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
  69. package/dist/types/core/aggregation/conditions.d.ts +28 -0
  70. package/dist/types/core/aggregation/conditions.d.ts.map +1 -1
  71. package/dist/types/core/aggregation/fallback-spend.d.ts +94 -0
  72. package/dist/types/core/aggregation/fallback-spend.d.ts.map +1 -0
  73. package/dist/types/core/aggregation/logger.d.ts +1 -1
  74. package/dist/types/core/aggregation/messages/base.d.ts +6 -0
  75. package/dist/types/core/aggregation/messages/base.d.ts.map +1 -1
  76. package/dist/types/core/aggregation/messages/bodies.d.ts +50 -2
  77. package/dist/types/core/aggregation/messages/bodies.d.ts.map +1 -1
  78. package/dist/types/core/aggregation/messages/constants.d.ts +8 -0
  79. package/dist/types/core/aggregation/messages/constants.d.ts.map +1 -1
  80. package/dist/types/core/aggregation/messages/factories.d.ts +40 -0
  81. package/dist/types/core/aggregation/messages/factories.d.ts.map +1 -1
  82. package/dist/types/core/aggregation/messages/guards.d.ts.map +1 -1
  83. package/dist/types/core/aggregation/participant.d.ts +47 -4
  84. package/dist/types/core/aggregation/participant.d.ts.map +1 -1
  85. package/dist/types/core/aggregation/phases.d.ts +8 -2
  86. package/dist/types/core/aggregation/phases.d.ts.map +1 -1
  87. package/dist/types/core/aggregation/recovery-policy.d.ts +132 -0
  88. package/dist/types/core/aggregation/recovery-policy.d.ts.map +1 -0
  89. package/dist/types/core/aggregation/recovery-spend.d.ts +94 -0
  90. package/dist/types/core/aggregation/recovery-spend.d.ts.map +1 -0
  91. package/dist/types/core/aggregation/runner/aggregation-runner.d.ts +13 -3
  92. package/dist/types/core/aggregation/runner/aggregation-runner.d.ts.map +1 -1
  93. package/dist/types/core/aggregation/runner/events.d.ts +48 -16
  94. package/dist/types/core/aggregation/runner/events.d.ts.map +1 -1
  95. package/dist/types/core/aggregation/runner/participant-runner.d.ts +23 -14
  96. package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
  97. package/dist/types/core/aggregation/runner/service-runner.d.ts +102 -23
  98. package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
  99. package/dist/types/core/aggregation/service.d.ts +26 -2
  100. package/dist/types/core/aggregation/service.d.ts.map +1 -1
  101. package/dist/types/core/aggregation/signing-session.d.ts +2 -2
  102. package/dist/types/core/aggregation/signing-session.d.ts.map +1 -1
  103. package/dist/types/core/aggregation/transport/http/envelope.d.ts +3 -3
  104. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts +1 -1
  105. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts +1 -1
  106. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts +1 -1
  107. package/dist/types/core/aggregation/transport/http/request-auth.d.ts +1 -1
  108. package/dist/types/core/aggregation/transport/http/server.d.ts +1 -1
  109. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts +2 -2
  110. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts +1 -1
  111. package/dist/types/core/aggregation/transport/in-memory.d.ts +1 -1
  112. package/dist/types/core/aggregation/transport/nostr.d.ts +4 -4
  113. package/dist/types/core/aggregation/transport/transport.d.ts +4 -4
  114. package/dist/types/core/aggregation/transport/transport.d.ts.map +1 -1
  115. package/dist/types/core/beacon/beacon.d.ts +7 -7
  116. package/dist/types/core/beacon/beacon.d.ts.map +1 -1
  117. package/dist/types/core/beacon/cas-beacon.d.ts +2 -2
  118. package/dist/types/core/beacon/interfaces.d.ts +1 -1
  119. package/dist/types/core/resolver.d.ts +3 -3
  120. package/dist/types/core/updater.d.ts +4 -4
  121. package/dist/types/did-btcr2.d.ts +2 -2
  122. package/dist/types/index.d.ts +3 -0
  123. package/dist/types/index.d.ts.map +1 -1
  124. package/package.json +4 -4
  125. package/src/core/aggregation/beacon-strategy.ts +27 -9
  126. package/src/core/aggregation/cohort.ts +171 -35
  127. package/src/core/aggregation/conditions.ts +56 -0
  128. package/src/core/aggregation/fallback-spend.ts +223 -0
  129. package/src/core/aggregation/logger.ts +1 -1
  130. package/src/core/aggregation/messages/base.ts +6 -0
  131. package/src/core/aggregation/messages/bodies.ts +94 -1
  132. package/src/core/aggregation/messages/constants.ts +12 -0
  133. package/src/core/aggregation/messages/factories.ts +57 -0
  134. package/src/core/aggregation/messages/guards.ts +6 -0
  135. package/src/core/aggregation/participant.ts +264 -20
  136. package/src/core/aggregation/phases.ts +9 -0
  137. package/src/core/aggregation/recovery-policy.ts +237 -0
  138. package/src/core/aggregation/recovery-spend.ts +192 -0
  139. package/src/core/aggregation/runner/aggregation-runner.ts +27 -5
  140. package/src/core/aggregation/runner/events.ts +47 -19
  141. package/src/core/aggregation/runner/participant-runner.ts +113 -31
  142. package/src/core/aggregation/runner/service-runner.ts +484 -203
  143. package/src/core/aggregation/service.ts +260 -14
  144. package/src/core/aggregation/signing-session.ts +3 -3
  145. package/src/core/aggregation/transport/http/envelope.ts +3 -3
  146. package/src/core/aggregation/transport/http/inbox-buffer.ts +1 -1
  147. package/src/core/aggregation/transport/http/nonce-cache.ts +1 -1
  148. package/src/core/aggregation/transport/http/rate-limiter.ts +1 -1
  149. package/src/core/aggregation/transport/http/request-auth.ts +1 -1
  150. package/src/core/aggregation/transport/http/server.ts +1 -1
  151. package/src/core/aggregation/transport/http/sse-stream.ts +2 -2
  152. package/src/core/aggregation/transport/http/sse-writer.ts +1 -1
  153. package/src/core/aggregation/transport/in-memory.ts +1 -1
  154. package/src/core/aggregation/transport/nostr.ts +6 -6
  155. package/src/core/aggregation/transport/transport.ts +4 -4
  156. package/src/core/beacon/beacon.ts +16 -11
  157. package/src/core/beacon/cas-beacon.ts +7 -7
  158. package/src/core/beacon/interfaces.ts +1 -1
  159. package/src/core/beacon/singleton-beacon.ts +2 -2
  160. package/src/core/beacon/smt-beacon.ts +3 -3
  161. package/src/core/resolver.ts +8 -8
  162. package/src/core/updater.ts +6 -6
  163. package/src/did-btcr2.ts +2 -2
  164. package/src/index.ts +3 -0
@@ -1,12 +1,15 @@
1
1
  import { canonicalHash } from '@did-btcr2/common';
2
2
  import type { SignedBTCR2Update } from '@did-btcr2/cryptosuite';
3
3
  import type { SerializedSMTProof} from '@did-btcr2/smt';
4
+ import { schnorr } from '@noble/curves/secp256k1.js';
4
5
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
5
- import { Transaction } from '@scure/btc-signer';
6
+ import { Script, Transaction } from '@scure/btc-signer';
6
7
  import { getBeaconStrategy } from './beacon-strategy.js';
7
8
  import { AggregationCohort } from './cohort.js';
8
9
  import type { CohortConditions } from './conditions.js';
9
10
  import { AggregationParticipantError } from './errors.js';
11
+ import { fallbackSighash } from './fallback-spend.js';
12
+ import { buildFallbackLeaf } from './recovery-policy.js';
10
13
  import type { BaseMessage } from './messages/base.js';
11
14
  import { isCohortAdvertMessage } from './messages/bodies.js';
12
15
  import { AGGREGATION_WIRE_VERSION } from './messages/base.js';
@@ -17,11 +20,14 @@ import {
17
20
  COHORT_OPT_IN_ACCEPT,
18
21
  COHORT_READY,
19
22
  DISTRIBUTE_AGGREGATED_DATA,
23
+ FALLBACK_AUTHORIZATION_REQUEST,
20
24
  } from './messages/constants.js';
21
25
  import {
22
26
  createCohortOptInMessage,
27
+ createFallbackSignatureMessage,
23
28
  createNonceContributionMessage,
24
29
  createSignatureAuthorizationMessage,
30
+ createSubmitNonIncludedMessage,
25
31
  createSubmitUpdateMessage,
26
32
  createValidationAckMessage,
27
33
  } from './messages/factories.js';
@@ -30,6 +36,30 @@ import { ParticipantCohortPhase } from './phases.js';
30
36
  import type { AggregationSigner } from './signer.js';
31
37
  import { BeaconSigningSession } from './signing-session.js';
32
38
 
39
+ /**
40
+ * True if `tx` has an OP_RETURN output whose payload equals the 32-byte signal
41
+ * `signalHex`. A member binds its fallback signature to the exact signal it
42
+ * validated, so a coordinator that drives the fallback output selection cannot
43
+ * anchor a different announcement (a stale signal, or one whose CAS/SMT root
44
+ * omits the member's update) under the member's signature.
45
+ */
46
+ function txEmbedsSignal(tx: Transaction, signalHex: string): boolean {
47
+ let expected: Uint8Array;
48
+ try { expected = hexToBytes(signalHex); } catch { return false; }
49
+ if(expected.length === 0) return false;
50
+ for(let i = 0; i < tx.outputsLength; i++) {
51
+ const script = tx.getOutput(i)?.script;
52
+ if(!script) continue;
53
+ let decoded: Array<string | Uint8Array>;
54
+ try { decoded = Script.decode(script) as Array<string | Uint8Array>; } catch { continue; }
55
+ if(decoded.length === 2 && decoded[0] === 'RETURN' && decoded[1] instanceof Uint8Array) {
56
+ const payload = decoded[1];
57
+ if(payload.length === expected.length && payload.every((b, j) => b === expected[j])) return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+
33
63
  /**
34
64
  * Cohort advert as discovered by the participant (UI: list of joinable cohorts).
35
65
  * Carries the advertised {@link CohortConditions} (beaconType, minParticipants,
@@ -42,7 +72,7 @@ export interface CohortAdvert extends CohortConditions {
42
72
  serviceCommunicationPk: Uint8Array;
43
73
  }
44
74
 
45
- /** Joined cohort info available after the cohort is finalized. */
75
+ /** Joined cohort info, available after the cohort is finalized. */
46
76
  export interface JoinedCohortInfo {
47
77
  cohortId: string;
48
78
  serviceDid: string;
@@ -57,8 +87,11 @@ export interface PendingValidation {
57
87
  signalBytesHex: string;
58
88
  casAnnouncement?: Record<string, string>;
59
89
  smtProof?: SerializedSMTProof;
90
+ /** Canonical hash of this participant's update; empty for a decliner. */
60
91
  expectedHash: string;
61
92
  matches: boolean;
93
+ /** True if this participant submitted an update; false if it declined (non-inclusion). */
94
+ included: boolean;
62
95
  }
63
96
 
64
97
  /** Pending signing request (UI: review tx for approval). */
@@ -71,7 +104,22 @@ export interface PendingSigningRequest {
71
104
  prevOutValue: string;
72
105
  }
73
106
 
74
- /** Per-cohort participant state — internal. */
107
+ /**
108
+ * Pending fallback signing request (UI: review the fallback spend for approval).
109
+ * The service fell back to the k-of-n script path; the member signs the SAME
110
+ * beacon transaction over the fallback script-path sighash (ADR 042).
111
+ */
112
+ export interface PendingFallbackRequest {
113
+ cohortId: string;
114
+ sessionId: string;
115
+ pendingTxHex: string;
116
+ prevOutScriptHex: string;
117
+ prevOutValue: string;
118
+ /** Fallback leaf script, hex (advisory; the member recomputes it from its own cohort). */
119
+ fallbackLeafScriptHex: string;
120
+ }
121
+
122
+ /** Per-cohort participant state (internal). */
75
123
  interface ParticipantCohortState {
76
124
  phase: ParticipantCohortPhaseType;
77
125
  cohortId: string;
@@ -79,8 +127,15 @@ interface ParticipantCohortState {
79
127
  advert?: CohortAdvert;
80
128
  cohort?: AggregationCohort;
81
129
  submittedUpdate?: SignedBTCR2Update;
130
+ /**
131
+ * This round's intent, persisted because the phase advances past
132
+ * NonIncluded/UpdateSubmitted into validation/signing. true = submitted an
133
+ * update, false = declined (non-inclusion), undefined = not yet responded.
134
+ */
135
+ included?: boolean;
82
136
  validation?: PendingValidation;
83
137
  signingRequest?: PendingSigningRequest;
138
+ fallbackRequest?: PendingFallbackRequest;
84
139
  signingSession?: BeaconSigningSession;
85
140
  }
86
141
 
@@ -126,7 +181,7 @@ export class AggregationParticipant {
126
181
 
127
182
  /**
128
183
  * Process an incoming message. Updates internal state but never produces
129
- * outgoing messages those come exclusively from action methods.
184
+ * outgoing messages: those come exclusively from action methods.
130
185
  */
131
186
  public receive(message: BaseMessage): void {
132
187
  // Reject messages whose wire version doesn't match what this build speaks.
@@ -153,8 +208,11 @@ export class AggregationParticipant {
153
208
  case AGGREGATED_NONCE:
154
209
  this.#handleAggregatedNonce(message);
155
210
  break;
211
+ case FALLBACK_AUTHORIZATION_REQUEST:
212
+ this.#handleFallbackAuthorizationRequest(message);
213
+ break;
156
214
  default:
157
- // Unknown message type silently ignore
215
+ // Unknown message type, silently ignore
158
216
  break;
159
217
  }
160
218
  }
@@ -207,13 +265,20 @@ export class AggregationParticipant {
207
265
  );
208
266
  }
209
267
 
210
- // Create local cohort to track our view
268
+ // Create local cohort to track our view. Carry the advertised recovery
269
+ // params so validateMembership recomputes the same script-tree beacon
270
+ // address the service derived (ADR 042); a mismatch rejects the cohort.
271
+ const advert = state.advert!;
211
272
  const cohort = new AggregationCohort({
212
- id : cohortId,
213
- serviceDid : state.serviceDid,
214
- minParticipants : state.advert!.minParticipants,
215
- network : state.advert!.network,
216
- beaconType : state.advert!.beaconType,
273
+ id : cohortId,
274
+ serviceDid : state.serviceDid,
275
+ minParticipants : advert.minParticipants,
276
+ network : advert.network,
277
+ beaconType : advert.beaconType,
278
+ recoveryKey : advert.recoveryKey ? hexToBytes(advert.recoveryKey) : undefined,
279
+ recoverySequence : advert.recoverySequence,
280
+ fundingModel : advert.fundingModel,
281
+ fallbackThreshold : advert.fallbackThreshold,
217
282
  });
218
283
  state.cohort = cohort;
219
284
  state.phase = ParticipantCohortPhase.OptedIn;
@@ -230,12 +295,12 @@ export class AggregationParticipant {
230
295
  }
231
296
 
232
297
  #handleOptInAccept(message: BaseMessage): void {
233
- // Acknowledgment from service no state change needed
298
+ // Acknowledgment from service, no state change needed
234
299
  void message;
235
300
  }
236
301
 
237
302
 
238
- /** Cohorts that have been finalized beacon address available. */
303
+ /** Cohorts that have been finalized: beacon address available. */
239
304
  public get joinedCohorts(): ReadonlyMap<string, JoinedCohortInfo> {
240
305
  const map = new Map<string, JoinedCohortInfo>();
241
306
  for(const [id, state] of this.#cohortStates) {
@@ -284,6 +349,7 @@ export class AggregationParticipant {
284
349
  }
285
350
 
286
351
  state.submittedUpdate = signedUpdate;
352
+ state.included = true;
287
353
  state.phase = ParticipantCohortPhase.UpdateSubmitted;
288
354
 
289
355
  const message = createSubmitUpdateMessage({
@@ -295,6 +361,34 @@ export class AggregationParticipant {
295
361
  return [message];
296
362
  }
297
363
 
364
+ /**
365
+ * User action: decline to submit an update this round (cooperative
366
+ * non-inclusion). The member stays in the cohort and still signs; it will be
367
+ * absent from the CAS Announcement Map, or carry a non-inclusion leaf in the
368
+ * SMT. Returns the SUBMIT_NONINCLUDED message to send.
369
+ */
370
+ public declineUpdate(cohortId: string): BaseMessage[] {
371
+ const state = this.#cohortStates.get(cohortId);
372
+ if(!state || state.phase !== ParticipantCohortPhase.CohortReady) {
373
+ throw new AggregationParticipantError(
374
+ `Cannot decline in cohort ${cohortId}: not in CohortReady phase.`,
375
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
376
+ );
377
+ }
378
+
379
+ // Leave submittedUpdate unset; included=false is the load-bearing signal that
380
+ // the validation handler uses to validate the non-inclusion slot.
381
+ state.included = false;
382
+ state.phase = ParticipantCohortPhase.NonIncluded;
383
+
384
+ const message = createSubmitNonIncludedMessage({
385
+ from : this.did,
386
+ to : state.serviceDid,
387
+ cohortId,
388
+ });
389
+ return [message];
390
+ }
391
+
298
392
 
299
393
  /** Aggregated data awaiting user validation. */
300
394
  public get pendingValidations(): ReadonlyMap<string, PendingValidation> {
@@ -307,12 +401,29 @@ export class AggregationParticipant {
307
401
  return map;
308
402
  }
309
403
 
404
+ /**
405
+ * The validated aggregated data retained for a cohort, regardless of phase.
406
+ * Unlike {@link pendingValidations} (which lists only cohorts still awaiting
407
+ * the validate decision), this returns the stored validation, including the
408
+ * participant's sidecar (the CAS Announcement map or its SMT inclusion proof),
409
+ * so it is still readable once the cohort reaches Complete. Returns
410
+ * undefined before aggregated data has been received.
411
+ */
412
+ public getValidation(cohortId: string): PendingValidation | undefined {
413
+ return this.#cohortStates.get(cohortId)?.validation;
414
+ }
415
+
310
416
  #handleDistributeAggregatedData(message: BaseMessage): void {
311
417
  const cohortId = message.body?.cohortId;
312
418
  if(!cohortId) return;
313
419
  const state = this.#cohortStates.get(cohortId);
314
- if(!state || state.phase !== ParticipantCohortPhase.UpdateSubmitted) return;
315
- if(!state.submittedUpdate) return;
420
+ // A submitter is in UpdateSubmitted; a decliner (cooperative non-inclusion)
421
+ // is in NonIncluded. Both validate their own slot in the distributed data.
422
+ if(!state || (state.phase !== ParticipantCohortPhase.UpdateSubmitted && state.phase !== ParticipantCohortPhase.NonIncluded)) return;
423
+
424
+ const declined = state.included === false;
425
+ // A submitter must have its update stored; a decliner has none by design.
426
+ if(!declined && !state.submittedUpdate) return;
316
427
 
317
428
  const beaconType = message.body?.beaconType;
318
429
  if(!beaconType) return;
@@ -320,11 +431,14 @@ export class AggregationParticipant {
320
431
  if(!strategy) return;
321
432
 
322
433
  const signalBytesHex = message.body?.signalBytesHex ?? '';
323
- const expectedHash = canonicalHash(state.submittedUpdate);
434
+ // Decliner validates its non-inclusion slot (CAS absence / SMT non-inclusion
435
+ // proof); submitter validates inclusion against its update hash.
436
+ const expectedHash = declined ? '' : canonicalHash(state.submittedUpdate!);
324
437
  const result = strategy.validateParticipantView({
325
438
  participantDid : this.did,
326
- submittedUpdate : state.submittedUpdate,
327
- expectedHash,
439
+ included : !declined,
440
+ submittedUpdate : declined ? undefined : state.submittedUpdate,
441
+ expectedHash : declined ? undefined : expectedHash,
328
442
  body : message.body!,
329
443
  });
330
444
 
@@ -336,6 +450,7 @@ export class AggregationParticipant {
336
450
  matches : result.matches,
337
451
  casAnnouncement : result.casAnnouncement,
338
452
  smtProof : result.smtProof,
453
+ included : !declined,
339
454
  };
340
455
  state.phase = ParticipantCohortPhase.AwaitingValidation;
341
456
  }
@@ -415,6 +530,30 @@ export class AggregationParticipant {
415
530
  state.phase = ParticipantCohortPhase.AwaitingSigning;
416
531
  }
417
532
 
533
+ /**
534
+ * Bind a signing approval to the announcement the member validated: a beacon
535
+ * transaction MUST carry an OP_RETURN with the exact 32-byte signal stored when
536
+ * the aggregated data was distributed. Both the optimistic nonce approval and
537
+ * the fallback approval sign with SIGHASH_DEFAULT (committing to every output)
538
+ * while the coordinator drives output selection, so without this check a
539
+ * coordinator could anchor a different signal under the member's signature.
540
+ */
541
+ #assertTxAnchorsValidatedSignal(cohortId: string, state: ParticipantCohortState, tx: Transaction): void {
542
+ const signalHex = state.validation?.signalBytesHex;
543
+ if(!signalHex) {
544
+ throw new AggregationParticipantError(
545
+ `Cohort ${cohortId} has no validated signal to bind the signature to.`,
546
+ 'MISSING_STATE', { cohortId }
547
+ );
548
+ }
549
+ if(!txEmbedsSignal(tx, signalHex)) {
550
+ throw new AggregationParticipantError(
551
+ `Transaction for cohort ${cohortId} does not anchor the validated signal.`,
552
+ 'SIGNAL_MISMATCH', { cohortId }
553
+ );
554
+ }
555
+ }
556
+
418
557
  /**
419
558
  * User action: approve signing and generate nonce contribution.
420
559
  */
@@ -433,7 +572,13 @@ export class AggregationParticipant {
433
572
  );
434
573
  }
435
574
 
436
- const tx = Transaction.fromRaw(hexToBytes(state.signingRequest.pendingTxHex));
575
+ // allowUnknownOutputs: a beacon transaction carries an OP_RETURN signal
576
+ // output, which scure does not classify as a known (spendable) output type;
577
+ // re-parsing the raw tx would otherwise throw.
578
+ const tx = Transaction.fromRaw(hexToBytes(state.signingRequest.pendingTxHex), { allowUnknownOutputs: true });
579
+
580
+ // Refuse to sign unless the tx anchors the signal this member validated.
581
+ this.#assertTxAnchorsValidatedSignal(cohortId, state, tx);
437
582
 
438
583
  // Derive UTXO metadata for Taproot sighash (BIP-341). Use the script
439
584
  // supplied by the service in AUTHORIZATION_REQUEST rather than reading
@@ -483,7 +628,7 @@ export class AggregationParticipant {
483
628
 
484
629
  /**
485
630
  * User action: generate and return the partial signature.
486
- * In most UIs this is automatic after AwaitingPartialSig but exposing it
631
+ * In most UIs this is automatic after AwaitingPartialSig, but exposing it
487
632
  * as an explicit action lets the client UI confirm before signing if desired.
488
633
  */
489
634
  public generatePartialSignature(cohortId: string): BaseMessage[] {
@@ -517,6 +662,105 @@ export class AggregationParticipant {
517
662
  }
518
663
 
519
664
 
665
+ /** Fallback signing requests awaiting user approval (UI: review the fallback spend). */
666
+ public get pendingFallbackRequests(): ReadonlyMap<string, PendingFallbackRequest> {
667
+ const map = new Map<string, PendingFallbackRequest>();
668
+ for(const [id, state] of this.#cohortStates) {
669
+ if(state.phase === ParticipantCohortPhase.AwaitingFallbackSig && state.fallbackRequest) {
670
+ map.set(id, state.fallbackRequest);
671
+ }
672
+ }
673
+ return map;
674
+ }
675
+
676
+ #handleFallbackAuthorizationRequest(message: BaseMessage): void {
677
+ const cohortId = message.body?.cohortId;
678
+ if(!cohortId) return;
679
+ const state = this.#cohortStates.get(cohortId);
680
+ if(!state || !state.cohort) return;
681
+ // The service can fall back at any point after the member validated. This
682
+ // includes the local Complete phase a member reaches the moment it sends its
683
+ // optimistic partial signature: the cohort has NOT finalized (the service
684
+ // only falls back before optimistic completion), and those members are
685
+ // exactly the k signers the fallback needs. Signing both the optimistic
686
+ // partial sig and the fallback sig is safe - both authorize the same outputs,
687
+ // and only one witness can ever confirm the single UTXO. A genuinely failed
688
+ // member is excluded. Ignore a duplicate request already being processed.
689
+ const acceptFrom: ParticipantCohortPhaseType[] = [
690
+ ParticipantCohortPhase.ValidationSent,
691
+ ParticipantCohortPhase.AwaitingSigning,
692
+ ParticipantCohortPhase.NonceSent,
693
+ ParticipantCohortPhase.AwaitingPartialSig,
694
+ ParticipantCohortPhase.Complete,
695
+ ];
696
+ if(!acceptFrom.includes(state.phase)) return;
697
+
698
+ const sessionId = message.body?.sessionId;
699
+ const pendingTxHex = message.body?.pendingTx;
700
+ const prevOutScriptHex = message.body?.prevOutScriptHex;
701
+ const prevOutValue = message.body?.prevOutValue;
702
+ const fallbackLeafScriptHex = message.body?.fallbackLeafScriptHex;
703
+ if(!sessionId || !pendingTxHex || !prevOutScriptHex || !prevOutValue || !fallbackLeafScriptHex) return;
704
+
705
+ state.fallbackRequest = { cohortId, sessionId, pendingTxHex, prevOutScriptHex, prevOutValue, fallbackLeafScriptHex };
706
+ // The optimistic path is abandoned; wipe any retained secret nonce for it.
707
+ state.signingSession?.clearSecrets();
708
+ state.phase = ParticipantCohortPhase.AwaitingFallbackSig;
709
+ }
710
+
711
+ /**
712
+ * User action: authorize the fallback spend. Recomputes the k-of-n fallback
713
+ * leaf from the member's OWN cohort state (not the service-provided script),
714
+ * computes the BIP-341 script-path sighash over the requested transaction, and
715
+ * returns a standalone BIP-340 signature (no nonce round). The member completes
716
+ * once it has contributed; the service needs only k of these.
717
+ */
718
+ public approveFallback(cohortId: string): BaseMessage[] {
719
+ const state = this.#cohortStates.get(cohortId);
720
+ if(!state || state.phase !== ParticipantCohortPhase.AwaitingFallbackSig) {
721
+ throw new AggregationParticipantError(
722
+ `Cannot approve fallback for cohort ${cohortId}: not in AwaitingFallbackSig phase.`,
723
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
724
+ );
725
+ }
726
+ if(!state.fallbackRequest || !state.cohort) {
727
+ throw new AggregationParticipantError(
728
+ `Cohort ${cohortId} missing fallback request or cohort state.`,
729
+ 'MISSING_STATE', { cohortId }
730
+ );
731
+ }
732
+
733
+ const req = state.fallbackRequest;
734
+ const tx = Transaction.fromRaw(hexToBytes(req.pendingTxHex), { allowUnknownOutputs: true });
735
+ const prevOutScript = hexToBytes(req.prevOutScriptHex);
736
+ const prevOutValue = BigInt(req.prevOutValue);
737
+
738
+ // Refuse to sign unless the fallback tx anchors the signal this member
739
+ // validated (the coordinator drives output selection on the fallback path).
740
+ this.#assertTxAnchorsValidatedSignal(cohortId, state, tx);
741
+
742
+ // Recompute the fallback leaf from our own cohort keys so a malicious service
743
+ // cannot induce a signature over a different leaf than the one the funded
744
+ // address commits to.
745
+ const fallbackLeaf = buildFallbackLeaf({
746
+ cohortKeys : state.cohort.cohortKeys,
747
+ fallbackThreshold : state.cohort.effectiveFallbackThreshold,
748
+ });
749
+ const sighash = fallbackSighash(tx, 0, prevOutScript, prevOutValue, fallbackLeaf);
750
+ const signature = this.#signer.withSecret(secretKey => schnorr.sign(sighash, secretKey));
751
+
752
+ state.phase = ParticipantCohortPhase.Complete;
753
+ return [createFallbackSignatureMessage({
754
+ from : this.did,
755
+ to : state.serviceDid,
756
+ cohortId,
757
+ sessionId : req.sessionId,
758
+ signerPk : this.publicKey.slice(1),
759
+ fallbackSignature : signature,
760
+ })];
761
+ }
762
+
763
+
520
764
  public getCohortPhase(cohortId: string): ParticipantCohortPhaseType | undefined {
521
765
  return this.#cohortStates.get(cohortId)?.phase;
522
766
  }
@@ -19,6 +19,7 @@ export type ServiceCohortPhaseType =
19
19
  | 'SigningStarted'
20
20
  | 'NoncesCollected'
21
21
  | 'AwaitingPartialSigs'
22
+ | 'FallbackRequested'
22
23
  | 'Complete'
23
24
  | 'Failed';
24
25
 
@@ -33,6 +34,8 @@ export enum ServiceCohortPhase {
33
34
  SigningStarted = 'SigningStarted',
34
35
  NoncesCollected = 'NoncesCollected',
35
36
  AwaitingPartialSigs = 'AwaitingPartialSigs',
37
+ /** Optimistic n-of-n key path abandoned; collecting k-of-n fallback signatures (ADR 042). */
38
+ FallbackRequested = 'FallbackRequested',
36
39
  Complete = 'Complete',
37
40
  Failed = 'Failed',
38
41
  }
@@ -42,11 +45,13 @@ export type ParticipantCohortPhaseType =
42
45
  | 'OptedIn'
43
46
  | 'CohortReady'
44
47
  | 'UpdateSubmitted'
48
+ | 'NonIncluded'
45
49
  | 'AwaitingValidation'
46
50
  | 'ValidationSent'
47
51
  | 'AwaitingSigning'
48
52
  | 'NonceSent'
49
53
  | 'AwaitingPartialSig'
54
+ | 'AwaitingFallbackSig'
50
55
  | 'Complete'
51
56
  | 'Failed';
52
57
 
@@ -55,11 +60,15 @@ export enum ParticipantCohortPhase {
55
60
  OptedIn = 'OptedIn',
56
61
  CohortReady = 'CohortReady',
57
62
  UpdateSubmitted = 'UpdateSubmitted',
63
+ /** Member declined to submit an update this round (cooperative non-inclusion); still signs. */
64
+ NonIncluded = 'NonIncluded',
58
65
  AwaitingValidation = 'AwaitingValidation',
59
66
  ValidationSent = 'ValidationSent',
60
67
  AwaitingSigning = 'AwaitingSigning',
61
68
  NonceSent = 'NonceSent',
62
69
  AwaitingPartialSig = 'AwaitingPartialSig',
70
+ /** Service fell back to the k-of-n script path; member can sign the fallback (ADR 042). */
71
+ AwaitingFallbackSig = 'AwaitingFallbackSig',
63
72
  Complete = 'Complete',
64
73
  Failed = 'Failed',
65
74
  }