[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:
kien-ngo
2024-11-05 20:17:54 +00:00
parent 6469fd5236
commit 43b7c27b34
8 changed files with 184 additions and 39 deletions
+2 -1
View File
@@ -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=""
+10 -3
View File
@@ -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;
@@ -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
View File
@@ -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"]