@optimystic/db-p2p 0.24.1 → 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 (257) 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 +21 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +28 -17
  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 +78 -6
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +136 -57
  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 +150 -62
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/logger.d.ts +2 -2
  85. package/dist/src/logger.js +2 -2
  86. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  87. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  88. package/dist/src/network/open-protocol-stream.js +60 -0
  89. package/dist/src/network/open-protocol-stream.js.map +1 -0
  90. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  91. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  92. package/dist/src/network/register-protocol-handler.js +51 -0
  93. package/dist/src/network/register-protocol-handler.js.map +1 -0
  94. package/dist/src/peer-address-book.d.ts +128 -1
  95. package/dist/src/peer-address-book.d.ts.map +1 -1
  96. package/dist/src/peer-address-book.js +198 -15
  97. package/dist/src/peer-address-book.js.map +1 -1
  98. package/dist/src/pend-validation.d.ts +65 -0
  99. package/dist/src/pend-validation.d.ts.map +1 -0
  100. package/dist/src/pend-validation.js +69 -0
  101. package/dist/src/pend-validation.js.map +1 -0
  102. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  103. package/dist/src/reactivity/notify-transport.js +2 -1
  104. package/dist/src/reactivity/notify-transport.js.map +1 -1
  105. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  106. package/dist/src/reactivity/push-state-gossip.js +2 -1
  107. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  109. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  110. package/dist/src/repo/cluster-coordinator.js +44 -10
  111. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  112. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  113. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  114. package/dist/src/repo/coordinator-repo.js +650 -62
  115. package/dist/src/repo/coordinator-repo.js.map +1 -1
  116. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  117. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  118. package/dist/src/repo/served-repo-proxy.js +50 -0
  119. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  120. package/dist/src/repo/service.d.ts +22 -1
  121. package/dist/src/repo/service.d.ts.map +1 -1
  122. package/dist/src/repo/service.js +25 -13
  123. package/dist/src/repo/service.js.map +1 -1
  124. package/dist/src/reputation/types.d.ts +3 -1
  125. package/dist/src/reputation/types.d.ts.map +1 -1
  126. package/dist/src/reputation/types.js +3 -1
  127. package/dist/src/reputation/types.js.map +1 -1
  128. package/dist/src/rn.d.ts +24 -2
  129. package/dist/src/rn.d.ts.map +1 -1
  130. package/dist/src/rn.js +33 -4
  131. package/dist/src/rn.js.map +1 -1
  132. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  133. package/dist/src/routing/libp2p-known-peers.js +5 -0
  134. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  135. package/dist/src/storage/block-archive.d.ts +153 -0
  136. package/dist/src/storage/block-archive.d.ts.map +1 -0
  137. package/dist/src/storage/block-archive.js +194 -0
  138. package/dist/src/storage/block-archive.js.map +1 -0
  139. package/dist/src/storage/block-latch.d.ts +92 -0
  140. package/dist/src/storage/block-latch.d.ts.map +1 -0
  141. package/dist/src/storage/block-latch.js +132 -0
  142. package/dist/src/storage/block-latch.js.map +1 -0
  143. package/dist/src/storage/block-storage.d.ts +169 -12
  144. package/dist/src/storage/block-storage.d.ts.map +1 -1
  145. package/dist/src/storage/block-storage.js +415 -152
  146. package/dist/src/storage/block-storage.js.map +1 -1
  147. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  148. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  149. package/dist/src/storage/cached-raw-storage.js +16 -1
  150. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  151. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  152. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  153. package/dist/src/storage/cached-store-driver.js +39 -5
  154. package/dist/src/storage/cached-store-driver.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +70 -19
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js +21 -1
  158. package/dist/src/storage/i-block-storage.js.map +1 -1
  159. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  160. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  161. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  162. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  163. package/dist/src/storage/kv-raw-storage.js +29 -9
  164. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  165. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  166. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/memory-store-driver.js +22 -2
  168. package/dist/src/storage/memory-store-driver.js.map +1 -1
  169. package/dist/src/storage/raw-store-codec.js +2 -2
  170. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  171. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  172. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  173. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  174. package/dist/src/storage/shared-cache-pool.js +68 -4
  175. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  176. package/dist/src/storage/storage-repo.d.ts +204 -26
  177. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  178. package/dist/src/storage/storage-repo.js +422 -119
  179. package/dist/src/storage/storage-repo.js.map +1 -1
  180. package/dist/src/storage/store-identity.d.ts +21 -0
  181. package/dist/src/storage/store-identity.d.ts.map +1 -0
  182. package/dist/src/storage/store-identity.js +33 -0
  183. package/dist/src/storage/store-identity.js.map +1 -0
  184. package/dist/src/storage/struct.d.ts +15 -0
  185. package/dist/src/storage/struct.d.ts.map +1 -1
  186. package/dist/src/storage/with-read-cache.d.ts +92 -0
  187. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  188. package/dist/src/storage/with-read-cache.js +132 -0
  189. package/dist/src/storage/with-read-cache.js.map +1 -0
  190. package/dist/src/sync/service.d.ts +13 -3
  191. package/dist/src/sync/service.d.ts.map +1 -1
  192. package/dist/src/sync/service.js +7 -30
  193. package/dist/src/sync/service.js.map +1 -1
  194. package/dist/src/testing/mesh-harness.d.ts +75 -2
  195. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  196. package/dist/src/testing/mesh-harness.js +193 -81
  197. package/dist/src/testing/mesh-harness.js.map +1 -1
  198. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  199. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  200. package/dist/src/testing/raw-storage-conformance.js +97 -2
  201. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  202. package/package.json +5 -6
  203. package/src/cluster/block-transfer-service.ts +528 -312
  204. package/src/cluster/block-transfer.ts +547 -424
  205. package/src/cluster/certified-claims.ts +319 -0
  206. package/src/cluster/client-signature-verifier.ts +34 -0
  207. package/src/cluster/cluster-policy.ts +294 -203
  208. package/src/cluster/cluster-repo.ts +2501 -2034
  209. package/src/cluster/commit-proof.ts +344 -0
  210. package/src/cluster/quorum-restore.ts +348 -223
  211. package/src/cluster/rebalance-monitor.ts +539 -254
  212. package/src/cluster/reconcile-block.ts +404 -203
  213. package/src/cluster/service.ts +308 -293
  214. package/src/cluster/spread-on-churn.ts +352 -346
  215. package/src/cohort-topic/host.ts +8 -7
  216. package/src/cohort-topic/stream-util.ts +14 -43
  217. package/src/dispute/cascade.ts +31 -15
  218. package/src/dispute/dispute-service.ts +706 -696
  219. package/src/dispute/invalidation.ts +162 -52
  220. package/src/dispute/service.ts +126 -125
  221. package/src/index.ts +7 -0
  222. package/src/libp2p-key-network.ts +142 -58
  223. package/src/libp2p-node-base.ts +1793 -1675
  224. package/src/logger.ts +27 -27
  225. package/src/network/open-protocol-stream.ts +87 -0
  226. package/src/network/register-protocol-handler.ts +85 -0
  227. package/src/peer-address-book.ts +360 -149
  228. package/src/pend-validation.ts +89 -0
  229. package/src/reactivity/notify-transport.ts +145 -144
  230. package/src/reactivity/push-state-gossip.ts +292 -291
  231. package/src/repo/cluster-coordinator.ts +65 -11
  232. package/src/repo/coordinator-repo.ts +729 -71
  233. package/src/repo/served-repo-proxy.ts +55 -0
  234. package/src/repo/service.ts +332 -313
  235. package/src/reputation/types.ts +3 -1
  236. package/src/rn.ts +34 -4
  237. package/src/routing/libp2p-known-peers.ts +31 -26
  238. package/src/storage/block-archive.ts +242 -0
  239. package/src/storage/block-latch.ts +144 -0
  240. package/src/storage/block-storage.ts +787 -498
  241. package/src/storage/cached-raw-storage.ts +21 -1
  242. package/src/storage/cached-store-driver.ts +898 -859
  243. package/src/storage/i-block-storage.ts +172 -116
  244. package/src/storage/i-raw-storage.ts +40 -0
  245. package/src/storage/kv-raw-storage.ts +34 -9
  246. package/src/storage/memory-store-driver.ts +26 -2
  247. package/src/storage/raw-store-codec.ts +42 -42
  248. package/src/storage/raw-store-driver.ts +45 -3
  249. package/src/storage/shared-cache-pool.ts +516 -452
  250. package/src/storage/storage-repo.ts +1421 -1014
  251. package/src/storage/store-identity.ts +41 -0
  252. package/src/storage/struct.ts +52 -34
  253. package/src/storage/with-read-cache.ts +182 -0
  254. package/src/sync/service.ts +178 -198
  255. package/src/testing/cohort-topic-mesh-harness.ts +673 -673
  256. package/src/testing/mesh-harness.ts +524 -344
  257. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -0,0 +1,344 @@
