@bcts/frost-hubert 1.0.0-alpha.22 → 1.0.0-beta.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 (174) hide show
  1. package/dist/bin/frost.cjs +347 -75
  2. package/dist/bin/frost.cjs.map +1 -1
  3. package/dist/bin/frost.mjs +347 -75
  4. package/dist/bin/frost.mjs.map +1 -1
  5. package/dist/busy-DkM2jAIZ.mjs +27 -0
  6. package/dist/busy-DkM2jAIZ.mjs.map +1 -0
  7. package/dist/busy-EZU7EKr6.cjs +38 -0
  8. package/dist/busy-EZU7EKr6.cjs.map +1 -0
  9. package/dist/{chunk-uaV2rQ02.cjs → chunk-CZWwpsFl.cjs} +22 -32
  10. package/dist/{chunk-ClPoSABd.mjs → chunk-CjcI7cDX.mjs} +6 -12
  11. package/dist/cmd/index.cjs +46 -43
  12. package/dist/cmd/index.d.cts +2 -4
  13. package/dist/cmd/index.d.mts +2 -4
  14. package/dist/cmd/index.mjs +7 -6
  15. package/dist/cmd-Bw9_i2_f.cjs +130 -0
  16. package/dist/cmd-Bw9_i2_f.cjs.map +1 -0
  17. package/dist/cmd-CS1uJtuD.mjs +113 -0
  18. package/dist/cmd-CS1uJtuD.mjs.map +1 -0
  19. package/dist/common-CvH6dFvQ.mjs +282 -0
  20. package/dist/common-CvH6dFvQ.mjs.map +1 -0
  21. package/dist/common-DUWvtc08.mjs +96 -0
  22. package/dist/common-DUWvtc08.mjs.map +1 -0
  23. package/dist/common-lKP5EzHy.cjs +372 -0
  24. package/dist/common-lKP5EzHy.cjs.map +1 -0
  25. package/dist/common-lThIvJmZ.cjs +114 -0
  26. package/dist/common-lThIvJmZ.cjs.map +1 -0
  27. package/dist/dkg/index.cjs +245 -7
  28. package/dist/dkg/index.cjs.map +1 -0
  29. package/dist/dkg/index.d.cts +2 -2
  30. package/dist/dkg/index.d.mts +2 -2
  31. package/dist/dkg/index.mjs +238 -2
  32. package/dist/dkg/index.mjs.map +1 -0
  33. package/dist/finalize-BRgJK-Xv.cjs +402 -0
  34. package/dist/finalize-BRgJK-Xv.cjs.map +1 -0
  35. package/dist/finalize-BfLgzn8f.cjs +303 -0
  36. package/dist/finalize-BfLgzn8f.cjs.map +1 -0
  37. package/dist/finalize-CNTDj6aS.mjs +389 -0
  38. package/dist/finalize-CNTDj6aS.mjs.map +1 -0
  39. package/dist/finalize-EC3ikHQq.mjs +252 -0
  40. package/dist/finalize-EC3ikHQq.mjs.map +1 -0
  41. package/dist/finalize-IA01t_Qq.mjs +290 -0
  42. package/dist/finalize-IA01t_Qq.mjs.map +1 -0
  43. package/dist/finalize-UPyI1yb1.cjs +265 -0
  44. package/dist/finalize-UPyI1yb1.cjs.map +1 -0
  45. package/dist/frost/index.cjs +8 -9
  46. package/dist/frost/index.cjs.map +1 -1
  47. package/dist/frost/index.mjs +2 -3
  48. package/dist/frost/index.mjs.map +1 -1
  49. package/dist/{group-invite-Dz1Jmiky.d.cts → index-B3c-80VS.d.cts} +25 -2
  50. package/dist/index-B3c-80VS.d.cts.map +1 -0
  51. package/dist/{index-CcvTi5EA.d.cts → index-BgbSGpxn.d.mts} +102 -80
  52. package/dist/index-BgbSGpxn.d.mts.map +1 -0
  53. package/dist/{registry-impl-CE76sTXQ.d.cts → index-C8QeHNwa.d.cts} +46 -2
  54. package/dist/index-C8QeHNwa.d.cts.map +1 -0
  55. package/dist/{group-invite-Wk9CIbHL.d.mts → index-D3QTWkEm.d.mts} +25 -2
  56. package/dist/index-D3QTWkEm.d.mts.map +1 -0
  57. package/dist/{registry-impl-BETn_lEO.d.mts → index-DVbWyOs7.d.mts} +46 -2
  58. package/dist/index-DVbWyOs7.d.mts.map +1 -0
  59. package/dist/{index-DNCPeLNM.d.mts → index-F1iNEAJR.d.cts} +102 -80
  60. package/dist/index-F1iNEAJR.d.cts.map +1 -0
  61. package/dist/index.cjs +72 -68
  62. package/dist/index.cjs.map +1 -1
  63. package/dist/index.d.cts +4 -7
  64. package/dist/index.d.cts.map +1 -1
  65. package/dist/index.d.mts +4 -7
  66. package/dist/index.d.mts.map +1 -1
  67. package/dist/index.mjs +11 -10
  68. package/dist/index.mjs.map +1 -1
  69. package/dist/invite-5277FQVT.cjs +274 -0
  70. package/dist/invite-5277FQVT.cjs.map +1 -0
  71. package/dist/invite-DUTcfTgX.cjs +109 -0
  72. package/dist/invite-DUTcfTgX.cjs.map +1 -0
  73. package/dist/invite-IU4n0dq2.mjs +96 -0
  74. package/dist/invite-IU4n0dq2.mjs.map +1 -0
  75. package/dist/invite-RU-OXTNS.mjs +219 -0
  76. package/dist/invite-RU-OXTNS.mjs.map +1 -0
  77. package/dist/parallel-D1R6ZGlY.cjs +318 -0
  78. package/dist/parallel-D1R6ZGlY.cjs.map +1 -0
  79. package/dist/parallel-D6zc6VW4.mjs +235 -0
  80. package/dist/parallel-D6zc6VW4.mjs.map +1 -0
  81. package/dist/proposed-participant-Dm1Eq6mX.cjs +141 -0
  82. package/dist/proposed-participant-Dm1Eq6mX.cjs.map +1 -0
  83. package/dist/proposed-participant-cWM7iUrO.mjs +129 -0
  84. package/dist/proposed-participant-cWM7iUrO.mjs.map +1 -0
  85. package/dist/receive-CAI-x4II.cjs +213 -0
  86. package/dist/receive-CAI-x4II.cjs.map +1 -0
  87. package/dist/receive-D2Nn68L7.mjs +188 -0
  88. package/dist/receive-D2Nn68L7.mjs.map +1 -0
  89. package/dist/receive-DA_KQEgk.mjs +177 -0
  90. package/dist/receive-DA_KQEgk.mjs.map +1 -0
  91. package/dist/receive-kZMsXhbK.cjs +190 -0
  92. package/dist/receive-kZMsXhbK.cjs.map +1 -0
  93. package/dist/registry/index.cjs +881 -13
  94. package/dist/registry/index.cjs.map +1 -0
  95. package/dist/registry/index.d.cts +1 -1
  96. package/dist/registry/index.d.mts +1 -1
  97. package/dist/registry/index.mjs +867 -2
  98. package/dist/registry/index.mjs.map +1 -0
  99. package/dist/{registry-FMU-ec5K.cjs → registry-9puTaRrD.cjs} +28 -31
  100. package/dist/registry-9puTaRrD.cjs.map +1 -0
  101. package/dist/{registry-BDnNV1Rk.mjs → registry-BpCwtrRt.mjs} +7 -10
  102. package/dist/{registry-BDnNV1Rk.mjs.map → registry-BpCwtrRt.mjs.map} +1 -1
  103. package/dist/round1-4Hyx8w0x.cjs +422 -0
  104. package/dist/round1-4Hyx8w0x.cjs.map +1 -0
  105. package/dist/round1-7v9LlE11.mjs +373 -0
  106. package/dist/round1-7v9LlE11.mjs.map +1 -0
  107. package/dist/round1-BHBjru1m.cjs +465 -0
  108. package/dist/round1-BHBjru1m.cjs.map +1 -0
  109. package/dist/round1-CMLKN2RR.mjs +195 -0
  110. package/dist/round1-CMLKN2RR.mjs.map +1 -0
  111. package/dist/round1-CWSXZx5R.cjs +208 -0
  112. package/dist/round1-CWSXZx5R.cjs.map +1 -0
  113. package/dist/round1-CcQCGlIT.mjs +208 -0
  114. package/dist/round1-CcQCGlIT.mjs.map +1 -0
  115. package/dist/round1-Cgm7j1kI.mjs +452 -0
  116. package/dist/round1-Cgm7j1kI.mjs.map +1 -0
  117. package/dist/round1-DQ0fnc1H.cjs +221 -0
  118. package/dist/round1-DQ0fnc1H.cjs.map +1 -0
  119. package/dist/round2-BWz9SQIi.cjs +305 -0
  120. package/dist/round2-BWz9SQIi.cjs.map +1 -0
  121. package/dist/round2-BkNRCXgS.mjs +292 -0
  122. package/dist/round2-BkNRCXgS.mjs.map +1 -0
  123. package/dist/round2-Bl2uK93U.mjs +450 -0
  124. package/dist/round2-Bl2uK93U.mjs.map +1 -0
  125. package/dist/round2-CdUT-AhH.cjs +499 -0
  126. package/dist/round2-CdUT-AhH.cjs.map +1 -0
  127. package/dist/round2-DOA3rnV-.mjs +280 -0
  128. package/dist/round2-DOA3rnV-.mjs.map +1 -0
  129. package/dist/round2-Dg24w-TU.mjs +397 -0
  130. package/dist/round2-Dg24w-TU.mjs.map +1 -0
  131. package/dist/round2-LylCa84n.cjs +293 -0
  132. package/dist/round2-LylCa84n.cjs.map +1 -0
  133. package/dist/round2-o2Q-GMbX.cjs +410 -0
  134. package/dist/round2-o2Q-GMbX.cjs.map +1 -0
  135. package/dist/storage-B-Gu68-O.cjs +79 -0
  136. package/dist/storage-B-Gu68-O.cjs.map +1 -0
  137. package/dist/storage-Bkkliz0K.mjs +74 -0
  138. package/dist/storage-Bkkliz0K.mjs.map +1 -0
  139. package/package.json +17 -17
  140. package/src/bin/frost.ts +849 -128
  141. package/src/cmd/common.ts +19 -1
  142. package/src/cmd/dkg/common.ts +97 -10
  143. package/src/cmd/dkg/coordinator/invite.ts +5 -2
  144. package/src/cmd/dkg/participant/finalize.ts +52 -18
  145. package/src/cmd/dkg/participant/round1.ts +39 -38
  146. package/src/cmd/dkg/participant/round2.ts +60 -26
  147. package/src/cmd/sign/coordinator/round2.ts +5 -1
  148. package/src/cmd/sign/participant/finalize.ts +6 -2
  149. package/src/cmd/sign/participant/receive.ts +5 -2
  150. package/src/dkg/group-invite.ts +12 -2
  151. package/src/dkg/proposed-participant.ts +33 -5
  152. package/src/frost/index.ts +1 -1
  153. package/src/registry/owner-record.ts +13 -2
  154. package/src/registry/participant-record.ts +36 -4
  155. package/src/registry/registry-impl.ts +74 -18
  156. package/dist/group-invite-CrbOabFL.cjs +0 -368
  157. package/dist/group-invite-CrbOabFL.cjs.map +0 -1
  158. package/dist/group-invite-Dz1Jmiky.d.cts.map +0 -1
  159. package/dist/group-invite-RPElq-fm.mjs +0 -338
  160. package/dist/group-invite-RPElq-fm.mjs.map +0 -1
  161. package/dist/group-invite-Wk9CIbHL.d.mts.map +0 -1
  162. package/dist/index-CcvTi5EA.d.cts.map +0 -1
  163. package/dist/index-DNCPeLNM.d.mts.map +0 -1
  164. package/dist/registry-FMU-ec5K.cjs.map +0 -1
  165. package/dist/registry-impl-BETn_lEO.d.mts.map +0 -1
  166. package/dist/registry-impl-C7w4awTv.cjs +0 -865
  167. package/dist/registry-impl-C7w4awTv.cjs.map +0 -1
  168. package/dist/registry-impl-CE76sTXQ.d.cts.map +0 -1
  169. package/dist/registry-impl-eYXVSPwM.mjs +0 -797
  170. package/dist/registry-impl-eYXVSPwM.mjs.map +0 -1
  171. package/dist/sign-2bOp18Fs.cjs +0 -4875
  172. package/dist/sign-2bOp18Fs.cjs.map +0 -1
  173. package/dist/sign-D8C3HJ4B.mjs +0 -4736
  174. package/dist/sign-D8C3HJ4B.mjs.map +0 -1
