paint-brush
Building a Production Grade Testnet Faucet With Typescript, Redis and Nextjsby@ernestnnamdi
129 reads

Building a Production Grade Testnet Faucet With Typescript, Redis and Nextjs

by Ernest NnamdiOctober 11th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

In this tutorial, I will teach you how to build your own faucet be it for your team or personal use.
featured image - Building a Production Grade Testnet Faucet With Typescript, Redis and Nextjs
Ernest Nnamdi HackerNoon profile picture

Whether you are a new developer or a seasoned developer, you have definitely wagged your fingers at how hard it is to get testnet tokens. From the lack of faucet solutions to strigent requirements for existing solutions. In this tutorial, I will teach you how to build your own faucet be it for your team or personal use.

Okay, but I have no idea what a faucet is in crypto

Thats totally okay too. Think of the traditional faucet that discharges water whenever you need it so also does crypto faucets discharge testnet tokens when you need it.


A textbook definition would be


A crypto faucet is a platform that provides users with small quantities of cryptocurrency for completing simple tasks. These tasks could range from viewing an ad, participating in a survey, or even just proving you're a human by completing a captcha. The quantities provided by these faucets are often small, akin to the drops from a leaky faucet, hence the name.


Testnet tokens are essentially test(or fake money) we use to transact or pay for transactions on the test environments of blockchain solutions. it provides a risk free environment for developers to deploy, test and explore smart contracts on the blockchain.


Sounds good, but if its a crypto faucet, why do we need Typescript, Nextjs and Redis

There are different approaches to building faucets for testnet tokens. Some solutions utilize a smart contract for disbursement of tokens but his approach while good, has some limitations. The most glaring disadvantage of using a smart contract for your faucet lies in the immutable nature of smart contracts themselves. Once deployed, the contract cannot be modified and this presents a roadblock for you if you need to upgrade, change configuration or even correct errors in implementation.


The best approach to building faucet, is to implement as a webApp and use web3 frameworks like EtherJs to interact with the blockchain. This gives us the necessary flexibility needed to be able to upgrade and modify our faucet in the future including setting up anti-sybil protection.


In this tutorial, we will be building a nextjs application with typescript and using redis to rate-limit users and also protect our faucet from sybil attacks. Our faucet will be able to disburse testnet ETH and also, ERC20 tokens.


Tech stack

  • NextJS 13 with App Router
  • Typescript
  • Ether.js for blockchain interactions
  • Redis for rate limiting and data persistence
  • Tailwind css for styling
  • shadcn for ui components
  • hcaptcha for bot protection


Architecture

To give a brief overview of how our faucet is built:

  • frontend: React components built with Nextjs
  • backend: Nextjs api routes for handling claims.
  • blockchain interactions: Ethers.js for sending transactions.
  • data storage: Redis for storing claim history and rate limiting.


Setting up your environment

phew! finally, lets start hacking. The first thing we need to do is to create our working directory. So on the terminal, we run


mkdir faucet

cd faucet


Then initialize a nextjs project


npx create-next-app@latest

you can just hit enter to select all default options


Next, we install shadcn and install all the components we will be using. you can check out the documentation to learn more about shadcn.


npx shadcn@latest init -d


npx shadcn@latest add button form input label select toast  use-toast


Now, we add other dependencies we will be needing.


npm i [email protected] ioredis rate-limiter-flexible 


Your project should look exactly like mine if you’ve followed the above steps.

ensure you have all dependencies downloaded


Building the Homepage

Go to the page.tsx file in the app folder, delete the existing boilerplate code and replace it with the code below. we are keeping our homepage cutesy(depending on the year you read this, forgive the cringeness) but simple.


import FaucetForm from "@/components/FaucetForm";
import { InfoIcon } from "lucide-react";

