@bitsocial/pubsub-votes 0.0.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/LICENSE +674 -0
- package/README.md +212 -0
- package/dist/chain/bucket.d.ts +13 -0
- package/dist/chain/bucket.js +24 -0
- package/dist/chain/ticker.d.ts +15 -0
- package/dist/chain/ticker.js +25 -0
- package/dist/chain/types.d.ts +90 -0
- package/dist/chain/types.js +1 -0
- package/dist/checkpoint/codec.d.ts +54 -0
- package/dist/checkpoint/codec.js +99 -0
- package/dist/client/root-puller.d.ts +49 -0
- package/dist/client/root-puller.js +140 -0
- package/dist/client/voter.d.ts +225 -0
- package/dist/client/voter.js +1195 -0
- package/dist/crdt/codec.d.ts +41 -0
- package/dist/crdt/codec.js +137 -0
- package/dist/crdt/crdt.d.ts +22 -0
- package/dist/crdt/crdt.js +127 -0
- package/dist/crdt/store.d.ts +8 -0
- package/dist/crdt/store.js +23 -0
- package/dist/crdt/types.d.ts +87 -0
- package/dist/crdt/types.js +1 -0
- package/dist/encoding/canonical.d.ts +22 -0
- package/dist/encoding/canonical.js +26 -0
- package/dist/errors.d.ts +72 -0
- package/dist/errors.js +111 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +39 -0
- package/dist/rules/constant.d.ts +14 -0
- package/dist/rules/constant.js +18 -0
- package/dist/rules/erc20-balance.d.ts +30 -0
- package/dist/rules/erc20-balance.js +44 -0
- package/dist/rules/erc721-min-balance.d.ts +18 -0
- package/dist/rules/erc721-min-balance.js +56 -0
- package/dist/rules/registry.d.ts +42 -0
- package/dist/rules/registry.js +61 -0
- package/dist/rules/types.d.ts +74 -0
- package/dist/rules/types.js +1 -0
- package/dist/schema/common.d.ts +25 -0
- package/dist/schema/common.js +24 -0
- package/dist/schema/criteria.d.ts +78 -0
- package/dist/schema/criteria.js +76 -0
- package/dist/schema/directory.d.ts +42 -0
- package/dist/schema/directory.js +52 -0
- package/dist/schema/votes.d.ts +55 -0
- package/dist/schema/votes.js +116 -0
- package/dist/signer/eip712.d.ts +103 -0
- package/dist/signer/eip712.js +85 -0
- package/dist/signer/types.d.ts +31 -0
- package/dist/signer/types.js +1 -0
- package/dist/storage/browser.d.ts +3 -0
- package/dist/storage/browser.js +110 -0
- package/dist/storage/memory.d.ts +10 -0
- package/dist/storage/memory.js +56 -0
- package/dist/storage/node.d.ts +5 -0
- package/dist/storage/node.js +106 -0
- package/dist/storage/types.d.ts +46 -0
- package/dist/storage/types.js +1 -0
- package/dist/store/indexeddb.d.ts +9 -0
- package/dist/store/indexeddb.js +72 -0
- package/dist/store/memory.d.ts +15 -0
- package/dist/store/memory.js +22 -0
- package/dist/store/select.d.ts +15 -0
- package/dist/store/select.js +64 -0
- package/dist/store/sqlite.d.ts +11 -0
- package/dist/store/sqlite.js +68 -0
- package/dist/store/types.d.ts +57 -0
- package/dist/store/types.js +1 -0
- package/dist/tally/tally.d.ts +44 -0
- package/dist/tally/tally.js +89 -0
- package/dist/tally/types.d.ts +51 -0
- package/dist/tally/types.js +13 -0
- package/dist/topic.d.ts +20 -0
- package/dist/topic.js +28 -0
- package/dist/transport/accepted-dedup.d.ts +30 -0
- package/dist/transport/accepted-dedup.js +34 -0
- package/dist/transport/announce/browser.d.ts +9 -0
- package/dist/transport/announce/browser.js +14 -0
- package/dist/transport/announce/node.d.ts +38 -0
- package/dist/transport/announce/node.js +162 -0
- package/dist/transport/announce/types.d.ts +74 -0
- package/dist/transport/announce/types.js +16 -0
- package/dist/transport/bundle-store.d.ts +11 -0
- package/dist/transport/bundle-store.js +34 -0
- package/dist/transport/chase.d.ts +83 -0
- package/dist/transport/chase.js +88 -0
- package/dist/transport/gossip-validator.d.ts +107 -0
- package/dist/transport/gossip-validator.js +99 -0
- package/dist/transport/helia.d.ts +41 -0
- package/dist/transport/helia.js +98 -0
- package/dist/transport/integration/harness.d.ts +60 -0
- package/dist/transport/integration/harness.js +262 -0
- package/dist/transport/messages.d.ts +97 -0
- package/dist/transport/messages.js +117 -0
- package/dist/transport/rate-limit.d.ts +11 -0
- package/dist/transport/rate-limit.js +20 -0
- package/dist/transport/transport.d.ts +20 -0
- package/dist/transport/transport.js +35 -0
- package/dist/transport/types.d.ts +165 -0
- package/dist/transport/types.js +1 -0
- package/dist/verify/background.d.ts +81 -0
- package/dist/verify/background.js +236 -0
- package/dist/verify/bundle.d.ts +58 -0
- package/dist/verify/bundle.js +84 -0
- package/dist/verify/cache.d.ts +48 -0
- package/dist/verify/cache.js +62 -0
- package/dist/verify/constraints.d.ts +16 -0
- package/dist/verify/constraints.js +35 -0
- package/dist/verify/gate-result-cache.d.ts +65 -0
- package/dist/verify/gate-result-cache.js +91 -0
- package/dist/verify/name-resolution-cache.d.ts +59 -0
- package/dist/verify/name-resolution-cache.js +64 -0
- package/dist/verify/signature.d.ts +9 -0
- package/dist/verify/signature.js +55 -0
- package/dist/verify/types.d.ts +101 -0
- package/dist/verify/types.js +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { CID } from "multiformats/cid";
|
|
2
|
+
import type { PeerId } from "@libp2p/interface";
|
|
3
|
+
import type { Helia } from "helia";
|
|
4
|
+
import type { RootRecord } from "./messages.js";
|
|
5
|
+
/**
|
|
6
|
+
* Transport interfaces. This is the ONLY part of the library that
|
|
7
|
+
* touches libp2p/helia. The core (schema/verify/crdt/tally) does not import it, so the
|
|
8
|
+
* engine is testable without a network.
|
|
9
|
+
*
|
|
10
|
+
* Three exchanges (see DESIGN.md "Transport"):
|
|
11
|
+
* - pubsub: broadcast and receive **inline bundle deltas** and **root-record heartbeats**
|
|
12
|
+
* (gossipsub), with a topic validator that drops invalid messages before the mesh
|
|
13
|
+
* re-forwards them.
|
|
14
|
+
* - fetch protocol: pull a connected peer's current root record on cold start / reconnect,
|
|
15
|
+
* then union across peers so a single liar cannot hide a vote.
|
|
16
|
+
* - directed bitswap: pull the checkpoint blocks behind an advertised root from the
|
|
17
|
+
* connected peers that advertised it (through the blockstore) — the only bitswap use.
|
|
18
|
+
*
|
|
19
|
+
* The library does not start a node and does not take a host SDK. It receives the
|
|
20
|
+
* host's already-running **Helia node** directly (e.g. the value `createHelia` returns,
|
|
21
|
+
* which for a pkc-js host is `pkc.clients.libp2pJsClients[key]._helia`) and drives its
|
|
22
|
+
* libp2p pubsub + blockstore itself — there is no host-written adapter. The node must
|
|
23
|
+
* carry a gossipsub service at `libp2p.services.pubsub` and a usable `blockstore`;
|
|
24
|
+
* construction enforces both (see `requireHeliaServices` / `MissingPubsubError` /
|
|
25
|
+
* `MissingBlockstoreError`).
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Verdict from a gossipsub topic validator. Mirrors the semantics of libp2p's
|
|
29
|
+
* `TopicValidatorResult` (accept / reject / ignore) but is declared locally:
|
|
30
|
+
* `@libp2p/interface` 3.x no longer re-exports the pubsub types, so the
|
|
31
|
+
* implementation maps these onto whatever enum the host's gossipsub uses.
|
|
32
|
+
* - "accept": valid; deliver to subscribers and let the mesh re-forward it.
|
|
33
|
+
* - "reject": invalid; drop, do not forward, and penalize the sender's peer score.
|
|
34
|
+
* - "ignore": drop and do not forward, without penalizing (well-formed but not useful).
|
|
35
|
+
*/
|
|
36
|
+
export type TopicValidatorResult = "accept" | "reject" | "ignore";
|
|
37
|
+
/**
|
|
38
|
+
* The subset of a libp2p pubsub (gossipsub) service this library drives, declared
|
|
39
|
+
* structurally because `@libp2p/interface` 3.x no longer re-exports `PubSub`. Any
|
|
40
|
+
* gossipsub implementation the host registers at `services.pubsub` satisfies this
|
|
41
|
+
* (e.g. `@chainsafe/libp2p-gossipsub`). Kept minimal on purpose; the full transport
|
|
42
|
+
* (topic validators, fetch protocol) is design-only and may widen this as it lands.
|
|
43
|
+
*/
|
|
44
|
+
export interface PubsubService {
|
|
45
|
+
/**
|
|
46
|
+
* Broadcast bytes to a topic mesh. gossipsub resolves to `{ recipients }` — the peers it
|
|
47
|
+
* *directly sent the RPC to* at publish time (first-hop fan-out, filtered for send failures),
|
|
48
|
+
* NOT total network reach and NOT an acceptance confirmation (each recipient still runs the
|
|
49
|
+
* forward-gate). With the host's default `floodPublish`, that's every connected topic peer
|
|
50
|
+
* above the publish score threshold. Typed loosely (`recipients` optional) so a non-gossipsub
|
|
51
|
+
* pubsub still satisfies it; the transport reads `recipients?.length ?? 0`. Note gossipsub
|
|
52
|
+
* *rejects* with `NoPeersSubscribedToTopic` when it would send to zero peers unless the host
|
|
53
|
+
* sets `allowPublishToZeroTopicPeers`.
|
|
54
|
+
*/
|
|
55
|
+
publish(topic: string, data: Uint8Array): Promise<{
|
|
56
|
+
recipients?: readonly PeerId[];
|
|
57
|
+
}>;
|
|
58
|
+
/** Join a topic; received messages arrive via the "message" event. */
|
|
59
|
+
subscribe(topic: string): void;
|
|
60
|
+
/** Leave a topic. */
|
|
61
|
+
unsubscribe(topic: string): void;
|
|
62
|
+
/** Peers we currently see subscribed to a topic, used to pick fetch targets. */
|
|
63
|
+
getSubscribers(topic: string): PeerId[];
|
|
64
|
+
addEventListener(type: "message", listener: (evt: {
|
|
65
|
+
detail: {
|
|
66
|
+
topic: string;
|
|
67
|
+
data: Uint8Array;
|
|
68
|
+
from?: PeerId;
|
|
69
|
+
};
|
|
70
|
+
}) => void): void;
|
|
71
|
+
removeEventListener(type: "message", listener: (evt: {
|
|
72
|
+
detail: {
|
|
73
|
+
topic: string;
|
|
74
|
+
data: Uint8Array;
|
|
75
|
+
from?: PeerId;
|
|
76
|
+
};
|
|
77
|
+
}) => void): void;
|
|
78
|
+
/**
|
|
79
|
+
* gossipsub's per-topic validator map. The transport installs the async forward-gate
|
|
80
|
+
* here (`topicValidators.set(topic, gate)`); gossipsub awaits the returned promise
|
|
81
|
+
* before re-forwarding the message to the mesh, which is what makes `reject` land on the
|
|
82
|
+
* sender for semantic invalidity. Optional because a non-gossipsub pubsub lacks it — the
|
|
83
|
+
* transport checks for it at `start()` and throws if absent. `@libp2p/gossipsub` exposes
|
|
84
|
+
* it as a mutable `Map`. See DESIGN.md "Transport".
|
|
85
|
+
*/
|
|
86
|
+
topicValidators?: Map<string, GossipTopicValidator>;
|
|
87
|
+
}
|
|
88
|
+
/** A received pubsub message, as passed to a gossipsub topic validator. */
|
|
89
|
+
export interface GossipMessage {
|
|
90
|
+
topic: string;
|
|
91
|
+
data: Uint8Array;
|
|
92
|
+
from?: PeerId;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A gossipsub topic validator: run on every received message BEFORE re-forwarding, returning
|
|
96
|
+
* (or resolving to) a {@link TopicValidatorResult}. Returning a promise makes the gate async —
|
|
97
|
+
* gossipsub awaits it, so the full fetch + verify pipeline can run before the message is
|
|
98
|
+
* forwarded (see DESIGN.md "Transport"). Declared structurally so the library imports no
|
|
99
|
+
* gossipsub package; `@libp2p/gossipsub`'s `TopicValidatorFn` satisfies it.
|
|
100
|
+
*/
|
|
101
|
+
export type GossipTopicValidator = (peer: PeerId, message: GossipMessage) => TopicValidatorResult | Promise<TopicValidatorResult>;
|
|
102
|
+
/**
|
|
103
|
+
* The subset of the libp2p **fetch service** (`@libp2p/fetch`) this library drives,
|
|
104
|
+
* declared structurally so no runtime dependency on the package is needed. The host MUST
|
|
105
|
+
* register it at `libp2p.services.fetch`; construction throws `MissingFetchError`
|
|
106
|
+
* otherwise. Used for the root-record pull (see DESIGN.md "Checkpoints"): this library
|
|
107
|
+
* registers a lookup for its own key prefix (the responder) and fetches connected topic
|
|
108
|
+
* peers' records on cold start (the requester).
|
|
109
|
+
*/
|
|
110
|
+
export interface FetchServiceLike {
|
|
111
|
+
/** Request the value for `key` from a connected peer; nullish when the peer has none. */
|
|
112
|
+
fetch(peer: PeerId, key: string, options?: {
|
|
113
|
+
signal?: AbortSignal;
|
|
114
|
+
}): Promise<Uint8Array | undefined | null>;
|
|
115
|
+
/**
|
|
116
|
+
* Register the responder for every key starting with `prefix`. `@libp2p/fetch` invokes the
|
|
117
|
+
* lookup with the requested key as **raw bytes** (`Uint8Array`), not a string — it matches the
|
|
118
|
+
* `prefix` against the utf8-decoded key but hands the callback the identifier bytes. The
|
|
119
|
+
* responder decodes them itself (see `PubsubVoter.#rootLookup`).
|
|
120
|
+
*/
|
|
121
|
+
registerLookupFunction(prefix: string, lookup: (key: Uint8Array) => Promise<Uint8Array | undefined>): void;
|
|
122
|
+
/** Remove a registered responder (all of the prefix's, when `lookup` is omitted). */
|
|
123
|
+
unregisterLookupFunction(prefix: string, lookup?: (key: Uint8Array) => Promise<Uint8Array | undefined>): void;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The subset of a Helia blockstore this library drives, declared structurally. Vote
|
|
127
|
+
* bundles are content-addressed blocks fetched/stored by CID through it (bitswap
|
|
128
|
+
* retrieves through the blockstore). The full `Blocks` type carries progress-event
|
|
129
|
+
* generics we do not need here.
|
|
130
|
+
*/
|
|
131
|
+
export interface BlockstoreLike {
|
|
132
|
+
/** `options.signal` cancels an in-flight bitswap fetch — see the chase deadline (DESIGN.md "Checkpoints"). */
|
|
133
|
+
get(cid: CID, options?: {
|
|
134
|
+
signal?: AbortSignal;
|
|
135
|
+
}): Promise<Uint8Array>;
|
|
136
|
+
put(cid: CID, block: Uint8Array): Promise<CID>;
|
|
137
|
+
has(cid: CID): Promise<boolean>;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The host's running Helia node, as injected into {@link PubsubVoter}. Typed with the
|
|
141
|
+
* default libp2p `ServiceMap`, so `libp2p.services.pubsub` is `unknown` and cannot be
|
|
142
|
+
* trusted at compile time (a plain Helia node has no pubsub) — `requireHeliaServices`
|
|
143
|
+
* validates the gossipsub service and the blockstore at construction and narrows them.
|
|
144
|
+
*/
|
|
145
|
+
export type HeliaInstance = Helia;
|
|
146
|
+
/** Live-delta propagation over pubsub (one inline bundle per message + root heartbeats). */
|
|
147
|
+
export interface VoteTransport {
|
|
148
|
+
/**
|
|
149
|
+
* Subscribe to the topic and install the async validate-before-forward gate as the
|
|
150
|
+
* topic validator. See DESIGN.md "Transport: gossipsub topic + validation".
|
|
151
|
+
*/
|
|
152
|
+
start(): Promise<void>;
|
|
153
|
+
stop(): Promise<void>;
|
|
154
|
+
/**
|
|
155
|
+
* Publish one bundle's exact binary block bytes as a live delta (a new vote, a client
|
|
156
|
+
* re-publish, or a withdrawal). Resolves `recipientCount`: how many peers gossipsub sent the
|
|
157
|
+
* message directly to (see {@link PubsubService.publish} for what that number does and does not
|
|
158
|
+
* mean).
|
|
159
|
+
*/
|
|
160
|
+
publishBundle(blockBytes: Uint8Array): Promise<{
|
|
161
|
+
recipientCount: number;
|
|
162
|
+
}>;
|
|
163
|
+
/** Publish a root-record heartbeat (see DESIGN.md "Checkpoints"). */
|
|
164
|
+
publishRootRecord(record: RootRecord): Promise<void>;
|
|
165
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CID } from "multiformats/cid";
|
|
2
|
+
import type { VotesBundle } from "../schema/votes.js";
|
|
3
|
+
import type { Criteria } from "../schema/criteria.js";
|
|
4
|
+
import type { RuleRegistry } from "../rules/types.js";
|
|
5
|
+
import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
|
|
6
|
+
import type { GateResultCache } from "./gate-result-cache.js";
|
|
7
|
+
import { type NameResolutionCache } from "./name-resolution-cache.js";
|
|
8
|
+
import type { VerdictCache } from "./cache.js";
|
|
9
|
+
import type { VerifyFail } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* The background chain verifier: runs the two deferred NETWORK checks — the on-chain gate
|
|
12
|
+
* (`rule` scores the wallet `> 0n` at the bucket block) and community-name resolution — for
|
|
13
|
+
* bundles that were admitted *provisionally* after the synchronous offline checks (signature +
|
|
14
|
+
* constraints). This is what makes a cold join non-blocking: the chase admits a checkpoint's
|
|
15
|
+
* bundles on offline validity alone (µs each), the first tally renders immediately with
|
|
16
|
+
* `chainVerified: false` rows, and this verifier confirms or evicts in the background (see
|
|
17
|
+
* DESIGN.md "Background chain verification").
|
|
18
|
+
*
|
|
19
|
+
* Batched, not sequential: a checkpoint's bundles share one bucket sample block, so the gate
|
|
20
|
+
* stage groups pending wallets per sample block and prefers the rule's `evaluateMany` (one
|
|
21
|
+
* multicall3 round trip for N wallets) over N serial `readContract` calls, falling back to
|
|
22
|
+
* `limit`-bounded per-wallet reads for rules without a batched form. Results feed the shared
|
|
23
|
+
* `(wallet, sampleBlock)` gate-result cache, and a settled bundle's terminal verdict feeds the
|
|
24
|
+
* shared per-CID verdict cache — so a later re-publish of the same bundle short-circuits at
|
|
25
|
+
* the gossip gate with zero chain work.
|
|
26
|
+
*
|
|
27
|
+
* Failure classes are kept apart, mirroring the forward-gate's `reject`/`ignore` split:
|
|
28
|
+
* - gate scores `0n` → EVICT + cache the provable `reject` (deterministic).
|
|
29
|
+
* - name missing/mismatched → EVICT, NOT cached (view-dependent `ignore`-class: v1
|
|
30
|
+
* resolves at head — see verify/bundle.ts step 4).
|
|
31
|
+
* - RPC / resolver THREW → infra, nobody's verdict: the bundle STAYS pending, the
|
|
32
|
+
* round retries with capped full-jitter backoff, and
|
|
33
|
+
* `onError` surfaces the degraded state to the host
|
|
34
|
+
* (Contest `error`) so "RPC down" is not silent.
|
|
35
|
+
*
|
|
36
|
+
* Pure seams, no libp2p import — unit-testable offline like the rest of the engine.
|
|
37
|
+
*/
|
|
38
|
+
/** One provisionally admitted bundle awaiting its deferred checks. */
|
|
39
|
+
export interface PendingBundle {
|
|
40
|
+
cid: CID;
|
|
41
|
+
bundle: VotesBundle;
|
|
42
|
+
}
|
|
43
|
+
export interface BackgroundVerifierDeps {
|
|
44
|
+
criteria: Criteria;
|
|
45
|
+
registry: RuleRegistry;
|
|
46
|
+
chainFor: (ticker: string) => ChainClient;
|
|
47
|
+
bucketMath: BucketMath;
|
|
48
|
+
nameResolvers: NameResolver[];
|
|
49
|
+
/** Shared `(wallet, sampleBlock)` gate scores — batch results land here, hits skip the read. */
|
|
50
|
+
gateResultCache: GateResultCache;
|
|
51
|
+
/** Shared persistent name-resolution cache (pkc-js rule, 1h max-age); omitted ⇒ resolve live. */
|
|
52
|
+
nameResolutionCache?: NameResolutionCache;
|
|
53
|
+
/** The gate's per-CID verdict cache — a settled bundle's terminal verdict is stored here. */
|
|
54
|
+
cache: VerdictCache;
|
|
55
|
+
/** The bundle's gate read confirmed `> 0n` (flip `chainVerified`, kick the tally). */
|
|
56
|
+
onGateVerified: (cid: CID) => void;
|
|
57
|
+
/** The bundle's carried name resolved to its claimed publicKey (flip `nameResolved`). */
|
|
58
|
+
onNameResolved: (cid: CID) => void;
|
|
59
|
+
/** Remove a failed bundle from the working set (gate `0n`, or a name that did not check out). */
|
|
60
|
+
onEvict: (cid: CID, verdict: VerifyFail) => void;
|
|
61
|
+
/** An infra-class failure (RPC/resolver threw): the round will retry; surface the degradation. */
|
|
62
|
+
onError: (error: unknown) => void;
|
|
63
|
+
/** Concurrency cap for the un-batched fallbacks (per-wallet reads, name resolutions). */
|
|
64
|
+
limit: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
65
|
+
/** Infra-retry backoff base / cap (ms). Full-jittered exponential between rounds. */
|
|
66
|
+
retryBaseMs?: number;
|
|
67
|
+
retryCapMs?: number;
|
|
68
|
+
}
|
|
69
|
+
export interface BackgroundChainVerifier {
|
|
70
|
+
/** Queue provisionally admitted bundles and return immediately; the drain runs detached. */
|
|
71
|
+
enqueue(entries: PendingBundle[]): void;
|
|
72
|
+
/** Bundles whose deferred checks have not settled yet (queued, in-flight, or awaiting retry). */
|
|
73
|
+
pendingCount(): number;
|
|
74
|
+
/** Resolves once every queued bundle has settled and no retry is armed (tests/introspection). */
|
|
75
|
+
idle(): Promise<void>;
|
|
76
|
+
/** Clear the retry timer (topic leave / voter destroy). Pending state is kept for `resume`. */
|
|
77
|
+
stop(): void;
|
|
78
|
+
/** Re-kick the drain after `stop()` if anything is still pending (topic re-join). */
|
|
79
|
+
resume(): void;
|
|
80
|
+
}
|
|
81
|
+
export declare function makeBackgroundVerifier(deps: BackgroundVerifierDeps): BackgroundChainVerifier;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { tickerForRef } from "../chain/ticker.js";
|
|
2
|
+
import { UnknownRuleError } from "../errors.js";
|
|
3
|
+
import { resolveNameThroughCache } from "./name-resolution-cache.js";
|
|
4
|
+
const RETRY_BASE_MS = 2_000;
|
|
5
|
+
const RETRY_CAP_MS = 60_000;
|
|
6
|
+
export function makeBackgroundVerifier(deps) {
|
|
7
|
+
const { criteria, registry, chainFor, bucketMath, nameResolvers, gateResultCache, nameResolutionCache, cache, limit } = deps;
|
|
8
|
+
const retryBaseMs = deps.retryBaseMs ?? RETRY_BASE_MS;
|
|
9
|
+
const retryCapMs = deps.retryCapMs ?? RETRY_CAP_MS;
|
|
10
|
+
// Resolve the gate `rule`, its options, and its chain once (same shape as verify/bundle.ts).
|
|
11
|
+
// The re-binding after the guard keeps the non-undefined narrowing inside the closures below.
|
|
12
|
+
const maybeRule = registry[criteria.rule.type];
|
|
13
|
+
if (!maybeRule)
|
|
14
|
+
throw new UnknownRuleError("rule", criteria.rule.type);
|
|
15
|
+
const rule = maybeRule;
|
|
16
|
+
const ruleOptions = rule.optionsSchema.parse(criteria.rule);
|
|
17
|
+
const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
|
|
18
|
+
const queue = [];
|
|
19
|
+
/** CIDs queued or in-flight, so a re-chased root cannot double-verify a bundle. */
|
|
20
|
+
const inFlight = new Set();
|
|
21
|
+
let draining = false;
|
|
22
|
+
let stopped = false;
|
|
23
|
+
/** Consecutive infra-failed rounds, driving the backoff exponent. */
|
|
24
|
+
let failedRounds = 0;
|
|
25
|
+
let retryTimer;
|
|
26
|
+
const idleResolvers = [];
|
|
27
|
+
function settle(item) {
|
|
28
|
+
inFlight.delete(item.cid.toString());
|
|
29
|
+
}
|
|
30
|
+
function maybeResolveIdle() {
|
|
31
|
+
if (queue.length === 0 && !draining && retryTimer === undefined) {
|
|
32
|
+
for (const resolve of idleResolvers.splice(0))
|
|
33
|
+
resolve();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** The wallet's gate score at its bundle's bucket sample block. */
|
|
37
|
+
function sampleBlockFor(bundle) {
|
|
38
|
+
return bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Gate stage for one round's batch: group the not-yet-gated items per sample block, read the
|
|
42
|
+
* distinct uncached wallets — `evaluateMany` when the rule has it, `limit`-bounded singles
|
|
43
|
+
* otherwise — and feed every score into the shared gate-result cache. Throws on the FIRST
|
|
44
|
+
* infra failure: the round's unfinished items are re-queued by the caller.
|
|
45
|
+
*/
|
|
46
|
+
async function gateStage(items) {
|
|
47
|
+
const groups = new Map();
|
|
48
|
+
for (const item of items) {
|
|
49
|
+
if (item.gateDone)
|
|
50
|
+
continue;
|
|
51
|
+
const block = sampleBlockFor(item.bundle);
|
|
52
|
+
groups.set(block, [...(groups.get(block) ?? []), item]);
|
|
53
|
+
}
|
|
54
|
+
for (const [sampleBlock, group] of groups) {
|
|
55
|
+
const wallets = [];
|
|
56
|
+
for (const item of group) {
|
|
57
|
+
const wallet = item.bundle.address;
|
|
58
|
+
if ((await gateResultCache.get(wallet, sampleBlock)) === undefined && !wallets.includes(wallet)) {
|
|
59
|
+
wallets.push(wallet);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (wallets.length > 0) {
|
|
63
|
+
const ctx = { chain: ruleChain, blockNumber: sampleBlock };
|
|
64
|
+
const results = rule.evaluateMany
|
|
65
|
+
? await rule.evaluateMany({ options: ruleOptions, walletAddresses: wallets, ctx })
|
|
66
|
+
: await Promise.all(wallets.map((walletAddress) => limit(() => rule.evaluate({ options: ruleOptions, walletAddress, ctx }))));
|
|
67
|
+
wallets.forEach((wallet, i) => gateResultCache.set(wallet, sampleBlock, results[i].score));
|
|
68
|
+
}
|
|
69
|
+
for (const item of group) {
|
|
70
|
+
item.ruleScore = (await gateResultCache.get(item.bundle.address, sampleBlock));
|
|
71
|
+
item.gateDone = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Settle one gate-passed item's name checks. Resolutions are deduped per round via
|
|
77
|
+
* `resolutions`. Returns "verified" | "evicted"; throws on a resolver infra failure
|
|
78
|
+
* (the caller re-queues the item — its `gateDone` survives, so only names re-run).
|
|
79
|
+
*/
|
|
80
|
+
async function nameStage(item, resolutions) {
|
|
81
|
+
for (const v of item.bundle.votes) {
|
|
82
|
+
const name = v.community.name;
|
|
83
|
+
if (!name || item.resolvedNames[name])
|
|
84
|
+
continue;
|
|
85
|
+
const resolver = nameResolvers.find((r) => r.canResolve({ name }));
|
|
86
|
+
if (!resolver) {
|
|
87
|
+
// `ignore`-class, view-dependent (a missing resolver differs per verifier) — evict,
|
|
88
|
+
// never cache (see verify/bundle.ts step 4).
|
|
89
|
+
deps.onEvict(item.cid, { valid: false, disposition: "ignore", reason: `no resolver handles community name "${name}"` });
|
|
90
|
+
return "evicted";
|
|
91
|
+
}
|
|
92
|
+
let resolution = resolutions.get(name);
|
|
93
|
+
if (!resolution) {
|
|
94
|
+
resolution = limit(() => resolveNameThroughCache({ resolver, name, cache: nameResolutionCache }));
|
|
95
|
+
resolutions.set(name, resolution);
|
|
96
|
+
}
|
|
97
|
+
const record = await resolution;
|
|
98
|
+
if (!record) {
|
|
99
|
+
deps.onEvict(item.cid, { valid: false, disposition: "ignore", reason: `community name "${name}" does not resolve` });
|
|
100
|
+
return "evicted";
|
|
101
|
+
}
|
|
102
|
+
if (record.publicKey !== v.community.publicKey) {
|
|
103
|
+
deps.onEvict(item.cid, {
|
|
104
|
+
valid: false,
|
|
105
|
+
disposition: "ignore",
|
|
106
|
+
reason: `community name "${name}" resolves to ${record.publicKey}, not the claimed ${v.community.publicKey}`
|
|
107
|
+
});
|
|
108
|
+
return "evicted";
|
|
109
|
+
}
|
|
110
|
+
item.resolvedNames[name] = record.publicKey;
|
|
111
|
+
}
|
|
112
|
+
return "verified";
|
|
113
|
+
}
|
|
114
|
+
/** One drain round over everything currently queued. Re-queues + backs off on infra failure. */
|
|
115
|
+
async function round() {
|
|
116
|
+
const batch = queue.splice(0);
|
|
117
|
+
const requeue = [];
|
|
118
|
+
let infraError;
|
|
119
|
+
// Gate stage first, whole batch: this is where batching wins (one multicall per sample
|
|
120
|
+
// block instead of one read per wallet). An infra throw leaves every un-gated item intact.
|
|
121
|
+
try {
|
|
122
|
+
await gateStage(batch);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
infraError = error;
|
|
126
|
+
}
|
|
127
|
+
const resolutions = new Map();
|
|
128
|
+
for (const item of batch) {
|
|
129
|
+
if (!item.gateDone) {
|
|
130
|
+
requeue.push(item); // gate read never happened (infra) — retry the whole item
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (item.ruleScore === 0n) {
|
|
134
|
+
// Provable, deterministic reject — safe to cache so a re-publish short-circuits.
|
|
135
|
+
const verdict = {
|
|
136
|
+
valid: false,
|
|
137
|
+
disposition: "reject",
|
|
138
|
+
reason: `not admitted: rule score is 0n at block ${sampleBlockFor(item.bundle)}`
|
|
139
|
+
};
|
|
140
|
+
cache.set(item.cid, verdict);
|
|
141
|
+
deps.onEvict(item.cid, verdict);
|
|
142
|
+
settle(item);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!item.gateNotified) {
|
|
146
|
+
item.gateNotified = true;
|
|
147
|
+
deps.onGateVerified(item.cid);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
if ((await nameStage(item, resolutions)) === "evicted") {
|
|
151
|
+
settle(item);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
infraError = error;
|
|
157
|
+
requeue.push(item); // gateDone survives — the retry only re-runs names
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (item.bundle.votes.some((v) => v.community.name))
|
|
161
|
+
deps.onNameResolved(item.cid);
|
|
162
|
+
// Fully settled: store the terminal valid verdict (same shape the forward-gate caches).
|
|
163
|
+
cache.set(item.cid, { valid: true, ruleScore: item.ruleScore, resolvedNames: item.resolvedNames });
|
|
164
|
+
settle(item);
|
|
165
|
+
}
|
|
166
|
+
if (requeue.length > 0) {
|
|
167
|
+
queue.push(...requeue);
|
|
168
|
+
failedRounds += 1;
|
|
169
|
+
deps.onError(infraError);
|
|
170
|
+
armRetry();
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
failedRounds = 0;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function armRetry() {
|
|
177
|
+
if (stopped || retryTimer !== undefined)
|
|
178
|
+
return;
|
|
179
|
+
const ceiling = Math.min(retryCapMs, retryBaseMs * 2 ** (failedRounds - 1));
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
retryTimer = undefined;
|
|
182
|
+
kickDrain();
|
|
183
|
+
}, Math.random() * ceiling);
|
|
184
|
+
timer.unref?.();
|
|
185
|
+
retryTimer = timer;
|
|
186
|
+
}
|
|
187
|
+
function kickDrain() {
|
|
188
|
+
if (draining || stopped || queue.length === 0) {
|
|
189
|
+
maybeResolveIdle();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
draining = true;
|
|
193
|
+
void (async () => {
|
|
194
|
+
try {
|
|
195
|
+
// A round that infra-fails re-queues and arms the retry timer instead of spinning.
|
|
196
|
+
while (queue.length > 0 && !stopped && retryTimer === undefined)
|
|
197
|
+
await round();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
draining = false;
|
|
201
|
+
maybeResolveIdle();
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
enqueue(entries) {
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
const key = entry.cid.toString();
|
|
209
|
+
if (inFlight.has(key))
|
|
210
|
+
continue;
|
|
211
|
+
inFlight.add(key);
|
|
212
|
+
queue.push({ ...entry, gateDone: false, gateNotified: false, ruleScore: 0n, resolvedNames: {} });
|
|
213
|
+
}
|
|
214
|
+
kickDrain();
|
|
215
|
+
},
|
|
216
|
+
pendingCount() {
|
|
217
|
+
return inFlight.size;
|
|
218
|
+
},
|
|
219
|
+
idle() {
|
|
220
|
+
if (queue.length === 0 && !draining && retryTimer === undefined)
|
|
221
|
+
return Promise.resolve();
|
|
222
|
+
return new Promise((resolve) => idleResolvers.push(resolve));
|
|
223
|
+
},
|
|
224
|
+
stop() {
|
|
225
|
+
stopped = true;
|
|
226
|
+
if (retryTimer !== undefined)
|
|
227
|
+
clearTimeout(retryTimer);
|
|
228
|
+
retryTimer = undefined;
|
|
229
|
+
maybeResolveIdle();
|
|
230
|
+
},
|
|
231
|
+
resume() {
|
|
232
|
+
stopped = false;
|
|
233
|
+
kickDrain();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Criteria } from "../schema/criteria.js";
|
|
2
|
+
import type { RuleRegistry } from "../rules/types.js";
|
|
3
|
+
import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
|
|
4
|
+
import type { GateResultCache } from "./gate-result-cache.js";
|
|
5
|
+
import { type NameResolutionCache } from "./name-resolution-cache.js";
|
|
6
|
+
import type { BundleVerifier } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* The full validity pipeline for one bundle — the work the gossip forward-gate runs before
|
|
9
|
+
* re-forwarding (see DESIGN.md "Transport"). Cheap-to-expensive with early exit so the
|
|
10
|
+
* costly network/chain steps only run for genuinely-new, signature-valid bundles:
|
|
11
|
+
*
|
|
12
|
+
* 1. signature (local, µs): recover the EIP-712 signer, must equal `bundle.address`.
|
|
13
|
+
* 2. constraints (local, µs): `votes.length <= maxVotesPerAddress`, each vote in range.
|
|
14
|
+
* 3. gate (chain): the `rule` scores the wallet `> 0n` at the bucket block.
|
|
15
|
+
* `0n` -> not admitted -> drop.
|
|
16
|
+
* 4. name (network): each vote's `community.name` (if any) must resolve to the
|
|
17
|
+
* claimed `publicKey`; a squatted/absent name drops the bundle.
|
|
18
|
+
*
|
|
19
|
+
* Every step only ever SUBTRACTS trust (a bundle is valid or dropped), which is what lets the
|
|
20
|
+
* gate reject without forwarding. Weight *magnitude* is not computed here — it is a ranking
|
|
21
|
+
* concern the tally derives lazily, not a validity concern (see DESIGN.md "Tally").
|
|
22
|
+
*
|
|
23
|
+
* Expiry is deliberately out of scope here: it depends on the current bucket (a clock), so it
|
|
24
|
+
* is enforced by the CRDT's read-time filter (`current` drops decayed votes given the
|
|
25
|
+
* current bucket; `prune` bounds memory), not by this time-independent verifier.
|
|
26
|
+
*/
|
|
27
|
+
/** Everything the verifier needs, resolved once per contest. */
|
|
28
|
+
export interface BundleVerifierDeps {
|
|
29
|
+
criteria: Criteria;
|
|
30
|
+
/** The criteria document's CID bytes (`(await criteriaCid(criteria)).bytes`) — signature binding. */
|
|
31
|
+
criteriaCid: Uint8Array;
|
|
32
|
+
/** The rule chain's numeric chainId (bound in the ballot domain). */
|
|
33
|
+
chainId: number;
|
|
34
|
+
/** Resolved rule registry (built-ins + host overrides). */
|
|
35
|
+
registry: RuleRegistry;
|
|
36
|
+
/** Resolve a chain ticker (e.g. "base") to its viem client. */
|
|
37
|
+
chainFor: (ticker: string) => ChainClient;
|
|
38
|
+
/** Bucket math for `criteria.blocksPerBucket`. */
|
|
39
|
+
bucketMath: BucketMath;
|
|
40
|
+
/** Host-injected community-name resolvers (`PubsubVoterOptions.nameResolvers`). */
|
|
41
|
+
nameResolvers: NameResolver[];
|
|
42
|
+
/**
|
|
43
|
+
* Optional cache of gate results, keyed by `(wallet, sampleBlock)`. When present, a wallet's
|
|
44
|
+
* score at a bucket's sample block is read from chain at most once — a `0n` miss short-circuits
|
|
45
|
+
* a flood of fresh-signed bundles from an ineligible wallet, and a `> 0n` hit short-circuits an
|
|
46
|
+
* *eligible* wallet re-signing or cycling choices within a bucket. Both are deterministic,
|
|
47
|
+
* historical reads. Omitted ⇒ every novel bundle pays its own gate read (prior behaviour).
|
|
48
|
+
*/
|
|
49
|
+
gateResultCache?: GateResultCache;
|
|
50
|
+
/**
|
|
51
|
+
* Optional persistent cache of name resolutions (the pkc-js rule — see
|
|
52
|
+
* verify/name-resolution-cache.ts). When present, a carried name is resolved live at most
|
|
53
|
+
* once per {@link NAME_RESOLUTION_MAX_AGE_SECONDS} per resolver. Omitted ⇒ every verify
|
|
54
|
+
* resolves live (prior behaviour; unit tests).
|
|
55
|
+
*/
|
|
56
|
+
nameResolutionCache?: NameResolutionCache;
|
|
57
|
+
}
|
|
58
|
+
export declare function makeBundleVerifier(deps: BundleVerifierDeps): BundleVerifier;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { tickerForRef } from "../chain/ticker.js";
|
|
2
|
+
import { UnknownRuleError } from "../errors.js";
|
|
3
|
+
import { verifyBundleSignature } from "./signature.js";
|
|
4
|
+
import { checkBundleConstraints } from "./constraints.js";
|
|
5
|
+
import { resolveNameThroughCache } from "./name-resolution-cache.js";
|
|
6
|
+
export function makeBundleVerifier(deps) {
|
|
7
|
+
const { criteria, criteriaCid, chainId, registry, chainFor, bucketMath, nameResolvers, gateResultCache, nameResolutionCache } = deps;
|
|
8
|
+
// Resolve the gate `rule`, its options, and its chain client once. The rule reads at the
|
|
9
|
+
// bundle's bucket block, but which rule/chain to use is fixed by the criteria, so it need
|
|
10
|
+
// not be recomputed per bundle.
|
|
11
|
+
const rule = registry[criteria.rule.type];
|
|
12
|
+
if (!rule)
|
|
13
|
+
throw new UnknownRuleError("rule", criteria.rule.type);
|
|
14
|
+
const ruleOptions = rule.optionsSchema.parse(criteria.rule);
|
|
15
|
+
const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
|
|
16
|
+
// Stage 1, shared by `verify` and `verifyOffline`: signature + constraints, local and µs.
|
|
17
|
+
const verifyOffline = async (bundle) => {
|
|
18
|
+
// 1. Signature (free) — a forged/tampered bundle drops before any chain/network read.
|
|
19
|
+
const signature = await verifyBundleSignature({ bundle, criteriaCid, chainId });
|
|
20
|
+
if (!signature.valid)
|
|
21
|
+
return signature;
|
|
22
|
+
// 2. Criteria constraints (free) — cap + vote range.
|
|
23
|
+
return checkBundleConstraints(bundle, criteria);
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
verifyOffline,
|
|
27
|
+
async verify(bundle) {
|
|
28
|
+
const offline = await verifyOffline(bundle);
|
|
29
|
+
if (!offline.valid)
|
|
30
|
+
return offline;
|
|
31
|
+
// 3. Gate (chain) — read the `rule` at the bucket's sample block. The score is a pure
|
|
32
|
+
// function of a pinned historical block, so it is memoized per `(wallet, sampleBlock)`:
|
|
33
|
+
// a cache hit short-circuits the chain read for a flood of fresh-signed bundles from
|
|
34
|
+
// the same wallet, whether it is ineligible (`0n`, a `reject`) or eligible (`> 0n`,
|
|
35
|
+
// re-signing / cycling choices within one bucket).
|
|
36
|
+
const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
|
|
37
|
+
let score = await gateResultCache?.get(bundle.address, sampleBlock);
|
|
38
|
+
if (score === undefined) {
|
|
39
|
+
({ score } = await rule.evaluate({
|
|
40
|
+
options: ruleOptions,
|
|
41
|
+
walletAddress: bundle.address,
|
|
42
|
+
ctx: { chain: ruleChain, blockNumber: sampleBlock }
|
|
43
|
+
}));
|
|
44
|
+
gateResultCache?.set(bundle.address, sampleBlock, score);
|
|
45
|
+
}
|
|
46
|
+
if (score === 0n) {
|
|
47
|
+
return { valid: false, disposition: "reject", reason: `not admitted: rule score is 0n at block ${sampleBlock}` };
|
|
48
|
+
}
|
|
49
|
+
// 4. Community-name resolution (network) — a carried name is a claim, verified against
|
|
50
|
+
// the registry. A name that has no resolver, does not resolve, or resolves to a
|
|
51
|
+
// different publicKey than the vote claims drops the whole bundle. These failures
|
|
52
|
+
// are `ignore`, not `reject`: v1 resolves at head, so they are view-/clock-dependent
|
|
53
|
+
// (a missing resolver differs per verifier; a re-point produces a transient window
|
|
54
|
+
// where honest peers disagree — see DESIGN.md "Tally"/"Open questions"). Penalizing
|
|
55
|
+
// the sender for that would punish honest relayers; the drop still stops propagation.
|
|
56
|
+
// (Once pinned-block resolution lands, a steady-state mismatch becomes provable
|
|
57
|
+
// `reject`.) The gossip gate therefore does NOT cache these verdicts. Successful
|
|
58
|
+
// resolutions DO go through the shared name-resolution cache (the pkc-js rule,
|
|
59
|
+
// 1-hour max-age) — bounding the RPC cost, while a re-point is still honored
|
|
60
|
+
// within the hour and a failed resolution is never negatively cached.
|
|
61
|
+
const resolvedNames = {};
|
|
62
|
+
for (const v of bundle.votes) {
|
|
63
|
+
const name = v.community.name;
|
|
64
|
+
if (!name)
|
|
65
|
+
continue;
|
|
66
|
+
const resolver = nameResolvers.find((r) => r.canResolve({ name }));
|
|
67
|
+
if (!resolver)
|
|
68
|
+
return { valid: false, disposition: "ignore", reason: `no resolver handles community name "${name}"` };
|
|
69
|
+
const record = await resolveNameThroughCache({ resolver, name, cache: nameResolutionCache });
|
|
70
|
+
if (!record)
|
|
71
|
+
return { valid: false, disposition: "ignore", reason: `community name "${name}" does not resolve` };
|
|
72
|
+
if (record.publicKey !== v.community.publicKey) {
|
|
73
|
+
return {
|
|
74
|
+
valid: false,
|
|
75
|
+
disposition: "ignore",
|
|
76
|
+
reason: `community name "${name}" resolves to ${record.publicKey}, not the claimed ${v.community.publicKey}`
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
resolvedNames[name] = record.publicKey;
|
|
80
|
+
}
|
|
81
|
+
return { valid: true, ruleScore: score, resolvedNames };
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|