Files

18 KiB
Raw Permalink Blame History

Solution: LP-0016 — Anonymous Forum with Threshold Moderation and Membership Revocation

Submitted by: Davit Maisuradze (@jeefxM)

Summary

A complete LP-0016 implementation: a forum-agnostic moderation SDK plus a reference Logos Basecamp module that drives the full lifecycle (registration with stake, anonymous posting, N-of-M moderation, K-strike slashing, retroactive deanonymization). Membership registration and slashing are on-chain on the public LEZ testnet; posting and moderation are off-chain over Waku, the way the protocol is designed (free common path, paid revocation).

  • Anonymous posting with a Groth16 membership proof (≈5 s per post, generated and verified off-chain by the local proof-daemon via snarkjs).
  • N-of-M moderation certificates aggregated off-chain over Waku, with the daemon enforcing the N threshold before a certificate is emitted.
  • K-strike slashing via Shamir reconstruction of the member's nullifier secret; one on-chain tx per slash; the slashed commitment enters the on-chain revocation set and the published secret lets any verifier reject the member's future posts.
  • Two parameterized forum instances live on LEZ testnet under one program ID (different K and N-of-M), each with register-with-stake on chain.
  • Reference Qt6 GUI (standalone ForumApp; Logos Basecamp module packaging in progress) built on the SDK's public, forum-agnostic API. Non-CLI, click-driven: connect → create identity → create forum → register → post → strike → slash, with a persistent MEMBER REVOKED banner, per-post ✓ valid / ✗ author revoked badges, a Shamir evidence panel that fills in share-by-share as strikes land, and a chain-evidence panel showing the registry account, tree root, and on-chain register/slash tx hashes.

Repository

Approach

Built on top of the Logos stack:

  • LEZ membership_registry program (programs/registry-spel/, SPEL IDL at programs/registry-spel/registry-spel.idl.json) holds the on-chain membership tree, the revoked commitment set, and a slash verifier that runs inside the RISC0 zkVM (ark-bn254 poly_eval + Ed25519 signature checks). Deployed once; forum instances are seed-derived ForumState PDAs that carry their own ForumConfig (K and N-of-M) — see docs/deployments.md.
  • Forum-agnostic SDK (sdk/, @logos-forum/moderation-sdk) wraps a local proof-daemon (Groth16 prover + chain submitter) and a Waku transport (@waku/sdk). The SDK operates on abstract content identifiers and makes no assumptions about forum structure, satisfying the bounty's "forum-agnostic library" requirement.
  • ZK membership proof is a Groth16 circuit (circuits/, ADR-010). The circom + rapidsnark path was chosen over RISC0 for the per-post proof because membership proof generation needed to fit under the bounty's 10 s budget on a standard laptop (rapidsnark proves the circuit in ~5 s vs ~55 s for an equivalent RISC0 STARK — the RISC0 number is measured by bench_post_proof at RISC0_DEV_MODE=0, see docs/cu-costs.md).
  • Slash via Shamir: each post envelope embeds a Shamir share of the member's nullifier secret (degree-(K-1) polynomial, secret evaluates at x = 0). Moderation certificates bind shares to content identifiers. Once K certificates exist for a member's nullifier, anyone can reconstruct the secret and submit a single on-chain slash tx (/v1/slash/recoversubmitSlash). Below K, no information about the secret leaks — the reconstruction is information-theoretically secure.
  • Basecamp module (basecamp/) is a Qt6 UI module. QML is in basecamp/qml/Main.qml; the C++ backend in basecamp/src/ForumBackend.cpp drives a Node sidecar (basecamp/sidecar/forum-sidecar.mjs) via QProcess. The sidecar consumes the same TS SDK that sdk/tests/lifecycle.mjs runs, so the GUI's behaviour follows the proven path. The Basecamp module is built entirely on the SDK's public, forum-agnostic API and adds no forum-specific code to the library.

Why Logos: censorship resistance for the off-chain moderation record (Waku is the only viable transport for moderator certificates that cannot be silently deleted), and trustless enforcement at the point of revocation (LEZ verifies the cert evidence cryptographically; no centralized authority can selectively enforce or veto a slash). A centralized alternative would either trust a server with the moderation record (defeats auditability) or skip the revocation enforcement (defeats the threshold moderation guarantee).

