@bitsocial/pubsub-voting 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -6
- package/dist/client/voter.d.ts +37 -0
- package/dist/client/voter.js +19 -0
- package/dist/rules/constant.js +3 -1
- package/dist/rules/erc20-balance.js +8 -2
- package/dist/rules/erc5192-min-balance.js +39 -7
- package/dist/rules/erc721-min-balance.js +14 -5
- package/dist/rules/nft-balance.d.ts +9 -0
- package/dist/rules/nft-balance.js +13 -0
- package/dist/rules/result.d.ts +16 -0
- package/dist/rules/result.js +30 -0
- package/dist/rules/types.d.ts +61 -31
- package/dist/tally/tally.js +5 -3
- package/dist/transport/integration/harness.js +4 -1
- package/dist/verify/background.js +9 -8
- package/dist/verify/bundle.d.ts +5 -4
- package/dist/verify/bundle.js +17 -7
- package/dist/verify/types.d.ts +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,6 +87,31 @@ await contest.update(); // join the topic, col
|
|
|
87
87
|
// await contest.stop(); // leave the topic
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
### Will this wallet's vote count?
|
|
91
|
+
|
|
92
|
+
Ask the contest before signing. `checkEligibility` runs the contest's **real gate rule** through
|
|
93
|
+
the same chain client, head reader and memo the forward gate uses, so it reads at whatever block
|
|
94
|
+
that rule reads at and applies whatever threshold it applies. When it refuses, the `error` is the
|
|
95
|
+
rule's own wording — render it verbatim:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const check = await contest.checkEligibility({ address: wallet });
|
|
99
|
+
if (check.eligible) {
|
|
100
|
+
show(`eligible — holds ${check.score}`);
|
|
101
|
+
} else {
|
|
102
|
+
show(check.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Do **not** reimplement this by reading balances yourself: which block counts is the rule's
|
|
107
|
+
business and changes when the rule changes. A client that hard-codes "peers verify at the bucket
|
|
108
|
+
boundary" keeps telling voters to wait for a window that a head-reading gate no longer imposes.
|
|
109
|
+
|
|
110
|
+
It is a courtesy check, not a promise — eligibility can change between the check and the publish,
|
|
111
|
+
and each peer verifies against its own chain view. `publish()` deliberately does not call it: the
|
|
112
|
+
gate is the network's decision, and a rejection still surfaces after the fact as
|
|
113
|
+
`VoteEvictedError`, carrying the same kind of reason.
|
|
114
|
+
|
|
90
115
|
Each ranking row carries one flag **per deferred verification operation** (mirroring pkc-js's
|
|
91
116
|
`nameResolved`), and every background settlement re-fires `update` — so a leaderboard can render
|
|
92
117
|
provisional rows immediately and refine them in place:
|
|
@@ -196,7 +221,7 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
|
|
|
196
221
|
|
|
197
222
|
### Custom rules
|
|
198
223
|
|
|
199
|
-
The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → {
|
|
224
|
+
The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` — where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **rule** slot treats the score as a gate (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `rule.success ? weight.score : 0n`. A rule that needs a threshold fails below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, wallets, ctx })` batch hook (`wallets` in place of `wallet`, returning `{ results }` — one per input wallet, in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads. The batch is simply everything pending, so its wallets need not share a `sampleBlock`: a rule reading the head scores them all at once, a rule reading pinned blocks groups them itself. (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification).)
|
|
200
225
|
|
|
201
226
|
Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
|
|
202
227
|
|
|
@@ -217,7 +242,9 @@ const seeditModAllowlist: Rule<{ type: "seedit-mod-allowlist"; allow: string[] }
|
|
|
217
242
|
type: "seedit-mod-allowlist",
|
|
218
243
|
optionsSchema: z.object({ type: z.literal("seedit-mod-allowlist"), allow: z.array(z.string()) }),
|
|
219
244
|
async evaluate({ options, wallet }) {
|
|
220
|
-
return
|
|
245
|
+
return options.allow.includes(wallet.address)
|
|
246
|
+
? { success: true, score: 1n }
|
|
247
|
+
: { success: false, error: "this wallet is not on the moderator allowlist" }; // shown to the voter
|
|
221
248
|
}
|
|
222
249
|
};
|
|
223
250
|
|
|
@@ -255,15 +282,19 @@ const { values } = await ctx.cache.memoMany({
|
|
|
255
282
|
});
|
|
256
283
|
```
|
|
257
284
|
|
|
258
|
-
`RuleResult`
|
|
285
|
+
`RuleResult` is a discriminated union, shaped like pkc-js's `ChallengeResult`:
|
|
259
286
|
|
|
260
287
|
```ts
|
|
261
|
-
|
|
288
|
+
type RuleResult =
|
|
289
|
+
| { success: true; score: bigint } // score MUST be > 0n
|
|
290
|
+
| { success: false; error: string; penalize?: boolean }; // penalize default true
|
|
262
291
|
```
|
|
263
292
|
|
|
264
|
-
**`
|
|
293
|
+
**`error` is required on the failing branch**, because only the rule knows why a wallet fell short — it holds none, it holds too few, the contract gates nothing. That sentence is what the library shows the voter: it becomes the verdict reason, so it reaches the publisher on `VoteEvictedError.verdict.reason`, and it is what `contest.checkEligibility()` returns. Write it about the wallet ("this wallet holds none of the gate token"), not about the library, and leave block numbers out unless a voter can act on them. Making it optional would mean every UI re-deriving the rule's thresholds and block choice to say anything useful — which is exactly the coupling `ctx` and `RuleCache` exist to remove.
|
|
294
|
+
|
|
295
|
+
**`penalize`** answers the one thing the library cannot: may the failure be blamed on the sender? `true` (the default) says every honest verifier computes this same failure — true of a read pinned to the block the bundle names — so the forward gate `reject`s the message (penalizing the delivering peer in gossipsub's scoring) and the verdict is cached as terminal. `false` says an honest peer could legitimately disagree, so the bundle is dropped `ignore`-class instead: no penalty, verdict uncached, and the background verifier re-examines it for a grace window before giving up.
|
|
265
296
|
|
|
266
|
-
`erc5192-min-balance` reads the head first — so a freshly-acquired Pass counts immediately — falls back to `wallet.sampleBlock` when the head refuses (ERC-5192 does not forbid burning, and without the fallback a burn would erase votes retroactively for peers that had not verified them yet), memoizes each leg under its own epoch, and returns `penalize: false`, because at validation time it cannot attribute a
|
|
297
|
+
`erc5192-min-balance` reads the head first — so a freshly-acquired Pass counts immediately — falls back to `wallet.sampleBlock` when the head refuses (ERC-5192 does not forbid burning, and without the fallback a burn would erase votes retroactively for peers that had not verified them yet), memoizes each leg under its own epoch, and returns `penalize: false`, because at validation time it cannot attribute a failure to anyone: the peer that forwarded the vote verified it against *its* head. Every other rule in the tree reads `wallet.sampleBlock` and leaves `penalize` at its default — a transferable or fungible balance can decrease, so reading it at the head would silently invalidate votes already counted. See [DESIGN.md, What a rule owns](./DESIGN.md#what-a-rule-owns-and-what-the-pipeline-owns).
|
|
267
298
|
|
|
268
299
|
### Weighted voting (deferred)
|
|
269
300
|
|
package/dist/client/voter.d.ts
CHANGED
|
@@ -65,6 +65,19 @@ export interface PublishOutcome {
|
|
|
65
65
|
readonly recipientCount: number;
|
|
66
66
|
}
|
|
67
67
|
/** One contest's reactive read view: subscribe, keep the tally in sync, read it. */
|
|
68
|
+
/**
|
|
69
|
+
* What {@link Contest.checkEligibility} found. Shaped like {@link RuleResult} on purpose — it is
|
|
70
|
+
* that result, surfaced — so the failing branch always carries a reason a client can display.
|
|
71
|
+
*/
|
|
72
|
+
export type EligibilityResult = {
|
|
73
|
+
eligible: true;
|
|
74
|
+
/** The wallet's gate score, `> 0n`. For a balance gate this is the holding itself. */
|
|
75
|
+
score: bigint;
|
|
76
|
+
} | {
|
|
77
|
+
eligible: false;
|
|
78
|
+
/** The rule's own explanation, written for the voter. Render it verbatim. */
|
|
79
|
+
error: string;
|
|
80
|
+
};
|
|
68
81
|
export interface Contest {
|
|
69
82
|
/** The criteria document this contest runs (already validated). */
|
|
70
83
|
readonly criteria: Criteria;
|
|
@@ -85,6 +98,30 @@ export interface Contest {
|
|
|
85
98
|
stop(): Promise<void>;
|
|
86
99
|
/** Compute the current contest ranking fresh, bypassing the cache. */
|
|
87
100
|
getTally(): Promise<ContestTally>;
|
|
101
|
+
/**
|
|
102
|
+
* Would this contest's gate admit `address` right now? Ask before signing, to tell a voter
|
|
103
|
+
* whether their ballot will count — and, when it will not, exactly why.
|
|
104
|
+
*
|
|
105
|
+
* This runs the contest's REAL gate rule through the same context the forward-gate and the
|
|
106
|
+
* background verifier use: the same chain client, the same coalesced head reader, the same
|
|
107
|
+
* memo. So it reads at whatever block the rule reads at, applies whatever threshold the rule
|
|
108
|
+
* applies, and returns the rule's own {@link RuleResult.error} wording verbatim. A client
|
|
109
|
+
* renders `error` and needs to know nothing about blocks, buckets or thresholds — which is
|
|
110
|
+
* the point: re-deriving any of that outside the rule is how a UI ends up confidently
|
|
111
|
+
* telling voters to wait for a window that no longer gates anything.
|
|
112
|
+
*
|
|
113
|
+
* It is a courtesy check, not a promise. Eligibility is a fact about the chain and can change
|
|
114
|
+
* between this call and the publish, and each peer verifies against its own view — so a
|
|
115
|
+
* `true` here can still be followed by a `VoteEvictedError` (which carries the same kind of
|
|
116
|
+
* reason). `publish()` deliberately does NOT call this: the gate is the network's decision,
|
|
117
|
+
* and refusing locally would only hide a vote the rest of the topic would have accepted.
|
|
118
|
+
*
|
|
119
|
+
* Costs one gate evaluation, usually served from the shared memo — the same read the verifier
|
|
120
|
+
* would do anyway, not an extra one.
|
|
121
|
+
*/
|
|
122
|
+
checkEligibility(args: {
|
|
123
|
+
address: string;
|
|
124
|
+
}): Promise<EligibilityResult>;
|
|
88
125
|
/**
|
|
89
126
|
* Fired when incoming votes change the state; `tally` carries the freshly recomputed
|
|
90
127
|
* ranking. Background check settlements fire it too: a cold join emits a first tally with
|
package/dist/client/voter.js
CHANGED
|
@@ -15,6 +15,7 @@ import { encodeBundle, decodeBundle, bundleCidForBytes } from "../crdt/codec.js"
|
|
|
15
15
|
import { resolveRegistry, validateCriteriaRules } from "../rules/registry.js";
|
|
16
16
|
import { makeVoteCrdt } from "../crdt/crdt.js";
|
|
17
17
|
import { makePersistentRuleCache } from "../rules/cache.js";
|
|
18
|
+
import { gateFailure, scoreOrZero } from "../rules/result.js";
|
|
18
19
|
import { makeBundleVerifier } from "../verify/bundle.js";
|
|
19
20
|
import { makeVerdictCache } from "../verify/cache.js";
|
|
20
21
|
import { makeNameResolutionCache } from "../verify/name-resolution-cache.js";
|
|
@@ -871,6 +872,21 @@ class ContestEngine {
|
|
|
871
872
|
return true;
|
|
872
873
|
return sampleBucket <= (await this.#nowBucket());
|
|
873
874
|
}
|
|
875
|
+
/**
|
|
876
|
+
* Run the gate rule for one wallet, against the ballot block a vote published NOW would carry
|
|
877
|
+
* — the engine half of {@link Contest.checkEligibility}.
|
|
878
|
+
*
|
|
879
|
+
* All the interesting decisions belong to the rule: this resolves the current bucket's sample
|
|
880
|
+
* block, hands it over, and translates the rule's own answer. It never looks at what kind of
|
|
881
|
+
* rule it is holding, so it stays correct for a head-scoring gate, a pinned one, or anything
|
|
882
|
+
* a host registers later.
|
|
883
|
+
*/
|
|
884
|
+
async checkEligibility({ address }) {
|
|
885
|
+
const sampleBlock = this.#bucketMath.sampleBlockForBucket(await this.#nowBucket());
|
|
886
|
+
const result = await this.#verifier.checkGate({ address, sampleBlock });
|
|
887
|
+
const failed = gateFailure(result);
|
|
888
|
+
return failed ? { eligible: false, error: failed.error } : { eligible: true, score: scoreOrZero(result) };
|
|
889
|
+
}
|
|
874
890
|
/** Hash of the current bucket boundary block on the gating (`rule`) chain (rolling tie seed). */
|
|
875
891
|
async #bucketBlockHash() {
|
|
876
892
|
const head = await this.#ruleChain.getBlockNumber();
|
|
@@ -1696,6 +1712,9 @@ class ContestView {
|
|
|
1696
1712
|
getTally() {
|
|
1697
1713
|
return this.#engine.computeTally();
|
|
1698
1714
|
}
|
|
1715
|
+
checkEligibility(args) {
|
|
1716
|
+
return this.#engine.checkEligibility(args);
|
|
1717
|
+
}
|
|
1699
1718
|
/**
|
|
1700
1719
|
* Internal hook (not part of the {@link Contest} interface): this contest's current checkpoint
|
|
1701
1720
|
* root record, encoded on demand. The fetch responder and heartbeat use the engine directly;
|
package/dist/rules/constant.js
CHANGED
|
@@ -13,6 +13,8 @@ export const constant = {
|
|
|
13
13
|
type: "constant",
|
|
14
14
|
optionsSchema: ConstantOptionsSchema,
|
|
15
15
|
async evaluate({ options }) {
|
|
16
|
-
|
|
16
|
+
// `value` is schema-constrained positive, so this rule has no failing branch at all —
|
|
17
|
+
// in the rule slot it is the no-op gate that admits everyone.
|
|
18
|
+
return { success: true, score: BigInt(options.value) };
|
|
17
19
|
}
|
|
18
20
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { erc20Abi, getAddress, parseUnits } from "viem";
|
|
1
|
+
import { erc20Abi, formatUnits, getAddress, parseUnits } from "viem";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ChainTickerSchema } from "../schema/common.js";
|
|
4
4
|
/**
|
|
@@ -50,6 +50,12 @@ export const erc20Balance = {
|
|
|
50
50
|
blockNumber: BigInt(wallet.sampleBlock)
|
|
51
51
|
});
|
|
52
52
|
const minUnits = parseUnits(options.min.toString(), options.decimals);
|
|
53
|
-
|
|
53
|
+
if (raw >= minUnits)
|
|
54
|
+
return { success: true, score: raw };
|
|
55
|
+
return {
|
|
56
|
+
success: false,
|
|
57
|
+
error: `this wallet holds ${formatUnits(raw, options.decimals)} of the gate token ` +
|
|
58
|
+
`(${getAddress(options.contract)}), but ${options.min} is required`
|
|
59
|
+
};
|
|
54
60
|
}
|
|
55
61
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BaseError, ContractFunctionRevertedError, ContractFunctionZeroDataError, getAddress } from "viem";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ChainTickerSchema } from "../schema/common.js";
|
|
4
|
-
import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.js";
|
|
4
|
+
import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from "./nft-balance.js";
|
|
5
5
|
/**
|
|
6
6
|
* Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
|
|
7
7
|
*
|
|
@@ -89,6 +89,15 @@ const HEAD_EPOCH_BLOCKS = 30;
|
|
|
89
89
|
/** Cache-key prefixes: head-scored entries expire with the head, pinned ones never do. */
|
|
90
90
|
const HEAD_PREFIX = "head/";
|
|
91
91
|
const PINNED_PREFIX = "pin/";
|
|
92
|
+
/**
|
|
93
|
+
* The voter-facing wording for every way this rule reaches `0n` ({@link RuleResult.error}).
|
|
94
|
+
*
|
|
95
|
+
* Deliberately generic and self-contained: the rule knows a contract address and a threshold, not
|
|
96
|
+
* that the deployment calls this token a "5chan Pass". A client renders these verbatim, which is
|
|
97
|
+
* the point — it then needs to know nothing about which block the rule read or what `min` is.
|
|
98
|
+
*/
|
|
99
|
+
const undeclaredError = (contract) => `the gate contract ${contract} does not declare ERC-5192, so it gates nothing and no wallet can qualify ` +
|
|
100
|
+
`— this contest's criteria name a contract that is not soulbound`;
|
|
92
101
|
/** One `supportsInterface(0xb45a3c0e)` at `block`. Revert/zero-data ⇒ "does not declare". */
|
|
93
102
|
async function declaresErc5192(args) {
|
|
94
103
|
try {
|
|
@@ -120,7 +129,7 @@ async function declaresErc5192(args) {
|
|
|
120
129
|
async function scoreAt(args) {
|
|
121
130
|
const { contract, min, wallets, block, epoch, prefix, ctx } = args;
|
|
122
131
|
if (wallets.length === 0)
|
|
123
|
-
return { scores: [] };
|
|
132
|
+
return { scores: [], errors: [] };
|
|
124
133
|
const [declared] = (await ctx.cache.memoMany({
|
|
125
134
|
keys: [`${prefix}lock/${contract.toLowerCase()}`],
|
|
126
135
|
epoch,
|
|
@@ -132,7 +141,7 @@ async function scoreAt(args) {
|
|
|
132
141
|
// A contract that does not claim its tokens are locked gates nothing: admit nobody rather
|
|
133
142
|
// than gate on something transferable (see the rule doc above).
|
|
134
143
|
if (declared !== "1")
|
|
135
|
-
return { scores: wallets.map(() => 0n) };
|
|
144
|
+
return { scores: wallets.map(() => 0n), errors: wallets.map(() => undeclaredError(contract)) };
|
|
136
145
|
const { values } = await ctx.cache.memoMany({
|
|
137
146
|
keys: wallets.map((wallet) => `${prefix}bal/${wallet.toLowerCase()}`),
|
|
138
147
|
epoch,
|
|
@@ -146,8 +155,22 @@ async function scoreAt(args) {
|
|
|
146
155
|
return { values: balances.map((balance) => balance.toString()) };
|
|
147
156
|
}
|
|
148
157
|
});
|
|
149
|
-
|
|
158
|
+
const scores = values.map((balance) => scoreOf(BigInt(balance), min));
|
|
159
|
+
return {
|
|
160
|
+
scores,
|
|
161
|
+
errors: scores.map((score, i) => (score > 0n ? undefined : shortfallError(BigInt(values[i]), min, contract)))
|
|
162
|
+
};
|
|
150
163
|
}
|
|
164
|
+
/** `{ success: true, score }` when the leg admitted, else the failing branch with its reason. */
|
|
165
|
+
function resultOf(score, error) {
|
|
166
|
+
// `penalize: false` on every failure: neither leg makes one attributable. The peer that
|
|
167
|
+
// forwarded a vote verified it against ITS head, and any peer ahead of us may legitimately
|
|
168
|
+
// see an acquisition we have not — and with burning possible, holding at neither block does
|
|
169
|
+
// not even prove the wallet never held.
|
|
170
|
+
return score > 0n ? { success: true, score } : { success: false, error: error ?? UNKNOWN_ERROR, penalize: false };
|
|
171
|
+
}
|
|
172
|
+
/** Unreachable: `scoreAt` pairs every non-positive score with a reason. Kept total, not thrown. */
|
|
173
|
+
const UNKNOWN_ERROR = "this wallet does not qualify for this contest's gate";
|
|
151
174
|
export const erc5192MinBalance = {
|
|
152
175
|
type: "erc5192-min-balance",
|
|
153
176
|
optionsSchema: Erc5192MinBalanceOptionsSchema,
|
|
@@ -184,10 +207,15 @@ export const erc5192MinBalance = {
|
|
|
184
207
|
* head (a stale `0n` must not outlive {@link HEAD_EPOCH_BLOCKS}), while the pinned leg is a
|
|
185
208
|
* historical read that is true forever and is keyed by the block itself.
|
|
186
209
|
*
|
|
187
|
-
* **`penalize: false`** on every
|
|
210
|
+
* **`penalize: false`** on every failure: neither leg makes one attributable. The peer that
|
|
188
211
|
* forwarded a vote verified it against ITS head, and any peer ahead of us may legitimately
|
|
189
212
|
* see an acquisition we have not — and with burning possible, not holding at either block
|
|
190
213
|
* does not even prove the wallet never held.
|
|
214
|
+
*
|
|
215
|
+
* **Three distinct failures**, each with its own {@link RuleResult.error}: the contract does
|
|
216
|
+
* not declare ERC-5192 (so it gates nothing and no wallet can ever qualify), the wallet holds
|
|
217
|
+
* none, or it holds some but fewer than `min`. The fallback leg has the last word on a
|
|
218
|
+
* wallet's score, so it owns that wallet's reason too.
|
|
191
219
|
*/
|
|
192
220
|
async evaluateMany({ options, wallets, ctx }) {
|
|
193
221
|
const contract = getAddress(options.contract);
|
|
@@ -196,7 +224,7 @@ export const erc5192MinBalance = {
|
|
|
196
224
|
// Everything behind the current window is unreachable — nothing will look it up again.
|
|
197
225
|
// Scoped to the head keys, so the permanently-valid pinned entries are left alone.
|
|
198
226
|
ctx.cache.purgeBelow({ epoch, keyPrefix: HEAD_PREFIX });
|
|
199
|
-
const { scores } = await scoreAt({
|
|
227
|
+
const { scores, errors } = await scoreAt({
|
|
200
228
|
contract,
|
|
201
229
|
min: options.min,
|
|
202
230
|
wallets: wallets.map((wallet) => wallet.address),
|
|
@@ -226,8 +254,12 @@ export const erc5192MinBalance = {
|
|
|
226
254
|
});
|
|
227
255
|
indexes.forEach((at, i) => {
|
|
228
256
|
scores[at] = fallback.scores[i];
|
|
257
|
+
// The fallback leg had the last word on the score, so it owns the reason too:
|
|
258
|
+
// a wallet that holds none at the head but held some at its ballot's block is
|
|
259
|
+
// admitted, and one that holds none at either gets the pinned leg's wording.
|
|
260
|
+
errors[at] = fallback.errors[i];
|
|
229
261
|
});
|
|
230
262
|
}));
|
|
231
|
-
return { results: scores.map((score) => (
|
|
263
|
+
return { results: scores.map((score, i) => resultOf(score, errors[i])) };
|
|
232
264
|
}
|
|
233
265
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getAddress } from "viem";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ChainTickerSchema } from "../schema/common.js";
|
|
4
|
-
import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.js";
|
|
4
|
+
import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from "./nft-balance.js";
|
|
5
5
|
/**
|
|
6
6
|
* Hold at least `min` of a **plain** ERC-721.
|
|
7
7
|
*
|
|
@@ -28,6 +28,15 @@ export const Erc721MinBalanceOptionsSchema = z.object({
|
|
|
28
28
|
contract: z.string(),
|
|
29
29
|
min: z.number().int().positive().default(1)
|
|
30
30
|
});
|
|
31
|
+
/**
|
|
32
|
+
* One pinned-block balance as a {@link RuleResult}. `penalize` is left at its default `true`:
|
|
33
|
+
* this rule reads the block the bundle itself names, so every honest verifier computes the same
|
|
34
|
+
* answer forever and a failure IS attributable to whoever sent it.
|
|
35
|
+
*/
|
|
36
|
+
function pinnedResult(balance, min, contract) {
|
|
37
|
+
const score = scoreOf(balance, min);
|
|
38
|
+
return score > 0n ? { success: true, score } : { success: false, error: shortfallError(balance, min, contract) };
|
|
39
|
+
}
|
|
31
40
|
export const erc721MinBalance = {
|
|
32
41
|
type: "erc721-min-balance",
|
|
33
42
|
optionsSchema: Erc721MinBalanceOptionsSchema,
|
|
@@ -45,7 +54,7 @@ export const erc721MinBalance = {
|
|
|
45
54
|
block: wallet.sampleBlock,
|
|
46
55
|
ctx
|
|
47
56
|
});
|
|
48
|
-
return
|
|
57
|
+
return pinnedResult(balance, options.min, getAddress(options.contract));
|
|
49
58
|
},
|
|
50
59
|
async evaluateMany({ options, wallets, ctx }) {
|
|
51
60
|
const contract = getAddress(options.contract);
|
|
@@ -54,7 +63,7 @@ export const erc721MinBalance = {
|
|
|
54
63
|
// a group it is multicall3 `aggregate3` batching (chunking policy in nft-balance.ts) —
|
|
55
64
|
// the path the background chain verifier rides on a cold join; a client that cannot
|
|
56
65
|
// batch takes the per-wallet fallback.
|
|
57
|
-
const
|
|
66
|
+
const results = new Array(wallets.length);
|
|
58
67
|
const byBlock = new Map();
|
|
59
68
|
wallets.forEach((wallet, i) => byBlock.set(wallet.sampleBlock, [...(byBlock.get(wallet.sampleBlock) ?? []), i]));
|
|
60
69
|
await Promise.all([...byBlock].map(async ([block, indexes]) => {
|
|
@@ -65,9 +74,9 @@ export const erc721MinBalance = {
|
|
|
65
74
|
balances: await Promise.all(group.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
|
|
66
75
|
};
|
|
67
76
|
indexes.forEach((at, i) => {
|
|
68
|
-
|
|
77
|
+
results[at] = pinnedResult(balances[i], options.min, contract);
|
|
69
78
|
});
|
|
70
79
|
}));
|
|
71
|
-
return { results:
|
|
80
|
+
return { results: results.map((result) => result) };
|
|
72
81
|
}
|
|
73
82
|
};
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { ChainReadContext } from "./types.js";
|
|
2
2
|
/** Score from one balance: the holding when it meets `min`, else `0n` (does not qualify). */
|
|
3
3
|
export declare function scoreOf(balance: bigint, min: number): bigint;
|
|
4
|
+
/**
|
|
5
|
+
* The voter-facing wording for a token-count shortfall ({@link RuleResult.error}), shared by
|
|
6
|
+
* every balance-scored rule so they explain themselves identically.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately generic: a rule knows a contract address and a threshold, not that the deployment
|
|
9
|
+
* calls this token a "5chan Pass". A client renders it verbatim, which is the point — it then
|
|
10
|
+
* needs to know nothing about which block the rule read or what `min` is.
|
|
11
|
+
*/
|
|
12
|
+
export declare function shortfallError(balance: bigint, min: number, contract: string): string;
|
|
4
13
|
/**
|
|
5
14
|
* True when the client can run multicall3 `aggregate3` batches — it needs both the action and
|
|
6
15
|
* its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
|
|
@@ -28,6 +28,19 @@ const CHUNK_RETRY_DELAY_MS = CHAIN_CHUNK_RETRY_DELAY_MS;
|
|
|
28
28
|
export function scoreOf(balance, min) {
|
|
29
29
|
return balance >= BigInt(min) ? balance : 0n;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The voter-facing wording for a token-count shortfall ({@link RuleResult.error}), shared by
|
|
33
|
+
* every balance-scored rule so they explain themselves identically.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately generic: a rule knows a contract address and a threshold, not that the deployment
|
|
36
|
+
* calls this token a "5chan Pass". A client renders it verbatim, which is the point — it then
|
|
37
|
+
* needs to know nothing about which block the rule read or what `min` is.
|
|
38
|
+
*/
|
|
39
|
+
export function shortfallError(balance, min, contract) {
|
|
40
|
+
return balance === 0n
|
|
41
|
+
? `this wallet holds none of the gate token (${contract})`
|
|
42
|
+
: `this wallet holds ${balance} of the gate token (${contract}), but ${min} are required`;
|
|
43
|
+
}
|
|
31
44
|
/**
|
|
32
45
|
* True when the client can run multicall3 `aggregate3` batches — it needs both the action and
|
|
33
46
|
* its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RuleResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* `undefined` when the wallet is admitted, else why it was not and whether the sender may be
|
|
4
|
+
* blamed for it.
|
|
5
|
+
*
|
|
6
|
+
* `penalize` is normalised here (defaulting to `true`) so callers never repeat the
|
|
7
|
+
* `!== false` dance, and a `success: true, score: 0n` — impossible per the contract, but not
|
|
8
|
+
* expressible in the type — is treated as a failure rather than silently admitting a
|
|
9
|
+
* zero-weight vote.
|
|
10
|
+
*/
|
|
11
|
+
export declare function gateFailure(result: RuleResult): {
|
|
12
|
+
error: string;
|
|
13
|
+
penalize: boolean;
|
|
14
|
+
} | undefined;
|
|
15
|
+
/** The weight slot's reading: a failure contributes nothing rather than dropping the vote. */
|
|
16
|
+
export declare function scoreOrZero(result: RuleResult): bigint;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two things the pipeline does with a {@link RuleResult}, in one place so the gate path and
|
|
3
|
+
* the background verifier cannot drift apart.
|
|
4
|
+
*
|
|
5
|
+
* Neither helper looks at which rule produced the result — that is the standing rule of this
|
|
6
|
+
* codebase (AGENTS.md, "What a rule owns, and what the pipeline owns"). They read the discriminant
|
|
7
|
+
* and nothing else.
|
|
8
|
+
*/
|
|
9
|
+
/** A rule that says `success: true` but scores nothing has a bug; refuse rather than admit. */
|
|
10
|
+
const NON_POSITIVE_SUCCESS = "the contest's rule reported success without a score, which is a bug in that rule";
|
|
11
|
+
/**
|
|
12
|
+
* `undefined` when the wallet is admitted, else why it was not and whether the sender may be
|
|
13
|
+
* blamed for it.
|
|
14
|
+
*
|
|
15
|
+
* `penalize` is normalised here (defaulting to `true`) so callers never repeat the
|
|
16
|
+
* `!== false` dance, and a `success: true, score: 0n` — impossible per the contract, but not
|
|
17
|
+
* expressible in the type — is treated as a failure rather than silently admitting a
|
|
18
|
+
* zero-weight vote.
|
|
19
|
+
*/
|
|
20
|
+
export function gateFailure(result) {
|
|
21
|
+
if (!result.success)
|
|
22
|
+
return { error: result.error, penalize: result.penalize !== false };
|
|
23
|
+
if (result.score <= 0n)
|
|
24
|
+
return { error: NON_POSITIVE_SUCCESS, penalize: true };
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/** The weight slot's reading: a failure contributes nothing rather than dropping the vote. */
|
|
28
|
+
export function scoreOrZero(result) {
|
|
29
|
+
return result.success && result.score > 0n ? result.score : 0n;
|
|
30
|
+
}
|
package/dist/rules/types.d.ts
CHANGED
|
@@ -12,49 +12,79 @@ import type { RuleCache } from "./cache.js";
|
|
|
12
12
|
* entries shadow builtins). The criteria still has two slots that draw from this one
|
|
13
13
|
* registry:
|
|
14
14
|
*
|
|
15
|
-
* - rule slot: the
|
|
16
|
-
*
|
|
15
|
+
* - rule slot: the result is a GATE. `success: true` admits the wallet, `success: false`
|
|
16
|
+
* rejects it and must say why ({@link RuleResult.error}).
|
|
17
|
+
* - weight slot: a successful result's `score` is the vote's MAGNITUDE; a failure is zero
|
|
18
|
+
* weight.
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* A single numeric return covers both roles: a rule that needs a threshold
|
|
21
|
-
* (min Passes, min balance) bakes it in by returning 0n when the wallet falls short.
|
|
22
|
-
* That is why the gate slot does not need a separate boolean kind.
|
|
20
|
+
* One return shape covers both roles: a rule that needs a threshold (min Passes, min balance)
|
|
21
|
+
* bakes it in by failing when the wallet falls short, so the gate slot needs no separate kind.
|
|
23
22
|
*/
|
|
24
23
|
/**
|
|
25
|
-
* The result of one evaluation
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
24
|
+
* The result of one evaluation: a wallet either qualifies with a score, or it does not and the
|
|
25
|
+
* rule says why.
|
|
26
|
+
*
|
|
27
|
+
* Modelled on pkc-js's `ChallengeResult` (community/schema.ts) — a `success` discriminant, with
|
|
28
|
+
* `error` REQUIRED on the failing branch. Making it required is the whole point: only the rule
|
|
29
|
+
* knows why a wallet fell short, and if that reason is optional it will simply be omitted, which
|
|
30
|
+
* leaves the pipeline with one undifferentiated "score is 0n" and forces every UI to re-derive
|
|
31
|
+
* the rule's thresholds and block choice to say anything useful. That duplication is exactly what
|
|
32
|
+
* {@link ChainReadContext} and {@link RuleCache} exist to prevent everywhere else, and it is what
|
|
33
|
+
* broke when the gate moved from a pinned block to the head: a client's hand-rolled explanation
|
|
34
|
+
* kept telling voters to wait for a window that no longer gated anything.
|
|
35
|
+
*
|
|
36
|
+
* - rule slot: `success: true` admits the wallet; `success: false` rejects it.
|
|
37
|
+
* - weight slot: `success: true` carries the vote's MAGNITUDE; `success: false` is zero weight.
|
|
38
|
+
*
|
|
39
|
+
* Final vote value = gate `success: false` ? `0n` : weight `success` ? weight `score` : `0n`.
|
|
40
|
+
*
|
|
41
|
+
* `score` MUST be `> 0n` on the success branch — "qualifies, with no weight" is not a state this
|
|
42
|
+
* models, and the pipeline treats a non-positive success score as a rule bug and refuses the
|
|
43
|
+
* wallet rather than silently admitting a zero-weight vote.
|
|
30
44
|
*/
|
|
31
|
-
export
|
|
45
|
+
export type RuleResult = {
|
|
46
|
+
success: true;
|
|
47
|
+
/** The wallet's magnitude. MUST be `> 0n`. */
|
|
32
48
|
score: bigint;
|
|
49
|
+
} | {
|
|
50
|
+
success: false;
|
|
33
51
|
/**
|
|
34
|
-
*
|
|
52
|
+
* Why this wallet does not qualify, phrased for the person who cast (or is about to
|
|
53
|
+
* cast) the vote. Surfaces on `VerifyFail.reason`, on `VoteEvictedError.verdict`, and
|
|
54
|
+
* from `Contest.checkEligibility`, so a client renders it verbatim and stays correct
|
|
55
|
+
* across rule changes it knows nothing about.
|
|
35
56
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
* Write it as a statement about the wallet, not about the library: "this wallet holds
|
|
58
|
+
* none of the gate token", not "gate rule returned 0". Do not cite block numbers unless
|
|
59
|
+
* they are actionable — a voter can do nothing with a sample block.
|
|
60
|
+
*/
|
|
61
|
+
error: string;
|
|
62
|
+
/**
|
|
63
|
+
* May this failure be blamed on the peer that sent the vote? Default `true`.
|
|
64
|
+
*
|
|
65
|
+
* The pipeline has two decisions the rule cannot make for it — whether the gossip
|
|
66
|
+
* forward-gate `reject`s the message (penalizing the delivering peer through
|
|
67
|
+
* gossipsub's invalid-message score, eventually pruning and graylisting it) or merely
|
|
68
|
+
* `ignore`s it, and whether the background verifier evicts the bundle at once or holds
|
|
69
|
+
* it for a grace window. Both hinge on one thing only the rule knows: is this failure
|
|
41
70
|
* attributable?
|
|
42
71
|
*
|
|
43
|
-
* `true` (the default) says every honest verifier necessarily computes this same
|
|
44
|
-
* of a read pinned to a historical block, since the block is named by
|
|
45
|
-
* chain's history does not move. The bundle is dropped, the sender
|
|
46
|
-
* cached as terminal.
|
|
72
|
+
* `true` (the default) says every honest verifier necessarily computes this same
|
|
73
|
+
* failure — true of a read pinned to a historical block, since the block is named by
|
|
74
|
+
* the bundle and the chain's history does not move. The bundle is dropped, the sender
|
|
75
|
+
* penalized, the verdict cached as terminal.
|
|
47
76
|
*
|
|
48
77
|
* `false` says an honest peer could legitimately disagree, so nobody may be blamed. The
|
|
49
78
|
* bundle is still dropped, but `ignore`-class — no penalty, verdict uncached, and the
|
|
50
|
-
* background verifier re-examines it for a grace window before giving up. This is what
|
|
51
|
-
* rule scoring the chain head must return: the peer that forwarded the vote verified
|
|
52
|
-
* against ITS head, and any peer ahead of us can legitimately see an acquisition we
|
|
53
|
-
* not. Penalizing there punishes honest relayers for being current — and does so
|
|
54
|
-
* when a wallet has just acquired the gate asset, which is when peer heads
|
|
79
|
+
* background verifier re-examines it for a grace window before giving up. This is what
|
|
80
|
+
* a rule scoring the chain head must return: the peer that forwarded the vote verified
|
|
81
|
+
* it against ITS head, and any peer ahead of us can legitimately see an acquisition we
|
|
82
|
+
* have not. Penalizing there punishes honest relayers for being current — and does so
|
|
83
|
+
* exactly when a wallet has just acquired the gate asset, which is when peer heads
|
|
84
|
+
* straddle.
|
|
55
85
|
*/
|
|
56
86
|
penalize?: boolean;
|
|
57
|
-
}
|
|
87
|
+
};
|
|
58
88
|
/** Everything a rule needs to read chain state and remember what it read. */
|
|
59
89
|
export interface ChainReadContext {
|
|
60
90
|
/**
|
|
@@ -101,8 +131,8 @@ export interface RuleWallet {
|
|
|
101
131
|
}
|
|
102
132
|
/**
|
|
103
133
|
* The one rule kind. `O` is the validated options type (from its `optionsSchema`).
|
|
104
|
-
* `evaluate` returns a
|
|
105
|
-
*
|
|
134
|
+
* `evaluate` returns a {@link RuleResult}: either `{ success: true, score }` with `score > 0n`,
|
|
135
|
+
* or `{ success: false, error }` — "does not qualify", with the reason the voter is shown.
|
|
106
136
|
*/
|
|
107
137
|
export interface Rule<O = unknown> {
|
|
108
138
|
readonly type: string;
|
package/dist/tally/tally.js
CHANGED
|
@@ -2,6 +2,7 @@ import { base58btc } from "multiformats/bases/base58";
|
|
|
2
2
|
import { sha256 } from "multiformats/hashes/sha2";
|
|
3
3
|
import { tickerForRef } from "../chain/ticker.js";
|
|
4
4
|
import { makeMemoryRuleCache } from "../rules/cache.js";
|
|
5
|
+
import { scoreOrZero } from "../rules/result.js";
|
|
5
6
|
import { UnknownRuleError } from "../errors.js";
|
|
6
7
|
/** Byte-lexicographic compare of two byte arrays (returns <0, 0, >0). */
|
|
7
8
|
function compareBytes(x, y) {
|
|
@@ -37,12 +38,13 @@ export function makeTally(deps) {
|
|
|
37
38
|
};
|
|
38
39
|
const weightFor = async (wallet, blockNumber) => {
|
|
39
40
|
const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(blockNumber));
|
|
40
|
-
|
|
41
|
+
// A weight rule that fails a wallet contributes nothing; it does NOT drop the vote —
|
|
42
|
+
// admission was the gate's decision, already made (see rules/result.ts).
|
|
43
|
+
return scoreOrZero(await weight.evaluate({
|
|
41
44
|
options: weightOptions,
|
|
42
45
|
wallet: { address: wallet, sampleBlock },
|
|
43
46
|
ctx: weightCtx
|
|
44
|
-
});
|
|
45
|
-
return score;
|
|
47
|
+
}));
|
|
46
48
|
};
|
|
47
49
|
/** The rolling tie seed for a community: sha256(bucketBlockHash ‖ publicKey bytes). */
|
|
48
50
|
const tieSeed = async (blockHash, publicKey) => {
|
|
@@ -116,7 +116,10 @@ export async function makeVoteNode(topic, options = {}) {
|
|
|
116
116
|
let verifyImpl = async () => okVerifier();
|
|
117
117
|
const verifier = {
|
|
118
118
|
verify: (bundle) => verifyImpl(bundle),
|
|
119
|
-
verifyOffline: (bundle) => verifyImpl(bundle)
|
|
119
|
+
verifyOffline: (bundle) => verifyImpl(bundle),
|
|
120
|
+
// These harness nodes drive the transport, not the eligibility surface; nothing here
|
|
121
|
+
// calls checkGate, so it admits rather than pretending to model a gate.
|
|
122
|
+
checkGate: async () => ({ success: true, score: 1n })
|
|
120
123
|
};
|
|
121
124
|
const admit = async ({ cid, bytes }) => {
|
|
122
125
|
await blockstore.put(cid, bytes);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { tickerForRef } from "../chain/ticker.js";
|
|
2
2
|
import { makeMemoryRuleCache } from "../rules/cache.js";
|
|
3
3
|
import { GATE_GRACE_MS, GATE_RETRY_MS } from "./gate-grace.js";
|
|
4
|
+
import { gateFailure, scoreOrZero } from "../rules/result.js";
|
|
4
5
|
import { UnknownRuleError } from "../errors.js";
|
|
5
6
|
import { resolveNameThroughCache } from "./name-resolution-cache.js";
|
|
6
7
|
const RETRY_BASE_MS = 2_000;
|
|
@@ -78,8 +79,8 @@ export function makeBackgroundVerifier(deps) {
|
|
|
78
79
|
for (const item of pending) {
|
|
79
80
|
const key = `${item.bundle.address.toLowerCase()}:${sampleBlockFor(item.bundle)}`;
|
|
80
81
|
const result = results[at.get(key)];
|
|
81
|
-
item.
|
|
82
|
-
item.
|
|
82
|
+
item.gateFailed = gateFailure(result);
|
|
83
|
+
item.ruleScore = scoreOrZero(result);
|
|
83
84
|
item.gateDone = true;
|
|
84
85
|
}
|
|
85
86
|
}
|
|
@@ -143,13 +144,13 @@ export function makeBackgroundVerifier(deps) {
|
|
|
143
144
|
requeue.push(item); // gate read never happened (infra) — retry the whole item
|
|
144
145
|
continue;
|
|
145
146
|
}
|
|
146
|
-
if (item.
|
|
147
|
-
if (item.
|
|
147
|
+
if (item.gateFailed) {
|
|
148
|
+
if (item.gateFailed.penalize) {
|
|
148
149
|
// Provable, deterministic reject — safe to cache so a re-publish short-circuits.
|
|
149
150
|
const verdict = {
|
|
150
151
|
valid: false,
|
|
151
152
|
disposition: "reject",
|
|
152
|
-
reason: `not admitted:
|
|
153
|
+
reason: `not admitted: ${item.gateFailed.error}`
|
|
153
154
|
};
|
|
154
155
|
cache.set(item.cid, verdict);
|
|
155
156
|
deps.onEvict(item.cid, verdict);
|
|
@@ -157,7 +158,7 @@ export function makeBackgroundVerifier(deps) {
|
|
|
157
158
|
continue;
|
|
158
159
|
}
|
|
159
160
|
// The rule declined to blame anyone (see rules/types.ts, RuleResult.penalize):
|
|
160
|
-
//
|
|
161
|
+
// the failure means "not yet", not "no". The wallet may have acquired the asset in
|
|
161
162
|
// a block this verifier has not seen, or may acquire it a moment from now — a
|
|
162
163
|
// client that signs the instant it mints races its own transaction. Evicting here
|
|
163
164
|
// would make whether a vote counts depend on whose RPC was a few blocks ahead, so
|
|
@@ -173,7 +174,7 @@ export function makeBackgroundVerifier(deps) {
|
|
|
173
174
|
const verdict = {
|
|
174
175
|
valid: false,
|
|
175
176
|
disposition: "ignore",
|
|
176
|
-
reason: `not admitted:
|
|
177
|
+
reason: `not admitted: ${item.gateFailed.error} (still true after the grace window)`
|
|
177
178
|
};
|
|
178
179
|
deps.onEvict(item.cid, verdict);
|
|
179
180
|
settle(item);
|
|
@@ -260,7 +261,7 @@ export function makeBackgroundVerifier(deps) {
|
|
|
260
261
|
gateDone: false,
|
|
261
262
|
gateNotified: false,
|
|
262
263
|
ruleScore: 0n,
|
|
263
|
-
|
|
264
|
+
gateFailed: undefined,
|
|
264
265
|
resolvedNames: {},
|
|
265
266
|
queuedAt: Date.now()
|
|
266
267
|
});
|
package/dist/verify/bundle.d.ts
CHANGED
|
@@ -11,10 +11,11 @@ import type { BundleVerifier } from "./types.js";
|
|
|
11
11
|
*
|
|
12
12
|
* 1. signature (local, µs): recover the EIP-712 signer, must equal `bundle.address`.
|
|
13
13
|
* 2. constraints (local, µs): `votes.length <= maxVotesPerAddress`, each vote in range.
|
|
14
|
-
* 3. gate (chain): the `rule`
|
|
15
|
-
* itself reads (rules/types.ts).
|
|
16
|
-
* a `reject` when the rule blames the sender for it and an
|
|
17
|
-
* `ignore` when it does not
|
|
14
|
+
* 3. gate (chain): the `rule` must return `success: true`, at whichever block the
|
|
15
|
+
* rule itself reads (rules/types.ts). A failure -> not admitted ->
|
|
16
|
+
* drop, as a `reject` when the rule blames the sender for it and an
|
|
17
|
+
* `ignore` when it does not, carrying the rule's own `error` text
|
|
18
|
+
* as the verdict reason so the voter is told what actually failed.
|
|
18
19
|
* 4. name (network): each vote's `community.name` (if any) must resolve to the
|
|
19
20
|
* claimed `publicKey`; a squatted/absent name drops the bundle.
|
|
20
21
|
*
|
package/dist/verify/bundle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { tickerForRef } from "../chain/ticker.js";
|
|
2
2
|
import { makeMemoryRuleCache } from "../rules/cache.js";
|
|
3
|
+
import { gateFailure, scoreOrZero } from "../rules/result.js";
|
|
3
4
|
import { UnknownRuleError } from "../errors.js";
|
|
4
5
|
import { verifyBundleSignature } from "./signature.js";
|
|
5
6
|
import { checkBundleConstraints } from "./constraints.js";
|
|
@@ -32,6 +33,10 @@ export function makeBundleVerifier(deps) {
|
|
|
32
33
|
};
|
|
33
34
|
return {
|
|
34
35
|
verifyOffline,
|
|
36
|
+
// The gate step on its own, against the SAME rule/options/ctx `verify` uses below — which
|
|
37
|
+
// is the entire value of exposing it: a caller asking "would this wallet's vote count?"
|
|
38
|
+
// can never drift from what the gate actually does.
|
|
39
|
+
checkGate: ({ address, sampleBlock }) => rule.evaluate({ options: ruleOptions, wallet: { address, sampleBlock }, ctx }),
|
|
35
40
|
async verify(bundle) {
|
|
36
41
|
const offline = await verifyOffline(bundle);
|
|
37
42
|
if (!offline.valid)
|
|
@@ -40,24 +45,29 @@ export function makeBundleVerifier(deps) {
|
|
|
40
45
|
// sees fit. The bundle's bucketized sample block is handed over as the pinned
|
|
41
46
|
// block the ballot names; a rule scoring current state ignores it for `ctx.head`.
|
|
42
47
|
const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(bundle.blockNumber));
|
|
43
|
-
const
|
|
48
|
+
const gate = await rule.evaluate({
|
|
44
49
|
options: ruleOptions,
|
|
45
50
|
wallet: { address: bundle.address, sampleBlock },
|
|
46
51
|
ctx
|
|
47
52
|
});
|
|
48
|
-
|
|
53
|
+
const gateFailed = gateFailure(gate);
|
|
54
|
+
if (gateFailed) {
|
|
49
55
|
// Disposition comes from the rule's own answer, never from the rule's identity.
|
|
50
|
-
// A
|
|
51
|
-
// `reject`: the sender is penalized and the verdict cached as terminal.
|
|
56
|
+
// A failure the rule stands behind is identical on every honest verifier, so it is
|
|
57
|
+
// a `reject`: the sender is penalized and the verdict cached as terminal. One it
|
|
52
58
|
// will not blame anyone for — the rule read this verifier's head, where my view
|
|
53
59
|
// and yours legitimately differ — drops the bundle just the same but stays
|
|
54
60
|
// `ignore`-class: no penalty for a relayer that saw a fresher chain, and
|
|
55
61
|
// uncached, so it is re-judged rather than frozen (the same treatment community
|
|
56
62
|
// name resolution has always had, step 4 below).
|
|
63
|
+
//
|
|
64
|
+
// The reason is the RULE's wording, verbatim: only it knows whether this wallet
|
|
65
|
+
// holds too few, holds none, or faces a contract that gates nothing, and that
|
|
66
|
+
// sentence is what reaches the voter through `VoteEvictedError`.
|
|
57
67
|
return {
|
|
58
68
|
valid: false,
|
|
59
|
-
disposition: penalize
|
|
60
|
-
reason: `not admitted:
|
|
69
|
+
disposition: gateFailed.penalize ? "reject" : "ignore",
|
|
70
|
+
reason: `not admitted: ${gateFailed.error}`
|
|
61
71
|
};
|
|
62
72
|
}
|
|
63
73
|
// 4. Community-name resolution (network) — a carried name is a claim, verified against
|
|
@@ -92,7 +102,7 @@ export function makeBundleVerifier(deps) {
|
|
|
92
102
|
}
|
|
93
103
|
resolvedNames[name] = record.publicKey;
|
|
94
104
|
}
|
|
95
|
-
return { valid: true, ruleScore:
|
|
105
|
+
return { valid: true, ruleScore: scoreOrZero(gate), resolvedNames };
|
|
96
106
|
}
|
|
97
107
|
};
|
|
98
108
|
}
|
package/dist/verify/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { VotesBundle } from "../schema/votes.js";
|
|
2
|
+
import type { RuleResult } from "../rules/types.js";
|
|
2
3
|
/**
|
|
3
4
|
* Verification interfaces, design only.
|
|
4
5
|
*
|
|
@@ -83,6 +84,19 @@ export interface BundleVerifier {
|
|
|
83
84
|
* DESIGN.md "Background chain verification").
|
|
84
85
|
*/
|
|
85
86
|
verifyOffline(bundle: VotesBundle): Promise<VerifyResult>;
|
|
87
|
+
/**
|
|
88
|
+
* Step 3 alone, for a wallet rather than a bundle: run the gate rule and hand back its raw
|
|
89
|
+
* {@link RuleResult}. Backs `Contest.checkEligibility`, so a client can ask "would this vote
|
|
90
|
+
* count?" through the very same rule instance, options, chain client, head reader and memo
|
|
91
|
+
* the forward gate uses — never a reimplementation of them.
|
|
92
|
+
*
|
|
93
|
+
* `sampleBlock` is the pinned block the prospective ballot would name (the caller's current
|
|
94
|
+
* bucket). A head-scoring rule ignores it exactly as it does during verification.
|
|
95
|
+
*/
|
|
96
|
+
checkGate(args: {
|
|
97
|
+
address: string;
|
|
98
|
+
sampleBlock: number;
|
|
99
|
+
}): Promise<RuleResult>;
|
|
86
100
|
}
|
|
87
101
|
/**
|
|
88
102
|
* The per-bundle record of the two deferred *network* checks. The offline checks (signature,
|