[Dashboard] Improve faucet claim (#5226)
## Problem solved
Short description of the bug fixed or feature added
<!-- start pr-codex -->
---
## PR-Codex overview
This PR focuses on enhancing the functionality of the `Faucet` feature in the dashboard application. It introduces new environment variables, updates modal components to allow for state management, and improves API endpoints for better user authentication and request handling.
### Detailed summary
- Added `REDIS_URL` and updated `TURNSTILE_SECRET_KEY` in `.env.example`.
- Updated `turbo.json` to include new environment variables.
- Modified `OnboardingModal` to accept `onOpenChange` prop for state management.
- Enhanced API routes to check for email verification before allowing faucet claims.
- Improved error handling for wallet connections and request limits.
- Updated `FaucetButton` to show onboarding modal for email verification.
- Added caching logic for user requests to prevent abuse.
> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
<!-- end pr-codex -->
This commit is contained in:
@@ -91,4 +91,5 @@ API_SERVER_SECRET=""
|
||||
|
||||
# Used for the Faucet page (/<chain_id>)
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
REDIS_URL=""
|
||||
@@ -8,9 +8,6 @@ export const IPFS_GATEWAY_URL =
|
||||
(process.env.NEXT_PUBLIC_IPFS_GATEWAY_URL as string) ||
|
||||
"https://{clientId}.ipfscdn.io/ipfs/{cid}/{path}";
|
||||
|
||||
export const THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
|
||||
|
||||
export const isProd =
|
||||
(process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
|
||||
"production";
|
||||
@@ -24,5 +21,15 @@ export const DASHBOARD_STORAGE_URL =
|
||||
export const API_SERVER_URL =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
|
||||
|
||||
/**
|
||||
* Faucet stuff
|
||||
*/
|
||||
// Cloudflare Turnstile Site key
|
||||
export const TURNSTILE_SITE_KEY =
|
||||
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "";
|
||||
export const THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
|
||||
export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
|
||||
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
|
||||
// Comma-separated list of chain IDs to disable faucet for.
|
||||
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
|
||||
|
||||
+53
-6
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
|
||||
@@ -9,11 +10,15 @@ import {
|
||||
} from "@/constants/env";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
|
||||
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
|
||||
import { Onboarding } from "components/onboarding";
|
||||
import { mapV4ChainToV5Chain } from "contexts/map-chains";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { toUnits } from "thirdweb";
|
||||
@@ -113,6 +118,10 @@ export function FaucetButton({
|
||||
},
|
||||
});
|
||||
|
||||
const accountQuery = useAccount();
|
||||
const userQuery = useLoggedInUser();
|
||||
const [showOnboarding, setShowOnBoarding] = useState(false);
|
||||
|
||||
const canClaimFaucetQuery = useQuery({
|
||||
queryKey: ["testnet-faucet-can-claim", chainId],
|
||||
queryFn: async () => {
|
||||
@@ -133,6 +142,32 @@ export function FaucetButton({
|
||||
|
||||
const form = useForm<z.infer<typeof claimFaucetSchema>>();
|
||||
|
||||
// Force users to log in to claim the faucet
|
||||
if (!address || !userQuery.user) {
|
||||
return (
|
||||
<CustomConnectWallet
|
||||
loginRequired={true}
|
||||
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (accountQuery.isPending) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2">
|
||||
Loading account <Spinner className="size-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accountQuery.data) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2" disabled>
|
||||
Failed to load account
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// loading state
|
||||
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
|
||||
return (
|
||||
@@ -145,7 +180,7 @@ export function FaucetButton({
|
||||
// faucet is empty
|
||||
if (isFaucetEmpty) {
|
||||
return (
|
||||
<Button variant="outline" disabled className="!opacity-100 w-full ">
|
||||
<Button variant="outline" disabled className="!opacity-100 w-full">
|
||||
Faucet is empty right now
|
||||
</Button>
|
||||
);
|
||||
@@ -168,12 +203,24 @@ export function FaucetButton({
|
||||
);
|
||||
}
|
||||
|
||||
if (!address) {
|
||||
// Email verification is required to claim from the faucet
|
||||
if (accountQuery.data.status === "noCustomer") {
|
||||
return (
|
||||
<CustomConnectWallet
|
||||
loginRequired={false}
|
||||
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
|
||||
/>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!opacity-100 w-full"
|
||||
onClick={() => setShowOnBoarding(true)}
|
||||
>
|
||||
Verify your Email
|
||||
</Button>
|
||||
{/* We will show the modal only if the user click on it, because this is a public page */}
|
||||
{showOnboarding && (
|
||||
<ChakraProviderSetup>
|
||||
<Onboarding onOpenChange={setShowOnBoarding} />
|
||||
</ChakraProviderSetup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import {
|
||||
DISABLE_FAUCET_CHAIN_IDS,
|
||||
THIRDWEB_ACCESS_TOKEN,
|
||||
THIRDWEB_ENGINE_FAUCET_WALLET,
|
||||
THIRDWEB_ENGINE_URL,
|
||||
} from "@/constants/env";
|
||||
import { ipAddress } from "@vercel/functions";
|
||||
import { cacheTtl } from "lib/redis";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { CanClaimResponseType } from "./CanClaimResponseType";
|
||||
|
||||
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
|
||||
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
|
||||
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
|
||||
|
||||
// Comma-separated list of chain IDs to disable faucet for.
|
||||
const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
|
||||
|
||||
// Note: This handler cannot use "edge" runtime because of Redis usage.
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
@@ -50,7 +48,7 @@ export const GET = async (req: NextRequest) => {
|
||||
|
||||
if (
|
||||
!THIRDWEB_ENGINE_URL ||
|
||||
!NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET ||
|
||||
!THIRDWEB_ENGINE_FAUCET_WALLET ||
|
||||
!THIRDWEB_ACCESS_TOKEN ||
|
||||
isFaucetDisabled
|
||||
) {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import {
|
||||
API_SERVER_URL,
|
||||
THIRDWEB_ACCESS_TOKEN,
|
||||
THIRDWEB_ENGINE_FAUCET_WALLET,
|
||||
THIRDWEB_ENGINE_URL,
|
||||
} from "@/constants/env";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ipAddress } from "@vercel/functions";
|
||||
import { startOfToday } from "date-fns";
|
||||
import { cacheGet, cacheSet } from "lib/redis";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { ZERO_ADDRESS, getAddress } from "thirdweb";
|
||||
import { getFaucetClaimAmount } from "./claim-amount";
|
||||
|
||||
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
|
||||
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
|
||||
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
|
||||
|
||||
interface RequestTestnetFundsPayload {
|
||||
chainId: number;
|
||||
toAddress: string;
|
||||
@@ -18,8 +21,68 @@ interface RequestTestnetFundsPayload {
|
||||
turnstileToken: string;
|
||||
}
|
||||
|
||||
// Note: This handler cannot use "edge" runtime because of Redis usage.
|
||||
/**
|
||||
* How this endpoint works:
|
||||
* Only users who have signed in to thirdweb.com with an account that is email-verified can claim.
|
||||
* Those who satisfy the requirement above can claim once per 24 hours for every account
|
||||
*
|
||||
* Note: This handler cannot use "edge" runtime because of Redis usage.
|
||||
*/
|
||||
export const POST = async (req: NextRequest) => {
|
||||
// Make sure user's connected to the site
|
||||
const activeAccount = req.cookies.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
|
||||
if (!activeAccount) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "No wallet detected",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const authCookieName = COOKIE_PREFIX_TOKEN + getAddress(activeAccount);
|
||||
|
||||
const authCookie = req.cookies.get(authCookieName);
|
||||
|
||||
if (!authCookie) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "No wallet connected",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Make sure the connected wallet has a thirdweb account
|
||||
const accountRes = await fetch(`${API_SERVER_URL}/v1/account/me`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authCookie.value}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (accountRes.status !== 200) {
|
||||
// Account not found on this connected address
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "thirdweb account not found",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const account: { data: Account } = await accountRes.json();
|
||||
|
||||
// Make sure the logged-in account has verified its email
|
||||
if (account.data.status === "noCustomer") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Account owner hasn't verified email",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const requestBody = (await req.json()) as RequestTestnetFundsPayload;
|
||||
const { chainId, toAddress, turnstileToken } = requestBody;
|
||||
if (Number.isNaN(chainId)) {
|
||||
@@ -28,7 +91,7 @@ export const POST = async (req: NextRequest) => {
|
||||
|
||||
if (
|
||||
!THIRDWEB_ENGINE_URL ||
|
||||
!NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET ||
|
||||
!THIRDWEB_ENGINE_FAUCET_WALLET ||
|
||||
!THIRDWEB_ACCESS_TOKEN
|
||||
) {
|
||||
return NextResponse.json(
|
||||
@@ -89,15 +152,23 @@ export const POST = async (req: NextRequest) => {
|
||||
|
||||
const ipCacheKey = `testnet-faucet:${chainId}:${ip}`;
|
||||
const addressCacheKey = `testnet-faucet:${chainId}:${toAddress}`;
|
||||
const accountCacheKey = `testnet-faucet:${chainId}:${account.data.id}`;
|
||||
|
||||
// Assert 1 request per IP/chain every 24 hours.
|
||||
// get the cached value
|
||||
const [ipCacheValue, addressCache] = await Promise.all([
|
||||
cacheGet(ipCacheKey),
|
||||
cacheGet(addressCacheKey),
|
||||
]);
|
||||
const [ipCacheValue, accountCacheValue, addressCacheValue] =
|
||||
await Promise.all([
|
||||
cacheGet(ipCacheKey),
|
||||
cacheGet(accountCacheKey),
|
||||
cacheGet(addressCacheKey),
|
||||
]);
|
||||
|
||||
// if we have a cached value, return an error
|
||||
if (ipCacheValue !== null || addressCache !== null) {
|
||||
if (
|
||||
ipCacheValue !== null ||
|
||||
accountCacheValue !== null ||
|
||||
addressCacheValue !== null
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Already requested funds on this chain in the past 24 hours." },
|
||||
{ status: 429 },
|
||||
@@ -117,6 +188,7 @@ export const POST = async (req: NextRequest) => {
|
||||
// Store the claim request for 24 hours.
|
||||
await Promise.all([
|
||||
cacheSet(ipCacheKey, "claimed", 24 * 60 * 60),
|
||||
cacheSet(accountCacheKey, "claimed", 24 * 60 * 60),
|
||||
cacheSet(addressCacheKey, "claimed", 24 * 60 * 60),
|
||||
]);
|
||||
// then actually transfer the funds
|
||||
@@ -126,7 +198,7 @@ export const POST = async (req: NextRequest) => {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-idempotency-key": idempotencyKey,
|
||||
"x-backend-wallet-address": NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET,
|
||||
"x-backend-wallet-address": THIRDWEB_ENGINE_FAUCET_WALLET,
|
||||
Authorization: `Bearer ${THIRDWEB_ACCESS_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconLogo } from "components/logo";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { ComponentWithChildren } from "types/component-with-children";
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean;
|
||||
wide?: boolean;
|
||||
// Pass this props to make the modal closable (it will enable backdrop + the "x" icon)
|
||||
onOpenChange?: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const OnboardingModal: ComponentWithChildren<OnboardingModalProps> = ({
|
||||
children,
|
||||
isOpen,
|
||||
wide,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
className={cn("z-[10001] max-h-[90vh] overflow-auto", {
|
||||
"!max-w-[768px]": wide,
|
||||
})}
|
||||
dialogCloseClassName="hidden"
|
||||
dialogCloseClassName={onOpenChange !== undefined ? "" : "hidden"}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-[40px]">
|
||||
|
||||
@@ -5,7 +5,14 @@ import {
|
||||
useAccount,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
Suspense,
|
||||
lazy,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useActiveWallet } from "thirdweb/react";
|
||||
import { useTrack } from "../../hooks/analytics/useTrack";
|
||||
import { LazyOnboardingBilling } from "./LazyOnboardingBilling";
|
||||
@@ -42,7 +49,10 @@ type OnboardingState =
|
||||
| "skipped"
|
||||
| undefined;
|
||||
|
||||
export const Onboarding: React.FC = () => {
|
||||
export const Onboarding: React.FC<{
|
||||
// Pass this props to make the modal closable (it will enable backdrop + the "x" icon)
|
||||
onOpenChange?: Dispatch<SetStateAction<boolean>>;
|
||||
}> = ({ onOpenChange }) => {
|
||||
const meQuery = useAccount();
|
||||
|
||||
const { isLoggedIn } = useLoggedInUser();
|
||||
@@ -202,7 +212,11 @@ export const Onboarding: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingModal isOpen={!!state} wide={state === "plan"}>
|
||||
<OnboardingModal
|
||||
isOpen={!!state}
|
||||
wide={state === "plan"}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{state === "onboarding" && (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<OnboardingGeneral
|
||||
|
||||
+3
-1
@@ -45,7 +45,9 @@
|
||||
"ANALYTICS_SERVICE_API_KEY",
|
||||
"DISABLE_FAUCET_CHAIN_IDS",
|
||||
"API_SERVER_SECRET",
|
||||
"TURNSTILE_SECRET_KEY"
|
||||
"TURNSTILE_SECRET_KEY",
|
||||
"NEXT_PUBLIC_TURNSTILE_SITE_KEY",
|
||||
"NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET"
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**"],
|
||||
"dependsOn": ["^build"]
|
||||
|
||||
Reference in New Issue
Block a user