@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,48 @@
1
+ import type { CID } from "multiformats/cid";
2
+ import type { VotesBundle } from "../schema/votes.js";
3
+ import type { BundleVerifier, BundleVerdict } from "./types.js";
4
+ /**
5
+ * Per-CID verdict cache. A bundle is immutable and content-addressed, and its verdict
6
+ * (signature + constraints + gate-at-bucket-block + name resolution) is deterministic,
7
+ * so it is computed once and reused. This is the lever that keeps the forward-gate cheap:
8
+ * steady-state gossip re-announces already-known bundle CIDs, so the same bundle CID would
9
+ * otherwise be re-verified once per gossiping peer. See DESIGN.md "Transport" ("verdict cache").
10
+ */
11
+ export interface VerdictCache {
12
+ get(cid: CID): BundleVerdict | undefined;
13
+ set(cid: CID, verdict: BundleVerdict): void;
14
+ has(cid: CID): boolean;
15
+ }
16
+ /**
17
+ * Only *terminal* verdicts may be cached: an `accept`, or a `reject` that is a pure function
18
+ * of the bundle bytes + pinned historical chain state (bad signature, gate miss, ...). A
19
+ * transient `ignore` (name resolved at head during a re-point window, a `blockNumber` bucket
20
+ * ahead of this verifier's head) is view-/clock-dependent and can change as heads/records
21
+ * converge, so caching it would wrongly pin a stale verdict. See DESIGN.md "Transport"
22
+ * ("verdict cache") and verify/types.ts `VerdictDisposition`.
23
+ */
24
+ export declare function isCacheableVerdict(verdict: BundleVerdict): boolean;
25
+ /**
26
+ * An in-memory verdict cache keyed by the CID's canonical string, bounded to `maxEntries` with
27
+ * FIFO eviction (same pattern as `makeGateResultCache` / `makeAcceptedDedup`). Without a bound the
28
+ * `Map` grows one entry per novel CID forever: an ineligible wallet (or a flood of fresh wallets)
29
+ * minting fresh-signed bundles yields a distinct provable-`reject` per CID, so an unbounded cache is
30
+ * a memory-exhaustion vector (see DESIGN.md "Can valid votes clog the topic?"). Eviction is safe
31
+ * because a cached verdict is *terminal* and deterministic — an evicted entry only costs a re-fetch
32
+ * + re-verify on the next re-announce, never a wrong answer, and the `(wallet, sampleBlock)`
33
+ * gate-result cache still short-circuits the chain read so that recomputation stays cheap.
34
+ */
35
+ export declare function makeVerdictCache(maxEntries?: number): VerdictCache;
36
+ /** A verifier addressed by CID, memoizing verdicts through a {@link VerdictCache}. */
37
+ export interface CachingBundleVerifier {
38
+ verify(cid: CID, bundle: VotesBundle): Promise<BundleVerdict>;
39
+ }
40
+ /**
41
+ * Wrap a {@link BundleVerifier} with a {@link VerdictCache}: a CID already seen returns its
42
+ * cached verdict without touching the chain or the network; a new CID runs the full pipeline
43
+ * once and stores the result — but only if it is terminal ({@link isCacheableVerdict}): a
44
+ * valid bundle or a provable `reject` is remembered (a known-bad bundle is not re-fetched or
45
+ * re-checked), while a transient `ignore` is re-evaluated next time so a stale head/record
46
+ * cannot pin it.
47
+ */
48
+ export declare function makeCachingVerifier(verifier: BundleVerifier, cache: VerdictCache): CachingBundleVerifier;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Only *terminal* verdicts may be cached: an `accept`, or a `reject` that is a pure function
3
+ * of the bundle bytes + pinned historical chain state (bad signature, gate miss, ...). A
4
+ * transient `ignore` (name resolved at head during a re-point window, a `blockNumber` bucket
5
+ * ahead of this verifier's head) is view-/clock-dependent and can change as heads/records
6
+ * converge, so caching it would wrongly pin a stale verdict. See DESIGN.md "Transport"
7
+ * ("verdict cache") and verify/types.ts `VerdictDisposition`.
8
+ */
9
+ export function isCacheableVerdict(verdict) {
10
+ return verdict.valid || verdict.disposition === "reject";
11
+ }
12
+ /**
13
+ * An in-memory verdict cache keyed by the CID's canonical string, bounded to `maxEntries` with
14
+ * FIFO eviction (same pattern as `makeGateResultCache` / `makeAcceptedDedup`). Without a bound the
15
+ * `Map` grows one entry per novel CID forever: an ineligible wallet (or a flood of fresh wallets)
16
+ * minting fresh-signed bundles yields a distinct provable-`reject` per CID, so an unbounded cache is
17
+ * a memory-exhaustion vector (see DESIGN.md "Can valid votes clog the topic?"). Eviction is safe
18
+ * because a cached verdict is *terminal* and deterministic — an evicted entry only costs a re-fetch
19
+ * + re-verify on the next re-announce, never a wrong answer, and the `(wallet, sampleBlock)`
20
+ * gate-result cache still short-circuits the chain read so that recomputation stays cheap.
21
+ */
22
+ export function makeVerdictCache(maxEntries = 4096) {
23
+ const byCid = new Map();
24
+ const order = [];
25
+ return {
26
+ get: (cid) => byCid.get(cid.toString()),
27
+ set: (cid, verdict) => {
28
+ const k = cid.toString();
29
+ if (byCid.has(k))
30
+ return; // idempotent: a terminal verdict never changes, so keep FIFO position
31
+ byCid.set(k, verdict);
32
+ order.push(k);
33
+ if (order.length > maxEntries) {
34
+ const evicted = order.shift();
35
+ if (evicted !== undefined)
36
+ byCid.delete(evicted);
37
+ }
38
+ },
39
+ has: (cid) => byCid.has(cid.toString())
40
+ };
41
+ }
42
+ /**
43
+ * Wrap a {@link BundleVerifier} with a {@link VerdictCache}: a CID already seen returns its
44
+ * cached verdict without touching the chain or the network; a new CID runs the full pipeline
45
+ * once and stores the result — but only if it is terminal ({@link isCacheableVerdict}): a
46
+ * valid bundle or a provable `reject` is remembered (a known-bad bundle is not re-fetched or
47
+ * re-checked), while a transient `ignore` is re-evaluated next time so a stale head/record
48
+ * cannot pin it.
49
+ */
50
+ export function makeCachingVerifier(verifier, cache) {
51
+ return {
52
+ async verify(cid, bundle) {
53
+ const cached = cache.get(cid);
54
+ if (cached)
55
+ return cached;
56
+ const verdict = await verifier.verify(bundle);
57
+ if (isCacheableVerdict(verdict))
58
+ cache.set(cid, verdict);
59
+ return verdict;
60
+ }
61
+ };
62
+ }
@@ -0,0 +1,16 @@
1
+ import type { VotesBundle } from "../schema/votes.js";
2
+ import type { Criteria } from "../schema/criteria.js";
3
+ import type { VerifyResult } from "./types.js";
4
+ /**
5
+ * Offline verify stage 1, constraints half: the criteria-bound checks that gate a bundle
6
+ * before any signature recovery or chain read — `votes.length <= criteria.maxVotesPerAddress`
7
+ * and each `vote` within `criteria.voteSchema` (see verify/types.ts stage 1).
8
+ *
9
+ * Pure and synchronous: no signature recovery, no chain. These bounds live in the
10
+ * *criteria*, not in the wire type, which is exactly why `VotesBundleSchema` does NOT
11
+ * enforce them — the schema owns only criteria-independent wire shape (pairwise-distinct
12
+ * communities). This runtime check is where the cap belongs. See DESIGN.md "Votes wire".
13
+ *
14
+ * An empty `votes` array (withdrawal/abstention) is always valid regardless of the cap.
15
+ */
16
+ export declare function checkBundleConstraints(bundle: VotesBundle, criteria: Criteria): VerifyResult;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Offline verify stage 1, constraints half: the criteria-bound checks that gate a bundle
3
+ * before any signature recovery or chain read — `votes.length <= criteria.maxVotesPerAddress`
4
+ * and each `vote` within `criteria.voteSchema` (see verify/types.ts stage 1).
5
+ *
6
+ * Pure and synchronous: no signature recovery, no chain. These bounds live in the
7
+ * *criteria*, not in the wire type, which is exactly why `VotesBundleSchema` does NOT
8
+ * enforce them — the schema owns only criteria-independent wire shape (pairwise-distinct
9
+ * communities). This runtime check is where the cap belongs. See DESIGN.md "Votes wire".
10
+ *
11
+ * An empty `votes` array (withdrawal/abstention) is always valid regardless of the cap.
12
+ */
13
+ export function checkBundleConstraints(bundle, criteria) {
14
+ // Withdrawal/abstention: the empty bundle is always legal, cap and range notwithstanding.
15
+ if (bundle.votes.length === 0)
16
+ return { valid: true };
17
+ if (bundle.votes.length > criteria.maxVotesPerAddress) {
18
+ return {
19
+ valid: false,
20
+ disposition: "reject",
21
+ reason: `votes.length ${bundle.votes.length} exceeds maxVotesPerAddress ${criteria.maxVotesPerAddress}`
22
+ };
23
+ }
24
+ const { min, max } = criteria.voteSchema;
25
+ for (const v of bundle.votes) {
26
+ if (v.vote < min || v.vote > max) {
27
+ return {
28
+ valid: false,
29
+ disposition: "reject",
30
+ reason: `vote ${v.vote} for community ${v.community.publicKey} is outside voteSchema [${min}, ${max}]`
31
+ };
32
+ }
33
+ }
34
+ return { valid: true };
35
+ }
@@ -0,0 +1,65 @@
1
+ import type { LruStorage } from "../storage/types.js";
2
+ /**
3
+ * A bounded cache of gate `rule` results, keyed by `(wallet, sampleBlock)`.
4
+ *
5
+ * The gate score is a pure function of *historical* chain state — a past, pinned block that never
6
+ * changes — so a novel bundle CID from the same wallet at the same sample block scores identically
7
+ * and need not repeat the chain read. Memoizing the score (a `bigint`, where `0n` is the "not
8
+ * admitted" case) bounds the "one gate read per unique bundle" RPC amplifier (see DESIGN.md
9
+ * "Transport", resource-exhaustion residual) for BOTH directions:
10
+ *
11
+ * - an ineligible wallet minting fresh-signed bundles pays a single chain read per bucket, not
12
+ * one per bundle (the `0n` case — the former negative cache); and
13
+ * - an *eligible* wallet re-signing / cycling vote choices within a bucket likewise pays one read
14
+ * per bucket, not one per fresh CID (the `> 0n` case).
15
+ *
16
+ * Keyed on `(wallet, sampleBlock)` — NOT wallet alone — so a wallet whose holding *changes* in a
17
+ * later bucket is re-read at that bucket's sample block rather than being pinned to a stale score.
18
+ *
19
+ * `get` is async because the cache may be backed by the voter's persistent store (sqlite /
20
+ * IndexedDB — see {@link makePersistentGateResultCache}); `set` returns immediately and lets any
21
+ * persistence settle in the background, so the verify hot path never waits on a cache write.
22
+ *
23
+ * This is the per-CID verdict cache's complement: the verdict cache dedupes *re-announcements of the
24
+ * same bundle*; this dedupes *distinct bundles that share a `(wallet, bucket)` gate result*.
25
+ */
26
+ export interface GateResultCache {
27
+ /** The memoized gate score for `(wallet, sampleBlock)`, or `undefined` if not yet read. */
28
+ get(wallet: string, sampleBlock: number): Promise<bigint | undefined>;
29
+ /** Memoize `(wallet, sampleBlock) -> score` (idempotent; evicts oldest past the cap). */
30
+ set(wallet: string, sampleBlock: number, score: bigint): void;
31
+ }
32
+ /**
33
+ * An in-memory {@link GateResultCache} bounded to `maxEntries` with FIFO eviction. Eviction is
34
+ * safe because a score is deterministic — an evicted entry only ever costs a re-read, never a
35
+ * wrong answer; without a bound, a flood of fresh wallets is a memory-exhaustion vector (see
36
+ * DESIGN.md "Can valid votes clog the topic?").
37
+ */
38
+ export declare function makeGateResultCache(maxEntries?: number): GateResultCache;
39
+ /**
40
+ * A {@link GateResultCache} layered over the voter's persistent store: an in-memory FIFO front
41
+ * (the hot path — steady-state gossip hits it synchronously) with read-through to the store on
42
+ * a miss and fire-and-forget write-through on `set`. Scores travel as decimal strings (JSON has
43
+ * no bigint). A broken store read or write degrades to a live chain read — never an error into
44
+ * the verify pipeline — because everything here is a pure function of pinned historical state.
45
+ */
46
+ export declare function makePersistentGateResultCache(opts: {
47
+ store: LruStorage;
48
+ /** Identifies the gate rule (hash of the canonical criteria `rule` + chainId — see voter.ts). */
49
+ ruleHash: string;
50
+ maxMemEntries?: number;
51
+ }): GateResultCache;
52
+ /**
53
+ * Deterministic expiry purge for one rule's persisted gate results — better than LRU here
54
+ * because staleness is *provable*: a score at bucket B's sample block is only ever consulted
55
+ * while bundles from B are admissible (within `voteExpiryBuckets` of head), so anything older
56
+ * than the oldest admissible sample block can never be read again. Run per contest whenever a
57
+ * head read advances the expiry boundary (see the engine's `#maybePurgeGateResults`); the
58
+ * store's LRU bound stays as the backstop for rules never purged.
59
+ */
60
+ export declare function purgeExpiredGateResults(opts: {
61
+ store: LruStorage;
62
+ ruleHash: string;
63
+ /** The oldest admissible bucket's sample block; strictly older entries are dead. */
64
+ oldestSampleBlock: number;
65
+ }): Promise<void>;
@@ -0,0 +1,91 @@
1
+ const memKeyFor = (wallet, sampleBlock) => `${wallet.toLowerCase()}:${sampleBlock}`;
2
+ /**
3
+ * An in-memory {@link GateResultCache} bounded to `maxEntries` with FIFO eviction. Eviction is
4
+ * safe because a score is deterministic — an evicted entry only ever costs a re-read, never a
5
+ * wrong answer; without a bound, a flood of fresh wallets is a memory-exhaustion vector (see
6
+ * DESIGN.md "Can valid votes clog the topic?").
7
+ */
8
+ export function makeGateResultCache(maxEntries = 4096) {
9
+ const byKey = new Map();
10
+ const order = [];
11
+ return {
12
+ get: async (wallet, sampleBlock) => byKey.get(memKeyFor(wallet, sampleBlock)),
13
+ set: (wallet, sampleBlock, score) => {
14
+ const k = memKeyFor(wallet, sampleBlock);
15
+ if (byKey.has(k))
16
+ return; // idempotent: never refresh position or overwrite a pinned score
17
+ byKey.set(k, score);
18
+ order.push(k);
19
+ if (order.length > maxEntries) {
20
+ const evicted = order.shift();
21
+ if (evicted !== undefined)
22
+ byKey.delete(evicted);
23
+ }
24
+ }
25
+ };
26
+ }
27
+ /** The persistent gate store's key. `ruleHash` disambiguates: the shared store spans every
28
+ * contest on the voter, and one wallet can hold different scores under different gate rules
29
+ * (or the same rule at different chainIds). Same score under the same rule is what lets two
30
+ * contests over one gate (a 5chan-style directory) share each other's reads. */
31
+ const storeKeyFor = (ruleHash, wallet, sampleBlock) => `${ruleHash}:${wallet.toLowerCase()}:${sampleBlock}`;
32
+ /**
33
+ * A {@link GateResultCache} layered over the voter's persistent store: an in-memory FIFO front
34
+ * (the hot path — steady-state gossip hits it synchronously) with read-through to the store on
35
+ * a miss and fire-and-forget write-through on `set`. Scores travel as decimal strings (JSON has
36
+ * no bigint). A broken store read or write degrades to a live chain read — never an error into
37
+ * the verify pipeline — because everything here is a pure function of pinned historical state.
38
+ */
39
+ export function makePersistentGateResultCache(opts) {
40
+ const { store, ruleHash } = opts;
41
+ const mem = makeGateResultCache(opts.maxMemEntries);
42
+ return {
43
+ async get(wallet, sampleBlock) {
44
+ const cached = await mem.get(wallet, sampleBlock);
45
+ if (cached !== undefined)
46
+ return cached;
47
+ let persisted;
48
+ try {
49
+ persisted = await store.getItem(storeKeyFor(ruleHash, wallet, sampleBlock));
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ if (typeof persisted !== "string" || !/^\d+$/.test(persisted))
55
+ return undefined;
56
+ const score = BigInt(persisted);
57
+ mem.set(wallet, sampleBlock, score);
58
+ return score;
59
+ },
60
+ set(wallet, sampleBlock, score) {
61
+ mem.set(wallet, sampleBlock, score);
62
+ void store.setItem(storeKeyFor(ruleHash, wallet, sampleBlock), score.toString()).catch(() => {
63
+ // a failed persist costs a future re-read, never a wrong answer
64
+ });
65
+ }
66
+ };
67
+ }
68
+ /**
69
+ * Deterministic expiry purge for one rule's persisted gate results — better than LRU here
70
+ * because staleness is *provable*: a score at bucket B's sample block is only ever consulted
71
+ * while bundles from B are admissible (within `voteExpiryBuckets` of head), so anything older
72
+ * than the oldest admissible sample block can never be read again. Run per contest whenever a
73
+ * head read advances the expiry boundary (see the engine's `#maybePurgeGateResults`); the
74
+ * store's LRU bound stays as the backstop for rules never purged.
75
+ */
76
+ export async function purgeExpiredGateResults(opts) {
77
+ const prefix = `${opts.ruleHash}:`;
78
+ try {
79
+ for (const key of await opts.store.keys()) {
80
+ if (!key.startsWith(prefix))
81
+ continue;
82
+ const sampleBlock = Number(key.slice(key.lastIndexOf(":") + 1));
83
+ if (Number.isFinite(sampleBlock) && sampleBlock < opts.oldestSampleBlock) {
84
+ await opts.store.removeItem(key);
85
+ }
86
+ }
87
+ }
88
+ catch {
89
+ // purge is best-effort; the store's LRU bound is the correctness-free backstop
90
+ }
91
+ }
@@ -0,0 +1,59 @@
1
+ import type { NameResolver } from "../chain/types.js";
2
+ import type { LruStorage } from "../storage/types.js";
3
+ /**
4
+ * Persistent cache of community-name resolutions, a port of pkc-js's `NameResolutionCache`
5
+ * (clients/name-resolution-cache.ts) — the SAME rule, so a host reasons about one caching
6
+ * policy across both libraries:
7
+ *
8
+ * - LRU-bounded persistent store, no stored TTL: an entry lives until evicted.
9
+ * - Freshness is a per-CALL max-age, modeled on HTTP `Cache-Control: max-age` (RFC 9111
10
+ * §5.2.1.1): `maxAgeSeconds: 0` bypasses the cache, `N` serves an entry only while
11
+ * `Date.now() - resolvedAtMs <= N * 1000`, `undefined` serves whatever is cached.
12
+ * - Only SUCCESSFUL resolutions are stored; a name that fails to resolve is retried by the
13
+ * caller, never negatively cached. No stale-while-revalidate.
14
+ * - Keyed `{name}::{resolverKey}::{sha256(provider)}` — per resolver identity, because two
15
+ * resolvers (or providers) may legitimately disagree during a migration.
16
+ *
17
+ * This cache exists because the host's own pkc-js instance caches at ITS call sites, not
18
+ * inside the injected resolver — so without this layer every verify here pays a live
19
+ * registry read that pkc-js would have served from cache.
20
+ */
21
+ export interface NameResolutionCacheEntry {
22
+ publicKey: string;
23
+ resolverKey: string;
24
+ provider: string;
25
+ resolvedAtMs: number;
26
+ }
27
+ export interface NameResolutionCache {
28
+ get(args: {
29
+ name: string;
30
+ resolverKey: string;
31
+ provider: string;
32
+ maxAgeSeconds?: number;
33
+ }): Promise<NameResolutionCacheEntry | undefined>;
34
+ set(args: {
35
+ name: string;
36
+ resolverKey: string;
37
+ provider: string;
38
+ publicKey: string;
39
+ }): Promise<void>;
40
+ }
41
+ /**
42
+ * The verify pipeline's freshness bound, matching pkc-js's background resolution call site
43
+ * (`cache: { maxAge: 3600 }`): a re-pointed name is honored here within at most one hour.
44
+ * Names gate vote validity, so this is the staleness ceiling a community re-point can see.
45
+ */
46
+ export declare const NAME_RESOLUTION_MAX_AGE_SECONDS = 3600;
47
+ export declare function makeNameResolutionCache(store: LruStorage): NameResolutionCache;
48
+ /**
49
+ * The verify pipeline's single resolution path (inline forward-gate AND background verifier):
50
+ * serve from the cache within {@link NAME_RESOLUTION_MAX_AGE_SECONDS}, otherwise resolve live
51
+ * and persist a success. `undefined` (no record) is returned uncached — the pkc-js rule.
52
+ */
53
+ export declare function resolveNameThroughCache(opts: {
54
+ resolver: NameResolver;
55
+ name: string;
56
+ cache: NameResolutionCache | undefined;
57
+ }): Promise<{
58
+ publicKey: string;
59
+ } | undefined>;
@@ -0,0 +1,64 @@
1
+ import { sha256, stringToHex } from "viem";
2
+ /**
3
+ * The verify pipeline's freshness bound, matching pkc-js's background resolution call site
4
+ * (`cache: { maxAge: 3600 }`): a re-pointed name is honored here within at most one hour.
5
+ * Names gate vote validity, so this is the staleness ceiling a community re-point can see.
6
+ */
7
+ export const NAME_RESOLUTION_MAX_AGE_SECONDS = 3600;
8
+ const keyFor = (args) => `${args.name}::${args.resolverKey}::${sha256(stringToHex(args.provider))}`;
9
+ export function makeNameResolutionCache(store) {
10
+ return {
11
+ async get(args) {
12
+ if (args.maxAgeSeconds === 0)
13
+ return undefined;
14
+ let entry;
15
+ try {
16
+ entry = await store.getItem(keyFor(args));
17
+ }
18
+ catch {
19
+ return undefined; // a broken cache read degrades to a live resolution, never a failure
20
+ }
21
+ if (entry === null ||
22
+ typeof entry !== "object" ||
23
+ typeof entry.publicKey !== "string" ||
24
+ typeof entry.resolvedAtMs !== "number") {
25
+ return undefined;
26
+ }
27
+ const cached = entry;
28
+ if (typeof args.maxAgeSeconds === "number" && Date.now() - cached.resolvedAtMs > args.maxAgeSeconds * 1000) {
29
+ return undefined;
30
+ }
31
+ return cached;
32
+ },
33
+ async set(args) {
34
+ const entry = {
35
+ publicKey: args.publicKey,
36
+ resolverKey: args.resolverKey,
37
+ provider: args.provider,
38
+ resolvedAtMs: Date.now()
39
+ };
40
+ try {
41
+ await store.setItem(keyFor(args), entry);
42
+ }
43
+ catch {
44
+ // a failed cache write costs a future re-resolution, never a wrong answer
45
+ }
46
+ }
47
+ };
48
+ }
49
+ /**
50
+ * The verify pipeline's single resolution path (inline forward-gate AND background verifier):
51
+ * serve from the cache within {@link NAME_RESOLUTION_MAX_AGE_SECONDS}, otherwise resolve live
52
+ * and persist a success. `undefined` (no record) is returned uncached — the pkc-js rule.
53
+ */
54
+ export async function resolveNameThroughCache(opts) {
55
+ const { resolver, name, cache } = opts;
56
+ const identity = { name, resolverKey: resolver.key, provider: resolver.provider };
57
+ const cached = await cache?.get({ ...identity, maxAgeSeconds: NAME_RESOLUTION_MAX_AGE_SECONDS });
58
+ if (cached)
59
+ return { publicKey: cached.publicKey };
60
+ const record = await resolver.resolve({ name });
61
+ if (record && cache)
62
+ await cache.set({ ...identity, publicKey: record.publicKey });
63
+ return record;
64
+ }
@@ -0,0 +1,9 @@
1
+ import type { VotesBundle } from "../schema/votes.js";
2
+ import type { OfflineBundleVerifier, VerifyResult } from "./types.js";
3
+ export declare function verifyBundleSignature(args: {
4
+ bundle: VotesBundle;
5
+ criteriaCid: Uint8Array;
6
+ chainId: number;
7
+ }): Promise<VerifyResult>;
8
+ /** The {@link OfflineBundleVerifier} seam, backed by {@link verifyBundleSignature}. */
9
+ export declare const offlineBundleVerifier: OfflineBundleVerifier;
@@ -0,0 +1,55 @@
1
+ import { recoverTypedDataAddress } from "viem";
2
+ import { ballotTypedData, EIP712_SIGNATURE_TYPE } from "../signer/eip712.js";
3
+ /**
4
+ * Offline verify stage 1, signature half: recover the EIP-712 ballot signer and check it
5
+ * equals `bundle.address`. Pure and cheap (no chain read), so it runs first in the gate —
6
+ * a bad signature drops a vote before any fetch or chain work (see DESIGN.md "Transport").
7
+ *
8
+ * The recovered signer *is* the voter (there is no pkc-js author, no author->wallet
9
+ * binding). Rebuilding the typed data from the criteria CID bytes + chainId + the bundle's
10
+ * `votes`/`blockNumber` means a signature gathered for one contest, chain, or block cannot
11
+ * validate on another — the binding is in the hash, not in a trusted `address` field, so a
12
+ * forged `address` simply fails the recovery check here.
13
+ */
14
+ /** True when `s` is `0x`-hex, narrowing it to viem's `Hex` without an `any` cast. */
15
+ function isHex(s) {
16
+ return s.startsWith("0x");
17
+ }
18
+ export async function verifyBundleSignature(args) {
19
+ const { bundle, criteriaCid, chainId } = args;
20
+ if (bundle.signature.type !== EIP712_SIGNATURE_TYPE) {
21
+ return {
22
+ valid: false,
23
+ disposition: "reject",
24
+ reason: `unsupported signature type "${bundle.signature.type}" (expected "${EIP712_SIGNATURE_TYPE}")`
25
+ };
26
+ }
27
+ if (!isHex(bundle.signature.signature)) {
28
+ return { valid: false, disposition: "reject", reason: "signature is not 0x-hex" };
29
+ }
30
+ const typedData = ballotTypedData({
31
+ criteriaCid,
32
+ chainId,
33
+ votes: bundle.votes,
34
+ blockNumber: bundle.blockNumber
35
+ });
36
+ let recovered;
37
+ try {
38
+ recovered = await recoverTypedDataAddress({ ...typedData, signature: bundle.signature.signature });
39
+ }
40
+ catch (err) {
41
+ return { valid: false, disposition: "reject", reason: `signature does not recover: ${err.message}` };
42
+ }
43
+ // The recovered address is EIP-55 checksummed; `bundle.address` may be lowercase or
44
+ // differently cased. Compare case-insensitively.
45
+ if (recovered.toLowerCase() !== bundle.address.toLowerCase()) {
46
+ return {
47
+ valid: false,
48
+ disposition: "reject",
49
+ reason: `recovered signer ${recovered} does not match bundle.address ${bundle.address}`
50
+ };
51
+ }
52
+ return { valid: true };
53
+ }
54
+ /** The {@link OfflineBundleVerifier} seam, backed by {@link verifyBundleSignature}. */
55
+ export const offlineBundleVerifier = { verifyBundleSignature };
@@ -0,0 +1,101 @@
1
+ import type { VotesBundle } from "../schema/votes.js";
2
+ /**
3
+ * Verification interfaces, design only.
4
+ *
5
+ * Verification has one cheap, offline stage (no chain reads) and one chain stage:
6
+ * 1. offline: recover the EIP-712 signer and check it equals bundle.address, and the
7
+ * criteria-bound constraints (votes.length <= maxVotesPerAddress, vote in range).
8
+ * Pairwise-distinct community.publicKeys is enforced even earlier, by
9
+ * VotesBundleSchema at parse time (see DESIGN.md "Votes wire")
10
+ * 2. chain: the `rule` (gate) + weight rules read state at the bucket block, and
11
+ * each vote's community.name claim is resolved through the injected nameResolvers
12
+ * (a name that does not resolve to the claimed publicKey drops the bundle)
13
+ *
14
+ * The tally runs stage 2 lazily (only where it can change the visible ranking), so the
15
+ * cheap offline check is split out and runs first — a bad signature drops a vote for zero
16
+ * chain reads. There is no separate wallet-binding stage: the single wallet signature is
17
+ * the identity. See DESIGN.md "Identity: the voting wallet, nothing else" and "Tally".
18
+ */
19
+ /**
20
+ * How a failing verdict should be blamed, mirroring the gossip gate's `reject`/`ignore`
21
+ * split (see DESIGN.md "Transport"):
22
+ * - "reject": PROVABLY invalid — a pure function of the bundle bytes and pinned historical
23
+ * chain state (bad signature, out-of-range vote, wallet the gate rejects at the bucket
24
+ * block). Deterministic and stable, so it is safe to penalize the sender AND to cache.
25
+ * - "ignore": not provably the sender's fault because the check is view- or clock-dependent
26
+ * and two honest peers can legitimately disagree right now (a `community.name` resolved *at
27
+ * head* during a re-point window; a `blockNumber` bucket ahead of this verifier's chain
28
+ * head). No penalty, and NOT cached — the verdict can change as heads/records converge.
29
+ */
30
+ export type VerdictDisposition = "reject" | "ignore";
31
+ export type VerifyOk = {
32
+ valid: true;
33
+ };
34
+ export type VerifyFail = {
35
+ valid: false;
36
+ disposition: VerdictDisposition;
37
+ reason: string;
38
+ };
39
+ export type VerifyResult = VerifyOk | VerifyFail;
40
+ /** Stage 1: ballot signature only. No chain access. */
41
+ export interface OfflineBundleVerifier {
42
+ /**
43
+ * Rebuild the EIP-712 ballot typed data from the criteria CID bytes + chainId + the
44
+ * bundle's `votes` and `blockNumber` (see signer/eip712.ts), recover the signer with
45
+ * `viem.recoverTypedDataAddress`, and check it equals `bundle.address`. No chain read.
46
+ */
47
+ verifyBundleSignature(args: {
48
+ bundle: VotesBundle;
49
+ criteriaCid: Uint8Array;
50
+ chainId: number;
51
+ }): Promise<VerifyResult>;
52
+ }
53
+ /**
54
+ * A passing full-bundle verdict. Beyond `valid: true` it carries the work the gate already
55
+ * did so downstream stages need not redo it:
56
+ * - `ruleScore`: the gate `rule`'s score for the voting wallet at the bucket block
57
+ * (always `> 0n` here — `0n` would have failed the gate).
58
+ * - `resolvedNames`: for each vote that carried a `community.name`, the `publicKey` the name
59
+ * resolved to (equal to the claimed key, since a mismatch fails the gate). Votes with no
60
+ * name are absent. Lets a UI show a verified name without re-resolving.
61
+ */
62
+ export interface BundleVerdictValid {
63
+ valid: true;
64
+ ruleScore: bigint;
65
+ resolvedNames: Record<string, string>;
66
+ }
67
+ /**
68
+ * The full validity verdict for one bundle: signature + criteria constraints + on-chain
69
+ * gate (`rule`) + community-name resolution, in cheap-to-expensive order with early exit. This is
70
+ * what the gossip forward-gate runs *before* re-forwarding (see DESIGN.md "Transport"): a
71
+ * failing verdict is dropped and never forwarded, stored, or counted. Weight *magnitude*
72
+ * (ranking, not validity) is deliberately NOT computed here — the tally derives it lazily.
73
+ */
74
+ export type BundleVerdict = BundleVerdictValid | VerifyFail;
75
+ /** Runs the full validity pipeline for one already-fetched, schema-parsed bundle. */
76
+ export interface BundleVerifier {
77
+ verify(bundle: VotesBundle): Promise<BundleVerdict>;
78
+ /**
79
+ * Stage 1 only: signature + criteria constraints — local, µs, no chain read and no name
80
+ * resolution. This is what admits a bundle *provisionally* on the cold-join chase path; the
81
+ * deferred network checks (gate chain read, name resolution) then run in the background
82
+ * chain verifier and either confirm the bundle or evict it (see verify/background.ts and
83
+ * DESIGN.md "Background chain verification").
84
+ */
85
+ verifyOffline(bundle: VotesBundle): Promise<VerifyResult>;
86
+ }
87
+ /**
88
+ * The per-bundle record of the two deferred *network* checks. The offline checks (signature,
89
+ * constraints) are never recorded here — they are synchronous preconditions for admission, so
90
+ * an admitted bundle has always passed them.
91
+ *
92
+ * - `chainVerified`: the gate `rule` scored the wallet `> 0n` at the bucket block. `false`
93
+ * means "not yet read", never "failed" — a failed gate evicts the bundle instead.
94
+ * - `nameResolved`: `undefined` when the bundle carries no `community.name`; `false` while
95
+ * the carried name is unresolved; `true` once it resolved to the claimed `publicKey`. A
96
+ * name that resolves to a DIFFERENT key evicts the bundle (it is never counted unchecked).
97
+ */
98
+ export interface BundleChecks {
99
+ chainVerified: boolean;
100
+ nameResolved?: boolean;
101
+ }
@@ -0,0 +1 @@
1
+ export {};