Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
# Sepolia
NEXT_PUBLIC_FAUCET_ADDRESS_SEPOLIA=

# Sepolia — Blacklight L1 NIL, reached only via `?chain=blacklight` (not on the landing page).
NEXT_PUBLIC_FAUCET_ADDRESS_SEPOLIA_BLACKLIGHT=

# Anvil (local dev) - run `./docker/deploy-faucet.sh` to get the address
# NIL token is pre-deployed at 0x5FbDB2315678afecb367f032d93F642f64180aa3
NEXT_PUBLIC_FAUCET_ADDRESS_ANVIL=
Expand Down
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ Nillion Faucet — a testnet faucet web app for claiming NIL tokens on **L1 (Eth
**Key source layout:**

- `src/lib/wagmi.ts` — Chain config (Sepolia, Nillion Testnet, Anvil in dev). Defines `nillionTestnet` chain.
- `src/lib/contracts.ts` — Shared ABIs (faucet + ERC-20), `getFaucetConfig()` resolves contract address + explorer URL per chain. Constants: `NILLION_TESTNET_CHAIN_ID`, `NILLION_TESTNET_RPC_URL`.
- `src/lib/contracts.ts` — Shared ABIs (faucet + ERC-20), `getFaucetConfig(chainId, variant?)` resolves contract address + explorer URL. Constants: `NILLION_TESTNET_CHAIN_ID`, `NILLION_TESTNET_RPC_URL`, `BLACKLIGHT_CHAIN_PARAM`. Type: `FaucetVariant`.
- `src/hooks/useFaucetVariant.ts` — resolves the faucet **variant** from `?chain=`; see "Two faucets on one chain" below.
- `src/lib/l2/faucet.ts` — Server-side viem logic: `sendPayout()` sends ETH then NIL sequentially, `getL2FaucetConfig()` returns drip amounts, `checkFunding()` validates balances. Singleton `FaucetContext` (lazy-initialized).
- `src/lib/l2/rate-limit.ts` — Redis cooldown: `checkCooldown()`, `markCooldown()`, `getCooldownMs()`. Key prefix: `nillion:faucet:l2:cooldown`.
- `src/lib/l2/redis.ts` — Singleton ioredis client via `getRedisClient()`.
Expand All @@ -49,6 +50,19 @@ Nillion Faucet — a testnet faucet web app for claiming NIL tokens on **L1 (Eth

**L1 contract architecture:** `NILFaucet` wraps an immutable ERC-20 `TOKEN` reference. `canClaim(address)` returns `(bool, string)` where the string is a reason code: `PAUSED`, `DRIP_0`, `EMPTY`, `COOLDOWN`. The frontend maps these to UI states directly.

**Two faucets on one chain (the `variant` concept):** Sepolia hosts the original NIL faucet **and** one for Blacklight L1's NIL — a different ERC-20 at a different address, deployed 2026-08-25. `TOKEN` is immutable, so one contract cannot serve both tokens; each needs its own `NILFaucet` instance.

Because both are on chain 11155111, `chainId` cannot distinguish them, and the app was keyed on `chainId` alone. A **variant** does the disambiguating:

- `?chain=blacklight` → variant `"blacklight"` → `NEXT_PUBLIC_FAUCET_ADDRESS_SEPOLIA_BLACKLIGHT`
- anything else (`?chain=L1`, `?chain=L2`, no param) → variant `undefined` → the chain's default, i.e. **existing behaviour, unchanged**

`useFaucetVariant()` reads it **synchronously** rather than in an effect, on purpose: resolving a tick late would let the first render of `?chain=blacklight` read the _default_ faucet, painting the wrong token's balance and drip, and a fast click could send `claim()` to the wrong contract. It is `window`-guarded for SSR.

Everything downstream is automatic — the UI reads `TOKEN`, `dripAmount` and `cooldownSeconds` _from the contract_, so pointing at a different faucet gets the right token, drip and cooldown with no further wiring.

**It is deliberately absent from the landing page.** No `NetworkCard` renders it; the URL is the only way in. If its env var is unset the card says "Faucet not configured" rather than falling back to the other faucet — a silent fallback between two different NILs is the one failure mode worth refusing outright.

**L2 flow:** Client POSTs wallet address → server checks Redis cooldown → server sends ETH transfer then ERC-20 transfer sequentially (avoids nonce collisions) → marks cooldown in Redis → returns both tx hashes.

## Code Conventions
Expand All @@ -69,6 +83,7 @@ See `.env.example`. Key vars:

- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` — Required for WalletConnect
- `NEXT_PUBLIC_FAUCET_ADDRESS_SEPOLIA` / `NEXT_PUBLIC_FAUCET_ADDRESS_ANVIL` — Contract addresses per chain
- `NEXT_PUBLIC_FAUCET_ADDRESS_SEPOLIA_BLACKLIGHT` — the Blacklight L1 NIL faucet on Sepolia, reached only via `?chain=blacklight`. Needs its own deployed `NILFaucet` (drip 20 NIL = `20000000`, cooldown `86400`), funded with that token
- `NEXT_PUBLIC_SEPOLIA_RPC_URL` / `NEXT_PUBLIC_ANVIL_RPC_URL` — Optional RPC overrides

**L2 (server-side):**
Expand Down
14 changes: 13 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { useConnection } from "wagmi";

import { useChainFromUrl } from "@/hooks/useChainFromUrl";
import { BLACKLIGHT_CHAIN_PARAM } from "@/lib/contracts";

import { FaucetCard } from "./components/FaucetCard";

Expand Down Expand Up @@ -108,11 +109,22 @@ interface FaucetPageProps {
onBack: () => void;
}

/**
* Two faucets now sit on Sepolia, so "Ethereum Sepolia" alone no longer says which one you are
* looking at. Naming the Blacklight variant is the cheapest guard against claiming from the
* wrong token and concluding the faucet is broken.
*/
function networkLabelFor(chainParam: string): string {
if (chainParam === "L2") return "Nillion Testnet";
if (chainParam.toLowerCase() === BLACKLIGHT_CHAIN_PARAM) return "Blacklight L1 · Ethereum Sepolia";
return "Ethereum Sepolia";
}

function FaucetPage({ chainParam, onBack }: FaucetPageProps): React.JSX.Element {
const { isConnected } = useConnection();
useChainFromUrl();

const networkLabel = chainParam === "L2" ? "Nillion Testnet" : "Ethereum Sepolia";
const networkLabel = networkLabelFor(chainParam);

return (
<main className="min-h-screen flex flex-col items-center justify-center p-8">
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useChainFromUrl.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useEffect } from "react";
import { useChainId, useSwitchChain } from "wagmi";

import { NILLION_TESTNET_CHAIN_ID } from "@/lib/contracts";
import { BLACKLIGHT_CHAIN_PARAM, NILLION_TESTNET_CHAIN_ID } from "@/lib/contracts";

const SEPOLIA_CHAIN_ID = 11155111;

const CHAIN_PARAM_MAP: Record<string, number> = {
l1: SEPOLIA_CHAIN_ID,
l2: NILLION_TESTNET_CHAIN_ID,
[BLACKLIGHT_CHAIN_PARAM]: SEPOLIA_CHAIN_ID,
};

export function useChainFromUrl(): void {
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useClaim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useConnection, useChainId, useWaitForTransactionReceipt, useWriteContract } from "wagmi";

import { useFaucetVariant } from "@/hooks/useFaucetVariant";
import { FAUCET_ABI, getFaucetConfig } from "@/lib/contracts";

/** Status of the claim transaction lifecycle */
Expand Down Expand Up @@ -33,7 +34,8 @@ export interface UseClaimResult {
export function useClaim(onSuccess?: () => void): UseClaimResult {
const { address } = useConnection();
const chainId = useChainId();
const { address: faucetAddress, explorerUrl } = getFaucetConfig(chainId);
const variant = useFaucetVariant();
const { address: faucetAddress, explorerUrl } = getFaucetConfig(chainId, variant);

const [error, setError] = useState<Error | null>(null);
const toastShownForTx = useRef<string | null>(null);
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useFaucetStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useConnection, useChainId, useChains, useReadContract, useReadContracts } from "wagmi";

import { useFaucetVariant } from "@/hooks/useFaucetVariant";
import { ERC20_ABI, FAUCET_ABI, getFaucetConfig } from "@/lib/contracts";

/** Faucet status and user eligibility data */
Expand Down Expand Up @@ -55,8 +56,10 @@ export function useFaucetStatus(): FaucetStatus {
const chainId = useChainId();
const chains = useChains();

const variant = useFaucetVariant();

const chainName = chains.find((c) => c.id === chainId)?.name ?? "Unknown";
const { address: faucetAddress, explorerUrl } = getFaucetConfig(chainId);
const { address: faucetAddress, explorerUrl } = getFaucetConfig(chainId, variant);

// First, read the token address from the faucet contract
const {
Expand Down
15 changes: 15 additions & 0 deletions src/hooks/useFaucetVariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use client";

import { BLACKLIGHT_CHAIN_PARAM, type FaucetVariant } from "@/lib/contracts";

/**
* Which faucet on the current chain the URL is asking for.
*
* Sepolia hosts two faucets — the original NIL one and the Blacklight L1 one — so `chainId`
* cannot tell them apart. `?chain=blacklight` selects the second
*/
export function useFaucetVariant(): FaucetVariant | undefined {
if (typeof window === "undefined") return undefined;
const param = new URLSearchParams(window.location.search).get("chain")?.toLowerCase();
return param === BLACKLIGHT_CHAIN_PARAM ? "blacklight" : undefined;
}
27 changes: 23 additions & 4 deletions src/lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,30 @@ export const ERC20_ABI = [

const SEPOLIA_CHAIN_ID = 11155111;

/**
* A faucet VARIANT distinguishes two faucets that live on the SAME chain, which chainId
* alone cannot. Sepolia now hosts two: the original NIL faucet, and one for the Blacklight
* L1 NIL token deployed 2026-08-25 — a different ERC-20 at a different address.
*/
export type FaucetVariant = "blacklight";

/** URL value that selects the Blacklight faucet: `?chain=blacklight`. Deliberately not on the landing page. */
export const BLACKLIGHT_CHAIN_PARAM = "blacklight";

function isHexAddress(value: string | undefined): value is `0x${string}` {
return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
}

// Contract addresses per chain - loaded from environment
function getFaucetAddress(chainId: number): `0x${string}` | undefined {
function getFaucetAddress(chainId: number, variant?: FaucetVariant): `0x${string}` | undefined {
if (chainId === SEPOLIA_CHAIN_ID) {
const addr = process.env.NEXT_PUBLIC_FAUCET_ADDRESS_SEPOLIA;
// Each branch names its env var LITERALLY. `process.env[someKey]` is not statically
// analysable, so Next inlines nothing and every NEXT_PUBLIC_* read comes back undefined
// in the browser — a faucet that silently reports "not configured" in production only.
const addr =
variant === "blacklight"
? process.env.NEXT_PUBLIC_FAUCET_ADDRESS_SEPOLIA_BLACKLIGHT
: process.env.NEXT_PUBLIC_FAUCET_ADDRESS_SEPOLIA;
return isHexAddress(addr) ? addr : undefined;
}
if (chainId === ANVIL_CHAIN_ID) {
Expand All @@ -125,12 +141,15 @@ function getExplorerUrl(chainId: number): string {
return explorerUrls[chainId] || "https://etherscan.io";
}

export function getFaucetConfig(chainId: number): {
export function getFaucetConfig(
chainId: number,
variant?: FaucetVariant,
): {
address: `0x${string}` | undefined;
explorerUrl: string;
} {
return {
address: getFaucetAddress(chainId),
address: getFaucetAddress(chainId, variant),
explorerUrl: getExplorerUrl(chainId),
};
}
Loading