From cd6fccde0ea6f6b23fca9839edd3e8fa2da8bef3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 6 Jul 2026 10:31:10 +0800 Subject: [PATCH 1/3] Add Quantus Captcha widget/demo and Debate Tree plan captcha/ is the embeddable proof-of-work captcha: drop-in widget, Web Worker WASM solver glue, demo form page, and integration docs; it consumes the pool-service API from quantus-miner. debate-tree/PLAN.md is the full plan for the AI-moderated debate platform the captcha gates. Co-authored-by: Cursor --- captcha/.gitignore | 1 + captcha/README.md | 87 ++++++++++++ captcha/demo/index.html | 90 ++++++++++++ captcha/scripts/build-solver.sh | 13 ++ captcha/widget/quan-captcha.js | 174 ++++++++++++++++++++++++ captcha/widget/solver-worker.js | 55 ++++++++ debate-tree/PLAN.md | 233 ++++++++++++++++++++++++++++++++ 7 files changed, 653 insertions(+) create mode 100644 captcha/.gitignore create mode 100644 captcha/README.md create mode 100644 captcha/demo/index.html create mode 100755 captcha/scripts/build-solver.sh create mode 100644 captcha/widget/quan-captcha.js create mode 100644 captcha/widget/solver-worker.js create mode 100644 debate-tree/PLAN.md diff --git a/captcha/.gitignore b/captcha/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/captcha/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/captcha/README.md b/captcha/README.md new file mode 100644 index 00000000..426310c6 --- /dev/null +++ b/captcha/README.md @@ -0,0 +1,87 @@ +# Quantus Captcha + +A proof-of-work captcha whose work is a **real Quantus mining share**. +No tracking, no image puzzles, no data labeling — the visitor's device does +~1 second of Poseidon2 hashing over the current block header, and the site +host earns any block the aggregate share stream happens to find. + +Components: + +- **Pool service** (`quantus-miner/crates/pool-service`) — connects to a + `quantus-node` as an external miner, hands out low-difficulty share + challenges with disjoint nonce ranges, verifies solves, mints single-use + tokens, submits network-difficulty shares as blocks. +- **WASM solver** (`quantus-miner/crates/solver-wasm`) — raw C-ABI WASM + module (no wasm-bindgen), built with plain `cargo build`. +- **Widget** (`widget/`) — drop-in JS: checkbox UI, Web Worker solver, + hidden token input, `quan-captcha-solved` event. +- **Demo** (`demo/`) — a spam-protected form. + +## Quick start (standalone demo, no node needed) + +```sh +# 1. Build the WASM solver into dist/ +./scripts/build-solver.sh + +# 2. Run the pool in standalone mode, serving this directory +cd ../../quantus-miner +cargo run -p pool-service -- --serve-dir ../quantus-apps/captcha + +# 3. Open the demo +open http://127.0.0.1:8787/demo/ +``` + +## Against a real node + +```sh +quantus-node --dev # external-miner QUIC endpoint on :9833 +cargo run -p pool-service -- \ + --node-addr 127.0.0.1:9833 \ + --share-difficulty 2000 \ + --site-secret "$(openssl rand -hex 16)" \ + --serve-dir ../quantus-apps/captcha +``` + +## Integrating on a website + +```html +
+ +
+ +
+ +``` + +Server-side, redeem the submitted `quan-captcha-token` exactly once +(mirrors the reCAPTCHA/Turnstile siteverify shape): + +```sh +curl -X POST https://pool.example.com/siteverify \ + -H 'content-type: application/json' \ + -d '{"secret": "", "response": ""}' +# -> {"success": true, "challenge_ts": 1751600000} +``` + +## API + +| Endpoint | Caller | Purpose | +|---|---|---| +| `POST /api/session` | widget | issue challenge: header, disjoint nonce range, share target | +| `POST /api/share` | widget | verify solved nonce, mint single-use token | +| `POST /siteverify` | site backend | redeem token (secret + response) | +| `GET /api/stats` | anyone | pool counters | + +## Threat model, honestly + +- This is a **rate limiter, not sybil resistance**: it prices requests in + compute, it does not identify humans. A GPU farm pays less per share than + a phone; keep the share difficulty in "annoying to bots, invisible to + humans" territory and layer account/balance gates for high-value actions. +- Sessions are single-use, expire in 120 s, and shares are only valid inside + the session's assigned nonce range over the session's header snapshot — + no precomputation, no replay, no share theft. +- Tokens are single-use and expire in 300 s. +- Work runs only on explicit user action (Coinhive's fatal mistake was + ambient page-load mining without consent — see the plan doc in + `../debate-tree/PLAN.md`). diff --git a/captcha/demo/index.html b/captcha/demo/index.html new file mode 100644 index 00000000..d1115f6c --- /dev/null +++ b/captcha/demo/index.html @@ -0,0 +1,90 @@ + + + + + + Quantus Captcha — demo + + + +

