Summary
Add faucet endpoints to the middleware so a public faucet web app can dispense the devnet test tokens (DEMO, PROMPT, USDCX) to any registered address — no wallet connection, rate-limited server-side.
UI issue (approved design + frontend scope): ChainSafe/canton-snap#96
The middleware is the natural home: it already has ledger access, the EVM→party mapping, and the drip logic in scripts/utils/fund-wallet.go — this feature essentially productizes fund-wallet.go behind rate-limited endpoints, executed from a dedicated funded faucet party with server-side signing (like the custodial transfer path).
All endpoints follow existing conventions: snake_case JSON, { "error": "..." } error bodies, { "items": [...] } list envelopes.
Endpoints
1. GET /api/v2/faucet/tokens
Faucet config for the UI token selector (amounts/cooldowns not hardcoded client-side).
{ "items": [
{ "symbol": "DEMO", "name": "Demo Token", "drip_amount": "100",
"cooldown_seconds": 3600, "kind": "direct", "enabled": true },
{ "symbol": "PROMPT", "name": "Prompt Token", "drip_amount": "50",
"cooldown_seconds": 3600, "kind": "direct", "enabled": true },
{ "symbol": "USDCX", "name": "USDC on Canton", "drip_amount": "250",
"cooldown_seconds": 3600, "kind": "offer", "enabled": true }
] }
kind reflects the existing offer-based distinction (OFFER_BASED_TOKENS) — the UI uses it to explain that USDCX arrives as a transfer offer to accept in the dapp. Drip amounts / cooldowns above are placeholders, final values TBD.
2. POST /api/v2/faucet/drip
The main action. Public / unauthenticated (no wallet connection by design), so all abuse control is server-side.
// request
{ "address": "0x4f8AC1b02fA9DcB4f21e83Ec5700A3B2E1D09a21", "token": "DEMO", "captcha_token": "..." }
// 200 (direct token)
{ "tx_id": "1220ab...", "kind": "direct", "amount": "100",
"token": "DEMO", "next_available_at": "2026-07-10T12:00:00Z" }
// 200 (offer-based token)
{ "contract_id": "00ab...", "kind": "offer", "amount": "250", "token": "USDCX",
"expires_at": "2026-07-11T12:00:00Z", "next_available_at": "2026-07-10T12:00:00Z" }
Server-side flow:
- Validate address + token; verify captcha if enabled.
- Resolve the EVM address to its Canton party (same lookup as
GET /profile) — recipients must already be registered.
- Check cooldown (per address + token) and IP-level limits.
- Execute the transfer from the faucet party (server-side signing). Direct for DEMO/PROMPT; for USDCX create a transfer offer with a
validity_seconds (e.g. the existing 86400 default).
Error responses (distinct so the UI can render specific states):
| Status |
Meaning |
Body |
400 |
Malformed address / unknown or disabled token / bad captcha |
{ "error": "..." } |
404 |
Address not registered as a Canton party |
{ "error": "address not registered" } |
429 |
Cooldown active |
{ "error": "cooldown active", "retry_after_seconds": 1740 } |
503 |
Faucet reserves depleted for that token |
{ "error": "faucet empty" } |
3. GET /api/v2/faucet/status?address=0x...
Per-token readiness so the UI can show "ready now" vs. a countdown (and disable the button) before submitting.
{ "items": [
{ "token": "DEMO", "available": true },
{ "token": "PROMPT", "available": true },
{ "token": "USDCX", "available": false, "retry_after_seconds": 1740 }
] }
4. GET /api/v2/faucet/drips/recent?limit=10
Powers the public "Recent drips" feed. Unauthenticated — truncate addresses server-side (0x91bD…44eC), same as the public transfer-list endpoints truncate party IDs.
{ "items": [
{ "address": "0x91bD…44eC", "token": "USDCX", "amount": "250",
"kind": "offer", "created_at": "2026-07-10T11:58:03Z" }
] }
5. Health
Reuse the existing GET /health (nothing new needed if faucet routes live in the middleware).
New infrastructure required
- Faucet party: a dedicated, funded Canton party whose key the middleware holds; reserves monitored/topped-up out-of-band.
- Cooldown store: per
(address, token) last-drip timestamps (PostgreSQL is already in the stack).
- Rate limiting: net-new — nothing exists today (current abuse control is the manual registration whitelist). Needs per-address+token cooldown plus an IP-level limit; optional captcha (e.g. Cloudflare Turnstile) behind a config flag, hence
captcha_token.
- Config: per-token drip amount / cooldown / enabled flag; devnet-only guard so these routes never ship enabled on mainnet.
Out of scope
Summary
Add faucet endpoints to the middleware so a public faucet web app can dispense the devnet test tokens (DEMO, PROMPT, USDCX) to any registered address — no wallet connection, rate-limited server-side.
UI issue (approved design + frontend scope): ChainSafe/canton-snap#96
The middleware is the natural home: it already has ledger access, the EVM→party mapping, and the drip logic in
scripts/utils/fund-wallet.go— this feature essentially productizesfund-wallet.gobehind rate-limited endpoints, executed from a dedicated funded faucet party with server-side signing (like the custodial transfer path).All endpoints follow existing conventions:
snake_caseJSON,{ "error": "..." }error bodies,{ "items": [...] }list envelopes.Endpoints
1.
GET /api/v2/faucet/tokensFaucet config for the UI token selector (amounts/cooldowns not hardcoded client-side).
{ "items": [ { "symbol": "DEMO", "name": "Demo Token", "drip_amount": "100", "cooldown_seconds": 3600, "kind": "direct", "enabled": true }, { "symbol": "PROMPT", "name": "Prompt Token", "drip_amount": "50", "cooldown_seconds": 3600, "kind": "direct", "enabled": true }, { "symbol": "USDCX", "name": "USDC on Canton", "drip_amount": "250", "cooldown_seconds": 3600, "kind": "offer", "enabled": true } ] }kindreflects the existing offer-based distinction (OFFER_BASED_TOKENS) — the UI uses it to explain that USDCX arrives as a transfer offer to accept in the dapp. Drip amounts / cooldowns above are placeholders, final values TBD.2.
POST /api/v2/faucet/dripThe main action. Public / unauthenticated (no wallet connection by design), so all abuse control is server-side.
Server-side flow:
GET /profile) — recipients must already be registered.validity_seconds(e.g. the existing 86400 default).Error responses (distinct so the UI can render specific states):
400{ "error": "..." }404{ "error": "address not registered" }429{ "error": "cooldown active", "retry_after_seconds": 1740 }503{ "error": "faucet empty" }3.
GET /api/v2/faucet/status?address=0x...Per-token readiness so the UI can show "ready now" vs. a countdown (and disable the button) before submitting.
{ "items": [ { "token": "DEMO", "available": true }, { "token": "PROMPT", "available": true }, { "token": "USDCX", "available": false, "retry_after_seconds": 1740 } ] }4.
GET /api/v2/faucet/drips/recent?limit=10Powers the public "Recent drips" feed. Unauthenticated — truncate addresses server-side (
0x91bD…44eC), same as the public transfer-list endpoints truncate party IDs.{ "items": [ { "address": "0x91bD…44eC", "token": "USDCX", "amount": "250", "kind": "offer", "created_at": "2026-07-10T11:58:03Z" } ] }5. Health
Reuse the existing
GET /health(nothing new needed if faucet routes live in the middleware).New infrastructure required
(address, token)last-drip timestamps (PostgreSQL is already in the stack).captcha_token.Out of scope
to_party_idrecipients (address-only keeps the public surface simpler/safer; can revisit).