@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.
- package/README.md +51 -11
- package/dist/chain/types.d.ts +10 -9
- package/dist/client/voter.d.ts +76 -16
- package/dist/client/voter.js +82 -66
- package/dist/errors.d.ts +6 -8
- package/dist/errors.js +8 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/rules/erc20-balance.d.ts +0 -1
- package/dist/rules/erc20-balance.js +0 -2
- package/dist/rules/erc5192-min-balance.d.ts +0 -1
- package/dist/rules/erc5192-min-balance.js +0 -2
- package/dist/rules/erc721-min-balance.d.ts +0 -1
- package/dist/rules/erc721-min-balance.js +0 -2
- package/dist/rules/gate.d.ts +164 -0
- package/dist/rules/gate.js +209 -0
- package/dist/rules/registry.d.ts +11 -5
- package/dist/rules/registry.js +18 -9
- package/dist/schema/common.d.ts +5 -5
- package/dist/schema/common.js +5 -5
- package/dist/schema/criteria.d.ts +58 -29
- package/dist/schema/criteria.js +123 -23
- package/dist/signer/eip712.d.ts +2 -2
- package/dist/signer/eip712.js +1 -1
- package/dist/tally/tally.d.ts +2 -1
- package/dist/tally/tally.js +10 -14
- package/dist/tally/types.d.ts +1 -1
- package/dist/transport/integration/harness.js +3 -3
- package/dist/verify/background.d.ts +8 -6
- package/dist/verify/background.js +57 -42
- package/dist/verify/bundle.d.ts +12 -10
- package/dist/verify/bundle.js +69 -38
- package/dist/verify/types.d.ts +37 -16
- package/package.json +1 -1
- package/dist/chain/ticker.d.ts +0 -15
- package/dist/chain/ticker.js +0 -25
|
@@ -19,7 +19,7 @@ import { z } from "zod";
|
|
|
19
19
|
export declare const VoteRangeSchema: z.ZodObject<{
|
|
20
20
|
min: z.ZodNumber;
|
|
21
21
|
max: z.ZodNumber;
|
|
22
|
-
}, z.core.$
|
|
22
|
+
}, z.core.$strict>;
|
|
23
23
|
/**
|
|
24
24
|
* A reference to a rule by `type`, plus rule-specific options.
|
|
25
25
|
* Kept loose on purpose: each rule owns and validates its own option schema
|
|
@@ -31,60 +31,89 @@ export declare const RuleRefSchema: z.ZodObject<{
|
|
|
31
31
|
type: z.ZodString;
|
|
32
32
|
}, z.core.$loose>;
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* The gate: a boolean tree over rule references, deciding who may vote.
|
|
35
|
+
*
|
|
36
|
+
* A leaf wraps one {@link RuleRefSchema}; `all` and `any` compose leaves into conjunction and
|
|
37
|
+
* disjunction, so a contest can require "holds the Pass AND is not on the deny list", or "holds
|
|
38
|
+
* the Pass OR is a moderator", without either rule knowing the other exists. Composition is the
|
|
39
|
+
* document's business, never a rule's: a rule still answers exactly one question about one wallet
|
|
40
|
+
* (see rules/types.ts), and `rules/gate.ts` folds the answers.
|
|
35
41
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* (`PubsubVoterOptions.chains` maps ticker/chainId to a client), and two honest verifiers
|
|
40
|
-
* reading the same pinned block through different gateways compute identical results. Keeping
|
|
41
|
-
* URLs out means an operator can swap a dead RPC provider without changing the document's
|
|
42
|
-
* bytes — i.e. without forking the topic and orphaning the contest's votes.
|
|
42
|
+
* The leaf is WRAPPED (`{ rule: { type, ... } }`) rather than bare because {@link RuleRefSchema}
|
|
43
|
+
* is loose by design — a custom rule may carry an option named `all` or `any`, which would make a
|
|
44
|
+
* bare leaf structurally ambiguous with a branch exactly when someone writes such a rule.
|
|
43
45
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* Canonicity constraints, all of them load-bearing rather than stylistic. The topic is the CID of
|
|
47
|
+
* these bytes, so any document that differs in bytes but not in meaning is a silent topic FORK —
|
|
48
|
+
* two peers running identical rules on two topics, each invisible to the other:
|
|
49
|
+
* - a branch needs at least TWO children, so `{ all: [X] }` cannot exist alongside `X`;
|
|
50
|
+
* - a branch may not REPEAT a child (compared by canonical bytes, so two leaves of one rule type
|
|
51
|
+
* on different options stay distinct requirements) — a repeat among siblings says nothing the
|
|
52
|
+
* shorter tree does not. Across BRANCHES a rule may repeat, deliberately: that is how a gate
|
|
53
|
+
* expresses a requirement no repetition-free tree can ("any two of these three" is
|
|
54
|
+
* `{ any: [{ all: [A, B] }, { all: [A, C] }, { all: [B, C] }] }`). The price is that some
|
|
55
|
+
* redundant spellings survive — `{ all: [{ any: [A, B] }, A] }` is `A` by absorption — so a
|
|
56
|
+
* leaf's identity is NOT unique within a gate, and its position is what identifies it
|
|
57
|
+
* (`EligibilityCheck.leaf`);
|
|
58
|
+
* - a branch may not nest a branch of its OWN kind: `{ all: [{ all: [A, B] }, C] }` admits,
|
|
59
|
+
* scores, blames and penalizes exactly as `{ all: [A, B, C] }` does, since min and `some` are
|
|
60
|
+
* associative, so the nesting carries no meaning and only new bytes;
|
|
61
|
+
* - depth is capped at {@link MAX_GATE_DEPTH} and leaves at {@link MAX_GATE_LEAVES}, because a
|
|
62
|
+
* criteria document is attacker-supplied input that every peer parses and evaluates.
|
|
63
|
+
*
|
|
64
|
+
* Child ORDER is deliberately significant rather than normalized away: it is what the lazy forward
|
|
65
|
+
* gate evaluates in, so it decides which rule's chain read is paid first and the order failures are
|
|
66
|
+
* reported. Two orderings are two documents, and an author picks the one that reads best.
|
|
48
67
|
*/
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
}
|
|
68
|
+
export interface GateLeaf {
|
|
69
|
+
rule: RuleRef;
|
|
70
|
+
}
|
|
71
|
+
export interface GateAll {
|
|
72
|
+
all: GateNode[];
|
|
73
|
+
}
|
|
74
|
+
export interface GateAny {
|
|
75
|
+
any: GateNode[];
|
|
76
|
+
}
|
|
77
|
+
export type GateNode = GateLeaf | GateAll | GateAny;
|
|
78
|
+
/** Maximum nesting depth of the gate tree (a leaf alone is depth 1). */
|
|
79
|
+
export declare const MAX_GATE_DEPTH = 4;
|
|
80
|
+
/** Maximum number of rule references in one gate tree. */
|
|
81
|
+
export declare const MAX_GATE_LEAVES = 8;
|
|
82
|
+
export declare const GateSchema: z.ZodPipe<z.ZodUnknown, z.ZodType<GateNode, unknown, z.core.$ZodTypeInternals<GateNode, unknown>>>;
|
|
52
83
|
/**
|
|
53
84
|
* The dependency manifest. A client reads this on join and checks that it
|
|
54
85
|
* implements every named rule; if not, it is too old and must recuse
|
|
55
86
|
* itself rather than miscount. This is how criteria upgrades fork cleanly.
|
|
87
|
+
*
|
|
88
|
+
* Strict for the same reason the top level is: the topic is derived from the PARSED document, so
|
|
89
|
+
* an unknown key must fail loudly rather than be stripped — a stripping schema would drop it and
|
|
90
|
+
* derive a different topic from the one the author's bytes imply. That is what makes a document
|
|
91
|
+
* still carrying the pre-`bucketChainId` `chains` map (or the even older `rpcUrls`) an error
|
|
92
|
+
* instead of a silent re-topic.
|
|
56
93
|
*/
|
|
57
94
|
export declare const RequiresSchema: z.ZodObject<{
|
|
58
95
|
rules: z.ZodArray<z.ZodString>;
|
|
59
|
-
|
|
60
|
-
chainId: z.ZodNumber;
|
|
61
|
-
}, z.core.$strict>>;
|
|
62
|
-
}, z.core.$strip>;
|
|
96
|
+
}, z.core.$strict>;
|
|
63
97
|
export declare const CriteriaSchema: z.ZodObject<{
|
|
64
98
|
name: z.ZodString;
|
|
65
99
|
contestId: z.ZodString;
|
|
66
100
|
voteSchema: z.ZodObject<{
|
|
67
101
|
min: z.ZodNumber;
|
|
68
102
|
max: z.ZodNumber;
|
|
69
|
-
}, z.core.$
|
|
103
|
+
}, z.core.$strict>;
|
|
70
104
|
maxVotesPerAddress: z.ZodNumber;
|
|
105
|
+
bucketChainId: z.ZodNumber;
|
|
71
106
|
blocksPerBucket: z.ZodNumber;
|
|
72
107
|
voteExpiryBuckets: z.ZodNumber;
|
|
73
|
-
|
|
74
|
-
type: z.ZodString;
|
|
75
|
-
}, z.core.$loose>;
|
|
108
|
+
gate: z.ZodPipe<z.ZodUnknown, z.ZodType<GateNode, unknown, z.core.$ZodTypeInternals<GateNode, unknown>>>;
|
|
76
109
|
weight: z.ZodObject<{
|
|
77
110
|
type: z.ZodString;
|
|
78
111
|
}, z.core.$loose>;
|
|
79
112
|
requires: z.ZodObject<{
|
|
80
113
|
rules: z.ZodArray<z.ZodString>;
|
|
81
|
-
|
|
82
|
-
chainId: z.ZodNumber;
|
|
83
|
-
}, z.core.$strict>>;
|
|
84
|
-
}, z.core.$strip>;
|
|
114
|
+
}, z.core.$strict>;
|
|
85
115
|
}, z.core.$strict>;
|
|
86
116
|
export type VoteRange = z.infer<typeof VoteRangeSchema>;
|
|
87
117
|
export type RuleRef = z.infer<typeof RuleRefSchema>;
|
|
88
|
-
export type ChainConfig = z.infer<typeof ChainConfigSchema>;
|
|
89
118
|
export type Requires = z.infer<typeof RequiresSchema>;
|
|
90
119
|
export type Criteria = z.infer<typeof CriteriaSchema>;
|
package/dist/schema/criteria.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { encodeCanonical } from "../encoding/canonical.js";
|
|
3
3
|
/**
|
|
4
4
|
* The criteria document.
|
|
5
5
|
*
|
|
@@ -17,7 +17,7 @@ import { ChainTickerSchema } from "./common.js";
|
|
|
17
17
|
* a non-canonical change silently changes the topic.
|
|
18
18
|
*/
|
|
19
19
|
/** Inclusive numeric bounds for a single `vote` value. v1 is { min: 1, max: 1 }. */
|
|
20
|
-
export const VoteRangeSchema = z.
|
|
20
|
+
export const VoteRangeSchema = z.strictObject({
|
|
21
21
|
min: z.number().int(),
|
|
22
22
|
max: z.number().int()
|
|
23
23
|
});
|
|
@@ -31,33 +31,108 @@ export const VoteRangeSchema = z.object({
|
|
|
31
31
|
export const RuleRefSchema = z.looseObject({
|
|
32
32
|
type: z.string().min(1)
|
|
33
33
|
});
|
|
34
|
+
/** Maximum nesting depth of the gate tree (a leaf alone is depth 1). */
|
|
35
|
+
export const MAX_GATE_DEPTH = 4;
|
|
36
|
+
/** Maximum number of rule references in one gate tree. */
|
|
37
|
+
export const MAX_GATE_LEAVES = 8;
|
|
38
|
+
const GateNodeSchema = z.lazy(() => z.union([
|
|
39
|
+
z.strictObject({ rule: RuleRefSchema }),
|
|
40
|
+
z.strictObject({ all: z.array(GateNodeSchema).min(2) }),
|
|
41
|
+
z.strictObject({ any: z.array(GateNodeSchema).min(2) })
|
|
42
|
+
]));
|
|
43
|
+
/** Depth (a leaf is 1), leaf count, and the redundant spellings, in one walk. */
|
|
44
|
+
function gateShape(node) {
|
|
45
|
+
if ("rule" in node)
|
|
46
|
+
return { depth: 1, leaves: 1, redundant: undefined };
|
|
47
|
+
const kind = "all" in node ? "all" : "any";
|
|
48
|
+
const children = "all" in node ? node.all : node.any;
|
|
49
|
+
let depth = 0;
|
|
50
|
+
let leaves = 0;
|
|
51
|
+
let redundant;
|
|
52
|
+
// Canonical bytes are the identity: two children that encode identically ARE the same
|
|
53
|
+
// requirement, however differently they were written. Siblings only — a rule repeated in
|
|
54
|
+
// another branch is how a gate expresses "any two of these three".
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (const child of children) {
|
|
57
|
+
const shape = gateShape(child);
|
|
58
|
+
depth = Math.max(depth, shape.depth);
|
|
59
|
+
leaves += shape.leaves;
|
|
60
|
+
redundant ??= shape.redundant;
|
|
61
|
+
if (kind in child) {
|
|
62
|
+
redundant ??= `a \`${kind}\` nested directly inside an \`${kind}\` says nothing its parent does not; inline its children`;
|
|
63
|
+
}
|
|
64
|
+
const bytes = bytesToHex(encodeCanonical(child));
|
|
65
|
+
if (seen.has(bytes))
|
|
66
|
+
redundant ??= `a \`${kind}\` repeats one of its children; drop the duplicate`;
|
|
67
|
+
seen.add(bytes);
|
|
68
|
+
}
|
|
69
|
+
return { depth: depth + 1, leaves, redundant };
|
|
70
|
+
}
|
|
71
|
+
const bytesToHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
34
72
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* reading the same pinned block through different gateways compute identical results. Keeping
|
|
42
|
-
* URLs out means an operator can swap a dead RPC provider without changing the document's
|
|
43
|
-
* bytes — i.e. without forking the topic and orphaning the contest's votes.
|
|
44
|
-
*
|
|
45
|
-
* Strict on purpose: the topic is derived from the PARSED document, so an unknown key must
|
|
46
|
-
* fail loudly here — a plain (stripping) object would silently drop it and derive a different
|
|
47
|
-
* topic than the author's raw document implies. This also makes pre-v1 documents that still
|
|
48
|
-
* carry `rpcUrls` a loud error instead of a silent re-topic.
|
|
73
|
+
* Structural bounds, checked on the RAW value before the recursive schema ever descends into it.
|
|
74
|
+
* {@link GateNodeSchema} is `z.lazy` and {@link gateShape} recurses, so a pathological document
|
|
75
|
+
* overflows the stack long before any cap can fire — and a `RangeError` escaping `safeParse`
|
|
76
|
+
* breaks the one guarantee that call makes. This walk is iterative and stops at the first node
|
|
77
|
+
* past a bound; anything within them it passes through untouched, so the real schema still
|
|
78
|
+
* produces the precise error for an ordinary authoring mistake.
|
|
49
79
|
*/
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
const MAX_GATE_NODES = MAX_GATE_LEAVES * 2;
|
|
81
|
+
function checkGateBounds(raw, ctx) {
|
|
82
|
+
const stack = [{ node: raw, depth: 1 }];
|
|
83
|
+
let nodes = 0;
|
|
84
|
+
while (stack.length > 0) {
|
|
85
|
+
const { node, depth } = stack.pop();
|
|
86
|
+
if (depth > MAX_GATE_DEPTH) {
|
|
87
|
+
ctx.addIssue({ code: "custom", message: `gate tree is more than ${MAX_GATE_DEPTH} levels deep` });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
nodes += 1;
|
|
91
|
+
if (nodes > MAX_GATE_NODES) {
|
|
92
|
+
ctx.addIssue({ code: "custom", message: `gate tree has more than ${MAX_GATE_NODES} nodes` });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (typeof node !== "object" || node === null)
|
|
96
|
+
continue; // not a node; the schema says so
|
|
97
|
+
const branch = node;
|
|
98
|
+
const children = Array.isArray(branch.all) ? branch.all : branch.any;
|
|
99
|
+
if (!Array.isArray(children))
|
|
100
|
+
continue;
|
|
101
|
+
for (const child of children)
|
|
102
|
+
stack.push({ node: child, depth: depth + 1 });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export const GateSchema = z
|
|
106
|
+
.unknown()
|
|
107
|
+
.superRefine(checkGateBounds)
|
|
108
|
+
.pipe(GateNodeSchema)
|
|
109
|
+
.superRefine((node, ctx) => {
|
|
110
|
+
const { depth, leaves, redundant } = gateShape(node);
|
|
111
|
+
if (depth > MAX_GATE_DEPTH) {
|
|
112
|
+
ctx.addIssue({ code: "custom", message: `gate tree is ${depth} levels deep; the maximum is ${MAX_GATE_DEPTH}` });
|
|
113
|
+
}
|
|
114
|
+
if (leaves > MAX_GATE_LEAVES) {
|
|
115
|
+
ctx.addIssue({ code: "custom", message: `gate tree names ${leaves} rules; the maximum is ${MAX_GATE_LEAVES}` });
|
|
116
|
+
}
|
|
117
|
+
// Every redundant spelling is a topic fork waiting to happen: it means the same thing as a
|
|
118
|
+
// shorter tree while encoding to different bytes, so two authors expressing one contest can
|
|
119
|
+
// land on two topics. Same reason a branch may not have a single child.
|
|
120
|
+
if (redundant !== undefined)
|
|
121
|
+
ctx.addIssue({ code: "custom", message: `gate tree has a redundant spelling: ${redundant}` });
|
|
52
122
|
});
|
|
53
123
|
/**
|
|
54
124
|
* The dependency manifest. A client reads this on join and checks that it
|
|
55
125
|
* implements every named rule; if not, it is too old and must recuse
|
|
56
126
|
* itself rather than miscount. This is how criteria upgrades fork cleanly.
|
|
127
|
+
*
|
|
128
|
+
* Strict for the same reason the top level is: the topic is derived from the PARSED document, so
|
|
129
|
+
* an unknown key must fail loudly rather than be stripped — a stripping schema would drop it and
|
|
130
|
+
* derive a different topic from the one the author's bytes imply. That is what makes a document
|
|
131
|
+
* still carrying the pre-`bucketChainId` `chains` map (or the even older `rpcUrls`) an error
|
|
132
|
+
* instead of a silent re-topic.
|
|
57
133
|
*/
|
|
58
|
-
export const RequiresSchema = z.
|
|
59
|
-
rules: z.array(z.string().min(1)).nonempty()
|
|
60
|
-
chains: z.record(ChainTickerSchema, ChainConfigSchema)
|
|
134
|
+
export const RequiresSchema = z.strictObject({
|
|
135
|
+
rules: z.array(z.string().min(1)).nonempty()
|
|
61
136
|
});
|
|
62
137
|
export const CriteriaSchema = z
|
|
63
138
|
.object({
|
|
@@ -76,12 +151,37 @@ export const CriteriaSchema = z
|
|
|
76
151
|
* always allowed as withdrawal/abstention regardless of this cap.
|
|
77
152
|
*/
|
|
78
153
|
maxVotesPerAddress: z.number().int().positive(),
|
|
154
|
+
/**
|
|
155
|
+
* The chain whose blocks this contest counts in, by numeric chain id.
|
|
156
|
+
*
|
|
157
|
+
* The contest has exactly ONE clock, and this names it: `blocksPerBucket` and
|
|
158
|
+
* `voteExpiryBuckets` are measured in its blocks, a ballot's `blockNumber` and the
|
|
159
|
+
* `sampleBlock` every rule is handed are numbers on it, the tie-break seed is the hash of
|
|
160
|
+
* its bucket boundary block, and its id is bound into every EIP-712 ballot domain.
|
|
161
|
+
*
|
|
162
|
+
* It is a chain ID rather than a ticker because that is the identity the signature domain
|
|
163
|
+
* already carries — a ticker is a label local to a document, and two documents spelling
|
|
164
|
+
* one chain differently would be two topics for one contest. Rules do not name a chain at
|
|
165
|
+
* all: they read this one. Gating across several chains is future work, and needs an
|
|
166
|
+
* answer for what block a rule on a SECOND chain is handed before it can ship — see
|
|
167
|
+
* DESIGN.md "Open questions".
|
|
168
|
+
*
|
|
169
|
+
* RPC endpoints are deliberately not part of the criteria: which gateway a client trusts
|
|
170
|
+
* is client-local configuration (`PubsubVoterOptions.chains` maps this id to a client),
|
|
171
|
+
* and two honest verifiers reading the same pinned block through different gateways
|
|
172
|
+
* compute identical results. That keeps an operator's dead-RPC swap from forking the
|
|
173
|
+
* topic and orphaning the contest's votes.
|
|
174
|
+
*/
|
|
175
|
+
bucketChainId: z.number().int().positive(),
|
|
79
176
|
/** Block bucket size; all verifiers price the same block per bucket. */
|
|
80
177
|
blocksPerBucket: z.number().int().positive(),
|
|
81
178
|
/** How many buckets a bundle stays valid after its blockNumber. */
|
|
82
179
|
voteExpiryBuckets: z.number().int().positive(),
|
|
83
|
-
/**
|
|
84
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Who may vote (gates a wallet in or out): one rule, or a boolean tree of them.
|
|
182
|
+
* A single-rule gate is spelled `{ rule: { type, ... } }` — see {@link GateSchema}.
|
|
183
|
+
*/
|
|
184
|
+
gate: GateSchema,
|
|
85
185
|
/** How much an eligible vote counts. */
|
|
86
186
|
weight: RuleRefSchema,
|
|
87
187
|
/** Dependency manifest + version negotiation. */
|
package/dist/signer/eip712.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { Vote } from "../schema/votes.js";
|
|
|
17
17
|
* - `votes`: each community (`{ name, publicKey }`) + numeric vote.
|
|
18
18
|
* - `blockNumber`: the LWW key and the bucketized block every verifier reads at.
|
|
19
19
|
*
|
|
20
|
-
* The `domain.chainId` is the
|
|
20
|
+
* The `domain.chainId` is the contest's `bucketChainId`, giving cross-chain/cross-app domain
|
|
21
21
|
* separation for free. This module is pure: no key material, no network, no viem import
|
|
22
22
|
* — it only shapes the object both sides feed to viem.
|
|
23
23
|
*/
|
|
@@ -96,7 +96,7 @@ export interface BallotTypedData {
|
|
|
96
96
|
export declare function ballotTypedData(args: {
|
|
97
97
|
/** The criteria CID's raw binary bytes (`(await criteriaCid(criteria)).bytes`). */
|
|
98
98
|
criteriaCid: Uint8Array;
|
|
99
|
-
/** The
|
|
99
|
+
/** The chain the contest counts in (`criteria.bucketChainId`). */
|
|
100
100
|
chainId: number;
|
|
101
101
|
votes: Vote[];
|
|
102
102
|
blockNumber: number;
|
package/dist/signer/eip712.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - `votes`: each community (`{ name, publicKey }`) + numeric vote.
|
|
17
17
|
* - `blockNumber`: the LWW key and the bucketized block every verifier reads at.
|
|
18
18
|
*
|
|
19
|
-
* The `domain.chainId` is the
|
|
19
|
+
* The `domain.chainId` is the contest's `bucketChainId`, giving cross-chain/cross-app domain
|
|
20
20
|
* separation for free. This module is pure: no key material, no network, no viem import
|
|
21
21
|
* — it only shapes the object both sides feed to viem.
|
|
22
22
|
*/
|
package/dist/tally/tally.d.ts
CHANGED
|
@@ -25,7 +25,8 @@ import type { Tally } from "./types.js";
|
|
|
25
25
|
export interface TallyDeps {
|
|
26
26
|
criteria: Criteria;
|
|
27
27
|
registry: RuleRegistry;
|
|
28
|
-
|
|
28
|
+
/** The contest's one chain client — the weight rule reads it too (DESIGN.md "One clock"). */
|
|
29
|
+
chain: ChainClient;
|
|
29
30
|
bucketMath: BucketMath;
|
|
30
31
|
/**
|
|
31
32
|
* The CRDT's current bundles (one per wallet, LWW-resolved; empty-votes bundles are
|
package/dist/tally/tally.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { base58btc } from "multiformats/bases/base58";
|
|
2
2
|
import { sha256 } from "multiformats/hashes/sha2";
|
|
3
|
-
import { tickerForRef } from "../chain/ticker.js";
|
|
4
3
|
import { makeMemoryRuleCache } from "../rules/cache.js";
|
|
5
4
|
import { scoreOrZero } from "../rules/result.js";
|
|
6
5
|
import { UnknownRuleError } from "../errors.js";
|
|
@@ -16,24 +15,21 @@ function compareBytes(x, y) {
|
|
|
16
15
|
return x.length - y.length;
|
|
17
16
|
}
|
|
18
17
|
export function makeTally(deps) {
|
|
19
|
-
const { criteria, registry,
|
|
20
|
-
const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await
|
|
21
|
-
// Resolve the weight rule
|
|
18
|
+
const { criteria, registry, chain, bucketMath, current, bucketBlockHash } = deps;
|
|
19
|
+
const readHead = deps.readHead ?? (async ({ chain: client }) => ({ block: Number(await client.getBlockNumber()) }));
|
|
20
|
+
// Resolve the weight rule and its options once (see verify/bundle.ts).
|
|
22
21
|
const weight = registry[criteria.weight.type];
|
|
23
22
|
if (!weight)
|
|
24
23
|
throw new UnknownRuleError("weight", criteria.weight.type);
|
|
25
24
|
const weightOptions = weight.optionsSchema.parse(criteria.weight);
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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).
|
|
25
|
+
// The weight rule picks its own block exactly as a gate rule does (see rules/types.ts): it is
|
|
26
|
+
// handed the bundle's pinned sample block and this verifier's head, and reads whichever it
|
|
27
|
+
// needs. The tally never asks what kind of rule it is holding. It reads the contest's one
|
|
28
|
+
// chain — a weight rule on a second chain is the same open question as a gate leaf on one
|
|
29
|
+
// (DESIGN.md "Open questions").
|
|
34
30
|
const weightCtx = {
|
|
35
|
-
chain
|
|
36
|
-
head: () => readHead({ chain
|
|
31
|
+
chain,
|
|
32
|
+
head: () => readHead({ chain }),
|
|
37
33
|
cache: deps.ruleCache ?? makeMemoryRuleCache()
|
|
38
34
|
};
|
|
39
35
|
const weightFor = async (wallet, blockNumber) => {
|
package/dist/tally/types.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface CommunityTally {
|
|
|
25
25
|
/** Summed weight of upvotes counted so far, in rule score units (`bigint`). */
|
|
26
26
|
weight: bigint;
|
|
27
27
|
/**
|
|
28
|
-
* True once every bundle contributing to this row has had its gate
|
|
28
|
+
* True once every bundle contributing to this row has had its gate confirmed
|
|
29
29
|
* by an on-chain read at its bucket block. `false` means at least one contribution is still
|
|
30
30
|
* awaiting its background gate read — never that one failed (a failed gate evicts the
|
|
31
31
|
* bundle and recounts the row).
|
|
@@ -37,7 +37,7 @@ const CURRENT_BUCKET = 0;
|
|
|
37
37
|
const BLOCKS_PER_BUCKET = 43_200;
|
|
38
38
|
const VOTE_EXPIRY_BUCKETS = 30;
|
|
39
39
|
/** A permissive default verifier; individual tests swap it via {@link VoteNode.setVerifier}. */
|
|
40
|
-
const okVerifier = () => ({ valid: true,
|
|
40
|
+
const okVerifier = () => ({ valid: true, resolvedNames: {} });
|
|
41
41
|
/**
|
|
42
42
|
* One real loopback libp2p + Helia node carrying gossipsub (topic-scoped score params) and
|
|
43
43
|
* `@libp2p/fetch` — the raw host node with nothing else wired. {@link makeVoteNode} builds the
|
|
@@ -118,8 +118,8 @@ export async function makeVoteNode(topic, options = {}) {
|
|
|
118
118
|
verify: (bundle) => verifyImpl(bundle),
|
|
119
119
|
verifyOffline: (bundle) => verifyImpl(bundle),
|
|
120
120
|
// These harness nodes drive the transport, not the eligibility surface; nothing here
|
|
121
|
-
// calls
|
|
122
|
-
|
|
121
|
+
// calls checkGates, so it admits rather than pretending to model a gate.
|
|
122
|
+
checkGates: async () => ({ kind: "leaf", leaf: 0, satisfied: true, score: 1n, penalize: false })
|
|
123
123
|
};
|
|
124
124
|
const admit = async ({ cid, bytes }) => {
|
|
125
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 {
|
|
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";
|
|
@@ -52,18 +52,20 @@ export interface PendingBundle {
|
|
|
52
52
|
export interface BackgroundVerifierDeps {
|
|
53
53
|
criteria: Criteria;
|
|
54
54
|
registry: RuleRegistry;
|
|
55
|
-
|
|
55
|
+
/** The contest's one chain client — every rule reads it (DESIGN.md "One clock"). */
|
|
56
|
+
chain: ChainClient;
|
|
56
57
|
bucketMath: BucketMath;
|
|
57
58
|
nameResolvers: NameResolver[];
|
|
58
59
|
/**
|
|
59
|
-
*
|
|
60
|
-
* forward-gate verifier, so neither re-reads what
|
|
60
|
+
* One memo per gate leaf, in `gateLeaves` order, handed to each rule as `ctx.cache`
|
|
61
|
+
* (rules/cache.ts). Shared with the inline forward-gate verifier, so neither re-reads what
|
|
62
|
+
* the other settled.
|
|
61
63
|
*/
|
|
62
|
-
|
|
64
|
+
ruleCaches?: readonly RuleCache[];
|
|
63
65
|
/**
|
|
64
66
|
* This verifier's current head, handed to the rule as `ctx.head`. Resolved by the rule at
|
|
65
67
|
* most once per batch, so a round stays batchable. Never called by a rule that scores pinned
|
|
66
|
-
* historical state. Defaults to the
|
|
68
|
+
* historical state. Defaults to the contest chain's own `getBlockNumber()`; the voter injects
|
|
67
69
|
* its coalesced reader.
|
|
68
70
|
*/
|
|
69
71
|
readHead?: (args: {
|
|
@@ -1,31 +1,20 @@
|
|
|
1
|
-
import { tickerForRef } from "../chain/ticker.js";
|
|
2
|
-
import { makeMemoryRuleCache } from "../rules/cache.js";
|
|
3
1
|
import { GATE_GRACE_MS, GATE_RETRY_MS } from "./gate-grace.js";
|
|
4
|
-
import {
|
|
5
|
-
import { UnknownRuleError } from "../errors.js";
|
|
2
|
+
import { dedupeLeaves, evaluateGate, gateBlame, gatePenalize, gateReason, resolveGate } from "../rules/gate.js";
|
|
6
3
|
import { resolveNameThroughCache } from "./name-resolution-cache.js";
|
|
7
4
|
const RETRY_BASE_MS = 2_000;
|
|
8
5
|
const RETRY_CAP_MS = 60_000;
|
|
9
6
|
export function makeBackgroundVerifier(deps) {
|
|
10
|
-
const { criteria, registry,
|
|
7
|
+
const { criteria, registry, chain, bucketMath, nameResolvers, nameResolutionCache, cache, limit } = deps;
|
|
11
8
|
const retryBaseMs = deps.retryBaseMs ?? RETRY_BASE_MS;
|
|
12
9
|
const retryCapMs = deps.retryCapMs ?? RETRY_CAP_MS;
|
|
13
10
|
const gateGraceMs = deps.gateGraceMs ?? GATE_GRACE_MS;
|
|
14
11
|
const gateRetryMs = deps.gateRetryMs ?? GATE_RETRY_MS;
|
|
15
|
-
// Resolve
|
|
16
|
-
//
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const ruleOptions = rule.optionsSchema.parse(criteria.rule);
|
|
22
|
-
const ruleChain = chainFor(tickerForRef(criteria, criteria.rule, ruleOptions));
|
|
23
|
-
const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
|
|
24
|
-
const ctx = {
|
|
25
|
-
chain: ruleChain,
|
|
26
|
-
head: () => readHead({ chain: ruleChain }),
|
|
27
|
-
cache: deps.ruleCache ?? makeMemoryRuleCache()
|
|
28
|
-
};
|
|
12
|
+
// Resolve every gate leaf — rule, options, chain, memo — once (same call as verify/bundle.ts,
|
|
13
|
+
// so the two verifiers cannot resolve a gate differently).
|
|
14
|
+
const readHead = deps.readHead ?? (async ({ chain: client }) => ({ block: Number(await client.getBlockNumber()) }));
|
|
15
|
+
const leaves = resolveGate({ criteria, registry, chain, readHead, caches: deps.ruleCaches });
|
|
16
|
+
// Which leaves ask the same question: a rule named twice in one gate is batched once.
|
|
17
|
+
const { representatives, ofLeaf } = dedupeLeaves(leaves);
|
|
29
18
|
const queue = [];
|
|
30
19
|
/** CIDs queued or in-flight, so a re-chased root cannot double-verify a bundle. */
|
|
31
20
|
const inFlight = new Set();
|
|
@@ -49,15 +38,22 @@ export function makeBackgroundVerifier(deps) {
|
|
|
49
38
|
return bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
|
|
50
39
|
}
|
|
51
40
|
/**
|
|
52
|
-
* Gate stage for one round's batch: hand every not-yet-gated wallet to
|
|
53
|
-
* `evaluateMany` when
|
|
54
|
-
*
|
|
41
|
+
* Gate stage for one round's batch: hand every not-yet-gated wallet to EACH gate leaf at once
|
|
42
|
+
* — `evaluateMany` when the rule has one, `limit`-bounded per-wallet `evaluate` otherwise —
|
|
43
|
+
* then fold each item's leaf answers into its verdict.
|
|
44
|
+
*
|
|
45
|
+
* Note this deliberately does NOT short-circuit the tree the way the inline gate does. Here
|
|
46
|
+
* the axis of batching is the rule, not the wallet: one `evaluateMany` per leaf covers the
|
|
47
|
+
* whole round, so scoring every leaf costs one round trip per leaf no matter how many wallets
|
|
48
|
+
* are pending, while short-circuiting per wallet would fragment those batches into
|
|
49
|
+
* per-wallet reads. Collecting all is the cheaper shape on this path, and it is also what
|
|
50
|
+
* gives every item a complete blame set.
|
|
55
51
|
*
|
|
56
|
-
* No grouping and no cache lookups here
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
52
|
+
* No grouping and no cache lookups here: which block each wallet is read at, and what may be
|
|
53
|
+
* memoized under which key, are the rule's decisions (rules/types.ts, rules/cache.ts).
|
|
54
|
+
* Duplicate wallets are still collapsed before the calls, because that is a property of THIS
|
|
55
|
+
* batch rather than of any rule. Throws on the FIRST infra failure: the round's unfinished
|
|
56
|
+
* items are re-queued by the caller.
|
|
61
57
|
*/
|
|
62
58
|
async function gateStage(items) {
|
|
63
59
|
const pending = items.filter((item) => !item.gateDone);
|
|
@@ -73,14 +69,24 @@ export function makeBackgroundVerifier(deps) {
|
|
|
73
69
|
at.set(key, wallets.length);
|
|
74
70
|
wallets.push(wallet);
|
|
75
71
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
// One batched call per distinct QUESTION, those in parallel: each is a single multicall
|
|
73
|
+
// for the whole round, and the voter's per-client in-flight budget still bounds what
|
|
74
|
+
// reaches an RPC. A rule named twice in one gate ("any two of these three") is asked once
|
|
75
|
+
// and its answer fanned back out to both positions.
|
|
76
|
+
const byQuestion = await Promise.all(representatives.map((leaf) => {
|
|
77
|
+
const { rule, options, ctx } = leaves[leaf];
|
|
78
|
+
return rule.evaluateMany
|
|
79
|
+
? rule.evaluateMany({ options, wallets, ctx }).then(({ results }) => results)
|
|
80
|
+
: Promise.all(wallets.map((wallet) => limit(() => rule.evaluate({ options, wallet, ctx }))));
|
|
81
|
+
}));
|
|
79
82
|
for (const item of pending) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
item.
|
|
83
|
-
|
|
83
|
+
const wallet = at.get(`${item.bundle.address.toLowerCase()}:${sampleBlockFor(item.bundle)}`);
|
|
84
|
+
// Every leaf is already scored, so this "evaluation" reads the batch — no chain work.
|
|
85
|
+
item.gate = await evaluateGate({
|
|
86
|
+
node: criteria.gate,
|
|
87
|
+
evaluate: async (leaf) => byQuestion[ofLeaf[leaf]][wallet],
|
|
88
|
+
collectAll: true
|
|
89
|
+
});
|
|
84
90
|
item.gateDone = true;
|
|
85
91
|
}
|
|
86
92
|
}
|
|
@@ -144,20 +150,29 @@ export function makeBackgroundVerifier(deps) {
|
|
|
144
150
|
requeue.push(item); // gate read never happened (infra) — retry the whole item
|
|
145
151
|
continue;
|
|
146
152
|
}
|
|
147
|
-
if (item.
|
|
148
|
-
|
|
153
|
+
if (item.gate !== undefined && item.gate.satisfied !== true) {
|
|
154
|
+
// The rules' own wording, narrowed to the failures that explain the refusal, plus
|
|
155
|
+
// the recursive answer to "may this be blamed on the sender" (rules/gate.ts).
|
|
156
|
+
const failures = gateBlame(item.gate).map((leaf) => ({
|
|
157
|
+
type: leaves[leaf.leaf].ref.type,
|
|
158
|
+
error: leaf.error ?? ""
|
|
159
|
+
}));
|
|
160
|
+
const reason = gateReason(item.gate);
|
|
161
|
+
if (gatePenalize(item.gate)) {
|
|
149
162
|
// Provable, deterministic reject — safe to cache so a re-publish short-circuits.
|
|
150
163
|
const verdict = {
|
|
151
164
|
valid: false,
|
|
152
165
|
disposition: "reject",
|
|
153
|
-
reason: `not admitted: ${
|
|
166
|
+
reason: `not admitted: ${reason}`,
|
|
167
|
+
failures
|
|
154
168
|
};
|
|
155
169
|
cache.set(item.cid, verdict);
|
|
156
170
|
deps.onEvict(item.cid, verdict);
|
|
157
171
|
settle(item);
|
|
158
172
|
continue;
|
|
159
173
|
}
|
|
160
|
-
//
|
|
174
|
+
// No failure the gate will blame anyone for (see rules/types.ts, RuleResult.penalize,
|
|
175
|
+
// and rules/gate.ts for how that folds through `all`/`any`):
|
|
161
176
|
// the failure means "not yet", not "no". The wallet may have acquired the asset in
|
|
162
177
|
// a block this verifier has not seen, or may acquire it a moment from now — a
|
|
163
178
|
// client that signs the instant it mints races its own transaction. Evicting here
|
|
@@ -174,7 +189,8 @@ export function makeBackgroundVerifier(deps) {
|
|
|
174
189
|
const verdict = {
|
|
175
190
|
valid: false,
|
|
176
191
|
disposition: "ignore",
|
|
177
|
-
reason: `not admitted: ${
|
|
192
|
+
reason: `not admitted: ${reason} (still true after the grace window)`,
|
|
193
|
+
failures
|
|
178
194
|
};
|
|
179
195
|
deps.onEvict(item.cid, verdict);
|
|
180
196
|
settle(item);
|
|
@@ -198,7 +214,7 @@ export function makeBackgroundVerifier(deps) {
|
|
|
198
214
|
if (item.bundle.votes.some((v) => v.community.name))
|
|
199
215
|
deps.onNameResolved(item.cid);
|
|
200
216
|
// Fully settled: store the terminal valid verdict (same shape the forward-gate caches).
|
|
201
|
-
cache.set(item.cid, { valid: true,
|
|
217
|
+
cache.set(item.cid, { valid: true, resolvedNames: item.resolvedNames });
|
|
202
218
|
settle(item);
|
|
203
219
|
}
|
|
204
220
|
if (requeue.length > 0) {
|
|
@@ -260,8 +276,7 @@ export function makeBackgroundVerifier(deps) {
|
|
|
260
276
|
...entry,
|
|
261
277
|
gateDone: false,
|
|
262
278
|
gateNotified: false,
|
|
263
|
-
|
|
264
|
-
gateFailed: undefined,
|
|
279
|
+
gate: undefined,
|
|
265
280
|
resolvedNames: {},
|
|
266
281
|
queuedAt: Date.now()
|
|
267
282
|
});
|