@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.
- package/README.md +73 -6
- package/dist/client/voter.d.ts +37 -0
- package/dist/client/voter.js +67 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/rules/cache.d.ts +107 -0
- package/dist/rules/cache.js +160 -0
- package/dist/rules/constant.js +3 -1
- package/dist/rules/erc20-balance.js +15 -5
- package/dist/rules/erc5192-min-balance.d.ts +17 -9
- package/dist/rules/erc5192-min-balance.js +191 -37
- package/dist/rules/erc721-min-balance.js +46 -12
- package/dist/rules/nft-balance.d.ts +33 -6
- package/dist/rules/nft-balance.js +29 -13
- package/dist/rules/result.d.ts +16 -0
- package/dist/rules/result.js +30 -0
- package/dist/rules/types.d.ts +128 -32
- package/dist/tally/tally.d.ts +14 -0
- package/dist/tally/tally.js +21 -5
- package/dist/transport/integration/harness.js +4 -1
- package/dist/verify/background.d.ts +42 -11
- package/dist/verify/background.js +93 -38
- package/dist/verify/bundle.d.ts +24 -9
- package/dist/verify/bundle.js +45 -21
- package/dist/verify/gate-grace.d.ts +32 -0
- package/dist/verify/gate-grace.js +32 -0
- package/dist/verify/types.d.ts +14 -0
- package/package.json +2 -1
- package/dist/verify/gate-result-cache.d.ts +0 -65
- package/dist/verify/gate-result-cache.js +0 -91
|
@@ -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(
|
|
37
|
-
|
|
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
|
|
40
|
-
export function balanceOf(
|
|
41
|
-
|
|
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(
|
|
46
|
-
blockNumber: BigInt(
|
|
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
|
|
51
|
-
*
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/dist/rules/types.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
45
|
+
export type RuleResult = {
|
|
46
|
+
success: true;
|
|
47
|
+
/** The wallet's magnitude. MUST be `> 0n`. */
|
|
29
48
|
score: bigint;
|
|
30
|
-
}
|
|
31
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
/**
|
|
40
|
-
|
|
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
|
|
45
|
-
*
|
|
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
|
-
|
|
142
|
+
wallet: RuleWallet;
|
|
53
143
|
ctx: ChainReadContext;
|
|
54
144
|
}): Promise<RuleResult>;
|
|
55
145
|
/**
|
|
56
|
-
* Optional batched form of {@link evaluate}: score many wallets
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
159
|
+
wallets: RuleWallet[];
|
|
66
160
|
ctx: ChainReadContext;
|
|
67
|
-
}): Promise<
|
|
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
|
package/dist/tally/tally.d.ts
CHANGED
|
@@ -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 —
|
package/dist/tally/tally.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
29
|
-
ctx:
|
|
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
|
|
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:
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the
|
|
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
|
|
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
|
-
/**
|
|
50
|
-
|
|
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. */
|