@bitsocial/pubsub-voting 0.2.1 → 0.4.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.
@@ -28,27 +28,42 @@ const CHUNK_RETRY_DELAY_MS = CHAIN_CHUNK_RETRY_DELAY_MS;
28
28
  export function scoreOf(balance, min) {
29
29
  return balance >= BigInt(min) ? balance : 0n;
30
30
  }
31
+ /**
32
+ * The voter-facing wording for a token-count shortfall ({@link RuleResult.error}), shared by
33
+ * every balance-scored rule so they explain themselves identically.
34
+ *
35
+ * Deliberately generic: a rule knows a contract address and a threshold, not that the deployment
36
+ * calls this token a "5chan Pass". A client renders it verbatim, which is the point — it then
37
+ * needs to know nothing about which block the rule read or what `min` is.
38
+ */
39
+ export function shortfallError(balance, min, contract) {
40
+ return balance === 0n
41
+ ? `this wallet holds none of the gate token (${contract})`
42
+ : `this wallet holds ${balance} of the gate token (${contract}), but ${min} are required`;
43
+ }
31
44
  /**
32
45
  * True when the client can run multicall3 `aggregate3` batches — it needs both the action and
33
46
  * its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
34
47
  * multicall3) takes the per-wallet path instead.
35
48
  */
36
- export function canBatch(ctx) {
37
- return typeof ctx.chain.multicall === "function" && Boolean(ctx.chain.chain?.contracts?.multicall3);
49
+ export function canBatch(args) {
50
+ const { ctx } = args;
51
+ return { batchable: typeof ctx.chain.multicall === "function" && Boolean(ctx.chain.chain?.contracts?.multicall3) };
38
52
  }
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,
53
+ /** One wallet's `balanceOf` at `block` (the caller's choice — a pinned block or the head). */
54
+ export async function balanceOf(args) {
55
+ const balance = await args.ctx.chain.readContract({
56
+ address: args.contract,
43
57
  abi: erc721Abi,
44
58
  functionName: "balanceOf",
45
- args: [getAddress(walletAddress)],
46
- blockNumber: BigInt(ctx.blockNumber)
59
+ args: [getAddress(args.wallet)],
60
+ blockNumber: BigInt(args.block)
47
61
  });
62
+ return { balance };
48
63
  }
49
64
  /**
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}.
65
+ * Many wallets' `balanceOf` at ONE block — the path the background chain verifier rides on a
66
+ * cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
52
67
  *
53
68
  * The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
54
69
  * viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
@@ -57,7 +72,8 @@ export function balanceOf(contract, walletAddress, ctx) {
57
72
  * completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
58
73
  * twice still fails the whole call: the caller gets one rejection, not partial results.
59
74
  */
60
- export async function balancesOfBatched(contract, walletAddresses, ctx) {
75
+ export async function balancesOfBatched(args) {
76
+ const { contract, wallets: walletAddresses, block, ctx } = args;
61
77
  const chunks = [];
62
78
  for (let at = 0; at < walletAddresses.length; at += READS_PER_MULTICALL) {
63
79
  chunks.push(walletAddresses.slice(at, at + READS_PER_MULTICALL));
@@ -72,7 +88,7 @@ export async function balancesOfBatched(contract, walletAddresses, ctx) {
72
88
  })),
73
89
  allowFailure: false,
74
90
  batchSize: 0,
75
- blockNumber: BigInt(ctx.blockNumber)
91
+ blockNumber: BigInt(block)
76
92
  });
77
93
  let nextChunk = 0;
78
94
  const worker = async () => {
@@ -93,5 +109,5 @@ export async function balancesOfBatched(contract, walletAddresses, ctx) {
93
109
  }
94
110
  };
95
111
  await Promise.all(Array.from({ length: Math.min(MULTICALL_CONCURRENCY, chunks.length) }, worker));
96
- return balances;
112
+ return { balances };
97
113
  }
