diff --git a/.env.example b/.env.example index c00380c..a76988e 100644 --- a/.env.example +++ b/.env.example @@ -22,4 +22,16 @@ NEXT_PUBLIC_HELIUS_API_KEY= # Leave empty to fall back to HTTP polling ZEBRA_GRPC_URL=127.0.0.1:8230 +# Testnet Faucet +# Talks to taps (https://github.com/zcashme/taps) — an Orchard-only Rust faucet +# wallet daemon. Hosted at light.zcash.me/taps; cipherscan Express handles +# Turnstile, then proxies to taps for the spend. +TAPS_URL=https://light.zcash.me/taps +TAPS_API_KEY= + +# Cloudflare Turnstile (https://dash.cloudflare.com/?to=/:account/turnstile). +# Set both to enable captcha; leave both empty to disable. +TURNSTILE_SECRET_KEY= +NEXT_PUBLIC_TURNSTILE_SITE_KEY= + # Optional: Analytics, etc. diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx new file mode 100644 index 0000000..a6d5343 --- /dev/null +++ b/app/faucet/FaucetClient.tsx @@ -0,0 +1,438 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { QRCodeSVG } from 'qrcode.react'; +import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'; +import { Card, CardBody } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { CopyButton } from '@/components/CopyButton'; +import { useTheme } from '@/contexts/ThemeContext'; +import { getApiUrl } from '@/lib/api-config'; +import { isTestnet } from '@/lib/config'; + +const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''; + +// Slider events emit 0.30000000000000004 etc. — snap to the increment. +function snapToStep(v: number, step: number): number { + return Math.round(v / step) * step; +} + +function formatTaz(v: number): string { + return parseFloat(v.toFixed(4)).toString(); +} + +interface FaucetStatus { + balanceTaz: number; + maxDispensableTaz: number; + maxSpendTaz: number; + minSpendTaz: number; + stepTaz: number; + donateAddress: string | null; +} + +const SYNC_NOTICE_THRESHOLD = 0.2; + +function isValidTestnetUnifiedAddress(addr: string): boolean { + return /^utest1[02-9ac-hj-np-z]{40,}$/.test(addr.trim()); +} + +function errorMessage(data: { error?: string; detail?: string }): string { + switch (data.error) { + case 'invalid address': + return 'invalid testnet address — expected utest1…'; + case 'drained': + return 'faucet is dry — mining the next refill, check back later'; + case 'captcha failed': + return 'captcha verification failed'; + default: + return data.error || data.detail || 'something broke, try again'; + } +} + +export default function FaucetClient() { + const [address, setAddress] = useState(''); + const [amountTaz, setAmountTaz] = useState(null); + const [pending, setPending] = useState(false); + const [notice, setNotice] = useState(null); + const [result, setResult] = useState<{ txid: string; amountTaz: number } | null>(null); + const [status, setStatus] = useState(null); + const [captchaToken, setCaptchaToken] = useState(null); + const turnstileRef = useRef(null); + const { theme, mounted: themeMounted } = useTheme(); + const captchaMisconfigured = !TURNSTILE_SITE_KEY; + + const lowSpendable = + status != null && + status.maxSpendTaz > 0 && + status.maxDispensableTaz < status.maxSpendTaz * SYNC_NOTICE_THRESHOLD; + const overSpendable = status != null && amountTaz != null && amountTaz > status.maxDispensableTaz + 1e-9; + + useEffect(() => { + let cancelled = false; + async function loadStatus() { + try { + const res = await fetch(`${getApiUrl()}/api/faucet/status`); + if (!res.ok) return; + const data: FaucetStatus = await res.json(); + if (!cancelled) setStatus(data); + } catch {} + } + loadStatus(); + const interval = setInterval(loadStatus, 30_000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, []); + + useEffect(() => { + if (status == null) return; + if (amountTaz == null || amountTaz > status.maxDispensableTaz) { + setAmountTaz(status.maxDispensableTaz); + } + }, [status, amountTaz]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = address.trim(); + if (!isValidTestnetUnifiedAddress(trimmed)) { + setNotice('invalid testnet address — expected utest1…'); + return; + } + if (!captchaToken) { + setNotice('complete the captcha first'); + return; + } + if (amountTaz == null) { + setNotice('still loading — try again in a moment'); + return; + } + setNotice(null); + setPending(true); + + try { + const res = await fetch(`${getApiUrl()}/api/faucet/dispense`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: trimmed, amountTaz, captchaToken }), + }); + const data = await res.json().catch(() => ({})); + + if (res.ok && data.txid) { + setResult({ txid: data.txid, amountTaz }); + return; + } + + turnstileRef.current?.reset(); + setCaptchaToken(null); + setNotice(errorMessage(data)); + } catch (err) { + turnstileRef.current?.reset(); + setCaptchaToken(null); + setNotice(err instanceof Error ? err.message : 'network error'); + } finally { + setPending(false); + } + } + + function reset() { + setAddress(''); + setNotice(null); + setResult(null); + } + + if (!isTestnet) { + return ( +
+
+
+ + + +
+