@@ -355,7 +355,14 @@ export class DkgInvitation {
355
355
  recipientPrivateKeys,
356
356
  );
357
357
 
358
- if (expectedSender !== undefined && sealedRequest.sender().xid() !== expectedSender.xid()) {
358
+ // Rust `group_invite.rs:275-279` uses `!=` on `XID`, which is the
359
+ // derived `PartialEq` (byte-wise compare on the 32-byte ARID). TS
360
+ // `!==` is reference identity — two distinct XID instances with
361
+ // the same bytes would fail. Compare via hex equality instead.
362
+ if (
363
+ expectedSender !== undefined &&
364
+ !sealedRequest.sender().xid().equals(expectedSender.xid())
365
+ ) {
359
366
  throw new Error("Invite sender does not match expected sender");
360
367
  }
361
368
 
@@ -391,7 +398,10 @@ export class DkgInvitation {
391
398
  XIDVerifySignature.Inception,
392
399
  );
393
400
 
394
- if (xidDocument.xid() !== recipientXid) {
401
+ // Rust `group_invite.rs` compares via `XID::PartialEq` (byte-wise).
402
+ // TS reference identity would never match for two distinct
403
+ // `XID` instances representing the same identity.
404
+ if (!xidDocument.xid().equals(recipientXid)) {
395
405
  continue;
396
406
  }
397
407
 
@@ -11,7 +11,6 @@
11
11
  */
12
12
 
13
13
  import { type ARID, type XID } from "@bcts/components";
14
- import { type Cbor } from "@bcts/dcbor";
15
14
  import { Envelope } from "@bcts/envelope";
16
15
  import { UR } from "@bcts/uniform-resources";
17
16
  import { XIDDocument, XIDVerifySignature } from "@bcts/xid";
@@ -96,12 +95,41 @@ export class DkgProposedParticipant {
96
95
 
97
96
  /**
98
97
  * Compare participants by XID for sorting.
98
+ *
99
+ * Mirrors Rust `PartialOrd::partial_cmp` which uses
100
+ * `self.xid().cmp(&other.xid())` — i.e. the underlying 32-byte XID
101
+ * data is compared lexicographically (`Vec<u8>` ordering). The
102
+ * earlier port used `xid.toString().localeCompare(...)`, which (a)
103
+ * compares the UR-encoded base32-ish string, not the bytes, and (b)
104
+ * is locale-aware. Sorting on UR strings differs from the byte
105
+ * order whenever the underlying bytes contain values ≥ 0x80, so
106
+ * Rust and TS would assign different FROST identifiers to the same
107
+ * participant set — producing different secret shares.
99
108
  */
100
109
  compareTo(other: DkgProposedParticipant): number {
101
- const thisXid = this.xid().toString();
102
- const otherXid = other.xid().toString();
103
- return thisXid.localeCompare(otherXid);
110
+ return compareXidBytes(this.xid().toData(), other.xid().toData());
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Lexicographic byte compare matching Rust's `Vec<u8>::cmp` /
116
+ * `XID::cmp`. Exported so the cmd-tree call sites (round1 / finalize)
117
+ * can use the same comparator when they sort deduplicated XID lists.
118
+ *
119
+ * `XID` is exactly 32 bytes so this only ever compares two equal-length
120
+ * inputs; the length-tiebreak branch mirrors the generic `Ord` impl on
121
+ * `Vec<u8>` and is included for correctness if ever applied to other
122
+ * byte sequences.
123
+ *
124
+ * @internal
125
+ */
126
+ export function compareXidBytes(a: Uint8Array, b: Uint8Array): number {
127
+ const minLen = Math.min(a.length, b.length);
128
+ for (let i = 0; i < minLen; i++) {
129
+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
104
130
  }
131
+ if (a.length !== b.length) return a.length < b.length ? -1 : 1;
132
+ return 0;
105
133
  }
106
134
 
107
135
  /**
@@ -121,7 +149,7 @@ function parseXidEnvelope(input: string): [Envelope, XIDDocument] {
121
149
  throw new Error(`Expected a ur:xid document, found ur:${urType}`);
122
150
  }
123
151
 
124
- const envelopeCbor = ur.cbor() as unknown as Cbor;
152
+ const envelopeCbor = ur.cbor();
125
153
  // Try tagged CBOR first, then untagged
126
154
  let envelope: Envelope;
127
155
  try {
@@ -61,7 +61,7 @@ export type DkgRound2SecretPackage = keys.dkg.round2.SecretPackage;
61
61
  */
62
62
  export class SecureRng implements RandomSource {
63
63
  fill(array: Uint8Array): void {
64
- globalThis.crypto.getRandomValues(array);
64
+ globalThis.crypto.getRandomValues(array as Uint8Array<ArrayBuffer>);
65
65
  }
66
66
  }
67
67
 
@@ -11,7 +11,6 @@
11
11
  */
12
12
 
13
13
  import { type XID } from "@bcts/components";
14
- import { type Cbor } from "@bcts/dcbor";
15
14
  import { Envelope } from "@bcts/envelope";
16
15
  import { UR } from "@bcts/uniform-resources";
17
16
  import { XIDDocument, XIDVerifySignature } from "@bcts/xid";
@@ -102,8 +101,20 @@ export class OwnerRecord {
102
101
 
103
102
  /**
104
103
  * Deserialize from JSON object.
104
+ *
105
+ * Mirrors Rust's `#[serde(deny_unknown_fields)]` derive on
106
+ * `OwnerRecord` (`owner_record.rs:13-17`) — any field not in
107
+ * `{xid_document, pet_name}` causes Rust's `serde_json::from_str`
108
+ * to error with `unknown field`, and we mirror that here so a
109
+ * registry file produced by a future Rust version with extra
110
+ * fields is rejected explicitly rather than silently lossy.
105
111
  */
106
112
  static fromJSON(json: Record<string, unknown>): OwnerRecord {
113
+ for (const key of Object.keys(json)) {
114
+ if (key !== "xid_document" && key !== "pet_name") {
115
+ throw new Error(`unknown field \`${key}\`, expected \`xid_document\` or \`pet_name\``);
116
+ }
117
+ }
107
118
  const xidDocumentUr = json["xid_document"] as string;
108
119
  const petName = json["pet_name"] as string | undefined;
109
120
  return OwnerRecord.fromSignedXidUr(xidDocumentUr, petName);
@@ -123,7 +134,7 @@ function parseRelaxedXidDocument(xidDocumentUr: string): [string, XIDDocument] {
123
134
  throw new Error(`Expected a ur:xid document, found ur:${ur.urTypeStr()}`);
124
135
  }
125
136
 
126
- const envelopeCbor = ur.cbor() as unknown as Cbor;
137
+ const envelopeCbor = ur.cbor();
127
138
  let envelope: Envelope;
128
139
  try {
129
140
  envelope = Envelope.fromTaggedCbor(envelopeCbor);
@@ -11,7 +11,6 @@
11
11
  */
12
12
 
13
13
  import { type PublicKeys, type XID } from "@bcts/components";
14
- import { type Cbor } from "@bcts/dcbor";
15
14
  import { Envelope } from "@bcts/envelope";
16
15
  import { UR } from "@bcts/uniform-resources";
17
16
  import { XIDDocument, XIDVerifySignature } from "@bcts/xid";
@@ -142,8 +141,21 @@ export class ParticipantRecord {
142
141
 
143
142
  /**
144
143
  * Deserialize from JSON object.
144
+ *
145
+ * Mirrors Rust's `#[serde(deny_unknown_fields)]` derive on
146
+ * `ParticipantRecord` (`participant_record.rs:12-17`) — Rust's
147
+ * `serde_json::from_str` errors with `unknown field` for any
148
+ * field outside `{xid_document, pet_name}`. We mirror that
149
+ * behaviour so a registry file produced by a future Rust
150
+ * version with extra fields is rejected explicitly rather than
151
+ * silently lossy.
145
152
  */
146
153
  static fromJSON(json: Record<string, unknown>): ParticipantRecord {
154
+ for (const key of Object.keys(json)) {
155
+ if (key !== "xid_document" && key !== "pet_name") {
156
+ throw new Error(`unknown field \`${key}\`, expected \`xid_document\` or \`pet_name\``);
157
+ }
158
+ }
147
159
  const xidDocumentUr = json["xid_document"] as string;
148
160
  const petName = json["pet_name"] as string | undefined;
149
161
  return ParticipantRecord.recreateFromSerialized(xidDocumentUr, petName);
@@ -163,15 +175,35 @@ function parseSignedXidDocument(xidDocumentUr: string): [string, XIDDocument] {
163
175
  throw new Error(`Expected a ur:xid document, found ur:${ur.urTypeStr()}`);
164
176
  }
165
177
 
166
- const envelopeCbor = ur.cbor() as unknown as Cbor;
178
+ const envelopeCbor = ur.cbor();
167
179
  let envelope: Envelope;
168
180
  try {
169
181
  envelope = Envelope.fromTaggedCbor(envelopeCbor);
170
182
  } catch {
171
- envelope = Envelope.fromUntaggedCbor(envelopeCbor);
183
+ try {
184
+ envelope = Envelope.fromUntaggedCbor(envelopeCbor);
185
+ } catch (e) {
186
+ throw new Error(
187
+ `Unable to decode XID document envelope: ${(e as Error).message ?? String(e)}`,
188
+ {
189
+ cause: e,
190
+ },
191
+ );
192
+ }
172
193
  }
173
194
 
174
- const document = XIDDocument.fromEnvelope(envelope, undefined, XIDVerifySignature.Inception);
195
+ // Mirror Rust `participant_record.rs:198-203`'s `.context(...)` wrap:
196
+ // any failure from `XIDDocument::from_envelope(..., XIDVerifySignature::Inception)`
197
+ // is surfaced as "XID document must be signed by its inception key: <cause>".
198
+ let document: XIDDocument;
199
+ try {
200
+ document = XIDDocument.fromEnvelope(envelope, undefined, XIDVerifySignature.Inception);
201
+ } catch (e) {
202
+ throw new Error(
203
+ `XID document must be signed by its inception key: ${(e as Error).message ?? String(e)}`,
204
+ { cause: e },
205
+ );
206
+ }
175
207
 
176
208
  return [sanitized, document];
177
209
  }
@@ -13,7 +13,7 @@
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
 
16
- import { type ARID, type XID } from "@bcts/components";
16
+ import { type ARID, type PublicKeys, type XID } from "@bcts/components";
17
17
 
18
18
  import { GroupRecord } from "./group-record.js";
19
19
  import { OwnerRecord } from "./owner-record.js";
@@ -140,20 +140,57 @@ export class Registry {
140
140
  /**
141
141
  * Add a participant.
142
142
  *
143
- * Returns the outcome indicating whether the participant was already present or newly inserted.
143
+ * Mirrors Rust `Registry::add_participant`
144
+ * (`registry_impl.rs:83-124`):
145
+ *
146
+ * 1. If `record.pet_name()` is already used by some other XID,
147
+ * bail with `"Pet name '{name}' already used by another
148
+ * participant"`.
149
+ * 2. If `record.pet_name()` matches an existing record under the
150
+ * *same* XID and the public keys also match, return
151
+ * `AlreadyPresent`.
152
+ * 3. If the pet name matches the same XID but public keys don't,
153
+ * bail with `"Participant already exists with a different pet
154
+ * name"`.
155
+ * 4. Otherwise look up by XID. If present and public-keys + pet-name
156
+ * both match, return `AlreadyPresent`; if XID is present but
157
+ * anything differs, bail. If XID is new, insert and return
158
+ * `Inserted`.
159
+ *
160
+ * The earlier port short-circuited on `participants.has(xidUr)` and
161
+ * always returned `AlreadyPresent` — silently allowing re-adds with
162
+ * a different pet name or different public keys, which Rust
163
+ * correctly forbids.
144
164
  */
145
165
  addParticipant(xid: XID, record: ParticipantRecord): AddOutcome {
146
166
  const xidUr = xid.urString();
167
+ const petName = record.petName();
147
168
 
148
- // Check if already present
149
- if (this._participants.has(xidUr)) {
150
- return AddOutcome.AlreadyPresent;
169
+ // Steps 1–3: pet-name conflict resolution.
170
+ if (petName !== undefined) {
171
+ for (const [existingXidUr, existingRecord] of this._participants) {
172
+ if (existingRecord.petName() === petName) {
173
+ if (existingXidUr !== xidUr) {
174
+ throw new Error(`Pet name '${petName}' already used by another participant`);
175
+ }
176
+ if (publicKeysEqual(existingRecord.publicKeys(), record.publicKeys())) {
177
+ return AddOutcome.AlreadyPresent;
178
+ }
179
+ throw new Error("Participant already exists with a different pet name");
180
+ }
181
+ }
151
182
  }
152
183
 
153
- // Check for conflicting pet name
154
- const petName = record.petName();
155
- if (petName !== undefined && this.petNameExists(petName)) {
156
- throw new Error(`Pet name "${petName}" is already used by another participant`);
184
+ // Step 4: XID lookup.
185
+ const existing = this._participants.get(xidUr);
186
+ if (existing !== undefined) {
187
+ if (
188
+ publicKeysEqual(existing.publicKeys(), record.publicKeys()) &&
189
+ existing.petName() === record.petName()
190
+ ) {
191
+ return AddOutcome.AlreadyPresent;
192
+ }
193
+ throw new Error("Participant already exists with a different pet name");
157
194
  }
158
195
 
159
196
  this._participants.set(xidUr, record);
@@ -287,26 +324,34 @@ export class Registry {
287
324
 
288
325
  /**
289
326
  * Serialize to JSON object.
327
+ *
328
+ * Mirrors Rust `Registry`'s field declaration order
329
+ * (`registry_impl.rs:8-14` — `owner, participants, groups`). JSON
330
+ * object member order is not semantically significant, but
331
+ * `serde_json::to_string_pretty` emits keys in declaration order,
332
+ * so for byte-equal `registry.json` (used by the integration tests
333
+ * as a string assertion) the TS port must match Rust's order.
334
+ * Empty `owner` is omitted via `Option::None` skip in Rust; we
335
+ * reproduce that by only setting the key when the owner exists.
290
336
  */
291
337
  toJSON(): Record<string, unknown> {
338
+ const obj: Record<string, unknown> = {};
339
+
340
+ if (this._owner !== undefined) {
341
+ obj["owner"] = this._owner.toJSON();
342
+ }
343
+
292
344
  const participants: Record<string, unknown> = {};
293
345
  for (const [xidUr, record] of this._participants) {
294
346
  participants[xidUr] = record.toJSON();
295
347
  }
348
+ obj["participants"] = participants;
296
349
 
297
350
  const groups: Record<string, unknown> = {};
298
351
  for (const [aridHex, record] of this._groups) {
299
352
  groups[aridHex] = record.toJSON();
300
353
  }
301
-
302
- const obj: Record<string, unknown> = {
303
- groups,
304
- participants,
305
- };
306
-
307
- if (this._owner !== undefined) {
308
- obj["owner"] = this._owner.toJSON();
309
- }
354
+ obj["groups"] = groups;
310
355
 
311
356
  return obj;
312
357
  }
@@ -366,3 +411,14 @@ export function resolveRegistryPath(registryArg: string | undefined, cwd: string
366
411
  // Otherwise, treat as relative path
367
412
  return path.resolve(cwd, registryArg);
368
413
  }
414
+
415
+ /**
416
+ * Structural equality on `PublicKeys`, mirroring Rust's
417
+ * `PartialEq::eq` derive (per-component comparison of the signing
418
+ * and encapsulation public keys).
419
+ *
420
+ * @internal
421
+ */
422
+ function publicKeysEqual(a: PublicKeys, b: PublicKeys): boolean {
423
+ return a.equals(b);
424
+ }