@bitsocial/pubsub-voting 0.4.1 → 0.5.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.
@@ -1,7 +1,7 @@
1
1
  import type { Criteria } from "../schema/criteria.js";
2
2
  import type { RuleRegistry } from "../rules/types.js";
3
3
  import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
4
- import { type RuleCache } from "../rules/cache.js";
4
+ import type { RuleCache } from "../rules/cache.js";
5
5
  import { type NameResolutionCache } from "./name-resolution-cache.js";
6
6
  import type { BundleVerifier } from "./types.js";
7
7
  /**
@@ -32,12 +32,12 @@ export interface BundleVerifierDeps {
32
32
  criteria: Criteria;
33
33
  /** The criteria document's CID bytes (`(await criteriaCid(criteria)).bytes`) — signature binding. */
34
34
  criteriaCid: Uint8Array;
35
- /** The rule chain's numeric chainId (bound in the ballot domain). */
35
+ /** The contest's numeric chainId (`criteria.bucketChainId`, bound in the ballot domain). */
36
36
  chainId: number;
37
37
  /** Resolved rule registry (built-ins + host overrides). */
38
38
  registry: RuleRegistry;
39
- /** Resolve a chain ticker (e.g. "base") to its viem client. */
40
- chainFor: (ticker: string) => ChainClient;
39
+ /** The contest's one chain client — every rule reads it (DESIGN.md "One clock"). */
40
+ chain: ChainClient;
41
41
  /** Bucket math for `criteria.blocksPerBucket`. */
42
42
  bucketMath: BucketMath;
43
43
  /** Host-injected community-name resolvers (`PubsubVoterOptions.nameResolvers`). */
@@ -55,13 +55,15 @@ export interface BundleVerifierDeps {
55
55
  block: number;
56
56
  }>;
57
57
  /**
58
- * The gate rule's memo (see rules/cache.ts), handed to it as `ctx.cache`. This is what keeps
59
- * a wallet's gate read from repeating per bundle — an ineligible wallet minting fresh-signed
60
- * bundles, or an eligible one cycling choices, costs one read per key per rule epoch rather
61
- * than one per bundle. Defaults to a private in-memory cache (unit tests); the voter injects
62
- * the persistent, contest-shared one.
58
+ * One memo per gate leaf (see rules/cache.ts), in `gateLeaves` order, handed to each rule as
59
+ * its `ctx.cache`. This is what keeps a wallet's gate read from repeating per bundle — an
60
+ * ineligible wallet minting fresh-signed bundles, or an eligible one cycling choices, costs
61
+ * one read per key per rule epoch rather than one per bundle. Each leaf gets its OWN
62
+ * namespace, so two leaves of the same rule `type` on different options can never read each
63
+ * other's answers. Defaults to private in-memory caches (unit tests); the voter injects the
64
+ * persistent, contest-shared ones.
63
65
  */
