@optimystic/db-p2p 0.24.2 → 0.25.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 (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,223 +1,348 @@
1
- import type { IBlock } from "@optimystic/db-core";
2
- import { sha256 } from "multiformats/hashes/sha2";
3
- import { toString as uint8ArrayToString } from 'uint8arrays';
4
-
5
- /**
6
- * Quorum-corroboration helpers shared by the two block-restoration paths:
7
- * - read-repair (`CoordinatorRepo.queryClusterForLatest`)
8
- * - reconcile (`libp2p-node-base.reconcileBlock`)
9
- *
10
- * Both paths previously trusted a single peer's self-reported "latest" (max rev
11
- * wins), so one lying peer could steer restoration. These helpers replace
12
- * "max wins" with "highest value corroborated by a quorum of distinct peers".
13
- *
14
- * NOTE: the quorum here is corroboration-of-a-claim, NOT Sybil-resistant cohort
15
- * membership. A peer minting fresh keypairs can still cast a vote — proving a
16
- * voter is a legitimate cohort member requires commit-cert + membership
17
- * anchoring, deferred to backlog `debt-read-repair-commit-cert-verification`.
18
- */
19
-
20
- /** A single peer's self-reported (rev, actionId) for a block. */
21
- export interface RevClaim {
22
- /** Distinct voter identity (peer-id string). One vote per distinct peerId per group. */
23
- peerId: string;
24
- rev: number;
25
- actionId: string;
26
- }
27
-
28
- /** The (rev, actionId) pair a quorum agreed on, plus the peers that corroborated it. */
29
- export interface QuorumRev {
30
- rev: number;
31
- actionId: string;
32
- /** Distinct peer-ids that voted for this exact (rev, actionId). */
33
- supporters: string[];
34
- }
35
-
36
- /**
37
- * Votes a claim needs when the cohort is big enough to supply them: a claim must be
38
- * seconded by a second, independent peer. See {@link quorumSize} for the cap that
39
- * applies when the cohort is smaller than this.
40
- */
41
- export const CORROBORATION_FLOOR = 2;
42
-
43
- /**
44
- * Number of distinct corroborating votes required to accept a claim:
45
- * `floor(simpleMajorityThreshold × responderCount)`, never below
46
- * {@link CORROBORATION_FLOOR} except that demanding more corroborators than the cohort
47
- * can possibly supply is a deadlock, not a safety property, so the floor is additionally
48
- * capped at `corroboratorCapacity`.
49
- *
50
- * `corroboratorCapacity` is how many peers OTHER than the asking node could answer at
51
- * all. Omit it when the caller cannot state one; the absolute floor of two then applies,
52
- * which is the conservative direction (a claim no one seconded is never accepted).
53
- *
54
- * Note the capacity caps the FLOOR only, never the proportional term: with many
55
- * responders the majority requirement still grows past two.
56
- */
57
- export function quorumSize(
58
- responderCount: number,
59
- simpleMajorityThreshold: number,
60
- corroboratorCapacity: number = Number.POSITIVE_INFINITY
61
- ): number {
62
- const floor = Math.max(1, Math.min(CORROBORATION_FLOOR, corroboratorCapacity));
63
- return Math.max(floor, Math.floor(simpleMajorityThreshold * responderCount));
64
- }
65
-
66
- /**
67
- * The `corroboratorCapacity` to hand {@link quorumSize}: how many peers other than the asking node
68
- * could answer for a block at all, given `cohortPeerCount` peers currently visible (self already
69
- * excluded) and `repairCorroborationClusterSize` the cohort size this deployment is measured
70
- * against, resolved by `resolveClusterPolicy` in `cluster/cluster-policy.ts`.
71
- *
72
- * Deliberately the MAX of the two: the corroboration floor may be relaxed only for a cohort that is
73
- * *genuinely* small, never for one that merely looks small. Cohort views are unauthenticated — the
74
- * read path takes them from `IKeyNetwork.findCluster`, the commit path from a coordinator-declared
75
- * peer set so a partition, a self-shrunk record, or an attacker with routing influence could
76
- * otherwise talk the requirement down to a single voter. Measuring against the resolved size keeps a
77
- * shrunken view out of the relaxed branch.
78
- *
79
- * An unconfigured node resolves this to its `clusterSize` (default 10), so the floor of two binds and
80
- * a shrunken view gains nothing. The escape hatch for a real two-node deployment is one explicit
81
- * operator declaration — `clusterPolicy.assumedClusterSize: 2`, which does NOT also drop the
82
- * replication factor, or an honest `clusterSize: 2`.
83
- *
84
- * Shared by both restoration paths so the two can never drift apart on the rule that decides how
85
- * much trust a lone peer gets.
86
- */
87
- export function corroboratorCapacity(cohortPeerCount: number, repairCorroborationClusterSize: number): number {
88
- return Math.max(cohortPeerCount, repairCorroborationClusterSize - 1);
89
- }
90
-
91
- /**
92
- * Select the highest revision corroborated by a quorum of distinct peers.
93
- *
94
- * Claims are grouped by the exact `(rev, actionId)` pair; a liar's fabricated
95
- * pair lands in its own singleton group and is outvoted. The highest rev whose
96
- * group has `>= quorum` distinct voters wins.
97
- *
98
- * **Claims must not include the asking node's own revision.** A node confirming its own
99
- * answer is not evidence; its revision is the baseline being repaired, and counting it
100
- * both inflates the responder count and lets a lone reader "corroborate" itself. Callers
101
- * filter self out before calling (see `CoordinatorRepo.queryClusterForLatest`,
102
- * `reconcileBlock`).
103
- *
104
- * `corroboratorCapacity` how many peers other than the asking node could corroborate at
105
- * alllets a genuinely tiny cohort still converge: a cohort with exactly one other peer
106
- * cannot produce two corroborators, so requiring two makes divergence permanent rather
107
- * than making it safe. Pass a capacity that a shrunken view of the network cannot talk
108
- * down (see {@link corroboratorCapacity}), or omit it to keep the floor at two.
109
- *
110
- * Returns `undefined` when nothing is corroborated — an uncorroborated claim
111
- * must never drive restoration.
112
- */
113
- export function selectQuorumRev(
114
- claims: RevClaim[],
115
- simpleMajorityThreshold: number,
116
- corroboratorCapacity?: number
117
- ): QuorumRev | undefined {
118
- if (claims.length === 0) return undefined;
119
-
120
- const groups = new Map<string, { rev: number; actionId: string; supporters: Set<string> }>();
121
- const responders = new Set<string>();
122
- for (const c of claims) {
123
- responders.add(c.peerId);
124
- const key = `${c.rev}\0${c.actionId}`;
125
- let g = groups.get(key);
126
- if (!g) {
127
- g = { rev: c.rev, actionId: c.actionId, supporters: new Set() };
128
- groups.set(key, g);
129
- }
130
- g.supporters.add(c.peerId);
131
- }
132
-
133
- const quorum = quorumSize(responders.size, simpleMajorityThreshold, corroboratorCapacity);
134
-
135
- // Highest rev whose (rev, actionId) group meets quorum. There is deliberately no
136
- // "too few responders, but they all agree" fallback: that rule fired at ANY cohort
137
- // size, so a lone responder in a large cohort where a second corroborator does
138
- // exist and simply did not answer within the per-peer timeout — was accepted on its
139
- // own word. `corroboratorCapacity` expresses the same permissiveness where it is
140
- // actually justified (a cohort that cannot supply a second corroborator) and nowhere
141
- // else.
142
- let best: { rev: number; actionId: string; supporters: Set<string> } | undefined;
143
- for (const g of groups.values()) {
144
- if (g.supporters.size >= quorum && (!best || g.rev > best.rev)) {
145
- best = g;
146
- }
147
- }
148
- return best
149
- ? { rev: best.rev, actionId: best.actionId, supporters: [...best.supporters] }
150
- : undefined;
151
- }
152
-
153
- /** Deterministic JSON: sorts object keys so the hash is order-independent. Mirrors `ClusterMember.canonicalJson`. */
154
- function canonicalJson(value: unknown): string {
155
- return JSON.stringify(value, (_, v) =>
156
- v && typeof v === 'object' && !Array.isArray(v)
157
- ? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
158
- : v
159
- );
160
- }
161
-
162
- /** Canonical sha256 of a block's content, base64url. Same pattern as `ClusterMember.computeCommitHash`. */
163
- export async function canonicalBlockHash(block: IBlock): Promise<string> {
164
- const bytes = new TextEncoder().encode(canonicalJson(block));
165
- const digest = await sha256.digest(bytes);
166
- return uint8ArrayToString(digest.digest, 'base64url');
167
- }
168
-
169
- /** One block candidate paired with its serving peer and canonical hash. */
170
- export interface BlockHashCandidate {
171
- peerId: string;
172
- hash: string;
173
- block: IBlock;
174
- }
175
-
176
- /**
177
- * Among candidates already known to corroborate the target `(rev, actionId)`,
178
- * pick the block content agreed by a quorum. Requires a UNIQUE hash group meeting
179
- * `quorum` a cohort split on content declines rather than picking a side.
180
- * Returns the agreed block, or `undefined`.
181
- *
182
- * `corroboratorCapacity` caps the floor exactly as it does in {@link selectQuorumRev}, and for
183
- * the same reason: a cohort holding one other peer cannot produce two block-carriers, so
184
- * demanding two makes the block permanently unrestorable on this node rather than making it safe.
185
- * Omit it to keep the floor at two.
186
- *
187
- * **What relaxing this does and does not cost.** Block ids are random 256-bit strings, not
188
- * content-addressed, so {@link canonicalBlockHash} is a cross-peer *agreement* hash and never a
189
- * check against the requested id — nothing here re-derives an id from received bytes. At capacity
190
- * one the sole peer's content is therefore taken on its word. That extends no trust the cohort had
191
- * not already extended: the same peer's `(rev, actionId)` claim is equally uncorroborable at that
192
- * size, and a two-member cohort has no honest majority to appeal to. Pass a capacity a shrunken
193
- * view of the network cannot talk down (see {@link corroboratorCapacity}), so only a cohort that is
194
- * *genuinely* that small reaches this branch.
195
- */
196
- export function selectQuorumBlock(
197
- candidates: BlockHashCandidate[],
198
- simpleMajorityThreshold: number,
199
- corroboratorCapacity?: number
200
- ): { block: IBlock; hash: string } | undefined {
201
- if (candidates.length === 0) return undefined;
202
-
203
- // One vote per distinct peer per hash group, matching selectQuorumRev — a peer appearing twice
204
- // must not be able to second itself into a content quorum.
205
- const groups = new Map<string, { block: IBlock; supporters: Set<string> }>();
206
- const voters = new Set<string>();
207
- for (const c of candidates) {
208
- voters.add(c.peerId);
209
- let g = groups.get(c.hash);
210
- if (!g) {
211
- g = { block: c.block, supporters: new Set() };
212
- groups.set(c.hash, g);
213
- }
214
- g.supporters.add(c.peerId);
215
- }
216
-
217
- const quorum = quorumSize(voters.size, simpleMajorityThreshold, corroboratorCapacity);
218
- const meeting = [...groups.entries()].filter(([, g]) => g.supporters.size >= quorum);
219
- // Exactly one hash may meet quorum; a tie is a genuine content disagreement → decline.
220
- if (meeting.length !== 1) return undefined;
221
- const [hash, group] = meeting[0]!;
222
- return { block: group.block, hash };
223
- }
1
+ import type { IBlock } from "@optimystic/db-core";
2
+ import type { BlockCommitProof } from "./commit-proof.js";
3
+
4
+ /**
5
+ * Quorum-corroboration helpers shared by the two block-restoration paths:
6
+ * - read-repair (`CoordinatorRepo.queryClusterForLatest`)
7
+ * - reconcile (`libp2p-node-base.reconcileBlock`)
8
+ *
9
+ * Both paths previously trusted a single peer's self-reported "latest" (max rev
10
+ * wins), so one lying peer could steer restoration. These helpers replace
11
+ * "max wins" with "highest value corroborated by a quorum of distinct peers".
12
+ *
13
+ * NOTE: the quorum here is corroboration-of-a-claim, NOT Sybil-resistant cohort
14
+ * membership. A peer minting fresh keypairs can still cast a vote. Selection now
15
+ * additionally weighs *certified* claims those whose cohort commit proof a
16
+ * caller has already verified (`cluster/certified-claims.ts`) and marked via the
17
+ * injected `certified` flag so a lone honest holder with a valid proof is
18
+ * sufficient where uncertified claims still need distinct-peer corroboration.
19
+ * Verification itself never happens here: both selectors stay pure and
20
+ * synchronous; verdicts arrive as booleans.
21
+ */
22
+
23
+ /** A single peer's self-reported (rev, actionId) for a block. */
24
+ export interface RevClaim {
25
+ /** Distinct voter identity (peer-id string). One vote per distinct peerId per group. */
26
+ peerId: string;
27
+ rev: number;
28
+ actionId: string;
29
+ /**
30
+ * The cohort commit proof the claiming peer attached, when it had one. Selection never reads
31
+ * this field: presence proves nothing on its own — the peer chose what to attach. A caller
32
+ * that verifies it (`certifyClaim` / `certifyContent` in `cluster/certified-claims.ts`, built
33
+ * on `verifyBlockCommitProofClaim`) records the verdict in {@link certified}, which is what
34
+ * {@link selectQuorumRev} weighs.
35
+ */
36
+ proof?: BlockCommitProof;
37
+ /**
38
+ * Injected verdict: the caller verified this claim's cohort commit proof and it certifies this
39
+ * exact `(rev, actionId)`. A certified claim carries the cohort's signature set as its
40
+ * corroboration, so {@link selectQuorumRev} can select it without a second peer vouching.
41
+ * Never set this from the mere presence of {@link proof}.
42
+ */
43
+ certified?: boolean;
44
+ }
45
+
46
+ /** The (rev, actionId) pair a quorum agreed on, plus the peers that corroborated it. */
47
+ export interface QuorumRev {
48
+ rev: number;
49
+ actionId: string;
50
+ /**
51
+ * Distinct peer-ids that voted for this exact (rev, actionId). When {@link certified} is set,
52
+ * these are the certified claimants at that pair instead possibly a single peer, whose
53
+ * corroboration is the proof's signature set rather than other voters.
54
+ */
55
+ supporters: string[];
56
+ /** Set when the certified path selected this pair, so callers can log which rule won. */
57
+ certified?: true;
58
+ }
59
+
60
+ /**
61
+ * Votes a claim needs when the cohort is big enough to supply them: a claim must be
62
+ * seconded by a second, independent peer. See {@link quorumSize} for the cap that
63
+ * applies when the cohort is smaller than this.
64
+ */
65
+ export const CORROBORATION_FLOOR = 2;
66
+
67
+ /**
68
+ * Number of distinct corroborating votes required to accept a claim:
69
+ * `floor(simpleMajorityThreshold × responderCount)`, never below
70
+ * {@link CORROBORATION_FLOOR} except that demanding more corroborators than the cohort
71
+ * can possibly supply is a deadlock, not a safety property, so the floor is additionally
72
+ * capped at `corroboratorCapacity`.
73
+ *
74
+ * `corroboratorCapacity` is how many peers OTHER than the asking node could answer at
75
+ * all. Omit it when the caller cannot state one; the absolute floor of two then applies,
76
+ * which is the conservative direction (a claim no one seconded is never accepted).
77
+ *
78
+ * Note the capacity caps the FLOOR only, never the proportional term: with many
79
+ * responders the majority requirement still grows past two.
80
+ */
81
+ export function quorumSize(
82
+ responderCount: number,
83
+ simpleMajorityThreshold: number,
84
+ corroboratorCapacity: number = Number.POSITIVE_INFINITY
85
+ ): number {
86
+ const floor = Math.max(1, Math.min(CORROBORATION_FLOOR, corroboratorCapacity));
87
+ return Math.max(floor, Math.floor(simpleMajorityThreshold * responderCount));
88
+ }
89
+
90
+ /**
91
+ * The `corroboratorCapacity` to hand {@link quorumSize}: how many peers other than the asking node
92
+ * could answer for a block at all, given `cohortPeerCount` peers currently visible (self already
93
+ * excluded) and `repairCorroborationClusterSize` — the cohort size this deployment is measured
94
+ * against, resolved by `resolveClusterPolicy` in `cluster/cluster-policy.ts`.
95
+ *
96
+ * Deliberately the MAX of the two: the corroboration floor may be relaxed only for a cohort that is
97
+ * *genuinely* small, never for one that merely looks small. Cohort views are unauthenticated — the
98
+ * read path takes them from `IKeyNetwork.findCluster`, the commit path from a coordinator-declared
99
+ * peer set so a partition, a self-shrunk record, or an attacker with routing influence could
100
+ * otherwise talk the requirement down to a single voter. Measuring against the resolved size keeps a
101
+ * shrunken view out of the relaxed branch.
102
+ *
103
+ * An unconfigured node resolves this to its `clusterSize` (default 10), so the floor of two binds and
104
+ * a shrunken view gains nothing. The escape hatch for a real two-node deployment is one explicit
105
+ * operator declaration `clusterPolicy.assumedClusterSize: 2`, which does NOT also drop the
106
+ * replication factor, or an honest `clusterSize: 2`.
107
+ *
108
+ * Shared by both restoration paths so the two can never drift apart on the rule that decides how
109
+ * much trust a lone peer gets.
110
+ */
111
+ export function corroboratorCapacity(cohortPeerCount: number, repairCorroborationClusterSize: number): number {
112
+ return Math.max(cohortPeerCount, repairCorroborationClusterSize - 1);
113
+ }
114
+
115
+ /**
116
+ * Select the highest revision corroborated by a quorum of distinct peers.
117
+ *
118
+ * Claims are grouped by the exact `(rev, actionId)` pair; a liar's fabricated
119
+ * pair lands in its own singleton group and is outvoted. The highest rev whose
120
+ * group has `>= quorum` distinct voters wins.
121
+ *
122
+ * **Claims must not include the asking node's own revision.** A node confirming its own
123
+ * answer is not evidence; its revision is the baseline being repaired, and counting it
124
+ * both inflates the responder count and lets a lone reader "corroborate" itself. Callers
125
+ * filter self out before calling (see `CoordinatorRepo.queryClusterForLatest`,
126
+ * `reconcileBlock`).
127
+ *
128
+ * `corroboratorCapacity` — how many peers other than the asking node could corroborate at
129
+ * all — lets a genuinely tiny cohort still converge: a cohort with exactly one other peer
130
+ * cannot produce two corroborators, so requiring two makes divergence permanent rather
131
+ * than making it safe. Pass a capacity that a shrunken view of the network cannot talk
132
+ * down (see {@link corroboratorCapacity}), or omit it to keep the floor at two.
133
+ *
134
+ * Returns `undefined` when nothing is corroborated — an uncorroborated claim
135
+ * must never drive restoration.
136
+ *
137
+ * **Certified claims** (`certified === true`, injected by a caller that verified the claim's
138
+ * cohort commit proof see `cluster/certified-claims.ts`) short-circuit the distinct-peer rule,
139
+ * because the proof's signature set IS the corroboration:
140
+ *
141
+ * - No certified claims → today's corroboration result, unchanged.
142
+ * - A corroborated pair at a HIGHER rev than every certified claim wins corroboration stays a
143
+ * legitimate weaker path, so a legacy uncertified tail written after the last proven rev
144
+ * remains readable.
145
+ * - Otherwise the highest certified rev wins: this covers a certified rev beaten in raw rev only
146
+ * by an *uncorroborated* claim (which failed quorum and is no evidence), and the equal-rev
147
+ * tie, where the proof outweighs votes.
148
+ * - Except: two distinct `actionId`s certified at that top rev is equivocation — the cohort
149
+ * provably signed two different actions into one revision — and the whole selection declines
150
+ * (`undefined`). Callers distinguish this decline from a plain no-quorum via
151
+ * {@link certifiedEquivocation}.
152
+ */
153
+ export function selectQuorumRev(
154
+ claims: RevClaim[],
155
+ simpleMajorityThreshold: number,
156
+ corroboratorCapacity?: number
157
+ ): QuorumRev | undefined {
158
+ if (claims.length === 0) return undefined;
159
+
160
+ const groups = new Map<string, { rev: number; actionId: string; supporters: Set<string> }>();
161
+ const responders = new Set<string>();
162
+ for (const c of claims) {
163
+ responders.add(c.peerId);
164
+ const key = `${c.rev}\0${c.actionId}`;
165
+ let g = groups.get(key);
166
+ if (!g) {
167
+ g = { rev: c.rev, actionId: c.actionId, supporters: new Set() };
168
+ groups.set(key, g);
169
+ }
170
+ g.supporters.add(c.peerId);
171
+ }
172
+
173
+ const quorum = quorumSize(responders.size, simpleMajorityThreshold, corroboratorCapacity);
174
+
175
+ // Highest rev whose (rev, actionId) group meets quorum. There is deliberately no
176
+ // "too few responders, but they all agree" fallback: that rule fired at ANY cohort
177
+ // size, so a lone responder in a large cohort where a second corroborator does
178
+ // exist and simply did not answer within the per-peer timeout was accepted on its
179
+ // own word. `corroboratorCapacity` expresses the same permissiveness where it is
180
+ // actually justified (a cohort that cannot supply a second corroborator) and nowhere
181
+ // else.
182
+ let best: { rev: number; actionId: string; supporters: Set<string> } | undefined;
183
+ for (const g of groups.values()) {
184
+ if (g.supporters.size >= quorum && (!best || g.rev > best.rev)) {
185
+ best = g;
186
+ }
187
+ }
188
+ const corroborated = best
189
+ ? { rev: best.rev, actionId: best.actionId, supporters: [...best.supporters] }
190
+ : undefined;
191
+
192
+ const certified = certifiedGroups(claims);
193
+ if (!certified) return corroborated;
194
+ // A corroborated pair strictly above every certified rev wins — a legacy uncertified tail
195
+ // must stay readable. A merely UNcorroborated higher rev never reaches here (it is not in
196
+ // `corroborated`), so it cannot outrank a proof.
197
+ if (corroborated && corroborated.rev > certified.rev) return corroborated;
198
+ // Two actions provably signed into the same top revision: decline the whole selection rather
199
+ // than pick a side. Callers log via certifiedEquivocation.
200
+ if (certified.byAction.size !== 1) return undefined;
201
+ const [entry] = certified.byAction;
202
+ const [actionId, supporters] = entry!; // size === 1 checked above
203
+ return { rev: certified.rev, actionId, supporters: [...supporters], certified: true };
204
+ }
205
+
206
+ /** Certified claims at the top certified rev, keyed by actionId → distinct certified claimants. */
207
+ function certifiedGroups(claims: RevClaim[]): { rev: number; byAction: Map<string, Set<string>> } | undefined {
208
+ let top: number | undefined;
209
+ for (const c of claims) {
210
+ if (c.certified === true && (top === undefined || c.rev > top)) top = c.rev;
211
+ }
212
+ if (top === undefined) return undefined;
213
+ const byAction = new Map<string, Set<string>>();
214
+ for (const c of claims) {
215
+ if (c.certified !== true || c.rev !== top) continue;
216
+ let s = byAction.get(c.actionId);
217
+ if (!s) {
218
+ s = new Set();
219
+ byAction.set(c.actionId, s);
220
+ }
221
+ s.add(c.peerId);
222
+ }
223
+ return { rev: top, byAction };
224
+ }
225
+
226
+ /**
227
+ * The conflicting certified set at the top certified rev, when there is one: two-plus distinct
228
+ * `actionId`s each carrying a verified cohort commit proof for the SAME revision. It deserves a
229
+ * distinct log line from a plain no-quorum — the cohort (or whoever holds its keys) provably
230
+ * signed both sides. Selection stays pure, so callers do the logging with what this reports.
231
+ *
232
+ * `undefined` when no certified claim exists or the top certified rev names a single action —
233
+ * conflicts at LOWER certified revs are history already superseded, not equivocation worth
234
+ * declining over.
235
+ *
236
+ * **Ask this only on a decline.** A non-`undefined` result does NOT imply {@link selectQuorumRev}
237
+ * returned `undefined`: a corroborated pair STRICTLY above the top certified rev still wins, and
238
+ * the equivocation below it is reported here while selection succeeded. Call it when selection
239
+ * declined, to say WHY it declined.
240
+ */
241
+ export function certifiedEquivocation(claims: RevClaim[]): { rev: number; actionIds: string[] } | undefined {
242
+ const groups = certifiedGroups(claims);
243
+ if (!groups || groups.byAction.size < 2) return undefined;
244
+ return { rev: groups.rev, actionIds: [...groups.byAction.keys()] };
245
+ }
246
+
247
+ /** One block candidate paired with its serving peer and canonical hash
248
+ * (`canonicalBlockHash` from `@optimystic/db-core`). */
249
+ export interface BlockHashCandidate {
250
+ peerId: string;
251
+ hash: string;
252
+ block: IBlock;
253
+ /**
254
+ * Injected verdict: the caller verified a cohort commit proof binding this candidate's CONTENT
255
+ * (`certifyContent` in `cluster/certified-claims.ts` — the declared digest matched these
256
+ * bytes), so the cohort's signatures stand in for other peers serving the same hash.
257
+ */
258
+ certified?: boolean;
259
+ }
260
+
261
+ /**
262
+ * Among candidates already known to corroborate the target `(rev, actionId)`,
263
+ * pick the block content agreed by a quorum. Requires a UNIQUE hash group meeting
264
+ * `quorum` — a cohort split on content declines rather than picking a side.
265
+ * Returns the agreed block, or `undefined`.
266
+ *
267
+ * `corroboratorCapacity` caps the floor exactly as it does in {@link selectQuorumRev}, and for
268
+ * the same reason: a cohort holding one other peer cannot produce two block-carriers, so
269
+ * demanding two makes the block permanently unrestorable on this node rather than making it safe.
270
+ * Omit it to keep the floor at two.
271
+ *
272
+ * **What relaxing this does and does not cost.** Block ids are random 256-bit strings, not
273
+ * content-addressed, so `canonicalBlockHash` (db-core) is a cross-peer *agreement* hash and never a
274
+ * check against the requested id — nothing here re-derives an id from received bytes. At capacity
275
+ * one the sole peer's content is therefore taken on its word. That extends no trust the cohort had
276
+ * not already extended: the same peer's `(rev, actionId)` claim is equally uncorroborable at that
277
+ * size, and a two-member cohort has no honest majority to appeal to. Pass a capacity a shrunken
278
+ * view of the network cannot talk down (see {@link corroboratorCapacity}), so only a cohort that is
279
+ * *genuinely* that small reaches this branch.
280
+ *
281
+ * **Certified candidates** (`certified === true` — a caller verified a cohort commit proof whose
282
+ * declared digest matches these exact bytes) short-circuit the hash quorum: exactly one distinct
283
+ * certified hash → that block wins outright, however many peers served it. Two-plus distinct
284
+ * certified hashes is certified content equivocation → decline (`undefined`), mirroring the
285
+ * existing unique-hash-tie decline; callers name that decline via
286
+ * {@link certifiedContentEquivocation}. No certified candidate → the hash quorum below, unchanged.
287
+ */
288
+ export function selectQuorumBlock(
289
+ candidates: BlockHashCandidate[],
290
+ simpleMajorityThreshold: number,
291
+ corroboratorCapacity?: number
292
+ ): { block: IBlock; hash: string } | undefined {
293
+ if (candidates.length === 0) return undefined;
294
+
295
+ const certifiedByHash = certifiedHashes(candidates);
296
+ if (certifiedByHash.size === 1) {
297
+ const [entry] = certifiedByHash;
298
+ const [hash, block] = entry!;
299
+ return { block, hash };
300
+ }
301
+ if (certifiedByHash.size > 1) return undefined; // certified content equivocation — decline
302
+
303
+ // One vote per distinct peer per hash group, matching selectQuorumRev — a peer appearing twice
304
+ // must not be able to second itself into a content quorum.
305
+ const groups = new Map<string, { block: IBlock; supporters: Set<string> }>();
306
+ const voters = new Set<string>();
307
+ for (const c of candidates) {
308
+ voters.add(c.peerId);
309
+ let g = groups.get(c.hash);
310
+ if (!g) {
311
+ g = { block: c.block, supporters: new Set() };
312
+ groups.set(c.hash, g);
313
+ }
314
+ g.supporters.add(c.peerId);
315
+ }
316
+
317
+ const quorum = quorumSize(voters.size, simpleMajorityThreshold, corroboratorCapacity);
318
+ const meeting = [...groups.entries()].filter(([, g]) => g.supporters.size >= quorum);
319
+ // Exactly one hash may meet quorum; a tie is a genuine content disagreement → decline.
320
+ if (meeting.length !== 1) return undefined;
321
+ const [hash, group] = meeting[0]!;
322
+ return { block: group.block, hash };
323
+ }
324
+
325
+ /** Distinct hashes carried by certified candidates → the first block instance serving each. */
326
+ function certifiedHashes(candidates: BlockHashCandidate[]): Map<string, IBlock> {
327
+ const byHash = new Map<string, IBlock>();
328
+ for (const c of candidates) {
329
+ if (c.certified === true && !byHash.has(c.hash)) byHash.set(c.hash, c.block);
330
+ }
331
+ return byHash;
332
+ }
333
+
334
+ /**
335
+ * The conflicting certified hashes when there are two or more — the content-side sibling of
336
+ * {@link certifiedEquivocation}, and the reason {@link selectQuorumBlock} declines outright.
337
+ * Candidates reaching that selector all carry the SAME `(rev, actionId)`, so two certified hashes
338
+ * mean the cohort's keys signed two different digests into one revision: a provable compromise an
339
+ * operator must be able to tell apart from the routine "not enough carriers agreed" decline. Both
340
+ * declines return `undefined`, so without this they log identically.
341
+ *
342
+ * `undefined` when fewer than two distinct certified hashes exist — including the ordinary
343
+ * no-certified-candidate case, where a decline really is a plain content-quorum shortfall.
344
+ */
345
+ export function certifiedContentEquivocation(candidates: BlockHashCandidate[]): { hashes: string[] } | undefined {
346
+ const byHash = certifiedHashes(candidates);
347
+ return byHash.size < 2 ? undefined : { hashes: [...byHash.keys()] };
348
+ }