Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions captcha/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
87 changes: 87 additions & 0 deletions captcha/README.md
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`).
90 changes: 90 additions & 0 deletions captcha/demo/index.html
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>
13 changes: 13 additions & 0 deletions captcha/scripts/build-solver.sh
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)"
174 changes: 174 additions & 0 deletions captcha/widget/quan-captcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// 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 &middot; 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");
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));
}
Comment thread
illuzen marked this conversation as resolved.
}
};
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();
}
})();
Loading
Loading