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: 2 additions & 1 deletion apps/dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
13 changes: 10 additions & 3 deletions apps/dashboard/src/@/constants/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 (
Expand All @@ -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>
);
Expand All @@ -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>
)}
</>
);
}

Expand Down
16 changes: 7 additions & 9 deletions apps/dashboard/src/app/api/testnet-faucet/can-claim/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
) {
Expand Down
100 changes: 86 additions & 14 deletions apps/dashboard/src/app/api/testnet-faucet/claim/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)) {
Expand All @@ -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(
Expand Down Expand Up @@ -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 },
Expand All @@ -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
Expand All @@ -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({
Expand Down
8 changes: 6 additions & 2 deletions apps/dashboard/src/components/onboarding/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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]">
Expand Down
20 changes: 17 additions & 3 deletions apps/dashboard/src/components/onboarding/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading