@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/phases.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Phases for the did:btcr2 Aggregate Beacon protocol.
3
+ *
4
+ * The protocol has two roles (AggregationService and AggregationParticipant)
5
+ * which experience different phases for the same cohort. Each role has its own
6
+ * phase enum.
7
+ *
8
+ * The signing session has its own phase enum because MuSig2 signing is a
9
+ * sub-protocol within the larger aggregation protocol.
10
+ */
11
+ export type ServiceCohortPhaseType =
12
+ | 'Created'
13
+ | 'Advertised'
14
+ | 'CohortSet'
15
+ | 'CollectingUpdates'
16
+ | 'UpdatesCollected'
17
+ | 'DataDistributed'
18
+ | 'Validated'
19
+ | 'SigningStarted'
20
+ | 'NoncesCollected'
21
+ | 'AwaitingPartialSigs'
22
+ | 'FallbackRequested'
23
+ | 'Complete'
24
+ | 'Failed';
25
+
26
+ export enum ServiceCohortPhase {
27
+ Created = 'Created',
28
+ Advertised = 'Advertised',
29
+ CohortSet = 'CohortSet',
30
+ CollectingUpdates = 'CollectingUpdates',
31
+ UpdatesCollected = 'UpdatesCollected',
32
+ DataDistributed = 'DataDistributed',
33
+ Validated = 'Validated',
34
+ SigningStarted = 'SigningStarted',
35
+ NoncesCollected = 'NoncesCollected',
36
+ AwaitingPartialSigs = 'AwaitingPartialSigs',
37
+ /** Optimistic n-of-n key path abandoned; collecting k-of-n fallback signatures (ADR 042). */
38
+ FallbackRequested = 'FallbackRequested',
39
+ Complete = 'Complete',
40
+ Failed = 'Failed',
41
+ }
42
+
43
+ export type ParticipantCohortPhaseType =
44
+ | 'Discovered'
45
+ | 'OptedIn'
46
+ | 'CohortReady'
47
+ | 'UpdateSubmitted'
48
+ | 'NonIncluded'
49
+ | 'AwaitingValidation'
50
+ | 'ValidationSent'
51
+ | 'AwaitingSigning'
52
+ | 'NonceSent'
53
+ | 'AwaitingPartialSig'
54
+ | 'AwaitingFallbackSig'
55
+ | 'Complete'
56
+ | 'Failed';
57
+
58
+ export enum ParticipantCohortPhase {
59
+ Discovered = 'Discovered',
60
+ OptedIn = 'OptedIn',
61
+ CohortReady = 'CohortReady',
62
+ UpdateSubmitted = 'UpdateSubmitted',
63
+ /** Member declined to submit an update this round (cooperative non-inclusion); still signs. */
64
+ NonIncluded = 'NonIncluded',
65
+ AwaitingValidation = 'AwaitingValidation',
66
+ ValidationSent = 'ValidationSent',
67
+ AwaitingSigning = 'AwaitingSigning',
68
+ NonceSent = 'NonceSent',
69
+ AwaitingPartialSig = 'AwaitingPartialSig',
70
+ /** Service fell back to the k-of-n script path; member can sign the fallback (ADR 042). */
71
+ AwaitingFallbackSig = 'AwaitingFallbackSig',
72
+ Complete = 'Complete',
73
+ Failed = 'Failed',
74
+ }
75
+
76
+ export type SigningSessionPhaseType =
77
+ | 'AwaitingNonceContributions'
78
+ | 'NonceContributionsReceived'
79
+ | 'AwaitingPartialSignatures'
80
+ | 'PartialSignaturesReceived'
81
+ | 'Complete'
82
+ | 'Failed';
83
+
84
+ export enum SigningSessionPhase {
85
+ AwaitingNonceContributions = 'AwaitingNonceContributions',
86
+ NonceContributionsReceived = 'NonceContributionsReceived',
87
+ AwaitingPartialSignatures = 'AwaitingPartialSignatures',
88
+ PartialSignaturesReceived = 'PartialSignaturesReceived',
89
+ Complete = 'Complete',
90
+ Failed = 'Failed',
91
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Recovery policy 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. A key-path-only output can be spent only with a
6
+ * signature from every cohort member, so a single missing signer would lock the
7
+ * funds forever. To prevent that, the output commits to a script tree with two
8
+ * leaves:
9
+ * - a k-of-n fallback leaf (`p2tr_ms`, BIP-342 CHECKSIGADD): any k cohort members
10
+ * can still push the announcement through if some signers go missing, so the
11
+ * beacon stays live without waiting out the timelock.
12
+ * - a CSV recovery leaf: after a relative-timelock delay the funder can reclaim
13
+ * the UTXO unilaterally with its own recovery key, so funds are never
14
+ * permanently stranded even if fewer than k members remain.
15
+ *
16
+ * This module is the seam between the funding model and the concrete script
17
+ * leaves. Only `operator-funded` is implemented today (the k-of-n fallback leaf
18
+ * plus one CSV leaf keyed to the operator's recovery key). A future
19
+ * `participant-funded` model can be added as another case returning
20
+ * per-participant refund leaves, without touching the cohort, service, or
21
+ * signing code. See ADR 042.
22
+ */
23
+
24
+ import { schnorr } from '@noble/curves/secp256k1.js';
25
+ import { concatBytes } from '@noble/hashes/utils';
26
+ import { Script, p2tr_ms } from '@scure/btc-signer';
27
+ import { sortKeys } from '@scure/btc-signer/musig2';
28
+ import { AggregationCohortError } from './errors.js';
29
+
30
+ /**
31
+ * Who funds the beacon UTXO and holds the recovery path.
32
+ * - `operator-funded`: the service operator funds the UTXO and holds a single
33
+ * CSV recovery key. Implemented.
34
+ * - `participant-funded`: reserved for per-participant funding with
35
+ * per-participant refund leaves. Not implemented.
36
+ */
37
+ export type FundingModel = 'operator-funded' | 'participant-funded';
38
+
39
+ /** Default funding model when an advert/config leaves it unspecified. */
40
+ export const DEFAULT_FUNDING_MODEL: FundingModel = 'operator-funded';
41
+
42
+ /**
43
+ * Default relative-timelock (in blocks) before operator recovery is spendable.
44
+ * About one day at 10-minute blocks. Operators set their own per-cohort value;
45
+ * this is only a convenience default for callers.
46
+ */
47
+ export const DEFAULT_RECOVERY_SEQUENCE = 144;
48
+
49
+ /**
50
+ * Maximum allowed `recoverySequence`, 0xffff (65535 blocks, about 455 days).
51
+ *
52
+ * The value is a BIP-68 block-based relative timelock and is placed directly in
53
+ * the spending input's nSequence. BIP-68 only uses the low 16 bits as the value
54
+ * when the type flag (bit 22) is clear, so constraining to [1, 0xffff] keeps the
55
+ * timelock block-based and, critically, leaves the disable flag (bit 31) clear.
56
+ * A value with bit 31 set would disable CHECKSEQUENCEVERIFY entirely, letting the
57
+ * recovery key spend with no delay; an upper bound of 0xffff makes that
58
+ * unrepresentable.
59
+ */
60
+ export const MAX_RECOVERY_SEQUENCE = 0xffff;
61
+
62
+ /** Inputs needed to build the CSV recovery leaf. */
63
+ export interface RecoveryPolicyParams {
64
+ /** Operator recovery key, x-only (32 bytes). Spends the recovery leaf via CHECKSIG. */
65
+ recoveryKey: Uint8Array;
66
+ /**
67
+ * Relative-timelock value (BIP-68 nSequence) the recovery spend must wait,
68
+ * encoded into the leaf as `<recoverySequence> CHECKSEQUENCEVERIFY`.
69
+ */
70
+ recoverySequence: number;
71
+ }
72
+
73
+ /** Inputs needed to build the k-of-n fallback leaf. */
74
+ export interface FallbackPolicyParams {
75
+ /**
76
+ * The cohort's participant public keys (compressed secp256k1, 33 bytes each).
77
+ * Sorted internally per BIP-327 and reduced to x-only so the leaf is
78
+ * deterministic regardless of the order keys are supplied in.
79
+ */
80
+ cohortKeys: Uint8Array[];
81
+ /**
82
+ * Number of signers (k) the fallback leaf requires, 1..n. This is the
83
+ * resolved threshold; callers that carry an advertised-or-default value should
84
+ * resolve it with {@link resolveFallbackThreshold} first.
85
+ */
86
+ fallbackThreshold: number;
87
+ }
88
+
89
+ /** All inputs the beacon output's script tree commits to (fallback leaf + recovery leaf). */
90
+ export interface BeaconLeafParams extends RecoveryPolicyParams, FallbackPolicyParams {}
91
+
92
+ /** A single Taproot script-tree leaf, spent at the default tapscript leaf version (0xc0). */
93
+ export interface TaprootScriptLeaf {
94
+ script: Uint8Array;
95
+ }
96
+
97
+ /** BIP-341 default tapscript leaf version. */
98
+ export const TAPROOT_LEAF_VERSION = 0xc0;
99
+
100
+ /** BIP-340 compact-size (varint) prefix for a byte length (scripts stay well under 2^32). */
101
+ function compactSize(n: number): Uint8Array {
102
+ if(n < 0xfd) return new Uint8Array([ n ]);
103
+ if(n <= 0xffff) return new Uint8Array([ 0xfd, n & 0xff, (n >> 8) & 0xff ]);
104
+ return new Uint8Array([ 0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff ]);
105
+ }
106
+
107
+ /**
108
+ * BIP-341 tapleaf hash: `taggedHash("TapLeaf", leafVersion || compactSize(len) || script)`.
109
+ * Used to key a script-path signature to its leaf when assembling the witness
110
+ * for a script-path spend (the fallback and recovery spend builders).
111
+ */
112
+ export function tapLeafHash(script: Uint8Array, leafVersion: number = TAPROOT_LEAF_VERSION): Uint8Array {
113
+ return schnorr.utils.taggedHash(
114
+ 'TapLeaf',
115
+ concatBytes(new Uint8Array([ leafVersion ]), compactSize(script.length), script)
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Resolve the effective fallback threshold k from an advertised value and the
121
+ * cohort size n. When unadvertised it defaults to n-1 (tolerate one missing or
122
+ * defecting signer, the cheapest useful fallback witness), floored at 1. Both
123
+ * the service and every participant resolve this identically so they derive the
124
+ * same beacon address.
125
+ */
126
+ export function resolveFallbackThreshold(advertised: number | undefined, n: number): number {
127
+ return advertised ?? Math.max(1, n - 1);
128
+ }
129
+
130
+ function assertFallbackParams({ cohortKeys, fallbackThreshold }: FallbackPolicyParams): void {
131
+ const n = cohortKeys.length;
132
+ if(n === 0) {
133
+ throw new AggregationCohortError(
134
+ 'Cannot build fallback leaf: no cohort keys.',
135
+ 'NO_COHORT_KEYS'
136
+ );
137
+ }
138
+ for(const key of cohortKeys) {
139
+ if(key.length !== 33) {
140
+ throw new AggregationCohortError(
141
+ `Cohort key must be a 33-byte compressed public key, got ${key.length} bytes.`,
142
+ 'INVALID_COHORT_KEY', { length: key.length }
143
+ );
144
+ }
145
+ }
146
+ if(!Number.isInteger(fallbackThreshold) || fallbackThreshold < 1 || fallbackThreshold > n) {
147
+ throw new AggregationCohortError(
148
+ `Fallback threshold must be an integer in [1, ${n}] (k-of-n), got ${fallbackThreshold}.`,
149
+ 'INVALID_FALLBACK_THRESHOLD', { fallbackThreshold, n }
150
+ );
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Build the k-of-n fallback leaf script: a BIP-342 `p2tr_ms` CHECKSIGADD
156
+ * multisig over the cohort's x-only keys. Any k members can spend this leaf to
157
+ * push the announcement through when the optimistic n-of-n key path stalls.
158
+ *
159
+ * The keys are sorted per BIP-327 (matching the MuSig2 internal-key ordering)
160
+ * and reduced to x-only, so the leaf is identical for every party that builds it.
161
+ */
162
+ export function buildFallbackLeaf(params: FallbackPolicyParams): Uint8Array {
163
+ assertFallbackParams(params);
164
+ const xOnlyKeys = sortKeys(params.cohortKeys).map(k => k.slice(1));
165
+ return p2tr_ms(params.fallbackThreshold, xOnlyKeys).script;
166
+ }
167
+
168
+ function assertRecoveryParams({ recoveryKey, recoverySequence }: RecoveryPolicyParams): void {
169
+ if(recoveryKey.length !== 32) {
170
+ throw new AggregationCohortError(
171
+ `Recovery key must be a 32-byte x-only public key, got ${recoveryKey.length} bytes.`,
172
+ 'INVALID_RECOVERY_KEY'
173
+ );
174
+ }
175
+ if(!Number.isInteger(recoverySequence) || recoverySequence < 1 || recoverySequence > MAX_RECOVERY_SEQUENCE) {
176
+ throw new AggregationCohortError(
177
+ `Recovery sequence must be a block-based BIP-68 relative timelock in [1, ${MAX_RECOVERY_SEQUENCE}], got ${recoverySequence}.`,
178
+ 'INVALID_RECOVERY_SEQUENCE'
179
+ );
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Build the CSV recovery leaf script:
185
+ * `<recoverySequence> CHECKSEQUENCEVERIFY DROP <recoveryKey> CHECKSIG`.
186
+ *
187
+ * After `recoverySequence` blocks (relative to the UTXO's confirmation) the
188
+ * holder of `recoveryKey` can spend the output via the script path.
189
+ */
190
+ export function buildRecoveryScript(params: RecoveryPolicyParams): Uint8Array {
191
+ assertRecoveryParams(params);
192
+ return Script.encode([
193
+ params.recoverySequence,
194
+ 'CHECKSEQUENCEVERIFY',
195
+ 'DROP',
196
+ params.recoveryKey,
197
+ 'CHECKSIG',
198
+ ]);
199
+ }
200
+
201
+ /**
202
+ * Build the Taproot script-tree leaves for a funding model. The returned leaves
203
+ * are the script tree the beacon output key commits to alongside the cohort's
204
+ * MuSig2 internal key.
205
+ *
206
+ * Canonical leaf order is fallback (k-of-n, leaf A) then CSV recovery (leaf B).
207
+ * For the current two-leaf tree the Merkle root is order-invariant (a TapBranch
208
+ * sorts its two child hashes), but fixing the order keeps the construction
209
+ * deterministic and reviewable, and is consensus-affecting should the tree ever
210
+ * grow past two leaves.
211
+ *
212
+ * @throws {AggregationCohortError} when the funding model is reserved (e.g.
213
+ * `participant-funded`) or unknown, or when the fallback/recovery params are
214
+ * invalid.
215
+ */
216
+ export function buildRecoveryLeaves(
217
+ fundingModel: FundingModel,
218
+ params: BeaconLeafParams
219
+ ): TaprootScriptLeaf[] {
220
+ switch(fundingModel) {
221
+ case 'operator-funded':
222
+ return [
223
+ { script: buildFallbackLeaf(params) },
224
+ { script: buildRecoveryScript(params) },
225
+ ];
226
+ case 'participant-funded':
227
+ throw new AggregationCohortError(
228
+ 'Funding model \'participant-funded\' is reserved and not yet implemented.',
229
+ 'UNSUPPORTED_FUNDING_MODEL', { fundingModel }
230
+ );
231
+ default:
232
+ throw new AggregationCohortError(
233
+ `Unknown funding model: ${fundingModel}.`,
234
+ 'UNKNOWN_FUNDING_MODEL', { fundingModel }
235
+ );
236
+ }
237
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Recovery 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 timelocked
6
+ * recovery leaf (see {@link ./recovery-policy.ts} and ADR 042). When the
7
+ * optimistic cooperative key-path spend cannot complete (a missing or defecting
8
+ * signer), the funder reclaims the UTXO via the recovery leaf after the
9
+ * relative-timelock delay.
10
+ *
11
+ * This module builds and signs that script-path spend. It is pure with respect
12
+ * to the network: it takes a UTXO reference plus the cohort and recovery
13
+ * parameters and returns a finalized {@link Transaction}; broadcasting and
14
+ * confirmation are the caller's concern. The recovery key path is a single
15
+ * BIP-340 Schnorr signature, so unlike the MuSig2 key path it can be driven by
16
+ * an opaque or HSM-backed signer.
17
+ *
18
+ * The relative timelock is enforced by consensus (BIP-68/112): the spend is only
19
+ * valid once the UTXO has `recoverySequence` confirmations, and the input's
20
+ * nSequence is set accordingly. The transaction version is 2 so BIP-68 applies.
21
+ */
22
+
23
+ import { getNetwork } from '@did-btcr2/bitcoin';
24
+ import { schnorr } from '@noble/curves/secp256k1.js';
25
+ import { p2tr, Transaction } from '@scure/btc-signer';
26
+ import { keyAggExport, keyAggregate, sortKeys } from '@scure/btc-signer/musig2';
27
+ import { AggregationCohortError } from './errors.js';
28
+ import type { FundingModel } from './recovery-policy.js';
29
+ import { DEFAULT_FUNDING_MODEL, buildRecoveryLeaves, resolveFallbackThreshold } from './recovery-policy.js';
30
+
31
+ /** Reference to the beacon UTXO being recovered. */
32
+ export interface BeaconUtxoRef {
33
+ /** Transaction id (Esplora display-order hex), as returned by the REST UTXO endpoint. */
34
+ txid: string;
35
+ /** Output index within that transaction. */
36
+ vout: number;
37
+ /** Value of the beacon UTXO in satoshis. */
38
+ value: bigint;
39
+ }
40
+
41
+ /** Inputs to {@link buildRecoverySpend}. */
42
+ export interface RecoverySpendParams {
43
+ /**
44
+ * The cohort's participant public keys (compressed secp256k1). Sorted
45
+ * internally per BIP-327 so the reconstructed internal key matches the one the
46
+ * beacon address was derived from.
47
+ */
48
+ cohortKeys: Uint8Array[];
49
+ /** Operator recovery secret key (32 bytes). Its x-only public key must equal {@link recoveryKey}. */
50
+ recoverySecretKey: Uint8Array;
51
+ /** Operator recovery key, x-only (32 bytes), as committed in the recovery leaf. */
52
+ recoveryKey: Uint8Array;
53
+ /** Relative-timelock (BIP-68 nSequence) the recovery leaf enforces. */
54
+ recoverySequence: number;
55
+ /**
56
+ * The advertised k of the k-of-n fallback leaf the beacon output also commits
57
+ * to, or omit to use the same n-1 default the cohort applies. Resolved against
58
+ * `cohortKeys.length` exactly as the cohort does, so the reconstructed script
59
+ * tree and control block match the funded address.
60
+ */
61
+ fallbackThreshold?: number;
62
+ /** Funding model governing the recovery leaves. Defaults to 'operator-funded'. */
63
+ fundingModel?: FundingModel;
64
+ /** Bitcoin network name (bitcoin/mainnet, mutinynet, signet, testnet, regtest). */
65
+ network: string;
66
+ /**
67
+ * The beacon UTXO being recovered.
68
+ *
69
+ * Precondition: the on-chain output at this `txid:vout` MUST have been funded
70
+ * to the beacon address derived from these same `cohortKeys`, `recoveryKey`,
71
+ * `recoverySequence`, and `fundingModel`. If it was funded under different
72
+ * parameters the reconstructed `witnessUtxo.script` will not match the chain,
73
+ * the signature will be invalid, and the spend will be rejected. Pass
74
+ * {@link beaconAddress} to assert this reconstruction up front.
75
+ */
76
+ utxo: BeaconUtxoRef;
77
+ /** Address receiving the recovered funds. */
78
+ destinationAddress: string;
79
+ /** Absolute fee (satoshis) deducted from the recovered value. */
80
+ fee: bigint;
81
+ /**
82
+ * Optional: the cohort's funded beacon address. When provided, the builder
83
+ * asserts that the address it reconstructs from the cohort and recovery params
84
+ * equals it, catching a caller mismatch before producing an unspendable tx.
85
+ */
86
+ beaconAddress?: string;
87
+ }
88
+
89
+ /**
90
+ * Conservative dust floor (satoshis). The standard p2pkh dust limit; a generic
91
+ * floor below which the recovered output would not be relayable regardless of
92
+ * the destination script type.
93
+ */
94
+ const DUST_LIMIT_SATS = 546n;
95
+
96
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
97
+ return a.length === b.length && a.every((x, i) => x === b[i]);
98
+ }
99
+
100
+ /**
101
+ * Build and sign a script-path recovery spend of an aggregate beacon UTXO.
102
+ *
103
+ * Reconstructs the same Taproot output the cohort funded (MuSig2 internal key +
104
+ * recovery script tree), spends it through the recovery leaf with the operator's
105
+ * recovery key, and finalizes the witness (signature + leaf script + control
106
+ * block). The returned transaction is ready to broadcast once the UTXO has
107
+ * `recoverySequence` confirmations.
108
+ *
109
+ * @throws {AggregationCohortError} when the recovery secret key does not match
110
+ * the committed recovery key, when there are no cohort keys, or when the fee
111
+ * exceeds the UTXO value.
112
+ */
113
+ export function buildRecoverySpend(params: RecoverySpendParams): Transaction {
114
+ const {
115
+ cohortKeys, recoverySecretKey, recoveryKey, recoverySequence, fallbackThreshold,
116
+ fundingModel, network, utxo, destinationAddress, fee, beaconAddress,
117
+ } = params;
118
+
119
+ if(cohortKeys.length === 0) {
120
+ throw new AggregationCohortError(
121
+ 'Cannot build recovery spend: no cohort keys.',
122
+ 'NO_COHORT_KEYS'
123
+ );
124
+ }
125
+
126
+ // The recovery leaf commits to recoveryKey; signing it requires the matching
127
+ // secret. Fail loudly here rather than producing a transaction that cannot
128
+ // finalize (btc-signer would throw an opaque "No taproot scripts signed").
129
+ const derivedPub = schnorr.getPublicKey(recoverySecretKey);
130
+ if(!bytesEqual(derivedPub, recoveryKey)) {
131
+ throw new AggregationCohortError(
132
+ 'Recovery secret key does not correspond to the committed recovery key.',
133
+ 'RECOVERY_KEY_MISMATCH'
134
+ );
135
+ }
136
+
137
+ const out = utxo.value - fee;
138
+ if(out <= 0n) {
139
+ throw new AggregationCohortError(
140
+ `Recovery fee ${fee} exceeds UTXO value ${utxo.value}.`,
141
+ 'FEE_EXCEEDS_VALUE', { fee: fee.toString(), value: utxo.value.toString() }
142
+ );
143
+ }
144
+ if(out < DUST_LIMIT_SATS) {
145
+ throw new AggregationCohortError(
146
+ `Recovered output ${out} is below the dust limit ${DUST_LIMIT_SATS}; the spend would not relay.`,
147
+ 'DUST_OUTPUT', { output: out.toString(), dustLimit: DUST_LIMIT_SATS.toString() }
148
+ );
149
+ }
150
+
151
+ // Reconstruct the funded output. sortKeys mirrors the cohort's key ordering so
152
+ // the aggregate internal key (and therefore the address and control block)
153
+ // match the UTXO being spent.
154
+ const internalKey = keyAggExport(keyAggregate(sortKeys(cohortKeys)));
155
+ const leaves = buildRecoveryLeaves(fundingModel ?? DEFAULT_FUNDING_MODEL, {
156
+ recoveryKey, recoverySequence, cohortKeys,
157
+ fallbackThreshold : resolveFallbackThreshold(fallbackThreshold, cohortKeys.length),
158
+ });
159
+ const net = getNetwork(network);
160
+ // allowUnknownOutputs: the CSV recovery leaf is a custom script (see cohort.ts).
161
+ const payment = p2tr(internalKey, leaves, net, true);
162
+
163
+ // Catch a caller mismatch up front: if the reconstructed address does not match
164
+ // the cohort's funded address, the witnessUtxo script would not match the chain
165
+ // and the spend would be unspendable.
166
+ if(beaconAddress !== undefined && payment.address !== beaconAddress) {
167
+ throw new AggregationCohortError(
168
+ `Reconstructed beacon address ${payment.address} does not match the cohort's funded address ${beaconAddress}.`,
169
+ 'BEACON_ADDRESS_MISMATCH', { reconstructed: payment.address, expected: beaconAddress }
170
+ );
171
+ }
172
+
173
+ // allowUnknownInputs: the CSV recovery leaf is a custom (non-template) script,
174
+ // so btc-signer's finalizer treats it as 'unknown' and will only assemble the
175
+ // witness for it under this flag (it then signs "what we can": our single
176
+ // recovery signature). version 2 activates BIP-68 relative timelocks.
177
+ const tx = new Transaction({ version: 2, allowUnknownInputs: true });
178
+ // Only tapLeafScript is set (no tapInternalKey): btc-signer then skips the
179
+ // key-path branch and signs the recovery leaf with recoverySecretKey. The
180
+ // input nSequence carries the BIP-68 relative timelock the leaf enforces.
181
+ tx.addInput({
182
+ txid : utxo.txid,
183
+ index : utxo.vout,
184
+ witnessUtxo : { script: payment.script, amount: utxo.value },
185
+ tapLeafScript : payment.tapLeafScript,
186
+ sequence : recoverySequence,
187
+ });
188
+ tx.addOutputAddress(destinationAddress, out, net);
189
+ tx.signIdx(recoverySecretKey, 0);
190
+ tx.finalize();
191
+ return tx;
192
+ }
@@ -0,0 +1,125 @@
1
+ import type { SchnorrKeyPair } from '@did-btcr2/keypair';
2
+ import { bytesToHex } from '@noble/hashes/utils';
3
+ import { DEFAULT_FUNDING_MODEL, DEFAULT_RECOVERY_SEQUENCE } from '../recovery-policy.js';
4
+ import type { AggregationResult } from '../service.js';
5
+ import { InMemoryBus, InMemoryTransport } from '../transport/in-memory.js';
6
+ import { AggregationParticipantRunner } from './participant-runner.js';
7
+ import type { OnProvideUpdate } from './participant-runner.js';
8
+ import { AggregationServiceRunner } from './service-runner.js';
9
+ import type { OnProvideTxData } from './service-runner.js';
10
+ import type { FeeEstimator } from '@did-btcr2/bitcoin';
11
+
12
+ /** Identity (DID + keys) for one actor in an {@link AggregationRunner.solo} run. */
13
+ export interface SoloActor {
14
+ did: string;
15
+ keys: SchnorrKeyPair;
16
+ }
17
+
18
+ /** Options for {@link AggregationRunner.solo}. */
19
+ export interface SoloCohortOptions {
20
+ /** The coordinating service identity. */
21
+ service: SoloActor;
22
+ /** The single participant identity (the lone signer of the cohort). */
23
+ participant: SoloActor;
24
+ /**
25
+ * Bitcoin network and beacon type (`'CASBeacon'` | `'SMTBeacon'`) for the
26
+ * cohort. Recovery params are optional here: in a solo run the service actor
27
+ * is the operator and holds full keys, so when `recoveryKey` is omitted the
28
+ * service's own x-only key is used as the CSV recovery key (it can actually
29
+ * perform the recovery), with {@link DEFAULT_RECOVERY_SEQUENCE} as the delay.
30
+ */
31
+ config: {
32
+ network: string;
33
+ beaconType: string;
34
+ /** Operator recovery key, x-only (64-hex). Defaults to the service actor's x-only key. */
35
+ recoveryKey?: string;
36
+ /** Relative-timelock (BIP-68) before recovery is spendable. Defaults to {@link DEFAULT_RECOVERY_SEQUENCE}. */
37
+ recoverySequence?: number;
38
+ };
39
+ /** Provide the participant's signed BTCR2 update for the cohort. */
40
+ onProvideUpdate: OnProvideUpdate;
41
+ /** Provide the Bitcoin transaction data the cohort signs. */
42
+ onProvideTxData: OnProvideTxData;
43
+ /**
44
+ * Fee estimator forwarded to {@link onProvideTxData} so the cohort transaction is
45
+ * sized at a chosen rate. Defaults to a static 5 sat/vB estimator (ADR 045).
46
+ */
47
+ feeEstimator?: FeeEstimator;
48
+ /** Optional overall wall-clock budget for the run (ms). */
49
+ cohortTtlMs?: number;
50
+ /** Optional per-phase stall timeout (ms). */
51
+ phaseTimeoutMs?: number;
52
+ }
53
+
54
+ /**
55
+ * High-level facades for driving an aggregation cohort to completion.
56
+ *
57
+ * @class AggregationRunner
58
+ */
59
+ export class AggregationRunner {
60
+ /**
61
+ * Run a cohort of ONE participant entirely in-process and return the
62
+ * aggregated MuSig2 result.
63
+ *
64
+ * One party plays both the coordinating service and the lone participant,
65
+ * connected over an {@link InMemoryTransport} (no relay or HTTP server). This
66
+ * makes the single-participant aggregate-beacon path (the N=1 corner of the
67
+ * two-axis beacon matrix, see ADR 037) first-class, useful for generating
68
+ * and reproducing single-participant aggregate test vectors.
69
+ *
70
+ * The service advertises a cohort with `minParticipants: 1`; the participant
71
+ * joins, submits its update, and the two complete keygen, data distribution,
72
+ * validation, and a one-signer MuSig2 P2TR key-path signing round.
73
+ *
74
+ * @param options Service + participant identities, cohort config, and the
75
+ * update / tx-data callbacks.
76
+ * @returns The {@link AggregationResult} (cohort id, aggregated signature, signed tx).
77
+ */
78
+ static async solo(options: SoloCohortOptions): Promise<AggregationResult> {
79
+ const transport = new InMemoryTransport(new InMemoryBus());
80
+ transport.registerActor(options.service.did, options.service.keys);
81
+ transport.registerActor(options.participant.did, options.participant.keys);
82
+ // Pre-register communication keys both ways. Production exchanges these via
83
+ // the protocol handshake; in-process we wire them directly.
84
+ transport.registerPeer(options.participant.did, options.participant.keys.publicKey.compressed);
85
+ transport.registerPeer(options.service.did, options.service.keys.publicKey.compressed);
86
+ transport.start();
87
+
88
+ // In a solo run the service actor is the operator and holds full keys, so it
89
+ // can both advertise the recovery terms and, if ever needed, perform the
90
+ // recovery. Default the recovery key to the service's own x-only key.
91
+ const recoveryKey = options.config.recoveryKey
92
+ ?? bytesToHex(options.service.keys.publicKey.compressed.slice(1));
93
+ const recoverySequence = options.config.recoverySequence ?? DEFAULT_RECOVERY_SEQUENCE;
94
+
95
+ const service = new AggregationServiceRunner({
96
+ transport,
97
+ did : options.service.did,
98
+ keys : options.service.keys,
99
+ config : { minParticipants: 1, network: options.config.network, beaconType: options.config.beaconType, recoveryKey, recoverySequence, fundingModel: DEFAULT_FUNDING_MODEL },
100
+ onProvideTxData : options.onProvideTxData,
101
+ feeEstimator : options.feeEstimator,
102
+ cohortTtlMs : options.cohortTtlMs,
103
+ phaseTimeoutMs : options.phaseTimeoutMs,
104
+ // In-process bus with the participant already listening: a single advert
105
+ // suffices, so disable the republish loop (no dangling interval).
106
+ advertRepeatIntervalMs : 0,
107
+ });
108
+
109
+ const participant = new AggregationParticipantRunner({
110
+ transport,
111
+ did : options.participant.did,
112
+ keys : options.participant.keys,
113
+ shouldJoin : async () => true,
114
+ onProvideUpdate : options.onProvideUpdate,
115
+ });
116
+
117
+ await participant.start();
118
+ try {
119
+ return await service.run();
120
+ } finally {
121
+ participant.stop();
122
+ service.stop();
123
+ }
124
+ }
125
+ }