export default function Home() {
  return (
    <main className="flex flex-col min-h-screen p-4 md:p-8  text-gray-200">
      <div className="flex-grow max-w-4xl mx-auto w-full">
        <section className="py-8 md:py-12 flex flex-col items-center text-center gap-6">
          <h1 className="text-3xl md:text-4xl font-bold text-white">EVM Token Faucet</h1>
          <p className="text-lg md:text-xl text-gray-300 max-w-2xl">
            Claim Testnet tokens every 24 hours for your development and testing needs.
          </p>
          <div className="flex items-start gap-2 bg-gray-800 border border-gray-700 rounded-lg p-4 text-sm text-gray-300 max-w-2xl">
            <InfoIcon className="w-5 h-5 mt-0.5 flex-shrink-0 text-blue-400" />
            <p className="text-left">
              Please note: These are testnet-only tokens with no real economic value.
            </p>
          </div>
        </section>

        <section className="flex flex-col items-center justify-center w-full my-8">
          <div className="w-full max-w-3xl p-6 md:p-12 bg-gray-800 shadow-lg rounded-lg border border-gray-700">
            <FaucetForm />
          </div>
        </section>
      </div>

    </main>
  );
}


As you could already predict, the next component we have to build is the FaucetForm component.


FaucetForm.tsx

In the components folder, create a new file called FaucetForm.tsx and paste in the code below.


"use client";

import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "./ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "./ui/select";

export default function FaucetForm() {
  const [isLoading, setIsLoading] = useState(false);

  const formSchema = z.object({
    address: z
      .string()
      .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address"),
    token: z.enum(["ETH", "PIRONTOKEN"]),
  });

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      address: "",
      token: "ETH",
    },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(() => {})} className="space-y-4">
        <div className="grid grid-cols-5 gap-4">
          <FormField
            control={form.control}
            name="token"
            render={({ field }) => (
              <FormItem className="col-span-2">
                <Select
                  onValueChange={field.onChange}
                  defaultValue={field.value}
                >
                  <FormControl>
                    <SelectTrigger className="bg-gray-700 border-gray-600 text-white rounded-md w-full">
                      <SelectValue placeholder="Select token" />
                    </SelectTrigger>
                  </FormControl>
                  <SelectContent className="bg-gray-700 border-gray-600">
                    <SelectItem value="ETH" className="text-white">
                      0.01 ETH
                    </SelectItem>
                    <SelectItem value="PIRONTOKEN" className="text-white">
                      10 PIRONTOKEN
                    </SelectItem>
                  </SelectContent>
                </Select>
                <FormMessage />
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="address"
            render={({ field }) => (
              <FormItem className="col-span-3">
                <FormControl>
                  <Input
                    className="bg-gray-700 border-gray-600 text-white placeholder-gray-400 rounded-md w-full"
                    placeholder="Wallet Address"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>

        <Button
          size="lg"
          disabled={isLoading}
          type="submit"
          className="w-full bg-white text-black hover:bg-gray-200 rounded-md"
        >
          {isLoading
            ? "Processing..."
            : `Send ${
                form.watch("token") === "ETH" ? "0.01 ETH" : "10 PIRONTOKEN"
              }`}
        </Button>
      </form>
    </Form>
  );
}


This basic form utilizes the react-hook-form and zod which is provisioned for us from shadcn. The form allows users to choose between claiming ETH or our ERC20 token. On your browser, the faucet should look like below.


kinda sleek innit?


Now, at the moment, our faucet doesnt do anything because no logic has been implemented. Lets start off by adding our environment variables.

env.local

In the root of the project, we create a file called env.local and we add the following environment variables. I will be using Morph’s rpc url(you can use that of any chain of your choice). Piron token is the name of the ERC20 token we are using for the sake of this tutorial.


For the REDIS_URL, you can get it by creating an account on redis cloud and replacing the boiler text with your password and public endpoint.


Finally for the captcha keys, visit hcaptcha and create a site, set type to bot management, captcha behaviour to always challenge and treshold to auto.


MORPH_RPC_URL=https://rpc-quicknode-holesky.morphl2.io
PRIVATE_KEY=your-private-key
PIRON_TOKEN_ADDRESS=bleh


REDIS_URL=redis://default:your-password@your-public-endpoint

NEXT_PUBLIC_HCAPTCHA_SITE_KEY=site-public-key
HCAPTCHA_SECRET_KEY=your-secret-key


redis.ts

Next, lets create a Redis client. In the lib folder, create a new file called redis.ts. The ioredis library helps us connect and interact with the redis server. The code below initializes the redis client and limits the amount of retries (in connecting to the server) to three while increasing the time between each retry from 50ms to 2 seconds.


import Redis from "ioredis";

if (!process.env.REDIS_URL) {
  throw new Error("REDIS_URL is not defined in the environment variables");
}

const redis = new Redis(process.env.REDIS_URL, {
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    const delay = Math.min(times * 50, 2000);
    return delay;
  },
  reconnectOnError(err) {
    const targetError = "READONLY";
    if (err.message.includes(targetError)) {
      // Only reconnect when the error contains "READONLY"
      return true;
    }
    return false;
  },
});

redis.on("error", (error) => {
  console.error("Redis Client Error:", error);
});

redis.on("connect", () => {
  console.log("Connected to Redis");
});

export default redis;


rate-limiter.ts

The next logic we need to work on, is the rate limiter. Still in the lib folder, create another file called rate-limiter.ts and paste in the code below. The rate-limiter-flexible library uses redis as the backend to limit the amount of request (8) a specific ip address can make within a specified time window (per hour). this is done to prevent abuse and also bots from draining our faucet.


import { Redis } from "ioredis";
import { RateLimiterRedis } from "rate-limiter-flexible";

const redisClient = new Redis(process.env.REDIS_URL as string);

export const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: "ratelimit",
  points: 8, // Number of requests
  duration: 60 * 60, // Per hour
});

