├── .nvmrc ├── .prettierignore ├── .eslintrc.json ├── components ├── ClaimButton │ ├── index.tsx │ ├── _GoogleReCaptchaClaimButton.tsx │ ├── ClaimButton.tsx │ └── _BaseClaimButton.tsx ├── Content.tsx ├── Layout.tsx ├── Alert.tsx ├── Item.tsx ├── RoundedBox.tsx ├── OpenSourceMemo.tsx ├── CaptchaProvider.tsx ├── Footer.tsx └── Header.tsx ├── screenshot.png ├── .yarnrc.yml ├── .prettierrc ├── interfaces ├── Captcha.ts ├── Nonce.ts ├── TransactionHistory.ts ├── Blockchain.ts └── Response.ts ├── errors ├── InvalidCaptcha.ts ├── NonEmptyWalletError.ts ├── NonceExpiredError.ts ├── SignatureMismatchError.ts ├── InsufficientFundsError.ts ├── IpLimitExceeded.ts └── WalletAlreadyFunded.ts ├── next.config.js ├── window.d.ts ├── consts ├── wallets.ts └── env.ts ├── next-env.d.ts ├── public └── eth.svg ├── utils ├── ethAddressUtils.ts ├── textMessage.ts ├── bootstrapCaptcha.ts ├── bootstrapEthereum.ts └── bootstrapTransactionHistory.ts ├── .editorconfig ├── hooks ├── hasMetamask.ts └── useWalletClassification.ts ├── _scripts └── createWallet.ts ├── services ├── HttpClient.ts ├── GoogleReCaptcha.ts ├── WalletClassification.ts ├── TimestampNonce.ts ├── IpTransactionHistory.ts ├── EtherscanTransactionHistory.ts ├── RedisTransactionHistory.ts └── Ethereum.ts ├── pages ├── _document.tsx ├── api │ ├── nonce.ts │ └── claim.ts ├── _app.tsx └── index.tsx ├── tsconfig.json ├── .env ├── .gitignore ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /components/ClaimButton/index.tsx: -------------------------------------------------------------------------------- 1 | export { ClaimButton } from "./ClaimButton" 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateuszsokola/eth-faucet/main/screenshot.png -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.2.2.cjs 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 120, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /interfaces/Captcha.ts: -------------------------------------------------------------------------------- 1 | export interface Captcha { 2 | verifyCaptcha(token: string): Promise 3 | } 4 | -------------------------------------------------------------------------------- /components/Content.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material" 2 | 3 | export const Content = styled("div")(() => ({ 4 | flex: 1 5 | })) 6 | -------------------------------------------------------------------------------- /errors/InvalidCaptcha.ts: -------------------------------------------------------------------------------- 1 | export class InvalidCaptcha extends Error { 2 | code = 403 3 | message = "Provided captcha was invalid." 4 | } 5 | -------------------------------------------------------------------------------- /errors/NonEmptyWalletError.ts: -------------------------------------------------------------------------------- 1 | export class NonEmptyWalletError extends Error { 2 | code = 403 3 | message = "Your wallet has enough Görli ETH." 4 | } 5 | -------------------------------------------------------------------------------- /errors/NonceExpiredError.ts: -------------------------------------------------------------------------------- 1 | export class NonceExpiredError extends Error { 2 | code = 401 3 | message = "Your nonce has expired. Try again." 4 | } 5 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /errors/SignatureMismatchError.ts: -------------------------------------------------------------------------------- 1 | export class SignatureMismatchError extends Error { 2 | code = 401 3 | message = "The message has not been signed by your wallet" 4 | } 5 | -------------------------------------------------------------------------------- /errors/InsufficientFundsError.ts: -------------------------------------------------------------------------------- 1 | export class InsufficientFundsError extends Error { 2 | code = 500 3 | message = "Our wallet run out of Görli ETH. Try again later." 4 | } 5 | -------------------------------------------------------------------------------- /window.d.ts: -------------------------------------------------------------------------------- 1 | import { MetaMaskInpageProvider } from "@metamask/providers" 2 | 3 | declare global { 4 | interface Window { 5 | ethereum: MetaMaskInpageProvider 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /consts/wallets.ts: -------------------------------------------------------------------------------- 1 | import { normalizeAddress } from "../utils/ethAddressUtils" 2 | 3 | const rawPrivilegedWallets: string[] = [] 4 | 5 | export const privilegedWallets = rawPrivilegedWallets.map(normalizeAddress) 6 | -------------------------------------------------------------------------------- /interfaces/Nonce.ts: -------------------------------------------------------------------------------- 1 | export interface NonceResponseBody { 2 | nonce: string 3 | } 4 | 5 | export interface Nonce { 6 | generate: () => Promise 7 | verify: (nonce: string) => Promise 8 | } 9 | -------------------------------------------------------------------------------- /errors/IpLimitExceeded.ts: -------------------------------------------------------------------------------- 1 | export class IpLimitExceeded extends Error { 2 | code = 403 3 | message = "Your IP address has received Görli ETH from us already. You need to wait 24 hours to claim tokens again." 4 | } 5 | -------------------------------------------------------------------------------- /errors/WalletAlreadyFunded.ts: -------------------------------------------------------------------------------- 1 | export class WalletAlreadyFunded extends Error { 2 | code = 403 3 | message = "Your wallet has received Görli ETH from us already. You need to wait 24 hours to claim tokens again." 4 | } 5 | -------------------------------------------------------------------------------- /interfaces/TransactionHistory.ts: -------------------------------------------------------------------------------- 1 | export interface TransactionHistory { 2 | hasReceivedTokens: (address: string, minLayover?: number) => Promise 3 | recordTransaction: (address: string) => Promise 4 | } 5 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material" 2 | 3 | export const Layout = styled("div")(() => ({ 4 | background: "rgb(242, 244, 246)", 5 | display: "flex", 6 | minHeight: "100vh", 7 | flexDirection: "column" 8 | })) 9 | -------------------------------------------------------------------------------- /public/eth.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/ethAddressUtils.ts: -------------------------------------------------------------------------------- 1 | export const normalizeAddress = (address: string) => { 2 | return address.toLocaleLowerCase() 3 | } 4 | 5 | export const compareAddresses = (a: string, b: string) => { 6 | return normalizeAddress(a) === normalizeAddress(b) 7 | } 8 | -------------------------------------------------------------------------------- /interfaces/Blockchain.ts: -------------------------------------------------------------------------------- 1 | export interface Blockchain { 2 | fundWallet: (address: string) => Promise 3 | verifyMessage: (address: string, message: string, signature: string) => Promise 4 | isEligible: (address: string) => Promise 5 | } 6 | -------------------------------------------------------------------------------- /interfaces/Response.ts: -------------------------------------------------------------------------------- 1 | export interface SuccessResponse { 2 | status: "ok" 3 | data?: K 4 | } 5 | 6 | export interface ErrorResponse { 7 | status: "error" 8 | message: string 9 | } 10 | 11 | export type DefaultResponse = SuccessResponse | ErrorResponse 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /components/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { Alert as MuiAlert, AlertProps, styled } from "@mui/material" 2 | 3 | const StyledAlert = styled(MuiAlert)(({ theme }) => ({ 4 | marginTop: theme.spacing(2) 5 | })) 6 | 7 | export const Alert = ({ children, severity }: AlertProps) => {children} 8 | -------------------------------------------------------------------------------- /components/Item.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material" 2 | 3 | export const Item = styled("div")(({ theme }) => ({ 4 | display: "flex", 5 | margin: `${theme.spacing(1)} 0`, 6 | ...theme.typography.body2, 7 | "& > span": { 8 | flex: 1 9 | }, 10 | "& > span:last-child": { 11 | textAlign: "right" 12 | } 13 | })) 14 | -------------------------------------------------------------------------------- /utils/textMessage.ts: -------------------------------------------------------------------------------- 1 | export const messageTemplate = (nonce: string = "") => 2 | `Please sign this message to confirm you own this wallet.\n\n\nNonce: ${nonce}` 3 | 4 | export const extractNonceFromMessage = (message: string) => { 5 | const truncate = messageTemplate() 6 | const nonce = message.replace(truncate, "") 7 | 8 | return nonce.trim() 9 | } 10 | -------------------------------------------------------------------------------- /hooks/hasMetamask.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | export const hasMetamask = () => { 4 | const [installed, setInstalled] = useState(false) 5 | 6 | useEffect(() => { 7 | if (typeof window !== "undefined" && typeof window.ethereum !== "undefined") { 8 | setInstalled(true) 9 | } 10 | }, [installed]) 11 | 12 | return installed 13 | } 14 | -------------------------------------------------------------------------------- /components/RoundedBox.tsx: -------------------------------------------------------------------------------- 1 | import { Box, styled } from "@mui/material" 2 | 3 | export const RoundedBox = styled(Box)(({ theme }) => ({ 4 | background: theme.palette.background.default, 5 | borderRadius: theme.shape.borderRadius, 6 | margin: `${theme.spacing(2)} auto`, 7 | padding: theme.spacing(2), 8 | minWidth: theme.spacing(40), 9 | maxWidth: theme.spacing(70), 10 | width: "100%", 11 | ...theme.typography.body2 12 | })) 13 | -------------------------------------------------------------------------------- /utils/bootstrapCaptcha.ts: -------------------------------------------------------------------------------- 1 | import { GoogleReCaptcha } from "../services/GoogleReCaptcha" 2 | 3 | export const bootstrapCaptcha = () => { 4 | switch (process.env.NEXT_PUBLIC_ENABLE_CAPTCHA) { 5 | case "recaptcha_v3": { 6 | const apiKey = process.env.RECAPTCHA_SECRET_KEY as string 7 | const captchaService = new GoogleReCaptcha(apiKey) 8 | 9 | return captchaService 10 | } 11 | default: { 12 | return undefined 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /_scripts/createWallet.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers" 2 | 3 | const run = async () => { 4 | const wallet = ethers.Wallet.createRandom() 5 | 6 | console.log("Your new Ethereum wallet credentials:") 7 | console.log("\n\n") 8 | console.log(`export WALLET_ADDRESS=${wallet.address}\n`) 9 | console.log(`export WALLET_MNEMONIC_PHRASE=${wallet.mnemonic.phrase}\n`) 10 | console.log(`export WALLET_PRIVATE_KEY=${wallet.privateKey}\n`) 11 | console.log("\n\n") 12 | } 13 | 14 | run() 15 | -------------------------------------------------------------------------------- /services/HttpClient.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | const httpClient = axios.create({ baseURL: "/api" }) 4 | 5 | export const retrieveNonce = async () => { 6 | const response = await httpClient.get("/nonce").then(({ data }) => data) 7 | 8 | return response.data.nonce 9 | } 10 | 11 | export const claimTokens = async (address: string, message: string, signature: string, captcha: string) => { 12 | return await httpClient.post("/claim", { address, message, signature, captcha }).then(({ data }) => data) 13 | } 14 | -------------------------------------------------------------------------------- /hooks/useWalletClassification.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react" 2 | import { privilegedWallets } from "../consts/wallets" 3 | import { WalletClassification } from "../services/WalletClassification" 4 | 5 | export const useWalletClassification = () => { 6 | const classificationService = new WalletClassification(privilegedWallets) 7 | 8 | const retriveAmount = useCallback((address: string | undefined) => { 9 | return classificationService.retrieveAmount(address) 10 | }, []) 11 | 12 | return [retriveAmount] 13 | } 14 | -------------------------------------------------------------------------------- /components/OpenSourceMemo.tsx: -------------------------------------------------------------------------------- 1 | import { Link as MuiLink } from "@mui/material" 2 | import Link from "next/link" 3 | import { RoundedBox } from "./RoundedBox" 4 | 5 | export const OpenSourceMemo = () => ( 6 | 7 | Would you like to build similar app? Feel free to browse the  8 | 9 | 10 | source code on Github 11 | 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document" 2 | 3 | const Document = () => ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |

