@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,92 +1,92 @@
1
- import type { IKVStore } from "../storage/i-kv-store.js";
2
- import type { ITransactionStateStore, PersistedCoordinatorState, PersistedParticipantState } from "./i-transaction-state-store.js";
3
-
4
- /**
5
- * ITransactionStateStore backed by an IKVStore for cross-platform persistence.
6
- *
7
- * Key namespace:
8
- * coordinator/{messageHash} → JSON(PersistedCoordinatorState)
9
- * participant/{messageHash} → JSON(PersistedParticipantState)
10
- * executed/{messageHash} → JSON({ timestamp: number })
11
- */
12
- export class PersistentTransactionStateStore implements ITransactionStateStore {
13
- constructor(private readonly kv: IKVStore) {}
14
-
15
- // --- Coordinator ---
16
-
17
- async saveCoordinatorState(messageHash: string, state: PersistedCoordinatorState): Promise<void> {
18
- await this.kv.set(`coordinator/${messageHash}`, JSON.stringify(state));
19
- }
20
-
21
- async getCoordinatorState(messageHash: string): Promise<PersistedCoordinatorState | undefined> {
22
- const raw = await this.kv.get(`coordinator/${messageHash}`);
23
- return raw ? JSON.parse(raw) as PersistedCoordinatorState : undefined;
24
- }
25
-
26
- async deleteCoordinatorState(messageHash: string): Promise<void> {
27
- await this.kv.delete(`coordinator/${messageHash}`);
28
- }
29
-
30
- async getAllCoordinatorStates(): Promise<PersistedCoordinatorState[]> {
31
- const keys = await this.kv.list('coordinator/');
32
- const results: PersistedCoordinatorState[] = [];
33
- for (const key of keys) {
34
- const raw = await this.kv.get(key);
35
- if (raw) {
36
- results.push(JSON.parse(raw) as PersistedCoordinatorState);
37
- }
38
- }
39
- return results;
40
- }
41
-
42
- // --- Participant ---
43
-
44
- async saveParticipantState(messageHash: string, state: PersistedParticipantState): Promise<void> {
45
- await this.kv.set(`participant/${messageHash}`, JSON.stringify(state));
46
- }
47
-
48
- async getParticipantState(messageHash: string): Promise<PersistedParticipantState | undefined> {
49
- const raw = await this.kv.get(`participant/${messageHash}`);
50
- return raw ? JSON.parse(raw) as PersistedParticipantState : undefined;
51
- }
52
-
53
- async deleteParticipantState(messageHash: string): Promise<void> {
54
- await this.kv.delete(`participant/${messageHash}`);
55
- }
56
-
57
- async getAllParticipantStates(): Promise<PersistedParticipantState[]> {
58
- const keys = await this.kv.list('participant/');
59
- const results: PersistedParticipantState[] = [];
60
- for (const key of keys) {
61
- const raw = await this.kv.get(key);
62
- if (raw) {
63
- results.push(JSON.parse(raw) as PersistedParticipantState);
64
- }
65
- }
66
- return results;
67
- }
68
-
69
- // --- Executed ---
70
-
71
- async markExecuted(messageHash: string, timestamp: number): Promise<void> {
72
- await this.kv.set(`executed/${messageHash}`, JSON.stringify({ timestamp }));
73
- }
74
-
75
- async wasExecuted(messageHash: string): Promise<boolean> {
76
- const raw = await this.kv.get(`executed/${messageHash}`);
77
- return raw !== undefined;
78
- }
79
-
80
- async pruneExecuted(olderThan: number): Promise<void> {
81
- const keys = await this.kv.list('executed/');
82
- for (const key of keys) {
83
- const raw = await this.kv.get(key);
84
- if (raw) {
85
- const { timestamp } = JSON.parse(raw) as { timestamp: number };
86
- if (timestamp < olderThan) {
87
- await this.kv.delete(key);
88
- }
89
- }
90
- }
91
- }
92
- }
1
+ import type { IKVStore } from "../storage/i-kv-store.js";
2
+ import type { ITransactionStateStore, PersistedCoordinatorState, PersistedParticipantState } from "./i-transaction-state-store.js";
3
+
4
+ /**
5
+ * ITransactionStateStore backed by an IKVStore for cross-platform persistence.
6
+ *
7
+ * Key namespace:
8
+ * coordinator/{messageHash} → JSON(PersistedCoordinatorState)
9
+ * participant/{messageHash} → JSON(PersistedParticipantState)
10
+ * executed/{messageHash} → JSON({ timestamp: number })
11
+ */
12
+ export class PersistentTransactionStateStore implements ITransactionStateStore {
13
+ constructor(private readonly kv: IKVStore) {}
14
+
15
+ // --- Coordinator ---
16
+
17
+ async saveCoordinatorState(messageHash: string, state: PersistedCoordinatorState): Promise<void> {
18
+ await this.kv.set(`coordinator/${messageHash}`, JSON.stringify(state));
19
+ }
20
+
21
+ async getCoordinatorState(messageHash: string): Promise<PersistedCoordinatorState | undefined> {
22
+ const raw = await this.kv.get(`coordinator/${messageHash}`);
23
+ return raw ? JSON.parse(raw) as PersistedCoordinatorState : undefined;
24
+ }
25
+
26
+ async deleteCoordinatorState(messageHash: string): Promise<void> {
27
+ await this.kv.delete(`coordinator/${messageHash}`);
28
+ }
29
+
30
+ async getAllCoordinatorStates(): Promise<PersistedCoordinatorState[]> {
31
+ const keys = await this.kv.list('coordinator/');
32
+ const results: PersistedCoordinatorState[] = [];
33
+ for (const key of keys) {
34
+ const raw = await this.kv.get(key);
35
+ if (raw) {
36
+ results.push(JSON.parse(raw) as PersistedCoordinatorState);
37
+ }
38
+ }
39
+ return results;
40
+ }
41
+
42
+ // --- Participant ---
43
+
44
+ async saveParticipantState(messageHash: string, state: PersistedParticipantState): Promise<void> {
45
+ await this.kv.set(`participant/${messageHash}`, JSON.stringify(state));
46
+ }
47
+
48
+ async getParticipantState(messageHash: string): Promise<PersistedParticipantState | undefined> {
49
+ const raw = await this.kv.get(`participant/${messageHash}`);
50
+ return raw ? JSON.parse(raw) as PersistedParticipantState : undefined;
51
+ }
52
+
53
+ async deleteParticipantState(messageHash: string): Promise<void> {
54
+ await this.kv.delete(`participant/${messageHash}`);
55
+ }
56
+
57
+ async getAllParticipantStates(): Promise<PersistedParticipantState[]> {
58
+ const keys = await this.kv.list('participant/');
59
+ const results: PersistedParticipantState[] = [];
60
+ for (const key of keys) {
61
+ const raw = await this.kv.get(key);
62
+ if (raw) {
63
+ results.push(JSON.parse(raw) as PersistedParticipantState);
64
+ }
65
+ }
66
+ return results;
67
+ }
68
+
69
+ // --- Executed ---
70
+
71
+ async markExecuted(messageHash: string, timestamp: number): Promise<void> {
72
+ await this.kv.set(`executed/${messageHash}`, JSON.stringify({ timestamp }));
73
+ }
74
+
75
+ async wasExecuted(messageHash: string): Promise<boolean> {
76
+ const raw = await this.kv.get(`executed/${messageHash}`);
77
+ return raw !== undefined;
78
+ }
79
+
80
+ async pruneExecuted(olderThan: number): Promise<void> {
81
+ const keys = await this.kv.list('executed/');
82
+ for (const key of keys) {
83
+ const raw = await this.kv.get(key);
84
+ if (raw) {
85
+ const { timestamp } = JSON.parse(raw) as { timestamp: number };
86
+ if (timestamp < olderThan) {
87
+ await this.kv.delete(key);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
@@ -1,223 +1,223 @@
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
- * all — lets 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 { 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
+ * all — lets 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
+ }