64
- ruleCache?: RuleCache;
66
+ ruleCaches?: readonly RuleCache[];
65
67
  /**
66
68
  * Optional persistent cache of name resolutions (the pkc-js rule — see
67
69
  * verify/name-resolution-cache.ts). When present, a carried name is resolved live at most
@@ -1,26 +1,32 @@
1
- import { tickerForRef } from "../chain/ticker.js";
2
- import { makeMemoryRuleCache } from "../rules/cache.js";
3
- import { gateFailure, scoreOrZero } from "../rules/result.js";
4
- import { UnknownRuleError } from "../errors.js";
1
+ import { dedupeLeaves, evaluateGate, gateBlame, gatePenalize, gateReason, resolveGate } from "../rules/gate.js";
5
2
  import { verifyBundleSignature } from "./signature.js";
6
3
  import { checkBundleConstraints } from "./constraints.js";
7
4
  import { resolveNameThroughCache } from "./name-resolution-cache.js";
8
5
  export function makeBundleVerifier(deps) {
9
- const { criteria, criteriaCid, chainId, registry, chainFor, bucketMath, nameResolvers, nameResolutionCache } = deps;
10
- // Resolve the gate `rule`, its options and its chain client once: they are fixed by the
11
- // criteria, so none of it is recomputed per bundle.
12
- const rule = registry[criteria.rule.type];
13
- if (!rule)
14
- throw new UnknownRuleError("rule", criteria.rule.type);
15
- const ruleOptions = rule.optionsSchema.parse(criteria.rule);
16
- const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
17
- const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
18
- // The rule's whole world: its chain, this verifier's head (lazy never read for a rule that
19
- // does not want it), and its own memo. Which block it actually reads at is its business.
20
- const ctx = {
21
- chain: ruleChain,
22
- head: () => readHead({ chain: ruleChain }),
23
- cache: deps.ruleCache ?? makeMemoryRuleCache()
6
+ const { criteria, criteriaCid, chainId, registry, chain, bucketMath, nameResolvers, nameResolutionCache } = deps;
7
+ // Resolve every gate leaf — rule, options, memo once: they are fixed by the criteria, so
8
+ // none of it is recomputed per bundle. Same for which leaves ask the same question.
9
+ const readHead = deps.readHead ?? (async ({ chain: client }) => ({ block: Number(await client.getBlockNumber()) }));
10
+ const leaves = resolveGate({ criteria, registry, chain, readHead, caches: deps.ruleCaches });
11
+ const { ofLeaf } = dedupeLeaves(leaves);
12
+ /**
13
+ * Score one leaf for one wallet, sharing one answer between leaves that ask the same question
14
+ * (`dedupeLeaves`). The per-wallet map is what makes the sharing real: the fold evaluates
15
+ * leaves concurrently, so two positions of one rule would otherwise both miss its memo before
16
+ * either wrote to it.
17
+ */
18
+ const scoreLeaf = (wallet) => {
19
+ const asked = new Map();
20
+ return (leaf) => {
21
+ const question = ofLeaf[leaf];
22
+ const already = asked.get(question);
23
+ if (already)
24
+ return already;
25
+ const { rule, options, ctx } = leaves[leaf];
26
+ const answer = rule.evaluate({ options, wallet, ctx });
27
+ asked.set(question, answer);
28
+ return answer;
29
+ };
24
30
  };
25
31
  // Stage 1, shared by `verify` and `verifyOffline`: signature + constraints, local and µs.
