@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,779 @@
1
+ import { canonicalHash } from '@did-btcr2/common';
2
+ import type { SignedBTCR2Update } from '@did-btcr2/cryptosuite';
3
+ import type { SerializedSMTProof} from '@did-btcr2/smt';
4
+ import { schnorr } from '@noble/curves/secp256k1.js';
5
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
6
+ import { Script, Transaction } from '@scure/btc-signer';
7
+ import { getBeaconStrategy } from './beacon-strategy.js';
8
+ import { AggregationCohort } from './cohort.js';
9
+ import type { CohortConditions } from './conditions.js';
10
+ import { AggregationParticipantError } from './errors.js';
11
+ import { fallbackSighash } from './fallback-spend.js';
12
+ import { buildFallbackLeaf } from './recovery-policy.js';
13
+ import type { BaseMessage } from './messages/base.js';
14
+ import { isCohortAdvertMessage } from './messages/bodies.js';
15
+ import { AGGREGATION_WIRE_VERSION } from './messages/base.js';
16
+ import {
17
+ AGGREGATED_NONCE,
18
+ AUTHORIZATION_REQUEST,
19
+ COHORT_ADVERT,
20
+ COHORT_OPT_IN_ACCEPT,
21
+ COHORT_READY,
22
+ DISTRIBUTE_AGGREGATED_DATA,
23
+ FALLBACK_AUTHORIZATION_REQUEST,
24
+ } from './messages/constants.js';
25
+ import {
26
+ createCohortOptInMessage,
27
+ createFallbackSignatureMessage,
28
+ createNonceContributionMessage,
29
+ createSignatureAuthorizationMessage,
30
+ createSubmitNonIncludedMessage,
31
+ createSubmitUpdateMessage,
32
+ createValidationAckMessage,
33
+ } from './messages/factories.js';
34
+ import type { ParticipantCohortPhaseType } from './phases.js';
35
+ import { ParticipantCohortPhase } from './phases.js';
36
+ import type { AggregationSigner } from './signer.js';
37
+ import { BeaconSigningSession } from './signing-session.js';
38
+
39
+ /**
40
+ * True if `tx` has an OP_RETURN output whose payload equals the 32-byte signal
41
+ * `signalHex`. A member binds its fallback signature to the exact signal it
42
+ * validated, so a coordinator that drives the fallback output selection cannot
43
+ * anchor a different announcement (a stale signal, or one whose CAS/SMT root
44
+ * omits the member's update) under the member's signature.
45
+ */
46
+ function txEmbedsSignal(tx: Transaction, signalHex: string): boolean {
47
+ let expected: Uint8Array;
48
+ try { expected = hexToBytes(signalHex); } catch { return false; }
49
+ if(expected.length === 0) return false;
50
+ for(let i = 0; i < tx.outputsLength; i++) {
51
+ const script = tx.getOutput(i)?.script;
52
+ if(!script) continue;
53
+ let decoded: Array<string | Uint8Array>;
54
+ try { decoded = Script.decode(script) as Array<string | Uint8Array>; } catch { continue; }
55
+ if(decoded.length === 2 && decoded[0] === 'RETURN' && decoded[1] instanceof Uint8Array) {
56
+ const payload = decoded[1];
57
+ if(payload.length === expected.length && payload.every((b, j) => b === expected[j])) return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * Cohort advert as discovered by the participant (UI: list of joinable cohorts).
65
+ * Carries the advertised {@link CohortConditions} (beaconType, minParticipants,
66
+ * maxParticipants, costs, ...) so a `shouldJoin` decision can inspect them.
67
+ */
68
+ export interface CohortAdvert extends CohortConditions {
69
+ cohortId: string;
70
+ serviceDid: string;
71
+ network: string;
72
+ serviceCommunicationPk: Uint8Array;
73
+ }
74
+
75
+ /** Joined cohort info, available after the cohort is finalized. */
76
+ export interface JoinedCohortInfo {
77
+ cohortId: string;
78
+ serviceDid: string;
79
+ beaconAddress: string;
80
+ cohortKeys: Array<Uint8Array>;
81
+ }
82
+
83
+ /** Aggregated data awaiting participant validation (UI: review for approval). */
84
+ export interface PendingValidation {
85
+ cohortId: string;
86
+ beaconType: string;
87
+ signalBytesHex: string;
88
+ casAnnouncement?: Record<string, string>;
89
+ smtProof?: SerializedSMTProof;
90
+ /** Canonical hash of this participant's update; empty for a decliner. */
91
+ expectedHash: string;
92
+ matches: boolean;
93
+ /** True if this participant submitted an update; false if it declined (non-inclusion). */
94
+ included: boolean;
95
+ }
96
+
97
+ /** Pending signing request (UI: review tx for approval). */
98
+ export interface PendingSigningRequest {
99
+ cohortId: string;
100
+ sessionId: string;
101
+ pendingTxHex: string;
102
+ /** Hex-encoded scriptPubKey of the UTXO being spent. Required for BIP-341 sighash. */
103
+ prevOutScriptHex: string;
104
+ prevOutValue: string;
105
+ }
106
+
107
+ /**
108
+ * Pending fallback signing request (UI: review the fallback spend for approval).
109
+ * The service fell back to the k-of-n script path; the member signs the SAME
110
+ * beacon transaction over the fallback script-path sighash (ADR 042).
111
+ */
112
+ export interface PendingFallbackRequest {
113
+ cohortId: string;
114
+ sessionId: string;
115
+ pendingTxHex: string;
116
+ prevOutScriptHex: string;
117
+ prevOutValue: string;
118
+ /** Fallback leaf script, hex (advisory; the member recomputes it from its own cohort). */
119
+ fallbackLeafScriptHex: string;
120
+ }
121
+
122
+ /** Per-cohort participant state (internal). */
123
+ interface ParticipantCohortState {
124
+ phase: ParticipantCohortPhaseType;
125
+ cohortId: string;
126
+ serviceDid: string;
127
+ advert?: CohortAdvert;
128
+ cohort?: AggregationCohort;
129
+ submittedUpdate?: SignedBTCR2Update;
130
+ /**
131
+ * This round's intent, persisted because the phase advances past
132
+ * NonIncluded/UpdateSubmitted into validation/signing. true = submitted an
133
+ * update, false = declined (non-inclusion), undefined = not yet responded.
134
+ */
135
+ included?: boolean;
136
+ validation?: PendingValidation;
137
+ signingRequest?: PendingSigningRequest;
138
+ fallbackRequest?: PendingFallbackRequest;
139
+ signingSession?: BeaconSigningSession;
140
+ }
141
+
142
+ export interface AggregationParticipantParams {
143
+ did: string;
144
+ /**
145
+ * The participant's MuSig2 signing capability. The raw secret is materialized
146
+ * only for the duration of a single nonce/partial-sign operation (see ADR 038);
147
+ * pass a {@link KeyPairAggregationSigner} to back it with an in-memory keypair.
148
+ */
149
+ signer: AggregationSigner;
150
+ }
151
+
152
+ /**
153
+ * Sans-I/O state machine for an Aggregation Participant.
154
+ *
155
+ * Manages multiple cohorts simultaneously. The client app drives the state
156
+ * machine via `receive()` (for incoming messages) and explicit action methods
157
+ * (for user decisions). All outgoing messages are returned for the caller to
158
+ * send via whatever transport.
159
+ *
160
+ * @class AggregationParticipant
161
+ */
162
+ export class AggregationParticipant {
163
+ public readonly did: string;
164
+
165
+ /** MuSig2 signing capability. The raw secret never lives as a field here. */
166
+ readonly #signer: AggregationSigner;
167
+
168
+ /** Per-cohort state, keyed by cohortId. */
169
+ #cohortStates: Map<string, ParticipantCohortState> = new Map();
170
+
171
+ constructor({ did, signer }: AggregationParticipantParams) {
172
+ this.did = did;
173
+ this.#signer = signer;
174
+ }
175
+
176
+ /** The participant's compressed (33-byte) MuSig2 public key. Not secret. */
177
+ public get publicKey(): Uint8Array {
178
+ return this.#signer.publicKey;
179
+ }
180
+
181
+
182
+ /**
183
+ * Process an incoming message. Updates internal state but never produces
184
+ * outgoing messages: those come exclusively from action methods.
185
+ */
186
+ public receive(message: BaseMessage): void {
187
+ // Reject messages whose wire version doesn't match what this build speaks.
188
+ if(message.version === undefined || message.version !== AGGREGATION_WIRE_VERSION) {
189
+ return;
190
+ }
191
+ const type = message.type;
192
+ switch(type) {
193
+ case COHORT_ADVERT:
194
+ this.#handleCohortAdvert(message);
195
+ break;
196
+ case COHORT_OPT_IN_ACCEPT:
197
+ this.#handleOptInAccept(message);
198
+ break;
199
+ case COHORT_READY:
200
+ this.#handleCohortReady(message);
201
+ break;
202
+ case DISTRIBUTE_AGGREGATED_DATA:
203
+ this.#handleDistributeAggregatedData(message);
204
+ break;
205
+ case AUTHORIZATION_REQUEST:
206
+ this.#handleAuthorizationRequest(message);
207
+ break;
208
+ case AGGREGATED_NONCE:
209
+ this.#handleAggregatedNonce(message);
210
+ break;
211
+ case FALLBACK_AUTHORIZATION_REQUEST:
212
+ this.#handleFallbackAuthorizationRequest(message);
213
+ break;
214
+ default:
215
+ // Unknown message type, silently ignore
216
+ break;
217
+ }
218
+ }
219
+
220
+
221
+ /** Cohorts the participant has discovered but not yet joined. */
222
+ public get discoveredCohorts(): ReadonlyMap<string, CohortAdvert> {
223
+ const map = new Map<string, CohortAdvert>();
224
+ for(const [id, state] of this.#cohortStates) {
225
+ if(state.phase === ParticipantCohortPhase.Discovered && state.advert) {
226
+ map.set(id, state.advert);
227
+ }
228
+ }
229
+ return map;
230
+ }
231
+
232
+ #handleCohortAdvert(message: BaseMessage): void {
233
+ // Validate the wire shape (incl. minParticipants range) before trusting it,
234
+ // rather than reading fields with `?? 0` fallbacks (see ADR 039).
235
+ if(!isCohortAdvertMessage(message)) return;
236
+ const { cohortId, network, communicationPk, ...conditions } = message.body;
237
+ if(this.#cohortStates.has(cohortId)) return; // Already known
238
+
239
+ const advert: CohortAdvert = {
240
+ cohortId,
241
+ serviceDid : message.from,
242
+ network,
243
+ serviceCommunicationPk : communicationPk,
244
+ ...conditions,
245
+ };
246
+
247
+ this.#cohortStates.set(cohortId, {
248
+ phase : ParticipantCohortPhase.Discovered,
249
+ cohortId,
250
+ serviceDid : message.from,
251
+ advert,
252
+ });
253
+ }
254
+
255
+ /**
256
+ * User action: join a discovered cohort.
257
+ * Returns the opt-in message to send.
258
+ */
259
+ public joinCohort(cohortId: string): BaseMessage[] {
260
+ const state = this.#cohortStates.get(cohortId);
261
+ if(!state || state.phase !== ParticipantCohortPhase.Discovered) {
262
+ throw new AggregationParticipantError(
263
+ `Cannot join cohort ${cohortId}: not in Discovered phase.`,
264
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
265
+ );
266
+ }
267
+
268
+ // Create local cohort to track our view. Carry the advertised recovery
269
+ // params so validateMembership recomputes the same script-tree beacon
270
+ // address the service derived (ADR 042); a mismatch rejects the cohort.
271
+ const advert = state.advert!;
272
+ const cohort = new AggregationCohort({
273
+ id : cohortId,
274
+ serviceDid : state.serviceDid,
275
+ minParticipants : advert.minParticipants,
276
+ network : advert.network,
277
+ beaconType : advert.beaconType,
278
+ recoveryKey : advert.recoveryKey ? hexToBytes(advert.recoveryKey) : undefined,
279
+ recoverySequence : advert.recoverySequence,
280
+ fundingModel : advert.fundingModel,
281
+ fallbackThreshold : advert.fallbackThreshold,
282
+ });
283
+ state.cohort = cohort;
284
+ state.phase = ParticipantCohortPhase.OptedIn;
285
+
286
+ const optInMessage = createCohortOptInMessage({
287
+ from : this.did,
288
+ to : state.serviceDid,
289
+ cohortId,
290
+ participantPk : this.publicKey,
291
+ communicationPk : this.publicKey,
292
+ });
293
+
294
+ return [optInMessage];
295
+ }
296
+
297
+ #handleOptInAccept(message: BaseMessage): void {
298
+ // Acknowledgment from service, no state change needed
299
+ void message;
300
+ }
301
+
302
+
303
+ /** Cohorts that have been finalized: beacon address available. */
304
+ public get joinedCohorts(): ReadonlyMap<string, JoinedCohortInfo> {
305
+ const map = new Map<string, JoinedCohortInfo>();
306
+ for(const [id, state] of this.#cohortStates) {
307
+ if(state.cohort && state.cohort.beaconAddress) {
308
+ map.set(id, {
309
+ cohortId : id,
310
+ serviceDid : state.serviceDid,
311
+ beaconAddress : state.cohort.beaconAddress,
312
+ cohortKeys : state.cohort.cohortKeys,
313
+ });
314
+ }
315
+ }
316
+ return map;
317
+ }
318
+
319
+ #handleCohortReady(message: BaseMessage): void {
320
+ const cohortId = message.body?.cohortId;
321
+ if(!cohortId) return;
322
+ const state = this.#cohortStates.get(cohortId);
323
+ if(!state || !state.cohort) return;
324
+ if(state.phase !== ParticipantCohortPhase.OptedIn) return;
325
+
326
+ const beaconAddress = message.body?.beaconAddress;
327
+ const cohortKeys = message.body?.cohortKeys;
328
+ if(!beaconAddress || !cohortKeys) return;
329
+
330
+ const participantPkHex = bytesToHex(this.publicKey);
331
+ const cohortKeysHex = cohortKeys.map(k => bytesToHex(new Uint8Array(k)));
332
+
333
+ state.cohort.validateMembership(participantPkHex, cohortKeysHex, beaconAddress);
334
+ state.phase = ParticipantCohortPhase.CohortReady;
335
+ }
336
+
337
+
338
+ /**
339
+ * User action: submit a signed BTCR2 update for inclusion in the cohort's
340
+ * aggregated signal.
341
+ */
342
+ public submitUpdate(cohortId: string, signedUpdate: SignedBTCR2Update): BaseMessage[] {
343
+ const state = this.#cohortStates.get(cohortId);
344
+ if(!state || state.phase !== ParticipantCohortPhase.CohortReady) {
345
+ throw new AggregationParticipantError(
346
+ `Cannot submit update to cohort ${cohortId}: not in CohortReady phase.`,
347
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
348
+ );
349
+ }
350
+
351
+ state.submittedUpdate = signedUpdate;
352
+ state.included = true;
353
+ state.phase = ParticipantCohortPhase.UpdateSubmitted;
354
+
355
+ const message = createSubmitUpdateMessage({
356
+ from : this.did,
357
+ to : state.serviceDid,
358
+ cohortId,
359
+ signedUpdate : signedUpdate as unknown as Record<string, unknown>,
360
+ });
361
+ return [message];
362
+ }
363
+
364
+ /**
365
+ * User action: decline to submit an update this round (cooperative
366
+ * non-inclusion). The member stays in the cohort and still signs; it will be
367
+ * absent from the CAS Announcement Map, or carry a non-inclusion leaf in the
368
+ * SMT. Returns the SUBMIT_NONINCLUDED message to send.
369
+ */
370
+ public declineUpdate(cohortId: string): BaseMessage[] {
371
+ const state = this.#cohortStates.get(cohortId);
372
+ if(!state || state.phase !== ParticipantCohortPhase.CohortReady) {
373
+ throw new AggregationParticipantError(
374
+ `Cannot decline in cohort ${cohortId}: not in CohortReady phase.`,
375
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
376
+ );
377
+ }
378
+
379
+ // Leave submittedUpdate unset; included=false is the load-bearing signal that
380
+ // the validation handler uses to validate the non-inclusion slot.
381
+ state.included = false;
382
+ state.phase = ParticipantCohortPhase.NonIncluded;
383
+
384
+ const message = createSubmitNonIncludedMessage({
385
+ from : this.did,
386
+ to : state.serviceDid,
387
+ cohortId,
388
+ });
389
+ return [message];
390
+ }
391
+
392
+
393
+ /** Aggregated data awaiting user validation. */
394
+ public get pendingValidations(): ReadonlyMap<string, PendingValidation> {
395
+ const map = new Map<string, PendingValidation>();
396
+ for(const [id, state] of this.#cohortStates) {
397
+ if(state.phase === ParticipantCohortPhase.AwaitingValidation && state.validation) {
398
+ map.set(id, state.validation);
399
+ }
400
+ }
401
+ return map;
402
+ }
403
+
404
+ /**
405
+ * The validated aggregated data retained for a cohort, regardless of phase.
406
+ * Unlike {@link pendingValidations} (which lists only cohorts still awaiting
407
+ * the validate decision), this returns the stored validation, including the
408
+ * participant's sidecar (the CAS Announcement map or its SMT inclusion proof),
409
+ * so it is still readable once the cohort reaches Complete. Returns
410
+ * undefined before aggregated data has been received.
411
+ */
412
+ public getValidation(cohortId: string): PendingValidation | undefined {
413
+ return this.#cohortStates.get(cohortId)?.validation;
414
+ }
415
+
416
+ #handleDistributeAggregatedData(message: BaseMessage): void {
417
+ const cohortId = message.body?.cohortId;
418
+ if(!cohortId) return;
419
+ const state = this.#cohortStates.get(cohortId);
420
+ // A submitter is in UpdateSubmitted; a decliner (cooperative non-inclusion)
421
+ // is in NonIncluded. Both validate their own slot in the distributed data.
422
+ if(!state || (state.phase !== ParticipantCohortPhase.UpdateSubmitted && state.phase !== ParticipantCohortPhase.NonIncluded)) return;
423
+
424
+ const declined = state.included === false;
425
+ // A submitter must have its update stored; a decliner has none by design.
426
+ if(!declined && !state.submittedUpdate) return;
427
+
428
+ const beaconType = message.body?.beaconType;
429
+ if(!beaconType) return;
430
+ const strategy = getBeaconStrategy(beaconType);
431
+ if(!strategy) return;
432
+
433
+ const signalBytesHex = message.body?.signalBytesHex ?? '';
434
+ // Decliner validates its non-inclusion slot (CAS absence / SMT non-inclusion
435
+ // proof); submitter validates inclusion against its update hash.
436
+ const expectedHash = declined ? '' : canonicalHash(state.submittedUpdate!);
437
+ const result = strategy.validateParticipantView({
438
+ participantDid : this.did,
439
+ included : !declined,
440
+ submittedUpdate : declined ? undefined : state.submittedUpdate,
441
+ expectedHash : declined ? undefined : expectedHash,
442
+ body : message.body!,
443
+ });
444
+
445
+ state.validation = {
446
+ cohortId,
447
+ beaconType,
448
+ signalBytesHex,
449
+ expectedHash,
450
+ matches : result.matches,
451
+ casAnnouncement : result.casAnnouncement,
452
+ smtProof : result.smtProof,
453
+ included : !declined,
454
+ };
455
+ state.phase = ParticipantCohortPhase.AwaitingValidation;
456
+ }
457
+
458
+ /**
459
+ * User action: approve aggregated data and send validation ack.
460
+ */
461
+ public approveValidation(cohortId: string): BaseMessage[] {
462
+ const state = this.#cohortStates.get(cohortId);
463
+ if(!state || state.phase !== ParticipantCohortPhase.AwaitingValidation) {
464
+ throw new AggregationParticipantError(
465
+ `Cannot approve validation for cohort ${cohortId}: not in AwaitingValidation phase.`,
466
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
467
+ );
468
+ }
469
+ state.phase = ParticipantCohortPhase.ValidationSent;
470
+ return [createValidationAckMessage({
471
+ from : this.did,
472
+ to : state.serviceDid,
473
+ cohortId,
474
+ approved : true,
475
+ })];
476
+ }
477
+
478
+ /**
479
+ * User action: reject aggregated data and send rejection ack.
480
+ */
481
+ public rejectValidation(cohortId: string): BaseMessage[] {
482
+ const state = this.#cohortStates.get(cohortId);
483
+ if(!state || state.phase !== ParticipantCohortPhase.AwaitingValidation) {
484
+ throw new AggregationParticipantError(
485
+ `Cannot reject validation for cohort ${cohortId}: not in AwaitingValidation phase.`,
486
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
487
+ );
488
+ }
489
+ state.phase = ParticipantCohortPhase.Failed;
490
+ return [createValidationAckMessage({
491
+ from : this.did,
492
+ to : state.serviceDid,
493
+ cohortId,
494
+ approved : false,
495
+ })];
496
+ }
497
+
498
+
499
+ /** Signing requests awaiting user approval. */
500
+ public get pendingSigningRequests(): ReadonlyMap<string, PendingSigningRequest> {
501
+ const map = new Map<string, PendingSigningRequest>();
502
+ for(const [id, state] of this.#cohortStates) {
503
+ if(state.phase === ParticipantCohortPhase.AwaitingSigning && state.signingRequest) {
504
+ map.set(id, state.signingRequest);
505
+ }
506
+ }
507
+ return map;
508
+ }
509
+
510
+ #handleAuthorizationRequest(message: BaseMessage): void {
511
+ const cohortId = message.body?.cohortId;
512
+ if(!cohortId) return;
513
+ const state = this.#cohortStates.get(cohortId);
514
+ if(!state || !state.cohort) return;
515
+ if(state.phase !== ParticipantCohortPhase.ValidationSent) return;
516
+
517
+ const sessionId = message.body?.sessionId;
518
+ const pendingTxHex = message.body?.pendingTx;
519
+ const prevOutScriptHex = message.body?.prevOutScriptHex;
520
+ const prevOutValue = message.body?.prevOutValue;
521
+ if(!sessionId || !pendingTxHex || !prevOutScriptHex || !prevOutValue) return;
522
+
523
+ state.signingRequest = {
524
+ cohortId,
525
+ sessionId,
526
+ pendingTxHex,
527
+ prevOutScriptHex,
528
+ prevOutValue,
529
+ };
530
+ state.phase = ParticipantCohortPhase.AwaitingSigning;
531
+ }
532
+
533
+ /**
534
+ * Bind a signing approval to the announcement the member validated: a beacon
535
+ * transaction MUST carry an OP_RETURN with the exact 32-byte signal stored when
536
+ * the aggregated data was distributed. Both the optimistic nonce approval and
537
+ * the fallback approval sign with SIGHASH_DEFAULT (committing to every output)
538
+ * while the coordinator drives output selection, so without this check a
539
+ * coordinator could anchor a different signal under the member's signature.
540
+ */
541
+ #assertTxAnchorsValidatedSignal(cohortId: string, state: ParticipantCohortState, tx: Transaction): void {
542
+ const signalHex = state.validation?.signalBytesHex;
543
+ if(!signalHex) {
544
+ throw new AggregationParticipantError(
545
+ `Cohort ${cohortId} has no validated signal to bind the signature to.`,
546
+ 'MISSING_STATE', { cohortId }
547
+ );
548
+ }
549
+ if(!txEmbedsSignal(tx, signalHex)) {
550
+ throw new AggregationParticipantError(
551
+ `Transaction for cohort ${cohortId} does not anchor the validated signal.`,
552
+ 'SIGNAL_MISMATCH', { cohortId }
553
+ );
554
+ }
555
+ }
556
+
557
+ /**
558
+ * User action: approve signing and generate nonce contribution.
559
+ */
560
+ public approveNonce(cohortId: string): BaseMessage[] {
561
+ const state = this.#cohortStates.get(cohortId);
562
+ if(!state || state.phase !== ParticipantCohortPhase.AwaitingSigning) {
563
+ throw new AggregationParticipantError(
564
+ `Cannot approve nonce for cohort ${cohortId}: not in AwaitingSigning phase.`,
565
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
566
+ );
567
+ }
568
+ if(!state.signingRequest || !state.cohort) {
569
+ throw new AggregationParticipantError(
570
+ `Cohort ${cohortId} missing signing request or cohort state.`,
571
+ 'MISSING_STATE', { cohortId }
572
+ );
573
+ }
574
+
575
+ // allowUnknownOutputs: a beacon transaction carries an OP_RETURN signal
576
+ // output, which scure does not classify as a known (spendable) output type;
577
+ // re-parsing the raw tx would otherwise throw.
578
+ const tx = Transaction.fromRaw(hexToBytes(state.signingRequest.pendingTxHex), { allowUnknownOutputs: true });
579
+
580
+ // Refuse to sign unless the tx anchors the signal this member validated.
581
+ this.#assertTxAnchorsValidatedSignal(cohortId, state, tx);
582
+
583
+ // Derive UTXO metadata for Taproot sighash (BIP-341). Use the script
584
+ // supplied by the service in AUTHORIZATION_REQUEST rather than reading
585
+ // the change output: input and change may use different scripts in future
586
+ // beacon designs, and the prevOutScript must be the UTXO script, not the
587
+ // change script.
588
+ const prevOutScripts = [hexToBytes(state.signingRequest.prevOutScriptHex)];
589
+ const prevOutValues = [BigInt(state.signingRequest.prevOutValue)];
590
+
591
+ const session = new BeaconSigningSession({
592
+ id : state.signingRequest.sessionId,
593
+ cohort : state.cohort,
594
+ pendingTx : tx,
595
+ prevOutScripts,
596
+ prevOutValues,
597
+ });
598
+ state.signingSession = session;
599
+
600
+ const nonceContribution = this.#signer.withSecret(
601
+ secretKey => session.generateNonceContribution(this.publicKey, secretKey)
602
+ );
603
+
604
+ state.phase = ParticipantCohortPhase.NonceSent;
605
+
606
+ return [createNonceContributionMessage({
607
+ from : this.did,
608
+ to : state.serviceDid,
609
+ cohortId,
610
+ sessionId : session.id,
611
+ nonceContribution,
612
+ })];
613
+ }
614
+
615
+ #handleAggregatedNonce(message: BaseMessage): void {
616
+ const cohortId = message.body?.cohortId;
617
+ if(!cohortId) return;
618
+ const state = this.#cohortStates.get(cohortId);
619
+ if(!state || !state.signingSession) return;
620
+ if(state.phase !== ParticipantCohortPhase.NonceSent) return;
621
+
622
+ const aggregatedNonce = message.body?.aggregatedNonce;
623
+ if(!aggregatedNonce) return;
624
+
625
+ state.signingSession.aggregatedNonce = aggregatedNonce;
626
+ state.phase = ParticipantCohortPhase.AwaitingPartialSig;
627
+ }
628
+
629
+ /**
630
+ * User action: generate and return the partial signature.
631
+ * In most UIs this is automatic after AwaitingPartialSig, but exposing it
632
+ * as an explicit action lets the client UI confirm before signing if desired.
633
+ */
634
+ public generatePartialSignature(cohortId: string): BaseMessage[] {
635
+ const state = this.#cohortStates.get(cohortId);
636
+ if(!state || state.phase !== ParticipantCohortPhase.AwaitingPartialSig) {
637
+ throw new AggregationParticipantError(
638
+ `Cannot generate partial signature for cohort ${cohortId}: not in AwaitingPartialSig phase.`,
639
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
640
+ );
641
+ }
642
+ if(!state.signingSession) {
643
+ throw new AggregationParticipantError(
644
+ `No signing session for cohort ${cohortId}.`,
645
+ 'MISSING_STATE', { cohortId }
646
+ );
647
+ }
648
+
649
+ const signingSession = state.signingSession;
650
+ const partialSig = this.#signer.withSecret(
651
+ secretKey => signingSession.generatePartialSignature(secretKey)
652
+ );
653
+ state.phase = ParticipantCohortPhase.Complete;
654
+
655
+ return [createSignatureAuthorizationMessage({
656
+ from : this.did,
657
+ to : state.serviceDid,
658
+ cohortId,
659
+ sessionId : state.signingSession.id,
660
+ partialSignature : partialSig,
661
+ })];
662
+ }
663
+
664
+
665
+ /** Fallback signing requests awaiting user approval (UI: review the fallback spend). */
666
+ public get pendingFallbackRequests(): ReadonlyMap<string, PendingFallbackRequest> {
667
+ const map = new Map<string, PendingFallbackRequest>();
668
+ for(const [id, state] of this.#cohortStates) {
669
+ if(state.phase === ParticipantCohortPhase.AwaitingFallbackSig && state.fallbackRequest) {
670
+ map.set(id, state.fallbackRequest);
671
+ }
672
+ }
673
+ return map;
674
+ }
675
+
676
+ #handleFallbackAuthorizationRequest(message: BaseMessage): void {
677
+ const cohortId = message.body?.cohortId;
678
+ if(!cohortId) return;
679
+ const state = this.#cohortStates.get(cohortId);
680
+ if(!state || !state.cohort) return;
681
+ // The service can fall back at any point after the member validated. This
682
+ // includes the local Complete phase a member reaches the moment it sends its
683
+ // optimistic partial signature: the cohort has NOT finalized (the service
684
+ // only falls back before optimistic completion), and those members are
685
+ // exactly the k signers the fallback needs. Signing both the optimistic
686
+ // partial sig and the fallback sig is safe - both authorize the same outputs,
687
+ // and only one witness can ever confirm the single UTXO. A genuinely failed
688
+ // member is excluded. Ignore a duplicate request already being processed.
689
+ const acceptFrom: ParticipantCohortPhaseType[] = [
690
+ ParticipantCohortPhase.ValidationSent,
691
+ ParticipantCohortPhase.AwaitingSigning,
692
+ ParticipantCohortPhase.NonceSent,
693
+ ParticipantCohortPhase.AwaitingPartialSig,
694
+ ParticipantCohortPhase.Complete,
695
+ ];
696
+ if(!acceptFrom.includes(state.phase)) return;
697
+
698
+ const sessionId = message.body?.sessionId;
699
+ const pendingTxHex = message.body?.pendingTx;
700
+ const prevOutScriptHex = message.body?.prevOutScriptHex;
701
+ const prevOutValue = message.body?.prevOutValue;
702
+ const fallbackLeafScriptHex = message.body?.fallbackLeafScriptHex;
703
+ if(!sessionId || !pendingTxHex || !prevOutScriptHex || !prevOutValue || !fallbackLeafScriptHex) return;
704
+
705
+ state.fallbackRequest = { cohortId, sessionId, pendingTxHex, prevOutScriptHex, prevOutValue, fallbackLeafScriptHex };
706
+ // The optimistic path is abandoned; wipe any retained secret nonce for it.
707
+ state.signingSession?.clearSecrets();
708
+ state.phase = ParticipantCohortPhase.AwaitingFallbackSig;
709
+ }
710
+
711
+ /**
712
+ * User action: authorize the fallback spend. Recomputes the k-of-n fallback
713
+ * leaf from the member's OWN cohort state (not the service-provided script),
714
+ * computes the BIP-341 script-path sighash over the requested transaction, and
715
+ * returns a standalone BIP-340 signature (no nonce round). The member completes
716
+ * once it has contributed; the service needs only k of these.
717
+ */
718
+ public approveFallback(cohortId: string): BaseMessage[] {
719
+ const state = this.#cohortStates.get(cohortId);
720
+ if(!state || state.phase !== ParticipantCohortPhase.AwaitingFallbackSig) {
721
+ throw new AggregationParticipantError(
722
+ `Cannot approve fallback for cohort ${cohortId}: not in AwaitingFallbackSig phase.`,
723
+ 'INVALID_PHASE', { cohortId, phase: state?.phase }
724
+ );
725
+ }
726
+ if(!state.fallbackRequest || !state.cohort) {
727
+ throw new AggregationParticipantError(
728
+ `Cohort ${cohortId} missing fallback request or cohort state.`,
729
+ 'MISSING_STATE', { cohortId }
730
+ );
731
+ }
732
+
733
+ const req = state.fallbackRequest;
734
+ const tx = Transaction.fromRaw(hexToBytes(req.pendingTxHex), { allowUnknownOutputs: true });
735
+ const prevOutScript = hexToBytes(req.prevOutScriptHex);
736
+ const prevOutValue = BigInt(req.prevOutValue);
737
+
738
+ // Refuse to sign unless the fallback tx anchors the signal this member
739
+ // validated (the coordinator drives output selection on the fallback path).
740
+ this.#assertTxAnchorsValidatedSignal(cohortId, state, tx);
741
+
742
+ // Recompute the fallback leaf from our own cohort keys so a malicious service
743
+ // cannot induce a signature over a different leaf than the one the funded
744
+ // address commits to.
745
+ const fallbackLeaf = buildFallbackLeaf({
746
+ cohortKeys : state.cohort.cohortKeys,
747
+ fallbackThreshold : state.cohort.effectiveFallbackThreshold,
748
+ });
749
+ const sighash = fallbackSighash(tx, 0, prevOutScript, prevOutValue, fallbackLeaf);
750
+ const signature = this.#signer.withSecret(secretKey => schnorr.sign(sighash, secretKey));
751
+
752
+ state.phase = ParticipantCohortPhase.Complete;
753
+ return [createFallbackSignatureMessage({
754
+ from : this.did,
755
+ to : state.serviceDid,
756
+ cohortId,
757
+ sessionId : req.sessionId,
758
+ signerPk : this.publicKey.slice(1),
759
+ fallbackSignature : signature,
760
+ })];
761
+ }
762
+
763
+
764
+ public getCohortPhase(cohortId: string): ParticipantCohortPhaseType | undefined {
765
+ return this.#cohortStates.get(cohortId)?.phase;
766
+ }
767
+
768
+ /**
769
+ * Zeroize any retained MuSig2 secret nonces across all cohorts. The raw
770
+ * signing key is never held here (it lives behind the {@link AggregationSigner}
771
+ * and is wiped per-operation), but an abandoned signing session can still hold
772
+ * a secret nonce; call this on teardown to clear it deterministically.
773
+ */
774
+ public clearSecrets(): void {
775
+ for(const state of this.#cohortStates.values()) {
776
+ state.signingSession?.clearSecrets();
777
+ }
778
+ }
779
+ }