@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.
- package/{readme.md → README.md} +688 -668
- package/dist/src/cluster/block-transfer-service.d.ts +133 -10
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +155 -9
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/block-transfer.d.ts +67 -7
- package/dist/src/cluster/block-transfer.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer.js +127 -24
- package/dist/src/cluster/block-transfer.js.map +1 -1
- package/dist/src/cluster/certified-claims.d.ts +170 -0
- package/dist/src/cluster/certified-claims.d.ts.map +1 -0
- package/dist/src/cluster/certified-claims.js +200 -0
- package/dist/src/cluster/certified-claims.js.map +1 -0
- package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
- package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
- package/dist/src/cluster/client-signature-verifier.js +33 -0
- package/dist/src/cluster/client-signature-verifier.js.map +1 -0
- package/dist/src/cluster/cluster-policy.d.ts +9 -3
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
- package/dist/src/cluster/cluster-policy.js +111 -27
- package/dist/src/cluster/cluster-policy.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +140 -11
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +485 -29
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/commit-proof.d.ts +134 -0
- package/dist/src/cluster/commit-proof.d.ts.map +1 -0
- package/dist/src/cluster/commit-proof.js +263 -0
- package/dist/src/cluster/commit-proof.js.map +1 -0
- package/dist/src/cluster/quorum-restore.d.ts +95 -8
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +113 -14
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
- package/dist/src/cluster/rebalance-monitor.js +207 -5
- package/dist/src/cluster/rebalance-monitor.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts +27 -7
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +179 -38
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/cluster/service.d.ts +13 -4
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +17 -18
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts +8 -2
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +7 -7
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +8 -7
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +7 -6
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +14 -41
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/dispute/cascade.d.ts +7 -8
- package/dist/src/dispute/cascade.d.ts.map +1 -1
- package/dist/src/dispute/cascade.js +24 -7
- package/dist/src/dispute/cascade.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts +5 -0
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +13 -3
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/dispute/invalidation.d.ts +33 -15
- package/dist/src/dispute/invalidation.d.ts.map +1 -1
- package/dist/src/dispute/invalidation.js +127 -34
- package/dist/src/dispute/invalidation.js.map +1 -1
- package/dist/src/dispute/service.d.ts.map +1 -1
- package/dist/src/dispute/service.js +2 -1
- package/dist/src/dispute/service.js.map +1 -1
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +14 -10
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +28 -58
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +38 -7
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +148 -63
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/network/open-protocol-stream.d.ts +56 -0
- package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
- package/dist/src/network/open-protocol-stream.js +60 -0
- package/dist/src/network/open-protocol-stream.js.map +1 -0
- package/dist/src/network/register-protocol-handler.d.ts +66 -0
- package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
- package/dist/src/network/register-protocol-handler.js +51 -0
- package/dist/src/network/register-protocol-handler.js.map +1 -0
- package/dist/src/peer-address-book.d.ts +60 -2
- package/dist/src/peer-address-book.d.ts.map +1 -1
- package/dist/src/peer-address-book.js +89 -1
- package/dist/src/peer-address-book.js.map +1 -1
- package/dist/src/pend-validation.d.ts +65 -0
- package/dist/src/pend-validation.d.ts.map +1 -0
- package/dist/src/pend-validation.js +69 -0
- package/dist/src/pend-validation.js.map +1 -0
- package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
- package/dist/src/reactivity/notify-transport.js +2 -1
- package/dist/src/reactivity/notify-transport.js.map +1 -1
- package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
- package/dist/src/reactivity/push-state-gossip.js +2 -1
- package/dist/src/reactivity/push-state-gossip.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +32 -6
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +44 -10
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +181 -25
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +650 -62
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/served-repo-proxy.d.ts +30 -0
- package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
- package/dist/src/repo/served-repo-proxy.js +50 -0
- package/dist/src/repo/served-repo-proxy.js.map +1 -0
- package/dist/src/repo/service.d.ts +16 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +18 -16
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/reputation/types.d.ts +3 -1
- package/dist/src/reputation/types.d.ts.map +1 -1
- package/dist/src/reputation/types.js +3 -1
- package/dist/src/reputation/types.js.map +1 -1
- package/dist/src/rn.d.ts +24 -2
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +33 -4
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/block-archive.d.ts +153 -0
- package/dist/src/storage/block-archive.d.ts.map +1 -0
- package/dist/src/storage/block-archive.js +194 -0
- package/dist/src/storage/block-archive.js.map +1 -0
- package/dist/src/storage/block-latch.d.ts +92 -0
- package/dist/src/storage/block-latch.d.ts.map +1 -0
- package/dist/src/storage/block-latch.js +132 -0
- package/dist/src/storage/block-latch.js.map +1 -0
- package/dist/src/storage/block-storage.d.ts +169 -12
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +415 -152
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +5 -1
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/cached-raw-storage.js +16 -1
- package/dist/src/storage/cached-raw-storage.js.map +1 -1
- package/dist/src/storage/cached-store-driver.d.ts +14 -4
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
- package/dist/src/storage/cached-store-driver.js +39 -5
- package/dist/src/storage/cached-store-driver.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +70 -19
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js +21 -1
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +38 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +14 -8
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.js +29 -9
- package/dist/src/storage/kv-raw-storage.js.map +1 -1
- package/dist/src/storage/memory-store-driver.d.ts +4 -1
- package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
- package/dist/src/storage/memory-store-driver.js +22 -2
- package/dist/src/storage/memory-store-driver.js.map +1 -1
- package/dist/src/storage/raw-store-codec.js +2 -2
- package/dist/src/storage/raw-store-driver.d.ts +44 -3
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +60 -3
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.js +68 -4
- package/dist/src/storage/shared-cache-pool.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +204 -26
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +422 -119
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/store-identity.d.ts +21 -0
- package/dist/src/storage/store-identity.d.ts.map +1 -0
- package/dist/src/storage/store-identity.js +33 -0
- package/dist/src/storage/store-identity.js.map +1 -0
- package/dist/src/storage/struct.d.ts +15 -0
- package/dist/src/storage/struct.d.ts.map +1 -1
- package/dist/src/storage/with-read-cache.d.ts +92 -0
- package/dist/src/storage/with-read-cache.d.ts.map +1 -0
- package/dist/src/storage/with-read-cache.js +132 -0
- package/dist/src/storage/with-read-cache.js.map +1 -0
- package/dist/src/sync/service.d.ts +13 -3
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +7 -30
- package/dist/src/sync/service.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +75 -2
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +193 -81
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +97 -2
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +5 -6
- package/src/cluster/block-transfer-service.ts +528 -312
- package/src/cluster/block-transfer.ts +547 -424
- package/src/cluster/certified-claims.ts +319 -0
- package/src/cluster/client-signature-verifier.ts +34 -0
- package/src/cluster/cluster-policy.ts +294 -203
- package/src/cluster/cluster-repo.ts +2501 -2034
- package/src/cluster/commit-proof.ts +344 -0
- package/src/cluster/quorum-restore.ts +348 -223
- package/src/cluster/rebalance-monitor.ts +539 -254
- package/src/cluster/reconcile-block.ts +404 -203
- package/src/cluster/service.ts +23 -20
- package/src/cluster/spread-on-churn.ts +352 -346
- package/src/cohort-topic/host.ts +8 -7
- package/src/cohort-topic/stream-util.ts +14 -43
- package/src/dispute/cascade.ts +31 -15
- package/src/dispute/dispute-service.ts +706 -696
- package/src/dispute/invalidation.ts +162 -52
- package/src/dispute/service.ts +126 -125
- package/src/index.ts +7 -0
- package/src/libp2p-key-network.ts +1204 -1235
- package/src/libp2p-node-base.ts +185 -70
- package/src/network/open-protocol-stream.ts +87 -0
- package/src/network/register-protocol-handler.ts +85 -0
- package/src/peer-address-book.ts +96 -2
- package/src/pend-validation.ts +89 -0
- package/src/reactivity/notify-transport.ts +2 -1
- package/src/reactivity/push-state-gossip.ts +2 -1
- package/src/repo/cluster-coordinator.ts +65 -11
- package/src/repo/coordinator-repo.ts +729 -71
- package/src/repo/served-repo-proxy.ts +55 -0
- package/src/repo/service.ts +25 -16
- package/src/reputation/types.ts +3 -1
- package/src/rn.ts +34 -4
- package/src/storage/block-archive.ts +242 -0
- package/src/storage/block-latch.ts +144 -0
- package/src/storage/block-storage.ts +787 -498
- package/src/storage/cached-raw-storage.ts +21 -1
- package/src/storage/cached-store-driver.ts +898 -859
- package/src/storage/i-block-storage.ts +172 -116
- package/src/storage/i-raw-storage.ts +40 -0
- package/src/storage/kv-raw-storage.ts +34 -9
- package/src/storage/memory-store-driver.ts +26 -2
- package/src/storage/raw-store-codec.ts +42 -42
- package/src/storage/raw-store-driver.ts +45 -3
- package/src/storage/shared-cache-pool.ts +516 -452
- package/src/storage/storage-repo.ts +1421 -1014
- package/src/storage/store-identity.ts +41 -0
- package/src/storage/struct.ts +52 -34
- package/src/storage/with-read-cache.ts +182 -0
- package/src/sync/service.ts +178 -198
- package/src/testing/mesh-harness.ts +524 -344
- 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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
log('dispute-
|
|
192
|
-
return undefined;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
this.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
this.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
outcome
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if (
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
const sigBytes = await this.privateKey.sign(
|
|
628
|
-
return uint8ArrayToString(sigBytes, 'base64url');
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
): Promise<
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*/
|
|
671
|
-
private async
|
|
672
|
-
record
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
+
}
|