Skip to content

Latest commit

 

History

History
71 lines (48 loc) · 16.3 KB

File metadata and controls

71 lines (48 loc) · 16.3 KB

Roadmap: @bitsocial/pubsub-voting

The scope-of-record for what ships when. DESIGN.md is how each part works and why; this is which parts are in v1 and what is deliberately held back. When the two disagree about scope, this file wins.

Guiding principle for v1: the NFT use case, nothing more

v1 does exactly one thing: holder-decided directory voting gated by a soulbound ERC-721 (the 5chan Pass), one Pass = one vote. Everything that is not on that path is deferred, even where the code or design already exists. Keeping the shipped surface this small is the point — it is the smallest thing that solves 5chan's competitive directory system, and every deferred item below is a real capability the architecture already leaves room for, not a rewrite.

Concretely, v1 is:

  • Gate (gate): erc5192-min-balance (hold ≥ N of the Pass, on a contract that declares ERC-5192 — the lock assertion is what makes "one Pass = one vote" true; see DESIGN.md, Does one Pass mean one vote?).
  • Weight: constant (1 Pass = 1 vote) — no balance reads, so the tally does zero chain reads.
  • Voting: upvote-only, one vote per topic (maxVotesPerAddress: 1), approval-style across contests.
  • Built-in rule registry: exactly erc5192-min-balance + constant. Nothing else is registered — including the plain erc721-min-balance, which gates on a transferable asset.

Token-weighting, ERC-20 in either slot, and multi-chain resolution are out of v1 (see Deferred).

Status

The engine, client lifecycle, and live-delta transport are implemented and unit-tested, and the two-node gossipsub integration test now exercises them against real @libp2p/gossipsub; the host-side registration gap has closed too (pkc-js registers gossipsub + @libp2p/fetch on the shared node as of 0.0.63), leaving only the pkc-js accessor and tuning follow-ups below. Accurate as of this file's commit — verify against the tree, not this list, if they drift.

