@optimystic/db-core 0.22.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +336 -336
- package/dist/src/cluster/structs.d.ts +39 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +24 -0
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/collection.d.ts +17 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +24 -2
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +5 -0
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +7 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/network/i-peer-network.d.ts +16 -0
- package/dist/src/network/i-peer-network.d.ts.map +1 -1
- package/dist/src/network/struct.d.ts +39 -2
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js +18 -0
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +95 -8
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +121 -8
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +1 -1
- package/dist/src/transaction/transaction.js +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +48 -13
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +25 -2
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/package.json +1 -1
- package/src/cluster/membership.ts +85 -85
- package/src/cluster/structs.ts +43 -4
- package/src/cohort-topic/addressing.ts +120 -120
- package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
- package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
- package/src/cohort-topic/antidos/index.ts +5 -5
- package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
- package/src/cohort-topic/antidos/replay-guard.ts +146 -146
- package/src/cohort-topic/antidos/topic-budget.ts +160 -160
- package/src/cohort-topic/antiflood/index.ts +2 -2
- package/src/cohort-topic/antiflood/invariants.ts +108 -108
- package/src/cohort-topic/antiflood/jitter.ts +117 -117
- package/src/cohort-topic/coldstart.ts +237 -237
- package/src/cohort-topic/dmax.ts +88 -88
- package/src/cohort-topic/gossip/bus.ts +254 -254
- package/src/cohort-topic/gossip/index.ts +3 -3
- package/src/cohort-topic/gossip/records.ts +45 -45
- package/src/cohort-topic/gossip/view.ts +91 -91
- package/src/cohort-topic/index.ts +20 -20
- package/src/cohort-topic/load/barometer.ts +134 -134
- package/src/cohort-topic/load/index.ts +1 -1
- package/src/cohort-topic/member-engine.ts +430 -430
- package/src/cohort-topic/membership/index.ts +3 -3
- package/src/cohort-topic/membership/publisher.ts +163 -163
- package/src/cohort-topic/membership/source.ts +41 -41
- package/src/cohort-topic/membership/verifier.ts +461 -461
- package/src/cohort-topic/ports.ts +157 -157
- package/src/cohort-topic/promotion.ts +405 -405
- package/src/cohort-topic/registration/bytes.ts +37 -37
- package/src/cohort-topic/registration/handoff.ts +154 -154
- package/src/cohort-topic/registration/index.ts +6 -6
- package/src/cohort-topic/registration/renewal.ts +495 -495
- package/src/cohort-topic/registration/sharding.ts +61 -61
- package/src/cohort-topic/registration/store.ts +81 -81
- package/src/cohort-topic/registration/types.ts +91 -91
- package/src/cohort-topic/ring-hash.ts +50 -50
- package/src/cohort-topic/service.ts +416 -416
- package/src/cohort-topic/sig/index.ts +2 -2
- package/src/cohort-topic/sig/payloads.ts +59 -59
- package/src/cohort-topic/sig/threshold.ts +64 -64
- package/src/cohort-topic/tiers.ts +74 -74
- package/src/cohort-topic/traffic.ts +233 -233
- package/src/cohort-topic/walk.ts +326 -326
- package/src/cohort-topic/willingness.ts +237 -237
- package/src/cohort-topic/wire/codec.ts +216 -216
- package/src/cohort-topic/wire/index.ts +18 -18
- package/src/cohort-topic/wire/payloads.ts +126 -126
- package/src/cohort-topic/wire/primitives.ts +188 -188
- package/src/cohort-topic/wire/types.ts +475 -475
- package/src/cohort-topic/wire/validate.ts +512 -512
- package/src/collection/collection-type-registry.ts +37 -37
- package/src/collection/collection.ts +25 -2
- package/src/collections/diary/diary.ts +68 -68
- package/src/collections/tree/readme.md +4 -0
- package/src/collections/tree/tree.ts +320 -312
- package/src/matchmaking/capability-filter.ts +45 -45
- package/src/matchmaking/config.ts +98 -98
- package/src/matchmaking/index.ts +21 -21
- package/src/matchmaking/multi-cohort-seeker.ts +234 -234
- package/src/matchmaking/provider.ts +123 -123
- package/src/matchmaking/query-eval.ts +105 -105
- package/src/matchmaking/seeker-walk.ts +127 -127
- package/src/matchmaking/seeker.ts +86 -86
- package/src/matchmaking/topic-anchor.ts +90 -90
- package/src/matchmaking/voting-quorum.ts +394 -394
- package/src/matchmaking/wire.ts +603 -603
- package/src/network/i-peer-network.ts +17 -0
- package/src/network/stale-failure.ts +43 -43
- package/src/network/struct.ts +41 -2
- package/src/network/types.ts +37 -37
- package/src/reactivity/backfill.ts +220 -220
- package/src/reactivity/backpressure.ts +191 -191
- package/src/reactivity/checkpoint.ts +308 -308
- package/src/reactivity/config.ts +172 -172
- package/src/reactivity/dedupe.ts +132 -132
- package/src/reactivity/forwarder.ts +87 -87
- package/src/reactivity/index.ts +34 -34
- package/src/reactivity/notification.ts +123 -123
- package/src/reactivity/policy.ts +79 -79
- package/src/reactivity/push-state.ts +310 -310
- package/src/reactivity/recover.ts +153 -153
- package/src/reactivity/replay-buffer.ts +141 -141
- package/src/reactivity/resume.ts +549 -549
- package/src/reactivity/rotation.ts +415 -415
- package/src/reactivity/subscriber.ts +132 -132
- package/src/reactivity/subscription.ts +66 -66
- package/src/reactivity/topic-anchor.ts +71 -71
- package/src/reactivity/verify.ts +73 -73
- package/src/reactivity/wire-validate.ts +13 -13
- package/src/reactivity/wire.ts +224 -224
- package/src/testing/async-wait.ts +65 -65
- package/src/testing/index.ts +2 -2
- package/src/testing/test-transactor.ts +638 -502
- package/src/transaction/errors.ts +91 -91
- package/src/transaction/operations-hash.ts +196 -196
- package/src/transaction/read-dependency-collector.ts +78 -78
- package/src/transaction/transaction.ts +1 -1
- package/src/transactor/change-notifier.ts +80 -80
- package/src/transactor/index.ts +5 -5
- package/src/transactor/network-transactor.ts +49 -14
- package/src/transactor/transactor-source.ts +25 -2
- package/src/transform/atomic-proxy.ts +92 -92
- package/src/transform/helpers.ts +159 -159
- package/src/utility/backoff.ts +95 -95
- package/src/utility/batch-coordinator.ts +191 -191
- package/dist/src/transaction/context.d.ts +0 -60
- package/dist/src/transaction/context.d.ts.map +0 -1
- package/dist/src/transaction/context.js +0 -91
- package/dist/src/transaction/context.js.map +0 -1
|
@@ -1,394 +1,394 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Matchmaking — voting-quorum assembler (db-core, discovery-only composition).
|
|
3
|
-
*
|
|
4
|
-
* The voting subsystem's **discovery** flow, built as a thin composition over the matchmaking module
|
|
5
|
-
* (`docs/matchmaking.md` §Voting-quorum assembly). Matchmaking already provides "find the peers": the
|
|
6
|
-
* stable {@link import("./topic-anchor.js").MatchTopicAnchor}, the signed provider registration
|
|
7
|
-
* ({@link MatchmakingProvider}), the single-cohort hang-out walk and the hot-topic multi-cohort sweep,
|
|
8
|
-
* and the reply-side per-entry `registrationSig` re-validation ({@link verifyProviderEntry}). This
|
|
9
|
-
* module adds the small amount of glue that turns that surface into "assemble a quorum of eligible
|
|
10
|
-
* voters for proposal `P`".
|
|
11
|
-
*
|
|
12
|
-
* **No matchmaking wire-protocol change is introduced.** The genuinely voting-layer pieces are injected
|
|
13
|
-
* or left out of scope entirely:
|
|
14
|
-
*
|
|
15
|
-
* - **Eligibility proof minting** — the caller mints an opaque proof (a signature over the proposal from
|
|
16
|
-
* a stake-bearing key, etc.) and supplies the {@link VotingQuorumRequest.verifyEligibility} predicate
|
|
17
|
-
* over it. Matchmaking treats the proof as opaque bytes carried in the provider's `capabilities`.
|
|
18
|
-
* - **The quorum selection rule** (random sample, stake-weighted, geographic) — injected via
|
|
19
|
-
* {@link VotingQuorumRequest.select}; the default is first-`targetSize`.
|
|
20
|
-
* - **Ballots, tally, dispute, ballot privacy** — out of scope; a separate forthcoming voting doc.
|
|
21
|
-
*
|
|
22
|
-
* ## Resolved design decisions (`docs/matchmaking.md` §Voting-quorum assembly)
|
|
23
|
-
*
|
|
24
|
-
* 1. **Reply-side per-entry verification is the default and required path; a client-side reputation
|
|
25
|
-
* pre-filter is optional and additive.** Quorum-assembly *liveness* must never depend on
|
|
26
|
-
* reputation-subsystem *availability*, so reputation is an optimization, never a dependency.
|
|
27
|
-
* Matchmaking forwards each entry's `registrationSig` plus the eligibility proof in `capabilities`,
|
|
28
|
-
* so reply-side verification is effectively free; the cohort returns ineligible entries the
|
|
29
|
-
* coordinator discards (bandwidth bounded by `query_limit_max × swept-cohort-count`).
|
|
30
|
-
* 2. **Flash-vote fairness among competing coordinators is a voting-layer concern; no matchmaking signal
|
|
31
|
-
* is added.** Matchmaking returns an advisory *candidate set*, never an allocation — overlapping sets
|
|
32
|
-
* are expected and quorum non-collision is decided by the voting protocol.
|
|
33
|
-
* 3. **No matchmaking protocol additions are needed.** `AggregateCountV1` is threshold-signed (an
|
|
34
|
-
* attested *registered*-provider count); an attested *eligible*-voter count, if voting needs one, is a
|
|
35
|
-
* voting-layer aggregate computed after discovery+verification, not a matchmaking message.
|
|
36
|
-
* 4. **Quorum-assembler delegation.** The seeker role may be held by the coordinator itself or by a
|
|
37
|
-
* delegated assembler peer; whoever holds it owns `patienceMs` and the reply-side re-validation duty.
|
|
38
|
-
* A delegated assembler returns an already-validated {@link ProviderEntryV1}`[]`; because every entry
|
|
39
|
-
* carries `registrationSig` + the proof in `capabilities`, the set is independently checkable, so the
|
|
40
|
-
* coordinator MAY re-validate (trust-but-verify) on receipt by re-running the verify→select pipeline.
|
|
41
|
-
*
|
|
42
|
-
* ## Architecture seam
|
|
43
|
-
*
|
|
44
|
-
* db-core is transport-free, so the actual discovery I/O (the register/query/renew walk, the root
|
|
45
|
-
* `AggregateCountV1` sweep) is **injected** as a {@link QuorumDiscovery} port — exactly as the seeker
|
|
46
|
-
* walk client and entry verifier are injected elsewhere in this module. db-p2p binds the port to
|
|
47
|
-
* `MatchmakingSeeker.walk` (single-cohort) and `multi-cohort-seeker` (sweep). The assembler itself never
|
|
48
|
-
* dials voters, never collects ballots, never tallies.
|
|
49
|
-
*/
|
|
50
|
-
|
|
51
|
-
import { createMatchTopicAnchor, type MatchTopicAnchor } from "./topic-anchor.js";
|
|
52
|
-
import { MatchmakingProvider } from "./provider.js";
|
|
53
|
-
import { verifyProviderEntry, type ProviderEntryV1, type EntrySigVerifier } from "./wire.js";
|
|
54
|
-
|
|
55
|
-
/** Default concurrent-vote-collection budget for a registered voter — accepts one vote-collection RPC. */
|
|
56
|
-
export const DEFAULT_VOTER_CAPACITY_BUDGET = 1;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Indicative `patienceMs` range for voting-quorum assembly (`docs/matchmaking.md` §Patience budgeting).
|
|
60
|
-
* Advisory only — the layer does not dictate; {@link VotingQuorumAssembler.assembleQuorum} requires a
|
|
61
|
-
* positive budget but does not clamp to this window.
|
|
62
|
-
*/
|
|
63
|
-
export const VOTING_PATIENCE_MIN_MS = 30_000;
|
|
64
|
-
export const VOTING_PATIENCE_MAX_MS = 300_000;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Reserved capability-tag prefix carrying a voter's opaque eligibility proof. The proof is bound into the
|
|
68
|
-
* provider `capabilities`, which {@link import("./wire.js").providerSigningPayload} covers, so the
|
|
69
|
-
* provider's `registrationSig` attests the proof verbatim. The caller's `verifyEligibility` predicate
|
|
70
|
-
* reads it back from the forwarded entry via {@link eligibilityProofOf}.
|
|
71
|
-
*/
|
|
72
|
-
export const ELIGIBILITY_TAG_PREFIX = "voter-eligibility:";
|
|
73
|
-
|
|
74
|
-
/** Encode an opaque eligibility proof as the reserved capability tag. */
|
|
75
|
-
export function eligibilityTag(proof: string): string {
|
|
76
|
-
return ELIGIBILITY_TAG_PREFIX + proof;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Extract the eligibility proof from a capability set, or `undefined` if no eligibility tag is present. */
|
|
80
|
-
export function eligibilityProofFromCapabilities(capabilities: readonly string[]): string | undefined {
|
|
81
|
-
const tag = capabilities.find((c) => c.startsWith(ELIGIBILITY_TAG_PREFIX));
|
|
82
|
-
return tag === undefined ? undefined : tag.slice(ELIGIBILITY_TAG_PREFIX.length);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Extract the eligibility proof carried by a forwarded {@link ProviderEntryV1} (`undefined` if absent). */
|
|
86
|
-
export function eligibilityProofOf(entry: ProviderEntryV1): string | undefined {
|
|
87
|
-
return eligibilityProofFromCapabilities(entry.capabilities);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// --- discovery seam (satisfied by MatchmakingSeeker.walk + multi-cohort-seeker, wired in db-p2p) ---
|
|
91
|
-
|
|
92
|
-
/** One discovery slice: candidate entries plus the hotness signal that drives the single-vs-sweep choice. */
|
|
93
|
-
export interface QuorumDiscoverySlice {
|
|
94
|
-
/** Candidate provider entries returned by this discovery hop (advisory — the assembler re-verifies). */
|
|
95
|
-
readonly entries: readonly ProviderEntryV1[];
|
|
96
|
-
/**
|
|
97
|
-
* Max `topicTraffic.childCohortCount` observed across the walked tiers. `> 0` means the topic has
|
|
98
|
-
* promoted (it is hot), so a single-cohort sample is unrepresentative and the assembler escalates to
|
|
99
|
-
* the multi-cohort sweep. The sweep hop sets this to `0` (the decision is already made).
|
|
100
|
-
*/
|
|
101
|
-
readonly childCohortCount: number;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Inputs to one discovery hop. */
|
|
105
|
-
export interface QuorumDiscoveryRequest {
|
|
106
|
-
/** The quorum topic id (`H("quorum" ‖ proposalHash ‖ "match")`). */
|
|
107
|
-
readonly topicId: Uint8Array;
|
|
108
|
-
/** Providers desired (drives the hang-out feasibility math / how many shards the sweep unions). */
|
|
109
|
-
readonly wantCount: number;
|
|
110
|
-
/** Patience budget remaining for this hop (ms); drains across walk → sweep. */
|
|
111
|
-
readonly patienceMs: number;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* The discovery surface {@link VotingQuorumAssembler} composes over. db-p2p binds:
|
|
116
|
-
* - {@link QuorumDiscovery.walk} → `MatchmakingSeeker.walk` (single-cohort hang-out walk), and
|
|
117
|
-
* - {@link QuorumDiscovery.sweep} → `multi-cohort-seeker` (root `AggregateCountV1` → tier shards union).
|
|
118
|
-
*
|
|
119
|
-
* Both surface already-deduped, `registrationSig`-validated entries in production; the assembler treats
|
|
120
|
-
* them as advisory candidates and re-validates regardless, which keeps the delegated-assembler
|
|
121
|
-
* (trust-but-verify) path and the mock-tier test fixtures honest.
|
|
122
|
-
*/
|
|
123
|
-
export interface QuorumDiscovery {
|
|
124
|
-
/** Single-cohort hang-out walk; returns matched candidates plus the observed hotness signal. */
|
|
125
|
-
walk(req: QuorumDiscoveryRequest): Promise<QuorumDiscoverySlice>;
|
|
126
|
-
/** Multi-cohort sweep across high-population tier shards; returns the unioned candidate slice. */
|
|
127
|
-
sweep(req: QuorumDiscoveryRequest): Promise<QuorumDiscoverySlice>;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// --- request / result ---
|
|
131
|
-
|
|
132
|
-
/** A request to assemble a voting quorum for one proposal. All voting-layer policy is injected here. */
|
|
133
|
-
export interface VotingQuorumRequest {
|
|
134
|
-
/** The proposal hash — the quorum topic's label. */
|
|
135
|
-
readonly proposalHash: string;
|
|
136
|
-
/** Desired quorum size (integer `>= 1`). */
|
|
137
|
-
readonly targetSize: number;
|
|
138
|
-
/** Patience budget (ms); positive. Voting range is 30–300 s (§Patience budgeting), advisory. */
|
|
139
|
-
readonly patienceMs: number;
|
|
140
|
-
/** Voting/stake-layer eligibility predicate over the opaque proof in an entry's `capabilities`. */
|
|
141
|
-
readonly verifyEligibility: (entry: ProviderEntryV1) => boolean;
|
|
142
|
-
/** Voting-layer quorum selection rule; default = first `targetSize`. Must choose from the input set. */
|
|
143
|
-
readonly select?: (eligible: ProviderEntryV1[], targetSize: number) => ProviderEntryV1[];
|
|
144
|
-
/** Optional, additive reputation pre-filter applied *before* verification to trim candidates. */
|
|
145
|
-
readonly reputationPrefilter?: (candidates: ProviderEntryV1[]) => ProviderEntryV1[];
|
|
146
|
-
/** Force the multi-cohort sweep even when a single-cohort walk would suffice (representativeness). */
|
|
147
|
-
readonly preferSweep?: boolean;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** The outcome of {@link VotingQuorumAssembler.assembleQuorum}. */
|
|
151
|
-
export interface VotingQuorumResult {
|
|
152
|
-
/** The selected, eligibility-verified quorum, deduped by `participantId`. */
|
|
153
|
-
readonly quorum: ProviderEntryV1[];
|
|
154
|
-
/** Distinct candidates discovery surfaced (after dedup, before eligibility filtering). */
|
|
155
|
-
readonly candidates: number;
|
|
156
|
-
/** Candidates that passed `registrationSig` re-validation **and** `verifyEligibility`. */
|
|
157
|
-
readonly eligible: number;
|
|
158
|
-
/** Whether the assembled quorum met `targetSize` (`quorum.length >= targetSize`). */
|
|
159
|
-
readonly metTarget: boolean;
|
|
160
|
-
/** Whether the multi-cohort sweep was used (hot topic or `preferSweep`). */
|
|
161
|
-
readonly swept: boolean;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Construction inputs for a {@link VotingQuorumAssembler}; deps are per-role and validated at call time. */
|
|
165
|
-
export interface VotingQuorumAssemblerDeps {
|
|
166
|
-
/** Stable topic anchor; defaults to db-core's ring-hash anchor ({@link createMatchTopicAnchor}). */
|
|
167
|
-
readonly anchor?: MatchTopicAnchor;
|
|
168
|
-
/** Discovery surface (walk + sweep) — required by {@link VotingQuorumAssembler.assembleQuorum}. */
|
|
169
|
-
readonly discovery?: QuorumDiscovery;
|
|
170
|
-
/**
|
|
171
|
-
* Per-entry `registrationSig` verifier (db-p2p binds `verifyPeerSig`) — required by
|
|
172
|
-
* {@link VotingQuorumAssembler.assembleQuorum}. db-core is crypto-free, so the peer-key check is
|
|
173
|
-
* injected, matching {@link verifyProviderEntry}'s contract.
|
|
174
|
-
*/
|
|
175
|
-
readonly verifyEntrySig?: EntrySigVerifier;
|
|
176
|
-
/** Sign a provider registration image — required by {@link VotingQuorumAssembler.registerEligibleVoter}. */
|
|
177
|
-
readonly sign?: (payload: Uint8Array) => Promise<string>;
|
|
178
|
-
/**
|
|
179
|
-
* Register a signed voter-provider payload at the quorum topic (cohort-topic T2) — required by
|
|
180
|
-
* {@link VotingQuorumAssembler.registerEligibleVoter}. db-p2p binds this to the provider manager /
|
|
181
|
-
* `MatchmakingProvider.register` once `matchmaking-sweep-adversarial-module` lands.
|
|
182
|
-
*/
|
|
183
|
-
readonly registerProvider?: (req: RegisterVoterProviderRequest) => Promise<void>;
|
|
184
|
-
/** CSPRNG source for the provider correlation id (injectable for deterministic tests). */
|
|
185
|
-
readonly randomBytes?: (n: number) => Uint8Array;
|
|
186
|
-
/** Wall clock (unix ms), injectable for tests; default `Date.now`. Splits patience across walk → sweep. */
|
|
187
|
-
readonly clock?: () => number;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** What {@link VotingQuorumAssembler.registerEligibleVoter} hands the injected `registerProvider` port. */
|
|
191
|
-
export interface RegisterVoterProviderRequest {
|
|
192
|
-
/** The quorum topic id this voter registers at. */
|
|
193
|
-
readonly topicId: Uint8Array;
|
|
194
|
-
/** The opaque, signed cohort-topic `RegisterV1.appPayload` bytes for the voter provider. */
|
|
195
|
-
readonly appPayloadBytes: Uint8Array;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Construction inputs for {@link VotingQuorumAssembler.registerEligibleVoter}. */
|
|
199
|
-
export interface RegisterEligibleVoterRequest {
|
|
200
|
-
/** The proposal hash — the quorum topic's label. */
|
|
201
|
-
readonly proposalHash: string;
|
|
202
|
-
/** base64url, app-defined eligibility proof minted by the voting/stake layer. */
|
|
203
|
-
readonly eligibilityProof: string;
|
|
204
|
-
/** Extra application capability tags merged into the registration (the proof tag is always added). */
|
|
205
|
-
readonly capabilityTags?: readonly string[];
|
|
206
|
-
/** Concurrent vote-collection budget; default {@link DEFAULT_VOTER_CAPACITY_BUDGET} (1). */
|
|
207
|
-
readonly capacityBudget?: number;
|
|
208
|
-
/** Multiaddr or PeerId-based callback for the vote-collection dial. */
|
|
209
|
-
readonly contactHint: string;
|
|
210
|
-
/** Optional soft expiry hint (unix ms). */
|
|
211
|
-
readonly serviceUntil?: number;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Discovery-only voting-quorum assembler over the matchmaking module. One instance can act in either
|
|
216
|
-
* role: a voter calls {@link registerEligibleVoter}; a coordinator (or delegated assembler) calls
|
|
217
|
-
* {@link assembleQuorum}. Deps are role-specific and asserted per method, so a coordinator need not
|
|
218
|
-
* supply `sign`/`registerProvider` and a voter need not supply `discovery`/`verifyEntrySig`.
|
|
219
|
-
*/
|
|
220
|
-
export class VotingQuorumAssembler {
|
|
221
|
-
private readonly anchor: MatchTopicAnchor;
|
|
222
|
-
private readonly deps: VotingQuorumAssemblerDeps;
|
|
223
|
-
private readonly clock: () => number;
|
|
224
|
-
|
|
225
|
-
constructor(deps: VotingQuorumAssemblerDeps = {}) {
|
|
226
|
-
this.deps = deps;
|
|
227
|
-
this.anchor = deps.anchor ?? createMatchTopicAnchor();
|
|
228
|
-
this.clock = deps.clock ?? ((): number => Date.now());
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* The quorum topic anchor for a proposal: `kind = "quorum"`, `label = proposalHash`, yielding
|
|
233
|
-
* `topicId = H("quorum" ‖ proposalHash ‖ "match")` via the matchmaking topic anchor.
|
|
234
|
-
*/
|
|
235
|
-
static quorumTopic(proposalHash: string): { kind: "quorum"; label: string } {
|
|
236
|
-
return { kind: "quorum", label: proposalHash };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Resolve the cohort-topic `topicId` for a proposal's quorum topic. */
|
|
240
|
-
topicIdFor(proposalHash: string): Uint8Array {
|
|
241
|
-
const { kind, label } = VotingQuorumAssembler.quorumTopic(proposalHash);
|
|
242
|
-
return this.anchor.topicId(kind, label);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Voter side: bind the caller-minted eligibility proof into `capabilities`, build the signed provider
|
|
247
|
-
* registration, and register it at the quorum topic (cohort-topic T2). The proof is opaque to
|
|
248
|
-
* matchmaking; the provider `signature` covers `(topicId, capabilities, capacityBudget)`, so it binds
|
|
249
|
-
* the proof to the registration.
|
|
250
|
-
*/
|
|
251
|
-
async registerEligibleVoter(req: RegisterEligibleVoterRequest): Promise<void> {
|
|
252
|
-
const sign = this.requireSign();
|
|
253
|
-
const registerProvider = this.requireRegisterProvider();
|
|
254
|
-
const topicId = this.topicIdFor(req.proposalHash);
|
|
255
|
-
const capacityBudget = req.capacityBudget ?? DEFAULT_VOTER_CAPACITY_BUDGET;
|
|
256
|
-
const capabilities = mergeEligibilityCapabilities(req.capabilityTags ?? [], req.eligibilityProof);
|
|
257
|
-
|
|
258
|
-
const provider = new MatchmakingProvider({
|
|
259
|
-
topicId,
|
|
260
|
-
capabilities,
|
|
261
|
-
capacityBudget,
|
|
262
|
-
contactHint: req.contactHint,
|
|
263
|
-
sign,
|
|
264
|
-
...(req.serviceUntil !== undefined ? { serviceUntil: req.serviceUntil } : {}),
|
|
265
|
-
...(this.deps.randomBytes !== undefined ? { randomBytes: this.deps.randomBytes } : {}),
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
await registerProvider({ topicId, appPayloadBytes: await provider.appPayloadBytes() });
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Coordinator / delegated-assembler side: discover → verify → select. Drives the single-cohort walk,
|
|
273
|
-
* escalates to the multi-cohort sweep on a hot topic (`childCohortCount > 0`) or when `preferSweep`,
|
|
274
|
-
* dedups by `participantId`, applies the optional reputation pre-filter, re-validates each entry's
|
|
275
|
-
* `registrationSig` **and** `verifyEligibility`, then applies the selection rule. Passes the *draining*
|
|
276
|
-
* patience budget to each discovery hop (walk, then sweep) — the deadline is fixed at entry and each
|
|
277
|
-
* hop receives the remaining slice — and on exhaustion returns whatever matched with `metTarget = false`.
|
|
278
|
-
* (Both legs honour `patienceMs`: the walk enforces a wall-clock deadline across hops and hang-out; the
|
|
279
|
-
* sweep stops starting new shard queries once the budget drains. The residual is at most one in-flight
|
|
280
|
-
* shard RPC — assembly never blocks materially past `patienceMs`.)
|
|
281
|
-
*/
|
|
282
|
-
async assembleQuorum(req: VotingQuorumRequest): Promise<VotingQuorumResult> {
|
|
283
|
-
const discovery = this.requireDiscovery();
|
|
284
|
-
const verifyEntrySig = this.requireVerifyEntrySig();
|
|
285
|
-
const targetSize = requireTargetSize(req.targetSize);
|
|
286
|
-
requirePositivePatience(req.patienceMs);
|
|
287
|
-
const topicId = this.topicIdFor(req.proposalHash);
|
|
288
|
-
|
|
289
|
-
const deadline = this.clock() + req.patienceMs;
|
|
290
|
-
const remaining = (): number => Math.max(0, deadline - this.clock());
|
|
291
|
-
|
|
292
|
-
// Step 2: single-cohort walk, then the single-vs-sweep decision.
|
|
293
|
-
const walkSlice = await discovery.walk({ topicId, wantCount: targetSize, patienceMs: remaining() });
|
|
294
|
-
const swept = req.preferSweep === true || walkSlice.childCohortCount > 0;
|
|
295
|
-
const slices: QuorumDiscoverySlice[] = [walkSlice];
|
|
296
|
-
if (swept) {
|
|
297
|
-
slices.push(await discovery.sweep({ topicId, wantCount: targetSize, patienceMs: remaining() }));
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Step 3: dedup the unioned candidate set by participantId (defensive — promotion/redirect overlap).
|
|
301
|
-
const candidates = dedupByParticipant(slices.flatMap((s) => [...s.entries]));
|
|
302
|
-
|
|
303
|
-
// Step 4: optional, additive reputation pre-filter (bandwidth/quality trim; never a dependency).
|
|
304
|
-
const prefiltered = req.reputationPrefilter ? req.reputationPrefilter([...candidates]) : candidates;
|
|
305
|
-
|
|
306
|
-
// Step 5: reply-side default — both registrationSig re-validation AND verifyEligibility must pass.
|
|
307
|
-
const eligible = prefiltered.filter(
|
|
308
|
-
(entry) => verifyProviderEntry(topicId, entry, verifyEntrySig) && req.verifyEligibility(entry),
|
|
309
|
-
);
|
|
310
|
-
|
|
311
|
-
// Step 6: selection rule (default first targetSize), deduped defensively.
|
|
312
|
-
const select = req.select ?? defaultSelect;
|
|
313
|
-
const quorum = dedupByParticipant(select([...eligible], targetSize));
|
|
314
|
-
|
|
315
|
-
return {
|
|
316
|
-
quorum,
|
|
317
|
-
candidates: candidates.length,
|
|
318
|
-
eligible: eligible.length,
|
|
319
|
-
metTarget: quorum.length >= targetSize,
|
|
320
|
-
swept,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
private requireDiscovery(): QuorumDiscovery {
|
|
325
|
-
if (this.deps.discovery === undefined) {
|
|
326
|
-
throw new Error("VotingQuorumAssembler.assembleQuorum requires a `discovery` dependency");
|
|
327
|
-
}
|
|
328
|
-
return this.deps.discovery;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
private requireVerifyEntrySig(): EntrySigVerifier {
|
|
332
|
-
if (this.deps.verifyEntrySig === undefined) {
|
|
333
|
-
throw new Error("VotingQuorumAssembler.assembleQuorum requires a `verifyEntrySig` dependency");
|
|
334
|
-
}
|
|
335
|
-
return this.deps.verifyEntrySig;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
private requireSign(): (payload: Uint8Array) => Promise<string> {
|
|
339
|
-
if (this.deps.sign === undefined) {
|
|
340
|
-
throw new Error("VotingQuorumAssembler.registerEligibleVoter requires a `sign` dependency");
|
|
341
|
-
}
|
|
342
|
-
return this.deps.sign;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
private requireRegisterProvider(): (req: RegisterVoterProviderRequest) => Promise<void> {
|
|
346
|
-
if (this.deps.registerProvider === undefined) {
|
|
347
|
-
throw new Error("VotingQuorumAssembler.registerEligibleVoter requires a `registerProvider` dependency");
|
|
348
|
-
}
|
|
349
|
-
return this.deps.registerProvider;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** The default selection rule: take the first `targetSize` eligible entries (the discovery order). */
|
|
354
|
-
export function defaultSelect(eligible: ProviderEntryV1[], targetSize: number): ProviderEntryV1[] {
|
|
355
|
-
return eligible.slice(0, targetSize);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Merge application capability tags with the eligibility proof tag: drop any caller-supplied tag that
|
|
360
|
-
* already uses the reserved prefix (the proof is authoritative), then append exactly one proof tag.
|
|
361
|
-
* Order is preserved end-to-end (the cohort forwards `capabilities` verbatim), so the provider signature
|
|
362
|
-
* reconstructs byte-for-byte on the seeker side.
|
|
363
|
-
*/
|
|
364
|
-
function mergeEligibilityCapabilities(tags: readonly string[], proof: string): string[] {
|
|
365
|
-
const appTags = tags.filter((tag) => !tag.startsWith(ELIGIBILITY_TAG_PREFIX));
|
|
366
|
-
return [...appTags, eligibilityTag(proof)];
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/** Dedup entries by `participantId`, keeping the first occurrence (preserves discovery/FCFS order). */
|
|
370
|
-
function dedupByParticipant(entries: readonly ProviderEntryV1[]): ProviderEntryV1[] {
|
|
371
|
-
const seen = new Map<string, ProviderEntryV1>();
|
|
372
|
-
for (const entry of entries) {
|
|
373
|
-
if (!seen.has(entry.participantId)) {
|
|
374
|
-
seen.set(entry.participantId, entry);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
return [...seen.values()];
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** Validate `targetSize` is an integer `>= 1`. */
|
|
381
|
-
function requireTargetSize(targetSize: number): number {
|
|
382
|
-
if (!Number.isInteger(targetSize) || targetSize < 1) {
|
|
383
|
-
throw new RangeError(`voting quorum: targetSize must be an integer >= 1, got ${targetSize}`);
|
|
384
|
-
}
|
|
385
|
-
return targetSize;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Validate `patienceMs` is a finite number `> 0` (the voting 30–300 s range is advisory, not enforced). */
|
|
389
|
-
function requirePositivePatience(patienceMs: number): number {
|
|
390
|
-
if (!Number.isFinite(patienceMs) || patienceMs <= 0) {
|
|
391
|
-
throw new RangeError(`voting quorum: patienceMs must be a finite number > 0, got ${patienceMs}`);
|
|
392
|
-
}
|
|
393
|
-
return patienceMs;
|
|
394
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Matchmaking — voting-quorum assembler (db-core, discovery-only composition).
|
|
3
|
+
*
|
|
4
|
+
* The voting subsystem's **discovery** flow, built as a thin composition over the matchmaking module
|
|
5
|
+
* (`docs/matchmaking.md` §Voting-quorum assembly). Matchmaking already provides "find the peers": the
|
|
6
|
+
* stable {@link import("./topic-anchor.js").MatchTopicAnchor}, the signed provider registration
|
|
7
|
+
* ({@link MatchmakingProvider}), the single-cohort hang-out walk and the hot-topic multi-cohort sweep,
|
|
8
|
+
* and the reply-side per-entry `registrationSig` re-validation ({@link verifyProviderEntry}). This
|
|
9
|
+
* module adds the small amount of glue that turns that surface into "assemble a quorum of eligible
|
|
10
|
+
* voters for proposal `P`".
|
|
11
|
+
*
|
|
12
|
+
* **No matchmaking wire-protocol change is introduced.** The genuinely voting-layer pieces are injected
|
|
13
|
+
* or left out of scope entirely:
|
|
14
|
+
*
|
|
15
|
+
* - **Eligibility proof minting** — the caller mints an opaque proof (a signature over the proposal from
|
|
16
|
+
* a stake-bearing key, etc.) and supplies the {@link VotingQuorumRequest.verifyEligibility} predicate
|
|
17
|
+
* over it. Matchmaking treats the proof as opaque bytes carried in the provider's `capabilities`.
|
|
18
|
+
* - **The quorum selection rule** (random sample, stake-weighted, geographic) — injected via
|
|
19
|
+
* {@link VotingQuorumRequest.select}; the default is first-`targetSize`.
|
|
20
|
+
* - **Ballots, tally, dispute, ballot privacy** — out of scope; a separate forthcoming voting doc.
|
|
21
|
+
*
|
|
22
|
+
* ## Resolved design decisions (`docs/matchmaking.md` §Voting-quorum assembly)
|
|
23
|
+
*
|
|
24
|
+
* 1. **Reply-side per-entry verification is the default and required path; a client-side reputation
|
|
25
|
+
* pre-filter is optional and additive.** Quorum-assembly *liveness* must never depend on
|
|
26
|
+
* reputation-subsystem *availability*, so reputation is an optimization, never a dependency.
|
|
27
|
+
* Matchmaking forwards each entry's `registrationSig` plus the eligibility proof in `capabilities`,
|
|
28
|
+
* so reply-side verification is effectively free; the cohort returns ineligible entries the
|
|
29
|
+
* coordinator discards (bandwidth bounded by `query_limit_max × swept-cohort-count`).
|
|
30
|
+
* 2. **Flash-vote fairness among competing coordinators is a voting-layer concern; no matchmaking signal
|
|
31
|
+
* is added.** Matchmaking returns an advisory *candidate set*, never an allocation — overlapping sets
|
|
32
|
+
* are expected and quorum non-collision is decided by the voting protocol.
|
|
33
|
+
* 3. **No matchmaking protocol additions are needed.** `AggregateCountV1` is threshold-signed (an
|
|
34
|
+
* attested *registered*-provider count); an attested *eligible*-voter count, if voting needs one, is a
|
|
35
|
+
* voting-layer aggregate computed after discovery+verification, not a matchmaking message.
|
|
36
|
+
* 4. **Quorum-assembler delegation.** The seeker role may be held by the coordinator itself or by a
|
|
37
|
+
* delegated assembler peer; whoever holds it owns `patienceMs` and the reply-side re-validation duty.
|
|
38
|
+
* A delegated assembler returns an already-validated {@link ProviderEntryV1}`[]`; because every entry
|
|
39
|
+
* carries `registrationSig` + the proof in `capabilities`, the set is independently checkable, so the
|
|
40
|
+
* coordinator MAY re-validate (trust-but-verify) on receipt by re-running the verify→select pipeline.
|
|
41
|
+
*
|
|
42
|
+
* ## Architecture seam
|
|
43
|
+
*
|
|
44
|
+
* db-core is transport-free, so the actual discovery I/O (the register/query/renew walk, the root
|
|
45
|
+
* `AggregateCountV1` sweep) is **injected** as a {@link QuorumDiscovery} port — exactly as the seeker
|
|
46
|
+
* walk client and entry verifier are injected elsewhere in this module. db-p2p binds the port to
|
|
47
|
+
* `MatchmakingSeeker.walk` (single-cohort) and `multi-cohort-seeker` (sweep). The assembler itself never
|
|
48
|
+
* dials voters, never collects ballots, never tallies.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { createMatchTopicAnchor, type MatchTopicAnchor } from "./topic-anchor.js";
|
|
52
|
+
import { MatchmakingProvider } from "./provider.js";
|
|
53
|
+
import { verifyProviderEntry, type ProviderEntryV1, type EntrySigVerifier } from "./wire.js";
|
|
54
|
+
|
|
55
|
+
/** Default concurrent-vote-collection budget for a registered voter — accepts one vote-collection RPC. */
|
|
56
|
+
export const DEFAULT_VOTER_CAPACITY_BUDGET = 1;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Indicative `patienceMs` range for voting-quorum assembly (`docs/matchmaking.md` §Patience budgeting).
|
|
60
|
+
* Advisory only — the layer does not dictate; {@link VotingQuorumAssembler.assembleQuorum} requires a
|
|
61
|
+
* positive budget but does not clamp to this window.
|
|
62
|
+
*/
|
|
63
|
+
export const VOTING_PATIENCE_MIN_MS = 30_000;
|
|
64
|
+
export const VOTING_PATIENCE_MAX_MS = 300_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reserved capability-tag prefix carrying a voter's opaque eligibility proof. The proof is bound into the
|
|
68
|
+
* provider `capabilities`, which {@link import("./wire.js").providerSigningPayload} covers, so the
|
|
69
|
+
* provider's `registrationSig` attests the proof verbatim. The caller's `verifyEligibility` predicate
|
|
70
|
+
* reads it back from the forwarded entry via {@link eligibilityProofOf}.
|
|
71
|
+
*/
|
|
72
|
+
export const ELIGIBILITY_TAG_PREFIX = "voter-eligibility:";
|
|
73
|
+
|
|
74
|
+
/** Encode an opaque eligibility proof as the reserved capability tag. */
|
|
75
|
+
export function eligibilityTag(proof: string): string {
|
|
76
|
+
return ELIGIBILITY_TAG_PREFIX + proof;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Extract the eligibility proof from a capability set, or `undefined` if no eligibility tag is present. */
|
|
80
|
+
export function eligibilityProofFromCapabilities(capabilities: readonly string[]): string | undefined {
|
|
81
|
+
const tag = capabilities.find((c) => c.startsWith(ELIGIBILITY_TAG_PREFIX));
|
|
82
|
+
return tag === undefined ? undefined : tag.slice(ELIGIBILITY_TAG_PREFIX.length);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Extract the eligibility proof carried by a forwarded {@link ProviderEntryV1} (`undefined` if absent). */
|
|
86
|
+
export function eligibilityProofOf(entry: ProviderEntryV1): string | undefined {
|
|
87
|
+
return eligibilityProofFromCapabilities(entry.capabilities);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- discovery seam (satisfied by MatchmakingSeeker.walk + multi-cohort-seeker, wired in db-p2p) ---
|
|
91
|
+
|
|
92
|
+
/** One discovery slice: candidate entries plus the hotness signal that drives the single-vs-sweep choice. */
|
|
93
|
+
export interface QuorumDiscoverySlice {
|
|
94
|
+
/** Candidate provider entries returned by this discovery hop (advisory — the assembler re-verifies). */
|
|
95
|
+
readonly entries: readonly ProviderEntryV1[];
|
|
96
|
+
/**
|
|
97
|
+
* Max `topicTraffic.childCohortCount` observed across the walked tiers. `> 0` means the topic has
|
|
98
|
+
* promoted (it is hot), so a single-cohort sample is unrepresentative and the assembler escalates to
|
|
99
|
+
* the multi-cohort sweep. The sweep hop sets this to `0` (the decision is already made).
|
|
100
|
+
*/
|
|
101
|
+
readonly childCohortCount: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Inputs to one discovery hop. */
|
|
105
|
+
export interface QuorumDiscoveryRequest {
|
|
106
|
+
/** The quorum topic id (`H("quorum" ‖ proposalHash ‖ "match")`). */
|
|
107
|
+
readonly topicId: Uint8Array;
|
|
108
|
+
/** Providers desired (drives the hang-out feasibility math / how many shards the sweep unions). */
|
|
109
|
+
readonly wantCount: number;
|
|
110
|
+
/** Patience budget remaining for this hop (ms); drains across walk → sweep. */
|
|
111
|
+
readonly patienceMs: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The discovery surface {@link VotingQuorumAssembler} composes over. db-p2p binds:
|
|
116
|
+
* - {@link QuorumDiscovery.walk} → `MatchmakingSeeker.walk` (single-cohort hang-out walk), and
|
|
117
|
+
* - {@link QuorumDiscovery.sweep} → `multi-cohort-seeker` (root `AggregateCountV1` → tier shards union).
|
|
118
|
+
*
|
|
119
|
+
* Both surface already-deduped, `registrationSig`-validated entries in production; the assembler treats
|
|
120
|
+
* them as advisory candidates and re-validates regardless, which keeps the delegated-assembler
|
|
121
|
+
* (trust-but-verify) path and the mock-tier test fixtures honest.
|
|
122
|
+
*/
|
|
123
|
+
export interface QuorumDiscovery {
|
|
124
|
+
/** Single-cohort hang-out walk; returns matched candidates plus the observed hotness signal. */
|
|
125
|
+
walk(req: QuorumDiscoveryRequest): Promise<QuorumDiscoverySlice>;
|
|
126
|
+
/** Multi-cohort sweep across high-population tier shards; returns the unioned candidate slice. */
|
|
127
|
+
sweep(req: QuorumDiscoveryRequest): Promise<QuorumDiscoverySlice>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- request / result ---
|
|
131
|
+
|
|
132
|
+
/** A request to assemble a voting quorum for one proposal. All voting-layer policy is injected here. */
|
|
133
|
+
export interface VotingQuorumRequest {
|
|
134
|
+
/** The proposal hash — the quorum topic's label. */
|
|
135
|
+
readonly proposalHash: string;
|
|
136
|
+
/** Desired quorum size (integer `>= 1`). */
|
|
137
|
+
readonly targetSize: number;
|
|
138
|
+
/** Patience budget (ms); positive. Voting range is 30–300 s (§Patience budgeting), advisory. */
|
|
139
|
+
readonly patienceMs: number;
|
|
140
|
+
/** Voting/stake-layer eligibility predicate over the opaque proof in an entry's `capabilities`. */
|
|
141
|
+
readonly verifyEligibility: (entry: ProviderEntryV1) => boolean;
|
|
142
|
+
/** Voting-layer quorum selection rule; default = first `targetSize`. Must choose from the input set. */
|
|
143
|
+
readonly select?: (eligible: ProviderEntryV1[], targetSize: number) => ProviderEntryV1[];
|
|
144
|
+
/** Optional, additive reputation pre-filter applied *before* verification to trim candidates. */
|
|
145
|
+
readonly reputationPrefilter?: (candidates: ProviderEntryV1[]) => ProviderEntryV1[];
|
|
146
|
+
/** Force the multi-cohort sweep even when a single-cohort walk would suffice (representativeness). */
|
|
147
|
+
readonly preferSweep?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The outcome of {@link VotingQuorumAssembler.assembleQuorum}. */
|
|
151
|
+
export interface VotingQuorumResult {
|
|
152
|
+
/** The selected, eligibility-verified quorum, deduped by `participantId`. */
|
|
153
|
+
readonly quorum: ProviderEntryV1[];
|
|
154
|
+
/** Distinct candidates discovery surfaced (after dedup, before eligibility filtering). */
|
|
155
|
+
readonly candidates: number;
|
|
156
|
+
/** Candidates that passed `registrationSig` re-validation **and** `verifyEligibility`. */
|
|
157
|
+
readonly eligible: number;
|
|
158
|
+
/** Whether the assembled quorum met `targetSize` (`quorum.length >= targetSize`). */
|
|
159
|
+
readonly metTarget: boolean;
|
|
160
|
+
/** Whether the multi-cohort sweep was used (hot topic or `preferSweep`). */
|
|
161
|
+
readonly swept: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Construction inputs for a {@link VotingQuorumAssembler}; deps are per-role and validated at call time. */
|
|
165
|
+
export interface VotingQuorumAssemblerDeps {
|
|
166
|
+
/** Stable topic anchor; defaults to db-core's ring-hash anchor ({@link createMatchTopicAnchor}). */
|
|
167
|
+
readonly anchor?: MatchTopicAnchor;
|
|
168
|
+
/** Discovery surface (walk + sweep) — required by {@link VotingQuorumAssembler.assembleQuorum}. */
|
|
169
|
+
readonly discovery?: QuorumDiscovery;
|
|
170
|
+
/**
|
|
171
|
+
* Per-entry `registrationSig` verifier (db-p2p binds `verifyPeerSig`) — required by
|
|
172
|
+
* {@link VotingQuorumAssembler.assembleQuorum}. db-core is crypto-free, so the peer-key check is
|
|
173
|
+
* injected, matching {@link verifyProviderEntry}'s contract.
|
|
174
|
+
*/
|
|
175
|
+
readonly verifyEntrySig?: EntrySigVerifier;
|
|
176
|
+
/** Sign a provider registration image — required by {@link VotingQuorumAssembler.registerEligibleVoter}. */
|
|
177
|
+
readonly sign?: (payload: Uint8Array) => Promise<string>;
|
|
178
|
+
/**
|
|
179
|
+
* Register a signed voter-provider payload at the quorum topic (cohort-topic T2) — required by
|
|
180
|
+
* {@link VotingQuorumAssembler.registerEligibleVoter}. db-p2p binds this to the provider manager /
|
|
181
|
+
* `MatchmakingProvider.register` once `matchmaking-sweep-adversarial-module` lands.
|
|
182
|
+
*/
|
|
183
|
+
readonly registerProvider?: (req: RegisterVoterProviderRequest) => Promise<void>;
|
|
184
|
+
/** CSPRNG source for the provider correlation id (injectable for deterministic tests). */
|
|
185
|
+
readonly randomBytes?: (n: number) => Uint8Array;
|
|
186
|
+
/** Wall clock (unix ms), injectable for tests; default `Date.now`. Splits patience across walk → sweep. */
|
|
187
|
+
readonly clock?: () => number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What {@link VotingQuorumAssembler.registerEligibleVoter} hands the injected `registerProvider` port. */
|
|
191
|
+
export interface RegisterVoterProviderRequest {
|
|
192
|
+
/** The quorum topic id this voter registers at. */
|
|
193
|
+
readonly topicId: Uint8Array;
|
|
194
|
+
/** The opaque, signed cohort-topic `RegisterV1.appPayload` bytes for the voter provider. */
|
|
195
|
+
readonly appPayloadBytes: Uint8Array;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Construction inputs for {@link VotingQuorumAssembler.registerEligibleVoter}. */
|
|
199
|
+
export interface RegisterEligibleVoterRequest {
|
|
200
|
+
/** The proposal hash — the quorum topic's label. */
|
|
201
|
+
readonly proposalHash: string;
|
|
202
|
+
/** base64url, app-defined eligibility proof minted by the voting/stake layer. */
|
|
203
|
+
readonly eligibilityProof: string;
|
|
204
|
+
/** Extra application capability tags merged into the registration (the proof tag is always added). */
|
|
205
|
+
readonly capabilityTags?: readonly string[];
|
|
206
|
+
/** Concurrent vote-collection budget; default {@link DEFAULT_VOTER_CAPACITY_BUDGET} (1). */
|
|
207
|
+
readonly capacityBudget?: number;
|
|
208
|
+
/** Multiaddr or PeerId-based callback for the vote-collection dial. */
|
|
209
|
+
readonly contactHint: string;
|
|
210
|
+
/** Optional soft expiry hint (unix ms). */
|
|
211
|
+
readonly serviceUntil?: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Discovery-only voting-quorum assembler over the matchmaking module. One instance can act in either
|
|
216
|
+
* role: a voter calls {@link registerEligibleVoter}; a coordinator (or delegated assembler) calls
|
|
217
|
+
* {@link assembleQuorum}. Deps are role-specific and asserted per method, so a coordinator need not
|
|
218
|
+
* supply `sign`/`registerProvider` and a voter need not supply `discovery`/`verifyEntrySig`.
|
|
219
|
+
*/
|
|
220
|
+
export class VotingQuorumAssembler {
|
|
221
|
+
private readonly anchor: MatchTopicAnchor;
|
|
222
|
+
private readonly deps: VotingQuorumAssemblerDeps;
|
|
223
|
+
private readonly clock: () => number;
|
|
224
|
+
|
|
225
|
+
constructor(deps: VotingQuorumAssemblerDeps = {}) {
|
|
226
|
+
this.deps = deps;
|
|
227
|
+
this.anchor = deps.anchor ?? createMatchTopicAnchor();
|
|
228
|
+
this.clock = deps.clock ?? ((): number => Date.now());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The quorum topic anchor for a proposal: `kind = "quorum"`, `label = proposalHash`, yielding
|
|
233
|
+
* `topicId = H("quorum" ‖ proposalHash ‖ "match")` via the matchmaking topic anchor.
|
|
234
|
+
*/
|
|
235
|
+
static quorumTopic(proposalHash: string): { kind: "quorum"; label: string } {
|
|
236
|
+
return { kind: "quorum", label: proposalHash };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Resolve the cohort-topic `topicId` for a proposal's quorum topic. */
|
|
240
|
+
topicIdFor(proposalHash: string): Uint8Array {
|
|
241
|
+
const { kind, label } = VotingQuorumAssembler.quorumTopic(proposalHash);
|
|
242
|
+
return this.anchor.topicId(kind, label);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Voter side: bind the caller-minted eligibility proof into `capabilities`, build the signed provider
|
|
247
|
+
* registration, and register it at the quorum topic (cohort-topic T2). The proof is opaque to
|
|
248
|
+
* matchmaking; the provider `signature` covers `(topicId, capabilities, capacityBudget)`, so it binds
|
|
249
|
+
* the proof to the registration.
|
|
250
|
+
*/
|
|
251
|
+
async registerEligibleVoter(req: RegisterEligibleVoterRequest): Promise<void> {
|
|
252
|
+
const sign = this.requireSign();
|
|
253
|
+
const registerProvider = this.requireRegisterProvider();
|
|
254
|
+
const topicId = this.topicIdFor(req.proposalHash);
|
|
255
|
+
const capacityBudget = req.capacityBudget ?? DEFAULT_VOTER_CAPACITY_BUDGET;
|
|
256
|
+
const capabilities = mergeEligibilityCapabilities(req.capabilityTags ?? [], req.eligibilityProof);
|
|
257
|
+
|
|
258
|
+
const provider = new MatchmakingProvider({
|
|
259
|
+
topicId,
|
|
260
|
+
capabilities,
|
|
261
|
+
capacityBudget,
|
|
262
|
+
contactHint: req.contactHint,
|
|
263
|
+
sign,
|
|
264
|
+
...(req.serviceUntil !== undefined ? { serviceUntil: req.serviceUntil } : {}),
|
|
265
|
+
...(this.deps.randomBytes !== undefined ? { randomBytes: this.deps.randomBytes } : {}),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await registerProvider({ topicId, appPayloadBytes: await provider.appPayloadBytes() });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Coordinator / delegated-assembler side: discover → verify → select. Drives the single-cohort walk,
|
|
273
|
+
* escalates to the multi-cohort sweep on a hot topic (`childCohortCount > 0`) or when `preferSweep`,
|
|
274
|
+
* dedups by `participantId`, applies the optional reputation pre-filter, re-validates each entry's
|
|
275
|
+
* `registrationSig` **and** `verifyEligibility`, then applies the selection rule. Passes the *draining*
|
|
276
|
+
* patience budget to each discovery hop (walk, then sweep) — the deadline is fixed at entry and each
|
|
277
|
+
* hop receives the remaining slice — and on exhaustion returns whatever matched with `metTarget = false`.
|
|
278
|
+
* (Both legs honour `patienceMs`: the walk enforces a wall-clock deadline across hops and hang-out; the
|
|
279
|
+
* sweep stops starting new shard queries once the budget drains. The residual is at most one in-flight
|
|
280
|
+
* shard RPC — assembly never blocks materially past `patienceMs`.)
|
|
281
|
+
*/
|
|
282
|
+
async assembleQuorum(req: VotingQuorumRequest): Promise<VotingQuorumResult> {
|
|
283
|
+
const discovery = this.requireDiscovery();
|
|
284
|
+
const verifyEntrySig = this.requireVerifyEntrySig();
|
|
285
|
+
const targetSize = requireTargetSize(req.targetSize);
|
|
286
|
+
requirePositivePatience(req.patienceMs);
|
|
287
|
+
const topicId = this.topicIdFor(req.proposalHash);
|
|
288
|
+
|
|
289
|
+
const deadline = this.clock() + req.patienceMs;
|
|
290
|
+
const remaining = (): number => Math.max(0, deadline - this.clock());
|
|
291
|
+
|
|
292
|
+
// Step 2: single-cohort walk, then the single-vs-sweep decision.
|
|
293
|
+
const walkSlice = await discovery.walk({ topicId, wantCount: targetSize, patienceMs: remaining() });
|
|
294
|
+
const swept = req.preferSweep === true || walkSlice.childCohortCount > 0;
|
|
295
|
+
const slices: QuorumDiscoverySlice[] = [walkSlice];
|
|
296
|
+
if (swept) {
|
|
297
|
+
slices.push(await discovery.sweep({ topicId, wantCount: targetSize, patienceMs: remaining() }));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Step 3: dedup the unioned candidate set by participantId (defensive — promotion/redirect overlap).
|
|
301
|
+
const candidates = dedupByParticipant(slices.flatMap((s) => [...s.entries]));
|
|
302
|
+
|
|
303
|
+
// Step 4: optional, additive reputation pre-filter (bandwidth/quality trim; never a dependency).
|
|
304
|
+
const prefiltered = req.reputationPrefilter ? req.reputationPrefilter([...candidates]) : candidates;
|
|
305
|
+
|
|
306
|
+
// Step 5: reply-side default — both registrationSig re-validation AND verifyEligibility must pass.
|
|
307
|
+
const eligible = prefiltered.filter(
|
|
308
|
+
(entry) => verifyProviderEntry(topicId, entry, verifyEntrySig) && req.verifyEligibility(entry),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Step 6: selection rule (default first targetSize), deduped defensively.
|
|
312
|
+
const select = req.select ?? defaultSelect;
|
|
313
|
+
const quorum = dedupByParticipant(select([...eligible], targetSize));
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
quorum,
|
|
317
|
+
candidates: candidates.length,
|
|
318
|
+
eligible: eligible.length,
|
|
319
|
+
metTarget: quorum.length >= targetSize,
|
|
320
|
+
swept,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private requireDiscovery(): QuorumDiscovery {
|
|
325
|
+
if (this.deps.discovery === undefined) {
|
|
326
|
+
throw new Error("VotingQuorumAssembler.assembleQuorum requires a `discovery` dependency");
|
|
327
|
+
}
|
|
328
|
+
return this.deps.discovery;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private requireVerifyEntrySig(): EntrySigVerifier {
|
|
332
|
+
if (this.deps.verifyEntrySig === undefined) {
|
|
333
|
+
throw new Error("VotingQuorumAssembler.assembleQuorum requires a `verifyEntrySig` dependency");
|
|
334
|
+
}
|
|
335
|
+
return this.deps.verifyEntrySig;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private requireSign(): (payload: Uint8Array) => Promise<string> {
|
|
339
|
+
if (this.deps.sign === undefined) {
|
|
340
|
+
throw new Error("VotingQuorumAssembler.registerEligibleVoter requires a `sign` dependency");
|
|
341
|
+
}
|
|
342
|
+
return this.deps.sign;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private requireRegisterProvider(): (req: RegisterVoterProviderRequest) => Promise<void> {
|
|
346
|
+
if (this.deps.registerProvider === undefined) {
|
|
347
|
+
throw new Error("VotingQuorumAssembler.registerEligibleVoter requires a `registerProvider` dependency");
|
|
348
|
+
}
|
|
349
|
+
return this.deps.registerProvider;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The default selection rule: take the first `targetSize` eligible entries (the discovery order). */
|
|
354
|
+
export function defaultSelect(eligible: ProviderEntryV1[], targetSize: number): ProviderEntryV1[] {
|
|
355
|
+
return eligible.slice(0, targetSize);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Merge application capability tags with the eligibility proof tag: drop any caller-supplied tag that
|
|
360
|
+
* already uses the reserved prefix (the proof is authoritative), then append exactly one proof tag.
|
|
361
|
+
* Order is preserved end-to-end (the cohort forwards `capabilities` verbatim), so the provider signature
|
|
362
|
+
* reconstructs byte-for-byte on the seeker side.
|
|
363
|
+
*/
|
|
364
|
+
function mergeEligibilityCapabilities(tags: readonly string[], proof: string): string[] {
|
|
365
|
+
const appTags = tags.filter((tag) => !tag.startsWith(ELIGIBILITY_TAG_PREFIX));
|
|
366
|
+
return [...appTags, eligibilityTag(proof)];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Dedup entries by `participantId`, keeping the first occurrence (preserves discovery/FCFS order). */
|
|
370
|
+
function dedupByParticipant(entries: readonly ProviderEntryV1[]): ProviderEntryV1[] {
|
|
371
|
+
const seen = new Map<string, ProviderEntryV1>();
|
|
372
|
+
for (const entry of entries) {
|
|
373
|
+
if (!seen.has(entry.participantId)) {
|
|
374
|
+
seen.set(entry.participantId, entry);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return [...seen.values()];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Validate `targetSize` is an integer `>= 1`. */
|
|
381
|
+
function requireTargetSize(targetSize: number): number {
|
|
382
|
+
if (!Number.isInteger(targetSize) || targetSize < 1) {
|
|
383
|
+
throw new RangeError(`voting quorum: targetSize must be an integer >= 1, got ${targetSize}`);
|
|
384
|
+
}
|
|
385
|
+
return targetSize;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Validate `patienceMs` is a finite number `> 0` (the voting 30–300 s range is advisory, not enforced). */
|
|
389
|
+
function requirePositivePatience(patienceMs: number): number {
|
|
390
|
+
if (!Number.isFinite(patienceMs) || patienceMs <= 0) {
|
|
391
|
+
throw new RangeError(`voting quorum: patienceMs must be a finite number > 0, got ${patienceMs}`);
|
|
392
|
+
}
|
|
393
|
+
return patienceMs;
|
|
394
|
+
}
|