Skip to content

Commit 58b0542

Browse files
committed
update
1 parent f40d247 commit 58b0542

File tree

7 files changed

+203
-56
lines changed

7 files changed

+203
-56
lines changed

apps/dashboard/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,5 @@ API_SERVER_SECRET=""
9191

9292
# Used for the Faucet page (/<chain_id>)
9393
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
94-
TURNSTILE_SECRET_KEY=""
94+
TURNSTILE_SECRET_KEY=""
95+
REDIS_URL=""

apps/dashboard/src/@/constants/env.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ export const IPFS_GATEWAY_URL =
88
(process.env.NEXT_PUBLIC_IPFS_GATEWAY_URL as string) ||
99
"https://{clientId}.ipfscdn.io/ipfs/{cid}/{path}";
1010

11-
export const THIRDWEB_ENGINE_FAUCET_WALLET =
12-
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
13-
1411
export const isProd =
1512
(process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
1613
"production";
@@ -24,5 +21,15 @@ export const DASHBOARD_STORAGE_URL =
2421
export const API_SERVER_URL =
2522
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
2623

24+
/**
25+
* Faucet stuff
26+
*/
27+
// Cloudflare Turnstile Site key
2728
export const TURNSTILE_SITE_KEY =
2829
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "";
30+
export const THIRDWEB_ENGINE_FAUCET_WALLET =
31+
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
32+
export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
33+
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
34+
// Comma-separated list of chain IDs to disable faucet for.
35+
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;

apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
34
import { Spinner } from "@/components/ui/Spinner/Spinner";
45
import { Button } from "@/components/ui/button";
56
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
@@ -9,11 +10,15 @@ import {
910
} from "@/constants/env";
1011
import { useThirdwebClient } from "@/constants/thirdweb.client";
1112
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
13+
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
14+
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
1215
import { Turnstile } from "@marsidev/react-turnstile";
1316
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1417
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
18+
import { Onboarding } from "components/onboarding";
1519
import { mapV4ChainToV5Chain } from "contexts/map-chains";
1620
import { useTrack } from "hooks/analytics/useTrack";
21+
import { useState } from "react";
1722
import { useForm } from "react-hook-form";
1823
import { toast } from "sonner";
1924
import { toUnits } from "thirdweb";
@@ -113,18 +118,28 @@ export function FaucetButton({
113118
},
114119
});
115120

121+
const accountQuery = useAccount();
122+
const userQuery = useLoggedInUser();
123+
const [showOnboarding, setShowOnBoarding] = useState(false);
124+
116125
const canClaimFaucetQuery = useQuery({
117126
queryKey: ["testnet-faucet-can-claim", chainId],
118127
queryFn: async () => {
128+
if (!accountQuery.data) {
129+
throw new Error("Account not connected");
130+
}
119131
const response = await fetch(
120-
`/api/testnet-faucet/can-claim?chainId=${chainId}`,
132+
`/api/testnet-faucet/can-claim?chainId=${chainId}&accountId=${accountQuery.data.id}`,
121133
);
122134
if (!response.ok) {
123135
throw new Error("Failed to get claim status");
124136
}
125137
const data = (await response.json()) as CanClaimResponseType;
126138
return data;
127139
},
140+
// Only run this query if user has logged in
141+
// since we rely on the account id to check for the claim eligibility
142+
enabled: !!accountQuery.data,
128143
});
129144

