@bitsocial/pubsub-voting 0.0.6

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 (119) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +223 -0
  3. package/dist/chain/bucket.d.ts +13 -0
  4. package/dist/chain/bucket.js +24 -0
  5. package/dist/chain/coalescer.d.ts +56 -0
  6. package/dist/chain/coalescer.js +217 -0
  7. package/dist/chain/ticker.d.ts +15 -0
  8. package/dist/chain/ticker.js +25 -0
  9. package/dist/chain/types.d.ts +90 -0
  10. package/dist/chain/types.js +1 -0
  11. package/dist/checkpoint/codec.d.ts +54 -0
  12. package/dist/checkpoint/codec.js +99 -0
  13. package/dist/client/root-puller.d.ts +49 -0
  14. package/dist/client/root-puller.js +140 -0
  15. package/dist/client/voter.d.ts +239 -0
  16. package/dist/client/voter.js +1343 -0
  17. package/dist/crdt/codec.d.ts +41 -0
  18. package/dist/crdt/codec.js +137 -0
  19. package/dist/crdt/crdt.d.ts +22 -0
  20. package/dist/crdt/crdt.js +127 -0
  21. package/dist/crdt/store.d.ts +8 -0
  22. package/dist/crdt/store.js +23 -0
  23. package/dist/crdt/types.d.ts +87 -0
  24. package/dist/crdt/types.js +1 -0
  25. package/dist/encoding/canonical.d.ts +22 -0
  26. package/dist/encoding/canonical.js +26 -0
  27. package/dist/errors.d.ts +81 -0
  28. package/dist/errors.js +126 -0
  29. package/dist/index.d.ts +32 -0
  30. package/dist/index.js +43 -0
  31. package/dist/rules/constant.d.ts +14 -0
  32. package/dist/rules/constant.js +18 -0
  33. package/dist/rules/erc20-balance.d.ts +30 -0
  34. package/dist/rules/erc20-balance.js +44 -0
  35. package/dist/rules/erc721-min-balance.d.ts +18 -0
  36. package/dist/rules/erc721-min-balance.js +100 -0
  37. package/dist/rules/registry.d.ts +42 -0
  38. package/dist/rules/registry.js +61 -0
  39. package/dist/rules/types.d.ts +74 -0
  40. package/dist/rules/types.js +1 -0
  41. package/dist/schema/common.d.ts +25 -0
  42. package/dist/schema/common.js +24 -0
  43. package/dist/schema/criteria.d.ts +78 -0
  44. package/dist/schema/criteria.js +76 -0
  45. package/dist/schema/directory.d.ts +42 -0
  46. package/dist/schema/directory.js +52 -0
  47. package/dist/schema/votes.d.ts +55 -0
  48. package/dist/schema/votes.js +116 -0
  49. package/dist/signer/eip712.d.ts +103 -0
  50. package/dist/signer/eip712.js +85 -0
  51. package/dist/signer/types.d.ts +31 -0
  52. package/dist/signer/types.js +1 -0
  53. package/dist/storage/browser.d.ts +3 -0
  54. package/dist/storage/browser.js +110 -0
  55. package/dist/storage/memory.d.ts +10 -0
  56. package/dist/storage/memory.js +56 -0
  57. package/dist/storage/node.d.ts +5 -0
  58. package/dist/storage/node.js +106 -0
  59. package/dist/storage/types.d.ts +46 -0
  60. package/dist/storage/types.js +1 -0
  61. package/dist/store/indexeddb.d.ts +9 -0
  62. package/dist/store/indexeddb.js +72 -0
  63. package/dist/store/memory.d.ts +15 -0
  64. package/dist/store/memory.js +22 -0
  65. package/dist/store/select.d.ts +15 -0
  66. package/dist/store/select.js +64 -0
  67. package/dist/store/sqlite.d.ts +11 -0
  68. package/dist/store/sqlite.js +68 -0
  69. package/dist/store/types.d.ts +57 -0
  70. package/dist/store/types.js +1 -0
  71. package/dist/tally/tally.d.ts +44 -0
  72. package/dist/tally/tally.js +89 -0
  73. package/dist/tally/types.d.ts +51 -0
  74. package/dist/tally/types.js +13 -0
  75. package/dist/topic.d.ts +20 -0
  76. package/dist/topic.js +28 -0
  77. package/dist/transport/accepted-dedup.d.ts +30 -0
  78. package/dist/transport/accepted-dedup.js +34 -0
  79. package/dist/transport/announce/browser.d.ts +9 -0
  80. package/dist/transport/announce/browser.js +14 -0
  81. package/dist/transport/announce/node.d.ts +38 -0
  82. package/dist/transport/announce/node.js +162 -0
  83. package/dist/transport/announce/types.d.ts +74 -0
  84. package/dist/transport/announce/types.js +16 -0
  85. package/dist/transport/bundle-store.d.ts +11 -0
  86. package/dist/transport/bundle-store.js +34 -0
  87. package/dist/transport/chase.d.ts +125 -0
  88. package/dist/transport/chase.js +182 -0
  89. package/dist/transport/gossip-validator.d.ts +107 -0
  90. package/dist/transport/gossip-validator.js +99 -0
  91. package/dist/transport/helia.d.ts +55 -0
  92. package/dist/transport/helia.js +114 -0
  93. package/dist/transport/integration/harness.d.ts +65 -0
  94. package/dist/transport/integration/harness.js +278 -0
  95. package/dist/transport/messages.d.ts +97 -0
  96. package/dist/transport/messages.js +117 -0
  97. package/dist/transport/rate-limit.d.ts +11 -0
  98. package/dist/transport/rate-limit.js +20 -0
  99. package/dist/transport/transport.d.ts +20 -0
  100. package/dist/transport/transport.js +35 -0
  101. package/dist/transport/types.d.ts +189 -0
  102. package/dist/transport/types.js +1 -0
  103. package/dist/verify/background.d.ts +81 -0
  104. package/dist/verify/background.js +236 -0
  105. package/dist/verify/bundle.d.ts +58 -0
  106. package/dist/verify/bundle.js +84 -0
  107. package/dist/verify/cache.d.ts +48 -0
  108. package/dist/verify/cache.js +62 -0
  109. package/dist/verify/constraints.d.ts +16 -0
  110. package/dist/verify/constraints.js +35 -0
  111. package/dist/verify/gate-result-cache.d.ts +65 -0
  112. package/dist/verify/gate-result-cache.js +91 -0
  113. package/dist/verify/name-resolution-cache.d.ts +59 -0
  114. package/dist/verify/name-resolution-cache.js +64 -0
  115. package/dist/verify/signature.d.ts +9 -0
  116. package/dist/verify/signature.js +55 -0
  117. package/dist/verify/types.d.ts +101 -0
  118. package/dist/verify/types.js +1 -0
  119. package/package.json +78 -0
