@bitsocial/pubsub-voting 0.4.1 → 0.6.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 +89 -26
- package/dist/chain/types.d.ts +10 -9
- package/dist/client/voter.d.ts +175 -34
- package/dist/client/voter.js +368 -106
- package/dist/errors.d.ts +6 -12
- package/dist/errors.js +8 -19
- 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/chase.d.ts +8 -0
- package/dist/transport/chase.js +5 -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/errors.d.ts
CHANGED
|
@@ -15,26 +15,24 @@ export declare class NotImplementedError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
17
|
* Thrown when a criteria document names a rule `type` this client does not
|
|
18
|
-
* implement (in the `
|
|
18
|
+
* implement (in a `gate` leaf, the `weight` slot, or in `requires.rules`). A
|
|
19
19
|
* client that hits this is too old (or missing a host override) and must recuse
|
|
20
20
|
* itself from the contest rather than miscount. See DESIGN.md "Rules".
|
|
21
21
|
*/
|
|
22
22
|
export declare class UnknownRuleError extends Error {
|
|
23
|
-
readonly slot: "
|
|
23
|
+
readonly slot: "gate" | "weight" | "requires";
|
|
24
24
|
readonly type: string;
|
|
25
|
-
constructor(slot: "
|
|
25
|
+
constructor(slot: "gate" | "weight" | "requires", type: string);
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* Thrown by `createContest` / `createContestVote` when the
|
|
29
|
-
*
|
|
30
|
-
* client (it returned `undefined`). RPC endpoints are client-local settings, not part of
|
|
28
|
+
* Thrown by `createContest` / `createContestVote` when the host's `ChainClientFactory` cannot
|
|
29
|
+
* resolve the contest's `bucketChainId` to a client (it returned `undefined`). RPC endpoints are client-local settings, not part of
|
|
31
30
|
* the criteria document, so a client with no gateway configured for a required chain must
|
|
32
31
|
* recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
|
|
33
32
|
*/
|
|
34
33
|
export declare class MissingChainClientError extends Error {
|
|
35
|
-
readonly chain: string;
|
|
36
34
|
readonly chainId: number;
|
|
37
|
-
constructor(
|
|
35
|
+
constructor(chainId: number);
|
|
38
36
|
}
|
|
39
37
|
/**
|
|
40
38
|
* Thrown at construction when the injected Helia node's libp2p has no usable pubsub
|
|
@@ -89,10 +87,6 @@ export declare class DuplicateContestIdError extends Error {
|
|
|
89
87
|
readonly contestId: string;
|
|
90
88
|
constructor(contestId: string);
|
|
91
89
|
}
|
|
92
|
-
/** Thrown when a publish (vote/withdraw) is attempted on a voter constructed without a signer. */
|
|
93
|
-
export declare class ReadOnlyError extends Error {
|
|
94
|
-
constructor();
|
|
95
|
-
}
|
|
96
90
|
/**
|
|
97
91
|
* Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
|
|
98
92
|
* fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
|
package/dist/errors.js
CHANGED
|
@@ -16,7 +16,7 @@ export class NotImplementedError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* Thrown when a criteria document names a rule `type` this client does not
|
|
19
|
-
* implement (in the `
|
|
19
|
+
* implement (in a `gate` leaf, the `weight` slot, or in `requires.rules`). A
|
|
20
20
|
* client that hits this is too old (or missing a host override) and must recuse
|
|
21
21
|
* itself from the contest rather than miscount. See DESIGN.md "Rules".
|
|
22
22
|
*/
|
|
@@ -33,21 +33,18 @@ export class UnknownRuleError extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Thrown by `createContest` / `createContestVote` when the
|
|
37
|
-
*
|
|
38
|
-
* client (it returned `undefined`). RPC endpoints are client-local settings, not part of
|
|
36
|
+
* Thrown by `createContest` / `createContestVote` when the host's `ChainClientFactory` cannot
|
|
37
|
+
* resolve the contest's `bucketChainId` to a client (it returned `undefined`). RPC endpoints are client-local settings, not part of
|
|
39
38
|
* the criteria document, so a client with no gateway configured for a required chain must
|
|
40
39
|
* recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
|
|
41
40
|
*/
|
|
42
41
|
export class MissingChainClientError extends Error {
|
|
43
|
-
chain;
|
|
44
42
|
chainId;
|
|
45
|
-
constructor(
|
|
46
|
-
super(`No chain client for
|
|
47
|
-
`
|
|
48
|
-
`configure the \`chains\` factory (PubsubVoterOptions.chains) to
|
|
49
|
-
`PublicClient for this chain, or recuse this contest.`);
|
|
50
|
-
this.chain = chain;
|
|
43
|
+
constructor(chainId) {
|
|
44
|
+
super(`No chain client for chainId ${chainId}, which this contest counts its buckets in ` +
|
|
45
|
+
`(\`criteria.bucketChainId\`). RPC endpoints are client settings, not part of the ` +
|
|
46
|
+
`criteria document: configure the \`chains\` factory (PubsubVoterOptions.chains) to ` +
|
|
47
|
+
`return a viem PublicClient for this chain, or recuse this contest.`);
|
|
51
48
|
this.chainId = chainId;
|
|
52
49
|
this.name = "MissingChainClientError";
|
|
53
50
|
}
|
|
@@ -136,14 +133,6 @@ export class DuplicateContestIdError extends Error {
|
|
|
136
133
|
this.name = "DuplicateContestIdError";
|
|
137
134
|
}
|
|
138
135
|
}
|
|
139
|
-
/** Thrown when a publish (vote/withdraw) is attempted on a voter constructed without a signer. */
|
|
140
|
-
export class ReadOnlyError extends Error {
|
|
141
|
-
constructor() {
|
|
142
|
-
super("This voter is read-only: it was constructed without a `signer`. " +
|
|
143
|
-
"Provide a VoteSigner to publish or withdraw votes; reading tallies needs no signer.");
|
|
144
|
-
this.name = "ReadOnlyError";
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
136
|
/**
|
|
148
137
|
* Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
|
|
149
138
|
* fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export * from "./rules/erc721-min-balance.js";
|
|
|
20
20
|
export * from "./rules/constant.js";
|
|
21
21
|
export * from "./rules/registry.js";
|
|
22
22
|
export { makeMemoryRuleCache, makePersistentRuleCache, type RuleCache } from "./rules/cache.js";
|
|
23
|
+
export { gateLeaves } from "./rules/gate.js";
|
|
24
|
+
export type { GateResult, GateLeafResult } from "./rules/gate.js";
|
|
23
25
|
export * from "./encoding/canonical.js";
|
|
24
26
|
export * from "./topic.js";
|
|
25
27
|
export * from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,10 @@ export * from "./rules/registry.js";
|
|
|
38
38
|
// owns the store, the bound and the purge. A custom chain-reading rule MUST memoize through it —
|
|
39
39
|
// see rules/cache.ts and README "Custom rules".
|
|
40
40
|
export { makeMemoryRuleCache, makePersistentRuleCache } from "./rules/cache.js";
|
|
41
|
+
// The gate tree. `gateLeaves` enumerates a criteria's rules in document order without evaluating
|
|
42
|
+
// anything (labelling a requirements list, say); the fold itself is the library's, so composition
|
|
43
|
+
// cannot drift between the forward gate, the background verifier and `checkEligibility`.
|
|
44
|
+
export { gateLeaves } from "./rules/gate.js";
|
|
41
45
|
// Implemented runtime: encoding, topic, errors, identity seam, facade.
|
|
42
46
|
export * from "./encoding/canonical.js";
|
|
43
47
|
export * from "./topic.js";
|
|
@@ -28,7 +28,6 @@ import type { Rule } from "./types.js";
|
|
|
28
28
|
*/
|
|
29
29
|
export declare const Erc20BalanceOptionsSchema: z.ZodObject<{
|
|
30
30
|
type: z.ZodLiteral<"erc20-balance">;
|
|
31
|
-
chain: z.ZodString;
|
|
32
31
|
contract: z.ZodString;
|
|
33
32
|
decimals: z.ZodDefault<z.ZodNumber>;
|
|
34
33
|
min: z.ZodDefault<z.ZodNumber>;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { erc20Abi, formatUnits, getAddress, parseUnits } from "viem";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { ChainTickerSchema } from "../schema/common.js";
|
|
4
3
|
/**
|
|
5
4
|
* Score by ERC-20 balance (for example BSO). Reserved for the pass + BSO combo path.
|
|
6
5
|
*
|
|
@@ -29,7 +28,6 @@ import { ChainTickerSchema } from "../schema/common.js";
|
|
|
29
28
|
*/
|
|
30
29
|
export const Erc20BalanceOptionsSchema = z.object({
|
|
31
30
|
type: z.literal("erc20-balance"),
|
|
32
|
-
chain: ChainTickerSchema,
|
|
33
31
|
contract: z.string(),
|
|
34
32
|
decimals: z.number().int().nonnegative().default(18),
|
|
35
33
|
min: z.number().nonnegative().default(0)
|
|
@@ -41,7 +41,6 @@ import type { Rule } from "./types.js";
|
|
|
41
41
|
export declare const ERC5192_INTERFACE_ID: "0xb45a3c0e";
|
|
42
42
|
export declare const Erc5192MinBalanceOptionsSchema: z.ZodObject<{
|
|
43
43
|
type: z.ZodLiteral<"erc5192-min-balance">;
|
|
44
|
-
chain: z.ZodString;
|
|
45
44
|
contract: z.ZodString;
|
|
46
45
|
min: z.ZodDefault<z.ZodNumber>;
|
|
47
46
|
}, z.core.$strip>;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { BaseError, ContractFunctionRevertedError, ContractFunctionZeroDataError, getAddress } from "viem";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { ChainTickerSchema } from "../schema/common.js";
|
|
4
3
|
import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from "./nft-balance.js";
|
|
5
4
|
/**
|
|
6
5
|
* Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
|
|
@@ -52,7 +51,6 @@ const erc165Abi = [
|
|
|
52
51
|
];
|
|
53
52
|
export const Erc5192MinBalanceOptionsSchema = z.object({
|
|
54
53
|
type: z.literal("erc5192-min-balance"),
|
|
55
|
-
chain: ChainTickerSchema,
|
|
56
54
|
contract: z.string(),
|
|
57
55
|
min: z.number().int().positive().default(1)
|
|
58
56
|
});
|
|
@@ -22,7 +22,6 @@ import type { Rule } from "./types.js";
|
|
|
22
22
|
*/
|
|
23
23
|
export declare const Erc721MinBalanceOptionsSchema: z.ZodObject<{
|
|
24
24
|
type: z.ZodLiteral<"erc721-min-balance">;
|
|
25
|
-
chain: z.ZodString;
|
|
26
25
|
contract: z.ZodString;
|
|
27
26
|
min: z.ZodDefault<z.ZodNumber>;
|
|
28
27
|
}, z.core.$strip>;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { getAddress } from "viem";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { ChainTickerSchema } from "../schema/common.js";
|
|
4
3
|
import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from "./nft-balance.js";
|
|
5
4
|
/**
|
|
6
5
|
* Hold at least `min` of a **plain** ERC-721.
|
|
@@ -24,7 +23,6 @@ import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from
|
|
|
24
23
|
*/
|
|
25
24
|
export const Erc721MinBalanceOptionsSchema = z.object({
|
|
26
25
|
type: z.literal("erc721-min-balance"),
|
|
27
|
-
chain: ChainTickerSchema,
|
|
28
26
|
contract: z.string(),
|
|
29
27
|
min: z.number().int().positive().default(1)
|
|
30
28
|
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Criteria, GateNode, RuleRef } from "../schema/criteria.js";
|
|
2
|
+
import type { ChainReadContext, Rule, RuleRegistry, RuleResult } from "./types.js";
|
|
3
|
+
import type { ChainClient } from "../chain/types.js";
|
|
4
|
+
import { type RuleCache } from "./cache.js";
|
|
5
|
+
/**
|
|
6
|
+
* The gate tree: how one wallet's per-rule answers fold into one admission decision.
|
|
7
|
+
*
|
|
8
|
+
* The criteria document composes rule references with `all` / `any` (schema/criteria.ts). A rule
|
|
9
|
+
* still answers exactly one question about one wallet and knows nothing about the others — the
|
|
10
|
+
* composition lives here, and like {@link gateFailure} / {@link scoreOrZero} in result.ts it reads
|
|
11
|
+
* the tree's structure and the results' discriminants, NEVER which rule produced them (AGENTS.md,
|
|
12
|
+
* "What a rule owns, and what the pipeline owns").
|
|
13
|
+
*
|
|
14
|
+
* One evaluator serves both callers, which is the whole point of it living in one file: the inline
|
|
15
|
+
* forward gate evaluates lazily and stops as soon as the root is determined (one wallet, and every
|
|
16
|
+
* leaf costs a chain read), while the background verifier and `checkEligibility` evaluate every
|
|
17
|
+
* leaf — the first because its reads are batched per rule across a whole round (collecting all is
|
|
18
|
+
* CHEAPER there than short-circuiting), the second because listing every failure is the feature.
|
|
19
|
+
*/
|
|
20
|
+
/** Leaf refs in depth-first order — the index every per-leaf array in the pipeline is keyed by. */
|
|
21
|
+
export declare function gateLeaves(node: GateNode): RuleRef[];
|
|
22
|
+
/** One gate leaf, resolved against a registry and ready to score wallets. */
|
|
23
|
+
export interface ResolvedGateLeaf {
|
|
24
|
+
/** The criteria's reference, verbatim — the bytes a `ruleId` is derived from. */
|
|
25
|
+
ref: RuleRef;
|
|
26
|
+
rule: Rule;
|
|
27
|
+
/** `ref` parsed through the rule's own schema (defaults applied; never written back). */
|
|
28
|
+
options: unknown;
|
|
29
|
+
/** The rule's whole world: its chain, this verifier's head, its own memo. */
|
|
30
|
+
ctx: ChainReadContext;
|
|
31
|
+
/**
|
|
32
|
+
* The QUESTION this leaf asks, as the canonical bytes of its reference. Two leaves with the
|
|
33
|
+
* same key are one question in two positions — legal, and the only way to write "any two of
|
|
34
|
+
* these three" (schema/criteria.ts) — so they must be evaluated once, not once each.
|
|
35
|
+
*/
|
|
36
|
+
key: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve every gate leaf once per contest: its rule, its parsed options and the memo it computes
|
|
40
|
+
* through. Shared by the inline verifier and the background verifier so the two cannot resolve a
|
|
41
|
+
* gate differently — the same reason the fold above lives in one file.
|
|
42
|
+
*
|
|
43
|
+
* There is one `chain` because a contest has one clock (`criteria.bucketChainId`): every rule is
|
|
44
|
+
* handed block numbers counted in it, so a rule reading a second chain would be answering about
|
|
45
|
+
* the wrong history. See DESIGN.md "One clock".
|
|
46
|
+
*
|
|
47
|
+
* `caches` is per leaf in {@link gateLeaves} order (the voter's persistent, per-rule namespaces);
|
|
48
|
+
* omitted, each leaf gets a private in-memory memo, which is what unit tests want.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveGate(args: {
|
|
51
|
+
criteria: Criteria;
|
|
52
|
+
registry: RuleRegistry;
|
|
53
|
+
chain: ChainClient;
|
|
54
|
+
readHead: (args: {
|
|
55
|
+
chain: ChainClient;
|
|
56
|
+
}) => Promise<{
|
|
57
|
+
block: number;
|
|
58
|
+
}>;
|
|
59
|
+
caches?: readonly RuleCache[] | undefined;
|
|
60
|
+
}): ResolvedGateLeaf[];
|
|
61
|
+
/**
|
|
62
|
+
* Which leaves ask the same question, computed once per contest.
|
|
63
|
+
*
|
|
64
|
+
* `representatives` lists one leaf index per DISTINCT question, and `ofLeaf[i]` is the position in
|
|
65
|
+
* that list which leaf `i` maps to. Callers evaluate the representatives and fan the answers back
|
|
66
|
+
* out, so a rule named twice in one gate costs one evaluation and one batched chain read — not
|
|
67
|
+
* one per position. Without this the duplicate is not merely wasted CPU: the two positions race,
|
|
68
|
+
* so both miss the rule's memo before either writes it.
|
|
69
|
+
*/
|
|
70
|
+
export declare function dedupeLeaves(leaves: readonly ResolvedGateLeaf[]): {
|
|
71
|
+
representatives: number[];
|
|
72
|
+
ofLeaf: number[];
|
|
73
|
+
};
|
|
74
|
+
/** The tree with its leaves numbered, so evaluation order cannot shift what a leaf index means. */
|
|
75
|
+
export type IndexedGate = {
|
|
76
|
+
kind: "leaf";
|
|
77
|
+
leaf: number;
|
|
78
|
+
} | {
|
|
79
|
+
kind: "all" | "any";
|
|
80
|
+
children: IndexedGate[];
|
|
81
|
+
};
|
|
82
|
+
export declare function indexGate(node: GateNode, next?: {
|
|
83
|
+
leaf: number;
|
|
84
|
+
}): IndexedGate;
|
|
85
|
+
/** One leaf's verdict. `satisfied: undefined` means the evaluation short-circuited past it. */
|
|
86
|
+
export interface GateLeafResult {
|
|
87
|
+
kind: "leaf";
|
|
88
|
+
leaf: number;
|
|
89
|
+
satisfied: boolean | undefined;
|
|
90
|
+
/** `0n` unless the leaf passed. */
|
|
91
|
+
score: bigint;
|
|
92
|
+
/** The rule's own voter-facing sentence; present only on a failure. */
|
|
93
|
+
error?: string;
|
|
94
|
+
/** Whether THIS leaf's failure may be blamed on the sender (rules/types.ts). */
|
|
95
|
+
penalize: boolean;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The evaluated tree. `satisfied: undefined` marks a node the evaluation never reached — an `all`
|
|
99
|
+
* short-circuits only on a failing child and an `any` only on a passing one, so everything after
|
|
100
|
+
* the deciding child is skipped and reported as unknown rather than as failed.
|
|
101
|
+
*/
|
|
102
|
+
export type GateResult = GateLeafResult | {
|
|
103
|
+
kind: "all" | "any";
|
|
104
|
+
satisfied: boolean | undefined;
|
|
105
|
+
children: GateResult[];
|
|
106
|
+
};
|
|
107
|
+
/** Number every leaf of `node`, then score them through `evaluate` and fold the answers. */
|
|
108
|
+
export declare function evaluateGate(args: {
|
|
109
|
+
node: GateNode;
|
|
110
|
+
/** Score one leaf by its index. Callers memoize; this never calls the same index twice. */
|
|
111
|
+
evaluate: (leaf: number) => Promise<RuleResult>;
|
|
112
|
+
/**
|
|
113
|
+
* Evaluate every leaf even once the outcome is determined. Required whenever the per-leaf
|
|
114
|
+
* results are the output (`checkEligibility`) or already in hand (the batched background
|
|
115
|
+
* verifier); the inline gate leaves it off and pays for only what it needs.
|
|
116
|
+
*/
|
|
117
|
+
collectAll?: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Treat a leaf whose evaluation THREW as unknown (`satisfied: undefined`) instead of letting
|
|
120
|
+
* it reject the whole fold. Off by default, and deliberately so: on a verification path an
|
|
121
|
+
* unreachable chain is an infra failure whose only safe handling is to retry the bundle, and
|
|
122
|
+
* silently folding "could not read" into the tree would let an outage decide a vote.
|
|
123
|
+
*
|
|
124
|
+
* `checkEligibility` turns it on because it answers a person, not the network: a wallet that
|
|
125
|
+
* qualifies through a branch that DID answer must not be told it is ineligible because some
|
|
126
|
+
* other rule's RPC timed out. When the tree cannot be decided without the failed leaf the root
|
|
127
|
+
* comes back unknown, and that caller re-throws the underlying error rather than inventing a
|
|
128
|
+
* verdict.
|
|
129
|
+
*/
|
|
130
|
+
tolerateLeafErrors?: boolean;
|
|
131
|
+
/** Called for each leaf that threw under {@link tolerateLeafErrors}, in completion order. */
|
|
132
|
+
onLeafError?: (leaf: number, error: unknown) => void;
|
|
133
|
+
}): Promise<GateResult>;
|
|
134
|
+
/**
|
|
135
|
+
* The gate's score for a wallet it admitted: a leaf's own score, the MINIMUM across an `all` (the
|
|
136
|
+
* binding constraint), the MAXIMUM across the satisfied children of an `any` (the wallet's best
|
|
137
|
+
* qualification). Degenerates to the rule's own score for a single-rule gate. Informational only —
|
|
138
|
+
* a vote's magnitude comes from `criteria.weight`, never from here.
|
|
139
|
+
*/
|
|
140
|
+
export declare function gateScore(result: GateResult): bigint;
|
|
141
|
+
/**
|
|
142
|
+
* The failing leaves that EXPLAIN a refusal — what a client shows the voter, and the only honest
|
|
143
|
+
* answer to "which rules failed". It is not every failed leaf: a leaf that failed inside a
|
|
144
|
+
* satisfied `any` cost the wallet nothing, and telling someone to go acquire an asset they do not
|
|
145
|
+
* need is worse than saying nothing.
|
|
146
|
+
*/
|
|
147
|
+
export declare function gateBlame(result: GateResult): GateLeafResult[];
|
|
148
|
+
/**
|
|
149
|
+
* May a refusal be blamed on the peer that sent the vote? The recursive generalization of
|
|
150
|
+
* `RuleResult.penalize` (rules/types.ts) — and the reason it cannot be a simple OR:
|
|
151
|
+
*
|
|
152
|
+
* - an `all` fails as soon as ONE child does, so a single attributable failure is enough: that
|
|
153
|
+
* child alone closes the gate identically on every honest verifier.
|
|
154
|
+
* - an `any` fails only when EVERY child does, so it is attributable only if every one of them
|
|
155
|
+
* is. One child whose failure another peer could legitimately disagree about means that peer
|
|
156
|
+
* may be looking at a wallet this gate would admit.
|
|
157
|
+
*
|
|
158
|
+
* A short-circuited `all` can only UNDER-report (it stops at the first failure and never learns
|
|
159
|
+
* whether a later child was attributable), which is the fail-safe direction: the cost is a
|
|
160
|
+
* spammer we merely `ignore`, against wrongly reject-scoring an honest relayer.
|
|
161
|
+
*/
|
|
162
|
+
export declare function gatePenalize(result: GateResult): boolean;
|
|
163
|
+
/** The blame set's reasons, as the one sentence `VerifyFail.reason` and `VoteEvictedError` carry. */
|
|
164
|
+
export declare function gateReason(result: GateResult): string;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { encodeCanonical } from "../encoding/canonical.js";
|
|
2
|
+
import { makeMemoryRuleCache } from "./cache.js";
|
|
3
|
+
import { UnknownRuleError } from "../errors.js";
|
|
4
|
+
import { gateFailure, scoreOrZero } from "./result.js";
|
|
5
|
+
/**
|
|
6
|
+
* The gate tree: how one wallet's per-rule answers fold into one admission decision.
|
|
7
|
+
*
|
|
8
|
+
* The criteria document composes rule references with `all` / `any` (schema/criteria.ts). A rule
|
|
9
|
+
* still answers exactly one question about one wallet and knows nothing about the others — the
|
|
10
|
+
* composition lives here, and like {@link gateFailure} / {@link scoreOrZero} in result.ts it reads
|
|
11
|
+
* the tree's structure and the results' discriminants, NEVER which rule produced them (AGENTS.md,
|
|
12
|
+
* "What a rule owns, and what the pipeline owns").
|
|
13
|
+
*
|
|
14
|
+
* One evaluator serves both callers, which is the whole point of it living in one file: the inline
|
|
15
|
+
* forward gate evaluates lazily and stops as soon as the root is determined (one wallet, and every
|
|
16
|
+
* leaf costs a chain read), while the background verifier and `checkEligibility` evaluate every
|
|
17
|
+
* leaf — the first because its reads are batched per rule across a whole round (collecting all is
|
|
18
|
+
* CHEAPER there than short-circuiting), the second because listing every failure is the feature.
|
|
19
|
+
*/
|
|
20
|
+
/** Leaf refs in depth-first order — the index every per-leaf array in the pipeline is keyed by. */
|
|
21
|
+
export function gateLeaves(node) {
|
|
22
|
+
if ("rule" in node)
|
|
23
|
+
return [node.rule];
|
|
24
|
+
const children = "all" in node ? node.all : node.any;
|
|
25
|
+
return children.flatMap(gateLeaves);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve every gate leaf once per contest: its rule, its parsed options and the memo it computes
|
|
29
|
+
* through. Shared by the inline verifier and the background verifier so the two cannot resolve a
|
|
30
|
+
* gate differently — the same reason the fold above lives in one file.
|
|
31
|
+
*
|
|
32
|
+
* There is one `chain` because a contest has one clock (`criteria.bucketChainId`): every rule is
|
|
33
|
+
* handed block numbers counted in it, so a rule reading a second chain would be answering about
|
|
34
|
+
* the wrong history. See DESIGN.md "One clock".
|
|
35
|
+
*
|
|
36
|
+
* `caches` is per leaf in {@link gateLeaves} order (the voter's persistent, per-rule namespaces);
|
|
37
|
+
* omitted, each leaf gets a private in-memory memo, which is what unit tests want.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveGate(args) {
|
|
40
|
+
const { criteria, registry, chain, readHead, caches } = args;
|
|
41
|
+
return gateLeaves(criteria.gate).map((ref, index) => {
|
|
42
|
+
const rule = registry[ref.type];
|
|
43
|
+
if (!rule)
|
|
44
|
+
throw new UnknownRuleError("gate", ref.type);
|
|
45
|
+
const options = rule.optionsSchema.parse(ref);
|
|
46
|
+
return {
|
|
47
|
+
ref,
|
|
48
|
+
rule,
|
|
49
|
+
options,
|
|
50
|
+
ctx: { chain, head: () => readHead({ chain }), cache: caches?.[index] ?? makeMemoryRuleCache() },
|
|
51
|
+
key: Array.from(encodeCanonical(ref), (byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Which leaves ask the same question, computed once per contest.
|
|
57
|
+
*
|
|
58
|
+
* `representatives` lists one leaf index per DISTINCT question, and `ofLeaf[i]` is the position in
|
|
59
|
+
* that list which leaf `i` maps to. Callers evaluate the representatives and fan the answers back
|
|
60
|
+
* out, so a rule named twice in one gate costs one evaluation and one batched chain read — not
|
|
61
|
+
* one per position. Without this the duplicate is not merely wasted CPU: the two positions race,
|
|
62
|
+
* so both miss the rule's memo before either writes it.
|
|
63
|
+
*/
|
|
64
|
+
export function dedupeLeaves(leaves) {
|
|
65
|
+
const at = new Map();
|
|
66
|
+
const representatives = [];
|
|
67
|
+
const ofLeaf = leaves.map((leaf, index) => {
|
|
68
|
+
const seen = at.get(leaf.key);
|
|
69
|
+
if (seen !== undefined)
|
|
70
|
+
return seen;
|
|
71
|
+
at.set(leaf.key, representatives.length);
|
|
72
|
+
representatives.push(index);
|
|
73
|
+
return representatives.length - 1;
|
|
74
|
+
});
|
|
75
|
+
return { representatives, ofLeaf };
|
|
76
|
+
}
|
|
77
|
+
export function indexGate(node, next = { leaf: 0 }) {
|
|
78
|
+
if ("rule" in node)
|
|
79
|
+
return { kind: "leaf", leaf: next.leaf++ };
|
|
80
|
+
const kind = "all" in node ? "all" : "any";
|
|
81
|
+
const children = "all" in node ? node.all : node.any;
|
|
82
|
+
return { kind, children: children.map((child) => indexGate(child, next)) };
|
|
83
|
+
}
|
|
84
|
+
const UNEVALUATED = (leaf) => ({ kind: "leaf", leaf, satisfied: undefined, score: 0n, penalize: false });
|
|
85
|
+
/** Number every leaf of `node`, then score them through `evaluate` and fold the answers. */
|
|
86
|
+
export async function evaluateGate(args) {
|
|
87
|
+
const { node, evaluate, collectAll = false, tolerateLeafErrors = false, onLeafError } = args;
|
|
88
|
+
const run = async (indexed) => {
|
|
89
|
+
if (indexed.kind === "leaf") {
|
|
90
|
+
let result;
|
|
91
|
+
try {
|
|
92
|
+
result = await evaluate(indexed.leaf);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!tolerateLeafErrors)
|
|
96
|
+
throw error;
|
|
97
|
+
onLeafError?.(indexed.leaf, error);
|
|
98
|
+
return UNEVALUATED(indexed.leaf);
|
|
99
|
+
}
|
|
100
|
+
const failed = gateFailure(result);
|
|
101
|
+
return failed
|
|
102
|
+
? { kind: "leaf", leaf: indexed.leaf, satisfied: false, score: 0n, error: failed.error, penalize: failed.penalize }
|
|
103
|
+
: { kind: "leaf", leaf: indexed.leaf, satisfied: true, score: scoreOrZero(result), penalize: false };
|
|
104
|
+
}
|
|
105
|
+
// An `all` is determined by its first failing child, an `any` by its first passing one.
|
|
106
|
+
const decidesAt = indexed.kind === "all" ? false : true;
|
|
107
|
+
if (collectAll) {
|
|
108
|
+
const children = await Promise.all(indexed.children.map(run));
|
|
109
|
+
return { kind: indexed.kind, satisfied: foldSatisfied(indexed.kind, children), children };
|
|
110
|
+
}
|
|
111
|
+
const children = [];
|
|
112
|
+
for (const [position, child] of indexed.children.entries()) {
|
|
113
|
+
const result = await run(child);
|
|
114
|
+
children.push(result);
|
|
115
|
+
if (isSatisfied(result) === decidesAt) {
|
|
116
|
+
// Everything after this point cannot change the answer: record the skipped leaves
|
|
117
|
+
// as unevaluated so the tree still mirrors the document's shape.
|
|
118
|
+
for (const rest of indexed.children.slice(position + 1))
|
|
119
|
+
children.push(...skipped(rest));
|
|
120
|
+
return { kind: indexed.kind, satisfied: decidesAt, children };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// No child decided it, so every one of them settled the other way — unless a read failed
|
|
124
|
+
// under `tolerateLeafErrors`, in which case the branch is honestly unknown.
|
|
125
|
+
return { kind: indexed.kind, satisfied: foldSatisfied(indexed.kind, children), children };
|
|
126
|
+
};
|
|
127
|
+
return run(indexGate(node));
|
|
128
|
+
}
|
|
129
|
+
/** A node the evaluation never reached, shaped like the result it would have produced. */
|
|
130
|
+
function skipped(indexed) {
|
|
131
|
+
if (indexed.kind === "leaf")
|
|
132
|
+
return [UNEVALUATED(indexed.leaf)];
|
|
133
|
+
return [{ kind: indexed.kind, satisfied: undefined, children: indexed.children.flatMap(skipped) }];
|
|
134
|
+
}
|
|
135
|
+
const isSatisfied = (result) => result.satisfied === true;
|
|
136
|
+
/**
|
|
137
|
+
* A branch's verdict from its children's, in three values rather than two — `undefined` is "not
|
|
138
|
+
* known", and only a KNOWN answer may decide. An `all` fails on any known failure and admits only
|
|
139
|
+
* once every child is known to admit; an `any` admits on any known success and refuses only once
|
|
140
|
+
* every alternative is known to have failed. Anything else is unknown, which is what keeps a leaf
|
|
141
|
+
* nobody could read (see `tolerateLeafErrors`) or one the evaluation skipped from being counted as
|
|
142
|
+
* a refusal the wallet is supposed to act on.
|
|
143
|
+
*/
|
|
144
|
+
function foldSatisfied(kind, children) {
|
|
145
|
+
const decidesAt = kind === "all" ? false : true;
|
|
146
|
+
if (children.some((child) => child.satisfied === decidesAt))
|
|
147
|
+
return decidesAt;
|
|
148
|
+
return children.every((child) => child.satisfied === !decidesAt) ? !decidesAt : undefined;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The gate's score for a wallet it admitted: a leaf's own score, the MINIMUM across an `all` (the
|
|
152
|
+
* binding constraint), the MAXIMUM across the satisfied children of an `any` (the wallet's best
|
|
153
|
+
* qualification). Degenerates to the rule's own score for a single-rule gate. Informational only —
|
|
154
|
+
* a vote's magnitude comes from `criteria.weight`, never from here.
|
|
155
|
+
*/
|
|
156
|
+
export function gateScore(result) {
|
|
157
|
+
if (result.kind === "leaf")
|
|
158
|
+
return result.score;
|
|
159
|
+
const scores = result.children.filter(isSatisfied).map(gateScore);
|
|
160
|
+
if (scores.length === 0)
|
|
161
|
+
return 0n;
|
|
162
|
+
return result.kind === "all"
|
|
163
|
+
? scores.reduce((min, score) => (score < min ? score : min))
|
|
164
|
+
: scores.reduce((max, score) => (score > max ? score : max));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The failing leaves that EXPLAIN a refusal — what a client shows the voter, and the only honest
|
|
168
|
+
* answer to "which rules failed". It is not every failed leaf: a leaf that failed inside a
|
|
169
|
+
* satisfied `any` cost the wallet nothing, and telling someone to go acquire an asset they do not
|
|
170
|
+
* need is worse than saying nothing.
|
|
171
|
+
*/
|
|
172
|
+
export function gateBlame(result) {
|
|
173
|
+
if (result.satisfied !== false)
|
|
174
|
+
return [];
|
|
175
|
+
if (result.kind === "leaf")
|
|
176
|
+
return [result];
|
|
177
|
+
// A failing `all` is explained by whichever children failed; a failing `any` by all of them,
|
|
178
|
+
// since every one of its alternatives is a road the wallet could have taken and did not.
|
|
179
|
+
return result.children.flatMap(gateBlame);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* May a refusal be blamed on the peer that sent the vote? The recursive generalization of
|
|
183
|
+
* `RuleResult.penalize` (rules/types.ts) — and the reason it cannot be a simple OR:
|
|
184
|
+
*
|
|
185
|
+
* - an `all` fails as soon as ONE child does, so a single attributable failure is enough: that
|
|
186
|
+
* child alone closes the gate identically on every honest verifier.
|
|
187
|
+
* - an `any` fails only when EVERY child does, so it is attributable only if every one of them
|
|
188
|
+
* is. One child whose failure another peer could legitimately disagree about means that peer
|
|
189
|
+
* may be looking at a wallet this gate would admit.
|
|
190
|
+
*
|
|
191
|
+
* A short-circuited `all` can only UNDER-report (it stops at the first failure and never learns
|
|
192
|
+
* whether a later child was attributable), which is the fail-safe direction: the cost is a
|
|
193
|
+
* spammer we merely `ignore`, against wrongly reject-scoring an honest relayer.
|
|
194
|
+
*/
|
|
195
|
+
export function gatePenalize(result) {
|
|
196
|
+
if (result.satisfied !== false)
|
|
197
|
+
return false;
|
|
198
|
+
if (result.kind === "leaf")
|
|
199
|
+
return result.penalize;
|
|
200
|
+
const failing = result.children.filter((child) => child.satisfied === false);
|
|
201
|
+
if (failing.length === 0)
|
|
202
|
+
return false;
|
|
203
|
+
return result.kind === "all" ? failing.some(gatePenalize) : failing.every(gatePenalize);
|
|
204
|
+
}
|
|
205
|
+
/** The blame set's reasons, as the one sentence `VerifyFail.reason` and `VoteEvictedError` carry. */
|
|
206
|
+
export function gateReason(result) {
|
|
207
|
+
const reasons = gateBlame(result).map((leaf) => leaf.error ?? "this wallet does not qualify");
|
|
208
|
+
return [...new Set(reasons)].join("; ");
|
|
209
|
+
}
|
package/dist/rules/registry.d.ts
CHANGED
|
@@ -53,13 +53,19 @@ export declare const V1_BUILTIN_RULE_TYPES: readonly ["erc5192-min-balance", "co
|
|
|
53
53
|
*/
|
|
54
54
|
export declare function resolveRegistry(overrides?: RuleRegistry): RuleRegistry;
|
|
55
55
|
/**
|
|
56
|
-
* Validate a criteria document against a resolved registry: the `
|
|
57
|
-
* `weight`
|
|
56
|
+
* Validate a criteria document against a resolved registry: every leaf of the `gate` tree and the
|
|
57
|
+
* `weight` ref must name rules this registry implements, their options must
|
|
58
58
|
* parse against the rule's own schema, and every name in `requires.rules`
|
|
59
59
|
* must be resolvable (so an out-of-date client recuses itself instead of miscounting).
|
|
60
60
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* There is no chain to check: a rule names no chain at all, it reads the one the contest counts in
|
|
62
|
+
* (`criteria.bucketChainId`). That invariant is structural rather than validated — the class of
|
|
63
|
+
* "this leaf reads a different chain from the buckets" bug cannot be expressed. Gating across
|
|
64
|
+
* several chains is future work and needs an answer for what block a rule on a SECOND chain is
|
|
65
|
+
* handed; see DESIGN.md "Open questions".
|
|
66
|
+
*
|
|
67
|
+
* Throws `UnknownRuleError` / a zod error on the first failure. This is a check, not a transform:
|
|
68
|
+
* it never mutates `criteria`, so the topic-bearing bytes are untouched (option defaults applied
|
|
69
|
+
* here do not leak back into the encoded criteria).
|
|
64
70
|
*/
|
|
65
71
|
export declare function validateCriteriaRules(criteria: Criteria, registry: RuleRegistry): void;
|
package/dist/rules/registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gateLeaves } from "./gate.js";
|
|
1
2
|
import { UnknownRuleError } from "../errors.js";
|
|
2
3
|
import { erc5192MinBalance } from "./erc5192-min-balance.js";
|
|
3
4
|
import { constant } from "./constant.js";
|
|
@@ -59,20 +60,28 @@ export function resolveRegistry(overrides) {
|
|
|
59
60
|
return { ...builtinRegistry, ...overrides };
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
62
|
-
* Validate a criteria document against a resolved registry: the `
|
|
63
|
-
* `weight`
|
|
63
|
+
* Validate a criteria document against a resolved registry: every leaf of the `gate` tree and the
|
|
64
|
+
* `weight` ref must name rules this registry implements, their options must
|
|
64
65
|
* parse against the rule's own schema, and every name in `requires.rules`
|
|
65
66
|
* must be resolvable (so an out-of-date client recuses itself instead of miscounting).
|
|
66
67
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* There is no chain to check: a rule names no chain at all, it reads the one the contest counts in
|
|
69
|
+
* (`criteria.bucketChainId`). That invariant is structural rather than validated — the class of
|
|
70
|
+
* "this leaf reads a different chain from the buckets" bug cannot be expressed. Gating across
|
|
71
|
+
* several chains is future work and needs an answer for what block a rule on a SECOND chain is
|
|
72
|
+
* handed; see DESIGN.md "Open questions".
|
|
73
|
+
*
|
|
74
|
+
* Throws `UnknownRuleError` / a zod error on the first failure. This is a check, not a transform:
|
|
75
|
+
* it never mutates `criteria`, so the topic-bearing bytes are untouched (option defaults applied
|
|
76
|
+
* here do not leak back into the encoded criteria).
|
|
70
77
|
*/
|
|
71
78
|
export function validateCriteriaRules(criteria, registry) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
for (const ref of gateLeaves(criteria.gate)) {
|
|
80
|
+
const rule = registry[ref.type];
|
|
81
|
+
if (!rule)
|
|
82
|
+
throw new UnknownRuleError("gate", ref.type);
|
|
83
|
+
rule.optionsSchema.parse(ref);
|
|
84
|
+
}
|
|
76
85
|
const weight = registry[criteria.weight.type];
|
|
77
86
|
if (!weight)
|
|
78
87
|
throw new UnknownRuleError("weight", criteria.weight.type);
|