@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,625 +1,625 @@
1
- import { peerIdFromString } from '@libp2p/peer-id';
2
- import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string';
3
- import type {
4
- ActionId, BlockId, IBlock, Log,
5
- DisputeResolutionProof, ArbitrationVoteProof, RevertedBlock,
6
- } from '@optimystic/db-core';
7
- import { applyTransform, hashString } from '@optimystic/db-core';
8
- import type { IBlockStorage } from '../storage/i-block-storage.js';
9
- import type { DisputeResolution, ArbitrationVote } from './types.js';
10
- import { createLogger } from '../logger.js';
11
-
12
- const log = createLogger('invalidation');
13
-
14
- /**
15
- * Accepted arbitration-vote wire-format version. The v3 signed payload binds each vote to BOTH the
16
- * specific reversed transaction (`targetHash`, #2) and the legitimately-selected arbitrator set
17
- * (`setHash`, #1); v1/v2 (un-set-bound) votes are rejected, never accepted-by-default.
18
- */
19
- export const VOTE_VERSION = 'v3' as const;
20
-
21
- /**
22
- * The transaction an invalidation certificate's votes are bound to — the committed action being
23
- * reversed and the blocks it wrote. Threaded into {@link verifyInvalidationCertificate} so a genuine
24
- * `challenger-wins` proof for transaction X cannot be replayed to revert an unrelated transaction Y:
25
- * the arbitrators signed over X's `targetHash`, so verifying against any other target makes every
26
- * signature fail → 0 decisive votes → reject.
27
- */
28
- export type CertificateTarget = {
29
- readonly invalidatedActionId: ActionId;
30
- readonly blockIds: ReadonlyArray<BlockId>;
31
- };
32
-
33
- /**
34
- * Binds a vote to its reversal target: `hashString(`${messageHash}|${invalidatedActionId}|${sortedBlockIds}`)`.
35
- * `blockIds` are lexically sorted so the binding is independent of the order a member happens to list
36
- * them, and {@link hashString} is the same db-core helper the compensating-state computation uses, so
37
- * every member recomputes an identical `targetHash`.
38
- */
39
- export async function computeTargetHash(messageHash: string, target: CertificateTarget): Promise<string> {
40
- const sortedBlockIds = [...target.blockIds].sort();
41
- return await hashString(`${messageHash}|${target.invalidatedActionId}|${sortedBlockIds.join(',')}`);
42
- }
43
-
44
- /**
45
- * Digest of the legitimately-selected arbitrator set (#1) — `hashString(sortedArbitratorSet.join(','))`.
46
- * Peer-ids are lexically sorted so the digest is independent of the order any party happens to list the
47
- * set in, and every member (arbitrators signing votes, the challenger signing the set, the verifier)
48
- * recomputes an identical `setHash`.
49
- */
50
- export async function computeArbitratorSetHash(arbitratorSet: ReadonlyArray<string>): Promise<string> {
51
- return await hashString([...arbitratorSet].sort().join(','));
52
- }
53
-
54
- /**
55
- * The exact bytes an arbitrator signs for a v3 vote — `utf8(`v3:${disputeId}:${vote}:${computedHash}:${targetHash}:${setHash}`)`.
56
- * Shared by the origination path ({@link makeVote in dispute-service}) and {@link verifyVoteSignature}
57
- * so the signed and verified preimages can never drift.
58
- */
59
- export function voteSigningPayload(disputeId: string, vote: string, computedHash: string, targetHash: string, setHash: string): Uint8Array {
60
- return new TextEncoder().encode(`${VOTE_VERSION}:${disputeId}:${vote}:${computedHash}:${targetHash}:${setHash}`);
61
- }
62
-
63
- /**
64
- * The exact bytes the **challenger** signs to bind `(disputeId, target, arbitratorSet)` —
65
- * `utf8(`v3set:${disputeId}:${targetHash}:${setHash}`)`. Verified against the challenger's embedded key
66
- * ({@link DisputeResolutionProof.challengerPeerId}); shared with the origination path so it can never drift.
67
- * The version marker is derived from {@link VOTE_VERSION} (currently `v3` → `v3set`) so a future format
68
- * bump moves the vote and set preimages together — a proof is wholly one wire version, never a mix.
69
- */
70
- export function arbitratorSetSigningPayload(disputeId: string, targetHash: string, setHash: string): Uint8Array {
71
- return new TextEncoder().encode(`${VOTE_VERSION}set:${disputeId}:${targetHash}:${setHash}`);
72
- }
73
-
74
- /**
75
- * Context handed to an {@link ArbitratorSetRecompute} capability: everything it needs to re-derive the
76
- * legitimate arbitrator set from the verifying member's own topology and judge the carried set against it.
77
- */
78
- export type ArbitratorSetRecomputeContext = {
79
- readonly disputeId: string;
80
- readonly messageHash: string;
81
- readonly invalidatedActionId: ActionId;
82
- readonly blockIds: ReadonlyArray<BlockId>;
83
- /** The carried set being judged (peer-id strings). */
84
- readonly arbitratorSet: ReadonlyArray<string>;
85
- /**
86
- * Escalation round the carried set was drawn at (0-based). A recompute capability MUST feed this to
87
- * `sampleArbitrators` so it re-derives the SAME dispersed draw. Today every dispute is round 0
88
- * (single-round arbitration), so the verify path pins it to 0; `design-dispute-synchronous-escalation`
89
- * threads the real round through the proof and updates this seam.
90
- */
91
- readonly round: number;
92
- /**
93
- * Agreed membership epoch the carried set was drawn against — the bytes `sampleArbitrators` folds into
94
- * every coordinate. A recompute capability MUST feed this so its re-derivation matches the original
95
- * draw. Optional until `design-dispute-synchronous-escalation` carries/derives it on the proof; while
96
- * absent a recompute cannot reconstruct the draw and should return `{ feasible: false }` (degradation),
97
- * which matches today's behavior — no recompute capability is wired in production.
98
- */
99
- readonly epoch?: Uint8Array;
100
- };
101
-
102
- /**
103
- * Verdict from an {@link ArbitratorSetRecompute}:
104
- * - `{ feasible: false }` — the member could not reconstruct the historical selection (late-joiner,
105
- * churned DHT view); the verifier falls through to the degradation posture (layer-1 accept + log).
106
- * - `{ feasible: true, legitimate }` — it recomputed the eligible set and judged whether the carried
107
- * `arbitratorSet` matches it (within whatever churn tolerance the capability applies).
108
- */
109
- export type ArbitratorSetVerdict =
110
- | { readonly feasible: false }
111
- | { readonly feasible: true; readonly legitimate: boolean };
112
-
113
- /**
114
- * Injected **layer-2** capability: re-derives the legitimately-selected arbitrator set from the verifying
115
- * member's topology view (it has `peerNetwork` / FRET routing) and judges the carried set. Supplied by
116
- * callers that hold a network (e.g. `ClusterMember.applyConsensusInvalidation`); the pure
117
- * {@link verifyInvalidationCertificate} stays usable without it (layer-1 + degradation). Closing the
118
- * fully-malicious-challenger vector requires this layer (or the layer-3 trust anchor).
119
- */
120
- export type ArbitratorSetRecompute = (ctx: ArbitratorSetRecomputeContext) => Promise<ArbitratorSetVerdict>;
121
-
122
- /** Reported when a certificate is accepted on layer-1 alone — see {@link VerifyCertificateOptions.onUnanchored}. */
123
- export type UnanchoredAcceptanceInfo = {
124
- readonly disputeId: string;
125
- readonly arbitratorSet: ReadonlyArray<string>;
126
- /** Why the stronger layers did not apply. */
127
- readonly reason: 'no-recompute-capability' | 'recompute-infeasible';
128
- };
129
-
130
- /** Optional capabilities a caller threads into {@link verifyInvalidationCertificate} when it has a network. */
131
- export type VerifyCertificateOptions = {
132
- /** Layer-2 recompute capability; omitted → pure layer-1 verification with the documented degradation. */
133
- readonly recomputeArbitratorSet?: ArbitratorSetRecompute;
134
- /**
135
- * Invoked when the certificate is accepted on the challenger-bound set + membership + dedup ALONE —
136
- * the interim posture when neither recompute (layer 2) nor a trust anchor (layer 3) resolved the set.
137
- * Lets a caller surface "applied an invalidation it could not fully anchor" alongside the internal log.
138
- */
139
- readonly onUnanchored?: (info: UnanchoredAcceptanceInfo) => void;
140
- };
141
-
142
- /**
143
- * Marks a `reverted` block whose as-if-`T_inv`-absent state is a *deletion* (T_inv created the
144
- * block, so there is no prior content to restore). {@link applyInvalidation} now physically removes
145
- * such blocks (a forward tombstone via {@link IBlockStorage.saveDeletion}); this sentinel is the
146
- * `restoredContentHash` it records for the deleted block, telling read-dependents "the observed
147
- * content no longer exists → invalidate" (a created block can never equal "absent"). Greppable so it
148
- * is never mistaken for a real content hash.
149
- */
150
- export const DELETED_BLOCK_RESTORE = 'deleted:block-creation-reverted';
151
-
152
- // ─── DisputeResolution → DisputeResolutionProof ───
153
-
154
- /**
155
- * The arbitrator-set binding a proof carries (#1): the legitimately-selected set, the challenger that
156
- * selected it, and the challenger's signature over `(disputeId, target, arbitratorSet)`. Assembled by
157
- * the originator ({@link DisputeService.maybeInvalidate}), which holds the selected set from
158
- * `initiateDispute` and signs it with the challenger's key.
159
- */
160
- export type ArbitratorSetBinding = {
161
- readonly arbitratorSet: ReadonlyArray<string>;
162
- readonly challengerPeerId: string;
163
- readonly arbitratorSetSignature: string;
164
- };
165
-
166
- /**
167
- * Projects a db-p2p {@link DisputeResolution} onto the db-core {@link DisputeResolutionProof} —
168
- * the independently-verifiable subset (outcome + signed votes + arbitrator-set binding) that an
169
- * {@link InvalidationEntry} carries. `messageHash` is the original transaction's hash (from the
170
- * challenge), the anchor the proof pins the reversal to; `binding` carries the legitimately-selected
171
- * arbitrator set and the challenger's signature over it (#1).
172
- */
173
- export function buildDisputeResolutionProof(resolution: DisputeResolution, messageHash: string, binding: ArbitratorSetBinding): DisputeResolutionProof {
174
- return {
175
- disputeId: resolution.disputeId,
176
- messageHash,
177
- outcome: resolution.outcome,
178
- challengerPeerId: binding.challengerPeerId,
179
- arbitratorSet: binding.arbitratorSet,
180
- arbitratorSetSignature: binding.arbitratorSetSignature,
181
- votes: resolution.votes.map(toVoteProof),
182
- };
183
- }
184
-
185
- function toVoteProof(vote: ArbitrationVote): ArbitrationVoteProof {
186
- return {
187
- version: vote.version,
188
- arbitratorPeerId: vote.arbitratorPeerId,
189
- vote: vote.vote,
190
- computedHash: vote.evidence.computedHash,
191
- signature: vote.signature,
192
- };
193
- }
194
-
195
- // ─── Invalidation certificate verification ───
196
-
197
- /**
198
- * Verifies that a {@link DisputeResolutionProof} is a valid invalidation certificate — the reversal
199
- * analogue of the commit certificate. A member accepts an invalidation **only** if this returns true.
200
- * Defense-in-depth, strongest-available-layer-wins:
201
- *
202
- * **Target binding (#2).** `target` is the transaction actually being reverted; the verifier recomputes
203
- * `targetHash` from `proof.messageHash` + `target` and checks each vote's signature over the v3 payload
204
- * `v3:${disputeId}:${vote}:${computedHash}:${targetHash}:${setHash}`. Because the arbitrators signed the
205
- * *real* transaction's `targetHash`, feeding any other target (a replay against an innocent transaction)
206
- * makes every signature fail. A vote whose version is absent/unrecognized (legacy/v1/v2) is rejected
207
- * before counting — never accepted-by-default.
208
- *
209
- * **Arbitrator-set binding (#1) — layer 1 (always).** The proof carries `arbitratorSet` (the K peer-ids
210
- * the challenger selected) and `arbitratorSetSignature` (the challenger's signature over
211
- * `(disputeId, target, arbitratorSet)`, verified against `challengerPeerId`'s embedded key). The verifier:
212
- * - rejects a proof missing the set / challenger fields;
213
- * - rejects a proof whose challenger signature does not validate over the recomputed `(targetHash, setHash)`
214
- * — so tampering with the set (adding sybils) breaks the certificate;
215
- * - counts only votes whose `arbitratorPeerId` ∈ `arbitratorSet` (a signature-valid vote from a peer
216
- * outside the set is dropped), and whose vote payload also commits to the same `setHash`.
217
- * This closes the third-party-relay / originator sybil vector and binds the originator. It does NOT by
218
- * itself stop a *fully-malicious* challenger (who legitimately holds its own key and signs a sybil set it
219
- * minted) — that needs layer 2 or 3.
220
- *
221
- * **Per-arbitrator dedup (#3).** Among the in-set votes, decisive verdicts are tallied at most once per
222
- * `arbitratorPeerId`: a repeated identical decisive vote counts once; an arbitrator with conflicting
223
- * decisive votes (equivocation) is dropped from both sides. `inconclusive` votes are non-decisive.
224
- *
225
- * **Recompute — layer 2 (best effort).** When `options.recomputeArbitratorSet` is supplied (the caller
226
- * holds a topology view), the carried set is re-derived and judged. A `{feasible:true, legitimate:false}`
227
- * verdict rejects the certificate even though layer 1 passed — closing the malicious-challenger vector.
228
- * `{feasible:false}` (member can't reconstruct the historical topology) falls through to degradation.
229
- *
230
- * **Degradation (layer 3 / none-available).** With no recompute capability (or an infeasible recompute)
231
- * and no trust anchor yet, a layer-1-valid certificate is **accepted and logged** as not-fully-anchored
232
- * (and `options.onUnanchored` is invoked) — never a silent accept-by-default, and never a false reject
233
- * that would break liveness for late-joiners. This is the interim posture until the cohort-topic
234
- * membership-cert trust-anchor chain lands (`tickets/plan/cohort-topic-membership-cert-trust-anchoring.md`),
235
- * at which point it is upgraded to a hard gate.
236
- */
237
- export async function verifyInvalidationCertificate(proof: DisputeResolutionProof, target: CertificateTarget, options: VerifyCertificateOptions = {}): Promise<boolean> {
238
- if (proof.outcome !== 'challenger-wins') {
239
- return false;
240
- }
241
-
242
- // Structural: a v3 certificate must carry the arbitrator-set binding. Absent → reject (never accept a
243
- // pre-set-binding proof by default).
244
- if (!proof.arbitratorSet?.length || !proof.challengerPeerId || !proof.arbitratorSetSignature) {
245
- return false;
246
- }
247
-
248
- const targetHash = await computeTargetHash(proof.messageHash, target);
249
- const setHash = await computeArbitratorSetHash(proof.arbitratorSet);
250
-
251
- // Layer 1a: challenger binds (disputeId, target, arbitratorSet). A tampered set breaks this signature.
252
- if (!(await verifyChallengerSetSignature(proof, targetHash, setHash))) {
253
- log('verify-reject-challenger-set-signature disputeId=%s challenger=%s', proof.disputeId, proof.challengerPeerId);
254
- return false;
255
- }
256
-
257
- // Layer 1b: count only signature-valid, target+set-bound, in-set votes — deduped/equivocation-dropped.
258
- const arbitratorSet = new Set(proof.arbitratorSet);
259
- type Decisive = 'agree-with-challenger' | 'agree-with-majority';
260
- const decisiveByArbitrator = new Map<string, Decisive | 'equivocated'>();
261
- for (const vote of proof.votes) {
262
- if (!arbitratorSet.has(vote.arbitratorPeerId)) {
263
- continue; // not a legitimately-selected arbitrator → never counted (#1)
264
- }
265
- if (!(await verifyVoteSignature(proof.disputeId, vote, targetHash, setHash))) {
266
- continue;
267
- }
268
- if (vote.vote !== 'agree-with-challenger' && vote.vote !== 'agree-with-majority') {
269
- continue; // 'inconclusive' — valid but not decisive
270
- }
271
- const prior = decisiveByArbitrator.get(vote.arbitratorPeerId);
272
- if (prior === undefined) {
273
- decisiveByArbitrator.set(vote.arbitratorPeerId, vote.vote);
274
- } else if (prior !== 'equivocated' && prior !== vote.vote) {
275
- // Same arbitrator, conflicting decisive votes → drop entirely (do not let one peer be on both sides).
276
- decisiveByArbitrator.set(vote.arbitratorPeerId, 'equivocated');
277
- }
278
- // prior === vote.vote (duplicate) or already 'equivocated': counted at most once, no change.
279
- }
280
-
281
- let challengerVotes = 0;
282
- let majorityVotes = 0;
283
- for (const decision of decisiveByArbitrator.values()) {
284
- if (decision === 'agree-with-challenger') {
285
- challengerVotes++;
286
- } else if (decision === 'agree-with-majority') {
287
- majorityVotes++;
288
- }
289
- }
290
-
291
- const totalDecisive = challengerVotes + majorityVotes;
292
- if (totalDecisive === 0) {
293
- return false;
294
- }
295
- const superMajorityThreshold = Math.ceil(totalDecisive * 2 / 3);
296
- if (challengerVotes < superMajorityThreshold) {
297
- return false;
298
- }
299
-
300
- // Layer 2: re-derive the eligible set from the member's own topology when it can — closes a
301
- // fully-malicious challenger that self-signed a sybil set that passed layer 1.
302
- if (options.recomputeArbitratorSet) {
303
- const verdict = await options.recomputeArbitratorSet({
304
- disputeId: proof.disputeId,
305
- messageHash: proof.messageHash,
306
- invalidatedActionId: target.invalidatedActionId,
307
- blockIds: target.blockIds,
308
- arbitratorSet: proof.arbitratorSet,
309
- // NOTE: `round` pinned to 0 (single-round arbitration today) and `epoch` omitted until
310
- // `design-dispute-synchronous-escalation` threads the real (round, epoch) through the proof, so a
311
- // `sampleArbitrators`-based recompute re-derives the identical dispersed set. Until then no recompute
312
- // capability is wired in production; the test recomputes ignore these fields.
313
- round: 0,
314
- });
315
- if (verdict.feasible) {
316
- if (!verdict.legitimate) {
317
- log('verify-reject-recompute-mismatch disputeId=%s', proof.disputeId);
318
- return false;
319
- }
320
- return true; // fully anchored to the recomputed topology
321
- }
322
- // infeasible → fall through to the documented degradation posture
323
- return acceptUnanchored(proof, 'recompute-infeasible', options);
324
- }
325
-
326
- // Layer 3 / none-available: accept on layer 1 alone, but never silently — log + surface the residual.
327
- return acceptUnanchored(proof, 'no-recompute-capability', options);
328
- }
329
-
330
- /**
331
- * Accept-and-log a layer-1-valid certificate that could not be fully anchored (no recompute capability
332
- * or an infeasible recompute, and no trust anchor yet). Documented interim posture — never a silent
333
- * accept. Returns true.
334
- */
335
- function acceptUnanchored(proof: DisputeResolutionProof, reason: UnanchoredAcceptanceInfo['reason'], options: VerifyCertificateOptions): boolean {
336
- log('verify-accept-unanchored disputeId=%s reason=%s setSize=%d', proof.disputeId, reason, proof.arbitratorSet.length);
337
- try {
338
- options.onUnanchored?.({ disputeId: proof.disputeId, arbitratorSet: proof.arbitratorSet, reason });
339
- } catch (err) {
340
- log('verify-onUnanchored-error disputeId=%s error=%o', proof.disputeId, err);
341
- }
342
- return true;
343
- }
344
-
345
- /**
346
- * Verify the challenger's signature binding `(disputeId, target, arbitratorSet)` against the embedded
347
- * Ed25519 key in `proof.challengerPeerId`. Closes the third-party-relay sybil vector: a relay cannot
348
- * swap the carried set without the challenger's key.
349
- */
350
- async function verifyChallengerSetSignature(proof: DisputeResolutionProof, targetHash: string, setHash: string): Promise<boolean> {
351
- try {
352
- const publicKey = peerIdFromString(proof.challengerPeerId).publicKey;
353
- if (!publicKey) {
354
- return false;
355
- }
356
- const payload = arbitratorSetSigningPayload(proof.disputeId, targetHash, setHash);
357
- const sigBytes = uint8ArrayFromString(proof.arbitratorSetSignature, 'base64url');
358
- return await publicKey.verify(payload, sigBytes);
359
- } catch (err) {
360
- log('challenger-set-signature-verify-error challenger=%s error=%o', proof.challengerPeerId, err);
361
- return false;
362
- }
363
- }
364
-
365
- /**
366
- * Verify one arbitration vote's Ed25519 signature against its arbitrator peer id's embedded key, over
367
- * the **target- and set-bound v3 payload**. Rejects any vote that is not the v3 format before trusting
368
- * it, so a legacy/unversioned (v1/v2) vote can never slip through.
369
- */
370
- async function verifyVoteSignature(disputeId: string, vote: ArbitrationVoteProof, targetHash: string, setHash: string): Promise<boolean> {
371
- // Runtime gate: `vote` arrives off the wire, so its `version` may not match the declared type.
372
- if (vote.version !== VOTE_VERSION) {
373
- return false;
374
- }
375
- try {
376
- const publicKey = peerIdFromString(vote.arbitratorPeerId).publicKey;
377
- if (!publicKey) {
378
- return false;
379
- }
380
- const payload = voteSigningPayload(disputeId, vote.vote, vote.computedHash, targetHash, setHash);
381
- const sigBytes = uint8ArrayFromString(vote.signature, 'base64url');
382
- return await publicKey.verify(payload, sigBytes);
383
- } catch (err) {
384
- log('vote-signature-verify-error arbitrator=%s error=%o', vote.arbitratorPeerId, err);
385
- return false;
386
- }
387
- }
388
-
389
- // ─── Compensating-state computation ───
390
-
391
- /**
392
- * The recomputed "as-if-`T_inv`-never-committed" state for a single block.
393
- * - `restore`: the block existed before `T_inv`; `block` is its recomputed content (the revision
394
- * immediately before `T_inv`, with any surviving later actions replayed on top).
395
- * - `delete`: `T_inv` created the block, so the as-if-absent state is a deletion (physically removed
396
- * by {@link applyInvalidation} via a tombstone — see {@link DELETED_BLOCK_RESTORE}).
397
- */
398
- export type RevertedComputation =
399
- | { kind: 'restore'; block: IBlock; restoredContentHash: string; fromRev: number; laterActions: number }
400
- | { kind: 'delete'; fromRev: number };
401
-
402
- /**
403
- * Reconstructs the compensating content for one block from stored revisions only (never by re-running
404
- * the engine — so it does not depend on engine availability and stays deterministic across members).
405
- *
406
- * Base = the block's content at the highest stored revision strictly before `T_inv` (no such revision
407
- * ⇒ `T_inv` created the block ⇒ a deletion). In the single-collection/no-cascade core, "surviving later
408
- * actions" = every committed action after `T_inv` on this block, replayed verbatim on the rolled-back
409
- * base. The cascade (see `cascade.ts`) layers true read-dependent re-evaluation on top of this blind
410
- * replay — it reverts the genuine successors this primitive leaves as-is and logs.
411
- */
412
- export async function computeRevertedBlock(blockStorage: IBlockStorage, invalidatedRev: number): Promise<RevertedComputation> {
413
- const latest = await blockStorage.getLatest();
414
- const fromRev = latest?.rev ?? invalidatedRev;
415
-
416
- // Find the highest stored revision strictly before T_inv — the base to roll back to. A descending
417
- // listRevisions(invalidatedRev - 1, 1) yields it first (same descending-scan pattern materializeBlock
418
- // uses, so no new cost class). No such revision ⇒ T_inv CREATED this block ⇒ the as-if-absent state is
419
- // a deletion — at ANY rev, without throwing (the previous `invalidatedRev <= 1` special-case folds into
420
- // this: at rev <= 1 the probe is skipped and priorRev stays undefined). We intentionally do NOT replay
421
- // surviving later actions onto an absent base: replaying an update on `undefined` stays `undefined`, and
422
- // any later writer of a created-then-reverted block is itself a read-dependent the cascade (`cascade.ts`)
423
- // re-evaluates and reverts.
424
- let priorRev: number | undefined;
425
- if (invalidatedRev > 1) {
426
- for await (const ar of blockStorage.listRevisions(invalidatedRev - 1, 1)) {
427
- priorRev = ar.rev;
428
- break;
429
- }
430
- }
431
- if (priorRev === undefined) {
432
- return { kind: 'delete', fromRev };
433
- }
434
- const base = await blockStorage.getBlock(priorRev);
435
- if (!base) {
436
- return { kind: 'delete', fromRev };
437
- }
438
-
439
- // Replay surviving later actions (committed strictly after T_inv) onto the rolled-back base.
440
- // Guard on `fromRev > invalidatedRev`: listRevisions treats start > end as a *descending*
441
- // range, so an unguarded listRevisions(invalidatedRev + 1, fromRev) when no later action
442
- // exists would wrongly re-include T_inv's own revision.
443
- let block: IBlock | undefined = base.block;
444
- let laterActions = 0;
445
- if (fromRev > invalidatedRev) {
446
- for await (const actionRev of blockStorage.listRevisions(invalidatedRev + 1, fromRev)) {
447
- const transform = await blockStorage.getTransaction(actionRev.actionId);
448
- if (!transform) {
449
- continue;
450
- }
451
- block = applyTransform(block, transform);
452
- laterActions++;
453
- }
454
- }
455
- if (!block) {
456
- return { kind: 'delete', fromRev };
457
- }
458
- const restoredContentHash = await hashBlockContent(block);
459
- return { kind: 'restore', block, restoredContentHash, fromRev, laterActions };
460
- }
461
-
462
- /**
463
- * Deterministic content hash for a materialized block — the single hashing convention shared by
464
- * the compensating-state computation ({@link computeRevertedBlock}) and the cascade re-evaluator
465
- * (`db-p2p/src/dispute/cascade.ts`). Both must agree byte-for-byte: the cascade decides whether a
466
- * read-dependent still holds by comparing the hash of the content it *observed* against the
467
- * `restoredContentHash` an invalidation recorded — so the two hashes have to be produced the same way.
468
- */
469
- export async function hashBlockContent(block: IBlock): Promise<string> {
470
- return await hashString(stableStringify(block));
471
- }
472
-
473
- // ─── Deterministic apply ───
474
-
475
- /** Everything the apply primitive needs from the host: the collection log and per-block storage. */
476
- export type InvalidationContext = {
477
- /** The collection log of the collection `T_inv` wrote (where the compensating entry is appended). */
478
- readonly log: Log<unknown>;
479
- /** Resolves a block's storage so the compensating revision can be written. */
480
- readonly createBlockStorage: (blockId: BlockId) => IBlockStorage;
481
- /**
482
- * Runs `fn` while holding the same per-block commit latch `StorageRepo.commit` and
483
- * `saveReplicatedBlock` hold, so the compensating `saveReplica`/`saveDeletion` read-modify-write of
484
- * `meta.latest` is mutually exclusive with a concurrent local commit on that block. Without it, an
485
- * invalidation advancing `latest` outside that latch is invisible to commit's staleness guard, so a
486
- * commit that read a stale `latest` can clobber it back down — a lost-update / non-monotonic
487
- * regression. Optional: when omitted (unit tests / non-`StorageRepo` hosts) the write runs unlatched,
488
- * preserving today's behavior. The host supplies `StorageRepo`'s `withBlockCommitLatch`.
489
- */
490
- readonly withBlockCommitLatch?: <T>(blockId: BlockId, fn: () => Promise<T>) => Promise<T>;
491
- };
492
-
493
- export type ApplyInvalidationParams = {
494
- readonly invalidatedActionId: ActionId;
495
- readonly invalidatedRev: number;
496
- /** Blocks `T_inv` wrote (its commit's blockIds). */
497
- readonly blockIds: ReadonlyArray<BlockId>;
498
- /** The invalidation certificate. */
499
- readonly proof: DisputeResolutionProof;
500
- /**
501
- * Consensus-assigned revision slot for the compensating revision (collection-global). When
502
- * omitted, computed as one past the highest current tip across the reverted blocks — the value a
503
- * local/single-node apply uses; the consensus path passes the agreed slot.
504
- */
505
- readonly rev?: number;
506
- /**
507
- * When this invalidation is a cascade step (a read-dependent of an already-invalidated root being
508
- * reverted), the `actionId` of the root invalidation that triggered the cascade. Recorded on the
509
- * resulting {@link InvalidationEntry} (`cascadeRoot`) so the reversal is auditable as part of one
510
- * logical cascade event. Absent for a root invalidation.
511
- */
512
- readonly cascadeRoot?: ActionId;
513
- /**
514
- * The target the proof's votes are bound to — the transaction the dispute actually resolved. For a
515
- * **root** invalidation this equals this call's own `(invalidatedActionId, blockIds)` and may be
516
- * omitted (defaulted below). A **cascade child** reuses the *root's* proof to authorize reverting a
517
- * read-dependent whose own target differs, so it MUST pass the root's target here: the votes were
518
- * signed over the root's `targetHash`, not the child's. The child-specific justification is the
519
- * deterministic cascade derivation every member replays — not the certificate, which only attests
520
- * the root is invalid. (The network-facing apply path `applyConsensusInvalidation` never sets this:
521
- * it verifies against the request's *own* target, which is the replay boundary this ticket closes.)
522
- */
523
- readonly certificateTarget?: CertificateTarget;
524
- readonly timestamp?: number;
525
- };
526
-
527
- export type ApplyInvalidationResult = {
528
- readonly applied: boolean;
529
- readonly reason?: 'already-applied' | 'invalid-certificate';
530
- readonly rev?: number;
531
- readonly reverted: ReadonlyArray<RevertedBlock>;
532
- };
533
-
534
- /**
535
- * Deterministically applies a single-collection invalidation: the durable reversal primitive every
536
- * cluster member runs identically (it carries the `reverted` targets and proof, exactly as the
537
- * consensus-apply path runs committed operations on every peer).
538
- *
539
- * Steps, in order:
540
- * 1. **Dedup** — if the log already holds an invalidation for `(invalidatedActionId, disputeId)`,
541
- * this is a re-receipt (rebroadcast / sync / retry): no-op, append nothing.
542
- * 2. **Certificate** — reject (append nothing) unless `proof` is a valid challenger-wins certificate.
543
- * 3. **Reverted revisions** — for each block, recompute the as-if-`T_inv`-absent content and write a
544
- * new monotonic revision (a forward compensating transform; prior revisions are retained).
545
- * 4. **Log entry** — append the {@link InvalidationEntry} carrying the proof and `reverted` targets,
546
- * making `committed-invalidated` durable and recoverable on sync.
547
- */
548
- export async function applyInvalidation(ctx: InvalidationContext, params: ApplyInvalidationParams): Promise<ApplyInvalidationResult> {
549
- const { invalidatedActionId, invalidatedRev, blockIds, proof } = params;
550
-
551
- // 1. Idempotent re-receipt — keyed on (invalidatedActionId, disputeId).
552
- const existing = await ctx.log.findInvalidation(invalidatedActionId);
553
- if (existing && existing.resolution.disputeId === proof.disputeId) {
554
- log('apply-skip-duplicate actionId=%s disputeId=%s', invalidatedActionId, proof.disputeId);
555
- return { applied: false, reason: 'already-applied', reverted: [...existing.reverted] };
556
- }
557
-
558
- // 2. Certificate verification — never trust a single peer's say-so. The proof's votes are bound to
559
- // a specific target; verify against the target they were signed over. For a root invalidation
560
- // that is this call's own target; a cascade child passes the root's target via `certificateTarget`
561
- // (the child-target justification is the deterministic cascade, not the certificate). A mismatched
562
- // target (a genuine proof replayed against an innocent transaction) fails every signature here, so
563
- // no compensating revision or log entry is ever written for it.
564
- const certificateTarget = params.certificateTarget ?? { invalidatedActionId, blockIds };
565
- if (!(await verifyInvalidationCertificate(proof, certificateTarget))) {
566
- log('apply-reject-certificate actionId=%s disputeId=%s outcome=%s', invalidatedActionId, proof.disputeId, proof.outcome);
567
- return { applied: false, reason: 'invalid-certificate', reverted: [] };
568
- }
569
-
570
- // 3. Compute compensating content + the collection-global revision slot.
571
- const computations = await Promise.all(
572
- blockIds.map(async (blockId) => {
573
- const storage = ctx.createBlockStorage(blockId);
574
- return { blockId, storage, computation: await computeRevertedBlock(storage, invalidatedRev) };
575
- })
576
- );
577
- const maxFromRev = computations.reduce((max, c) => Math.max(max, c.computation.fromRev), invalidatedRev);
578
- const rev = params.rev ?? maxFromRev + 1;
579
-
580
- const reverted: RevertedBlock[] = [];
581
- for (const { blockId, storage, computation } of computations) {
582
- // Deterministic compensating-revision actionId — identical on every member, so all converge on
583
- // the same (rev, actionId) for both the restore and the tombstone path.
584
- const revertActionId = await hashString(`inv:${invalidatedActionId}:${proof.disputeId}:${blockId}:${rev}`);
585
- // Hold the per-block commit latch around ONLY the compensating write (matching saveReplicatedBlock's
586
- // scope): the monotonic guard inside saveReplica/saveDeletion then runs under the same latch a
587
- // concurrent commit holds, so the two RMW of meta.latest serialize and latest stays monotonic.
588
- // Acquire per block, one at a time — invalidation never holds two block latches, so it cannot
589
- // deadlock against commit's sorted multi-latch acquisition. When no runner is injected, the write
590
- // runs unlatched (today's behavior).
591
- const runLatched = <T>(fn: () => Promise<T>): Promise<T> =>
592
- ctx.withBlockCommitLatch ? ctx.withBlockCommitLatch(blockId, fn) : fn();
593
- if (computation.kind === 'delete') {
594
- // Block-creation reversal: physically remove the created block by writing a forward tombstone
595
- // revision. The `restoredContentHash` is the DELETED_BLOCK_RESTORE sentinel — a deleted block
596
- // has no content hash, and the sentinel tells dependents "observed content is gone → invalidate".
597
- await runLatched(() => storage.saveDeletion({ rev, actionId: revertActionId }));
598
- log('apply-delete-restore blockId=%s invalidatedRev=%d rev=%d', blockId, invalidatedRev, rev);
599
- reverted.push({ blockId, fromRev: computation.fromRev, restoredContentHash: DELETED_BLOCK_RESTORE });
600
- continue;
601
- }
602
- if (computation.laterActions > 0) {
603
- // Surviving later actions were replayed verbatim; true read-dependents are out of scope here.
604
- log('apply-replayed-later-actions blockId=%s count=%d', blockId, computation.laterActions);
605
- }
606
- await runLatched(() => storage.saveReplica(computation.block, { rev, actionId: revertActionId }));
607
- reverted.push({ blockId, fromRev: computation.fromRev, restoredContentHash: computation.restoredContentHash });
608
- }
609
-
610
- // 4. Durable, append-only invalidation entry (the source of truth for committed-invalidated).
611
- // `cascadeRoot` is set when this is a cascade step (a reverted read-dependent), undefined for a root.
612
- await ctx.log.addInvalidation(invalidatedActionId, invalidatedRev, proof, reverted, rev, params.cascadeRoot, params.timestamp);
613
- log('apply-complete actionId=%s disputeId=%s rev=%d blocks=%d', invalidatedActionId, proof.disputeId, rev, reverted.length);
614
-
615
- return { applied: true, rev, reverted };
616
- }
617
-
618
- /** Deterministic, key-sorted JSON for content hashing — stable across members regardless of key order. */
619
- function stableStringify(value: unknown): string {
620
- return JSON.stringify(value, (_key, v) =>
621
- v && typeof v === 'object' && !Array.isArray(v)
622
- ? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = (v as Record<string, unknown>)[k]; return o; }, {})
623
- : v
624
- );
625
- }
1
+ import { peerIdFromString } from '@libp2p/peer-id';
2
+ import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string';
3
+ import type {
4
+ ActionId, BlockId, IBlock, Log,
5
+ DisputeResolutionProof, ArbitrationVoteProof, RevertedBlock,
6
+ } from '@optimystic/db-core';
7
+ import { applyTransform, hashString } from '@optimystic/db-core';
8
+ import type { IBlockStorage } from '../storage/i-block-storage.js';
9
+ import type { DisputeResolution, ArbitrationVote } from './types.js';
10
+ import { createLogger } from '../logger.js';
11
+
12
+ const log = createLogger('invalidation');
13
+
14
+ /**
15
+ * Accepted arbitration-vote wire-format version. The v3 signed payload binds each vote to BOTH the
16
+ * specific reversed transaction (`targetHash`, #2) and the legitimately-selected arbitrator set
17
+ * (`setHash`, #1); v1/v2 (un-set-bound) votes are rejected, never accepted-by-default.
18
+ */
19
+ export const VOTE_VERSION = 'v3' as const;
20
+
21
+ /**
22
+ * The transaction an invalidation certificate's votes are bound to — the committed action being
23
+ * reversed and the blocks it wrote. Threaded into {@link verifyInvalidationCertificate} so a genuine
24
+ * `challenger-wins` proof for transaction X cannot be replayed to revert an unrelated transaction Y:
25
+ * the arbitrators signed over X's `targetHash`, so verifying against any other target makes every
26
+ * signature fail → 0 decisive votes → reject.
27
+ */
28
+ export type CertificateTarget = {
29
+ readonly invalidatedActionId: ActionId;
30
+ readonly blockIds: ReadonlyArray<BlockId>;
31
+ };
32
+
33
+ /**
34
+ * Binds a vote to its reversal target: `hashString(`${messageHash}|${invalidatedActionId}|${sortedBlockIds}`)`.
35
+ * `blockIds` are lexically sorted so the binding is independent of the order a member happens to list
36
+ * them, and {@link hashString} is the same db-core helper the compensating-state computation uses, so
37
+ * every member recomputes an identical `targetHash`.
38
+ */
39
+ export async function computeTargetHash(messageHash: string, target: CertificateTarget): Promise<string> {
40
+ const sortedBlockIds = [...target.blockIds].sort();
41
+ return await hashString(`${messageHash}|${target.invalidatedActionId}|${sortedBlockIds.join(',')}`);
42
+ }
43
+
44
+ /**
45
+ * Digest of the legitimately-selected arbitrator set (#1) — `hashString(sortedArbitratorSet.join(','))`.
46
+ * Peer-ids are lexically sorted so the digest is independent of the order any party happens to list the
47
+ * set in, and every member (arbitrators signing votes, the challenger signing the set, the verifier)
48
+ * recomputes an identical `setHash`.
49
+ */
50
+ export async function computeArbitratorSetHash(arbitratorSet: ReadonlyArray<string>): Promise<string> {
51
+ return await hashString([...arbitratorSet].sort().join(','));
52
+ }
53
+
54
+ /**
55
+ * The exact bytes an arbitrator signs for a v3 vote — `utf8(`v3:${disputeId}:${vote}:${computedHash}:${targetHash}:${setHash}`)`.
56
+ * Shared by the origination path ({@link makeVote in dispute-service}) and {@link verifyVoteSignature}
57
+ * so the signed and verified preimages can never drift.
58
+ */
59
+ export function voteSigningPayload(disputeId: string, vote: string, computedHash: string, targetHash: string, setHash: string): Uint8Array {
60
+ return new TextEncoder().encode(`${VOTE_VERSION}:${disputeId}:${vote}:${computedHash}:${targetHash}:${setHash}`);
61
+ }
62
+
63
+ /**
64
+ * The exact bytes the **challenger** signs to bind `(disputeId, target, arbitratorSet)` —
65
+ * `utf8(`v3set:${disputeId}:${targetHash}:${setHash}`)`. Verified against the challenger's embedded key
66
+ * ({@link DisputeResolutionProof.challengerPeerId}); shared with the origination path so it can never drift.
67
+ * The version marker is derived from {@link VOTE_VERSION} (currently `v3` → `v3set`) so a future format
68
+ * bump moves the vote and set preimages together — a proof is wholly one wire version, never a mix.
69
+ */
70
+ export function arbitratorSetSigningPayload(disputeId: string, targetHash: string, setHash: string): Uint8Array {
71
+ return new TextEncoder().encode(`${VOTE_VERSION}set:${disputeId}:${targetHash}:${setHash}`);
72
+ }
73
+
74
+ /**
75
+ * Context handed to an {@link ArbitratorSetRecompute} capability: everything it needs to re-derive the
76
+ * legitimate arbitrator set from the verifying member's own topology and judge the carried set against it.
77
+ */
78
+ export type ArbitratorSetRecomputeContext = {
79
+ readonly disputeId: string;
80
+ readonly messageHash: string;
81
+ readonly invalidatedActionId: ActionId;
82
+ readonly blockIds: ReadonlyArray<BlockId>;
83
+ /** The carried set being judged (peer-id strings). */
84
+ readonly arbitratorSet: ReadonlyArray<string>;
85
+ /**
86
+ * Escalation round the carried set was drawn at (0-based). A recompute capability MUST feed this to
87
+ * `sampleArbitrators` so it re-derives the SAME dispersed draw. Today every dispute is round 0
88
+ * (single-round arbitration), so the verify path pins it to 0; `design-dispute-synchronous-escalation`
89
+ * threads the real round through the proof and updates this seam.
90
+ */
91
+ readonly round: number;
92
+ /**
93
+ * Agreed membership epoch the carried set was drawn against — the bytes `sampleArbitrators` folds into
94
+ * every coordinate. A recompute capability MUST feed this so its re-derivation matches the original
95
+ * draw. Optional until `design-dispute-synchronous-escalation` carries/derives it on the proof; while
96
+ * absent a recompute cannot reconstruct the draw and should return `{ feasible: false }` (degradation),
97
+ * which matches today's behavior — no recompute capability is wired in production.
98
+ */
99
+ readonly epoch?: Uint8Array;
100
+ };
101
+
102
+ /**
103
+ * Verdict from an {@link ArbitratorSetRecompute}:
104
+ * - `{ feasible: false }` — the member could not reconstruct the historical selection (late-joiner,
105
+ * churned DHT view); the verifier falls through to the degradation posture (layer-1 accept + log).
106
+ * - `{ feasible: true, legitimate }` — it recomputed the eligible set and judged whether the carried
107
+ * `arbitratorSet` matches it (within whatever churn tolerance the capability applies).
108
+ */
109
+ export type ArbitratorSetVerdict =
110
+ | { readonly feasible: false }
111
+ | { readonly feasible: true; readonly legitimate: boolean };
112
+
113
+ /**
114
+ * Injected **layer-2** capability: re-derives the legitimately-selected arbitrator set from the verifying
115
+ * member's topology view (it has `peerNetwork` / FRET routing) and judges the carried set. Supplied by
116
+ * callers that hold a network (e.g. `ClusterMember.applyConsensusInvalidation`); the pure
117
+ * {@link verifyInvalidationCertificate} stays usable without it (layer-1 + degradation). Closing the
118
+ * fully-malicious-challenger vector requires this layer (or the layer-3 trust anchor).
119
+ */
120
+ export type ArbitratorSetRecompute = (ctx: ArbitratorSetRecomputeContext) => Promise<ArbitratorSetVerdict>;
121
+
122
+ /** Reported when a certificate is accepted on layer-1 alone — see {@link VerifyCertificateOptions.onUnanchored}. */
123
+ export type UnanchoredAcceptanceInfo = {
124
+ readonly disputeId: string;
125
+ readonly arbitratorSet: ReadonlyArray<string>;
126
+ /** Why the stronger layers did not apply. */
127
+ readonly reason: 'no-recompute-capability' | 'recompute-infeasible';
128
+ };
129
+
130
+ /** Optional capabilities a caller threads into {@link verifyInvalidationCertificate} when it has a network. */
131
+ export type VerifyCertificateOptions = {
132
+ /** Layer-2 recompute capability; omitted → pure layer-1 verification with the documented degradation. */
133
+ readonly recomputeArbitratorSet?: ArbitratorSetRecompute;
134
+ /**
135
+ * Invoked when the certificate is accepted on the challenger-bound set + membership + dedup ALONE —
136
+ * the interim posture when neither recompute (layer 2) nor a trust anchor (layer 3) resolved the set.
137
+ * Lets a caller surface "applied an invalidation it could not fully anchor" alongside the internal log.
138
+ */
139
+ readonly onUnanchored?: (info: UnanchoredAcceptanceInfo) => void;
140
+ };
141
+
142
+ /**
143
+ * Marks a `reverted` block whose as-if-`T_inv`-absent state is a *deletion* (T_inv created the
144
+ * block, so there is no prior content to restore). {@link applyInvalidation} now physically removes
145
+ * such blocks (a forward tombstone via {@link IBlockStorage.saveDeletion}); this sentinel is the
146
+ * `restoredContentHash` it records for the deleted block, telling read-dependents "the observed
147
+ * content no longer exists → invalidate" (a created block can never equal "absent"). Greppable so it
148
+ * is never mistaken for a real content hash.
149
+ */
150
+ export const DELETED_BLOCK_RESTORE = 'deleted:block-creation-reverted';
151
+
152
+ // ─── DisputeResolution → DisputeResolutionProof ───
153
+
154
+ /**
155
+ * The arbitrator-set binding a proof carries (#1): the legitimately-selected set, the challenger that
156
+ * selected it, and the challenger's signature over `(disputeId, target, arbitratorSet)`. Assembled by
157
+ * the originator ({@link DisputeService.maybeInvalidate}), which holds the selected set from
158
+ * `initiateDispute` and signs it with the challenger's key.
159
+ */
160
+ export type ArbitratorSetBinding = {
161
+ readonly arbitratorSet: ReadonlyArray<string>;
162
+ readonly challengerPeerId: string;
163
+ readonly arbitratorSetSignature: string;
164
+ };
165
+
166
+ /**
167
+ * Projects a db-p2p {@link DisputeResolution} onto the db-core {@link DisputeResolutionProof} —
168
+ * the independently-verifiable subset (outcome + signed votes + arbitrator-set binding) that an
169
+ * {@link InvalidationEntry} carries. `messageHash` is the original transaction's hash (from the
170
+ * challenge), the anchor the proof pins the reversal to; `binding` carries the legitimately-selected
171
+ * arbitrator set and the challenger's signature over it (#1).
172
+ */
173
+ export function buildDisputeResolutionProof(resolution: DisputeResolution, messageHash: string, binding: ArbitratorSetBinding): DisputeResolutionProof {
174
+ return {
175
+ disputeId: resolution.disputeId,
176
+ messageHash,
177
+ outcome: resolution.outcome,
178
+ challengerPeerId: binding.challengerPeerId,
179
+ arbitratorSet: binding.arbitratorSet,
180
+ arbitratorSetSignature: binding.arbitratorSetSignature,
181
+ votes: resolution.votes.map(toVoteProof),
182
+ };
183
+ }
184
+
185
+ function toVoteProof(vote: ArbitrationVote): ArbitrationVoteProof {
186
+ return {
187
+ version: vote.version,
188
+ arbitratorPeerId: vote.arbitratorPeerId,
189
+ vote: vote.vote,
190
+ computedHash: vote.evidence.computedHash,
191
+ signature: vote.signature,
192
+ };
193
+ }
194
+
195
+ // ─── Invalidation certificate verification ───
196
+
197
+ /**
198
+ * Verifies that a {@link DisputeResolutionProof} is a valid invalidation certificate — the reversal
199
+ * analogue of the commit certificate. A member accepts an invalidation **only** if this returns true.
200
+ * Defense-in-depth, strongest-available-layer-wins:
201
+ *
202
+ * **Target binding (#2).** `target` is the transaction actually being reverted; the verifier recomputes
203
+ * `targetHash` from `proof.messageHash` + `target` and checks each vote's signature over the v3 payload
204
+ * `v3:${disputeId}:${vote}:${computedHash}:${targetHash}:${setHash}`. Because the arbitrators signed the
205
+ * *real* transaction's `targetHash`, feeding any other target (a replay against an innocent transaction)
206
+ * makes every signature fail. A vote whose version is absent/unrecognized (legacy/v1/v2) is rejected
207
+ * before counting — never accepted-by-default.
208
+ *
209
+ * **Arbitrator-set binding (#1) — layer 1 (always).** The proof carries `arbitratorSet` (the K peer-ids
210
+ * the challenger selected) and `arbitratorSetSignature` (the challenger's signature over
211
+ * `(disputeId, target, arbitratorSet)`, verified against `challengerPeerId`'s embedded key). The verifier:
212
+ * - rejects a proof missing the set / challenger fields;
213
+ * - rejects a proof whose challenger signature does not validate over the recomputed `(targetHash, setHash)`
214
+ * — so tampering with the set (adding sybils) breaks the certificate;
215
+ * - counts only votes whose `arbitratorPeerId` ∈ `arbitratorSet` (a signature-valid vote from a peer
216
+ * outside the set is dropped), and whose vote payload also commits to the same `setHash`.
217
+ * This closes the third-party-relay / originator sybil vector and binds the originator. It does NOT by
218
+ * itself stop a *fully-malicious* challenger (who legitimately holds its own key and signs a sybil set it
219
+ * minted) — that needs layer 2 or 3.
220
+ *
221
+ * **Per-arbitrator dedup (#3).** Among the in-set votes, decisive verdicts are tallied at most once per
222
+ * `arbitratorPeerId`: a repeated identical decisive vote counts once; an arbitrator with conflicting
223
+ * decisive votes (equivocation) is dropped from both sides. `inconclusive` votes are non-decisive.
224
+ *
225
+ * **Recompute — layer 2 (best effort).** When `options.recomputeArbitratorSet` is supplied (the caller
226
+ * holds a topology view), the carried set is re-derived and judged. A `{feasible:true, legitimate:false}`
227
+ * verdict rejects the certificate even though layer 1 passed — closing the malicious-challenger vector.
228
+ * `{feasible:false}` (member can't reconstruct the historical topology) falls through to degradation.
229
+ *
230
+ * **Degradation (layer 3 / none-available).** With no recompute capability (or an infeasible recompute)
231
+ * and no trust anchor yet, a layer-1-valid certificate is **accepted and logged** as not-fully-anchored
232
+ * (and `options.onUnanchored` is invoked) — never a silent accept-by-default, and never a false reject
233
+ * that would break liveness for late-joiners. This is the interim posture until the cohort-topic
234
+ * membership-cert trust-anchor chain lands (`tickets/plan/cohort-topic-membership-cert-trust-anchoring.md`),
235
+ * at which point it is upgraded to a hard gate.
236
+ */
237
+ export async function verifyInvalidationCertificate(proof: DisputeResolutionProof, target: CertificateTarget, options: VerifyCertificateOptions = {}): Promise<boolean> {
238
+ if (proof.outcome !== 'challenger-wins') {
239
+ return false;
240
+ }
241
+
242
+ // Structural: a v3 certificate must carry the arbitrator-set binding. Absent → reject (never accept a
243
+ // pre-set-binding proof by default).
244
+ if (!proof.arbitratorSet?.length || !proof.challengerPeerId || !proof.arbitratorSetSignature) {
245
+ return false;
246
+ }
247
+
248
+ const targetHash = await computeTargetHash(proof.messageHash, target);
249
+ const setHash = await computeArbitratorSetHash(proof.arbitratorSet);
250
+
251
+ // Layer 1a: challenger binds (disputeId, target, arbitratorSet). A tampered set breaks this signature.
252
+ if (!(await verifyChallengerSetSignature(proof, targetHash, setHash))) {
253
+ log('verify-reject-challenger-set-signature disputeId=%s challenger=%s', proof.disputeId, proof.challengerPeerId);
254
+ return false;
255
+ }
256
+
257
+ // Layer 1b: count only signature-valid, target+set-bound, in-set votes — deduped/equivocation-dropped.
258
+ const arbitratorSet = new Set(proof.arbitratorSet);
259
+ type Decisive = 'agree-with-challenger' | 'agree-with-majority';
260
+ const decisiveByArbitrator = new Map<string, Decisive | 'equivocated'>();
261
+ for (const vote of proof.votes) {
262
+ if (!arbitratorSet.has(vote.arbitratorPeerId)) {
263
+ continue; // not a legitimately-selected arbitrator → never counted (#1)
264
+ }
265
+ if (!(await verifyVoteSignature(proof.disputeId, vote, targetHash, setHash))) {
266
+ continue;
267
+ }
268
+ if (vote.vote !== 'agree-with-challenger' && vote.vote !== 'agree-with-majority') {
269
+ continue; // 'inconclusive' — valid but not decisive
270
+ }
271
+ const prior = decisiveByArbitrator.get(vote.arbitratorPeerId);
272
+ if (prior === undefined) {
273
+ decisiveByArbitrator.set(vote.arbitratorPeerId, vote.vote);
274
+ } else if (prior !== 'equivocated' && prior !== vote.vote) {
275
+ // Same arbitrator, conflicting decisive votes → drop entirely (do not let one peer be on both sides).
276
+ decisiveByArbitrator.set(vote.arbitratorPeerId, 'equivocated');
277
+ }
278
+ // prior === vote.vote (duplicate) or already 'equivocated': counted at most once, no change.
279
+ }
280
+
281
+ let challengerVotes = 0;
282
+ let majorityVotes = 0;
283
+ for (const decision of decisiveByArbitrator.values()) {
284
+ if (decision === 'agree-with-challenger') {
285
+ challengerVotes++;
286
+ } else if (decision === 'agree-with-majority') {
287
+ majorityVotes++;
288
+ }
289
+ }
290
+
291
+ const totalDecisive = challengerVotes + majorityVotes;
292
+ if (totalDecisive === 0) {
293
+ return false;
294
+ }
295
+ const superMajorityThreshold = Math.ceil(totalDecisive * 2 / 3);
296
+ if (challengerVotes < superMajorityThreshold) {
297
+ return false;
298
+ }
299
+
300
+ // Layer 2: re-derive the eligible set from the member's own topology when it can — closes a
301
+ // fully-malicious challenger that self-signed a sybil set that passed layer 1.
302
+ if (options.recomputeArbitratorSet) {
303
+ const verdict = await options.recomputeArbitratorSet({
304
+ disputeId: proof.disputeId,
305
+ messageHash: proof.messageHash,
306
+ invalidatedActionId: target.invalidatedActionId,
307
+ blockIds: target.blockIds,
308
+ arbitratorSet: proof.arbitratorSet,
309
+ // NOTE: `round` pinned to 0 (single-round arbitration today) and `epoch` omitted until
310
+ // `design-dispute-synchronous-escalation` threads the real (round, epoch) through the proof, so a
311
+ // `sampleArbitrators`-based recompute re-derives the identical dispersed set. Until then no recompute
312
+ // capability is wired in production; the test recomputes ignore these fields.
313
+ round: 0,
314
+ });
315
+ if (verdict.feasible) {
316
+ if (!verdict.legitimate) {
317
+ log('verify-reject-recompute-mismatch disputeId=%s', proof.disputeId);
318
+ return false;
319
+ }
320
+ return true; // fully anchored to the recomputed topology
321
+ }
322
+ // infeasible → fall through to the documented degradation posture
323
+ return acceptUnanchored(proof, 'recompute-infeasible', options);
324
+ }
325
+
326
+ // Layer 3 / none-available: accept on layer 1 alone, but never silently — log + surface the residual.
327
+ return acceptUnanchored(proof, 'no-recompute-capability', options);
328
+ }
329
+
330
+ /**
331
+ * Accept-and-log a layer-1-valid certificate that could not be fully anchored (no recompute capability
332
+ * or an infeasible recompute, and no trust anchor yet). Documented interim posture — never a silent
333
+ * accept. Returns true.
334
+ */
335
+ function acceptUnanchored(proof: DisputeResolutionProof, reason: UnanchoredAcceptanceInfo['reason'], options: VerifyCertificateOptions): boolean {
336
+ log('verify-accept-unanchored disputeId=%s reason=%s setSize=%d', proof.disputeId, reason, proof.arbitratorSet.length);
337
+ try {
338
+ options.onUnanchored?.({ disputeId: proof.disputeId, arbitratorSet: proof.arbitratorSet, reason });
339
+ } catch (err) {
340
+ log('verify-onUnanchored-error disputeId=%s error=%o', proof.disputeId, err);
341
+ }
342
+ return true;
343
+ }
344
+
345
+ /**
346
+ * Verify the challenger's signature binding `(disputeId, target, arbitratorSet)` against the embedded
347
+ * Ed25519 key in `proof.challengerPeerId`. Closes the third-party-relay sybil vector: a relay cannot
348
+ * swap the carried set without the challenger's key.
349
+ */
350
+ async function verifyChallengerSetSignature(proof: DisputeResolutionProof, targetHash: string, setHash: string): Promise<boolean> {
351
+ try {
352
+ const publicKey = peerIdFromString(proof.challengerPeerId).publicKey;
353
+ if (!publicKey) {
354
+ return false;
355
+ }
356
+ const payload = arbitratorSetSigningPayload(proof.disputeId, targetHash, setHash);
357
+ const sigBytes = uint8ArrayFromString(proof.arbitratorSetSignature, 'base64url');
358
+ return await publicKey.verify(payload, sigBytes);
359
+ } catch (err) {
360
+ log('challenger-set-signature-verify-error challenger=%s error=%o', proof.challengerPeerId, err);
361
+ return false;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Verify one arbitration vote's Ed25519 signature against its arbitrator peer id's embedded key, over
367
+ * the **target- and set-bound v3 payload**. Rejects any vote that is not the v3 format before trusting
368
+ * it, so a legacy/unversioned (v1/v2) vote can never slip through.
369
+ */
370
+ async function verifyVoteSignature(disputeId: string, vote: ArbitrationVoteProof, targetHash: string, setHash: string): Promise<boolean> {
371
+ // Runtime gate: `vote` arrives off the wire, so its `version` may not match the declared type.
372
+ if (vote.version !== VOTE_VERSION) {
373
+ return false;
374
+ }
375
+ try {
376
+ const publicKey = peerIdFromString(vote.arbitratorPeerId).publicKey;
377
+ if (!publicKey) {
378
+ return false;
379
+ }
380
+ const payload = voteSigningPayload(disputeId, vote.vote, vote.computedHash, targetHash, setHash);
381
+ const sigBytes = uint8ArrayFromString(vote.signature, 'base64url');
382
+ return await publicKey.verify(payload, sigBytes);
383
+ } catch (err) {
384
+ log('vote-signature-verify-error arbitrator=%s error=%o', vote.arbitratorPeerId, err);
385
+ return false;
386
+ }
387
+ }
388
+
389
+ // ─── Compensating-state computation ───
390
+
391
+ /**
392
+ * The recomputed "as-if-`T_inv`-never-committed" state for a single block.
393
+ * - `restore`: the block existed before `T_inv`; `block` is its recomputed content (the revision
394
+ * immediately before `T_inv`, with any surviving later actions replayed on top).
395
+ * - `delete`: `T_inv` created the block, so the as-if-absent state is a deletion (physically removed
396
+ * by {@link applyInvalidation} via a tombstone — see {@link DELETED_BLOCK_RESTORE}).
397
+ */
398
+ export type RevertedComputation =
399
+ | { kind: 'restore'; block: IBlock; restoredContentHash: string; fromRev: number; laterActions: number }
400
+ | { kind: 'delete'; fromRev: number };
401
+
402
+ /**
403
+ * Reconstructs the compensating content for one block from stored revisions only (never by re-running
404
+ * the engine — so it does not depend on engine availability and stays deterministic across members).
405
+ *
406
+ * Base = the block's content at the highest stored revision strictly before `T_inv` (no such revision
407
+ * ⇒ `T_inv` created the block ⇒ a deletion). In the single-collection/no-cascade core, "surviving later
408
+ * actions" = every committed action after `T_inv` on this block, replayed verbatim on the rolled-back
409
+ * base. The cascade (see `cascade.ts`) layers true read-dependent re-evaluation on top of this blind
410
+ * replay — it reverts the genuine successors this primitive leaves as-is and logs.
411
+ */
412
+ export async function computeRevertedBlock(blockStorage: IBlockStorage, invalidatedRev: number): Promise<RevertedComputation> {
413
+ const latest = await blockStorage.getLatest();
414
+ const fromRev = latest?.rev ?? invalidatedRev;
415
+
416
+ // Find the highest stored revision strictly before T_inv — the base to roll back to. A descending
417
+ // listRevisions(invalidatedRev - 1, 1) yields it first (same descending-scan pattern materializeBlock
418
+ // uses, so no new cost class). No such revision ⇒ T_inv CREATED this block ⇒ the as-if-absent state is
419
+ // a deletion — at ANY rev, without throwing (the previous `invalidatedRev <= 1` special-case folds into
420
+ // this: at rev <= 1 the probe is skipped and priorRev stays undefined). We intentionally do NOT replay
421
+ // surviving later actions onto an absent base: replaying an update on `undefined` stays `undefined`, and
422
+ // any later writer of a created-then-reverted block is itself a read-dependent the cascade (`cascade.ts`)
423
+ // re-evaluates and reverts.
424
+ let priorRev: number | undefined;
425
+ if (invalidatedRev > 1) {
426
+ for await (const ar of blockStorage.listRevisions(invalidatedRev - 1, 1)) {
427
+ priorRev = ar.rev;
428
+ break;
429
+ }
430
+ }
431
+ if (priorRev === undefined) {
432
+ return { kind: 'delete', fromRev };
433
+ }
434
+ const base = await blockStorage.getBlock(priorRev);
435
+ if (!base) {
436
+ return { kind: 'delete', fromRev };
437
+ }
438
+
439
+ // Replay surviving later actions (committed strictly after T_inv) onto the rolled-back base.
440
+ // Guard on `fromRev > invalidatedRev`: listRevisions treats start > end as a *descending*
441
+ // range, so an unguarded listRevisions(invalidatedRev + 1, fromRev) when no later action
442
+ // exists would wrongly re-include T_inv's own revision.
443
+ let block: IBlock | undefined = base.block;
444
+ let laterActions = 0;
445
+ if (fromRev > invalidatedRev) {
446
+ for await (const actionRev of blockStorage.listRevisions(invalidatedRev + 1, fromRev)) {
447
+ const transform = await blockStorage.getTransaction(actionRev.actionId);
448
+ if (!transform) {
449
+ continue;
450
+ }
451
+ block = applyTransform(block, transform);
452
+ laterActions++;
453
+ }
454
+ }
455
+ if (!block) {
456
+ return { kind: 'delete', fromRev };
457
+ }
458
+ const restoredContentHash = await hashBlockContent(block);
459
+ return { kind: 'restore', block, restoredContentHash, fromRev, laterActions };
460
+ }
461
+
462
+ /**
463
+ * Deterministic content hash for a materialized block — the single hashing convention shared by
464
+ * the compensating-state computation ({@link computeRevertedBlock}) and the cascade re-evaluator
465
+ * (`db-p2p/src/dispute/cascade.ts`). Both must agree byte-for-byte: the cascade decides whether a
466
+ * read-dependent still holds by comparing the hash of the content it *observed* against the
467
+ * `restoredContentHash` an invalidation recorded — so the two hashes have to be produced the same way.
468
+ */
469
+ export async function hashBlockContent(block: IBlock): Promise<string> {
470
+ return await hashString(stableStringify(block));
471
+ }
472
+
473
+ // ─── Deterministic apply ───
474
+
475
+ /** Everything the apply primitive needs from the host: the collection log and per-block storage. */
476
+ export type InvalidationContext = {
477
+ /** The collection log of the collection `T_inv` wrote (where the compensating entry is appended). */
478
+ readonly log: Log<unknown>;
479
+ /** Resolves a block's storage so the compensating revision can be written. */
480
+ readonly createBlockStorage: (blockId: BlockId) => IBlockStorage;
481
+ /**
482
+ * Runs `fn` while holding the same per-block commit latch `StorageRepo.commit` and
483
+ * `saveReplicatedBlock` hold, so the compensating `saveReplica`/`saveDeletion` read-modify-write of
484
+ * `meta.latest` is mutually exclusive with a concurrent local commit on that block. Without it, an
485
+ * invalidation advancing `latest` outside that latch is invisible to commit's staleness guard, so a
486
+ * commit that read a stale `latest` can clobber it back down — a lost-update / non-monotonic
487
+ * regression. Optional: when omitted (unit tests / non-`StorageRepo` hosts) the write runs unlatched,
488
+ * preserving today's behavior. The host supplies `StorageRepo`'s `withBlockCommitLatch`.
489
+ */
490
+ readonly withBlockCommitLatch?: <T>(blockId: BlockId, fn: () => Promise<T>) => Promise<T>;
491
+ };
492
+
493
+ export type ApplyInvalidationParams = {
494
+ readonly invalidatedActionId: ActionId;
495
+ readonly invalidatedRev: number;
496
+ /** Blocks `T_inv` wrote (its commit's blockIds). */
497
+ readonly blockIds: ReadonlyArray<BlockId>;
498
+ /** The invalidation certificate. */
499
+ readonly proof: DisputeResolutionProof;
500
+ /**
501
+ * Consensus-assigned revision slot for the compensating revision (collection-global). When
502
+ * omitted, computed as one past the highest current tip across the reverted blocks — the value a
503
+ * local/single-node apply uses; the consensus path passes the agreed slot.
504
+ */
505
+ readonly rev?: number;
506
+ /**
507
+ * When this invalidation is a cascade step (a read-dependent of an already-invalidated root being
508
+ * reverted), the `actionId` of the root invalidation that triggered the cascade. Recorded on the
509
+ * resulting {@link InvalidationEntry} (`cascadeRoot`) so the reversal is auditable as part of one
510
+ * logical cascade event. Absent for a root invalidation.
511
+ */
512
+ readonly cascadeRoot?: ActionId;
513
+ /**
514
+ * The target the proof's votes are bound to — the transaction the dispute actually resolved. For a
515
+ * **root** invalidation this equals this call's own `(invalidatedActionId, blockIds)` and may be
516
+ * omitted (defaulted below). A **cascade child** reuses the *root's* proof to authorize reverting a
517
+ * read-dependent whose own target differs, so it MUST pass the root's target here: the votes were
518
+ * signed over the root's `targetHash`, not the child's. The child-specific justification is the
519
+ * deterministic cascade derivation every member replays — not the certificate, which only attests
520
+ * the root is invalid. (The network-facing apply path `applyConsensusInvalidation` never sets this:
521
+ * it verifies against the request's *own* target, which is the replay boundary this ticket closes.)
522
+ */
523
+ readonly certificateTarget?: CertificateTarget;
524
+ readonly timestamp?: number;
525
+ };
526
+
527
+ export type ApplyInvalidationResult = {
528
+ readonly applied: boolean;
529
+ readonly reason?: 'already-applied' | 'invalid-certificate';
530
+ readonly rev?: number;
531
+ readonly reverted: ReadonlyArray<RevertedBlock>;
532
+ };
533
+
534
+ /**
535
+ * Deterministically applies a single-collection invalidation: the durable reversal primitive every
536
+ * cluster member runs identically (it carries the `reverted` targets and proof, exactly as the
537
+ * consensus-apply path runs committed operations on every peer).
538
+ *
539
+ * Steps, in order:
540
+ * 1. **Dedup** — if the log already holds an invalidation for `(invalidatedActionId, disputeId)`,
541
+ * this is a re-receipt (rebroadcast / sync / retry): no-op, append nothing.
542
+ * 2. **Certificate** — reject (append nothing) unless `proof` is a valid challenger-wins certificate.
543
+ * 3. **Reverted revisions** — for each block, recompute the as-if-`T_inv`-absent content and write a
544
+ * new monotonic revision (a forward compensating transform; prior revisions are retained).
545
+ * 4. **Log entry** — append the {@link InvalidationEntry} carrying the proof and `reverted` targets,
546
+ * making `committed-invalidated` durable and recoverable on sync.
547
+ */
548
+ export async function applyInvalidation(ctx: InvalidationContext, params: ApplyInvalidationParams): Promise<ApplyInvalidationResult> {
549
+ const { invalidatedActionId, invalidatedRev, blockIds, proof } = params;
550
+
551
+ // 1. Idempotent re-receipt — keyed on (invalidatedActionId, disputeId).
552
+ const existing = await ctx.log.findInvalidation(invalidatedActionId);
553
+ if (existing && existing.resolution.disputeId === proof.disputeId) {
554
+ log('apply-skip-duplicate actionId=%s disputeId=%s', invalidatedActionId, proof.disputeId);
555
+ return { applied: false, reason: 'already-applied', reverted: [...existing.reverted] };
556
+ }
557
+
558
+ // 2. Certificate verification — never trust a single peer's say-so. The proof's votes are bound to
559
+ // a specific target; verify against the target they were signed over. For a root invalidation
560
+ // that is this call's own target; a cascade child passes the root's target via `certificateTarget`
561
+ // (the child-target justification is the deterministic cascade, not the certificate). A mismatched
562
+ // target (a genuine proof replayed against an innocent transaction) fails every signature here, so
563
+ // no compensating revision or log entry is ever written for it.
564
+ const certificateTarget = params.certificateTarget ?? { invalidatedActionId, blockIds };
565
+ if (!(await verifyInvalidationCertificate(proof, certificateTarget))) {
566
+ log('apply-reject-certificate actionId=%s disputeId=%s outcome=%s', invalidatedActionId, proof.disputeId, proof.outcome);
567
+ return { applied: false, reason: 'invalid-certificate', reverted: [] };
568
+ }
569
+
570
+ // 3. Compute compensating content + the collection-global revision slot.
571
+ const computations = await Promise.all(
572
+ blockIds.map(async (blockId) => {
573
+ const storage = ctx.createBlockStorage(blockId);
574
+ return { blockId, storage, computation: await computeRevertedBlock(storage, invalidatedRev) };
575
+ })
576
+ );
577
+ const maxFromRev = computations.reduce((max, c) => Math.max(max, c.computation.fromRev), invalidatedRev);
578
+ const rev = params.rev ?? maxFromRev + 1;
579
+
580
+ const reverted: RevertedBlock[] = [];
581
+ for (const { blockId, storage, computation } of computations) {
582
+ // Deterministic compensating-revision actionId — identical on every member, so all converge on
583
+ // the same (rev, actionId) for both the restore and the tombstone path.
584
+ const revertActionId = await hashString(`inv:${invalidatedActionId}:${proof.disputeId}:${blockId}:${rev}`);
585
+ // Hold the per-block commit latch around ONLY the compensating write (matching saveReplicatedBlock's
586
+ // scope): the monotonic guard inside saveReplica/saveDeletion then runs under the same latch a
587
+ // concurrent commit holds, so the two RMW of meta.latest serialize and latest stays monotonic.
588
+ // Acquire per block, one at a time — invalidation never holds two block latches, so it cannot
589
+ // deadlock against commit's sorted multi-latch acquisition. When no runner is injected, the write
590
+ // runs unlatched (today's behavior).
591
+ const runLatched = <T>(fn: () => Promise<T>): Promise<T> =>
592
+ ctx.withBlockCommitLatch ? ctx.withBlockCommitLatch(blockId, fn) : fn();
593
+ if (computation.kind === 'delete') {
594
+ // Block-creation reversal: physically remove the created block by writing a forward tombstone
595
+ // revision. The `restoredContentHash` is the DELETED_BLOCK_RESTORE sentinel — a deleted block
596
+ // has no content hash, and the sentinel tells dependents "observed content is gone → invalidate".
597
+ await runLatched(() => storage.saveDeletion({ rev, actionId: revertActionId }));
598
+ log('apply-delete-restore blockId=%s invalidatedRev=%d rev=%d', blockId, invalidatedRev, rev);
599
+ reverted.push({ blockId, fromRev: computation.fromRev, restoredContentHash: DELETED_BLOCK_RESTORE });
600
+ continue;
601
+ }
602
+ if (computation.laterActions > 0) {
603
+ // Surviving later actions were replayed verbatim; true read-dependents are out of scope here.
604
+ log('apply-replayed-later-actions blockId=%s count=%d', blockId, computation.laterActions);
605
+ }
606
+ await runLatched(() => storage.saveReplica(computation.block, { rev, actionId: revertActionId }));
607
+ reverted.push({ blockId, fromRev: computation.fromRev, restoredContentHash: computation.restoredContentHash });
608
+ }
609
+
610
+ // 4. Durable, append-only invalidation entry (the source of truth for committed-invalidated).
611
+ // `cascadeRoot` is set when this is a cascade step (a reverted read-dependent), undefined for a root.
612
+ await ctx.log.addInvalidation(invalidatedActionId, invalidatedRev, proof, reverted, rev, params.cascadeRoot, params.timestamp);
613
+ log('apply-complete actionId=%s disputeId=%s rev=%d blocks=%d', invalidatedActionId, proof.disputeId, rev, reverted.length);
614
+
615
+ return { applied: true, rev, reverted };
616
+ }
617
+
618
+ /** Deterministic, key-sorted JSON for content hashing — stable across members regardless of key order. */
619
+ function stableStringify(value: unknown): string {
620
+ return JSON.stringify(value, (_key, v) =>
621
+ v && typeof v === 'object' && !Array.isArray(v)
622
+ ? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = (v as Record<string, unknown>)[k]; return o; }, {})
623
+ : v
624
+ );
625
+ }