@optimystic/db-p2p 0.24.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,696 +1,706 @@
1
- import type { ClusterRecord, Signature, ITransactionValidator, InvalidateRequest, CommitRequest, PendRequest, CollectionId } from '@optimystic/db-core';
2
- import { blockIdsForTransforms, computeClusterPromiseHash, clusterVoteVerificationPayload, recordMembershipDigest } from '@optimystic/db-core';
3
- import { buildDisputeResolutionProof, computeTargetHash, computeArbitratorSetHash, voteSigningPayload, arbitratorSetSigningPayload, VOTE_VERSION, type CertificateTarget } from './invalidation.js';
4
- import type { PeerId, PrivateKey } from '@libp2p/interface';
5
- import { sha256 } from 'multiformats/hashes/sha2';
6
- import { base58btc } from 'multiformats/bases/base58';
7
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
8
- import { publicKeyFromRaw } from '@libp2p/crypto/keys';
9
- import { peerIdBindsPublicKey } from '../cluster/peer-key-binding.js';
10
- import type {
11
- ValidationEvidence,
12
- DisputeChallenge,
13
- ArbitrationVote,
14
- DisputeResolution,
15
- DisputeConfig,
16
- DisputeStatus,
17
- } from './types.js';
18
- import { DEFAULT_DISPUTE_CONFIG } from './types.js';
19
- import { EngineHealthMonitor } from './engine-health-monitor.js';
20
- import type { IPeerReputation } from '../reputation/types.js';
21
- import { PenaltyReason } from '../reputation/types.js';
22
- import { createLogger } from '../logger.js';
23
- import type { IPeerNetwork } from '@optimystic/db-core';
24
- import type { DisputeClient } from './client.js';
25
-
26
- const log = createLogger('dispute');
27
-
28
- /** Callback to create a DisputeClient for a given peer */
29
- export type CreateDisputeClient = (peerId: PeerId) => DisputeClient;
30
-
31
- /** Callback to re-execute a transaction and produce validation evidence */
32
- export type RevalidateTransaction = (record: ClusterRecord) => Promise<ValidationEvidence | undefined>;
33
-
34
- export interface DisputeServiceInit {
35
- peerId: PeerId;
36
- privateKey: PrivateKey;
37
- peerNetwork: IPeerNetwork;
38
- createDisputeClient: CreateDisputeClient;
39
- reputation?: IPeerReputation;
40
- validator?: ITransactionValidator;
41
- revalidate?: RevalidateTransaction;
42
- config?: Partial<DisputeConfig>;
43
- /**
44
- * Select arbitrators for a dispute via verifiable dispersed sampling (see `sampleArbitrators`): draw
45
- * `count` distinct peers from coordinates spread across the whole keyspace, not the block's neighborhood.
46
- * `round` (0-based escalation round) and `epoch` (agreed membership epoch bytes) are folded into every
47
- * coordinate so the draw is deterministic yet not pre-positionable — an honest node re-derives the
48
- * identical set from the same `(blockId, round, epoch)` + agreed membership.
49
- */
50
- selectArbitrators: (blockId: string, excludePeers: string[], count: number, round: number, epoch: Uint8Array) => Promise<PeerId[]>;
51
- /**
52
- * Originates the durable reversal when a dispute resolves `challenger-wins`. The dissent
53
- * coordinator (the node that initiated the dispute, holding the original record) builds the
54
- * {@link InvalidateRequest} and hands it here to be driven through the critical cluster as a
55
- * consensus-ordered invalidation; every member then applies it deterministically. Absent on
56
- * nodes not wired to originate invalidations (today's default) — the in-memory status still flips,
57
- * but nothing durable is written until this is supplied.
58
- */
59
- onInvalidation?: (request: InvalidateRequest) => Promise<void> | void;
60
- }
61
-
62
- /**
63
- * Manages the dispute escalation protocol.
64
- *
65
- * When a transaction proceeds despite minority rejections, the overridden
66
- * minority can escalate to independent arbitrators. The service coordinates
67
- * challenge initiation, arbitration vote collection, and resolution.
68
- */
69
- export class DisputeService {
70
- private readonly peerId: PeerId;
71
- private readonly privateKey: PrivateKey;
72
- private readonly createDisputeClient: CreateDisputeClient;
73
- private readonly reputation?: IPeerReputation;
74
- private readonly revalidate?: RevalidateTransaction;
75
- private readonly config: DisputeConfig;
76
- private readonly engineHealth: EngineHealthMonitor;
77
- private readonly selectArbitrators: DisputeServiceInit['selectArbitrators'];
78
- private readonly onInvalidation?: DisputeServiceInit['onInvalidation'];
79
-
80
- /** Active disputes initiated by this node */
81
- private activeDisputes: Map<string, DisputeChallenge> = new Map();
82
- /** Resolved disputes (disputeId -> resolution) */
83
- private resolvedDisputes: Map<string, DisputeResolution> = new Map();
84
- /** Challenges retained after resolution for status lookups */
85
- private resolvedChallenges: Map<string, DisputeChallenge> = new Map();
86
- /** Track which transactions we've already disputed (prevent spam) */
87
- private disputedTransactions: Set<string> = new Set();
88
- /** Track which transactions we've already originated an invalidation for (fire once per messageHash) */
89
- private invalidatedTransactions: Set<string> = new Set();
90
-
91
- constructor(init: DisputeServiceInit) {
92
- this.peerId = init.peerId;
93
- this.privateKey = init.privateKey;
94
- this.createDisputeClient = init.createDisputeClient;
95
- this.reputation = init.reputation;
96
- this.revalidate = init.revalidate;
97
- this.config = { ...DEFAULT_DISPUTE_CONFIG, ...init.config };
98
- this.engineHealth = new EngineHealthMonitor(this.config);
99
- this.selectArbitrators = init.selectArbitrators;
100
- this.onInvalidation = init.onInvalidation;
101
- }
102
-
103
- /** Get the engine health monitor */
104
- getEngineHealth(): EngineHealthMonitor {
105
- return this.engineHealth;
106
- }
107
-
108
- /** Check if disputes are enabled */
109
- isEnabled(): boolean {
110
- return this.config.disputeEnabled;
111
- }
112
-
113
- /** Get the dispute status for a transaction, if any */
114
- getDisputeStatus(messageHash: string): DisputeStatus | undefined {
115
- // Check if there's an active dispute for this transaction
116
- for (const [, challenge] of this.activeDisputes) {
117
- if (challenge.originalMessageHash === messageHash) {
118
- return 'committed-disputed';
119
- }
120
- }
121
- // Check resolved disputes
122
- for (const [, resolution] of this.resolvedDisputes) {
123
- const challenge = this.findChallengeForDispute(resolution.disputeId);
124
- if (challenge && challenge.originalMessageHash === messageHash) {
125
- if (resolution.outcome === 'challenger-wins') return 'committed-invalidated';
126
- if (resolution.outcome === 'majority-wins') return 'committed-validated';
127
- return 'committed-disputed'; // inconclusive
128
- }
129
- }
130
- return undefined;
131
- }
132
-
133
- /**
134
- * Initiate a dispute when this node's rejection was overridden.
135
- * Called by ClusterMember when it detects a disputed commit.
136
- */
137
- async initiateDispute(record: ClusterRecord, evidence: ValidationEvidence): Promise<DisputeResolution | undefined> {
138
- if (!this.config.disputeEnabled) {
139
- log('dispute-disabled', { messageHash: record.messageHash });
140
- return undefined;
141
- }
142
-
143
- // One dispute per transaction
144
- if (this.disputedTransactions.has(record.messageHash)) {
145
- log('dispute-already-initiated', { messageHash: record.messageHash });
146
- return undefined;
147
- }
148
-
149
- // Don't dispute if our engine is unhealthy
150
- if (this.engineHealth.isUnhealthy()) {
151
- log('dispute-skipped-unhealthy', { messageHash: record.messageHash });
152
- return undefined;
153
- }
154
-
155
- this.disputedTransactions.add(record.messageHash);
156
-
157
- const timestamp = Date.now();
158
- const disputeId = await this.computeDisputeId(record.messageHash, this.peerId.toString(), timestamp);
159
- const signature = await this.signDispute(disputeId);
160
-
161
- const defaultTtl = record.message.expiration
162
- ? (record.message.expiration - Date.now()) * 2
163
- : this.config.disputeArbitrationTimeoutMs * 2;
164
- const expiration = timestamp + Math.max(defaultTtl, this.config.disputeArbitrationTimeoutMs);
165
-
166
- // Select the arbitrator set FIRST so the challenge can carry it: each arbitrator folds the set's
167
- // digest into the v3 vote it signs, binding every vote to the legitimately-selected set (#1).
168
- const blockIds = record.coordinatingBlockIds ?? [];
169
- const blockId = blockIds[0] ?? record.messageHash;
170
- const originalPeers = Object.keys(record.peers);
171
- const arbitratorCount = this.config.arbitratorCount ?? originalPeers.length;
172
-
173
- // Round 0 is the only round today (single-round arbitration); `design-dispute-synchronous-escalation`
174
- // drives real-time round progression. `epoch` pins the dispersed draw to a membership the attacker
175
- // cannot freely advance.
176
- // NOTE: interim agreed-membership epoch — hash of the admission-gate-agreed responsible set
177
- // (`Object.keys(record.peers)`). When `design-cluster-membership-agreement` lands, `epoch` becomes
178
- // the agreed membership epoch rather than this locally-hashed stand-in.
179
- const round = 0;
180
- const epoch = await this.computeEpochBytes(originalPeers);
181
-
182
- let arbitrators: PeerId[];
183
- try {
184
- arbitrators = await this.selectArbitrators(blockId, originalPeers, arbitratorCount, round, epoch);
185
- } catch (err) {
186
- log('dispute-arbitrator-selection-failed', { disputeId, error: err instanceof Error ? err.message : String(err) });
187
- return undefined;
188
- }
189
-
190
- if (arbitrators.length === 0) {
191
- log('dispute-no-arbitrators', { disputeId });
192
- return undefined;
193
- }
194
-
195
- const arbitratorSet = arbitrators.map(a => a.toString());
196
-
197
- const challenge: DisputeChallenge = {
198
- disputeId,
199
- originalMessageHash: record.messageHash,
200
- originalRecord: record,
201
- challengerPeerId: this.peerId.toString(),
202
- challengerEvidence: evidence,
203
- signature,
204
- timestamp,
205
- expiration,
206
- arbitratorSet,
207
- };
208
-
209
- this.activeDisputes.set(disputeId, challenge);
210
- log('dispute-initiated', { disputeId, messageHash: record.messageHash });
211
-
212
- // Send challenge to all arbitrators and collect votes
213
- const votes = await this.collectVotes(challenge, arbitrators);
214
- const resolution = await this.resolveDispute(challenge, votes);
215
-
216
- this.resolvedChallenges.set(disputeId, challenge);
217
- this.activeDisputes.delete(disputeId);
218
- this.resolvedDisputes.set(disputeId, resolution);
219
-
220
- // Apply reputation effects
221
- this.applyReputationEffects(resolution, record);
222
-
223
- // On a proven-invalid transaction, originate the durable reversal through the cluster. The
224
- // challenger-selected `arbitratorSet` is bound into the proof (#1).
225
- await this.maybeInvalidate(resolution, record, arbitratorSet);
226
-
227
- // Broadcast resolution
228
- await this.broadcastResolution(resolution, arbitrators, originalPeers);
229
-
230
- log('dispute-resolved', {
231
- disputeId,
232
- outcome: resolution.outcome,
233
- votes: votes.length,
234
- affectedPeers: resolution.affectedPeers.length,
235
- });
236
-
237
- return resolution;
238
- }
239
-
240
- /**
241
- * Handle an incoming dispute challenge (when this node is selected as arbitrator).
242
- * Re-executes the transaction and returns a vote.
243
- */
244
- async handleChallenge(challenge: DisputeChallenge): Promise<ArbitrationVote> {
245
- log('dispute-handle-challenge', { disputeId: challenge.disputeId });
246
-
247
- // Bind every vote to the disputed transaction AND the legitimately-selected arbitrator set: derive
248
- // the reversal target the same way the originator/apply path does (so the verifier recomputes an
249
- // identical targetHash), digest the set the challenger carried, and sign over both (#1, #2).
250
- const targetHash = await this.computeChallengeTargetHash(challenge);
251
- const setHash = await computeArbitratorSetHash(challenge.arbitratorSet ?? []);
252
-
253
- // Verify the challenge signature bound to the challenger's id so a relay cannot attach a key it
254
- // controls under some other peer's id and pass a forged challenge as that peer.
255
- const validSignature = await this.verifyDisputeSignature(
256
- challenge.disputeId,
257
- challenge.signature,
258
- challenge.challengerPeerId,
259
- challenge.originalRecord.peers[challenge.challengerPeerId]?.publicKey
260
- );
261
-
262
- if (!validSignature) {
263
- log('dispute-invalid-challenge-signature', { disputeId: challenge.disputeId });
264
- return this.makeVote(challenge.disputeId, 'inconclusive', {
265
- computedHash: '',
266
- engineId: 'unknown',
267
- schemaHash: '',
268
- blockStateHashes: {},
269
- }, targetHash, setHash);
270
- }
271
-
272
- // Re-execute the transaction to produce our own evidence
273
- let evidence: ValidationEvidence | undefined;
274
- if (this.revalidate) {
275
- try {
276
- evidence = await this.revalidate(challenge.originalRecord);
277
- } catch (err) {
278
- log('dispute-revalidation-failed', {
279
- disputeId: challenge.disputeId,
280
- error: err instanceof Error ? err.message : String(err),
281
- });
282
- }
283
- }
284
-
285
- if (!evidence) {
286
- // Can't re-execute — vote inconclusive
287
- return this.makeVote(challenge.disputeId, 'inconclusive', {
288
- computedHash: '',
289
- engineId: 'unknown',
290
- schemaHash: '',
291
- blockStateHashes: {},
292
- }, targetHash, setHash);
293
- }
294
-
295
- // Compare our evidence with the challenger's
296
- let vote: ArbitrationVote['vote'];
297
- if (evidence.computedHash === challenge.challengerEvidence.computedHash) {
298
- // Our re-execution matches the challenger — the challenger is right
299
- vote = 'agree-with-challenger';
300
- } else {
301
- // Our re-execution differs from the challenger — the majority is likely right
302
- vote = 'agree-with-majority';
303
- }
304
-
305
- return this.makeVote(challenge.disputeId, vote, evidence, targetHash, setHash);
306
- }
307
-
308
- /**
309
- * Derives the v2 target hash binding a vote to the disputed transaction: its `messageHash` plus the
310
- * reversal target (the committed action being reversed and the blocks it wrote), extracted from the
311
- * challenge's `originalRecord` exactly as {@link extractInvalidationTarget} does on the originate/apply
312
- * side. A record with no extractable target yields an empty target — the resulting vote still verifies
313
- * self-consistently but will not match a real reversal, which is correct (no real reversal exists).
314
- */
315
- private async computeChallengeTargetHash(challenge: DisputeChallenge): Promise<string> {
316
- const target = DisputeService.extractInvalidationTarget(challenge.originalRecord);
317
- const certTarget: CertificateTarget = target
318
- ? { invalidatedActionId: target.actionId, blockIds: target.blockIds }
319
- : { invalidatedActionId: '', blockIds: [] };
320
- return computeTargetHash(challenge.originalRecord.messageHash, certTarget);
321
- }
322
-
323
- /**
324
- * Handle an incoming dispute resolution (broadcast from the dispute initiator).
325
- */
326
- handleResolution(resolution: DisputeResolution): void {
327
- this.resolvedDisputes.set(resolution.disputeId, resolution);
328
- log('dispute-resolution-received', {
329
- disputeId: resolution.disputeId,
330
- outcome: resolution.outcome,
331
- });
332
-
333
- // If we were penalized and the challenger won, check engine health
334
- const ourId = this.peerId.toString();
335
- const ourPenalty = resolution.affectedPeers.find(p => p.peerId === ourId);
336
- if (ourPenalty && ourPenalty.reason === 'false-approval') {
337
- this.engineHealth.recordDisputeLoss();
338
- }
339
- }
340
-
341
- /** Collect votes from arbitrators with a timeout */
342
- private async collectVotes(challenge: DisputeChallenge, arbitrators: PeerId[]): Promise<ArbitrationVote[]> {
343
- const timeoutMs = this.config.disputeArbitrationTimeoutMs;
344
- const votes: ArbitrationVote[] = [];
345
-
346
- const votePromises = arbitrators.map(async (arbitratorPeerId) => {
347
- try {
348
- const client = this.createDisputeClient(arbitratorPeerId);
349
- const vote = await client.sendChallenge(challenge, timeoutMs);
350
- return vote;
351
- } catch (err) {
352
- log('dispute-vote-collection-failed', {
353
- disputeId: challenge.disputeId,
354
- arbitrator: arbitratorPeerId.toString(),
355
- error: err instanceof Error ? err.message : String(err),
356
- });
357
- return undefined;
358
- }
359
- });
360
-
361
- const results = await Promise.allSettled(votePromises);
362
- for (const result of results) {
363
- if (result.status === 'fulfilled' && result.value) {
364
- votes.push(result.value);
365
- }
366
- }
367
-
368
- return votes;
369
- }
370
-
371
- /** Determine dispute resolution from collected votes */
372
- async resolveDispute(challenge: DisputeChallenge, votes: ArbitrationVote[]): Promise<DisputeResolution> {
373
- const challengerVotes = votes.filter(v => v.vote === 'agree-with-challenger').length;
374
- const majorityVotes = votes.filter(v => v.vote === 'agree-with-majority').length;
375
- const totalDecisive = challengerVotes + majorityVotes;
376
-
377
- // Need super-majority of decisive votes (>2/3)
378
- const superMajorityThreshold = Math.ceil(totalDecisive * 2 / 3);
379
-
380
- let outcome: DisputeResolution['outcome'];
381
- const affectedPeers: DisputeResolution['affectedPeers'] = [];
382
-
383
- if (totalDecisive === 0) {
384
- outcome = 'inconclusive';
385
- } else if (challengerVotes >= superMajorityThreshold) {
386
- outcome = 'challenger-wins';
387
- // Penalize majority peers who approved the transaction. The promise signatures in a challenge's
388
- // originalRecord are NOT otherwise verified on the dispute path, so an attacker who crafts a
389
- // challenge carrying a fabricated originalRecord could attach a forged approval under an honest
390
- // peer's id to get that peer a FalseApproval penalty. Gate each false-approval on the approval
391
- // being binding-valid (key bound to the id AND signature verifies); skip — never penalize —
392
- // any approval that is unbound or invalid.
393
- //
394
- // For the same reason, the peer multiaddrs inside `originalRecord.peers` are deliberately
395
- // NOT fed to the address book here, unlike the cluster/redirect paths (see
396
- // `peer-address-book.ts`). Those paths consume a record we are an intended recipient of;
397
- // a challenge's originalRecord is attacker-supplied on a path that already flags itself as
398
- // unverified, so learning addresses from it would widen that surface for no gain — a
399
- // disputing peer is not one we are about to need a route to.
400
- const originalRecord = challenge.originalRecord;
401
- const promiseHash = await this.computePromiseHash(originalRecord);
402
- for (const [peerId, signature] of Object.entries(originalRecord.promises)) {
403
- if (signature.type === 'approve' && peerId !== challenge.challengerPeerId) {
404
- if (await this.verifyPromiseSignature(originalRecord, peerId, promiseHash, signature)) {
405
- affectedPeers.push({ peerId, reason: 'false-approval' });
406
- } else {
407
- log('dispute-skip-unverified-approval', { disputeId: challenge.disputeId, peerId });
408
- }
409
- }
410
- }
411
- } else if (majorityVotes >= superMajorityThreshold) {
412
- outcome = 'majority-wins';
413
- // Penalize the challenger
414
- affectedPeers.push({ peerId: challenge.challengerPeerId, reason: 'dispute-lost' });
415
- } else {
416
- outcome = 'inconclusive';
417
- }
418
-
419
- return {
420
- disputeId: challenge.disputeId,
421
- outcome,
422
- votes,
423
- affectedPeers,
424
- timestamp: Date.now(),
425
- };
426
- }
427
-
428
- /** Apply reputation effects based on dispute resolution */
429
- private applyReputationEffects(resolution: DisputeResolution, _record: ClusterRecord): void {
430
- if (!this.reputation) return;
431
-
432
- for (const affected of resolution.affectedPeers) {
433
- if (affected.reason === 'false-approval') {
434
- // Weight: 40 as specified in ticket
435
- this.reputation.reportPeer(affected.peerId, PenaltyReason.FalseApproval,
436
- `dispute:false-approval:${resolution.disputeId}`);
437
- } else if (affected.reason === 'dispute-lost') {
438
- // Weight: 30 as specified in ticket
439
- this.reputation.reportPeer(affected.peerId, PenaltyReason.DisputeLost,
440
- `dispute:dispute-lost:${resolution.disputeId}`);
441
- }
442
- }
443
-
444
- // If challenger wins, track engine health for majority peers
445
- if (resolution.outcome === 'challenger-wins') {
446
- const ourId = this.peerId.toString();
447
- if (resolution.affectedPeers.some(p => p.peerId === ourId)) {
448
- this.engineHealth.recordDisputeLoss();
449
- }
450
- }
451
- }
452
-
453
- /**
454
- * When a dispute resolves `challenger-wins`, originate the durable invalidation. Builds the
455
- * independently-verifiable {@link buildDisputeResolutionProof proof} and the {@link InvalidateRequest}
456
- * from the disputed transaction, then hands it to the injected `onInvalidation` originator (which
457
- * drives it through the critical cluster). Fires at most once per disputed transaction. A no-op
458
- * when no originator is wired (the in-memory status still flips via {@link getDisputeStatus}).
459
- */
460
- private async maybeInvalidate(resolution: DisputeResolution, record: ClusterRecord, arbitratorSet: string[]): Promise<void> {
461
- if (resolution.outcome !== 'challenger-wins' || !this.onInvalidation) {
462
- return;
463
- }
464
- if (this.invalidatedTransactions.has(record.messageHash)) {
465
- return;
466
- }
467
-
468
- const target = DisputeService.extractInvalidationTarget(record);
469
- if (!target) {
470
- log('dispute-invalidation-no-target', { disputeId: resolution.disputeId, messageHash: record.messageHash });
471
- return;
472
- }
473
-
474
- // Bind the legitimately-selected arbitrator set (#1): the challenger signs (disputeId, target, set)
475
- // so a third-party relay cannot swap in its own cohort. Same target derivation as the apply path.
476
- const certTarget: CertificateTarget = { invalidatedActionId: target.actionId, blockIds: target.blockIds };
477
- const targetHash = await computeTargetHash(record.messageHash, certTarget);
478
- const setHash = await computeArbitratorSetHash(arbitratorSet);
479
- const arbitratorSetSignature = await this.signArbitratorSet(resolution.disputeId, targetHash, setHash);
480
-
481
- this.invalidatedTransactions.add(record.messageHash);
482
- const request: InvalidateRequest = {
483
- invalidatedActionId: target.actionId,
484
- invalidatedRev: target.rev,
485
- blockIds: target.blockIds,
486
- collectionId: target.collectionId,
487
- resolution: buildDisputeResolutionProof(resolution, record.messageHash, {
488
- arbitratorSet,
489
- challengerPeerId: this.peerId.toString(),
490
- arbitratorSetSignature,
491
- }),
492
- };
493
-
494
- try {
495
- await this.onInvalidation(request);
496
- log('dispute-invalidation-originated', {
497
- disputeId: resolution.disputeId,
498
- invalidatedActionId: target.actionId,
499
- blockCount: target.blockIds.length,
500
- });
501
- } catch (err) {
502
- // Roll back the once-guard so a retry can re-originate.
503
- this.invalidatedTransactions.delete(record.messageHash);
504
- log('dispute-invalidation-originate-failed', {
505
- disputeId: resolution.disputeId,
506
- error: err instanceof Error ? err.message : String(err),
507
- });
508
- }
509
- }
510
-
511
- /**
512
- * Derives the invalidation target — the committed action, its revision, the blocks it wrote, and
513
- * its owning collection — from the disputed record. Prefers the commit operation (the disputed
514
- * transaction is committed); falls back to the pend operation defensively.
515
- */
516
- private static extractInvalidationTarget(record: ClusterRecord): { actionId: string; rev: number; blockIds: string[]; collectionId: CollectionId } | undefined {
517
- for (const operation of record.message.operations) {
518
- if ('commit' in operation) {
519
- const commit = operation.commit as CommitRequest;
520
- return {
521
- actionId: commit.actionId,
522
- rev: commit.rev,
523
- blockIds: commit.blockIds,
524
- collectionId: commit.headerId ?? record.coordinatingBlockIds?.[0] ?? commit.blockIds[0]!,
525
- };
526
- }
527
- }
528
- for (const operation of record.message.operations) {
529
- if ('pend' in operation) {
530
- const pend = operation.pend as PendRequest;
531
- const blockIds = blockIdsForTransforms(pend.transforms);
532
- return {
533
- actionId: pend.actionId,
534
- rev: pend.rev ?? 0,
535
- blockIds,
536
- collectionId: record.coordinatingBlockIds?.[0] ?? blockIds[0]!,
537
- };
538
- }
539
- }
540
- return undefined;
541
- }
542
-
543
- /** Broadcast resolution to all interested parties */
544
- private async broadcastResolution(
545
- resolution: DisputeResolution,
546
- arbitrators: PeerId[],
547
- originalPeers: string[]
548
- ): Promise<void> {
549
- const allTargets = new Set<string>();
550
- for (const arb of arbitrators) allTargets.add(arb.toString());
551
- for (const peer of originalPeers) allTargets.add(peer);
552
- // Don't send to self
553
- allTargets.delete(this.peerId.toString());
554
-
555
- const promises = Array.from(allTargets).map(async (peerIdStr) => {
556
- try {
557
- const { peerIdFromString } = await import('@libp2p/peer-id');
558
- const client = this.createDisputeClient(peerIdFromString(peerIdStr));
559
- await client.sendResolution(resolution);
560
- } catch (err) {
561
- log('dispute-broadcast-failed', {
562
- disputeId: resolution.disputeId,
563
- peer: peerIdStr,
564
- error: err instanceof Error ? err.message : String(err),
565
- });
566
- }
567
- });
568
-
569
- await Promise.allSettled(promises);
570
- }
571
-
572
- private async makeVote(
573
- disputeId: string,
574
- vote: ArbitrationVote['vote'],
575
- evidence: ValidationEvidence,
576
- targetHash: string,
577
- setHash: string
578
- ): Promise<ArbitrationVote> {
579
- // Target- and set-bound v3 payload: binds the vote to the specific transaction being reversed (#2)
580
- // AND the legitimately-selected arbitrator set (#1) so a genuine vote can neither be replayed against
581
- // an unrelated transaction nor presented under a swapped cohort.
582
- const payloadBytes = voteSigningPayload(disputeId, vote, evidence.computedHash, targetHash, setHash);
583
- const sigBytes = await this.privateKey.sign(payloadBytes);
584
-
585
- return {
586
- version: VOTE_VERSION,
587
- disputeId,
588
- arbitratorPeerId: this.peerId.toString(),
589
- vote,
590
- evidence,
591
- signature: uint8ArrayToString(sigBytes, 'base64url'),
592
- };
593
- }
594
-
595
- /**
596
- * Interim agreed-membership epoch bytes for the dispersed arbitrator draw: SHA-256 of the sorted,
597
- * comma-joined responsible peer-id set (the same set the admission gate agrees on). Sorted so the
598
- * digest is independent of enumeration order, so every honest node computes the identical epoch — the
599
- * property that lets the verify path re-derive the same arbitrator set. Replaced by the real agreed
600
- * membership epoch when `design-cluster-membership-agreement` lands.
601
- */
602
- private async computeEpochBytes(peers: string[]): Promise<Uint8Array> {
603
- const joined = [...peers].sort().join(',');
604
- const digest = await sha256.digest(new TextEncoder().encode(joined));
605
- return digest.digest;
606
- }
607
-
608
- private async computeDisputeId(messageHash: string, peerId: string, timestamp: number): Promise<string> {
609
- const input = `${messageHash}+${peerId}+${timestamp}`;
610
- const inputBytes = new TextEncoder().encode(input);
611
- const hashBytes = await sha256.digest(inputBytes);
612
- return base58btc.encode(hashBytes.digest);
613
- }
614
-
615
- private async signDispute(disputeId: string): Promise<string> {
616
- const payload = new TextEncoder().encode(disputeId);
617
- const sigBytes = await this.privateKey.sign(payload);
618
- return uint8ArrayToString(sigBytes, 'base64url');
619
- }
620
-
621
- /**
622
- * Sign the arbitrator-set binding `(disputeId, target, arbitratorSet)` with the challenger's key.
623
- * Carried on the proof as {@link DisputeResolutionProof.arbitratorSetSignature}; a verifier validates
624
- * it against this peer's embedded key (`challengerPeerId`), so a relay cannot substitute its own cohort.
625
- */
626
- private async signArbitratorSet(disputeId: string, targetHash: string, setHash: string): Promise<string> {
627
- const sigBytes = await this.privateKey.sign(arbitratorSetSigningPayload(disputeId, targetHash, setHash));
628
- return uint8ArrayToString(sigBytes, 'base64url');
629
- }
630
-
631
- private async verifyDisputeSignature(
632
- disputeId: string,
633
- signature: string,
634
- challengerPeerId: string,
635
- publicKey?: string | Uint8Array
636
- ): Promise<boolean> {
637
- if (!publicKey?.length) return false;
638
- try {
639
- const keyBytes = typeof publicKey === 'string'
640
- ? uint8ArrayFromString(publicKey, 'base64url')
641
- : publicKey;
642
- // The key must be the one the challenger's id provably names, else a forged challenge could be
643
- // attributed to any peer id while signed by a key the relay controls.
644
- if (!peerIdBindsPublicKey(challengerPeerId, keyBytes)) return false;
645
- const pubKey = publicKeyFromRaw(keyBytes);
646
- const payload = new TextEncoder().encode(disputeId);
647
- const sigBytes = uint8ArrayFromString(signature, 'base64url');
648
- return pubKey.verify(payload, sigBytes);
649
- } catch {
650
- return false;
651
- }
652
- }
653
-
654
- /**
655
- * Reconstruct the cluster promise-vote hash for `record` — identical to `ClusterMember.computePromiseHash`,
656
- * and version-dispatched the same way: a v2 record folds its membership digest into the preimage, a v1 /
657
- * unversioned record hashes byte-identically to before. The promise signatures in a disputed record were
658
- * produced over this by the cluster path, so re-verifying an approval requires reproducing it here — a v2
659
- * originalRecord whose digest we omitted would fail to verify its (honest) approvals.
660
- */
661
- private async computePromiseHash(record: ClusterRecord): Promise<string> {
662
- return computeClusterPromiseHash(record.messageHash, record.message, recordMembershipDigest(record));
663
- }
664
-
665
- /**
666
- * True iff `signature` on `record` is a binding-valid promise vote from `peerId`: the record's key for
667
- * `peerId` must be the one that id provably names AND the signature must verify over the reconstructed
668
- * promise-vote preimage. Total: returns `false` (never throws) on a missing/unbound/malformed key or an
669
- * undecodable signature. Mirrors `ClusterMember.verifySignature`'s binding-then-verify order.
670
- */
671
- private async verifyPromiseSignature(
672
- record: ClusterRecord,
673
- peerId: string,
674
- promiseHash: string,
675
- signature: Signature
676
- ): Promise<boolean> {
677
- const publicKey = record.peers[peerId]?.publicKey;
678
- if (!publicKey?.length) return false;
679
- try {
680
- const keyBytes = typeof publicKey === 'string'
681
- ? uint8ArrayFromString(publicKey, 'base64url')
682
- : publicKey;
683
- if (!peerIdBindsPublicKey(peerId, keyBytes)) return false;
684
- const pubKey = publicKeyFromRaw(keyBytes);
685
- const payload = clusterVoteVerificationPayload(promiseHash, signature);
686
- const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
687
- return pubKey.verify(payload, sigBytes);
688
- } catch {
689
- return false;
690
- }
691
- }
692
-
693
- private findChallengeForDispute(disputeId: string): DisputeChallenge | undefined {
694
- return this.activeDisputes.get(disputeId) ?? this.resolvedChallenges.get(disputeId);
695
- }
696
- }
1
+ import type { ClusterRecord, Signature, ITransactionValidator, InvalidateRequest, CommitRequest, PendRequest, CollectionId } from '@optimystic/db-core';
2
+ import { blockIdsForTransforms, computeClusterPromiseHash, clusterVoteVerificationPayload, recordMembershipDigest } from '@optimystic/db-core';
3
+ import { buildDisputeResolutionProof, computeTargetHash, computeArbitratorSetHash, voteSigningPayload, arbitratorSetSigningPayload, VOTE_VERSION, type CertificateTarget } from './invalidation.js';
4
+ import type { PeerId, PrivateKey } from '@libp2p/interface';
5
+ import { sha256 } from 'multiformats/hashes/sha2';
6
+ import { base58btc } from 'multiformats/bases/base58';
7
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
8
+ import { publicKeyFromRaw } from '@libp2p/crypto/keys';
9
+ import { peerIdBindsPublicKey } from '../cluster/peer-key-binding.js';
10
+ import type {
11
+ ValidationEvidence,
12
+ DisputeChallenge,
13
+ ArbitrationVote,
14
+ DisputeResolution,
15
+ DisputeConfig,
16
+ DisputeStatus,
17
+ } from './types.js';
18
+ import { DEFAULT_DISPUTE_CONFIG } from './types.js';
19
+ import { EngineHealthMonitor } from './engine-health-monitor.js';
20
+ import type { IPeerReputation } from '../reputation/types.js';
21
+ import { PenaltyReason } from '../reputation/types.js';
22
+ import { createLogger } from '../logger.js';
23
+ import type { IPeerNetwork } from '@optimystic/db-core';
24
+ import type { DisputeClient } from './client.js';
25
+
26
+ const log = createLogger('dispute');
27
+
28
+ /** Callback to create a DisputeClient for a given peer */
29
+ export type CreateDisputeClient = (peerId: PeerId) => DisputeClient;
30
+
31
+ /** Callback to re-execute a transaction and produce validation evidence */
32
+ export type RevalidateTransaction = (record: ClusterRecord) => Promise<ValidationEvidence | undefined>;
33
+
34
+ export interface DisputeServiceInit {
35
+ peerId: PeerId;
36
+ privateKey: PrivateKey;
37
+ peerNetwork: IPeerNetwork;
38
+ createDisputeClient: CreateDisputeClient;
39
+ reputation?: IPeerReputation;
40
+ validator?: ITransactionValidator;
41
+ revalidate?: RevalidateTransaction;
42
+ config?: Partial<DisputeConfig>;
43
+ /**
44
+ * Select arbitrators for a dispute via verifiable dispersed sampling (see `sampleArbitrators`): draw
45
+ * `count` distinct peers from coordinates spread across the whole keyspace, not the block's neighborhood.
46
+ * `round` (0-based escalation round) and `epoch` (agreed membership epoch bytes) are folded into every
47
+ * coordinate so the draw is deterministic yet not pre-positionable — an honest node re-derives the
48
+ * identical set from the same `(blockId, round, epoch)` + agreed membership.
49
+ */
50
+ selectArbitrators: (blockId: string, excludePeers: string[], count: number, round: number, epoch: Uint8Array) => Promise<PeerId[]>;
51
+ /**
52
+ * Originates the durable reversal when a dispute resolves `challenger-wins`. The dissent
53
+ * coordinator (the node that initiated the dispute, holding the original record) builds the
54
+ * {@link InvalidateRequest} and hands it here to be driven through the critical cluster as a
55
+ * consensus-ordered invalidation; every member then applies it deterministically. Absent on
56
+ * nodes not wired to originate invalidations (today's default) — the in-memory status still flips,
57
+ * but nothing durable is written until this is supplied.
58
+ */
59
+ onInvalidation?: (request: InvalidateRequest) => Promise<void> | void;
60
+ }
61
+
62
+ /**
63
+ * Manages the dispute escalation protocol.
64
+ *
65
+ * When a transaction proceeds despite minority rejections, the overridden
66
+ * minority can escalate to independent arbitrators. The service coordinates
67
+ * challenge initiation, arbitration vote collection, and resolution.
68
+ */
69
+ export class DisputeService {
70
+ private readonly peerId: PeerId;
71
+ private readonly privateKey: PrivateKey;
72
+ private readonly createDisputeClient: CreateDisputeClient;
73
+ private readonly reputation?: IPeerReputation;
74
+ private readonly revalidate?: RevalidateTransaction;
75
+ private readonly config: DisputeConfig;
76
+ private readonly engineHealth: EngineHealthMonitor;
77
+ private readonly selectArbitrators: DisputeServiceInit['selectArbitrators'];
78
+ private readonly onInvalidation?: DisputeServiceInit['onInvalidation'];
79
+
80
+ /** Active disputes initiated by this node */
81
+ private activeDisputes: Map<string, DisputeChallenge> = new Map();
82
+ /** Resolved disputes (disputeId -> resolution) */
83
+ private resolvedDisputes: Map<string, DisputeResolution> = new Map();
84
+ /** Challenges retained after resolution for status lookups */
85
+ private resolvedChallenges: Map<string, DisputeChallenge> = new Map();
86
+ /** Track which transactions we've already disputed (prevent spam) */
87
+ private disputedTransactions: Set<string> = new Set();
88
+ /** Track which transactions we've already originated an invalidation for (fire once per messageHash) */
89
+ private invalidatedTransactions: Set<string> = new Set();
90
+
91
+ constructor(init: DisputeServiceInit) {
92
+ this.peerId = init.peerId;
93
+ this.privateKey = init.privateKey;
94
+ this.createDisputeClient = init.createDisputeClient;
95
+ this.reputation = init.reputation;
96
+ this.revalidate = init.revalidate;
97
+ this.config = { ...DEFAULT_DISPUTE_CONFIG, ...init.config };
98
+ this.engineHealth = new EngineHealthMonitor(this.config);
99
+ this.selectArbitrators = init.selectArbitrators;
100
+ this.onInvalidation = init.onInvalidation;
101
+ }
102
+
103
+ /** Get the engine health monitor */
104
+ getEngineHealth(): EngineHealthMonitor {
105
+ return this.engineHealth;
106
+ }
107
+
108
+ /** Check if disputes are enabled */
109
+ isEnabled(): boolean {
110
+ return this.config.disputeEnabled;
111
+ }
112
+
113
+ /** Get the dispute status for a transaction, if any */
114
+ getDisputeStatus(messageHash: string): DisputeStatus | undefined {
115
+ // Check if there's an active dispute for this transaction
116
+ for (const [, challenge] of this.activeDisputes) {
117
+ if (challenge.originalMessageHash === messageHash) {
118
+ return 'committed-disputed';
119
+ }
120
+ }
121
+ // Check resolved disputes
122
+ for (const [, resolution] of this.resolvedDisputes) {
123
+ const challenge = this.findChallengeForDispute(resolution.disputeId);
124
+ if (challenge && challenge.originalMessageHash === messageHash) {
125
+ if (resolution.outcome === 'challenger-wins') return 'committed-invalidated';
126
+ if (resolution.outcome === 'majority-wins') return 'committed-validated';
127
+ return 'committed-disputed'; // inconclusive
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ /**
134
+ * Initiate a dispute when this node's rejection was overridden.
135
+ * Called by ClusterMember when it detects a disputed commit.
136
+ */
137
+ async initiateDispute(record: ClusterRecord, evidence: ValidationEvidence): Promise<DisputeResolution | undefined> {
138
+ if (!this.config.disputeEnabled) {
139
+ log('dispute-disabled', { messageHash: record.messageHash });
140
+ return undefined;
141
+ }
142
+
143
+ // One dispute per transaction
144
+ if (this.disputedTransactions.has(record.messageHash)) {
145
+ log('dispute-already-initiated', { messageHash: record.messageHash });
146
+ return undefined;
147
+ }
148
+
149
+ // Don't dispute if our engine is unhealthy
150
+ if (this.engineHealth.isUnhealthy()) {
151
+ log('dispute-skipped-unhealthy', { messageHash: record.messageHash });
152
+ return undefined;
153
+ }
154
+
155
+ this.disputedTransactions.add(record.messageHash);
156
+
157
+ const timestamp = Date.now();
158
+ const disputeId = await this.computeDisputeId(record.messageHash, this.peerId.toString(), timestamp);
159
+ const signature = await this.signDispute(disputeId);
160
+
161
+ const defaultTtl = record.message.expiration
162
+ ? (record.message.expiration - Date.now()) * 2
163
+ : this.config.disputeArbitrationTimeoutMs * 2;
164
+ const expiration = timestamp + Math.max(defaultTtl, this.config.disputeArbitrationTimeoutMs);
165
+
166
+ // Select the arbitrator set FIRST so the challenge can carry it: each arbitrator folds the set's
167
+ // digest into the v3 vote it signs, binding every vote to the legitimately-selected set (#1).
168
+ // Read off the hash-covered `record.message`; there is no top-level copy (see `ClusterRecord`).
169
+ // Every coordinator-built record now carries this field — `ClusterCoordinator` derives it from the
170
+ // cohort key — so the `record.messageHash` fallback below is reached only by a record built outside
171
+ // that path. Disputes over a commit/cancel therefore draw arbitrators by the same block key that
172
+ // selected the cohort, rather than by an opaque message hash, which is the intended draw.
173
+ const blockIds = record.message.coordinatingBlockIds ?? [];
174
+ const blockId = blockIds[0] ?? record.messageHash;
175
+ const originalPeers = Object.keys(record.peers);
176
+ const arbitratorCount = this.config.arbitratorCount ?? originalPeers.length;
177
+
178
+ // Round 0 is the only round today (single-round arbitration); `design-dispute-synchronous-escalation`
179
+ // drives real-time round progression. `epoch` pins the dispersed draw to a membership the attacker
180
+ // cannot freely advance.
181
+ // NOTE: interim agreed-membership epoch — hash of the admission-gate-agreed responsible set
182
+ // (`Object.keys(record.peers)`). When `design-cluster-membership-agreement` lands, `epoch` becomes
183
+ // the agreed membership epoch rather than this locally-hashed stand-in.
184
+ const round = 0;
185
+ const epoch = await this.computeEpochBytes(originalPeers);
186
+
187
+ let arbitrators: PeerId[];
188
+ try {
189
+ arbitrators = await this.selectArbitrators(blockId, originalPeers, arbitratorCount, round, epoch);
190
+ } catch (err) {
191
+ log('dispute-arbitrator-selection-failed', { disputeId, error: err instanceof Error ? err.message : String(err) });
192
+ return undefined;
193
+ }
194
+
195
+ if (arbitrators.length === 0) {
196
+ log('dispute-no-arbitrators', { disputeId });
197
+ return undefined;
198
+ }
199
+
200
+ const arbitratorSet = arbitrators.map(a => a.toString());
201
+
202
+ const challenge: DisputeChallenge = {
203
+ disputeId,
204
+ originalMessageHash: record.messageHash,
205
+ originalRecord: record,
206
+ challengerPeerId: this.peerId.toString(),
207
+ challengerEvidence: evidence,
208
+ signature,
209
+ timestamp,
210
+ expiration,
211
+ arbitratorSet,
212
+ };
213
+
214
+ this.activeDisputes.set(disputeId, challenge);
215
+ log('dispute-initiated', { disputeId, messageHash: record.messageHash });
216
+
217
+ // Send challenge to all arbitrators and collect votes
218
+ const votes = await this.collectVotes(challenge, arbitrators);
219
+ const resolution = await this.resolveDispute(challenge, votes);
220
+
221
+ this.resolvedChallenges.set(disputeId, challenge);
222
+ this.activeDisputes.delete(disputeId);
223
+ this.resolvedDisputes.set(disputeId, resolution);
224
+
225
+ // Apply reputation effects
226
+ this.applyReputationEffects(resolution, record);
227
+
228
+ // On a proven-invalid transaction, originate the durable reversal through the cluster. The
229
+ // challenger-selected `arbitratorSet` is bound into the proof (#1).
230
+ await this.maybeInvalidate(resolution, record, arbitratorSet);
231
+
232
+ // Broadcast resolution
233
+ await this.broadcastResolution(resolution, arbitrators, originalPeers);
234
+
235
+ log('dispute-resolved', {
236
+ disputeId,
237
+ outcome: resolution.outcome,
238
+ votes: votes.length,
239
+ affectedPeers: resolution.affectedPeers.length,
240
+ });
241
+
242
+ return resolution;
243
+ }
244
+
245
+ /**
246
+ * Handle an incoming dispute challenge (when this node is selected as arbitrator).
247
+ * Re-executes the transaction and returns a vote.
248
+ */
249
+ async handleChallenge(challenge: DisputeChallenge): Promise<ArbitrationVote> {
250
+ log('dispute-handle-challenge', { disputeId: challenge.disputeId });
251
+
252
+ // Bind every vote to the disputed transaction AND the legitimately-selected arbitrator set: derive
253
+ // the reversal target the same way the originator/apply path does (so the verifier recomputes an
254
+ // identical targetHash), digest the set the challenger carried, and sign over both (#1, #2).
255
+ const targetHash = await this.computeChallengeTargetHash(challenge);
256
+ const setHash = await computeArbitratorSetHash(challenge.arbitratorSet ?? []);
257
+
258
+ // Verify the challenge signature — bound to the challenger's id so a relay cannot attach a key it
259
+ // controls under some other peer's id and pass a forged challenge as that peer.
260
+ const validSignature = await this.verifyDisputeSignature(
261
+ challenge.disputeId,
262
+ challenge.signature,
263
+ challenge.challengerPeerId,
264
+ challenge.originalRecord.peers[challenge.challengerPeerId]?.publicKey
265
+ );
266
+
267
+ if (!validSignature) {
268
+ log('dispute-invalid-challenge-signature', { disputeId: challenge.disputeId });
269
+ return this.makeVote(challenge.disputeId, 'inconclusive', {
270
+ computedHash: '',
271
+ engineId: 'unknown',
272
+ schemaHash: '',
273
+ blockStateHashes: {},
274
+ }, targetHash, setHash);
275
+ }
276
+
277
+ // Re-execute the transaction to produce our own evidence
278
+ let evidence: ValidationEvidence | undefined;
279
+ if (this.revalidate) {
280
+ try {
281
+ evidence = await this.revalidate(challenge.originalRecord);
282
+ } catch (err) {
283
+ log('dispute-revalidation-failed', {
284
+ disputeId: challenge.disputeId,
285
+ error: err instanceof Error ? err.message : String(err),
286
+ });
287
+ }
288
+ }
289
+
290
+ if (!evidence) {
291
+ // Can't re-execute — vote inconclusive
292
+ return this.makeVote(challenge.disputeId, 'inconclusive', {
293
+ computedHash: '',
294
+ engineId: 'unknown',
295
+ schemaHash: '',
296
+ blockStateHashes: {},
297
+ }, targetHash, setHash);
298
+ }
299
+
300
+ // Compare our evidence with the challenger's
301
+ let vote: ArbitrationVote['vote'];
302
+ if (evidence.computedHash === challenge.challengerEvidence.computedHash) {
303
+ // Our re-execution matches the challenger — the challenger is right
304
+ vote = 'agree-with-challenger';
305
+ } else {
306
+ // Our re-execution differs from the challenger — the majority is likely right
307
+ vote = 'agree-with-majority';
308
+ }
309
+
310
+ return this.makeVote(challenge.disputeId, vote, evidence, targetHash, setHash);
311
+ }
312
+
313
+ /**
314
+ * Derives the v2 target hash binding a vote to the disputed transaction: its `messageHash` plus the
315
+ * reversal target (the committed action being reversed and the blocks it wrote), extracted from the
316
+ * challenge's `originalRecord` exactly as {@link extractInvalidationTarget} does on the originate/apply
317
+ * side. A record with no extractable target yields an empty target — the resulting vote still verifies
318
+ * self-consistently but will not match a real reversal, which is correct (no real reversal exists).
319
+ */
320
+ private async computeChallengeTargetHash(challenge: DisputeChallenge): Promise<string> {
321
+ const target = DisputeService.extractInvalidationTarget(challenge.originalRecord);
322
+ const certTarget: CertificateTarget = target
323
+ ? { invalidatedActionId: target.actionId, blockIds: target.blockIds }
324
+ : { invalidatedActionId: '', blockIds: [] };
325
+ return computeTargetHash(challenge.originalRecord.messageHash, certTarget);
326
+ }
327
+
328
+ /**
329
+ * Handle an incoming dispute resolution (broadcast from the dispute initiator).
330
+ */
331
+ handleResolution(resolution: DisputeResolution): void {
332
+ this.resolvedDisputes.set(resolution.disputeId, resolution);
333
+ log('dispute-resolution-received', {
334
+ disputeId: resolution.disputeId,
335
+ outcome: resolution.outcome,
336
+ });
337
+
338
+ // If we were penalized and the challenger won, check engine health
339
+ const ourId = this.peerId.toString();
340
+ const ourPenalty = resolution.affectedPeers.find(p => p.peerId === ourId);
341
+ if (ourPenalty && ourPenalty.reason === 'false-approval') {
342
+ this.engineHealth.recordDisputeLoss();
343
+ }
344
+ }
345
+
346
+ /** Collect votes from arbitrators with a timeout */
347
+ private async collectVotes(challenge: DisputeChallenge, arbitrators: PeerId[]): Promise<ArbitrationVote[]> {
348
+ const timeoutMs = this.config.disputeArbitrationTimeoutMs;
349
+ const votes: ArbitrationVote[] = [];
350
+
351
+ const votePromises = arbitrators.map(async (arbitratorPeerId) => {
352
+ try {
353
+ const client = this.createDisputeClient(arbitratorPeerId);
354
+ const vote = await client.sendChallenge(challenge, timeoutMs);
355
+ return vote;
356
+ } catch (err) {
357
+ log('dispute-vote-collection-failed', {
358
+ disputeId: challenge.disputeId,
359
+ arbitrator: arbitratorPeerId.toString(),
360
+ error: err instanceof Error ? err.message : String(err),
361
+ });
362
+ return undefined;
363
+ }
364
+ });
365
+
366
+ const results = await Promise.allSettled(votePromises);
367
+ for (const result of results) {
368
+ if (result.status === 'fulfilled' && result.value) {
369
+ votes.push(result.value);
370
+ }
371
+ }
372
+
373
+ return votes;
374
+ }
375
+
376
+ /** Determine dispute resolution from collected votes */
377
+ async resolveDispute(challenge: DisputeChallenge, votes: ArbitrationVote[]): Promise<DisputeResolution> {
378
+ const challengerVotes = votes.filter(v => v.vote === 'agree-with-challenger').length;
379
+ const majorityVotes = votes.filter(v => v.vote === 'agree-with-majority').length;
380
+ const totalDecisive = challengerVotes + majorityVotes;
381
+
382
+ // Need super-majority of decisive votes (>2/3)
383
+ const superMajorityThreshold = Math.ceil(totalDecisive * 2 / 3);
384
+
385
+ let outcome: DisputeResolution['outcome'];
386
+ const affectedPeers: DisputeResolution['affectedPeers'] = [];
387
+
388
+ if (totalDecisive === 0) {
389
+ outcome = 'inconclusive';
390
+ } else if (challengerVotes >= superMajorityThreshold) {
391
+ outcome = 'challenger-wins';
392
+ // Penalize majority peers who approved the transaction. The promise signatures in a challenge's
393
+ // originalRecord are NOT otherwise verified on the dispute path, so an attacker who crafts a
394
+ // challenge carrying a fabricated originalRecord could attach a forged approval under an honest
395
+ // peer's id to get that peer a FalseApproval penalty. Gate each false-approval on the approval
396
+ // being binding-valid (key bound to the id AND signature verifies); skip never penalize —
397
+ // any approval that is unbound or invalid.
398
+ //
399
+ // For the same reason, the peer multiaddrs inside `originalRecord.peers` are deliberately
400
+ // NOT fed to the address book here, unlike the cluster/redirect paths (see
401
+ // `peer-address-book.ts`). Those paths consume a record we are an intended recipient of;
402
+ // a challenge's originalRecord is attacker-supplied on a path that already flags itself as
403
+ // unverified, so learning addresses from it would widen that surface for no gain — a
404
+ // disputing peer is not one we are about to need a route to.
405
+ const originalRecord = challenge.originalRecord;
406
+ const promiseHash = await this.computePromiseHash(originalRecord);
407
+ for (const [peerId, signature] of Object.entries(originalRecord.promises)) {
408
+ if (signature.type === 'approve' && peerId !== challenge.challengerPeerId) {
409
+ if (await this.verifyPromiseSignature(originalRecord, peerId, promiseHash, signature)) {
410
+ affectedPeers.push({ peerId, reason: 'false-approval' });
411
+ } else {
412
+ log('dispute-skip-unverified-approval', { disputeId: challenge.disputeId, peerId });
413
+ }
414
+ }
415
+ }
416
+ } else if (majorityVotes >= superMajorityThreshold) {
417
+ outcome = 'majority-wins';
418
+ // Penalize the challenger
419
+ affectedPeers.push({ peerId: challenge.challengerPeerId, reason: 'dispute-lost' });
420
+ } else {
421
+ outcome = 'inconclusive';
422
+ }
423
+
424
+ return {
425
+ disputeId: challenge.disputeId,
426
+ outcome,
427
+ votes,
428
+ affectedPeers,
429
+ timestamp: Date.now(),
430
+ };
431
+ }
432
+
433
+ /** Apply reputation effects based on dispute resolution */
434
+ private applyReputationEffects(resolution: DisputeResolution, _record: ClusterRecord): void {
435
+ if (!this.reputation) return;
436
+
437
+ for (const affected of resolution.affectedPeers) {
438
+ if (affected.reason === 'false-approval') {
439
+ // Weight: 40 as specified in ticket
440
+ this.reputation.reportPeer(affected.peerId, PenaltyReason.FalseApproval,
441
+ `dispute:false-approval:${resolution.disputeId}`);
442
+ } else if (affected.reason === 'dispute-lost') {
443
+ // Weight: 30 as specified in ticket
444
+ this.reputation.reportPeer(affected.peerId, PenaltyReason.DisputeLost,
445
+ `dispute:dispute-lost:${resolution.disputeId}`);
446
+ }
447
+ }
448
+
449
+ // If challenger wins, track engine health for majority peers
450
+ if (resolution.outcome === 'challenger-wins') {
451
+ const ourId = this.peerId.toString();
452
+ if (resolution.affectedPeers.some(p => p.peerId === ourId)) {
453
+ this.engineHealth.recordDisputeLoss();
454
+ }
455
+ }
456
+ }
457
+
458
+ /**
459
+ * When a dispute resolves `challenger-wins`, originate the durable invalidation. Builds the
460
+ * independently-verifiable {@link buildDisputeResolutionProof proof} and the {@link InvalidateRequest}
461
+ * from the disputed transaction, then hands it to the injected `onInvalidation` originator (which
462
+ * drives it through the critical cluster). Fires at most once per disputed transaction. A no-op
463
+ * when no originator is wired (the in-memory status still flips via {@link getDisputeStatus}).
464
+ */
465
+ private async maybeInvalidate(resolution: DisputeResolution, record: ClusterRecord, arbitratorSet: string[]): Promise<void> {
466
+ if (resolution.outcome !== 'challenger-wins' || !this.onInvalidation) {
467
+ return;
468
+ }
469
+ if (this.invalidatedTransactions.has(record.messageHash)) {
470
+ return;
471
+ }
472
+
473
+ const target = DisputeService.extractInvalidationTarget(record);
474
+ if (!target) {
475
+ log('dispute-invalidation-no-target', { disputeId: resolution.disputeId, messageHash: record.messageHash });
476
+ return;
477
+ }
478
+
479
+ // Bind the legitimately-selected arbitrator set (#1): the challenger signs (disputeId, target, set)
480
+ // so a third-party relay cannot swap in its own cohort. Same target derivation as the apply path.
481
+ const certTarget: CertificateTarget = { invalidatedActionId: target.actionId, blockIds: target.blockIds };
482
+ const targetHash = await computeTargetHash(record.messageHash, certTarget);
483
+ const setHash = await computeArbitratorSetHash(arbitratorSet);
484
+ const arbitratorSetSignature = await this.signArbitratorSet(resolution.disputeId, targetHash, setHash);
485
+
486
+ this.invalidatedTransactions.add(record.messageHash);
487
+ const request: InvalidateRequest = {
488
+ invalidatedActionId: target.actionId,
489
+ invalidatedRev: target.rev,
490
+ blockIds: target.blockIds,
491
+ collectionId: target.collectionId,
492
+ resolution: buildDisputeResolutionProof(resolution, record.messageHash, {
493
+ arbitratorSet,
494
+ challengerPeerId: this.peerId.toString(),
495
+ arbitratorSetSignature,
496
+ }),
497
+ };
498
+
499
+ try {
500
+ await this.onInvalidation(request);
501
+ log('dispute-invalidation-originated', {
502
+ disputeId: resolution.disputeId,
503
+ invalidatedActionId: target.actionId,
504
+ blockCount: target.blockIds.length,
505
+ });
506
+ } catch (err) {
507
+ // Roll back the once-guard so a retry can re-originate.
508
+ this.invalidatedTransactions.delete(record.messageHash);
509
+ log('dispute-invalidation-originate-failed', {
510
+ disputeId: resolution.disputeId,
511
+ error: err instanceof Error ? err.message : String(err),
512
+ });
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Derives the invalidation target — the committed action, its revision, the blocks it wrote, and
518
+ * its owning collection — from the disputed record. Prefers the commit operation (the disputed
519
+ * transaction is committed); falls back to the pend operation defensively.
520
+ *
521
+ * The `collectionId` fallbacks read the hash-covered `record.message.coordinatingBlockIds`. On the
522
+ * commit arm that field is now always present (the coordinator derives it from `commit.blockIds[0]`,
523
+ * the very key it selected the cohort by), so the middle fallback resolves to the same id the final
524
+ * `commit.blockIds[0]` fallback used to produce — the branch taken changed, the id did not.
525
+ */
526
+ private static extractInvalidationTarget(record: ClusterRecord): { actionId: string; rev: number; blockIds: string[]; collectionId: CollectionId } | undefined {
527
+ for (const operation of record.message.operations) {
528
+ if ('commit' in operation) {
529
+ const commit = operation.commit as CommitRequest;
530
+ return {
531
+ actionId: commit.actionId,
532
+ rev: commit.rev,
533
+ blockIds: commit.blockIds,
534
+ collectionId: commit.headerId ?? record.message.coordinatingBlockIds?.[0] ?? commit.blockIds[0]!,
535
+ };
536
+ }
537
+ }
538
+ for (const operation of record.message.operations) {
539
+ if ('pend' in operation) {
540
+ const pend = operation.pend as PendRequest;
541
+ const blockIds = blockIdsForTransforms(pend.transforms);
542
+ return {
543
+ actionId: pend.actionId,
544
+ rev: pend.rev ?? 0,
545
+ blockIds,
546
+ collectionId: record.message.coordinatingBlockIds?.[0] ?? blockIds[0]!,
547
+ };
548
+ }
549
+ }
550
+ return undefined;
551
+ }
552
+
553
+ /** Broadcast resolution to all interested parties */
554
+ private async broadcastResolution(
555
+ resolution: DisputeResolution,
556
+ arbitrators: PeerId[],
557
+ originalPeers: string[]
558
+ ): Promise<void> {
559
+ const allTargets = new Set<string>();
560
+ for (const arb of arbitrators) allTargets.add(arb.toString());
561
+ for (const peer of originalPeers) allTargets.add(peer);
562
+ // Don't send to self
563
+ allTargets.delete(this.peerId.toString());
564
+
565
+ const promises = Array.from(allTargets).map(async (peerIdStr) => {
566
+ try {
567
+ const { peerIdFromString } = await import('@libp2p/peer-id');
568
+ const client = this.createDisputeClient(peerIdFromString(peerIdStr));
569
+ await client.sendResolution(resolution);
570
+ } catch (err) {
571
+ log('dispute-broadcast-failed', {
572
+ disputeId: resolution.disputeId,
573
+ peer: peerIdStr,
574
+ error: err instanceof Error ? err.message : String(err),
575
+ });
576
+ }
577
+ });
578
+
579
+ await Promise.allSettled(promises);
580
+ }
581
+
582
+ private async makeVote(
583
+ disputeId: string,
584
+ vote: ArbitrationVote['vote'],
585
+ evidence: ValidationEvidence,
586
+ targetHash: string,
587
+ setHash: string
588
+ ): Promise<ArbitrationVote> {
589
+ // Target- and set-bound v3 payload: binds the vote to the specific transaction being reversed (#2)
590
+ // AND the legitimately-selected arbitrator set (#1) so a genuine vote can neither be replayed against
591
+ // an unrelated transaction nor presented under a swapped cohort.
592
+ const payloadBytes = voteSigningPayload(disputeId, vote, evidence.computedHash, targetHash, setHash);
593
+ const sigBytes = await this.privateKey.sign(payloadBytes);
594
+
595
+ return {
596
+ version: VOTE_VERSION,
597
+ disputeId,
598
+ arbitratorPeerId: this.peerId.toString(),
599
+ vote,
600
+ evidence,
601
+ signature: uint8ArrayToString(sigBytes, 'base64url'),
602
+ };
603
+ }
604
+
605
+ /**
606
+ * Interim agreed-membership epoch bytes for the dispersed arbitrator draw: SHA-256 of the sorted,
607
+ * comma-joined responsible peer-id set (the same set the admission gate agrees on). Sorted so the
608
+ * digest is independent of enumeration order, so every honest node computes the identical epoch — the
609
+ * property that lets the verify path re-derive the same arbitrator set. Replaced by the real agreed
610
+ * membership epoch when `design-cluster-membership-agreement` lands.
611
+ */
612
+ private async computeEpochBytes(peers: string[]): Promise<Uint8Array> {
613
+ const joined = [...peers].sort().join(',');
614
+ const digest = await sha256.digest(new TextEncoder().encode(joined));
615
+ return digest.digest;
616
+ }
617
+
618
+ private async computeDisputeId(messageHash: string, peerId: string, timestamp: number): Promise<string> {
619
+ const input = `${messageHash}+${peerId}+${timestamp}`;
620
+ const inputBytes = new TextEncoder().encode(input);
621
+ const hashBytes = await sha256.digest(inputBytes);
622
+ return base58btc.encode(hashBytes.digest);
623
+ }
624
+
625
+ private async signDispute(disputeId: string): Promise<string> {
626
+ const payload = new TextEncoder().encode(disputeId);
627
+ const sigBytes = await this.privateKey.sign(payload);
628
+ return uint8ArrayToString(sigBytes, 'base64url');
629
+ }
630
+
631
+ /**
632
+ * Sign the arbitrator-set binding `(disputeId, target, arbitratorSet)` with the challenger's key.
633
+ * Carried on the proof as {@link DisputeResolutionProof.arbitratorSetSignature}; a verifier validates
634
+ * it against this peer's embedded key (`challengerPeerId`), so a relay cannot substitute its own cohort.
635
+ */
636
+ private async signArbitratorSet(disputeId: string, targetHash: string, setHash: string): Promise<string> {
637
+ const sigBytes = await this.privateKey.sign(arbitratorSetSigningPayload(disputeId, targetHash, setHash));
638
+ return uint8ArrayToString(sigBytes, 'base64url');
639
+ }
640
+
641
+ private async verifyDisputeSignature(
642
+ disputeId: string,
643
+ signature: string,
644
+ challengerPeerId: string,
645
+ publicKey?: string | Uint8Array
646
+ ): Promise<boolean> {
647
+ if (!publicKey?.length) return false;
648
+ try {
649
+ const keyBytes = typeof publicKey === 'string'
650
+ ? uint8ArrayFromString(publicKey, 'base64url')
651
+ : publicKey;
652
+ // The key must be the one the challenger's id provably names, else a forged challenge could be
653
+ // attributed to any peer id while signed by a key the relay controls.
654
+ if (!peerIdBindsPublicKey(challengerPeerId, keyBytes)) return false;
655
+ const pubKey = publicKeyFromRaw(keyBytes);
656
+ const payload = new TextEncoder().encode(disputeId);
657
+ const sigBytes = uint8ArrayFromString(signature, 'base64url');
658
+ return pubKey.verify(payload, sigBytes);
659
+ } catch {
660
+ return false;
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Reconstruct the cluster promise-vote hash for `record` — identical to `ClusterMember.computePromiseHash`,
666
+ * and version-dispatched the same way: a v2 record folds its membership digest into the preimage, a v1 /
667
+ * unversioned record hashes byte-identically to before. The promise signatures in a disputed record were
668
+ * produced over this by the cluster path, so re-verifying an approval requires reproducing it here — a v2
669
+ * originalRecord whose digest we omitted would fail to verify its (honest) approvals.
670
+ */
671
+ private async computePromiseHash(record: ClusterRecord): Promise<string> {
672
+ return computeClusterPromiseHash(record.messageHash, record.message, recordMembershipDigest(record));
673
+ }
674
+
675
+ /**
676
+ * True iff `signature` on `record` is a binding-valid promise vote from `peerId`: the record's key for
677
+ * `peerId` must be the one that id provably names AND the signature must verify over the reconstructed
678
+ * promise-vote preimage. Total: returns `false` (never throws) on a missing/unbound/malformed key or an
679
+ * undecodable signature. Mirrors `ClusterMember.verifySignature`'s binding-then-verify order.
680
+ */
681
+ private async verifyPromiseSignature(
682
+ record: ClusterRecord,
683
+ peerId: string,
684
+ promiseHash: string,
685
+ signature: Signature
686
+ ): Promise<boolean> {
687
+ const publicKey = record.peers[peerId]?.publicKey;
688
+ if (!publicKey?.length) return false;
689
+ try {
690
+ const keyBytes = typeof publicKey === 'string'
691
+ ? uint8ArrayFromString(publicKey, 'base64url')
692
+ : publicKey;
693
+ if (!peerIdBindsPublicKey(peerId, keyBytes)) return false;
694
+ const pubKey = publicKeyFromRaw(keyBytes);
695
+ const payload = clusterVoteVerificationPayload(promiseHash, signature);
696
+ const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
697
+ return pubKey.verify(payload, sigBytes);
698
+ } catch {
699
+ return false;
700
+ }
701
+ }
702
+
703
+ private findChallengeForDispute(disputeId: string): DisputeChallenge | undefined {
704
+ return this.activeDisputes.get(disputeId) ?? this.resolvedChallenges.get(disputeId);
705
+ }
706
+ }