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