@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,195 +1,195 @@
1
- /**
2
- * Matchmaking — seeker-side adversarial traffic-reporting bounds + reputation cross-check hooks (db-p2p).
3
- *
4
- * `docs/matchmaking.md` §Adversarial cohort traffic reporting. A `QueryReplyV1.topicTraffic` (and the
5
- * registration reply's traffic) is signed by the cohort **primary's single member key, not a threshold
6
- * signature**, because the response is advisory — so a malicious primary can over- or under-report. The
7
- * doc proves the harm is bounded either way; this module makes those bounds explicit for the seeker walk
8
- * and emits the cross-check signals the reputation subsystem consumes.
9
- *
10
- * - **Over-reporting** (fake *hot* tier ⇒ seeker hangs out): bounded by the seeker's `patienceMs`. The
11
- * worst case is wasted patience plus the one `register → walk` hop after timeout — there is **no
12
- * spatial flood**, because the decision rule only ever walks *toward the root*, never speculatively
13
- * outward. {@link boundReportedTraffic} therefore caps any hang-out to the seeker's remaining
14
- * wall-clock patience ({@link TrafficBoundResult.capPatienceMs}).
15
- * - **Under-reporting** (fake *cold* tier ⇒ seeker escalates): bounded to **one extra hop per affected
16
- * tier**, terminating at the root where aggregated truth is hardest to fake
17
- * ({@link TrafficBoundResult.escalateAfterTiers}).
18
- * - **Cross-check via cohort gossip → reputation.** Other cohort members can detect a primary whose
19
- * reported rate diverges from the gossip-derived view. Detection *routing* is the reputation
20
- * subsystem's job (out of scope to implement); this module provides the **emission points**
21
- * ({@link TrafficCrossCheckSignal}) and a thin bridge ({@link reportTrafficCrossCheck}) into the
22
- * existing {@link IPeerReputation}. It scores nothing itself — the reputation subsystem owns the
23
- * aggregation/decay policy that turns a stream of advisory signals into an actual penalty.
24
- *
25
- * **GROUNDING (matchmaking.md §Adversarial traffic reporting):** no threshold signature is added per
26
- * `QueryReplyV1` — the single-member signature stands; the bounded worst-case here does not justify the
27
- * per-reply threshold cost. This module enforces the bounds purely seeker-side instead.
28
- */
29
-
30
- import { type QueryReplyV1 } from "@optimystic/db-core";
31
- import { PenaltyReason, type IPeerReputation } from "../reputation/index.js";
32
-
33
- /**
34
- * The seeker's running walk state at the moment a reply lands — enough to bound the reply's reported
35
- * traffic and attribute a cross-check signal. The seeker walk client (or the public seeker session)
36
- * supplies it per reply.
37
- */
38
- export interface SeekerWalkState {
39
- /** Tree tier `d` the reply came from. */
40
- readonly currentTier: number;
41
- /** Starting tier `d_max` (the walk only descends from here toward `0`). */
42
- readonly dMax: number;
43
- /** Register hops issued so far (probes + escalations). Bounded by `d_max + 1` — the walk never loops. */
44
- readonly tiersWalked: number;
45
- /** Total patience budget for the task (ms). */
46
- readonly patienceMs: number;
47
- /** Patience left on the wall-clock deadline (ms) — the hang-out cap. */
48
- readonly patienceRemainingMs: number;
49
- /** Filter-matched providers the seeker's *own* immediate `QueryV1` at this tier actually returned. */
50
- readonly observedMatches: number;
51
- /** The reply's cohort primary peer id, for reputation attribution (absent → no attribution). */
52
- readonly primaryId?: string;
53
- }
54
-
55
- /** Tunables for {@link boundReportedTraffic}. */
56
- export interface TrafficBoundConfig {
57
- /**
58
- * Upper bound on extra register hops a single under-reported tier may cost. The doc fixes this at
59
- * **1** ("one extra hop per affected tier"); exposed for tests / future policy, not meant to change.
60
- */
61
- readonly maxExtraHopsPerTier: number;
62
- /**
63
- * Over-report plausibility ratio. When a reply claims `directParticipants` more than this multiple of
64
- * the seeker's *own* query yield, the over-report is flagged *suspect* (a cross-check signal — never a
65
- * hard reject; the patience cap already bounds the harm).
66
- */
67
- readonly overReportSuspectRatio: number;
68
- }
69
-
70
- /** The documented defaults (`maxExtraHopsPerTier = 1`; suspect ratio chosen to tolerate normal churn). */
71
- export const DEFAULT_TRAFFIC_BOUND_CONFIG: TrafficBoundConfig = {
72
- maxExtraHopsPerTier: 1,
73
- overReportSuspectRatio: 8,
74
- };
75
-
76
- /** The kind of divergence a {@link TrafficCrossCheckSignal} reports. */
77
- export type TrafficCrossCheckKind = "over-report-suspected" | "under-report-suspected";
78
-
79
- /**
80
- * One advisory cross-check observation about a cohort primary's reported traffic. Emitted by
81
- * {@link boundReportedTraffic}; consumed by the reputation subsystem (via {@link reportTrafficCrossCheck}
82
- * or a custom sink). Carries the raw discrepancy so the reputation policy — not this module — decides
83
- * whether it warrants a penalty.
84
- */
85
- export interface TrafficCrossCheckSignal {
86
- readonly kind: TrafficCrossCheckKind;
87
- /** The cohort primary peer id (the subject), if the walk state attributed one. */
88
- readonly subjectId?: string;
89
- /** The tier the reply came from. */
90
- readonly tier: number;
91
- /** `topicTraffic.directParticipants` as reported. */
92
- readonly reportedDirectParticipants: number;
93
- /** `topicTraffic.arrivalsPerMin` as reported. */
94
- readonly reportedArrivalsPerMin: number;
95
- /** The seeker's own immediate-query yield, which the report is cross-checked against. */
96
- readonly observedMatches: number;
97
- }
98
-
99
- /** The outcome of {@link boundReportedTraffic}. */
100
- export interface TrafficBoundResult {
101
- /** Whether the reported traffic is within plausible bounds (advisory; a flag, not an admission). */
102
- readonly trusted: boolean;
103
- /**
104
- * Over-report bound: never hang out at this tier beyond the seeker's remaining wall-clock patience.
105
- * Equals `max(0, patienceRemainingMs)` — so a fabricated hot tier wastes at most that, then the walk
106
- * proceeds with its one register hop.
107
- */
108
- readonly capPatienceMs: number;
109
- /** Under-report bound: at most this many extra register hops attributable to one tier (== config). */
110
- readonly escalateAfterTiers: number;
111
- /** Cross-check emission points (possibly empty). Forward to the reputation subsystem if desired. */
112
- readonly reputationSignals: TrafficCrossCheckSignal[];
113
- }
114
-
115
- /**
116
- * Bound the harm of a (possibly adversarial) `topicTraffic` report and emit cross-check signals. Pure —
117
- * no I/O, no clock, no reputation scoring. The seeker walk consults `capPatienceMs` before hanging out
118
- * and treats `escalateAfterTiers` as the per-tier hop ceiling; the bounds it returns hold *by
119
- * construction* of the toward-root-only walk, so this function documents + asserts them rather than
120
- * changing the walk topology.
121
- */
122
- export function boundReportedTraffic(
123
- reply: QueryReplyV1,
124
- walkState: SeekerWalkState,
125
- cfg: TrafficBoundConfig = DEFAULT_TRAFFIC_BOUND_CONFIG,
126
- ): TrafficBoundResult {
127
- const traffic = reply.topicTraffic;
128
- const signals: TrafficCrossCheckSignal[] = [];
129
-
130
- // Over-report: a primary advertising a hot tier (many directParticipants) whose own query yields far
131
- // fewer matches is suspect. Harm is bounded regardless — the patience cap below means a fabricated hot
132
- // tier can only waste the seeker's remaining patience before it walks on (no spatial flood, since the
133
- // walk only steps toward the root).
134
- const overReportSuspect =
135
- traffic.directParticipants > cfg.overReportSuspectRatio * Math.max(walkState.observedMatches, 1);
136
- if (overReportSuspect) {
137
- signals.push(crossCheckSignal("over-report-suspected", traffic, walkState));
138
- }
139
-
140
- // Under-report: a primary claiming a cold tier (zero arrivals) while the seeker's own query yields
141
- // matches is suspect. Either way the escalation costs at most one extra hop for this tier and
142
- // terminates at the root.
143
- const underReportSuspect = traffic.arrivalsPerMin === 0 && walkState.observedMatches > 0;
144
- if (underReportSuspect) {
145
- signals.push(crossCheckSignal("under-report-suspected", traffic, walkState));
146
- }
147
-
148
- return {
149
- trusted: !overReportSuspect && !underReportSuspect,
150
- capPatienceMs: Math.max(0, walkState.patienceRemainingMs),
151
- escalateAfterTiers: cfg.maxExtraHopsPerTier,
152
- reputationSignals: signals,
153
- };
154
- }
155
-
156
- function crossCheckSignal(kind: TrafficCrossCheckKind, traffic: QueryReplyV1["topicTraffic"], walkState: SeekerWalkState): TrafficCrossCheckSignal {
157
- const signal: TrafficCrossCheckSignal = {
158
- kind,
159
- tier: walkState.currentTier,
160
- reportedDirectParticipants: traffic.directParticipants,
161
- reportedArrivalsPerMin: traffic.arrivalsPerMin,
162
- observedMatches: walkState.observedMatches,
163
- };
164
- if (walkState.primaryId !== undefined) {
165
- (signal as { subjectId: string }).subjectId = walkState.primaryId;
166
- }
167
- return signal;
168
- }
169
-
170
- /**
171
- * The total hop budget the toward-root-only walk can consume under any sequence of (honest or
172
- * adversarial) traffic reports: at most `maxExtraHopsPerTier` per tier from `d_max` down to the root,
173
- * plus the terminal hop — i.e. `(dMax + 1) * maxExtraHopsPerTier`. Exposed so the walk and its tests can
174
- * assert the under-report bound holds (the walk never exceeds it, because escalation is monotone toward
175
- * `d = 0`).
176
- */
177
- export function maxWalkHops(dMax: number, cfg: TrafficBoundConfig = DEFAULT_TRAFFIC_BOUND_CONFIG): number {
178
- return Math.max(0, dMax + 1) * cfg.maxExtraHopsPerTier;
179
- }
180
-
181
- /**
182
- * Integration hook: forward cross-check signals into the reputation subsystem. This is the **emission
183
- * point only** — it records a `ProtocolViolation` against the reporting primary; the reputation
184
- * subsystem's own weighting, decay, and deprioritize/ban thresholds (`PeerReputationService`) decide
185
- * whether an accumulation of these advisory signals actually penalizes the peer. Signals without an
186
- * attributed `subjectId` are skipped.
187
- */
188
- export function reportTrafficCrossCheck(reputation: IPeerReputation, signals: readonly TrafficCrossCheckSignal[]): void {
189
- for (const signal of signals) {
190
- if (signal.subjectId === undefined) {
191
- continue;
192
- }
193
- reputation.reportPeer(signal.subjectId, PenaltyReason.ProtocolViolation, `matchmaking:${signal.kind}`);
194
- }
195
- }
1
+ /**
2
+ * Matchmaking — seeker-side adversarial traffic-reporting bounds + reputation cross-check hooks (db-p2p).
3
+ *
4
+ * `docs/matchmaking.md` §Adversarial cohort traffic reporting. A `QueryReplyV1.topicTraffic` (and the
5
+ * registration reply's traffic) is signed by the cohort **primary's single member key, not a threshold
6
+ * signature**, because the response is advisory — so a malicious primary can over- or under-report. The
7
+ * doc proves the harm is bounded either way; this module makes those bounds explicit for the seeker walk
8
+ * and emits the cross-check signals the reputation subsystem consumes.
9
+ *
10
+ * - **Over-reporting** (fake *hot* tier ⇒ seeker hangs out): bounded by the seeker's `patienceMs`. The
11
+ * worst case is wasted patience plus the one `register → walk` hop after timeout — there is **no
12
+ * spatial flood**, because the decision rule only ever walks *toward the root*, never speculatively
13
+ * outward. {@link boundReportedTraffic} therefore caps any hang-out to the seeker's remaining
14
+ * wall-clock patience ({@link TrafficBoundResult.capPatienceMs}).
15
+ * - **Under-reporting** (fake *cold* tier ⇒ seeker escalates): bounded to **one extra hop per affected
16
+ * tier**, terminating at the root where aggregated truth is hardest to fake
17
+ * ({@link TrafficBoundResult.escalateAfterTiers}).
18
+ * - **Cross-check via cohort gossip → reputation.** Other cohort members can detect a primary whose
19
+ * reported rate diverges from the gossip-derived view. Detection *routing* is the reputation
20
+ * subsystem's job (out of scope to implement); this module provides the **emission points**
21
+ * ({@link TrafficCrossCheckSignal}) and a thin bridge ({@link reportTrafficCrossCheck}) into the
22
+ * existing {@link IPeerReputation}. It scores nothing itself — the reputation subsystem owns the
23
+ * aggregation/decay policy that turns a stream of advisory signals into an actual penalty.
24
+ *
25
+ * **GROUNDING (matchmaking.md §Adversarial traffic reporting):** no threshold signature is added per
26
+ * `QueryReplyV1` — the single-member signature stands; the bounded worst-case here does not justify the
27
+ * per-reply threshold cost. This module enforces the bounds purely seeker-side instead.
28
+ */
29
+
30
+ import { type QueryReplyV1 } from "@optimystic/db-core";
31
+ import { PenaltyReason, type IPeerReputation } from "../reputation/index.js";
32
+
33
+ /**
34
+ * The seeker's running walk state at the moment a reply lands — enough to bound the reply's reported
35
+ * traffic and attribute a cross-check signal. The seeker walk client (or the public seeker session)
36
+ * supplies it per reply.
37
+ */
38
+ export interface SeekerWalkState {
39
+ /** Tree tier `d` the reply came from. */
40
+ readonly currentTier: number;
41
+ /** Starting tier `d_max` (the walk only descends from here toward `0`). */
42
+ readonly dMax: number;
43
+ /** Register hops issued so far (probes + escalations). Bounded by `d_max + 1` — the walk never loops. */
44
+ readonly tiersWalked: number;
45
+ /** Total patience budget for the task (ms). */
46
+ readonly patienceMs: number;
47
+ /** Patience left on the wall-clock deadline (ms) — the hang-out cap. */
48
+ readonly patienceRemainingMs: number;
49
+ /** Filter-matched providers the seeker's *own* immediate `QueryV1` at this tier actually returned. */
50
+ readonly observedMatches: number;
51
+ /** The reply's cohort primary peer id, for reputation attribution (absent → no attribution). */
52
+ readonly primaryId?: string;
53
+ }
54
+
55
+ /** Tunables for {@link boundReportedTraffic}. */
56
+ export interface TrafficBoundConfig {
57
+ /**
58
+ * Upper bound on extra register hops a single under-reported tier may cost. The doc fixes this at
59
+ * **1** ("one extra hop per affected tier"); exposed for tests / future policy, not meant to change.
60
+ */
61
+ readonly maxExtraHopsPerTier: number;
62
+ /**
63
+ * Over-report plausibility ratio. When a reply claims `directParticipants` more than this multiple of
64
+ * the seeker's *own* query yield, the over-report is flagged *suspect* (a cross-check signal — never a
65
+ * hard reject; the patience cap already bounds the harm).
66
+ */
67
+ readonly overReportSuspectRatio: number;
68
+ }
69
+
70
+ /** The documented defaults (`maxExtraHopsPerTier = 1`; suspect ratio chosen to tolerate normal churn). */
71
+ export const DEFAULT_TRAFFIC_BOUND_CONFIG: TrafficBoundConfig = {
72
+ maxExtraHopsPerTier: 1,
73
+ overReportSuspectRatio: 8,
74
+ };
75
+
76
+ /** The kind of divergence a {@link TrafficCrossCheckSignal} reports. */
77
+ export type TrafficCrossCheckKind = "over-report-suspected" | "under-report-suspected";
78
+
79
+ /**
80
+ * One advisory cross-check observation about a cohort primary's reported traffic. Emitted by
81
+ * {@link boundReportedTraffic}; consumed by the reputation subsystem (via {@link reportTrafficCrossCheck}
82
+ * or a custom sink). Carries the raw discrepancy so the reputation policy — not this module — decides
83
+ * whether it warrants a penalty.
84
+ */
85
+ export interface TrafficCrossCheckSignal {
86
+ readonly kind: TrafficCrossCheckKind;
87
+ /** The cohort primary peer id (the subject), if the walk state attributed one. */
88
+ readonly subjectId?: string;
89
+ /** The tier the reply came from. */
90
+ readonly tier: number;
91
+ /** `topicTraffic.directParticipants` as reported. */
92
+ readonly reportedDirectParticipants: number;
93
+ /** `topicTraffic.arrivalsPerMin` as reported. */
94
+ readonly reportedArrivalsPerMin: number;
95
+ /** The seeker's own immediate-query yield, which the report is cross-checked against. */
96
+ readonly observedMatches: number;
97
+ }
98
+
99
+ /** The outcome of {@link boundReportedTraffic}. */
100
+ export interface TrafficBoundResult {
101
+ /** Whether the reported traffic is within plausible bounds (advisory; a flag, not an admission). */
102
+ readonly trusted: boolean;
103
+ /**
104
+ * Over-report bound: never hang out at this tier beyond the seeker's remaining wall-clock patience.
105
+ * Equals `max(0, patienceRemainingMs)` — so a fabricated hot tier wastes at most that, then the walk
106
+ * proceeds with its one register hop.
107
+ */
108
+ readonly capPatienceMs: number;
109
+ /** Under-report bound: at most this many extra register hops attributable to one tier (== config). */
110
+ readonly escalateAfterTiers: number;
111
+ /** Cross-check emission points (possibly empty). Forward to the reputation subsystem if desired. */
112
+ readonly reputationSignals: TrafficCrossCheckSignal[];
113
+ }
114
+
115
+ /**
116
+ * Bound the harm of a (possibly adversarial) `topicTraffic` report and emit cross-check signals. Pure —
117
+ * no I/O, no clock, no reputation scoring. The seeker walk consults `capPatienceMs` before hanging out
118
+ * and treats `escalateAfterTiers` as the per-tier hop ceiling; the bounds it returns hold *by
119
+ * construction* of the toward-root-only walk, so this function documents + asserts them rather than
120
+ * changing the walk topology.
121
+ */
122
+ export function boundReportedTraffic(
123
+ reply: QueryReplyV1,
124
+ walkState: SeekerWalkState,
125
+ cfg: TrafficBoundConfig = DEFAULT_TRAFFIC_BOUND_CONFIG,
126
+ ): TrafficBoundResult {
127
+ const traffic = reply.topicTraffic;
128
+ const signals: TrafficCrossCheckSignal[] = [];
129
+
130
+ // Over-report: a primary advertising a hot tier (many directParticipants) whose own query yields far
131
+ // fewer matches is suspect. Harm is bounded regardless — the patience cap below means a fabricated hot
132
+ // tier can only waste the seeker's remaining patience before it walks on (no spatial flood, since the
133
+ // walk only steps toward the root).
134
+ const overReportSuspect =
135
+ traffic.directParticipants > cfg.overReportSuspectRatio * Math.max(walkState.observedMatches, 1);
136
+ if (overReportSuspect) {
137
+ signals.push(crossCheckSignal("over-report-suspected", traffic, walkState));
138
+ }
139
+
140
+ // Under-report: a primary claiming a cold tier (zero arrivals) while the seeker's own query yields
141
+ // matches is suspect. Either way the escalation costs at most one extra hop for this tier and
142
+ // terminates at the root.
143
+ const underReportSuspect = traffic.arrivalsPerMin === 0 && walkState.observedMatches > 0;
144
+ if (underReportSuspect) {
145
+ signals.push(crossCheckSignal("under-report-suspected", traffic, walkState));
146
+ }
147
+
148
+ return {
149
+ trusted: !overReportSuspect && !underReportSuspect,
150
+ capPatienceMs: Math.max(0, walkState.patienceRemainingMs),
151
+ escalateAfterTiers: cfg.maxExtraHopsPerTier,
152
+ reputationSignals: signals,
153
+ };
154
+ }
155
+
156
+ function crossCheckSignal(kind: TrafficCrossCheckKind, traffic: QueryReplyV1["topicTraffic"], walkState: SeekerWalkState): TrafficCrossCheckSignal {
157
+ const signal: TrafficCrossCheckSignal = {
158
+ kind,
159
+ tier: walkState.currentTier,
160
+ reportedDirectParticipants: traffic.directParticipants,
161
+ reportedArrivalsPerMin: traffic.arrivalsPerMin,
162
+ observedMatches: walkState.observedMatches,
163
+ };
164
+ if (walkState.primaryId !== undefined) {
165
+ (signal as { subjectId: string }).subjectId = walkState.primaryId;
166
+ }
167
+ return signal;
168
+ }
169
+
170
+ /**
171
+ * The total hop budget the toward-root-only walk can consume under any sequence of (honest or
172
+ * adversarial) traffic reports: at most `maxExtraHopsPerTier` per tier from `d_max` down to the root,
173
+ * plus the terminal hop — i.e. `(dMax + 1) * maxExtraHopsPerTier`. Exposed so the walk and its tests can
174
+ * assert the under-report bound holds (the walk never exceeds it, because escalation is monotone toward
175
+ * `d = 0`).
176
+ */
177
+ export function maxWalkHops(dMax: number, cfg: TrafficBoundConfig = DEFAULT_TRAFFIC_BOUND_CONFIG): number {
178
+ return Math.max(0, dMax + 1) * cfg.maxExtraHopsPerTier;
179
+ }
180
+
181
+ /**
182
+ * Integration hook: forward cross-check signals into the reputation subsystem. This is the **emission
183
+ * point only** — it records a `ProtocolViolation` against the reporting primary; the reputation
184
+ * subsystem's own weighting, decay, and deprioritize/ban thresholds (`PeerReputationService`) decide
185
+ * whether an accumulation of these advisory signals actually penalizes the peer. Signals without an
186
+ * attributed `subjectId` are skipped.
187
+ */
188
+ export function reportTrafficCrossCheck(reputation: IPeerReputation, signals: readonly TrafficCrossCheckSignal[]): void {
189
+ for (const signal of signals) {
190
+ if (signal.subjectId === undefined) {
191
+ continue;
192
+ }
193
+ reputation.reportPeer(signal.subjectId, PenaltyReason.ProtocolViolation, `matchmaking:${signal.kind}`);
194
+ }
195
+ }
@@ -1,36 +1,36 @@
1
- import type { Libp2p, PrivateKey } from '@libp2p/interface';
2
- import type { IBlockChangeNotifier, IRepo } from '@optimystic/db-core';
3
- import type { DisputeService } from './dispute/dispute-service.js';
4
- import type { Libp2pKeyPeerNetwork } from './libp2p-key-network.js';
5
- import type { PeerReputationService } from './reputation/peer-reputation.js';
6
- import type { StorageRepo } from './storage/storage-repo.js';
7
-
8
- /**
9
- * The handles `createLibp2pNodeBase` attaches to the libp2p node it returns. This is the
10
- * sanctioned in-process surface a host reads — declared once here so reaching it does not
11
- * require a cast, and so a host cannot silently rebuild a component the node already owns.
12
- *
13
- * Deliberately NOT the full set of `(node as any).*` attachments made in `libp2p-node-base.ts`:
14
- * the churn/rebalance/ring-shift monitors, the cohort-topic host and the reactivity registries
15
- * are node-internal wiring, not a host-facing surface, and typing them is a separate job.
16
- */
17
- export interface OptimysticNodeAttachments {
18
- /**
19
- * The node's ONE key network — built from its resolved cluster policy, network-namespaced
20
- * protocol prefix, reputation tracker and persistence. A host that needs key/coordinator
21
- * lookup uses THIS; constructing a second one gives peer selection a different cohort
22
- * width and coordinator than the node's own consensus path uses for the same key.
23
- */
24
- keyNetwork: Libp2pKeyPeerNetwork;
25
- coordinatedRepo: IRepo;
26
- storageRepo: StorageRepo;
27
- /** Per-collection change origin. Replaced by the cohort-topic bridge notifier when enabled. */
28
- blockChangeNotifier: IBlockChangeNotifier;
29
- reputation: PeerReputationService;
30
- /** Present only when the dispute subsystem is configured. */
31
- disputeService?: DisputeService;
32
- /** The node's libp2p Ed25519 identity key, for hosts binding a client-transaction signer. */
33
- peerPrivateKey: PrivateKey;
34
- }
35
-
36
- export type OptimysticNode = Libp2p & OptimysticNodeAttachments;
1
+ import type { Libp2p, PrivateKey } from '@libp2p/interface';
2
+ import type { IBlockChangeNotifier, IRepo } from '@optimystic/db-core';
3
+ import type { DisputeService } from './dispute/dispute-service.js';
4
+ import type { Libp2pKeyPeerNetwork } from './libp2p-key-network.js';
5
+ import type { PeerReputationService } from './reputation/peer-reputation.js';
6
+ import type { StorageRepo } from './storage/storage-repo.js';
7
+
8
+ /**
9
+ * The handles `createLibp2pNodeBase` attaches to the libp2p node it returns. This is the
10
+ * sanctioned in-process surface a host reads — declared once here so reaching it does not
11
+ * require a cast, and so a host cannot silently rebuild a component the node already owns.
12
+ *
13
+ * Deliberately NOT the full set of `(node as any).*` attachments made in `libp2p-node-base.ts`:
14
+ * the churn/rebalance/ring-shift monitors, the cohort-topic host and the reactivity registries
15
+ * are node-internal wiring, not a host-facing surface, and typing them is a separate job.
16
+ */
17
+ export interface OptimysticNodeAttachments {
18
+ /**
19
+ * The node's ONE key network — built from its resolved cluster policy, network-namespaced
20
+ * protocol prefix, reputation tracker and persistence. A host that needs key/coordinator
21
+ * lookup uses THIS; constructing a second one gives peer selection a different cohort
22
+ * width and coordinator than the node's own consensus path uses for the same key.
23
+ */
24
+ keyNetwork: Libp2pKeyPeerNetwork;
25
+ coordinatedRepo: IRepo;
26
+ storageRepo: StorageRepo;
27
+ /** Per-collection change origin. Replaced by the cohort-topic bridge notifier when enabled. */
28
+ blockChangeNotifier: IBlockChangeNotifier;
29
+ reputation: PeerReputationService;
30
+ /** Present only when the dispute subsystem is configured. */
31
+ disputeService?: DisputeService;
32
+ /** The node's libp2p Ed25519 identity key, for hosts binding a client-transaction signer. */
33
+ peerPrivateKey: PrivateKey;
34
+ }
35
+
36
+ export type OptimysticNode = Libp2p & OptimysticNodeAttachments;
@@ -1,40 +1,53 @@
1
- import type { IRawStorage } from './storage/i-raw-storage.js';
2
-
3
- /**
4
- * Seed the resilience monitors' shared owned-block set from blocks already
5
- * durable in raw storage from a previous process run.
6
- *
7
- * The live owned-block feed (`storageRepo.onAnyCollectionChange`, wired in
8
- * `createLibp2pNodeBase`) only fires on NEW commits and RECEIVED replicas, so a
9
- * block that was already durable on disk from a previous run is not tracked
10
- * after a restart until it happens to be committed or replicated again — a
11
- * freshly restarted node would under-protect exactly the data it already holds.
12
- * This scan closes that gap by enumerating the metadata store (one id per block
13
- * with a committed revision or persisted replica the same population the live
14
- * feed tracks) and adding each id to `ownedBlocks`.
15
- *
16
- * Called AFTER the live feed is already subscribed, so a block committed/replicated
17
- * mid-scan is independently caught by the feed; `Set.add` is idempotent, so the
18
- * overlap is harmless. `isStopping()` is checked each iteration so a scan over a
19
- * huge store aborts promptly when the node is stopping (the `for await` then calls
20
- * the iterator's `return()` to release the backend cursor). A cooperative yield
21
- * every `yieldEvery` ids keeps a tight add-loop from monopolizing an event-loop tick.
22
- *
23
- * Backends that omit `listBlockIds` (or an in-memory backend with nothing durable
24
- * across a restart) yield no seed the monitors still populate over time via the
25
- * live feed.
26
- */
27
- export async function seedOwnedBlocksFromStorage(
28
- rawStorage: Pick<IRawStorage, 'listBlockIds'>,
29
- ownedBlocks: Set<string>,
30
- isStopping: () => boolean,
31
- yieldEvery = 1000,
32
- ): Promise<void> {
33
- if (typeof rawStorage.listBlockIds !== 'function') return;
34
- let n = 0;
35
- for await (const blockId of rawStorage.listBlockIds()) {
36
- if (isStopping()) break;
37
- ownedBlocks.add(blockId);
38
- if (++n % yieldEvery === 0) await new Promise((resolve) => setTimeout(resolve, 0));
39
- }
40
- }
1
+ import type { IRawStorage } from './storage/i-raw-storage.js';
2
+
3
+ /**
4
+ * Seed the resilience monitors' shared owned-block set from blocks already
5
+ * durable in raw storage from a previous process run.
6
+ *
7
+ * The live owned-block feed (`storageRepo.onAnyCollectionChange`, wired in
8
+ * `createLibp2pNodeBase`) only fires on NEW commits and RECEIVED replicas, so a
9
+ * block that was already durable on disk from a previous run is not tracked
10
+ * after a restart until it happens to be committed or replicated again — a
11
+ * freshly restarted node would under-protect exactly the data it already holds.
12
+ * This scan closes that gap by enumerating the metadata store one id per block
13
+ * with ANY durable metadata and adding each id to `ownedBlocks`.
14
+ *
15
+ * That population is a superset of what the live feed tracks, because metadata is
16
+ * also written on a plain pend — see the NOTE on the function body for why the
17
+ * resulting over-inclusion is accepted.
18
+ *
19
+ * Called AFTER the live feed is already subscribed, so a block committed/replicated
20
+ * mid-scan is independently caught by the feed; `Set.add` is idempotent, so the
21
+ * overlap is harmless. `isStopping()` is checked each iteration so a scan over a
22
+ * huge store aborts promptly when the node is stopping (the `for await` then calls
23
+ * the iterator's `return()` to release the backend cursor). A cooperative yield
24
+ * every `yieldEvery` ids keeps a tight add-loop from monopolizing an event-loop tick.
25
+ *
26
+ * Backends that omit `listBlockIds` (or an in-memory backend with nothing durable
27
+ * across a restart) yield no seed — the monitors still populate over time via the
28
+ * live feed.
29
+ */
30
+ export async function seedOwnedBlocksFromStorage(
31
+ rawStorage: Pick<IRawStorage, 'listBlockIds'>,
32
+ ownedBlocks: Set<string>,
33
+ isStopping: () => boolean,
34
+ yieldEvery = 1000,
35
+ ): Promise<void> {
36
+ // NOTE: accepted over-inclusion — this seeds pend-only blocks (metadata exists, no committed
37
+ // revision) alongside committed/replicated ones. Filtering to committed-only would mean reading and
38
+ // decoding metadata for every id at startup a per-block read on the fs backend, exactly the cost
39
+ // the streamed key enumeration exists to avoid. It is benign today because every consumer of the
40
+ // shared set re-checks local data before acting: `SpreadOnChurnMonitor.spreadCheck` untracks a
41
+ // tracked block whose `repo.get` returns nothing, and `BlockTransferCoordinator.confirmReplicated`
42
+ // reports a no-local-data block as unconfirmed, so it is never released and never becomes
43
+ // GC-eligible. REVISIT IF any consumer of the shared owned-block set ever takes a destructive or
44
+ // irreversible action keyed on membership alone, without a local-data check — then this scan must
45
+ // filter to committed blocks. Asserted as-is by `test/owned-block-seed-node-wiring.spec.ts`.
46
+ if (typeof rawStorage.listBlockIds !== 'function') return;
47
+ let n = 0;
48
+ for await (const blockId of rawStorage.listBlockIds()) {
49
+ if (isStopping()) break;
50
+ ownedBlocks.add(blockId);
51
+ if (++n % yieldEvery === 0) await new Promise((resolve) => setTimeout(resolve, 0));
52
+ }
53
+ }