❄️ How the faucet works
The captcha you never see: a post-quantum proof of work. ← back to the faucet
This faucet drips testnet FROST, the native token of the Frost L1 (an EIP-8141 post-quantum testnet, chain id 8141). Its rate limiter is not a captcha but a proof of work: your machine grinds ML-DSA-44 keypairs — generating fresh post-quantum keys over and over — until one of them hashes to a challenge-bound target.
Why key-grinding instead of a captcha
- Each attempt is a full ML-DSA-44 key generation (matrix expansion, NTT, rejection sampling — tens of microseconds even natively), not a nanosecond SHA-256 like classic hashcash. The work unit is ~1000× coarser, so GPU/ASIC leverage is far lower.
- Verification is trivial for the server: one hash prefix check plus one ML-DSA-44 signature verify — the exact operation the Frost chain itself prices via its PQ precompile.
- It demonstrates the product. The progress panel is literally post-quantum key generation running live in your browser, at the keys/sec your machine can manage.
The protocol: challenge → grind → claim
1. Challenge
GET /api/challenge?address=0x… returns a sealed token and
a difficulty:
{ "token": "…", "difficulty": 13,
"pool_bits": 12, "escalation_bits": 1, "prior_claims": 1,
"expires_at": 1785000000, "algorithm": "ML-DSA-44",
"rule": "SHA3-256(pk || token) must have 13 leading zero bits" }
The token is base64url(payload || HMAC-SHA256(secret,
payload)) where the payload encodes your address, the issue time,
the difficulty, and a random nonce. Everything the server needs later is
sealed inside the token itself, so issuing challenges is stateless — and
the difficulty is sealed too, so a client cannot shop for an easier one.
Because the grind target depends on the token, keys ground before the
challenge was issued are useless: no precomputation.
2. Grind
Generate ML-DSA-44 keypairs until the public key satisfies
SHA3-256(pk || token) has ≥ difficulty leading zero bits
(pk raw bytes, token as ASCII). Each attempt
is an independent coin flip with success probability
2-difficulty, so the expected work is
2difficulty keygens — but there is no fixed
finish line. The progress bar shows the honest probability that a key
has been found by now (1 − e−attempts/2^d);
unlucky grinds genuinely can take several times the expected time.
Once a key matches, the client signs the claim message with the ground secret key:
sig = ML-DSA-44.Sign(sk, "frost-faucet-v1" || token || address)
(address as lowercase 0x… ASCII). The signature proves
possession of the ground key and binds the work to your address, so an
on-path observer can neither steal the completed work nor redirect the
drip.
3. Claim
POST /api/claim
{ "address": "0x…", "token": "…", "pk": "<hex>", "sig": "<hex>" }
→ { "tx_hash": "0x…", "amount_wei": "…" }
The server checks, cheapest first: token MAC and expiry; address
matches the one sealed in the token; token not already spent (tokens are
single-use); the hash prefix meets the sealed difficulty; the signature
verifies; and finally that the current price hasn't risen above
the sealed one (if it has, the claim answers cooldown and
you must grind a fresh, harder challenge). Then the drip is enqueued and
you get the transaction hash.
How difficulty is priced
The difficulty you're quoted is the sum of a pool-wide price and a personal surcharge, capped at 24 bits:
- Base: 12 bits — calibrated so a browser (~800 keygen/s in pure JS) takes ~5 s on average.
- Bonding curve: the pool price rises as the 24 h
budget depletes, constant-product style —
+⌊log₂(budget_total / budget_remaining)⌋bits. The cost of a drip doubles every time the remaining pool halves, so the pool can never quite be drained; a sybil farmer draining it raises the price on themselves.pool_bitsin the challenge response is base + curve — it's also the number in the footer of the faucet page, the price everyone pays right now. - Per-actor escalation:
+1 bit(2× the work) for each successful claim in the last 24 h by your address or your network (/24 for IPv4, /56 for IPv6), whichever count is higher. Repeat claims never hard-fail — they just get exponentially more expensive. This isescalation_bits, and it's why the difficulty you're quoted can be higher than the footer's base.
When base + curve alone would exceed the 24-bit cap, or the pool
can't fund one more drip, challenges answer 503 budget with
a Retry-After instead of issuing a multi-hour grind.
Likewise, if the faucet's hot wallet itself can't cover a drip,
challenges answer 503 faucet_empty up front — no challenge
is issued, because grinding toward an unpayable claim would be wasted
work.
What the numbers mean in practice
| difficulty | expected keygens | browser ≈800/s | native ≈20k/s | token TTL |
|---|---|---|---|---|
| 12 (base) | 4,096 | ~5 s | <1 s | 5 min |
| 14 | 16,384 | ~20 s | ~1 s | 5 min |
| 16 | 65,536 | ~80 s | ~3 s | ~8 min |
| 18 | 262,144 | ~5 min | ~13 s | ~33 min |
| 21 | 2,097,152 | ~44 min | ~2 min | 2 h |
| 24 (cap) | 16,777,216 | impractical | ~14 min | 2 h |
Tokens are single-use and expire: the TTL scales with the sealed difficulty — 6× the expected grind time at a browser-class rate, floored at 5 minutes and capped at 2 hours — so an honest grinder almost never outlives its token, while a stale token can't be hoarded indefinitely. Past ~21 bits, grind natively (see below) rather than in a browser tab.
Other guardrails
- 24 h budget breaker: a hard cap on FROST dripped per rolling day — the actual drain backstop behind the curve.
- Small drips, small hot key: 1 FROST funds thousands of testnet transactions; the faucet's hot key holds only a small working balance.
- Per-IP rate buckets on both endpoints, and request-size caps on claims.
Scripting it
Everything above is two HTTP calls plus grinding — no browser needed,
and native keygen is ~20× faster than in-page JS. The
faucet page's “Script it instead” tab has the exact
recipe, /llms.txt summarizes it for machines,
and the faucet repository ships a multi-core reference grinder
(uv run grinder.py 0xYOURADDRESS --faucet …).
Live operational numbers — balance, budget remaining, current pool
difficulty — are at /api/status.