Why not on-chain moderation per-strike: every strike going on-chain would impose a per-cert gas cost on moderators. Keeping certificates on Waku and only the final slash on-chain (one tx per revocation) preserves the protocol's economic invariant: free common path, paid revocation.

Where each step runs:

  • On-chain (LEZ testnet, testnet.lez.logos.co): forum initialize, fund-escrow, register-with-stake, and slash. The testnet sequencer EXECUTES these transactions; it does not produce an inline STARK per tx. The two live parameterized instances carry on-chain state, register-with-stake, and slash. The testnet wallet at ~/wallet-testnet ships preconfigured genesis-funded accounts (6iArKUXx… = 10 000, 7wHg9sb… = 20 000, authenticated-transfer-owned → spendable) which fund the escrow via auth-transfer 1000 → escrow per ADR-011.
  • Off-chain (Waku + local proof-daemon): anonymous posting (Groth16 prove + verify via snarkjs), moderation vote signing and certificate aggregation, and the post-rejection check for revoked members. None of these touch the sequencer.
  • Real RISC0 STARK: the only real STARK in this submission is the membership post-proof guest, benchmarked by bench_post_proof at RISC0_DEV_MODE=0 (~55 s, prove + verify OK) — see docs/cu-costs.md and ADR-002.
  • Local on-chain-logic test: crates/lez-runner/tests/staking_lifecycle.rs exercises register → post → K certs → slash → revoke through the in-process V03State engine (it executes the program logic; it does not generate a STARK). This is the canonical local end-to-end because the LEZ standalone-mode chain has no runtime funding path (ADR-011 §"The faucet is genesis-only"), so register-with-stake against a live local sequencer is not possible by design; the funded path runs on testnet.

Disclosures (deliberate scope decisions):

  • Posting is demonstrated at K = 3 only. The membership circuit is parameterized by K (circuits/membership.circom, template Membership(TREE_DEPTH, K)), but only one instance is compiled and trusted-set-up: Membership(16, 3)membership_0.zkey, and the live daemon runs CIRCUIT_K=3. Each distinct K needs its own circuit compile + trusted setup, so Instance A (K = 3) demos the full lifecycle including posting + slash, while Instance B (K = 2) exercises the registry's parameterizability on-chain live (different K and N-of-M, register-with-stake on testnet — see Program ID + PDAs below) but its posting path is not exercised, because the running daemon's circuit is K = 3. The bounty's two-instance requirement is about instance parameters (ForumConfig K and N-of-M, which are fully general on-chain), not a second compiled circuit. Supporting K = 2 posting is a recompile + snarkjs groth16 setup away, but no K = 2 zkey is shipped today.
  • The Basecamp module's moderator coordination is collapsed for the single-operator demo: the backend holds all N moderator secrets locally and signs N votes per Strike click. In a real deployment those N keys live on N different machines and each moderator submits a vote independently over Waku; the SDK's aggregateCertificate accepts whichever ≥ N-threshold votes arrive first. Code path at basecamp/src/ForumBackend.cpp:moderate() and basecamp/sidecar/forum-sidecar.mjs:case "moderate".