@@ -0,0 +1,16 @@
1
+ import type { RuleResult } from "./types.js";
2
+ /**
3
+ * `undefined` when the wallet is admitted, else why it was not and whether the sender may be
4
+ * blamed for it.
5
+ *
6
+ * `penalize` is normalised here (defaulting to `true`) so callers never repeat the
7
+ * `!== false` dance, and a `success: true, score: 0n` — impossible per the contract, but not
8
+ * expressible in the type — is treated as a failure rather than silently admitting a
9
+ * zero-weight vote.
10
+ */
11
+ export declare function gateFailure(result: RuleResult): {
12
+ error: string;
13
+ penalize: boolean;
14
+ } | undefined;
15
+ /** The weight slot's reading: a failure contributes nothing rather than dropping the vote. */
16
+ export declare function scoreOrZero(result: RuleResult): bigint;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The two things the pipeline does with a {@link RuleResult}, in one place so the gate path and
3
+ * the background verifier cannot drift apart.
4
+ *
5
+ * Neither helper looks at which rule produced the result — that is the standing rule of this
6
+ * codebase (AGENTS.md, "What a rule owns, and what the pipeline owns"). They read the discriminant
7
+ * and nothing else.
8
+ */
9
+ /** A rule that says `success: true` but scores nothing has a bug; refuse rather than admit. */
10
+ const NON_POSITIVE_SUCCESS = "the contest's rule reported success without a score, which is a bug in that rule";
11
+ /**
12
+ * `undefined` when the wallet is admitted, else why it was not and whether the sender may be
13
+ * blamed for it.
14
+ *
15
+ * `penalize` is normalised here (defaulting to `true`) so callers never repeat the
16
+ * `!== false` dance, and a `success: true, score: 0n` — impossible per the contract, but not
17
+ * expressible in the type — is treated as a failure rather than silently admitting a
18
+ * zero-weight vote.
19
+ */
20
+ export function gateFailure(result) {
21
+ if (!result.success)
22
+ return { error: result.error, penalize: result.penalize !== false };
23
+ if (result.score <= 0n)
24
+ return { error: NON_POSITIVE_SUCCESS, penalize: true };
25
+ return undefined;
26
+ }
27
+ /** The weight slot's reading: a failure contributes nothing rather than dropping the vote. */
28
+ export function scoreOrZero(result) {
29
+ return result.success && result.score > 0n ? result.score : 0n;
30
+ }
@@ -1,70 +1,166 @@
1
1
  import type { z } from "zod";
2
2
  import type { ChainClient } from "../chain/types.js";
3
+ import type { RuleCache } from "./cache.js";
3
4
  /**
4
5
  * Rule interface, design + leaves implemented.
5
6
  *
6
7
  * 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
+ * score for one wallet, at whichever block the rule itself decides to read the pinned block
9
+ * the bundle names ({@link RuleWallet.sampleBlock}) or this verifier's current head
10
+ * ({@link ChainReadContext.head}). There is a SINGLE kind
8
11
  * (mirroring the flat pkc-js challenge registry: `Record<string, rule>`, user
9
12
  * entries shadow builtins). The criteria still has two slots that draw from this one
10
13
  * registry:
11
14
  *
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.
15
+ * - rule slot: the result is a GATE. `success: true` admits the wallet, `success: false`
16
+ * rejects it and must say why ({@link RuleResult.error}).
17
+ * - weight slot: a successful result's `score` is the vote's MAGNITUDE; a failure is zero
18
+ * weight.
14
19
  *
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
+ * One return shape covers both roles: a rule that needs a threshold (min Passes, min balance)
21
+ * bakes it in by failing when the wallet falls short, so the gate slot needs no separate kind.
20
22
  */
21
23
  /**
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").
24
+ * The result of one evaluation: a wallet either qualifies with a score, or it does not and the
25
+ * rule says why.
26
+ *
27
+ * Modelled on pkc-js's `ChallengeResult` (community/schema.ts) a `success` discriminant, with
28
+ * `error` REQUIRED on the failing branch. Making it required is the whole point: only the rule
29
+ * knows why a wallet fell short, and if that reason is optional it will simply be omitted, which
30
+ * leaves the pipeline with one undifferentiated "score is 0n" and forces every UI to re-derive
31
+ * the rule's thresholds and block choice to say anything useful. That duplication is exactly what
32
+ * {@link ChainReadContext} and {@link RuleCache} exist to prevent everywhere else, and it is what
33
+ * broke when the gate moved from a pinned block to the head: a client's hand-rolled explanation
34
+ * kept telling voters to wait for a window that no longer gated anything.
35
+ *
36
+ * - rule slot: `success: true` admits the wallet; `success: false` rejects it.
37
+ * - weight slot: `success: true` carries the vote's MAGNITUDE; `success: false` is zero weight.
38
+ *
39
+ * Final vote value = gate `success: false` ? `0n` : weight `success` ? weight `score` : `0n`.
40
+ *
41
+ * `score` MUST be `> 0n` on the success branch — "qualifies, with no weight" is not a state this
42
+ * models, and the pipeline treats a non-positive success score as a rule bug and refuses the
43
+ * wallet rather than silently admitting a zero-weight vote.
27
44
  */
