Skip to content

feat(faucet): add devnet faucet web app for test tokens#97

Draft
sadiq1971 wants to merge 3 commits into
mainfrom
feat/faucet-dapp
Draft

feat(faucet): add devnet faucet web app for test tokens#97
sadiq1971 wants to merge 3 commits into
mainfrom
feat/faucet-dapp

Conversation

@sadiq1971

Copy link
Copy Markdown
Member

Summary

New packages/faucet workspace — 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)
  • Distinct UI states for 404 (address not registered → register-in-dapp hint), 429 (cooldown → countdown), 503 (reserves drained)
  • Responsive down to mobile (token grid restacks, like the dapp's breakpoints)

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 a faucet-docker.yml workflow.

Side effects on existing packages:

  • packages/dapp/Dockerfile now copies the faucet manifest (required for npm ci once the workspace exists)
  • react-dom aligned to react 19.2.7 in dapp + faucet — React 19 throws at runtime on exact-version mismatch, which the fresh workspace install surfaced

Verification

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, test all green.

Notes / follow-ups

  • Drip amounts and cooldowns render from GET /api/v2/faucet/tokens, so final values are a middleware config concern.
  • Captcha (captcha_token) intentionally not wired yet — pending provider decision in canton-middleware#350.
  • Address validation is format-only (0x + 40 hex); no checksum validation to avoid pulling ethers into the bundle for one call.

🤖 Generated with Claude Code

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
@sadiq1971 sadiq1971 added the Type: Feature Added to issues and PRs to identify that the change is a new feature. label Jul 9, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +106 to +107
const data = await get<{ items: unknown[] }>(baseUrl, "/api/v2/faucet/tokens");
if (!Array.isArray(data.items)) throw new Error("Unexpected faucet tokens response shape");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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");

Comment on lines +122 to +126
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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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");

Comment on lines +143 to +147
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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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");

Comment on lines +198 to +201
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");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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");
}

Comment on lines +152 to +159
async function handlePaste() {
try {
const text = await navigator.clipboard.readText();
if (text) setAddress(text.trim());
} catch {
// Clipboard access denied — user can paste manually.
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.
    }
  }

Comment on lines +39 to +40
export function formatCooldownNoun(seconds: number): string {
if (seconds % 86400 === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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)
@sadiq1971
sadiq1971 marked this pull request as draft July 15, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Feature Added to issues and PRs to identify that the change is a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Faucet web app for devnet test tokens (DEMO, PROMPT, USDCX)

1 participant