@bitsocial/pubsub-voting 0.4.0 → 0.5.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 +57 -13
- package/dist/chain/types.d.ts +10 -9
- package/dist/client/voter.d.ts +79 -19
- package/dist/client/voter.js +82 -66
- package/dist/errors.d.ts +6 -8
- package/dist/errors.js +8 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/rules/erc20-balance.d.ts +0 -1
- package/dist/rules/erc20-balance.js +0 -2
- package/dist/rules/erc5192-min-balance.d.ts +0 -1
- package/dist/rules/erc5192-min-balance.js +0 -2
- package/dist/rules/erc721-min-balance.d.ts +0 -1
- package/dist/rules/erc721-min-balance.js +0 -2
- package/dist/rules/gate.d.ts +164 -0
- package/dist/rules/gate.js +209 -0
- package/dist/rules/registry.d.ts +11 -5
- package/dist/rules/registry.js +18 -9
- package/dist/schema/common.d.ts +5 -5
- package/dist/schema/common.js +5 -5
- package/dist/schema/criteria.d.ts +58 -29
- package/dist/schema/criteria.js +123 -23
- package/dist/signer/eip712.d.ts +2 -2
- package/dist/signer/eip712.js +1 -1
- package/dist/tally/tally.d.ts +2 -1
- package/dist/tally/tally.js +10 -14
- package/dist/tally/types.d.ts +1 -1
- package/dist/transport/integration/harness.js +3 -3
- package/dist/verify/background.d.ts +8 -6
- package/dist/verify/background.js +57 -42
- package/dist/verify/bundle.d.ts +12 -10
- package/dist/verify/bundle.js +69 -38
- package/dist/verify/types.d.ts +37 -16
- package/package.json +1 -1
- package/dist/chain/ticker.d.ts +0 -15
- package/dist/chain/ticker.js +0 -25
package/README.md
CHANGED
|
@@ -24,10 +24,14 @@ 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 soulbound-NFT path — an `erc5192-min-balance` gate
|
|
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, and the criteria's `gate` composes those rules with `all` / `any` — so "the Pass, or a moderator, and not banned" is a document, not a custom rule. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked, read at the verifier's head so a freshly-acquired Pass votes immediately) 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
|
|
|
31
|
+
## Research
|
|
32
|
+
|
|
33
|
+
- [Token-gated ephemeral boards](./docs/research/token-gated-ephemeral-boards.md) explores an adjacent, ownerless discussion protocol derived from on-chain assets. It is research rather than part of this library's roadmap or wire format.
|
|
34
|
+
|
|
31
35
|
## Usage
|
|
32
36
|
|
|
33
37
|
The library never starts a node and never takes a host SDK (there is no `pkc` argument). A host passes its own running Helia node in directly and injects its seams into a single `PubsubVoter`:
|
|
@@ -35,13 +39,24 @@ The library never starts a node and never takes a host SDK (there is no `pkc` ar
|
|
|
35
39
|
| Seam | Type | Required | Purpose |
|
|
36
40
|
|---|---|---|---|
|
|
37
41
|
| `helia` | `HeliaInstance` | yes | the host's running Helia node; must carry a gossipsub service at `libp2p.services.pubsub` (else `MissingPubsubError`), a `blockstore` (else `MissingBlockstoreError`), and a libp2p fetch service at `libp2p.services.fetch` (else `MissingFetchError`) |
|
|
38
|
-
| `chains` | `ChainClientFactory` | yes | resolves
|
|
42
|
+
| `chains` | `ChainClientFactory` | yes | resolves the chain a contest counts in (`{ chainId }`, from `criteria.bucketChainId`) to a viem `PublicClient`; every gate rule and the weight rule read through it. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that carries a multicall3 deployment in its viem `chain` config and serves **historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head** (the v1 gate reads the head first, but falls back to the block a ballot names — see [Custom rules](#custom-rules)); return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
|
|
39
43
|
| `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
|
|
40
44
|
| `nameResolvers` | `NameResolver[]` | no | community-name resolvers (same interface and instances as pkc-js's `nameResolvers`, e.g. `@bitsocial/bso-resolver` for `name.bso`); each vote's `community.name` claim is verified through them — inline at the forward-gate for live votes, in the background verifier for cold-join admits — and a bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted |
|
|
41
45
|
| `dataPath` | `string \| false` | no | directory for the voter's persistent state (gate-result + name-resolution caches, and each joined contest's **checkpoint snapshot** — its last fully-verified winner-set, reloaded at join so a restart with no other peer online keeps the tally), the pkc-js `dataPath` equivalent. Node default: `{cwd}/.bitsocial-pubsub-voting` (better-sqlite3 under `{dataPath}/lru-storage/` + `{dataPath}/checkpoints.db`); in the browser the path is ignored and everything lives in IndexedDB. Pass `false` for in-memory-only (the pkc-js `noData` equivalent). A restart re-serves settled gate reads and fresh name resolutions from the store instead of the RPC, and restores each contest's checkpoint before the cold-start pull. A seeder should always set a stable path |
|
|
42
46
|
| `httpRouterUrls` | `string[]` | no | Delegated Routing V1 router base URLs to **announce provider records to** (one unsigned `PUT /routing/v1/providers` per router; `Keys` batches every joined contest's criteria CID + current checkpoint root + chunk CIDs — hourly, debounced on root changes, and on address changes). **Seeders only**: absent/empty means never announce (the default — plain clients are not dialable), and the browser build never announces regardless. The node must be publicly **reachable** (its listening port open/forwarded/published), but it does not need to know its own public IP: private, loopback, and link-local addrs are filtered client-side, and when nothing survives — the normal zero-config case behind NAT or a Docker bridge, and even on public-IP hosts, since libp2p withholds unconfirmed public addrs pending AutoNAT — the announcer sends the wildcard sentinels (`/ip4/0.0.0.0/...`, `/ip6/::/...`) that the router rewrites to the PUT's observed source IP, exactly as kubo announces work. Configured `addresses.announce` values (concrete public addrs, DNS/AutoTLS, or a kubo-style wildcard) are used as-is. Only a loopback-only node announces nothing. *Querying* needs no URLs here — cold-join discovery uses the injected node's `libp2p.contentRouting`, which the host wires its routers into |
|
|
43
47
|
|
|
44
|
-
A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry + the `chains` factory: an unimplemented rule throws `UnknownRuleError`, an unresolvable required chain throws `MissingChainClientError` — recuse, don't miscount), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists. The document names
|
|
48
|
+
A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry + the `chains` factory: an unimplemented rule throws `UnknownRuleError`, an unresolvable required chain throws `MissingChainClientError` — recuse, don't miscount), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists. The document names its chain only by `bucketChainId`; RPC endpoints stay out of it, so operators can swap gateways without forking the topic.
|
|
49
|
+
|
|
50
|
+
Who may vote is the document's `gate` — one rule, or a boolean tree of them:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
gate: { rule: { type: "erc5192-min-balance", contract: "0x13d4…91b9", min: 1 } }
|
|
54
|
+
gate: { all: [{ rule: passRule }, { rule: notBannedRule }] } // every rule must admit
|
|
55
|
+
gate: { any: [{ rule: passRule }, { rule: moderatorRule }] } // any one of them admits
|
|
56
|
+
gate: { all: [{ any: [{ rule: passRule }, { rule: moderatorRule }] }, { rule: notBannedRule }] }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each rule still answers one question about one wallet and knows nothing about the others; the document composes them. Because the topic is the CID of these bytes, the schema rejects the spellings that would mean the same as a shorter tree — a branch needs **at least two** children, may not repeat a child, and may not nest a branch of its own kind (`{ all: [{ all: [A, B] }, C] }` is just `{ all: [A, B, C] }`) — since each would put one contest on two topics. A rule may appear in two different branches, which is how a gate says "any two of these three": `{ any: [{ all: [A, B] }, { all: [A, C] }, { all: [B, C] }] }`. Trees nest at most 4 deep with at most 8 rules in total. No rule names a chain: every one of them reads the chain the contest counts in (`bucketChainId`), which is the chain the block each rule is handed comes from. Child order is significant and is the order the forward gate evaluates in, so put the cheapest or most discriminating rule first.
|
|
45
60
|
|
|
46
61
|
### Construct a voter
|
|
47
62
|
|
|
@@ -57,12 +72,12 @@ const viemChainFactory = (): ChainClientFactory => {
|
|
|
57
72
|
const clients: Record<number, ReturnType<typeof createPublicClient>> = {
|
|
58
73
|
[base.id]: createPublicClient({ chain: base, transport: http("https://my-trusted-base-rpc.example") })
|
|
59
74
|
};
|
|
60
|
-
return ({ chainId }) => clients[chainId]; // undefined → recuse contests
|
|
75
|
+
return ({ chainId }) => clients[chainId]; // undefined → recuse contests counting in that chain
|
|
61
76
|
};
|
|
62
77
|
|
|
63
78
|
const voter = new PubsubVoter({
|
|
64
79
|
helia, // the host's Helia node; needs a gossipsub service at libp2p.services.pubsub + a blockstore
|
|
65
|
-
chains: viemChainFactory(), // ({
|
|
80
|
+
chains: viemChainFactory(), // ({ chainId }) => viem PublicClient | undefined
|
|
66
81
|
signer: mySigner, // optional; omit → read-only voter
|
|
67
82
|
nameResolvers: [bsoResolver], // optional; verifies community-name claims (e.g. @bitsocial/bso-resolver)
|
|
68
83
|
dataPath: "/path/to/data", // optional; persistent state: caches + checkpoint snapshots (default {cwd}/.bitsocial-pubsub-voting; false → in-memory)
|
|
@@ -76,7 +91,7 @@ Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingF
|
|
|
76
91
|
|
|
77
92
|
### Read a tally reactively (no signer needed)
|
|
78
93
|
|
|
79
|
-
`createContest` mints a per-contest read object; `update()` starts syncing and it emits `update` (carrying a fresh `tally`) and `error`, just like a
|
|
94
|
+
`createContest` mints a per-contest read object; `update()` starts syncing and it emits `update` (carrying a fresh `tally`) and `error`, just like a pkc-js `community`:
|
|
80
95
|
|
|
81
96
|
```ts
|
|
82
97
|
const contest = await voter.createContest({ criteria }); // criteria: the contest's full document (strictly validated here)
|
|
@@ -89,20 +104,47 @@ await contest.update(); // join the topic, col
|
|
|
89
104
|
|
|
90
105
|
### Will this wallet's vote count?
|
|
91
106
|
|
|
92
|
-
Ask the contest before signing. `checkEligibility` runs the contest's **real gate
|
|
93
|
-
the same chain
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
Ask the contest before signing. `checkEligibility` runs the contest's **real gate rules** through
|
|
108
|
+
the same chain clients, head reader and memos the forward gate uses, so each reads at whatever
|
|
109
|
+
block it reads at and applies whatever threshold it applies. When the gate refuses, the wording is
|
|
110
|
+
the rules' own — render it verbatim:
|
|
96
111
|
|
|
97
112
|
```ts
|
|
98
113
|
const check = await contest.checkEligibility({ address: wallet });
|
|
99
114
|
if (check.eligible) {
|
|
100
115
|
show(`eligible — holds ${check.score}`);
|
|
101
116
|
} else {
|
|
102
|
-
|
|
117
|
+
for (const failure of check.failures) show(failure.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
|
|
103
118
|
}
|
|
104
119
|
```
|
|
105
120
|
|
|
121
|
+
With a composite gate you get one entry per rule, so a client can show a checklist rather than a
|
|
122
|
+
single verdict:
|
|
123
|
+
|
|
124
|
+
| field | what it is |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| `checks` | every rule in the gate, in document order: `{ leaf, ruleId, type, satisfied, score, error? }` |
|
|
127
|
+
| `failures` | the rules whose failure **explains** the refusal — render these |
|
|
128
|
+
| `gate` | the same tree as `criteria.gate`, each node carrying its `satisfied`, for rendering the real requirement |
|
|
129
|
+
| `error` | `failures` joined into one sentence, for a caller that only wants a string |
|
|
130
|
+
|
|
131
|
+
`failures` is deliberately **not** `checks.filter(c => !c.satisfied)`. Under `any`, a wallet that
|
|
132
|
+
qualifies as a moderator also "fails" the Pass rule — telling it to go and buy a Pass would be
|
|
133
|
+
worse than saying nothing.
|
|
134
|
+
|
|
135
|
+
Key rows by **`leaf`** — the rule's position in the gate, and the only field guaranteed unique in
|
|
136
|
+
one result. `type` is not (a gate may name one rule twice on different options) and neither is
|
|
137
|
+
`ruleId` (a gate may name the same rule in two branches). Use `ruleId` to compare *across* results
|
|
138
|
+
instead: equal ids are one question, so a directory of 63 boards gated on one Pass shows the same
|
|
139
|
+
id everywhere, and shares one chain read behind it.
|
|
140
|
+
|
|
141
|
+
A check's `satisfied` is `true`, `false`, or **`undefined`** — the last meaning that rule's chain
|
|
142
|
+
read failed and the gate was decided without it. Render it as unknown, never as a requirement the
|
|
143
|
+
wallet is missing: nothing was learned about them. A wallet admitted by a branch that did answer
|
|
144
|
+
still gets `eligible: true` while an unrelated contract's RPC is down; the call throws only when
|
|
145
|
+
the gate cannot be decided without the rule that failed, because at that point there is no honest
|
|
146
|
+
answer to give.
|
|
147
|
+
|
|
106
148
|
Do **not** reimplement this by reading balances yourself: which block counts is the rule's
|
|
107
149
|
business and changes when the rule changes. A client that hard-codes "peers verify at the bucket
|
|
108
150
|
boundary" keeps telling voters to wait for a window that a head-reading gate no longer imposes.
|
|
@@ -136,7 +178,7 @@ Repeated `createContest` calls with byte-identical criteria return the same `Con
|
|
|
136
178
|
|
|
137
179
|
### Publish or withdraw a vote (needs a signer)
|
|
138
180
|
|
|
139
|
-
`createContestVote` mints a publishable ballot; `publish()` signs and broadcasts it once and emits `publishingstatechange`, like a
|
|
181
|
+
`createContestVote` mints a publishable ballot; `publish()` signs and broadcasts it once and emits `publishingstatechange`, like a pkc-js publication:
|
|
140
182
|
|
|
141
183
|
```ts
|
|
142
184
|
const vote = await voter.createContestVote({ criteria, votes: [{ community: { publicKey: "12D3KooW..." }, vote: 1 }] });
|
|
@@ -221,7 +263,7 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
|
|
|
221
263
|
|
|
222
264
|
### Custom rules
|
|
223
265
|
|
|
224
|
-
The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` — where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **
|
|
266
|
+
The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` — where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **gate** slot treats the score as admission (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `gate admits ? weight.score : 0n`. A rule never sees the gate it sits in: composition (`all` / `any`), which failures a voter is shown, and whether a refusal may be blamed on the sender are all folded by the library from what each rule returned. A rule that needs a threshold fails below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, wallets, ctx })` batch hook (`wallets` in place of `wallet`, returning `{ results }` — one per input wallet, in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads. The batch is simply everything pending, so its wallets need not share a `sampleBlock`: a rule reading the head scores them all at once, a rule reading pinned blocks groups them itself. (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification).)
|
|
225
267
|
|
|
226
268
|
Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
|
|
227
269
|
|
|
@@ -294,6 +336,8 @@ type RuleResult =
|
|
|
294
336
|
|
|
295
337
|
**`penalize`** answers the one thing the library cannot: may the failure be blamed on the sender? `true` (the default) says every honest verifier computes this same failure — true of a read pinned to the block the bundle names — so the forward gate `reject`s the message (penalizing the delivering peer in gossipsub's scoring) and the verdict is cached as terminal. `false` says an honest peer could legitimately disagree, so the bundle is dropped `ignore`-class instead: no penalty, verdict uncached, and the background verifier re-examines it for a grace window before giving up.
|
|
296
338
|
|
|
339
|
+
Across a composite gate the library folds those answers, and the fold is not a simple OR. An `all` fails as soon as one child does, so **one** attributable failure makes the whole refusal attributable — that rule alone closes the gate identically everywhere. An `any` fails only when every alternative does, so it is attributable only if **every** one of them is: a single unprovable failure means a peer on a fresher chain may be looking at a wallet this gate would admit, and penalizing it would punish honest relaying.
|
|
340
|
+
|
|
297
341
|
`erc5192-min-balance` reads the head first — so a freshly-acquired Pass counts immediately — falls back to `wallet.sampleBlock` when the head refuses (ERC-5192 does not forbid burning, and without the fallback a burn would erase votes retroactively for peers that had not verified them yet), memoizes each leg under its own epoch, and returns `penalize: false`, because at validation time it cannot attribute a failure to anyone: the peer that forwarded the vote verified it against *its* head. Every other rule in the tree reads `wallet.sampleBlock` and leaves `penalize` at its default — a transferable or fungible balance can decrease, so reading it at the head would silently invalidate votes already counted. See [DESIGN.md, What a rule owns](./DESIGN.md#what-a-rule-owns-and-what-the-pipeline-owns).
|
|
298
342
|
|
|
299
343
|
### Weighted voting (deferred)
|
package/dist/chain/types.d.ts
CHANGED
|
@@ -18,16 +18,18 @@ import type { PublicClient } from "viem";
|
|
|
18
18
|
* balance reads or chainTicker-to-RPC mapping. All of this is net-new here.
|
|
19
19
|
*/
|
|
20
20
|
export type ChainClient = PublicClient;
|
|
21
|
-
/** chainTicker -> client, built from `criteria.requires.chains`. */
|
|
22
|
-
export type ChainClients = Record<string, ChainClient>;
|
|
23
21
|
/**
|
|
24
|
-
* Factory the host provides: resolve
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
22
|
+
* Factory the host provides: resolve the chain a contest counts in (`criteria.bucketChainId`)
|
|
23
|
+
* to a viem `PublicClient`. The RPC endpoint is the HOST's setting — deliberately not part of
|
|
24
|
+
* the criteria document (see schema/criteria.ts, `bucketChainId`) — so this factory is where a
|
|
25
|
+
* chain id meets the gateways this client trusts (typically
|
|
26
|
+
* `createPublicClient({ chain, transport: http(myRpcUrl) })`).
|
|
29
27
|
*
|
|
30
|
-
*
|
|
28
|
+
* Keyed by chain ID alone, because that is the identity the criteria carries and the EIP-712
|
|
29
|
+
* ballot domain is signed over. A contest reads exactly one chain: every gate rule and the
|
|
30
|
+
* weight rule are handed this client (see DESIGN.md "One clock").
|
|
31
|
+
*
|
|
32
|
+
* Return `undefined` (or throw) when no RPC is configured for that chain: the voter
|
|
31
33
|
* then throws `MissingChainClientError` at the create seam (`createContest` /
|
|
32
34
|
* `createContestVote`) — this client must recuse the contest rather than miscount.
|
|
33
35
|
*
|
|
@@ -39,7 +41,6 @@ export type ChainClients = Record<string, ChainClient>;
|
|
|
39
41
|
* to bucket sample blocks) and carries a multicall3 deployment in its viem `chain` config.
|
|
40
42
|
*/
|
|
41
43
|
export type ChainClientFactory = (args: {
|
|
42
|
-
chain: string;
|
|
43
44
|
chainId: number;
|
|
44
45
|
}) => ChainClient | undefined;
|
|
45
46
|
/**
|
package/dist/client/voter.d.ts
CHANGED
|
@@ -21,16 +21,16 @@ import type { VoteSigner } from "../signer/types.js";
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function republishIntervalBuckets(criteria: Criteria): number;
|
|
23
23
|
/**
|
|
24
|
-
* Public facade — three objects, mirroring pkc-js
|
|
24
|
+
* Public facade — three objects, mirroring pkc-js:
|
|
25
25
|
* - {@link PubsubVoter} (`VoteClient`): the factory. Holds the host-injected dependencies once
|
|
26
26
|
* and owns one engine per contest, keyed by topic. A contest is addressed by its full criteria
|
|
27
27
|
* document — `createContest({ criteria })` validates it and derives the topic — so a directory
|
|
28
28
|
* host like 5chan authors its 63 documents however it likes (e.g. merged from a local
|
|
29
29
|
* manifest) and creates each contest, without wiring dependencies 63 times.
|
|
30
30
|
* - {@link Contest} (`createContest`): one contest's reactive **read** view. `update()` starts
|
|
31
|
-
* syncing and emits `update` (carrying a fresh `tally`) / `error`, like `
|
|
31
|
+
* syncing and emits `update` (carrying a fresh `tally`) / `error`, like `community.update()`.
|
|
32
32
|
* - {@link ContestVote} (`createContestVote`): one publishable **ballot**. `publish()` signs and
|
|
33
|
-
* broadcasts it once, emitting `publishingstatechange` / `error`, like a
|
|
33
|
+
* broadcasts it once, emitting `publishingstatechange` / `error`, like a pkc publication.
|
|
34
34
|
*
|
|
35
35
|
* The injected seams (helia, chains, signer, nameResolvers) are the ONLY host contact surface, so
|
|
36
36
|
* the same core runs under pkc-js, plebbit, or a raw node. The host passes its running Helia node
|
|
@@ -64,19 +64,73 @@ export interface PublishOutcome {
|
|
|
64
64
|
*/
|
|
65
65
|
readonly recipientCount: number;
|
|
66
66
|
}
|
|
67
|
-
/** One contest's reactive read view: subscribe, keep the tally in sync, read it. */
|
|
68
67
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* One rule's answer about one wallet, as {@link Contest.checkEligibility} reports it.
|
|
69
|
+
* Shaped like {@link RuleResult} on purpose — it is that result, surfaced — so a failure always
|
|
70
|
+
* carries a sentence a client can display.
|
|
71
|
+
*/
|
|
72
|
+
export interface EligibilityCheck {
|
|
73
|
+
/**
|
|
74
|
+
* This leaf's position in the gate, depth-first in document order — the render key, and the
|
|
75
|
+
* only field guaranteed unique within one result. `type` is not (a gate may name one rule
|
|
76
|
+
* twice on different options) and neither is `ruleId` (a gate may name the SAME rule in two
|
|
77
|
+
* branches, which is how "any two of these three" is written).
|
|
78
|
+
*/
|
|
79
|
+
readonly leaf: number;
|
|
80
|
+
/**
|
|
81
|
+
* Stable identity of this rule INSTANCE — the hash of its canonical criteria reference plus
|
|
82
|
+
* the id of the chain it reads, which is also the namespace of its memo. Equal ids mean one
|
|
83
|
+
* question, so they are what to compare ACROSS results: two contests of a 63-board directory
|
|
84
|
+
* gated on one Pass share this id, and share every chain read behind it. Within one gate two
|
|
85
|
+
* leaves may share it; use {@link leaf} to tell them apart.
|
|
86
|
+
*/
|
|
87
|
+
readonly ruleId: string;
|
|
88
|
+
/** The rule `type` this leaf names, for grouping and labelling. */
|
|
89
|
+
readonly type: string;
|
|
90
|
+
/**
|
|
91
|
+
* `undefined` when this rule has no answer: its chain read failed and the gate was decided
|
|
92
|
+
* without it. Render it as unknown, never as a failure — nothing was learned about the wallet.
|
|
93
|
+
*/
|
|
94
|
+
readonly satisfied: boolean | undefined;
|
|
95
|
+
/** This rule's score for the wallet; `0n` unless it passed. */
|
|
96
|
+
readonly score: bigint;
|
|
97
|
+
/** The rule's own explanation, written for the voter. Render it verbatim. */
|
|
98
|
+
readonly error?: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The gate tree with each node's verdict — the same shape as `criteria.gate`, so a client can
|
|
102
|
+
* render the real requirement ("the Pass AND not banned", "the Pass OR moderator") instead of a
|
|
103
|
+
* flat list that cannot express it.
|
|
104
|
+
*/
|
|
105
|
+
export type EligibilityNode = ({
|
|
106
|
+
kind: "leaf";
|
|
107
|
+
} & EligibilityCheck) | {
|
|
108
|
+
kind: "all" | "any";
|
|
109
|
+
satisfied: boolean | undefined;
|
|
110
|
+
children: EligibilityNode[];
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* What {@link Contest.checkEligibility} found.
|
|
114
|
+
*
|
|
115
|
+
* `checks` is every rule in the gate, in document order. `failures` is the subset that EXPLAINS a
|
|
116
|
+
* refusal, which is not the same thing: a rule that failed inside a satisfied `any` branch cost
|
|
117
|
+
* the wallet nothing, and telling someone to go acquire an asset they do not need is worse than
|
|
118
|
+
* saying nothing. Render `failures`; use `gate` when the shape of the requirement matters.
|
|
71
119
|
*/
|
|
72
120
|
export type EligibilityResult = {
|
|
73
121
|
eligible: true;
|
|
74
|
-
/** The
|
|
122
|
+
/** The gate's folded score (min across an `all`, max across an `any`), always `> 0n`. */
|
|
75
123
|
score: bigint;
|
|
124
|
+
readonly checks: readonly EligibilityCheck[];
|
|
125
|
+
readonly gate: EligibilityNode;
|
|
76
126
|
} | {
|
|
77
127
|
eligible: false;
|
|
78
|
-
/** The
|
|
128
|
+
/** The blame set's reasons joined — the one-string form, for a caller that wants one. */
|
|
79
129
|
error: string;
|
|
130
|
+
/** The rules whose failure explains the refusal. Never empty on this branch. */
|
|
131
|
+
readonly failures: readonly EligibilityCheck[];
|
|
132
|
+
readonly checks: readonly EligibilityCheck[];
|
|
133
|
+
readonly gate: EligibilityNode;
|
|
80
134
|
};
|
|
81
135
|
export interface Contest {
|
|
82
136
|
/** The criteria document this contest runs (already validated). */
|
|
@@ -100,24 +154,30 @@ export interface Contest {
|
|
|
100
154
|
getTally(): Promise<ContestTally>;
|
|
101
155
|
/**
|
|
102
156
|
* Would this contest's gate admit `address` right now? Ask before signing, to tell a voter
|
|
103
|
-
* whether their ballot will count — and, when it will not, exactly
|
|
157
|
+
* whether their ballot will count — and, when it will not, exactly which rules refused.
|
|
104
158
|
*
|
|
105
|
-
* This runs the contest's REAL gate
|
|
106
|
-
* background verifier use: the same chain
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
159
|
+
* This runs the contest's REAL gate rules through the same context the forward-gate and the
|
|
160
|
+
* background verifier use: the same chain clients, the same coalesced head reader, the same
|
|
161
|
+
* memos. So it reads at whatever block each rule reads at, applies whatever thresholds they
|
|
162
|
+
* apply, and returns their own {@link RuleResult.error} wording verbatim. A client renders
|
|
163
|
+
* those sentences and needs to know nothing about blocks, buckets or thresholds — which is
|
|
110
164
|
* the point: re-deriving any of that outside the rule is how a UI ends up confidently
|
|
111
165
|
* telling voters to wait for a window that no longer gates anything.
|
|
112
166
|
*
|
|
167
|
+
* Every leaf is evaluated, even once the outcome is settled, because naming each failure is
|
|
168
|
+
* what this call is for (the forward gate, which only needs the verdict, stops early instead).
|
|
169
|
+
* A rule whose chain read fails comes back `satisfied: undefined` rather than sinking the
|
|
170
|
+
* whole answer — a wallet admitted by a branch that did answer is still told so. It throws
|
|
171
|
+
* only when the gate cannot be decided without the rule that failed.
|
|
172
|
+
*
|
|
113
173
|
* It is a courtesy check, not a promise. Eligibility is a fact about the chain and can change
|
|
114
174
|
* between this call and the publish, and each peer verifies against its own view — so a
|
|
115
175
|
* `true` here can still be followed by a `VoteEvictedError` (which carries the same kind of
|
|
116
176
|
* reason). `publish()` deliberately does NOT call this: the gate is the network's decision,
|
|
117
177
|
* and refusing locally would only hide a vote the rest of the topic would have accepted.
|
|
118
178
|
*
|
|
119
|
-
* Costs one gate
|
|
120
|
-
* would do anyway, not
|
|
179
|
+
* Costs one evaluation per gate rule, usually served from the shared memos — the same reads
|
|
180
|
+
* the verifier would do anyway, not extra ones.
|
|
121
181
|
*/
|
|
122
182
|
checkEligibility(args: {
|
|
123
183
|
address: string;
|
|
@@ -228,10 +288,10 @@ export interface PubsubVoterOptions {
|
|
|
228
288
|
*/
|
|
229
289
|
helia: HeliaInstance;
|
|
230
290
|
/**
|
|
231
|
-
* Resolves
|
|
232
|
-
*
|
|
291
|
+
* Resolves the chain a contest counts in (`criteria.bucketChainId`) to a viem
|
|
292
|
+
* `PublicClient`. Which RPC gateway to use is THIS client's setting — RPC URLs
|
|
233
293
|
* are deliberately not part of the criteria document, so this factory is where the host
|
|
234
|
-
* maps
|
|
294
|
+
* maps chain ids to the endpoints it trusts. Return one shared client per chain (memoized),
|
|
235
295
|
* and `undefined` for a chain with no RPC configured — `createContest` /
|
|
236
296
|
* `createContestVote` then throws `MissingChainClientError` (recuse, don't miscount).
|
|
237
297
|
* See `ChainClientFactory` (src/chain/types.ts) for the full contract.
|
package/dist/client/voter.js
CHANGED
|
@@ -3,7 +3,6 @@ import { CriteriaSchema } from "../schema/criteria.js";
|
|
|
3
3
|
import { VotesBundleSchema } from "../schema/votes.js";
|
|
4
4
|
import { coalescingChainFactory } from "../chain/coalescer.js";
|
|
5
5
|
import { makeBucketMath } from "../chain/bucket.js";
|
|
6
|
-
import { tickerForRef } from "../chain/ticker.js";
|
|
7
6
|
import { requireHeliaServices } from "../transport/helia.js";
|
|
8
7
|
import { makeBlockstoreBundleStore } from "../transport/bundle-store.js";
|
|
9
8
|
import { makeRateLimiter } from "../transport/rate-limit.js";
|
|
@@ -15,7 +14,7 @@ import { encodeBundle, decodeBundle, bundleCidForBytes } from "../crdt/codec.js"
|
|
|
15
14
|
import { resolveRegistry, validateCriteriaRules } from "../rules/registry.js";
|
|
16
15
|
import { makeVoteCrdt } from "../crdt/crdt.js";
|
|
17
16
|
import { makePersistentRuleCache } from "../rules/cache.js";
|
|
18
|
-
import {
|
|
17
|
+
import { gateBlame, gateLeaves, gateReason, gateScore } from "../rules/gate.js";
|
|
19
18
|
import { makeBundleVerifier } from "../verify/bundle.js";
|
|
20
19
|
import { makeVerdictCache } from "../verify/cache.js";
|
|
21
20
|
import { makeNameResolutionCache } from "../verify/name-resolution-cache.js";
|
|
@@ -533,12 +532,10 @@ class ContestEngine {
|
|
|
533
532
|
topic;
|
|
534
533
|
readOnly;
|
|
535
534
|
#deps;
|
|
536
|
-
/** Chain clients for this contest, built from `criteria.requires.chains` via the factory. */
|
|
537
|
-
#chainClients;
|
|
538
535
|
#criteriaCid;
|
|
539
|
-
/** The
|
|
536
|
+
/** The contest's chain id (`criteria.bucketChainId`), bound into every ballot signature. */
|
|
540
537
|
#chainId;
|
|
541
|
-
/** The
|
|
538
|
+
/** The contest's one chain client, also the seed chain for the tally's tie-break block hash. */
|
|
542
539
|
#ruleChain;
|
|
543
540
|
#bucketMath;
|
|
544
541
|
#crdt;
|
|
@@ -604,24 +601,16 @@ class ContestEngine {
|
|
|
604
601
|
this.readOnly = deps.signer === undefined;
|
|
605
602
|
this.#deps = deps;
|
|
606
603
|
this.#criteriaCid = criteriaCidBytes;
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
if (!rule)
|
|
618
|
-
throw new UnknownRuleError("rule", criteria.rule.type);
|
|
619
|
-
const ruleTicker = tickerForRef(criteria, criteria.rule, rule.optionsSchema.parse(criteria.rule));
|
|
620
|
-
const ruleChain = this.#chainClients[ruleTicker];
|
|
621
|
-
if (!ruleChain)
|
|
622
|
-
throw new Error(`no chain client for gating (\`rule\`) chain "${ruleTicker}"`);
|
|
623
|
-
this.#ruleChain = ruleChain;
|
|
624
|
-
this.#chainId = criteria.requires.chains[ruleTicker].chainId;
|
|
604
|
+
// The contest's ONE chain (`bucketChainId`): it fixes the ballot's chainId, the blocks the
|
|
605
|
+
// buckets count, the block every rule is handed, and the tie-break seed. Resolved eagerly,
|
|
606
|
+
// so a client with no RPC configured for it finds out at the create seam (and recuses)
|
|
607
|
+
// rather than on its first verify.
|
|
608
|
+
this.#chainId = criteria.bucketChainId;
|
|
609
|
+
const chain = deps.chains({ chainId: this.#chainId });
|
|
610
|
+
if (chain === undefined)
|
|
611
|
+
throw new MissingChainClientError(this.#chainId);
|
|
612
|
+
this.#ruleChain = chain;
|
|
613
|
+
const gateRefs = gateLeaves(criteria.gate);
|
|
625
614
|
this.#bucketMath = makeBucketMath(criteria.blocksPerBucket);
|
|
626
615
|
const store = makeBlockstoreBundleStore(deps.blockstore);
|
|
627
616
|
// The CRDT keeps a superseded bundle alive while its superseder's deferred checks are
|
|
@@ -632,40 +621,38 @@ class ContestEngine {
|
|
|
632
621
|
voteExpiryBuckets: criteria.voteExpiryBuckets,
|
|
633
622
|
isProvisional: (cid) => this.#isPending(cid)
|
|
634
623
|
});
|
|
635
|
-
//
|
|
636
|
-
// and the background chain verifier so neither re-reads what the other settled —
|
|
637
|
-
// over the voter's persistent store and namespaced by the hash of
|
|
638
|
-
// reference + chainId. That hash is exactly the sharing boundary: two
|
|
639
|
-
// gate (a directory of boards on the same Pass) share each other's
|
|
640
|
-
// different
|
|
641
|
-
// under it, and for how long, is the rule's business, not the engine's.
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
//
|
|
624
|
+
// One memo per gate leaf (rules/cache.ts), each shared between the inline forward-gate
|
|
625
|
+
// verifier and the background chain verifier so neither re-reads what the other settled —
|
|
626
|
+
// layered over the voter's persistent store and namespaced by the hash of that leaf's
|
|
627
|
+
// canonical rule reference + chainId. That hash is exactly the sharing boundary: two
|
|
628
|
+
// contests over one gate rule (a directory of boards on the same Pass) share each other's
|
|
629
|
+
// reads, while different rules, or one rule on different options, can never collide. What
|
|
630
|
+
// is stored under it, and for how long, is the rule's business, not the engine's. The same
|
|
631
|
+
// hash is each leaf's public `ruleId` in `checkEligibility` — NOT unique within one gate,
|
|
632
|
+
// because a rule may be named in two branches, and two positions of one question SHOULD
|
|
633
|
+
// share a keyspace (see `dedupeLeaves`).
|
|
634
|
+
this.#gateRefs = gateRefs;
|
|
635
|
+
this.#ruleIds = gateRefs.map((ref) => sha256(encodeDagCbor({ chainId: this.#chainId, rule: ref })));
|
|
636
|
+
const gateCaches = this.#ruleIds.map((namespace) => makePersistentRuleCache({ store: deps.gateStore, namespace }));
|
|
637
|
+
// The weight rule gets its own namespace on the same terms: its canonical reference plus
|
|
638
|
+
// this contest's chain id. It reads the same chain as everything else here — a weight rule
|
|
639
|
+
// on a second chain is the same open question as a gate leaf on one.
|
|
649
640
|
const weight = deps.registry[criteria.weight.type];
|
|
650
641
|
if (!weight)
|
|
651
642
|
throw new UnknownRuleError("weight", criteria.weight.type);
|
|
652
|
-
const weightTicker = tickerForRef(criteria, criteria.weight, weight.optionsSchema.parse(criteria.weight));
|
|
653
|
-
const weightChainId = criteria.requires.chains[weightTicker]?.chainId;
|
|
654
|
-
if (weightChainId === undefined)
|
|
655
|
-
throw new Error(`no chain client for weight chain "${weightTicker}"`);
|
|
656
643
|
const weightCache = makePersistentRuleCache({
|
|
657
644
|
store: deps.gateStore,
|
|
658
|
-
namespace: sha256(encodeDagCbor({ chainId:
|
|
645
|
+
namespace: sha256(encodeDagCbor({ chainId: this.#chainId, rule: criteria.weight }))
|
|
659
646
|
});
|
|
660
647
|
const verifier = makeBundleVerifier({
|
|
661
648
|
criteria,
|
|
662
649
|
criteriaCid: criteriaCidBytes,
|
|
663
650
|
chainId: this.#chainId,
|
|
664
651
|
registry: deps.registry,
|
|
665
|
-
|
|
652
|
+
chain: this.#ruleChain,
|
|
666
653
|
bucketMath: this.#bucketMath,
|
|
667
654
|
nameResolvers: deps.nameResolvers,
|
|
668
|
-
|
|
655
|
+
ruleCaches: gateCaches,
|
|
669
656
|
nameResolutionCache: deps.nameResolutionCache,
|
|
670
657
|
readHead: ({ chain }) => this.#readHead({ chain })
|
|
671
658
|
});
|
|
@@ -678,10 +665,10 @@ class ContestEngine {
|
|
|
678
665
|
this.#background = makeBackgroundVerifier({
|
|
679
666
|
criteria,
|
|
680
667
|
registry: deps.registry,
|
|
681
|
-
|
|
668
|
+
chain: this.#ruleChain,
|
|
682
669
|
bucketMath: this.#bucketMath,
|
|
683
670
|
nameResolvers: deps.nameResolvers,
|
|
684
|
-
|
|
671
|
+
ruleCaches: gateCaches,
|
|
685
672
|
nameResolutionCache: deps.nameResolutionCache,
|
|
686
673
|
readHead: ({ chain }) => this.#readHead({ chain }),
|
|
687
674
|
cache: this.#cache,
|
|
@@ -694,7 +681,7 @@ class ContestEngine {
|
|
|
694
681
|
this.#tally = makeTally({
|
|
695
682
|
criteria,
|
|
696
683
|
registry: deps.registry,
|
|
697
|
-
|
|
684
|
+
chain: this.#ruleChain,
|
|
698
685
|
bucketMath: this.#bucketMath,
|
|
699
686
|
readHead: ({ chain }) => this.#readHead({ chain }),
|
|
700
687
|
ruleCache: weightCache,
|
|
@@ -705,8 +692,10 @@ class ContestEngine {
|
|
|
705
692
|
});
|
|
706
693
|
}
|
|
707
694
|
#store;
|
|
708
|
-
/**
|
|
709
|
-
#
|
|
695
|
+
/** Per gate leaf: hash of its canonical rule ref + chainId — its keyspace in the shared gate store. */
|
|
696
|
+
#ruleIds;
|
|
697
|
+
/** The gate's leaf refs in document order, aligned with {@link #ruleIds}. */
|
|
698
|
+
#gateRefs;
|
|
710
699
|
#cache;
|
|
711
700
|
#acceptedDedup;
|
|
712
701
|
#verifier;
|
|
@@ -805,12 +794,6 @@ class ContestEngine {
|
|
|
805
794
|
emitError(error) {
|
|
806
795
|
this.#emitError(error);
|
|
807
796
|
}
|
|
808
|
-
#chainFor(ticker) {
|
|
809
|
-
const client = this.#chainClients[ticker];
|
|
810
|
-
if (!client)
|
|
811
|
-
throw new Error(`no chain client configured for chain "${ticker}"`);
|
|
812
|
-
return client;
|
|
813
|
-
}
|
|
814
797
|
/**
|
|
815
798
|
* Read the gating-chain head and update {@link #currentBucketCache}; returns the bucket.
|
|
816
799
|
*
|
|
@@ -873,21 +856,54 @@ class ContestEngine {
|
|
|
873
856
|
return sampleBucket <= (await this.#nowBucket());
|
|
874
857
|
}
|
|
875
858
|
/**
|
|
876
|
-
* Run the gate
|
|
877
|
-
*
|
|
859
|
+
* Run the gate for one wallet, against the ballot block a vote published NOW would carry —
|
|
860
|
+
* the engine half of {@link Contest.checkEligibility}.
|
|
878
861
|
*
|
|
879
|
-
* All the interesting decisions belong to the
|
|
880
|
-
* block, hands it over, and translates
|
|
881
|
-
*
|
|
882
|
-
* a host registers later.
|
|
862
|
+
* All the interesting decisions belong to the rules: this resolves the current bucket's sample
|
|
863
|
+
* block, hands it over, and translates their own answers. It never looks at what kind of rules
|
|
864
|
+
* it is holding, so it stays correct for a head-scoring gate, a pinned one, a composite of
|
|
865
|
+
* both, or anything a host registers later.
|
|
883
866
|
*/
|
|
884
867
|
async checkEligibility({ address }) {
|
|
885
868
|
const sampleBlock = this.#bucketMath.sampleBlockForBucket(await this.#nowBucket());
|
|
886
|
-
const
|
|
887
|
-
const
|
|
888
|
-
|
|
869
|
+
const gate = await this.#verifier.checkGates({ address, sampleBlock });
|
|
870
|
+
const checks = new Map();
|
|
871
|
+
const node = this.#toEligibilityNode(gate, checks);
|
|
872
|
+
// Document order, not evaluation order: the leaves as the criteria lists them.
|
|
873
|
+
const ordered = [...checks.entries()].sort(([a], [b]) => a - b).map(([, check]) => check);
|
|
874
|
+
if (gate.satisfied === true)
|
|
875
|
+
return { eligible: true, score: gateScore(gate), checks: ordered, gate: node };
|
|
876
|
+
return {
|
|
877
|
+
eligible: false,
|
|
878
|
+
error: gateReason(gate),
|
|
879
|
+
// The blame set, not every failed leaf: a failure inside a satisfied `any` explains
|
|
880
|
+
// nothing the voter can act on (rules/gate.ts `gateBlame`).
|
|
881
|
+
failures: gateBlame(gate).map((leaf) => checks.get(leaf.leaf)),
|
|
882
|
+
checks: ordered,
|
|
883
|
+
gate: node
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
/** Project one evaluated gate node onto the public shape, collecting its leaves by index. */
|
|
887
|
+
#toEligibilityNode(result, checks) {
|
|
888
|
+
if (result.kind !== "leaf") {
|
|
889
|
+
return {
|
|
890
|
+
kind: result.kind,
|
|
891
|
+
satisfied: result.satisfied,
|
|
892
|
+
children: result.children.map((child) => this.#toEligibilityNode(child, checks))
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
const check = {
|
|
896
|
+
leaf: result.leaf,
|
|
897
|
+
ruleId: this.#ruleIds[result.leaf],
|
|
898
|
+
type: this.#gateRefs[result.leaf].type,
|
|
899
|
+
satisfied: result.satisfied,
|
|
900
|
+
score: result.score,
|
|
901
|
+
...(result.error === undefined ? {} : { error: result.error })
|
|
902
|
+
};
|
|
903
|
+
checks.set(result.leaf, check);
|
|
904
|
+
return { kind: "leaf", ...check };
|
|
889
905
|
}
|
|
890
|
-
/** Hash of the current bucket boundary block on the
|
|
906
|
+
/** Hash of the current bucket boundary block on the contest's chain (rolling tie seed). */
|
|
891
907
|
async #bucketBlockHash() {
|
|
892
908
|
const head = await this.#ruleChain.getBlockNumber();
|
|
893
909
|
const boundary = this.#bucketMath.sampleBlockForBucket(this.#bucketMath.bucketForBlock(Number(head)));
|