IP:

13 | 14 | 15 | 16 | ) 17 | 18 | export default Document 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "window.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /services/GoogleReCaptcha.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import { InvalidCaptcha } from "../errors/InvalidCaptcha" 3 | import { Captcha } from "../interfaces/Captcha" 4 | 5 | export class GoogleReCaptcha implements Captcha { 6 | constructor(private readonly apiKey: string) {} 7 | 8 | async verifyCaptcha(token: string) { 9 | const url = `https://www.google.com/recaptcha/api/siteverify?secret=${this.apiKey}&response=${token}` 10 | const response = await axios.post(url).then((response) => response.data) 11 | 12 | if (response.success === true) { 13 | return true 14 | } 15 | 16 | throw new InvalidCaptcha() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Create a copy of this file and replace with correct values. Find more details in README.md 2 | WALLET_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 3 | NEXT_PUBLIC_ETH_API_URL=https://goerli.infura.io/v3/00000000000000000000000000000000 4 | NEXT_PUBLIC_DEFAULT_WALLET_ETH_AMOUNT=0.25 5 | NEXT_PUBLIC_PRIVILEGED_WALLET_ETH_AMOUNT=1 6 | ENABLE_TRANSACTION_CHECKS=none 7 | ETHERSCAN_API_KEY=00000000000000000000000000000000 8 | REDIS_URL=rediss://user:password@redis:6379 9 | NEXT_PUBLIC_ENABLE_CAPTCHA=none 10 | NEXT_PUBLIC_RECAPTCHA_SITE_KEY=0000000000000000000000000000000000000000 11 | RECAPTCHA_SECRET_KEY=0000000000000000000000000000000000000000 12 | -------------------------------------------------------------------------------- /pages/api/nonce.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next" 2 | import { NonceResponseBody } from "../../interfaces/Nonce" 3 | import { DefaultResponse } from "../../interfaces/Response" 4 | import { TimestampNonce } from "../../services/TimestampNonce" 5 | 6 | type NonceResponse = DefaultResponse 7 | 8 | const handler = async (req: NextApiRequest, res: NextApiResponse) => { 9 | const nonceService = new TimestampNonce() 10 | const nonce = await nonceService.generate() 11 | 12 | res.status(200).json({ 13 | status: "ok", 14 | data: { 15 | nonce 16 | } 17 | }) 18 | } 19 | 20 | export default handler 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # yarn@3.x.x 4 | .yarn/* 5 | !.yarn/patches 6 | !.yarn/releases 7 | !.yarn/plugins 8 | !.yarn/sdks 9 | !.yarn/versions 10 | .pnp.* 11 | 12 | # dependencies 13 | /node_modules 14 | /.pnp 15 | .pnp.js 16 | 17 | # testing 18 | /coverage 19 | 20 | # next.js 21 | /.next/ 22 | /out/ 23 | 24 | # production 25 | /build 26 | 27 | # misc 28 | .DS_Store 29 | *.pem 30 | 31 | # debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | .pnpm-debug.log* 36 | 37 | # local env files 38 | .env*.local 39 | 40 | # vercel 41 | .vercel 42 | 43 | # typescript 44 | *.tsbuildinfo 45 | -------------------------------------------------------------------------------- /components/CaptchaProvider.tsx: -------------------------------------------------------------------------------- 1 | import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3" 2 | 3 | type CaptchaProviderProps = { 4 | children: JSX.Element 5 | } 6 | 7 | export const CaptchaProvider = ({ children }: CaptchaProviderProps) => { 8 | switch (process.env.NEXT_PUBLIC_ENABLE_CAPTCHA) { 9 | case "recaptcha_v3": { 10 | const siteApiKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY as string 11 | 12 | return ( 13 | 14 | {children} 15 | 16 | ) 17 | } 18 | default: { 19 | return children 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /services/WalletClassification.ts: -------------------------------------------------------------------------------- 1 | import { defaultWalletWeiAmount, privilegedWalletWeiAmount } from "../consts/env" 2 | import { normalizeAddress } from "../utils/ethAddressUtils" 3 | 4 | export class WalletClassification { 5 | constructor(private readonly addresses: string[] = []) {} 6 | 7 | isPrivileged(address: string | undefined) { 8 | const normalizedAddress = normalizeAddress(address || "") 9 | return this.addresses.includes(normalizedAddress) 10 | } 11 | 12 | retrieveAmount(address: string | undefined) { 13 | if (this.isPrivileged(address)) { 14 | return privilegedWalletWeiAmount 15 | } 16 | 17 | return defaultWalletWeiAmount 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /components/ClaimButton/_GoogleReCaptchaClaimButton.tsx: -------------------------------------------------------------------------------- 1 | import { useGoogleReCaptcha } from "react-google-recaptcha-v3" 2 | import { BaseClaimButton } from "./_BaseClaimButton" 3 | 4 | type GoogleReCaptchaClaimButtonProps = { 5 | onSuccess: () => void 6 | onError: (message: string) => void 7 | } 8 | 9 | export const GoogleReCaptchaClaimButton = (props: GoogleReCaptchaClaimButtonProps) => { 10 | const { executeRecaptcha } = useGoogleReCaptcha() 11 | 12 | const retrieveCaptcha = async () => { 13 | if (!executeRecaptcha) { 14 | throw new Error("Couldn’t generate captcha") 15 | } 16 | 17 | return await executeRecaptcha("claim") 18 | } 19 | 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /components/ClaimButton/ClaimButton.tsx: -------------------------------------------------------------------------------- 1 | import { BaseClaimButton } from "./_BaseClaimButton" 2 | import { GoogleReCaptchaClaimButton } from "./_GoogleReCaptchaClaimButton" 3 | 4 | type ClaimButtonProps = { 5 | onSuccess: () => void 6 | onError: (message: string) => void 7 | } 8 | 9 | export const ClaimButton = (props: ClaimButtonProps) => { 10 | switch (process.env.NEXT_PUBLIC_ENABLE_CAPTCHA) { 11 | case "recaptcha_v3": { 12 | return 13 | } 14 | default: { 15 | const retrieveCaptcha = async (): Promise => { 16 | return new Promise((resolve) => resolve("")) 17 | } 18 | 19 | return 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /consts/env.ts: -------------------------------------------------------------------------------- 1 | export const defaultWalletEthAmount = 2 | process.env.NEXT_PUBLIC_DEFAULT_WALLET_ETH_AMOUNT !== undefined 3 | ? parseFloat(process.env.NEXT_PUBLIC_DEFAULT_WALLET_ETH_AMOUNT) 4 | : 0.25 5 | 6 | export const privilegedWalletEthAmount = 7 | process.env.NEXT_PUBLIC_PRIVILEGED_WALLET_ETH_AMOUNT !== undefined 8 | ? parseFloat(process.env.NEXT_PUBLIC_PRIVILEGED_WALLET_ETH_AMOUNT) 9 | : 1 10 | 11 | export const defaultWalletWeiAmount = BigInt(defaultWalletEthAmount * 10 ** 18) 12 | 13 | export const privilegedWalletWeiAmount = BigInt(privilegedWalletEthAmount * 10 ** 18) 14 | 15 | export const pollingInterval = 20_000 16 | 17 | export const defaultMillisecondsLayover = 86400000 // 24h 18 | 19 | export const defaultBlockLayover = 5400 // ~24h, 16s per block on Görli 20 | -------------------------------------------------------------------------------- /services/TimestampNonce.ts: -------------------------------------------------------------------------------- 1 | import { Nonce } from "../interfaces/Nonce" 2 | 3 | const timeTolerance = 600 // 10 minutes 4 | 5 | export class TimestampNonce implements Nonce { 6 | constructor() {} 7 | 8 | async verify(input: string) { 9 | return new Promise((resolve, reject) => { 10 | const userTimestamp = parseInt(input) 11 | const currentTimestamp = Math.floor(new Date().getTime() / 1000) 12 | 13 | if (currentTimestamp <= userTimestamp + timeTolerance) { 14 | return resolve(true) 15 | } 16 | 17 | return reject(false) 18 | }) 19 | } 20 | 21 | async generate() { 22 | return new Promise((resolve) => { 23 | const timestamp = Math.floor(new Date().getTime() / 1000) 24 | resolve(`${timestamp}`) 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Link as MuiLink, styled } from "@mui/material" 2 | import Link from "next/link" 3 | 4 | const FooterDiv = styled("footer")(({ theme }) => ({ 5 | margin: `${theme.spacing(1)} auto`, 6 | padding: theme.spacing(4), 7 | minWidth: theme.spacing(40), 8 | maxWidth: theme.spacing(70), 9 | width: "100%", 10 | textAlign: "center", 11 | color: theme.palette.text.secondary, 12 | ...theme.typography.body2 13 | })) 14 | 15 | export const Footer = () => { 16 | return ( 17 | 18 | Made with ❤️ by{" "} 19 | 20 | 21 | Matt Sokola 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { styled, Typography } from "@mui/material" 2 | 3 | const HeaderDiv = styled("div")(({ theme }) => ({ 4 | margin: `0 auto`, 5 | padding: `${theme.spacing(3)} ${theme.spacing(2)}`, 6 | minWidth: theme.spacing(40), 7 | maxWidth: theme.spacing(70), 8 | width: "100%", 9 | "& > h1": { 10 | ...theme.typography.h4, 11 | marginBottom: theme.spacing(2), 12 | fontWeight: theme.typography.fontWeightMedium 13 | } 14 | })) 15 | 16 | export const Header = () => { 17 | return ( 18 | 19 | Claim Görli ETH 20 | 21 | Görli ETH has no monetary value. It means you can’t sell or exchange it for any goods or services. The 22 | only utility it has is testing your decentralized application (DApp). 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /services/IpTransactionHistory.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis" 2 | import { defaultMillisecondsLayover } from "../consts/env" 3 | import { TransactionHistory } from "../interfaces/TransactionHistory" 4 | 5 | export class IpTransactionHistory implements TransactionHistory { 6 | constructor(private readonly redis: Redis) {} 7 | 8 | async hasReceivedTokens(address: string, minLayover: number = defaultMillisecondsLayover): Promise { 9 | const timeString = await this.redis.get(address) 10 | 11 | if (timeString === null) { 12 | return false 13 | } 14 | 15 | const lastTransactionTime = new Date(timeString).getTime() 16 | const nowTime = new Date().getTime() 17 | 18 | return nowTime - lastTransactionTime < minLayover 19 | } 20 | 21 | async recordTransaction(address: string) { 22 | const nowTime = new Date().toISOString() 23 | 24 | await this.redis.set(address, nowTime) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /services/EtherscanTransactionHistory.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers" 2 | import { defaultBlockLayover } from "../consts/env" 3 | import { TransactionHistory } from "../interfaces/TransactionHistory" 4 | 5 | export class EtherscanTransactionHistory implements TransactionHistory { 6 | constructor(private readonly provider: ethers.providers.EtherscanProvider) {} 7 | 8 | async hasReceivedTokens(address: string, blockSpan: number = defaultBlockLayover): Promise { 9 | const endBlock = await this.provider.getBlockNumber() 10 | const startBlock = endBlock - blockSpan 11 | 12 | const transactions = await this.provider.getHistory(address, startBlock, endBlock) 13 | 14 | for (const transaction of transactions) { 15 | if (transaction.from.toLocaleLowerCase() === process.env.WALLET_ADDRESS?.toLocaleLowerCase()) { 16 | return true 17 | } 18 | } 19 | 20 | return false 21 | } 22 | 23 | async recordTransaction(_: string) { 24 | // do nothing 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /services/RedisTransactionHistory.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis" 2 | import { defaultMillisecondsLayover } from "../consts/env" 3 | import { TransactionHistory } from "../interfaces/TransactionHistory" 4 | import { normalizeAddress } from "../utils/ethAddressUtils" 5 | 6 | export class RedisTransactionHistory implements TransactionHistory { 7 | constructor(private readonly redis: Redis) {} 8 | 9 | async hasReceivedTokens(address: string, minLayover: number = defaultMillisecondsLayover): Promise { 10 | const normalizedAddress = normalizeAddress(address) 11 | const timeString = await this.redis.get(normalizedAddress) 12 | 13 | if (timeString === null) { 14 | return false 15 | } 16 | 17 | const lastTransactionTime = new Date(timeString).getTime() 18 | const nowTime = new Date().getTime() 19 | 20 | return nowTime - lastTransactionTime < minLayover 21 | } 22 | 23 | async recordTransaction(address: string) { 24 | const normalizedAddress = normalizeAddress(address) 25 | const nowTime = new Date().toISOString() 26 | 27 | await this.redis.set(normalizedAddress, nowTime) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mateusz Sokola 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /utils/bootstrapEthereum.ts: -------------------------------------------------------------------------------- 1 | import { Goerli } from "@usedapp/core" 2 | import { ethers } from "ethers" 3 | import { privilegedWallets } from "../consts/wallets" 4 | import { Ethereum } from "../services/Ethereum" 5 | import { TimestampNonce } from "../services/TimestampNonce" 6 | import { WalletClassification } from "../services/WalletClassification" 7 | import { bootstrapTransactionHistory, TransactionHistoryType } from "./bootstrapTransactionHistory" 8 | 9 | export const bootstrapEthereum = (chainId: number = Goerli.chainId) => { 10 | // Wallet Classification Service 11 | const classificationService = new WalletClassification(privilegedWallets) 12 | 13 | // Transaction History Service 14 | const enabledTransactionChecks = process.env.ENABLE_TRANSACTION_CHECKS as TransactionHistoryType 15 | const transactionHistoryService = bootstrapTransactionHistory(enabledTransactionChecks, { chainId }) 16 | 17 | // Nonce Service 18 | const nonceService = new TimestampNonce() 19 | 20 | // Blockchain Service 21 | const provider = new ethers.providers.JsonRpcProvider(process.env.NEXT_PUBLIC_ETH_API_URL || "", chainId) 22 | const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY || "", provider) 23 | const ethereum = new Ethereum(wallet, nonceService, classificationService, transactionHistoryService) 24 | 25 | return ethereum 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eth-faucet", 3 | "version": "0.1.1", 4 | "license": "MIT", 5 | "scripts": { 6 | "create-wallet": "ts-node ./_scripts/createWallet.ts", 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "latest", 14 | "@emotion/styled": "latest", 15 | "@metamask/eth-sig-util": "latest", 16 | "@mui/icons-material": "latest", 17 | "@mui/lab": "latest", 18 | "@mui/material": "latest", 19 | "@usedapp/core": "latest", 20 | "axios": "latest", 21 | "ethers": "latest", 22 | "ioredis": "latest", 23 | "lodash": "latest", 24 | "next": "12.1.6", 25 | "react": "18.1.0", 26 | "react-dom": "18.1.0", 27 | "react-google-recaptcha-v3": "1.10.0", 28 | "request-ip": "latest" 29 | }, 30 | "devDependencies": { 31 | "@types/lodash": "latest", 32 | "@types/node": "17.0.35", 33 | "@types/react": "18.0.9", 34 | "@types/react-dom": "18.0.4", 35 | "@types/request-ip": "latest", 36 | "eslint": "8.16.0", 37 | "eslint-config-next": "12.1.6", 38 | "prettier": "latest", 39 | "ts-node": "10.9.1", 40 | "typescript": "4.6.4" 41 | }, 42 | "engines": { 43 | "node": "16.x.x", 44 | "yarn": "3.x.x", 45 | "npm": "please-use-yarn" 46 | }, 47 | "packageManager": "yarn@3.2.2" 48 | } 49 | -------------------------------------------------------------------------------- /utils/bootstrapTransactionHistory.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers" 2 | import Redis from "ioredis" 3 | import { TransactionHistory } from "../interfaces/TransactionHistory" 4 | import { EtherscanTransactionHistory } from "../services/EtherscanTransactionHistory" 5 | import { IpTransactionHistory } from "../services/IpTransactionHistory" 6 | import { RedisTransactionHistory } from "../services/RedisTransactionHistory" 7 | 8 | export type TransactionHistoryType = "etherscan" | "redis" | "ip" 9 | 10 | export const bootstrapTransactionHistory = ( 11 | type: TransactionHistoryType, 12 | options?: any 13 | ): TransactionHistory | undefined => { 14 | switch (type) { 15 | case "etherscan": { 16 | const { chainId } = options as { chainId: number } 17 | const etherscan = new ethers.providers.EtherscanProvider(chainId, process.env.ETHERSCAN_API_KEY) 18 | const etherscanService = new EtherscanTransactionHistory(etherscan) 19 | 20 | return etherscanService 21 | } 22 | case "redis": { 23 | const redis = new Redis(process.env.REDIS_URL as string) 24 | const redisService = new RedisTransactionHistory(redis) 25 | 26 | return redisService 27 | } 28 | case "ip": { 29 | const redis = new Redis(process.env.REDIS_URL as string) 30 | const ipService = new IpTransactionHistory(redis) 31 | 32 | return ipService 33 | } 34 | default: { 35 | return undefined 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app" 2 | import { createTheme, CssBaseline, ThemeProvider } from "@mui/material" 3 | import { Goerli, DAppProvider, Config } from "@usedapp/core" 4 | import Head from "next/head" 5 | import { OpenSourceMemo } from "../components/OpenSourceMemo" 6 | import { Header } from "../components/Header" 7 | import { Footer } from "../components/Footer" 8 | import { Layout } from "../components/Layout" 9 | import { Content } from "../components/Content" 10 | import { pollingInterval } from "../consts/env" 11 | import { CaptchaProvider } from "../components/CaptchaProvider" 12 | 13 | const config: Config = { 14 | readOnlyChainId: Goerli.chainId, 15 | readOnlyUrls: { 16 | [Goerli.chainId]: process.env.NEXT_PUBLIC_ETH_API_URL as string 17 | }, 18 | pollingInterval 19 | } 20 | const theme = createTheme() 21 | 22 | const EthereumFaucet = ({ Component, pageProps }: AppProps) => ( 23 | <> 24 | 25 | Claim Görli ETH 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | 35 | 36 | 37 |