@bitsocial/pubsub-voting 0.2.1 → 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 +73 -6
- package/dist/client/voter.d.ts +37 -0
- package/dist/client/voter.js +67 -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/constant.js +3 -1
- package/dist/rules/erc20-balance.js +15 -5
- package/dist/rules/erc5192-min-balance.d.ts +17 -9
- package/dist/rules/erc5192-min-balance.js +191 -37
- package/dist/rules/erc721-min-balance.js +46 -12
- package/dist/rules/nft-balance.d.ts +33 -6
- package/dist/rules/nft-balance.js +29 -13
- package/dist/rules/result.d.ts +16 -0
- package/dist/rules/result.js +30 -0
- package/dist/rules/types.d.ts +128 -32
- package/dist/tally/tally.d.ts +14 -0
- package/dist/tally/tally.js +21 -5
- package/dist/transport/integration/harness.js +4 -1
- package/dist/verify/background.d.ts +42 -11
- package/dist/verify/background.js +93 -38
- package/dist/verify/bundle.d.ts +24 -9
- package/dist/verify/bundle.js +45 -21
- package/dist/verify/gate-grace.d.ts +32 -0
- package/dist/verify/gate-grace.js +32 -0
- package/dist/verify/types.d.ts +14 -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
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** `${key}:${epoch}` — the epoch trails, so a purge can parse it back off the end. */
|
|
2
|
+
const entryKey = (key, epoch) => `${key}:${epoch}`;
|
|
3
|
+
/**
|
|
4
|
+
* An in-memory {@link RuleCache}, FIFO-bounded. Used on its own by unit tests and as the hot
|
|
5
|
+
* front of the persistent cache. Eviction is safe: an evicted entry costs a re-read, never a
|
|
6
|
+
* wrong answer — and without a bound, a flood of fresh wallets would be a memory-exhaustion
|
|
7
|
+
* vector (DESIGN.md "Can valid votes clog the topic?").
|
|
8
|
+
*/
|
|
9
|
+
export function makeMemoryRuleCache(args = {}) {
|
|
10
|
+
const maxEntries = args.maxEntries ?? 4096;
|
|
11
|
+
const byKey = new Map();
|
|
12
|
+
const order = [];
|
|
13
|
+
const cache = {
|
|
14
|
+
async get({ key, epoch }) {
|
|
15
|
+
return { value: byKey.get(entryKey(key, epoch)) };
|
|
16
|
+
},
|
|
17
|
+
set({ key, epoch, value }) {
|
|
18
|
+
const k = entryKey(key, epoch);
|
|
19
|
+
if (byKey.has(k))
|
|
20
|
+
return; // idempotent: never refresh position or overwrite
|
|
21
|
+
byKey.set(k, value);
|
|
22
|
+
order.push(k);
|
|
23
|
+
if (order.length > maxEntries) {
|
|
24
|
+
const evicted = order.shift();
|
|
25
|
+
if (evicted !== undefined)
|
|
26
|
+
byKey.delete(evicted);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
memoMany: (memoArgs) => memoManyOver(cache, memoArgs),
|
|
30
|
+
purgeBelow({ epoch, keyPrefix }) {
|
|
31
|
+
for (const k of [...byKey.keys()]) {
|
|
32
|
+
if (keyPrefix !== undefined && !k.startsWith(keyPrefix))
|
|
33
|
+
continue;
|
|
34
|
+
const at = Number(k.slice(k.lastIndexOf(":") + 1));
|
|
35
|
+
if (Number.isFinite(at) && at < epoch) {
|
|
36
|
+
byKey.delete(k);
|
|
37
|
+
const i = order.indexOf(k);
|
|
38
|
+
if (i >= 0)
|
|
39
|
+
order.splice(i, 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
return cache;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A {@link RuleCache} over the voter's persistent store: the in-memory FIFO front above, with
|
|
48
|
+
* read-through on a miss and fire-and-forget write-through. A broken store read or write
|
|
49
|
+
* degrades to a live chain read — never an error into the verify pipeline.
|
|
50
|
+
*
|
|
51
|
+
* `namespace` is the rule's keyspace (see {@link RuleCache}); the voter derives it from the
|
|
52
|
+
* canonical rule reference + chain id.
|
|
53
|
+
*/
|
|
54
|
+
export function makePersistentRuleCache(args) {
|
|
55
|
+
const { store, namespace } = args;
|
|
56
|
+
const mem = makeMemoryRuleCache(args.maxMemEntries === undefined ? {} : { maxEntries: args.maxMemEntries });
|
|
57
|
+
const storeKey = (key, epoch) => `${namespace}:${entryKey(key, epoch)}`;
|
|
58
|
+
/** The highest epoch already purged per prefix, so a steady head costs no key scan. */
|
|
59
|
+
const purged = new Map();
|
|
60
|
+
const cache = {
|
|
61
|
+
async get({ key, epoch }) {
|
|
62
|
+
const hit = await mem.get({ key, epoch });
|
|
63
|
+
if (hit.value !== undefined)
|
|
64
|
+
return hit;
|
|
65
|
+
let persisted;
|
|
66
|
+
try {
|
|
67
|
+
persisted = await store.getItem(storeKey(key, epoch));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { value: undefined };
|
|
71
|
+
}
|
|
72
|
+
if (typeof persisted !== "string")
|
|
73
|
+
return { value: undefined };
|
|
74
|
+
mem.set({ key, epoch, value: persisted });
|
|
75
|
+
return { value: persisted };
|
|
76
|
+
},
|
|
77
|
+
set({ key, epoch, value }) {
|
|
78
|
+
mem.set({ key, epoch, value });
|
|
79
|
+
void store.setItem(storeKey(key, epoch), value).catch(() => {
|
|
80
|
+
// a failed persist costs a future re-read, never a wrong answer
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
memoMany: (memoArgs) => memoManyOver(cache, memoArgs),
|
|
84
|
+
purgeBelow({ epoch, keyPrefix }) {
|
|
85
|
+
const prefix = `${namespace}:${keyPrefix ?? ""}`;
|
|
86
|
+
if ((purged.get(prefix) ?? 0) >= epoch)
|
|
87
|
+
return;
|
|
88
|
+
purged.set(prefix, epoch);
|
|
89
|
+
mem.purgeBelow(keyPrefix === undefined ? { epoch } : { epoch, keyPrefix });
|
|
90
|
+
void (async () => {
|
|
91
|
+
try {
|
|
92
|
+
for (const key of await store.keys()) {
|
|
93
|
+
if (!key.startsWith(prefix))
|
|
94
|
+
continue;
|
|
95
|
+
const at = Number(key.slice(key.lastIndexOf(":") + 1));
|
|
96
|
+
if (Number.isFinite(at) && at < epoch)
|
|
97
|
+
await store.removeItem(key);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// purge is best-effort; the store's LRU bound is the correctness-free backstop
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return cache;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The shared {@link RuleCache.memoMany} body: read the misses once, in order, then memoize.
|
|
110
|
+
*
|
|
111
|
+
* The lookups are issued CONCURRENTLY, not one at a time. In the persistent cache a miss on the
|
|
112
|
+
* in-memory front falls through to a store round trip, so awaiting each key in turn would cost a
|
|
113
|
+
* cold join one serial store hit per wallet before its single batched chain read even starts —
|
|
114
|
+
* exactly the shape of the first `memoMany` after a restart, when the front is empty by
|
|
115
|
+
* definition. Order is preserved explicitly instead of by loop sequencing.
|
|
116
|
+
*/
|
|
117
|
+
async function memoManyOver(cache, args) {
|
|
118
|
+
const { keys, epoch, read } = args;
|
|
119
|
+
const values = new Array(keys.length);
|
|
120
|
+
const missing = [];
|
|
121
|
+
const missingAt = [];
|
|
122
|
+
// Unique keys first, each remembering every position it occupies, so a duplicate key is
|
|
123
|
+
// looked up once and read once.
|
|
124
|
+
const positions = new Map();
|
|
125
|
+
for (let i = 0; i < keys.length; i++) {
|
|
126
|
+
const key = keys[i];
|
|
127
|
+
const at = positions.get(key);
|
|
128
|
+
if (at)
|
|
129
|
+
at.push(i);
|
|
130
|
+
else
|
|
131
|
+
positions.set(key, [i]);
|
|
132
|
+
}
|
|
133
|
+
const unique = [...positions.keys()];
|
|
134
|
+
const hits = await Promise.all(unique.map((key) => cache.get({ key, epoch })));
|
|
135
|
+
for (let u = 0; u < unique.length; u++) {
|
|
136
|
+
const key = unique[u];
|
|
137
|
+
const at = positions.get(key);
|
|
138
|
+
const { value } = hits[u];
|
|
139
|
+
if (value !== undefined) {
|
|
140
|
+
for (const i of at)
|
|
141
|
+
values[i] = value;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
missingAt.push(at);
|
|
145
|
+
missing.push(key);
|
|
146
|
+
}
|
|
147
|
+
if (missing.length > 0) {
|
|
148
|
+
const read_ = await read({ keys: missing });
|
|
149
|
+
if (read_.values.length !== missing.length) {
|
|
150
|
+
throw new Error(`RuleCache.memoMany: read returned ${read_.values.length} values for ${missing.length} keys`);
|
|
151
|
+
}
|
|
152
|
+
missing.forEach((key, i) => {
|
|
153
|
+
const value = read_.values[i];
|
|
154
|
+
cache.set({ key, epoch, value });
|
|
155
|
+
for (const at of missingAt[i])
|
|
156
|
+
values[at] = value;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { values: values.map((value) => value) };
|
|
160
|
+
}
|
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
|
/**
|
|
@@ -37,15 +37,25 @@ export const Erc20BalanceOptionsSchema = z.object({
|
|
|
37
37
|
export const erc20Balance = {
|
|
38
38
|
type: "erc20-balance",
|
|
39
39
|
optionsSchema: Erc20BalanceOptionsSchema,
|
|
40
|
-
|
|
40
|
+
// Scores at the bundle's OWN pinned block: a fungible balance is the least stable score
|
|
41
|
+
// there is — it moves in both directions with every transfer — so it may not be read at the
|
|
42
|
+
// head, and a `0n` here is attributable (`penalize` stays at its default). See registry.ts
|
|
43
|
+
// for why this rule is unregistered regardless.
|
|
44
|
+
async evaluate({ options, wallet, ctx }) {
|
|
41
45
|
const raw = await ctx.chain.readContract({
|
|
42
46
|
address: getAddress(options.contract),
|
|
43
47
|
abi: erc20Abi,
|
|
44
48
|
functionName: "balanceOf",
|
|
45
|
-
args: [getAddress(
|
|
46
|
-
blockNumber: BigInt(
|
|
49
|
+
args: [getAddress(wallet.address)],
|
|
50
|
+
blockNumber: BigInt(wallet.sampleBlock)
|
|
47
51
|
});
|
|
48
52
|
const minUnits = parseUnits(options.min.toString(), options.decimals);
|
|
49
|
-
|
|
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
|
+
};
|
|
50
60
|
}
|
|
51
61
|
};
|
|
@@ -3,19 +3,27 @@ import type { Rule } from "./types.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
|
|
5
5
|
*
|
|
6
|
-
* Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the
|
|
7
|
-
* if it meets `min`, else `0n` — plus one assertion at the SAME
|
|
6
|
+
* Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the sampled block
|
|
7
|
+
* if it meets `min`, else `0n` — plus one assertion at the SAME block: the contract must
|
|
8
8
|
* declare ERC-5192 (`supportsInterface(0xb45a3c0e)`). A contract that does not declare it scores
|
|
9
9
|
* `0n` for every wallet, so the contest admits nobody rather than gating on a transferable asset.
|
|
10
10
|
*
|
|
11
|
+
* Unlike the other rules in the tree, this one scores at the verifier's CURRENT head rather than
|
|
12
|
+
* at the bundle's bucket boundary, falling back to that boundary only when the head read refuses
|
|
13
|
+
* — so a freshly-acquired Pass counts immediately. See `evaluateMany` for why both legs exist,
|
|
14
|
+
* and rules/types.ts for what the pipeline does with `penalize: false`.
|
|
15
|
+
*
|
|
11
16
|
* **Why the assertion is the whole point.** The gate bounds Sybils only if the gating asset
|
|
12
|
-
* cannot move (DESIGN.md "Does one Pass mean one vote?")
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* cannot move (DESIGN.md "Does one Pass mean one vote?"). A vote is verified ONCE, when it is
|
|
18
|
+
* merged, and then stays live for `voteExpiryBuckets` in a winner set LWW-keyed per wallet — so
|
|
19
|
+
* one transferable token walked A → B → C inside a single expiry window backs three concurrent
|
|
20
|
+
* live votes, each read true when it was checked and none collapsed by LWW. Nothing in the
|
|
21
|
+
* verify pipeline can see that: every ballot is individually correct. (Reading pinned blocks,
|
|
22
|
+
* the three reads land at three different historical blocks; reading the head, at three
|
|
23
|
+
* different verification times. Transferability defeats both.) Requiring the asset to be
|
|
24
|
+
* non-transferable AND to say so on-chain closes it with no wire change — and it is the same
|
|
25
|
+
* property that makes scoring at the head sound at all. Pinned by
|
|
26
|
+
* `src/crdt/amplification.test.ts`.
|
|
19
27
|
*
|
|
20
28
|
* **What the assertion does and does not prove.** `supportsInterface(0xb45a3c0e)` asserts the
|
|
21
29
|
* contract *reports* lock state — ERC-5192's only function is `locked(uint256)`. ERC-5192
|
|
@@ -1,23 +1,31 @@
|
|
|
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
|
*
|
|
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,195 @@ 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
|
+
/**
|
|
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`;
|
|
101
|
+
/** One `supportsInterface(0xb45a3c0e)` at `block`. Revert/zero-data ⇒ "does not declare". */
|
|
102
|
+
async function declaresErc5192(args) {
|
|
72
103
|
try {
|
|
73
|
-
|
|
74
|
-
address: contract,
|
|
104
|
+
const declares = await args.ctx.chain.readContract({
|
|
105
|
+
address: args.contract,
|
|
75
106
|
abi: erc165Abi,
|
|
76
107
|
functionName: "supportsInterface",
|
|
77
108
|
args: [ERC5192_INTERFACE_ID],
|
|
78
|
-
blockNumber: BigInt(
|
|
109
|
+
blockNumber: BigInt(args.block)
|
|
79
110
|
});
|
|
111
|
+
return { declares };
|
|
80
112
|
}
|
|
81
113
|
catch (err) {
|
|
82
114
|
if (isContractRefusal(err))
|
|
83
|
-
return false;
|
|
115
|
+
return { declares: false };
|
|
84
116
|
throw err;
|
|
85
117
|
}
|
|
86
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Score every wallet at ONE block, memoized under one epoch — the whole rule body, run once per
|
|
121
|
+
* leg (see `evaluateMany`).
|
|
122
|
+
*
|
|
123
|
+
* Two memos, both through the rule's cache: the ERC-5192 declaration, keyed per CONTRACT (it is
|
|
124
|
+
* not a per-wallet fact, so a whole checkpoint's wallets share one probe — a key shape the old
|
|
125
|
+
* per-wallet gate cache could not express at all), and each wallet's raw balance. Balances are
|
|
126
|
+
* cached rather than scores so a change to `min` cannot be served a stale verdict; the batched
|
|
127
|
+
* read behind the misses is one multicall3 `aggregate3` per 200 wallets.
|
|
128
|
+
*/
|
|
129
|
+
async function scoreAt(args) {
|
|
130
|
+
const { contract, min, wallets, block, epoch, prefix, ctx } = args;
|
|
131
|
+
if (wallets.length === 0)
|
|
132
|
+
return { scores: [], errors: [] };
|
|
133
|
+
const [declared] = (await ctx.cache.memoMany({
|
|
134
|
+
keys: [`${prefix}lock/${contract.toLowerCase()}`],
|
|
135
|
+
epoch,
|
|
136
|
+
read: async () => {
|
|
137
|
+
const { declares } = await declaresErc5192({ contract, block, ctx });
|
|
138
|
+
return { values: [declares ? "1" : "0"] };
|
|
139
|
+
}
|
|
140
|
+
})).values;
|
|
141
|
+
// A contract that does not claim its tokens are locked gates nothing: admit nobody rather
|
|
142
|
+
// than gate on something transferable (see the rule doc above).
|
|
143
|
+
if (declared !== "1")
|
|
144
|
+
return { scores: wallets.map(() => 0n), errors: wallets.map(() => undeclaredError(contract)) };
|
|
145
|
+
const { values } = await ctx.cache.memoMany({
|
|
146
|
+
keys: wallets.map((wallet) => `${prefix}bal/${wallet.toLowerCase()}`),
|
|
147
|
+
epoch,
|
|
148
|
+
read: async ({ keys }) => {
|
|
149
|
+
const missing = keys.map((key) => key.slice(key.lastIndexOf("/") + 1));
|
|
150
|
+
const { balances } = canBatch({ ctx }).batchable
|
|
151
|
+
? await balancesOfBatched({ contract, wallets: missing, block, ctx })
|
|
152
|
+
: {
|
|
153
|
+
balances: await Promise.all(missing.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
|
|
154
|
+
};
|
|
155
|
+
return { values: balances.map((balance) => balance.toString()) };
|
|
156
|
+
}
|
|
157
|
+
});
|
|
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
|
+
};
|
|
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";
|
|
87
174
|
export const erc5192MinBalance = {
|
|
88
175
|
type: "erc5192-min-balance",
|
|
89
176
|
optionsSchema: Erc5192MinBalanceOptionsSchema,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
return
|
|
177
|
+
// One wallet is the batch of one: the two legs, the two epochs and the memos are identical,
|
|
178
|
+
// so there is one body. Called through `erc5192MinBalance.evaluateMany` rather than `this`,
|
|
179
|
+
// which a destructured or re-exported rule object would not carry (issue #29).
|
|
180
|
+
async evaluate({ options, wallet, ctx }) {
|
|
181
|
+
const { results } = await erc5192MinBalance.evaluateMany({ options, wallets: [wallet], ctx });
|
|
182
|
+
return results[0];
|
|
96
183
|
},
|
|
97
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Score at the HEAD first, falling back to each wallet's own pinned block.
|
|
186
|
+
*
|
|
187
|
+
* **Head first** is what lets a wallet vote in the block it acquires the Pass. Scoring only
|
|
188
|
+
* at the bundle's bucket boundary — the block a ballot names, floored — meant waiting up to
|
|
189
|
+
* a full bucket (an hour on 5chan's live manifest) before a fresh holding was visible, which
|
|
190
|
+
* was never a chain requirement: a read pinned at block N already sees a mint that happened
|
|
191
|
+
* in N. It is sound because a soulbound holding cannot move, so a peer whose head lags can
|
|
192
|
+
* only be LATE to admit a vote, never in lasting disagreement about it.
|
|
193
|
+
*
|
|
194
|
+
* **The pinned fallback** covers what that reasoning does not: ERC-5192 requires transfers to
|
|
195
|
+
* revert while locked, but says nothing about BURNING (a burn does not go through
|
|
196
|
+
* `transferFrom`), so a compliant Pass may be burnable and this rule cannot check otherwise.
|
|
197
|
+
* Without the fallback, a burn would make a peer that verified earlier keep a vote its
|
|
198
|
+
* checkpoint still serves while a cold joiner rejects it — divergence for up to the expiry
|
|
199
|
+
* window — and would hand whoever can burn a retroactive veto over votes already cast.
|
|
200
|
+
* Reading the wallet's own `sampleBlock` when the head says `0n` restores agreement for any
|
|
201
|
+
* vote that was legitimately held when it was cast. It admits nothing the pinned-only v1 gate
|
|
202
|
+
* did not, so it opens no new amplification; a burn-and-remint to a fresh wallet is a
|
|
203
|
+
* transfer by another name and remains a property of the deployment, not something any read
|
|
204
|
+
* can close. Its cost is that a verifier still needs archive depth for that leg.
|
|
205
|
+
*
|
|
206
|
+
* **Two epochs**, which is why this caching lives in the rule: the head leg expires with the
|
|
207
|
+
* head (a stale `0n` must not outlive {@link HEAD_EPOCH_BLOCKS}), while the pinned leg is a
|
|
208
|
+
* historical read that is true forever and is keyed by the block itself.
|
|
209
|
+
*
|
|
210
|
+
* **`penalize: false`** on every failure: neither leg makes one attributable. The peer that
|
|
211
|
+
* forwarded a vote verified it against ITS head, and any peer ahead of us may legitimately
|
|
212
|
+
* see an acquisition we have not — and with burning possible, not holding at either block
|
|
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.
|
|
219
|
+
*/
|
|
220
|
+
async evaluateMany({ options, wallets, ctx }) {
|
|
98
221
|
const contract = getAddress(options.contract);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
222
|
+
const { block: head } = await ctx.head();
|
|
223
|
+
const epoch = Math.floor(head / HEAD_EPOCH_BLOCKS) * HEAD_EPOCH_BLOCKS;
|
|
224
|
+
// Everything behind the current window is unreachable — nothing will look it up again.
|
|
225
|
+
// Scoped to the head keys, so the permanently-valid pinned entries are left alone.
|
|
226
|
+
ctx.cache.purgeBelow({ epoch, keyPrefix: HEAD_PREFIX });
|
|
227
|
+
const { scores, errors } = await scoreAt({
|
|
228
|
+
contract,
|
|
229
|
+
min: options.min,
|
|
230
|
+
wallets: wallets.map((wallet) => wallet.address),
|
|
231
|
+
block: head,
|
|
232
|
+
epoch,
|
|
233
|
+
prefix: HEAD_PREFIX,
|
|
234
|
+
ctx
|
|
235
|
+
});
|
|
236
|
+
// Only wallets the head leg refused reach the fallback, so a holder costs one read path.
|
|
237
|
+
// Grouped by sample block: bundles from different buckets name different pinned blocks.
|
|
238
|
+
const byBlock = new Map();
|
|
239
|
+
scores.forEach((score, i) => {
|
|
240
|
+
if (score > 0n)
|
|
241
|
+
return;
|
|
242
|
+
const block = wallets[i].sampleBlock;
|
|
243
|
+
byBlock.set(block, [...(byBlock.get(block) ?? []), i]);
|
|
244
|
+
});
|
|
245
|
+
await Promise.all([...byBlock].map(async ([block, indexes]) => {
|
|
246
|
+
const fallback = await scoreAt({
|
|
247
|
+
contract,
|
|
248
|
+
min: options.min,
|
|
249
|
+
wallets: indexes.map((i) => wallets[i].address),
|
|
250
|
+
block,
|
|
251
|
+
epoch: block,
|
|
252
|
+
prefix: PINNED_PREFIX,
|
|
253
|
+
ctx
|
|
254
|
+
});
|
|
255
|
+
indexes.forEach((at, i) => {
|
|
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];
|
|
261
|
+
});
|
|
262
|
+
}));
|
|
263
|
+
return { results: scores.map((score, i) => resultOf(score, errors[i])) };
|
|
110
264
|
}
|
|
111
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,21 +28,55 @@ 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,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
// Scores at the bundle's OWN pinned block, and deliberately not at the head: a transferable
|
|
44
|
+
// balance can go DOWN, so a vote admitted today would silently become invalid the moment the
|
|
45
|
+
// token moved, and whether it still counted would depend on when each peer last looked. A
|
|
46
|
+
// pinned read is identical on every verifier forever, which is what leaves `penalize` at its
|
|
47
|
+
// default — a `0n` here IS attributable to the sender. That difference is a second,
|
|
48
|
+
// independent reason this rule stays out of `builtinRegistry`, on top of the Sybil
|
|
49
|
+
// amplification described in registry.ts.
|
|
50
|
+
async evaluate({ options, wallet, ctx }) {
|
|
51
|
+
const { balance } = await balanceOf({
|
|
52
|
+
contract: getAddress(options.contract),
|
|
53
|
+
wallet: wallet.address,
|
|
54
|
+
block: wallet.sampleBlock,
|
|
55
|
+
ctx
|
|
56
|
+
});
|
|
57
|
+
return pinnedResult(balance, options.min, getAddress(options.contract));
|
|
37
58
|
},
|
|
38
|
-
async evaluateMany({ options,
|
|
59
|
+
async evaluateMany({ options, wallets, ctx }) {
|
|
39
60
|
const contract = getAddress(options.contract);
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
// Grouped by sample block, because a batch is no longer guaranteed to share one: the
|
|
62
|
+
// pipeline hands over whatever is pending and each rule groups the way it reads. Within
|
|
63
|
+
// a group it is multicall3 `aggregate3` batching (chunking policy in nft-balance.ts) —
|
|
64
|
+
// the path the background chain verifier rides on a cold join; a client that cannot
|
|
65
|
+
// batch takes the per-wallet fallback.
|
|
66
|
+
const results = new Array(wallets.length);
|
|
67
|
+
const byBlock = new Map();
|
|
68
|
+
wallets.forEach((wallet, i) => byBlock.set(wallet.sampleBlock, [...(byBlock.get(wallet.sampleBlock) ?? []), i]));
|
|
69
|
+
await Promise.all([...byBlock].map(async ([block, indexes]) => {
|
|
70
|
+
const group = indexes.map((i) => wallets[i].address);
|
|
71
|
+
const { balances } = canBatch({ ctx }).batchable
|
|
72
|
+
? await balancesOfBatched({ contract, wallets: group, block, ctx })
|
|
73
|
+
: {
|
|
74
|
+
balances: await Promise.all(group.map(async (wallet) => (await balanceOf({ contract, wallet, block, ctx })).balance))
|
|
75
|
+
};
|
|
76
|
+
indexes.forEach((at, i) => {
|
|
77
|
+
results[at] = pinnedResult(balances[i], options.min, contract);
|
|
78
|
+
});
|
|
79
|
+
}));
|
|
80
|
+
return { results: results.map((result) => result) };
|
|
47
81
|
}
|
|
48
82
|
};
|
|
@@ -1,17 +1,37 @@
|
|
|
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
|
|
7
16
|
* multicall3) takes the per-wallet path instead.
|
|
8
17
|
*/
|
|
9
|
-
export declare function canBatch(
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
export declare function canBatch(args: {
|
|
19
|
+
ctx: ChainReadContext;
|
|
20
|
+
}): {
|
|
21
|
+
batchable: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** One wallet's `balanceOf` at `block` (the caller's choice — a pinned block or the head). */
|
|
24
|
+
export declare function balanceOf(args: {
|
|
25
|
+
contract: `0x${string}`;
|
|
26
|
+
wallet: string;
|
|
27
|
+
block: number;
|
|
28
|
+
ctx: ChainReadContext;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
balance: bigint;
|
|
31
|
+
}>;
|
|
12
32
|
/**
|
|
13
|
-
* Many wallets' `balanceOf` at ONE
|
|
14
|
-
*
|
|
33
|
+
* Many wallets' `balanceOf` at ONE block — the path the background chain verifier rides on a
|
|
34
|
+
* cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
|
|
15
35
|
*
|
|
16
36
|
* The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
|
|
17
37
|
* viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
|
|
@@ -20,4 +40,11 @@ export declare function balanceOf(contract: `0x${string}`, walletAddress: string
|
|
|
20
40
|
* completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
|
|
21
41
|
* twice still fails the whole call: the caller gets one rejection, not partial results.
|
|
22
42
|
*/
|
|
23
|
-
export declare function balancesOfBatched(
|
|
43
|
+
export declare function balancesOfBatched(args: {
|
|
44
|
+
contract: `0x${string}`;
|
|
45
|
+
wallets: string[];
|
|
46
|
+
block: number;
|
|
47
|
+
ctx: ChainReadContext;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
balances: bigint[];
|
|
50
|
+
}>;
|