From 43b7c27b34c70ac3486a61f38743085932ca8ba7 Mon Sep 17 00:00:00 2001 From: kien-ngo Date: Tue, 5 Nov 2024 20:17:54 +0000 Subject: [PATCH] [Dashboard] Improve faucet claim (#5226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem solved Short description of the bug fixed or feature added --- ## 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}` --- apps/dashboard/.env.example | 3 +- apps/dashboard/src/@/constants/env.ts | 13 ++- .../components/client/FaucetButton.tsx | 59 +++++++++-- .../app/api/testnet-faucet/can-claim/route.ts | 16 ++- .../src/app/api/testnet-faucet/claim/route.ts | 100 +++++++++++++++--- .../src/components/onboarding/Modal.tsx | 8 +- .../src/components/onboarding/index.tsx | 20 +++- turbo.json | 4 +- 8 files changed, 184 insertions(+), 39 deletions(-) diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 511a6670423..15c83994d88 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -91,4 +91,5 @@ API_SERVER_SECRET="" # Used for the Faucet page (/) NEXT_PUBLIC_TURNSTILE_SITE_KEY="" -TURNSTILE_SECRET_KEY="" \ No newline at end of file +TURNSTILE_SECRET_KEY="" +REDIS_URL="" \ No newline at end of file diff --git a/apps/dashboard/src/@/constants/env.ts b/apps/dashboard/src/@/constants/env.ts index 84b5d20844d..a174c2dc2b1 100644 --- a/apps/dashboard/src/@/constants/env.ts +++ b/apps/dashboard/src/@/constants/env.ts @@ -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; diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx index 029117e962b..65e5d30e92f 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx @@ -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>(); + // Force users to log in to claim the faucet + if (!address || !userQuery.user) { + return ( + + ); + } + + if (accountQuery.isPending) { + return ( + + ); + } + + if (!accountQuery.data) { + return ( + + ); + } + // loading state if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) { return ( @@ -145,7 +180,7 @@ export function FaucetButton({ // faucet is empty if (isFaucetEmpty) { return ( - ); @@ -168,12 +203,24 @@ export function FaucetButton({ ); } - if (!address) { + // Email verification is required to claim from the faucet + if (accountQuery.data.status === "noCustomer") { return ( - + <> + + {/* We will show the modal only if the user click on it, because this is a public page */} + {showOnboarding && ( + + + + )} + ); } diff --git a/apps/dashboard/src/app/api/testnet-faucet/can-claim/route.ts b/apps/dashboard/src/app/api/testnet-faucet/can-claim/route.ts index a7515fd78be..503779977b0 100644 --- a/apps/dashboard/src/app/api/testnet-faucet/can-claim/route.ts +++ b/apps/dashboard/src/app/api/testnet-faucet/can-claim/route.ts @@ -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 ) { diff --git a/apps/dashboard/src/app/api/testnet-faucet/claim/route.ts b/apps/dashboard/src/app/api/testnet-faucet/claim/route.ts index d22e2c52bc9..168fc2a20c6 100644 --- a/apps/dashboard/src/app/api/testnet-faucet/claim/route.ts +++ b/apps/dashboard/src/app/api/testnet-faucet/claim/route.ts @@ -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({ diff --git a/apps/dashboard/src/components/onboarding/Modal.tsx b/apps/dashboard/src/components/onboarding/Modal.tsx index ce3a61e5f81..d444d2c3672 100644 --- a/apps/dashboard/src/components/onboarding/Modal.tsx +++ b/apps/dashboard/src/components/onboarding/Modal.tsx @@ -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>; } export const OnboardingModal: ComponentWithChildren = ({ children, isOpen, wide, + onOpenChange, }) => { return ( - +
diff --git a/apps/dashboard/src/components/onboarding/index.tsx b/apps/dashboard/src/components/onboarding/index.tsx index 8cd7da79538..c347480f32d 100644 --- a/apps/dashboard/src/components/onboarding/index.tsx +++ b/apps/dashboard/src/components/onboarding/index.tsx @@ -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>; +}> = ({ onOpenChange }) => { const meQuery = useAccount(); const { isLoggedIn } = useLoggedInUser(); @@ -202,7 +212,11 @@ export const Onboarding: React.FC = () => { } return ( - + {state === "onboarding" && ( }>