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