@bitsocial/pubsub-voting 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ This library does not start its own node. It consumes the host's running Helia n
24
24
 
25
25
  - **Settings live in the topic.** `topic = "bitsocial-votes/" + CID(dag-cbor(criteria))`. Two peers on the same topic provably ran identical rules, so the network validates itself with no intermediary.
26
26
  - **Votes are a state-based grow-only CRDT.** A signed `Votes` bundle is a standalone dag-cbor block (no parent links); each wallet gossips its own bundle **inline as a live delta**, validated straight from the message bytes — no fetch toward the publisher. State is a last-write-wins set keyed by wallet, so aggregation is a monotonic union: a peer can omit a vote but can never subtract one that an honest peer serves. Cold start and gap-fill exchange a tiny **root record** (libp2p-fetch pull + a slow topic heartbeat) and pull the checkpoint blocks behind it via directed bitswap from its advertisers.
27
- - **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the NFT path — an `erc721-min-balance` gate `rule` (5chan Pass) and `constant` weight (1 pass = 1 vote). Balance-derived (token-weighted) voting is deferred; see [ROADMAP.md](./ROADMAP.md).
27
+ - **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate `rule` (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked) and `constant` weight (1 pass = 1 vote). A gate on a *transferable* asset would let one Pass back several concurrent votes, so the plain `erc721-min-balance` rule ships unregistered; balance-derived (token-weighted) voting is deferred. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote) and [ROADMAP.md](./ROADMAP.md).
28
28
 
29
29
  See [DESIGN.md](./DESIGN.md) for the full rationale, including how this resists vote-dropping and how criteria upgrades fork cleanly.
30
30
 
@@ -196,9 +196,18 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
196
196
 
197
197
  ### Custom rules
198
198
 
199
- The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema and is evaluated at the bundle's bucket block. Chain-reading rules get `ctx.chain` — the viem `PublicClient` for their `options.chain` — and write their own reads (`readContract`, `getBalance`, ...), pinning each call to the sampled block with `blockNumber: BigInt(ctx.blockNumber)`. There is **one kind**: `evaluate → { score: bigint }`, a non-negative score where `0n` means "does not qualify" (a result object, not a bare `bigint`, so slot-specific fields can be added later). The criteria has two *slots* drawing from the one registry — the **rule** slot treats the score as a gate (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `rule.score > 0n ? weight.score : 0n`. A rule that needs a threshold returns `0n` below it (so `erc721-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany(walletAddresses, ctx)` batch hook — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads (`erc721-min-balance` implements it over multicall3; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification)).
199
+ The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema and is evaluated at the bundle's bucket block. Chain-reading rules get `ctx.chain` — the viem `PublicClient` for their `options.chain` — and write their own reads (`readContract`, `getBalance`, ...), pinning each call to the sampled block with `blockNumber: BigInt(ctx.blockNumber)`. There is **one kind**: `evaluate → { score: bigint }`, a non-negative score where `0n` means "does not qualify" (a result object, not a bare `bigint`, so slot-specific fields can be added later). The criteria has two *slots* drawing from the one registry — the **rule** slot treats the score as a gate (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `rule.score > 0n ? weight.score : 0n`. A rule that needs a threshold returns `0n` below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, walletAddresses, ctx })` batch hook (same argument object as `evaluate`, with `walletAddresses` in place of `walletAddress`, returning one `RuleResult` per input wallet in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification)).
200
200
 
201
- Built-ins: `erc721-min-balance` (v1) and `constant` (v1). A host adds or shadows rules by `type` via the `rules` option — this is how clients like 5chan or seedit register custom rules without forking the library:
201
+ Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
202
+
203
+ Two chain-reading rules ship in the tree but are deliberately **not** built in, so a criteria naming either recuses via `UnknownRuleError` instead of silently gating on an asset that does not bound Sybils:
204
+
205
+ - **`erc721-min-balance`** (exported) — a bare `balanceOf` on a *transferable* token. One token walked A → B → C inside one expiry window backs three concurrent live votes, since every bundle is verified at its own pinned block and the winner set is LWW-keyed per wallet. `erc5192-min-balance` is this rule plus `supportsInterface(0xb45a3c0e)`, which refuses a contract that does not declare its tokens locked.
206
+ - **`erc20-balance`** (not exported) — the same amplification, reopened by fungibility, plus the open lazy-tally ceiling question for balance-derived weight.
207
+
208
+ A host that wants a transferable gate anyway registers `erc721MinBalance` explicitly through the `rules` option below — the library stops blessing the configuration, it does not forbid it. `erc20-balance` is not exported at all, so a host that wants balance-weighting supplies its own rule of that `type`. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote).
209
+
210
+ A host adds or shadows rules by `type` via the `rules` option — this is how clients like 5chan or seedit register custom rules without forking the library:
202
211
 