26
32
  const verifyOffline = async (bundle) => {
@@ -33,25 +39,47 @@ export function makeBundleVerifier(deps) {
33
39
  };
34
40
  return {
35
41
  verifyOffline,
36
- // The gate step on its own, against the SAME rule/options/ctx `verify` uses below — which
37
- // is the entire value of exposing it: a caller asking "would this wallet's vote count?"
38
- // can never drift from what the gate actually does.
39
- checkGate: ({ address, sampleBlock }) => rule.evaluate({ options: ruleOptions, wallet: { address, sampleBlock }, ctx }),
42
+ // The gate step on its own, against the SAME leaves/options/ctxs `verify` uses below —
43
+ // which is the entire value of exposing it: a caller asking "would this wallet's vote
44
+ // count?" can never drift from what the gate actually does. Every leaf is scored here
45
+ // (`collectAll`), because naming each failure is what this call is for.
46
+ async checkGates({ address, sampleBlock }) {
47
+ // A leaf whose read FAILED is not a leaf that said no. Under an `any`, refusing the
48
+ // whole check because one rule's RPC timed out would tell a wallet that qualifies
49
+ // through another branch that it is ineligible — over an outage it cannot act on. So
50
+ // a failed read is folded as unknown, and only if the tree cannot be decided without
51
+ // it does the original error surface (never a verdict invented from a missing read).
52
+ let firstError;
53
+ let failed = false;
54
+ const gate = await evaluateGate({
55
+ node: criteria.gate,
56
+ evaluate: scoreLeaf({ address, sampleBlock }),
57
+ collectAll: true,
58
+ tolerateLeafErrors: true,
59
+ onLeafError: (_leaf, error) => {
60
+ if (!failed)
61
+ [failed, firstError] = [true, error];
62
+ }
63
+ });
64
+ if (gate.satisfied === undefined)
65
+ throw firstError;
66
+ return gate;
67
+ },
40
68
  async verify(bundle) {
41
69
  const offline = await verifyOffline(bundle);
42
70
  if (!offline.valid)
43
71
  return offline;
44
- // 3. Gate (chain) — the rule scores this wallet, reading and memoizing however it
45
- // sees fit. The bundle's bucketized sample block is handed over as the pinned
46
- // block the ballot names; a rule scoring current state ignores it for `ctx.head`.
72
+ // 3. Gate (chain) — each leaf rule scores this wallet, reading and memoizing however
73
+ // it sees fit, and `all`/`any` fold the answers (rules/gate.ts). The bundle's
74
+ // bucketized sample block is handed over as the pinned block the ballot names; a
75
+ // rule scoring current state ignores it for `ctx.head`. Lazy on this path: a
76
+ // determined tree stops, so a composite gate costs only the leaves it needed.
47
77
  const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
48
- const gate = await rule.evaluate({
49
- options: ruleOptions,
50
- wallet: { address: bundle.address, sampleBlock },
51
- ctx
78
+ const gate = await evaluateGate({
79
+ node: criteria.gate,
80
+ evaluate: scoreLeaf({ address: bundle.address, sampleBlock })
52
81
  });
53
- const gateFailed = gateFailure(gate);
54
- if (gateFailed) {
82
+ if (gate.satisfied !== true) {
55
83
  // Disposition comes from the rule's own answer, never from the rule's identity.
56
84
  // A failure the rule stands behind is identical on every honest verifier, so it is
57
85
  // a `reject`: the sender is penalized and the verdict cached as terminal. One it
@@ -61,13 +89,16 @@ export function makeBundleVerifier(deps) {
61
89
  // uncached, so it is re-judged rather than frozen (the same treatment community
62
90
  // name resolution has always had, step 4 below).
63
91
  //
64
- // The reason is the RULE's wording, verbatim: only it knows whether this wallet
65
- // holds too few, holds none, or faces a contract that gates nothing, and that
66
- // sentence is what reaches the voter through `VoteEvictedError`.
92
+ // The reason is the RULES' wording, verbatim: only they know whether this wallet
93
+ // holds too few, holds none, or faces a contract that gates nothing, and those
94
+ // sentences are what reach the voter through `VoteEvictedError` — narrowed to the
95
+ // failures that actually explain the refusal (`gateBlame`), so a wallet is never
96
+ // told to go acquire something a satisfied `any` branch never needed.
67
97
  return {
68
98
  valid: false,
69
- disposition: gateFailed.penalize ? "reject" : "ignore",
70
- reason: `not admitted: ${gateFailed.error}`
99
+ disposition: gatePenalize(gate) ? "reject" : "ignore",
100
+ reason: `not admitted: ${gateReason(gate)}`,
101
+ failures: gateBlame(gate).map((leaf) => ({ type: leaves[leaf.leaf].ref.type, error: leaf.error ?? "" }))
71
102
  };
72
103
  }
73
104
  // 4. Community-name resolution (network) — a carried name is a claim, verified against
@@ -102,7 +133,7 @@ export function makeBundleVerifier(deps) {
102
133
  }
103
134
  resolvedNames[name] = record.publicKey;
104
135
  }
105
- return { valid: true, ruleScore: scoreOrZero(gate), resolvedNames };
136
+ return { valid: true, resolvedNames };
106
137
  }
107
138
  };
108
139
  }
@@ -1,5 +1,5 @@
1
1
  import type { VotesBundle } from "../schema/votes.js";
2
- import type { RuleResult } from "../rules/types.js";
2
+ import type { GateResult } from "../rules/gate.js";
3
3
  /**
4
4
  * Verification interfaces, design only.
5
5
  *
@@ -36,6 +36,17 @@ export type VerifyFail = {
36
36
  valid: false;
37
37
  disposition: VerdictDisposition;
38
38
  reason: string;
39
+ /**
40
+ * Present when the GATE refused: one entry per rule whose failure explains the refusal
41
+ * (rules/gate.ts `gateBlame` — not every failed leaf, since one inside a satisfied `any`
42
+ * cost the wallet nothing). `reason` is these same sentences joined, kept so a caller that
43
+ * only renders a string needs no change; this is the structured form, so a client listing
44
+ * "what you are missing" never has to split one.
45
+ */
46
+ failures?: readonly {
47
+ type: string;
48
+ error: string;
49
+ }[];
39
50
  };
40
51
  export type VerifyResult = VerifyOk | VerifyFail;
41
52
  /** Stage 1: ballot signature only. No chain access. */
@@ -52,17 +63,20 @@ export interface OfflineBundleVerifier {
52
63
  }): Promise<VerifyResult>;
53
64
  }
