feat(faucet): add devnet faucet web app for test tokens#97
Conversation
New packages/faucet workspace: a public faucet for the devnet test tokens (DEMO, PROMPT, USDCX) built against the middleware faucet API (ChainSafe/canton-middleware#350), following the approved design in #96. - Single-page drip flow: token selector, recipient address, per-token cooldown countdown (restored from the status endpoint), offer-based receipt copy for USDCX, and a recent-drips feed - Distinct error states for unregistered address (404), active cooldown (429), and drained faucet reserves (503) - Mirrors the dapp stack and conventions: Vite + React 19, CSS modules, shared design tokens, nginx Docker image with runtime env injection, faucet-v* docker workflow, release-please component - Align react-dom with react across workspaces (React 19 requires exact version match at runtime) Closes #96
There was a problem hiding this comment.
Code Review
This pull request introduces a new @chainsafe/canton-faucet package, which is a React-based public web faucet for devnet test tokens, complete with Docker configuration, Nginx setup, and integration into the monorepo workspace. The review feedback suggests several robustness improvements, including adding defensive checks for API responses to prevent potential null-pointer exceptions, verifying the existence of navigator.clipboard to avoid crashes in non-secure HTTP environments, and handling non-positive values in the cooldown formatting utility.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const data = await get<{ items: unknown[] }>(baseUrl, "/api/v2/faucet/tokens"); | ||
| if (!Array.isArray(data.items)) throw new Error("Unexpected faucet tokens response shape"); |
There was a problem hiding this comment.
Add a defensive check to ensure data is not null or undefined before accessing data.items. If the API returns a null response, accessing data.items will throw a TypeError.
| const data = await get<{ items: unknown[] }>(baseUrl, "/api/v2/faucet/tokens"); | |
| if (!Array.isArray(data.items)) throw new Error("Unexpected faucet tokens response shape"); | |
| const data = await get<{ items: unknown[] }>(baseUrl, "/api/v2/faucet/tokens"); | |
| if (!data || !Array.isArray(data.items)) throw new Error("Unexpected faucet tokens response shape"); |
| const data = await get<{ items: unknown[] }>( | ||
| baseUrl, | ||
| `/api/v2/faucet/status?address=${encodeURIComponent(address)}`, | ||
| ); | ||
| if (!Array.isArray(data.items)) throw new Error("Unexpected faucet status response shape"); |
There was a problem hiding this comment.
Add a defensive check to ensure data is not null or undefined before accessing data.items to prevent potential runtime TypeErrors if the API returns a null response.
| const data = await get<{ items: unknown[] }>( | |
| baseUrl, | |
| `/api/v2/faucet/status?address=${encodeURIComponent(address)}`, | |
| ); | |
| if (!Array.isArray(data.items)) throw new Error("Unexpected faucet status response shape"); | |
| const data = await get<{ items: unknown[] }>( | |
| baseUrl, | |
| `/api/v2/faucet/status?address=${encodeURIComponent(address)}`, | |
| ); | |
| if (!data || !Array.isArray(data.items)) throw new Error("Unexpected faucet status response shape"); |
| const data = await get<{ items: unknown[] }>( | ||
| baseUrl, | ||
| `/api/v2/faucet/drips/recent?limit=${limit}`, | ||
| ); | ||
| if (!Array.isArray(data.items)) throw new Error("Unexpected recent drips response shape"); |
There was a problem hiding this comment.
Add a defensive check to ensure data is not null or undefined before accessing data.items to prevent potential runtime TypeErrors if the API returns a null response.
| const data = await get<{ items: unknown[] }>( | |
| baseUrl, | |
| `/api/v2/faucet/drips/recent?limit=${limit}`, | |
| ); | |
| if (!Array.isArray(data.items)) throw new Error("Unexpected recent drips response shape"); | |
| const data = await get<{ items: unknown[] }>( | |
| baseUrl, | |
| `/api/v2/faucet/drips/recent?limit=${limit}`, | |
| ); | |
| if (!data || !Array.isArray(data.items)) throw new Error("Unexpected recent drips response shape"); |
| const data = JSON.parse(text) as Record<string, unknown>; | ||
| if (!isDripKind(data.kind) || typeof data.amount !== "string" || typeof data.token !== "string") { | ||
| throw new Error("Unexpected drip response shape"); | ||
| } |
There was a problem hiding this comment.
Add a defensive check to ensure data is not null or undefined before accessing its properties (data.kind, data.amount, data.token) to prevent potential runtime TypeErrors if the API returns a null response.
| const data = JSON.parse(text) as Record<string, unknown>; | |
| if (!isDripKind(data.kind) || typeof data.amount !== "string" || typeof data.token !== "string") { | |
| throw new Error("Unexpected drip response shape"); | |
| } | |
| const data = JSON.parse(text) as Record<string, unknown>; | |
| if (!data || !isDripKind(data.kind) || typeof data.amount !== "string" || typeof data.token !== "string") { | |
| throw new Error("Unexpected drip response shape"); | |
| } |
| async function handlePaste() { | ||
| try { | ||
| const text = await navigator.clipboard.readText(); | ||
| if (text) setAddress(text.trim()); | ||
| } catch { | ||
| // Clipboard access denied — user can paste manually. | ||
| } | ||
| } |
There was a problem hiding this comment.
Check if navigator.clipboard is defined before calling readText(). In non-secure contexts (HTTP) or certain browsers, navigator.clipboard may be undefined, which would cause a runtime TypeError when attempting to access readText.
async function handlePaste() {
try {
if (navigator.clipboard) {
const text = await navigator.clipboard.readText();
if (text) setAddress(text.trim());
}
} catch {
// Clipboard access denied — user can paste manually.
}
}
| export function formatCooldownNoun(seconds: number): string { | ||
| if (seconds % 86400 === 0) { |
There was a problem hiding this comment.
Handle cases where seconds is less than or equal to 0 (e.g., if a token has no cooldown configured) to avoid displaying confusing labels like 0 days or 0 minutes.
| export function formatCooldownNoun(seconds: number): string { | |
| if (seconds % 86400 === 0) { | |
| export function formatCooldownNoun(seconds: number): string { | |
| if (seconds <= 0) return "no cooldown"; | |
| if (seconds % 86400 === 0) { |
- Drop stale status responses that would wipe a cooldown recorded by a just-completed drip (or 429) and re-enable the button too early - Fall back to the token's configured cooldown when the server omits retry_after_seconds, so the countdown always shows - Only map drip 404s to "not registered" when the body says so; other 404s (wrong middleware URL, unrouted path) surface as errors - Treat an all-invalid tokens payload as a contract error instead of rendering an empty faucet - Add the missing .spinner-ring animation rule (spinner never rotated) - Clear stale receipt/error when pasting a new address - Stop the countdown interval once the cooldown lapses - Guard faucet-docker workflow_dispatch against non-faucet-v* inputs, which would have overwritten the :latest image - Align react range with react-dom (^19.2.7) in dapp and faucet - Satisfy react-hooks/set-state-in-effect by moving synchronous setState out of effect bodies (matches App.tsx convention in the dapp)
Summary
New
packages/faucetworkspace — a public web faucet for the devnet test tokens (DEMO, PROMPT, USDCX), implementing the approved design from #96 against the middleware faucet API specced in ChainSafe/canton-middleware#350 (assumed available under/api/v2/faucet/*).Closes #96
What's included
Faucet app (
packages/faucet) — mirrors the dapp's stack and structure (Vite + React 19 + TS, CSS modules, same design tokens, SPDX headers,cn()/component conventions):lib/faucet.ts— typed client for the four faucet endpoints (tokens,drip,status,drips/recent), snake_case→camelCase mapping, response-shape guards, and typed errors (NotRegisteredError/CooldownActiveError/FaucetDrainedError)pages/FaucetPage.tsx— the single drip flow: token selector (config-driven from the API, nothing hardcoded), recipient address with validation + paste helper, drip button with per-token cooldown countdown (restored from the status endpoint when an address is re-entered), success receipts (offer-specific copy for USDCX: "accept it from Offers in the dapp"), and a live recent-drips feed (30s refresh)Monorepo wiring: workspace + root scripts (
dev:faucet,build:faucet,test:faucet, lint/format coverage), eslint react-hooks rules, release-please component (faucet-v*), Docker image (unprivileged nginx + runtime env injection, same pattern as the dapp) with afaucet-docker.ymlworkflow.Side effects on existing packages:
packages/dapp/Dockerfilenow copies the faucet manifest (required fornpm cionce the workspace exists)react-domaligned toreact19.2.7 in dapp + faucet — React 19 throws at runtime on exact-version mismatch, which the fresh workspace install surfacedVerification
Driven end-to-end with Playwright against a mock of the middleware faucet API: 14/14 checks pass — initial load (tokens + feed from API), address validation, direct drip receipt with tx id, USDCX offer receipt, countdown start/restore via status endpoint, 404/503 error states, mobile layout, no console errors.
npm run build,lint,format:check,testall green.Notes / follow-ups
GET /api/v2/faucet/tokens, so final values are a middleware config concern.captcha_token) intentionally not wired yet — pending provider decision in canton-middleware#350.0x+ 40 hex); no checksum validation to avoid pulling ethers into the bundle for one call.🤖 Generated with Claude Code