@did-btcr2/aggregation 0.1.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 (233) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +19 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/browser.js +24017 -0
  5. package/dist/browser.mjs +24011 -0
  6. package/dist/cjs/index.js +5690 -0
  7. package/dist/cjs/package.json +1 -0
  8. package/dist/esm/beacon-strategy.js +76 -0
  9. package/dist/esm/beacon-strategy.js.map +1 -0
  10. package/dist/esm/cohort.js +316 -0
  11. package/dist/esm/cohort.js.map +1 -0
  12. package/dist/esm/conditions.js +102 -0
  13. package/dist/esm/conditions.js.map +1 -0
  14. package/dist/esm/errors.js +22 -0
  15. package/dist/esm/errors.js.map +1 -0
  16. package/dist/esm/fallback-spend.js +132 -0
  17. package/dist/esm/fallback-spend.js.map +1 -0
  18. package/dist/esm/index.js +21 -0
  19. package/dist/esm/index.js.map +1 -0
  20. package/dist/esm/logger.js +15 -0
  21. package/dist/esm/logger.js.map +1 -0
  22. package/dist/esm/messages/base.js +36 -0
  23. package/dist/esm/messages/base.js.map +1 -0
  24. package/dist/esm/messages/bodies.js +149 -0
  25. package/dist/esm/messages/bodies.js.map +1 -0
  26. package/dist/esm/messages/constants.js +37 -0
  27. package/dist/esm/messages/constants.js.map +1 -0
  28. package/dist/esm/messages/factories.js +139 -0
  29. package/dist/esm/messages/factories.js.map +1 -0
  30. package/dist/esm/messages/guards.js +40 -0
  31. package/dist/esm/messages/guards.js.map +1 -0
  32. package/dist/esm/messages/index.js +6 -0
  33. package/dist/esm/messages/index.js.map +1 -0
  34. package/dist/esm/participant.js +588 -0
  35. package/dist/esm/participant.js.map +1 -0
  36. package/dist/esm/phases.js +45 -0
  37. package/dist/esm/phases.js.map +1 -0
  38. package/dist/esm/recovery-policy.js +156 -0
  39. package/dist/esm/recovery-policy.js.map +1 -0
  40. package/dist/esm/recovery-spend.js +106 -0
  41. package/dist/esm/recovery-spend.js.map +1 -0
  42. package/dist/esm/runner/aggregation-runner.js +75 -0
  43. package/dist/esm/runner/aggregation-runner.js.map +1 -0
  44. package/dist/esm/runner/events.js +2 -0
  45. package/dist/esm/runner/events.js.map +1 -0
  46. package/dist/esm/runner/index.js +6 -0
  47. package/dist/esm/runner/index.js.map +1 -0
  48. package/dist/esm/runner/participant-runner.js +407 -0
  49. package/dist/esm/runner/participant-runner.js.map +1 -0
  50. package/dist/esm/runner/service-runner.js +701 -0
  51. package/dist/esm/runner/service-runner.js.map +1 -0
  52. package/dist/esm/runner/typed-emitter.js +80 -0
  53. package/dist/esm/runner/typed-emitter.js.map +1 -0
  54. package/dist/esm/service.js +774 -0
  55. package/dist/esm/service.js.map +1 -0
  56. package/dist/esm/signer.js +33 -0
  57. package/dist/esm/signer.js.map +1 -0
  58. package/dist/esm/signing-session.js +195 -0
  59. package/dist/esm/signing-session.js.map +1 -0
  60. package/dist/esm/transport/didcomm.js +41 -0
  61. package/dist/esm/transport/didcomm.js.map +1 -0
  62. package/dist/esm/transport/error.js +12 -0
  63. package/dist/esm/transport/error.js.map +1 -0
  64. package/dist/esm/transport/factory.js +29 -0
  65. package/dist/esm/transport/factory.js.map +1 -0
  66. package/dist/esm/transport/http/client.js +342 -0
  67. package/dist/esm/transport/http/client.js.map +1 -0
  68. package/dist/esm/transport/http/envelope.js +126 -0
  69. package/dist/esm/transport/http/envelope.js.map +1 -0
  70. package/dist/esm/transport/http/errors.js +11 -0
  71. package/dist/esm/transport/http/errors.js.map +1 -0
  72. package/dist/esm/transport/http/inbox-buffer.js +45 -0
  73. package/dist/esm/transport/http/inbox-buffer.js.map +1 -0
  74. package/dist/esm/transport/http/index.js +12 -0
  75. package/dist/esm/transport/http/index.js.map +1 -0
  76. package/dist/esm/transport/http/nonce-cache.js +38 -0
  77. package/dist/esm/transport/http/nonce-cache.js.map +1 -0
  78. package/dist/esm/transport/http/protocol.js +28 -0
  79. package/dist/esm/transport/http/protocol.js.map +1 -0
  80. package/dist/esm/transport/http/rate-limiter.js +45 -0
  81. package/dist/esm/transport/http/rate-limiter.js.map +1 -0
  82. package/dist/esm/transport/http/request-auth.js +100 -0
  83. package/dist/esm/transport/http/request-auth.js.map +1 -0
  84. package/dist/esm/transport/http/server.js +475 -0
  85. package/dist/esm/transport/http/server.js.map +1 -0
  86. package/dist/esm/transport/http/sse-stream.js +110 -0
  87. package/dist/esm/transport/http/sse-stream.js.map +1 -0
  88. package/dist/esm/transport/http/sse-writer.js +25 -0
  89. package/dist/esm/transport/http/sse-writer.js.map +1 -0
  90. package/dist/esm/transport/in-memory.js +149 -0
  91. package/dist/esm/transport/in-memory.js.map +1 -0
  92. package/dist/esm/transport/index.js +8 -0
  93. package/dist/esm/transport/index.js.map +1 -0
  94. package/dist/esm/transport/nostr.js +491 -0
  95. package/dist/esm/transport/nostr.js.map +1 -0
  96. package/dist/esm/transport/transport.js +2 -0
  97. package/dist/esm/transport/transport.js.map +1 -0
  98. package/dist/types/beacon-strategy.d.ts +56 -0
  99. package/dist/types/beacon-strategy.d.ts.map +1 -0
  100. package/dist/types/cohort.d.ts +188 -0
  101. package/dist/types/cohort.d.ts.map +1 -0
  102. package/dist/types/conditions.d.ts +86 -0
  103. package/dist/types/conditions.d.ts.map +1 -0
  104. package/dist/types/errors.d.ts +14 -0
  105. package/dist/types/errors.d.ts.map +1 -0
  106. package/dist/types/fallback-spend.d.ts +94 -0
  107. package/dist/types/fallback-spend.d.ts.map +1 -0
  108. package/dist/types/index.d.ts +17 -0
  109. package/dist/types/index.d.ts.map +1 -0
  110. package/dist/types/logger.d.ts +22 -0
  111. package/dist/types/logger.d.ts.map +1 -0
  112. package/dist/types/messages/base.d.ts +58 -0
  113. package/dist/types/messages/base.d.ts.map +1 -0
  114. package/dist/types/messages/bodies.d.ts +177 -0
  115. package/dist/types/messages/bodies.d.ts.map +1 -0
  116. package/dist/types/messages/constants.d.ts +31 -0
  117. package/dist/types/messages/constants.d.ts.map +1 -0
  118. package/dist/types/messages/factories.d.ts +217 -0
  119. package/dist/types/messages/factories.d.ts.map +1 -0
  120. package/dist/types/messages/guards.d.ts +11 -0
  121. package/dist/types/messages/guards.d.ts.map +1 -0
  122. package/dist/types/messages/index.d.ts +6 -0
  123. package/dist/types/messages/index.d.ts.map +1 -0
  124. package/dist/types/participant.d.ts +162 -0
  125. package/dist/types/participant.d.ts.map +1 -0
  126. package/dist/types/phases.d.ts +55 -0
  127. package/dist/types/phases.d.ts.map +1 -0
  128. package/dist/types/recovery-policy.d.ts +132 -0
  129. package/dist/types/recovery-policy.d.ts.map +1 -0
  130. package/dist/types/recovery-spend.d.ts +94 -0
  131. package/dist/types/recovery-spend.d.ts.map +1 -0
  132. package/dist/types/runner/aggregation-runner.d.ts +72 -0
  133. package/dist/types/runner/aggregation-runner.d.ts.map +1 -0
  134. package/dist/types/runner/events.d.ts +147 -0
  135. package/dist/types/runner/events.d.ts.map +1 -0
  136. package/dist/types/runner/index.d.ts +6 -0
  137. package/dist/types/runner/index.d.ts.map +1 -0
  138. package/dist/types/runner/participant-runner.d.ts +122 -0
  139. package/dist/types/runner/participant-runner.d.ts.map +1 -0
  140. package/dist/types/runner/service-runner.d.ts +228 -0
  141. package/dist/types/runner/service-runner.d.ts.map +1 -0
  142. package/dist/types/runner/typed-emitter.d.ts +41 -0
  143. package/dist/types/runner/typed-emitter.d.ts.map +1 -0
  144. package/dist/types/service.d.ts +177 -0
  145. package/dist/types/service.d.ts.map +1 -0
  146. package/dist/types/signer.d.ts +50 -0
  147. package/dist/types/signer.d.ts.map +1 -0
  148. package/dist/types/signing-session.d.ts +81 -0
  149. package/dist/types/signing-session.d.ts.map +1 -0
  150. package/dist/types/transport/didcomm.d.ts +23 -0
  151. package/dist/types/transport/didcomm.d.ts.map +1 -0
  152. package/dist/types/transport/error.d.ts +8 -0
  153. package/dist/types/transport/error.d.ts.map +1 -0
  154. package/dist/types/transport/factory.d.ts +28 -0
  155. package/dist/types/transport/factory.d.ts.map +1 -0
  156. package/dist/types/transport/http/client.d.ts +56 -0
  157. package/dist/types/transport/http/client.d.ts.map +1 -0
  158. package/dist/types/transport/http/envelope.d.ts +64 -0
  159. package/dist/types/transport/http/envelope.d.ts.map +1 -0
  160. package/dist/types/transport/http/errors.d.ts +9 -0
  161. package/dist/types/transport/http/errors.d.ts.map +1 -0
  162. package/dist/types/transport/http/inbox-buffer.d.ts +32 -0
  163. package/dist/types/transport/http/inbox-buffer.d.ts.map +1 -0
  164. package/dist/types/transport/http/index.d.ts +12 -0
  165. package/dist/types/transport/http/index.d.ts.map +1 -0
  166. package/dist/types/transport/http/nonce-cache.d.ts +26 -0
  167. package/dist/types/transport/http/nonce-cache.d.ts.map +1 -0
  168. package/dist/types/transport/http/protocol.d.ts +53 -0
  169. package/dist/types/transport/http/protocol.d.ts.map +1 -0
  170. package/dist/types/transport/http/rate-limiter.d.ts +41 -0
  171. package/dist/types/transport/http/rate-limiter.d.ts.map +1 -0
  172. package/dist/types/transport/http/request-auth.d.ts +50 -0
  173. package/dist/types/transport/http/request-auth.d.ts.map +1 -0
  174. package/dist/types/transport/http/server.d.ts +118 -0
  175. package/dist/types/transport/http/server.d.ts.map +1 -0
  176. package/dist/types/transport/http/sse-stream.d.ts +34 -0
  177. package/dist/types/transport/http/sse-stream.d.ts.map +1 -0
  178. package/dist/types/transport/http/sse-writer.d.ts +12 -0
  179. package/dist/types/transport/http/sse-writer.d.ts.map +1 -0
  180. package/dist/types/transport/in-memory.d.ts +64 -0
  181. package/dist/types/transport/in-memory.d.ts.map +1 -0
  182. package/dist/types/transport/index.d.ts +8 -0
  183. package/dist/types/transport/index.d.ts.map +1 -0
  184. package/dist/types/transport/nostr.d.ts +153 -0
  185. package/dist/types/transport/nostr.d.ts.map +1 -0
  186. package/dist/types/transport/transport.d.ts +62 -0
  187. package/dist/types/transport/transport.d.ts.map +1 -0
  188. package/package.json +128 -0
  189. package/src/beacon-strategy.ts +142 -0
  190. package/src/cohort.ts +422 -0
  191. package/src/conditions.ts +172 -0
  192. package/src/errors.ts +25 -0
  193. package/src/fallback-spend.ts +223 -0
  194. package/src/index.ts +23 -0
  195. package/src/logger.ts +33 -0
  196. package/src/messages/base.ts +80 -0
  197. package/src/messages/bodies.ts +328 -0
  198. package/src/messages/constants.ts +40 -0
  199. package/src/messages/factories.ts +297 -0
  200. package/src/messages/guards.ts +61 -0
  201. package/src/messages/index.ts +5 -0
  202. package/src/participant.ts +779 -0
  203. package/src/phases.ts +91 -0
  204. package/src/recovery-policy.ts +237 -0
  205. package/src/recovery-spend.ts +192 -0
  206. package/src/runner/aggregation-runner.ts +125 -0
  207. package/src/runner/events.ts +129 -0
  208. package/src/runner/index.ts +5 -0
  209. package/src/runner/participant-runner.ts +488 -0
  210. package/src/runner/service-runner.ts +891 -0
  211. package/src/runner/typed-emitter.ts +87 -0
  212. package/src/service.ts +1000 -0
  213. package/src/signer.ts +67 -0
  214. package/src/signing-session.ts +290 -0
  215. package/src/transport/didcomm.ts +59 -0
  216. package/src/transport/error.ts +13 -0
  217. package/src/transport/factory.ts +65 -0
  218. package/src/transport/http/client.ts +409 -0
  219. package/src/transport/http/envelope.ts +204 -0
  220. package/src/transport/http/errors.ts +11 -0
  221. package/src/transport/http/inbox-buffer.ts +53 -0
  222. package/src/transport/http/index.ts +11 -0
  223. package/src/transport/http/nonce-cache.ts +43 -0
  224. package/src/transport/http/protocol.ts +57 -0
  225. package/src/transport/http/rate-limiter.ts +75 -0
  226. package/src/transport/http/request-auth.ts +164 -0
  227. package/src/transport/http/server.ts +617 -0
  228. package/src/transport/http/sse-stream.ts +121 -0
  229. package/src/transport/http/sse-writer.ts +23 -0
  230. package/src/transport/in-memory.ts +178 -0
  231. package/src/transport/index.ts +7 -0
  232. package/src/transport/nostr.ts +576 -0
  233. package/src/transport/transport.ts +79 -0