54
65
  /**
55
- * A passing full-bundle verdict. Beyond `valid: true` it carries the work the gate already
56
- * did so downstream stages need not redo it:
57
- * - `ruleScore`: the gate `rule`'s score for the voting wallet at the bucket block
58
- * (always `> 0n` here `0n` would have failed the gate).
59
- * - `resolvedNames`: for each vote that carried a `community.name`, the `publicKey` the name
60
- * resolved to (equal to the claimed key, since a mismatch fails the gate). Votes with no
61
- * name are absent. Lets a UI show a verified name without re-resolving.
66
+ * A passing full-bundle verdict. Beyond `valid: true` it carries the work the gate already did so
67
+ * downstream stages need not redo it: `resolvedNames` maps each vote that carried a
68
+ * `community.name` to the `publicKey` the name resolved to (equal to the claimed key, since a
69
+ * mismatch fails the gate). Votes with no name are absent. Lets a UI show a verified name without
70
+ * re-resolving.
71
+ *
72
+ * The gate's folded SCORE is deliberately not here. Nothing downstream reads it — a vote's
73
+ * magnitude comes from `criteria.weight`, never from the gate — and a number no one consumes grows
74
+ * semantics by accident, which a min-across-`all` fold over unrelated rules ("holds 5" and "not
75
+ * banned = 1") cannot survive. A client that wants per-rule scores asks `checkEligibility`, which
76
+ * reports each leaf's own.
62
77
  */
63
78
  export interface BundleVerdictValid {
64
79
  valid: true;
65
- ruleScore: bigint;
66
80
  resolvedNames: Record<string, string>;
67
81
  }
68
82
  /**
@@ -85,25 +99,32 @@ export interface BundleVerifier {
85
99
  */
86
100
  verifyOffline(bundle: VotesBundle): Promise<VerifyResult>;
87
101
  /**
88
- * Step 3 alone, for a wallet rather than a bundle: run the gate rule and hand back its raw
89
- * {@link RuleResult}. Backs `Contest.checkEligibility`, so a client can ask "would this vote
90
- * count?" through the very same rule instance, options, chain client, head reader and memo
91
- * the forward gate uses — never a reimplementation of them.
102
+ * Step 3 alone, for a wallet rather than a bundle: score EVERY leaf of the gate tree and hand
103
+ * back the folded {@link GateResult}. Backs `Contest.checkEligibility`, so a client can ask
104
+ * "would this vote count?" through the very same rule instances, options, chain clients, head
105
+ * reader and memos the forward gate uses — never a reimplementation of them.
106
+ *
107
+ * Unlike `verify`, this never short-circuits: a caller asking which rules a wallet fails needs
108
+ * all of their answers, not just the first one that settled the outcome.
109
+ *
110
+ * Also unlike `verify`, a leaf whose chain read THROWS does not fail the call outright — it is
111
+ * folded as unknown, so a wallet admitted by a branch that did answer still gets its answer.
112
+ * The error is re-thrown only when the tree cannot be decided without that leaf.
92
113
  *
93
114
  * `sampleBlock` is the pinned block the prospective ballot would name (the caller's current
94
115
  * bucket). A head-scoring rule ignores it exactly as it does during verification.
95
116
  */