Quantus Captcha demo

+

+ The checkbox below does ~1 second of real Poseidon2 proof-of-work over the + current Quantus block header. The work is a genuine mining share: if it + ever meets full network difficulty, the pool submits it as a block. + No tracking, no image puzzles, no data labeling. +

+ +
+
+ + + +
+
+
+ + +
+
+
+ +

+ After solving, the widget puts a single-use token in the form. A real + backend would redeem it server-side via POST /siteverify + with its secret. This demo calls it from the page for illustration only — + never expose your secret in production. +

+ + + + + diff --git a/captcha/scripts/build-solver.sh b/captcha/scripts/build-solver.sh new file mode 100755 index 00000000..c8d96ede --- /dev/null +++ b/captcha/scripts/build-solver.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Builds the WASM solver from quantus-miner and copies it into dist/. +set -euo pipefail + +CAPTCHA_DIR="$(cd "$(dirname "$0")/.." && pwd)" +MINER_DIR="${QUANTUS_MINER_DIR:-$CAPTCHA_DIR/../../quantus-miner}" + +echo "Building solver-wasm from $MINER_DIR" +(cd "$MINER_DIR" && CARGO_TARGET_DIR=target cargo build -p solver-wasm --target wasm32-unknown-unknown --release) + +mkdir -p "$CAPTCHA_DIR/dist" +cp "$MINER_DIR/target/wasm32-unknown-unknown/release/solver_wasm.wasm" "$CAPTCHA_DIR/dist/" +echo "Wrote $CAPTCHA_DIR/dist/solver_wasm.wasm ($(wc -c < "$CAPTCHA_DIR/dist/solver_wasm.wasm") bytes)" diff --git a/captcha/widget/quan-captcha.js b/captcha/widget/quan-captcha.js new file mode 100644 index 00000000..1ad0d72b --- /dev/null +++ b/captcha/widget/quan-captcha.js @@ -0,0 +1,174 @@ +// Quantus Captcha widget. +// +// Usage: +//
+// +// +// On solve, a hidden input named "quan-captcha-token" is added to the widget's +// enclosing form (or the widget element itself), and a "quan-captcha-solved" +// CustomEvent (detail: { token }) is dispatched on the widget element. +// The site backend then redeems the token: POST {endpoint}/siteverify +// with JSON {"secret": "...", "response": ""}. + +(function () { + "use strict"; + + const WIDGET_CLASS = "quan-captcha"; + + const STYLE = ` + .quan-captcha-box { + display: inline-flex; align-items: center; gap: 10px; + border: 1px solid #d0d5dd; border-radius: 8px; padding: 10px 14px; + font: 14px/1.4 system-ui, -apple-system, sans-serif; color: #1f2937; + background: #fff; min-width: 260px; user-select: none; + } + .quan-captcha-check { + width: 22px; height: 22px; border: 2px solid #98a2b3; border-radius: 5px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; flex: none; background: #fff; transition: border-color .15s; + } + .quan-captcha-box[data-state="idle"] .quan-captcha-check:hover { border-color: #2563eb; } + .quan-captcha-box[data-state="solving"] .quan-captcha-check { + border-color: #2563eb; border-top-color: transparent; border-radius: 50%; + animation: quan-captcha-spin .8s linear infinite; + } + .quan-captcha-box[data-state="solved"] .quan-captcha-check { + border-color: #16a34a; background: #16a34a; color: #fff; cursor: default; + } + .quan-captcha-box[data-state="error"] .quan-captcha-check { border-color: #dc2626; } + .quan-captcha-label { flex: 1; } + .quan-captcha-sub { display: block; font-size: 11px; color: #667085; margin-top: 1px; } + @keyframes quan-captcha-spin { to { transform: rotate(360deg); } } + `; + + function injectStyle() { + if (document.getElementById("quan-captcha-style")) return; + const style = document.createElement("style"); + style.id = "quan-captcha-style"; + style.textContent = STYLE; + document.head.appendChild(style); + } + + function formatHashrate(h) { + if (h >= 1e6) return (h / 1e6).toFixed(1) + " MH/s"; + if (h >= 1e3) return (h / 1e3).toFixed(1) + " kH/s"; + return h.toFixed(0) + " H/s"; + } + + function initWidget(el) { + if (el.dataset.quanCaptchaInit) return; + el.dataset.quanCaptchaInit = "1"; + + const endpoint = (el.dataset.endpoint || "").replace(/\/$/, ""); + const workerUrl = el.dataset.workerUrl || endpoint + "/widget/solver-worker.js"; + const wasmUrl = el.dataset.wasmUrl || endpoint + "/dist/solver_wasm.wasm"; + + const box = document.createElement("div"); + box.className = "quan-captcha-box"; + box.dataset.state = "idle"; + box.innerHTML = + '' + + 'I\'m not a spammer' + + 'Quantus proof-of-work · no tracking, no puzzles' + + ""; + el.appendChild(box); + + const check = box.querySelector(".quan-captcha-check"); + const sub = box.querySelector(".quan-captcha-sub"); + let worker = null; + const startedAt = { t: 0 }; + + function setState(state, subText) { + box.dataset.state = state; + check.setAttribute("aria-checked", state === "solved" ? "true" : "false"); + if (state === "solved") check.textContent = "\u2713"; + if (subText) sub.textContent = subText; + } + + function fail(message) { + if (worker) { worker.terminate(); worker = null; } + setState("error", message + " — click to retry"); + } + + async function start() { + if (box.dataset.state === "solving" || box.dataset.state === "solved") return; + setState("solving", "requesting challenge…"); + try { + const res = await fetch(endpoint + "/api/session", { method: "POST" }); + if (!res.ok) throw new Error("challenge unavailable (" + res.status + ")"); + const session = await res.json(); + + setState("solving", "computing (~" + session.expected_hashes + " hashes)…"); + startedAt.t = performance.now(); + + worker = new Worker(workerUrl); + worker.onerror = () => fail("solver failed to load"); + worker.onmessage = async (e) => { + const msg = e.data; + if (msg.type === "progress") { + const secs = (performance.now() - startedAt.t) / 1000; + sub.textContent = "computing… " + formatHashrate(msg.hashes / Math.max(secs, 0.001)); + } else if (msg.type === "error") { + fail(msg.message); + } else if (msg.type === "found") { + worker.terminate(); + worker = null; + try { + const shareRes = await fetch(endpoint + "/api/share", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ session_id: session.session_id, nonce: msg.nonce }), + }); + const share = await shareRes.json(); + if (!share.success) throw new Error(share.error || "share rejected"); + + const secs = ((performance.now() - startedAt.t) / 1000).toFixed(1); + setState("solved", "verified in " + secs + "s (" + msg.hashes + " hashes)" + + (share.block_found ? " — BLOCK FOUND!" : "")); + + const form = el.closest("form") || el; + let input = form.querySelector('input[name="quan-captcha-token"]'); + if (!input) { + input = document.createElement("input"); + input.type = "hidden"; + input.name = "quan-captcha-token"; + form.appendChild(input); + } + input.value = share.token; + el.dispatchEvent(new CustomEvent("quan-captcha-solved", { + bubbles: true, + detail: { token: share.token, blockFound: !!share.block_found }, + })); + } catch (err) { + fail(String(err.message || err)); + } + } + }; + worker.postMessage({ + wasmUrl: wasmUrl, + headerHash: session.header_hash, + nonceStart: session.nonce_start, + shareTarget: session.share_target, + }); + } catch (err) { + fail(String(err.message || err)); + } + } + + check.addEventListener("click", start); + check.addEventListener("keydown", (e) => { + if (e.key === " " || e.key === "Enter") { e.preventDefault(); start(); } + }); + } + + function initAll() { + injectStyle(); + document.querySelectorAll("." + WIDGET_CLASS).forEach(initWidget); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initAll); + } else { + initAll(); + } +})(); diff --git a/captcha/widget/solver-worker.js b/captcha/widget/solver-worker.js new file mode 100644 index 00000000..ebe9c1a2 --- /dev/null +++ b/captcha/widget/solver-worker.js @@ -0,0 +1,55 @@ +// Web Worker: loads the WASM solver and grinds nonces in chunks, reporting +// progress so the widget can animate. Terminated by the main thread when done. + +const CHUNK_ITERS = 2048; + +// I/O buffer layout inside the WASM module (see solver-wasm/src/lib.rs) +const HEADER_OFF = 0; +const NONCE_OFF = 32; +const TARGET_OFF = 96; +const IO_SIZE = 224; + +function hexToBytes(hex, length) { + const clean = hex.replace(/^0x/, "").padStart(length * 2, "0"); + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes) { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +self.onmessage = async (e) => { + const { wasmUrl, headerHash, nonceStart, shareTarget } = e.data; + try { + const response = await fetch(wasmUrl); + if (!response.ok) throw new Error(`failed to fetch solver wasm: ${response.status}`); + const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {}); + const { io_ptr, io_len, solve, memory } = instance.exports; + + if (io_len() !== IO_SIZE) throw new Error("solver wasm I/O layout mismatch"); + const ioBase = io_ptr(); + const io = () => new Uint8Array(memory.buffer, ioBase, IO_SIZE); + + io().set(hexToBytes(headerHash, 32), HEADER_OFF); + io().set(hexToBytes(nonceStart, 64), NONCE_OFF); + io().set(hexToBytes(shareTarget, 64), TARGET_OFF); + + let hashes = 0; + for (;;) { + const found = solve(CHUNK_ITERS); + hashes += CHUNK_ITERS; + if (found === 1) { + const nonce = bytesToHex(io().slice(NONCE_OFF, NONCE_OFF + 64)); + self.postMessage({ type: "found", nonce, hashes }); + return; + } + self.postMessage({ type: "progress", hashes }); + } + } catch (err) { + self.postMessage({ type: "error", message: String(err && err.message ? err.message : err) }); + } +}; diff --git a/debate-tree/PLAN.md b/debate-tree/PLAN.md new file mode 100644 index 00000000..19352c44 --- /dev/null +++ b/debate-tree/PLAN.md @@ -0,0 +1,233 @@ +# Debate Tree — Project Plan + +AI-moderated structured debate for the Quantus ecosystem, spam-protected by +Quantus-native primitives (mining-share proof-of-work and QUAN balance gates). + +This plan covers three deliverables across two repos: + +| # | Component | Location | What it is | +|---|-----------|----------|------------| +| 1 | **Share Pool** (pool middleman) | `quantus-miner/pool-service` | A pool-like service that turns captcha solves into real mining shares and pays hosts | +| 2 | **Captcha Gadget** | `quantus-apps/captcha` | Embeddable widget + verify API — a drop-in Turnstile/reCAPTCHA replacement | +| 3 | **Debate Tree** | `quantus-apps/debate-tree` | The debate webapp: tree UI, AI steelman moderator, chain-gated writes | + +Dependency direction: `debate-tree` → `captcha` → `pool-service` → `quantus-node`. +Each layer is independently useful; the captcha is a product in its own right. + +--- + +## 1. Product vision + +**Debate Tree** is a structured-debate platform: a question at the root, +candidate answers below it, pros/cons under each answer, and responses to +those, recursively. (Prior art: Kialo — the tree format is proven. Our +differentiators are the AI moderator and the chain-native spam economics.) + +**The AI is a moderator on the write path, not a participant:** + +- **Deduplication** — before a new node is accepted, embeddings + a cheap LLM + pass check whether the point already exists in the tree; near-duplicates are + redirected to the existing node ("upvote / extend this instead?"). +- **Steelmanning loop** — a contributor drafts a point; the AI reflects back + the strongest version of it; the contributor revises or accepts; capped at + ~3 rounds, with a "publish as-is (flagged unrefined)" escape hatch. Only + the version the *contributor* approves is published. The original text + stays attached (visible on click) — nobody's voice is erased. +- **The AI never rewrites imported/seeded content.** Seeded arguments cite + their sources verbatim. + +**Spam / cost protection** (also bounds our LLM spend): + +- **Reading**: free, no account, indexable. The tree is the growth asset. +- **Creating a question**: requires a signed challenge from a wallet holding + ≥ N QUAN (threshold configurable per space). Capital-at-stake, nothing + locked or slashed. +- **Posting answers/pros/cons + starting a steelman session**: requires a + proof-of-work share via the captcha gadget. Rate limiter, not identity. +- App-layer rate limits and per-user/global LLM spend caps on top. + +**Governance tie-in**: Quantus currently has no community governance lane +(the runtime's referenda are tech-collective-only) and QIPs have no +discussion venue. Debate Tree is the deliberation layer; on-chain referenda +remain the decision layer. Tree conclusions link to referenda; content +itself stays off-chain (optionally hash-anchored per published node). + +--- + +## 2. Component 1 — Share Pool (`quantus-miner/pool-service`) + +A new crate alongside `miner-service`, reusing `pow-core` hashing and the +`quantus-miner-api` types. + +**Concept**: the node's external-miner protocol already broadcasts +`NewJob { header_hash, difficulty }` and accepts `JobResult`. The pool +service sits between a node and thousands of weak browser solvers: + +``` +quantus-node ──NewJob──► pool-service ──job + nonce-range + share-target──► browser solvers +quantus-node ◄──block── pool-service ◄──────────share (nonce)──────────── browser solvers +``` + +- Holds the current job from an upstream node (QUIC, existing protocol). +- Issues **captcha sessions**: `{ header_hash, disjoint nonce range, share + target (≪ network difficulty), expiry }`. The nonce range is the session + binding — a returned nonce identifies which session earned it. Freshness + is free: shares are only valid against the current block template. +- Verifies submitted shares (one hash) and issues a single-use + **share token** consumed by the captcha verify API. +- If a share also meets full network difficulty → submit as a real block; + reward accrues to the pool operator's account. +- Tracks per-host share counts for **pro-rata (PPLNS-style) payouts** to + registered captcha hosts. Self-hosters can point their share stream at a + community pool or run solo. + +**Economics honesty** (goes in the README, not just here): expected revenue +per captcha is (client work ÷ network hashrate) × emission rate — dust once +the chain has real hashrate. Early-chain revenue is real; long-term the +honest pitch is *non-wasteful* PoW (work secures the network instead of +being burned, unlike Friendly Captcha / Anubis) plus dust. **Pay the host, +never the solver** — paying solvers pays people to spam. + +**Deliverables**: +- [x] `pool-service` crate: upstream QUIC client, session issuance API, + share verification, share-token store, block submission +- [x] `siteverify` endpoint (see Component 2 — same service, site-facing) +- [ ] Host registration + payout ledger (payouts can be manual at first) +- [ ] Metrics (reuse `metrics` crate patterns), Docker image +- [x] Integration test against `quantus-node --dev` (manual e2e: browser + solved real shares against a dev node's block headers, 2026-07-04) + +## 3. Component 2 — Captcha Gadget (`quantus-apps/captcha`) + +A drop-in, privacy-first captcha. Positioning: Turnstile's UX without +Cloudflare, Friendly Captcha / Anubis mechanics but the work is real mining. +No puzzles, no tracking, no data labeling. Coinhive's captcha proved the UX; +Coinhive's death defines our guardrails: + +- Work only on explicit user action (form submit), never ambient page-load. +- Bounded and disclosed: "~1s of computation supports this site." +- Open source, first-party-servable loader (no single CDN domain to blocklist). + +**Pieces**: +- `solver/` — Rust → WASM build of `pow-core` hashing (lives here or under + `quantus-miner/web-miner`, which already has a Vite+WebGPU scaffold; + decide when wiring the build). WebGPU fast path, WASM fallback. +- `widget/` — TS embed: `
` + + ~3 kB loader. Renders checkbox → fetches session from pool-service → + solves → posts share → emits share token into the form. +- Server-side verify: site backend calls `POST /siteverify {token, secret}` + on pool-service (mirrors reCAPTCHA/Turnstile API shape for trivial migration). +- `demo/` — demo page + abuse-cost calculator. + +**Deliverables**: +- [x] WASM solver package (`quantus-miner/crates/solver-wasm`, raw C ABI, + ~40 kB; measured ≈120 kH/s in-browser on an M-series laptop) + — WebGPU fast path still open +- [x] Embed widget + loader, Turnstile-compatible verify API +- [x] Docs: integration guide, threat model (rate-limiter not sybil-proof; + native-GPU attacker pays less per share than a phone — tune share + target accordingly), Coinhive-lessons disclosure +- [x] Demo site (`demo/`, served by pool-service `--serve-dir`) + +## 4. Component 3 — Debate Tree webapp (`quantus-apps/debate-tree`) + +**Stack** (proposed): TypeScript web frontend + backend (framework TBD at +kickoff), Postgres + pgvector (tree + embeddings), LLM API for +steelman/dedup (cheap model for loop turns, stronger model for final +published version). Wallet auth via ML-DSA signature verification — +`quantus_sdk`'s Rust bridge and `rust-transaction-parser` are references; +server-side verification can link the same Rust crates. + +**Data model (sketch)**: +- `space` — a debate context (e.g. "QIPs", "PQ-migration"), holds config: + question threshold N QUAN, share target, model tier. +- `node` — id, space, parent, kind (`question | answer | pro | con`), + published text, original text, author account, source citations (for + seeded nodes), content hash (for optional on-chain anchoring), status. +- `steelman_session` — node draft, transcript, round count, state. +- `share_token` / `balance_attestation` — consumed gate proofs. + +**Write path**: +1. Client requests action → backend issues nonce challenge. +2. Question: wallet signs `{nonce, action, timestamp}`; backend verifies + signature + balance ≥ N via node RPC / `quantus_subsquid`. + Answer/pro/con: captcha share token required to open a steelman session. +3. Dedup check (embedding similarity → LLM confirm on borderline). +4. Steelman loop (≤ 3 rounds) → contributor approves → publish. + +**Seed content — the djb hybrid-vs-pure-PQ debate**: +- Question: *"Should TLS 1.3 standardize pure ML-KEM key agreement, or + require hybrid (ECC+PQ)?"* +- Curated from the public record with per-node citations: IETF TLS WG + mailing list, djb's IESG appeals (Oct/Dec 2025), blog.cr.yp.to, LWN + coverage. **No AI paraphrasing of imported arguments** — verbatim quotes + + neutral summaries with links. +- Map the *technical* debate only; keep the process/consensus-legitimacy + fight (appeals drama) out of the seed tree. +- **Neutrality disclosure, prominent**: Quantus is a pure-PQ chain and + therefore a party to this debate. "We have a stake; here's the map; + correct us." Invite corrections before promoting it anywhere. +- Second space: QIP discussions (own community, real decisions, zero + current venue). + +**Deliverables**: +- [ ] Steelman-loop spike (see §5 — build first, throwaway UI) +- [ ] Tree UI (read): collapsible tree, node detail w/ original text + + citations, shareable node links +- [ ] Wallet auth + balance gate; captcha gate integration +- [ ] Dedup + steelman write path with round caps and spend caps +- [ ] Seeded djb tree + QIP space +- [ ] Optional: per-node hash anchoring via `system.remark` (defer) + +--- + +## 5. Build order + +**Track A (start now): pool-service + captcha as ONE vertical slice.** +Neither is testable end-to-end without the other — a pool with no solver +client proves nothing, a widget with no verifier is a mock. Milestone: +demo page on a laptop solves a share against `quantus-node --dev`, verify +endpoint accepts the token, dashboard shows accrued shares. + +**Track B (parallel, cheap): steelman-loop spike.** +The single highest product risk is whether the steelman negotiation feels +respectful rather than condescending — no chain deps, just an LLM chat +loop + prompt iteration on real contentious arguments. A weekend spike; +throwaway code, keep the prompts. + +**Then: Debate Tree webapp** consuming both tracks, launching with the +seeded djb tree + QIP space. Rationale for not building the webapp first: +its write path *is* the gates + the steelman loop; building it first means +building it twice. The captcha is also independently shippable/marketable +regardless of how Debate Tree evolves. + +**Sequencing summary**: +1. Track A slice (pool + widget + demo) — Track B spike in parallel +2. Debate Tree read UI + seeded djb/QIP content (valuable even before + writes open — "the map" is the marketing artifact) +3. Debate Tree write path (gates + moderator) +4. Payouts polish, on-chain anchoring, additional spaces + +## 6. Risks + +| Risk | Mitigation | +|------|------------| +| Cryptojacking stigma / AV & adblock flagging | Consent + bounded work + first-party loader + open source; never ambient mining | +| Share revenue ≈ dust as hashrate grows | Market as non-wasteful + host-paid, not get-rich; pool aggregation for variance | +| Native-GPU spammers vs. phone users (PoW asymmetry) | Share target tuned low (rate limiter framing); balance gate for high-value actions | +| AI steelman feels condescending / voice laundering | Spike first; contributor approval required; original text always attached | +| Moderator bias becomes tree bias | Publish steelman prompts; show diff original→published | +| Quantus not neutral on the seed debate | Prominent disclosure; verbatim citations; invite corrections | +| djb reacts badly to AI paraphrase | Never AI-rewrite imported content | +| LLM spend abuse | Share token required per steelman session; round caps; per-user/global spend caps | +| Balance gate = plutocratic speech | Gate only question creation; answers need only PoW; bonds-not-balances revisit later | + +## 7. Open questions + +- Pool payout cadence/mechanism (manual → automated on-chain batch?). +- Where the WASM solver crate lives (`quantus-apps/captcha/solver` vs + `quantus-miner/web-miner`) — decide when wiring the build. +- Webapp framework + hosting; whether backend verifies ML-DSA sigs via + linked Rust crate or a small verifier sidecar. +- Per-space QUAN thresholds — governance-adjustable? fiat-pegged? +- Whether/when to anchor node hashes on-chain. From 17ba15e7d84af6b85f531f25019115bd9de86872 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 6 Jul 2026 16:56:22 +0800 Subject: [PATCH 2/3] Clear stale checkmark and set solved state only after fallible tail work setState now owns the checkmark in both directions, and the solved state is applied after the hidden-token injection so a throw in the tail lands in fail() without leaving solved visuals behind. Co-authored-by: Cursor --- captcha/widget/quan-captcha.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/captcha/widget/quan-captcha.js b/captcha/widget/quan-captcha.js index 1ad0d72b..3273a93a 100644 --- a/captcha/widget/quan-captcha.js +++ b/captcha/widget/quan-captcha.js @@ -81,7 +81,9 @@ function setState(state, subText) { box.dataset.state = state; check.setAttribute("aria-checked", state === "solved" ? "true" : "false"); - if (state === "solved") check.textContent = "\u2713"; + // Own the mark in both directions so error/retry states never keep a + // stale checkmark from a previous solved transition. + check.textContent = state === "solved" ? "\u2713" : ""; if (subText) sub.textContent = subText; } @@ -122,10 +124,8 @@ const share = await shareRes.json(); if (!share.success) throw new Error(share.error || "share rejected"); - const secs = ((performance.now() - startedAt.t) / 1000).toFixed(1); - setState("solved", "verified in " + secs + "s (" + msg.hashes + " hashes)" + - (share.block_found ? " — BLOCK FOUND!" : "")); - + // Do all fallible tail work BEFORE showing the solved state, so + // a throw here lands in fail() without leaving solved visuals. const form = el.closest("form") || el; let input = form.querySelector('input[name="quan-captcha-token"]'); if (!input) { @@ -135,6 +135,11 @@ form.appendChild(input); } input.value = share.token; + + const secs = ((performance.now() - startedAt.t) / 1000).toFixed(1); + setState("solved", "verified in " + secs + "s (" + msg.hashes + " hashes)" + + (share.block_found ? " — BLOCK FOUND!" : "")); + el.dispatchEvent(new CustomEvent("quan-captcha-solved", { bubbles: true, detail: { token: share.token, blockFound: !!share.block_found }, From 7389dfb1ed07458cdb983fcc3ee23478b88e23b9 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 6 Jul 2026 17:56:33 +0800 Subject: [PATCH 3/3] add steelman logic --- debate-tree/spike/index.html | 221 ++++++++++++++++++++++++++++++++++ debate-tree/spike/prompts.mjs | 67 +++++++++++ debate-tree/spike/server.mjs | 147 ++++++++++++++++++++++ 3 files changed, 435 insertions(+) create mode 100644 debate-tree/spike/index.html create mode 100644 debate-tree/spike/prompts.mjs create mode 100644 debate-tree/spike/server.mjs diff --git a/debate-tree/spike/index.html b/debate-tree/spike/index.html new file mode 100644 index 00000000..5e9d5145 --- /dev/null +++ b/debate-tree/spike/index.html @@ -0,0 +1,221 @@ + + + + + + Steelman spike + + + +

Steelman negotiation spike — the AI moderator write-path, isolated

+ +
+
+ +
+ + + + + + + + + + + + + + +
+
+ +
+ + + + diff --git a/debate-tree/spike/prompts.mjs b/debate-tree/spike/prompts.mjs new file mode 100644 index 00000000..598c75f5 --- /dev/null +++ b/debate-tree/spike/prompts.mjs @@ -0,0 +1,67 @@ +// The keeper artifact of this spike: the steelman prompts. +// The code around them is throwaway; iterate on these. + +export const SYSTEM_PROMPT = `You are the moderator of a structured debate platform. A participant has +written an argument they want to add to a debate tree. Your job is to +STEELMAN it: produce the strongest, clearest version of THEIR point, +which they must recognize as their own. + +Hard rules: +1. Never change the author's position, weaken it into agreeableness, or + add hedges they didn't imply. If they are against something, the + steelman is firmly against it. +2. Strengthen: sharpen the core claim, make implicit reasoning explicit, + replace insults and sneers with force of argument, cut filler. +3. Keep the author's voice: first person if they wrote in first person, + similar length (never more than ~1.5x), plain language. No debate-club + jargon, no "one might argue". +4. Do not invent facts, sources, or examples the author didn't reference + or clearly imply. If their claim depends on a fact you're unsure of, + keep their phrasing rather than "correcting" it. +5. If the argument bundles several points, keep the strongest one central + rather than flattening it into a list. +6. If (and only if) the author's position is genuinely ambiguous, ask ONE + short clarifying question. + +The author will review your draft and may push back. When they do, their +feedback is authoritative about what they meant — revise to match their +intent, not to defend your previous draft. + +Respond with ONLY a JSON object, no markdown fences: +{ + "steelman": "", + "notes": "<1-3 short bullets, each starting with '- ', saying what you changed and why>", + "question": "" +}`; + +export function buildFirstRoundPrompt({ question, parent, stance, original }) { + return `Debate question: ${question} +${parent ? `The participant is responding to this claim: ${parent}\n` : ""}Their stance on it: ${stance} + +Their argument, verbatim: +--- +${original} +--- + +Steelman it.`; +} + +export function buildRevisionPrompt({ original, draft, feedback, round }) { + return `This is revision round ${round}. Reminder of the author's original, verbatim: +--- +${original} +--- + +Your previous draft: +--- +${draft} +--- + +The author's feedback on your draft: +--- +${feedback} +--- + +Revise the steelman to match the author's intent. Their feedback wins over +your judgment about what makes the argument "better".`; +} diff --git a/debate-tree/spike/server.mjs b/debate-tree/spike/server.mjs new file mode 100644 index 00000000..b46c76f5 --- /dev/null +++ b/debate-tree/spike/server.mjs @@ -0,0 +1,147 @@ +// Steelman-loop spike server. Throwaway code; the prompts are the artifact. +// +// node server.mjs # stub model (offline, tests the flow) +// ANTHROPIC_API_KEY=... node server.mjs +// OPENAI_API_KEY=... node server.mjs +// +// Zero dependencies; serves index.html and POST /api/steelman. + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SYSTEM_PROMPT, buildFirstRoundPrompt, buildRevisionPrompt } from "./prompts.mjs"; + +const PORT = process.env.PORT || 8788; +const DIR = dirname(fileURLToPath(import.meta.url)); + +const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY; +const OPENAI_KEY = process.env.OPENAI_API_KEY; +const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-5"; +const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-5.2"; + +const provider = ANTHROPIC_KEY ? "anthropic" : OPENAI_KEY ? "openai" : "stub"; +console.log(`model provider: ${provider}`); + +async function callAnthropic(messages) { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": ANTHROPIC_KEY, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: ANTHROPIC_MODEL, + max_tokens: 1024, + system: SYSTEM_PROMPT, + messages, + }), + }); + if (!res.ok) throw new Error(`anthropic ${res.status}: ${await res.text()}`); + const body = await res.json(); + return body.content.map((c) => c.text || "").join(""); +} + +async function callOpenAI(messages) { + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${OPENAI_KEY}`, + }, + body: JSON.stringify({ + model: OPENAI_MODEL, + messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages], + }), + }); + if (!res.ok) throw new Error(`openai ${res.status}: ${await res.text()}`); + const body = await res.json(); + return body.choices[0].message.content; +} + +// Offline stand-in so the interaction flow is testable without a key: it +// performs a crude mechanical "steelman" and echoes feedback acknowledgment. +function callStub(messages) { + const last = messages[messages.length - 1].content; + const original = (last.match(/---\n([\s\S]*?)\n---/) || [, last])[1].trim(); + const isRevision = /revision round/.test(last); + const cleaned = original + .replace(/\b(stupid|idiotic|insane|moronic|garbage|bullshit)\b/gi, "deeply flawed") + .replace(/!+/g, ".") + .trim(); + const steelman = isRevision + ? cleaned + " (revised per your feedback)" + : "The core of my position: " + cleaned; + return Promise.resolve( + JSON.stringify({ + steelman, + notes: "- [stub model] softened insults, kept your position\n- set an API key for real steelmanning", + question: null, + }) + ); +} + +const callModel = provider === "anthropic" ? callAnthropic : provider === "openai" ? callOpenAI : callStub; + +function parseModelJson(text) { + // Models occasionally wrap JSON in fences despite instructions. + const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim(); + const parsed = JSON.parse(cleaned); + if (typeof parsed.steelman !== "string") throw new Error("model reply missing steelman"); + return { + steelman: parsed.steelman, + notes: typeof parsed.notes === "string" ? parsed.notes : "", + question: typeof parsed.question === "string" ? parsed.question : null, + }; +} + +async function handleSteelman(req, res) { + let raw = ""; + for await (const chunk of req) raw += chunk; + const body = JSON.parse(raw); + + // Rebuild the conversation from the client-held transcript. rounds is + // [{draft, feedback}, ...] for completed rounds. + const messages = [ + { role: "user", content: buildFirstRoundPrompt(body) }, + ]; + (body.rounds || []).forEach((r, i) => { + messages.push({ role: "assistant", content: JSON.stringify({ steelman: r.draft }) }); + messages.push({ + role: "user", + content: buildRevisionPrompt({ + original: body.original, + draft: r.draft, + feedback: r.feedback, + round: i + 1, + }), + }); + }); + + const reply = await callModel(messages); + const parsed = parseModelJson(reply); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(parsed)); +} + +const server = createServer(async (req, res) => { + try { + if (req.method === "POST" && req.url === "/api/steelman") { + return await handleSteelman(req, res); + } + if (req.method === "GET" && (req.url === "/" || req.url === "/index.html")) { + const html = await readFile(join(DIR, "index.html")); + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + return res.end(html); + } + res.writeHead(404); + res.end("not found"); + } catch (err) { + console.error(err); + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: String(err.message || err) })); + } +}); + +server.listen(PORT, () => console.log(`steelman spike: http://127.0.0.1:${PORT}/`));