@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,223 @@
1
+ /**
2
+ * Fallback spend builder for aggregate beacon outputs.
3
+ *
4
+ * An aggregate beacon UTXO is a Taproot output whose key path is the cohort's
5
+ * n-of-n MuSig2 aggregate key and whose script path carries a k-of-n fallback
6
+ * leaf (`p2tr_ms` CHECKSIGADD) plus a CSV recovery leaf (see
7
+ * {@link ./recovery-policy.ts} and ADR 042). When the optimistic n-of-n key path
8
+ * stalls (a missing or defecting signer), the cohort can still push the SAME
9
+ * announcement transaction through the fallback leaf with any k members'
10
+ * signatures, instead of abandoning the round or waiting out the recovery
11
+ * timelock.
12
+ *
13
+ * Unlike the MuSig2 key path, the fallback is a plain k-of-n script-path spend:
14
+ * each signer produces a standalone BIP-340 signature over the script-path
15
+ * sighash (no nonce round), and the coordinator assembles k of them into the
16
+ * witness. The coordinator never holds a participant secret - it injects the
17
+ * collected signatures and finalizes.
18
+ *
19
+ * This module is pure with respect to the network: it takes the beacon
20
+ * transaction, the cohort and recovery parameters, the spent output, and the
21
+ * collected signatures, and returns a finalized {@link Transaction}.
22
+ * Broadcasting is the caller's concern.
23
+ */
24
+
25
+ import { getNetwork } from '@did-btcr2/bitcoin';
26
+ import { schnorr } from '@noble/curves/secp256k1.js';
27
+ import type { Transaction} from '@scure/btc-signer';
28
+ import { SigHash, p2tr } from '@scure/btc-signer';
29
+ import { keyAggExport, keyAggregate, sortKeys } from '@scure/btc-signer/musig2';
30
+ import { AggregationCohortError } from './errors.js';
31
+ import type { FundingModel } from './recovery-policy.js';
32
+ import {
33
+ DEFAULT_FUNDING_MODEL,
34
+ TAPROOT_LEAF_VERSION,
35
+ buildFallbackLeaf,
36
+ buildRecoveryLeaves,
37
+ resolveFallbackThreshold,
38
+ tapLeafHash,
39
+ } from './recovery-policy.js';
40
+
41
+ /** A single member's standalone signature over the fallback script-path sighash. */
42
+ export interface FallbackSignature {
43
+ /** Signer's x-only public key (32 bytes). Must be one of the cohort's keys. */
44
+ pubKey: Uint8Array;
45
+ /** 64-byte BIP-340 Schnorr signature over the script-path sighash. */
46
+ signature: Uint8Array;
47
+ }
48
+
49
+ /** Inputs to {@link buildFallbackSpend}. */
50
+ export interface FallbackSpendParams {
51
+ /**
52
+ * The beacon announcement transaction to finalize. Input {@link inputIndex}
53
+ * spends the beacon UTXO; its key-path witness must not be set (the fallback
54
+ * assembles a script-path witness instead).
55
+ */
56
+ pendingTx: Transaction;
57
+ /** Index of the beacon input within {@link pendingTx}. Defaults to 0. */
58
+ inputIndex?: number;
59
+ /**
60
+ * The cohort's participant public keys (compressed secp256k1, 33 bytes).
61
+ * Sorted internally per BIP-327 so the reconstructed tree matches the funded
62
+ * output.
63
+ */
64
+ cohortKeys: Uint8Array[];
65
+ /**
66
+ * The advertised k of the k-of-n fallback leaf, or omit for the cohort's n-1
67
+ * default. Resolved against `cohortKeys.length` exactly as the cohort does.
68
+ */
69
+ fallbackThreshold?: number;
70
+ /** Operator recovery key, x-only (32 bytes). Needed to reconstruct the script tree. */
71
+ recoveryKey: Uint8Array;
72
+ /** Relative-timelock (BIP-68) the recovery leaf enforces. Needed to reconstruct the script tree. */
73
+ recoverySequence: number;
74
+ /** Funding model governing the leaves. Defaults to 'operator-funded'. */
75
+ fundingModel?: FundingModel;
76
+ /** Bitcoin network name (bitcoin/mainnet, mutinynet, signet, testnet, regtest). */
77
+ network: string;
78
+ /** scriptPubKey of the beacon UTXO being spent. Must equal the reconstructed tree output script. */
79
+ prevOutScript: Uint8Array;
80
+ /** Value of the beacon UTXO in satoshis. */
81
+ prevOutValue: bigint;
82
+ /**
83
+ * Standalone signatures collected from cohort members. At least
84
+ * {@link fallbackThreshold} valid, distinct-by-key signatures are required;
85
+ * exactly k are injected (a k-of-n CHECKSIGADD leaf demands exactly k).
86
+ */
87
+ signatures: FallbackSignature[];
88
+ }
89
+
90
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
91
+ return a.length === b.length && a.every((x, i) => x === b[i]);
92
+ }
93
+
94
+ /**
95
+ * Compute the BIP-341 script-path sighash a cohort member must sign to authorize
96
+ * the fallback spend. The leaf is the k-of-n fallback leaf; signers produce a
97
+ * standalone BIP-340 signature over this digest with SIGHASH_DEFAULT.
98
+ */
99
+ export function fallbackSighash(
100
+ tx: Transaction,
101
+ inputIndex: number,
102
+ prevOutScript: Uint8Array,
103
+ prevOutValue: bigint,
104
+ fallbackLeafScript: Uint8Array,
105
+ ): Uint8Array {
106
+ return tx.preimageWitnessV1(
107
+ inputIndex,
108
+ [ prevOutScript ],
109
+ SigHash.DEFAULT,
110
+ [ prevOutValue ],
111
+ undefined,
112
+ fallbackLeafScript,
113
+ TAPROOT_LEAF_VERSION,
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Assemble and finalize a k-of-n fallback spend of an aggregate beacon UTXO.
119
+ *
120
+ * Reconstructs the funded Taproot output (MuSig2 internal key + fallback/recovery
121
+ * script tree), verifies each collected signature against the script-path
122
+ * sighash and a cohort key, injects exactly k distinct valid signatures into the
123
+ * fallback leaf, and finalizes the witness. The returned transaction is the same
124
+ * announcement the optimistic path would have produced, signed via the script
125
+ * path instead of the key path.
126
+ *
127
+ * @throws {AggregationCohortError} when there are no cohort keys, the spent
128
+ * script does not match the reconstructed tree output, or fewer than k valid
129
+ * distinct signatures were supplied.
130
+ */
131
+ export function buildFallbackSpend(params: FallbackSpendParams): Transaction {
132
+ const {
133
+ pendingTx, cohortKeys, recoveryKey, recoverySequence,
134
+ fundingModel, network, prevOutScript, prevOutValue, signatures,
135
+ } = params;
136
+ const inputIndex = params.inputIndex ?? 0;
137
+
138
+ if(cohortKeys.length === 0) {
139
+ throw new AggregationCohortError(
140
+ 'Cannot build fallback spend: no cohort keys.',
141
+ 'NO_COHORT_KEYS'
142
+ );
143
+ }
144
+
145
+ // Resolve k the same way the cohort did, so the reconstructed leaf matches.
146
+ const fallbackThreshold = resolveFallbackThreshold(params.fallbackThreshold, cohortKeys.length);
147
+
148
+ // Reconstruct the funded output the same way the cohort derived its address.
149
+ const internalKey = keyAggExport(keyAggregate(sortKeys(cohortKeys)));
150
+ const leaves = buildRecoveryLeaves(fundingModel ?? DEFAULT_FUNDING_MODEL, {
151
+ recoveryKey, recoverySequence, cohortKeys, fallbackThreshold,
152
+ });
153
+ const net = getNetwork(network);
154
+ // allowUnknownOutputs: the CSV recovery leaf is a custom script (see cohort.ts).
155
+ const payment = p2tr(internalKey, leaves, net, true);
156
+
157
+ // The output we are spending must be the cohort's funded beacon output; if the
158
+ // reconstructed tree script differs, the witness would not match the chain.
159
+ if(!bytesEqual(payment.script, prevOutScript)) {
160
+ throw new AggregationCohortError(
161
+ 'Reconstructed beacon output script does not match the spent prevout script.',
162
+ 'PREVOUT_SCRIPT_MISMATCH'
163
+ );
164
+ }
165
+
166
+ const fallbackLeaf = buildFallbackLeaf({ cohortKeys, fallbackThreshold });
167
+ // Locate the fallback leaf's tapLeafScript entry: each entry is
168
+ // [controlBlock, script || leafVersion]; strip the trailing version byte to
169
+ // compare the script.
170
+ const leafEntry = payment.tapLeafScript?.find(([ , scriptVer ]) => {
171
+ const script = scriptVer.slice(0, scriptVer.length - 1);
172
+ return bytesEqual(script, fallbackLeaf);
173
+ });
174
+ if(!leafEntry) {
175
+ throw new AggregationCohortError(
176
+ 'Could not locate the fallback leaf in the reconstructed beacon output.',
177
+ 'FALLBACK_LEAF_NOT_FOUND'
178
+ );
179
+ }
180
+
181
+ // Validate the collected signatures against the script-path sighash. The leaf
182
+ // is keyed by x-only cohort keys, so a valid signature must verify against one
183
+ // of them. Keep only distinct, valid signatures (first occurrence per key).
184
+ const cohortXOnly = sortKeys(cohortKeys).map(k => k.slice(1));
185
+ const tx = pendingTx;
186
+ const sighash = fallbackSighash(tx, inputIndex, prevOutScript, prevOutValue, fallbackLeaf);
187
+
188
+ const leafHash = tapLeafHash(fallbackLeaf);
189
+ const accepted = new Map<string, Uint8Array>(); // x-only hex to signature
190
+ for(const { pubKey, signature } of signatures) {
191
+ if(pubKey.length !== 32 || signature.length !== 64) continue;
192
+ const isCohortKey = cohortXOnly.some(k => bytesEqual(k, pubKey));
193
+ if(!isCohortKey) continue;
194
+ const hex = Array.from(pubKey, b => b.toString(16).padStart(2, '0')).join('');
195
+ if(accepted.has(hex)) continue;
196
+ let ok = false;
197
+ try { ok = schnorr.verify(signature, sighash, pubKey); } catch { ok = false; }
198
+ if(ok) accepted.set(hex, signature);
199
+ }
200
+
201
+ if(accepted.size < fallbackThreshold) {
202
+ throw new AggregationCohortError(
203
+ `Not enough valid fallback signatures: have ${accepted.size}, need ${fallbackThreshold}.`,
204
+ 'NOT_ENOUGH_FALLBACK_SIGNATURES', { have: accepted.size, need: fallbackThreshold }
205
+ );
206
+ }
207
+
208
+ // A k-of-n CHECKSIGADD leaf demands EXACTLY k satisfied signatures (the final
209
+ // <k> NUMEQUAL fails if the running count differs), so inject exactly k.
210
+ const chosen = cohortXOnly
211
+ .filter(k => accepted.has(Array.from(k, b => b.toString(16).padStart(2, '0')).join('')))
212
+ .slice(0, fallbackThreshold);
213
+
214
+ tx.updateInput(inputIndex, {
215
+ tapLeafScript : [ leafEntry ],
216
+ tapScriptSig : chosen.map(pubKey => {
217
+ const hex = Array.from(pubKey, b => b.toString(16).padStart(2, '0')).join('');
218
+ return [ { pubKey, leafHash }, accepted.get(hex)! ];
219
+ }),
220
+ });
221
+ tx.finalize();
222
+ return tx;
223
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ // State machines + core data
2
+ export * from './service.js';
3
+ export * from './participant.js';
4
+ export * from './signer.js';
5
+ export * from './conditions.js';
6
+ export * from './recovery-policy.js';
7
+ export * from './recovery-spend.js';
8
+ export * from './fallback-spend.js';
9
+ export * from './cohort.js';
10
+ export * from './signing-session.js';
11
+ export * from './phases.js';
12
+ export * from './errors.js';
13
+ export * from './beacon-strategy.js';
14
+ export * from './logger.js';
15
+
16
+ // Messages
17
+ export * from './messages/index.js';
18
+
19
+ // Transport
20
+ export * from './transport/index.js';
21
+
22
+ // Runner facades
23
+ export * from './runner/index.js';
package/src/logger.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Minimal injectable logger for the aggregation subsystem.
3
+ *
4
+ * Each runner and transport adapter accepts a `Logger` option; the default is
5
+ * {@link CONSOLE_LOGGER}, which forwards to `console.*`. Pass
6
+ * {@link SILENT_LOGGER} to suppress output (useful for tests) or a custom
7
+ * implementation to route logs to pino, winston, Sentry, etc.
8
+ *
9
+ * The interface is intentionally small: we don't want production code taking
10
+ * a hard dependency on any specific logger library.
11
+ */
12
+ export interface Logger {
13
+ debug(message: string, ...args: unknown[]): void;
14
+ info(message: string, ...args: unknown[]): void;
15
+ warn(message: string, ...args: unknown[]): void;
16
+ error(message: string, ...args: unknown[]): void;
17
+ }
18
+
19
+ /** Console-backed logger. Default for runners and transports. */
20
+ export const CONSOLE_LOGGER: Logger = {
21
+ debug : (msg, ...args) => console.debug(msg, ...args),
22
+ info : (msg, ...args) => console.info(msg, ...args),
23
+ warn : (msg, ...args) => console.warn(msg, ...args),
24
+ error : (msg, ...args) => console.error(msg, ...args),
25
+ };
26
+
27
+ /** No-op logger. Useful for tests and production environments with own logging pipeline. */
28
+ export const SILENT_LOGGER: Logger = {
29
+ debug : () => {},
30
+ info : () => {},
31
+ warn : () => {},
32
+ error : () => {},
33
+ };
@@ -0,0 +1,80 @@
1
+ import type { CohortConditions } from '../conditions.js';
2
+
3
+ /**
4
+ * Current on-the-wire protocol version.
5
+ *
6
+ * Receivers reject messages with an unknown (mismatched) version. Bumping this
7
+ * requires coordinated updates across all participants and any intermediate
8
+ * relays that inspect message content.
9
+ */
10
+ export const AGGREGATION_WIRE_VERSION = 1;
11
+
12
+ // Cohort conditions (beaconType, minParticipants, maxParticipants, ...) ride on
13
+ // the wire as flat optional body fields, supplied via `Partial<CohortConditions>`
14
+ // below so the bag stays a single source of truth for the advertised conditions.
15
+ export type BaseBody = Partial<CohortConditions> & {
16
+ cohortId: string;
17
+ network?: string;
18
+ participantPk?: Uint8Array;
19
+ beaconAddress?: string;
20
+ cohortKeys?: Array<Uint8Array>;
21
+ sessionId?: string;
22
+ aggregatedNonce?: Uint8Array;
23
+ nonceContribution?: Uint8Array;
24
+ partialSignature?: Uint8Array;
25
+ /** Fallback (k-of-n script-path) leaf script, hex. Carried on FALLBACK_AUTHORIZATION_REQUEST. */
26
+ fallbackLeafScriptHex?: string;
27
+ /** Signer's x-only key for a fallback signature (which CHECKSIGADD slot it satisfies). */
28
+ signerPk?: Uint8Array;
29
+ /** A member's standalone BIP-340 fallback script-path signature (64 bytes). */
30
+ fallbackSignature?: Uint8Array;
31
+ pendingTx?: string;
32
+ /** Hex-encoded scriptPubKey of the UTXO being spent. Required for BIP-341 sighash. */
33
+ prevOutScriptHex?: string;
34
+ prevOutValue?: string;
35
+ communicationPk?: Uint8Array;
36
+ data?: string;
37
+ signedUpdate?: Record<string, unknown>;
38
+ casAnnouncement?: Record<string, string>;
39
+ smtProof?: Record<string, unknown>;
40
+ signalBytesHex?: string;
41
+ approved?: boolean;
42
+ };
43
+
44
+ export type Base = {
45
+ type: string;
46
+ version?: number;
47
+ to?: string;
48
+ from: string;
49
+ body?: BaseBody;
50
+ };
51
+
52
+ export class BaseMessage {
53
+ public type: string;
54
+ public version: number;
55
+ public to?: string;
56
+ public from: string;
57
+ public body?: BaseBody;
58
+
59
+ constructor({ type, version, to, from, body }: Base) {
60
+ this.type = type;
61
+ this.version = version ?? AGGREGATION_WIRE_VERSION;
62
+ this.to = to;
63
+ this.from = from;
64
+ this.body = body;
65
+ }
66
+
67
+ /**
68
+ * Converts a BaseMessage to a JSON object.
69
+ * @returns {Base} The JSON representation of the BaseMessage.
70
+ */
71
+ public toJSON(): Base {
72
+ return {
73
+ type : this.type,
74
+ version : this.version,
75
+ to : this.to,
76
+ from : this.from,
77
+ body : this.body
78
+ };
79
+ }
80
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Per-message-type body interfaces and a discriminated {@link AggregationMessage}
3
+ * union.
4
+ *
5
+ * {@link BaseBody} remains the superset-of-all-fields body type used by the
6
+ * raw {@link BaseMessage} class (see `base.ts`). The narrow interfaces here
7
+ * describe what each specific message type is *required* to carry and are
8
+ * exposed alongside type guards for consumers who want compile-time narrowing.
9
+ *
10
+ * Guards validate both `type` and the presence of required body fields so they
11
+ * are safe to use on messages that have round-tripped through JSON / a relay.
12
+ */
13
+
14
+ import type { SerializedSMTProof } from '@did-btcr2/smt';
15
+ import type { CohortConditions } from '../conditions.js';
16
+ import { KNOWN_FUNDING_MODELS } from '../conditions.js';
17
+ import { MAX_RECOVERY_SEQUENCE } from '../recovery-policy.js';
18
+ import type { BaseMessage } from './base.js';
19
+ import {
20
+ AGGREGATED_NONCE,
21
+ AUTHORIZATION_REQUEST,
22
+ COHORT_ADVERT,
23
+ COHORT_OPT_IN,
24
+ COHORT_OPT_IN_ACCEPT,
25
+ COHORT_READY,
26
+ DISTRIBUTE_AGGREGATED_DATA,
27
+ FALLBACK_AUTHORIZATION_REQUEST,
28
+ FALLBACK_SIGNATURE,
29
+ NONCE_CONTRIBUTION,
30
+ SIGNATURE_AUTHORIZATION,
31
+ SUBMIT_NONINCLUDED,
32
+ SUBMIT_UPDATE,
33
+ VALIDATION_ACK,
34
+ } from './constants.js';
35
+
36
+ // ── Cohort formation (Step 1) ─────────────────────────────────────────────
37
+
38
+ export interface CohortAdvertBody extends CohortConditions {
39
+ cohortId: string;
40
+ network: string;
41
+ communicationPk: Uint8Array;
42
+ }
43
+
44
+ export interface CohortOptInBody {
45
+ cohortId: string;
46
+ participantPk: Uint8Array;
47
+ communicationPk: Uint8Array;
48
+ }
49
+
50
+ export interface CohortOptInAcceptBody {
51
+ cohortId: string;
52
+ }
53
+
54
+ export interface CohortReadyBody {
55
+ cohortId: string;
56
+ beaconAddress: string;
57
+ cohortKeys: Array<Uint8Array>;
58
+ }
59
+
60
+ // ── Update / aggregation (Steps 2-3) ──────────────────────────────────────
61
+
62
+ export interface SubmitUpdateBody {
63
+ cohortId: string;
64
+ signedUpdate: Record<string, unknown>;
65
+ }
66
+
67
+ /**
68
+ * A member declines to submit an update this round (cooperative non-inclusion).
69
+ * Carries only the cohortId; membership is proven by the signed transport
70
+ * envelope (the sender DID), so no payload is needed.
71
+ */
72
+ export interface SubmitNonIncludedBody {
73
+ cohortId: string;
74
+ }
75
+
76
+ export interface DistributeAggregatedDataBody {
77
+ cohortId: string;
78
+ beaconType: string;
79
+ signalBytesHex: string;
80
+ casAnnouncement?: Record<string, string>;
81
+ smtProof?: Record<string, unknown> | SerializedSMTProof;
82
+ }
83
+
84
+ export interface ValidationAckBody {
85
+ cohortId: string;
86
+ approved: boolean;
87
+ }
88
+
89
+ // ── Signing (Step 4) ──────────────────────────────────────────────────────
90
+
91
+ export interface AuthorizationRequestBody {
92
+ cohortId: string;
93
+ sessionId: string;
94
+ pendingTx: string;
95
+ prevOutScriptHex: string;
96
+ prevOutValue: string;
97
+ }
98
+
99
+ export interface NonceContributionBody {
100
+ cohortId: string;
101
+ sessionId: string;
102
+ nonceContribution: Uint8Array;
103
+ }
104
+
105
+ export interface AggregatedNonceBody {
106
+ cohortId: string;
107
+ sessionId: string;
108
+ aggregatedNonce: Uint8Array;
109
+ }
110
+
111
+ export interface SignatureAuthorizationBody {
112
+ cohortId: string;
113
+ sessionId: string;
114
+ partialSignature: Uint8Array;
115
+ }
116
+
117
+ /**
118
+ * Service asks members to authorize the k-of-n fallback (script-path) spend of
119
+ * the SAME beacon transaction (ADR 042). Carries the unsigned tx and the spent
120
+ * output so each member recomputes the script-path sighash itself. The fallback
121
+ * leaf script is included for cross-check; members recompute it from their own
122
+ * cohort state.
123
+ */
124
+ export interface FallbackAuthorizationRequestBody {
125
+ cohortId: string;
126
+ sessionId: string;
127
+ pendingTx: string;
128
+ prevOutScriptHex: string;
129
+ prevOutValue: string;
130
+ fallbackLeafScriptHex: string;
131
+ }
132
+
133
+ /**
134
+ * A member's standalone BIP-340 signature over the fallback script-path sighash.
135
+ * `signerPk` is the member's x-only key (which CHECKSIGADD slot it satisfies).
136
+ */
137
+ export interface FallbackSignatureBody {
138
+ cohortId: string;
139
+ sessionId: string;
140
+ signerPk: Uint8Array;
141
+ fallbackSignature: Uint8Array;
142
+ }
143
+
144
+ // ── Narrow message types (BaseMessage & { type, body }) ──────────────────
145
+
146
+ export type CohortAdvertMessage = BaseMessage & { type: typeof COHORT_ADVERT; body: CohortAdvertBody };
147
+ export type CohortOptInMessage = BaseMessage & { type: typeof COHORT_OPT_IN; body: CohortOptInBody };
148
+ export type CohortOptInAcceptMessage = BaseMessage & { type: typeof COHORT_OPT_IN_ACCEPT; body: CohortOptInAcceptBody };
149
+ export type CohortReadyMessage = BaseMessage & { type: typeof COHORT_READY; body: CohortReadyBody };
150
+ export type SubmitUpdateMessage = BaseMessage & { type: typeof SUBMIT_UPDATE; body: SubmitUpdateBody };
151
+ export type SubmitNonIncludedMessage = BaseMessage & { type: typeof SUBMIT_NONINCLUDED; body: SubmitNonIncludedBody };
152
+ export type DistributeAggregatedDataMessage = BaseMessage & { type: typeof DISTRIBUTE_AGGREGATED_DATA; body: DistributeAggregatedDataBody };
153
+ export type ValidationAckMessage = BaseMessage & { type: typeof VALIDATION_ACK; body: ValidationAckBody };
154
+ export type AuthorizationRequestMessage = BaseMessage & { type: typeof AUTHORIZATION_REQUEST; body: AuthorizationRequestBody };
155
+ export type NonceContributionMessage = BaseMessage & { type: typeof NONCE_CONTRIBUTION; body: NonceContributionBody };
156
+ export type AggregatedNonceMessage = BaseMessage & { type: typeof AGGREGATED_NONCE; body: AggregatedNonceBody };
157
+ export type SignatureAuthorizationMessage = BaseMessage & { type: typeof SIGNATURE_AUTHORIZATION; body: SignatureAuthorizationBody };
158
+ export type FallbackAuthorizationRequestMessage = BaseMessage & { type: typeof FALLBACK_AUTHORIZATION_REQUEST; body: FallbackAuthorizationRequestBody };
159
+ export type FallbackSignatureMessage = BaseMessage & { type: typeof FALLBACK_SIGNATURE; body: FallbackSignatureBody };
160
+
161
+ /** Discriminated union of every well-formed aggregation message. */
162
+ export type AggregationMessage =
163
+ | CohortAdvertMessage
164
+ | CohortOptInMessage
165
+ | CohortOptInAcceptMessage
166
+ | CohortReadyMessage
167
+ | SubmitUpdateMessage
168
+ | SubmitNonIncludedMessage
169
+ | DistributeAggregatedDataMessage
170
+ | ValidationAckMessage
171
+ | AuthorizationRequestMessage
172
+ | NonceContributionMessage
173
+ | AggregatedNonceMessage
174
+ | SignatureAuthorizationMessage
175
+ | FallbackAuthorizationRequestMessage
176
+ | FallbackSignatureMessage;
177
+
178
+ // ── Type guards ───────────────────────────────────────────────────────────
179
+ // Each guard validates `type` plus required body fields so it's safe to use
180
+ // on messages that have round-tripped through JSON / a relay.
181
+
182
+ const hasStr = (b: unknown, k: string): boolean =>
183
+ !!b && typeof (b as Record<string, unknown>)[k] === 'string';
184
+ /** Present, an integer, and >= min. */
185
+ const hasIntMin = (b: unknown, k: string, min: number): boolean => {
186
+ const v = b ? (b as Record<string, unknown>)[k] : undefined;
187
+ return typeof v === 'number' && Number.isInteger(v) && v >= min;
188
+ };
189
+ /** Absent, or present as an integer >= min. */
190
+ const optIntMin = (b: unknown, k: string, min: number): boolean => {
191
+ const v = b ? (b as Record<string, unknown>)[k] : undefined;
192
+ return v === undefined || (typeof v === 'number' && Number.isInteger(v) && v >= min);
193
+ };
194
+ /** Present, an integer, and within [min, max] inclusive. */
195
+ const intInRange = (b: unknown, k: string, min: number, max: number): boolean => {
196
+ const v = b ? (b as Record<string, unknown>)[k] : undefined;
197
+ return typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
198
+ };
199
+ /** Absent, or present as a string in the allowed set. */
200
+ const optStrOneOf = (b: unknown, k: string, allowed: readonly string[]): boolean => {
201
+ const v = b ? (b as Record<string, unknown>)[k] : undefined;
202
+ return v === undefined || (typeof v === 'string' && allowed.includes(v));
203
+ };
204
+ const hasBool = (b: unknown, k: string): boolean =>
205
+ !!b && typeof (b as Record<string, unknown>)[k] === 'boolean';
206
+ const hasBytes = (b: unknown, k: string): boolean =>
207
+ !!b && (b as Record<string, unknown>)[k] instanceof Uint8Array;
208
+ const hasBytesArray = (b: unknown, k: string): boolean => {
209
+ const v = b ? (b as Record<string, unknown>)[k] : undefined;
210
+ return Array.isArray(v) && v.every(x => x instanceof Uint8Array);
211
+ };
212
+
213
+ export function isCohortAdvertMessage(m: BaseMessage): m is CohortAdvertMessage {
214
+ // Range-check the participant bounds so a malformed advert (missing or
215
+ // zero/negative minParticipants) is rejected rather than silently accepted as
216
+ // a zero-floor cohort. The service does the full cross-field validation at
217
+ // createCohort (see validateCohortConditions); here we guard the wire shape.
218
+ return m.type === COHORT_ADVERT
219
+ && hasStr(m.body, 'cohortId')
220
+ && hasIntMin(m.body, 'minParticipants', 1)
221
+ && optIntMin(m.body, 'maxParticipants', 1)
222
+ && hasStr(m.body, 'beaconType')
223
+ && hasStr(m.body, 'network')
224
+ // Recovery params are mandatory: a participant must see the recovery terms
225
+ // (key + timelock) before funding the beacon UTXO (ADR 042). recoverySequence
226
+ // is range-checked here because the participant funds based on the advert and
227
+ // does not separately run validateCohortConditions: an out-of-range value
228
+ // (e.g. one with the BIP-68 disable bit set) must be rejected before funding.
229
+ && hasStr(m.body, 'recoveryKey')
230
+ && intInRange(m.body, 'recoverySequence', 1, MAX_RECOVERY_SEQUENCE)
231
+ // fundingModel is optional on the wire (absent means the operator-funded
232
+ // default), but when present it must name a known model.
233
+ && optStrOneOf(m.body, 'fundingModel', KNOWN_FUNDING_MODELS)
234
+ // fallbackThreshold is optional (absent means n-1 at keygen); when present it
235
+ // must be a positive integer. The upper bound against the cohort size is
236
+ // checked when the participant recomputes the beacon address.
237
+ && optIntMin(m.body, 'fallbackThreshold', 1)
238
+ && hasBytes(m.body, 'communicationPk');
239
+ }
240
+
241
+ export function isCohortOptInMessage(m: BaseMessage): m is CohortOptInMessage {
242
+ return m.type === COHORT_OPT_IN
243
+ && hasStr(m.body, 'cohortId')
244
+ && hasBytes(m.body, 'participantPk')
245
+ && hasBytes(m.body, 'communicationPk');
246
+ }
247
+
248
+ export function isCohortOptInAcceptMessage(m: BaseMessage): m is CohortOptInAcceptMessage {
249
+ return m.type === COHORT_OPT_IN_ACCEPT && hasStr(m.body, 'cohortId');
250
+ }
251
+
252
+ export function isCohortReadyMessage(m: BaseMessage): m is CohortReadyMessage {
253
+ return m.type === COHORT_READY
254
+ && hasStr(m.body, 'cohortId')
255
+ && hasStr(m.body, 'beaconAddress')
256
+ && hasBytesArray(m.body, 'cohortKeys');
257
+ }
258
+
259
+ export function isSubmitUpdateMessage(m: BaseMessage): m is SubmitUpdateMessage {
260
+ return m.type === SUBMIT_UPDATE
261
+ && hasStr(m.body, 'cohortId')
262
+ && !!m.body && typeof (m.body as Record<string, unknown>).signedUpdate === 'object';
263
+ }
264
+
265
+ export function isSubmitNonIncludedMessage(m: BaseMessage): m is SubmitNonIncludedMessage {
266
+ return m.type === SUBMIT_NONINCLUDED && hasStr(m.body, 'cohortId');
267
+ }
268
+
269
+ export function isDistributeAggregatedDataMessage(m: BaseMessage): m is DistributeAggregatedDataMessage {
270
+ return m.type === DISTRIBUTE_AGGREGATED_DATA
271
+ && hasStr(m.body, 'cohortId')
272
+ && hasStr(m.body, 'beaconType')
273
+ && hasStr(m.body, 'signalBytesHex');
274
+ }
275
+
276
+ export function isValidationAckMessage(m: BaseMessage): m is ValidationAckMessage {
277
+ return m.type === VALIDATION_ACK
278
+ && hasStr(m.body, 'cohortId')
279
+ && hasBool(m.body, 'approved');
280
+ }
281
+
282
+ export function isAuthorizationRequestMessage(m: BaseMessage): m is AuthorizationRequestMessage {
283
+ return m.type === AUTHORIZATION_REQUEST
284
+ && hasStr(m.body, 'cohortId')
285
+ && hasStr(m.body, 'sessionId')
286
+ && hasStr(m.body, 'pendingTx')
287
+ && hasStr(m.body, 'prevOutScriptHex')
288
+ && hasStr(m.body, 'prevOutValue');
289
+ }
290
+
291
+ export function isNonceContributionMessage(m: BaseMessage): m is NonceContributionMessage {
292
+ return m.type === NONCE_CONTRIBUTION
293
+ && hasStr(m.body, 'cohortId')
294
+ && hasStr(m.body, 'sessionId')
295
+ && hasBytes(m.body, 'nonceContribution');
296
+ }
297
+
298
+ export function isAggregatedNonceMessage(m: BaseMessage): m is AggregatedNonceMessage {
299
+ return m.type === AGGREGATED_NONCE
300
+ && hasStr(m.body, 'cohortId')
301
+ && hasStr(m.body, 'sessionId')
302
+ && hasBytes(m.body, 'aggregatedNonce');
303
+ }
304
+
305
+ export function isSignatureAuthorizationMessage(m: BaseMessage): m is SignatureAuthorizationMessage {
306
+ return m.type === SIGNATURE_AUTHORIZATION
307
+ && hasStr(m.body, 'cohortId')
308
+ && hasStr(m.body, 'sessionId')
309
+ && hasBytes(m.body, 'partialSignature');
310
+ }
311
+
312
+ export function isFallbackAuthorizationRequestMessage(m: BaseMessage): m is FallbackAuthorizationRequestMessage {
313
+ return m.type === FALLBACK_AUTHORIZATION_REQUEST
314
+ && hasStr(m.body, 'cohortId')
315
+ && hasStr(m.body, 'sessionId')
316
+ && hasStr(m.body, 'pendingTx')
317
+ && hasStr(m.body, 'prevOutScriptHex')
318
+ && hasStr(m.body, 'prevOutValue')
319
+ && hasStr(m.body, 'fallbackLeafScriptHex');
320
+ }
321
+
322
+ export function isFallbackSignatureMessage(m: BaseMessage): m is FallbackSignatureMessage {
323
+ return m.type === FALLBACK_SIGNATURE
324
+ && hasStr(m.body, 'cohortId')
325
+ && hasStr(m.body, 'sessionId')
326
+ && hasBytes(m.body, 'signerPk')
327
+ && hasBytes(m.body, 'fallbackSignature');
328
+ }