❄️ 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

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:

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

difficultyexpected keygensbrowser ≈800/snative ≈20k/stoken TTL
12 (base)4,096~5 s<1 s5 min
1416,384~20 s~1 s5 min
1665,536~80 s~3 s~8 min
18262,144~5 min~13 s~33 min
212,097,152~44 min~2 min2 h
24 (cap)16,777,216impractical~14 min2 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

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.