28
- export interface RuleResult {
45
+ export type RuleResult = {
46
+ success: true;
47
+ /** The wallet's magnitude. MUST be `> 0n`. */
29
48
  score: bigint;
30
- }
31
- /** Everything a rule needs to read chain state for one evaluation. */
49
+ } | {
50
+ success: false;
51
+ /**
52
+ * Why this wallet does not qualify, phrased for the person who cast (or is about to
53
+ * cast) the vote. Surfaces on `VerifyFail.reason`, on `VoteEvictedError.verdict`, and
54
+ * from `Contest.checkEligibility`, so a client renders it verbatim and stays correct
55
+ * across rule changes it knows nothing about.
56
+ *
57
+ * Write it as a statement about the wallet, not about the library: "this wallet holds
58
+ * none of the gate token", not "gate rule returned 0". Do not cite block numbers unless
59
+ * they are actionable — a voter can do nothing with a sample block.
60
+ */
61
+ error: string;
62
+ /**
63
+ * May this failure be blamed on the peer that sent the vote? Default `true`.
64
+ *
65
+ * The pipeline has two decisions the rule cannot make for it — whether the gossip
66
+ * forward-gate `reject`s the message (penalizing the delivering peer through
67
+ * gossipsub's invalid-message score, eventually pruning and graylisting it) or merely
68
+ * `ignore`s it, and whether the background verifier evicts the bundle at once or holds
69
+ * it for a grace window. Both hinge on one thing only the rule knows: is this failure
70
+ * attributable?
71
+ *
72
+ * `true` (the default) says every honest verifier necessarily computes this same
73
+ * failure — true of a read pinned to a historical block, since the block is named by
74
+ * the bundle and the chain's history does not move. The bundle is dropped, the sender
75
+ * penalized, the verdict cached as terminal.
76
+ *
77
+ * `false` says an honest peer could legitimately disagree, so nobody may be blamed. The
78
+ * bundle is still dropped, but `ignore`-class — no penalty, verdict uncached, and the
79
+ * background verifier re-examines it for a grace window before giving up. This is what
80
+ * a rule scoring the chain head must return: the peer that forwarded the vote verified
81
+ * it against ITS head, and any peer ahead of us can legitimately see an acquisition we
82
+ * have not. Penalizing there punishes honest relayers for being current — and does so
83
+ * exactly when a wallet has just acquired the gate asset, which is when peer heads
84
+ * straddle.
85
+ */
86
+ penalize?: boolean;
87
+ };
88
+ /** Everything a rule needs to read chain state and remember what it read. */
32
89
  export interface ChainReadContext {
33
90
  /**
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)`.
91
+ * The viem `PublicClient` for the rule's `options.chain`. Use the full viem read surface
92
+ * directly (`readContract`, `getBalance`, ...). ALWAYS pin each call to an explicit
93
+ * `blockNumber: BigInt(...)`: the read coalescer (src/chain/coalescer.ts) only folds reads
94
+ * carrying an explicit block into one multicall3, so a `blockTag` or an omitted block
95
+ * silently drops out of batching and back to one HTTP round trip per read.
37
96
  */
38
97
  chain: ChainClient;
39
- /** The single sampled block for the bundle's bucket. */
40
- blockNumber: number;
98
+ /**
99
+ * This verifier's current head on {@link chain}. A rule scoring historical state never calls
100
+ * it and never pays for it; a rule scoring "now" resolves it ONCE per evaluation and pins
101
+ * its reads to that number, so a batch still lands in a single multicall.
102
+ *
103
+ * Shared and coalesced by the voter, because the gate runs on the verify path — one call per
104
+ * incoming vote — so an unshared head read would be one `eth_blockNumber` per bundle per
105
+ * contest.
106
+ */
107
+ head: () => Promise<{
108
+ block: number;
109
+ }>;
110
+ /**
111
+ * This rule's memo (see rules/cache.ts). A chain-reading rule MUST compute through it: it is
112
+ * what turns "one chain read per unique bundle" into "one read per key per epoch", which is
113
+ * the bound that stops an ineligible wallet from making every peer on the topic pay an RPC
114
+ * round trip per fresh-signed bundle.
115
+ */
116
+ cache: RuleCache;
117
+ }
118
+ /** One wallet to score, and the pinned block the bundle it came from names. */
119
+ export interface RuleWallet {
120
+ /** The voting wallet (the address recovered from the bundle's signature). */
121
+ address: string;
122
+ /**
123
+ * The bundle's bucketized sample block, already floored to the bucket boundary — the
124
+ * historical block every verifier agrees this ballot names. A rule scoring pinned state
125
+ * reads here (and its answer is then identical on every verifier, forever); a rule scoring
126
+ * current state ignores it and uses {@link ChainReadContext.head} instead. It is NOT a
127
+ * claim about when the ballot was signed: it is floored to the bucket, so it can trail the
128
+ * actual signing moment by up to `blocksPerBucket`.
129
+ */
130
+ sampleBlock: number;
41
131
  }