export async function limitRate(ip: string): Promise<boolean> {
  try {
    await rateLimiter.consume(ip);
    return true; // Request allowed
  } catch (rejRes) {
    return false; // Request blocked
  }
}


route.ts

After setting up our redis client and rate limiter, we can finally start building the backend for our faucet. In the app folder, create a folder named api and append a file called route.ts. This is where we will be building the api routes for handling claims. In the route.ts file, paste the code below.


import { NextRequest } from "next/server";
import { ethers } from "ethers";
import redis from "../../lib/redis";
import { limitRate } from "../../lib/rate-limiter";

const ETH_FAUCET_AMOUNT = ethers.utils.parseEther("0.03");
const PIRON_FAUCET_AMOUNT = ethers.utils.parseUnits("10", 18);
const COOLDOWN_PERIOD = 12 * 60 * 60; // 12 hours in seconds

const provider = new ethers.providers.JsonRpcProvider({
  url: process.env.MORPH_RPC_URL as string,
  skipFetchSetup: true,
});

const wallet = new ethers.Wallet(process.env.PRIVATE_KEY as string, provider);

const PIRON_TOKEN_ADDRESS = process.env.PIRON_TOKEN_ADDRESS as string;

const pironTokenABI = [
  "function transfer(address to, uint256 amount) returns (bool)",
];
const pironTokenContract = new ethers.Contract(
  PIRON_TOKEN_ADDRESS,
  pironTokenABI,
  wallet
);

let lastNonce = -1;

async function getNextNonce() {
  const currentNonce = await wallet.getTransactionCount("pending");
  lastNonce = Math.max(lastNonce, currentNonce - 1);
  return lastNonce + 1;
}

async function verifyCaptcha(captchaResponse: string): Promise<boolean> {
  const verifyUrl = `https://hcaptcha.com/siteverify`;
  const response = await fetch(verifyUrl, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: `response=${captchaResponse}&secret=${process.env.HCAPTCHA_SECRET_KEY}`,
  });
  const data = await response.json();
  return data.success;
}

