@bitsocial/pubsub-voting 0.2.0 → 0.3.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 +42 -6
- package/dist/client/voter.js +48 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/rules/cache.d.ts +107 -0
- package/dist/rules/cache.js +160 -0
- package/dist/rules/erc20-balance.js +7 -3
- package/dist/rules/erc5192-min-balance.d.ts +17 -9
- package/dist/rules/erc5192-min-balance.js +158 -36
- package/dist/rules/erc721-min-balance.js +35 -10
- package/dist/rules/nft-balance.d.ts +24 -6
- package/dist/rules/nft-balance.js +16 -13
- package/dist/rules/types.d.ts +82 -16
- package/dist/tally/tally.d.ts +14 -0
- package/dist/tally/tally.js +16 -2
- package/dist/verify/background.d.ts +42 -11
- package/dist/verify/background.js +91 -37
- package/dist/verify/bundle.d.ts +23 -9
- package/dist/verify/bundle.js +33 -19
- package/dist/verify/gate-grace.d.ts +32 -0
- package/dist/verify/gate-grace.js +32 -0
- package/package.json +2 -1
- package/dist/verify/gate-result-cache.d.ts +0 -65
- package/dist/verify/gate-result-cache.js +0 -91
|
@@ -5,19 +5,27 @@ import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.j
|
|
|
5
5
|
/**
|
|
6
6
|
* Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
|
|
7
7
|
*
|
|
8
|
-
* Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the
|
|
9
|
-
* if it meets `min`, else `0n` — plus one assertion at the SAME
|
|
8
|
+
* Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the sampled block
|
|
9
|
+
* if it meets `min`, else `0n` — plus one assertion at the SAME block: the contract must
|
|
10
10
|
* declare ERC-5192 (`supportsInterface(0xb45a3c0e)`). A contract that does not declare it scores
|
|
11
11
|
* `0n` for every wallet, so the contest admits nobody rather than gating on a transferable asset.
|
|
12
12
|
*
|
|
13
|
+
* Unlike the other rules in the tree, this one scores at the verifier's CURRENT head rather than
|
|
14
|
+
* at the bundle's bucket boundary, falling back to that boundary only when the head read refuses
|
|
15
|
+
* — so a freshly-acquired Pass counts immediately. See `evaluateMany` for why both legs exist,
|
|
16
|
+
* and rules/types.ts for what the pipeline does with `penalize: false`.
|
|
17
|
+
*
|
|
13
18
|
* **Why the assertion is the whole point.** The gate bounds Sybils only if the gating asset
|
|
14
|
-
* cannot move (DESIGN.md "Does one Pass mean one vote?")
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* cannot move (DESIGN.md "Does one Pass mean one vote?"). A vote is verified ONCE, when it is
|
|
20
|
+
* merged, and then stays live for `voteExpiryBuckets` in a winner set LWW-keyed per wallet — so
|
|
21
|
+
* one transferable token walked A → B → C inside a single expiry window backs three concurrent
|
|
22
|
+
* live votes, each read true when it was checked and none collapsed by LWW. Nothing in the
|
|
23
|
+
* verify pipeline can see that: every ballot is individually correct. (Reading pinned blocks,
|
|
24
|
+
* the three reads land at three different historical blocks; reading the head, at three
|
|
25
|
+
* different verification times. Transferability defeats both.) Requiring the asset to be
|
|
26
|
+
* non-transferable AND to say so on-chain closes it with no wire change — and it is the same
|
|
27
|
+
* property that makes scoring at the head sound at all. Pinned by
|
|
28
|
+
* `src/crdt/amplification.test.ts`.
|
|
21
29
|
*
|
|
22
30
|
* **What the assertion does and does not prove.** `supportsInterface(0xb45a3c0e)` asserts the
|
|
23
31
|
* contract *reports* lock state — ERC-5192's only function is `locked(uint256)`. ERC-5192
|
|
@@ -63,49 +71,163 @@ function isContractRefusal(err) {
|
|
|
63
71
|
return err.walk((cause) => cause instanceof ContractFunctionRevertedError || cause instanceof ContractFunctionZeroDataError) !== null;
|
|
64
72
|
}
|
|
65
73
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
74
|
+
* How coarsely a head-scored read is memoized, in blocks (~1 minute on Base's 2 s blocks).
|
|
75
|
+
*
|
|
76
|
+
* The read itself is at the freshest head this verifier has — that is the whole point, a Pass
|
|
77
|
+
* acquired seconds ago must count now. Only the cache EPOCH is quantized, and it has to be:
|
|
78
|
+
* an epoch that moved every block would never hit, and the memo is what stops an ineligible
|
|
79
|
+
* wallet from costing every peer one chain read per fresh-signed bundle (see rules/cache.ts).
|
|
80
|
+
*
|
|
81
|
+
* The number to reason about is what a stale NEGATIVE costs: a wallet already checked and failed
|
|
82
|
+
* stays failed on this verifier until the epoch rolls, even if it acquires the Pass in between —
|
|
83
|
+
* so it is deliberately short. A positive is unaffected in practice (a holding that cannot move
|
|
84
|
+
* cannot stop being true), and re-reading one when the epoch rolls is a wasted read, never a
|
|
85
|
+
* wrong answer. Local resource policy, NOT consensus: two peers may quantize differently and
|
|
86
|
+
* still agree on every verdict, which is why it lives here and not in the criteria.
|
|
70
87
|
*/
|
|
71
|
-
|
|
88
|
+
const HEAD_EPOCH_BLOCKS = 30;
|
|
89
|
+
/** Cache-key prefixes: head-scored entries expire with the head, pinned ones never do. */
|
|
90
|
+
const HEAD_PREFIX = "head/";
|
|
91
|
+
const PINNED_PREFIX = "pin/";
|
|
92
|
+
/** One `supportsInterface(0xb45a3c0e)` at `block`. Revert/zero-data ⇒ "does not declare". */
|
|
93
|
+
async function declaresErc5192(args) {
|
|
72
94
|
try {
|
|
73
|
-
|
|
74
|
-
address: contract,
|
|
95
|
+
const declares = await args.ctx.chain.readContract({
|
|
96
|
+
address: args.contract,
|
|
75
97
|
abi: erc165Abi,
|
|
76
98
|
functionName: "supportsInterface",
|
|
77
99
|
args: [ERC5192_INTERFACE_ID],
|
|
78
|
-
blockNumber: BigInt(
|
|
100
|
+
blockNumber: BigInt(args.block)
|
|
79
101
|
});
|
|
102
|
+
return { declares };
|
|
80
103
|
}
|
|
81
104
|
catch (err) {
|
|
82
105
|
if (isContractRefusal(err))
|
|
83
|
-
return false;
|
|
106
|
+
return { declares: false };
|
|
84
107
|
throw err;
|
|
85
108
|
}
|
|
86
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Score every wallet at ONE block, memoized under one epoch — the whole rule body, run once per
|
|
112
|
+
* leg (see `evaluateMany`).
|
|
113
|
+
*
|
|
114
|
+
* Two memos, both through the rule's cache: the ERC-5192 declaration, keyed per CONTRACT (it is
|
|
115
|
+
* not a per-wallet fact, so a whole checkpoint's wallets share one probe — a key shape the old
|
|
116
|
+
* per-wallet gate cache could not express at all), and each wallet's raw balance. Balances are
|
|
117
|
+
* cached rather than scores so a change to `min` cannot be served a stale verdict; the batched
|
|
118
|
+
* read behind the misses is one multicall3 `aggregate3` per 200 wallets.
|
|
119
|
+
*/
|
|
120
|
+
async function scoreAt(args) {
|
|
121
|
+
const { contract, min, wallets, block, epoch, prefix, ctx } = args;
|
|
122
|
+
if (wallets.length === 0)
|
|
123
|
+
return { scores: [] };
|
|
124
|
+
const [declared] = (await ctx.cache.memoMany({
|
|
125
|
+
keys: [`${prefix}lock/${contract.toLowerCase()}`],
|
|
126
|
+
epoch,
|
|
127
|
+
read: async () => {
|
|
128
|
+
const { declares } = await declaresErc5192({ contract, block, ctx });
|
|
129
|
+
return { values: [declares ? "1" : "0"] };
|
|
130
|
+
}
|
|
131
|
+
})).values;
|
|
132
|
+
// A contract that does not claim its tokens are locked gates nothing: admit nobody rather
|
|
133
|
+
// than gate on something transferable (see the rule doc above).
|
|
134
|
+
if (declared !== "1")
|
|
135
|
+
return { scores: wallets.map(() => 0n) };
|
|
136
|
+
const { values } = await ctx.cache.memoMany({
|
|
137
|
+
keys: wallets.map((wallet) => `${prefix}bal/${wallet.toLowerCase()}`),
|
|
138
|
+
epoch,
|
|
139
|
+
read: async ({ keys }) => {
|
|
140
|
+
const missing = keys.map((key) => key.slice(key.lastIndexOf("/") + 1));
|
|
141
|
+
const { balances } = canBatch({ ctx }).batchable
|
|
142
|
+
? await balancesOfBatched({ contract, wallets: missing, block, ctx })
|
|
143
|
+
: {
|
|
144
|
+
balances: await Promise.all(missing.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
|
|
145
|
+
};
|
|
146
|
+
return { values: balances.map((balance) => balance.toString()) };
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return { scores: values.map((balance) => scoreOf(BigInt(balance), min)) };
|
|
150
|
+
}
|
|
87
151
|
export const erc5192MinBalance = {
|
|
88
152
|
type: "erc5192-min-balance",
|
|
89
153
|
optionsSchema: Erc5192MinBalanceOptionsSchema,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
return
|
|
154
|
+
// One wallet is the batch of one: the two legs, the two epochs and the memos are identical,
|
|
155
|
+
// so there is one body. Called through `erc5192MinBalance.evaluateMany` rather than `this`,
|
|
156
|
+
// which a destructured or re-exported rule object would not carry (issue #29).
|
|
157
|
+
async evaluate({ options, wallet, ctx }) {
|
|
158
|
+
const { results } = await erc5192MinBalance.evaluateMany({ options, wallets: [wallet], ctx });
|
|
159
|
+
return results[0];
|
|
96
160
|
},
|
|
97
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Score at the HEAD first, falling back to each wallet's own pinned block.
|
|
163
|
+
*
|
|
164
|
+
* **Head first** is what lets a wallet vote in the block it acquires the Pass. Scoring only
|
|
165
|
+
* at the bundle's bucket boundary — the block a ballot names, floored — meant waiting up to
|
|
166
|
+
* a full bucket (an hour on 5chan's live manifest) before a fresh holding was visible, which
|
|
167
|
+
* was never a chain requirement: a read pinned at block N already sees a mint that happened
|
|
168
|
+
* in N. It is sound because a soulbound holding cannot move, so a peer whose head lags can
|
|
169
|
+
* only be LATE to admit a vote, never in lasting disagreement about it.
|
|
170
|
+
*
|
|
171
|
+
* **The pinned fallback** covers what that reasoning does not: ERC-5192 requires transfers to
|
|
172
|
+
* revert while locked, but says nothing about BURNING (a burn does not go through
|
|
173
|
+
* `transferFrom`), so a compliant Pass may be burnable and this rule cannot check otherwise.
|
|
174
|
+
* Without the fallback, a burn would make a peer that verified earlier keep a vote its
|
|
175
|
+
* checkpoint still serves while a cold joiner rejects it — divergence for up to the expiry
|
|
176
|
+
* window — and would hand whoever can burn a retroactive veto over votes already cast.
|
|
177
|
+
* Reading the wallet's own `sampleBlock` when the head says `0n` restores agreement for any
|
|
178
|
+
* vote that was legitimately held when it was cast. It admits nothing the pinned-only v1 gate
|
|
179
|
+
* did not, so it opens no new amplification; a burn-and-remint to a fresh wallet is a
|
|
180
|
+
* transfer by another name and remains a property of the deployment, not something any read
|
|
181
|
+
* can close. Its cost is that a verifier still needs archive depth for that leg.
|
|
182
|
+
*
|
|
183
|
+
* **Two epochs**, which is why this caching lives in the rule: the head leg expires with the
|
|
184
|
+
* head (a stale `0n` must not outlive {@link HEAD_EPOCH_BLOCKS}), while the pinned leg is a
|
|
185
|
+
* historical read that is true forever and is keyed by the block itself.
|
|
186
|
+
*
|
|
187
|
+
* **`penalize: false`** on every result: neither leg makes a `0n` attributable. The peer that
|
|
188
|
+
* forwarded a vote verified it against ITS head, and any peer ahead of us may legitimately
|
|
189
|
+
* see an acquisition we have not — and with burning possible, not holding at either block
|
|
190
|
+
* does not even prove the wallet never held.
|
|
191
|
+
*/
|
|
192
|
+
async evaluateMany({ options, wallets, ctx }) {
|
|
98
193
|
const contract = getAddress(options.contract);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
194
|
+
const { block: head } = await ctx.head();
|
|
195
|
+
const epoch = Math.floor(head / HEAD_EPOCH_BLOCKS) * HEAD_EPOCH_BLOCKS;
|
|
196
|
+
// Everything behind the current window is unreachable — nothing will look it up again.
|
|
197
|
+
// Scoped to the head keys, so the permanently-valid pinned entries are left alone.
|
|
198
|
+
ctx.cache.purgeBelow({ epoch, keyPrefix: HEAD_PREFIX });
|
|
199
|
+
const { scores } = await scoreAt({
|
|
200
|
+
contract,
|
|
201
|
+
min: options.min,
|
|
202
|
+
wallets: wallets.map((wallet) => wallet.address),
|
|
203
|
+
block: head,
|
|
204
|
+
epoch,
|
|
205
|
+
prefix: HEAD_PREFIX,
|
|
206
|
+
ctx
|
|
207
|
+
});
|
|
208
|
+
// Only wallets the head leg refused reach the fallback, so a holder costs one read path.
|
|
209
|
+
// Grouped by sample block: bundles from different buckets name different pinned blocks.
|
|
210
|
+
const byBlock = new Map();
|
|
211
|
+
scores.forEach((score, i) => {
|
|
212
|
+
if (score > 0n)
|
|
213
|
+
return;
|
|
214
|
+
const block = wallets[i].sampleBlock;
|
|
215
|
+
byBlock.set(block, [...(byBlock.get(block) ?? []), i]);
|
|
216
|
+
});
|
|
217
|
+
await Promise.all([...byBlock].map(async ([block, indexes]) => {
|
|
218
|
+
const fallback = await scoreAt({
|
|
219
|
+
contract,
|
|
220
|
+
min: options.min,
|
|
221
|
+
wallets: indexes.map((i) => wallets[i].address),
|
|
222
|
+
block,
|
|
223
|
+
epoch: block,
|
|
224
|
+
prefix: PINNED_PREFIX,
|
|
225
|
+
ctx
|
|
226
|
+
});
|
|
227
|
+
indexes.forEach((at, i) => {
|
|
228
|
+
scores[at] = fallback.scores[i];
|
|
229
|
+
});
|
|
230
|
+
}));
|
|
231
|
+
return { results: scores.map((score) => ({ score, penalize: false })) };
|
|
110
232
|
}
|
|
111
233
|
};
|
|
@@ -31,18 +31,43 @@ export const Erc721MinBalanceOptionsSchema = z.object({
|
|
|
31
31
|
export const erc721MinBalance = {
|
|
32
32
|
type: "erc721-min-balance",
|
|
33
33
|
optionsSchema: Erc721MinBalanceOptionsSchema,
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// Scores at the bundle's OWN pinned block, and deliberately not at the head: a transferable
|
|
35
|
+
// balance can go DOWN, so a vote admitted today would silently become invalid the moment the
|
|
36
|
+
// token moved, and whether it still counted would depend on when each peer last looked. A
|
|
37
|
+
// pinned read is identical on every verifier forever, which is what leaves `penalize` at its
|
|
38
|
+
// default — a `0n` here IS attributable to the sender. That difference is a second,
|
|
39
|
+
// independent reason this rule stays out of `builtinRegistry`, on top of the Sybil
|
|
40
|
+
// amplification described in registry.ts.
|
|
41
|
+
async evaluate({ options, wallet, ctx }) {
|
|
42
|
+
const { balance } = await balanceOf({
|
|
43
|
+
contract: getAddress(options.contract),
|
|
44
|
+
wallet: wallet.address,
|
|
45
|
+
block: wallet.sampleBlock,
|
|
46
|
+
ctx
|
|
47
|
+
});
|
|
36
48
|
return { score: scoreOf(balance, options.min) };
|
|
37
49
|
},
|
|
38
|
-
async evaluateMany({ options,
|
|
50
|
+
async evaluateMany({ options, wallets, ctx }) {
|
|
39
51
|
const contract = getAddress(options.contract);
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
// Grouped by sample block, because a batch is no longer guaranteed to share one: the
|
|
53
|
+
// pipeline hands over whatever is pending and each rule groups the way it reads. Within
|
|
54
|
+
// a group it is multicall3 `aggregate3` batching (chunking policy in nft-balance.ts) —
|
|
55
|
+
// the path the background chain verifier rides on a cold join; a client that cannot
|
|
56
|
+
// batch takes the per-wallet fallback.
|
|
57
|
+
const scores = new Array(wallets.length);
|
|
58
|
+
const byBlock = new Map();
|
|
59
|
+
wallets.forEach((wallet, i) => byBlock.set(wallet.sampleBlock, [...(byBlock.get(wallet.sampleBlock) ?? []), i]));
|
|
60
|
+
await Promise.all([...byBlock].map(async ([block, indexes]) => {
|
|
61
|
+
const group = indexes.map((i) => wallets[i].address);
|
|
62
|
+
const { balances } = canBatch({ ctx }).batchable
|
|
63
|
+
? await balancesOfBatched({ contract, wallets: group, block, ctx })
|
|
64
|
+
: {
|
|
65
|
+
balances: await Promise.all(group.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
|
|
66
|
+
};
|
|
67
|
+
indexes.forEach((at, i) => {
|
|
68
|
+
scores[at] = scoreOf(balances[i], options.min);
|
|
69
|
+
});
|
|
70
|
+
}));
|
|
71
|
+
return { results: scores.map((score) => ({ score })) };
|
|
47
72
|
}
|
|
48
73
|
};
|
|
@@ -6,12 +6,23 @@ export declare function scoreOf(balance: bigint, min: number): bigint;
|
|
|
6
6
|
* its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
|
|
7
7
|
* multicall3) takes the per-wallet path instead.
|
|
8
8
|
*/
|
|
9
|
-
export declare function canBatch(
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
export declare function canBatch(args: {
|
|
10
|
+
ctx: ChainReadContext;
|
|
11
|
+
}): {
|
|
12
|
+
batchable: boolean;
|
|
13
|
+
};
|
|
14
|
+
/** One wallet's `balanceOf` at `block` (the caller's choice — a pinned block or the head). */
|
|
15
|
+
export declare function balanceOf(args: {
|
|
16
|
+
contract: `0x${string}`;
|
|
17
|
+
wallet: string;
|
|
18
|
+
block: number;
|
|
19
|
+
ctx: ChainReadContext;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
balance: bigint;
|
|
22
|
+
}>;
|
|
12
23
|
/**
|
|
13
|
-
* Many wallets' `balanceOf` at ONE
|
|
14
|
-
*
|
|
24
|
+
* Many wallets' `balanceOf` at ONE block — the path the background chain verifier rides on a
|
|
25
|
+
* cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
|
|
15
26
|
*
|
|
16
27
|
* The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
|
|
17
28
|
* viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
|
|
@@ -20,4 +31,11 @@ export declare function balanceOf(contract: `0x${string}`, walletAddress: string
|
|
|
20
31
|
* completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
|
|
21
32
|
* twice still fails the whole call: the caller gets one rejection, not partial results.
|
|
22
33
|
*/
|
|
23
|
-
export declare function balancesOfBatched(
|
|
34
|
+
export declare function balancesOfBatched(args: {
|
|
35
|
+
contract: `0x${string}`;
|
|
36
|
+
wallets: string[];
|
|
37
|
+
block: number;
|
|
38
|
+
ctx: ChainReadContext;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
balances: bigint[];
|
|
41
|
+
}>;
|
|
@@ -33,22 +33,24 @@ export function scoreOf(balance, min) {
|
|
|
33
33
|
* its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
|
|
34
34
|
* multicall3) takes the per-wallet path instead.
|
|
35
35
|
*/
|
|
36
|
-
export function canBatch(
|
|
37
|
-
|
|
36
|
+
export function canBatch(args) {
|
|
37
|
+
const { ctx } = args;
|
|
38
|
+
return { batchable: typeof ctx.chain.multicall === "function" && Boolean(ctx.chain.chain?.contracts?.multicall3) };
|
|
38
39
|
}
|
|
39
|
-
/** One wallet's `balanceOf` at the
|
|
40
|
-
export function balanceOf(
|
|
41
|
-
|
|
42
|
-
address: contract,
|
|
40
|
+
/** One wallet's `balanceOf` at `block` (the caller's choice — a pinned block or the head). */
|
|
41
|
+
export async function balanceOf(args) {
|
|
42
|
+
const balance = await args.ctx.chain.readContract({
|
|
43
|
+
address: args.contract,
|
|
43
44
|
abi: erc721Abi,
|
|
44
45
|
functionName: "balanceOf",
|
|
45
|
-
args: [getAddress(
|
|
46
|
-
blockNumber: BigInt(
|
|
46
|
+
args: [getAddress(args.wallet)],
|
|
47
|
+
blockNumber: BigInt(args.block)
|
|
47
48
|
});
|
|
49
|
+
return { balance };
|
|
48
50
|
}
|
|
49
51
|
/**
|
|
50
|
-
* Many wallets' `balanceOf` at ONE
|
|
51
|
-
*
|
|
52
|
+
* Many wallets' `balanceOf` at ONE block — the path the background chain verifier rides on a
|
|
53
|
+
* cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
|
|
52
54
|
*
|
|
53
55
|
* The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
|
|
54
56
|
* viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
|
|
@@ -57,7 +59,8 @@ export function balanceOf(contract, walletAddress, ctx) {
|
|
|
57
59
|
* completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
|
|
58
60
|
* twice still fails the whole call: the caller gets one rejection, not partial results.
|
|
59
61
|
*/
|
|
60
|
-
export async function balancesOfBatched(
|
|
62
|
+
export async function balancesOfBatched(args) {
|
|
63
|
+
const { contract, wallets: walletAddresses, block, ctx } = args;
|
|
61
64
|
const chunks = [];
|
|
62
65
|
for (let at = 0; at < walletAddresses.length; at += READS_PER_MULTICALL) {
|
|
63
66
|
chunks.push(walletAddresses.slice(at, at + READS_PER_MULTICALL));
|
|
@@ -72,7 +75,7 @@ export async function balancesOfBatched(contract, walletAddresses, ctx) {
|
|
|
72
75
|
})),
|
|
73
76
|
allowFailure: false,
|
|
74
77
|
batchSize: 0,
|
|
75
|
-
blockNumber: BigInt(
|
|
78
|
+
blockNumber: BigInt(block)
|
|
76
79
|
});
|
|
77
80
|
let nextChunk = 0;
|
|
78
81
|
const worker = async () => {
|
|
@@ -93,5 +96,5 @@ export async function balancesOfBatched(contract, walletAddresses, ctx) {
|
|
|
93
96
|
}
|
|
94
97
|
};
|
|
95
98
|
await Promise.all(Array.from({ length: Math.min(MULTICALL_CONCURRENCY, chunks.length) }, worker));
|
|
96
|
-
return balances;
|
|
99
|
+
return { balances };
|
|
97
100
|
}
|
package/dist/rules/types.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import type { ChainClient } from "../chain/types.js";
|
|
3
|
+
import type { RuleCache } from "./cache.js";
|
|
3
4
|
/**
|
|
4
5
|
* Rule interface, design + leaves implemented.
|
|
5
6
|
*
|
|
6
7
|
* A rule turns a criteria `{ type, ...options }` reference into a non-negative
|
|
7
|
-
* score for one wallet,
|
|
8
|
+
* score for one wallet, at whichever block the rule itself decides to read — the pinned block
|
|
9
|
+
* the bundle names ({@link RuleWallet.sampleBlock}) or this verifier's current head
|
|
10
|
+
* ({@link ChainReadContext.head}). There is a SINGLE kind
|
|
8
11
|
* (mirroring the flat pkc-js challenge registry: `Record<string, rule>`, user
|
|
9
12
|
* entries shadow builtins). The criteria still has two slots that draw from this one
|
|
10
13
|
* registry:
|
|
@@ -27,17 +30,74 @@ import type { ChainClient } from "../chain/types.js";
|
|
|
27
30
|
*/
|
|
28
31
|
export interface RuleResult {
|
|
29
32
|
score: bigint;
|
|
33
|
+
/**
|
|
34
|
+
* May a `0n` be blamed on the sender? Default `true`.
|
|
35
|
+
*
|
|
36
|
+
* The pipeline has two decisions to make about a `0n` that the rule cannot make for it —
|
|
37
|
+
* whether the gossip forward-gate `reject`s the message (which penalizes the delivering peer
|
|
38
|
+
* through gossipsub's invalid-message score, eventually pruning and graylisting it) or
|
|
39
|
+
* merely `ignore`s it, and whether the background verifier evicts the bundle at once or
|
|
40
|
+
* holds it for a grace window. Both hinge on one thing only the rule knows: is this `0n`
|
|
41
|
+
* attributable?
|
|
42
|
+
*
|
|
43
|
+
* `true` (the default) says every honest verifier necessarily computes this same `0n` — true
|
|
44
|
+
* of a read pinned to a historical block, since the block is named by the bundle and the
|
|
45
|
+
* chain's history does not move. The bundle is dropped, the sender penalized, the verdict
|
|
46
|
+
* cached as terminal.
|
|
47
|
+
*
|
|
48
|
+
* `false` says an honest peer could legitimately disagree, so nobody may be blamed. The
|
|
49
|
+
* 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 a
|
|
51
|
+
* rule scoring the chain head must return: the peer that forwarded the vote verified it
|
|
52
|
+
* against ITS head, and any peer ahead of us can legitimately see an acquisition we have
|
|
53
|
+
* not. Penalizing there punishes honest relayers for being current — and does so exactly
|
|
54
|
+
* when a wallet has just acquired the gate asset, which is when peer heads straddle.
|
|
55
|
+
*/
|
|
56
|
+
penalize?: boolean;
|
|
30
57
|
}
|
|
31
|
-
/** Everything a rule needs to read chain state
|
|
58
|
+
/** Everything a rule needs to read chain state and remember what it read. */
|
|
32
59
|
export interface ChainReadContext {
|
|
33
60
|
/**
|
|
34
|
-
* The viem `PublicClient` for the rule's `options.chain`. Use the full viem
|
|
35
|
-
*
|
|
36
|
-
*
|
|
61
|
+
* The viem `PublicClient` for the rule's `options.chain`. Use the full viem read surface
|
|
62
|
+
* directly (`readContract`, `getBalance`, ...). ALWAYS pin each call to an explicit
|
|
63
|
+
* `blockNumber: BigInt(...)`: the read coalescer (src/chain/coalescer.ts) only folds reads
|
|
64
|
+
* carrying an explicit block into one multicall3, so a `blockTag` or an omitted block
|
|
65
|
+
* silently drops out of batching and back to one HTTP round trip per read.
|
|
37
66
|
*/
|
|
38
67
|
chain: ChainClient;
|
|
39
|
-
/**
|
|
40
|
-
|
|
68
|
+
/**
|
|
69
|
+
* This verifier's current head on {@link chain}. A rule scoring historical state never calls
|
|
70
|
+
* it and never pays for it; a rule scoring "now" resolves it ONCE per evaluation and pins
|
|
71
|
+
* its reads to that number, so a batch still lands in a single multicall.
|
|
72
|
+
*
|
|
73
|
+
* Shared and coalesced by the voter, because the gate runs on the verify path — one call per
|
|
74
|
+
* incoming vote — so an unshared head read would be one `eth_blockNumber` per bundle per
|
|
75
|
+
* contest.
|
|
76
|
+
*/
|
|
77
|
+
head: () => Promise<{
|
|
78
|
+
block: number;
|
|
79
|
+
}>;
|
|
80
|
+
/**
|
|
81
|
+
* This rule's memo (see rules/cache.ts). A chain-reading rule MUST compute through it: it is
|
|
82
|
+
* what turns "one chain read per unique bundle" into "one read per key per epoch", which is
|
|
83
|
+
* the bound that stops an ineligible wallet from making every peer on the topic pay an RPC
|
|
84
|
+
* round trip per fresh-signed bundle.
|
|
85
|
+
*/
|
|
86
|
+
cache: RuleCache;
|
|
87
|
+
}
|
|
88
|
+
/** One wallet to score, and the pinned block the bundle it came from names. */
|
|
89
|
+
export interface RuleWallet {
|
|
90
|
+
/** The voting wallet (the address recovered from the bundle's signature). */
|
|
91
|
+
address: string;
|
|
92
|
+
/**
|
|
93
|
+
* The bundle's bucketized sample block, already floored to the bucket boundary — the
|
|
94
|
+
* historical block every verifier agrees this ballot names. A rule scoring pinned state
|
|
95
|
+
* reads here (and its answer is then identical on every verifier, forever); a rule scoring
|
|
96
|
+
* current state ignores it and uses {@link ChainReadContext.head} instead. It is NOT a
|
|
97
|
+
* claim about when the ballot was signed: it is floored to the bucket, so it can trail the
|
|
98
|
+
* actual signing moment by up to `blocksPerBucket`.
|
|
99
|
+
*/
|
|
100
|
+
sampleBlock: number;
|
|
41
101
|
}
|
|
42
102
|
/**
|
|
43
103
|
* The one rule kind. `O` is the validated options type (from its `optionsSchema`).
|
|
@@ -49,22 +109,28 @@ export interface Rule<O = unknown> {
|
|
|
49
109
|
readonly optionsSchema: z.ZodType<O>;
|
|
50
110
|
evaluate(args: {
|
|
51
111
|
options: O;
|
|
52
|
-
|
|
112
|
+
wallet: RuleWallet;
|
|
53
113
|
ctx: ChainReadContext;
|
|
54
114
|
}): Promise<RuleResult>;
|
|
55
115
|
/**
|
|
56
|
-
* Optional batched form of {@link evaluate}: score many wallets
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
116
|
+
* Optional batched form of {@link evaluate}: score many wallets in as few RPC round trips as
|
|
117
|
+
* the rule can manage (e.g. one multicall3 `aggregate3` for a whole checkpoint's wallets).
|
|
118
|
+
* Returns one result per input wallet, in order. Semantics MUST equal mapping `evaluate` over
|
|
119
|
+
* the wallets — this is a transport optimization, never a different answer. The background
|
|
120
|
+
* chain verifier prefers it when present and falls back to per-wallet `evaluate` otherwise.
|
|
121
|
+
*
|
|
122
|
+
* The wallets are NOT guaranteed to share a `sampleBlock`: the pipeline no longer groups them
|
|
123
|
+
* (it cannot, since which block a rule reads at is the rule's own business), so a batch is
|
|
124
|
+
* whatever was pending. A rule scoring the head reads once for the whole batch; a rule
|
|
125
|
+
* scoring pinned state groups by `sampleBlock` itself.
|
|
62
126
|
*/
|
|
63
127
|
evaluateMany?(args: {
|
|
64
128
|
options: O;
|
|
65
|
-
|
|
129
|
+
wallets: RuleWallet[];
|
|
66
130
|
ctx: ChainReadContext;
|
|
67
|
-
}): Promise<
|
|
131
|
+
}): Promise<{
|
|
132
|
+
results: RuleResult[];
|
|
133
|
+
}>;
|
|
68
134
|
}
|
|
69
135
|
/**
|
|
70
136
|
* The registry: a flat `type -> rule` map. Built-ins are provided by this
|
package/dist/tally/tally.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { VotesBundle } from "../schema/votes.js";
|
|
|
3
3
|
import type { RuleRegistry } from "../rules/types.js";
|
|
4
4
|
import type { ChainClient, BucketMath } from "../chain/types.js";
|
|
5
5
|
import type { BundleChecks } from "../verify/types.js";
|
|
6
|
+
import { type RuleCache } from "../rules/cache.js";
|
|
6
7
|
import type { Tally } from "./types.js";
|
|
7
8
|
/**
|
|
8
9
|
* Deterministic per-contest aggregation over the CRDT's current bundles. Every aggregated
|
|
@@ -34,6 +35,19 @@ export interface TallyDeps {
|
|
|
34
35
|
bundle: VotesBundle;
|
|
35
36
|
checks: BundleChecks;
|
|
36
37
|
}>;
|
|
38
|
+
/**
|
|
39
|
+
* This verifier's current head, handed to the weight rule as `ctx.head`. Never called by a
|
|
40
|
+
* weight rule that scores pinned historical state — including `constant`, which reads no
|
|
41
|
+
* chain at all — so the "an idle contest does zero chain reads" property is untouched.
|
|
42
|
+
* Defaults to the weight chain's own `getBlockNumber()`.
|
|
43
|
+
*/
|
|
44
|
+
readHead?: (args: {
|
|
45
|
+
chain: ChainClient;
|
|
46
|
+
}) => Promise<{
|
|
47
|
+
block: number;
|
|
48
|
+
}>;
|
|
49
|
+
/** The weight rule's memo, handed to it as `ctx.cache` (rules/cache.ts). */
|
|
50
|
+
ruleCache?: RuleCache;
|
|
37
51
|
/**
|
|
38
52
|
* Hash of the current bucket boundary block on the criteria's chain, for the rolling tie
|
|
39
53
|
* seed. Invoked at most once per `compute`, and only when a tie must actually be broken —
|
package/dist/tally/tally.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { base58btc } from "multiformats/bases/base58";
|
|
2
2
|
import { sha256 } from "multiformats/hashes/sha2";
|
|
3
3
|
import { tickerForRef } from "../chain/ticker.js";
|
|
4
|
+
import { makeMemoryRuleCache } from "../rules/cache.js";
|
|
4
5
|
import { UnknownRuleError } from "../errors.js";
|
|
5
6
|
/** Byte-lexicographic compare of two byte arrays (returns <0, 0, >0). */
|
|
6
7
|
function compareBytes(x, y) {
|
|
@@ -15,18 +16,31 @@ function compareBytes(x, y) {
|
|
|
15
16
|
}
|
|
16
17
|
export function makeTally(deps) {
|
|
17
18
|
const { criteria, registry, chainFor, bucketMath, current, bucketBlockHash } = deps;
|
|
19
|
+
const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
|
|
18
20
|
// Resolve the weight rule, its options, and its chain once (see verify/bundle.ts).
|
|
19
21
|
const weight = registry[criteria.weight.type];
|
|
20
22
|
if (!weight)
|
|
21
23
|
throw new UnknownRuleError("weight", criteria.weight.type);
|
|
22
24
|
const weightOptions = weight.optionsSchema.parse(criteria.weight);
|
|
23
25
|
const weightChain = chainFor(tickerForRef(criteria, criteria.weight, weightOptions));
|
|
26
|
+
// The weight rule picks its own block exactly as the gate rule does (see rules/types.ts):
|
|
27
|
+
// it is handed the bundle's pinned sample block and this verifier's head, and reads whichever
|
|
28
|
+
// it needs. The tally never asks what kind of rule it is holding.
|
|
29
|
+
//
|
|
30
|
+
// `deps.ruleCache` MUST be namespaced by the weight rule's OWN chain, not the gating chain —
|
|
31
|
+
// `weightChain` here resolves through `criteria.weight`'s ticker, which may name a different
|
|
32
|
+
// entry of `requires.chains` (the voter derives it that way; see client/voter.ts).
|
|
33
|
+
const weightCtx = {
|
|
34
|
+
chain: weightChain,
|
|
35
|
+
head: () => readHead({ chain: weightChain }),
|
|
36
|
+
cache: deps.ruleCache ?? makeMemoryRuleCache()
|
|
37
|
+
};
|
|
24
38
|
const weightFor = async (wallet, blockNumber) => {
|
|
25
39
|
const sampleBlock = bucketMath.sampleBlockForBucket(bucketMath.bucketForBlock(blockNumber));
|
|
26
40
|
const { score } = await weight.evaluate({
|
|
27
41
|
options: weightOptions,
|
|
28
|
-
|
|
29
|
-
ctx:
|
|
42
|
+
wallet: { address: wallet, sampleBlock },
|
|
43
|
+
ctx: weightCtx
|
|
30
44
|
});
|
|
31
45
|
return score;
|
|
32
46
|
};
|