130145
const isFaucetEmpty =
@@ -133,6 +148,32 @@ export function FaucetButton({
133148

134149
const form = useForm<z.infer<typeof claimFaucetSchema>>();
135150

151+
// Force users to log in to claim the faucet
152+
if (!address || !userQuery.user) {
153+
return (
154+
<CustomConnectWallet
155+
loginRequired={true}
156+
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
157+
/>
158+
);
159+
}
160+
161+
if (accountQuery.isPending) {
162+
return (
163+
<Button variant="outline" className="w-full gap-2">
164+
Loading account <Spinner className="size-3" />
165+
</Button>
166+
);
167+
}
168+
169+
if (!accountQuery.data) {
170+
return (
171+
<Button variant="outline" className="w-full gap-2" disabled>
172+
Failed to load account
173+
</Button>
174+
);
175+
}
176+
136177
// loading state
137178
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
138179
return (
@@ -145,7 +186,7 @@ export function FaucetButton({
145186
// faucet is empty
146187
if (isFaucetEmpty) {
147188
return (
148-
<Button variant="outline" disabled className="!opacity-100 w-full ">
189+
<Button variant="outline" disabled className="!opacity-100 w-full">
149190
Faucet is empty right now
150191
</Button>
151192
);
@@ -168,12 +209,24 @@ export function FaucetButton({
168209
);
169210
}
170211

171-
if (!address) {
212+
// Email verification is required to claim from the faucet
213+
if (accountQuery.data.status === "noCustomer") {
172214
return (
173-
<CustomConnectWallet
174-
loginRequired={false}
175-
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
176-
/>
215+
<>
216+
<Button
217+
variant="outline"
218+
className="!opacity-100 w-full"
219+
onClick={() => setShowOnBoarding(true)}
220+
>
221+
Verify your email
222+
</Button>
223+
{/* We will show the modal only if the user click on it, because this is a public page */}
224+
{showOnboarding && (
225+
<ChakraProviderSetup>
226+
<Onboarding onOpenChange={setShowOnBoarding} />
227+
</ChakraProviderSetup>
228+
)}
229+
</>
177230
);
178231
}
179232

apps/dashboard/src/app/api/testnet-faucet/can-claim/route.ts

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1+
import {
2+
DISABLE_FAUCET_CHAIN_IDS,
3+
THIRDWEB_ACCESS_TOKEN,
4+
THIRDWEB_ENGINE_FAUCET_WALLET,
5+
THIRDWEB_ENGINE_URL,
6+
} from "@/constants/env";
17
import { cacheTtl } from "lib/redis";
28
import { NextResponse } from "next/server";
39
import type { NextRequest } from "next/server";
410
import type { CanClaimResponseType } from "./CanClaimResponseType";
511

6-
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
7-
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
8-
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
9-
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
10-
11-
// Comma-separated list of chain IDs to disable faucet for.
12-
const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
13-
1412
// Note: This handler cannot use "edge" runtime because of Redis usage.
1513
export const GET = async (req: NextRequest) => {
1614
const searchParams = req.nextUrl.searchParams;
@@ -36,6 +34,19 @@ export const GET = async (req: NextRequest) => {
3634
);
3735
}
3836

37+
const accountId = searchParams.get("accountId");
38+
39+
if (!accountId) {
40+
return NextResponse.json(
41+
{
42+
error: "Account not connected",
43+
},
44+
{ status: 400 },
45+
);
46+
}
47+
// No need to verify if the accountId is valid. That is done in /api/testnet-faucet/claim
48+
// We only need the accountId here for checking the claim countdown (using Redis)
49+
3950
// Check if faucet is disabled for this chain.
4051
let isFaucetDisabled = false;
4152
if (DISABLE_FAUCET_CHAIN_IDS) {
@@ -49,7 +60,7 @@ export const GET = async (req: NextRequest) => {
4960

5061
if (
5162
!THIRDWEB_ENGINE_URL ||
52-
!NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET ||
63+
!THIRDWEB_ENGINE_FAUCET_WALLET ||
5364
!THIRDWEB_ACCESS_TOKEN ||
5465
isFaucetDisabled
5566
) {
@@ -60,20 +71,7 @@ export const GET = async (req: NextRequest) => {
6071
return NextResponse.json(res);
6172
}
6273

63-
// CF header, fallback to req.ip, then X-Forwarded-For
64-
const ipAddress =
65-
req.headers.get("CF-Connecting-IP") ||
66-
req.ip ||
67-
req.headers.get("X-Forwarded-For");
68-
if (!ipAddress) {
69-
return NextResponse.json(
70-
{
71-
error: "Could not validate eligibility.",
72-
},
73-
{ status: 400 },
74-
);
75-
}
76-
const cacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
74+
const cacheKey = `testnet-faucet:${chainId}:${accountId}`;
7775
const ttlSeconds = await cacheTtl(cacheKey);
7876

7977
const res: CanClaimResponseType = {

apps/dashboard/src/app/api/testnet-faucet/claim/route.ts

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
2+
import {
3+
API_SERVER_URL,
4+
THIRDWEB_ACCESS_TOKEN,
5+
THIRDWEB_ENGINE_FAUCET_WALLET,
6+
THIRDWEB_ENGINE_URL,
7+
} from "@/constants/env";
8+
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
19
import { startOfToday } from "date-fns";
210
import { cacheGet, cacheSet } from "lib/redis";
311
import { type NextRequest, NextResponse } from "next/server";
4-
import { ZERO_ADDRESS } from "thirdweb";
12+
import { ZERO_ADDRESS, getAddress } from "thirdweb";
513
import { getFaucetClaimAmount } from "./claim-amount";
614

7-
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
8-
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
9-
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
10-
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
11-
1215
interface RequestTestnetFundsPayload {
1316
chainId: number;
1417
toAddress: string;
@@ -17,8 +20,68 @@ interface RequestTestnetFundsPayload {
1720
turnstileToken: string;
1821
}
1922

20-
// Note: This handler cannot use "edge" runtime because of Redis usage.
23+
/**
24+
* How this endpoint works:
25+
* Only users who have signed in to thirdweb.com with an account that is email-verified can claim.
26+
* Those who satisfy the requirement above can claim once per 24 hours for every account
27+
*
28+
* Note: This handler cannot use "edge" runtime because of Redis usage.
29+
*/
2130
export const POST = async (req: NextRequest) => {
31+
// Make sure user's connected to the site
32+
const activeAccount = req.cookies.get(COOKIE_ACTIVE_ACCOUNT)?.value;
33+
34+
if (!activeAccount) {
35+
return NextResponse.json(
36+
{
37+
error: "No wallet detected",
38+
},
39+
{ status: 400 },
40+
);
41+
}
42+
const authCookieName = COOKIE_PREFIX_TOKEN + getAddress(activeAccount);
43+
44+
const authCookie = req.cookies.get(authCookieName);
45+
46+
if (!authCookie) {
47+
return NextResponse.json(
48+
{
49+
error: "No wallet connected",
50+
},
51+
{ status: 400 },
52+
);
53+
}
54+
55+
// Make sure the connected wallet has a thirdweb account
56+
const accountRes = await fetch(`${API_SERVER_URL}/v1/account/me`, {
57+
method: "GET",
58+
headers: {
59+
Authorization: `Bearer ${authCookie.value}`,
60+
},
61+
});
62+
63+
if (accountRes.status !== 200) {
64+
// Account not found on this connected address
65+
return NextResponse.json(
66+
{
67+
error: "thirdweb account not found",
68+
},
69+
{ status: 400 },
70+
);
71+
}
72+
73+
const account: Account = await accountRes.json();
74+
75+
// Make sure the logged-in account has verified its email
76+
if (account.status === "noCustomer") {
77+
return NextResponse.json(
78+
{
79+
error: "Account owner hasn't verified email",
80+
},
81+
{ status: 400 },
82+
);
83+
}
84+
2285
const requestBody = (await req.json()) as RequestTestnetFundsPayload;
2386
const { chainId, toAddress, turnstileToken } = requestBody;
2487
if (Number.isNaN(chainId)) {
@@ -27,7 +90,7 @@ export const POST = async (req: NextRequest) => {
2790

2891
if (
2992
!THIRDWEB_ENGINE_URL ||
30-
!NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET ||
93+
!THIRDWEB_ENGINE_FAUCET_WALLET ||
3194
!THIRDWEB_ACCESS_TOKEN
3295
) {
3396
return NextResponse.json(
@@ -86,19 +149,24 @@ export const POST = async (req: NextRequest) => {
86149
);
87150
}
88151

89-
const ipCacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
90152
const addressCacheKey = `testnet-faucet:${chainId}:${toAddress}`;
153+
const accountCacheKey = `testnet-faucet:${chainId}:${account.id}`;
91154

92-
// Assert 1 request per IP/chain every 24 hours.
155+
// Assert 1 request per userId every 24 hours.
93156
// get the cached value
94-
const [ipCacheValue, addressCache] = await Promise.all([
95-
cacheGet(ipCacheKey),
157+
const [accountCacheValue, addressCacheValue] = await Promise.all([
158+
cacheGet(accountCacheKey),
96159
cacheGet(addressCacheKey),
97160
]);
161+
98162
// if we have a cached value, return an error
99-
if (ipCacheValue !== null || addressCache !== null) {
163+
if (accountCacheValue !== null || addressCacheValue !== null) {
100164
return NextResponse.json(
101-
{ error: "Already requested funds on this chain in the past 24 hours." },
165+
{
166+
error: "Already requested funds on this chain in the past 24 hours.",
167+
accountCacheValue,
168+
addressCacheValue,
169+
},
102170
{ status: 429 },
103171
);
104172
}
@@ -109,13 +177,13 @@ export const POST = async (req: NextRequest) => {
109177
todayLocal.getTime() - todayLocal.getTimezoneOffset() * 60000,
110178
);
111179
const todayUTCSeconds = Math.floor(todayUTC.getTime() / 1000);
112-
const idempotencyKey = `${ipCacheKey}:${todayUTCSeconds}`;
180+
const idempotencyKey = `${addressCacheKey}:${todayUTCSeconds}`;
113181
const amountToClaim = getFaucetClaimAmount(chainId).toString();
114182

115183
try {
116184
// Store the claim request for 24 hours.
117185
await Promise.all([
118-
cacheSet(ipCacheKey, "claimed", 24 * 60 * 60),
186+
cacheSet(accountCacheKey, "claimed", 24 * 60 * 60),
119187
cacheSet(addressCacheKey, "claimed", 24 * 60 * 60),
120188
]);
121189
// then actually transfer the funds
@@ -125,7 +193,7 @@ export const POST = async (req: NextRequest) => {
125193
headers: {
126194
"Content-Type": "application/json",
127195
"x-idempotency-key": idempotencyKey,
128-
"x-backend-wallet-address": NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET,
196+
"x-backend-wallet-address": THIRDWEB_ENGINE_FAUCET_WALLET,
129197
Authorization: `Bearer ${THIRDWEB_ACCESS_TOKEN}`,
130198
},
131199
body: JSON.stringify({

0 commit comments

Comments
 (0)