export async function POST(request: NextRequest) {
  const ip = request.ip || "unknown";
  const isAllowed = await limitRate(ip);
  if (!isAllowed) {
    return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
  }
  const { address, token, captcha } = await request.json();

  if (!ethers.utils.isAddress(address)) {
    return Response.json(
      { error: "Invalid Ethereum address" },
      { status: 400 }
    );
  }

  if (!["ETH", "PIRONTOKEN"].includes(token)) {
    return Response.json({ error: "Invalid token selection" }, { status: 400 });
  }

  const isCaptchaValid = await verifyCaptcha(captcha);
  if (!isCaptchaValid) {
    return Response.json({ error: "Invalid captcha" }, { status: 400 });
  }

  try {
    const lastClaimTime = await redis.get(`lastClaim:${address}`);
    const now = Math.floor(Date.now() / 1000);

    if (lastClaimTime) {
      const timeSinceLastClaim = now - parseInt(lastClaimTime);
      if (timeSinceLastClaim < COOLDOWN_PERIOD) {
        const remainingTime = Math.ceil(
          (COOLDOWN_PERIOD - timeSinceLastClaim) / 60
        );
        return Response.json(
          {
            error: `Please wait ${remainingTime} minutes before claiming again`,
          },
          { status: 429 }
        );
      }
    }

    const nonce = await getNextNonce();

    let tx;
    if (token === "ETH") {
      tx = await wallet.sendTransaction({
        to: address,
        value: ETH_FAUCET_AMOUNT,
        nonce: nonce,
        gasPrice: await provider.getGasPrice(),
        gasLimit: 21000,
      });
    } else if (token === "PIRONTOKEN") {
      tx = await pironTokenContract.transfer(address, PIRON_FAUCET_AMOUNT, {
        nonce: nonce,
        gasPrice: await provider.getGasPrice(),
        gasLimit: 100000,
      });
    }

    await tx.wait(1);

    // Record the claim in Redis with automatic expiration
    await redis.set(`lastClaim:${address}`, now, "EX", COOLDOWN_PERIOD);

    return Response.json({ success: true, txHash: tx.hash });
  } catch (error) {
    console.error("Error processing claim:", error);

    if (error instanceof Error) {
      if (error.message.includes("already known")) {
        return Response.json(
          {
            error:
              "Transaction already submitted. Please wait and try again later.",
          },
          { status: 409 }
        );
      }
      if (error.message.includes("replacement fee too low")) {
        return Response.json(
          { error: "Network is busy. Please try again later." },
          { status: 503 }
        );
      }
    }

    return Response.json(
      { error: "An unexpected error occurred" },
      { status: 500 }
    );
  }
}


The code above allows a user to claim once every 12 hours. it creates an instance of a wallet by using our rpc_url(we are using morph in this example) and private key. Next, we create an instance of the piron token contract(our ERC20 token) by using ethers and passing in the contract address, abi and the wallet we just created as arguments.


for the getNextNonce function, Ethereum transactions require a nonce to prevent replay attacks. This function ensures that the correct nonce is used for transactions by tracking the last nonce and incrementing it. It is also useful in situations where there is network congestion.


The verifyCaptcha function sends a post request to hCaptcha to verify the user’s captcha response. The function returns a boolean value.


The main function which handles the claim, performs a number of checks before allowing the user to claim any token. First, it takes the ip address of the user to check if the user's IP has exceeded the rate limit. If they have, the request is rejected with a 429 Too Many Requests status.


next, it takes the data sent over from the frontend and ensures they’re valid before calling the verifyCaptcha function which we talked about earlier. If the captcha is verified, it checks for the lastClaimTime of the user by calling our redis database to ensure that the user is not abusing the faucet. If the time has exceeded the cooldown period , it proceeds to send the testnet tokens.


Sending native ETH and ERC20 tokens require different methods as demonstrated in the code above.

finally, we set the last claim time for the user to the current time and add some error handling.


After this is done, your file structure should look like this.


our api route fully setup


FaucetForm.tsx

Back in our faucet form component, its time to connect the UI to the api. we start by installing one more dependency


npm i @hcaptcha/react-hcaptcha


Next we create a captcha ref and destructure toast from the use-toast hook.


import { useToast } from "@/hooks/use-toast";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ToastAction } from "./ui/toast";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { Button } from "./ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "./ui/select";