42
132
  /**
43
133
  * 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).
134
+ * `evaluate` returns a {@link RuleResult}: either `{ success: true, score }` with `score > 0n`,
135
+ * or `{ success: false, error }` — "does not qualify", with the reason the voter is shown.
46
136
  */
47
137
  export interface Rule<O = unknown> {
48
138
  readonly type: string;
49
139
  readonly optionsSchema: z.ZodType<O>;
50
140
  evaluate(args: {
51
141
  options: O;
52
- walletAddress: string;
142
+ wallet: RuleWallet;
53
143
  ctx: ChainReadContext;
54
144
  }): Promise<RuleResult>;
55
145
  /**
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).
146
+ * Optional batched form of {@link evaluate}: score many wallets in as few RPC round trips as
147
+ * the rule can manage (e.g. one multicall3 `aggregate3` for a whole checkpoint's wallets).
148
+ * Returns one result per input wallet, in order. Semantics MUST equal mapping `evaluate` over
149
+ * the wallets — this is a transport optimization, never a different answer. The background
150
+ * chain verifier prefers it when present and falls back to per-wallet `evaluate` otherwise.
151
+ *
152
+ * The wallets are NOT guaranteed to share a `sampleBlock`: the pipeline no longer groups them
153
+ * (it cannot, since which block a rule reads at is the rule's own business), so a batch is
154
+ * whatever was pending. A rule scoring the head reads once for the whole batch; a rule
155
+ * scoring pinned state groups by `sampleBlock` itself.
62
156
  */
63
157
  evaluateMany?(args: {
64
158
  options: O;
65
- walletAddresses: string[];
159
+ wallets: RuleWallet[];
66
160
  ctx: ChainReadContext;
67
- }): Promise<RuleResult[]>;
161
+ }): Promise<{
162
+ results: RuleResult[];
163
+ }>;
68
164
  }
69
165
  /**
70
166
  * The registry: a flat `type -> rule` map. Built-ins are provided by this
@@ -3,6 +3,7 @@ import type { VotesBundle } from "../schema/votes.js";
3
3
  import type { RuleRegistry } from "../rules/types.js";
4
4
  import type { ChainClient, BucketMath } from "../chain/types.js";
5
5
  import type { BundleChecks } from "../verify/types.js";
6
+ import { type RuleCache } from "../rules/cache.js";
6
7
  import type { Tally } from "./types.js";
7
8
  /**
8
9
  * Deterministic per-contest aggregation over the CRDT's current bundles. Every aggregated
@@ -34,6 +35,19 @@ export interface TallyDeps {
34
35
  bundle: VotesBundle;
35
36
  checks: BundleChecks;
36
37
  }>;
38
+ /**
39
+ * This verifier's current head, handed to the weight rule as `ctx.head`. Never called by a
40
+ * weight rule that scores pinned historical state — including `constant`, which reads no
41
+ * chain at all — so the "an idle contest does zero chain reads" property is untouched.
42
+ * Defaults to the weight chain's own `getBlockNumber()`.
43
+ */
44
+ readHead?: (args: {
45
+ chain: ChainClient;
46
+ }) => Promise<{
47
+ block: number;
48
+ }>;
49
+ /** The weight rule's memo, handed to it as `ctx.cache` (rules/cache.ts). */
50
+ ruleCache?: RuleCache;
37
51
  /**
38
52
  * Hash of the current bucket boundary block on the criteria's chain, for the rolling tie
39
53
  * seed. Invoked at most once per `compute`, and only when a tie must actually be broken —
@@ -1,6 +1,8 @@
1
1
  import { base58btc } from "multiformats/bases/base58";
2
2
  import { sha256 } from "multiformats/hashes/sha2";
3
3
  import { tickerForRef } from "../chain/ticker.js";
4
+ import { makeMemoryRuleCache } from "../rules/cache.js";
5
+ import { scoreOrZero } from "../rules/result.js";
4
6
  import { UnknownRuleError } from "../errors.js";
5
7
  /** Byte-lexicographic compare of two byte arrays (returns <0, 0, >0). */