1
+ import type { ActionId, BlockId, ClusterRecord, IBlock, RepoMessage, Signature, CommitRequest } from "@optimystic/db-core";
2
+ import {
3
+ canonicalBlockHash, clusterVoteVerificationPayload, computeClusterCommitHash,
4
+ computeClusterMessageHash, computeClusterPromiseHash, membershipDigestFromIds
5
+ } from "@optimystic/db-core";
6
+ import { peerIdFromString } from "@libp2p/peer-id";
7
+ import { publicKeyFromRaw } from "@libp2p/crypto/keys";
8
+ import { fromString as uint8ArrayFromString } from "uint8arrays/from-string";
9
+
10
+ /**
11
+ * A durable, self-contained proof that a cluster cohort agreed on a commit — everything an offline
12
+ * verifier needs to re-derive the consensus hashes and check every vote signature, with NO access to
13
+ * the live cluster. Unlike a `CommitCert` (whose `signedPayload` is an opaque hash preimage, retained
14
+ * only ~60s in memory for reactivity), this artifact carries the commit `RepoMessage` itself, so a
15
+ * receiver can check the claim "block B at revision R holds these bytes" against it.
16
+ *
17
+ * **No public keys are carried.** Every signer's Ed25519 key is recovered from its peer id — the id
18
+ * IS the multihash of the key (the mechanism `peerIdBindsPublicKey` relies on), so carrying keys
19
+ * would add bytes and a second thing to disagree with the id.
20
+ *
21
+ * **Measured size**: 4578 bytes serialized for a 10-peer cohort signing a two-block fully-signed
22
+ * commit (`JSON.stringify(proof).length`, pinned by the "size" test in `test/commit-proof.spec.ts`).
23
+ * It rides inside sync responses bounded by `MAX_CONTROL_MESSAGE_BYTES` (1 MiB), which already carry
24
+ * whole blocks, so proof size is a rounding error there.
25
+ */
26
+ export type BlockCommitProof = {
27
+ v: 1;
28
+ messageHash: string;
29
+ /** The commit RepoMessage exactly as hashed - carries the commit op incl. blockDigests. */
30
+ message: RepoMessage;
31
+ /** Promise-round votes, verbatim: the commitHash preimage includes canonicalJson(promises),
32
+ * and the approve promises are the votes that actually carry "I checked this". */
33
+ promises: Record<string, Signature>;
34
+ /** Commit-round votes. */
35
+ commits: Record<string, Signature>;
36
+ /** Always 2. A v1 / unversioned record binds no peer set and is never certifiable. */
37
+ membershipVersion: 2;
38
+ membershipDigest: string;
39
+ /** The record's full sorted peer-id list - the threshold denominator, bound by membershipDigest. */
40
+ peerIds: string[];
41
+ };
42
+
43
+ /** The claim a proof is checked against: this block, at this revision, under this committing action. */
44
+ export type ProofClaim = { blockId: BlockId; rev: number; actionId: ActionId };
45
+
46
+ export type ProofThresholds = {
47
+ /** Gates the promise-round approvals: `approves >= ceil(superMajorityThreshold * peerIds.length)`. */
48
+ superMajorityThreshold: number;
49
+ /**
50
+ * Gates the commit-round approvals: `approves > peerIds.length * simpleMajorityThreshold`. Pass
51
+ * **0.5** to mirror what members actually enforce — `ClusterMember.hasMajority` hardcodes
52
+ * `count > total / 2` (cluster-repo.ts), NOT the config's 0.51 default.
53
+ */
54
+ simpleMajorityThreshold: number;
55
+ };
56
+
57
+ export type ProofFailure =
58
+ | 'legacy-record' | 'membership-mismatch' | 'message-hash-mismatch'
59
+ | 'unknown-signer' | 'duplicate-signer' | 'non-ed25519-signer' | 'malformed-signature'
60
+ | 'promise-threshold' | 'commit-threshold'
61
+ | 'claim-not-in-message' | 'no-digest-declared' | 'digest-mismatch'
62
+ /** Structurally invalid input (wrong types, missing fields, throwing shapes). Not in the original
63
+ * spec enum — added deliberately so "pure and total on hostile input" has an honest catch-all
64
+ * instead of mislabeling garbage as one of the semantic failures. */
65
+ | 'malformed-proof';
66
+
67
+ export type ProofVerdict =
68
+ | { ok: true; declaredDigest?: string }
69
+ | { ok: false; reason: ProofFailure };
70
+
71
+ /**
72
+ * Project a consensus-committed {@link ClusterRecord} into a {@link BlockCommitProof}. A cheap
73
+ * projection — no hashing, no signature work; the record's maps are carried by reference (the
74
+ * storage layer JSON-snapshots on save). Returns `undefined` for a v1 / unversioned record: its
75
+ * hashes bind no peer set, so its signer list is unbound and it can never be certified (the caller
76
+ * logs the skip).
77
+ */
78
+ export function buildBlockCommitProof(record: ClusterRecord): BlockCommitProof | undefined {
79
+ if (record.membershipVersion !== 2 || typeof record.membershipDigest !== 'string') {
80
+ return undefined;
81
+ }
82
+ return {
83
+ v: 1,
84
+ messageHash: record.messageHash,
85
+ message: record.message,
86
+ promises: record.promises,
87
+ commits: record.commits,
88
+ membershipVersion: 2,
89
+ membershipDigest: record.membershipDigest,
90
+ peerIds: Object.keys(record.peers).sort()
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Verify that `proof` certifies the CLAIM — `claim.blockId` at `claim.rev` under `claim.actionId` —
96
+ * without needing the block bytes. Pure and total on hostile input: never throws; every failure is a
97
+ * distinguishable {@link ProofFailure}.
98
+ *
99
+ * Mirrors the outcome discipline of `ClusterMember.verifySignature`: a malformed or unbound signer is
100
+ * a verification failure that must NEVER be turned into a reputation penalty, because the identity
101
+ * behind it was not proven. Callers deciding attributability must key off the reason values, and
102
+ * treat `unknown-signer` / `non-ed25519-signer` / `malformed-signature` / `malformed-proof` as
103
+ * non-attributable.
104
+ *
105
+ * **Why both thresholds.** The commit-round vote is cast blind — a member signs the commit whenever
106
+ * the approve-promises reach super-majority, regardless of its own promise vote. The promise-round
107
+ * approvals are the votes that carry "I validated this message, including its content digest".
108
+ * Requiring only the commit approvals would count signatures that attest to nothing; requiring only
109
+ * the promises would accept a record the cohort never actually committed. Require both.
110
+ *
111
+ * The claim step (a `{ commit }` op whose `blockIds`/`actionId`/`rev` all match) is what stops
112
+ * replay: a genuine proof for rev 5 presented for rev 9, or for a different block id, dies there.
113
+ *
114
+ * ## What a passing verdict does NOT prove — two caller obligations
115
+ *
116
+ * 1. **The signers are not bound to the block.** A verdict says "the cohort in `proof.peerIds`
117
+ * agreed", never "that is the cohort responsible for `claim.blockId`". Any attacker who controls
118
+ * N keys can stand up their own N-peer cohort, sign a commit for any block id at any revision,
119
+ * and produce a proof that verifies here. Nothing offline can close this: a block's cohort is
120
+ * chosen by live placement and rotates over history, so `peerIds` cannot be checked against a
121
+ * fixed expected set. A caller accepting proofs from untrusted peers MUST corroborate the cohort
122
+ * separately (overlap with the block's currently-derived cohort, or a membership anchor — see
123
+ * `feat-cluster-membership-threshold-cert-anchoring`). Both repair paths do this via
124
+ * `cluster/certified-claims.ts`, whose layer 2 logs the overlap and surfaces the unanchored
125
+ * residual; the anchor itself is still open.
126
+ * 2. **Cost is attacker-chosen.** This performs one Ed25519 verify per approve vote and hashes the
127
+ * whole message; nothing here caps `peerIds`, the vote maps, or the message. A caller reading a
128
+ * proof off the wire must bound its size and cohort count BEFORE calling.
129
+ */
130
+ export async function verifyBlockCommitProofClaim(
131
+ proof: BlockCommitProof, claim: ProofClaim, thresholds: ProofThresholds
132
+ ): Promise<ProofVerdict> {
133
+ try {
134
+ if (proof === null || typeof proof !== 'object') {
135
+ return { ok: false, reason: 'malformed-proof' };
136
+ }
137
+ // A version this verifier does not implement is never half-verified. `membershipVersion !== 2`
138
+ // means the signer list is unbound (v1 / unversioned history) — never certifiable.
139
+ if (proof.v !== 1 || proof.membershipVersion !== 2) {
140
+ return { ok: false, reason: 'legacy-record' };
141
+ }
142
+ if (typeof proof.messageHash !== 'string'
143
+ || typeof proof.membershipDigest !== 'string'
144
+ || proof.message === null || typeof proof.message !== 'object'
145
+ || !Array.isArray(proof.message.operations)
146
+ || !isVoteMap(proof.promises) || !isVoteMap(proof.commits)
147
+ || !Array.isArray(proof.peerIds) || !proof.peerIds.every(id => typeof id === 'string')) {
148
+ return { ok: false, reason: 'malformed-proof' };
149
+ }
150
+
151
+ // A duplicated id in the denominator list would let one signer count twice against the
152
+ // threshold. (A JSON-parsed vote map cannot carry duplicate keys, so the array is the one
153
+ // reachable site for duplication.)
154
+ const peerSet = new Set(proof.peerIds);
155
+ if (peerSet.size !== proof.peerIds.length) {
156
+ return { ok: false, reason: 'duplicate-signer' };
157
+ }
158
+
159
+ // The peer list must be the one every signature covers: recompute the membership digest from
160
+ // it, then the message hash over (message + digest). Tampering with peerIds dies here;
161
+ // tampering with the message dies on the next check.
162
+ if (await membershipDigestFromIds(proof.peerIds) !== proof.membershipDigest) {
163
+ return { ok: false, reason: 'membership-mismatch' };
164
+ }
165
+ if (await computeClusterMessageHash(proof.message, proof.membershipDigest) !== proof.messageHash) {
166
+ return { ok: false, reason: 'message-hash-mismatch' };
167
+ }
168
+
169
+ const promiseHash = await computeClusterPromiseHash(proof.messageHash, proof.message, proof.membershipDigest);
170
+ const commitHash = await computeClusterCommitHash(proof.messageHash, proof.message, proof.promises, proof.membershipDigest);
171
+
172
+ const promiseRound = await countApprovals(proof.promises, promiseHash, peerSet);
173
+ if ('reason' in promiseRound) {
174
+ return { ok: false, reason: promiseRound.reason };
175
+ }
176
+ const commitRound = await countApprovals(proof.commits, commitHash, peerSet);
177
+ if ('reason' in commitRound) {
178
+ return { ok: false, reason: commitRound.reason };
179
+ }
180
+
181
+ // Threshold gates. When a threshold fails AND that round skipped a signer outside `peerIds`,
182
+ // report `unknown-signer` rather than the bare threshold reason — the skipped signature is the
183
+ // record of why the count came up short.
184
+ const superMajority = Math.ceil(thresholds.superMajorityThreshold * proof.peerIds.length);
185
+ if (promiseRound.approves < superMajority) {
186
+ return { ok: false, reason: promiseRound.sawUnknownSigner ? 'unknown-signer' : 'promise-threshold' };
187
+ }
188
+ if (!(commitRound.approves > proof.peerIds.length * thresholds.simpleMajorityThreshold)) {
189
+ return { ok: false, reason: commitRound.sawUnknownSigner ? 'unknown-signer' : 'commit-threshold' };
190
+ }
191
+
192
+ const op = findClaimedCommitOp(proof.message, claim);
193
+ if (!op) {
194
+ return { ok: false, reason: 'claim-not-in-message' };
195
+ }
196
+ return { ok: true, declaredDigest: op.blockDigests?.[claim.blockId]?.digest };
197
+ } catch {
198
+ return { ok: false, reason: 'malformed-proof' };
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Claim verification ({@link verifyBlockCommitProofClaim}) PLUS: the digest the commit op declared
204
+ * for `claim.blockId` must be present (`no-digest-declared`) and equal `canonicalBlockHash(block)`
205
+ * (`digest-mismatch`). Total like the claim half; a block shape that `canonicalBlockHash` cannot
206
+ * digest reads as `digest-mismatch` — the received bytes provably are not the declared content.
207
+ */
208
+ export async function verifyBlockCommitProofContent(
209
+ proof: BlockCommitProof, claim: ProofClaim, block: IBlock, thresholds: ProofThresholds
210
+ ): Promise<ProofVerdict> {
211
+ const verdict = await verifyBlockCommitProofClaim(proof, claim, thresholds);
212
+ if (!verdict.ok) {
213
+ return verdict;
214
+ }
215
+ if (typeof verdict.declaredDigest !== 'string') {
216
+ return { ok: false, reason: 'no-digest-declared' };
217
+ }
218
+ try {
219
+ if (await canonicalBlockHash(block) !== verdict.declaredDigest) {
220
+ return { ok: false, reason: 'digest-mismatch' };
221
+ }
222
+ } catch {
223
+ return { ok: false, reason: 'digest-mismatch' };
224
+ }
225
+ return verdict;
226
+ }
227
+
228
+ /**
229
+ * The digest `proof.message`'s commit operation declares for the claimed block, or `undefined`
230
+ * when the claim resolves no commit op or the op carries no digest for that block id. This is the
231
+ * SAME op resolution {@link verifyBlockCommitProofClaim}'s claim step uses, so the digest the
232
+ * storage layer's retention rule compares against (persist only when the local materialization
233
+ * matches) can never drift from the digest a later verifier extracts.
234
+ */
235
+ export function proofDeclaredDigest(proof: BlockCommitProof, claim: ProofClaim): string | undefined {
236
+ return findClaimedCommitOp(proof.message, claim)?.blockDigests?.[claim.blockId]?.digest;
237
+ }
238
+
239
+ /**
240
+ * Does `proof`'s message actually carry a commit operation for this exact
241
+ * `(blockId, rev, actionId)`? The claim step of {@link verifyBlockCommitProofClaim} with NO
242
+ * cryptography — the same {@link findClaimedCommitOp} resolution, so the cheap structural check and
243
+ * the full verification can never disagree about which op a claim names.
244
+ *
245
+ * This is the pairing guard a SERVER uses: a proof is looked up by revision, so attaching one whose
246
+ * message names a different revision (or block, or action) would be publishing a mis-paired
247
+ * artifact. It says nothing about signatures or thresholds — a receiver must still verify. Total on
248
+ * hostile input like its verifying sibling: never throws, `false` on any malformed shape.
249
+ */
250
+ export function proofClaimsCommit(proof: BlockCommitProof, claim: ProofClaim): boolean {
251
+ try {
252
+ return findClaimedCommitOp(proof.message, claim) !== undefined;
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
257
+
258
+ /** A plain-object (non-array) map of votes — the shape `promises` / `commits` must have. */
259
+ function isVoteMap(value: unknown): value is Record<string, Signature> {
260
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
261
+ }
262
+
263
+ /**
264
+ * Count distinct `approve` votes over `hash` from signers inside `peerSet`, verifying each signature
265
+ * against the Ed25519 key its peer id names. Per-entry rules (the spec's step 5/6):
266
+ *
267
+ * - a non-`approve` vote is ignored silently (rejects/conflicts are part of the signed record but
268
+ * never count toward approval);
269
+ * - a signer outside `peerSet` is skipped and noted (`sawUnknownSigner`) — the caller downgrades a
270
+ * failed threshold to `unknown-signer`;
271
+ * - a signer inside `peerSet` whose id is not a valid Ed25519 peer id → immediate
272
+ * `non-ed25519-signer` (its key cannot be recovered, so the proof as a whole is unverifiable);
273
+ * - a signature that is not a string, fails base64url decode, or fails cryptographic verify →
274
+ * immediate `malformed-signature`;
275
+ * - each signer id is counted at most once.
276
+ */
277
+ async function countApprovals(
278
+ votes: Record<string, Signature>,
279
+ hash: string,
280
+ peerSet: ReadonlySet<string>
281
+ ): Promise<{ approves: number; sawUnknownSigner: boolean } | { reason: ProofFailure }> {
282
+ let approves = 0;
283
+ let sawUnknownSigner = false;
284
+ const counted = new Set<string>();
285
+ for (const [signerId, vote] of Object.entries(votes)) {
286
+ if (vote === null || typeof vote !== 'object' || vote.type !== 'approve') {
287
+ continue;
288
+ }
289
+ if (!peerSet.has(signerId)) {
290
+ sawUnknownSigner = true;
291
+ continue;
292
+ }
293
+ if (counted.has(signerId)) {
294
+ continue;
295
+ }
296
+ let rawKey: Uint8Array;
297
+ try {
298
+ const peerId = peerIdFromString(signerId);
299
+ if (peerId.type !== 'Ed25519' || peerId.publicKey === undefined) {
300
+ return { reason: 'non-ed25519-signer' };
301
+ }
302
+ rawKey = peerId.publicKey.raw;
303
+ } catch {
304
+ // An id that does not even parse as a peer id certainly names no Ed25519 key.
305
+ return { reason: 'non-ed25519-signer' };
306
+ }
307
+ try {
308
+ if (typeof vote.signature !== 'string') {
309
+ return { reason: 'malformed-signature' };
310
+ }
311
+ const sigBytes = uint8ArrayFromString(vote.signature, 'base64url');
312
+ const payload = clusterVoteVerificationPayload(hash, vote);
313
+ if (!await publicKeyFromRaw(rawKey).verify(payload, sigBytes)) {
314
+ return { reason: 'malformed-signature' };
315
+ }
316
+ } catch {
317
+ return { reason: 'malformed-signature' };
318
+ }
319
+ counted.add(signerId);
320
+ approves++;
321
+ }
322
+ return { approves, sawUnknownSigner };
323
+ }
324
+
325
+ /**
326
+ * The `{ commit }` operation the claim points at: `blockIds` contains the claimed block and the
327
+ * op's `actionId` / `rev` equal the claim's. Absent → `claim-not-in-message` (the replay stop).
328
+ */
329
+ function findClaimedCommitOp(message: RepoMessage, claim: ProofClaim): CommitRequest | undefined {
330
+ for (const operation of message.operations) {
331
+ if (operation === null || typeof operation !== 'object' || !('commit' in operation)) {
332
+ continue;
333
+ }
334
+ const commit = operation.commit;
335
+ if (commit !== null && typeof commit === 'object'
336
+ && Array.isArray(commit.blockIds)
337
+ && commit.blockIds.includes(claim.blockId)
338
+ && commit.actionId === claim.actionId
339
+ && commit.rev === claim.rev) {
340
+ return commit;
341
+ }
342
+ }
343
+ return undefined;
344
+ }