@optimystic/db-p2p 0.22.0 → 0.24.1

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 (194) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +39 -14
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +226 -119
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/service.d.ts +32 -1
  10. package/dist/src/cluster/service.d.ts.map +1 -1
  11. package/dist/src/cluster/service.js +43 -2
  12. package/dist/src/cluster/service.js.map +1 -1
  13. package/dist/src/cohort-topic/host.js +34 -11
  14. package/dist/src/cohort-topic/host.js.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.d.ts +37 -7
  16. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  17. package/dist/src/cohort-topic/stream-util.js +77 -19
  18. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  19. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  20. package/dist/src/dispute/dispute-service.js +9 -3
  21. package/dist/src/dispute/dispute-service.js.map +1 -1
  22. package/dist/src/index.d.ts +3 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +3 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/libp2p-key-network.d.ts +88 -2
  27. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  28. package/dist/src/libp2p-key-network.js +134 -28
  29. package/dist/src/libp2p-key-network.js.map +1 -1
  30. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  31. package/dist/src/libp2p-node-base.js +25 -1
  32. package/dist/src/libp2p-node-base.js.map +1 -1
  33. package/dist/src/logger.d.ts +17 -1
  34. package/dist/src/logger.d.ts.map +1 -1
  35. package/dist/src/logger.js +19 -2
  36. package/dist/src/logger.js.map +1 -1
  37. package/dist/src/matchmaking/query-transport.js +3 -3
  38. package/dist/src/matchmaking/query-transport.js.map +1 -1
  39. package/dist/src/owned-block-seed.d.ts +6 -3
  40. package/dist/src/owned-block-seed.d.ts.map +1 -1
  41. package/dist/src/owned-block-seed.js +16 -3
  42. package/dist/src/owned-block-seed.js.map +1 -1
  43. package/dist/src/peer-address-book.d.ts +72 -0
  44. package/dist/src/peer-address-book.d.ts.map +1 -0
  45. package/dist/src/peer-address-book.js +123 -0
  46. package/dist/src/peer-address-book.js.map +1 -0
  47. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  48. package/dist/src/reactivity/notify-transport.js +6 -6
  49. package/dist/src/reactivity/notify-transport.js.map +1 -1
  50. package/dist/src/reactivity/push-state-gossip.js +2 -2
  51. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  52. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  53. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  54. package/dist/src/reactivity/recover-transport.js +7 -3
  55. package/dist/src/reactivity/recover-transport.js.map +1 -1
  56. package/dist/src/repo/client.d.ts.map +1 -1
  57. package/dist/src/repo/client.js +11 -2
  58. package/dist/src/repo/client.js.map +1 -1
  59. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  60. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  61. package/dist/src/repo/cluster-coordinator.js +95 -3
  62. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  63. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  64. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  65. package/dist/src/repo/coordinator-repo.js +242 -73
  66. package/dist/src/repo/coordinator-repo.js.map +1 -1
  67. package/dist/src/rn.d.ts +3 -0
  68. package/dist/src/rn.d.ts.map +1 -1
  69. package/dist/src/rn.js +3 -0
  70. package/dist/src/rn.js.map +1 -1
  71. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  72. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  73. package/dist/src/storage/cached-raw-storage.js +152 -0
  74. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  75. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  76. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  77. package/dist/src/storage/cached-store-driver.js +775 -0
  78. package/dist/src/storage/cached-store-driver.js.map +1 -0
  79. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  80. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  81. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  82. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  83. package/dist/src/storage/shared-cache-pool.js +354 -0
  84. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  85. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  86. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  87. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  88. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  89. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  90. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  91. package/dist/src/testing/raw-storage-conformance.js +35 -2
  92. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  93. package/package.json +3 -3
  94. package/readme.md +668 -668
  95. package/src/cluster/block-transfer.ts +424 -424
  96. package/src/cluster/client.ts +119 -88
  97. package/src/cluster/cluster-error.ts +64 -64
  98. package/src/cluster/cluster-policy.ts +203 -203
  99. package/src/cluster/cluster-repo.ts +242 -122
  100. package/src/cluster/cluster-size-coupling.ts +45 -45
  101. package/src/cluster/commit-cert.ts +139 -139
  102. package/src/cluster/i-transaction-state-store.ts +43 -43
  103. package/src/cluster/memory-transaction-state-store.ts +56 -56
  104. package/src/cluster/peer-key-binding.ts +37 -37
  105. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  106. package/src/cluster/quorum-restore.ts +223 -223
  107. package/src/cluster/reconcile-block.ts +203 -203
  108. package/src/cluster/service.ts +293 -241
  109. package/src/cluster/supermajority-coupling.ts +37 -37
  110. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  111. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  112. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  113. package/src/cohort-topic/change-bridge.ts +109 -109
  114. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  115. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  116. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  117. package/src/cohort-topic/host.ts +42 -11
  118. package/src/cohort-topic/index.ts +13 -13
  119. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  120. package/src/cohort-topic/membership-source.ts +68 -68
  121. package/src/cohort-topic/peer-codec.ts +31 -31
  122. package/src/cohort-topic/peer-sig.ts +86 -86
  123. package/src/cohort-topic/protocols.ts +71 -71
  124. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  125. package/src/cohort-topic/size-estimator.ts +16 -16
  126. package/src/cohort-topic/stream-util.ts +79 -19
  127. package/src/cohort-topic/threshold-crypto.ts +239 -239
  128. package/src/cohort-topic/topic-router.ts +77 -77
  129. package/src/dispute/arbitrator-selection.ts +138 -138
  130. package/src/dispute/cascade.ts +524 -524
  131. package/src/dispute/dispute-service.ts +11 -5
  132. package/src/dispute/invalidation.ts +625 -625
  133. package/src/inbound-authorization.ts +190 -190
  134. package/src/index.ts +52 -49
  135. package/src/libp2p-key-network.ts +1120 -990
  136. package/src/libp2p-node-base.ts +1675 -1651
  137. package/src/libp2p-node-rn.ts +30 -30
  138. package/src/libp2p-node.ts +36 -36
  139. package/src/logger.ts +19 -2
  140. package/src/matchmaking/aggregate-counts.ts +104 -104
  141. package/src/matchmaking/index.ts +20 -20
  142. package/src/matchmaking/module.ts +363 -363
  143. package/src/matchmaking/protocols.ts +51 -51
  144. package/src/matchmaking/provider-manager.ts +95 -95
  145. package/src/matchmaking/query-handler.ts +88 -88
  146. package/src/matchmaking/query-transport.ts +3 -3
  147. package/src/matchmaking/seeker-manager.ts +64 -64
  148. package/src/matchmaking/seeker-walk-client.ts +293 -293
  149. package/src/matchmaking/traffic-validation.ts +195 -195
  150. package/src/optimystic-node.ts +36 -36
  151. package/src/owned-block-seed.ts +53 -40
  152. package/src/peer-address-book.ts +149 -0
  153. package/src/protocol-limits.ts +33 -33
  154. package/src/reactivity/forwarder-host.ts +438 -438
  155. package/src/reactivity/index.ts +19 -19
  156. package/src/reactivity/notify-transport.ts +144 -144
  157. package/src/reactivity/origination-manager.ts +192 -192
  158. package/src/reactivity/protocols.ts +61 -61
  159. package/src/reactivity/push-state-gossip.ts +291 -291
  160. package/src/reactivity/recover-transport.ts +7 -3
  161. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  162. package/src/reactivity/subscriber-registry.ts +96 -96
  163. package/src/reactivity/subscription-manager.ts +450 -450
  164. package/src/reactivity/topic-bytes.ts +37 -37
  165. package/src/repo/client.ts +12 -2
  166. package/src/repo/cluster-coordinator.ts +99 -3
  167. package/src/repo/coordinator-repo.ts +281 -74
  168. package/src/repo/types.ts +7 -7
  169. package/src/rn.ts +39 -36
  170. package/src/rpc-deadline.ts +45 -45
  171. package/src/storage/arachnode-partition.ts +74 -74
  172. package/src/storage/cached-raw-storage.ts +180 -0
  173. package/src/storage/cached-store-driver.ts +859 -0
  174. package/src/storage/i-kv-store.ts +8 -8
  175. package/src/storage/i-raw-storage.ts +12 -5
  176. package/src/storage/kv-raw-storage.ts +135 -135
  177. package/src/storage/memory-kv-store.ts +28 -28
  178. package/src/storage/memory-storage.ts +25 -25
  179. package/src/storage/memory-store-driver.ts +157 -157
  180. package/src/storage/raw-store-codec.ts +42 -42
  181. package/src/storage/raw-store-driver.ts +80 -80
  182. package/src/storage/ring-selector.ts +317 -317
  183. package/src/storage/ring-shift-coordinator.ts +271 -271
  184. package/src/storage/shared-cache-pool.ts +452 -0
  185. package/src/storage/storage-repo.ts +1014 -1014
  186. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
  187. package/src/testing/index.ts +8 -8
  188. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  189. package/src/testing/raw-storage-conformance.ts +453 -417
  190. package/src/testing/reactivity-mesh-harness.ts +922 -922
  191. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  192. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  193. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  194. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,20 +1,77 @@
