@optimystic/db-p2p 0.14.0 → 0.16.2
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 +616 -609
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +4 -3
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/block-transfer.d.ts +52 -3
- package/dist/src/cluster/block-transfer.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer.js +129 -6
- package/dist/src/cluster/block-transfer.js.map +1 -1
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +3 -1
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +182 -11
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +429 -69
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/peer-key-binding.d.ts +25 -0
- package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
- package/dist/src/cluster/peer-key-binding.js +39 -0
- package/dist/src/cluster/peer-key-binding.js.map +1 -0
- package/dist/src/cluster/quorum-restore.d.ts +72 -0
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
- package/dist/src/cluster/quorum-restore.js +0 -0
- package/dist/src/cluster/quorum-restore.js.map +1 -0
- package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
- package/dist/src/cluster/rebalance-monitor.js +17 -2
- package/dist/src/cluster/rebalance-monitor.js.map +1 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +7 -1
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts +7 -0
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +4 -1
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
- package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
- package/dist/src/cluster/supermajority-coupling.js +27 -0
- package/dist/src/cluster/supermajority-coupling.js.map +1 -0
- package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
- package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
- package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
- package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
- package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
- package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
- package/dist/src/cohort-topic/host.d.ts +197 -27
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +643 -87
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
- package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
- package/dist/src/dispute/arbitrator-selection.js +105 -16
- package/dist/src/dispute/arbitrator-selection.js.map +1 -1
- package/dist/src/dispute/cascade.d.ts +7 -0
- package/dist/src/dispute/cascade.d.ts.map +1 -1
- package/dist/src/dispute/cascade.js +1 -1
- package/dist/src/dispute/cascade.js.map +1 -1
- package/dist/src/dispute/client.d.ts.map +1 -1
- package/dist/src/dispute/client.js +3 -2
- package/dist/src/dispute/client.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts +32 -3
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +82 -9
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/dispute/index.d.ts +1 -1
- package/dist/src/dispute/index.d.ts.map +1 -1
- package/dist/src/dispute/index.js +1 -1
- package/dist/src/dispute/index.js.map +1 -1
- package/dist/src/dispute/invalidation.d.ts +25 -0
- package/dist/src/dispute/invalidation.d.ts.map +1 -1
- package/dist/src/dispute/invalidation.js +14 -2
- 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 +7 -1
- package/dist/src/dispute/service.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/it-utility.d.ts +1 -1
- package/dist/src/it-utility.d.ts.map +1 -1
- package/dist/src/it-utility.js +4 -13
- package/dist/src/it-utility.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +98 -1
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +272 -17
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +12 -0
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +415 -87
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/matchmaking/query-transport.d.ts +2 -0
- package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
- package/dist/src/matchmaking/query-transport.js +6 -0
- package/dist/src/matchmaking/query-transport.js.map +1 -1
- package/dist/src/matchmaking/traffic-validation.js +1 -0
- package/dist/src/matchmaking/traffic-validation.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +2 -2
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +8 -4
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/owned-block-seed.d.ts +27 -0
- package/dist/src/owned-block-seed.d.ts.map +1 -0
- package/dist/src/owned-block-seed.js +37 -0
- package/dist/src/owned-block-seed.js.map +1 -0
- package/dist/src/protocol-client.d.ts +1 -0
- package/dist/src/protocol-client.d.ts.map +1 -1
- package/dist/src/protocol-client.js +9 -2
- package/dist/src/protocol-client.js.map +1 -1
- package/dist/src/protocol-limits.d.ts +32 -0
- package/dist/src/protocol-limits.d.ts.map +1 -0
- package/dist/src/protocol-limits.js +32 -0
- package/dist/src/protocol-limits.js.map +1 -0
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +3 -0
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +41 -4
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +128 -80
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +38 -1
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +92 -13
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +7 -1
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/reputation/peer-reputation.js +1 -1
- package/dist/src/reputation/peer-reputation.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 -0
- package/dist/src/reputation/types.js.map +1 -1
- package/dist/src/rn.d.ts +5 -1
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +7 -1
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
- package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
- package/dist/src/storage/arachnode-fret-adapter.js +15 -2
- package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
- package/dist/src/storage/arachnode-partition.d.ts +49 -0
- package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
- package/dist/src/storage/arachnode-partition.js +61 -0
- package/dist/src/storage/arachnode-partition.js.map +1 -0
- package/dist/src/storage/block-storage.d.ts +40 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +150 -57
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +8 -0
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +13 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +45 -0
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
- package/dist/src/storage/kv-raw-storage.js +113 -0
- package/dist/src/storage/kv-raw-storage.js.map +1 -0
- package/dist/src/storage/memory-storage.d.ts +20 -58
- package/dist/src/storage/memory-storage.d.ts.map +1 -1
- package/dist/src/storage/memory-storage.js +22 -148
- package/dist/src/storage/memory-storage.js.map +1 -1
- package/dist/src/storage/memory-store-driver.d.ts +39 -0
- package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
- package/dist/src/storage/memory-store-driver.js +129 -0
- package/dist/src/storage/memory-store-driver.js.map +1 -0
- package/dist/src/storage/raw-store-codec.d.ts +10 -0
- package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
- package/dist/src/storage/raw-store-codec.js +36 -0
- package/dist/src/storage/raw-store-codec.js.map +1 -0
- package/dist/src/storage/raw-store-driver.d.ts +69 -0
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
- package/dist/src/storage/raw-store-driver.js +2 -0
- package/dist/src/storage/raw-store-driver.js.map +1 -0
- package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
- package/dist/src/storage/restoration-coordinator-v2.js +1 -1
- package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
- package/dist/src/storage/restoration-coordinator.d.ts +73 -0
- package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
- package/dist/src/storage/restoration-coordinator.js +181 -0
- package/dist/src/storage/restoration-coordinator.js.map +1 -0
- package/dist/src/storage/ring-selector.d.ts +92 -5
- package/dist/src/storage/ring-selector.d.ts.map +1 -1
- package/dist/src/storage/ring-selector.js +153 -51
- package/dist/src/storage/ring-selector.js.map +1 -1
- package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
- package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
- package/dist/src/storage/ring-shift-coordinator.js +196 -0
- package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
- package/dist/src/storage/storage-monitor.d.ts +28 -0
- package/dist/src/storage/storage-monitor.d.ts.map +1 -1
- package/dist/src/storage/storage-monitor.js +42 -2
- package/dist/src/storage/storage-monitor.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +19 -0
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +172 -22
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/sync/client.d.ts.map +1 -1
- package/dist/src/sync/client.js +5 -1
- package/dist/src/sync/client.js.map +1 -1
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +2 -1
- package/dist/src/sync/service.js.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
- package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
- package/dist/src/testing/index.d.ts +1 -0
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +15 -7
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
- package/dist/src/testing/raw-storage-conformance.js +313 -0
- package/dist/src/testing/raw-storage-conformance.js.map +1 -0
- package/package.json +4 -4
- package/src/cluster/block-transfer-service.ts +4 -3
- package/src/cluster/block-transfer.ts +424 -265
- package/src/cluster/client.ts +3 -1
- package/src/cluster/cluster-repo.ts +481 -71
- package/src/cluster/peer-key-binding.ts +37 -0
- package/src/cluster/quorum-restore.ts +0 -0
- package/src/cluster/rebalance-monitor.ts +34 -3
- package/src/cluster/service.ts +7 -1
- package/src/cluster/spread-on-churn.ts +11 -1
- package/src/cluster/supermajority-coupling.ts +37 -0
- package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
- package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
- package/src/cohort-topic/host.ts +831 -105
- package/src/dispute/arbitrator-selection.ts +138 -28
- package/src/dispute/cascade.ts +8 -1
- package/src/dispute/client.ts +3 -2
- package/src/dispute/dispute-service.ts +94 -9
- package/src/dispute/index.ts +7 -1
- package/src/dispute/invalidation.ts +40 -2
- package/src/dispute/service.ts +7 -1
- package/src/index.ts +46 -43
- package/src/it-utility.ts +5 -13
- package/src/libp2p-key-network.ts +281 -18
- package/src/libp2p-node-base.ts +485 -79
- package/src/matchmaking/query-transport.ts +9 -0
- package/src/network/network-manager-service.ts +8 -2
- package/src/owned-block-seed.ts +40 -0
- package/src/protocol-client.ts +10 -3
- package/src/protocol-limits.ts +33 -0
- package/src/repo/client.ts +3 -0
- package/src/repo/cluster-coordinator.ts +145 -82
- package/src/repo/coordinator-repo.ts +96 -16
- package/src/repo/service.ts +7 -1
- package/src/reputation/types.ts +3 -0
- package/src/rn.ts +7 -1
- package/src/storage/arachnode-fret-adapter.ts +33 -2
- package/src/storage/arachnode-partition.ts +74 -0
- package/src/storage/block-storage.ts +169 -61
- package/src/storage/i-block-storage.ts +9 -0
- package/src/storage/i-raw-storage.ts +14 -0
- package/src/storage/kv-raw-storage.ts +135 -0
- package/src/storage/memory-storage.ts +25 -173
- package/src/storage/memory-store-driver.ts +157 -0
- package/src/storage/raw-store-codec.ts +42 -0
- package/src/storage/raw-store-driver.ts +80 -0
- package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
- package/src/storage/ring-selector.ts +317 -176
- package/src/storage/ring-shift-coordinator.ts +271 -0
- package/src/storage/storage-monitor.ts +70 -3
- package/src/storage/storage-repo.ts +710 -558
- package/src/sync/client.ts +5 -1
- package/src/sync/service.ts +2 -1
- package/src/testing/cohort-topic-mesh-harness.ts +61 -11
- package/src/testing/index.ts +1 -0
- package/src/testing/mesh-harness.ts +14 -6
- package/src/testing/raw-storage-conformance.ts +397 -0
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import type { IRepo, ClusterRecord, Signature, RepoMessage, ITransactionValidator, ClusterConsensusConfig, CommitResult, BlockId, ActionId, ActionRev, CommitRequest, CommitCert, InvalidateRequest } from "@optimystic/db-core";
|
|
1
|
+
import type { IRepo, ClusterRecord, ClusterPeers, Signature, RepoMessage, ITransactionValidator, ClusterConsensusConfig, CommitResult, BlockId, ActionId, ActionRev, CommitRequest, CommitCert, InvalidateRequest } from "@optimystic/db-core";
|
|
2
2
|
import type { ICluster } from "@optimystic/db-core";
|
|
3
3
|
import type { IPeerNetwork } from "@optimystic/db-core";
|
|
4
|
-
import { blockIdsForTransforms } from "@optimystic/db-core";
|
|
4
|
+
import { blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
|
|
5
|
+
import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority } from "@optimystic/db-core";
|
|
5
6
|
import { verifyInvalidationCertificate, type ArbitratorSetRecompute } from "../dispute/invalidation.js";
|
|
6
7
|
import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
|
|
7
8
|
import { ClusterClient } from "./client.js";
|
|
8
9
|
import type { PeerId, PrivateKey } from "@libp2p/interface";
|
|
9
10
|
import { peerIdFromString } from "@libp2p/peer-id";
|
|
10
11
|
import { publicKeyFromRaw } from "@libp2p/crypto/keys";
|
|
11
|
-
import {
|
|
12
|
-
import { base58btc } from "multiformats/bases/base58";
|
|
12
|
+
import { peerIdBindsPublicKey } from "./peer-key-binding.js";
|
|
13
13
|
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
14
14
|
import { createLogger } from '../logger.js'
|
|
15
15
|
import type { PartitionDetector } from "./partition-detector.js";
|
|
@@ -37,6 +37,18 @@ interface TransactionState {
|
|
|
37
37
|
lastUpdate: number;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Result of verifying one vote signature. `penalize` distinguishes "identity was never proven"
|
|
42
|
+
* (no key / not Ed25519 / key not bound to the peer id / malformed input — reject, but do NOT
|
|
43
|
+
* report the named peer, whose id may have been attacker-chosen) from "the key IS the one the
|
|
44
|
+
* peer id names, yet the signature does not verify" (reject AND report — a genuine bad vote from
|
|
45
|
+
* that proven identity). Collapsing both into a bare `false` would let a coordinator get an honest
|
|
46
|
+
* peer penalized just by attaching a key it controls under that peer's id.
|
|
47
|
+
*/
|
|
48
|
+
type VerifyOutcome =
|
|
49
|
+
| { valid: true }
|
|
50
|
+
| { valid: false; penalize: boolean };
|
|
51
|
+
|
|
40
52
|
/**
|
|
41
53
|
* Actively reconciles a block this member committed without having seen the matching
|
|
42
54
|
* pend (cohort drift between the independent pend and commit cluster-transactions).
|
|
@@ -85,6 +97,33 @@ export type InvalidationApplySink = (request: InvalidateRequest) => Promise<void
|
|
|
85
97
|
*/
|
|
86
98
|
export type RecomputeArbitratorSetCapability = ArbitratorSetRecompute;
|
|
87
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The member's own independently-derived view of a block's responsible cluster, plus FRET's confidence in
|
|
102
|
+
* the underlying network-size estimate (0..1). Returned by {@link DeriveExpectedClusterCallback} and
|
|
103
|
+
* consumed by {@link ClusterMember}'s membership admission gate: the member checks a coordinator-declared
|
|
104
|
+
* peer set against `peers` (its expected set `E`) and gates on `confidence` (low confidence ⇒ fail closed
|
|
105
|
+
* for any downsizing, the partition posture).
|
|
106
|
+
*/
|
|
107
|
+
export type ExpectedClusterView = {
|
|
108
|
+
/** The member's own derived responsible-peer set for the block (its view of the legitimate cluster). */
|
|
109
|
+
peers: ClusterPeers;
|
|
110
|
+
/** FRET's confidence in the current network-size estimate (0..1); ≤ threshold ⇒ treated as untrusted. */
|
|
111
|
+
confidence: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Independently derive this member's own view of a block's responsible cluster. Injected so
|
|
116
|
+
* {@link ClusterMember} stays transport-agnostic — the composition root supplies it from
|
|
117
|
+
* `IKeyNetwork.findCluster` + FRET (mirroring how the coordinator derives the cluster). Absent on nodes
|
|
118
|
+
* that cannot derive a view (no FRET, unit tests): with no derived view AND no configured full-size
|
|
119
|
+
* reference the gate preserves legacy approve behavior, but a configured `clusterSize` still lets the gate
|
|
120
|
+
* fail closed on an unjustified downsize. See {@link ClusterMember} admission gate.
|
|
121
|
+
*/
|
|
122
|
+
export type DeriveExpectedClusterCallback = (blockId: BlockId) => Promise<ExpectedClusterView>;
|
|
123
|
+
|
|
124
|
+
/** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
|
|
125
|
+
export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
|
|
126
|
+
|
|
88
127
|
interface ClusterMemberComponents {
|
|
89
128
|
storageRepo: IRepo;
|
|
90
129
|
peerNetwork: IPeerNetwork;
|
|
@@ -106,6 +145,8 @@ interface ClusterMemberComponents {
|
|
|
106
145
|
onInvalidate?: InvalidationApplySink;
|
|
107
146
|
/** Layer-2 arbitrator-set recompute for invalidation verification; see {@link RecomputeArbitratorSetCapability}. */
|
|
108
147
|
recomputeArbitratorSet?: RecomputeArbitratorSetCapability;
|
|
148
|
+
/** Member-side cluster derivation for the membership admission gate; see {@link DeriveExpectedClusterCallback}. */
|
|
149
|
+
deriveExpectedCluster?: DeriveExpectedClusterCallback;
|
|
109
150
|
}
|
|
110
151
|
|
|
111
152
|
export function clusterMember(components: ClusterMemberComponents): ClusterMember {
|
|
@@ -124,7 +165,8 @@ export function clusterMember(components: ClusterMemberComponents): ClusterMembe
|
|
|
124
165
|
components.reconcileBlock,
|
|
125
166
|
components.onCommitCertificate,
|
|
126
167
|
components.onInvalidate,
|
|
127
|
-
components.recomputeArbitratorSet
|
|
168
|
+
components.recomputeArbitratorSet,
|
|
169
|
+
components.deriveExpectedCluster
|
|
128
170
|
);
|
|
129
171
|
}
|
|
130
172
|
|
|
@@ -168,8 +210,28 @@ export class ClusterMember implements ICluster {
|
|
|
168
210
|
private readonly expirationInterval: NodeJS.Timeout;
|
|
169
211
|
private readonly cleanupInterval: NodeJS.Timeout;
|
|
170
212
|
|
|
171
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Confidence floor at/below which FRET's network-size view is treated as untrustworthy for the
|
|
215
|
+
* membership gate. Above it the member trusts its derived view (confident path); at/below it the gate
|
|
216
|
+
* fails closed for downsizing. Matches the coordinator's `validateSmallCluster` confidence gate (> 0.5).
|
|
217
|
+
*/
|
|
218
|
+
private static readonly MembershipConfidenceThreshold = 0.5;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Effective super-majority threshold this member accepts as sufficient for a commit. Defaults to
|
|
222
|
+
* {@link DEFAULT_SUPER_MAJORITY_THRESHOLD} (0.75) when no config is supplied — the SAME default the
|
|
223
|
+
* coordinator uses, so the two can never silently disagree about whether a transaction is final.
|
|
224
|
+
* (Previously defaulted to 1.0/unanimity, which split the member from a coordinator committing at 0.75.)
|
|
225
|
+
*/
|
|
172
226
|
private readonly superMajorityThreshold: number;
|
|
227
|
+
// Membership admission gate parameters (see {@link admitMembership}). Read once from consensusConfig
|
|
228
|
+
// so the gate has stable thresholds independent of the (untrusted) values a record declares.
|
|
229
|
+
private readonly minAbsoluteClusterSize: number;
|
|
230
|
+
private readonly clusterSizeTolerance: number;
|
|
231
|
+
private readonly membershipAdmissionFraction: number;
|
|
232
|
+
/** Configured full cluster size, or undefined when unknown (then the gate cannot judge a downsize). */
|
|
233
|
+
private readonly configuredClusterSize: number | undefined;
|
|
234
|
+
private readonly allowUnvalidatedSmallCluster: boolean;
|
|
173
235
|
|
|
174
236
|
constructor(
|
|
175
237
|
private readonly storageRepo: IRepo,
|
|
@@ -187,9 +249,15 @@ export class ClusterMember implements ICluster {
|
|
|
187
249
|
private readonly reconcileBlock?: ReconcileBlockCallback,
|
|
188
250
|
private readonly onCommitCertificate?: CommitCertificateSink,
|
|
189
251
|
private readonly onInvalidate?: InvalidationApplySink,
|
|
190
|
-
private readonly recomputeArbitratorSet?: RecomputeArbitratorSetCapability
|
|
252
|
+
private readonly recomputeArbitratorSet?: RecomputeArbitratorSetCapability,
|
|
253
|
+
private readonly deriveExpectedCluster?: DeriveExpectedClusterCallback
|
|
191
254
|
) {
|
|
192
|
-
this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ??
|
|
255
|
+
this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD;
|
|
256
|
+
this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
|
|
257
|
+
this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
|
|
258
|
+
this.membershipAdmissionFraction = consensusConfig?.membershipAdmissionFraction ?? 0.75;
|
|
259
|
+
this.configuredClusterSize = consensusConfig?.clusterSize;
|
|
260
|
+
this.allowUnvalidatedSmallCluster = consensusConfig?.allowUnvalidatedSmallCluster ?? false;
|
|
193
261
|
// Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
|
|
194
262
|
this.expirationInterval = setInterval(() => this.queueExpiredTransactions(), 60000);
|
|
195
263
|
this.expirationInterval.unref();
|
|
@@ -198,6 +266,16 @@ export class ClusterMember implements ICluster {
|
|
|
198
266
|
this.cleanupInterval.unref();
|
|
199
267
|
}
|
|
200
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The resolved super-majority threshold this member runs on. Exposed so the composition root can
|
|
271
|
+
* fail-fast if the member and the coordinator would run different thresholds (see the coupling
|
|
272
|
+
* assertion in `libp2p-node-base.ts`) — a mismatch is a latent phase-disagreement, caught at startup
|
|
273
|
+
* rather than mid-consensus.
|
|
274
|
+
*/
|
|
275
|
+
get effectiveSuperMajorityThreshold(): number {
|
|
276
|
+
return this.superMajorityThreshold;
|
|
277
|
+
}
|
|
278
|
+
|
|
201
279
|
/**
|
|
202
280
|
* Clears all interval and timeout handles and empties active state.
|
|
203
281
|
* Called during node shutdown to prevent leaked timers.
|
|
@@ -338,10 +416,7 @@ export class ClusterMember implements ICluster {
|
|
|
338
416
|
log('cluster-member:action-consensus-after-commit', {
|
|
339
417
|
messageHash: record.messageHash
|
|
340
418
|
});
|
|
341
|
-
|
|
342
|
-
if (!await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
|
|
343
|
-
await this.handleConsensus(currentRecord);
|
|
344
|
-
}
|
|
419
|
+
await this.handleConsensus(currentRecord);
|
|
345
420
|
}
|
|
346
421
|
}
|
|
347
422
|
shouldPersist = false;
|
|
@@ -350,12 +425,7 @@ export class ClusterMember implements ICluster {
|
|
|
350
425
|
log('cluster-member:action-consensus', {
|
|
351
426
|
messageHash: record.messageHash
|
|
352
427
|
});
|
|
353
|
-
|
|
354
|
-
if (await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
|
|
355
|
-
log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
|
|
356
|
-
} else {
|
|
357
|
-
await this.handleConsensus(currentRecord);
|
|
358
|
-
}
|
|
428
|
+
await this.handleConsensus(currentRecord);
|
|
359
429
|
// Don't call clearTransaction here - it happens in handleConsensus
|
|
360
430
|
shouldPersist = false;
|
|
361
431
|
break;
|
|
@@ -432,10 +502,32 @@ export class ClusterMember implements ICluster {
|
|
|
432
502
|
if (existing.messageHash !== incoming.messageHash) {
|
|
433
503
|
throw new Error('Message hash mismatch');
|
|
434
504
|
}
|
|
435
|
-
if (
|
|
505
|
+
if (ClusterMember.canonicalJson(existing.message) !== ClusterMember.canonicalJson(incoming.message)) {
|
|
436
506
|
throw new Error('Message content mismatch');
|
|
437
507
|
}
|
|
438
|
-
if (
|
|
508
|
+
if (existing.membershipVersion === 2 || incoming.membershipVersion === 2) {
|
|
509
|
+
// v2: the sorted peer-id set (captured by membershipDigest) is bound into messageHash, so equal
|
|
510
|
+
// messageHash MUST imply equal membership on any honest path. A mismatch here — different digest
|
|
511
|
+
// or version at equal hash — is a protocol violation (a bug or a hash-collision attack), NOT an
|
|
512
|
+
// honest divergence: two honest members with different views now hold two DIFFERENT hashes, i.e.
|
|
513
|
+
// two competing transactions the race machinery resolves, not one contested record. Log loudly
|
|
514
|
+
// and reject; never silently adopt the incoming set. (validateRecord already proved each record's
|
|
515
|
+
// own digest matches its own peers, so multiaddr / pubkey churn within the SAME id set — which
|
|
516
|
+
// keeps the same digest and hash — does NOT trip this.)
|
|
517
|
+
if (existing.membershipVersion !== incoming.membershipVersion || existing.membershipDigest !== incoming.membershipDigest) {
|
|
518
|
+
log('cluster-member:peers-mismatch-invariant-violation', {
|
|
519
|
+
messageHash: existing.messageHash,
|
|
520
|
+
existingVersion: existing.membershipVersion,
|
|
521
|
+
incomingVersion: incoming.membershipVersion,
|
|
522
|
+
existingDigest: existing.membershipDigest,
|
|
523
|
+
incomingDigest: incoming.membershipDigest,
|
|
524
|
+
existingPeers: Object.keys(existing.peers ?? {}).sort(),
|
|
525
|
+
incomingPeers: Object.keys(incoming.peers ?? {}).sort()
|
|
526
|
+
});
|
|
527
|
+
throw new Error('Peers mismatch');
|
|
528
|
+
}
|
|
529
|
+
} else if (ClusterMember.canonicalJson(existing.peers) !== ClusterMember.canonicalJson(incoming.peers)) {
|
|
530
|
+
// v1 (legacy, membership unbound): full peer-object equality is the only available guard.
|
|
439
531
|
throw new Error('Peers mismatch');
|
|
440
532
|
}
|
|
441
533
|
|
|
@@ -466,6 +558,10 @@ export class ClusterMember implements ICluster {
|
|
|
466
558
|
phase: 'promise' | 'commit',
|
|
467
559
|
messageHash: string
|
|
468
560
|
): Record<string, Signature> {
|
|
561
|
+
// NOTE: relies on validateSignatures() (via validateRecord in processUpdate) having already run on
|
|
562
|
+
// every record reaching here, so each peerId's signature is key-bound. Without that guarantee the
|
|
563
|
+
// Equivocation penalty below would act on self-asserted, unverified peer ids and could frame an
|
|
564
|
+
// honest peer. Do not call this on unvalidated signatures.
|
|
469
565
|
const merged = { ...existing };
|
|
470
566
|
|
|
471
567
|
for (const [peerId, incomingSig] of Object.entries(incoming)) {
|
|
@@ -498,8 +594,27 @@ export class ClusterMember implements ICluster {
|
|
|
498
594
|
}
|
|
499
595
|
|
|
500
596
|
private async validateRecord(record: ClusterRecord): Promise<void> {
|
|
501
|
-
//
|
|
502
|
-
|
|
597
|
+
// Reject a record whose membership-binding version this code does not implement. The cluster
|
|
598
|
+
// consensus code is a single deployable unit (all cluster members upgrade together), so a version
|
|
599
|
+
// we don't understand is rejected rather than cross-version-consensus'd.
|
|
600
|
+
if (record.membershipVersion !== undefined && record.membershipVersion !== 1 && record.membershipVersion !== 2) {
|
|
601
|
+
throw new Error(`Unsupported membershipVersion: ${record.membershipVersion}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// v2: the declared membership digest must match the record's own peer set. A record whose declared
|
|
605
|
+
// digest doesn't match its peers is malformed (and its messageHash — computed over that digest —
|
|
606
|
+
// would not bind the real membership).
|
|
607
|
+
// NOTE: recomputes membershipDigest (one SHA256 over the sorted peer-id list) on every incoming v2
|
|
608
|
+
// record; if a hot cluster ever shows this as a cost, memoize per (messageHash → digest).
|
|
609
|
+
if (record.membershipVersion === 2) {
|
|
610
|
+
const expectedDigest = await membershipDigest(record.peers);
|
|
611
|
+
if (expectedDigest !== record.membershipDigest) {
|
|
612
|
+
throw new Error(`Membership digest mismatch: expected=${expectedDigest}, received=${record.membershipDigest ?? 'undefined'}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Validate message hash matches the message content (v2 folds in the membership digest)
|
|
617
|
+
const expectedHash = await this.computeMessageHash(record);
|
|
503
618
|
if (expectedHash !== record.messageHash) {
|
|
504
619
|
throw new Error(`Message hash mismatch: expected=${expectedHash}, received=${record.messageHash}`);
|
|
505
620
|
}
|
|
@@ -514,21 +629,25 @@ export class ClusterMember implements ICluster {
|
|
|
514
629
|
}
|
|
515
630
|
|
|
516
631
|
/**
|
|
517
|
-
* Compute message hash using the same algorithm as the coordinator.
|
|
518
|
-
*
|
|
632
|
+
* Compute message hash using the same algorithm as the coordinator. Version-dispatched: a v2 record
|
|
633
|
+
* folds its membership digest into the preimage, a v1 / unversioned record hashes byte-identically to
|
|
634
|
+
* before this change. Must match cluster-coordinator.ts createMessageHash().
|
|
519
635
|
*/
|
|
520
|
-
private async computeMessageHash(
|
|
521
|
-
|
|
522
|
-
const hashBytes = await sha256.digest(msgBytes);
|
|
523
|
-
return base58btc.encode(hashBytes.digest);
|
|
636
|
+
private async computeMessageHash(record: Pick<ClusterRecord, 'message' | 'membershipVersion' | 'membershipDigest'>): Promise<string> {
|
|
637
|
+
return computeClusterMessageHash(record.message, recordMembershipDigest(record));
|
|
524
638
|
}
|
|
525
639
|
|
|
526
640
|
private async validateSignatures(record: ClusterRecord): Promise<void> {
|
|
527
|
-
// Validate promise signatures
|
|
641
|
+
// Validate promise signatures. Reject on any failure, but only report an InvalidSignature
|
|
642
|
+
// penalty when the key was proven to belong to `peerId` (outcome.penalize) — otherwise the id
|
|
643
|
+
// is attacker-chosen and reporting it would let a coordinator frame an honest peer.
|
|
528
644
|
const promiseHash = await this.computePromiseHash(record);
|
|
529
645
|
for (const [peerId, signature] of Object.entries(record.promises)) {
|
|
530
|
-
|
|
531
|
-
|
|
646
|
+
const outcome = await this.verifySignature(record, peerId, promiseHash, signature);
|
|
647
|
+
if (!outcome.valid) {
|
|
648
|
+
if (outcome.penalize) {
|
|
649
|
+
this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `promise:${record.messageHash}`);
|
|
650
|
+
}
|
|
532
651
|
throw new Error(`Invalid promise signature from ${peerId}`);
|
|
533
652
|
}
|
|
534
653
|
}
|
|
@@ -536,14 +655,23 @@ export class ClusterMember implements ICluster {
|
|
|
536
655
|
// Validate commit signatures
|
|
537
656
|
const commitHash = await this.computeCommitHash(record);
|
|
538
657
|
for (const [peerId, signature] of Object.entries(record.commits)) {
|
|
539
|
-
|
|
540
|
-
|
|
658
|
+
const outcome = await this.verifySignature(record, peerId, commitHash, signature);
|
|
659
|
+
if (!outcome.valid) {
|
|
660
|
+
if (outcome.penalize) {
|
|
661
|
+
this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `commit:${record.messageHash}`);
|
|
662
|
+
}
|
|
541
663
|
throw new Error(`Invalid commit signature from ${peerId}`);
|
|
542
664
|
}
|
|
543
665
|
}
|
|
544
666
|
}
|
|
545
667
|
|
|
546
|
-
/**
|
|
668
|
+
/**
|
|
669
|
+
* Deterministic JSON: sorts object keys so comparisons are order-independent.
|
|
670
|
+
* NOTE: this is a second copy of the canonicalization in db-core `membership.ts` (which feeds the hash
|
|
671
|
+
* preimages). It is used here only for equality checks (message-content / v1-peers compare), and those
|
|
672
|
+
* run *after* a messageHash-equality gate, so a drift between the two can't silently forge agreement —
|
|
673
|
+
* but keep them byte-identical. If a third caller appears, promote to a single exported helper.
|
|
674
|
+
*/
|
|
547
675
|
private static canonicalJson(value: unknown): string {
|
|
548
676
|
return JSON.stringify(value, (_, v) =>
|
|
549
677
|
v && typeof v === 'object' && !Array.isArray(v)
|
|
@@ -553,15 +681,11 @@ export class ClusterMember implements ICluster {
|
|
|
553
681
|
}
|
|
554
682
|
|
|
555
683
|
private async computePromiseHash(record: ClusterRecord): Promise<string> {
|
|
556
|
-
|
|
557
|
-
const hashBytes = await sha256.digest(msgBytes);
|
|
558
|
-
return uint8ArrayToString(hashBytes.digest, 'base64url');
|
|
684
|
+
return computeClusterPromiseHash(record.messageHash, record.message, recordMembershipDigest(record));
|
|
559
685
|
}
|
|
560
686
|
|
|
561
687
|
private async computeCommitHash(record: ClusterRecord): Promise<string> {
|
|
562
|
-
|
|
563
|
-
const hashBytes = await sha256.digest(msgBytes);
|
|
564
|
-
return uint8ArrayToString(hashBytes.digest, 'base64url');
|
|
688
|
+
return computeClusterCommitHash(record.messageHash, record.message, record.promises, recordMembershipDigest(record));
|
|
565
689
|
}
|
|
566
690
|
|
|
567
691
|
private computeSigningPayload(hash: string, type: string, rejectReason?: string): Uint8Array {
|
|
@@ -575,17 +699,53 @@ export class ClusterMember implements ICluster {
|
|
|
575
699
|
return uint8ArrayToString(sigBytes, 'base64url');
|
|
576
700
|
}
|
|
577
701
|
|
|
578
|
-
|
|
702
|
+
/**
|
|
703
|
+
* Verify one vote signature and classify the outcome (see {@link VerifyOutcome}). Total on hostile
|
|
704
|
+
* input: a missing/empty key, a non-Ed25519 id, a key not bound to `peerId`, or malformed bytes all
|
|
705
|
+
* yield `{ valid:false, penalize:false }` (reject without penalizing an unproven identity) rather
|
|
706
|
+
* than throwing. Only after the key is proven to be the one `peerId` names does a failed
|
|
707
|
+
* cryptographic verify yield `{ valid:false, penalize:true }`.
|
|
708
|
+
*
|
|
709
|
+
* NOTE: the binding check (`peerIdBindsPublicKey`) proves the vote was signed by the key `peerId`
|
|
710
|
+
* names — it does NOT establish that `peerId` is legitimately in the cohort. A coordinator minting
|
|
711
|
+
* fresh keypairs and using each key's own derived id passes this for every one. Sybil/cohort
|
|
712
|
+
* membership is a separate layer (cohort-topic membership certificates), not solved here.
|
|
713
|
+
*/
|
|
714
|
+
private async verifySignature(record: ClusterRecord, peerId: string, hash: string, signature: Signature): Promise<VerifyOutcome> {
|
|
579
715
|
const peerInfo = record.peers[peerId];
|
|
580
716
|
if (!peerInfo?.publicKey?.length) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
717
|
+
// No key to check against — identity not proven. Reject without penalty.
|
|
718
|
+
return { valid: false, penalize: false };
|
|
719
|
+
}
|
|
720
|
+
let keyBytes: Uint8Array;
|
|
721
|
+
try {
|
|
722
|
+
// publicKey is base64url-encoded string (JSON-serialization safe)
|
|
723
|
+
keyBytes = uint8ArrayFromString(peerInfo.publicKey, 'base64url');
|
|
724
|
+
} catch {
|
|
725
|
+
return { valid: false, penalize: false };
|
|
726
|
+
}
|
|
727
|
+
// The key must be the one `peerId` provably names, else the vote could be attributed to any peer
|
|
728
|
+
// id while signed by a key the coordinator controls. Binding failure ⇒ identity unproven ⇒ no penalty.
|
|
729
|
+
if (!peerIdBindsPublicKey(peerId, keyBytes)) {
|
|
730
|
+
return { valid: false, penalize: false };
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
const pubKey = publicKeyFromRaw(keyBytes);
|
|
734
|
+
const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
|
|
735
|
+
const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
|
|
736
|
+
const ok = await pubKey.verify(payload, sigBytes);
|
|
737
|
+
// Key is bound to peerId: a failed verify is a genuine bad vote from a proven identity → penalize.
|
|
738
|
+
// NOTE: residual — an Ed25519 peer's public key is derivable from its (public) id, so an attacker
|
|
739
|
+
// can attach a victim's REAL key with a garbage signature and still trip this InvalidSignature
|
|
740
|
+
// penalty on the victim. Binding narrows framing (the attacker must use the victim's own key, not
|
|
741
|
+
// an arbitrary one) but cannot eliminate it here: a single signature can't distinguish "victim
|
|
742
|
+
// signed badly" from "someone pasted the victim's public key + junk". Fully closing it needs an
|
|
743
|
+
// authenticated membership/channel layer (cohort-topic membership certs), out of scope for this fix.
|
|
744
|
+
return ok ? { valid: true } : { valid: false, penalize: true };
|
|
745
|
+
} catch {
|
|
746
|
+
// Malformed signature bytes / key decode failure: reject, but do not penalize on unparseable input.
|
|
747
|
+
return { valid: false, penalize: false };
|
|
748
|
+
}
|
|
589
749
|
}
|
|
590
750
|
|
|
591
751
|
private async getTransactionPhase(record: ClusterRecord): Promise<TransactionPhase> {
|
|
@@ -633,8 +793,11 @@ export class ClusterMember implements ICluster {
|
|
|
633
793
|
}
|
|
634
794
|
|
|
635
795
|
private async handlePromiseNeeded(record: ClusterRecord): Promise<ClusterRecord> {
|
|
636
|
-
//
|
|
637
|
-
|
|
796
|
+
// Membership admission gate runs BEFORE pend validation: a member independently checks the declared
|
|
797
|
+
// peer set is a legitimate cluster it belongs to, and refuses (reject vote) rather than rubber-stamping
|
|
798
|
+
// a set the coordinator chose (e.g. a self-shrunk minority-partition set). On admission failure we skip
|
|
799
|
+
// pend validation entirely and emit the membership rejection.
|
|
800
|
+
const validationResult = await this.evaluatePromise(record);
|
|
638
801
|
|
|
639
802
|
const promiseHash = await this.computePromiseHash(record);
|
|
640
803
|
const type = validationResult.valid ? 'approve' as const : 'reject' as const;
|
|
@@ -661,6 +824,161 @@ export class ClusterMember implements ICluster {
|
|
|
661
824
|
};
|
|
662
825
|
}
|
|
663
826
|
|
|
827
|
+
/**
|
|
828
|
+
* The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
|
|
829
|
+
* admitted) validate its pend operations. Failing either yields a `{ valid:false, reason }` the caller
|
|
830
|
+
* turns into a `reject` vote. Splitting membership from pend validation keeps the reason strings
|
|
831
|
+
* distinct — a `membership-not-admitted` reject is a different signal (feeds the dispute path) than a
|
|
832
|
+
* stale-revision / custom-validator reject.
|
|
833
|
+
*/
|
|
834
|
+
private async evaluatePromise(record: ClusterRecord): Promise<{ valid: boolean; reason?: string }> {
|
|
835
|
+
const admission = await this.admitMembership(record);
|
|
836
|
+
if (!admission.admit) {
|
|
837
|
+
return { valid: false, reason: admission.reason ?? MEMBERSHIP_NOT_ADMITTED };
|
|
838
|
+
}
|
|
839
|
+
return await this.validatePendOperations(record);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Membership admission gate. Decides whether the coordinator-declared peer set (`record.peers`, call it
|
|
844
|
+
* `D`) is a *legitimate* cluster this member may vote inside, judged against the member's OWN
|
|
845
|
+
* independently-derived view — not against anything the (untrusted) record declares about its size.
|
|
846
|
+
* Evaluated on the promise path before the member signs an approve.
|
|
847
|
+
*
|
|
848
|
+
* The predicate admits `D` iff ALL hold:
|
|
849
|
+
* 1. **Self-membership** — this member's id ∈ `D`; else this block is not its responsibility (and a
|
|
850
|
+
* coordinator must not route a record to a non-member to pad approval counts).
|
|
851
|
+
* 2. **Not a self-shrink below the floor** — with a confident derived view `E`, `|D| ≥ ⌈fraction·|E|⌉`
|
|
852
|
+
* (and ≥ minAbsoluteClusterSize). `|E|` is the member's own confident cluster-size estimate `K_est`,
|
|
853
|
+
* so a minority-partition set (small `D`) is rejected against the member's larger view.
|
|
854
|
+
* 3. **Consistency with the derived view** — `|D △ E|` within `clusterSizeTolerance·|E|`; honest churn
|
|
855
|
+
* of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
|
|
856
|
+
*
|
|
857
|
+
* **Fail-closed posture.** When the member cannot confidently derive `E` (no capability, low FRET
|
|
858
|
+
* confidence — exactly what a partition induces), it must refuse any *downsizing* decision: a
|
|
859
|
+
* below-full-size `D` is rejected against the configured full `clusterSize`. With NEITHER a confident
|
|
860
|
+
* view NOR a configured full size the gate cannot judge a downsize at all, so it preserves the legacy
|
|
861
|
+
* approve behavior (backward-compatible for nodes/tests with no derivation wired). `allowUnvalidatedSmallCluster`
|
|
862
|
+
* is the explicit opt-in (single-node / local dev knowingly below the safe floor), matching the
|
|
863
|
+
* coordinator's `validateSmallCluster` semantics.
|
|
864
|
+
*/
|
|
865
|
+
private async admitMembership(record: ClusterRecord): Promise<{ admit: boolean; reason?: string }> {
|
|
866
|
+
const ourId = this.peerId.toString();
|
|
867
|
+
const declared = Object.keys(record.peers ?? {});
|
|
868
|
+
|
|
869
|
+
// Predicate 1: self-membership. Always enforced (independent of any opt-in): a member does not vote
|
|
870
|
+
// in a cluster it is not part of.
|
|
871
|
+
if (!declared.includes(ourId)) {
|
|
872
|
+
log('cluster-member:admission-reject', { messageHash: record.messageHash, reason: 'self-not-member', declaredSize: declared.length });
|
|
873
|
+
return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:self-not-member` };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Explicit opt-in: knowingly transact below the safe floor (single-node / local dev). Skips the
|
|
877
|
+
// size/consistency gates but not self-membership above.
|
|
878
|
+
if (this.allowUnvalidatedSmallCluster) {
|
|
879
|
+
return { admit: true };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const derived = await this.deriveExpectedClusterView(record);
|
|
883
|
+
// An empty derived view (kEst === 0) carries no usable reference set: measured against it every
|
|
884
|
+
// non-empty declared set is wholly "inconsistent" (maxDiff = ceil(tol·0) = 0), which would spuriously
|
|
885
|
+
// reject a legitimate full cluster — a stricter, worse outcome than an absent view. Treat empty as
|
|
886
|
+
// not-confident so it takes the fail-closed-or-legacy branch below instead. (Not normally reachable:
|
|
887
|
+
// a responsible member's findCluster includes at least itself; this guards a transient empty read.)
|
|
888
|
+
const derivedSize = derived !== undefined ? Object.keys(derived.peers ?? {}).length : 0;
|
|
889
|
+
const confident = derived !== undefined
|
|
890
|
+
&& derived.confidence > ClusterMember.MembershipConfidenceThreshold
|
|
891
|
+
&& derivedSize > 0;
|
|
892
|
+
|
|
893
|
+
if (!confident) {
|
|
894
|
+
// Fail closed for downsizing under low/absent confidence. A full-size (or larger) declared set is
|
|
895
|
+
// still admitted — there is nothing to shrink. Without a configured full-size reference we cannot
|
|
896
|
+
// tell a downsize from a legitimate small cluster, so we preserve legacy approve behavior.
|
|
897
|
+
if (this.configuredClusterSize === undefined) {
|
|
898
|
+
return { admit: true };
|
|
899
|
+
}
|
|
900
|
+
if (declared.length >= this.configuredClusterSize) {
|
|
901
|
+
return { admit: true };
|
|
902
|
+
}
|
|
903
|
+
log('cluster-member:admission-reject', {
|
|
904
|
+
messageHash: record.messageHash,
|
|
905
|
+
reason: 'low-confidence-downsize',
|
|
906
|
+
declaredSize: declared.length,
|
|
907
|
+
configuredClusterSize: this.configuredClusterSize,
|
|
908
|
+
confidence: derived?.confidence
|
|
909
|
+
});
|
|
910
|
+
return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:low-confidence-downsize` };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const expected = Object.keys(derived!.peers ?? {});
|
|
914
|
+
const kEst = expected.length;
|
|
915
|
+
|
|
916
|
+
// Predicate 2: floor derived from the member's OWN confident estimate.
|
|
917
|
+
const floor = Math.max(this.minAbsoluteClusterSize, Math.ceil(this.membershipAdmissionFraction * kEst));
|
|
918
|
+
if (declared.length < floor) {
|
|
919
|
+
log('cluster-member:admission-reject', {
|
|
920
|
+
messageHash: record.messageHash,
|
|
921
|
+
reason: 'below-floor',
|
|
922
|
+
declaredSize: declared.length,
|
|
923
|
+
floor,
|
|
924
|
+
kEst
|
|
925
|
+
});
|
|
926
|
+
return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:below-floor` };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Predicate 3: consistency with the derived view within tolerance.
|
|
930
|
+
const symmetricDiff = ClusterMember.symmetricDifferenceSize(declared, expected);
|
|
931
|
+
const maxDiff = Math.ceil(this.clusterSizeTolerance * kEst);
|
|
932
|
+
if (symmetricDiff > maxDiff) {
|
|
933
|
+
log('cluster-member:admission-reject', {
|
|
934
|
+
messageHash: record.messageHash,
|
|
935
|
+
reason: 'inconsistent-with-derived-view',
|
|
936
|
+
declaredSize: declared.length,
|
|
937
|
+
kEst,
|
|
938
|
+
symmetricDiff,
|
|
939
|
+
maxDiff
|
|
940
|
+
});
|
|
941
|
+
return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:inconsistent-with-derived-view` };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return { admit: true };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Derive this member's own view of the record's block cluster via the injected capability, or
|
|
949
|
+
* `undefined` when it cannot (no capability, no coordinating block id, or a derivation error — all of
|
|
950
|
+
* which the gate treats as "not confident"). Derived from the record's coordinating block, the same key
|
|
951
|
+
* the coordinator used to select the cluster.
|
|
952
|
+
*/
|
|
953
|
+
private async deriveExpectedClusterView(record: ClusterRecord): Promise<ExpectedClusterView | undefined> {
|
|
954
|
+
if (!this.deriveExpectedCluster) {
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
const blockId = record.coordinatingBlockIds?.[0];
|
|
958
|
+
if (blockId === undefined) {
|
|
959
|
+
return undefined;
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
// NOTE: derives (findCluster) once per inbound record on the promise path — one routing lookup
|
|
963
|
+
// per vote. If this shows up as hot, cache the derived view per (blockId, short TTL): it is a
|
|
964
|
+
// pure read of current topology, so a few-seconds-stale view is safe for admission.
|
|
965
|
+
return await this.deriveExpectedCluster(blockId as BlockId);
|
|
966
|
+
} catch (err) {
|
|
967
|
+
log('cluster-member:derive-expected-cluster-error', { messageHash: record.messageHash, error: (err as Error).message });
|
|
968
|
+
return undefined;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** |A △ B| over two id lists (order-independent set symmetric difference). */
|
|
973
|
+
private static symmetricDifferenceSize(a: string[], b: string[]): number {
|
|
974
|
+
const setA = new Set(a);
|
|
975
|
+
const setB = new Set(b);
|
|
976
|
+
let count = 0;
|
|
977
|
+
for (const x of setA) if (!setB.has(x)) count++;
|
|
978
|
+
for (const x of setB) if (!setA.has(x)) count++;
|
|
979
|
+
return count;
|
|
980
|
+
}
|
|
981
|
+
|
|
664
982
|
/**
|
|
665
983
|
* Validates pend operations in a cluster record using the transaction validator.
|
|
666
984
|
* Also checks for stale revisions to prevent consensus on operations that would fail.
|
|
@@ -731,9 +1049,12 @@ export class ClusterMember implements ICluster {
|
|
|
731
1049
|
* @warning This method executes on ALL cluster peers, not just the coordinator.
|
|
732
1050
|
* Each peer independently applies the operations to its local storage.
|
|
733
1051
|
*
|
|
734
|
-
* @pitfall **Check-then-act race** -
|
|
735
|
-
* (before any `await`) to prevent duplicate execution
|
|
736
|
-
* nature makes synchronous check-and-set atomic.
|
|
1052
|
+
* @pitfall **Check-then-act race** - The in-memory guard must be checked AND set
|
|
1053
|
+
* atomically (before any `await`) to prevent duplicate execution; JavaScript's
|
|
1054
|
+
* single-threaded nature makes that synchronous check-and-set atomic. The durable
|
|
1055
|
+
* marker, by contrast, is persisted only *after* apply succeeds — writing it eagerly
|
|
1056
|
+
* would leave a stuck marker on a caught fault or a crash mid-apply, silently dropping
|
|
1057
|
+
* the transaction on this member on redelivery.
|
|
737
1058
|
*
|
|
738
1059
|
* @pitfall **Independent node storage** - Each node has its own storage. After consensus,
|
|
739
1060
|
* each node applies operations locally. Nodes must fetch missing blocks from cluster
|
|
@@ -742,31 +1063,52 @@ export class ClusterMember implements ICluster {
|
|
|
742
1063
|
* @see docs/internals.md "Check-Then-Act Race in Consensus" and "Independent Node Storage" pitfalls
|
|
743
1064
|
*/
|
|
744
1065
|
private async handleConsensus(record: ClusterRecord): Promise<void> {
|
|
1066
|
+
// Check persistent store first for post-recovery dedup (in-memory map is cleared on restart).
|
|
1067
|
+
// wasTransactionExecutedAsync also checks the in-memory map as a fast path.
|
|
1068
|
+
if (await this.wasTransactionExecutedAsync(record.messageHash)) {
|
|
1069
|
+
log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
745
1072
|
// Check-and-set ATOMICALLY to prevent race condition where multiple calls
|
|
746
|
-
// pass the check before any completes. Since JavaScript is single-threaded,
|
|
1073
|
+
// pass the async check before any completes. Since JavaScript is single-threaded,
|
|
747
1074
|
// this synchronous check-and-set is atomic before any await.
|
|
748
1075
|
if (this.executedTransactions.has(record.messageHash)) {
|
|
749
1076
|
log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
|
|
750
1077
|
return;
|
|
751
1078
|
}
|
|
752
|
-
//
|
|
1079
|
+
// Set the in-memory guard IMMEDIATELY, before any async operations: its synchronous
|
|
1080
|
+
// check-and-set (line above's `has` + this `set`) is what prevents the concurrent
|
|
1081
|
+
// apply-window race where two handleConsensus calls for the same hash both pass the
|
|
1082
|
+
// async check. The durable marker is deliberately NOT written here — see below.
|
|
753
1083
|
const executedAt = Date.now();
|
|
754
1084
|
this.executedTransactions.set(record.messageHash, executedAt);
|
|
755
|
-
this.stateStore?.markExecuted(record.messageHash, executedAt)
|
|
756
|
-
.catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: (err as Error).message }));
|
|
757
1085
|
|
|
758
1086
|
try {
|
|
759
1087
|
for (const operation of record.message.operations) {
|
|
760
1088
|
await this.applyConsensusOperation(record, operation);
|
|
761
1089
|
}
|
|
762
1090
|
} catch (err) {
|
|
763
|
-
// A genuinely unexpected fault (e.g. storage I/O) — roll back the
|
|
1091
|
+
// A genuinely unexpected fault (e.g. storage I/O) — roll back the in-memory
|
|
764
1092
|
// marker so a corrected retry can re-run, and propagate so the caller learns
|
|
765
|
-
// the real cause.
|
|
766
|
-
//
|
|
1093
|
+
// the real cause. The durable marker was never written (it lands only after
|
|
1094
|
+
// apply succeeds, below), so there is nothing to roll back. Recoverable local
|
|
1095
|
+
// divergence is absorbed inside applyConsensusOperation and never reaches here.
|
|
767
1096
|
this.executedTransactions.delete(record.messageHash);
|
|
768
1097
|
throw err;
|
|
769
1098
|
}
|
|
1099
|
+
|
|
1100
|
+
// Persist the durable marker only now that apply has actually succeeded. Writing it
|
|
1101
|
+
// eagerly (before the loop) would leave a stuck marker on a caught fault OR a crash
|
|
1102
|
+
// mid-apply, and on redelivery handleConsensus short-circuits at the async
|
|
1103
|
+
// wasTransactionExecuted check — silently dropping the transaction on this member
|
|
1104
|
+
// forever. The durable marker exists only for post-restart dedup (the in-memory map
|
|
1105
|
+
// is empty after restart), and the narrow window between "apply succeeded" and
|
|
1106
|
+
// "durable write landed" is safe to re-run on restart: re-applying an
|
|
1107
|
+
// already-applied consensus transaction is idempotent (the "ahead" divergence path
|
|
1108
|
+
// in applyConsensusOperation tolerates it as a no-op), so it converges rather than
|
|
1109
|
+
// dropping. Fire-and-forget: a persist failure must not fail the apply that succeeded.
|
|
1110
|
+
this.stateStore?.markExecuted(record.messageHash, executedAt)
|
|
1111
|
+
.catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: (err as Error).message }));
|
|
770
1112
|
}
|
|
771
1113
|
|
|
772
1114
|
/**
|
|
@@ -1136,25 +1478,93 @@ export class ClusterMember implements ICluster {
|
|
|
1136
1478
|
}
|
|
1137
1479
|
|
|
1138
1480
|
/**
|
|
1139
|
-
* Resolve race between two conflicting transactions.
|
|
1140
|
-
*
|
|
1481
|
+
* Resolve a race between two conflicting transactions. Total and deterministic, so every honest
|
|
1482
|
+
* member computes the identical winner (the Theorem 1 Case-2 premise). Order:
|
|
1483
|
+
* 1. more promise signatures wins (progress monotonicity — see safety note below);
|
|
1484
|
+
* 2. equal promise counts → higher aged priority wins (fairness — see {@link recordPriority});
|
|
1485
|
+
* 3. still tied → higher message hash wins.
|
|
1486
|
+
*
|
|
1487
|
+
* Promise count is FIRST so this comparison never displaces a transaction that is further along.
|
|
1488
|
+
* That restores the pre-priority safety invariant: a member commits purely on promise supermajority
|
|
1489
|
+
* (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
|
|
1490
|
+
* conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
|
|
1491
|
+
* With promises-first, once transaction X holds a promise supermajority every conflicting rival Y has
|
|
1492
|
+
* strictly fewer promises — Y can only match X's count by getting the intersecting quorum member to
|
|
1493
|
+
* promise it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
|
|
1494
|
+
* `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
|
|
1495
|
+
* overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
|
|
1496
|
+
* Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
|
|
1497
|
+
* higher-priority Y with fewer promises, letting BOTH commit (split brain) — the regression fixed by
|
|
1498
|
+
* ticket occ-priority-first-breaks-promise-monotonicity.
|
|
1499
|
+
*
|
|
1500
|
+
* Priority is now a tie-break that runs only at EQUAL promise counts, which is exactly the
|
|
1501
|
+
* concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
|
|
1502
|
+
* on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
|
|
1503
|
+
* fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
|
|
1504
|
+
* defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
|
|
1505
|
+
* is the deferred feat-occ-priority-reservation).
|
|
1506
|
+
*
|
|
1507
|
+
* NOTE: residual-fairness tripwire. Under promises-first an aged transaction can still lose to a fresh
|
|
1508
|
+
* rival that has *legitimately* gathered even one more promise — that is not the pure-coin-flip
|
|
1509
|
+
* starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
|
|
1510
|
+
* more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
|
|
1511
|
+
* rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
|
|
1512
|
+
* to this race tie-break.
|
|
1513
|
+
*
|
|
1514
|
+
* NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
|
|
1515
|
+
* priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
|
|
1516
|
+
* exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
|
|
1517
|
+
* below the promise count, so it can only break equal-count ties it might have ~50% won anyway,
|
|
1518
|
+
* degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
|
|
1519
|
+
* honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
|
|
1520
|
+
*
|
|
1521
|
+
* NOTE: keep priority a self-contained additive message field + this one comparison key so it
|
|
1522
|
+
* composes with — does not block — a future HLC/crdt-sync redesign of this same path
|
|
1523
|
+
* (design-hot-log-tail-sharding-guidance).
|
|
1141
1524
|
*/
|
|
1142
1525
|
private resolveRace(existing: ClusterRecord, incoming: ClusterRecord): 'keep-existing' | 'accept-incoming' {
|
|
1526
|
+
// 1. Transaction with more promises wins — never displace a more-progressed rival (safety, see above).
|
|
1143
1527
|
const existingCount = Object.keys(existing.promises).length;
|
|
1144
1528
|
const incomingCount = Object.keys(incoming.promises).length;
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
if (existingCount > incomingCount) {
|
|
1148
|
-
return 'keep-existing';
|
|
1529
|
+
if (existingCount !== incomingCount) {
|
|
1530
|
+
return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
|
|
1149
1531
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1532
|
+
|
|
1533
|
+
// 2. Equal promise counts → higher aged priority wins (fairness tie-break).
|
|
1534
|
+
const existingPriority = this.recordPriority(existing);
|
|
1535
|
+
const incomingPriority = this.recordPriority(incoming);
|
|
1536
|
+
if (existingPriority !== incomingPriority) {
|
|
1537
|
+
return existingPriority > incomingPriority ? 'keep-existing' : 'accept-incoming';
|
|
1152
1538
|
}
|
|
1153
1539
|
|
|
1154
|
-
// Tie-breaker: higher message hash wins (deterministic)
|
|
1540
|
+
// 3. Tie-breaker: higher message hash wins (deterministic).
|
|
1155
1541
|
return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
|
|
1156
1542
|
}
|
|
1157
1543
|
|
|
1544
|
+
/**
|
|
1545
|
+
* Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
|
|
1546
|
+
* The multi-collection path carries it on `pend.transaction.priority`; the single-collection
|
|
1547
|
+
* (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
|
|
1548
|
+
* legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
|
|
1549
|
+
* (backward compatible: such transactions simply never age). Both carriers live inside the signed
|
|
1550
|
+
* `message`, so priority is integrity-protected in transit; clamping here bounds a self-asserted
|
|
1551
|
+
* out-of-range value to the cap.
|
|
1552
|
+
*
|
|
1553
|
+
* NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
|
|
1554
|
+
* separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
|
|
1555
|
+
* the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
|
|
1556
|
+
* consulted at the promise decision (hasConflict), i.e. between two still-open conflicting
|
|
1557
|
+
* transactions, which is exactly the concurrent-contention case priority is meant to order.
|
|
1558
|
+
*/
|
|
1559
|
+
private recordPriority(record: ClusterRecord): number {
|
|
1560
|
+
for (const op of record.message.operations) {
|
|
1561
|
+
if ('pend' in op) {
|
|
1562
|
+
return clampPriority(op.pend.transaction?.priority ?? op.pend.priority);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return 0;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1158
1568
|
private operationsConflict(ops1: RepoMessage['operations'], ops2: RepoMessage['operations']): boolean {
|
|
1159
1569
|
// Check if one is a commit for the same action as a pend - these don't conflict
|
|
1160
1570
|
const actionId1 = this.getActionId(ops1);
|
|
@@ -1226,7 +1636,7 @@ export class ClusterMember implements ICluster {
|
|
|
1226
1636
|
const client = ClusterClient.create(peerIdFromString(peerId), this.peerNetwork, this.protocolPrefix);
|
|
1227
1637
|
promises.push(client.update(record));
|
|
1228
1638
|
} catch (error) {
|
|
1229
|
-
|
|
1639
|
+
log('ERROR: Failed to propagate to peer %s: %o', peerId, error);
|
|
1230
1640
|
}
|
|
1231
1641
|
}
|
|
1232
1642
|
await Promise.allSettled(promises);
|