export default function FaucetForm() {
  const [isLoading, setIsLoading] = useState(false);
  const { toast } = useToast();
  const captchaRef = useRef<HCaptcha>(null);



const executeCaptcha = () => {
    if (captchaRef.current) {
      captchaRef.current.execute();
    }
  };

  const onCaptchaVerify = async (token: string) => {
    if (!token) {
      toast({
        variant: "destructive",
        title: "Captcha verification failed",
        description: "Please try again.",
      });
      return;
    }

    const formData = form.getValues();
    await handleSubmit({ ...formData, captcha: token });
  };


The executeCaptcha function triggers the captcha to execute and the onCaptchaVerify function checks if a token was returned(ifnot its displays an error) and passes it to the handleSubmit function.


 const handleSubmit = async (
    data: z.infer<typeof formSchema> & { captcha: string }
  ) => {
    setIsLoading(true);
    try {
      const response = await fetch("/api/claim", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(data),
      });

      const result = await response.json();

      if (response.ok) {
        toast({
          variant: "success",
          title: "Faucet claim successful!",
          action: (
            <ToastAction
              className="bg-green-900 text-white"
              altText="View Transaction"
              onClick={() => {
                window.open(
                  `https://explorer-holesky.morphl2.io/tx/${result.txHash}`
                );
              }}
            >
              View transaction on Explorer
            </ToastAction>
          ),
        });
      } else {
        toast({
          variant: "destructive",
          title: "Could not process your claim",
          description: `${result.error}`,
        });
      }
    } catch (error) {
      toast({
        variant: "destructive",
        description:
          error instanceof Error
            ? error.message
            : "An error occurred. Please try again.",
      });
    } finally {
      setIsLoading(false);
      if (captchaRef.current) {
        captchaRef.current.resetCaptcha();
      }
    }
  };


The handleSubmit function takes in an argument called data which contains both the form data and also the token from captcha. Next, it sends a POST request to the api route, passing in the data as the request body. if the request is successful, it displays a success modal.


The toast component was modified to include a success variant so in your components/ui/toast replace the existing code with this


"use client";

import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";

import { cn } from "@/lib/utils";

const ToastProvider = ToastPrimitives.Provider;

const ToastViewport = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Viewport>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Viewport
    ref={ref}
    className={cn(
      "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
      className
    )}
    {...props}
  />
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;

const toastVariants = cva(
  "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
  {
    variants: {
      variant: {
        default: "border bg-background text-foreground",
        destructive:
          "destructive group border-destructive bg-destructive text-destructive-foreground",
        success:
          "border border-green-600 bg-green-100 text-green-800 dark:border-green-500 dark:bg-green-900 dark:text-green-200",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
);

const Toast = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Root>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
    VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
  return (
    <ToastPrimitives.Root
      ref={ref}
      className={cn(toastVariants({ variant }), className)}
      {...props}
    />
  );
});
Toast.displayName = ToastPrimitives.Root.displayName;

const ToastAction = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Action>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Action
    ref={ref}
    className={cn(
      "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-opacity-10 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
      className
    )}
    {...props}
  />
));
ToastAction.displayName = ToastPrimitives.Action.displayName;

const ToastClose = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Close>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Close
    ref={ref}
    className={cn(
      "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
      className
    )}
    toast-close=""
    {...props}
  >
    <X className="h-4 w-4" />
  </ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;

const ToastTitle = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Title>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Title
    ref={ref}
    className={cn("text-sm font-semibold", className)}
    {...props}
  />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;

const ToastDescription = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Description>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Description
    ref={ref}
    className={cn("text-sm opacity-90", className)}
    {...props}
  />
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;

type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;

type ToastActionElement = React.ReactElement<typeof ToastAction>;

export {
  type ToastProps,
  type ToastActionElement,
  ToastProvider,
  ToastViewport,
  Toast,
  ToastTitle,
  ToastDescription,
  ToastClose,
  ToastAction,
};


Finally, your updated FaucetForm should look like this


"use client";

import { useRef, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useToast } from "@/hooks/use-toast";
import { z } from "zod";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "./ui/button";
import { ToastAction } from "./ui/toast";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "./ui/select";