Done (implemented + unit-tested)

  • zod schemas (criteria, votes, wire primitives) and canonical dag-cbor encoding
  • topic derivation (topic = "bitsocial-votes/" + CID(dag-cbor(criteria))) and manifest → per-contest criteria derivation
  • EIP-712 ballot signer + frozen conformance vector (the cross-client wire spec)
  • verify pipeline: signature + address-recovery + criteria constraints + on-chain gate (rule) + community-name resolution, with a per-bundle verdict cache
  • state-based CRDT: LWW winner-set keyed by wallet, binary dag-cbor bundle codec (frozen byte vector), in-memory node store; forward-gate anti-amplification caches (gate-result + accepted-dedup)
  • tally: deterministic per-contest aggregation over pre-validated bundles, rolling-seed tiebreak
  • transport validate-before-forward gossip gate (src/transport/gossip-validator.ts) over the two-kind pubsub union (one inline bundle per message with the criteria-derived size cap, or a root record): the full pipeline runs on the inlined bytes in an async topic validator before forwardMessage — no fetch on the live path
  • root-record checkpoint sync: on-demand checkpoint encode (dirty-flag cached, blocks blockstore-backed), the suppressed 10-min root heartbeat, the libp2p-fetch responder + k = 4 cold-join requester (MissingFetchError construction guard) (re-armed on gossipsub subscription-change for one heartbeat interval after join, closing the join-races-subscription-gossip gap — issue #15), and the bounded directed-bitswap chase of divergent roots (src/transport/chase.ts)
  • checkpoint-snapshot persistence (issue #14): each joined contest's last fully-verified checkpoint persists under dataPath (src/checkpoint/snapshot.ts + the non-LRU SnapshotStorage in src/storage/, one atomic blob per topic) — written debounced 10 s on winner-set changes, skipped while any deferred check is pending, flushed on leave(), reloaded at join() through the chase's decode+verify path — so a seeder restart with no other peer online keeps the tally (measured: npm run bench:warm-restart, zero gate RPCs on reload)
  • chain bucket math and the chainId → chain-client seam (ChainClientFactory)
  • RPC endpoints are client settings, not consensus content: the document names its chain once, as bucketChainId (a numeric chain id; strict schemas, so a document still carrying the older requires.chains map or the pre-v1 rpcUrls fails loudly instead of silently deriving a different topic), so an operator can swap a dead or rate-limited gateway without forking the topic. The host's chains factory owns the chainId → RPC mapping (one shared client per chain, wrapped by the cross-contest read coalescer); a chain the factory cannot resolve throws MissingChainClientError at the create seam (recuse, not miscount), and an RPC read that throws stays infra-class everywhere (gossip gate ignore, background verifier retry) — see DESIGN.md "Criteria document" for the gateway requirements
  • rules: erc5192-min-balance + constant (registered); erc721-min-balance (exported) and erc20-balance present in-tree and unit-tested but not registered — both gate on an asset that can move (see Deferred)
  • reactive facade: PubsubVoter (factory) minting Contest (createContestupdate() + update/error events + tally) and ContestVote (createContestVotepublish() + publishingstatechange/error); PubsubVoter.stop/destroy lifecycle (no start() — a seeder just creates + updates every contest; the fetch responder registers lazily on the first topic join). Republishing a live vote is the client's job — the library publishes each vote once and exports republishIntervalBuckets(criteria) so the client can schedule its own refreshes (no scheduler, no persistence; see DESIGN.md "Republishing is the client's job")
  • two-node gossipsub integration test (src/transport/integration/, run via npm run test:integration, excluded from the unit npm test): two real loopback libp2p + Helia nodes on @libp2p/gossipsub 16.0.3 pinning what a fake cannot — an invalid inline bundle is not forwarded and its sender is reject-scored (P₄); a valid one is forwarded and merges; a verify past the deadline yields ignore with no penalty and is re-evaluable (uncached); a converged pair's matching root triggers no chase; a divergent root is chased over directed bitswap to convergence. It surfaced a latent bug — the injected Helia blockstore.get is an async generator, now normalised by adaptBlockstore (src/transport/helia.ts)

Remaining for v1

Nothing in this repository. The required host-side service registrations have landed in pkc-js; the follow-ups below are non-blocking.

Deferred pkc-js work (external dependency)

  • A documented, version-stable accessor for the shared Helia node — done in pkc-js 0.0.72 (Libp2pJsClient.heliaNode, pkc-js#221 / PR #223): returns the node carrying libp2p.services.pubsub, blockstore, the fetch service, and libp2p.contentRouting, semver-covered. The pinned devDependency and both pkc-js host tests use it; consumers no longer reach through the private ._helia field.
  • Adding @libp2p/fetch to the shared node's construction — done in pkc-js 0.0.63 (fetch: libp2pFetch() alongside pubsub: gossipsub() in helia-for-pkc). This library registers its own lookup function and runs its own requester against libp2p.services.fetch (see Done, root-record checkpoint sync); PubsubVoter still throws MissingFetchError at construction on a node without the service.

Filed as pkc-js#183, closed 2026-07 — the service registrations landed (gossipsub 16.0.2, above the >= 15.0.23 floor, plus @libp2p/fetch); the version-stable accessor followed in 0.0.72. Still open upstream: score tuning; and this library pins gossipsub 16.0.3, so a follow-up asks the host to match.

Deferred (designed, not shipped)

These are decided in principle and the architecture leaves room for each without an engine rewrite, but none ships in v1. Kept here so v1 stays small and the intent is not lost.

  • A transferable NFT gate (erc721-min-balance). In-tree, exported, unit-tested, and unregistered: a bare balanceOf says nothing about whether the asset can move, and a gating asset that moves backs one concurrent live vote per transfer inside an expiry window (see DESIGN.md, Does one Pass mean one vote?; vector in src/crdt/amplification.test.ts). v1 ships erc5192-min-balance instead — the same read plus a supportsInterface(0xb45a3c0e) assertion at the same pinned block. A host that wants a transferable gate registers it through the rules override map; the library declines to bless it, it does not forbid it. #27.

  • Balance-derived / token-weighted voting. erc20-balance (Pass gate + BSO weight) stays in-tree and unit-tested but is unregistered, so a criteria naming it recuses via UnknownRuleError rather than silently enabling token-weighting. Re-registering it in builtinRegistry + re-exporting from src/index.ts is the whole re-ship, but it must land with the open question below resolved — and with the Sybil amplification closed. See DESIGN.md, Future improvements.

    • Blocker — the amplification a fungible gate reopens. Every bundle is verified at its own pinned block and the winner set is LWW-keyed per wallet, so one balance walked through several wallets inside a single expiry window backs several concurrent votes (see DESIGN.md, Does one Pass mean one vote?; vector in src/crdt/amplification.test.ts). The NFT path closes this by requiring a gating asset that is non-transferable and declares it on-chain (erc5192-min-balance); a fungible balance is neither that nor keyable by token id, so it needs a hold-duration guardmin at the pinned block and at pinned − voteExpiryBuckets × blocksPerBucket, so two live votes would require two wallets to have held the balance simultaneously. A shorter hold window H only bounds the amplification to ⌊E/H⌋ + 1. The likeliest blocker is archive depth: the second read is a full expiry window back (30 days on the live manifest), which public endpoints frequently do not serve. Tracked in #28.
    • Open question — lazy-tally upper bounds for non-constant weight. The bound-based early stop in "Tally" assumes each unverified vote has a cheap ceiling — trivially 1 for constant. A balance-derived weight like erc20-balance derives magnitude from the chain read, so it carries no free wire-side bound; lazy tally there needs a self-declared, verify-down balance, or it degrades to verifying every ranking-relevant vote. The { score } result object exists precisely to grow a ceiling field for this without another signature break. Decide when the first weighted contest ships — and, if a weight reduction ever lands, alongside it.
  • Gate combining — DONE. criteria.gate composes rules with all / any (a tree, not the flat AND-array first planned), folded by src/rules/gate.ts; checkEligibility reports one entry per rule plus the blame set. See DESIGN.md, The gate is a tree.

  • Multi-chain gating — a rule reading a chain other than the contest's. Today a contest names one chain (criteria.bucketChainId) and every gate rule and the weight rule read it; a rule carries no chain option at all. Letting a leaf name its own chain is additive — it would accept documents refused today, so it re-topics nothing and can land in any release — but the field is the easy half. The hard half is what block such a rule is handed: wallet.sampleBlock is a number on the bucket chain and means nothing on another. The three candidate answers (head-only; bucket-timestamp → nearest block; per-chain bucket math in the document) are written out with their costs in DESIGN.md, Open questions. Head-only is the front-runner precisely because it needs no further document field. Whichever wins also settles the tie-break seed chain and pinned name resolution below, so decide all three together.

  • Weight combining — a weight-only, non-recursive reduction. For multi-asset additive weight (e.g. #Passes + BSO/1000), a sum over weight-slot terms. Requires the lazy-tally upper-bound question above.

  • Pinned-block name resolution across chains. Community-name verification currently resolves at head. Pinning it to a canonical historical block needs a per-bucket block on the registry's chain (Ethereum for .bso), which differs from the criteria's chain (Base in the 5chan example) — the same multi-chain block-selection problem as the tie seed and multi-chain gating. The resolver API already accepts an optional blockNumber; v1 leaves it unset and accepts a transient disagreement window around a name re-point. See DESIGN.md, Open questions.

  • Account-activity gate rules (e.g. min-nonce). A well-formed pure gate rule — read getTransactionCount(wallet) at the bucket block, score > 0n only above a threshold — unlike a rate/timestamp check, which is stateful/temporal and so cannot be a consensus reject (see DESIGN.md, Can valid votes clog the topic?). Not on the Pass v1 path: it is redundant with the non-transferable Pass gate, ~free to bypass (one dust tx per wallet on an L2), and would reject legitimate gasless-minted Pass holders whose wallet is nonce 0 (MintPass mints server-side to the holder's address). Kept for a future token-gated config where a bare balance gate wants a cheap activity signal; the nonce is always read from chain, never carried on the wire.

Acknowledged for v2+ (not designed)

Unlike the deferred items above, these are not designed — only acknowledged so the intent is not lost. Neither ships in v1 or v1.x, and both are breaking wire changes (the topic is the CID of the criteria document, so any criteria-shape change forks every topic), so they must land together in a single v2 criteria revision.

  • Abstract the criteria document away from web3 (drop block-based expiry from the top level). Today web3 primitives are baked into the criteria's top-level shape: blocksPerBucket and voteExpiryBuckets define freshness in chain blocks, requires.chains is a per-chain chainId manifest (RPC endpoints are already client settings, out of the document), and every bundle is stamped with a blockNumber. A v2 revision should move time/freshness behind an abstract epoch notion (with the block-bucket scheme as one pluggable implementation, chosen by the rule that needs it) so the top-level document carries nothing chain-specific — only the rule/weight refs would name web3 when the contest actually gates on a chain. Part of the seam already exists — the CRDT and verify pipeline consume freshness through an injected bucketMath rather than reading the chain themselves — but the criteria shape, the bundle's on-wire blockNumber stamp, and the block-derived tie seed are all chain-block-native today, which is what makes this a v2 wire revision rather than an internal refactor.

  • TODO — investigate web2 voting mechanisms (e.g. whitelist). Explore gate rules that need no chain at all: a static allowlist of addresses baked into the criteria (the document already travels in full, so a whitelist is just rule options), or other non-chain eligibility signals. The rule registry already supports chain-free rules (constant does zero reads), but a genuinely web2 gate is blocked on the abstraction above — without it every contest still drags in block buckets and a chain manifest it doesn't use. Open questions to investigate: what replaces the block-derived expiry and rolling tie seed for a chain-free contest, and whether signature recovery stays EIP-712/secp256k1 when the identity is no longer a wallet.