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