203
212
  ```ts
204
213
  import { PubsubVoter, type Rule } from "@bitsocial/pubsub-voting";
@@ -222,7 +231,7 @@ A custom `type` becomes part of `dag-cbor(criteria)`, so it is provably pinned t
222
231
 
223
232
  ### Weighted voting (deferred)
224
233
 
225
- v1 ships `constant` weight (one Pass, one vote) **on purpose** — it resists whale dominance and downvote weaponization. Balance-derived, token-weighted voting (Pass gate + BSO weight via `erc20-balance`) is a designed-but-unshipped capability: the rule path and result shape leave room for it with no engine change, but it is not in the v1 built-ins and carries open governance/abuse and lazy-tally questions. See [ROADMAP.md](./ROADMAP.md) and [DESIGN.md, Future improvements](./DESIGN.md#future-improvements).
234
+ v1 ships `constant` weight (one Pass, one vote) **on purpose** — it resists whale dominance and downvote weaponization. Balance-derived, token-weighted voting (Pass gate + BSO weight via `erc20-balance`) is a designed-but-unshipped capability: the rule path and result shape leave room for it with no engine change, but it is not in the v1 built-ins and carries open governance/abuse and lazy-tally questions — plus the Sybil amplification a fungible gate reopens, which the soulbound gate's fix cannot close for a balance (it needs a hold-duration guard instead). See [ROADMAP.md](./ROADMAP.md), [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote), and [DESIGN.md, Future improvements](./DESIGN.md#future-improvements).
226
235
 
227
236
  ## Layout
228
237
 
@@ -1,7 +1,7 @@
1
1
  import type { Criteria, RuleRef } from "../schema/criteria.js";
2
2
  /**
3
3
  * Resolve which chain a rule reads. A rule's parsed options may name a
4
- * `chain` ticker (e.g. `erc721-min-balance` -> "base"); a chainless rule (e.g.
4
+ * `chain` ticker (e.g. `erc5192-min-balance` -> "base"); a chainless rule (e.g.
5
5
  * `constant`) names none, so callers fall back to the first configured chain. Shared by the
6
6
  * verifier, the tally, and the facade so the fallback rule stays identical everywhere.
7
7
  */
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  /**
3
3
  * Resolve which chain a rule reads. A rule's parsed options may name a
4
- * `chain` ticker (e.g. `erc721-min-balance` -> "base"); a chainless rule (e.g.
4
+ * `chain` ticker (e.g. `erc5192-min-balance` -> "base"); a chainless rule (e.g.
5
5
  * `constant`) names none, so callers fall back to the first configured chain. Shared by the
6
6
  * verifier, the tally, and the facade so the fallback rule stays identical everywhere.
7
7
  */
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export * from "./schema/common.js";
15
15
  export * from "./schema/votes.js";
16
16
  export * from "./schema/criteria.js";
17
17
  export * from "./schema/directory.js";
18
+ export * from "./rules/erc5192-min-balance.js";
18
19
  export * from "./rules/erc721-min-balance.js";
19
20
  export * from "./rules/constant.js";
20
21
  export * from "./rules/registry.js";
package/dist/index.js CHANGED
@@ -25,8 +25,12 @@ export * from "./schema/directory.js";
25
25
  // validation). The leaf rules read through the injected viem `PublicClient`
26
26
  // (`ctx.chain`). Rule composition (combining several into one slot) is a
27
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").
28
+ // v1 ships the soulbound-NFT path only: `erc5192-min-balance` + `constant`.
29
+ // `erc721-min-balance` is exported but NOT registered a bare `balanceOf` gate admits the
30
+ // transfer amplification `erc5192-min-balance`'s lock assertion closes (issue #27), so a host
31
+ // that wants it must opt in explicitly through the `rules` override map. `erc20-balance` stays
32
+ // in the tree and is neither registered nor re-exported — see ROADMAP.md ("Deferred") and #28.
33
+ export * from "./rules/erc5192-min-balance.js";
30
34
  export * from "./rules/erc721-min-balance.js";
31
35
  export * from "./rules/constant.js";
32
36
  export * from "./rules/registry.js";
@@ -3,6 +3,13 @@ import type { Rule } from "./types.js";
3
3
  /**
4
4
  * Score by ERC-20 balance (for example BSO). Reserved for the pass + BSO combo path.
5
5
  *
6
+ * **NOT registered** in `builtinRegistry` — a criteria naming it recuses via
7
+ * `UnknownRuleError`. Two independent blockers: the design-open lazy-tally ceiling for a
8
+ * balance-derived weight, and the Sybil amplification a fungible gate reopens (one balance
9
+ * walked through several wallets inside one expiry window backs several concurrent votes;
10
+ * a balance can be neither non-transferable nor LWW-keyed by token id, so it needs a
11
+ * hold-duration guard instead). See registry.ts and issue #28 before re-registering it.
12
+ *
6
13
  * Score = the wallet's raw balance (base units) at the bucket block if it meets `min`,
7
14
  * else 0n. `min` (in whole tokens, default 0) is what lets this single rule serve
8
15
  * BOTH slots: in the weight slot leave `min` at 0 and the score is the magnitude; in the
@@ -4,6 +4,13 @@ import { ChainTickerSchema } from "../schema/common.js";
4
4
  /**
5
5
  * Score by ERC-20 balance (for example BSO). Reserved for the pass + BSO combo path.
6
6
  *
7
+ * **NOT registered** in `builtinRegistry` — a criteria naming it recuses via
8
+ * `UnknownRuleError`. Two independent blockers: the design-open lazy-tally ceiling for a
9
+ * balance-derived weight, and the Sybil amplification a fungible gate reopens (one balance
10
+ * walked through several wallets inside one expiry window backs several concurrent votes;
11
+ * a balance can be neither non-transferable nor LWW-keyed by token id, so it needs a
12
+ * hold-duration guard instead). See registry.ts and issue #28 before re-registering it.
13
+ *
7
14
  * Score = the wallet's raw balance (base units) at the bucket block if it meets `min`,
8
15
  * else 0n. `min` (in whole tokens, default 0) is what lets this single rule serve
9
16
  * BOTH slots: in the weight slot leave `min` at 0 and the score is the magnitude; in the
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+ import type { Rule } from "./types.js";
3
+ /**
4
+ * Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
5
+ *
6
+ * Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the bucket block
7
+ * if it meets `min`, else `0n` — plus one assertion at the SAME pinned block: the contract must
8
+ * declare ERC-5192 (`supportsInterface(0xb45a3c0e)`). A contract that does not declare it scores
9
+ * `0n` for every wallet, so the contest admits nobody rather than gating on a transferable asset.
10
+ *
11
+ * **Why the assertion is the whole point.** The gate bounds Sybils only if the gating asset
12
+ * cannot move (DESIGN.md "Does one Pass mean one vote?"): every bundle is verified at its OWN
13
+ * pinned block, stays live for `voteExpiryBuckets`, and the winner set is LWW-keyed per wallet —
14
+ * so one transferable token walked A → B → C inside a single expiry window backs three
15
+ * concurrent live votes, each read true at its own block and none collapsed by LWW. Nothing in
16
+ * the verify pipeline can see that: every ballot is individually correct. Requiring the asset to
17
+ * be non-transferable AND to say so on-chain closes it with no wire change, no extra archive
18
+ * depth, and no second read per wallet. Pinned by `src/crdt/amplification.test.ts`.
19
+ *
20
+ * **What the assertion does and does not prove.** `supportsInterface(0xb45a3c0e)` asserts the
21
+ * contract *reports* lock state — ERC-5192's only function is `locked(uint256)`. ERC-5192
22
+ * permits unlockable tokens (it defines an `Unlocked` event), so this is not proof that a given
23
+ * token is locked; it refuses contracts that do not even claim the property. Per-token proof
24
+ * would need `locked(tokenId)`, i.e. token ids in the signed bundle — a re-pin of the frozen
25
+ * EIP-712 vector. The deployed gate closes the gap by being permanently locked with a constant
26
+ * `locked() == true`.
27
+ *
28
+ * ERC-5192 mandates ERC-721 conformance, so `balanceOf` stays valid. In the rule slot `> 0`
29
+ * admits the wallet; in the weight slot it weights by the number of Passes held. The body reads
30
+ * through the injected viem client (no libp2p/helia import), unit-testable against a stub.
31
+ */
32
+ /** ERC-5192's interface id (its only function is `locked(uint256)`). */
33
+ export declare const ERC5192_INTERFACE_ID: "0xb45a3c0e";
34
+ export declare const Erc5192MinBalanceOptionsSchema: z.ZodObject<{
35
+ type: z.ZodLiteral<"erc5192-min-balance">;
36
+ chain: z.ZodString;
37
+ contract: z.ZodString;
38
+ min: z.ZodDefault<z.ZodNumber>;
39
+ }, z.core.$strip>;
40
+ export type Erc5192MinBalanceOptions = z.infer<typeof Erc5192MinBalanceOptionsSchema>;
41
+ export declare const erc5192MinBalance: Rule<Erc5192MinBalanceOptions>;
@@ -0,0 +1,111 @@
1
+ import { BaseError, ContractFunctionRevertedError, ContractFunctionZeroDataError, getAddress } from "viem";
2
+ import { z } from "zod";
3
+ import { ChainTickerSchema } from "../schema/common.js";
4
+ import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.js";
5
+ /**
6
+ * Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
7
+ *
8
+ * Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the bucket block
9
+ * if it meets `min`, else `0n` — plus one assertion at the SAME pinned block: the contract must
10
+ * declare ERC-5192 (`supportsInterface(0xb45a3c0e)`). A contract that does not declare it scores
11
+ * `0n` for every wallet, so the contest admits nobody rather than gating on a transferable asset.
12
+ *
13
+ * **Why the assertion is the whole point.** The gate bounds Sybils only if the gating asset
14
+ * cannot move (DESIGN.md "Does one Pass mean one vote?"): every bundle is verified at its OWN
15
+ * pinned block, stays live for `voteExpiryBuckets`, and the winner set is LWW-keyed per wallet —
16
+ * so one transferable token walked A → B → C inside a single expiry window backs three
17
+ * concurrent live votes, each read true at its own block and none collapsed by LWW. Nothing in
18
+ * the verify pipeline can see that: every ballot is individually correct. Requiring the asset to
19
+ * be non-transferable AND to say so on-chain closes it with no wire change, no extra archive
20
+ * depth, and no second read per wallet. Pinned by `src/crdt/amplification.test.ts`.
21
+ *
22
+ * **What the assertion does and does not prove.** `supportsInterface(0xb45a3c0e)` asserts the
23
+ * contract *reports* lock state — ERC-5192's only function is `locked(uint256)`. ERC-5192
24
+ * permits unlockable tokens (it defines an `Unlocked` event), so this is not proof that a given
25
+ * token is locked; it refuses contracts that do not even claim the property. Per-token proof
26
+ * would need `locked(tokenId)`, i.e. token ids in the signed bundle — a re-pin of the frozen
27
+ * EIP-712 vector. The deployed gate closes the gap by being permanently locked with a constant
28
+ * `locked() == true`.
29
+ *
30
+ * ERC-5192 mandates ERC-721 conformance, so `balanceOf` stays valid. In the rule slot `> 0`
31
+ * admits the wallet; in the weight slot it weights by the number of Passes held. The body reads
32
+ * through the injected viem client (no libp2p/helia import), unit-testable against a stub.
33
+ */
34
+ /** ERC-5192's interface id (its only function is `locked(uint256)`). */
35
+ export const ERC5192_INTERFACE_ID = "0xb45a3c0e";
36
+ const erc165Abi = [
37
+ {
38
+ type: "function",
39
+ name: "supportsInterface",
40
+ stateMutability: "view",
41
+ inputs: [{ name: "interfaceId", type: "bytes4" }],
42
+ outputs: [{ type: "bool" }]
43
+ }
44
+ ];
45
+ export const Erc5192MinBalanceOptionsSchema = z.object({
46
+ type: z.literal("erc5192-min-balance"),
47
+ chain: ChainTickerSchema,
48
+ contract: z.string(),
49
+ min: z.number().int().positive().default(1)
50
+ });
51
+ /**
52
+ * Does the read prove "this contract does not answer `supportsInterface`", as opposed to "the
53
+ * gateway failed"? A contract with no ERC-165 at all reverts (or returns no data) — that is a
54
+ * chain FACT and means the gate must refuse, permanently and identically for every verifier. An
55
+ * RPC outage is not a fact about the chain, and DESIGN.md keeps a throwing read infra-class
56
+ * everywhere (gossip `ignore`, background retry) precisely so a flaky gateway never turns into a
57
+ * consensus `reject` that scores honest relayers down. So only viem's revert/zero-data errors
58
+ * map to "does not declare"; everything else (transport, timeout, rate limit) rethrows.
59
+ */
60
+ function isContractRefusal(err) {
61
+ if (!(err instanceof BaseError))
62
+ return false;
63
+ return err.walk((cause) => cause instanceof ContractFunctionRevertedError || cause instanceof ContractFunctionZeroDataError) !== null;
64
+ }
65
+ /**
66
+ * One `supportsInterface(0xb45a3c0e)` at the sampled block. Identical calldata at the same block
67
+ * for every wallet in a batch, so the voter's read coalescer (src/chain/coalescer.ts) dedupes a
68
+ * whole checkpoint's worth of wallets — and every parallel contest on the same contract — onto a
69
+ * single extra read, folded into the same `aggregate3` as the balances.
70
+ */
71
+ async function declaresErc5192(contract, ctx) {
72
+ try {
73
+ return await ctx.chain.readContract({
74
+ address: contract,
75
+ abi: erc165Abi,
76
+ functionName: "supportsInterface",
77
+ args: [ERC5192_INTERFACE_ID],
78
+ blockNumber: BigInt(ctx.blockNumber)
79
+ });
80
+ }
81
+ catch (err) {
82
+ if (isContractRefusal(err))
83
+ return false;
84
+ throw err;
85
+ }
86
+ }
87
+ export const erc5192MinBalance = {
88
+ type: "erc5192-min-balance",
89
+ optionsSchema: Erc5192MinBalanceOptionsSchema,
90
+ async evaluate({ options, walletAddress, ctx }) {
91
+ const contract = getAddress(options.contract);
92
+ // Issued together, not sequenced: same block, so the coalescer folds both into one
93
+ // aggregate3 — the lock assertion costs no extra round trip.
94
+ const [declares, balance] = await Promise.all([declaresErc5192(contract, ctx), balanceOf(contract, walletAddress, ctx)]);
95
+ return { score: declares ? scoreOf(balance, options.min) : 0n };
96
+ },
97
+ async evaluateMany({ options, walletAddresses, ctx }) {
98
+ const contract = getAddress(options.contract);
99
+ // ONE lock assertion for the whole batch (hoisted out of the per-wallet reads), in
100
+ // parallel with the balances so both share the coalescing window.
101
+ const [declares, balances] = await Promise.all([
102
+ declaresErc5192(contract, ctx),
103
+ canBatch(ctx)
104
+ ? balancesOfBatched(contract, walletAddresses, ctx)
105
+ : Promise.all(walletAddresses.map((wallet) => balanceOf(contract, wallet, ctx)))
106
+ ]);
107
+ if (!declares)
108
+ return walletAddresses.map(() => ({ score: 0n }));
109
+ return balances.map((balance) => ({ score: scoreOf(balance, options.min) }));
110
+ }
111
+ };
@@ -1,12 +1,24 @@
1
1
  import { z } from "zod";
2
2
  import type { Rule } from "./types.js";
3
3
  /**
4
- * Hold at least `min` of an ERC-721 (the 5chan Pass). v1.
4
+ * Hold at least `min` of a **plain** ERC-721.
5
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.
6
+ * **NOT registered** in `builtinRegistry` a criteria naming it recuses via `UnknownRuleError`.
7
+ * It reads a bare `balanceOf` and asserts nothing about transferability, so it gates on an asset
8
+ * that can move: one token walked A B C inside a single expiry window backs three concurrent
9
+ * live votes, since every bundle is verified at its own pinned block and the winner set is
10
+ * LWW-keyed per wallet (DESIGN.md "Does one Pass mean one vote?"; pinned by
11
+ * `src/crdt/amplification.test.ts`). The v1 gate is `erc5192-min-balance`, which is this rule plus
12
+ * a `supportsInterface(0xb45a3c0e)` assertion that the contract declares its tokens locked.
13
+ *
14
+ * Kept in the tree, exported, and unit-tested: a host that genuinely wants a transferable gate
15
+ * can still register it through the `rules` override map. The library stops blessing the
16
+ * configuration; it does not forbid it. See registry.ts and issue #27.
17
+ *
18
+ * Score = the wallet's holding at the bucket block if it meets `min`, else 0. In the rule slot,
19
+ * `> 0` admits the wallet; in the weight slot it weights by the number of tokens held. The body
20
+ * reads its own `balanceOf` via the injected viem client (no libp2p/helia import), unit-testable
21
+ * against a stubbed client.
10
22
  */
11
23
  export declare const Erc721MinBalanceOptionsSchema: z.ZodObject<{
12
24
  type: z.ZodLiteral<"erc721-min-balance">;
@@ -1,14 +1,26 @@
1
- import { erc721Abi, getAddress } from "viem";
1
+ import { getAddress } from "viem";
2
2
  import { z } from "zod";
3
- import { CHAIN_CHUNK_RETRY_DELAY_MS, CHAIN_MULTICALL_CONCURRENCY, CHAIN_READS_PER_MULTICALL } from "../chain/coalescer.js";
4
3
  import { ChainTickerSchema } from "../schema/common.js";
4
+ import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.js";
5
5
  /**
6
- * Hold at least `min` of an ERC-721 (the 5chan Pass). v1.
6
+ * Hold at least `min` of a **plain** ERC-721.
7
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.
8
+ * **NOT registered** in `builtinRegistry` a criteria naming it recuses via `UnknownRuleError`.
9
+ * It reads a bare `balanceOf` and asserts nothing about transferability, so it gates on an asset
10
+ * that can move: one token walked A B C inside a single expiry window backs three concurrent
11
+ * live votes, since every bundle is verified at its own pinned block and the winner set is
12
+ * LWW-keyed per wallet (DESIGN.md "Does one Pass mean one vote?"; pinned by
13
+ * `src/crdt/amplification.test.ts`). The v1 gate is `erc5192-min-balance`, which is this rule plus
14
+ * a `supportsInterface(0xb45a3c0e)` assertion that the contract declares its tokens locked.
15
+ *
16
+ * Kept in the tree, exported, and unit-tested: a host that genuinely wants a transferable gate
17
+ * can still register it through the `rules` override map. The library stops blessing the
18
+ * configuration; it does not forbid it. See registry.ts and issue #27.
19
+ *
20
+ * Score = the wallet's holding at the bucket block if it meets `min`, else 0. In the rule slot,
21
+ * `> 0` admits the wallet; in the weight slot it weights by the number of tokens held. The body
22
+ * reads its own `balanceOf` via the injected viem client (no libp2p/helia import), unit-testable
23
+ * against a stubbed client.
12
24
  */
13
25
  export const Erc721MinBalanceOptionsSchema = z.object({
14
26
  type: z.literal("erc721-min-balance"),
@@ -16,85 +28,21 @@ export const Erc721MinBalanceOptionsSchema = z.object({
16
28
  contract: z.string(),
17
29
  min: z.number().int().positive().default(1)
18
30
  });
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
31
  export const erc721MinBalance = {
38
32
  type: "erc721-min-balance",
39
33
  optionsSchema: Erc721MinBalanceOptionsSchema,
40
34
  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
- });
35
+ const balance = await balanceOf(getAddress(options.contract), walletAddress, ctx);
48
36
  return { score: scoreOf(balance, options.min) };
49
37
  },
50
38
  async evaluateMany({ options, walletAddresses, ctx }) {
51
39
  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 })));
40
+ // Multicall3 `aggregate3` batching (see nft-balance.ts for the chunking policy) the
41
+ // path the background chain verifier rides on a cold join. A client that cannot batch
42
+ // takes the per-wallet fallback.
43
+ const balances = canBatch(ctx)
44
+ ? await balancesOfBatched(contract, walletAddresses, ctx)
45
+ : await Promise.all(walletAddresses.map((wallet) => balanceOf(contract, wallet, ctx)));
46
+ return balances.map((balance) => ({ score: scoreOf(balance, options.min) }));
99
47
  }
100
48
  };
@@ -0,0 +1,23 @@
1
+ import type { ChainReadContext } from "./types.js";
2
+ /** Score from one balance: the holding when it meets `min`, else `0n` (does not qualify). */
3
+ export declare function scoreOf(balance: bigint, min: number): bigint;
4
+ /**
5
+ * True when the client can run multicall3 `aggregate3` batches — it needs both the action and
6
+ * its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
7
+ * multicall3) takes the per-wallet path instead.
8
+ */
9
+ export declare function canBatch(ctx: ChainReadContext): boolean;
10
+ /** One wallet's `balanceOf` at the bundle's sampled block. */
11
+ export declare function balanceOf(contract: `0x${string}`, walletAddress: string, ctx: ChainReadContext): Promise<bigint>;
12
+ /**
13
+ * Many wallets' `balanceOf` at ONE sampled block — the path the background chain verifier rides
14
+ * on a cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
15
+ *
16
+ * The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
17
+ * viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
18
+ * each, so a big batch is a handful of polite round trips rather than a ~40-request burst a
19
+ * public endpoint throttles — and the retry re-reads only the chunk that failed, never a
20
+ * completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
21
+ * twice still fails the whole call: the caller gets one rejection, not partial results.
22
+ */
23
+ export declare function balancesOfBatched(contract: `0x${string}`, walletAddresses: string[], ctx: ChainReadContext): Promise<bigint[]>;
@@ -0,0 +1,97 @@
1
+ import { erc721Abi, getAddress } from "viem";
2
+ import { CHAIN_CHUNK_RETRY_DELAY_MS, CHAIN_MULTICALL_CONCURRENCY, CHAIN_READS_PER_MULTICALL } from "../chain/coalescer.js";
3
+ /**
4
+ * The shared `balanceOf` read path behind the two NFT gate rules (`erc5192-min-balance`, the
5
+ * registered v1 gate, and the unregistered `erc721-min-balance`). Internal — not exported from
6
+ * `src/index.ts`, not part of the public API.
7
+ *
8
+ * Both rules score the same way (`balance >= min ? balance : 0n`); they differ only in whether
9
+ * the contract must additionally declare ERC-5192. Keeping ONE copy of the chunking policy
10
+ * matters: the numbers below are tuned against real public endpoints (see below), and two
11
+ * drifting copies would silently reintroduce the burst this exists to prevent.
12
+ */
13
+ /**
14
+ * Chunking policy shared with the voter-level read coalescer (src/chain/coalescer.ts). viem's
15
+ * own default chunking (1,024 bytes of calldata ≈ 27 `balanceOf`s) would split a 1000-wallet
16
+ * batch into ~38 chunks and fire them ALL concurrently — a burst public RPC endpoints throttle
17
+ * (measured against `mainnet.base.org`: 33/38 requests answered HTTP 429 `-32016 over rate
18
+ * limit` and the batch never settled). 200 reads is ~45 KB of calldata and ~2–5M `eth_call`
19
+ * gas — inside public request-size and gas caps — so a 1000-wallet batch is 5 round trips.
20
+ * The in-flight bound here is per batch call; the coalescer additionally enforces the
21
+ * same budget globally across parallel contests (its wrapped `multicall` is what the batched
22
+ * path below runs through).
23
+ */
24
+ const READS_PER_MULTICALL = CHAIN_READS_PER_MULTICALL;
25
+ const MULTICALL_CONCURRENCY = CHAIN_MULTICALL_CONCURRENCY;
26
+ const CHUNK_RETRY_DELAY_MS = CHAIN_CHUNK_RETRY_DELAY_MS;
27
+ /** Score from one balance: the holding when it meets `min`, else `0n` (does not qualify). */
28
+ export function scoreOf(balance, min) {
29
+ return balance >= BigInt(min) ? balance : 0n;
30
+ }
31
+ /**
32
+ * True when the client can run multicall3 `aggregate3` batches — it needs both the action and
33
+ * its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
34
+ * multicall3) takes the per-wallet path instead.
35
+ */
36
+ export function canBatch(ctx) {
37
+ return typeof ctx.chain.multicall === "function" && Boolean(ctx.chain.chain?.contracts?.multicall3);
38
+ }
39
+ /** One wallet's `balanceOf` at the bundle's sampled block. */
40
+ export function balanceOf(contract, walletAddress, ctx) {
41
+ return ctx.chain.readContract({
42
+ address: contract,
43
+ abi: erc721Abi,
44
+ functionName: "balanceOf",
45
+ args: [getAddress(walletAddress)],
46
+ blockNumber: BigInt(ctx.blockNumber)
47
+ });
48
+ }
49
+ /**
50
+ * Many wallets' `balanceOf` at ONE sampled block — the path the background chain verifier rides
51
+ * on a cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
52
+ *
53
+ * The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
54
+ * viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
55
+ * each, so a big batch is a handful of polite round trips rather than a ~40-request burst a
56
+ * public endpoint throttles — and the retry re-reads only the chunk that failed, never a
57
+ * completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
58
+ * twice still fails the whole call: the caller gets one rejection, not partial results.
59
+ */
60
+ export async function balancesOfBatched(contract, walletAddresses, ctx) {
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 balances = 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 read;
83
+ try {
84
+ read = await readChunk(chunk);
85
+ }
86
+ catch {
87
+ await new Promise((resolve) => setTimeout(resolve, CHUNK_RETRY_DELAY_MS));
88
+ read = await readChunk(chunk);
89
+ }
90
+ for (let i = 0; i < chunk.length; i++) {
91
+ balances[index * READS_PER_MULTICALL + i] = read[i];
92
+ }
93
+ }
94
+ };
95
+ await Promise.all(Array.from({ length: Math.min(MULTICALL_CONCURRENCY, chunks.length) }, worker));
96
+ return balances;
97
+ }
@@ -14,15 +14,38 @@ import type { RuleRegistry } from "./types.js";
14
14
  /**
15
15
  * The library's built-in rules, before any host override.
16
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.
17
+ * v1 ships exactly the soulbound-NFT path: `erc5192-min-balance` (Pass gate) + `constant`
18
+ * weight. TWO chain-reading rules stay in the tree and unit-tested (only `erc721-min-balance` is
19
+ * re-exported from `src/index.ts`) but deliberately OUT of this map, so a criteria naming either
20
+ * recuses via `UnknownRuleError` instead of silently gating on an asset that does not bound
21
+ * Sybils. A host that wants one anyway can still register it through the override map below —
22
+ * the library declines to bless the configuration, it does not forbid it.
23
+ *
24
+ * **`erc721-min-balance`** — a bare `balanceOf` on a *transferable* token. The gate bounds
25
+ * Sybils only because the asset cannot move (DESIGN.md "Does one Pass mean one vote?"): every
26
+ * bundle is verified at its OWN pinned block, stays live for `voteExpiryBuckets`, and the
27
+ * winner set is LWW-keyed per wallet, so one token walked A → B → C inside a single expiry
28
+ * window backs three concurrent live votes — each read true at its own block, none collapsed by
29
+ * LWW, and not one of them individually invalid. `erc5192-min-balance` is the same rule plus an
30
+ * on-chain assertion that the contract declares its tokens locked (issue #27).
31
+ *
32
+ * **`erc20-balance`** — the same amplification, reopened by fungibility, plus a second blocker.
33
+ * Both must resolve before it re-ships:
34
+ *
35
+ * 1. the weight path is design-open — a balance-derived weight derives its magnitude from
36
+ * the chain read, so it carries no free wire-side ceiling for the lazy tally (see
37
+ * `RuleResult` in types.ts, ROADMAP.md "Deferred");
38
+ * 2. the ERC-5192 fix does not transfer to fungibles: a balance can be neither soulbound nor
39
+ * LWW-keyed by token id. Closing it needs a hold-duration guard instead — require `min` at
40
+ * the pinned block AND at `pinned - expiryWindow`, which forces two wallets to have held
41
+ * the balance simultaneously. Tracked in issue #28.
42
+ *
43
+ * Both exclusions are pinned by tests: the amplification each one permits in
44
+ * `src/crdt/amplification.test.ts`, the absence from this map in `rules.test.ts`.
22
45
  */
23
46
  export declare const builtinRegistry: RuleRegistry;
24
47
  /** type ids the v1 implementation guarantees; checked against `requires.rules`. */
25
- export declare const V1_BUILTIN_RULE_TYPES: readonly ["erc721-min-balance", "constant"];
48
+ export declare const V1_BUILTIN_RULE_TYPES: readonly ["erc5192-min-balance", "constant"];
26
49
  /**
27
50
  * Merge host overrides over the built-ins. Overrides shadow built-ins by `type`. The
28
51
  * override map is a plain `RuleRegistry` (a flat record already allows any subset
@@ -1,5 +1,5 @@
1
1
  import { UnknownRuleError } from "../errors.js";
2
- import { erc721MinBalance } from "./erc721-min-balance.js";
2
+ import { erc5192MinBalance } from "./erc5192-min-balance.js";
3
3
  import { constant } from "./constant.js";
4
4
  /**
5
5
  * The rule registry: builtins, the shadowing resolver, and criteria validation.
@@ -15,18 +15,41 @@ import { constant } from "./constant.js";
15
15
  /**
16
16
  * The library's built-in rules, before any host override.
17
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.
18
+ * v1 ships exactly the soulbound-NFT path: `erc5192-min-balance` (Pass gate) + `constant`
19
+ * weight. TWO chain-reading rules stay in the tree and unit-tested (only `erc721-min-balance` is
20
+ * re-exported from `src/index.ts`) but deliberately OUT of this map, so a criteria naming either
21
+ * recuses via `UnknownRuleError` instead of silently gating on an asset that does not bound
22
+ * Sybils. A host that wants one anyway can still register it through the override map below —
23
+ * the library declines to bless the configuration, it does not forbid it.
24
+ *
25
+ * **`erc721-min-balance`** — a bare `balanceOf` on a *transferable* token. The gate bounds
26
+ * Sybils only because the asset cannot move (DESIGN.md "Does one Pass mean one vote?"): every
27
+ * bundle is verified at its OWN pinned block, stays live for `voteExpiryBuckets`, and the
28
+ * winner set is LWW-keyed per wallet, so one token walked A → B → C inside a single expiry
29
+ * window backs three concurrent live votes — each read true at its own block, none collapsed by
30
+ * LWW, and not one of them individually invalid. `erc5192-min-balance` is the same rule plus an
31
+ * on-chain assertion that the contract declares its tokens locked (issue #27).
32
+ *
33
+ * **`erc20-balance`** — the same amplification, reopened by fungibility, plus a second blocker.
34
+ * Both must resolve before it re-ships:
35
+ *
36
+ * 1. the weight path is design-open — a balance-derived weight derives its magnitude from
37
+ * the chain read, so it carries no free wire-side ceiling for the lazy tally (see
38
+ * `RuleResult` in types.ts, ROADMAP.md "Deferred");
39
+ * 2. the ERC-5192 fix does not transfer to fungibles: a balance can be neither soulbound nor
40
+ * LWW-keyed by token id. Closing it needs a hold-duration guard instead — require `min` at
41
+ * the pinned block AND at `pinned - expiryWindow`, which forces two wallets to have held
42
+ * the balance simultaneously. Tracked in issue #28.
43
+ *
44
+ * Both exclusions are pinned by tests: the amplification each one permits in
45
+ * `src/crdt/amplification.test.ts`, the absence from this map in `rules.test.ts`.
23
46
  */
24
47
  export const builtinRegistry = {
25
- [erc721MinBalance.type]: erc721MinBalance,
48
+ [erc5192MinBalance.type]: erc5192MinBalance,
26
49
  [constant.type]: constant
27
50
  };
28
51
  /** type ids the v1 implementation guarantees; checked against `requires.rules`. */
29
- export const V1_BUILTIN_RULE_TYPES = ["erc721-min-balance", "constant"];
52
+ export const V1_BUILTIN_RULE_TYPES = ["erc5192-min-balance", "constant"];
30
53
  /**
31
54
  * Merge host overrides over the built-ins. Overrides shadow built-ins by `type`. The
32
55
  * override map is a plain `RuleRegistry` (a flat record already allows any subset
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",