Platform points → wallet claims: single-use, handle-bound, anchored · qalclaim/README.md
qalclaim — the points→credit CLAIM system
Created 2026-09-09 · service :8836 · zero-dep (Python stdlib only) ·
qalclaim.py serve to run, test_qalclaim.sh for the full proof suite.
qalclaim makes the QALS promise real: "earn QALS as points across our platforms, then log in and claim them." Platforms award points to a handle; the user opens their wallet, signs in with their Qal ID, and pulls the points into their loopd account as spendable AU¢ credits — with every claim anchored on-chain.
The flow
┌─────────────┐ POST /issuers ┌──────────────────────────────┐
│ qalclaim │←── (admin key)───────────│ platform "acme-app" │
│ │ gets issuer_id + │ (any app awarding points) │
│ │ HMAC issuer_key └──────────────┬───────────────┘
│ │←── POST /issuers/{id}/fund (HMAC) │
│ │ → loopd escrow agent funded │
│ │←── POST /issues (HMAC-signed) │
│ │ {subject_handle: alice, points: 500} │
│ │←── POST /issue/{id}/link │
│ │ → qalclaim://claim?t=<payload>.<sig> │
│ │ (this is what the app shows the │
│ │ user — QR / in-app / email) │
│ │ ▼
│ WALLET (user) ────────────────────────────────────────────────
│ 1. GET /challenge ← single-use nonce
│ 2. POST /whoami {handle, agent_id, ← "sign in with Qal ID":
│ challenge, sig=HMAC(loopd secret, discovers pending claims)
│ canonical challenge body)}
│ 3. POST /claim {claim_token, ← the money move:
│ claiming_handle, agent_id, escrow → wallet (loopd
│ challenge, sig)} transfer, exact cents)
│ + claim anchored on-chain
└─────────────┐ (qalpipe DataAnchor)
▼
loopd :8823 (ledger) qalnet :9000 (chain anchors)
Claim response carries the receipt: {receipt_id, cents, transfer, anchor}
— anchor is the sha256 of issue_id|claimant|ts|receipt_id, verifiable
forever via python3 qalpipe/qalpipe.py verify <file>.
Security model (what each attack hits)
| Attack | Defense |
|---|---|
| Fake/stolen points issued by impostor | Issuer HMAC keys (issued once at registration, admin-gated) + 5-min anti-replay ts window |
| Points inflated in transit | Claim tokens are server-HMAC-signed b64url payloads — any tamper → 401 FORGED |
| Claim token shared/leaked | Handle binding: token's sub must equal claiming_handle → bob's proof on alice's token → 403 |
| Token reused (replay) | Single-use registry at issue level + dead-jti set; replay → 409 carrying the ORIGINAL receipt id |
| Stale tokens | 7-day default expiry (exp in payload) → 410 |
| Impersonating a handle | Proof-of-handle: claim/whoami require a fresh single-use challenge signed (HMAC-SHA256, canonical JSON) with the handle's loopd secret. qalclaim verifies by replaying a state-preserving authenticated call to loopd (POST /revoke {revoked:false} — no-op for a live agent), so the wallet never sends its loopd secret to qalclaim. Unclaimed/revoked loopd handles are refused outright. |
| Brute-forcing claims | 5 failed attempts per handle per hour → 15-minute lockout (persisted; blocks whoami too; per-handle — other users unaffected). Successful claims clear the count. |
| Paying from thin air | Claims pay ONLY from the issuer's funded loopd escrow agent — underfunded → 402, nothing moves |
| Concurrent double-claim race | Issue is reserved inside the single-use critical section (status: claiming) before the transfer; our own failures un-reserve, replays can't |
| Silent history rewrite | Every claim anchored on-chain (sha256 of issue|claimant|ts|receipt_id); qalpipe verify = tamper detection. Chain-down degrades gracefully (anchored: false) — /admin/retry-anchors re-anchors later. |
Integration — 3 lines of curl for any app
# 1. award 500 points to alice (one HMAC-signed call; sign = HMAC(issuer_key, canon(body)))
curl -X POST http://127.0.0.1:8836/issues -d '{"issuer":"'$ISS_ID'","subject_handle":"alice","points":500,"campaign":"signup","note":"welcome bonus","ts":'$TS',"sig":"'$SIG'"}'
# 2. mint the claim link (show this to the user as QR / in-app / email)
curl -X POST http://127.0.0.1:8836/issue/$ISSUE_ID/link -d '{}'
# → {"claim_token": "qalclaim://claim?t=…"}
# 3. fund escrow when it runs low (HMAC-signed, same canon rule)
curl -X POST http://127.0.0.1:8836/issuers/$ISS_ID/fund -d '{"issuer":"'$ISS_ID'","cents":100000,"ts":'$TS',"sig":"'$SIG'"}'
Canonical serialization everywhere: json.dumps(obj, sort_keys=True,
separators=(",", ":")). One point = 1 AU¢ = 0.01 Qals (configurable via
data/config.json: cents_per_point).
Wallet-side one-liners (the CLI does challenge→sign→post for you):
python3 qalclaim/qalclaim.py whoami <handle> <agent_id> <loopd_secret>
python3 qalclaim/qalclaim.py claim <handle> <agent_id> <loopd_secret> "qalclaim://claim?t=…"
python3 qalclaim/qalclaim.py report # admin totals: issued/claimed/pending/by_campaign
Units
Points convert at cents_per_point (default 1). loopd works in AU¢
(1 B-QALS = 100¢ = AU$1 when backed — see content/token_supply.md).
500 points → 500¢ → 5.00 Qals of service credit.
Honest notes
- Issuer keys are the trust root. Whoever holds an issuer HMAC key can
mint point-balances for that issuer (bounded by escrow funding — points
without escrow can never pay out). The admin key bootstraps issuer
registration and is stored at
data/admin.json(0600). - Escrow secrets live in
data/issuers.json(qalclaim-created loopd agents). They are the only accounts claims ever pay from — treat the file as hot wallet keys. - Proof-of-handle v1 reuses loopd's HMAC-secret ownership (the qalchat ensure/claim/bind pattern). It proves "this device owns this payment handle," not "this human is who they say" — the loopd secret lives in a client file. Human-grade login is the passkey/WebAuthn upgrade path: swap the challenge-signature for a WebAuthn assertion bound to the handle; nothing else in the flow changes.
- On-chain upgrade path: issuance/escrow can move from the loopd paper
ledger to
qal_credit::CreditAccounton qalnet (same semantics, hard per-epoch caps), and claim tokens can be minted as soul-claim objects — qalclaim's API is deliberately ledger-agnostic over loopd HTTP. - Rate limits are per-handle, persisted across restarts, and lock logins as well as claims (fail closed). Lockouts clear after 15 minutes.
- Anchoring needs the qalnet devnet (
:9000) + gas. Runbook:bash scripts/gen_devnet.sh(seeqalnet/qal/README.md). loopd runbook:python3 loopd/loopd.py(paper ledger on:8823).
Files
| Path | Purpose |
|---|---|
qalclaim.py |
service + CLI (serve, register-issuer, fund, issue, link, whoami, claim, challenge, report, retry-anchors) |
test_qalclaim.sh |
26-assertion end-to-end proof suite (uses :8837 + data-test/ to leave prod state alone) |
data/ (default) |
admin/config/server key, issuers, issues.jsonl (append-only), claims.jsonl, claim receipt files, rate-limit + nonce state |