package/dist/errors.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Library error types.
3
+ *
4
+ * The engine and client lifecycle are implemented — schemas, encoding, topic derivation,
5
+ * the verify pipeline, the LWW winner-set CRDT, the tally, the transport's
6
+ * validate-before-forward gossip gate, and the reactive `PubsubVoter` / `Contest` / `ContestVote`
7
+ * facade. Republishing a live vote is the client's job (no scheduler, no persistence); see
8
+ * DESIGN.md "Republishing is the client's job".
9
+ */
10
+ /** Thrown by any facade path deferred to a later version (none in v1's shipped surface). */
11
+ export class NotImplementedError extends Error {
12
+ constructor(what) {
13
+ super(`${what} is not implemented yet. See DESIGN.md / ROADMAP.md.`);
14
+ this.name = "NotImplementedError";
15
+ }
16
+ }
17
+ /**
18
+ * Thrown when a criteria document names a rule `type` this client does not
19
+ * implement (in the `rule`/`weight` slot or in `requires.rules`). A
20
+ * client that hits this is too old (or missing a host override) and must recuse
21
+ * itself from the contest rather than miscount. See DESIGN.md "Rules".
22
+ */
23
+ export class UnknownRuleError extends Error {
24
+ slot;
25
+ type;
26
+ constructor(slot, type) {
27
+ super(`Unknown rule "${type}" referenced by the criteria ${slot}. This client does not ` +
28
+ `implement it; pass it via the \`rules\` option to shadow/extend the built-ins, ` +
29
+ `or recuse this contest. Built-ins: see registry.ts.`);
30
+ this.slot = slot;
31
+ this.type = type;
32
+ this.name = "UnknownRuleError";
33
+ }
34
+ }
35
+ /**
36
+ * Thrown at construction when the injected Helia node's libp2p has no usable pubsub
37
+ * (gossipsub) service at `libp2p.services.pubsub`. The library broadcasts and receives
38
+ * winner bundle CIDs over gossipsub, so a node without it cannot participate. Helia's default
39
+ * libp2p services do NOT include pubsub — the host must register a gossipsub service
40
+ * (e.g. `@chainsafe/libp2p-gossipsub`) before passing the node in. We fail fast here
41
+ * rather than letting a later `publish`/`subscribe` fail obscurely. See DESIGN.md
42
+ * "Transport".
43
+ */
44
+ export class MissingPubsubError extends Error {
45
+ constructor() {
46
+ super("The injected Helia node's libp2p has no usable pubsub service at " +
47
+ "`libp2p.services.pubsub`. This library needs a gossipsub service to broadcast " +
48
+ "and receive votes. Register one before constructing PubsubVoter (Helia's " +
49
+ "default services do not include pubsub; e.g. add `@chainsafe/libp2p-gossipsub` " +
50
+ "as `services.pubsub`). See DESIGN.md \"Transport\".");
51
+ this.name = "MissingPubsubError";
52
+ }
53
+ }
54
+ /**
55
+ * Thrown at construction when the injected Helia node has no usable `blockstore`. Vote
56
+ * bundles are immutable content-addressed blocks fetched by CID through the host's
57
+ * blockstore (bitswap retrieves through it), so the engine cannot resolve a bundle without
58
+ * one. Note "bitswap" is not a separately introspectable property of a Helia node — it
59
+ * is a block broker wired *beneath* `blockstore` — so the checkable guarantee is a
60
+ * well-formed blockstore, the surface bitswap retrieves through. See DESIGN.md
61
+ * "Transport".
62
+ */
63
+ export class MissingBlockstoreError extends Error {
64
+ constructor() {
65
+ super("The injected Helia node has no usable `blockstore`. This library fetches vote " +
66
+ "bundles by CID through it (bitswap retrieves through the blockstore), so a " +
67
+ "node without one cannot resolve votes. Pass a full Helia instance (e.g. the " +
68
+ "result of `createHelia`). See DESIGN.md \"Transport\".");
69
+ this.name = "MissingBlockstoreError";
70
+ }
71
+ }
72
+ /**
73
+ * Thrown at construction when the injected Helia node's libp2p has no usable **fetch
74
+ * service** at `libp2p.services.fetch`. The root-record pull — cold-start / reconnect
75
+ * checkpoint sync — rides the libp2p fetch protocol (this library registers its own lookup
76
+ * and runs its own requester), so the host must register `@libp2p/fetch` on the shared
77
+ * node. See DESIGN.md "Checkpoints" and "Deferred pkc-js work".
78
+ */
79
+ export class MissingFetchError extends Error {
80
+ constructor() {
81
+ super("The injected Helia node's libp2p has no usable fetch service at " +
82
+ "`libp2p.services.fetch`. This library pulls peers' checkpoint root records " +
83
+ "over the libp2p fetch protocol on cold start, so the service is required. " +
84
+ "Register `@libp2p/fetch` as `services.fetch` before constructing PubsubVoter. " +
85
+ "See DESIGN.md \"Checkpoints\".");
86
+ this.name = "MissingFetchError";
87
+ }
88
+ }
89
+ /**
90
+ * Thrown once a voter has been `destroy()`ed and something tries to keep using it. Unlike `stop()`
91
+ * (which leaves every topic but keeps the client reusable), `destroy()` is terminal: every contest
92
+ * is stopped and can no longer update or publish. Surfaced by `createContest` / `createContestVote`,
93
+ * and by a pre-existing `Contest.update()` / `ContestVote.publish()`. Construct a new
94
+ * `PubsubVoter` to participate again.
95
+ */
96
+ export class VoterDestroyedError extends Error {
97
+ constructor() {
98
+ super("This voter has been destroyed: its contests are stopped and can no longer update or " +
99
+ "publish. `destroy()` is terminal (unlike the reusable `stop()`). Construct a new " +
100
+ "PubsubVoter to participate again.");
101
+ this.name = "VoterDestroyedError";
102
+ }
103
+ }
104
+ /**
105
+ * Thrown by `deriveDirectoryCriteria` when two manifest entries derive the same `contestId`.
106
+ * One directory slot decided by two topics is an authoring bug in the manifest: either the
107
+ * duplicate entry is a copy-paste mistake, or the slot's electorate would be split.
108
+ */
109
+ export class DuplicateContestIdError extends Error {
110
+ contestId;
111
+ constructor(contestId) {
112
+ super(`Directory manifest derives contestId "${contestId}" twice. Each directory slot must ` +
113
+ `be decided by exactly one contest (one criteria document, one topic); remove or ` +
114
+ `rename the duplicate entry.`);
115
+ this.contestId = contestId;
116
+ this.name = "DuplicateContestIdError";
117
+ }
118
+ }
119
+ /** Thrown when a publish (vote/withdraw) is attempted on a voter constructed without a signer. */
120
+ export class ReadOnlyError extends Error {
121
+ constructor() {
122
+ super("This voter is read-only: it was constructed without a `signer`. " +
123
+ "Provide a VoteSigner to publish or withdraw votes; reading tallies needs no signer.");
124
+ this.name = "ReadOnlyError";
125
+ }
126
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Public entry.
3
+ *
4
+ * The engine is implemented and unit-tested: the zod schemas, canonical dag-cbor encoding,
5
+ * topic derivation, the verify pipeline (signature + constraints +
6
+ * gate + name resolution), the state-based grow-only LWW winner-set CRDT, the tally, and the
7
+ * transport's validate-before-forward gossip gate. The public facade is the reactive
8
+ * `PubsubVoter` / `Contest` (`createContest`) / `ContestVote` (`createContestVote`) trio. Keeping a
9
+ * live vote from decaying is the consuming client's job — this library publishes each vote once
10
+ * (see `republishIntervalBuckets` and DESIGN.md "Republishing is the client's job"). See DESIGN.md
11
+ * for architecture, and "Transport" for the forward-gate that verifies a bundle (signature,
12
+ * on-chain gate, community-name resolution) before gossipsub re-forwards it.
13
+ */
14
+ export * from "./schema/common.js";
15
+ export * from "./schema/votes.js";
16
+ export * from "./schema/criteria.js";
17
+ export * from "./schema/directory.js";
18
+ export * from "./rules/erc721-min-balance.js";
19
+ export * from "./rules/constant.js";
20
+ export * from "./rules/registry.js";
21
+ export * from "./encoding/canonical.js";
22
+ export * from "./topic.js";
23
+ export * from "./errors.js";
24
+ export * from "./client/voter.js";
25
+ export * from "./signer/eip712.js";
26
+ export type { VoteSigner } from "./signer/types.js";
27
+ export type * from "./rules/types.js";
28
+ export type * from "./chain/types.js";
29
+ export type * from "./verify/types.js";
30
+ export type * from "./crdt/types.js";
31
+ export type * from "./transport/types.js";
32
+ export type * from "./tally/types.js";
package/dist/index.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Public entry.
3
+ *
4
+ * The engine is implemented and unit-tested: the zod schemas, canonical dag-cbor encoding,
5
+ * topic derivation, the verify pipeline (signature + constraints +
6
+ * gate + name resolution), the state-based grow-only LWW winner-set CRDT, the tally, and the
7
+ * transport's validate-before-forward gossip gate. The public facade is the reactive
8
+ * `PubsubVoter` / `Contest` (`createContest`) / `ContestVote` (`createContestVote`) trio. Keeping a
9
+ * live vote from decaying is the consuming client's job — this library publishes each vote once
10
+ * (see `republishIntervalBuckets` and DESIGN.md "Republishing is the client's job"). See DESIGN.md
11
+ * for architecture, and "Transport" for the forward-gate that verifies a bundle (signature,
12
+ * on-chain gate, community-name resolution) before gossipsub re-forwards it.
13
+ */
14
+ // Schemas (runtime values) and their inferred types.
15
+ export * from "./schema/common.js";
16
+ export * from "./schema/votes.js";
17
+ export * from "./schema/criteria.js";
18
+ // Directory authoring: derive per-contest criteria documents from a defaults+contests
19
+ // manifest. Pure and offline; exported so every consumer (web client, seeder) shares the
20
+ // exact merge semantics — byte-identical documents are what make peers share a topic.
21
+ export * from "./schema/directory.js";
22
+ // Rules: a single kind, one file per `type`, each owning its option schema and an
23
+ // `evaluate` returning `{ score: bigint }` (the `rule` slot gates on `score > 0n`,
24
+ // the weight slot uses the magnitude), plus the registry (built-ins, the host-shadowing resolver, and criteria
25
+ // validation). The leaf rules read through the injected viem `PublicClient`
26
+ // (`ctx.chain`). Rule composition (combining several into one slot) is a
27
+ // documented future extension, not a built-in — see DESIGN.md "Future improvements".
28
+ // v1 ships the NFT path only: `erc721-min-balance` + `constant`. `erc20-balance` stays in
29
+ // the tree but is not registered or re-exported — see ROADMAP.md ("Deferred").
30
+ export * from "./rules/erc721-min-balance.js";
31
+ export * from "./rules/constant.js";
32
+ export * from "./rules/registry.js";
33
+ // Implemented runtime: encoding, topic, errors, identity seam, facade.
34
+ export * from "./encoding/canonical.js";
35
+ export * from "./topic.js";
36
+ export * from "./errors.js";
37
+ export * from "./client/voter.js";
38
+ // There is no library-side vote persistence: republishing a live vote is the client's job, so
39
+ // the client tracks what it voted for (see DESIGN.md "Republishing is the client's job"). The
40
+ // facade exports `republishIntervalBuckets` to help the client schedule its own refreshes.
41
+ // Identity seam: the EIP-712 ballot builder + constants a host needs to implement a
42
+ // signer, plus the signer interface itself.
43
+ export * from "./signer/eip712.js";
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ import type { Rule } from "./types.js";
3
+ /**
4
+ * A fixed score for every wallet. v1.
5
+ *
6
+ * In the weight slot this is "1 pass = 1 vote" (`value: 1`). In the rule slot a
7
+ * positive `value` admits everyone (a no-op gate). No chain read.
8
+ */
9
+ export declare const ConstantOptionsSchema: z.ZodObject<{
10
+ type: z.ZodLiteral<"constant">;
11
+ value: z.ZodDefault<z.ZodNumber>;
12
+ }, z.core.$strip>;
13
+ export type ConstantOptions = z.infer<typeof ConstantOptionsSchema>;
14
+ export declare const constant: Rule<ConstantOptions>;
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * A fixed score for every wallet. v1.
4
+ *
5
+ * In the weight slot this is "1 pass = 1 vote" (`value: 1`). In the rule slot a
6
+ * positive `value` admits everyone (a no-op gate). No chain read.
7
+ */
8
+ export const ConstantOptionsSchema = z.object({
9
+ type: z.literal("constant"),
10
+ value: z.number().int().positive().default(1)
11
+ });
12
+ export const constant = {
13
+ type: "constant",
14
+ optionsSchema: ConstantOptionsSchema,
15
+ async evaluate({ options }) {
16
+ return { score: BigInt(options.value) };
17
+ }
18
+ };
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ import type { Rule } from "./types.js";
3
+ /**
4
+ * Score by ERC-20 balance (for example BSO). Reserved for the pass + BSO combo path.
5
+ *
6
+ * Score = the wallet's raw balance (base units) at the bucket block if it meets `min`,
7
+ * else 0n. `min` (in whole tokens, default 0) is what lets this single rule serve
8
+ * BOTH slots: in the weight slot leave `min` at 0 and the score is the magnitude; in the
9
+ * rule slot set `min` and a wallet below it scores 0n (rejected).
10
+ *
11
+ * The score is the exact `bigint` viem returns — no `Number()` cast, no precision loss.
12
+ * We deliberately do NOT divide by `decimals`: that divide is monotonic, so it never
13
+ * changes the ranking, and doing it in a JS `number` is exactly what used to lose
14
+ * precision. `decimals` is retained only to convert `min` (whole tokens) to base units
15
+ * via viem `parseUnits`, so the gate compares like-for-like. The score's absolute
16
+ * magnitude is therefore base units; formatting to whole tokens for display is a caller
17
+ * concern (`viem.formatUnits`). See DESIGN.md "Rules".
18
+ *
19
+ * (Edge: `min` is a normal token threshold. A `min` large enough that `.toString()`
20
+ * yields scientific notation would break `parseUnits` — out of scope for real thresholds.)
21
+ */
22
+ export declare const Erc20BalanceOptionsSchema: z.ZodObject<{
23
+ type: z.ZodLiteral<"erc20-balance">;
24
+ chain: z.ZodString;
25
+ contract: z.ZodString;
26
+ decimals: z.ZodDefault<z.ZodNumber>;
27
+ min: z.ZodDefault<z.ZodNumber>;
28
+ }, z.core.$strip>;
29
+ export type Erc20BalanceOptions = z.infer<typeof Erc20BalanceOptionsSchema>;
30
+ export declare const erc20Balance: Rule<Erc20BalanceOptions>;
@@ -0,0 +1,44 @@
1
+ import { erc20Abi, getAddress, parseUnits } from "viem";
2
+ import { z } from "zod";
3
+ import { ChainTickerSchema } from "../schema/common.js";
4
+ /**
5
+ * Score by ERC-20 balance (for example BSO). Reserved for the pass + BSO combo path.
6
+ *
7
+ * Score = the wallet's raw balance (base units) at the bucket block if it meets `min`,
8
+ * else 0n. `min` (in whole tokens, default 0) is what lets this single rule serve
9
+ * BOTH slots: in the weight slot leave `min` at 0 and the score is the magnitude; in the
10
+ * rule slot set `min` and a wallet below it scores 0n (rejected).
11
+ *
12
+ * The score is the exact `bigint` viem returns — no `Number()` cast, no precision loss.
13
+ * We deliberately do NOT divide by `decimals`: that divide is monotonic, so it never
14
+ * changes the ranking, and doing it in a JS `number` is exactly what used to lose
15
+ * precision. `decimals` is retained only to convert `min` (whole tokens) to base units
16
+ * via viem `parseUnits`, so the gate compares like-for-like. The score's absolute
17
+ * magnitude is therefore base units; formatting to whole tokens for display is a caller
18
+ * concern (`viem.formatUnits`). See DESIGN.md "Rules".
19
+ *
20
+ * (Edge: `min` is a normal token threshold. A `min` large enough that `.toString()`
21
+ * yields scientific notation would break `parseUnits` — out of scope for real thresholds.)
22
+ */
23
+ export const Erc20BalanceOptionsSchema = z.object({
24
+ type: z.literal("erc20-balance"),
25
+ chain: ChainTickerSchema,
26
+ contract: z.string(),
27
+ decimals: z.number().int().nonnegative().default(18),
28
+ min: z.number().nonnegative().default(0)
29
+ });
30
+ export const erc20Balance = {
31
+ type: "erc20-balance",
32
+ optionsSchema: Erc20BalanceOptionsSchema,
33
+ async evaluate({ options, walletAddress, ctx }) {
34
+ const raw = await ctx.chain.readContract({
35
+ address: getAddress(options.contract),
36
+ abi: erc20Abi,
37
+ functionName: "balanceOf",
38
+ args: [getAddress(walletAddress)],
39
+ blockNumber: BigInt(ctx.blockNumber)
40
+ });
41
+ const minUnits = parseUnits(options.min.toString(), options.decimals);
42
+ return { score: raw >= minUnits ? raw : 0n };
43
+ }
44
+ };
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ import type { Rule } from "./types.js";
3
+ /**
4
+ * Hold at least `min` of an ERC-721 (the 5chan Pass). v1.
5
+ *
6
+ * Score = the wallet's holding at the bucket block if it meets `min`, else 0. In the
7
+ * rule slot, `> 0` admits the wallet (it holds the Pass); in the weight slot it
8
+ * weights by the number of Passes held. The body reads its own `balanceOf` via the
9
+ * injected viem client (no libp2p/helia import), unit-testable against a stubbed client.
10
+ */
11
+ export declare const Erc721MinBalanceOptionsSchema: z.ZodObject<{
12
+ type: z.ZodLiteral<"erc721-min-balance">;
13
+ chain: z.ZodString;
14
+ contract: z.ZodString;
15
+ min: z.ZodDefault<z.ZodNumber>;
16
+ }, z.core.$strip>;
17
+ export type Erc721MinBalanceOptions = z.infer<typeof Erc721MinBalanceOptionsSchema>;
18
+ export declare const erc721MinBalance: Rule<Erc721MinBalanceOptions>;
@@ -0,0 +1,100 @@
1
+ import { erc721Abi, getAddress } from "viem";
2
+ import { z } from "zod";
3
+ import { CHAIN_CHUNK_RETRY_DELAY_MS, CHAIN_MULTICALL_CONCURRENCY, CHAIN_READS_PER_MULTICALL } from "../chain/coalescer.js";
4
+ import { ChainTickerSchema } from "../schema/common.js";
5
+ /**
6
+ * Hold at least `min` of an ERC-721 (the 5chan Pass). v1.
7
+ *
8
+ * Score = the wallet's holding at the bucket block if it meets `min`, else 0. In the
9
+ * rule slot, `> 0` admits the wallet (it holds the Pass); in the weight slot it
10
+ * weights by the number of Passes held. The body reads its own `balanceOf` via the
11
+ * injected viem client (no libp2p/helia import), unit-testable against a stubbed client.
12
+ */
13
+ export const Erc721MinBalanceOptionsSchema = z.object({
14
+ type: z.literal("erc721-min-balance"),
15
+ chain: ChainTickerSchema,
16
+ contract: z.string(),
17
+ min: z.number().int().positive().default(1)
18
+ });
19
+ /** Score from one balance: the holding when it meets `min`, else `0n` (does not qualify). */
20
+ function scoreOf(balance, min) {
21
+ return balance >= BigInt(min) ? balance : 0n;
22
+ }
23
+ /**
24
+ * Chunking policy shared with the voter-level read coalescer (src/chain/coalescer.ts). viem's
25
+ * own default chunking (1,024 bytes of calldata ≈ 27 `balanceOf`s) would split a 1000-wallet
26
+ * batch into ~38 chunks and fire them ALL concurrently — a burst public RPC endpoints throttle
27
+ * (measured against `mainnet.base.org`: 33/38 requests answered HTTP 429 `-32016 over rate
28
+ * limit` and the batch never settled). 200 reads is ~45 KB of calldata and ~2–5M `eth_call`
29
+ * gas — inside public request-size and gas caps — so a 1000-wallet batch is 5 round trips.
30
+ * The in-flight bound here is per evaluateMany call; the coalescer additionally enforces the
31
+ * same budget globally across parallel contests (its wrapped `multicall` is what this rule's
32
+ * batched path runs through).
33
+ */
34
+ const READS_PER_MULTICALL = CHAIN_READS_PER_MULTICALL;
35
+ const MULTICALL_CONCURRENCY = CHAIN_MULTICALL_CONCURRENCY;
36
+ const CHUNK_RETRY_DELAY_MS = CHAIN_CHUNK_RETRY_DELAY_MS;
37
+ export const erc721MinBalance = {
38
+ type: "erc721-min-balance",
39
+ optionsSchema: Erc721MinBalanceOptionsSchema,
40
+ async evaluate({ options, walletAddress, ctx }) {
41
+ const balance = await ctx.chain.readContract({
42
+ address: getAddress(options.contract),
43
+ abi: erc721Abi,
44
+ functionName: "balanceOf",
45
+ args: [getAddress(walletAddress)],
46
+ blockNumber: BigInt(ctx.blockNumber)
47
+ });
48
+ return { score: scoreOf(balance, options.min) };
49
+ },
50
+ async evaluateMany({ options, walletAddresses, ctx }) {
51
+ const contract = getAddress(options.contract);
52
+ // Multicall3 `aggregate3` batching — the path the background chain verifier rides on a
53
+ // cold join. The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3,
54
+ // `batchSize: 0` disables viem's own 1KB re-chunking) and the chunks are sent with
55
+ // bounded concurrency plus one in-rule retry each, so a big batch is a handful of
56
+ // polite round trips rather than a ~40-request burst a public endpoint throttles —
57
+ // and one failed chunk never discards the others' results. Needs the client to know
58
+ // its chain's multicall3 deployment; a client built without a `chain` (or on a chain
59
+ // without multicall3) takes the per-wallet fallback below.
60
+ if (typeof ctx.chain.multicall === "function" && ctx.chain.chain?.contracts?.multicall3) {
61
+ const chunks = [];
62
+ for (let at = 0; at < walletAddresses.length; at += READS_PER_MULTICALL) {
63
+ chunks.push(walletAddresses.slice(at, at + READS_PER_MULTICALL));
64
+ }
65
+ const results = new Array(walletAddresses.length);
66
+ const readChunk = async (chunk) => ctx.chain.multicall({
67
+ contracts: chunk.map((wallet) => ({
68
+ address: contract,
69
+ abi: erc721Abi,
70
+ functionName: "balanceOf",
71
+ args: [getAddress(wallet)]
72
+ })),
73
+ allowFailure: false,
74
+ batchSize: 0,
75
+ blockNumber: BigInt(ctx.blockNumber)
76
+ });
77
+ let nextChunk = 0;
78
+ const worker = async () => {
79
+ while (nextChunk < chunks.length) {
80
+ const index = nextChunk++;
81
+ const chunk = chunks[index];
82
+ let balances;
83
+ try {
84
+ balances = await readChunk(chunk);
85
+ }
86
+ catch {
87
+ await new Promise((resolve) => setTimeout(resolve, CHUNK_RETRY_DELAY_MS));
88
+ balances = await readChunk(chunk);
89
+ }
90
+ for (let i = 0; i < chunk.length; i++) {
91
+ results[index * READS_PER_MULTICALL + i] = { score: scoreOf(balances[i], options.min) };
92
+ }
93
+ }
94
+ };
95
+ await Promise.all(Array.from({ length: Math.min(MULTICALL_CONCURRENCY, chunks.length) }, worker));
96
+ return results;
97
+ }
98
+ return Promise.all(walletAddresses.map((walletAddress) => this.evaluate({ options, walletAddress, ctx })));
99
+ }
100
+ };
@@ -0,0 +1,42 @@
1
+ import type { Criteria } from "../schema/criteria.js";
2
+ import type { RuleRegistry } from "./types.js";
3
+ /**
4
+ * The rule registry: builtins, the shadowing resolver, and criteria validation.
5
+ *
6
+ * One flat `type -> rule` map (single kind; see types.ts), mirroring the pkc-js
7
+ * challenge registry (`pkc?.settings?.challenges?.[name] ?? pkcJsChallenges[name]`): a
8
+ * host's overrides shadow built-ins by `type`, so clients like 5chan or seedit register
9
+ * custom rules by passing `{ "their-type": rule }` to `PubsubVoter` without
10
+ * forking this library. A custom `type` becomes part of the criteria bytes, so it is
11
+ * provably pinned to the topic it runs on. Both criteria slots (rule, weight) draw
12
+ * from this single registry.
13
+ */
14
+ /**
15
+ * The library's built-in rules, before any host override.
16
+ *
17
+ * v1 ships exactly the NFT path: `erc721-min-balance` (Pass gate) + `constant` weight.
18
+ * `erc20-balance` is intentionally NOT registered — it stays in the tree (`erc20-balance.ts`,
19
+ * unit-tested) as the design-open weight path, but is unshipped so a criteria naming it
20
+ * recuses via `UnknownRuleError` rather than silently enabling token-weighting. See
21
+ * ROADMAP.md ("Deferred") for when it re-ships.
22
+ */
23
+ export declare const builtinRegistry: RuleRegistry;
24
+ /** type ids the v1 implementation guarantees; checked against `requires.rules`. */
25
+ export declare const V1_BUILTIN_RULE_TYPES: readonly ["erc721-min-balance", "constant"];
26
+ /**
27
+ * Merge host overrides over the built-ins. Overrides shadow built-ins by `type`. The
28
+ * override map is a plain `RuleRegistry` (a flat record already allows any subset
29
+ * of `type`s), so a host passes only the rules it adds or replaces.
30
+ */
31
+ export declare function resolveRegistry(overrides?: RuleRegistry): RuleRegistry;
32
+ /**
33
+ * Validate a criteria document against a resolved registry: the `rule` and
34
+ * `weight` refs must name rules this registry implements, their options must
35
+ * parse against the rule's own schema, and every name in `requires.rules`
36
+ * must be resolvable (so an out-of-date client recuses itself instead of miscounting).
37
+ *
38
+ * Throws `UnknownRuleError` / a zod error on the first failure. This is a check,
39
+ * not a transform: it never mutates `criteria`, so the topic-bearing bytes are untouched
40
+ * (option defaults applied here do not leak back into the encoded criteria).
41
+ */
42
+ export declare function validateCriteriaRules(criteria: Criteria, registry: RuleRegistry): void;
@@ -0,0 +1,61 @@
1
+ import { UnknownRuleError } from "../errors.js";
2
+ import { erc721MinBalance } from "./erc721-min-balance.js";
3
+ import { constant } from "./constant.js";
4
+ /**
5
+ * The rule registry: builtins, the shadowing resolver, and criteria validation.
6
+ *
7
+ * One flat `type -> rule` map (single kind; see types.ts), mirroring the pkc-js
8
+ * challenge registry (`pkc?.settings?.challenges?.[name] ?? pkcJsChallenges[name]`): a
9
+ * host's overrides shadow built-ins by `type`, so clients like 5chan or seedit register
10
+ * custom rules by passing `{ "their-type": rule }` to `PubsubVoter` without
11
+ * forking this library. A custom `type` becomes part of the criteria bytes, so it is
12
+ * provably pinned to the topic it runs on. Both criteria slots (rule, weight) draw
13
+ * from this single registry.
14
+ */
15
+ /**
16
+ * The library's built-in rules, before any host override.
17
+ *
18
+ * v1 ships exactly the NFT path: `erc721-min-balance` (Pass gate) + `constant` weight.
19
+ * `erc20-balance` is intentionally NOT registered — it stays in the tree (`erc20-balance.ts`,
20
+ * unit-tested) as the design-open weight path, but is unshipped so a criteria naming it
21
+ * recuses via `UnknownRuleError` rather than silently enabling token-weighting. See
22
+ * ROADMAP.md ("Deferred") for when it re-ships.
23
+ */
24
+ export const builtinRegistry = {
25
+ [erc721MinBalance.type]: erc721MinBalance,
26
+ [constant.type]: constant
27
+ };
28
+ /** type ids the v1 implementation guarantees; checked against `requires.rules`. */
29
+ export const V1_BUILTIN_RULE_TYPES = ["erc721-min-balance", "constant"];
30
+ /**
31
+ * Merge host overrides over the built-ins. Overrides shadow built-ins by `type`. The
32
+ * override map is a plain `RuleRegistry` (a flat record already allows any subset
33
+ * of `type`s), so a host passes only the rules it adds or replaces.
34
+ */
35
+ export function resolveRegistry(overrides) {
36
+ return { ...builtinRegistry, ...overrides };
37
+ }
38
+ /**
39
+ * Validate a criteria document against a resolved registry: the `rule` and
40
+ * `weight` refs must name rules this registry implements, their options must
41
+ * parse against the rule's own schema, and every name in `requires.rules`
42
+ * must be resolvable (so an out-of-date client recuses itself instead of miscounting).
43
+ *
44
+ * Throws `UnknownRuleError` / a zod error on the first failure. This is a check,
45
+ * not a transform: it never mutates `criteria`, so the topic-bearing bytes are untouched
46
+ * (option defaults applied here do not leak back into the encoded criteria).
47
+ */
48
+ export function validateCriteriaRules(criteria, registry) {
49
+ const rule = registry[criteria.rule.type];
50
+ if (!rule)
51
+ throw new UnknownRuleError("rule", criteria.rule.type);
52
+ rule.optionsSchema.parse(criteria.rule);
53
+ const weight = registry[criteria.weight.type];
54
+ if (!weight)
55
+ throw new UnknownRuleError("weight", criteria.weight.type);
56
+ weight.optionsSchema.parse(criteria.weight);
57
+ for (const name of criteria.requires.rules) {
58
+ if (!registry[name])
59
+ throw new UnknownRuleError("requires", name);
60
+ }
61
+ }
@@ -0,0 +1,74 @@
1
+ import type { z } from "zod";
2
+ import type { ChainClient } from "../chain/types.js";
3
+ /**
4
+ * Rule interface, design + leaves implemented.
5
+ *
6
+ * A rule turns a criteria `{ type, ...options }` reference into a non-negative
7
+ * score for one wallet, evaluated at the bundle's bucket block. There is a SINGLE kind
8
+ * (mirroring the flat pkc-js challenge registry: `Record<string, rule>`, user
9
+ * entries shadow builtins). The criteria still has two slots that draw from this one
10
+ * registry:
11
+ *
12
+ * - rule slot: the score is a GATE. `> 0n` admits the wallet, `0n` rejects it.
13
+ * - weight slot: the score is the vote's MAGNITUDE.
14
+ *
15
+ * Final vote value = `evaluate(rule).score === 0n ? 0n : evaluate(weight).score`.
16
+ *
17
+ * A single numeric return covers both roles: a rule that needs a threshold
18
+ * (min Passes, min balance) bakes it in by returning 0n when the wallet falls short.
19
+ * That is why the gate slot does not need a separate boolean kind.
20
+ */
21
+ /**
22
+ * The result of one evaluation. `score` is a non-negative `bigint`; `0n` means "does not
23
+ * qualify" (rejected in the rule slot, no weight in the weight slot). It is an
24
+ * object, not a bare `bigint`, so slot-specific fields can be added without changing the
25
+ * signature again — e.g. a self-declared `ceiling` for balance-derived weight, which the
26
+ * lazy tally needs as a wire-side upper bound (see DESIGN.md "Open questions").
27
+ */
28
+ export interface RuleResult {
29
+ score: bigint;
30
+ }
31
+ /** Everything a rule needs to read chain state for one evaluation. */
32
+ export interface ChainReadContext {
33
+ /**
34
+ * The viem `PublicClient` for the rule's `options.chain`. Use the full viem
35
+ * read surface directly (`readContract`, `getBalance`, ...), pinning every call to
36
+ * the sampled block with `blockNumber: BigInt(ctx.blockNumber)`.
37
+ */
38
+ chain: ChainClient;
39
+ /** The single sampled block for the bundle's bucket. */
40
+ blockNumber: number;
41
+ }
42
+ /**
43
+ * The one rule kind. `O` is the validated options type (from its `optionsSchema`).
44
+ * `evaluate` returns a `RuleResult` whose `score` is a non-negative `bigint`; `0n`
45
+ * means "does not qualify" (rejected in the rule slot, no weight in the weight slot).
46
+ */
47
+ export interface Rule<O = unknown> {
48
+ readonly type: string;
49
+ readonly optionsSchema: z.ZodType<O>;
50
+ evaluate(args: {
51
+ options: O;
52
+ walletAddress: string;
53
+ ctx: ChainReadContext;
54
+ }): Promise<RuleResult>;
55
+ /**
56
+ * Optional batched form of {@link evaluate}: score many wallets at the SAME sampled block in
57
+ * as few RPC round trips as the rule can manage (e.g. one multicall3 `aggregate3` for a whole
58
+ * checkpoint's wallets). Returns one result per input wallet, in order. Semantics MUST equal
59
+ * mapping `evaluate` over the wallets — this is a transport optimization, never a different
60
+ * answer. The background chain verifier prefers it when present and falls back to per-wallet
61
+ * `evaluate` calls otherwise (see verify/background.ts).
62
+ */
63
+ evaluateMany?(args: {
64
+ options: O;
65
+ walletAddresses: string[];
66
+ ctx: ChainReadContext;
67
+ }): Promise<RuleResult[]>;
68
+ }
69
+ /**
70
+ * The registry: a flat `type -> rule` map. Built-ins are provided by this
71
+ * library; hosts may pass overrides that shadow built-ins by `type`. Mirrors pkc-js
72
+ * src/runtime/node/community/challenges/index.ts.
73
+ */
74
+ export type RuleRegistry = Record<string, Rule>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Shared wire primitives.
4
+ *
5
+ * There is no pkc-js author in this library: a vote is signed directly by the
6
+ * gating-chain wallet that holds the Pass/ERC-20 (see DESIGN.md "Identity: the
7
+ * voting wallet, nothing else"). What survives from the old author/wallet schema is
8
+ * two small, identity-agnostic primitives reused across the wire:
9
+ * - `ChainTickerSchema`: the chain identifier ("base", "eth", ...) used by criteria
10
+ * and chain-reading rules.
11
+ * - `SignatureSchema`: the detached `{ signature, type }` shape (kept from pkc-js),
12
+ * here carrying the wallet's EIP-712 ballot signature.
13
+ */
14
+ /** Chain ticker is unrestricted ("base", "eth", ...), matching pkc-js. */
15
+ export declare const ChainTickerSchema: z.ZodString;
16
+ /**
17
+ * A detached signature with an explicit scheme tag. For a Votes bundle the `type` is
18
+ * the EIP-712 scheme ({@link EIP712_SIGNATURE_TYPE}) and `signature` is the hex
19
+ * signature the voting wallet produced over the ballot typed data.
20
+ */
21
+ export declare const SignatureSchema: z.ZodObject<{
22
+ signature: z.ZodString;
23
+ type: z.ZodString;
24
+ }, z.core.$strip>;
25
+ export type Signature = z.infer<typeof SignatureSchema>;