1
1
  /**
2
- * Cohort-topic libp2p stream helpers.
2
+ * Single-frame libp2p stream helpers, shared by cohort-topic, matchmaking, and reactivity.
3
3
  *
4
- * The cohort-topic protocols exchange a single self-delimiting cohort frame each way (the db-core wire
5
- * codec already length-prefixes the body), so request/response is one `send` + one bounded read. This
6
- * mirrors FRET's `rpc/maybe-act.ts` exactly and reuses FRET's exported `readAllBounded`, keeping the
7
- * stream lifecycle (open send close-write read close) consistent across both protocol families.
4
+ * These protocols exchange a single varint-length-prefixed frame each way, via FRET's exported
5
+ * `sendFramed` / `readFramed` pair — the varint prefix is what delimits the frame on the wire
6
+ * (the db-core codec's own internal length prefix travels *inside* the framed body and plays no
7
+ * part in stream delimiting). Request/response is one framed send + one framed read, matching
8
+ * FRET's own four RPC protocols and the `it-length-prefixed` framing the rest of `db-p2p`
9
+ * (`protocol-client.ts`, the cluster/repo/sync/dispute services) already uses, keeping the stream
10
+ * lifecycle (open → send → close-write → read → close) consistent across all protocol families.
11
+ *
12
+ * NOTE: every `sendFramed` here discards its boolean result (`false` = "write accepted, transport
13
+ * buffer now full"), so these helpers apply no backpressure. Harmless while each protocol writes
14
+ * exactly one bounded frame per stream and then closes; if a caller ever writes repeatedly on one
15
+ * stream, honor the flag by awaiting the stream's `'drain'` event the way FRET's `rpcRequest` does.
16
+ *
17
+ * {@link openStream} deliberately duplicates FRET's `rpc/protocols.ts#openRpcStream` connection
18
+ * selection. FRET now exports `openRpcStream`, but the accepted tradeoff on
19
+ * {@link STREAM_OPTIONS} is why the local copy stays: FRET's helper pins `negotiateFully: false`,
20
+ * which this module deliberately does not set — swapping would silently reverse that decision.
21
+ * Same applies to `libp2p-key-network.ts#connect`, which is a third copy.
8
22
  */
9
23
 
10
24
  import type { Libp2p } from "libp2p";