6
8
  function compareBytes(x, y) {
@@ -15,20 +17,34 @@ function compareBytes(x, y) {
15
17
  }
16
18
  export function makeTally(deps) {
17
19
  const { criteria, registry, chainFor, bucketMath, current, bucketBlockHash } = deps;
20
+ const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
18
21
  // Resolve the weight rule, its options, and its chain once (see verify/bundle.ts).
19
22
  const weight = registry[criteria.weight.type];
20
23
  if (!weight)
21
24
  throw new UnknownRuleError("weight", criteria.weight.type);
22
25
  const weightOptions = weight.optionsSchema.parse(criteria.weight);
23
26
  const weightChain = chainFor(tickerForRef(criteria, criteria.weight, weightOptions));
27
+ // The weight rule picks its own block exactly as the gate rule does (see rules/types.ts):
28
+ // it is handed the bundle's pinned sample block and this verifier's head, and reads whichever
29
+ // it needs. The tally never asks what kind of rule it is holding.
30
+ //
31
+ // `deps.ruleCache` MUST be namespaced by the weight rule's OWN chain, not the gating chain —
32
+ // `weightChain` here resolves through `criteria.weight`'s ticker, which may name a different
33
+ // entry of `requires.chains` (the voter derives it that way; see client/voter.ts).
34
+ const weightCtx = {
35
+ chain: weightChain,
36
+ head: () => readHead({ chain: weightChain }),
37
+ cache: deps.ruleCache ?? makeMemoryRuleCache()
38
+ };
24
39
  const weightFor = async (wallet, blockNumber) => {
25
40
  const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(blockNumber));
26
- const { score } = await weight.evaluate({
41
+ // A weight rule that fails a wallet contributes nothing; it does NOT drop the vote —
42
+ // admission was the gate's decision, already made (see rules/result.ts).
43
+ return scoreOrZero(await weight.evaluate({
27
44
  options: weightOptions,
28
- walletAddress: wallet,
29
- ctx: { chain: weightChain, blockNumber: sampleBlock }
30
- });
31
- return score;
45
+ wallet: { address: wallet, sampleBlock },
46
+ ctx: weightCtx
47
+ }));
32
48
  };
33
49
  /** The rolling tie seed for a community: sha256(bucketBlockHash ‖ publicKey bytes). */
34
50
  const tieSeed = async (blockHash, publicKey) => {
@@ -116,7 +116,10 @@ export async function makeVoteNode(topic, options = {}) {
116
116
  let verifyImpl = async () => okVerifier();
117
117
  const verifier = {
118
118
  verify: (bundle) => verifyImpl(bundle),
119
- verifyOffline: (bundle) => verifyImpl(bundle)
119
+ verifyOffline: (bundle) => verifyImpl(bundle),
120
+ // These harness nodes drive the transport, not the eligibility surface; nothing here
121
+ // calls checkGate, so it admits rather than pretending to model a gate.
122
+ checkGate: async () => ({ success: true, score: 1n })
120
123
  };
121
124
  const admit = async ({ cid, bytes }) => {
122
125
  await blockstore.put(cid, bytes);
@@ -3,7 +3,7 @@ import type { VotesBundle } from "../schema/votes.js";
3
3
  import type { Criteria } from "../schema/criteria.js";
4
4
  import type { RuleRegistry } from "../rules/types.js";
5
5
  import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
6
- import type { GateResultCache } from "./gate-result-cache.js";
6
+ import { type RuleCache } from "../rules/cache.js";
7
7
  import { type NameResolutionCache } from "./name-resolution-cache.js";
8
8
  import type { VerdictCache } from "./cache.js";
9
9
  import type { VerifyFail } from "./types.js";
@@ -16,16 +16,25 @@ import type { VerifyFail } from "./types.js";
16
16
  * `chainVerified: false` rows, and this verifier confirms or evicts in the background (see
17
17
  * DESIGN.md "Background chain verification").
18
18
  *
19
- * Batched, not sequential: a checkpoint's bundles share one bucket sample block, so the gate
20
- * stage groups pending wallets per sample block and prefers the rule's `evaluateMany` (one
21
- * multicall3 round trip for N wallets) over N serial `readContract` calls, falling back to
22
- * `limit`-bounded per-wallet reads for rules without a batched form. Results feed the shared
23
- * `(wallet, sampleBlock)` gate-result cache, and a settled bundle's terminal verdict feeds the
24
- * shared per-CID verdict cache so a later re-publish of the same bundle short-circuits at
25
- * the gossip gate with zero chain work.
19
+ * Batched, not sequential: the gate stage hands a whole round's pending wallets to the rule's
20
+ * `evaluateMany` (one multicall3 round trip for N wallets) rather than making N serial
21
+ * `readContract` calls, falling back to `limit`-bounded per-wallet `evaluate` for rules without
22
+ * a batched form. It does NOT group them by block which block each wallet is read at is the
23
+ * rule's business now, and a rule that needs grouping does it itself (see rules/types.ts).
24
+ * Deduping and memoizing reads is likewise the rule's, through the cache it is handed
25
+ * (rules/cache.ts), so a wallet settled by the forward gate costs nothing here; what this stage
26
+ * still owns is the per-CID verdict cache, so a later re-publish of a settled bundle
27
+ * short-circuits at the gossip gate with zero chain work.
26
28
  *
27
29
  * Failure classes are kept apart, mirroring the forward-gate's `reject`/`ignore` split:
28
- * - gate scores `0n` → EVICT + cache the provable `reject` (deterministic).
30
+ * - gate `0n`, blamed on the sender → EVICT + cache the `reject` (the rule stands behind it:
31
+ * every honest verifier computes the same score, so terminal).
32
+ * - gate `0n`, blamed on nobody → "not yet", NOT "no": the item is re-examined until a grace
33
+ * window closes (verify/gate-grace.ts), then evicted
34
+ * `ignore`-class and uncached. A wallet that acquired the gate
35
+ * asset seconds ago scores `0n` only for whoever's head lags,
36
+ * so evicting on the spot would let RPC lag decide whether a
37
+ * vote counts.
29
38
  * - name missing/mismatched → EVICT, NOT cached (view-dependent `ignore`-class: v1
30
39
  * resolves at head — see verify/bundle.ts step 4).
31
40
  * - RPC / resolver THREW → infra, nobody's verdict: the bundle STAYS pending, the
@@ -46,8 +55,22 @@ export interface BackgroundVerifierDeps {
46
55
  chainFor: (ticker: string) => ChainClient;
47
56
  bucketMath: BucketMath;
48
57
  nameResolvers: NameResolver[];
49
- /** Shared `(wallet, sampleBlock)` gate scores — batch results land here, hits skip the read. */
50
- gateResultCache: GateResultCache;
58
+ /**
59
+ * The gate rule's memo, handed to it as `ctx.cache` (rules/cache.ts). Shared with the inline
60
+ * forward-gate verifier, so neither re-reads what the other settled.
61
+ */
62
+ ruleCache?: RuleCache;
63
+ /**
64
+ * This verifier's current head, handed to the rule as `ctx.head`. Resolved by the rule at
65
+ * most once per batch, so a round stays batchable. Never called by a rule that scores pinned
66
+ * historical state. Defaults to the rule chain's own `getBlockNumber()`; the voter injects
67
+ * its coalesced reader.
68
+ */
69
+ readHead?: (args: {
70
+ chain: ChainClient;
71
+ }) => Promise<{
72
+ block: number;
73
+ }>;
51
74
  /** Shared persistent name-resolution cache (pkc-js rule, 1h max-age); omitted ⇒ resolve live. */
52
75
  nameResolutionCache?: NameResolutionCache;
53
76
  /** The gate's per-CID verdict cache — a settled bundle's terminal verdict is stored here. */
@@ -65,6 +88,14 @@ export interface BackgroundVerifierDeps {
65
88
  /** Infra-retry backoff base / cap (ms). Full-jittered exponential between rounds. */
66
89
  retryBaseMs?: number;
67
90
  retryCapMs?: number;
91
+ /**
92
+ * Grace / re-examination interval (ms) — how long a `0n` the rule blamed on nobody is
93
+ * treated as "not yet" before the bundle is dropped, and how often it is looked at in the
94
+ * meantime. Defaults to {@link GATE_GRACE_MS} / {@link GATE_RETRY_MS}; overridable so tests
95
+ * do not sit through the real window. Unused when the rule blames the sender.
96
+ */
97
+ gateGraceMs?: number;
98
+ gateRetryMs?: number;
68
99
  }
69
100
  export interface BackgroundChainVerifier {
70
101
  /** Queue provisionally admitted bundles and return immediately; the drain runs detached. */