@did-btcr2/method 0.35.0 → 0.36.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 (46) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/browser.js +146 -56
  3. package/dist/browser.mjs +146 -56
  4. package/dist/cjs/index.js +146 -55
  5. package/dist/esm/core/aggregation/cohort.js +3 -1
  6. package/dist/esm/core/aggregation/cohort.js.map +1 -1
  7. package/dist/esm/core/aggregation/conditions.js +75 -0
  8. package/dist/esm/core/aggregation/conditions.js.map +1 -0
  9. package/dist/esm/core/aggregation/messages/base.js.map +1 -1
  10. package/dist/esm/core/aggregation/messages/bodies.js +16 -2
  11. package/dist/esm/core/aggregation/messages/bodies.js.map +1 -1
  12. package/dist/esm/core/aggregation/messages/factories.js.map +1 -1
  13. package/dist/esm/core/aggregation/participant.js +9 -7
  14. package/dist/esm/core/aggregation/participant.js.map +1 -1
  15. package/dist/esm/core/aggregation/runner/service-runner.js +8 -0
  16. package/dist/esm/core/aggregation/runner/service-runner.js.map +1 -1
  17. package/dist/esm/core/aggregation/service.js +23 -3
  18. package/dist/esm/core/aggregation/service.js.map +1 -1
  19. package/dist/esm/index.js +1 -0
  20. package/dist/esm/index.js.map +1 -1
  21. package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
  22. package/dist/types/core/aggregation/conditions.d.ts +58 -0
  23. package/dist/types/core/aggregation/conditions.d.ts.map +1 -0
  24. package/dist/types/core/aggregation/messages/base.d.ts +2 -3
  25. package/dist/types/core/aggregation/messages/base.d.ts.map +1 -1
  26. package/dist/types/core/aggregation/messages/bodies.d.ts +2 -3
  27. package/dist/types/core/aggregation/messages/bodies.d.ts.map +1 -1
  28. package/dist/types/core/aggregation/messages/factories.d.ts +2 -3
  29. package/dist/types/core/aggregation/messages/factories.d.ts.map +1 -1
  30. package/dist/types/core/aggregation/participant.d.ts +7 -4
  31. package/dist/types/core/aggregation/participant.d.ts.map +1 -1
  32. package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
  33. package/dist/types/core/aggregation/service.d.ts +8 -4
  34. package/dist/types/core/aggregation/service.d.ts.map +1 -1
  35. package/dist/types/index.d.ts +1 -0
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/package.json +4 -4
  38. package/src/core/aggregation/cohort.ts +3 -1
  39. package/src/core/aggregation/conditions.ts +116 -0
  40. package/src/core/aggregation/messages/base.ts +6 -3
  41. package/src/core/aggregation/messages/bodies.ts +18 -6
  42. package/src/core/aggregation/messages/factories.ts +2 -3
  43. package/src/core/aggregation/participant.ts +16 -11
  44. package/src/core/aggregation/runner/service-runner.ts +9 -0
  45. package/src/core/aggregation/service.ts +39 -7
  46. package/src/index.ts +1 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Cohort conditions: the constraints an Aggregation Service advertises for a
