@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.
Files changed (117) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +212 -0
  3. package/dist/chain/bucket.d.ts +13 -0
  4. package/dist/chain/bucket.js +24 -0
  5. package/dist/chain/ticker.d.ts +15 -0
  6. package/dist/chain/ticker.js +25 -0
  7. package/dist/chain/types.d.ts +90 -0
  8. package/dist/chain/types.js +1 -0
  9. package/dist/checkpoint/codec.d.ts +54 -0
  10. package/dist/checkpoint/codec.js +99 -0
  11. package/dist/client/root-puller.d.ts +49 -0
  12. package/dist/client/root-puller.js +140 -0
  13. package/dist/client/voter.d.ts +225 -0
  14. package/dist/client/voter.js +1195 -0
  15. package/dist/crdt/codec.d.ts +41 -0
  16. package/dist/crdt/codec.js +137 -0
  17. package/dist/crdt/crdt.d.ts +22 -0
  18. package/dist/crdt/crdt.js +127 -0
  19. package/dist/crdt/store.d.ts +8 -0
  20. package/dist/crdt/store.js +23 -0
  21. package/dist/crdt/types.d.ts +87 -0
  22. package/dist/crdt/types.js +1 -0
  23. package/dist/encoding/canonical.d.ts +22 -0
  24. package/dist/encoding/canonical.js +26 -0
  25. package/dist/errors.d.ts +72 -0
  26. package/dist/errors.js +111 -0
  27. package/dist/index.d.ts +31 -0
  28. package/dist/index.js +39 -0
  29. package/dist/rules/constant.d.ts +14 -0
  30. package/dist/rules/constant.js +18 -0
  31. package/dist/rules/erc20-balance.d.ts +30 -0
  32. package/dist/rules/erc20-balance.js +44 -0
  33. package/dist/rules/erc721-min-balance.d.ts +18 -0
  34. package/dist/rules/erc721-min-balance.js +56 -0
  35. package/dist/rules/registry.d.ts +42 -0
  36. package/dist/rules/registry.js +61 -0
  37. package/dist/rules/types.d.ts +74 -0
  38. package/dist/rules/types.js +1 -0
  39. package/dist/schema/common.d.ts +25 -0
  40. package/dist/schema/common.js +24 -0
  41. package/dist/schema/criteria.d.ts +78 -0
  42. package/dist/schema/criteria.js +76 -0
  43. package/dist/schema/directory.d.ts +42 -0
  44. package/dist/schema/directory.js +52 -0
  45. package/dist/schema/votes.d.ts +55 -0
  46. package/dist/schema/votes.js +116 -0
  47. package/dist/signer/eip712.d.ts +103 -0
  48. package/dist/signer/eip712.js +85 -0
  49. package/dist/signer/types.d.ts +31 -0
  50. package/dist/signer/types.js +1 -0
  51. package/dist/storage/browser.d.ts +3 -0
  52. package/dist/storage/browser.js +110 -0
  53. package/dist/storage/memory.d.ts +10 -0
  54. package/dist/storage/memory.js +56 -0
  55. package/dist/storage/node.d.ts +5 -0
  56. package/dist/storage/node.js +106 -0
  57. package/dist/storage/types.d.ts +46 -0
  58. package/dist/storage/types.js +1 -0
  59. package/dist/store/indexeddb.d.ts +9 -0
  60. package/dist/store/indexeddb.js +72 -0
  61. package/dist/store/memory.d.ts +15 -0
  62. package/dist/store/memory.js +22 -0
  63. package/dist/store/select.d.ts +15 -0
  64. package/dist/store/select.js +64 -0
  65. package/dist/store/sqlite.d.ts +11 -0
  66. package/dist/store/sqlite.js +68 -0
  67. package/dist/store/types.d.ts +57 -0
  68. package/dist/store/types.js +1 -0
  69. package/dist/tally/tally.d.ts +44 -0
  70. package/dist/tally/tally.js +89 -0
  71. package/dist/tally/types.d.ts +51 -0
  72. package/dist/tally/types.js +13 -0
  73. package/dist/topic.d.ts +20 -0
  74. package/dist/topic.js +28 -0
  75. package/dist/transport/accepted-dedup.d.ts +30 -0
  76. package/dist/transport/accepted-dedup.js +34 -0
  77. package/dist/transport/announce/browser.d.ts +9 -0
  78. package/dist/transport/announce/browser.js +14 -0
  79. package/dist/transport/announce/node.d.ts +38 -0
  80. package/dist/transport/announce/node.js +162 -0
  81. package/dist/transport/announce/types.d.ts +74 -0
  82. package/dist/transport/announce/types.js +16 -0
  83. package/dist/transport/bundle-store.d.ts +11 -0
  84. package/dist/transport/bundle-store.js +34 -0
  85. package/dist/transport/chase.d.ts +83 -0
  86. package/dist/transport/chase.js +88 -0
  87. package/dist/transport/gossip-validator.d.ts +107 -0
  88. package/dist/transport/gossip-validator.js +99 -0
  89. package/dist/transport/helia.d.ts +41 -0
  90. package/dist/transport/helia.js +98 -0
  91. package/dist/transport/integration/harness.d.ts +60 -0
  92. package/dist/transport/integration/harness.js +262 -0
  93. package/dist/transport/messages.d.ts +97 -0
  94. package/dist/transport/messages.js +117 -0
  95. package/dist/transport/rate-limit.d.ts +11 -0
  96. package/dist/transport/rate-limit.js +20 -0
  97. package/dist/transport/transport.d.ts +20 -0
  98. package/dist/transport/transport.js +35 -0
  99. package/dist/transport/types.d.ts +165 -0
  100. package/dist/transport/types.js +1 -0
  101. package/dist/verify/background.d.ts +81 -0
  102. package/dist/verify/background.js +236 -0
  103. package/dist/verify/bundle.d.ts +58 -0
  104. package/dist/verify/bundle.js +84 -0
  105. package/dist/verify/cache.d.ts +48 -0
  106. package/dist/verify/cache.js +62 -0
  107. package/dist/verify/constraints.d.ts +16 -0
  108. package/dist/verify/constraints.js +35 -0
  109. package/dist/verify/gate-result-cache.d.ts +65 -0
  110. package/dist/verify/gate-result-cache.js +91 -0
  111. package/dist/verify/name-resolution-cache.d.ts +59 -0
  112. package/dist/verify/name-resolution-cache.js +64 -0
  113. package/dist/verify/signature.d.ts +9 -0
  114. package/dist/verify/signature.js +55 -0
  115. package/dist/verify/types.d.ts +101 -0
  116. package/dist/verify/types.js +1 -0
  117. 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
+ }