export default function FaucetForm() {
  const [isLoading, setIsLoading] = useState(false);
  const { toast } = useToast();
  const captchaRef = useRef<HCaptcha>(null);

  const formSchema = z.object({
    address: z
      .string()
      .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address"),
    token: z.enum(["ETH", "PIRONTOKEN"]),
  });

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      address: "",
      token: "ETH",
    },
  });

  const executeCaptcha = () => {
    if (captchaRef.current) {
      captchaRef.current.execute();
    }
  };

  const onCaptchaVerify = async (token: string) => {
    if (!token) {
      toast({
        variant: "destructive",
        title: "Captcha verification failed",
        description: "Please try again.",
      });
      return;
    }

    const formData = form.getValues();
    await handleSubmit({ ...formData, captcha: token });
  };

  const handleSubmit = async (
    data: z.infer<typeof formSchema> & { captcha: string }
  ) => {
    setIsLoading(true);
    try {
      const response = await fetch("/api/claim", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(data),
      });

      const result = await response.json();

      if (response.ok) {
        toast({
          variant: "success",
          title: "Faucet claim successful!",
          action: (
            <ToastAction
              className="bg-green-900 text-white"
              altText="View Transaction"
              onClick={() => {
                window.open(
                  `https://explorer-holesky.morphl2.io/tx/${result.txHash}`
                );
              }}
            >
              View transaction on Explorer
            </ToastAction>
          ),
        });
      } else {
        toast({
          variant: "destructive",
          title: "Could not process your claim",
          description: `${result.error}`,
        });
      }
    } catch (error) {
      toast({
        variant: "destructive",
        description:
          error instanceof Error
            ? error.message
            : "An error occurred. Please try again.",
      });
    } finally {
      setIsLoading(false);
      if (captchaRef.current) {
        captchaRef.current.resetCaptcha();
      }
    }
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(executeCaptcha)} className="space-y-4">
        <div className="grid grid-cols-5 gap-4">
          <FormField
            control={form.control}
            name="token"
            render={({ field }) => (
              <FormItem className="col-span-2">
                <Select
                  onValueChange={field.onChange}
                  defaultValue={field.value}
                >
                  <FormControl>
                    <SelectTrigger className="bg-gray-700 border-gray-600 text-white rounded-md w-full">
                      <SelectValue placeholder="Select token" />
                    </SelectTrigger>
                  </FormControl>
                  <SelectContent className="bg-gray-700 border-gray-600">
                    <SelectItem value="ETH" className="text-white">
                      0.03 ETH
                    </SelectItem>
                    <SelectItem value="PIRONTOKEN" className="text-white">
                      10 PIRON TOKEN
                    </SelectItem>
                  </SelectContent>
                </Select>
                <FormMessage />
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="address"
            render={({ field }) => (
              <FormItem className="col-span-3">
                <FormControl>
                  <Input
                    className="bg-gray-700 border-gray-600 text-white placeholder-gray-400 rounded-md w-full"
                    placeholder="Wallet Address"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>

        <Button
          size="lg"
          disabled={isLoading}
          type="submit"
          className="w-full bg-white text-black hover:bg-gray-200 rounded-md"
        >
          {isLoading
            ? "Processing..."
            : `Send ${
                form.watch("token") === "ETH" ? "0.01 ETH" : "10 PIRON TOKEN"
              }`}
        </Button>

        <HCaptcha
          sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY as string}
          onVerify={onCaptchaVerify}
          ref={captchaRef}
          size="invisible"
        />
      </form>
    </Form>
  );
}


whew! now lets test our faucet to ensure all is working well.


our captcha in action


transaction processing




the piron(okido)token contract



faucet transactions



transaction details


Conclusion

Building a production grade faucet takes a lot of work and considerations(eg security) and in this tutorial, we have covered basically all aspects of building one. You could modify this faucet to serve whatever need you have whether as a chain, project or for personal use. If you have any issues while replicating this project, reach out to me (or drop a comment) on telegram @ernestelijah.