3
+ * cohort (did:btcr2 spec, "Step 1: Create Aggregation Cohort"). See ADR 039.
4
+ *
5
+ * The spec frames these as an optional menu ("the Aggregation Service can define
6
+ * conditions such as ..."), so only `beaconType` and `minParticipants` are
7
+ * required here; every other condition is optional and, when absent, means
8
+ * unconstrained.
9
+ *
10
+ * Enforcement is staged (ADR 039): `beaconType` and the participant bounds are
11
+ * enforced by the state machine now; DIDs-per-participant, timing/cadence, and
12
+ * the pending-update trigger are modeled and advertised here but enforced when
13
+ * the multi-cohort (AGG-4) and non-inclusion (AGG-5) tracks land. The two cost
14
+ * conditions are advertised metadata only - the protocol performs no payment or
15
+ * settlement (consistent with ADR 008).
16
+ */
17
+
18
+ /** Beacon types that support aggregation (singleton is single-party only, per ADR 037). */
19
+ export const KNOWN_BEACON_TYPES = ['CASBeacon', 'SMTBeacon'] as const;
20
+
21
+ /**
22
+ * An advertised price. `unit` is operator-defined (the spec does not specify a
23
+ * currency); `basis` distinguishes a per-DID from a per-participant charge for
24
+ * "cost per announcement". Advertised only - never settled by the protocol.
25
+ */
26
+ export interface CohortCost {
27
+ amount: number;
28
+ unit: string;
29
+ basis?: 'per-did' | 'per-participant';
30
+ }
31
+
32
+ /** The seven spec cohort conditions. Only beaconType + minParticipants are required. */
33
+ export interface CohortConditions {
34
+ /** 1. Beacon mechanism: 'CASBeacon' or 'SMTBeacon'. Enforced. */
35
+ beaconType: string;
36
+ /** 2. Lower bound on cohort size. Enforced (finalize floor). */
37
+ minParticipants: number;
38
+ /** 2. Upper bound on cohort size. Enforced (accept/finalize ceiling). */
39
+ maxParticipants?: number;
40
+ /** 3. Lower bound on DIDs a participant may register. Advertised; enforcement staged (AGG-5). */
41
+ minDidsPerParticipant?: number;
42
+ /** 3. Upper bound on DIDs a participant may register. Advertised; enforcement staged (AGG-5). */
43
+ maxDidsPerParticipant?: number;
44
+ /** 4. One-time enrollment price. Advertised only - no settlement. */
45
+ costOfEnrollment?: CohortCost;
46
+ /** 5. Recurring per-announcement price. Advertised only - no settlement. */
47
+ costPerAnnouncement?: CohortCost;
48
+ /** 6. Floor on time between announcements (seconds). Advertised; enforcement staged (AGG-4/5). */
49
+ minSecondsBetweenAnnouncements?: number;
50
+ /** 6. Ceiling on time between announcements (seconds). Advertised; enforcement staged - generalizes the ADR 027 Cohort TTL. */
51
+ maxSecondsBetweenAnnouncements?: number;
52
+ /** 7. Pending-update count that triggers an announcement. Advertised; enforcement staged (AGG-5) - generalizes hasAllUpdates(). */
53
+ pendingUpdateTrigger?: number;
54
+ }
55
+
56
+ /** Validate an optional [min, max] integer pair. */
57
+ function checkPair(problems: string[], label: string, min?: number, max?: number): void {
58
+ if(min !== undefined && (!Number.isInteger(min) || min < 0)) {
59
+ problems.push(`min${label} must be an integer >= 0`);
60
+ }
61
+ if(max !== undefined && (!Number.isInteger(max) || max < 0)) {
62
+ problems.push(`max${label} must be an integer >= 0`);
63
+ }
64
+ if(min !== undefined && max !== undefined && Number.isInteger(min) && Number.isInteger(max) && max < min) {
65
+ problems.push(`max${label} must be >= min${label}`);
66
+ }
67
+ }
68
+
69
+ /** Validate an optional advertised cost. */
70
+ function checkCost(problems: string[], label: string, cost?: CohortCost): void {
71
+ if(cost === undefined) return;
72
+ if(typeof cost.amount !== 'number' || !Number.isFinite(cost.amount) || cost.amount < 0) {
73
+ problems.push(`${label}.amount must be a finite number >= 0`);
74
+ }
75
+ if(typeof cost.unit !== 'string' || cost.unit.length === 0) {
76
+ problems.push(`${label}.unit must be a non-empty string`);
77
+ }
78
+ if(cost.basis !== undefined && cost.basis !== 'per-did' && cost.basis !== 'per-participant') {
79
+ problems.push(`${label}.basis must be 'per-did' or 'per-participant'`);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Validate a set of cohort conditions. Returns a list of human-readable problems
85
+ * (empty when valid) so the caller can decide how to surface them. Used by
86
+ * `createCohort()` to fail fast instead of discovering invalidity at finalize.
87
+ */
88
+ export function validateCohortConditions(c: CohortConditions): string[] {
89
+ const problems: string[] = [];
90
+
91
+ if(!(KNOWN_BEACON_TYPES as readonly string[]).includes(c.beaconType)) {
92
+ problems.push(`beaconType must be one of ${KNOWN_BEACON_TYPES.join(', ')}`);
93
+ }
94
+ if(!Number.isInteger(c.minParticipants) || c.minParticipants < 1) {
95
+ problems.push('minParticipants must be an integer >= 1');
96
+ }
97
+ if(c.maxParticipants !== undefined) {
98
+ if(!Number.isInteger(c.maxParticipants) || c.maxParticipants < 1) {
99
+ problems.push('maxParticipants must be an integer >= 1');
100
+ } else if(Number.isInteger(c.minParticipants) && c.maxParticipants < c.minParticipants) {
101
+ problems.push('maxParticipants must be >= minParticipants');
102
+ }
103
+ }
104
+
105
+ checkPair(problems, 'DidsPerParticipant', c.minDidsPerParticipant, c.maxDidsPerParticipant);
106
+ checkPair(problems, 'SecondsBetweenAnnouncements', c.minSecondsBetweenAnnouncements, c.maxSecondsBetweenAnnouncements);
107
+
108
+ if(c.pendingUpdateTrigger !== undefined && (!Number.isInteger(c.pendingUpdateTrigger) || c.pendingUpdateTrigger < 1)) {
109
+ problems.push('pendingUpdateTrigger must be an integer >= 1');
110
+ }
111
+
112
+ checkCost(problems, 'costOfEnrollment', c.costOfEnrollment);
113
+ checkCost(problems, 'costPerAnnouncement', c.costPerAnnouncement);
114
+
115
+ return problems;
116
+ }
@@ -1,3 +1,5 @@
1
+ import type { CohortConditions } from '../conditions.js';
2
+
1
3
  /**
2
4
  * Current on-the-wire protocol version.
3
5
  *
@@ -7,9 +9,11 @@
7
9
  */
8
10
  export const AGGREGATION_WIRE_VERSION = 1;
9
11
 
10
- export type BaseBody = {
12
+ // Cohort conditions (beaconType, minParticipants, maxParticipants, ...) ride on
13
+ // the wire as flat optional body fields, supplied via `Partial<CohortConditions>`
14
+ // below so the bag stays a single source of truth for the advertised conditions.
15
+ export type BaseBody = Partial<CohortConditions> & {
11
16
  cohortId: string;
12
- cohortSize?: number;
13
17
  network?: string;
14
18
  participantPk?: Uint8Array;
15
19
  beaconAddress?: string;
@@ -23,7 +27,6 @@ export type BaseBody = {
23
27
  prevOutScriptHex?: string;
24
28
  prevOutValue?: string;
25
29
  communicationPk?: Uint8Array;
26
- beaconType?: string;
27
30
  data?: string;
28
31
  signedUpdate?: Record<string, unknown>;
29
32
  casAnnouncement?: Record<string, string>;
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { SerializedSMTProof } from '@did-btcr2/smt';
15
+ import type { CohortConditions } from '../conditions.js';
15
16
  import type { BaseMessage } from './base.js';
16
17
  import {
17
18
  AGGREGATED_NONCE,
@@ -29,10 +30,8 @@ import {
29
30
 
30
31
  // ── Cohort formation (Step 1) ─────────────────────────────────────────────
31
32
 
32
- export interface CohortAdvertBody {
33
+ export interface CohortAdvertBody extends CohortConditions {
33
34
  cohortId: string;
34
- cohortSize: number;
35
- beaconType: string;
36
35
  network: string;
37
36
  communicationPk: Uint8Array;
38
37
  }
@@ -135,8 +134,16 @@ export type AggregationMessage =
135
134
 
136
135
  const hasStr = (b: unknown, k: string): boolean =>
137
136
  !!b && typeof (b as Record<string, unknown>)[k] === 'string';
138
- const hasNum = (b: unknown, k: string): boolean =>
139
- !!b && typeof (b as Record<string, unknown>)[k] === 'number';
137
+ /** Present, an integer, and >= min. */
138
+ const hasIntMin = (b: unknown, k: string, min: number): boolean => {
139
+ const v = b ? (b as Record<string, unknown>)[k] : undefined;
140
+ return typeof v === 'number' && Number.isInteger(v) && v >= min;
141
+ };
142
+ /** Absent, or present as an integer >= min. */
143
+ const optIntMin = (b: unknown, k: string, min: number): boolean => {
144
+ const v = b ? (b as Record<string, unknown>)[k] : undefined;
145
+ return v === undefined || (typeof v === 'number' && Number.isInteger(v) && v >= min);
146
+ };
140
147
  const hasBool = (b: unknown, k: string): boolean =>
141
148
  !!b && typeof (b as Record<string, unknown>)[k] === 'boolean';
142
149
  const hasBytes = (b: unknown, k: string): boolean =>
@@ -147,9 +154,14 @@ const hasBytesArray = (b: unknown, k: string): boolean => {
147
154
  };
148
155
 
149
156
  export function isCohortAdvertMessage(m: BaseMessage): m is CohortAdvertMessage {
157
+ // Range-check the participant bounds so a malformed advert (missing or
158
+ // zero/negative minParticipants) is rejected rather than silently accepted as
159
+ // a zero-floor cohort. The service does the full cross-field validation at
160
+ // createCohort (see validateCohortConditions); here we guard the wire shape.
150
161
  return m.type === COHORT_ADVERT
151
162
  && hasStr(m.body, 'cohortId')
152
- && hasNum(m.body, 'cohortSize')
163
+ && hasIntMin(m.body, 'minParticipants', 1)
164
+ && optIntMin(m.body, 'maxParticipants', 1)
153
165
  && hasStr(m.body, 'beaconType')
154
166
  && hasStr(m.body, 'network')
155
167
  && hasBytes(m.body, 'communicationPk');
@@ -1,3 +1,4 @@
1
+ import type { CohortConditions } from '../conditions.js';
1
2
  import { BaseMessage } from './base.js';
2
3
  import {
3
4
  AGGREGATED_NONCE,
@@ -18,11 +19,9 @@ import {
18
19
  * Factory functions for creating messages related to the cohort formation step, where cohorts are
19
20
  * formed and participants opt in to join the cohort.
20
21
  */
21
- type CohortAdvertMessage = {
22
+ type CohortAdvertMessage = CohortConditions & {
22
23
  from: string;
23
24
  cohortId: string;
24
- cohortSize: number;
25
- beaconType: string;
26
25
  network: string;
27
26
  communicationPk: Uint8Array;
28
27
  };
@@ -5,8 +5,10 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
5
5
  import { Transaction } from '@scure/btc-signer';
6
6
  import { getBeaconStrategy } from './beacon-strategy.js';
7
7
  import { AggregationCohort } from './cohort.js';
8
+ import type { CohortConditions } from './conditions.js';
8
9
  import { AggregationParticipantError } from './errors.js';
9
10
  import type { BaseMessage } from './messages/base.js';
11
+ import { isCohortAdvertMessage } from './messages/bodies.js';
10
12
  import { AGGREGATION_WIRE_VERSION } from './messages/base.js';
11
13
  import {
12
14
  AGGREGATED_NONCE,
@@ -28,13 +30,15 @@ import { ParticipantCohortPhase } from './phases.js';
28
30
  import type { AggregationSigner } from './signer.js';
29
31
  import { BeaconSigningSession } from './signing-session.js';
30
32
 
31
- /** Cohort advert as discovered by the participant (UI: list of joinable cohorts). */
32
- export interface CohortAdvert {
33
+ /**
34
+ * Cohort advert as discovered by the participant (UI: list of joinable cohorts).
35
+ * Carries the advertised {@link CohortConditions} (beaconType, minParticipants,
36
+ * maxParticipants, costs, ...) so a `shouldJoin` decision can inspect them.
37
+ */
38
+ export interface CohortAdvert extends CohortConditions {
33
39
  cohortId: string;
34
40
  serviceDid: string;
35
- cohortSize: number;
36
41
  network: string;
37
- beaconType: string;
38
42
  serviceCommunicationPk: Uint8Array;
39
43
  }
40
44
 
@@ -168,17 +172,18 @@ export class AggregationParticipant {
168
172
  }
169
173
 
170
174
  #handleCohortAdvert(message: BaseMessage): void {
171
- const cohortId = message.body?.cohortId;
172
- if(!cohortId) return;
175
+ // Validate the wire shape (incl. minParticipants range) before trusting it,
176
+ // rather than reading fields with `?? 0` fallbacks (see ADR 039).
177
+ if(!isCohortAdvertMessage(message)) return;
178
+ const { cohortId, network, communicationPk, ...conditions } = message.body;
173
179
  if(this.#cohortStates.has(cohortId)) return; // Already known
174
180
 
175
181
  const advert: CohortAdvert = {
176
182
  cohortId,
177
183
  serviceDid : message.from,
178
- cohortSize : message.body?.cohortSize ?? 0,
179
- network : message.body?.network ?? '',
180
- beaconType : message.body?.beaconType ?? 'CASBeacon',
181
- serviceCommunicationPk : message.body?.communicationPk ?? new Uint8Array(),
184
+ network,
185
+ serviceCommunicationPk : communicationPk,
186
+ ...conditions,
182
187
  };
183
188
 
184
189
  this.#cohortStates.set(cohortId, {
@@ -206,7 +211,7 @@ export class AggregationParticipant {
206
211
  const cohort = new AggregationCohort({
207
212
  id : cohortId,
208
213
  serviceDid : state.serviceDid,
209
- minParticipants : state.advert!.cohortSize,
214
+ minParticipants : state.advert!.minParticipants,
210
215
  network : state.advert!.network,
211
216
  beaconType : state.advert!.beaconType,
212
217
  });
@@ -383,6 +383,15 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
383
383
  const decision = await this.#onOptInReceived(optIn);
384
384
  if(!decision.accepted) return;
385
385
 
386
+ // Don't accept past the advertised maxParticipants: acceptParticipant
387
+ // would throw COHORT_FULL and fail the run. Silently ignore the surplus
388
+ // opt-in (the cohort is full).
389
+ const maxParticipants = this.#config.maxParticipants;
390
+ const cohortNow = this.session.getCohort(this.#cohortId!);
391
+ if(maxParticipants !== undefined && cohortNow && cohortNow.participants.length >= maxParticipants) {
392
+ return;
393
+ }
394
+
386
395
  await this.#sendAll(this.session.acceptParticipant(this.#cohortId!, msg.from));
387
396
  this.emit('participant-accepted', { participantDid: msg.from });
388
397
 
@@ -6,6 +6,7 @@ import { bytesToHex } from '@noble/hashes/utils';
6
6
  import type { Transaction } from '@scure/btc-signer';
7
7
  import { getBeaconStrategy } from './beacon-strategy.js';
8
8
  import { AggregationCohort } from './cohort.js';
9
+ import { validateCohortConditions, type CohortConditions } from './conditions.js';
9
10
  import { AggregationServiceError } from './errors.js';
10
11
  import type { BaseMessage } from './messages/base.js';
11
12
  import { AGGREGATION_WIRE_VERSION } from './messages/base.js';
@@ -28,11 +29,14 @@ import type { ServiceCohortPhaseType } from './phases.js';
28
29
  import { ServiceCohortPhase } from './phases.js';
29
30
  import { BeaconSigningSession } from './signing-session.js';
30
31
 
31
- /** Cohort configuration set by the service operator. */
32
- export interface CohortConfig {
33
- minParticipants: number;
32
+ /**
33
+ * Cohort configuration set by the service operator: the advertised cohort
34
+ * {@link CohortConditions} plus the Bitcoin network. `beaconType` and
35
+ * `minParticipants` are required; the other conditions are optional (absent =
36
+ * unconstrained). See ADR 039.
37
+ */
38
+ export interface CohortConfig extends CohortConditions {
34
39
  network: string;
35
- beaconType: string;
36
40
  }
37
41
 
38
42
  /** Pending opt-in awaiting service operator approval. */
@@ -201,6 +205,14 @@ export class AggregationService {
201
205
  * Cohort starts in `Created` phase — call `advertise()` to broadcast.
202
206
  */
203
207
  createCohort(config: CohortConfig): string {
208
+ // Fail fast on invalid conditions rather than discovering them at finalize.
209
+ const problems = validateCohortConditions(config);
210
+ if(problems.length > 0) {
211
+ throw new AggregationServiceError(
212
+ `Invalid cohort conditions: ${problems.join('; ')}`,
213
+ 'INVALID_COHORT_CONDITIONS', { problems }
214
+ );
215
+ }
204
216
  const cohort = new AggregationCohort({
205
217
  serviceDid : this.did,
206
218
  minParticipants : config.minParticipants,
@@ -234,13 +246,15 @@ export class AggregationService {
234
246
  );
235
247
  }
236
248
 
249
+ // Advertise the full condition set (flat fields, per ADR 039). network is
250
+ // a separate cohort parameter; everything else in config is a condition.
251
+ const { network, ...conditions } = state.config;
237
252
  const message = createCohortAdvertMessage({
238
253
  from : this.did,
239
254
  cohortId,
240
- cohortSize : state.config.minParticipants,
241
- beaconType : state.config.beaconType,
242
- network : state.config.network,
255
+ network,
243
256
  communicationPk : this.publicKey.compressed,
257
+ ...conditions,
244
258
  });
245
259
 
246
260
  state.phase = ServiceCohortPhase.Advertised;
@@ -310,6 +324,15 @@ export class AggregationService {
310
324
  'ALREADY_ACCEPTED', { cohortId, participantDid }
311
325
  );
312
326
  }
327
+ // Enforce the maxParticipants condition: a cohort cannot grow past its
328
+ // advertised ceiling (closes the unbounded-growth path; see ADR 039).
329
+ const maxParticipants = state.config.maxParticipants;
330
+ if(maxParticipants !== undefined && state.acceptedParticipants.size >= maxParticipants) {
331
+ throw new AggregationServiceError(
332
+ `Cohort ${cohortId} is full: ${maxParticipants} participants already accepted.`,
333
+ 'COHORT_FULL', { cohortId, maxParticipants }
334
+ );
335
+ }
313
336
 
314
337
  state.acceptedParticipants.add(participantDid);
315
338
  state.cohort.participants.push(participantDid);
@@ -344,6 +367,15 @@ export class AggregationService {
344
367
  'NOT_ENOUGH_PARTICIPANTS', { cohortId }
345
368
  );
346
369
  }
370
+ // Ceiling defense: acceptParticipant already rejects past max, so reaching
371
+ // here over max means state was mutated out-of-band.
372
+ const maxParticipants = state.config.maxParticipants;
373
+ if(maxParticipants !== undefined && state.acceptedParticipants.size > maxParticipants) {
374
+ throw new AggregationServiceError(
375
+ `Cohort ${cohortId} has ${state.acceptedParticipants.size} accepted participants, exceeds max ${maxParticipants}.`,
376
+ 'TOO_MANY_PARTICIPANTS', { cohortId, maxParticipants }
377
+ );
378
+ }
347
379
 
348
380
  const beaconAddress = state.cohort.computeBeaconAddress();
349
381
  state.phase = ServiceCohortPhase.CohortSet;
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export * from './core/aggregation/service.js';
3
3
  export * from './core/aggregation/participant.js';
4
4
  export * from './core/aggregation/signer.js';
5
+ export * from './core/aggregation/conditions.js';
5
6
  export * from './core/aggregation/cohort.js';
6
7
  export * from './core/aggregation/signing-session.js';
7
8
  export * from './core/aggregation/phases.js';