@bitsocial/pubsub-voting 0.1.6 → 0.2.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 +13 -4
- package/dist/chain/ticker.d.ts +1 -1
- package/dist/chain/ticker.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -2
- package/dist/rules/erc20-balance.d.ts +7 -0
- package/dist/rules/erc20-balance.js +7 -0
- package/dist/rules/erc5192-min-balance.d.ts +41 -0
- package/dist/rules/erc5192-min-balance.js +111 -0
- package/dist/rules/erc721-min-balance.d.ts +17 -5
- package/dist/rules/erc721-min-balance.js +27 -79
- package/dist/rules/nft-balance.d.ts +23 -0
- package/dist/rules/nft-balance.js +97 -0
- package/dist/rules/registry.d.ts +29 -6
- package/dist/rules/registry.js +31 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ This library does not start its own node. It consumes the host's running Helia n
|
|
|
24
24
|
|
|
25
25
|
- **Settings live in the topic.** `topic = "bitsocial-votes/" + CID(dag-cbor(criteria))`. Two peers on the same topic provably ran identical rules, so the network validates itself with no intermediary.
|
|
26
26
|
- **Votes are a state-based grow-only CRDT.** A signed `Votes` bundle is a standalone dag-cbor block (no parent links); each wallet gossips its own bundle **inline as a live delta**, validated straight from the message bytes — no fetch toward the publisher. State is a last-write-wins set keyed by wallet, so aggregation is a monotonic union: a peer can omit a vote but can never subtract one that an honest peer serves. Cold start and gap-fill exchange a tiny **root record** (libp2p-fetch pull + a slow topic heartbeat) and pull the checkpoint blocks behind it via directed bitswap from its advertisers.
|
|
27
|
-
- **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the NFT path — an `
|
|
27
|
+
- **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate `rule` (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked) and `constant` weight (1 pass = 1 vote). A gate on a *transferable* asset would let one Pass back several concurrent votes, so the plain `erc721-min-balance` rule ships unregistered; balance-derived (token-weighted) voting is deferred. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote) and [ROADMAP.md](./ROADMAP.md).
|
|
28
28
|
|
|
29
29
|
See [DESIGN.md](./DESIGN.md) for the full rationale, including how this resists vote-dropping and how criteria upgrades fork cleanly.
|
|
30
30
|
|
|
@@ -196,9 +196,18 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
|
|
|
196
196
|
|
|
197
197
|
### Custom rules
|
|
198
198
|
|
|
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 and is evaluated at the bundle's bucket block. Chain-reading rules get `ctx.chain` — the viem `PublicClient` for their `options.chain` — and write their own reads (`readContract`, `getBalance`, ...), pinning each call to the sampled block with `blockNumber: BigInt(ctx.blockNumber)`. There is **one kind**: `evaluate → { score: bigint }`, a non-negative score where `0n` means "does not qualify" (a result object, not a bare `bigint`, so slot-specific fields can be added later). 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.score > 0n ? weight.score : 0n`. A rule that needs a threshold returns `0n` below it (so `
|
|
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 and is evaluated at the bundle's bucket block. Chain-reading rules get `ctx.chain` — the viem `PublicClient` for their `options.chain` — and write their own reads (`readContract`, `getBalance`, ...), pinning each call to the sampled block with `blockNumber: BigInt(ctx.blockNumber)`. There is **one kind**: `evaluate → { score: bigint }`, a non-negative score where `0n` means "does not qualify" (a result object, not a bare `bigint`, so slot-specific fields can be added later). 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.score > 0n ? weight.score : 0n`. A rule that needs a threshold returns `0n` 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, walletAddresses, ctx })` batch hook (same argument object as `evaluate`, with `walletAddresses` in place of `walletAddress`, returning one `RuleResult` per input wallet in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads (`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
200
|
|
|
201
|
-
Built-ins: `
|
|
201
|
+
Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
|
|
202
|
+
|
|
203
|
+
Two chain-reading rules ship in the tree but are deliberately **not** built in, so a criteria naming either recuses via `UnknownRuleError` instead of silently gating on an asset that does not bound Sybils:
|
|
204
|
+
|
|
205
|
+
- **`erc721-min-balance`** (exported) — a bare `balanceOf` on a *transferable* token. One token walked A → B → C inside one expiry window backs three concurrent live votes, since every bundle is verified at its own pinned block and the winner set is LWW-keyed per wallet. `erc5192-min-balance` is this rule plus `supportsInterface(0xb45a3c0e)`, which refuses a contract that does not declare its tokens locked.
|
|
206
|
+
- **`erc20-balance`** (not exported) — the same amplification, reopened by fungibility, plus the open lazy-tally ceiling question for balance-derived weight.
|
|
207
|
+
|
|
208
|
+
A host that wants a transferable gate anyway registers `erc721MinBalance` explicitly through the `rules` option below — the library stops blessing the configuration, it does not forbid it. `erc20-balance` is not exported at all, so a host that wants balance-weighting supplies its own rule of that `type`. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote).
|
|
209
|
+
|
|
210
|
+
A host adds or shadows rules by `type` via the `rules` option — this is how clients like 5chan or seedit register custom rules without forking the library:
|
|
202
211
|
|
|
203
212
|
```ts
|
|
204
213
|
import { PubsubVoter, type Rule } from "@bitsocial/pubsub-voting";
|
|
@@ -222,7 +231,7 @@ A custom `type` becomes part of `dag-cbor(criteria)`, so it is provably pinned t
|
|
|
222
231
|
|
|
223
232
|
### Weighted voting (deferred)
|
|
224
233
|
|
|
225
|
-
v1 ships `constant` weight (one Pass, one vote) **on purpose** — it resists whale dominance and downvote weaponization. Balance-derived, token-weighted voting (Pass gate + BSO weight via `erc20-balance`) is a designed-but-unshipped capability: the rule path and result shape leave room for it with no engine change, but it is not in the v1 built-ins and carries open governance/abuse and lazy-tally questions. See [ROADMAP.md](./ROADMAP.md) and [DESIGN.md, Future improvements](./DESIGN.md#future-improvements).
|
|
234
|
+
v1 ships `constant` weight (one Pass, one vote) **on purpose** — it resists whale dominance and downvote weaponization. Balance-derived, token-weighted voting (Pass gate + BSO weight via `erc20-balance`) is a designed-but-unshipped capability: the rule path and result shape leave room for it with no engine change, but it is not in the v1 built-ins and carries open governance/abuse and lazy-tally questions — plus the Sybil amplification a fungible gate reopens, which the soulbound gate's fix cannot close for a balance (it needs a hold-duration guard instead). See [ROADMAP.md](./ROADMAP.md), [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote), and [DESIGN.md, Future improvements](./DESIGN.md#future-improvements).
|
|
226
235
|
|
|
227
236
|
## Layout
|
|
228
237
|
|
package/dist/chain/ticker.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Criteria, RuleRef } from "../schema/criteria.js";
|
|
2
2
|
/**
|
|
3
3
|
* Resolve which chain a rule reads. A rule's parsed options may name a
|
|
4
|
-
* `chain` ticker (e.g. `
|
|
4
|
+
* `chain` ticker (e.g. `erc5192-min-balance` -> "base"); a chainless rule (e.g.
|
|
5
5
|
* `constant`) names none, so callers fall back to the first configured chain. Shared by the
|
|
6
6
|
* verifier, the tally, and the facade so the fallback rule stays identical everywhere.
|
|
7
7
|
*/
|
package/dist/chain/ticker.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
/**
|
|
3
3
|
* Resolve which chain a rule reads. A rule's parsed options may name a
|
|
4
|
-
* `chain` ticker (e.g. `
|
|
4
|
+
* `chain` ticker (e.g. `erc5192-min-balance` -> "base"); a chainless rule (e.g.
|
|
5
5
|
* `constant`) names none, so callers fall back to the first configured chain. Shared by the
|
|
6
6
|
* verifier, the tally, and the facade so the fallback rule stays identical everywhere.
|
|
7
7
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from "./schema/common.js";
|
|
|
15
15
|
export * from "./schema/votes.js";
|
|
16
16
|
export * from "./schema/criteria.js";
|
|
17
17
|
export * from "./schema/directory.js";
|
|
18
|
+
export * from "./rules/erc5192-min-balance.js";
|
|
18
19
|
export * from "./rules/erc721-min-balance.js";
|
|
19
20
|
export * from "./rules/constant.js";
|
|
20
21
|
export * from "./rules/registry.js";
|
package/dist/index.js
CHANGED
|
@@ -25,8 +25,12 @@ export * from "./schema/directory.js";
|
|
|
25
25
|
// validation). The leaf rules read through the injected viem `PublicClient`
|
|
26
26
|
// (`ctx.chain`). Rule composition (combining several into one slot) is a
|
|
27
27
|
// documented future extension, not a built-in — see DESIGN.md "Future improvements".
|
|
28
|
-
// v1 ships the NFT path only: `
|
|
29
|
-
//
|
|
28
|
+
// v1 ships the soulbound-NFT path only: `erc5192-min-balance` + `constant`.
|
|
29
|
+
// `erc721-min-balance` is exported but NOT registered — a bare `balanceOf` gate admits the
|
|
30
|
+
// transfer amplification `erc5192-min-balance`'s lock assertion closes (issue #27), so a host
|
|
31
|
+
// that wants it must opt in explicitly through the `rules` override map. `erc20-balance` stays
|
|
32
|
+
// in the tree and is neither registered nor re-exported — see ROADMAP.md ("Deferred") and #28.
|
|
33
|
+
export * from "./rules/erc5192-min-balance.js";
|
|
30
34
|
export * from "./rules/erc721-min-balance.js";
|
|
31
35
|
export * from "./rules/constant.js";
|
|
32
36
|
export * from "./rules/registry.js";
|
|
@@ -3,6 +3,13 @@ import type { Rule } from "./types.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Score by ERC-20 balance (for example BSO). Reserved for the pass + BSO combo path.
|
|
5
5
|
*
|
|
6
|
+
* **NOT registered** in `builtinRegistry` — a criteria naming it recuses via
|
|
7
|
+
* `UnknownRuleError`. Two independent blockers: the design-open lazy-tally ceiling for a
|
|
8
|
+
* balance-derived weight, and the Sybil amplification a fungible gate reopens (one balance
|
|
9
|
+
* walked through several wallets inside one expiry window backs several concurrent votes;
|
|
10
|
+
* a balance can be neither non-transferable nor LWW-keyed by token id, so it needs a
|
|
11
|
+
* hold-duration guard instead). See registry.ts and issue #28 before re-registering it.
|
|
12
|
+
*
|
|
6
13
|
* Score = the wallet's raw balance (base units) at the bucket block if it meets `min`,
|
|
7
14
|
* else 0n. `min` (in whole tokens, default 0) is what lets this single rule serve
|
|
8
15
|
* BOTH slots: in the weight slot leave `min` at 0 and the score is the magnitude; in the
|
|
@@ -4,6 +4,13 @@ import { ChainTickerSchema } from "../schema/common.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* Score by ERC-20 balance (for example BSO). Reserved for the pass + BSO combo path.
|
|
6
6
|
*
|
|
7
|
+
* **NOT registered** in `builtinRegistry` — a criteria naming it recuses via
|
|
8
|
+
* `UnknownRuleError`. Two independent blockers: the design-open lazy-tally ceiling for a
|
|
9
|
+
* balance-derived weight, and the Sybil amplification a fungible gate reopens (one balance
|
|
10
|
+
* walked through several wallets inside one expiry window backs several concurrent votes;
|
|
11
|
+
* a balance can be neither non-transferable nor LWW-keyed by token id, so it needs a
|
|
12
|
+
* hold-duration guard instead). See registry.ts and issue #28 before re-registering it.
|
|
13
|
+
*
|
|
7
14
|
* Score = the wallet's raw balance (base units) at the bucket block if it meets `min`,
|
|
8
15
|
* else 0n. `min` (in whole tokens, default 0) is what lets this single rule serve
|
|
9
16
|
* BOTH slots: in the weight slot leave `min` at 0 and the score is the magnitude; in the
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Rule } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
|
|
5
|
+
*
|
|
6
|
+
* Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the bucket block
|
|
7
|
+
* if it meets `min`, else `0n` — plus one assertion at the SAME pinned block: the contract must
|
|
8
|
+
* declare ERC-5192 (`supportsInterface(0xb45a3c0e)`). A contract that does not declare it scores
|
|
9
|
+
* `0n` for every wallet, so the contest admits nobody rather than gating on a transferable asset.
|
|
10
|
+
*
|
|
11
|
+
* **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?"): every bundle is verified at its OWN
|
|
13
|
+
* pinned block, stays live for `voteExpiryBuckets`, and the winner set is LWW-keyed per wallet —
|
|
14
|
+
* so one transferable token walked A → B → C inside a single expiry window backs three
|
|
15
|
+
* concurrent live votes, each read true at its own block and none collapsed by LWW. Nothing in
|
|
16
|
+
* the verify pipeline can see that: every ballot is individually correct. Requiring the asset to
|
|
17
|
+
* be non-transferable AND to say so on-chain closes it with no wire change, no extra archive
|
|
18
|
+
* depth, and no second read per wallet. Pinned by `src/crdt/amplification.test.ts`.
|
|
19
|
+
*
|
|
20
|
+
* **What the assertion does and does not prove.** `supportsInterface(0xb45a3c0e)` asserts the
|
|
21
|
+
* contract *reports* lock state — ERC-5192's only function is `locked(uint256)`. ERC-5192
|
|
22
|
+
* permits unlockable tokens (it defines an `Unlocked` event), so this is not proof that a given
|
|
23
|
+
* token is locked; it refuses contracts that do not even claim the property. Per-token proof
|
|
24
|
+
* would need `locked(tokenId)`, i.e. token ids in the signed bundle — a re-pin of the frozen
|
|
25
|
+
* EIP-712 vector. The deployed gate closes the gap by being permanently locked with a constant
|
|
26
|
+
* `locked() == true`.
|
|
27
|
+
*
|
|
28
|
+
* ERC-5192 mandates ERC-721 conformance, so `balanceOf` stays valid. In the rule slot `> 0`
|
|
29
|
+
* admits the wallet; in the weight slot it weights by the number of Passes held. The body reads
|
|
30
|
+
* through the injected viem client (no libp2p/helia import), unit-testable against a stub.
|
|
31
|
+
*/
|
|
32
|
+
/** ERC-5192's interface id (its only function is `locked(uint256)`). */
|
|
33
|
+
export declare const ERC5192_INTERFACE_ID: "0xb45a3c0e";
|
|
34
|
+
export declare const Erc5192MinBalanceOptionsSchema: z.ZodObject<{
|
|
35
|
+
type: z.ZodLiteral<"erc5192-min-balance">;
|
|
36
|
+
chain: z.ZodString;
|
|
37
|
+
contract: z.ZodString;
|
|
38
|
+
min: z.ZodDefault<z.ZodNumber>;
|
|
39
|
+
}, z.core.$strip>;
|
|
40
|
+
export type Erc5192MinBalanceOptions = z.infer<typeof Erc5192MinBalanceOptionsSchema>;
|
|
41
|
+
export declare const erc5192MinBalance: Rule<Erc5192MinBalanceOptions>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { BaseError, ContractFunctionRevertedError, ContractFunctionZeroDataError, getAddress } from "viem";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ChainTickerSchema } from "../schema/common.js";
|
|
4
|
+
import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.js";
|
|
5
|
+
/**
|
|
6
|
+
* Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
|
|
7
|
+
*
|
|
8
|
+
* Same `balanceOf` scoring as `erc721-min-balance` — the wallet's holding at the bucket block
|
|
9
|
+
* if it meets `min`, else `0n` — plus one assertion at the SAME pinned block: the contract must
|
|
10
|
+
* declare ERC-5192 (`supportsInterface(0xb45a3c0e)`). A contract that does not declare it scores
|
|
11
|
+
* `0n` for every wallet, so the contest admits nobody rather than gating on a transferable asset.
|
|
12
|
+
*
|
|
13
|
+
* **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?"): every bundle is verified at its OWN
|
|
15
|
+
* pinned block, stays live for `voteExpiryBuckets`, and the winner set is LWW-keyed per wallet —
|
|
16
|
+
* so one transferable token walked A → B → C inside a single expiry window backs three
|
|
17
|
+
* concurrent live votes, each read true at its own block and none collapsed by LWW. Nothing in
|
|
18
|
+
* the verify pipeline can see that: every ballot is individually correct. Requiring the asset to
|
|
19
|
+
* be non-transferable AND to say so on-chain closes it with no wire change, no extra archive
|
|
20
|
+
* depth, and no second read per wallet. Pinned by `src/crdt/amplification.test.ts`.
|
|
21
|
+
*
|
|
22
|
+
* **What the assertion does and does not prove.** `supportsInterface(0xb45a3c0e)` asserts the
|
|
23
|
+
* contract *reports* lock state — ERC-5192's only function is `locked(uint256)`. ERC-5192
|
|
24
|
+
* permits unlockable tokens (it defines an `Unlocked` event), so this is not proof that a given
|
|
25
|
+
* token is locked; it refuses contracts that do not even claim the property. Per-token proof
|
|
26
|
+
* would need `locked(tokenId)`, i.e. token ids in the signed bundle — a re-pin of the frozen
|
|
27
|
+
* EIP-712 vector. The deployed gate closes the gap by being permanently locked with a constant
|
|
28
|
+
* `locked() == true`.
|
|
29
|
+
*
|
|
30
|
+
* ERC-5192 mandates ERC-721 conformance, so `balanceOf` stays valid. In the rule slot `> 0`
|
|
31
|
+
* admits the wallet; in the weight slot it weights by the number of Passes held. The body reads
|
|
32
|
+
* through the injected viem client (no libp2p/helia import), unit-testable against a stub.
|
|
33
|
+
*/
|
|
34
|
+
/** ERC-5192's interface id (its only function is `locked(uint256)`). */
|
|
35
|
+
export const ERC5192_INTERFACE_ID = "0xb45a3c0e";
|
|
36
|
+
const erc165Abi = [
|
|
37
|
+
{
|
|
38
|
+
type: "function",
|
|
39
|
+
name: "supportsInterface",
|
|
40
|
+
stateMutability: "view",
|
|
41
|
+
inputs: [{ name: "interfaceId", type: "bytes4" }],
|
|
42
|
+
outputs: [{ type: "bool" }]
|
|
43
|
+
}
|
|
44
|
+
];
|
|
45
|
+
export const Erc5192MinBalanceOptionsSchema = z.object({
|
|
46
|
+
type: z.literal("erc5192-min-balance"),
|
|
47
|
+
chain: ChainTickerSchema,
|
|
48
|
+
contract: z.string(),
|
|
49
|
+
min: z.number().int().positive().default(1)
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Does the read prove "this contract does not answer `supportsInterface`", as opposed to "the
|
|
53
|
+
* gateway failed"? A contract with no ERC-165 at all reverts (or returns no data) — that is a
|
|
54
|
+
* chain FACT and means the gate must refuse, permanently and identically for every verifier. An
|
|
55
|
+
* RPC outage is not a fact about the chain, and DESIGN.md keeps a throwing read infra-class
|
|
56
|
+
* everywhere (gossip `ignore`, background retry) precisely so a flaky gateway never turns into a
|
|
57
|
+
* consensus `reject` that scores honest relayers down. So only viem's revert/zero-data errors
|
|
58
|
+
* map to "does not declare"; everything else (transport, timeout, rate limit) rethrows.
|
|
59
|
+
*/
|
|
60
|
+
function isContractRefusal(err) {
|
|
61
|
+
if (!(err instanceof BaseError))
|
|
62
|
+
return false;
|
|
63
|
+
return err.walk((cause) => cause instanceof ContractFunctionRevertedError || cause instanceof ContractFunctionZeroDataError) !== null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* One `supportsInterface(0xb45a3c0e)` at the sampled block. Identical calldata at the same block
|
|
67
|
+
* for every wallet in a batch, so the voter's read coalescer (src/chain/coalescer.ts) dedupes a
|
|
68
|
+
* whole checkpoint's worth of wallets — and every parallel contest on the same contract — onto a
|
|
69
|
+
* single extra read, folded into the same `aggregate3` as the balances.
|
|
70
|
+
*/
|
|
71
|
+
async function declaresErc5192(contract, ctx) {
|
|
72
|
+
try {
|
|
73
|
+
return await ctx.chain.readContract({
|
|
74
|
+
address: contract,
|
|
75
|
+
abi: erc165Abi,
|
|
76
|
+
functionName: "supportsInterface",
|
|
77
|
+
args: [ERC5192_INTERFACE_ID],
|
|
78
|
+
blockNumber: BigInt(ctx.blockNumber)
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (isContractRefusal(err))
|
|
83
|
+
return false;
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export const erc5192MinBalance = {
|
|
88
|
+
type: "erc5192-min-balance",
|
|
89
|
+
optionsSchema: Erc5192MinBalanceOptionsSchema,
|
|
90
|
+
async evaluate({ options, walletAddress, ctx }) {
|
|
91
|
+
const contract = getAddress(options.contract);
|
|
92
|
+
// Issued together, not sequenced: same block, so the coalescer folds both into one
|
|
93
|
+
// aggregate3 — the lock assertion costs no extra round trip.
|
|
94
|
+
const [declares, balance] = await Promise.all([declaresErc5192(contract, ctx), balanceOf(contract, walletAddress, ctx)]);
|
|
95
|
+
return { score: declares ? scoreOf(balance, options.min) : 0n };
|
|
96
|
+
},
|
|
97
|
+
async evaluateMany({ options, walletAddresses, ctx }) {
|
|
98
|
+
const contract = getAddress(options.contract);
|
|
99
|
+
// ONE lock assertion for the whole batch (hoisted out of the per-wallet reads), in
|
|
100
|
+
// parallel with the balances so both share the coalescing window.
|
|
101
|
+
const [declares, balances] = await Promise.all([
|
|
102
|
+
declaresErc5192(contract, ctx),
|
|
103
|
+
canBatch(ctx)
|
|
104
|
+
? balancesOfBatched(contract, walletAddresses, ctx)
|
|
105
|
+
: Promise.all(walletAddresses.map((wallet) => balanceOf(contract, wallet, ctx)))
|
|
106
|
+
]);
|
|
107
|
+
if (!declares)
|
|
108
|
+
return walletAddresses.map(() => ({ score: 0n }));
|
|
109
|
+
return balances.map((balance) => ({ score: scoreOf(balance, options.min) }));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { Rule } from "./types.js";
|
|
3
3
|
/**
|
|
4
|
-
* Hold at least `min` of
|
|
4
|
+
* Hold at least `min` of a **plain** ERC-721.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* **NOT registered** in `builtinRegistry` — a criteria naming it recuses via `UnknownRuleError`.
|
|
7
|
+
* It reads a bare `balanceOf` and asserts nothing about transferability, so it gates on an asset
|
|
8
|
+
* that can move: one token walked A → B → C inside a single expiry window backs three concurrent
|
|
9
|
+
* live votes, since every bundle is verified at its own pinned block and the winner set is
|
|
10
|
+
* LWW-keyed per wallet (DESIGN.md "Does one Pass mean one vote?"; pinned by
|
|
11
|
+
* `src/crdt/amplification.test.ts`). The v1 gate is `erc5192-min-balance`, which is this rule plus
|
|
12
|
+
* a `supportsInterface(0xb45a3c0e)` assertion that the contract declares its tokens locked.
|
|
13
|
+
*
|
|
14
|
+
* Kept in the tree, exported, and unit-tested: a host that genuinely wants a transferable gate
|
|
15
|
+
* can still register it through the `rules` override map. The library stops blessing the
|
|
16
|
+
* configuration; it does not forbid it. See registry.ts and issue #27.
|
|
17
|
+
*
|
|
18
|
+
* Score = the wallet's holding at the bucket block if it meets `min`, else 0. In the rule slot,
|
|
19
|
+
* `> 0` admits the wallet; in the weight slot it weights by the number of tokens held. The body
|
|
20
|
+
* reads its own `balanceOf` via the injected viem client (no libp2p/helia import), unit-testable
|
|
21
|
+
* against a stubbed client.
|
|
10
22
|
*/
|
|
11
23
|
export declare const Erc721MinBalanceOptionsSchema: z.ZodObject<{
|
|
12
24
|
type: z.ZodLiteral<"erc721-min-balance">;
|
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getAddress } from "viem";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { CHAIN_CHUNK_RETRY_DELAY_MS, CHAIN_MULTICALL_CONCURRENCY, CHAIN_READS_PER_MULTICALL } from "../chain/coalescer.js";
|
|
4
3
|
import { ChainTickerSchema } from "../schema/common.js";
|
|
4
|
+
import { balanceOf, balancesOfBatched, canBatch, scoreOf } from "./nft-balance.js";
|
|
5
5
|
/**
|
|
6
|
-
* Hold at least `min` of
|
|
6
|
+
* Hold at least `min` of a **plain** ERC-721.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* **NOT registered** in `builtinRegistry` — a criteria naming it recuses via `UnknownRuleError`.
|
|
9
|
+
* It reads a bare `balanceOf` and asserts nothing about transferability, so it gates on an asset
|
|
10
|
+
* that can move: one token walked A → B → C inside a single expiry window backs three concurrent
|
|
11
|
+
* live votes, since every bundle is verified at its own pinned block and the winner set is
|
|
12
|
+
* LWW-keyed per wallet (DESIGN.md "Does one Pass mean one vote?"; pinned by
|
|
13
|
+
* `src/crdt/amplification.test.ts`). The v1 gate is `erc5192-min-balance`, which is this rule plus
|
|
14
|
+
* a `supportsInterface(0xb45a3c0e)` assertion that the contract declares its tokens locked.
|
|
15
|
+
*
|
|
16
|
+
* Kept in the tree, exported, and unit-tested: a host that genuinely wants a transferable gate
|
|
17
|
+
* can still register it through the `rules` override map. The library stops blessing the
|
|
18
|
+
* configuration; it does not forbid it. See registry.ts and issue #27.
|
|
19
|
+
*
|
|
20
|
+
* Score = the wallet's holding at the bucket block if it meets `min`, else 0. In the rule slot,
|
|
21
|
+
* `> 0` admits the wallet; in the weight slot it weights by the number of tokens held. The body
|
|
22
|
+
* reads its own `balanceOf` via the injected viem client (no libp2p/helia import), unit-testable
|
|
23
|
+
* against a stubbed client.
|
|
12
24
|
*/
|
|
13
25
|
export const Erc721MinBalanceOptionsSchema = z.object({
|
|
14
26
|
type: z.literal("erc721-min-balance"),
|
|
@@ -16,85 +28,21 @@ export const Erc721MinBalanceOptionsSchema = z.object({
|
|
|
16
28
|
contract: z.string(),
|
|
17
29
|
min: z.number().int().positive().default(1)
|
|
18
30
|
});
|
|
19
|
-
/** Score from one balance: the holding when it meets `min`, else `0n` (does not qualify). */
|
|
20
|
-
function scoreOf(balance, min) {
|
|
21
|
-
return balance >= BigInt(min) ? balance : 0n;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Chunking policy shared with the voter-level read coalescer (src/chain/coalescer.ts). viem's
|
|
25
|
-
* own default chunking (1,024 bytes of calldata ≈ 27 `balanceOf`s) would split a 1000-wallet
|
|
26
|
-
* batch into ~38 chunks and fire them ALL concurrently — a burst public RPC endpoints throttle
|
|
27
|
-
* (measured against `mainnet.base.org`: 33/38 requests answered HTTP 429 `-32016 over rate
|
|
28
|
-
* limit` and the batch never settled). 200 reads is ~45 KB of calldata and ~2–5M `eth_call`
|
|
29
|
-
* gas — inside public request-size and gas caps — so a 1000-wallet batch is 5 round trips.
|
|
30
|
-
* The in-flight bound here is per evaluateMany call; the coalescer additionally enforces the
|
|
31
|
-
* same budget globally across parallel contests (its wrapped `multicall` is what this rule's
|
|
32
|
-
* batched path runs through).
|
|
33
|
-
*/
|
|
34
|
-
const READS_PER_MULTICALL = CHAIN_READS_PER_MULTICALL;
|
|
35
|
-
const MULTICALL_CONCURRENCY = CHAIN_MULTICALL_CONCURRENCY;
|
|
36
|
-
const CHUNK_RETRY_DELAY_MS = CHAIN_CHUNK_RETRY_DELAY_MS;
|
|
37
31
|
export const erc721MinBalance = {
|
|
38
32
|
type: "erc721-min-balance",
|
|
39
33
|
optionsSchema: Erc721MinBalanceOptionsSchema,
|
|
40
34
|
async evaluate({ options, walletAddress, ctx }) {
|
|
41
|
-
const balance = await ctx
|
|
42
|
-
address: getAddress(options.contract),
|
|
43
|
-
abi: erc721Abi,
|
|
44
|
-
functionName: "balanceOf",
|
|
45
|
-
args: [getAddress(walletAddress)],
|
|
46
|
-
blockNumber: BigInt(ctx.blockNumber)
|
|
47
|
-
});
|
|
35
|
+
const balance = await balanceOf(getAddress(options.contract), walletAddress, ctx);
|
|
48
36
|
return { score: scoreOf(balance, options.min) };
|
|
49
37
|
},
|
|
50
38
|
async evaluateMany({ options, walletAddresses, ctx }) {
|
|
51
39
|
const contract = getAddress(options.contract);
|
|
52
|
-
// Multicall3 `aggregate3` batching
|
|
53
|
-
// cold join.
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// without multicall3) takes the per-wallet fallback below.
|
|
60
|
-
if (typeof ctx.chain.multicall === "function" && ctx.chain.chain?.contracts?.multicall3) {
|
|
61
|
-
const chunks = [];
|
|
62
|
-
for (let at = 0; at < walletAddresses.length; at += READS_PER_MULTICALL) {
|
|
63
|
-
chunks.push(walletAddresses.slice(at, at + READS_PER_MULTICALL));
|
|
64
|
-
}
|
|
65
|
-
const results = new Array(walletAddresses.length);
|
|
66
|
-
const readChunk = async (chunk) => ctx.chain.multicall({
|
|
67
|
-
contracts: chunk.map((wallet) => ({
|
|
68
|
-
address: contract,
|
|
69
|
-
abi: erc721Abi,
|
|
70
|
-
functionName: "balanceOf",
|
|
71
|
-
args: [getAddress(wallet)]
|
|
72
|
-
})),
|
|
73
|
-
allowFailure: false,
|
|
74
|
-
batchSize: 0,
|
|
75
|
-
blockNumber: BigInt(ctx.blockNumber)
|
|
76
|
-
});
|
|
77
|
-
let nextChunk = 0;
|
|
78
|
-
const worker = async () => {
|
|
79
|
-
while (nextChunk < chunks.length) {
|
|
80
|
-
const index = nextChunk++;
|
|
81
|
-
const chunk = chunks[index];
|
|
82
|
-
let balances;
|
|
83
|
-
try {
|
|
84
|
-
balances = await readChunk(chunk);
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
await new Promise((resolve) => setTimeout(resolve, CHUNK_RETRY_DELAY_MS));
|
|
88
|
-
balances = await readChunk(chunk);
|
|
89
|
-
}
|
|
90
|
-
for (let i = 0; i < chunk.length; i++) {
|
|
91
|
-
results[index * READS_PER_MULTICALL + i] = { score: scoreOf(balances[i], options.min) };
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
await Promise.all(Array.from({ length: Math.min(MULTICALL_CONCURRENCY, chunks.length) }, worker));
|
|
96
|
-
return results;
|
|
97
|
-
}
|
|
98
|
-
return Promise.all(walletAddresses.map((walletAddress) => this.evaluate({ options, walletAddress, ctx })));
|
|
40
|
+
// Multicall3 `aggregate3` batching (see nft-balance.ts for the chunking policy) — the
|
|
41
|
+
// path the background chain verifier rides on a cold join. A client that cannot batch
|
|
42
|
+
// takes the per-wallet fallback.
|
|
43
|
+
const balances = canBatch(ctx)
|
|
44
|
+
? await balancesOfBatched(contract, walletAddresses, ctx)
|
|
45
|
+
: await Promise.all(walletAddresses.map((wallet) => balanceOf(contract, wallet, ctx)));
|
|
46
|
+
return balances.map((balance) => ({ score: scoreOf(balance, options.min) }));
|
|
99
47
|
}
|
|
100
48
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChainReadContext } from "./types.js";
|
|
2
|
+
/** Score from one balance: the holding when it meets `min`, else `0n` (does not qualify). */
|
|
3
|
+
export declare function scoreOf(balance: bigint, min: number): bigint;
|
|
4
|
+
/**
|
|
5
|
+
* True when the client can run multicall3 `aggregate3` batches — it needs both the action and
|
|
6
|
+
* its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
|
|
7
|
+
* multicall3) takes the per-wallet path instead.
|
|
8
|
+
*/
|
|
9
|
+
export declare function canBatch(ctx: ChainReadContext): boolean;
|
|
10
|
+
/** One wallet's `balanceOf` at the bundle's sampled block. */
|
|
11
|
+
export declare function balanceOf(contract: `0x${string}`, walletAddress: string, ctx: ChainReadContext): Promise<bigint>;
|
|
12
|
+
/**
|
|
13
|
+
* Many wallets' `balanceOf` at ONE sampled block — the path the background chain verifier rides
|
|
14
|
+
* on a cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
|
|
15
|
+
*
|
|
16
|
+
* The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
|
|
17
|
+
* viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
|
|
18
|
+
* each, so a big batch is a handful of polite round trips rather than a ~40-request burst a
|
|
19
|
+
* public endpoint throttles — and the retry re-reads only the chunk that failed, never a
|
|
20
|
+
* completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
|
|
21
|
+
* twice still fails the whole call: the caller gets one rejection, not partial results.
|
|
22
|
+
*/
|
|
23
|
+
export declare function balancesOfBatched(contract: `0x${string}`, walletAddresses: string[], ctx: ChainReadContext): Promise<bigint[]>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { erc721Abi, getAddress } from "viem";
|
|
2
|
+
import { CHAIN_CHUNK_RETRY_DELAY_MS, CHAIN_MULTICALL_CONCURRENCY, CHAIN_READS_PER_MULTICALL } from "../chain/coalescer.js";
|
|
3
|
+
/**
|
|
4
|
+
* The shared `balanceOf` read path behind the two NFT gate rules (`erc5192-min-balance`, the
|
|
5
|
+
* registered v1 gate, and the unregistered `erc721-min-balance`). Internal — not exported from
|
|
6
|
+
* `src/index.ts`, not part of the public API.
|
|
7
|
+
*
|
|
8
|
+
* Both rules score the same way (`balance >= min ? balance : 0n`); they differ only in whether
|
|
9
|
+
* the contract must additionally declare ERC-5192. Keeping ONE copy of the chunking policy
|
|
10
|
+
* matters: the numbers below are tuned against real public endpoints (see below), and two
|
|
11
|
+
* drifting copies would silently reintroduce the burst this exists to prevent.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Chunking policy shared with the voter-level read coalescer (src/chain/coalescer.ts). viem's
|
|
15
|
+
* own default chunking (1,024 bytes of calldata ≈ 27 `balanceOf`s) would split a 1000-wallet
|
|
16
|
+
* batch into ~38 chunks and fire them ALL concurrently — a burst public RPC endpoints throttle
|
|
17
|
+
* (measured against `mainnet.base.org`: 33/38 requests answered HTTP 429 `-32016 over rate
|
|
18
|
+
* limit` and the batch never settled). 200 reads is ~45 KB of calldata and ~2–5M `eth_call`
|
|
19
|
+
* gas — inside public request-size and gas caps — so a 1000-wallet batch is 5 round trips.
|
|
20
|
+
* The in-flight bound here is per batch call; the coalescer additionally enforces the
|
|
21
|
+
* same budget globally across parallel contests (its wrapped `multicall` is what the batched
|
|
22
|
+
* path below runs through).
|
|
23
|
+
*/
|
|
24
|
+
const READS_PER_MULTICALL = CHAIN_READS_PER_MULTICALL;
|
|
25
|
+
const MULTICALL_CONCURRENCY = CHAIN_MULTICALL_CONCURRENCY;
|
|
26
|
+
const CHUNK_RETRY_DELAY_MS = CHAIN_CHUNK_RETRY_DELAY_MS;
|
|
27
|
+
/** Score from one balance: the holding when it meets `min`, else `0n` (does not qualify). */
|
|
28
|
+
export function scoreOf(balance, min) {
|
|
29
|
+
return balance >= BigInt(min) ? balance : 0n;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* True when the client can run multicall3 `aggregate3` batches — it needs both the action and
|
|
33
|
+
* its chain's multicall3 deployment. A client built without a `chain` (or on a chain without
|
|
34
|
+
* multicall3) takes the per-wallet path instead.
|
|
35
|
+
*/
|
|
36
|
+
export function canBatch(ctx) {
|
|
37
|
+
return typeof ctx.chain.multicall === "function" && Boolean(ctx.chain.chain?.contracts?.multicall3);
|
|
38
|
+
}
|
|
39
|
+
/** One wallet's `balanceOf` at the bundle's sampled block. */
|
|
40
|
+
export function balanceOf(contract, walletAddress, ctx) {
|
|
41
|
+
return ctx.chain.readContract({
|
|
42
|
+
address: contract,
|
|
43
|
+
abi: erc721Abi,
|
|
44
|
+
functionName: "balanceOf",
|
|
45
|
+
args: [getAddress(walletAddress)],
|
|
46
|
+
blockNumber: BigInt(ctx.blockNumber)
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Many wallets' `balanceOf` at ONE sampled block — the path the background chain verifier rides
|
|
51
|
+
* on a cold join. Requires {@link canBatch}; callers fall back to mapping {@link balanceOf}.
|
|
52
|
+
*
|
|
53
|
+
* The wallets are chunked HERE (`READS_PER_MULTICALL` per aggregate3, `batchSize: 0` disables
|
|
54
|
+
* viem's own 1KB re-chunking) and the chunks are sent with bounded concurrency plus one retry
|
|
55
|
+
* each, so a big batch is a handful of polite round trips rather than a ~40-request burst a
|
|
56
|
+
* public endpoint throttles — and the retry re-reads only the chunk that failed, never a
|
|
57
|
+
* completed one (viem's own whole-batch retry re-fired the entire burst). A chunk that fails
|
|
58
|
+
* twice still fails the whole call: the caller gets one rejection, not partial results.
|
|
59
|
+
*/
|
|
60
|
+
export async function balancesOfBatched(contract, walletAddresses, ctx) {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
for (let at = 0; at < walletAddresses.length; at += READS_PER_MULTICALL) {
|
|
63
|
+
chunks.push(walletAddresses.slice(at, at + READS_PER_MULTICALL));
|
|
64
|
+
}
|
|
65
|
+
const balances = new Array(walletAddresses.length);
|
|
66
|
+
const readChunk = async (chunk) => ctx.chain.multicall({
|
|
67
|
+
contracts: chunk.map((wallet) => ({
|
|
68
|
+
address: contract,
|
|
69
|
+
abi: erc721Abi,
|
|
70
|
+
functionName: "balanceOf",
|
|
71
|
+
args: [getAddress(wallet)]
|
|
72
|
+
})),
|
|
73
|
+
allowFailure: false,
|
|
74
|
+
batchSize: 0,
|
|
75
|
+
blockNumber: BigInt(ctx.blockNumber)
|
|
76
|
+
});
|
|
77
|
+
let nextChunk = 0;
|
|
78
|
+
const worker = async () => {
|
|
79
|
+
while (nextChunk < chunks.length) {
|
|
80
|
+
const index = nextChunk++;
|
|
81
|
+
const chunk = chunks[index];
|
|
82
|
+
let read;
|
|
83
|
+
try {
|
|
84
|
+
read = await readChunk(chunk);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, CHUNK_RETRY_DELAY_MS));
|
|
88
|
+
read = await readChunk(chunk);
|
|
89
|
+
}
|
|
90
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
91
|
+
balances[index * READS_PER_MULTICALL + i] = read[i];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
await Promise.all(Array.from({ length: Math.min(MULTICALL_CONCURRENCY, chunks.length) }, worker));
|
|
96
|
+
return balances;
|
|
97
|
+
}
|
package/dist/rules/registry.d.ts
CHANGED
|
@@ -14,15 +14,38 @@ import type { RuleRegistry } from "./types.js";
|
|
|
14
14
|
/**
|
|
15
15
|
* The library's built-in rules, before any host override.
|
|
16
16
|
*
|
|
17
|
-
* v1 ships exactly the NFT path: `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* recuses via `UnknownRuleError`
|
|
21
|
-
*
|
|
17
|
+
* v1 ships exactly the soulbound-NFT path: `erc5192-min-balance` (Pass gate) + `constant`
|
|
18
|
+
* weight. TWO chain-reading rules stay in the tree and unit-tested (only `erc721-min-balance` is
|
|
19
|
+
* re-exported from `src/index.ts`) but deliberately OUT of this map, so a criteria naming either
|
|
20
|
+
* recuses via `UnknownRuleError` instead of silently gating on an asset that does not bound
|
|
21
|
+
* Sybils. A host that wants one anyway can still register it through the override map below —
|
|
22
|
+
* the library declines to bless the configuration, it does not forbid it.
|
|
23
|
+
*
|
|
24
|
+
* **`erc721-min-balance`** — a bare `balanceOf` on a *transferable* token. The gate bounds
|
|
25
|
+
* Sybils only because the asset cannot move (DESIGN.md "Does one Pass mean one vote?"): every
|
|
26
|
+
* bundle is verified at its OWN pinned block, stays live for `voteExpiryBuckets`, and the
|
|
27
|
+
* winner set is LWW-keyed per wallet, so one token walked A → B → C inside a single expiry
|
|
28
|
+
* window backs three concurrent live votes — each read true at its own block, none collapsed by
|
|
29
|
+
* LWW, and not one of them individually invalid. `erc5192-min-balance` is the same rule plus an
|
|
30
|
+
* on-chain assertion that the contract declares its tokens locked (issue #27).
|
|
31
|
+
*
|
|
32
|
+
* **`erc20-balance`** — the same amplification, reopened by fungibility, plus a second blocker.
|
|
33
|
+
* Both must resolve before it re-ships:
|
|
34
|
+
*
|
|
35
|
+
* 1. the weight path is design-open — a balance-derived weight derives its magnitude from
|
|
36
|
+
* the chain read, so it carries no free wire-side ceiling for the lazy tally (see
|
|
37
|
+
* `RuleResult` in types.ts, ROADMAP.md "Deferred");
|
|
38
|
+
* 2. the ERC-5192 fix does not transfer to fungibles: a balance can be neither soulbound nor
|
|
39
|
+
* LWW-keyed by token id. Closing it needs a hold-duration guard instead — require `min` at
|
|
40
|
+
* the pinned block AND at `pinned - expiryWindow`, which forces two wallets to have held
|
|
41
|
+
* the balance simultaneously. Tracked in issue #28.
|
|
42
|
+
*
|
|
43
|
+
* Both exclusions are pinned by tests: the amplification each one permits in
|
|
44
|
+
* `src/crdt/amplification.test.ts`, the absence from this map in `rules.test.ts`.
|
|
22
45
|
*/
|
|
23
46
|
export declare const builtinRegistry: RuleRegistry;
|
|
24
47
|
/** type ids the v1 implementation guarantees; checked against `requires.rules`. */
|
|
25
|
-
export declare const V1_BUILTIN_RULE_TYPES: readonly ["
|
|
48
|
+
export declare const V1_BUILTIN_RULE_TYPES: readonly ["erc5192-min-balance", "constant"];
|
|
26
49
|
/**
|
|
27
50
|
* Merge host overrides over the built-ins. Overrides shadow built-ins by `type`. The
|
|
28
51
|
* override map is a plain `RuleRegistry` (a flat record already allows any subset
|
package/dist/rules/registry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UnknownRuleError } from "../errors.js";
|
|
2
|
-
import {
|
|
2
|
+
import { erc5192MinBalance } from "./erc5192-min-balance.js";
|
|
3
3
|
import { constant } from "./constant.js";
|
|
4
4
|
/**
|
|
5
5
|
* The rule registry: builtins, the shadowing resolver, and criteria validation.
|
|
@@ -15,18 +15,41 @@ import { constant } from "./constant.js";
|
|
|
15
15
|
/**
|
|
16
16
|
* The library's built-in rules, before any host override.
|
|
17
17
|
*
|
|
18
|
-
* v1 ships exactly the NFT path: `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* recuses via `UnknownRuleError`
|
|
22
|
-
*
|
|
18
|
+
* v1 ships exactly the soulbound-NFT path: `erc5192-min-balance` (Pass gate) + `constant`
|
|
19
|
+
* weight. TWO chain-reading rules stay in the tree and unit-tested (only `erc721-min-balance` is
|
|
20
|
+
* re-exported from `src/index.ts`) but deliberately OUT of this map, so a criteria naming either
|
|
21
|
+
* recuses via `UnknownRuleError` instead of silently gating on an asset that does not bound
|
|
22
|
+
* Sybils. A host that wants one anyway can still register it through the override map below —
|
|
23
|
+
* the library declines to bless the configuration, it does not forbid it.
|
|
24
|
+
*
|
|
25
|
+
* **`erc721-min-balance`** — a bare `balanceOf` on a *transferable* token. The gate bounds
|
|
26
|
+
* Sybils only because the asset cannot move (DESIGN.md "Does one Pass mean one vote?"): every
|
|
27
|
+
* bundle is verified at its OWN pinned block, stays live for `voteExpiryBuckets`, and the
|
|
28
|
+
* winner set is LWW-keyed per wallet, so one token walked A → B → C inside a single expiry
|
|
29
|
+
* window backs three concurrent live votes — each read true at its own block, none collapsed by
|
|
30
|
+
* LWW, and not one of them individually invalid. `erc5192-min-balance` is the same rule plus an
|
|
31
|
+
* on-chain assertion that the contract declares its tokens locked (issue #27).
|
|
32
|
+
*
|
|
33
|
+
* **`erc20-balance`** — the same amplification, reopened by fungibility, plus a second blocker.
|
|
34
|
+
* Both must resolve before it re-ships:
|
|
35
|
+
*
|
|
36
|
+
* 1. the weight path is design-open — a balance-derived weight derives its magnitude from
|
|
37
|
+
* the chain read, so it carries no free wire-side ceiling for the lazy tally (see
|
|
38
|
+
* `RuleResult` in types.ts, ROADMAP.md "Deferred");
|
|
39
|
+
* 2. the ERC-5192 fix does not transfer to fungibles: a balance can be neither soulbound nor
|
|
40
|
+
* LWW-keyed by token id. Closing it needs a hold-duration guard instead — require `min` at
|
|
41
|
+
* the pinned block AND at `pinned - expiryWindow`, which forces two wallets to have held
|
|
42
|
+
* the balance simultaneously. Tracked in issue #28.
|
|
43
|
+
*
|
|
44
|
+
* Both exclusions are pinned by tests: the amplification each one permits in
|
|
45
|
+
* `src/crdt/amplification.test.ts`, the absence from this map in `rules.test.ts`.
|
|
23
46
|
*/
|
|
24
47
|
export const builtinRegistry = {
|
|
25
|
-
[
|
|
48
|
+
[erc5192MinBalance.type]: erc5192MinBalance,
|
|
26
49
|
[constant.type]: constant
|
|
27
50
|
};
|
|
28
51
|
/** type ids the v1 implementation guarantees; checked against `requires.rules`. */
|
|
29
|
-
export const V1_BUILTIN_RULE_TYPES = ["
|
|
52
|
+
export const V1_BUILTIN_RULE_TYPES = ["erc5192-min-balance", "constant"];
|
|
30
53
|
/**
|
|
31
54
|
* Merge host overrides over the built-ins. Overrides shadow built-ins by `type`. The
|
|
32
55
|
* override map is a plain `RuleRegistry` (a flat record already allows any subset
|