-
Notifications
You must be signed in to change notification settings - Fork 4
Add Quantus Captcha widget/demo and Debate Tree plan #558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
illuzen
wants to merge
3
commits into
main
Choose a base branch
from
illuzen/debate-tree
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| dist/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| <form action="/comment" method="post"> | ||
| <textarea name="text"></textarea> | ||
| <div class="quan-captcha" data-endpoint="https://pool.example.com"></div> | ||
| <button>Post</button> | ||
| </form> | ||
| <script src="https://pool.example.com/widget/quan-captcha.js" defer></script> | ||
| ``` | ||
|
|
||
| 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": "<your site secret>", "response": "<token>"}' | ||
| # -> {"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`). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>Quantus Captcha — demo</title> | ||
| <style> | ||
| body { | ||
| font: 16px/1.6 system-ui, -apple-system, sans-serif; color: #1f2937; | ||
| max-width: 640px; margin: 48px auto; padding: 0 20px; | ||
| } | ||
| h1 { font-size: 22px; } | ||
| .card { | ||
| border: 1px solid #e4e7ec; border-radius: 12px; padding: 24px; | ||
| margin-top: 24px; box-shadow: 0 1px 3px rgba(16, 24, 40, .06); | ||
| } | ||
| label { display: block; font-size: 14px; font-weight: 600; margin: 14px 0 4px; } | ||
| input[type=text], textarea { | ||
| width: 100%; box-sizing: border-box; border: 1px solid #d0d5dd; | ||
| border-radius: 8px; padding: 9px 12px; font: inherit; | ||
| } | ||
| button { | ||
| margin-top: 18px; background: #2563eb; color: #fff; border: 0; | ||
| border-radius: 8px; padding: 10px 18px; font: inherit; cursor: pointer; | ||
| } | ||
| button:disabled { background: #98a2b3; cursor: not-allowed; } | ||
| .note { font-size: 13px; color: #667085; margin-top: 24px; } | ||
| #result { margin-top: 14px; font-size: 14px; white-space: pre-wrap; font-family: ui-monospace, monospace; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <h1>Quantus Captcha demo</h1> | ||
| <p> | ||
| 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. | ||
| </p> | ||
|
|
||
| <div class="card"> | ||
| <form id="demo-form"> | ||
| <label for="msg">Post a comment (spam-protected)</label> | ||
| <textarea id="msg" rows="3" placeholder="Write something…"></textarea> | ||
|
|
||
| <div style="margin-top:16px"> | ||
| <div class="quan-captcha" data-endpoint=""></div> | ||
| </div> | ||
|
|
||
| <button type="submit" id="submit" disabled>Submit</button> | ||
| <div id="result"></div> | ||
| </form> | ||
| </div> | ||
|
|
||
| <p class="note"> | ||
| After solving, the widget puts a single-use token in the form. A real | ||
| backend would redeem it server-side via <code>POST /siteverify</code> | ||
| with its secret. This demo calls it from the page for illustration only — | ||
| never expose your secret in production. | ||
| </p> | ||
|
|
||
| <script src="/widget/quan-captcha.js" defer></script> | ||
| <script> | ||
| const form = document.getElementById("demo-form"); | ||
| const submit = document.getElementById("submit"); | ||
| const result = document.getElementById("result"); | ||
|
|
||
| form.addEventListener("quan-captcha-solved", (e) => { | ||
| submit.disabled = false; | ||
| if (e.detail.blockFound) { | ||
| result.textContent = "You just mined a real block while proving you're not a bot."; | ||
| } | ||
| }); | ||
|
|
||
| form.addEventListener("submit", async (e) => { | ||
| e.preventDefault(); | ||
| const token = form.querySelector('input[name="quan-captcha-token"]').value; | ||
| // Demo-only: siteverify belongs on the backend with a private secret. | ||
| const res = await fetch("/siteverify", { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ secret: "dev-secret", response: token }), | ||
| }); | ||
| const body = await res.json(); | ||
| result.textContent = "siteverify response:\n" + JSON.stringify(body, null, 2) + | ||
| (body.success ? "\n\nComment accepted." : "\n\nRejected (token reuse or expiry)."); | ||
| submit.disabled = true; | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| // Quantus Captcha widget. | ||
| // | ||
| // Usage: | ||
| // <div class="quan-captcha" data-endpoint="https://pool.example.com"></div> | ||
| // <script src="/widget/quan-captcha.js" defer></script> | ||
| // | ||
| // 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": "<token>"}. | ||
|
|
||
| (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 = | ||
| '<span class="quan-captcha-check" role="checkbox" aria-checked="false" tabindex="0"></span>' + | ||
| '<span class="quan-captcha-label">I\'m not a spammer' + | ||
| '<span class="quan-captcha-sub">Quantus proof-of-work · no tracking, no puzzles</span>' + | ||
| "</span>"; | ||
| 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"); | ||
| // 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; | ||
| } | ||
|
|
||
| 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"); | ||
|
|
||
| // 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) { | ||
| input = document.createElement("input"); | ||
| input.type = "hidden"; | ||
| input.name = "quan-captcha-token"; | ||
| 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 }, | ||
| })); | ||
| } 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(); | ||
| } | ||
| })(); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.