Testnet Only

+

The faucet dispenses testnet ZEC (TAZ).

+ + Go to Testnet + + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+

+ {'>'} TESTNET_FAUCET +

+

Testnet Faucet

+ {lowSpendable && ( +

+ wallet syncing — single dispense currently capped at {formatTaz(status!.maxDispensableTaz)} TAZ +

+ )} +
+ + {/* Form / Result */} + {result ? ( + + +
+ SENT + + {formatTaz(result.amountTaz)} TAZ dispatched to your address + +
+ +
+
+ {'>'} TXID +
+
+ {result.txid} + +
+

+ Likely unconfirmed — confirmation in ~75 seconds. +

+
+ +
+ + view tx → + + +
+
+
+ ) : ( + + +
+
+ + { + setAddress(e.target.value); + if (notice) setNotice(null); + }} + placeholder="utest1..." + spellCheck={false} + autoComplete="off" + disabled={pending} + className="input-field disabled:opacity-50" + /> + {notice && ( +

+ {notice} +

+ )} +
+ +
+
+
+ {'>'} AMOUNT +
+
+ {formatTaz(amountTaz ?? 0)} TAZ +
+
+ {status ? ( + <> + setAmountTaz(snapToStep(parseFloat(e.target.value), status.stepTaz))} + disabled={pending} + aria-label="Dispense amount in TAZ" + className="w-full accent-cipher-cyan cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" + /> +
+ {formatTaz(status.minSpendTaz)} TAZ + {formatTaz(status.maxDispensableTaz)} TAZ +
+ + ) : ( +
loading bounds…
+ )} + {overSpendable && ( +

+ only {formatTaz(status!.maxDispensableTaz)} TAZ spendable right now — pick a smaller amount +

+ )} +
+ + {captchaMisconfigured ? ( +

+ captcha misconfigured — set NEXT_PUBLIC_TURNSTILE_SITE_KEY on the build. +

+ ) : ( +
+ setCaptchaToken(token)} + onExpire={() => setCaptchaToken(null)} + onError={() => setCaptchaToken(null)} + options={{ + theme, + size: 'normal', + }} + /> +
+ )} + + +
+
+
+ )} + + {/* Wallet stats */} + + +

+ {'>'} FAUCET_STATS +

+
+
+
+ Wallet balance +
+
+ {status ? `${formatTaz(status.balanceTaz)} TAZ` : '…'} +
+
+
+
+ Available right now +
+
+ {status ? `${formatTaz(status.maxDispensableTaz)} TAZ` : '…'} +
+
+
+
+
+ + {/* Rules card */} + + +

+ {'>'} RULES_OF_ENGAGEMENT +

+
    +
  • · {status ? `${formatTaz(status.minSpendTaz)} – ${formatTaz(status.maxSpendTaz)} TAZ per request` : '…'}
  • +
  • · Orchard / Unified addresses (utest1…) only
  • +
+
+
+ + {/* Donate card */} + + +

+ {'>'} SUPPORT_THE_FAUCET +

