# 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

```bash
# 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):

```bash
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::CreditAccount` on 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` (see `qalnet/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 |