Success Criteria Checklist

  • Member can register with a stake and publish anonymous posts; posts from the same member are unlinkable below the slash threshold. Register-with-stake runs on testnet (escrow funded via auth-transfer, per ADR-011); posting runs off-chain with a Groth16 proof carrying an epoch-derived nullifier (same per (member, epoch), distinct across members), and each post embeds an independent Shamir share whose x-coordinate is the strike index — the share alone reveals nothing under K. Exercised by sdk/tests/lifecycle.mjs and the Basecamp GUI. See docs/protocol.md §3.

  • Slash retroactively deanonymizes the slashed member's prior posts; no other member is affected; documented in docs/protocol.md. See docs/protocol.md §4 ("Retroactive deanonymization on slash"). The reconstruction recovers exactly one member's secret; everyone else's shares remain below K.

  • N-of-M moderators can jointly produce a certificate; fewer cannot. The daemon's /v1/moderation/aggregate endpoint refuses to emit a certificate when fewer than N valid votes are supplied. Enforced client-side in sdk/src/index.ts:aggregateCertificate, and re-verified by the on-chain slash verifier.

  • K certs → slash → revocation, single on-chain tx. Demonstrated live on LEZ testnet by the full-lifecycle runner (programs/registry-spel/examples/src/bin/testnet_lifecycle.rs): 3 posts × 3 certs with distinct content IDs → Shamir reconstruction → one on-chain slash tx → the commitment enters the on-chain revocation set (verified by reading the state PDA back). Tx hashes in docs/deployments.md. The same flow is exercised locally (no funding required) by crates/lez-runner/tests/staking_lifecycle.rs through the in-process V03State engine.

  • Slashed commitment is added to the revocation list; subsequent posts from it are rejected. On slash, the reconstructed secret is published on-chain and the commitment enters the revocation set. Post-rejection is enforced off-chain by the proof-daemon: verify_post calls post_proof_core::is_revoked_post (crates/proof-daemon/src/proving.rs:252), which recomputes the published secret's nullifier H("null" || secret || epoch) for the proven epoch and rejects any post whose nullifier matches — across epochs, so the member cannot evade by changing epoch. Asserted by the unit test post_proof_core (revoked-member-posts-rejected-across-epochs) and end-to-end by sdk/tests/lifecycle.mjs (re-verifying the same post envelope after slash returns valid: false with a /revok/i reason). At the GUI layer the post sidecar runs verifyPostProof before publishing; a revoked author gets a red ✗ badge and the Post button switches to a disabled "Revoked" state once the GUI detects its own commitment in the revoked set.

  • Parameterizable K and N-of-M per forum instance. Two live instances under one program ID — Instance A (K=3, 2-of-3) and Instance B (K=2, 3-of-4) — share the SPEL membership_registry program but carry distinct ForumConfig in their PDAs. See docs/deployments.md.

  • Forum-agnostic moderation library, public API, no forum assumptions, uses Logos stack off-chain. @logos-forum/moderation-sdk exports createIdentity, createForumInstance, register, createPostProof, publishPost, subscribePosts, listPosts, signModerationVote, aggregateCertificate, publishCertificate, listCertificatesByNullifier, tryReconstructSlashEvidence, submitSlash, verifyPostProof, isRevoked. Operates on ContentId = Hex32; the SDK never names a body or content type.

  • Working Logos Basecamp app built on the library, usable by a non-technical user. basecamp/ builds a Logos Basecamp UI module (libforum_plugin.so, implementing the IComponent interface, IID com.logos.component.IComponent,

    • metadata.json/manifest.json). It is verified loadable into LogosBasecamp v0.1.2 via the AppImage's ui-host loader (the loader emits READY; load log captured — see the release artifacts). The same QML/backend also runs standalone as ForumApp for builders without Basecamp installed. The module is built entirely on the SDK's public, forum-agnostic API. The user sets the moderator count and N-of-M/K thresholds in the UI; the backend provisions distinct real moderator keypairs (no hardcoded secrets) and shows their public keys. Every lifecycle action is click-driven; the user never crafts a tx or runs a CLI command.
  • End-to-end demonstration on LEZ testnet with at least two independent forum instances using different K and N-of-M. Both instances are live on testnet.lez.logos.co under a single membership_registry deployment. The state PDAs decode to non-empty ForumState with advanced tree_root, distinct (K, N-of-M) config, and an escrow holding the staked balance (independently verifiable via wallet account get against the testnet sequencer):

    Instance A Instance B
    (K, N-of-M) (3, 2-of-3) (2, 3-of-4)
    state PDA 9zHLZn5qpMwaWurrs7DQgYDyF4XnF6EwE4HJfqkrDJ37 3gN9jzTbTL6WgxpqMMA5anUM8wavGdMk4isPY6HmX8p1
    escrow PDA 8yiWGFYQ3vAatQfJdpyT6mUBGpTzqRfikF2yDwzNf7B1 5XbV2TAjonag397C5PQNuUBU5yd3f9n6SkprqM7LpYxw
    lifecycle on chain register-with-stake → 3 posts → 3 N-of-M certs → slash register-with-stake
    tree_root after register ee499d794328661f… c562c3f806c58ab7…
    escrow balance 0 — stake claimed by the slasher when the slash executed 1000 (staked, live)
    slash tx 22d391be447aa12c…; commitment 0ba57a0c92e84302… is in the on-chain revocation set not run (the live daemon's circuit is K=3, so Instance B exercises parameterization on chain but not posting/slash)

    Program ID (both): 4766fcc24cac757ab4c504b3844c354468f4d7fbb7b630957573513c6eb9a30d, guest ImageID 69373bb59ef0468f8f8748229d79f7cf54ca08b954bef983c641dcedd6d91d47. Instance A's full on-chain lifecycle (initialize → fund-escrow → register → moderate → slash → revoke) is demonstrated live on testnet through the Basecamp sidecar; the drained escrow is the on-chain proof that the slash claimed the stake. Tx hashes and state read-back are in docs/deployments.md.

FURPS Self-Assessment

Functionality

Full LP-0016 protocol — register-with-stake (on-chain, testnet), anonymous posting with Groth16 proofs (off-chain), N-of-M moderation with off-chain certificates over Waku, K-strike Shamir reconstruction, and on-chain slash that revokes membership and claims stake. The Basecamp module surfaces the entire flow as click-driven actions. Two parameterized instances confirmed on chain.

Usability

  • The Basecamp app is the only thing a forum user needs. No CLI, no tx crafting. Each lifecycle action is a single click; the GUI shows busy state, success toasts, and a persistent error line below the feed.
  • Per-post visual state surfaces protocol semantics: green ✓ valid pill for posts that verify, red ✗ author revoked pill for posts whose author has been slashed.
  • A Shamir-evidence panel fills in one share-pill per strike, so the cryptographic story (K shares accumulate → secret recoverable) is visible without reading code.
  • A chain-evidence panel exposes the on-chain registry account, tree root, and most recent register / slash tx hashes for independent verification.

Reliability

  • Proof generation failures bubble as typed ForumError from the SDK; the GUI shows the daemon's error message verbatim and re-enables the button so the user can retry without consuming the nullifier (the nullifier is deterministic from (secret, epoch), so retry is safe).
  • The SDK's aggregateCertificate enforces the N threshold client-side before any on-chain submission — a partial certificate cannot reach the chain.
  • A durable outbox retries failed Waku publishes with exponential backoff. A publish that fails is persisted to disk (not dropped); the flush-outbox sidecar command retries pending items and removes each on success. Retries are idempotent because the nullifier and content id are deterministic (a duplicate publish is a protocol no-op). See basecamp/sidecar/forum-sidecar.mjs (durablePublish / flush-outbox).

Performance

  • Groth16 membership proof generation ≈ 5 s on the dev box (target: < 10 s). See docs/cu-costs.md.
  • On-chain register CU cost and slash CU cost documented in docs/cu-costs.md.

Supportability

  • docs/protocol.md covers system model, primitives, unlinkability + anonymity set, retroactive deanonymization on slash, revocation mechanism, moderator trust model, threat model, known limitations.
  • docs/deployments.md documents the live stack (Hetzner build host with RISC0_DEV_MODE=0 sequencer + nwaku + proof-daemon), the deployed program ID, both instance PDAs, and tx hashes for each lifecycle step.
  • docs/cu-costs.md measures CU cost for every on-chain operation and the real RISC0 STARK timing (bench_post_proof, RISC0_DEV_MODE=0).
  • docs/adr/ ADRs capture key implementation decisions (Groth16 vs RISC0 for membership; share-binding scheme; SPEL port; ATA model).
  • CI (.github/workflows/ci.yml) runs cargo fmt/clippy/test on the LEZ-independent crates plus the SDK build and unit tests. The full Waku + sequencer end-to-end (sdk/tests/lifecycle.mjs) needs a live LEZ sequencer + nwaku and runs on the build host, not in GitHub-hosted CI.
  • basecamp/README.md documents how to build the .lgx and load it into Basecamp, plus how to run the standalone ForumApp preview.

Supporting Materials

  • Video demo (narrated, full lifecycle, both instances): https://www.youtube.com/watch?v=Q6fMpLB_850
  • Live deployment (Hetzner): see docs/deployments.md.
  • Architecture decisions: docs/adr/.
  • Test suite: sdk/tests/lifecycle.mjs (full protocol, build host) and the crate unit tests run in CI.

Terms & Conditions

By submitting this solution, I confirm that I have read and agree to the Terms & Conditions.