+

+ Faucet running low? Send TAZ to keep it pouring. +

+ +
+ {/* QR */} +
+ {themeMounted && status?.donateAddress && ( + + )} +
+ + {/* Address + copy */} +
+
+ {'>'} ADDRESS +
+
+ {status?.donateAddress ? ( + <> + {status.donateAddress} + + + ) : ( + loading… + )} +
+
+
+
+
+ +
+ ); +} diff --git a/app/faucet/page.tsx b/app/faucet/page.tsx new file mode 100644 index 0000000..67213d3 --- /dev/null +++ b/app/faucet/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from 'next'; +import FaucetClient from './FaucetClient'; + +export const metadata: Metadata = { + title: 'Testnet Faucet | CipherScan', + description: 'Get TAZ for your Orchard Unified Address', + openGraph: { + title: 'Zcash Testnet Faucet | CipherScan', + description: 'Get TAZ for your Orchard Unified Address', + url: 'https://testnet.cipherscan.app/faucet', + siteName: 'CipherScan', + type: 'website', + }, + alternates: { + canonical: 'https://testnet.cipherscan.app/faucet', + }, +}; + +export default function FaucetPage() { + return ( +
+ +
+ ); +} diff --git a/components/NavBar.tsx b/components/NavBar.tsx index 882a51f..86ac69a 100644 --- a/components/NavBar.tsx +++ b/components/NavBar.tsx @@ -8,7 +8,7 @@ import { SearchBar } from '@/components/SearchBar'; import { DonateButton } from '@/components/DonateButton'; import { ThemeToggle } from '@/components/ThemeToggle'; import { useTheme } from '@/contexts/ThemeContext'; -import { NETWORK_LABEL, NETWORK_COLOR, isMainnet, isCrosslink, MAINNET_URL, TESTNET_URL, CROSSLINK_URL } from '@/lib/config'; +import { NETWORK_LABEL, NETWORK_COLOR, isMainnet, isTestnet, isCrosslink, MAINNET_URL, TESTNET_URL, CROSSLINK_URL } from '@/lib/config'; import { API_CONFIG, getApiUrl, usePostgresApiClient } from '@/lib/api-config'; @@ -321,7 +321,7 @@ export function NavBar() { )} - {priceData && isMainnet &&
} + {priceData && (isMainnet || isTestnet) &&
} {isMainnet && ( )} + + {isTestnet && ( + + > + Get TAZ + + )}
@@ -381,7 +391,7 @@ export function NavBar() { )} - {priceData && isMainnet &&
} + {priceData && (isMainnet || isTestnet) &&
} {isMainnet && ( )} + {isTestnet && ( + setMobileMenuOpen(false)} + className="flex items-center gap-1 font-mono text-xs font-bold text-cipher-yellow" + > + > + Get TAZ + + )}
{/* Mempool — promoted link */} diff --git a/lib/api-config.ts b/lib/api-config.ts index f787678..7396e2c 100644 --- a/lib/api-config.ts +++ b/lib/api-config.ts @@ -41,7 +41,7 @@ export const NETWORK = detectNetwork(); const POSTGRES_API_URLS: Record = { 'mainnet': 'https://api.mainnet.cipherscan.app', - 'testnet': 'https://api.testnet.cipherscan.app', + 'testnet': process.env.NEXT_PUBLIC_TESTNET_API_URL || 'https://api.testnet.cipherscan.app', 'crosslink-testnet': process.env.NEXT_PUBLIC_CROSSLINK_API_URL || 'https://api.crosslink.cipherscan.app', }; diff --git a/package-lock.json b/package-lock.json index 7d0e3f1..5c748a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", + "@marsidev/react-turnstile": "^1.5.2", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", "@types/node": "^22.10.5", @@ -179,10 +180,35 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@marsidev/react-turnstile": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.5.2.tgz", + "integrity": "sha512-+3aBPxp86JzSC0ZmgyonoGoUEENcUkH3LGahXSpkV87ArvD2DzRCmPgh0FyQk6PQRmJwQJDAfwNavFsxUxMQWA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0 || ^19.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" + } + }, "node_modules/@next/env": { "version": "15.5.7", "license": "MIT" }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/swc-darwin-x64": { "version": "15.5.7", "cpu": [ @@ -197,6 +223,96 @@ "node": ">= 10" } }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "license": "MIT", @@ -3784,111 +3900,6 @@ "optional": true } } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", - "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", - "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", - "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", - "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", - "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", - "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", - "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/package.json b/package.json index 1d0e584..370f75f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", + "@marsidev/react-turnstile": "^1.5.2", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", "@types/node": "^22.10.5", diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js new file mode 100644 index 0000000..5d9ffac --- /dev/null +++ b/server/api/routes/faucet.js @@ -0,0 +1,203 @@ +/** + * Testnet Faucet Routes + * /api/faucet/status, /api/faucet/dispense — proxies to taps, verifies Turnstile + */ + +const express = require("express"); +const rateLimit = require("express-rate-limit"); +const router = express.Router(); + +const DEFAULT_DISPENSE_TAZ = 0.5; +const UA_REGEX = /^utest1[02-9ac-hj-np-z]{40,}$/; +const FAUCET_MEMO = + "thanks for using the cipherscan testnet faucet — zipher, a zcash wallet for humans and agents, coming soon (in beta)"; + +const dispenseLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + skipFailedRequests: true, + message: { error: "rate limited" }, +}); + +const TAPS_URL = process.env.TAPS_URL || ""; +if (!TAPS_URL) { + console.error("[faucet] TAPS_URL not set — /api/faucet/* will 503"); +} + +const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY || ""; +if (!TURNSTILE_SECRET) { + console.error( + "[faucet] TURNSTILE_SECRET_KEY not set — /api/faucet/dispense will 503", + ); +} + +async function verifyTurnstile(token, remoteIp) { + if (!token) return false; + + try { + const body = new URLSearchParams({ + secret: TURNSTILE_SECRET, + response: token, + }); + if (remoteIp) body.append("remoteip", remoteIp); + + const res = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + body, + }, + ); + const data = await res.json(); + return data.success === true; + } catch (err) { + console.error("[faucet] Turnstile verify failed:", err.message); + return false; + } +} + +async function tapsStatus() { + const res = await fetch(`${TAPS_URL}/status`); + if (!res.ok) throw new Error(`taps /status ${res.status}`); + return res.json(); +} + +async function tapsSend({ recipient, amountTaz }) { + const apiKey = process.env.TAPS_API_KEY || ""; + const res = await fetch(`${TAPS_URL}/send`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Api-Key": apiKey, + }, + body: JSON.stringify({ recipient, amount: amountTaz, memo: FAUCET_MEMO }), + }); + const body = await res.json().catch(() => ({})); + return { status: res.status, body }; +} + +router.get("/api/faucet/status", async (_req, res) => { + if (!TAPS_URL) return res.status(503).json({ error: "taps not configured" }); + try { + const taps = await tapsStatus(); + const orchard = taps?.balances?.orchard; + const ua = taps?.unified_address; + const maxDispensable = taps?.max_dispensable_zat; + const maxSpend = taps?.max_spend_zat; + const minSpend = taps?.min_spend_zat; + const increment = taps?.spend_increment_zat; + res.json({ + balanceTaz: typeof orchard === "number" ? orchard : 0, + maxDispensableTaz: + typeof maxDispensable === "number" ? maxDispensable / 100000000 : 0, + maxSpendTaz: typeof maxSpend === "number" ? maxSpend / 100000000 : 0, + minSpendTaz: typeof minSpend === "number" ? minSpend / 100000000 : 0, + stepTaz: typeof increment === "number" ? increment / 100000000 : 0, + donateAddress: typeof ua === "string" && ua !== "unavailable" ? ua : null, + }); + } catch (err) { + console.error("[faucet] status failed:", err.message); + res.status(502).json({ error: "wallet unreachable" }); + } +}); + +router.post( + "/api/faucet/dispense", + dispenseLimiter, + express.json(), + async (req, res) => { + if (!TAPS_URL) + return res.status(503).json({ error: "taps not configured" }); + if (!TURNSTILE_SECRET) + return res.status(503).json({ error: "captcha not configured" }); + const { + address, + amountTaz: requestedAmount, + captchaToken, + } = req.body || {}; + + if ( + !address || + typeof address !== "string" || + !UA_REGEX.test(address.trim()) + ) { + return res.status(400).json({ error: "invalid address" }); + } + const addr = address.trim(); + + let amountTaz; + if (requestedAmount === undefined || requestedAmount === null) { + amountTaz = DEFAULT_DISPENSE_TAZ; + } else if ( + typeof requestedAmount !== "number" || + !Number.isFinite(requestedAmount) || + requestedAmount <= 0 + ) { + return res.status(400).json({ error: "invalid amount" }); + } else { + amountTaz = requestedAmount; + } + + const captchaOk = await verifyTurnstile(captchaToken, req.ip); + if (!captchaOk) { + return res.status(400).json({ error: "captcha failed" }); + } + + // Defense-in-depth cap: ask taps what the live ceiling is and reject above + // it. max_dispensable_zat is what the slider also reads, so client and + // server agree. Static config would drift; live fetch tracks balance + fees. + let status; + try { + status = await tapsStatus(); + } catch (err) { + console.error("[faucet] status check failed:", err.message); + return res.status(502).json({ error: "wallet unreachable" }); + } + const cap = (status?.max_dispensable_zat ?? 0) / 100000000; + if (cap > 0 && amountTaz > cap) { + return res + .status(400) + .json({ + error: "invalid amount", + detail: `amount exceeds available ${cap} TAZ`, + }); + } + + let result; + try { + result = await tapsSend({ recipient: addr, amountTaz }); + } catch (err) { + console.error("[faucet] taps /send failed:", err.message); + return res.status(502).json({ error: "wallet unreachable" }); + } + + if (result.status === 200 && result.body?.txid) { + console.log( + `[faucet] dispensed ${amountTaz} TAZ to ${addr.slice(0, 12)}… txid=${result.body.txid}`, + ); + return res.json({ txid: result.body.txid, amountTaz }); + } + + const tapsErr = result.body?.error || ""; + if (tapsErr === "invalid address") { + return res.status(400).json({ error: "invalid address" }); + } + if (tapsErr === "insufficient balance") { + return res.status(503).json({ error: "drained" }); + } + if (tapsErr.startsWith("amount ")) { + // taps amount-validation surface: too small, too large, wrong increment + return res.status(400).json({ error: "invalid amount", detail: tapsErr }); + } + if (result.status === 401) { + console.error("[faucet] taps rejected api key — check TAPS_API_KEY"); + return res.status(502).json({ error: "wallet auth" }); + } + console.error(`[faucet] taps /send ${result.status}:`, tapsErr); + return res.status(502).json({ error: "send failed", detail: tapsErr }); + }, +); + +module.exports = router; diff --git a/server/api/server.js b/server/api/server.js index b613d59..add239a 100644 --- a/server/api/server.js +++ b/server/api/server.js @@ -4,6 +4,7 @@ * Runs on DigitalOcean, serves data to Netlify frontend */ +require('dotenv').config({ path: require('path').resolve(__dirname, '../../.env.local') }); require('dotenv').config(); const express = require('express'); const cors = require('cors'); @@ -32,6 +33,7 @@ const swapRouter = require('./routes/swap'); const addressRouter = require('./routes/address'); const blendCheckRouter = require('./routes/blend-check'); const crosslinkRouter = require('./routes/crosslink'); +const faucetRouter = require('./routes/faucet'); // Import privacy linkage functions const { @@ -372,6 +374,9 @@ app.use(blendCheckRouter); // Crosslink routes: /api/crosslink app.use(crosslinkRouter); +// Faucet routes: /api/faucet/* +app.use(faucetRouter); + // ============================================================================ // WEBSOCKET SERVER (Real-time updates) // ============================================================================