11
25
  import type { Connection, PeerId, Stream } from "@libp2p/interface";
12
- import { readAllBounded } from "p2p-fret";
26
+ import { readFramed, sendFramed } from "p2p-fret";
13
27
 
14
28
  /** Default per-frame ceiling — matches FRET's 512 KiB maybe-act bound. */
15
29
  export const DEFAULT_STREAM_MAX_BYTES = 512 * 1024;
16
30
 
17
- /** Open `protocol` to `peer`, send `frame`, and read the bounded reply frame. */
31
+ /**
32
+ * libp2p refuses to open a protocol stream over a *limited* (circuit-relay) connection unless the
33
+ * caller opts in, so every peer reachable only through a relay — the steady state for browsers and
34
+ * NATed peers — depends on this flag. It is a harmless no-op on a direct connection.
35
+ *
36
+ * NOTE: accepted tradeoff — FRET's `openRpcStream` and `libp2p-key-network.ts#connect` also set
37
+ * `negotiateFully: false`; deliberately not set here. It saves a round trip but defers an
38
+ * unsupported-protocol failure from stream-open to the first read, which would turn
39
+ * {@link sendOneWay} against a peer lacking the protocol into a silent no-op. Revisit if
40
+ * stream-open latency shows up in a profile.
41
+ */
42
+ const STREAM_OPTIONS = { runOnLimitedConnection: true } as const;
43
+
44
+ /** True for a circuit-relay ("limited") connection: libp2p stamps one with per-circuit `limits`;
45
+ * sniffing `/p2p-circuit` in the multiaddr covers transports that leave `limits` unpopulated. */
46
+ function isLimitedConnection(c: Connection): boolean {
47
+ if ((c as { limits?: unknown }).limits != null) return true;
48
+ return c.remoteAddr?.toString?.().includes("/p2p-circuit") ?? false;
49
+ }
50
+
51
+ /**
52
+ * Open `protocol` to `peer`, reusing a healthy existing connection when there is one.
53
+ *
54
+ * Skips connections libp2p has not yet evicted from its index but that are no longer open, and
55
+ * prefers a direct connection over a relayed one — a relayed connection can be reset once the
56
+ * relay's per-circuit cap or reservation lapses, and after DCUtR upgrades a link to direct both
57
+ * briefly coexist. Falls back to the relayed connection when it is the only open path.
58
+ */
59
+ async function openStream(node: Libp2p, peer: PeerId, protocol: string): Promise<Stream> {
60
+ const open = node.getConnections(peer).filter(c => c?.status === "open" && typeof c?.newStream === "function");
61
+ const chosen = open.find(c => !isLimitedConnection(c)) ?? open[0];
62
+ return chosen ? await chosen.newStream([protocol], STREAM_OPTIONS) : await node.dialProtocol(peer, [protocol], STREAM_OPTIONS);
63
+ }
64
+
65
+ /**
66
+ * Open `protocol` to `peer`, send `frame`, and read the bounded reply frame.
67
+ *
68
+ * NOTE: takes no `AbortSignal`, so a caller cannot set its own deadline. Bounded today anyway —
69
+ * `readFramed` self-times-out at 5s and `dialProtocol` falls back to libp2p's default dial
70
+ * timeout (~30s) — so an unresponsive peer is slow, not hung. If a caller ever needs a tighter
71
+ * deadline (`membership-source.fetch` walks candidate peers *sequentially*, so its worst case is
72
+ * peers × dial-timeout), thread a signal through {@link openStream} the way
73
+ * `libp2p-key-network.ts#connect` does.
74
+ */
18
75
  export async function requestResponse(
19
76
  node: Libp2p,
20
77
  peer: PeerId,
@@ -22,13 +79,12 @@ export async function requestResponse(
22
79
  frame: Uint8Array,
23
80
  maxBytes = DEFAULT_STREAM_MAX_BYTES,
24
81
  ): Promise<Uint8Array> {
25
- const conns = node.getConnections(peer);
26
82
  let stream: Stream | undefined;
27
83
  try {
28
- stream = conns.length > 0 ? await conns[0]!.newStream([protocol]) : await node.dialProtocol(peer, [protocol]);
29
- stream.send(frame);
84
+ stream = await openStream(node, peer, protocol);
85
+ sendFramed(stream, frame);
30
86
  await stream.close();
31
- return await readAllBounded(stream, maxBytes);
87
+ return await readFramed(stream, maxBytes);
32
88
  } finally {
33
89
  if (stream != null) {
34
90
  try {
@@ -42,11 +98,10 @@ export async function requestResponse(
42
98
 
43
99
  /** Open `protocol` to `peer` and send `frame` without awaiting a reply (fire-and-forget gossip). */
44
100
  export async function sendOneWay(node: Libp2p, peer: PeerId, protocol: string, frame: Uint8Array): Promise<void> {
45
- const conns = node.getConnections(peer);
46
101
  let stream: Stream | undefined;
47
102
  try {
48
- stream = conns.length > 0 ? await conns[0]!.newStream([protocol]) : await node.dialProtocol(peer, [protocol]);
49
- stream.send(frame);
103
+ stream = await openStream(node, peer, protocol);
104
+ sendFramed(stream, frame);
50
105
  await stream.close();
51
106
  } finally {
52
107
  if (stream != null) {
@@ -59,7 +114,14 @@ export async function sendOneWay(node: Libp2p, peer: PeerId, protocol: string, f
59
114
  }
60
115
  }
61
116
 
62
- /** Register a request/response handler for `protocol`: read one bounded frame, reply with one frame. */
117
+ /**
118
+ * Register a request/response handler for `protocol`: read one bounded frame, reply with one frame.
119
+ *
120
+ * A `handle` that returns `undefined` (a drop, a gate rejection, no serving engine) replies with an
121
+ * explicit **zero-length frame** rather than silence: `readFramed` treats end-of-stream as a
122
+ * truncation error, so "no reply" must travel in-band — the dialer's {@link requestResponse}
123
+ * resolves it as empty bytes, exactly what the old read-to-EOF returned for a silent close.
124
+ */
63
125
  export function handleRequestResponse(
64
126
  node: Libp2p,
65
127
  protocol: string,
@@ -69,11 +131,9 @@ export function handleRequestResponse(
69
131
  void node.handle(protocol, (stream: Stream, connection: Connection) => {
70
132
  void (async (): Promise<void> => {
71
133
  try {
72
- const frame = await readAllBounded(stream, maxBytes);
134
+ const frame = await readFramed(stream, maxBytes);
73
135
  const reply = await handle(frame, connection.remotePeer);
74
- if (reply !== undefined) {
75
- stream.send(reply);
76
- }
136
+ sendFramed(stream, reply ?? new Uint8Array(0));
77
137
  await stream.close();
78
138
  } catch {
79
139
  try {
@@ -1,239 +1,239 @@
1
- /**
2
- * Cohort-topic `k − x` threshold-signature crypto — real collected Ed25519 multi-signature.
3
- *
4
- * Closes gap 1 (the interim single-signer `sha256(payload)` digest, which could never satisfy
5
- * `CohortSigner.verifyThreshold`'s `≥ minSigs` distinct-member rule at `minSigs = 14`). The scheme is
6
- * a **collected multisig**: `thresholdSig` is the aligned concatenation of fixed-width 64-byte Ed25519
7
- * signatures, one per `signers[i]`, each produced by that member's libp2p peer key over the *exact*
8
- * canonical payload. It needs **no trusted setup, no new crypto dependency, and no aggregation round**
9
- * (unlike BLS or FROST), maps directly onto the existing `(thresholdSig, signers)` contract, and reuses
10
- * the per-member peer-key signing primitive (`peer-sig.ts`) the codebase already uses for cluster-repo
11
- * commit signatures. Size is O(k) (≤ ~14 × 64 = 896 bytes at production `minSigs`) — negligible for
12
- * k ≤ 16; if size ever matters at larger k the scheme can be swapped behind the unchanged
13
- * {@link ICohortThresholdCrypto} port.
14
- *
15
- * **Coord-scoped.** `assemble(payload, minSigs)` carries no coord, so the adapter is constructed per
16
- * served coord (one per {@link import("./host.js").CoordEngine}): it knows the cohort around its coord,
17
- * the node's peer key (to add self's own signature without an RPC), and a `dialSign` seam over the
18
- * `/sign` protocol to collect the other members' endorsements.
19
- *
20
- * **Verify is synchronous** — `ICohortThresholdCrypto.verify` is called from the sync
21
- * `CohortSigner.verifyThreshold` on the participant path. {@link verifyCollectedMultisig} splits the
22
- * blob into `signers.length` 64-byte chunks and {@link verifyPeerSig}-checks each against the
23
- * corresponding signer's embedded Ed25519 key (noble, sync). Do not make it async.
24
- */
25
-
26
- import type { PrivateKey } from "@libp2p/interface";
27
- import type { ICohortThresholdCrypto, RingCoord, SignKind, SignReplyV1, SignRequestV1 } from "@optimystic/db-core";
28
- import { bytesToB64url, b64urlToBytes, compareBytes } from "@optimystic/db-core";
29
- import { signPeer, verifyPeerSig } from "./peer-sig.js";
30
- import { bytesToPeerIdString } from "./peer-codec.js";
31
-
32
- /** Width of an Ed25519 signature in bytes — the fixed stride of the concatenated `thresholdSig`. */
33
- export const ED25519_SIG_BYTES = 64;
34
-
35
- /** Default per-round deadline for collecting cohort endorsements (ms). */
36
- export const DEFAULT_SIGN_COLLECT_TIMEOUT_MS = 5_000;
37
-
38
- /**
39
- * Verify a collected Ed25519 multi-signature: `thresholdSig` must be exactly `signers.length × 64`
40
- * bytes, and chunk `i` must be a valid peer-key signature by `signers[i]` over `payload`. Pure and
41
- * synchronous; total (returns `false`, never throws). The db-core `CohortSigner.verifyThreshold` layer
42
- * adds the distinct-signer / `signers ⊆ cert.members` / `≥ minSigs` checks on top of this.
43
- */
44
- export function verifyCollectedMultisig(payload: Uint8Array, thresholdSig: Uint8Array, signers: readonly Uint8Array[]): boolean {
45
- if (signers.length === 0) {
46
- return false;
47
- }
48
- if (thresholdSig.length !== signers.length * ED25519_SIG_BYTES) {
49
- return false;
50
- }
51
- for (let i = 0; i < signers.length; i++) {
52
- const chunk = thresholdSig.subarray(i * ED25519_SIG_BYTES, (i + 1) * ED25519_SIG_BYTES);
53
- if (!verifyPeerSig(signers[i]!, payload, chunk)) {
54
- return false;
55
- }
56
- }
57
- return true;
58
- }
59
-
60
- /**
61
- * Dependencies for a coord-scoped {@link FretCohortThresholdCrypto}. The host supplies these per
62
- * {@link import("./host.js").CoordEngine}; the `kind` selects the endorsement policy the dialed
63
- * members apply (membership cert vs promotion / demotion notice).
64
- */
65
- export interface CohortThresholdCryptoDeps {
66
- /** What this signer assembles — sent in each {@link SignRequestV1} so members apply the right policy. */
67
- readonly kind: SignKind;
68
- /** The node's libp2p Ed25519 private key — signs self's own chunk locally (no RPC to self). */
69
- readonly privateKey: PrivateKey;
70
- /** Self's dialable member id (UTF-8 of the peer-id string); self is always a signer. */
71
- readonly selfMember: Uint8Array;
72
- /** The served coord whose cohort threshold-signs. */
73
- readonly coord: () => RingCoord;
74
- /** Current cohort epoch (raw bytes) for {@link coord}. */
75
- readonly cohortEpoch: () => Uint8Array;
76
- /** Current cohort member peer-id strings around {@link coord} (self may be included; excluded internally). */
77
- readonly cohortMembers: () => string[];
78
- /** Dial one cohort member's `/sign` RPC and return its reply. */
79
- readonly dialSign: (peerIdStr: string, request: SignRequestV1) => Promise<SignReplyV1>;
80
- /** Per-round collection deadline (ms). Default {@link DEFAULT_SIGN_COLLECT_TIMEOUT_MS}. */
81
- readonly collectTimeoutMs?: number;
82
- /**
83
- * Whether self may contribute its own signature to this assembly. Default `true` (self is always a
84
- * member of the cohort it signs over). The **rotation** producer scopes the assembly to the
85
- * *predecessor* cohort (`cohortMembers` = the prior epoch's members); when self was not a member of
86
- * that prior cohort it must NOT self-sign — its chunk would be dropped by the verifier (a signer not in
87
- * the predecessor cert's `members`), desyncing the count from what the verifier accepts. Returning
88
- * `false` then makes the quorum genuinely the outgoing cohort. See `cohort-topic-trust-anchor-rotation-production`.
89
- */
90
- readonly selfEligible?: () => boolean;
91
- }
92
-
93
- /** A collected, verified per-member signature awaiting concatenation. */
94
- interface CollectedSig {
95
- readonly signer: Uint8Array;
96
- readonly sig: Uint8Array;
97
- }
98
-
99
- /**
100
- * Real coord-scoped {@link ICohortThresholdCrypto}. `assemble` signs locally, concurrently dials the
101
- * rest of the cohort over the `/sign` protocol, verifies each returned signature before counting it,
102
- * and concatenates `≥ minSigs` distinct signatures into the threshold blob — or throws if a quorum is
103
- * unreachable (it must **never** fabricate a single-signer sig, the interim bug). `verify` is the pure
104
- * split-and-check.
105
- */
106
- export class FretCohortThresholdCrypto implements ICohortThresholdCrypto {
107
- constructor(private readonly deps: CohortThresholdCryptoDeps) {}
108
-
109
- async assemble(payload: Uint8Array, minSigs: number): Promise<{ thresholdSig: Uint8Array; signers: Uint8Array[] }> {
110
- const collected = new Map<string, CollectedSig>();
111
- const selfStr = bytesToPeerIdString(this.deps.selfMember);
112
-
113
- // Self is normally a signer — it is the acting member, even if a stale table omits it from its own
114
- // assembly. Sign locally (no RPC to self). A rotation producer that was NOT in the predecessor cohort
115
- // (`selfEligible` → false) skips this: its chunk would be dropped by the verifier (signer ∉ predecessor
116
- // cert members), so it must collect a quorum purely from the outgoing cohort instead.
117
- if (this.deps.selfEligible?.() ?? true) {
118
- const selfSig = await signPeer(this.deps.privateKey, payload);
119
- if (selfSig.length !== ED25519_SIG_BYTES) {
120
- throw new Error(`cohort threshold sign: self produced a ${selfSig.length}-byte signature (expected ${ED25519_SIG_BYTES}); non-Ed25519 identity?`);
121
- }
122
- collected.set(bytesToB64url(this.deps.selfMember), { signer: this.deps.selfMember, sig: selfSig });
123
- }
124
-
125
- // Concurrently collect endorsements from the rest of the cohort, up to the deadline.
126
- const others = this.deps.cohortMembers().filter((m) => m !== selfStr);
127
- if (others.length > 0 && collected.size < minSigs) {
128
- const request: SignRequestV1 = {
129
- v: 1,
130
- kind: this.deps.kind,
131
- coord: bytesToB64url(this.deps.coord()),
132
- cohortEpoch: bytesToB64url(this.deps.cohortEpoch()),
133
- payload: bytesToB64url(payload),
134
- };
135
- const timeoutMs = this.deps.collectTimeoutMs ?? DEFAULT_SIGN_COLLECT_TIMEOUT_MS;
136
- await Promise.all(others.map((peerStr) => this.collectFrom(peerStr, request, payload, collected, timeoutMs)));
137
- }
138
-
139
- if (collected.size < minSigs) {
140
- // Quorum unreachable / unwilling: no notice this round. The promotion / cert path re-fires next
141
- // tick. NEVER fabricate a single-signer sig — that is exactly the interim bug this replaces.
142
- throw new Error(`cohort threshold sign: gathered ${collected.size} of ${minSigs} required signatures`);
143
- }
144
-
145
- // Deterministic order (ascending by signer id) so the concatenation is reproducible and aligns
146
- // signers[i] ↔ chunk i for the verifier.
147
- const ordered = [...collected.values()].sort((a, b) => compareBytes(a.signer, b.signer));
148
- const signers = ordered.map((e) => e.signer);
149
- const thresholdSig = concatSigs(ordered.map((e) => e.sig));
150
- return { thresholdSig, signers };
151
- }
152
-
153
- verify(payload: Uint8Array, thresholdSig: Uint8Array, signers: readonly Uint8Array[]): boolean {
154
- return verifyCollectedMultisig(payload, thresholdSig, signers);
155
- }
156
-
157
- /** Dial one member, verify-before-count its endorsement, and add it to `collected` (dropping bad/dupe). */
158
- private async collectFrom(
159
- peerStr: string,
160
- request: SignRequestV1,
161
- payload: Uint8Array,
162
- collected: Map<string, CollectedSig>,
163
- timeoutMs: number,
164
- ): Promise<void> {
165
- try {
166
- const reply = await withTimeout(this.deps.dialSign(peerStr, request), timeoutMs);
167
- if (reply === undefined || "refused" in reply) {
168
- return; // unreachable (timed out) or the member declined to endorse
169
- }
170
- const signerBytes = b64urlToBytes(reply.signer);
171
- const sigBytes = b64urlToBytes(reply.signature);
172
- if (sigBytes.length !== ED25519_SIG_BYTES) {
173
- return; // a malformed chunk would desync the fixed-stride concatenation
174
- }
175
- // Verify before counting: a member must not be able to poison the blob with a bad signature.
176
- if (!verifyPeerSig(signerBytes, payload, sigBytes)) {
177
- return;
178
- }
179
- const key = bytesToB64url(signerBytes);
180
- if (collected.has(key)) {
181
- return; // a duplicate signer cannot pad the count toward minSigs
182
- }
183
- collected.set(key, { signer: signerBytes, sig: sigBytes });
184
- } catch {
185
- // Unreachable member or malformed reply — drop it; collection continues with the rest.
186
- }
187
- }
188
- }
189
-
190
- /**
191
- * A verify-only {@link ICohortThresholdCrypto} for the participant-side verifier, which never assembles
192
- * (it only checks inbound threshold-signed messages). `assemble` rejects so a misuse surfaces loudly
193
- * rather than fabricating a signature.
194
- */
195
- export function createVerifyOnlyThresholdCrypto(): ICohortThresholdCrypto {
196
- return {
197
- assemble: (): Promise<{ thresholdSig: Uint8Array; signers: Uint8Array[] }> =>
198
- Promise.reject(new Error("verify-only cohort threshold crypto cannot assemble signatures")),
199
- verify: verifyCollectedMultisig,
200
- };
201
- }
202
-
203
- /** Concatenate equal-width signature chunks into one fixed-stride blob aligned with the signer order. */
204
- function concatSigs(sigs: readonly Uint8Array[]): Uint8Array {
205
- const out = new Uint8Array(sigs.length * ED25519_SIG_BYTES);
206
- for (let i = 0; i < sigs.length; i++) {
207
- out.set(sigs[i]!, i * ED25519_SIG_BYTES);
208
- }
209
- return out;
210
- }
211
-
212
- /** Resolve `p`, or `undefined` if `timeoutMs` elapses first (bounds wall-clock against an unreachable peer). */
213
- function withTimeout<T>(p: Promise<T>, timeoutMs: number): Promise<T | undefined> {
214
- return new Promise<T | undefined>((resolve) => {
215
- let settled = false;
216
- const timer = setTimeout(() => {
217
- if (!settled) {
218
- settled = true;
219
- resolve(undefined);
220
- }
221
- }, timeoutMs);
222
- void p.then(
223
- (value) => {
224
- if (!settled) {
225
- settled = true;
226
- clearTimeout(timer);
227
- resolve(value);
228
- }
229
- },
230
- () => {
231
- if (!settled) {
232
- settled = true;
233
- clearTimeout(timer);
234
- resolve(undefined);
235
- }
236
- },
237
- );
238
- });
239
- }
1
+ /**
2
+ * Cohort-topic `k − x` threshold-signature crypto — real collected Ed25519 multi-signature.
3
+ *
4
+ * Closes gap 1 (the interim single-signer `sha256(payload)` digest, which could never satisfy
5
+ * `CohortSigner.verifyThreshold`'s `≥ minSigs` distinct-member rule at `minSigs = 14`). The scheme is
6
+ * a **collected multisig**: `thresholdSig` is the aligned concatenation of fixed-width 64-byte Ed25519
7
+ * signatures, one per `signers[i]`, each produced by that member's libp2p peer key over the *exact*
8
+ * canonical payload. It needs **no trusted setup, no new crypto dependency, and no aggregation round**
9
+ * (unlike BLS or FROST), maps directly onto the existing `(thresholdSig, signers)` contract, and reuses
10
+ * the per-member peer-key signing primitive (`peer-sig.ts`) the codebase already uses for cluster-repo
11
+ * commit signatures. Size is O(k) (≤ ~14 × 64 = 896 bytes at production `minSigs`) — negligible for
12
+ * k ≤ 16; if size ever matters at larger k the scheme can be swapped behind the unchanged
13
+ * {@link ICohortThresholdCrypto} port.
14
+ *
15
+ * **Coord-scoped.** `assemble(payload, minSigs)` carries no coord, so the adapter is constructed per
16
+ * served coord (one per {@link import("./host.js").CoordEngine}): it knows the cohort around its coord,
17
+ * the node's peer key (to add self's own signature without an RPC), and a `dialSign` seam over the
18
+ * `/sign` protocol to collect the other members' endorsements.
19
+ *
20
+ * **Verify is synchronous** — `ICohortThresholdCrypto.verify` is called from the sync
21
+ * `CohortSigner.verifyThreshold` on the participant path. {@link verifyCollectedMultisig} splits the
22
+ * blob into `signers.length` 64-byte chunks and {@link verifyPeerSig}-checks each against the
23
+ * corresponding signer's embedded Ed25519 key (noble, sync). Do not make it async.
24
+ */
25
+
26
+ import type { PrivateKey } from "@libp2p/interface";
27
+ import type { ICohortThresholdCrypto, RingCoord, SignKind, SignReplyV1, SignRequestV1 } from "@optimystic/db-core";
28
+ import { bytesToB64url, b64urlToBytes, compareBytes } from "@optimystic/db-core";
29
+ import { signPeer, verifyPeerSig } from "./peer-sig.js";
30
+ import { bytesToPeerIdString } from "./peer-codec.js";
31
+
32
+ /** Width of an Ed25519 signature in bytes — the fixed stride of the concatenated `thresholdSig`. */
33
+ export const ED25519_SIG_BYTES = 64;
34
+
35
+ /** Default per-round deadline for collecting cohort endorsements (ms). */
36
+ export const DEFAULT_SIGN_COLLECT_TIMEOUT_MS = 5_000;
37
+
38
+ /**
39
+ * Verify a collected Ed25519 multi-signature: `thresholdSig` must be exactly `signers.length × 64`
40
+ * bytes, and chunk `i` must be a valid peer-key signature by `signers[i]` over `payload`. Pure and
41
+ * synchronous; total (returns `false`, never throws). The db-core `CohortSigner.verifyThreshold` layer
42
+ * adds the distinct-signer / `signers ⊆ cert.members` / `≥ minSigs` checks on top of this.
43
+ */
44
+ export function verifyCollectedMultisig(payload: Uint8Array, thresholdSig: Uint8Array, signers: readonly Uint8Array[]): boolean {
45
+ if (signers.length === 0) {
46
+ return false;
47
+ }
48
+ if (thresholdSig.length !== signers.length * ED25519_SIG_BYTES) {
49
+ return false;
50
+ }
51
+ for (let i = 0; i < signers.length; i++) {
52
+ const chunk = thresholdSig.subarray(i * ED25519_SIG_BYTES, (i + 1) * ED25519_SIG_BYTES);
53
+ if (!verifyPeerSig(signers[i]!, payload, chunk)) {
54
+ return false;
55
+ }
56
+ }
57
+ return true;
58
+ }
59
+
60
+ /**
61
+ * Dependencies for a coord-scoped {@link FretCohortThresholdCrypto}. The host supplies these per
62
+ * {@link import("./host.js").CoordEngine}; the `kind` selects the endorsement policy the dialed
63
+ * members apply (membership cert vs promotion / demotion notice).
64
+ */
65
+ export interface CohortThresholdCryptoDeps {
66
+ /** What this signer assembles — sent in each {@link SignRequestV1} so members apply the right policy. */
67
+ readonly kind: SignKind;
68
+ /** The node's libp2p Ed25519 private key — signs self's own chunk locally (no RPC to self). */
69
+ readonly privateKey: PrivateKey;
70
+ /** Self's dialable member id (UTF-8 of the peer-id string); self is always a signer. */
71
+ readonly selfMember: Uint8Array;
72
+ /** The served coord whose cohort threshold-signs. */
73
+ readonly coord: () => RingCoord;
74
+ /** Current cohort epoch (raw bytes) for {@link coord}. */
75
+ readonly cohortEpoch: () => Uint8Array;
76
+ /** Current cohort member peer-id strings around {@link coord} (self may be included; excluded internally). */
77
+ readonly cohortMembers: () => string[];
78
+ /** Dial one cohort member's `/sign` RPC and return its reply. */
79
+ readonly dialSign: (peerIdStr: string, request: SignRequestV1) => Promise<SignReplyV1>;
80
+ /** Per-round collection deadline (ms). Default {@link DEFAULT_SIGN_COLLECT_TIMEOUT_MS}. */
81
+ readonly collectTimeoutMs?: number;
82
+ /**
83
+ * Whether self may contribute its own signature to this assembly. Default `true` (self is always a
84
+ * member of the cohort it signs over). The **rotation** producer scopes the assembly to the
85
+ * *predecessor* cohort (`cohortMembers` = the prior epoch's members); when self was not a member of
86
+ * that prior cohort it must NOT self-sign — its chunk would be dropped by the verifier (a signer not in
87
+ * the predecessor cert's `members`), desyncing the count from what the verifier accepts. Returning
88
+ * `false` then makes the quorum genuinely the outgoing cohort. See `cohort-topic-trust-anchor-rotation-production`.
89
+ */
90
+ readonly selfEligible?: () => boolean;
91
+ }
92
+
93
+ /** A collected, verified per-member signature awaiting concatenation. */
94
+ interface CollectedSig {
95
+ readonly signer: Uint8Array;
96
+ readonly sig: Uint8Array;
97
+ }
98
+
99
+ /**
100
+ * Real coord-scoped {@link ICohortThresholdCrypto}. `assemble` signs locally, concurrently dials the
101
+ * rest of the cohort over the `/sign` protocol, verifies each returned signature before counting it,
102
+ * and concatenates `≥ minSigs` distinct signatures into the threshold blob — or throws if a quorum is
103
+ * unreachable (it must **never** fabricate a single-signer sig, the interim bug). `verify` is the pure
104
+ * split-and-check.
105
+ */
106
+ export class FretCohortThresholdCrypto implements ICohortThresholdCrypto {
107
+ constructor(private readonly deps: CohortThresholdCryptoDeps) {}
108
+
109
+ async assemble(payload: Uint8Array, minSigs: number): Promise<{ thresholdSig: Uint8Array; signers: Uint8Array[] }> {
110
+ const collected = new Map<string, CollectedSig>();
111
+ const selfStr = bytesToPeerIdString(this.deps.selfMember);
112
+
113
+ // Self is normally a signer — it is the acting member, even if a stale table omits it from its own
114
+ // assembly. Sign locally (no RPC to self). A rotation producer that was NOT in the predecessor cohort
115
+ // (`selfEligible` → false) skips this: its chunk would be dropped by the verifier (signer ∉ predecessor
116
+ // cert members), so it must collect a quorum purely from the outgoing cohort instead.
117
+ if (this.deps.selfEligible?.() ?? true) {
118
+ const selfSig = await signPeer(this.deps.privateKey, payload);
119
+ if (selfSig.length !== ED25519_SIG_BYTES) {
120
+ throw new Error(`cohort threshold sign: self produced a ${selfSig.length}-byte signature (expected ${ED25519_SIG_BYTES}); non-Ed25519 identity?`);
121
+ }
122
+ collected.set(bytesToB64url(this.deps.selfMember), { signer: this.deps.selfMember, sig: selfSig });
123
+ }
124
+
125
+ // Concurrently collect endorsements from the rest of the cohort, up to the deadline.
126
+ const others = this.deps.cohortMembers().filter((m) => m !== selfStr);
127
+ if (others.length > 0 && collected.size < minSigs) {
128
+ const request: SignRequestV1 = {
129
+ v: 1,
130
+ kind: this.deps.kind,
131
+ coord: bytesToB64url(this.deps.coord()),
132
+ cohortEpoch: bytesToB64url(this.deps.cohortEpoch()),
133
+ payload: bytesToB64url(payload),
134
+ };
135
+ const timeoutMs = this.deps.collectTimeoutMs ?? DEFAULT_SIGN_COLLECT_TIMEOUT_MS;
136
+ await Promise.all(others.map((peerStr) => this.collectFrom(peerStr, request, payload, collected, timeoutMs)));
137
+ }
138
+
139
+ if (collected.size < minSigs) {
140
+ // Quorum unreachable / unwilling: no notice this round. The promotion / cert path re-fires next
141
+ // tick. NEVER fabricate a single-signer sig — that is exactly the interim bug this replaces.
142
+ throw new Error(`cohort threshold sign: gathered ${collected.size} of ${minSigs} required signatures`);
143
+ }
144
+
145
+ // Deterministic order (ascending by signer id) so the concatenation is reproducible and aligns
146
+ // signers[i] ↔ chunk i for the verifier.
147
+ const ordered = [...collected.values()].sort((a, b) => compareBytes(a.signer, b.signer));
148
+ const signers = ordered.map((e) => e.signer);
149
+ const thresholdSig = concatSigs(ordered.map((e) => e.sig));
150
+ return { thresholdSig, signers };
151
+ }
152
+
153
+ verify(payload: Uint8Array, thresholdSig: Uint8Array, signers: readonly Uint8Array[]): boolean {
154
+ return verifyCollectedMultisig(payload, thresholdSig, signers);
155
+ }
156
+
157
+ /** Dial one member, verify-before-count its endorsement, and add it to `collected` (dropping bad/dupe). */
158
+ private async collectFrom(
159
+ peerStr: string,
160
+ request: SignRequestV1,
161
+ payload: Uint8Array,
162
+ collected: Map<string, CollectedSig>,
163
+ timeoutMs: number,
164
+ ): Promise<void> {
165
+ try {
166
+ const reply = await withTimeout(this.deps.dialSign(peerStr, request), timeoutMs);
167
+ if (reply === undefined || "refused" in reply) {
168
+ return; // unreachable (timed out) or the member declined to endorse
169
+ }
170
+ const signerBytes = b64urlToBytes(reply.signer);
171
+ const sigBytes = b64urlToBytes(reply.signature);
172
+ if (sigBytes.length !== ED25519_SIG_BYTES) {
173
+ return; // a malformed chunk would desync the fixed-stride concatenation
174
+ }
175
+ // Verify before counting: a member must not be able to poison the blob with a bad signature.
176
+ if (!verifyPeerSig(signerBytes, payload, sigBytes)) {
177
+ return;
178
+ }
179
+ const key = bytesToB64url(signerBytes);
180
+ if (collected.has(key)) {
181
+ return; // a duplicate signer cannot pad the count toward minSigs
182
+ }
183
+ collected.set(key, { signer: signerBytes, sig: sigBytes });
184
+ } catch {
185
+ // Unreachable member or malformed reply — drop it; collection continues with the rest.
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * A verify-only {@link ICohortThresholdCrypto} for the participant-side verifier, which never assembles
192
+ * (it only checks inbound threshold-signed messages). `assemble` rejects so a misuse surfaces loudly
193
+ * rather than fabricating a signature.
194
+ */
195
+ export function createVerifyOnlyThresholdCrypto(): ICohortThresholdCrypto {
196
+ return {
197
+ assemble: (): Promise<{ thresholdSig: Uint8Array; signers: Uint8Array[] }> =>
198
+ Promise.reject(new Error("verify-only cohort threshold crypto cannot assemble signatures")),
199
+ verify: verifyCollectedMultisig,
200
+ };
201
+ }
202
+
203
+ /** Concatenate equal-width signature chunks into one fixed-stride blob aligned with the signer order. */
204
+ function concatSigs(sigs: readonly Uint8Array[]): Uint8Array {
205
+ const out = new Uint8Array(sigs.length * ED25519_SIG_BYTES);
206
+ for (let i = 0; i < sigs.length; i++) {
207
+ out.set(sigs[i]!, i * ED25519_SIG_BYTES);
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /** Resolve `p`, or `undefined` if `timeoutMs` elapses first (bounds wall-clock against an unreachable peer). */
213
+ function withTimeout<T>(p: Promise<T>, timeoutMs: number): Promise<T | undefined> {
214
+ return new Promise<T | undefined>((resolve) => {
215
+ let settled = false;
216
+ const timer = setTimeout(() => {
217
+ if (!settled) {
218
+ settled = true;
219
+ resolve(undefined);
220
+ }
221
+ }, timeoutMs);
222
+ void p.then(
223
+ (value) => {
224
+ if (!settled) {
225
+ settled = true;
226
+ clearTimeout(timer);
227
+ resolve(value);
228
+ }
229
+ },
230
+ () => {
231
+ if (!settled) {
232
+ settled = true;
233
+ clearTimeout(timer);
234
+ resolve(undefined);
235
+ }
236
+ },
237
+ );
238
+ });
239
+ }