96
- checkGate(args: {
117
+ checkGates(args: {
97
118
  address: string;
98
119
  sampleBlock: number;
99
- }): Promise<RuleResult>;
120
+ }): Promise<GateResult>;
100
121
  }
101
122
  /**
102
123
  * The per-bundle record of the two deferred *network* checks. The offline checks (signature,
103
124
  * constraints) are never recorded here — they are synchronous preconditions for admission, so
104
125
  * an admitted bundle has always passed them.
105
126
  *
106
- * - `chainVerified`: the gate `rule` scored the wallet `> 0n` at the bucket block. `false`
127
+ * - `chainVerified`: the gate admitted the wallet at the bucket block. `false`
107
128
  * means "not yet read", never "failed" — a failed gate evicts the bundle instead.
108
129
  * - `nameResolved`: `undefined` when the bundle carries no `community.name`; `false` while
109
130
  * the carried name is unresolved; `true` once it resolved to the claimed `publicKey`. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
@@ -1,15 +0,0 @@
1
- import type { Criteria, RuleRef } from "../schema/criteria.js";
2
- /**
3
- * Resolve which chain a rule reads. A rule's parsed options may name a
4
- * `chain` ticker (e.g. `erc5192-min-balance` -> "base"); a chainless rule (e.g.
5
- * `constant`) names none, so callers fall back to the first configured chain. Shared by the
6
- * verifier, the tally, and the facade so the fallback rule stays identical everywhere.
7
- */
8
- /** The `chain` ticker named in a rule's parsed options, or `undefined` if none. */
9
- export declare function chainTickerOf(options: unknown): string | undefined;
10
- /**
11
- * The chain ticker a rule ref uses: its own `chain` option, else the first chain in
12
- * `requires.chains`. Throws if neither exists (a chainless rule with no configured
13
- * chains cannot be read).
14
- */
15
- export declare function tickerForRef(criteria: Criteria, ref: RuleRef, options: unknown): string;
@@ -1,25 +0,0 @@
1
- import { z } from "zod";
2
- /**
3
- * Resolve which chain a rule reads. A rule's parsed options may name a
4
- * `chain` ticker (e.g. `erc5192-min-balance` -> "base"); a chainless rule (e.g.
5
- * `constant`) names none, so callers fall back to the first configured chain. Shared by the
6
- * verifier, the tally, and the facade so the fallback rule stays identical everywhere.
7
- */
8
- /** The `chain` ticker named in a rule's parsed options, or `undefined` if none. */
9
- export function chainTickerOf(options) {
10
- const parsed = z.object({ chain: z.string().min(1) }).safeParse(options);
11
- return parsed.success ? parsed.data.chain : undefined;
12
- }
13
- /**
14
- * The chain ticker a rule ref uses: its own `chain` option, else the first chain in
15
- * `requires.chains`. Throws if neither exists (a chainless rule with no configured
16
- * chains cannot be read).
17
- */
18
- export function tickerForRef(criteria, ref, options) {
19
- const ticker = chainTickerOf(options) ?? Object.keys(criteria.requires.chains)[0];
20
- if (!ticker) {
21
- throw new Error(`criteria rule "${ref.type}" names no chain and requires.chains is empty; ` +
22
- `cannot resolve a chain client to read it`);
23
- }
24
- return ticker;
25
- }