@@ -0,0 +1,774 @@
1
+ import { canonicalize } from '@did-btcr2/common';
2
+ import { BIP340Cryptosuite, SchnorrMultikey } from '@did-btcr2/cryptosuite';
3
+ import { schnorr } from '@noble/curves/secp256k1.js';
4
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
5
+ import { getBeaconStrategy } from './beacon-strategy.js';
6
+ import { AggregationCohort } from './cohort.js';
7
+ import { validateCohortConditions } from './conditions.js';
8
+ import { AggregationServiceError } from './errors.js';
9
+ import { buildFallbackSpend, fallbackSighash } from './fallback-spend.js';
10
+ import { buildFallbackLeaf } from './recovery-policy.js';
11
+ import { AGGREGATION_WIRE_VERSION } from './messages/base.js';
12
+ import { COHORT_OPT_IN, FALLBACK_SIGNATURE, NONCE_CONTRIBUTION, SIGNATURE_AUTHORIZATION, SUBMIT_NONINCLUDED, SUBMIT_UPDATE, VALIDATION_ACK, } from './messages/constants.js';
13
+ import { createAggregatedNonceMessage, createAuthorizationRequestMessage, createCohortAdvertMessage, createCohortOptInAcceptMessage, createCohortReadyMessage, createDistributeAggregatedDataMessage, createFallbackAuthorizationRequestMessage, } from './messages/factories.js';
14
+ import { ServiceCohortPhase } from './phases.js';
15
+ import { BeaconSigningSession } from './signing-session.js';
16
+ /** Default maximum canonicalized byte-length of a submitted BTCR2 update. */
17
+ export const DEFAULT_MAX_UPDATE_SIZE_BYTES = 256 * 1024;
18
+ /**
19
+ * Sans-I/O state machine for an Aggregation Service.
20
+ *
21
+ * Manages multiple cohorts simultaneously. The service operator drives the
22
+ * state machine via `receive()` (for incoming messages) and explicit action
23
+ * methods (advertising, accepting opt-ins, finalizing keygen, building
24
+ * aggregated data, starting signing). All outgoing messages are returned for
25
+ * the caller to send via whatever transport.
26
+ *
27
+ * @class AggregationService
28
+ */
29
+ export class AggregationService {
30
+ did;
31
+ publicKey;
32
+ maxUpdateSizeBytes;
33
+ /** Per-cohort state, keyed by cohortId. */
34
+ #cohortStates = new Map();
35
+ constructor({ did, publicKey, maxUpdateSizeBytes }) {
36
+ this.did = did;
37
+ this.publicKey = publicKey;
38
+ this.maxUpdateSizeBytes = maxUpdateSizeBytes ?? DEFAULT_MAX_UPDATE_SIZE_BYTES;
39
+ }
40
+ receive(message) {
41
+ // Reject messages whose wire version doesn't match what this build speaks.
42
+ // Missing version, treat as legacy and drop: bumping the protocol must be
43
+ // coordinated across all participants.
44
+ const version = message.version;
45
+ if (version === undefined || version !== AGGREGATION_WIRE_VERSION) {
46
+ const cohortId = message.body?.cohortId;
47
+ const state = cohortId ? this.#cohortStates.get(cohortId) : undefined;
48
+ if (state) {
49
+ state.rejections.push({
50
+ from: message.from,
51
+ code: 'WRONG_VERSION',
52
+ reason: `Expected wire version ${AGGREGATION_WIRE_VERSION}, got ${String(version)}`,
53
+ });
54
+ }
55
+ return;
56
+ }
57
+ const type = message.type;
58
+ switch (type) {
59
+ case COHORT_OPT_IN:
60
+ this.#handleOptIn(message);
61
+ break;
62
+ case SUBMIT_UPDATE:
63
+ this.#handleSubmitUpdate(message);
64
+ break;
65
+ case SUBMIT_NONINCLUDED:
66
+ this.#handleSubmitNonInclusion(message);
67
+ break;
68
+ case VALIDATION_ACK:
69
+ this.#handleValidationAck(message);
70
+ break;
71
+ case NONCE_CONTRIBUTION:
72
+ this.#handleNonceContribution(message);
73
+ break;
74
+ case SIGNATURE_AUTHORIZATION:
75
+ this.#handleSignatureAuthorization(message);
76
+ break;
77
+ case FALLBACK_SIGNATURE:
78
+ this.#handleFallbackSignature(message);
79
+ break;
80
+ default:
81
+ // Unknown message type - silently ignore
82
+ break;
83
+ }
84
+ }
85
+ /**
86
+ * Drain the rejection log for a cohort. Used by runners to surface silent
87
+ * drops (bad proof, oversized update, wrong version, etc.) as structured
88
+ * events without breaking the sans-I/O state machine contract.
89
+ */
90
+ drainRejections(cohortId) {
91
+ const state = this.#cohortStates.get(cohortId);
92
+ if (!state)
93
+ return [];
94
+ const out = state.rejections;
95
+ state.rejections = [];
96
+ return out;
97
+ }
98
+ /**
99
+ * Create a new cohort with the given config. Returns the cohort ID.
100
+ * Cohort starts in `Created` phase - call `advertise()` to broadcast.
101
+ */
102
+ createCohort(config) {
103
+ // Fail fast on invalid conditions rather than discovering them at finalize.
104
+ const problems = validateCohortConditions(config);
105
+ if (problems.length > 0) {
106
+ throw new AggregationServiceError(`Invalid cohort conditions: ${problems.join('; ')}`, 'INVALID_COHORT_CONDITIONS', { problems });
107
+ }
108
+ const cohort = new AggregationCohort({
109
+ serviceDid: this.did,
110
+ minParticipants: config.minParticipants,
111
+ network: config.network,
112
+ beaconType: config.beaconType,
113
+ recoveryKey: hexToBytes(config.recoveryKey),
114
+ recoverySequence: config.recoverySequence,
115
+ fundingModel: config.fundingModel,
116
+ fallbackThreshold: config.fallbackThreshold,
117
+ });
118
+ this.#cohortStates.set(cohort.id, {
119
+ phase: ServiceCohortPhase.Created,
120
+ cohort,
121
+ config,
122
+ pendingOptIns: new Map(),
123
+ acceptedParticipants: new Set(),
124
+ rejections: [],
125
+ });
126
+ return cohort.id;
127
+ }
128
+ /**
129
+ * Advertise a cohort to discover participants.
130
+ * Returns the advert message to broadcast.
131
+ */
132
+ advertise(cohortId) {
133
+ const state = this.#cohortStates.get(cohortId);
134
+ if (!state) {
135
+ throw new AggregationServiceError(`Cohort ${cohortId} not found.`, 'COHORT_NOT_FOUND', { cohortId });
136
+ }
137
+ if (state.phase !== ServiceCohortPhase.Created) {
138
+ throw new AggregationServiceError(`Cannot advertise cohort ${cohortId}: phase is ${state.phase}.`, 'INVALID_PHASE', { cohortId, phase: state.phase });
139
+ }
140
+ // Advertise the full condition set (flat fields, per ADR 039). network is
141
+ // a separate cohort parameter; everything else in config is a condition.
142
+ const { network, ...conditions } = state.config;
143
+ const message = createCohortAdvertMessage({
144
+ from: this.did,
145
+ cohortId,
146
+ network,
147
+ communicationPk: this.publicKey.compressed,
148
+ ...conditions,
149
+ });
150
+ state.phase = ServiceCohortPhase.Advertised;
151
+ return [message];
152
+ }
153
+ /** Pending opt-ins awaiting operator approval. */
154
+ pendingOptIns(cohortId) {
155
+ const state = this.#cohortStates.get(cohortId);
156
+ if (!state)
157
+ return new Map();
158
+ // Return only those not yet accepted
159
+ const map = new Map();
160
+ for (const [did, optIn] of state.pendingOptIns) {
161
+ if (!state.acceptedParticipants.has(did)) {
162
+ map.set(did, optIn);
163
+ }
164
+ }
165
+ return map;
166
+ }
167
+ #handleOptIn(message) {
168
+ const cohortId = message.body?.cohortId;
169
+ if (!cohortId)
170
+ return;
171
+ const state = this.#cohortStates.get(cohortId);
172
+ if (!state)
173
+ return;
174
+ if (state.phase !== ServiceCohortPhase.Advertised)
175
+ return;
176
+ const participantDid = message.from;
177
+ const participantPk = message.body?.participantPk;
178
+ const communicationPk = message.body?.communicationPk;
179
+ if (!participantPk || !communicationPk)
180
+ return;
181
+ // Reject re-opt-in from already-accepted participants. Without this guard a
182
+ // participant could send a second opt-in with a different key, overwriting
183
+ // pendingOptIns[did] while cohortKeys still holds the original key - opening
184
+ // a desync window where #verifySubmittedUpdate accepts updates signed with
185
+ // a key that is NOT in the MuSig2 cohort.
186
+ if (state.acceptedParticipants.has(participantDid))
187
+ return;
188
+ state.pendingOptIns.set(participantDid, {
189
+ cohortId,
190
+ participantDid,
191
+ participantPk,
192
+ communicationPk,
193
+ });
194
+ }
195
+ /**
196
+ * Service operator accepts a participant's opt-in.
197
+ * Returns the accept message to send.
198
+ */
199
+ acceptParticipant(cohortId, participantDid) {
200
+ const state = this.#cohortStates.get(cohortId);
201
+ if (!state) {
202
+ throw new AggregationServiceError(`Cohort ${cohortId} not found.`, 'COHORT_NOT_FOUND', { cohortId });
203
+ }
204
+ const optIn = state.pendingOptIns.get(participantDid);
205
+ if (!optIn) {
206
+ throw new AggregationServiceError(`No pending opt-in from ${participantDid} for cohort ${cohortId}.`, 'NO_OPT_IN', { cohortId, participantDid });
207
+ }
208
+ if (state.acceptedParticipants.has(participantDid)) {
209
+ throw new AggregationServiceError(`Participant ${participantDid} already accepted into cohort ${cohortId}.`, 'ALREADY_ACCEPTED', { cohortId, participantDid });
210
+ }
211
+ // Enforce the maxParticipants condition: a cohort cannot grow past its
212
+ // advertised ceiling (closes the unbounded-growth path; see ADR 039).
213
+ const maxParticipants = state.config.maxParticipants;
214
+ if (maxParticipants !== undefined && state.acceptedParticipants.size >= maxParticipants) {
215
+ throw new AggregationServiceError(`Cohort ${cohortId} is full: ${maxParticipants} participants already accepted.`, 'COHORT_FULL', { cohortId, maxParticipants });
216
+ }
217
+ state.acceptedParticipants.add(participantDid);
218
+ state.cohort.participants.push(participantDid);
219
+ state.cohort.participantKeys.set(participantDid, optIn.participantPk);
220
+ state.cohort.cohortKeys = [...state.cohort.cohortKeys, optIn.participantPk];
221
+ return [createCohortOptInAcceptMessage({
222
+ from: this.did,
223
+ to: participantDid,
224
+ cohortId,
225
+ })];
226
+ }
227
+ /**
228
+ * Finalize cohort keygen: compute MuSig2 Taproot beacon address and send
229
+ * COHORT_READY messages to all accepted participants.
230
+ */
231
+ finalizeKeygen(cohortId) {
232
+ const state = this.#cohortStates.get(cohortId);
233
+ if (!state) {
234
+ throw new AggregationServiceError(`Cohort ${cohortId} not found.`, 'COHORT_NOT_FOUND', { cohortId });
235
+ }
236
+ if (state.phase !== ServiceCohortPhase.Advertised) {
237
+ throw new AggregationServiceError(`Cannot finalize keygen for cohort ${cohortId}: phase is ${state.phase}.`, 'INVALID_PHASE', { cohortId, phase: state.phase });
238
+ }
239
+ if (state.acceptedParticipants.size < state.config.minParticipants) {
240
+ throw new AggregationServiceError(`Cohort ${cohortId} has only ${state.acceptedParticipants.size} accepted participants, need ${state.config.minParticipants}.`, 'NOT_ENOUGH_PARTICIPANTS', { cohortId });
241
+ }
242
+ // Ceiling defense: acceptParticipant already rejects past max, so reaching
243
+ // here over max means state was mutated out-of-band.
244
+ const maxParticipants = state.config.maxParticipants;
245
+ if (maxParticipants !== undefined && state.acceptedParticipants.size > maxParticipants) {
246
+ throw new AggregationServiceError(`Cohort ${cohortId} has ${state.acceptedParticipants.size} accepted participants, exceeds max ${maxParticipants}.`, 'TOO_MANY_PARTICIPANTS', { cohortId, maxParticipants });
247
+ }
248
+ const beaconAddress = state.cohort.computeBeaconAddress();
249
+ state.phase = ServiceCohortPhase.CohortSet;
250
+ const messages = [];
251
+ for (const participantDid of state.cohort.participants) {
252
+ messages.push(createCohortReadyMessage({
253
+ from: this.did,
254
+ to: participantDid,
255
+ cohortId,
256
+ beaconAddress,
257
+ cohortKeys: state.cohort.cohortKeys,
258
+ }));
259
+ }
260
+ return messages;
261
+ }
262
+ /** Updates collected so far for a cohort. */
263
+ collectedUpdates(cohortId) {
264
+ const state = this.#cohortStates.get(cohortId);
265
+ if (!state)
266
+ return new Map();
267
+ return state.cohort.pendingUpdates;
268
+ }
269
+ /**
270
+ * Handle an incoming SUBMIT_UPDATE message from a participant containing their signed update to
271
+ * submit for aggregation.
272
+ * @param {BaseMessage} message - incoming SUBMIT_UPDATE message containing a participant's signed
273
+ * update to submit for aggregation
274
+ * @returns {void} - no return value; updates the service state with the submitted update if valid
275
+ */
276
+ #handleSubmitUpdate(message) {
277
+ const cohortId = message.body?.cohortId;
278
+ if (!cohortId)
279
+ return;
280
+ const state = this.#cohortStates.get(cohortId);
281
+ if (!state)
282
+ return;
283
+ if (state.phase !== ServiceCohortPhase.CohortSet && state.phase !== ServiceCohortPhase.CollectingUpdates)
284
+ return;
285
+ const signedUpdate = message.body?.signedUpdate;
286
+ if (!signedUpdate) {
287
+ state.rejections.push({
288
+ from: message.from,
289
+ code: 'UPDATE_MALFORMED',
290
+ reason: 'SUBMIT_UPDATE missing signedUpdate body',
291
+ });
292
+ return;
293
+ }
294
+ // Cap the canonicalized update size before doing any heavier verification
295
+ // work. Without this guard, a participant could submit multi-MB payloads
296
+ // that the service would canonicalize, hash, and aggregate - cheap DoS.
297
+ const canonicalSize = canonicalize(signedUpdate).length;
298
+ if (canonicalSize > this.maxUpdateSizeBytes) {
299
+ state.rejections.push({
300
+ from: message.from,
301
+ code: 'UPDATE_TOO_LARGE',
302
+ reason: `Canonicalized update is ${canonicalSize} bytes; max allowed is ${this.maxUpdateSizeBytes}`,
303
+ });
304
+ return;
305
+ }
306
+ // Verify the BIP-340 Data Integrity proof before aggregating. Without this check,
307
+ // a malicious cohort member could submit updates with garbage proofs, which the
308
+ // service would aggregate into the CAS announcement / SMT root and ultimately
309
+ // anchor on-chain with the cohort's MuSig2 signature.
310
+ if (!this.#verifySubmittedUpdate(state, message.from, signedUpdate)) {
311
+ state.rejections.push({
312
+ from: message.from,
313
+ code: 'UPDATE_VERIFICATION_FAILED',
314
+ reason: 'BIP-340 Data Integrity proof verification failed',
315
+ });
316
+ return;
317
+ }
318
+ // One response per round. A member that already declined cannot also submit,
319
+ // and a member that already submitted cannot resubmit (a silent overwrite
320
+ // would corrupt the aggregated data the member already validated against).
321
+ // Both are dropped as rejections, symmetric with #handleSubmitNonInclusion.
322
+ if (state.cohort.nonIncluded.has(message.from)) {
323
+ state.rejections.push({
324
+ from: message.from,
325
+ code: 'UPDATE_MALFORMED',
326
+ reason: 'Participant already declined this round; cannot also submit an update',
327
+ });
328
+ return;
329
+ }
330
+ if (state.cohort.pendingUpdates.has(message.from)) {
331
+ state.rejections.push({
332
+ from: message.from,
333
+ code: 'UPDATE_MALFORMED',
334
+ reason: 'Participant already submitted an update this round; cannot resubmit',
335
+ });
336
+ return;
337
+ }
338
+ state.cohort.addUpdate(message.from, signedUpdate);
339
+ if (state.phase === ServiceCohortPhase.CohortSet) {
340
+ state.phase = ServiceCohortPhase.CollectingUpdates;
341
+ }
342
+ if (state.cohort.hasAllResponses()) {
343
+ state.phase = ServiceCohortPhase.UpdatesCollected;
344
+ }
345
+ }
346
+ /**
347
+ * Handle an incoming SUBMIT_NONINCLUDED message: a member declares it has no
348
+ * update this round (cooperative non-inclusion). Membership is proven by the
349
+ * signed transport envelope, so the body carries only the cohortId. The member
350
+ * stays in the cohort and still signs; it is absent from the CAS map and
351
+ * carries a non-inclusion leaf in the SMT.
352
+ */
353
+ #handleSubmitNonInclusion(message) {
354
+ const cohortId = message.body?.cohortId;
355
+ if (!cohortId)
356
+ return;
357
+ const state = this.#cohortStates.get(cohortId);
358
+ if (!state)
359
+ return;
360
+ if (state.phase !== ServiceCohortPhase.CohortSet && state.phase !== ServiceCohortPhase.CollectingUpdates)
361
+ return;
362
+ // Membership is proven by the signed envelope sender. A non-member decline is
363
+ // dropped as a rejection, never thrown: addNonInclusion would otherwise throw
364
+ // UNKNOWN_PARTICIPANT out of receive() and fail the whole cohort, a DoS any
365
+ // non-member could trigger. Mirrors the SUBMIT_UPDATE verification path.
366
+ if (!state.cohort.participants.includes(message.from)) {
367
+ state.rejections.push({
368
+ from: message.from,
369
+ code: 'UPDATE_MALFORMED',
370
+ reason: 'Sender is not a member of this cohort',
371
+ });
372
+ return;
373
+ }
374
+ // One response per round: already submitted or already declined. Surface the
375
+ // conflict as a rejection, symmetric with the double-submit guard above.
376
+ if (state.cohort.pendingUpdates.has(message.from) || state.cohort.nonIncluded.has(message.from)) {
377
+ state.rejections.push({
378
+ from: message.from,
379
+ code: 'UPDATE_MALFORMED',
380
+ reason: 'Participant already responded this round',
381
+ });
382
+ return;
383
+ }
384
+ state.cohort.addNonInclusion(message.from);
385
+ if (state.phase === ServiceCohortPhase.CohortSet) {
386
+ state.phase = ServiceCohortPhase.CollectingUpdates;
387
+ }
388
+ if (state.cohort.hasAllResponses()) {
389
+ state.phase = ServiceCohortPhase.UpdatesCollected;
390
+ }
391
+ }
392
+ /**
393
+ * Verify the BIP-340 Schnorr Data Integrity proof on a submitted update using the
394
+ * participant's public key from their cohort opt-in. Returns `false` (and the
395
+ * update is silently dropped) if the proof is missing, the verificationMethod does
396
+ * not name the sender's DID, the participant has no opt-in on record, or the
397
+ * signature fails verification.
398
+ * @param {ServiceCohortState} state - the current state of the cohort to which the update was submitted
399
+ * @param {string} sender - the DID of the participant who submitted the update
400
+ * @param {SignedBTCR2Update} signedUpdate - the signed update containing the proof to verify
401
+ * @returns {boolean} - `true` if the proof is valid and the update can be accepted; `false` otherwise
402
+ */
403
+ #verifySubmittedUpdate(state, sender, signedUpdate) {
404
+ const proof = signedUpdate.proof;
405
+ if (!proof?.verificationMethod || !proof.proofValue)
406
+ return false;
407
+ // The proof must be signed by the sender's own key. Reject if the
408
+ // verificationMethod references a different DID.
409
+ const vmDid = proof.verificationMethod.split('#')[0];
410
+ if (vmDid !== sender)
411
+ return false;
412
+ const optIn = state.pendingOptIns.get(sender);
413
+ if (!optIn)
414
+ return false;
415
+ try {
416
+ const multikey = SchnorrMultikey.fromPublicKey({
417
+ id: proof.verificationMethod,
418
+ controller: sender,
419
+ publicKeyBytes: optIn.participantPk,
420
+ });
421
+ const suite = new BIP340Cryptosuite(multikey);
422
+ return suite.verifyProof(signedUpdate).verified === true;
423
+ }
424
+ catch {
425
+ return false;
426
+ }
427
+ }
428
+ /**
429
+ * Build the aggregated data structure (CAS Announcement or SMT tree) and
430
+ * return distribute messages to send to all participants for validation.
431
+ */
432
+ buildAndDistribute(cohortId) {
433
+ const state = this.#cohortStates.get(cohortId);
434
+ if (!state) {
435
+ throw new AggregationServiceError(`Cohort ${cohortId} not found.`, 'COHORT_NOT_FOUND', { cohortId });
436
+ }
437
+ if (state.phase !== ServiceCohortPhase.UpdatesCollected) {
438
+ throw new AggregationServiceError(`Cannot build aggregated data for cohort ${cohortId}: phase is ${state.phase}.`, 'INVALID_PHASE', { cohortId, phase: state.phase });
439
+ }
440
+ const strategy = getBeaconStrategy(state.config.beaconType);
441
+ if (!strategy) {
442
+ throw new AggregationServiceError(`Unsupported beacon type: ${state.config.beaconType}`, 'UNSUPPORTED_BEACON_TYPE', { cohortId, beaconType: state.config.beaconType });
443
+ }
444
+ strategy.buildAggregatedData(state.cohort);
445
+ const signalBytesHex = bytesToHex(state.cohort.signalBytes);
446
+ state.phase = ServiceCohortPhase.DataDistributed;
447
+ const messages = [];
448
+ for (const participantDid of state.cohort.participants) {
449
+ const payload = strategy.getDistributePayload(state.cohort, participantDid);
450
+ messages.push(createDistributeAggregatedDataMessage({
451
+ from: this.did,
452
+ to: participantDid,
453
+ cohortId,
454
+ beaconType: state.config.beaconType,
455
+ signalBytesHex,
456
+ casAnnouncement: payload.casAnnouncement,
457
+ smtProof: payload.smtProof,
458
+ }));
459
+ }
460
+ return messages;
461
+ }
462
+ validationProgress(cohortId) {
463
+ const state = this.#cohortStates.get(cohortId);
464
+ if (!state) {
465
+ return { approved: new Set(), rejected: new Set(), pending: new Set(), total: 0 };
466
+ }
467
+ const approved = state.cohort.validationAcks;
468
+ const rejected = state.cohort.validationRejections;
469
+ const allParticipants = new Set(state.cohort.participants);
470
+ const responded = new Set([...approved, ...rejected]);
471
+ const pending = new Set([...allParticipants].filter(p => !responded.has(p)));
472
+ return {
473
+ approved,
474
+ rejected,
475
+ pending,
476
+ total: allParticipants.size,
477
+ };
478
+ }
479
+ #handleValidationAck(message) {
480
+ const cohortId = message.body?.cohortId;
481
+ if (!cohortId)
482
+ return;
483
+ const state = this.#cohortStates.get(cohortId);
484
+ if (!state)
485
+ return;
486
+ if (state.phase !== ServiceCohortPhase.DataDistributed)
487
+ return;
488
+ const approved = message.body?.approved;
489
+ if (approved === undefined)
490
+ return;
491
+ state.cohort.addValidation(message.from, approved);
492
+ // Transition to Validated only when all participants approved.
493
+ // Transition to Failed when all responses are in but at least one rejected.
494
+ if (state.cohort.isFullyValidated()) {
495
+ state.phase = ServiceCohortPhase.Validated;
496
+ }
497
+ else if (state.cohort.hasAllValidationResponses()) {
498
+ state.phase = ServiceCohortPhase.Failed;
499
+ }
500
+ }
501
+ /**
502
+ * Start a signing session by creating auth requests for all participants.
503
+ * The caller provides the transaction data - typically built via
504
+ * `buildBeaconTransaction()` against a Bitcoin connection.
505
+ */
506
+ startSigning(cohortId, txData) {
507
+ const state = this.#cohortStates.get(cohortId);
508
+ if (!state) {
509
+ throw new AggregationServiceError(`Cohort ${cohortId} not found.`, 'COHORT_NOT_FOUND', { cohortId });
510
+ }
511
+ if (state.phase !== ServiceCohortPhase.Validated) {
512
+ throw new AggregationServiceError(`Cannot start signing for cohort ${cohortId}: phase is ${state.phase}.`, 'INVALID_PHASE', { cohortId, phase: state.phase });
513
+ }
514
+ const session = new BeaconSigningSession({
515
+ cohort: state.cohort,
516
+ pendingTx: txData.tx,
517
+ prevOutScripts: txData.prevOutScripts,
518
+ prevOutValues: txData.prevOutValues,
519
+ });
520
+ state.signingSession = session;
521
+ state.phase = ServiceCohortPhase.SigningStarted;
522
+ const prevOutScript = txData.prevOutScripts[0];
523
+ if (!prevOutScript) {
524
+ throw new AggregationServiceError(`Cannot start signing for cohort ${cohortId}: txData.prevOutScripts[0] is missing.`, 'MISSING_PREV_OUT_SCRIPT', { cohortId });
525
+ }
526
+ const messages = [];
527
+ for (const participantDid of state.cohort.participants) {
528
+ messages.push(createAuthorizationRequestMessage({
529
+ from: this.did,
530
+ to: participantDid,
531
+ cohortId,
532
+ sessionId: session.id,
533
+ pendingTx: txData.tx.hex,
534
+ prevOutScriptHex: bytesToHex(prevOutScript),
535
+ prevOutValue: txData.prevOutValues[0]?.toString() ?? '0',
536
+ }));
537
+ }
538
+ return messages;
539
+ }
540
+ #handleNonceContribution(message) {
541
+ const cohortId = message.body?.cohortId;
542
+ if (!cohortId)
543
+ return;
544
+ const state = this.#cohortStates.get(cohortId);
545
+ if (!state || !state.signingSession)
546
+ return;
547
+ if (state.phase !== ServiceCohortPhase.SigningStarted)
548
+ return;
549
+ const sessionId = message.body?.sessionId;
550
+ if (sessionId !== state.signingSession.id)
551
+ return;
552
+ const nonceContribution = message.body?.nonceContribution;
553
+ if (!nonceContribution)
554
+ return;
555
+ state.signingSession.addNonceContribution(message.from, nonceContribution);
556
+ if (state.signingSession.nonceContributions.size === state.cohort.participants.length) {
557
+ state.phase = ServiceCohortPhase.NoncesCollected;
558
+ }
559
+ }
560
+ /**
561
+ * Generate the aggregated nonce and return messages to send to participants.
562
+ * Call after `validationProgress(cohortId).approved.size === total`.
563
+ */
564
+ sendAggregatedNonce(cohortId) {
565
+ const state = this.#cohortStates.get(cohortId);
566
+ if (!state) {
567
+ throw new AggregationServiceError(`Cohort ${cohortId} not found.`, 'COHORT_NOT_FOUND', { cohortId });
568
+ }
569
+ if (state.phase !== ServiceCohortPhase.NoncesCollected || !state.signingSession) {
570
+ throw new AggregationServiceError(`Cannot send aggregated nonce for cohort ${cohortId}: phase is ${state.phase}.`, 'INVALID_PHASE', { cohortId, phase: state.phase });
571
+ }
572
+ const aggregatedNonce = state.signingSession.generateAggregatedNonce();
573
+ state.phase = ServiceCohortPhase.AwaitingPartialSigs;
574
+ const messages = [];
575
+ for (const participantDid of state.cohort.participants) {
576
+ messages.push(createAggregatedNonceMessage({
577
+ from: this.did,
578
+ to: participantDid,
579
+ cohortId,
580
+ sessionId: state.signingSession.id,
581
+ aggregatedNonce,
582
+ }));
583
+ }
584
+ return messages;
585
+ }
586
+ #handleSignatureAuthorization(message) {
587
+ const cohortId = message.body?.cohortId;
588
+ if (!cohortId)
589
+ return;
590
+ const state = this.#cohortStates.get(cohortId);
591
+ if (!state || !state.signingSession)
592
+ return;
593
+ if (state.phase !== ServiceCohortPhase.AwaitingPartialSigs)
594
+ return;
595
+ const sessionId = message.body?.sessionId;
596
+ if (sessionId !== state.signingSession.id)
597
+ return;
598
+ const partialSignature = message.body?.partialSignature;
599
+ if (!partialSignature)
600
+ return;
601
+ state.signingSession.addPartialSignature(message.from, partialSignature);
602
+ if (state.signingSession.partialSignatures.size === state.cohort.participants.length) {
603
+ // All partial sigs received - generate final signature
604
+ const signature = state.signingSession.generateFinalSignature();
605
+ // Set Taproot key-path witness (finalScriptWitness injects the aggregated MuSig2 sig)
606
+ state.signingSession.pendingTx.updateInput(0, { finalScriptWitness: [signature] });
607
+ state.result = {
608
+ cohortId,
609
+ signature,
610
+ signedTx: state.signingSession.pendingTx,
611
+ path: 'key-path',
612
+ };
613
+ state.phase = ServiceCohortPhase.Complete;
614
+ }
615
+ }
616
+ /**
617
+ * Abandon the optimistic n-of-n key path and ask members to authorize the
618
+ * k-of-n fallback (script-path) spend of the SAME beacon transaction (graceful
619
+ * liveness, ADR 042). Reuses the in-flight signing session's transaction and
620
+ * spent output, so the announcement and its outputs are unchanged: only the
621
+ * witness path differs. Returns one FALLBACK_AUTHORIZATION_REQUEST per
622
+ * participant.
623
+ *
624
+ * Callable once optimistic signing has started (the session and its tx exist)
625
+ * and before it completes. A cohort can take exactly one of the two paths: the
626
+ * caller (runner) must commit to fallback and stop driving the optimistic path.
627
+ */
628
+ startFallbackSigning(cohortId) {
629
+ const state = this.#cohortStates.get(cohortId);
630
+ if (!state) {
631
+ throw new AggregationServiceError(`Cohort ${cohortId} not found.`, 'COHORT_NOT_FOUND', { cohortId });
632
+ }
633
+ if (!state.signingSession) {
634
+ throw new AggregationServiceError(`Cannot start fallback for cohort ${cohortId}: no signing session.`, 'NO_SIGNING_SESSION', { cohortId });
635
+ }
636
+ const signingPhases = [
637
+ ServiceCohortPhase.SigningStarted,
638
+ ServiceCohortPhase.NoncesCollected,
639
+ ServiceCohortPhase.AwaitingPartialSigs,
640
+ ];
641
+ if (!signingPhases.includes(state.phase)) {
642
+ throw new AggregationServiceError(`Cannot start fallback for cohort ${cohortId}: phase is ${state.phase}.`, 'INVALID_PHASE', { cohortId, phase: state.phase });
643
+ }
644
+ const session = state.signingSession;
645
+ const prevOutScript = session.prevOutScripts[0];
646
+ const prevOutValue = session.prevOutValues[0];
647
+ if (!prevOutScript || prevOutValue === undefined) {
648
+ throw new AggregationServiceError(`Cannot start fallback for cohort ${cohortId}: signing session missing prevout data.`, 'MISSING_PREV_OUT', { cohortId });
649
+ }
650
+ const fallbackLeaf = buildFallbackLeaf({
651
+ cohortKeys: state.cohort.cohortKeys,
652
+ fallbackThreshold: state.cohort.effectiveFallbackThreshold,
653
+ });
654
+ state.fallbackSignatures = new Map();
655
+ state.phase = ServiceCohortPhase.FallbackRequested;
656
+ const messages = [];
657
+ for (const participantDid of state.cohort.participants) {
658
+ messages.push(createFallbackAuthorizationRequestMessage({
659
+ from: this.did,
660
+ to: participantDid,
661
+ cohortId,
662
+ sessionId: session.id,
663
+ pendingTx: session.pendingTx.hex,
664
+ prevOutScriptHex: bytesToHex(prevOutScript),
665
+ prevOutValue: prevOutValue.toString(),
666
+ fallbackLeafScriptHex: bytesToHex(fallbackLeaf),
667
+ }));
668
+ }
669
+ return messages;
670
+ }
671
+ /**
672
+ * Handle an incoming FALLBACK_SIGNATURE: a member's standalone BIP-340
673
+ * signature over the fallback script-path sighash. The signature is
674
+ * authenticated to the sender (its `signerPk` must be the sender's own cohort
675
+ * key and the signature must verify against the sighash) and collected. Once k
676
+ * valid signatures are in, the k-of-n fallback spend is assembled and the
677
+ * cohort completes via the script path.
678
+ */
679
+ #handleFallbackSignature(message) {
680
+ const cohortId = message.body?.cohortId;
681
+ if (!cohortId)
682
+ return;
683
+ const state = this.#cohortStates.get(cohortId);
684
+ if (!state || !state.signingSession || !state.fallbackSignatures)
685
+ return;
686
+ if (state.phase !== ServiceCohortPhase.FallbackRequested)
687
+ return;
688
+ const sessionId = message.body?.sessionId;
689
+ if (sessionId !== state.signingSession.id)
690
+ return;
691
+ const signerPk = message.body?.signerPk;
692
+ const fallbackSignature = message.body?.fallbackSignature;
693
+ if (!signerPk || !fallbackSignature)
694
+ return;
695
+ const prevOutScript = state.signingSession.prevOutScripts[0];
696
+ const prevOutValue = state.signingSession.prevOutValues[0];
697
+ if (!prevOutScript || prevOutValue === undefined)
698
+ return;
699
+ // Authenticate the signature to the sender: signerPk must be the sender's own
700
+ // cohort key (x-only). A non-member or a mismatched key is dropped as a
701
+ // rejection, never thrown, so one bad contribution cannot stall the fallback.
702
+ const memberKey = state.cohort.participantKeys.get(message.from);
703
+ if (!memberKey) {
704
+ state.rejections.push({ from: message.from, code: 'UPDATE_MALFORMED', reason: 'Fallback signature from a non-member' });
705
+ return;
706
+ }
707
+ const memberXOnly = memberKey.slice(1);
708
+ if (signerPk.length !== 32 || !memberXOnly.every((b, i) => b === signerPk[i])) {
709
+ state.rejections.push({ from: message.from, code: 'UPDATE_MALFORMED', reason: 'Fallback signerPk does not match the sender cohort key' });
710
+ return;
711
+ }
712
+ const fallbackLeaf = buildFallbackLeaf({
713
+ cohortKeys: state.cohort.cohortKeys,
714
+ fallbackThreshold: state.cohort.effectiveFallbackThreshold,
715
+ });
716
+ const sighash = fallbackSighash(state.signingSession.pendingTx, 0, prevOutScript, prevOutValue, fallbackLeaf);
717
+ let valid = false;
718
+ try {
719
+ valid = fallbackSignature.length === 64 && schnorr.verify(fallbackSignature, sighash, signerPk);
720
+ }
721
+ catch {
722
+ valid = false;
723
+ }
724
+ if (!valid) {
725
+ state.rejections.push({ from: message.from, code: 'UPDATE_VERIFICATION_FAILED', reason: 'Fallback signature failed verification' });
726
+ return;
727
+ }
728
+ state.fallbackSignatures.set(message.from, { pubKey: signerPk, signature: fallbackSignature });
729
+ if (state.fallbackSignatures.size >= state.cohort.effectiveFallbackThreshold) {
730
+ const signedTx = buildFallbackSpend({
731
+ pendingTx: state.signingSession.pendingTx,
732
+ cohortKeys: state.cohort.cohortKeys,
733
+ fallbackThreshold: state.cohort.effectiveFallbackThreshold,
734
+ recoveryKey: state.cohort.recoveryKey,
735
+ recoverySequence: state.cohort.recoverySequence,
736
+ fundingModel: state.cohort.fundingModel,
737
+ network: state.cohort.network,
738
+ prevOutScript,
739
+ prevOutValue,
740
+ signatures: [...state.fallbackSignatures.values()],
741
+ });
742
+ state.result = { cohortId, signature: new Uint8Array(), signedTx, path: 'script-path' };
743
+ state.phase = ServiceCohortPhase.Complete;
744
+ }
745
+ }
746
+ getResult(cohortId) {
747
+ return this.#cohortStates.get(cohortId)?.result;
748
+ }
749
+ getCohortPhase(cohortId) {
750
+ return this.#cohortStates.get(cohortId)?.phase;
751
+ }
752
+ getCohort(cohortId) {
753
+ return this.#cohortStates.get(cohortId)?.cohort;
754
+ }
755
+ /**
756
+ * Get the signing session ID for a cohort, if a signing session has been started.
757
+ * @param {string} cohortId - The cohort ID.
758
+ * @returns {string | undefined} The session ID, or undefined if no session is active.
759
+ */
760
+ getSigningSessionId(cohortId) {
761
+ return this.#cohortStates.get(cohortId)?.signingSession?.id;
762
+ }
763
+ get cohorts() {
764
+ return [...this.#cohortStates.values()].map(s => s.cohort);
765
+ }
766
+ /**
767
+ * Remove a cohort from the state map. Used by runners to GC state on cohort
768
+ * completion, failure, or expiry. No-op if the cohort doesn't exist.
769
+ */
770
+ removeCohort(cohortId) {
771
+ this.#cohortStates.delete(cohortId);
772
+ }
773
+ }
774
+ //# sourceMappingURL=service.js.map