From ea28f7413cfc2d2294bdd54ce9020259e7cd1808 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Tue, 19 May 2026 09:30:20 +0530 Subject: [PATCH 01/35] feat(navbar): add Get TAZ CTA on testnet linking to /faucet --- components/NavBar.tsx | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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 */} From 0461e11d52e25f823d98d1c44909f05b20e789b0 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Tue, 19 May 2026 09:52:54 +0530 Subject: [PATCH 02/35] feat(faucet): add /faucet page UI stub with form, status strip, rules --- app/faucet/FaucetClient.tsx | 249 ++++++++++++++++++++++++++++++++++++ app/faucet/page.tsx | 26 ++++ 2 files changed, 275 insertions(+) create mode 100644 app/faucet/FaucetClient.tsx create mode 100644 app/faucet/page.tsx diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx new file mode 100644 index 0000000..5aff20e --- /dev/null +++ b/app/faucet/FaucetClient.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { Card, CardBody } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; + +const DISPENSE_AMOUNT_TAZ = 0.5; +const COOLDOWN_HOURS = 24; + +type SubmitState = + | { kind: 'idle' } + | { kind: 'submitting' } + | { kind: 'success'; txid: string } + | { kind: 'invalid' }; + +function isValidTestnetTransparentAddress(addr: string): boolean { + return /^tm[a-zA-Z0-9]{32,40}$/.test(addr.trim()); +} + +export default function FaucetClient() { + const [address, setAddress] = useState(''); + const [state, setState] = useState({ kind: 'idle' }); + const [copied, setCopied] = useState(false); + + // STUB: status will come from /api/faucet/status once backend lands + const stubStatus = { + balanceTAZ: 812.4, + lastDripTxid: '4f3b9c129a87d10e4d3fa1b2c5e6f0d2e8a9b3c4d5e6f708192a3b4c5d6e7f80a', + lastDripAgoMin: 2, + }; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = address.trim(); + if (!isValidTestnetTransparentAddress(trimmed)) { + setState({ kind: 'invalid' }); + return; + } + setState({ kind: 'submitting' }); + // STUB: simulate a 1.5s dispense + await new Promise((r) => setTimeout(r, 1500)); + setState({ + kind: 'success', + txid: '4f3b9c129a87d10e4d3fa1b2c5e6f0d2e8a9b3c4d5e6f708192a3b4c5d6e7f80a', + }); + } + + function reset() { + setAddress(''); + setState({ kind: 'idle' }); + setCopied(false); + } + + async function copyTxid(txid: string) { + await navigator.clipboard.writeText(txid); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + const isSubmitting = state.kind === 'submitting'; + const isSuccess = state.kind === 'success'; + + return ( +
+ {/* Header + status strip */} +
+
+

+ {'>'} TESTNET_FAUCET +

+

Get free testnet ZEC

+

+ {DISPENSE_AMOUNT_TAZ} TAZ per address, every {COOLDOWN_HOURS}h. Don't be a dick. +

+
+ +
+
+ balance{' '} + {stubStatus.balanceTAZ.toFixed(1)} TAZ +
+
+ last drip{' '} + + {stubStatus.lastDripTxid.slice(0, 8)}… + {' '} + · {stubStatus.lastDripAgoMin}m ago +
+
+
+ + {/* Mobile status strip */} +
+ + balance{' '} + {stubStatus.balanceTAZ.toFixed(1)} TAZ + + + last drip{' '} + + {stubStatus.lastDripTxid.slice(0, 6)}… + + +
+ + {/* Form / Result */} + {isSuccess ? ( + + +
+ SENT + + {DISPENSE_AMOUNT_TAZ} TAZ dispatched to your address + +
+ +
+
+ {'>'} TXID +
+
+ {state.txid} + +
+

+ Likely unconfirmed — confirmation in ~75 seconds. +

+
+ +
+ + view tx → + + +
+
+
+ ) : ( + + +
+
+ + { + setAddress(e.target.value); + if (state.kind === 'invalid') setState({ kind: 'idle' }); + }} + placeholder="tm..." + spellCheck={false} + autoComplete="off" + disabled={isSubmitting} + className="w-full bg-black/40 border border-cipher-border rounded-md px-3 py-2.5 font-mono text-sm text-primary placeholder:text-muted/40 focus:outline-none focus:border-cipher-cyan/60 focus:ring-1 focus:ring-cipher-cyan/30 transition-colors disabled:opacity-50" + /> + {state.kind === 'invalid' && ( +

+ invalid testnet address — expected tm… +

+ )} +
+ + {/* Captcha placeholder */} +
+ [ Turnstile widget · stub ] +
+ + +
+
+
+ )} + + {/* Rules card */} + + +

+ {'>'} RULES_OF_ENGAGEMENT +

+
    +
  • · {DISPENSE_AMOUNT_TAZ} TAZ per testnet address, max one per {COOLDOWN_HOURS}h
  • +
  • · transparent (tm…) addresses only · shielded support coming
  • +
  • · this is testnet ZEC — it has no monetary value, don't try
  • +
+
+
+ +

+ ui preview · backend wiring next +

+
+ ); +} diff --git a/app/faucet/page.tsx b/app/faucet/page.tsx new file mode 100644 index 0000000..90c78c0 --- /dev/null +++ b/app/faucet/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from 'next'; +import FaucetClient from './FaucetClient'; + +export const metadata: Metadata = { + title: 'Testnet Faucet — Get free TAZ | CipherScan', + description: + 'Free testnet ZEC delivered to any transparent address. 0.5 TAZ per address every 24 hours.', + openGraph: { + title: 'Zcash Testnet Faucet | CipherScan', + description: 'Get free testnet ZEC (TAZ) for development and testing.', + url: 'https://testnet.cipherscan.app/faucet', + siteName: 'CipherScan', + type: 'website', + }, + alternates: { + canonical: 'https://testnet.cipherscan.app/faucet', + }, +}; + +export default function FaucetPage() { + return ( +
+ +
+ ); +} From 03b5469d7b02b4c5da20517ebe2f0c9a95fa1ace Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Tue, 19 May 2026 09:57:50 +0530 Subject: [PATCH 03/35] feat(faucet): add donate card with QR and address (stub) --- app/faucet/FaucetClient.tsx | 61 +++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 5aff20e..0bb6e6e 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -2,11 +2,15 @@ import { useState } from 'react'; import Link from 'next/link'; +import { QRCodeSVG } from 'qrcode.react'; import { Card, CardBody } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; +import { useTheme } from '@/contexts/ThemeContext'; const DISPENSE_AMOUNT_TAZ = 0.5; const COOLDOWN_HOURS = 24; +// STUB: real address comes from env once wallet is provisioned +const FAUCET_DONATE_ADDRESS = 'tm9zNbDx7K2pVcRfYqWxJ8mE4hT3nL6Aoq5'; type SubmitState = | { kind: 'idle' } @@ -22,6 +26,9 @@ export default function FaucetClient() { const [address, setAddress] = useState(''); const [state, setState] = useState({ kind: 'idle' }); const [copied, setCopied] = useState(false); + const [addrCopied, setAddrCopied] = useState(false); + const { theme, mounted: themeMounted } = useTheme(); + const isDark = theme === 'dark'; // STUB: status will come from /api/faucet/status once backend lands const stubStatus = { @@ -58,6 +65,12 @@ export default function FaucetClient() { setTimeout(() => setCopied(false), 2000); } + async function copyDonateAddress() { + await navigator.clipboard.writeText(FAUCET_DONATE_ADDRESS); + setAddrCopied(true); + setTimeout(() => setAddrCopied(false), 2000); + } + const isSubmitting = state.kind === 'submitting'; const isSuccess = state.kind === 'success'; @@ -241,6 +254,54 @@ export default function FaucetClient() { + {/* Donate card */} + + +

+ {'>'} SUPPORT_THE_FAUCET +

+

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

+ +
+ {/* QR */} +
+ {themeMounted && ( + + )} +
+ + {/* Address + copy */} +
+
+ {'>'} ADDRESS +
+
+ {FAUCET_DONATE_ADDRESS} + +
+

+ transparent only · shielded donations coming +

+
+
+
+
+

ui preview · backend wiring next

From 70acc43132aa2c7ae53d8addcd96388a640bcb7e Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Tue, 19 May 2026 10:05:50 +0530 Subject: [PATCH 04/35] refactor(faucet): drop last-drip line and stub watermark --- app/faucet/FaucetClient.tsx | 41 ++++++++----------------------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 0bb6e6e..597c941 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -33,8 +33,6 @@ export default function FaucetClient() { // STUB: status will come from /api/faucet/status once backend lands const stubStatus = { balanceTAZ: 812.4, - lastDripTxid: '4f3b9c129a87d10e4d3fa1b2c5e6f0d2e8a9b3c4d5e6f708192a3b4c5d6e7f80a', - lastDripAgoMin: 2, }; async function handleSubmit(e: React.FormEvent) { @@ -88,36 +86,18 @@ export default function FaucetClient() {

-
-
- balance{' '} - {stubStatus.balanceTAZ.toFixed(1)} TAZ -
-
- last drip{' '} - - {stubStatus.lastDripTxid.slice(0, 8)}… - {' '} - · {stubStatus.lastDripAgoMin}m ago -
+
+ balance{' '} + + {stubStatus.balanceTAZ.toFixed(1)} TAZ +
- {/* Mobile status strip */} -
- - balance{' '} - {stubStatus.balanceTAZ.toFixed(1)} TAZ - - - last drip{' '} - - {stubStatus.lastDripTxid.slice(0, 6)}… - - + {/* Mobile balance */} +
+ balance{' '} + {stubStatus.balanceTAZ.toFixed(1)} TAZ
{/* Form / Result */} @@ -302,9 +282,6 @@ export default function FaucetClient() { -

- ui preview · backend wiring next -

); } From 6ff5cf5ab42aba56975dd27feb7ec3101aa1ce21 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Tue, 19 May 2026 10:30:45 +0530 Subject: [PATCH 05/35] feat(faucet): add /api/faucet routes and wallet RPC helper --- .env.example | 19 +++++ server/api/routes/faucet.js | 137 ++++++++++++++++++++++++++++++++++++ server/api/server.js | 84 ++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 server/api/routes/faucet.js diff --git a/.env.example b/.env.example index c00380c..bb6d868 100644 --- a/.env.example +++ b/.env.example @@ -22,4 +22,23 @@ NEXT_PUBLIC_HELIUS_API_KEY= # Leave empty to fall back to HTTP polling ZEBRA_GRPC_URL=127.0.0.1:8230 +# Testnet Faucet +# Wallet RPC (zcashd or Zallet) — speaks sendtoaddress / getbalance. +# Same JSON-RPC shape as Zebra, but targets the wallet daemon. +WALLET_RPC_URL=http://127.0.0.1:18232 +WALLET_RPC_USER= +WALLET_RPC_PASSWORD= +# Optional: cookie-file auth (alternative to user/password) +# WALLET_RPC_COOKIE_FILE=/root/.zcash/testnet3/.cookie + +# How much TAZ each dispense sends (decimal ZEC, not zatoshis) +FAUCET_DISPENSE_AMOUNT_TAZ=0.5 + +# Optional protections — both default to OFF. Set to enable. +# Per-address cooldown in seconds (0 = no cooldown) +FAUCET_COOLDOWN_SECONDS=0 +# Cloudflare Turnstile (set both keys to enable captcha) +TURNSTILE_SECRET_KEY= +NEXT_PUBLIC_TURNSTILE_SITE_KEY= + # Optional: Analytics, etc. diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js new file mode 100644 index 0000000..6070124 --- /dev/null +++ b/server/api/routes/faucet.js @@ -0,0 +1,137 @@ +/** + * Testnet Faucet + * GET /api/faucet/status → wallet balance + dispense amount + * POST /api/faucet/dispense → send TAZ to a transparent address + * + * Captcha (Turnstile) and per-address cooldown are both feature-flagged + * via env vars — see .env.example. Unset = disabled, no code change needed + * to enable. + */ + +const express = require('express'); +const router = express.Router(); + +const DEFAULT_DISPENSE_TAZ = 0.5; +const ADDRESS_REGEX = /^tm[a-zA-Z0-9]{32,40}$/; + +function dispenseAmountTaz() { + const raw = parseFloat(process.env.FAUCET_DISPENSE_AMOUNT_TAZ); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DISPENSE_TAZ; +} + +function cooldownSeconds() { + const raw = parseInt(process.env.FAUCET_COOLDOWN_SECONDS, 10); + return Number.isFinite(raw) && raw > 0 ? raw : 0; +} + +async function verifyTurnstile(token, remoteIp) { + const secret = process.env.TURNSTILE_SECRET_KEY; + if (!secret) return true; // captcha disabled + if (!token) return false; + + try { + const body = new URLSearchParams({ 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; + } +} + +router.get('/api/faucet/status', async (req, res) => { + const callWalletRPC = req.app.locals.callWalletRPC; + if (!callWalletRPC) { + return res.status(503).json({ error: 'wallet RPC not configured' }); + } + + try { + const balance = await callWalletRPC('getbalance', []); + res.json({ + balanceTaz: typeof balance === 'number' ? balance : parseFloat(balance) || 0, + dispenseAmountTaz: dispenseAmountTaz(), + cooldownSeconds: cooldownSeconds(), + captchaEnabled: !!process.env.TURNSTILE_SECRET_KEY, + }); + } catch (err) { + console.error('[faucet] status failed:', err.message); + res.status(502).json({ error: 'wallet unreachable' }); + } +}); + +router.post('/api/faucet/dispense', express.json(), async (req, res) => { + const callWalletRPC = req.app.locals.callWalletRPC; + const redisClient = req.app.locals.redisClient; + if (!callWalletRPC) { + return res.status(503).json({ error: 'wallet RPC not configured' }); + } + + const { address, captchaToken } = req.body || {}; + + if (!address || typeof address !== 'string' || !ADDRESS_REGEX.test(address.trim())) { + return res.status(400).json({ error: 'invalid address' }); + } + const addr = address.trim(); + + const captchaOk = await verifyTurnstile(captchaToken, req.ip); + if (!captchaOk) { + return res.status(400).json({ error: 'captcha failed' }); + } + + const cdSec = cooldownSeconds(); + if (cdSec > 0 && redisClient) { + const key = `faucet:cooldown:${addr}`; + try { + const existing = await redisClient.get(key); + if (existing) { + const ttl = await redisClient.ttl(key); + return res.status(429).json({ + error: 'cooldown', + retryAfterSeconds: ttl > 0 ? ttl : cdSec, + }); + } + } catch (err) { + console.error('[faucet] cooldown check failed:', err.message); + } + } + + const amount = dispenseAmountTaz(); + + let balance; + try { + balance = await callWalletRPC('getbalance', []); + } catch (err) { + console.error('[faucet] balance check failed:', err.message); + return res.status(502).json({ error: 'wallet unreachable' }); + } + if (typeof balance === 'number' && balance < amount) { + return res.status(503).json({ error: 'drained', balanceTaz: balance }); + } + + let txid; + try { + txid = await callWalletRPC('sendtoaddress', [addr, amount]); + } catch (err) { + console.error('[faucet] sendtoaddress failed:', err.message); + return res.status(502).json({ error: 'send failed', detail: err.message }); + } + + if (cdSec > 0 && redisClient) { + try { + await redisClient.set(`faucet:cooldown:${addr}`, '1', { EX: cdSec }); + } catch (err) { + console.error('[faucet] cooldown set failed:', err.message); + } + } + + console.log(`[faucet] dispensed ${amount} TAZ to ${addr.slice(0, 10)}… txid=${txid}`); + res.json({ txid, amountTaz: amount }); +}); + +module.exports = router; diff --git a/server/api/server.js b/server/api/server.js index b613d59..1aa8460 100644 --- a/server/api/server.js +++ b/server/api/server.js @@ -32,6 +32,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 { @@ -164,6 +165,85 @@ function getZebraAuth() { return _zebraAuth; } +// Wallet RPC (zcashd or Zallet) — same JSON-RPC shape as Zebra but +// targets a wallet daemon. Used by the faucet for sendtoaddress/getbalance. +const walletAgent = new (require('http').Agent)({ + keepAlive: true, + maxSockets: 4, + maxFreeSockets: 2, + timeout: 10000, +}); + +let _walletAuth = null; +function getWalletAuth() { + if (_walletAuth !== null) return _walletAuth; + const cookieFile = process.env.WALLET_RPC_COOKIE_FILE; + if (cookieFile) { + try { + const cookie = fs.readFileSync(cookieFile, 'utf8').trim(); + if (cookie) { + _walletAuth = Buffer.from(cookie).toString('base64'); + return _walletAuth; + } + } catch {} + } + const user = process.env.WALLET_RPC_USER || ''; + const password = process.env.WALLET_RPC_PASSWORD || ''; + _walletAuth = Buffer.from(`${user}:${password}`).toString('base64'); + return _walletAuth; +} + +async function callWalletRPC(method, params = [], { timeout = 30000 } = {}) { + const rpcUrl = process.env.WALLET_RPC_URL || 'http://127.0.0.1:18232'; + const auth = getWalletAuth(); + const requestBody = JSON.stringify({ + jsonrpc: '1.0', + id: 'cipherscan-wallet', + method, + params, + }); + const url = new URL(rpcUrl); + + return new Promise((resolve, reject) => { + const req = require('http').request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + agent: walletAgent, + timeout, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody), + 'Authorization': `Basic ${auth}`, + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + const response = JSON.parse(data); + if (response.error) { + reject(new Error(response.error.message || 'wallet RPC error')); + } else { + resolve(response.result); + } + } catch (error) { + reject(new Error(`Failed to parse wallet RPC response: ${data.slice(0, 120)}`)); + } + }); + } + ); + + req.on('timeout', () => { req.destroy(new Error('wallet RPC timeout')); }); + req.on('error', (error) => { reject(new Error(`wallet RPC request failed: ${error.message}`)); }); + req.write(requestBody); + req.end(); + }); +} + async function callZebraRPC(method, params = [], { timeout = 8000 } = {}) { const rpcUrl = process.env.ZEBRA_RPC_URL || 'http://127.0.0.1:18232'; const auth = getZebraAuth(); @@ -327,6 +407,7 @@ app.use((req, res, next) => { // Make additional dependencies available to routes app.locals.callZebraRPC = callZebraRPC; +app.locals.callWalletRPC = callWalletRPC; app.locals.CompactTxStreamer = CompactTxStreamer; app.locals.grpc = grpc; app.locals.findLinkedTransactions = findLinkedTransactions; @@ -372,6 +453,9 @@ app.use(blendCheckRouter); // Crosslink routes: /api/crosslink app.use(crosslinkRouter); +// Faucet routes: /api/faucet/* +app.use(faucetRouter); + // ============================================================================ // WEBSOCKET SERVER (Real-time updates) // ============================================================================ From bfc4e5899937477e8b70e959f26f482364fdc6cf Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Tue, 19 May 2026 12:48:07 +0530 Subject: [PATCH 06/35] feat(faucet): wire FaucetClient to /api/faucet status and dispense --- app/faucet/FaucetClient.tsx | 146 +++++++++++++++++++++++++++++------- 1 file changed, 118 insertions(+), 28 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 597c941..e5faa80 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -1,22 +1,41 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; import { QRCodeSVG } from 'qrcode.react'; import { Card, CardBody } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { useTheme } from '@/contexts/ThemeContext'; +import { getApiUrl } from '@/lib/api-config'; -const DISPENSE_AMOUNT_TAZ = 0.5; -const COOLDOWN_HOURS = 24; +const FALLBACK_DISPENSE_TAZ = 0.5; // STUB: real address comes from env once wallet is provisioned const FAUCET_DONATE_ADDRESS = 'tm9zNbDx7K2pVcRfYqWxJ8mE4hT3nL6Aoq5'; +interface FaucetStatus { + balanceTaz: number; + dispenseAmountTaz: number; + cooldownSeconds: number; + captchaEnabled: boolean; +} + type SubmitState = | { kind: 'idle' } | { kind: 'submitting' } | { kind: 'success'; txid: string } - | { kind: 'invalid' }; + | { kind: 'invalid' } + | { kind: 'cooldown'; retryAfterSeconds: number } + | { kind: 'drained' } + | { kind: 'error'; message: string }; + +function formatRetry(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.ceil(seconds / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + const rem = m % 60; + return rem === 0 ? `${h}h` : `${h}h ${rem}m`; +} function isValidTestnetTransparentAddress(addr: string): boolean { return /^tm[a-zA-Z0-9]{32,40}$/.test(addr.trim()); @@ -27,13 +46,30 @@ export default function FaucetClient() { const [state, setState] = useState({ kind: 'idle' }); const [copied, setCopied] = useState(false); const [addrCopied, setAddrCopied] = useState(false); + const [status, setStatus] = useState(null); const { theme, mounted: themeMounted } = useTheme(); const isDark = theme === 'dark'; - // STUB: status will come from /api/faucet/status once backend lands - const stubStatus = { - balanceTAZ: 812.4, - }; + const dispenseAmount = status?.dispenseAmountTaz ?? FALLBACK_DISPENSE_TAZ; + const cooldownEnabled = (status?.cooldownSeconds ?? 0) > 0; + + 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); + }; + }, []); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -43,12 +79,48 @@ export default function FaucetClient() { return; } setState({ kind: 'submitting' }); - // STUB: simulate a 1.5s dispense - await new Promise((r) => setTimeout(r, 1500)); - setState({ - kind: 'success', - txid: '4f3b9c129a87d10e4d3fa1b2c5e6f0d2e8a9b3c4d5e6f708192a3b4c5d6e7f80a', - }); + + try { + const res = await fetch(`${getApiUrl()}/api/faucet/dispense`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: trimmed }), + }); + const data = await res.json().catch(() => ({})); + + if (res.ok && data.txid) { + setState({ kind: 'success', txid: data.txid }); + return; + } + + switch (data.error) { + case 'invalid address': + setState({ kind: 'invalid' }); + break; + case 'cooldown': + setState({ + kind: 'cooldown', + retryAfterSeconds: data.retryAfterSeconds ?? 86400, + }); + break; + case 'drained': + setState({ kind: 'drained' }); + break; + case 'captcha failed': + setState({ kind: 'error', message: 'captcha verification failed' }); + break; + default: + setState({ + kind: 'error', + message: data.error || data.detail || 'something broke, try again', + }); + } + } catch (err) { + setState({ + kind: 'error', + message: err instanceof Error ? err.message : 'network error', + }); + } } function reset() { @@ -82,14 +154,15 @@ export default function FaucetClient() {

Get free testnet ZEC

- {DISPENSE_AMOUNT_TAZ} TAZ per address, every {COOLDOWN_HOURS}h. Don't be a dick. + {dispenseAmount} TAZ per address + {cooldownEnabled && `, every ${formatRetry(status!.cooldownSeconds)}`}. Don't be a dick.

balance{' '} - {stubStatus.balanceTAZ.toFixed(1)} TAZ + {status ? `${status.balanceTaz.toFixed(1)} TAZ` : '…'}
@@ -97,7 +170,9 @@ export default function FaucetClient() { {/* Mobile balance */}
balance{' '} - {stubStatus.balanceTAZ.toFixed(1)} TAZ + + {status ? `${status.balanceTaz.toFixed(1)} TAZ` : '…'} +
{/* Form / Result */} @@ -107,7 +182,7 @@ export default function FaucetClient() {
SENT - {DISPENSE_AMOUNT_TAZ} TAZ dispatched to your address + {dispenseAmount} TAZ dispatched to your address
@@ -165,7 +240,9 @@ export default function FaucetClient() { value={address} onChange={(e) => { setAddress(e.target.value); - if (state.kind === 'invalid') setState({ kind: 'idle' }); + if (state.kind !== 'idle' && state.kind !== 'submitting' && state.kind !== 'success') { + setState({ kind: 'idle' }); + } }} placeholder="tm..." spellCheck={false} @@ -178,16 +255,26 @@ export default function FaucetClient() { invalid testnet address — expected tm…

)} - - - {/* Captcha placeholder */} -
- [ Turnstile widget · stub ] + {state.kind === 'cooldown' && ( +

+ cooldown active — try again in {formatRetry(state.retryAfterSeconds)} +

+ )} + {state.kind === 'drained' && ( +

+ faucet is dry — mining the next refill, check back later +

+ )} + {state.kind === 'error' && ( +

+ {state.message} +

+ )}
@@ -227,7 +314,10 @@ export default function FaucetClient() { {'>'} RULES_OF_ENGAGEMENT
    -
  • · {DISPENSE_AMOUNT_TAZ} TAZ per testnet address, max one per {COOLDOWN_HOURS}h
  • +
  • + · {dispenseAmount} TAZ per testnet address + {cooldownEnabled && `, max one per ${formatRetry(status!.cooldownSeconds)}`} +
  • · transparent (tm…) addresses only · shielded support coming
  • · this is testnet ZEC — it has no monetary value, don't try
From a391cb03446ec06137b4fb1500d153a529f2088d Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Tue, 19 May 2026 13:14:49 +0530 Subject: [PATCH 07/35] feat(faucet): integrate Cloudflare Turnstile widget (gated on site key env var) --- app/faucet/FaucetClient.tsx | 44 ++++++- package-lock.json | 221 +++++++++++++++++++----------------- package.json | 1 + 3 files changed, 158 insertions(+), 108 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index e5faa80..4cd42b1 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -1,13 +1,16 @@ 'use client'; -import { useEffect, useState } from 'react'; +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 { useTheme } from '@/contexts/ThemeContext'; import { getApiUrl } from '@/lib/api-config'; +const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''; + const FALLBACK_DISPENSE_TAZ = 0.5; // STUB: real address comes from env once wallet is provisioned const FAUCET_DONATE_ADDRESS = 'tm9zNbDx7K2pVcRfYqWxJ8mE4hT3nL6Aoq5'; @@ -47,8 +50,11 @@ export default function FaucetClient() { const [copied, setCopied] = useState(false); const [addrCopied, setAddrCopied] = useState(false); const [status, setStatus] = useState(null); + const [captchaToken, setCaptchaToken] = useState(null); + const turnstileRef = useRef(null); const { theme, mounted: themeMounted } = useTheme(); const isDark = theme === 'dark'; + const captchaRequired = !!TURNSTILE_SITE_KEY; const dispenseAmount = status?.dispenseAmountTaz ?? FALLBACK_DISPENSE_TAZ; const cooldownEnabled = (status?.cooldownSeconds ?? 0) > 0; @@ -78,13 +84,17 @@ export default function FaucetClient() { setState({ kind: 'invalid' }); return; } + if (captchaRequired && !captchaToken) { + setState({ kind: 'error', message: 'complete the captcha first' }); + return; + } setState({ kind: 'submitting' }); try { const res = await fetch(`${getApiUrl()}/api/faucet/dispense`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address: trimmed }), + body: JSON.stringify({ address: trimmed, captchaToken }), }); const data = await res.json().catch(() => ({})); @@ -93,6 +103,11 @@ export default function FaucetClient() { return; } + // Any non-success response invalidates the captcha token — reset the widget + // so the user gets a fresh one for the next attempt. + turnstileRef.current?.reset(); + setCaptchaToken(null); + switch (data.error) { case 'invalid address': setState({ kind: 'invalid' }); @@ -116,6 +131,8 @@ export default function FaucetClient() { }); } } catch (err) { + turnstileRef.current?.reset(); + setCaptchaToken(null); setState({ kind: 'error', message: err instanceof Error ? err.message : 'network error', @@ -272,9 +289,30 @@ export default function FaucetClient() { )} + {captchaRequired && ( +
+ setCaptchaToken(token)} + onExpire={() => setCaptchaToken(null)} + onError={() => setCaptchaToken(null)} + options={{ + theme: isDark ? 'dark' : 'light', + size: 'normal', + }} + /> +
+ )} + + + {/* Wallet picker dropdown — wallets available */} + {showWalletPicker && chainWallets.length > 0 && ( +
+
+ Select wallet + {wallet.connected && ( + + )} +
+ {chainWallets.map((w) => ( + + ))} +
+ )} + + {/* No wallet info panel */} + {showWalletPicker && chainWallets.length === 0 && ( +
+
+ No {selectedToken.chainLabel} wallet +
+
+
+
+ + + +
+

+ No wallet needed.{' '} + Fill in the form and you'll get a deposit address to send funds manually. +

+
+
+
+ + + +
+

+ For auto-send, install a {selectedToken.chainLabel} wallet extension + {(() => { + const chain = selectedToken.chain; + if (['eth', 'base', 'arb', 'op', 'pol', 'avax', 'bsc', 'gnosis', 'bera', 'scroll'].includes(chain)) + return <> like MetaMask; + if (chain === 'sol') + return <> like Phantom; + if (chain === 'tron') + return <> like TronLink; + return null; + })()} + . +

+
+
+
+ +
+
+ )} + + )} + + + +
+ + {/* ─── CONNECT WALLET (gate step) ─── */} + {step === 'connect' && ( +
+
+

Connect Wallet

+

+ Select a wallet to auto-fill addresses and send directly. +

+
+ + {wallet.allWallets.length > 0 ? ( +
+ {(() => { + const typeMap = new Map(); + for (const w of wallet.allWallets) { + if (!w.type) continue; + const arr = typeMap.get(w.type) || []; + arr.push(w); + typeMap.set(w.type, arr); + } + const chainLabels: Record = { + evm: 'EVM Chains', + solana: 'Solana', + tron: 'Tron', + bitcoin: 'Bitcoin', + }; + return Array.from(typeMap.entries()).map(([type, wallets]) => ( +
+
{chainLabels[type] || type}
+
+ {wallets.map(w => ( + + ))} +
+
+ )); + })()} +
+ ) : ( +
+
+

Detecting wallets...

+
+ )} + + {walletError && ( +
+ {walletError} +
+ )} + +
+ +
+
+ )} + + {/* ─── FORM ─── */} + {step === 'form' && ( +
+ + {/* Step guide */} +
+ 1. Pick asset + {'>'} + 2. Amount & addresses + {'>'} + 3. Get quote +
+ + {/* From — asset selector (prominent first row) */} +
+ +
+ + + {/* Token picker dropdown */} + {showTokenPicker && ( + <> +
{ setShowTokenPicker(false); setTokenSearch(''); }} /> +
+
+ setTokenSearch(e.target.value)} + placeholder="Search token or chain..." + autoFocus + className="w-full px-3 py-2 rounded-lg bg-glass-4 text-primary font-mono text-sm placeholder:text-muted/40 focus:outline-none" + /> +
+
+ {tokensLoading ? ( +
+
+
Loading tokens...
+
+ ) : filteredTokens.length === 0 ? ( +
No tokens found
+ ) : ( + filteredTokens.map(t => ( + + )) + )} +
+
+ + )} +
+
+ + {/* Amount input (own row) */} +
+
+ + {wallet.connected && nativeBalance && ( +
+ + {parseFloat(nativeBalance).toLocaleString(undefined, { maximumFractionDigits: 4 })} {selectedToken.token} + + + +
+ )} +
+
+ { + const v = e.target.value; + if (v === '' || /^\d*\.?\d*$/.test(v)) setAmount(v); + }} + placeholder="0.00" + className="flex-1 min-w-0 px-4 py-3 bg-transparent text-primary font-mono text-lg placeholder:text-muted/30 focus:outline-none" + /> +
+ + {selectedToken.token} +
+
+ + {/* Privacy recommendation chips — amounts that blend on both source chain and ZEC side */} + {recommendations && recommendations.amounts.length > 0 && (() => { + const chips = recommendations.amounts + .filter(a => a.sourceAmount && a.sourceAmount > 0) + .filter(a => !a.sourceToken || a.sourceToken.toUpperCase() === selectedToken.token.toUpperCase()) + .slice(0, 4); + if (chips.length === 0) return null; + return ( +
+ {chips.map((rec, i) => { + const label = getBlendingLabel(rec.blendingScore, rec.dualBlendScore); + const token = rec.sourceToken || selectedToken.token; + return ( + + ); + })} +
+ ); + })()} +
+ + {/* Arrow divider */} +
+
+
+ + + +
+
+
+ + {/* To section */} +
+ +
+ setZecAddress(e.target.value)} + placeholder="Paste t1 or u1 address" + className="flex-1 min-w-0 px-4 py-3 bg-transparent text-primary font-mono text-sm placeholder:text-muted/30 focus:outline-none" + /> +
+ + ZEC +
+
+ {zecAddress && zecAddrError && ( +

{zecAddrError}

+ )} +
+ + {/* Return address — hidden when wallet connected, shown as fallback for manual users */} + {wallet.connected ? ( +
+
+ + Returns to {wallet.address?.slice(0, 6)}...{wallet.address?.slice(-4)} if swap fails +
+ +
+ ) : ( +
+
+ + +
+ setRefundAddress(e.target.value)} + placeholder={`The address you're sending from`} + className="w-full px-4 py-3 rounded-lg bg-glass-3 border border-glass-6 text-primary font-mono text-sm placeholder:text-muted/30 focus:outline-none focus:border-cipher-cyan/40 focus:shadow-[0_0_0_3px_rgb(var(--color-cyan-rgb)_/_0.06)] transition-all" + /> + {!refundAddress && ( +

+ Paste the {selectedToken.chainLabel} address you'll send from. Funds return here if the swap can't complete. +

+ )} +
+ )} + + {/* Slippage (expandable) */} + {showSlippage && ( +
+ +
+ {[{ label: '0.5%', value: 50 }, { label: '1%', value: 100 }, { label: '2%', value: 200 }].map(opt => ( + + ))} +
+
+ )} + + {/* Error */} + {(error || walletError) && ( +
+ {error || walletError} +
+ )} + + {/* CTA — smart contextual button */} + {(() => { + const needsWallet = !wallet.connected && chainWallets.length > 0 && amount && zecAddress && !zecAddrError && !effectiveRefundAddress; + return ( + + ); + })()} + + {/* Fee note */} +

+ Powered by NEAR Intents · Slippage: {slippage / 100}% +

+
+ )} + + {/* ─── QUOTE REVIEW ─── */} + {step === 'quote' && quote && ( +
+ {/* Summary */} +
+
+
+ +
+
{amount} {selectedToken.token}
+
{selectedToken.chainLabel}
+
+
+ + + +
+
+
{estimatedZec || '~'} ZEC
+
Estimated
+
+ +
+
+ +
+
+ Slippage + {slippage / 100}% +
+
+ Destination + {zecAddress.slice(0, 10)}...{zecAddress.slice(-6)} +
+
+
+ + {quoteTimeLeft > 0 && ( +
+
30 ? 'bg-cipher-green' : quoteTimeLeft > 10 ? 'bg-cipher-yellow' : 'bg-red-500 animate-pulse'}`} /> + 30 ? 'text-muted' : quoteTimeLeft > 10 ? 'text-cipher-yellow' : 'text-red-500'}> + Quote expires in {Math.floor(quoteTimeLeft / 60)}:{(quoteTimeLeft % 60).toString().padStart(2, '0')} + +
+ )} + +
+ + +
+
+ )} + + {/* ─── WAITING FOR DEPOSIT ─── */} + {step === 'waiting' && ( +
+ {/* Swap summary row */} +
+
+
+ +
+
{amount} {selectedToken.token}
+
{selectedToken.chainLabel}
+
+
+ + + +
+
+
{estimatedZec || '~'} ZEC
+
Estimated
+
+ +
+
+
+ + {/* Status indicator */} +
+
+ + {swapStatus ? swapStatus.replace(/_/g, ' ') : 'Waiting for deposit'} + +
+ + {/* One-click wallet send */} + {wallet.connected && !txHash && ( + + )} + + {txHash && ( +
+
+
+ + Sent + {txHash.slice(0, 8)}...{txHash.slice(-6)} +
+
+ + {CHAIN_EXPLORERS[selectedToken.chain] && ( + + + + )} +
+
+
+ )} + + {walletError &&

{walletError}

} + + {/* Manual deposit section */} + {(!wallet.connected || (wallet.connected && !txHash)) && ( + <> + {wallet.connected && ( +
+
+ or send manually +
+
+ )} + +
+
Deposit address
+
+ {depositAddress} + +
+
+ + )} + + {txHash ? ( +
+ + Swap will complete automatically + + +
+ ) : ( + + )} +
+ )} + + {/* ─── COMPLETE ─── */} + {step === 'complete' && ( +
+
+
+
+ + + +
+
+

Swap Complete

+

{estimatedZec} ZEC sent to your address

+
+
+
+ +
+ )} + + {/* ─── ERROR ─── */} + {step === 'error' && ( +
+
+
+
+ + + +
+
+

Swap Failed

+

{error}

+
+
+
+ +
+ )} +
+
+
+ + {/* ─── Sidebar ─── */} +
+ + {/* Why connect — only on connect step */} + {step === 'connect' && ( +
+
+ > Why_connect +
+
+ {[ + { label: 'Filtered tokens', desc: 'Only see assets your wallet supports' }, + { label: 'Auto-fill addresses', desc: 'No copy-pasting needed for refunds' }, + { label: 'One-click send', desc: 'Send directly from CipherScan' }, + ].map(item => ( +
+ +
+
{item.label}
+
{item.desc}
+
+
+ ))} +
+
+ )} + + {/* Privacy tips — only on form step */} + {step === 'form' && ( +
+
+ > Privacy_tips +
+
+ {recommendations && recommendations.amounts.length > 0 ? (() => { + const withSource = recommendations.amounts + .filter(a => a.sourceAmount && a.sourceAmount > 0) + .filter(a => !a.sourceToken || a.sourceToken.toUpperCase() === selectedToken.token.toUpperCase()); + return ( +
+

+ {withSource.length > 0 + ? `Amounts that blend in on both the ${selectedToken.chain.toUpperCase()} and Zcash sides for maximum privacy.` + : 'Common ZEC shielding amounts. Using popular amounts improves privacy.'} +

+
+ {(withSource.length > 0 ? withSource : recommendations.amounts).slice(0, 5).map((rec, i) => { + const label = getBlendingLabel(rec.blendingScore, rec.dualBlendScore); + const token = rec.sourceToken || selectedToken.token; + const displayAmount = rec.sourceAmount || rec.amountZec; + const displayToken = rec.sourceAmount ? token : 'ZEC'; + return ( + + ); + })} +
+ {recommendations.tip && ( +

{recommendations.tip}

+ )} +
+ ); + })() : ( +

+ Privacy recommendations appear once swap data is collected. +

+ )} +
+
+ )} + + {/* Estimated time — shown during quote/waiting */} + {(step === 'quote' || step === 'waiting') && ( +
+
+ > Estimated_time +
+
+
+
+ +
+
+
+ {(() => { + const c = selectedToken.chain; + if (c === 'sol') return '~1-2 min'; + if (['near', 'ton', 'sui'].includes(c)) return '~2-5 min'; + if (['eth', 'base', 'arb', 'op', 'pol', 'avax', 'bsc', 'gnosis', 'bera', 'scroll'].includes(c)) return '~5-15 min'; + if (['tron'].includes(c)) return '~3-10 min'; + if (['btc', 'ltc', 'bch', 'doge', 'dash'].includes(c)) return '~20-60 min'; + return '~5-30 min'; + })()} +
+
via {selectedToken.chainLabel}
+
+
+ +
+
+
+ Deposit to bridge address +
+
+
+ NEAR Intents bridging +
+
+
+ ZEC sent to your address +
+
+ +

+ Times depend on {selectedToken.chainLabel} block confirmations and NEAR solver availability. +

+
+
+ )} + + {/* Complete/error — minimal */} + {(step === 'complete' || step === 'error') && ( +
+
+

+ {step === 'complete' + ? 'Your ZEC has arrived. For maximum privacy, shield your balance using a wallet that supports Orchard.' + : 'If your swap failed, funds are returned to your refund address automatically.'} +

+
+
+ )} + +
+ + View Crosschain Analytics + + +
+
+
+
+ ); +} diff --git a/app/swap/page.tsx b/app/swap/page.tsx index 39b7459..0038a77 100644 --- a/app/swap/page.tsx +++ b/app/swap/page.tsx @@ -1,1558 +1,8 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import Link from 'next/link'; +import { notFound } from 'next/navigation'; import { isMainnet } from '@/lib/config'; -import { API_CONFIG } from '@/lib/api-config'; -import { TokenChainIcon } from '@/components/TokenChainIcon'; -import { useWallet, chainToWalletType, type DetectedWallet } from '@/hooks/useWallet'; - -const BASE58_CHARS = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; - -function validateZecAddress(addr: string): string | null { - if (!addr) return null; - if (addr.startsWith('u1') || addr.startsWith('utest')) { - if (addr.length < 80) return 'Unified address too short'; - return null; - } - if (addr.startsWith('zs') || addr.startsWith('ztestsapling')) { - if (addr.length < 70) return 'Sapling address too short'; - return null; - } - if (!addr.startsWith('t1') && !addr.startsWith('t3')) - return 'Must start with t1, t3, u1, or zs'; - if (!BASE58_CHARS.test(addr)) - return 'Contains invalid characters'; - if (addr.length !== 35) - return `Transparent address must be 35 characters (currently ${addr.length})`; - return null; -} - -interface CommonAmount { - amountZec: number; - txCount: number; - percentage: string; - blendingScore: number; - chainSwapCount?: number; - sourceAmount?: number | null; - sourceToken?: string | null; - dualBlendScore?: number; -} - -interface CommonAmountsResponse { - success: boolean; - period: string; - chain: string | null; - totalTransactions: number; - amounts: CommonAmount[]; - tip: string; -} - -function formatRecAmount(amount: number, token: string): string { - const t = token.toLowerCase(); - if (['usdc', 'usdt', 'dai', 'busd', 'tusd', 'usdp'].includes(t)) { - return amount >= 1 ? amount.toLocaleString(undefined, { maximumFractionDigits: 0 }) : amount.toString(); - } - if (amount === 0) return '0'; - if (amount >= 100) return amount.toLocaleString(undefined, { maximumFractionDigits: 2 }); - if (amount >= 1) return amount.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 3 }); - if (amount >= 0.01) return amount.toFixed(3); - return amount.toFixed(4); -} - -function getBlendingLabel(score: number, dualScore?: number): 'high' | 'medium' | 'low' { - const effective = dualScore || score; - if (effective >= 30) return 'high'; - if (effective >= 10) return 'medium'; - return 'low'; -} - -interface QuoteResponse { - success: boolean; - depositAddress?: string; - amountOut?: string; - estimatedAmountOut?: string; - deadline?: string; - error?: string; -} - -type SwapStep = 'connect' | 'form' | 'quote' | 'waiting' | 'complete' | 'error'; - -const PENDING_SWAP_KEY = 'cipherscan_pending_swap'; - -interface PendingSwap { - depositAddress: string; - amount: string; - token: string; - chain: string; - chainLabel: string; - assetId: string; - decimals: number; - contractAddress?: string; - zecAddress: string; - estimatedZec: string; - txHash?: string; - createdAt: number; -} - -interface SourceToken { - id: string; - chain: string; - chainLabel: string; - token: string; - decimals: number; - assetId: string; - contractAddress?: string; -} - -const CHAIN_EXPLORERS: Record = { - eth: 'https://etherscan.io/tx/', - base: 'https://basescan.org/tx/', - arb: 'https://arbiscan.io/tx/', - op: 'https://optimistic.etherscan.io/tx/', - pol: 'https://polygonscan.com/tx/', - avax: 'https://snowtrace.io/tx/', - bsc: 'https://bscscan.com/tx/', - sol: 'https://solscan.io/tx/', - btc: 'https://mempool.space/tx/', - near: 'https://nearblocks.io/txns/', - gnosis: 'https://gnosisscan.io/tx/', - bera: 'https://berascan.com/tx/', - scroll: 'https://scrollscan.com/tx/', - tron: 'https://tronscan.org/#/transaction/', -}; - -const CHAIN_LABELS: Record = { - eth: 'Ethereum', base: 'Base', arb: 'Arbitrum', sol: 'Solana', btc: 'Bitcoin', - near: 'NEAR', ton: 'TON', doge: 'Dogecoin', xrp: 'XRP', bsc: 'BNB Chain', - pol: 'Polygon', tron: 'Tron', sui: 'Sui', op: 'Optimism', avax: 'Avalanche', - ltc: 'Litecoin', bch: 'Bitcoin Cash', gnosis: 'Gnosis', bera: 'Berachain', - cardano: 'Cardano', starknet: 'Starknet', zec: 'Zcash', aleo: 'Aleo', - xlayer: 'XLayer', monad: 'Monad', adi: 'ADI', plasma: 'Plasma', scroll: 'Scroll', - dash: 'Dash', -}; - -const FALLBACK_TOKEN_ORDER = ['usdc', 'eth', 'usdt', 'btc', 'sol', 'bnb', 'near', 'dai', 'doge', 'xrp', 'ton', 'ltc']; -const FALLBACK_CHAIN_ORDER = ['eth', 'sol', 'base', 'arb', 'btc', 'bsc', 'op', 'pol', 'avax', 'near', 'ton', 'doge', 'xrp', 'ltc', 'sui', 'tron']; - -interface PopularPair { chain: string; token: string; swapCount: number } - -function sortTokens(tokens: SourceToken[], popularPairs: PopularPair[]): SourceToken[] { - if (popularPairs.length > 0) { - const pairRank = new Map(); - popularPairs.forEach((p, i) => { - pairRank.set(`${p.chain.toLowerCase()}:${p.token.toLowerCase()}`, i); - }); - return [...tokens].sort((a, b) => { - const aKey = `${a.chain.toLowerCase()}:${a.token.toLowerCase()}`; - const bKey = `${b.chain.toLowerCase()}:${b.token.toLowerCase()}`; - const aRank = pairRank.get(aKey) ?? 9999; - const bRank = pairRank.get(bKey) ?? 9999; - if (aRank !== bRank) return aRank - bRank; - const aToken = FALLBACK_TOKEN_ORDER.indexOf(a.token.toLowerCase()); - const bToken = FALLBACK_TOKEN_ORDER.indexOf(b.token.toLowerCase()); - if ((aToken >= 0 ? aToken : 999) !== (bToken >= 0 ? bToken : 999)) - return (aToken >= 0 ? aToken : 999) - (bToken >= 0 ? bToken : 999); - return a.chainLabel.localeCompare(b.chainLabel); - }); - } - return [...tokens].sort((a, b) => { - const aToken = FALLBACK_TOKEN_ORDER.indexOf(a.token.toLowerCase()); - const bToken = FALLBACK_TOKEN_ORDER.indexOf(b.token.toLowerCase()); - const aRank = aToken >= 0 ? aToken : 999; - const bRank = bToken >= 0 ? bToken : 999; - if (aRank !== bRank) return aRank - bRank; - const aChain = FALLBACK_CHAIN_ORDER.indexOf(a.chain.toLowerCase()); - const bChain = FALLBACK_CHAIN_ORDER.indexOf(b.chain.toLowerCase()); - if ((aChain >= 0 ? aChain : 999) !== (bChain >= 0 ? bChain : 999)) - return (aChain >= 0 ? aChain : 999) - (bChain >= 0 ? bChain : 999); - return a.chainLabel.localeCompare(b.chainLabel); - }); -} - -function apiTokensToSourceTokens(apiTokens: any[]): SourceToken[] { - return apiTokens - .filter(t => { - if (!t.assetId || !t.symbol || !t.blockchain || t.decimals == null) return false; - const id = t.assetId.toLowerCase(); - if (id.includes('zec') || t.blockchain === 'zec') return false; - return true; - }) - .map(t => { - let contractAddress: string | undefined; - if (t.address) contractAddress = t.address; - else if (t.contractAddress) contractAddress = t.contractAddress; - else if (t.assetId) { - const evmMatch = t.assetId.match(/0x[a-fA-F0-9]{40}/); - if (evmMatch) { - contractAddress = evmMatch[0]; - } else { - const solMatch = t.assetId.match(/sol-([A-HJ-NP-Za-km-z1-9]{32,44})\./); - if (solMatch) contractAddress = solMatch[1]; - } - } - return { - id: `${t.blockchain}-${t.symbol.toLowerCase()}-${t.assetId}`, - chain: t.blockchain, - chainLabel: CHAIN_LABELS[t.blockchain] || t.blockchain, - token: t.symbol, - decimals: t.decimals, - assetId: t.assetId, - contractAddress, - }; - }); -} - -const FALLBACK_TOKENS: SourceToken[] = [ - { id: 'eth-usdc', chain: 'eth', chainLabel: 'Ethereum', token: 'USDC', decimals: 6, assetId: 'nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near' }, - { id: 'eth-eth', chain: 'eth', chainLabel: 'Ethereum', token: 'ETH', decimals: 18, assetId: 'nep141:eth.omft.near' }, - { id: 'btc-btc', chain: 'btc', chainLabel: 'Bitcoin', token: 'BTC', decimals: 8, assetId: 'nep141:btc.omft.near' }, - { id: 'sol-sol', chain: 'sol', chainLabel: 'Solana', token: 'SOL', decimals: 9, assetId: 'nep141:sol.omft.near' }, - { id: 'near-near', chain: 'near', chainLabel: 'NEAR', token: 'NEAR', decimals: 24, assetId: 'nep141:wrap.near' }, -]; - -const ZEC_ASSET_ID = 'nep141:zec.omft.near'; -const ZEC_DECIMALS = 8; - -function WalletIcon({ wallet: w, size = 24 }: { wallet: DetectedWallet; size?: number }) { - if (w.icon) { - return ; - } - const chainFallback: Record = { - evm: '/chains/eth.png', solana: '/chains/sol.png', bitcoin: '/chains/btc.png', tron: '/chains/tron.png', - }; - const fallback = chainFallback[w.type || '']; - if (fallback) { - return ; - } - return ( -
- {w.name.charAt(0).toUpperCase()} -
- ); -} - -function ctaLabel(state: { - loading: boolean; - amount: string; - zecAddress: string; - refundAddress: string; - walletConnected: boolean; - hasWallets: boolean; - switching: boolean; - insufficientBalance: boolean; -}): string { - if (state.loading) return 'Getting quote...'; - if (state.switching) return 'Connecting...'; - if (!state.amount) return 'Enter amount'; - if (state.insufficientBalance) return 'Insufficient balance'; - if (!state.zecAddress) return 'Enter ZEC address'; - const addrErr = validateZecAddress(state.zecAddress); - if (addrErr) return 'Invalid ZEC address'; - if (!state.refundAddress) { - return state.hasWallets ? 'Connect wallet to continue' : 'Enter your sending address'; - } - return 'Get Quote'; -} +import SwapClient from './SwapClient'; export default function SwapPage() { - const [step, setStep] = useState('connect'); - const [manualMode, setManualMode] = useState(false); - const [availableTokens, setAvailableTokens] = useState(FALLBACK_TOKENS); - const [tokensLoading, setTokensLoading] = useState(true); - const [selectedToken, setSelectedToken] = useState(FALLBACK_TOKENS[0]); - const [showTokenPicker, setShowTokenPicker] = useState(false); - const [tokenSearch, setTokenSearch] = useState(''); - const [amount, setAmount] = useState(''); - const [zecAddress, setZecAddress] = useState(''); - const [refundAddress, setRefundAddress] = useState(''); - const [slippage, setSlippage] = useState(100); - const [quote, setQuote] = useState(null); - const [estimatedZec, setEstimatedZec] = useState(''); - const [depositAddress, setDepositAddress] = useState(''); - const [swapStatus, setSwapStatus] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [recommendations, setRecommendations] = useState(null); - const pollRef = useRef(null); - const [sendingTx, setSendingTx] = useState(false); - const [txHash, setTxHash] = useState(''); - const [walletError, setWalletError] = useState(''); - const [showSlippage, setShowSlippage] = useState(false); - const [copied, setCopied] = useState(false); - const [showWalletPicker, setShowWalletPicker] = useState(false); - const [nativeBalance, setNativeBalance] = useState(null); - const [balanceRefresh, setBalanceRefresh] = useState(0); - const [quoteExpiry, setQuoteExpiry] = useState(0); - const [quoteTimeLeft, setQuoteTimeLeft] = useState(0); - const wallet = useWallet(); - const pickerRef = useRef(null); - const tokenPickerRef = useRef(null); - - // Auto-advance past connect step if wallet already connected or no wallets detected - useEffect(() => { - if (step !== 'connect') return; - if (wallet.connected) { - setStep('form'); - } else if (wallet.allWallets.length === 0) { - // Wait a beat for wallet detection to complete before deciding - const t = setTimeout(() => { - if (!wallet.connected && wallet.allWallets.length === 0) { - setManualMode(true); - setStep('form'); - } - }, 2500); - return () => clearTimeout(t); - } - }, [step, wallet.connected, wallet.allWallets.length]); - - // When wallet connects from the connect step, move to form - useEffect(() => { - if (wallet.connected && step === 'connect') { - setStep('form'); - } - }, [wallet.connected, step]); - - // Restore pending swap from localStorage on mount - useEffect(() => { - try { - const raw = localStorage.getItem(PENDING_SWAP_KEY); - if (!raw) return; - const pending: PendingSwap = JSON.parse(raw); - const age = Date.now() - pending.createdAt; - if (age > 24 * 60 * 60 * 1000) { - localStorage.removeItem(PENDING_SWAP_KEY); - return; - } - setDepositAddress(pending.depositAddress); - setAmount(pending.amount); - setZecAddress(pending.zecAddress); - setEstimatedZec(pending.estimatedZec); - if (pending.txHash) setTxHash(pending.txHash); - const restored: SourceToken = { - id: `${pending.chain}:${pending.token}`, - chain: pending.chain, - chainLabel: pending.chainLabel, - token: pending.token, - decimals: pending.decimals, - assetId: pending.assetId, - contractAddress: pending.contractAddress, - }; - setSelectedToken(restored); - setStep('waiting'); - } catch { - localStorage.removeItem(PENDING_SWAP_KEY); - } - }, []); - - // Close dropdowns on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) setShowWalletPicker(false); - if (tokenPickerRef.current && !tokenPickerRef.current.contains(e.target as Node)) { setShowTokenPicker(false); setTokenSearch(''); } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - // Fetch available tokens and popularity ranking from API - useEffect(() => { - const fetchTokens = async () => { - try { - const [tokensRes, pairsRes] = await Promise.all([ - fetch(`${API_CONFIG.POSTGRES_API_URL}/api/swap/tokens`), - fetch(`${API_CONFIG.POSTGRES_API_URL}/api/crosschain/popular-pairs`).catch(() => null), - ]); - const tokensData = await tokensRes.json(); - const pairsData = pairsRes ? await pairsRes.json().catch(() => null) : null; - const popularPairs: PopularPair[] = pairsData?.success ? pairsData.pairs : []; - - if (tokensData.success && tokensData.tokens?.length) { - const mapped = apiTokensToSourceTokens(tokensData.tokens); - if (mapped.length > 0) { - const sorted = sortTokens(mapped, popularPairs); - setAvailableTokens(sorted); - setSelectedToken(sorted[0]); - } - } - } catch { /* fallback list stays */ } - finally { setTokensLoading(false); } - }; - if (isMainnet) fetchTokens(); - else setTokensLoading(false); - }, []); - - // Sync refund address with connected wallet + auto-select compatible token - useEffect(() => { - if (wallet.connected && wallet.address) { - setRefundAddress(wallet.address); - // If current token isn't compatible with connected wallet, switch to first compatible one - if (!manualMode && chainToWalletType(selectedToken.chain) !== wallet.walletType) { - const firstCompatible = availableTokens.find(t => chainToWalletType(t.chain) === wallet.walletType); - if (firstCompatible) { - setSelectedToken(firstCompatible); - setAmount(''); - } - } - } else { - setRefundAddress(''); - } - }, [wallet.connected, wallet.address, wallet.walletType]); - - // Quote countdown timer - useEffect(() => { - if (step !== 'quote' || !quoteExpiry) return; - const tick = () => { - const left = Math.max(0, Math.floor((quoteExpiry - Date.now()) / 1000)); - setQuoteTimeLeft(left); - if (left === 0) { - setStep('form'); - setError('Quote expired — please get a new one'); - } - }; - tick(); - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, [step, quoteExpiry]); - - const NATIVE_TOKENS = ['eth', 'sol', 'btc', 'bnb', 'doge', 'ltc', 'avax', 'matic', 'pol']; - const isNativeToken = NATIVE_TOKENS.includes(selectedToken.token.toLowerCase()) && !selectedToken.contractAddress; - - const evmChains = ['eth', 'base', 'arb', 'pol', 'op', 'avax', 'bsc']; - const chainKey = selectedToken.chain; - - useEffect(() => { - setNativeBalance(null); - if (!wallet.connected) return; - let cancelled = false; - const fetchBal = async () => { - let bal: string | null = null; - const isEvm = evmChains.includes(chainKey); - if (isNativeToken) { - bal = await wallet.getNativeBalance(isEvm ? chainKey : undefined); - } else if (selectedToken.contractAddress) { - bal = await wallet.getTokenBalance(selectedToken.contractAddress, selectedToken.decimals, isEvm ? chainKey : undefined); - } else { - bal = await wallet.getNativeBalance(isEvm ? chainKey : undefined); - } - if (!cancelled) setNativeBalance(bal); - }; - fetchBal(); - return () => { cancelled = true; }; - }, [wallet.connected, wallet.address, selectedToken, balanceRefresh]); - - // Fetch privacy-preserving amount recommendations (ZEC common amounts cross-referenced with source chain) - useEffect(() => { - const fetchRecs = async () => { - try { - const res = await fetch(`${API_CONFIG.POSTGRES_API_URL}/api/privacy/common-amounts?chain=${selectedToken.chain}&period=30d&limit=10`); - const data = await res.json(); - if (data.success && data.amounts?.length > 0) setRecommendations(data); - else setRecommendations(null); - } catch { - setRecommendations(null); - } - }; - if (isMainnet) fetchRecs(); - }, [selectedToken]); - - // Persist pending swap to localStorage - useEffect(() => { - if (step === 'waiting' && depositAddress) { - const pending: PendingSwap = { - depositAddress, - amount, - token: selectedToken.token, - chain: selectedToken.chain, - chainLabel: selectedToken.chainLabel, - assetId: selectedToken.assetId, - decimals: selectedToken.decimals, - contractAddress: selectedToken.contractAddress, - zecAddress, - estimatedZec, - txHash: txHash || undefined, - createdAt: Date.now(), - }; - localStorage.setItem(PENDING_SWAP_KEY, JSON.stringify(pending)); - } - }, [step, depositAddress, txHash]); - - // Poll swap status - useEffect(() => { - if (step !== 'waiting' || !depositAddress) return; - const poll = async () => { - try { - const res = await fetch(`${API_CONFIG.POSTGRES_API_URL}/api/swap/status?depositAddress=${encodeURIComponent(depositAddress)}`); - const data = await res.json(); - if (data.status === 'COMPLETE' || data.status === 'SUCCESS') { - setSwapStatus('complete'); - setStep('complete'); - localStorage.removeItem(PENDING_SWAP_KEY); - if (pollRef.current) clearInterval(pollRef.current); - } else if (data.status === 'FAILED' || data.status === 'REFUNDED') { - setSwapStatus(data.status.toLowerCase()); - setStep('error'); - setError(`Swap ${data.status.toLowerCase()}. Funds will be returned to your refund address.`); - localStorage.removeItem(PENDING_SWAP_KEY); - if (pollRef.current) clearInterval(pollRef.current); - } else { - setSwapStatus(data.status || 'processing'); - } - } catch { /* keep polling */ } - }; - poll(); - pollRef.current = setInterval(poll, 10000); - return () => { if (pollRef.current) clearInterval(pollRef.current); }; - }, [step, depositAddress]); - - const connectToWallet = async (w: DetectedWallet) => { - setWalletError(''); - setShowWalletPicker(false); - try { - await wallet.connect(w); - } catch (err: any) { - setWalletError(err.message || 'Connection failed'); - } - }; - - const chainWallets = wallet.getWalletsForChain(selectedToken.chain); - - // When wallet is connected, only show tokens from compatible chains - const compatibleTokens = wallet.connected && !manualMode - ? availableTokens.filter(t => chainToWalletType(t.chain) === wallet.walletType) - : availableTokens; - - const filteredTokens = compatibleTokens.filter(t => { - if (!tokenSearch) return true; - const q = tokenSearch.toLowerCase(); - return t.token.toLowerCase().includes(q) || t.chainLabel.toLowerCase().includes(q) || t.chain.includes(q); - }); - - const effectiveRefundAddress = refundAddress || wallet.address || ''; - - const getQuote = async () => { - if (!amount || !zecAddress || validateZecAddress(zecAddress) || !effectiveRefundAddress) return; - setLoading(true); - setError(''); - try { - const amountSmallest = BigInt(Math.round(parseFloat(amount) * Math.pow(10, selectedToken.decimals))).toString(); - const res = await fetch(`${API_CONFIG.POSTGRES_API_URL}/api/swap/quote`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - originAsset: selectedToken.assetId, - destinationAsset: ZEC_ASSET_ID, - amount: amountSmallest, - recipient: zecAddress, - refundTo: refundAddress || wallet.address, - slippageBps: slippage, - }), - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to get quote'); - const q = data.quote || data; - setQuote(data); - setDepositAddress(q.depositAddress || data.depositAddress || ''); - const outAmount = q.amountOut || q.estimatedAmountOut || data.amountOut; - if (outAmount) { - setEstimatedZec((parseInt(outAmount) / Math.pow(10, ZEC_DECIMALS)).toFixed(4)); - } - setQuoteExpiry(Date.now() + 60_000); - setStep('quote'); - } catch (err: any) { - setError(err.message || 'Failed to get quote'); - } finally { - setLoading(false); - } - }; - - const sendFromWallet = async () => { - setSendingTx(true); - setWalletError(''); - try { - const hash = await wallet.sendTransaction(depositAddress, amount, selectedToken.decimals, selectedToken.contractAddress); - setTxHash(hash); - } catch (err: any) { - setWalletError(err.message || 'Transaction rejected'); - } finally { - setSendingTx(false); - } - }; - - const copyAddress = () => { - navigator.clipboard.writeText(depositAddress); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const resetSwap = () => { - setStep('form'); - setQuote(null); - setDepositAddress(''); - setEstimatedZec(''); - setSwapStatus(''); - setError(''); - setTxHash(''); - setWalletError(''); - setCopied(false); - localStorage.removeItem(PENDING_SWAP_KEY); - setBalanceRefresh(n => n + 1); - }; - - const insufficientBalance = !!(wallet.connected && nativeBalance && amount && parseFloat(amount) > parseFloat(nativeBalance)); - const zecAddrError = validateZecAddress(zecAddress); - const ctaDisabled = loading || !amount || !zecAddress || !!zecAddrError || !effectiveRefundAddress || wallet.switching || insufficientBalance; - const ctaText = ctaLabel({ loading, amount, zecAddress, refundAddress: effectiveRefundAddress, walletConnected: wallet.connected, hasWallets: chainWallets.length > 0, switching: wallet.switching, insufficientBalance }); - - // -- Testnet fallback -- - if (!isMainnet) { - return ( -
-
-
-
- - - -
-

Mainnet Only

-

Cross-chain swaps require mainnet ZEC.

- - Go to Mainnet - - -
-
-
- ); - } - - return ( -
- - {/* Header — cypherpunk style, consistent with other pages */} -
-

- {'>'} CROSS_CHAIN_SWAP -

-
-

Buy ZEC

-

- Swap from 15+ chains via{' '} - NEAR Intents -

-
-
- -
- - {/* ─── Main Swap Card ─── */} -
-
- - {/* Card header */} -
-
- > SWAP - - {/* Wallet pill / picker — hidden during connect step */} - {step !== 'connect' && ( -
- - - {/* Wallet picker dropdown — wallets available */} - {showWalletPicker && chainWallets.length > 0 && ( -
-
- Select wallet - {wallet.connected && ( - - )} -
- {chainWallets.map((w) => ( - - ))} -
- )} - - {/* No wallet info panel */} - {showWalletPicker && chainWallets.length === 0 && ( -
-
- No {selectedToken.chainLabel} wallet -
-
-
-
- - - -
-

- No wallet needed.{' '} - Fill in the form and you'll get a deposit address to send funds manually. -

-
-
-
- - - -
-

- For auto-send, install a {selectedToken.chainLabel} wallet extension - {(() => { - const chain = selectedToken.chain; - if (['eth', 'base', 'arb', 'op', 'pol', 'avax', 'bsc', 'gnosis', 'bera', 'scroll'].includes(chain)) - return <> like MetaMask; - if (chain === 'sol') - return <> like Phantom; - if (chain === 'tron') - return <> like TronLink; - return null; - })()} - . -

-
-
-
- -
-
- )} -
- )} -
-
- -
- - {/* ─── CONNECT WALLET (gate step) ─── */} - {step === 'connect' && ( -
-
-

Connect Wallet

-

- Select a wallet to auto-fill addresses and send directly. -

-
- - {wallet.allWallets.length > 0 ? ( -
- {(() => { - const typeMap = new Map(); - for (const w of wallet.allWallets) { - if (!w.type) continue; - const arr = typeMap.get(w.type) || []; - arr.push(w); - typeMap.set(w.type, arr); - } - const chainLabels: Record = { - evm: 'EVM Chains', - solana: 'Solana', - tron: 'Tron', - bitcoin: 'Bitcoin', - }; - return Array.from(typeMap.entries()).map(([type, wallets]) => ( -
-
{chainLabels[type] || type}
-
- {wallets.map(w => ( - - ))} -
-
- )); - })()} -
- ) : ( -
-
-

Detecting wallets...

-
- )} - - {walletError && ( -
- {walletError} -
- )} - -
- -
-
- )} - - {/* ─── FORM ─── */} - {step === 'form' && ( -
- - {/* Step guide */} -
- 1. Pick asset - {'>'} - 2. Amount & addresses - {'>'} - 3. Get quote -
- - {/* From — asset selector (prominent first row) */} -
- -
- - - {/* Token picker dropdown */} - {showTokenPicker && ( - <> -
{ setShowTokenPicker(false); setTokenSearch(''); }} /> -
-
- setTokenSearch(e.target.value)} - placeholder="Search token or chain..." - autoFocus - className="w-full px-3 py-2 rounded-lg bg-glass-4 text-primary font-mono text-sm placeholder:text-muted/40 focus:outline-none" - /> -
-
- {tokensLoading ? ( -
-
-
Loading tokens...
-
- ) : filteredTokens.length === 0 ? ( -
No tokens found
- ) : ( - filteredTokens.map(t => ( - - )) - )} -
-
- - )} -
-
- - {/* Amount input (own row) */} -
-
- - {wallet.connected && nativeBalance && ( -
- - {parseFloat(nativeBalance).toLocaleString(undefined, { maximumFractionDigits: 4 })} {selectedToken.token} - - - -
- )} -
-
- { - const v = e.target.value; - if (v === '' || /^\d*\.?\d*$/.test(v)) setAmount(v); - }} - placeholder="0.00" - className="flex-1 min-w-0 px-4 py-3 bg-transparent text-primary font-mono text-lg placeholder:text-muted/30 focus:outline-none" - /> -
- - {selectedToken.token} -
-
- - {/* Privacy recommendation chips — amounts that blend on both source chain and ZEC side */} - {recommendations && recommendations.amounts.length > 0 && (() => { - const chips = recommendations.amounts - .filter(a => a.sourceAmount && a.sourceAmount > 0) - .filter(a => !a.sourceToken || a.sourceToken.toUpperCase() === selectedToken.token.toUpperCase()) - .slice(0, 4); - if (chips.length === 0) return null; - return ( -
- {chips.map((rec, i) => { - const label = getBlendingLabel(rec.blendingScore, rec.dualBlendScore); - const token = rec.sourceToken || selectedToken.token; - return ( - - ); - })} -
- ); - })()} -
- - {/* Arrow divider */} -
-
-
- - - -
-
-
- - {/* To section */} -
- -
- setZecAddress(e.target.value)} - placeholder="Paste t1 or u1 address" - className="flex-1 min-w-0 px-4 py-3 bg-transparent text-primary font-mono text-sm placeholder:text-muted/30 focus:outline-none" - /> -
- - ZEC -
-
- {zecAddress && zecAddrError && ( -

{zecAddrError}

- )} -
- - {/* Return address — hidden when wallet connected, shown as fallback for manual users */} - {wallet.connected ? ( -
-
- - Returns to {wallet.address?.slice(0, 6)}...{wallet.address?.slice(-4)} if swap fails -
- -
- ) : ( -
-
- - -
- setRefundAddress(e.target.value)} - placeholder={`The address you're sending from`} - className="w-full px-4 py-3 rounded-lg bg-glass-3 border border-glass-6 text-primary font-mono text-sm placeholder:text-muted/30 focus:outline-none focus:border-cipher-cyan/40 focus:shadow-[0_0_0_3px_rgb(var(--color-cyan-rgb)_/_0.06)] transition-all" - /> - {!refundAddress && ( -

- Paste the {selectedToken.chainLabel} address you'll send from. Funds return here if the swap can't complete. -

- )} -
- )} - - {/* Slippage (expandable) */} - {showSlippage && ( -
- -
- {[{ label: '0.5%', value: 50 }, { label: '1%', value: 100 }, { label: '2%', value: 200 }].map(opt => ( - - ))} -
-
- )} - - {/* Error */} - {(error || walletError) && ( -
- {error || walletError} -
- )} - - {/* CTA — smart contextual button */} - {(() => { - const needsWallet = !wallet.connected && chainWallets.length > 0 && amount && zecAddress && !zecAddrError && !effectiveRefundAddress; - return ( - - ); - })()} - - {/* Fee note */} -

- Powered by NEAR Intents · Slippage: {slippage / 100}% -

-
- )} - - {/* ─── QUOTE REVIEW ─── */} - {step === 'quote' && quote && ( -
- {/* Summary */} -
-
-
- -
-
{amount} {selectedToken.token}
-
{selectedToken.chainLabel}
-
-
- - - -
-
-
{estimatedZec || '~'} ZEC
-
Estimated
-
- -
-
- -
-
- Slippage - {slippage / 100}% -
-
- Destination - {zecAddress.slice(0, 10)}...{zecAddress.slice(-6)} -
-
-
- - {quoteTimeLeft > 0 && ( -
-
30 ? 'bg-cipher-green' : quoteTimeLeft > 10 ? 'bg-cipher-yellow' : 'bg-red-500 animate-pulse'}`} /> - 30 ? 'text-muted' : quoteTimeLeft > 10 ? 'text-cipher-yellow' : 'text-red-500'}> - Quote expires in {Math.floor(quoteTimeLeft / 60)}:{(quoteTimeLeft % 60).toString().padStart(2, '0')} - -
- )} - -
- - -
-
- )} - - {/* ─── WAITING FOR DEPOSIT ─── */} - {step === 'waiting' && ( -
- {/* Swap summary row */} -
-
-
- -
-
{amount} {selectedToken.token}
-
{selectedToken.chainLabel}
-
-
- - - -
-
-
{estimatedZec || '~'} ZEC
-
Estimated
-
- -
-
-
- - {/* Status indicator */} -
-
- - {swapStatus ? swapStatus.replace(/_/g, ' ') : 'Waiting for deposit'} - -
- - {/* One-click wallet send */} - {wallet.connected && !txHash && ( - - )} - - {txHash && ( -
-
-
- - Sent - {txHash.slice(0, 8)}...{txHash.slice(-6)} -
-
- - {CHAIN_EXPLORERS[selectedToken.chain] && ( - - - - )} -
-
-
- )} - - {walletError &&

{walletError}

} - - {/* Manual deposit section */} - {(!wallet.connected || (wallet.connected && !txHash)) && ( - <> - {wallet.connected && ( -
-
- or send manually -
-
- )} - -
-
Deposit address
-
- {depositAddress} - -
-
- - )} - - {txHash ? ( -
- - Swap will complete automatically - - -
- ) : ( - - )} -
- )} - - {/* ─── COMPLETE ─── */} - {step === 'complete' && ( -
-
-
-
- - - -
-
-

Swap Complete

-

{estimatedZec} ZEC sent to your address

-
-
-
- -
- )} - - {/* ─── ERROR ─── */} - {step === 'error' && ( -
-
-
-
- - - -
-
-

Swap Failed

-

{error}

-
-
-
- -
- )} -
-
-
- - {/* ─── Sidebar ─── */} -
- - {/* Why connect — only on connect step */} - {step === 'connect' && ( -
-
- > Why_connect -
-
- {[ - { label: 'Filtered tokens', desc: 'Only see assets your wallet supports' }, - { label: 'Auto-fill addresses', desc: 'No copy-pasting needed for refunds' }, - { label: 'One-click send', desc: 'Send directly from CipherScan' }, - ].map(item => ( -
- -
-
{item.label}
-
{item.desc}
-
-
- ))} -
-
- )} - - {/* Privacy tips — only on form step */} - {step === 'form' && ( -
-
- > Privacy_tips -
-
- {recommendations && recommendations.amounts.length > 0 ? (() => { - const withSource = recommendations.amounts - .filter(a => a.sourceAmount && a.sourceAmount > 0) - .filter(a => !a.sourceToken || a.sourceToken.toUpperCase() === selectedToken.token.toUpperCase()); - return ( -
-

- {withSource.length > 0 - ? `Amounts that blend in on both the ${selectedToken.chain.toUpperCase()} and Zcash sides for maximum privacy.` - : 'Common ZEC shielding amounts. Using popular amounts improves privacy.'} -

-
- {(withSource.length > 0 ? withSource : recommendations.amounts).slice(0, 5).map((rec, i) => { - const label = getBlendingLabel(rec.blendingScore, rec.dualBlendScore); - const token = rec.sourceToken || selectedToken.token; - const displayAmount = rec.sourceAmount || rec.amountZec; - const displayToken = rec.sourceAmount ? token : 'ZEC'; - return ( - - ); - })} -
- {recommendations.tip && ( -

{recommendations.tip}

- )} -
- ); - })() : ( -

- Privacy recommendations appear once swap data is collected. -

- )} -
-
- )} - - {/* Estimated time — shown during quote/waiting */} - {(step === 'quote' || step === 'waiting') && ( -
-
- > Estimated_time -
-
-
-
- -
-
-
- {(() => { - const c = selectedToken.chain; - if (c === 'sol') return '~1-2 min'; - if (['near', 'ton', 'sui'].includes(c)) return '~2-5 min'; - if (['eth', 'base', 'arb', 'op', 'pol', 'avax', 'bsc', 'gnosis', 'bera', 'scroll'].includes(c)) return '~5-15 min'; - if (['tron'].includes(c)) return '~3-10 min'; - if (['btc', 'ltc', 'bch', 'doge', 'dash'].includes(c)) return '~20-60 min'; - return '~5-30 min'; - })()} -
-
via {selectedToken.chainLabel}
-
-
- -
-
-
- Deposit to bridge address -
-
-
- NEAR Intents bridging -
-
-
- ZEC sent to your address -
-
- -

- Times depend on {selectedToken.chainLabel} block confirmations and NEAR solver availability. -

-
-
- )} - - {/* Complete/error — minimal */} - {(step === 'complete' || step === 'error') && ( -
-
-

- {step === 'complete' - ? 'Your ZEC has arrived. For maximum privacy, shield your balance using a wallet that supports Orchard.' - : 'If your swap failed, funds are returned to your refund address automatically.'} -

-
-
- )} - -
- - View Crosschain Analytics - - -
-
-
-
- ); + if (!isMainnet) notFound(); + return ; } From bd8175dd1a8bab3fcf9143996858929cf01fbc54 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sat, 23 May 2026 16:02:08 +0530 Subject: [PATCH 10/35] feat(faucet): proxy to taps wallet daemon instead of JSON-RPC --- .env.example | 18 +++--- server/api/routes/faucet.js | 110 ++++++++++++++++++++++-------------- server/api/server.js | 80 -------------------------- 3 files changed, 75 insertions(+), 133 deletions(-) diff --git a/.env.example b/.env.example index bb6d868..3dd8055 100644 --- a/.env.example +++ b/.env.example @@ -23,16 +23,14 @@ NEXT_PUBLIC_HELIUS_API_KEY= ZEBRA_GRPC_URL=127.0.0.1:8230 # Testnet Faucet -# Wallet RPC (zcashd or Zallet) — speaks sendtoaddress / getbalance. -# Same JSON-RPC shape as Zebra, but targets the wallet daemon. -WALLET_RPC_URL=http://127.0.0.1:18232 -WALLET_RPC_USER= -WALLET_RPC_PASSWORD= -# Optional: cookie-file auth (alternative to user/password) -# WALLET_RPC_COOKIE_FILE=/root/.zcash/testnet3/.cookie - -# How much TAZ each dispense sends (decimal ZEC, not zatoshis) -FAUCET_DISPENSE_AMOUNT_TAZ=0.5 +# Talks to taps (separate repo) — an Orchard-only Rust faucet wallet daemon +# running on loopback. Cipherscan Express handles Turnstile + cooldown, +# then proxies to taps for the actual spend. +TAPS_URL=http://127.0.0.1:3000 +TAPS_API_KEY= + +# How much TAZ each dispense sends — whole TAZ only (taps constraint) +FAUCET_DISPENSE_AMOUNT_TAZ=1 # Optional protections — both default to OFF. Set to enable. # Per-address cooldown in seconds (0 = no cooldown) diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js index 6070124..961835f 100644 --- a/server/api/routes/faucet.js +++ b/server/api/routes/faucet.js @@ -1,18 +1,26 @@ /** - * Testnet Faucet - * GET /api/faucet/status → wallet balance + dispense amount - * POST /api/faucet/dispense → send TAZ to a transparent address + * Testnet Faucet — proxy to the taps wallet daemon * - * Captcha (Turnstile) and per-address cooldown are both feature-flagged - * via env vars — see .env.example. Unset = disabled, no code change needed - * to enable. + * GET /api/faucet/status → balance, dispense amount, cooldown, captcha + * POST /api/faucet/dispense → send TAZ to a testnet Unified Address + * + * Taps (https://github.com/zcashme/taps — separate repo) runs on the same VPS, + * listens on loopback only, and holds the Orchard spending key. We sit in + * front of it for Turnstile + per-address cooldown. */ const express = require('express'); const router = express.Router(); -const DEFAULT_DISPENSE_TAZ = 0.5; -const ADDRESS_REGEX = /^tm[a-zA-Z0-9]{32,40}$/; +const DEFAULT_DISPENSE_TAZ = 1; +const DEFAULT_TAPS_URL = 'http://127.0.0.1:3000'; +// Loose testnet Unified Address check: bech32m charset, utest1 prefix. +// Strict parsing happens in taps. +const UA_REGEX = /^utest1[02-9ac-hj-np-z]{40,}$/; + +function tapsUrl() { + return (process.env.TAPS_URL || DEFAULT_TAPS_URL).replace(/\/$/, ''); +} function dispenseAmountTaz() { const raw = parseFloat(process.env.FAUCET_DISPENSE_AMOUNT_TAZ); @@ -45,16 +53,32 @@ async function verifyTurnstile(token, remoteIp) { } } -router.get('/api/faucet/status', async (req, res) => { - const callWalletRPC = req.app.locals.callWalletRPC; - if (!callWalletRPC) { - return res.status(503).json({ error: 'wallet RPC not configured' }); - } +async function tapsStatus() { + const res = await fetch(`${tapsUrl()}/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(`${tapsUrl()}/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': apiKey, + }, + body: JSON.stringify({ recipient, amount: amountTaz }), + }); + const body = await res.json().catch(() => ({})); + return { status: res.status, body }; +} + +router.get('/api/faucet/status', async (_req, res) => { try { - const balance = await callWalletRPC('getbalance', []); + const taps = await tapsStatus(); + const orchard = taps?.balances?.orchard; res.json({ - balanceTaz: typeof balance === 'number' ? balance : parseFloat(balance) || 0, + balanceTaz: typeof orchard === 'number' ? orchard : 0, dispenseAmountTaz: dispenseAmountTaz(), cooldownSeconds: cooldownSeconds(), captchaEnabled: !!process.env.TURNSTILE_SECRET_KEY, @@ -66,15 +90,10 @@ router.get('/api/faucet/status', async (req, res) => { }); router.post('/api/faucet/dispense', express.json(), async (req, res) => { - const callWalletRPC = req.app.locals.callWalletRPC; const redisClient = req.app.locals.redisClient; - if (!callWalletRPC) { - return res.status(503).json({ error: 'wallet RPC not configured' }); - } - const { address, captchaToken } = req.body || {}; - if (!address || typeof address !== 'string' || !ADDRESS_REGEX.test(address.trim())) { + if (!address || typeof address !== 'string' || !UA_REGEX.test(address.trim())) { return res.status(400).json({ error: 'invalid address' }); } const addr = address.trim(); @@ -101,37 +120,42 @@ router.post('/api/faucet/dispense', express.json(), async (req, res) => { } } - const amount = dispenseAmountTaz(); + const amountTaz = dispenseAmountTaz(); - let balance; + let result; try { - balance = await callWalletRPC('getbalance', []); + result = await tapsSend({ recipient: addr, amountTaz }); } catch (err) { - console.error('[faucet] balance check failed:', err.message); + console.error('[faucet] taps /send failed:', err.message); return res.status(502).json({ error: 'wallet unreachable' }); } - if (typeof balance === 'number' && balance < amount) { - return res.status(503).json({ error: 'drained', balanceTaz: balance }); - } - - let txid; - try { - txid = await callWalletRPC('sendtoaddress', [addr, amount]); - } catch (err) { - console.error('[faucet] sendtoaddress failed:', err.message); - return res.status(502).json({ error: 'send failed', detail: err.message }); - } - if (cdSec > 0 && redisClient) { - try { - await redisClient.set(`faucet:cooldown:${addr}`, '1', { EX: cdSec }); - } catch (err) { - console.error('[faucet] cooldown set failed:', err.message); + if (result.status === 200 && result.body?.txid) { + if (cdSec > 0 && redisClient) { + try { + await redisClient.set(`faucet:cooldown:${addr}`, '1', { EX: cdSec }); + } catch (err) { + console.error('[faucet] cooldown set failed:', err.message); + } } + console.log(`[faucet] dispensed ${amountTaz} TAZ to ${addr.slice(0, 12)}… txid=${result.body.txid}`); + return res.json({ txid: result.body.txid, amountTaz }); } - console.log(`[faucet] dispensed ${amount} TAZ to ${addr.slice(0, 10)}… txid=${txid}`); - res.json({ txid, amountTaz: amount }); + // Map taps errors → cipherscan UI shapes + 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 (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 1aa8460..bbdccfa 100644 --- a/server/api/server.js +++ b/server/api/server.js @@ -165,85 +165,6 @@ function getZebraAuth() { return _zebraAuth; } -// Wallet RPC (zcashd or Zallet) — same JSON-RPC shape as Zebra but -// targets a wallet daemon. Used by the faucet for sendtoaddress/getbalance. -const walletAgent = new (require('http').Agent)({ - keepAlive: true, - maxSockets: 4, - maxFreeSockets: 2, - timeout: 10000, -}); - -let _walletAuth = null; -function getWalletAuth() { - if (_walletAuth !== null) return _walletAuth; - const cookieFile = process.env.WALLET_RPC_COOKIE_FILE; - if (cookieFile) { - try { - const cookie = fs.readFileSync(cookieFile, 'utf8').trim(); - if (cookie) { - _walletAuth = Buffer.from(cookie).toString('base64'); - return _walletAuth; - } - } catch {} - } - const user = process.env.WALLET_RPC_USER || ''; - const password = process.env.WALLET_RPC_PASSWORD || ''; - _walletAuth = Buffer.from(`${user}:${password}`).toString('base64'); - return _walletAuth; -} - -async function callWalletRPC(method, params = [], { timeout = 30000 } = {}) { - const rpcUrl = process.env.WALLET_RPC_URL || 'http://127.0.0.1:18232'; - const auth = getWalletAuth(); - const requestBody = JSON.stringify({ - jsonrpc: '1.0', - id: 'cipherscan-wallet', - method, - params, - }); - const url = new URL(rpcUrl); - - return new Promise((resolve, reject) => { - const req = require('http').request( - { - hostname: url.hostname, - port: url.port, - path: url.pathname, - method: 'POST', - agent: walletAgent, - timeout, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(requestBody), - 'Authorization': `Basic ${auth}`, - }, - }, - (res) => { - let data = ''; - res.on('data', (chunk) => { data += chunk; }); - res.on('end', () => { - try { - const response = JSON.parse(data); - if (response.error) { - reject(new Error(response.error.message || 'wallet RPC error')); - } else { - resolve(response.result); - } - } catch (error) { - reject(new Error(`Failed to parse wallet RPC response: ${data.slice(0, 120)}`)); - } - }); - } - ); - - req.on('timeout', () => { req.destroy(new Error('wallet RPC timeout')); }); - req.on('error', (error) => { reject(new Error(`wallet RPC request failed: ${error.message}`)); }); - req.write(requestBody); - req.end(); - }); -} - async function callZebraRPC(method, params = [], { timeout = 8000 } = {}) { const rpcUrl = process.env.ZEBRA_RPC_URL || 'http://127.0.0.1:18232'; const auth = getZebraAuth(); @@ -407,7 +328,6 @@ app.use((req, res, next) => { // Make additional dependencies available to routes app.locals.callZebraRPC = callZebraRPC; -app.locals.callWalletRPC = callWalletRPC; app.locals.CompactTxStreamer = CompactTxStreamer; app.locals.grpc = grpc; app.locals.findLinkedTransactions = findLinkedTransactions; From 7e1d1eaadd5674f53a3ed3ce3242eeb0065a8f6e Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sat, 23 May 2026 16:02:11 +0530 Subject: [PATCH 11/35] feat(faucet): accept Orchard Unified Addresses (utest1...) --- app/faucet/FaucetClient.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 4cd42b1..d157074 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -11,8 +11,8 @@ import { getApiUrl } from '@/lib/api-config'; const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''; -const FALLBACK_DISPENSE_TAZ = 0.5; -// STUB: real address comes from env once wallet is provisioned +const FALLBACK_DISPENSE_TAZ = 1; +// STUB: real address comes from env once taps wallet is provisioned const FAUCET_DONATE_ADDRESS = 'tm9zNbDx7K2pVcRfYqWxJ8mE4hT3nL6Aoq5'; interface FaucetStatus { @@ -40,8 +40,10 @@ function formatRetry(seconds: number): string { return rem === 0 ? `${h}h` : `${h}h ${rem}m`; } -function isValidTestnetTransparentAddress(addr: string): boolean { - return /^tm[a-zA-Z0-9]{32,40}$/.test(addr.trim()); +// Loose testnet Unified Address check (bech32m charset). Strict parsing +// happens server-side in taps. +function isValidTestnetUnifiedAddress(addr: string): boolean { + return /^utest1[02-9ac-hj-np-z]{40,}$/.test(addr.trim()); } export default function FaucetClient() { @@ -80,7 +82,7 @@ export default function FaucetClient() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const trimmed = address.trim(); - if (!isValidTestnetTransparentAddress(trimmed)) { + if (!isValidTestnetUnifiedAddress(trimmed)) { setState({ kind: 'invalid' }); return; } @@ -172,7 +174,7 @@ export default function FaucetClient() {

Get free testnet ZEC

{dispenseAmount} TAZ per address - {cooldownEnabled && `, every ${formatRetry(status!.cooldownSeconds)}`}. Don't be a dick. + {cooldownEnabled && `, every ${formatRetry(status!.cooldownSeconds)}`}.

@@ -261,7 +263,7 @@ export default function FaucetClient() { setState({ kind: 'idle' }); } }} - placeholder="tm..." + placeholder="utest1..." spellCheck={false} autoComplete="off" disabled={isSubmitting} @@ -269,7 +271,7 @@ export default function FaucetClient() { /> {state.kind === 'invalid' && (

- invalid testnet address — expected tm… + invalid testnet address — expected utest1…

)} {state.kind === 'cooldown' && ( @@ -356,8 +358,7 @@ export default function FaucetClient() { · {dispenseAmount} TAZ per testnet address {cooldownEnabled && `, max one per ${formatRetry(status!.cooldownSeconds)}`} -
  • · transparent (tm…) addresses only · shielded support coming
  • -
  • · this is testnet ZEC — it has no monetary value, don't try
  • +
  • · Orchard / Unified addresses (utest1…) only
  • From 49eeba090f809613151a2c561b19560372e8fe9b Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 09:25:10 +0530 Subject: [PATCH 12/35] chore(server): also load .env.local for local-dev parity with next --- server/api/server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/api/server.js b/server/api/server.js index bbdccfa..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'); From 90b933e1e9951bba4f86ef87fc4e4e610fb5c8e9 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 09:25:19 +0530 Subject: [PATCH 13/35] chore(api-config): honor NEXT_PUBLIC_TESTNET_API_URL for local dev --- lib/api-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', }; From 3b6430824c3117bbb4243dea23b3959b6bf0a652 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 09:26:31 +0530 Subject: [PATCH 14/35] feat(faucet): surface taps wallet UA as donate address via /status --- app/faucet/FaucetClient.tsx | 36 +++++++++++++++++++++--------------- server/api/routes/faucet.js | 2 ++ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index d157074..dbd6c45 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -12,14 +12,13 @@ import { getApiUrl } from '@/lib/api-config'; const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''; const FALLBACK_DISPENSE_TAZ = 1; -// STUB: real address comes from env once taps wallet is provisioned -const FAUCET_DONATE_ADDRESS = 'tm9zNbDx7K2pVcRfYqWxJ8mE4hT3nL6Aoq5'; interface FaucetStatus { balanceTaz: number; dispenseAmountTaz: number; cooldownSeconds: number; captchaEnabled: boolean; + donateAddress: string | null; } type SubmitState = @@ -155,7 +154,8 @@ export default function FaucetClient() { } async function copyDonateAddress() { - await navigator.clipboard.writeText(FAUCET_DONATE_ADDRESS); + if (!status?.donateAddress) return; + await navigator.clipboard.writeText(status.donateAddress); setAddrCopied(true); setTimeout(() => setAddrCopied(false), 2000); } @@ -376,9 +376,9 @@ export default function FaucetClient() {
    {/* QR */}
    - {themeMounted && ( + {themeMounted && status?.donateAddress && ( {'>'} ADDRESS
    - {FAUCET_DONATE_ADDRESS} - + {status?.donateAddress ? ( + <> + {status.donateAddress} + + + ) : ( + loading… + )}

    - transparent only · shielded donations coming + transparent or shielded · both welcome

    diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js index 961835f..20fc5e6 100644 --- a/server/api/routes/faucet.js +++ b/server/api/routes/faucet.js @@ -77,11 +77,13 @@ router.get('/api/faucet/status', async (_req, res) => { try { const taps = await tapsStatus(); const orchard = taps?.balances?.orchard; + const ua = taps?.unified_address; res.json({ balanceTaz: typeof orchard === 'number' ? orchard : 0, dispenseAmountTaz: dispenseAmountTaz(), cooldownSeconds: cooldownSeconds(), captchaEnabled: !!process.env.TURNSTILE_SECRET_KEY, + donateAddress: typeof ua === 'string' && ua !== 'unavailable' ? ua : null, }); } catch (err) { console.error('[faucet] status failed:', err.message); From 1a2e9ed956cb431826a9e8f4712f5914a8f0afeb Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 09:28:47 +0530 Subject: [PATCH 15/35] =?UTF-8?q?feat(faucet):=20user-selectable=20amount?= =?UTF-8?q?=20via=20slider=20(0.001=20=E2=80=93=201=20TAZ)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/faucet/FaucetClient.tsx | 63 +++++++++++++++++++++++++++++++------ server/api/routes/faucet.js | 19 +++++++++-- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index dbd6c45..054cc59 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -13,6 +13,25 @@ const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''; const FALLBACK_DISPENSE_TAZ = 1; +// Match taps' deploy/taps.toml: min_spend_zat=100_000, spend_increment_zat=10_000, +// max_spend_zat=100_000_000 (1 TAZ). +const MIN_DISPENSE_TAZ = 0.001; +const MAX_DISPENSE_TAZ = 1; +const STEP_TAZ = 0.0001; +const DEFAULT_DISPENSE_TAZ = 0.1; + +// Snap a slider value to the nearest valid step to dodge float drift +// (e.g. 0.30000000000000004 → 0.3). +function snapToStep(v: number): number { + return Math.round(v / STEP_TAZ) * STEP_TAZ; +} + +// Strip trailing zeros from a 4-decimal-place fixed string. +// 0.1 → "0.1", 0.001 → "0.001", 1 → "1". +function formatTaz(v: number): string { + return parseFloat(v.toFixed(4)).toString(); +} + interface FaucetStatus { balanceTaz: number; dispenseAmountTaz: number; @@ -24,7 +43,7 @@ interface FaucetStatus { type SubmitState = | { kind: 'idle' } | { kind: 'submitting' } - | { kind: 'success'; txid: string } + | { kind: 'success'; txid: string; amountTaz: number } | { kind: 'invalid' } | { kind: 'cooldown'; retryAfterSeconds: number } | { kind: 'drained' } @@ -47,6 +66,7 @@ function isValidTestnetUnifiedAddress(addr: string): boolean { export default function FaucetClient() { const [address, setAddress] = useState(''); + const [amountTaz, setAmountTaz] = useState(DEFAULT_DISPENSE_TAZ); const [state, setState] = useState({ kind: 'idle' }); const [copied, setCopied] = useState(false); const [addrCopied, setAddrCopied] = useState(false); @@ -57,7 +77,6 @@ export default function FaucetClient() { const isDark = theme === 'dark'; const captchaRequired = !!TURNSTILE_SITE_KEY; - const dispenseAmount = status?.dispenseAmountTaz ?? FALLBACK_DISPENSE_TAZ; const cooldownEnabled = (status?.cooldownSeconds ?? 0) > 0; useEffect(() => { @@ -95,12 +114,12 @@ export default function FaucetClient() { const res = await fetch(`${getApiUrl()}/api/faucet/dispense`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address: trimmed, captchaToken }), + body: JSON.stringify({ address: trimmed, amountTaz, captchaToken }), }); const data = await res.json().catch(() => ({})); if (res.ok && data.txid) { - setState({ kind: 'success', txid: data.txid }); + setState({ kind: 'success', txid: data.txid, amountTaz }); return; } @@ -173,7 +192,7 @@ export default function FaucetClient() {

    Get free testnet ZEC

    - {dispenseAmount} TAZ per address + {MIN_DISPENSE_TAZ} – {MAX_DISPENSE_TAZ} TAZ per request, you choose {cooldownEnabled && `, every ${formatRetry(status!.cooldownSeconds)}`}.

    @@ -201,7 +220,7 @@ export default function FaucetClient() {
    SENT - {dispenseAmount} TAZ dispatched to your address + {formatTaz(state.amountTaz)} TAZ dispatched to your address
    @@ -291,6 +310,32 @@ export default function FaucetClient() { )}
    +
    +
    +
    + {'>'} AMOUNT +
    +
    + {formatTaz(amountTaz)} TAZ +
    +
    + setAmountTaz(snapToStep(parseFloat(e.target.value)))} + disabled={isSubmitting} + aria-label="Dispense amount in TAZ" + className="w-full accent-cipher-cyan cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" + /> +
    + {MIN_DISPENSE_TAZ} TAZ + {MAX_DISPENSE_TAZ} TAZ +
    +
    + {captchaRequired && (
    - Sending {dispenseAmount} TAZ… + Sending {formatTaz(amountTaz)} TAZ… ) : ( <> - {'>'} Send {dispenseAmount} TAZ + {'>'} Send {formatTaz(amountTaz)} TAZ )} @@ -355,7 +400,7 @@ export default function FaucetClient() {
    • - · {dispenseAmount} TAZ per testnet address + · {MIN_DISPENSE_TAZ} – {MAX_DISPENSE_TAZ} TAZ per request {cooldownEnabled && `, max one per ${formatRetry(status!.cooldownSeconds)}`}
    • · Orchard / Unified addresses (utest1…) only
    • diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js index 20fc5e6..d6eaa09 100644 --- a/server/api/routes/faucet.js +++ b/server/api/routes/faucet.js @@ -93,13 +93,24 @@ router.get('/api/faucet/status', async (_req, res) => { router.post('/api/faucet/dispense', express.json(), async (req, res) => { const redisClient = req.app.locals.redisClient; - const { address, captchaToken } = req.body || {}; + 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(); + // Caller chooses the amount; taps enforces min/max/increment. + // Fall back to the env default if the client didn't send one. + let amountTaz; + if (requestedAmount === undefined || requestedAmount === null) { + amountTaz = dispenseAmountTaz(); + } 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' }); @@ -122,8 +133,6 @@ router.post('/api/faucet/dispense', express.json(), async (req, res) => { } } - const amountTaz = dispenseAmountTaz(); - let result; try { result = await tapsSend({ recipient: addr, amountTaz }); @@ -152,6 +161,10 @@ router.post('/api/faucet/dispense', express.json(), async (req, res) => { 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' }); From efaec3fa2bb34f50765d35555f88869428ef9719 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 09:29:31 +0530 Subject: [PATCH 16/35] refactor(faucet): use shared CopyButton, drop ad-hoc copy state --- app/faucet/FaucetClient.tsx | 35 +++-------------------------------- 1 file changed, 3 insertions(+), 32 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 054cc59..090d9dd 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -6,6 +6,7 @@ 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'; @@ -68,8 +69,6 @@ export default function FaucetClient() { const [address, setAddress] = useState(''); const [amountTaz, setAmountTaz] = useState(DEFAULT_DISPENSE_TAZ); const [state, setState] = useState({ kind: 'idle' }); - const [copied, setCopied] = useState(false); - const [addrCopied, setAddrCopied] = useState(false); const [status, setStatus] = useState(null); const [captchaToken, setCaptchaToken] = useState(null); const turnstileRef = useRef(null); @@ -163,20 +162,6 @@ export default function FaucetClient() { function reset() { setAddress(''); setState({ kind: 'idle' }); - setCopied(false); - } - - async function copyTxid(txid: string) { - await navigator.clipboard.writeText(txid); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - async function copyDonateAddress() { - if (!status?.donateAddress) return; - await navigator.clipboard.writeText(status.donateAddress); - setAddrCopied(true); - setTimeout(() => setAddrCopied(false), 2000); } const isSubmitting = state.kind === 'submitting'; @@ -230,14 +215,7 @@ export default function FaucetClient() {
    {state.txid} - +

    Likely unconfirmed — confirmation in ~75 seconds. @@ -441,14 +419,7 @@ export default function FaucetClient() { {status?.donateAddress ? ( <> {status.donateAddress} - + ) : ( loading… From 8e67393e2337f809fb63b006c9810cd2fd0b722b Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 09:29:54 +0530 Subject: [PATCH 17/35] feat(faucet): rename heading to "Testnet Faucet", trim subhead copy --- app/faucet/FaucetClient.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 090d9dd..41b7e28 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -175,11 +175,12 @@ export default function FaucetClient() {

    {'>'} TESTNET_FAUCET

    -

    Get free testnet ZEC

    -

    - {MIN_DISPENSE_TAZ} – {MAX_DISPENSE_TAZ} TAZ per request, you choose - {cooldownEnabled && `, every ${formatRetry(status!.cooldownSeconds)}`}. -

    +

    Testnet Faucet

    + {cooldownEnabled && ( +

    + one request every {formatRetry(status!.cooldownSeconds)}. +

    + )}
    @@ -425,9 +426,6 @@ export default function FaucetClient() { loading… )}
    -

    - transparent or shielded · both welcome -

    From c3b4a9bc10586941f619228d1196a5a6b1120169 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 09:31:00 +0530 Subject: [PATCH 18/35] chore(faucet): drop unused FALLBACK_DISPENSE_TAZ const --- app/faucet/FaucetClient.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 41b7e28..aed3f72 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -12,8 +12,6 @@ import { getApiUrl } from '@/lib/api-config'; const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''; -const FALLBACK_DISPENSE_TAZ = 1; - // Match taps' deploy/taps.toml: min_spend_zat=100_000, spend_increment_zat=10_000, // max_spend_zat=100_000_000 (1 TAZ). const MIN_DISPENSE_TAZ = 0.001; From a2b42a74aa7076abd86a04b0a723e5fc86da1d5f Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 09:45:16 +0530 Subject: [PATCH 19/35] fix(faucet): drop dead state.kind !== 'success' check that blocked build --- app/faucet/FaucetClient.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index aed3f72..b0f4786 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -255,7 +255,7 @@ export default function FaucetClient() { value={address} onChange={(e) => { setAddress(e.target.value); - if (state.kind !== 'idle' && state.kind !== 'submitting' && state.kind !== 'success') { + if (state.kind !== 'idle' && state.kind !== 'submitting') { setState({ kind: 'idle' }); } }} From 742b191599d36792d71fcbb8246eac0a8b2cff04 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 09:46:42 +0530 Subject: [PATCH 20/35] docs(faucet): refresh stale page metadata and .env.example for slider --- .env.example | 20 ++++++++++++-------- app/faucet/page.tsx | 7 +++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 3dd8055..916e8d4 100644 --- a/.env.example +++ b/.env.example @@ -23,19 +23,23 @@ NEXT_PUBLIC_HELIUS_API_KEY= ZEBRA_GRPC_URL=127.0.0.1:8230 # Testnet Faucet -# Talks to taps (separate repo) — an Orchard-only Rust faucet wallet daemon -# running on loopback. Cipherscan Express handles Turnstile + cooldown, -# then proxies to taps for the actual spend. -TAPS_URL=http://127.0.0.1:3000 +# Talks to taps (https://github.com/zcashme/taps) — an Orchard-only Rust faucet +# wallet daemon. Run it on the same VPS as Express, bound to loopback only. +# Cipherscan Express handles Turnstile + per-address cooldown, then proxies +# to taps for the spend. +TAPS_URL=http://127.0.0.1:3001 TAPS_API_KEY= -# How much TAZ each dispense sends — whole TAZ only (taps constraint) +# Fallback dispense amount in TAZ. The UI sends `amountTaz` per request via the +# slider (0.001 – 1 TAZ); this only applies if a client omits that field. FAUCET_DISPENSE_AMOUNT_TAZ=1 -# Optional protections — both default to OFF. Set to enable. -# Per-address cooldown in seconds (0 = no cooldown) +# Per-address cooldown in seconds (0 = no cooldown). Production: 86400 (24h). +# Requires Redis to be reachable (REDIS_HOST/REDIS_PORT) or the gate no-ops. FAUCET_COOLDOWN_SECONDS=0 -# Cloudflare Turnstile (set both keys to enable captcha) + +# 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= diff --git a/app/faucet/page.tsx b/app/faucet/page.tsx index 43d6237..e73555c 100644 --- a/app/faucet/page.tsx +++ b/app/faucet/page.tsx @@ -4,12 +4,11 @@ import FaucetClient from './FaucetClient'; import { isTestnet } from '@/lib/config'; export const metadata: Metadata = { - title: 'Testnet Faucet — Get free TAZ | CipherScan', - description: - 'Free testnet ZEC delivered to any transparent address. 0.5 TAZ per address every 24 hours.', + title: 'Testnet Faucet | CipherScan', + description: 'Get TAZ for your Orchard Unified Address', openGraph: { title: 'Zcash Testnet Faucet | CipherScan', - description: 'Get free testnet ZEC (TAZ) for development and testing.', + description: 'Get TAZ for your Orchard Unified Address', url: 'https://testnet.cipherscan.app/faucet', siteName: 'CipherScan', type: 'website', From 31916b7b96dcdd4cda39e455dd4c2fbf9f25bc0a Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 10:28:27 +0530 Subject: [PATCH 21/35] refactor(faucet): rip out per-address cooldown + Redis gate --- .env.example | 7 +------ app/faucet/FaucetClient.tsx | 34 +--------------------------------- server/api/routes/faucet.js | 37 +++---------------------------------- 3 files changed, 5 insertions(+), 73 deletions(-) diff --git a/.env.example b/.env.example index 916e8d4..f96d4bf 100644 --- a/.env.example +++ b/.env.example @@ -25,8 +25,7 @@ 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. Run it on the same VPS as Express, bound to loopback only. -# Cipherscan Express handles Turnstile + per-address cooldown, then proxies -# to taps for the spend. +# Cipherscan Express handles Turnstile, then proxies to taps for the spend. TAPS_URL=http://127.0.0.1:3001 TAPS_API_KEY= @@ -34,10 +33,6 @@ TAPS_API_KEY= # slider (0.001 – 1 TAZ); this only applies if a client omits that field. FAUCET_DISPENSE_AMOUNT_TAZ=1 -# Per-address cooldown in seconds (0 = no cooldown). Production: 86400 (24h). -# Requires Redis to be reachable (REDIS_HOST/REDIS_PORT) or the gate no-ops. -FAUCET_COOLDOWN_SECONDS=0 - # Cloudflare Turnstile (https://dash.cloudflare.com/?to=/:account/turnstile). # Set both to enable captcha; leave both empty to disable. TURNSTILE_SECRET_KEY= diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index b0f4786..731a561 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -34,7 +34,6 @@ function formatTaz(v: number): string { interface FaucetStatus { balanceTaz: number; dispenseAmountTaz: number; - cooldownSeconds: number; captchaEnabled: boolean; donateAddress: string | null; } @@ -44,19 +43,9 @@ type SubmitState = | { kind: 'submitting' } | { kind: 'success'; txid: string; amountTaz: number } | { kind: 'invalid' } - | { kind: 'cooldown'; retryAfterSeconds: number } | { kind: 'drained' } | { kind: 'error'; message: string }; -function formatRetry(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - const m = Math.ceil(seconds / 60); - if (m < 60) return `${m}m`; - const h = Math.floor(m / 60); - const rem = m % 60; - return rem === 0 ? `${h}h` : `${h}h ${rem}m`; -} - // Loose testnet Unified Address check (bech32m charset). Strict parsing // happens server-side in taps. function isValidTestnetUnifiedAddress(addr: string): boolean { @@ -74,8 +63,6 @@ export default function FaucetClient() { const isDark = theme === 'dark'; const captchaRequired = !!TURNSTILE_SITE_KEY; - const cooldownEnabled = (status?.cooldownSeconds ?? 0) > 0; - useEffect(() => { let cancelled = false; async function loadStatus() { @@ -129,12 +116,6 @@ export default function FaucetClient() { case 'invalid address': setState({ kind: 'invalid' }); break; - case 'cooldown': - setState({ - kind: 'cooldown', - retryAfterSeconds: data.retryAfterSeconds ?? 86400, - }); - break; case 'drained': setState({ kind: 'drained' }); break; @@ -174,11 +155,6 @@ export default function FaucetClient() { {'>'} TESTNET_FAUCET

    Testnet Faucet

    - {cooldownEnabled && ( -

    - one request every {formatRetry(status!.cooldownSeconds)}. -

    - )}
    @@ -270,11 +246,6 @@ export default function FaucetClient() { invalid testnet address — expected utest1…

    )} - {state.kind === 'cooldown' && ( -

    - cooldown active — try again in {formatRetry(state.retryAfterSeconds)} -

    - )} {state.kind === 'drained' && (

    faucet is dry — mining the next refill, check back later @@ -376,10 +347,7 @@ export default function FaucetClient() { {'>'} RULES_OF_ENGAGEMENT

      -
    • - · {MIN_DISPENSE_TAZ} – {MAX_DISPENSE_TAZ} TAZ per request - {cooldownEnabled && `, max one per ${formatRetry(status!.cooldownSeconds)}`} -
    • +
    • · {MIN_DISPENSE_TAZ} – {MAX_DISPENSE_TAZ} TAZ per request
    • · Orchard / Unified addresses (utest1…) only
    diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js index d6eaa09..3ab6a56 100644 --- a/server/api/routes/faucet.js +++ b/server/api/routes/faucet.js @@ -1,19 +1,19 @@ /** * Testnet Faucet — proxy to the taps wallet daemon * - * GET /api/faucet/status → balance, dispense amount, cooldown, captcha + * GET /api/faucet/status → balance, dispense amount, captcha * POST /api/faucet/dispense → send TAZ to a testnet Unified Address * * Taps (https://github.com/zcashme/taps — separate repo) runs on the same VPS, * listens on loopback only, and holds the Orchard spending key. We sit in - * front of it for Turnstile + per-address cooldown. + * front of it for Turnstile only. */ const express = require('express'); const router = express.Router(); const DEFAULT_DISPENSE_TAZ = 1; -const DEFAULT_TAPS_URL = 'http://127.0.0.1:3000'; +const DEFAULT_TAPS_URL = 'http://127.0.0.1:3001'; // Loose testnet Unified Address check: bech32m charset, utest1 prefix. // Strict parsing happens in taps. const UA_REGEX = /^utest1[02-9ac-hj-np-z]{40,}$/; @@ -27,11 +27,6 @@ function dispenseAmountTaz() { return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DISPENSE_TAZ; } -function cooldownSeconds() { - const raw = parseInt(process.env.FAUCET_COOLDOWN_SECONDS, 10); - return Number.isFinite(raw) && raw > 0 ? raw : 0; -} - async function verifyTurnstile(token, remoteIp) { const secret = process.env.TURNSTILE_SECRET_KEY; if (!secret) return true; // captcha disabled @@ -81,7 +76,6 @@ router.get('/api/faucet/status', async (_req, res) => { res.json({ balanceTaz: typeof orchard === 'number' ? orchard : 0, dispenseAmountTaz: dispenseAmountTaz(), - cooldownSeconds: cooldownSeconds(), captchaEnabled: !!process.env.TURNSTILE_SECRET_KEY, donateAddress: typeof ua === 'string' && ua !== 'unavailable' ? ua : null, }); @@ -92,7 +86,6 @@ router.get('/api/faucet/status', async (_req, res) => { }); router.post('/api/faucet/dispense', express.json(), async (req, res) => { - const redisClient = req.app.locals.redisClient; const { address, amountTaz: requestedAmount, captchaToken } = req.body || {}; if (!address || typeof address !== 'string' || !UA_REGEX.test(address.trim())) { @@ -116,23 +109,6 @@ router.post('/api/faucet/dispense', express.json(), async (req, res) => { return res.status(400).json({ error: 'captcha failed' }); } - const cdSec = cooldownSeconds(); - if (cdSec > 0 && redisClient) { - const key = `faucet:cooldown:${addr}`; - try { - const existing = await redisClient.get(key); - if (existing) { - const ttl = await redisClient.ttl(key); - return res.status(429).json({ - error: 'cooldown', - retryAfterSeconds: ttl > 0 ? ttl : cdSec, - }); - } - } catch (err) { - console.error('[faucet] cooldown check failed:', err.message); - } - } - let result; try { result = await tapsSend({ recipient: addr, amountTaz }); @@ -142,13 +118,6 @@ router.post('/api/faucet/dispense', express.json(), async (req, res) => { } if (result.status === 200 && result.body?.txid) { - if (cdSec > 0 && redisClient) { - try { - await redisClient.set(`faucet:cooldown:${addr}`, '1', { EX: cdSec }); - } catch (err) { - console.error('[faucet] cooldown set failed:', err.message); - } - } console.log(`[faucet] dispensed ${amountTaz} TAZ to ${addr.slice(0, 12)}… txid=${result.body.txid}`); return res.json({ txid: result.body.txid, amountTaz }); } From e9d8a60b17ea18abe7db2c5410a9249a8e59a55d Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 10:52:44 +0530 Subject: [PATCH 22/35] feat(faucet): surface max_dispensable, warn when it dips below 20% of cap --- app/faucet/FaucetClient.tsx | 23 +++++++++++++++++++++++ server/api/routes/faucet.js | 6 ++++++ 2 files changed, 29 insertions(+) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 731a561..78f156d 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -33,11 +33,17 @@ function formatTaz(v: number): string { interface FaucetStatus { balanceTaz: number; + maxDispensableTaz: number; + maxSpendTaz: number; dispenseAmountTaz: number; captchaEnabled: boolean; donateAddress: string | null; } +// Show a "wallet syncing" notice when a single dispense can fulfill less +// than 20% of the per-tx cap. Above that we consider it healthy fluctuation. +const SYNC_NOTICE_THRESHOLD = 0.2; + type SubmitState = | { kind: 'idle' } | { kind: 'submitting' } @@ -63,6 +69,12 @@ export default function FaucetClient() { const isDark = theme === 'dark'; const captchaRequired = !!TURNSTILE_SITE_KEY; + const maxDispensable = status?.maxDispensableTaz ?? MAX_DISPENSE_TAZ; + const maxSpend = status?.maxSpendTaz ?? MAX_DISPENSE_TAZ; + const lowSpendable = + status != null && maxSpend > 0 && maxDispensable < maxSpend * SYNC_NOTICE_THRESHOLD; + const overSpendable = status != null && amountTaz > maxDispensable + 1e-9; + useEffect(() => { let cancelled = false; async function loadStatus() { @@ -155,6 +167,11 @@ export default function FaucetClient() { {'>'} TESTNET_FAUCET

    Testnet Faucet

    + {lowSpendable && ( +

    + wallet syncing — single dispense currently capped at {formatTaz(maxDispensable)} TAZ +

    + )}
    @@ -282,6 +299,11 @@ export default function FaucetClient() { {MIN_DISPENSE_TAZ} TAZ {MAX_DISPENSE_TAZ} TAZ
    + {overSpendable && ( +

    + only {formatTaz(maxDispensable)} TAZ spendable right now — pick a smaller amount +

    + )}
    {captchaRequired && ( @@ -306,6 +328,7 @@ export default function FaucetClient() { isSubmitting || !address.trim() || state.kind === 'drained' || + overSpendable || (captchaRequired && !captchaToken) } className="w-full bg-cipher-yellow text-black rounded-md px-4 py-3 font-mono font-bold text-sm hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity flex items-center justify-center gap-2" diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js index 3ab6a56..81d5f19 100644 --- a/server/api/routes/faucet.js +++ b/server/api/routes/faucet.js @@ -68,13 +68,19 @@ async function tapsSend({ recipient, amountTaz }) { return { status: res.status, body }; } +const ZAT_PER_TAZ = 100_000_000; + router.get('/api/faucet/status', async (_req, res) => { 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; res.json({ balanceTaz: typeof orchard === 'number' ? orchard : 0, + maxDispensableTaz: typeof maxDispensable === 'number' ? maxDispensable / ZAT_PER_TAZ : 0, + maxSpendTaz: typeof maxSpend === 'number' ? maxSpend / ZAT_PER_TAZ : 0, dispenseAmountTaz: dispenseAmountTaz(), captchaEnabled: !!process.env.TURNSTILE_SECRET_KEY, donateAddress: typeof ua === 'string' && ua !== 'unavailable' ? ua : null, From ec0a36bbb889521b191009146cba7536b7b8ba00 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 10:55:56 +0530 Subject: [PATCH 23/35] feat(faucet): promote balance to 3-up wallet stats card above donate --- app/faucet/FaucetClient.tsx | 72 +++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 78f156d..78a9bc4 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -160,34 +160,17 @@ export default function FaucetClient() { return (
    - {/* Header + status strip */} -
    -
    -

    - {'>'} TESTNET_FAUCET + {/* Header */} +

    +

    + {'>'} TESTNET_FAUCET +

    +

    Testnet Faucet

    + {lowSpendable && ( +

    + wallet syncing — single dispense currently capped at {formatTaz(maxDispensable)} TAZ

    -

    Testnet Faucet

    - {lowSpendable && ( -

    - wallet syncing — single dispense currently capped at {formatTaz(maxDispensable)} TAZ -

    - )} -
    - -
    - balance{' '} - - {status ? `${status.balanceTaz.toFixed(1)} TAZ` : '…'} - -
    -
    - - {/* Mobile balance */} -
    - balance{' '} - - {status ? `${status.balanceTaz.toFixed(1)} TAZ` : '…'} - + )}
    {/* Form / Result */} @@ -376,6 +359,41 @@ export default function FaucetClient() { + {/* Wallet stats */} + + +

    + {'>'} WALLET_STATS +

    +
    +
    +
    + Balance +
    +
    + {status ? `${formatTaz(status.balanceTaz)} TAZ` : '…'} +
    +
    +
    +
    + Spendable +
    +
    + {status ? `${formatTaz(status.maxDispensableTaz)} TAZ` : '…'} +
    +
    +
    +
    + Cap / tx +
    +
    + {status ? `${formatTaz(status.maxSpendTaz)} TAZ` : '…'} +
    +
    +
    +
    +
    + {/* Donate card */} From 73e9574abe5296a34553723d27999f6a8ed2e7d9 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 11:01:43 +0530 Subject: [PATCH 24/35] feat(faucet): move stats above rules, clearer labels, fix light-mode address input --- app/faucet/FaucetClient.tsx | 46 +++++++++++++++---------------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 78a9bc4..dfd8988 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -239,7 +239,7 @@ export default function FaucetClient() { spellCheck={false} autoComplete="off" disabled={isSubmitting} - className="w-full bg-black/40 border border-cipher-border rounded-md px-3 py-2.5 font-mono text-sm text-primary placeholder:text-muted/40 focus:outline-none focus:border-cipher-cyan/60 focus:ring-1 focus:ring-cipher-cyan/30 transition-colors disabled:opacity-50" + className="input-field disabled:opacity-50" /> {state.kind === 'invalid' && (

    @@ -346,54 +346,46 @@ export default function FaucetClient() { )} - {/* Rules card */} - - -

    - {'>'} RULES_OF_ENGAGEMENT -

    -
      -
    • · {MIN_DISPENSE_TAZ} – {MAX_DISPENSE_TAZ} TAZ per request
    • -
    • · Orchard / Unified addresses (utest1…) only
    • -
    -
    -
    - {/* Wallet stats */}

    {'>'} WALLET_STATS

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

    + {'>'} RULES_OF_ENGAGEMENT +

    +
      +
    • · {MIN_DISPENSE_TAZ} – {MAX_DISPENSE_TAZ} TAZ per request
    • +
    • · Orchard / Unified addresses (utest1…) only
    • +
    +
    +
    + {/* Donate card */} From f7b3c43d302524609e0babdad4ead8d092b412a4 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 11:10:56 +0530 Subject: [PATCH 25/35] refactor(faucet): rename WALLET_STATS to FAUCET_STATS, drop hardcoded fallbacks --- app/faucet/FaucetClient.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index dfd8988..b4d988c 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -69,11 +69,11 @@ export default function FaucetClient() { const isDark = theme === 'dark'; const captchaRequired = !!TURNSTILE_SITE_KEY; - const maxDispensable = status?.maxDispensableTaz ?? MAX_DISPENSE_TAZ; - const maxSpend = status?.maxSpendTaz ?? MAX_DISPENSE_TAZ; const lowSpendable = - status != null && maxSpend > 0 && maxDispensable < maxSpend * SYNC_NOTICE_THRESHOLD; - const overSpendable = status != null && amountTaz > maxDispensable + 1e-9; + status != null && + status.maxSpendTaz > 0 && + status.maxDispensableTaz < status.maxSpendTaz * SYNC_NOTICE_THRESHOLD; + const overSpendable = status != null && amountTaz > status.maxDispensableTaz + 1e-9; useEffect(() => { let cancelled = false; @@ -168,7 +168,7 @@ export default function FaucetClient() {

    Testnet Faucet

    {lowSpendable && (

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

    )}
    @@ -284,7 +284,7 @@ export default function FaucetClient() {
    {overSpendable && (

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

    )}
    @@ -350,7 +350,7 @@ export default function FaucetClient() {

    - {'>'} WALLET_STATS + {'>'} FAUCET_STATS

    From b59934909ba0c6636f0d72ad9a90086e49d07ed6 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 11:13:06 +0530 Subject: [PATCH 26/35] fix(faucet): drive captcha gate off server status, fail loud on env drift --- app/faucet/FaucetClient.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index b4d988c..8ef38eb 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -67,7 +67,12 @@ export default function FaucetClient() { const turnstileRef = useRef(null); const { theme, mounted: themeMounted } = useTheme(); const isDark = theme === 'dark'; - const captchaRequired = !!TURNSTILE_SITE_KEY; + // Drive captcha gating off the server (TURNSTILE_SECRET_KEY) — the public + // site key alone isn't authoritative. If the server enforces captcha but the + // UI is missing the site key, fail loud instead of silently 400-looping. + const captchaEnabledServer = status?.captchaEnabled === true; + const captchaRequired = captchaEnabledServer && !!TURNSTILE_SITE_KEY; + const captchaMisconfigured = captchaEnabledServer && !TURNSTILE_SITE_KEY; const lowSpendable = status != null && @@ -305,6 +310,13 @@ export default function FaucetClient() {
    )} + {captchaMisconfigured && ( +

    + captcha misconfigured — server requires it but site key is missing. + ask the operator to set NEXT_PUBLIC_TURNSTILE_SITE_KEY. +

    + )} +
    - {state.txid} - + {result.txid} +

    Likely unconfirmed — confirmation in ~75 seconds. @@ -204,7 +191,7 @@ export default function FaucetClient() {

    view tx → @@ -236,29 +223,17 @@ export default function FaucetClient() { value={address} onChange={(e) => { setAddress(e.target.value); - if (state.kind !== 'idle' && state.kind !== 'submitting') { - setState({ kind: 'idle' }); - } + if (notice) setNotice(null); }} placeholder="utest1..." spellCheck={false} autoComplete="off" - disabled={isSubmitting} + disabled={pending} className="input-field disabled:opacity-50" /> - {state.kind === 'invalid' && ( -

    - invalid testnet address — expected utest1… -

    - )} - {state.kind === 'drained' && ( -

    - faucet is dry — mining the next refill, check back later -

    - )} - {state.kind === 'error' && ( + {notice && (

    - {state.message} + {notice}

    )}
    @@ -279,7 +254,7 @@ export default function FaucetClient() { step={STEP_TAZ} value={amountTaz} onChange={(e) => setAmountTaz(snapToStep(parseFloat(e.target.value)))} - disabled={isSubmitting} + disabled={pending} aria-label="Dispense amount in TAZ" className="w-full accent-cipher-cyan cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" /> @@ -303,7 +278,7 @@ export default function FaucetClient() { onExpire={() => setCaptchaToken(null)} onError={() => setCaptchaToken(null)} options={{ - theme: isDark ? 'dark' : 'light', + theme, size: 'normal', }} /> @@ -320,16 +295,15 @@ export default function FaucetClient() {
    setAmountTaz(snapToStep(parseFloat(e.target.value)))} + onChange={(e) => setAmountTaz(snapToStep(parseFloat(e.target.value), stepTaz))} disabled={pending} aria-label="Dispense amount in TAZ" className="w-full accent-cipher-cyan cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" />
    - {MIN_DISPENSE_TAZ} TAZ - {MAX_DISPENSE_TAZ} TAZ + {formatTaz(minTaz)} TAZ + {formatTaz(maxTaz)} TAZ
    {overSpendable && (

    @@ -367,7 +360,7 @@ export default function FaucetClient() { {'>'} RULES_OF_ENGAGEMENT

      -
    • · {MIN_DISPENSE_TAZ} – {MAX_DISPENSE_TAZ} TAZ per request
    • +
    • · {formatTaz(minTaz)} – {formatTaz(maxTaz)} TAZ per request
    • · Orchard / Unified addresses (utest1…) only
    diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js index b3a10a5..2e7e762 100644 --- a/server/api/routes/faucet.js +++ b/server/api/routes/faucet.js @@ -1,25 +1,17 @@ /** - * Testnet Faucet — proxy to the taps wallet daemon - * - * GET /api/faucet/status → balance, dispense amount, captcha - * POST /api/faucet/dispense → send TAZ to a testnet Unified Address - * - * Taps (https://github.com/zcashme/taps — separate repo) runs on the same VPS, - * listens on loopback only, and holds the Orchard spending key. We sit in - * front of it for Turnstile only. + * Testnet Faucet Routes + * /api/faucet/status, /api/faucet/dispense — proxies to taps, verifies Turnstile */ const express = require('express'); const router = express.Router(); const DEFAULT_DISPENSE_TAZ = 1; -const DEFAULT_TAPS_URL = 'http://127.0.0.1:3001'; -// Loose testnet Unified Address check: bech32m charset, utest1 prefix. -// Strict parsing happens in taps. const UA_REGEX = /^utest1[02-9ac-hj-np-z]{40,}$/; -function tapsUrl() { - return (process.env.TAPS_URL || DEFAULT_TAPS_URL).replace(/\/$/, ''); +const TAPS_URL = process.env.TAPS_URL || ''; +if (!TAPS_URL) { + console.error('[faucet] TAPS_URL not set — /api/faucet/* will 503'); } function dispenseAmountTaz() { @@ -49,14 +41,14 @@ async function verifyTurnstile(token, remoteIp) { } async function tapsStatus() { - const res = await fetch(`${tapsUrl()}/status`); + 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(`${tapsUrl()}/send`, { + const res = await fetch(`${TAPS_URL}/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -68,19 +60,22 @@ async function tapsSend({ recipient, amountTaz }) { return { status: res.status, body }; } -const ZAT_PER_TAZ = 100_000_000; - 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 / ZAT_PER_TAZ : 0, - maxSpendTaz: typeof maxSpend === 'number' ? maxSpend / ZAT_PER_TAZ : 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, captchaEnabled: !!process.env.TURNSTILE_SECRET_KEY, donateAddress: typeof ua === 'string' && ua !== 'unavailable' ? ua : null, }); @@ -91,6 +86,7 @@ router.get('/api/faucet/status', async (_req, res) => { }); router.post('/api/faucet/dispense', express.json(), async (req, res) => { + if (!TAPS_URL) return res.status(503).json({ error: 'taps not configured' }); const { address, amountTaz: requestedAmount, captchaToken } = req.body || {}; if (!address || typeof address !== 'string' || !UA_REGEX.test(address.trim())) { @@ -98,8 +94,6 @@ router.post('/api/faucet/dispense', express.json(), async (req, res) => { } const addr = address.trim(); - // Caller chooses the amount; taps enforces min/max/increment. - // Fall back to the env default if the client didn't send one. let amountTaz; if (requestedAmount === undefined || requestedAmount === null) { amountTaz = dispenseAmountTaz(); @@ -127,7 +121,6 @@ router.post('/api/faucet/dispense', express.json(), async (req, res) => { return res.json({ txid: result.body.txid, amountTaz }); } - // Map taps errors → cipherscan UI shapes const tapsErr = result.body?.error || ''; if (tapsErr === 'invalid address') { return res.status(400).json({ error: 'invalid address' }); From 25404d834eeb3d3497332535ca7d54f46472a903 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 11:41:51 +0530 Subject: [PATCH 29/35] refactor(faucet): make captcha mandatory, drop captchaEnabled toggle --- app/faucet/FaucetClient.tsx | 22 ++++++++-------------- server/api/routes/faucet.js | 11 +++++++---- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index a529c81..74d438c 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -33,7 +33,6 @@ interface FaucetStatus { maxSpendTaz: number; minSpendTaz: number; stepTaz: number; - captchaEnabled: boolean; donateAddress: string | null; } @@ -66,9 +65,7 @@ export default function FaucetClient() { const [captchaToken, setCaptchaToken] = useState(null); const turnstileRef = useRef(null); const { theme, mounted: themeMounted } = useTheme(); - const captchaEnabledServer = status?.captchaEnabled === true; - const captchaRequired = captchaEnabledServer && !!TURNSTILE_SITE_KEY; - const captchaMisconfigured = captchaEnabledServer && !TURNSTILE_SITE_KEY; + const captchaMisconfigured = !TURNSTILE_SITE_KEY; const minTaz = status?.minSpendTaz || FALLBACK_MIN_TAZ; const maxTaz = status?.maxSpendTaz || FALLBACK_MAX_TAZ; @@ -105,7 +102,7 @@ export default function FaucetClient() { setNotice('invalid testnet address — expected utest1…'); return; } - if (captchaRequired && !captchaToken) { + if (!captchaToken) { setNotice('complete the captcha first'); return; } @@ -262,7 +259,11 @@ export default function FaucetClient() { )}
    - {captchaRequired && ( + {captchaMisconfigured ? ( +

    + captcha misconfigured — set NEXT_PUBLIC_TURNSTILE_SITE_KEY on the build. +

    + ) : (
    )} - {captchaMisconfigured && ( -

    - captcha misconfigured — server requires it but site key is missing. - ask the operator to set NEXT_PUBLIC_TURNSTILE_SITE_KEY. -

    - )} -
    - setAmountTaz(snapToStep(parseFloat(e.target.value), 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(minTaz)} TAZ - {formatTaz(maxTaz)} 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.maxSpendTaz)} TAZ +
    + + ) : ( +
    loading bounds…
    + )} {overSpendable && (

    only {formatTaz(status!.maxDispensableTaz)} TAZ spendable right now — pick a smaller amount @@ -284,6 +282,7 @@ export default function FaucetClient() { disabled={ pending || !address.trim() || + !status || overSpendable || captchaMisconfigured || !captchaToken @@ -354,7 +353,7 @@ export default function FaucetClient() { {'>'} RULES_OF_ENGAGEMENT

      -
    • · {formatTaz(minTaz)} – {formatTaz(maxTaz)} TAZ per request
    • +
    • · {status ? `${formatTaz(status.minSpendTaz)} – ${formatTaz(status.maxSpendTaz)} TAZ per request` : '…'}
    • · Orchard / Unified addresses (utest1…) only
    From d6ebfa9e66891cf8dbdbaeab03d6a576798eeff3 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 12:28:14 +0530 Subject: [PATCH 31/35] docs(faucet): point TAPS_URL example at hosted light.zcash.me/taps --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index f96d4bf..59a89bf 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,9 @@ 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. Run it on the same VPS as Express, bound to loopback only. -# Cipherscan Express handles Turnstile, then proxies to taps for the spend. -TAPS_URL=http://127.0.0.1:3001 +# 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= # Fallback dispense amount in TAZ. The UI sends `amountTaz` per request via the From 2c9b096dc418ede67aabe556c37c1d12276b9186 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 12:30:37 +0530 Subject: [PATCH 32/35] Revert "feat(swap): 404 /swap on non-mainnet networks" This reverts commit 50cbd58b657172b7db069b7931d13464e9309191. --- app/swap/SwapClient.tsx | 1535 -------------------------------------- app/swap/page.tsx | 1558 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 1554 insertions(+), 1539 deletions(-) delete mode 100644 app/swap/SwapClient.tsx diff --git a/app/swap/SwapClient.tsx b/app/swap/SwapClient.tsx deleted file mode 100644 index 3036bf3..0000000 --- a/app/swap/SwapClient.tsx +++ /dev/null @@ -1,1535 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import Link from 'next/link'; -import { isMainnet } from '@/lib/config'; -import { API_CONFIG } from '@/lib/api-config'; -import { TokenChainIcon } from '@/components/TokenChainIcon'; -import { useWallet, chainToWalletType, type DetectedWallet } from '@/hooks/useWallet'; - -const BASE58_CHARS = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; - -function validateZecAddress(addr: string): string | null { - if (!addr) return null; - if (addr.startsWith('u1') || addr.startsWith('utest')) { - if (addr.length < 80) return 'Unified address too short'; - return null; - } - if (addr.startsWith('zs') || addr.startsWith('ztestsapling')) { - if (addr.length < 70) return 'Sapling address too short'; - return null; - } - if (!addr.startsWith('t1') && !addr.startsWith('t3')) - return 'Must start with t1, t3, u1, or zs'; - if (!BASE58_CHARS.test(addr)) - return 'Contains invalid characters'; - if (addr.length !== 35) - return `Transparent address must be 35 characters (currently ${addr.length})`; - return null; -} - -interface CommonAmount { - amountZec: number; - txCount: number; - percentage: string; - blendingScore: number; - chainSwapCount?: number; - sourceAmount?: number | null; - sourceToken?: string | null; - dualBlendScore?: number; -} - -interface CommonAmountsResponse { - success: boolean; - period: string; - chain: string | null; - totalTransactions: number; - amounts: CommonAmount[]; - tip: string; -} - -function formatRecAmount(amount: number, token: string): string { - const t = token.toLowerCase(); - if (['usdc', 'usdt', 'dai', 'busd', 'tusd', 'usdp'].includes(t)) { - return amount >= 1 ? amount.toLocaleString(undefined, { maximumFractionDigits: 0 }) : amount.toString(); - } - if (amount === 0) return '0'; - if (amount >= 100) return amount.toLocaleString(undefined, { maximumFractionDigits: 2 }); - if (amount >= 1) return amount.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 3 }); - if (amount >= 0.01) return amount.toFixed(3); - return amount.toFixed(4); -} - -function getBlendingLabel(score: number, dualScore?: number): 'high' | 'medium' | 'low' { - const effective = dualScore || score; - if (effective >= 30) return 'high'; - if (effective >= 10) return 'medium'; - return 'low'; -} - -interface QuoteResponse { - success: boolean; - depositAddress?: string; - amountOut?: string; - estimatedAmountOut?: string; - deadline?: string; - error?: string; -} - -type SwapStep = 'connect' | 'form' | 'quote' | 'waiting' | 'complete' | 'error'; - -const PENDING_SWAP_KEY = 'cipherscan_pending_swap'; - -interface PendingSwap { - depositAddress: string; - amount: string; - token: string; - chain: string; - chainLabel: string; - assetId: string; - decimals: number; - contractAddress?: string; - zecAddress: string; - estimatedZec: string; - txHash?: string; - createdAt: number; -} - -interface SourceToken { - id: string; - chain: string; - chainLabel: string; - token: string; - decimals: number; - assetId: string; - contractAddress?: string; -} - -const CHAIN_EXPLORERS: Record = { - eth: 'https://etherscan.io/tx/', - base: 'https://basescan.org/tx/', - arb: 'https://arbiscan.io/tx/', - op: 'https://optimistic.etherscan.io/tx/', - pol: 'https://polygonscan.com/tx/', - avax: 'https://snowtrace.io/tx/', - bsc: 'https://bscscan.com/tx/', - sol: 'https://solscan.io/tx/', - btc: 'https://mempool.space/tx/', - near: 'https://nearblocks.io/txns/', - gnosis: 'https://gnosisscan.io/tx/', - bera: 'https://berascan.com/tx/', - scroll: 'https://scrollscan.com/tx/', - tron: 'https://tronscan.org/#/transaction/', -}; - -const CHAIN_LABELS: Record = { - eth: 'Ethereum', base: 'Base', arb: 'Arbitrum', sol: 'Solana', btc: 'Bitcoin', - near: 'NEAR', ton: 'TON', doge: 'Dogecoin', xrp: 'XRP', bsc: 'BNB Chain', - pol: 'Polygon', tron: 'Tron', sui: 'Sui', op: 'Optimism', avax: 'Avalanche', - ltc: 'Litecoin', bch: 'Bitcoin Cash', gnosis: 'Gnosis', bera: 'Berachain', - cardano: 'Cardano', starknet: 'Starknet', zec: 'Zcash', aleo: 'Aleo', - xlayer: 'XLayer', monad: 'Monad', adi: 'ADI', plasma: 'Plasma', scroll: 'Scroll', - dash: 'Dash', -}; - -const FALLBACK_TOKEN_ORDER = ['usdc', 'eth', 'usdt', 'btc', 'sol', 'bnb', 'near', 'dai', 'doge', 'xrp', 'ton', 'ltc']; -const FALLBACK_CHAIN_ORDER = ['eth', 'sol', 'base', 'arb', 'btc', 'bsc', 'op', 'pol', 'avax', 'near', 'ton', 'doge', 'xrp', 'ltc', 'sui', 'tron']; - -interface PopularPair { chain: string; token: string; swapCount: number } - -function sortTokens(tokens: SourceToken[], popularPairs: PopularPair[]): SourceToken[] { - if (popularPairs.length > 0) { - const pairRank = new Map(); - popularPairs.forEach((p, i) => { - pairRank.set(`${p.chain.toLowerCase()}:${p.token.toLowerCase()}`, i); - }); - return [...tokens].sort((a, b) => { - const aKey = `${a.chain.toLowerCase()}:${a.token.toLowerCase()}`; - const bKey = `${b.chain.toLowerCase()}:${b.token.toLowerCase()}`; - const aRank = pairRank.get(aKey) ?? 9999; - const bRank = pairRank.get(bKey) ?? 9999; - if (aRank !== bRank) return aRank - bRank; - const aToken = FALLBACK_TOKEN_ORDER.indexOf(a.token.toLowerCase()); - const bToken = FALLBACK_TOKEN_ORDER.indexOf(b.token.toLowerCase()); - if ((aToken >= 0 ? aToken : 999) !== (bToken >= 0 ? bToken : 999)) - return (aToken >= 0 ? aToken : 999) - (bToken >= 0 ? bToken : 999); - return a.chainLabel.localeCompare(b.chainLabel); - }); - } - return [...tokens].sort((a, b) => { - const aToken = FALLBACK_TOKEN_ORDER.indexOf(a.token.toLowerCase()); - const bToken = FALLBACK_TOKEN_ORDER.indexOf(b.token.toLowerCase()); - const aRank = aToken >= 0 ? aToken : 999; - const bRank = bToken >= 0 ? bToken : 999; - if (aRank !== bRank) return aRank - bRank; - const aChain = FALLBACK_CHAIN_ORDER.indexOf(a.chain.toLowerCase()); - const bChain = FALLBACK_CHAIN_ORDER.indexOf(b.chain.toLowerCase()); - if ((aChain >= 0 ? aChain : 999) !== (bChain >= 0 ? bChain : 999)) - return (aChain >= 0 ? aChain : 999) - (bChain >= 0 ? bChain : 999); - return a.chainLabel.localeCompare(b.chainLabel); - }); -} - -function apiTokensToSourceTokens(apiTokens: any[]): SourceToken[] { - return apiTokens - .filter(t => { - if (!t.assetId || !t.symbol || !t.blockchain || t.decimals == null) return false; - const id = t.assetId.toLowerCase(); - if (id.includes('zec') || t.blockchain === 'zec') return false; - return true; - }) - .map(t => { - let contractAddress: string | undefined; - if (t.address) contractAddress = t.address; - else if (t.contractAddress) contractAddress = t.contractAddress; - else if (t.assetId) { - const evmMatch = t.assetId.match(/0x[a-fA-F0-9]{40}/); - if (evmMatch) { - contractAddress = evmMatch[0]; - } else { - const solMatch = t.assetId.match(/sol-([A-HJ-NP-Za-km-z1-9]{32,44})\./); - if (solMatch) contractAddress = solMatch[1]; - } - } - return { - id: `${t.blockchain}-${t.symbol.toLowerCase()}-${t.assetId}`, - chain: t.blockchain, - chainLabel: CHAIN_LABELS[t.blockchain] || t.blockchain, - token: t.symbol, - decimals: t.decimals, - assetId: t.assetId, - contractAddress, - }; - }); -} - -const FALLBACK_TOKENS: SourceToken[] = [ - { id: 'eth-usdc', chain: 'eth', chainLabel: 'Ethereum', token: 'USDC', decimals: 6, assetId: 'nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near' }, - { id: 'eth-eth', chain: 'eth', chainLabel: 'Ethereum', token: 'ETH', decimals: 18, assetId: 'nep141:eth.omft.near' }, - { id: 'btc-btc', chain: 'btc', chainLabel: 'Bitcoin', token: 'BTC', decimals: 8, assetId: 'nep141:btc.omft.near' }, - { id: 'sol-sol', chain: 'sol', chainLabel: 'Solana', token: 'SOL', decimals: 9, assetId: 'nep141:sol.omft.near' }, - { id: 'near-near', chain: 'near', chainLabel: 'NEAR', token: 'NEAR', decimals: 24, assetId: 'nep141:wrap.near' }, -]; - -const ZEC_ASSET_ID = 'nep141:zec.omft.near'; -const ZEC_DECIMALS = 8; - -function WalletIcon({ wallet: w, size = 24 }: { wallet: DetectedWallet; size?: number }) { - if (w.icon) { - return ; - } - const chainFallback: Record = { - evm: '/chains/eth.png', solana: '/chains/sol.png', bitcoin: '/chains/btc.png', tron: '/chains/tron.png', - }; - const fallback = chainFallback[w.type || '']; - if (fallback) { - return ; - } - return ( -
    - {w.name.charAt(0).toUpperCase()} -
    - ); -} - -function ctaLabel(state: { - loading: boolean; - amount: string; - zecAddress: string; - refundAddress: string; - walletConnected: boolean; - hasWallets: boolean; - switching: boolean; - insufficientBalance: boolean; -}): string { - if (state.loading) return 'Getting quote...'; - if (state.switching) return 'Connecting...'; - if (!state.amount) return 'Enter amount'; - if (state.insufficientBalance) return 'Insufficient balance'; - if (!state.zecAddress) return 'Enter ZEC address'; - const addrErr = validateZecAddress(state.zecAddress); - if (addrErr) return 'Invalid ZEC address'; - if (!state.refundAddress) { - return state.hasWallets ? 'Connect wallet to continue' : 'Enter your sending address'; - } - return 'Get Quote'; -} - -export default function SwapClient() { - const [step, setStep] = useState('connect'); - const [manualMode, setManualMode] = useState(false); - const [availableTokens, setAvailableTokens] = useState(FALLBACK_TOKENS); - const [tokensLoading, setTokensLoading] = useState(true); - const [selectedToken, setSelectedToken] = useState(FALLBACK_TOKENS[0]); - const [showTokenPicker, setShowTokenPicker] = useState(false); - const [tokenSearch, setTokenSearch] = useState(''); - const [amount, setAmount] = useState(''); - const [zecAddress, setZecAddress] = useState(''); - const [refundAddress, setRefundAddress] = useState(''); - const [slippage, setSlippage] = useState(100); - const [quote, setQuote] = useState(null); - const [estimatedZec, setEstimatedZec] = useState(''); - const [depositAddress, setDepositAddress] = useState(''); - const [swapStatus, setSwapStatus] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [recommendations, setRecommendations] = useState(null); - const pollRef = useRef(null); - const [sendingTx, setSendingTx] = useState(false); - const [txHash, setTxHash] = useState(''); - const [walletError, setWalletError] = useState(''); - const [showSlippage, setShowSlippage] = useState(false); - const [copied, setCopied] = useState(false); - const [showWalletPicker, setShowWalletPicker] = useState(false); - const [nativeBalance, setNativeBalance] = useState(null); - const [balanceRefresh, setBalanceRefresh] = useState(0); - const [quoteExpiry, setQuoteExpiry] = useState(0); - const [quoteTimeLeft, setQuoteTimeLeft] = useState(0); - const wallet = useWallet(); - const pickerRef = useRef(null); - const tokenPickerRef = useRef(null); - - // Auto-advance past connect step if wallet already connected or no wallets detected - useEffect(() => { - if (step !== 'connect') return; - if (wallet.connected) { - setStep('form'); - } else if (wallet.allWallets.length === 0) { - // Wait a beat for wallet detection to complete before deciding - const t = setTimeout(() => { - if (!wallet.connected && wallet.allWallets.length === 0) { - setManualMode(true); - setStep('form'); - } - }, 2500); - return () => clearTimeout(t); - } - }, [step, wallet.connected, wallet.allWallets.length]); - - // When wallet connects from the connect step, move to form - useEffect(() => { - if (wallet.connected && step === 'connect') { - setStep('form'); - } - }, [wallet.connected, step]); - - // Restore pending swap from localStorage on mount - useEffect(() => { - try { - const raw = localStorage.getItem(PENDING_SWAP_KEY); - if (!raw) return; - const pending: PendingSwap = JSON.parse(raw); - const age = Date.now() - pending.createdAt; - if (age > 24 * 60 * 60 * 1000) { - localStorage.removeItem(PENDING_SWAP_KEY); - return; - } - setDepositAddress(pending.depositAddress); - setAmount(pending.amount); - setZecAddress(pending.zecAddress); - setEstimatedZec(pending.estimatedZec); - if (pending.txHash) setTxHash(pending.txHash); - const restored: SourceToken = { - id: `${pending.chain}:${pending.token}`, - chain: pending.chain, - chainLabel: pending.chainLabel, - token: pending.token, - decimals: pending.decimals, - assetId: pending.assetId, - contractAddress: pending.contractAddress, - }; - setSelectedToken(restored); - setStep('waiting'); - } catch { - localStorage.removeItem(PENDING_SWAP_KEY); - } - }, []); - - // Close dropdowns on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) setShowWalletPicker(false); - if (tokenPickerRef.current && !tokenPickerRef.current.contains(e.target as Node)) { setShowTokenPicker(false); setTokenSearch(''); } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - // Fetch available tokens and popularity ranking from API - useEffect(() => { - const fetchTokens = async () => { - try { - const [tokensRes, pairsRes] = await Promise.all([ - fetch(`${API_CONFIG.POSTGRES_API_URL}/api/swap/tokens`), - fetch(`${API_CONFIG.POSTGRES_API_URL}/api/crosschain/popular-pairs`).catch(() => null), - ]); - const tokensData = await tokensRes.json(); - const pairsData = pairsRes ? await pairsRes.json().catch(() => null) : null; - const popularPairs: PopularPair[] = pairsData?.success ? pairsData.pairs : []; - - if (tokensData.success && tokensData.tokens?.length) { - const mapped = apiTokensToSourceTokens(tokensData.tokens); - if (mapped.length > 0) { - const sorted = sortTokens(mapped, popularPairs); - setAvailableTokens(sorted); - setSelectedToken(sorted[0]); - } - } - } catch { /* fallback list stays */ } - finally { setTokensLoading(false); } - }; - if (isMainnet) fetchTokens(); - else setTokensLoading(false); - }, []); - - // Sync refund address with connected wallet + auto-select compatible token - useEffect(() => { - if (wallet.connected && wallet.address) { - setRefundAddress(wallet.address); - // If current token isn't compatible with connected wallet, switch to first compatible one - if (!manualMode && chainToWalletType(selectedToken.chain) !== wallet.walletType) { - const firstCompatible = availableTokens.find(t => chainToWalletType(t.chain) === wallet.walletType); - if (firstCompatible) { - setSelectedToken(firstCompatible); - setAmount(''); - } - } - } else { - setRefundAddress(''); - } - }, [wallet.connected, wallet.address, wallet.walletType]); - - // Quote countdown timer - useEffect(() => { - if (step !== 'quote' || !quoteExpiry) return; - const tick = () => { - const left = Math.max(0, Math.floor((quoteExpiry - Date.now()) / 1000)); - setQuoteTimeLeft(left); - if (left === 0) { - setStep('form'); - setError('Quote expired — please get a new one'); - } - }; - tick(); - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, [step, quoteExpiry]); - - const NATIVE_TOKENS = ['eth', 'sol', 'btc', 'bnb', 'doge', 'ltc', 'avax', 'matic', 'pol']; - const isNativeToken = NATIVE_TOKENS.includes(selectedToken.token.toLowerCase()) && !selectedToken.contractAddress; - - const evmChains = ['eth', 'base', 'arb', 'pol', 'op', 'avax', 'bsc']; - const chainKey = selectedToken.chain; - - useEffect(() => { - setNativeBalance(null); - if (!wallet.connected) return; - let cancelled = false; - const fetchBal = async () => { - let bal: string | null = null; - const isEvm = evmChains.includes(chainKey); - if (isNativeToken) { - bal = await wallet.getNativeBalance(isEvm ? chainKey : undefined); - } else if (selectedToken.contractAddress) { - bal = await wallet.getTokenBalance(selectedToken.contractAddress, selectedToken.decimals, isEvm ? chainKey : undefined); - } else { - bal = await wallet.getNativeBalance(isEvm ? chainKey : undefined); - } - if (!cancelled) setNativeBalance(bal); - }; - fetchBal(); - return () => { cancelled = true; }; - }, [wallet.connected, wallet.address, selectedToken, balanceRefresh]); - - // Fetch privacy-preserving amount recommendations (ZEC common amounts cross-referenced with source chain) - useEffect(() => { - const fetchRecs = async () => { - try { - const res = await fetch(`${API_CONFIG.POSTGRES_API_URL}/api/privacy/common-amounts?chain=${selectedToken.chain}&period=30d&limit=10`); - const data = await res.json(); - if (data.success && data.amounts?.length > 0) setRecommendations(data); - else setRecommendations(null); - } catch { - setRecommendations(null); - } - }; - if (isMainnet) fetchRecs(); - }, [selectedToken]); - - // Persist pending swap to localStorage - useEffect(() => { - if (step === 'waiting' && depositAddress) { - const pending: PendingSwap = { - depositAddress, - amount, - token: selectedToken.token, - chain: selectedToken.chain, - chainLabel: selectedToken.chainLabel, - assetId: selectedToken.assetId, - decimals: selectedToken.decimals, - contractAddress: selectedToken.contractAddress, - zecAddress, - estimatedZec, - txHash: txHash || undefined, - createdAt: Date.now(), - }; - localStorage.setItem(PENDING_SWAP_KEY, JSON.stringify(pending)); - } - }, [step, depositAddress, txHash]); - - // Poll swap status - useEffect(() => { - if (step !== 'waiting' || !depositAddress) return; - const poll = async () => { - try { - const res = await fetch(`${API_CONFIG.POSTGRES_API_URL}/api/swap/status?depositAddress=${encodeURIComponent(depositAddress)}`); - const data = await res.json(); - if (data.status === 'COMPLETE' || data.status === 'SUCCESS') { - setSwapStatus('complete'); - setStep('complete'); - localStorage.removeItem(PENDING_SWAP_KEY); - if (pollRef.current) clearInterval(pollRef.current); - } else if (data.status === 'FAILED' || data.status === 'REFUNDED') { - setSwapStatus(data.status.toLowerCase()); - setStep('error'); - setError(`Swap ${data.status.toLowerCase()}. Funds will be returned to your refund address.`); - localStorage.removeItem(PENDING_SWAP_KEY); - if (pollRef.current) clearInterval(pollRef.current); - } else { - setSwapStatus(data.status || 'processing'); - } - } catch { /* keep polling */ } - }; - poll(); - pollRef.current = setInterval(poll, 10000); - return () => { if (pollRef.current) clearInterval(pollRef.current); }; - }, [step, depositAddress]); - - const connectToWallet = async (w: DetectedWallet) => { - setWalletError(''); - setShowWalletPicker(false); - try { - await wallet.connect(w); - } catch (err: any) { - setWalletError(err.message || 'Connection failed'); - } - }; - - const chainWallets = wallet.getWalletsForChain(selectedToken.chain); - - // When wallet is connected, only show tokens from compatible chains - const compatibleTokens = wallet.connected && !manualMode - ? availableTokens.filter(t => chainToWalletType(t.chain) === wallet.walletType) - : availableTokens; - - const filteredTokens = compatibleTokens.filter(t => { - if (!tokenSearch) return true; - const q = tokenSearch.toLowerCase(); - return t.token.toLowerCase().includes(q) || t.chainLabel.toLowerCase().includes(q) || t.chain.includes(q); - }); - - const effectiveRefundAddress = refundAddress || wallet.address || ''; - - const getQuote = async () => { - if (!amount || !zecAddress || validateZecAddress(zecAddress) || !effectiveRefundAddress) return; - setLoading(true); - setError(''); - try { - const amountSmallest = BigInt(Math.round(parseFloat(amount) * Math.pow(10, selectedToken.decimals))).toString(); - const res = await fetch(`${API_CONFIG.POSTGRES_API_URL}/api/swap/quote`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - originAsset: selectedToken.assetId, - destinationAsset: ZEC_ASSET_ID, - amount: amountSmallest, - recipient: zecAddress, - refundTo: refundAddress || wallet.address, - slippageBps: slippage, - }), - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to get quote'); - const q = data.quote || data; - setQuote(data); - setDepositAddress(q.depositAddress || data.depositAddress || ''); - const outAmount = q.amountOut || q.estimatedAmountOut || data.amountOut; - if (outAmount) { - setEstimatedZec((parseInt(outAmount) / Math.pow(10, ZEC_DECIMALS)).toFixed(4)); - } - setQuoteExpiry(Date.now() + 60_000); - setStep('quote'); - } catch (err: any) { - setError(err.message || 'Failed to get quote'); - } finally { - setLoading(false); - } - }; - - const sendFromWallet = async () => { - setSendingTx(true); - setWalletError(''); - try { - const hash = await wallet.sendTransaction(depositAddress, amount, selectedToken.decimals, selectedToken.contractAddress); - setTxHash(hash); - } catch (err: any) { - setWalletError(err.message || 'Transaction rejected'); - } finally { - setSendingTx(false); - } - }; - - const copyAddress = () => { - navigator.clipboard.writeText(depositAddress); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const resetSwap = () => { - setStep('form'); - setQuote(null); - setDepositAddress(''); - setEstimatedZec(''); - setSwapStatus(''); - setError(''); - setTxHash(''); - setWalletError(''); - setCopied(false); - localStorage.removeItem(PENDING_SWAP_KEY); - setBalanceRefresh(n => n + 1); - }; - - const insufficientBalance = !!(wallet.connected && nativeBalance && amount && parseFloat(amount) > parseFloat(nativeBalance)); - const zecAddrError = validateZecAddress(zecAddress); - const ctaDisabled = loading || !amount || !zecAddress || !!zecAddrError || !effectiveRefundAddress || wallet.switching || insufficientBalance; - const ctaText = ctaLabel({ loading, amount, zecAddress, refundAddress: effectiveRefundAddress, walletConnected: wallet.connected, hasWallets: chainWallets.length > 0, switching: wallet.switching, insufficientBalance }); - - return ( -
    - - {/* Header — cypherpunk style, consistent with other pages */} -
    -

    - {'>'} CROSS_CHAIN_SWAP -

    -
    -

    Buy ZEC

    -

    - Swap from 15+ chains via{' '} - NEAR Intents -

    -
    -
    - -
    - - {/* ─── Main Swap Card ─── */} -
    -
    - - {/* Card header */} -
    -
    - > SWAP - - {/* Wallet pill / picker — hidden during connect step */} - {step !== 'connect' && ( -
    - - - {/* Wallet picker dropdown — wallets available */} - {showWalletPicker && chainWallets.length > 0 && ( -
    -
    - Select wallet - {wallet.connected && ( - - )} -
    - {chainWallets.map((w) => ( - - ))} -
    - )} - - {/* No wallet info panel */} - {showWalletPicker && chainWallets.length === 0 && ( -
    -
    - No {selectedToken.chainLabel} wallet -
    -
    -
    -
    - - - -
    -

    - No wallet needed.{' '} - Fill in the form and you'll get a deposit address to send funds manually. -

    -
    -
    -
    - - - -
    -

    - For auto-send, install a {selectedToken.chainLabel} wallet extension - {(() => { - const chain = selectedToken.chain; - if (['eth', 'base', 'arb', 'op', 'pol', 'avax', 'bsc', 'gnosis', 'bera', 'scroll'].includes(chain)) - return <> like MetaMask; - if (chain === 'sol') - return <> like Phantom; - if (chain === 'tron') - return <> like TronLink; - return null; - })()} - . -

    -
    -
    -
    - -
    -
    - )} -
    - )} -
    -
    - -
    - - {/* ─── CONNECT WALLET (gate step) ─── */} - {step === 'connect' && ( -
    -
    -

    Connect Wallet

    -

    - Select a wallet to auto-fill addresses and send directly. -

    -
    - - {wallet.allWallets.length > 0 ? ( -
    - {(() => { - const typeMap = new Map(); - for (const w of wallet.allWallets) { - if (!w.type) continue; - const arr = typeMap.get(w.type) || []; - arr.push(w); - typeMap.set(w.type, arr); - } - const chainLabels: Record = { - evm: 'EVM Chains', - solana: 'Solana', - tron: 'Tron', - bitcoin: 'Bitcoin', - }; - return Array.from(typeMap.entries()).map(([type, wallets]) => ( -
    -
    {chainLabels[type] || type}
    -
    - {wallets.map(w => ( - - ))} -
    -
    - )); - })()} -
    - ) : ( -
    -
    -

    Detecting wallets...

    -
    - )} - - {walletError && ( -
    - {walletError} -
    - )} - -
    - -
    -
    - )} - - {/* ─── FORM ─── */} - {step === 'form' && ( -
    - - {/* Step guide */} -
    - 1. Pick asset - {'>'} - 2. Amount & addresses - {'>'} - 3. Get quote -
    - - {/* From — asset selector (prominent first row) */} -
    - -
    - - - {/* Token picker dropdown */} - {showTokenPicker && ( - <> -
    { setShowTokenPicker(false); setTokenSearch(''); }} /> -
    -
    - setTokenSearch(e.target.value)} - placeholder="Search token or chain..." - autoFocus - className="w-full px-3 py-2 rounded-lg bg-glass-4 text-primary font-mono text-sm placeholder:text-muted/40 focus:outline-none" - /> -
    -
    - {tokensLoading ? ( -
    -
    -
    Loading tokens...
    -
    - ) : filteredTokens.length === 0 ? ( -
    No tokens found
    - ) : ( - filteredTokens.map(t => ( - - )) - )} -
    -
    - - )} -
    -
    - - {/* Amount input (own row) */} -
    -
    - - {wallet.connected && nativeBalance && ( -
    - - {parseFloat(nativeBalance).toLocaleString(undefined, { maximumFractionDigits: 4 })} {selectedToken.token} - - - -
    - )} -
    -
    - { - const v = e.target.value; - if (v === '' || /^\d*\.?\d*$/.test(v)) setAmount(v); - }} - placeholder="0.00" - className="flex-1 min-w-0 px-4 py-3 bg-transparent text-primary font-mono text-lg placeholder:text-muted/30 focus:outline-none" - /> -
    - - {selectedToken.token} -
    -
    - - {/* Privacy recommendation chips — amounts that blend on both source chain and ZEC side */} - {recommendations && recommendations.amounts.length > 0 && (() => { - const chips = recommendations.amounts - .filter(a => a.sourceAmount && a.sourceAmount > 0) - .filter(a => !a.sourceToken || a.sourceToken.toUpperCase() === selectedToken.token.toUpperCase()) - .slice(0, 4); - if (chips.length === 0) return null; - return ( -
    - {chips.map((rec, i) => { - const label = getBlendingLabel(rec.blendingScore, rec.dualBlendScore); - const token = rec.sourceToken || selectedToken.token; - return ( - - ); - })} -
    - ); - })()} -
    - - {/* Arrow divider */} -
    -
    -
    - - - -
    -
    -
    - - {/* To section */} -
    - -
    - setZecAddress(e.target.value)} - placeholder="Paste t1 or u1 address" - className="flex-1 min-w-0 px-4 py-3 bg-transparent text-primary font-mono text-sm placeholder:text-muted/30 focus:outline-none" - /> -
    - - ZEC -
    -
    - {zecAddress && zecAddrError && ( -

    {zecAddrError}

    - )} -
    - - {/* Return address — hidden when wallet connected, shown as fallback for manual users */} - {wallet.connected ? ( -
    -
    - - Returns to {wallet.address?.slice(0, 6)}...{wallet.address?.slice(-4)} if swap fails -
    - -
    - ) : ( -
    -
    - - -
    - setRefundAddress(e.target.value)} - placeholder={`The address you're sending from`} - className="w-full px-4 py-3 rounded-lg bg-glass-3 border border-glass-6 text-primary font-mono text-sm placeholder:text-muted/30 focus:outline-none focus:border-cipher-cyan/40 focus:shadow-[0_0_0_3px_rgb(var(--color-cyan-rgb)_/_0.06)] transition-all" - /> - {!refundAddress && ( -

    - Paste the {selectedToken.chainLabel} address you'll send from. Funds return here if the swap can't complete. -

    - )} -
    - )} - - {/* Slippage (expandable) */} - {showSlippage && ( -
    - -
    - {[{ label: '0.5%', value: 50 }, { label: '1%', value: 100 }, { label: '2%', value: 200 }].map(opt => ( - - ))} -
    -
    - )} - - {/* Error */} - {(error || walletError) && ( -
    - {error || walletError} -
    - )} - - {/* CTA — smart contextual button */} - {(() => { - const needsWallet = !wallet.connected && chainWallets.length > 0 && amount && zecAddress && !zecAddrError && !effectiveRefundAddress; - return ( - - ); - })()} - - {/* Fee note */} -

    - Powered by NEAR Intents · Slippage: {slippage / 100}% -

    -
    - )} - - {/* ─── QUOTE REVIEW ─── */} - {step === 'quote' && quote && ( -
    - {/* Summary */} -
    -
    -
    - -
    -
    {amount} {selectedToken.token}
    -
    {selectedToken.chainLabel}
    -
    -
    - - - -
    -
    -
    {estimatedZec || '~'} ZEC
    -
    Estimated
    -
    - -
    -
    - -
    -
    - Slippage - {slippage / 100}% -
    -
    - Destination - {zecAddress.slice(0, 10)}...{zecAddress.slice(-6)} -
    -
    -
    - - {quoteTimeLeft > 0 && ( -
    -
    30 ? 'bg-cipher-green' : quoteTimeLeft > 10 ? 'bg-cipher-yellow' : 'bg-red-500 animate-pulse'}`} /> - 30 ? 'text-muted' : quoteTimeLeft > 10 ? 'text-cipher-yellow' : 'text-red-500'}> - Quote expires in {Math.floor(quoteTimeLeft / 60)}:{(quoteTimeLeft % 60).toString().padStart(2, '0')} - -
    - )} - -
    - - -
    -
    - )} - - {/* ─── WAITING FOR DEPOSIT ─── */} - {step === 'waiting' && ( -
    - {/* Swap summary row */} -
    -
    -
    - -
    -
    {amount} {selectedToken.token}
    -
    {selectedToken.chainLabel}
    -
    -
    - - - -
    -
    -
    {estimatedZec || '~'} ZEC
    -
    Estimated
    -
    - -
    -
    -
    - - {/* Status indicator */} -
    -
    - - {swapStatus ? swapStatus.replace(/_/g, ' ') : 'Waiting for deposit'} - -
    - - {/* One-click wallet send */} - {wallet.connected && !txHash && ( - - )} - - {txHash && ( -
    -
    -
    - - Sent - {txHash.slice(0, 8)}...{txHash.slice(-6)} -
    -
    - - {CHAIN_EXPLORERS[selectedToken.chain] && ( - - - - )} -
    -
    -
    - )} - - {walletError &&

    {walletError}

    } - - {/* Manual deposit section */} - {(!wallet.connected || (wallet.connected && !txHash)) && ( - <> - {wallet.connected && ( -
    -
    - or send manually -
    -
    - )} - -
    -
    Deposit address
    -
    - {depositAddress} - -
    -
    - - )} - - {txHash ? ( -
    - - Swap will complete automatically - - -
    - ) : ( - - )} -
    - )} - - {/* ─── COMPLETE ─── */} - {step === 'complete' && ( -
    -
    -
    -
    - - - -
    -
    -

    Swap Complete

    -

    {estimatedZec} ZEC sent to your address

    -
    -
    -
    - -
    - )} - - {/* ─── ERROR ─── */} - {step === 'error' && ( -
    -
    -
    -
    - - - -
    -
    -

    Swap Failed

    -

    {error}

    -
    -
    -
    - -
    - )} -
    -
    -
    - - {/* ─── Sidebar ─── */} -
    - - {/* Why connect — only on connect step */} - {step === 'connect' && ( -
    -
    - > Why_connect -
    -
    - {[ - { label: 'Filtered tokens', desc: 'Only see assets your wallet supports' }, - { label: 'Auto-fill addresses', desc: 'No copy-pasting needed for refunds' }, - { label: 'One-click send', desc: 'Send directly from CipherScan' }, - ].map(item => ( -
    - -
    -
    {item.label}
    -
    {item.desc}
    -
    -
    - ))} -
    -
    - )} - - {/* Privacy tips — only on form step */} - {step === 'form' && ( -
    -
    - > Privacy_tips -
    -
    - {recommendations && recommendations.amounts.length > 0 ? (() => { - const withSource = recommendations.amounts - .filter(a => a.sourceAmount && a.sourceAmount > 0) - .filter(a => !a.sourceToken || a.sourceToken.toUpperCase() === selectedToken.token.toUpperCase()); - return ( -
    -

    - {withSource.length > 0 - ? `Amounts that blend in on both the ${selectedToken.chain.toUpperCase()} and Zcash sides for maximum privacy.` - : 'Common ZEC shielding amounts. Using popular amounts improves privacy.'} -

    -
    - {(withSource.length > 0 ? withSource : recommendations.amounts).slice(0, 5).map((rec, i) => { - const label = getBlendingLabel(rec.blendingScore, rec.dualBlendScore); - const token = rec.sourceToken || selectedToken.token; - const displayAmount = rec.sourceAmount || rec.amountZec; - const displayToken = rec.sourceAmount ? token : 'ZEC'; - return ( - - ); - })} -
    - {recommendations.tip && ( -

    {recommendations.tip}

    - )} -
    - ); - })() : ( -

    - Privacy recommendations appear once swap data is collected. -

    - )} -
    -
    - )} - - {/* Estimated time — shown during quote/waiting */} - {(step === 'quote' || step === 'waiting') && ( -
    -
    - > Estimated_time -
    -
    -
    -
    - -
    -
    -
    - {(() => { - const c = selectedToken.chain; - if (c === 'sol') return '~1-2 min'; - if (['near', 'ton', 'sui'].includes(c)) return '~2-5 min'; - if (['eth', 'base', 'arb', 'op', 'pol', 'avax', 'bsc', 'gnosis', 'bera', 'scroll'].includes(c)) return '~5-15 min'; - if (['tron'].includes(c)) return '~3-10 min'; - if (['btc', 'ltc', 'bch', 'doge', 'dash'].includes(c)) return '~20-60 min'; - return '~5-30 min'; - })()} -
    -
    via {selectedToken.chainLabel}
    -
    -
    - -
    -
    -
    - Deposit to bridge address -
    -
    -
    - NEAR Intents bridging -
    -
    -
    - ZEC sent to your address -
    -
    - -

    - Times depend on {selectedToken.chainLabel} block confirmations and NEAR solver availability. -

    -
    -
    - )} - - {/* Complete/error — minimal */} - {(step === 'complete' || step === 'error') && ( -
    -
    -

    - {step === 'complete' - ? 'Your ZEC has arrived. For maximum privacy, shield your balance using a wallet that supports Orchard.' - : 'If your swap failed, funds are returned to your refund address automatically.'} -

    -
    -
    - )} - -
    - - View Crosschain Analytics - - -
    -
    -
    -
    - ); -} diff --git a/app/swap/page.tsx b/app/swap/page.tsx index 0038a77..39b7459 100644 --- a/app/swap/page.tsx +++ b/app/swap/page.tsx @@ -1,8 +1,1558 @@ -import { notFound } from 'next/navigation'; +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import Link from 'next/link'; import { isMainnet } from '@/lib/config'; -import SwapClient from './SwapClient'; +import { API_CONFIG } from '@/lib/api-config'; +import { TokenChainIcon } from '@/components/TokenChainIcon'; +import { useWallet, chainToWalletType, type DetectedWallet } from '@/hooks/useWallet'; + +const BASE58_CHARS = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; + +function validateZecAddress(addr: string): string | null { + if (!addr) return null; + if (addr.startsWith('u1') || addr.startsWith('utest')) { + if (addr.length < 80) return 'Unified address too short'; + return null; + } + if (addr.startsWith('zs') || addr.startsWith('ztestsapling')) { + if (addr.length < 70) return 'Sapling address too short'; + return null; + } + if (!addr.startsWith('t1') && !addr.startsWith('t3')) + return 'Must start with t1, t3, u1, or zs'; + if (!BASE58_CHARS.test(addr)) + return 'Contains invalid characters'; + if (addr.length !== 35) + return `Transparent address must be 35 characters (currently ${addr.length})`; + return null; +} + +interface CommonAmount { + amountZec: number; + txCount: number; + percentage: string; + blendingScore: number; + chainSwapCount?: number; + sourceAmount?: number | null; + sourceToken?: string | null; + dualBlendScore?: number; +} + +interface CommonAmountsResponse { + success: boolean; + period: string; + chain: string | null; + totalTransactions: number; + amounts: CommonAmount[]; + tip: string; +} + +function formatRecAmount(amount: number, token: string): string { + const t = token.toLowerCase(); + if (['usdc', 'usdt', 'dai', 'busd', 'tusd', 'usdp'].includes(t)) { + return amount >= 1 ? amount.toLocaleString(undefined, { maximumFractionDigits: 0 }) : amount.toString(); + } + if (amount === 0) return '0'; + if (amount >= 100) return amount.toLocaleString(undefined, { maximumFractionDigits: 2 }); + if (amount >= 1) return amount.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 3 }); + if (amount >= 0.01) return amount.toFixed(3); + return amount.toFixed(4); +} + +function getBlendingLabel(score: number, dualScore?: number): 'high' | 'medium' | 'low' { + const effective = dualScore || score; + if (effective >= 30) return 'high'; + if (effective >= 10) return 'medium'; + return 'low'; +} + +interface QuoteResponse { + success: boolean; + depositAddress?: string; + amountOut?: string; + estimatedAmountOut?: string; + deadline?: string; + error?: string; +} + +type SwapStep = 'connect' | 'form' | 'quote' | 'waiting' | 'complete' | 'error'; + +const PENDING_SWAP_KEY = 'cipherscan_pending_swap'; + +interface PendingSwap { + depositAddress: string; + amount: string; + token: string; + chain: string; + chainLabel: string; + assetId: string; + decimals: number; + contractAddress?: string; + zecAddress: string; + estimatedZec: string; + txHash?: string; + createdAt: number; +} + +interface SourceToken { + id: string; + chain: string; + chainLabel: string; + token: string; + decimals: number; + assetId: string; + contractAddress?: string; +} + +const CHAIN_EXPLORERS: Record = { + eth: 'https://etherscan.io/tx/', + base: 'https://basescan.org/tx/', + arb: 'https://arbiscan.io/tx/', + op: 'https://optimistic.etherscan.io/tx/', + pol: 'https://polygonscan.com/tx/', + avax: 'https://snowtrace.io/tx/', + bsc: 'https://bscscan.com/tx/', + sol: 'https://solscan.io/tx/', + btc: 'https://mempool.space/tx/', + near: 'https://nearblocks.io/txns/', + gnosis: 'https://gnosisscan.io/tx/', + bera: 'https://berascan.com/tx/', + scroll: 'https://scrollscan.com/tx/', + tron: 'https://tronscan.org/#/transaction/', +}; + +const CHAIN_LABELS: Record = { + eth: 'Ethereum', base: 'Base', arb: 'Arbitrum', sol: 'Solana', btc: 'Bitcoin', + near: 'NEAR', ton: 'TON', doge: 'Dogecoin', xrp: 'XRP', bsc: 'BNB Chain', + pol: 'Polygon', tron: 'Tron', sui: 'Sui', op: 'Optimism', avax: 'Avalanche', + ltc: 'Litecoin', bch: 'Bitcoin Cash', gnosis: 'Gnosis', bera: 'Berachain', + cardano: 'Cardano', starknet: 'Starknet', zec: 'Zcash', aleo: 'Aleo', + xlayer: 'XLayer', monad: 'Monad', adi: 'ADI', plasma: 'Plasma', scroll: 'Scroll', + dash: 'Dash', +}; + +const FALLBACK_TOKEN_ORDER = ['usdc', 'eth', 'usdt', 'btc', 'sol', 'bnb', 'near', 'dai', 'doge', 'xrp', 'ton', 'ltc']; +const FALLBACK_CHAIN_ORDER = ['eth', 'sol', 'base', 'arb', 'btc', 'bsc', 'op', 'pol', 'avax', 'near', 'ton', 'doge', 'xrp', 'ltc', 'sui', 'tron']; + +interface PopularPair { chain: string; token: string; swapCount: number } + +function sortTokens(tokens: SourceToken[], popularPairs: PopularPair[]): SourceToken[] { + if (popularPairs.length > 0) { + const pairRank = new Map(); + popularPairs.forEach((p, i) => { + pairRank.set(`${p.chain.toLowerCase()}:${p.token.toLowerCase()}`, i); + }); + return [...tokens].sort((a, b) => { + const aKey = `${a.chain.toLowerCase()}:${a.token.toLowerCase()}`; + const bKey = `${b.chain.toLowerCase()}:${b.token.toLowerCase()}`; + const aRank = pairRank.get(aKey) ?? 9999; + const bRank = pairRank.get(bKey) ?? 9999; + if (aRank !== bRank) return aRank - bRank; + const aToken = FALLBACK_TOKEN_ORDER.indexOf(a.token.toLowerCase()); + const bToken = FALLBACK_TOKEN_ORDER.indexOf(b.token.toLowerCase()); + if ((aToken >= 0 ? aToken : 999) !== (bToken >= 0 ? bToken : 999)) + return (aToken >= 0 ? aToken : 999) - (bToken >= 0 ? bToken : 999); + return a.chainLabel.localeCompare(b.chainLabel); + }); + } + return [...tokens].sort((a, b) => { + const aToken = FALLBACK_TOKEN_ORDER.indexOf(a.token.toLowerCase()); + const bToken = FALLBACK_TOKEN_ORDER.indexOf(b.token.toLowerCase()); + const aRank = aToken >= 0 ? aToken : 999; + const bRank = bToken >= 0 ? bToken : 999; + if (aRank !== bRank) return aRank - bRank; + const aChain = FALLBACK_CHAIN_ORDER.indexOf(a.chain.toLowerCase()); + const bChain = FALLBACK_CHAIN_ORDER.indexOf(b.chain.toLowerCase()); + if ((aChain >= 0 ? aChain : 999) !== (bChain >= 0 ? bChain : 999)) + return (aChain >= 0 ? aChain : 999) - (bChain >= 0 ? bChain : 999); + return a.chainLabel.localeCompare(b.chainLabel); + }); +} + +function apiTokensToSourceTokens(apiTokens: any[]): SourceToken[] { + return apiTokens + .filter(t => { + if (!t.assetId || !t.symbol || !t.blockchain || t.decimals == null) return false; + const id = t.assetId.toLowerCase(); + if (id.includes('zec') || t.blockchain === 'zec') return false; + return true; + }) + .map(t => { + let contractAddress: string | undefined; + if (t.address) contractAddress = t.address; + else if (t.contractAddress) contractAddress = t.contractAddress; + else if (t.assetId) { + const evmMatch = t.assetId.match(/0x[a-fA-F0-9]{40}/); + if (evmMatch) { + contractAddress = evmMatch[0]; + } else { + const solMatch = t.assetId.match(/sol-([A-HJ-NP-Za-km-z1-9]{32,44})\./); + if (solMatch) contractAddress = solMatch[1]; + } + } + return { + id: `${t.blockchain}-${t.symbol.toLowerCase()}-${t.assetId}`, + chain: t.blockchain, + chainLabel: CHAIN_LABELS[t.blockchain] || t.blockchain, + token: t.symbol, + decimals: t.decimals, + assetId: t.assetId, + contractAddress, + }; + }); +} + +const FALLBACK_TOKENS: SourceToken[] = [ + { id: 'eth-usdc', chain: 'eth', chainLabel: 'Ethereum', token: 'USDC', decimals: 6, assetId: 'nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near' }, + { id: 'eth-eth', chain: 'eth', chainLabel: 'Ethereum', token: 'ETH', decimals: 18, assetId: 'nep141:eth.omft.near' }, + { id: 'btc-btc', chain: 'btc', chainLabel: 'Bitcoin', token: 'BTC', decimals: 8, assetId: 'nep141:btc.omft.near' }, + { id: 'sol-sol', chain: 'sol', chainLabel: 'Solana', token: 'SOL', decimals: 9, assetId: 'nep141:sol.omft.near' }, + { id: 'near-near', chain: 'near', chainLabel: 'NEAR', token: 'NEAR', decimals: 24, assetId: 'nep141:wrap.near' }, +]; + +const ZEC_ASSET_ID = 'nep141:zec.omft.near'; +const ZEC_DECIMALS = 8; + +function WalletIcon({ wallet: w, size = 24 }: { wallet: DetectedWallet; size?: number }) { + if (w.icon) { + return ; + } + const chainFallback: Record = { + evm: '/chains/eth.png', solana: '/chains/sol.png', bitcoin: '/chains/btc.png', tron: '/chains/tron.png', + }; + const fallback = chainFallback[w.type || '']; + if (fallback) { + return ; + } + return ( +
    + {w.name.charAt(0).toUpperCase()} +
    + ); +} + +function ctaLabel(state: { + loading: boolean; + amount: string; + zecAddress: string; + refundAddress: string; + walletConnected: boolean; + hasWallets: boolean; + switching: boolean; + insufficientBalance: boolean; +}): string { + if (state.loading) return 'Getting quote...'; + if (state.switching) return 'Connecting...'; + if (!state.amount) return 'Enter amount'; + if (state.insufficientBalance) return 'Insufficient balance'; + if (!state.zecAddress) return 'Enter ZEC address'; + const addrErr = validateZecAddress(state.zecAddress); + if (addrErr) return 'Invalid ZEC address'; + if (!state.refundAddress) { + return state.hasWallets ? 'Connect wallet to continue' : 'Enter your sending address'; + } + return 'Get Quote'; +} export default function SwapPage() { - if (!isMainnet) notFound(); - return ; + const [step, setStep] = useState('connect'); + const [manualMode, setManualMode] = useState(false); + const [availableTokens, setAvailableTokens] = useState(FALLBACK_TOKENS); + const [tokensLoading, setTokensLoading] = useState(true); + const [selectedToken, setSelectedToken] = useState(FALLBACK_TOKENS[0]); + const [showTokenPicker, setShowTokenPicker] = useState(false); + const [tokenSearch, setTokenSearch] = useState(''); + const [amount, setAmount] = useState(''); + const [zecAddress, setZecAddress] = useState(''); + const [refundAddress, setRefundAddress] = useState(''); + const [slippage, setSlippage] = useState(100); + const [quote, setQuote] = useState(null); + const [estimatedZec, setEstimatedZec] = useState(''); + const [depositAddress, setDepositAddress] = useState(''); + const [swapStatus, setSwapStatus] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [recommendations, setRecommendations] = useState(null); + const pollRef = useRef(null); + const [sendingTx, setSendingTx] = useState(false); + const [txHash, setTxHash] = useState(''); + const [walletError, setWalletError] = useState(''); + const [showSlippage, setShowSlippage] = useState(false); + const [copied, setCopied] = useState(false); + const [showWalletPicker, setShowWalletPicker] = useState(false); + const [nativeBalance, setNativeBalance] = useState(null); + const [balanceRefresh, setBalanceRefresh] = useState(0); + const [quoteExpiry, setQuoteExpiry] = useState(0); + const [quoteTimeLeft, setQuoteTimeLeft] = useState(0); + const wallet = useWallet(); + const pickerRef = useRef(null); + const tokenPickerRef = useRef(null); + + // Auto-advance past connect step if wallet already connected or no wallets detected + useEffect(() => { + if (step !== 'connect') return; + if (wallet.connected) { + setStep('form'); + } else if (wallet.allWallets.length === 0) { + // Wait a beat for wallet detection to complete before deciding + const t = setTimeout(() => { + if (!wallet.connected && wallet.allWallets.length === 0) { + setManualMode(true); + setStep('form'); + } + }, 2500); + return () => clearTimeout(t); + } + }, [step, wallet.connected, wallet.allWallets.length]); + + // When wallet connects from the connect step, move to form + useEffect(() => { + if (wallet.connected && step === 'connect') { + setStep('form'); + } + }, [wallet.connected, step]); + + // Restore pending swap from localStorage on mount + useEffect(() => { + try { + const raw = localStorage.getItem(PENDING_SWAP_KEY); + if (!raw) return; + const pending: PendingSwap = JSON.parse(raw); + const age = Date.now() - pending.createdAt; + if (age > 24 * 60 * 60 * 1000) { + localStorage.removeItem(PENDING_SWAP_KEY); + return; + } + setDepositAddress(pending.depositAddress); + setAmount(pending.amount); + setZecAddress(pending.zecAddress); + setEstimatedZec(pending.estimatedZec); + if (pending.txHash) setTxHash(pending.txHash); + const restored: SourceToken = { + id: `${pending.chain}:${pending.token}`, + chain: pending.chain, + chainLabel: pending.chainLabel, + token: pending.token, + decimals: pending.decimals, + assetId: pending.assetId, + contractAddress: pending.contractAddress, + }; + setSelectedToken(restored); + setStep('waiting'); + } catch { + localStorage.removeItem(PENDING_SWAP_KEY); + } + }, []); + + // Close dropdowns on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) setShowWalletPicker(false); + if (tokenPickerRef.current && !tokenPickerRef.current.contains(e.target as Node)) { setShowTokenPicker(false); setTokenSearch(''); } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + // Fetch available tokens and popularity ranking from API + useEffect(() => { + const fetchTokens = async () => { + try { + const [tokensRes, pairsRes] = await Promise.all([ + fetch(`${API_CONFIG.POSTGRES_API_URL}/api/swap/tokens`), + fetch(`${API_CONFIG.POSTGRES_API_URL}/api/crosschain/popular-pairs`).catch(() => null), + ]); + const tokensData = await tokensRes.json(); + const pairsData = pairsRes ? await pairsRes.json().catch(() => null) : null; + const popularPairs: PopularPair[] = pairsData?.success ? pairsData.pairs : []; + + if (tokensData.success && tokensData.tokens?.length) { + const mapped = apiTokensToSourceTokens(tokensData.tokens); + if (mapped.length > 0) { + const sorted = sortTokens(mapped, popularPairs); + setAvailableTokens(sorted); + setSelectedToken(sorted[0]); + } + } + } catch { /* fallback list stays */ } + finally { setTokensLoading(false); } + }; + if (isMainnet) fetchTokens(); + else setTokensLoading(false); + }, []); + + // Sync refund address with connected wallet + auto-select compatible token + useEffect(() => { + if (wallet.connected && wallet.address) { + setRefundAddress(wallet.address); + // If current token isn't compatible with connected wallet, switch to first compatible one + if (!manualMode && chainToWalletType(selectedToken.chain) !== wallet.walletType) { + const firstCompatible = availableTokens.find(t => chainToWalletType(t.chain) === wallet.walletType); + if (firstCompatible) { + setSelectedToken(firstCompatible); + setAmount(''); + } + } + } else { + setRefundAddress(''); + } + }, [wallet.connected, wallet.address, wallet.walletType]); + + // Quote countdown timer + useEffect(() => { + if (step !== 'quote' || !quoteExpiry) return; + const tick = () => { + const left = Math.max(0, Math.floor((quoteExpiry - Date.now()) / 1000)); + setQuoteTimeLeft(left); + if (left === 0) { + setStep('form'); + setError('Quote expired — please get a new one'); + } + }; + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [step, quoteExpiry]); + + const NATIVE_TOKENS = ['eth', 'sol', 'btc', 'bnb', 'doge', 'ltc', 'avax', 'matic', 'pol']; + const isNativeToken = NATIVE_TOKENS.includes(selectedToken.token.toLowerCase()) && !selectedToken.contractAddress; + + const evmChains = ['eth', 'base', 'arb', 'pol', 'op', 'avax', 'bsc']; + const chainKey = selectedToken.chain; + + useEffect(() => { + setNativeBalance(null); + if (!wallet.connected) return; + let cancelled = false; + const fetchBal = async () => { + let bal: string | null = null; + const isEvm = evmChains.includes(chainKey); + if (isNativeToken) { + bal = await wallet.getNativeBalance(isEvm ? chainKey : undefined); + } else if (selectedToken.contractAddress) { + bal = await wallet.getTokenBalance(selectedToken.contractAddress, selectedToken.decimals, isEvm ? chainKey : undefined); + } else { + bal = await wallet.getNativeBalance(isEvm ? chainKey : undefined); + } + if (!cancelled) setNativeBalance(bal); + }; + fetchBal(); + return () => { cancelled = true; }; + }, [wallet.connected, wallet.address, selectedToken, balanceRefresh]); + + // Fetch privacy-preserving amount recommendations (ZEC common amounts cross-referenced with source chain) + useEffect(() => { + const fetchRecs = async () => { + try { + const res = await fetch(`${API_CONFIG.POSTGRES_API_URL}/api/privacy/common-amounts?chain=${selectedToken.chain}&period=30d&limit=10`); + const data = await res.json(); + if (data.success && data.amounts?.length > 0) setRecommendations(data); + else setRecommendations(null); + } catch { + setRecommendations(null); + } + }; + if (isMainnet) fetchRecs(); + }, [selectedToken]); + + // Persist pending swap to localStorage + useEffect(() => { + if (step === 'waiting' && depositAddress) { + const pending: PendingSwap = { + depositAddress, + amount, + token: selectedToken.token, + chain: selectedToken.chain, + chainLabel: selectedToken.chainLabel, + assetId: selectedToken.assetId, + decimals: selectedToken.decimals, + contractAddress: selectedToken.contractAddress, + zecAddress, + estimatedZec, + txHash: txHash || undefined, + createdAt: Date.now(), + }; + localStorage.setItem(PENDING_SWAP_KEY, JSON.stringify(pending)); + } + }, [step, depositAddress, txHash]); + + // Poll swap status + useEffect(() => { + if (step !== 'waiting' || !depositAddress) return; + const poll = async () => { + try { + const res = await fetch(`${API_CONFIG.POSTGRES_API_URL}/api/swap/status?depositAddress=${encodeURIComponent(depositAddress)}`); + const data = await res.json(); + if (data.status === 'COMPLETE' || data.status === 'SUCCESS') { + setSwapStatus('complete'); + setStep('complete'); + localStorage.removeItem(PENDING_SWAP_KEY); + if (pollRef.current) clearInterval(pollRef.current); + } else if (data.status === 'FAILED' || data.status === 'REFUNDED') { + setSwapStatus(data.status.toLowerCase()); + setStep('error'); + setError(`Swap ${data.status.toLowerCase()}. Funds will be returned to your refund address.`); + localStorage.removeItem(PENDING_SWAP_KEY); + if (pollRef.current) clearInterval(pollRef.current); + } else { + setSwapStatus(data.status || 'processing'); + } + } catch { /* keep polling */ } + }; + poll(); + pollRef.current = setInterval(poll, 10000); + return () => { if (pollRef.current) clearInterval(pollRef.current); }; + }, [step, depositAddress]); + + const connectToWallet = async (w: DetectedWallet) => { + setWalletError(''); + setShowWalletPicker(false); + try { + await wallet.connect(w); + } catch (err: any) { + setWalletError(err.message || 'Connection failed'); + } + }; + + const chainWallets = wallet.getWalletsForChain(selectedToken.chain); + + // When wallet is connected, only show tokens from compatible chains + const compatibleTokens = wallet.connected && !manualMode + ? availableTokens.filter(t => chainToWalletType(t.chain) === wallet.walletType) + : availableTokens; + + const filteredTokens = compatibleTokens.filter(t => { + if (!tokenSearch) return true; + const q = tokenSearch.toLowerCase(); + return t.token.toLowerCase().includes(q) || t.chainLabel.toLowerCase().includes(q) || t.chain.includes(q); + }); + + const effectiveRefundAddress = refundAddress || wallet.address || ''; + + const getQuote = async () => { + if (!amount || !zecAddress || validateZecAddress(zecAddress) || !effectiveRefundAddress) return; + setLoading(true); + setError(''); + try { + const amountSmallest = BigInt(Math.round(parseFloat(amount) * Math.pow(10, selectedToken.decimals))).toString(); + const res = await fetch(`${API_CONFIG.POSTGRES_API_URL}/api/swap/quote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + originAsset: selectedToken.assetId, + destinationAsset: ZEC_ASSET_ID, + amount: amountSmallest, + recipient: zecAddress, + refundTo: refundAddress || wallet.address, + slippageBps: slippage, + }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to get quote'); + const q = data.quote || data; + setQuote(data); + setDepositAddress(q.depositAddress || data.depositAddress || ''); + const outAmount = q.amountOut || q.estimatedAmountOut || data.amountOut; + if (outAmount) { + setEstimatedZec((parseInt(outAmount) / Math.pow(10, ZEC_DECIMALS)).toFixed(4)); + } + setQuoteExpiry(Date.now() + 60_000); + setStep('quote'); + } catch (err: any) { + setError(err.message || 'Failed to get quote'); + } finally { + setLoading(false); + } + }; + + const sendFromWallet = async () => { + setSendingTx(true); + setWalletError(''); + try { + const hash = await wallet.sendTransaction(depositAddress, amount, selectedToken.decimals, selectedToken.contractAddress); + setTxHash(hash); + } catch (err: any) { + setWalletError(err.message || 'Transaction rejected'); + } finally { + setSendingTx(false); + } + }; + + const copyAddress = () => { + navigator.clipboard.writeText(depositAddress); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const resetSwap = () => { + setStep('form'); + setQuote(null); + setDepositAddress(''); + setEstimatedZec(''); + setSwapStatus(''); + setError(''); + setTxHash(''); + setWalletError(''); + setCopied(false); + localStorage.removeItem(PENDING_SWAP_KEY); + setBalanceRefresh(n => n + 1); + }; + + const insufficientBalance = !!(wallet.connected && nativeBalance && amount && parseFloat(amount) > parseFloat(nativeBalance)); + const zecAddrError = validateZecAddress(zecAddress); + const ctaDisabled = loading || !amount || !zecAddress || !!zecAddrError || !effectiveRefundAddress || wallet.switching || insufficientBalance; + const ctaText = ctaLabel({ loading, amount, zecAddress, refundAddress: effectiveRefundAddress, walletConnected: wallet.connected, hasWallets: chainWallets.length > 0, switching: wallet.switching, insufficientBalance }); + + // -- Testnet fallback -- + if (!isMainnet) { + return ( +
    +
    +
    +
    + + + +
    +

    Mainnet Only

    +

    Cross-chain swaps require mainnet ZEC.

    + + Go to Mainnet + + +
    +
    +
    + ); + } + + return ( +
    + + {/* Header — cypherpunk style, consistent with other pages */} +
    +

    + {'>'} CROSS_CHAIN_SWAP +

    +
    +

    Buy ZEC

    +

    + Swap from 15+ chains via{' '} + NEAR Intents +

    +
    +
    + +
    + + {/* ─── Main Swap Card ─── */} +
    +
    + + {/* Card header */} +
    +
    + > SWAP + + {/* Wallet pill / picker — hidden during connect step */} + {step !== 'connect' && ( +
    + + + {/* Wallet picker dropdown — wallets available */} + {showWalletPicker && chainWallets.length > 0 && ( +
    +
    + Select wallet + {wallet.connected && ( + + )} +
    + {chainWallets.map((w) => ( + + ))} +
    + )} + + {/* No wallet info panel */} + {showWalletPicker && chainWallets.length === 0 && ( +
    +
    + No {selectedToken.chainLabel} wallet +
    +
    +
    +
    + + + +
    +

    + No wallet needed.{' '} + Fill in the form and you'll get a deposit address to send funds manually. +

    +
    +
    +
    + + + +
    +

    + For auto-send, install a {selectedToken.chainLabel} wallet extension + {(() => { + const chain = selectedToken.chain; + if (['eth', 'base', 'arb', 'op', 'pol', 'avax', 'bsc', 'gnosis', 'bera', 'scroll'].includes(chain)) + return <> like MetaMask; + if (chain === 'sol') + return <> like Phantom; + if (chain === 'tron') + return <> like TronLink; + return null; + })()} + . +

    +
    +
    +
    + +
    +
    + )} +
    + )} +
    +
    + +
    + + {/* ─── CONNECT WALLET (gate step) ─── */} + {step === 'connect' && ( +
    +
    +

    Connect Wallet

    +

    + Select a wallet to auto-fill addresses and send directly. +

    +
    + + {wallet.allWallets.length > 0 ? ( +
    + {(() => { + const typeMap = new Map(); + for (const w of wallet.allWallets) { + if (!w.type) continue; + const arr = typeMap.get(w.type) || []; + arr.push(w); + typeMap.set(w.type, arr); + } + const chainLabels: Record = { + evm: 'EVM Chains', + solana: 'Solana', + tron: 'Tron', + bitcoin: 'Bitcoin', + }; + return Array.from(typeMap.entries()).map(([type, wallets]) => ( +
    +
    {chainLabels[type] || type}
    +
    + {wallets.map(w => ( + + ))} +
    +
    + )); + })()} +
    + ) : ( +
    +
    +

    Detecting wallets...

    +
    + )} + + {walletError && ( +
    + {walletError} +
    + )} + +
    + +
    +
    + )} + + {/* ─── FORM ─── */} + {step === 'form' && ( +
    + + {/* Step guide */} +
    + 1. Pick asset + {'>'} + 2. Amount & addresses + {'>'} + 3. Get quote +
    + + {/* From — asset selector (prominent first row) */} +
    + +
    + + + {/* Token picker dropdown */} + {showTokenPicker && ( + <> +
    { setShowTokenPicker(false); setTokenSearch(''); }} /> +
    +
    + setTokenSearch(e.target.value)} + placeholder="Search token or chain..." + autoFocus + className="w-full px-3 py-2 rounded-lg bg-glass-4 text-primary font-mono text-sm placeholder:text-muted/40 focus:outline-none" + /> +
    +
    + {tokensLoading ? ( +
    +
    +
    Loading tokens...
    +
    + ) : filteredTokens.length === 0 ? ( +
    No tokens found
    + ) : ( + filteredTokens.map(t => ( + + )) + )} +
    +
    + + )} +
    +
    + + {/* Amount input (own row) */} +
    +
    + + {wallet.connected && nativeBalance && ( +
    + + {parseFloat(nativeBalance).toLocaleString(undefined, { maximumFractionDigits: 4 })} {selectedToken.token} + + + +
    + )} +
    +
    + { + const v = e.target.value; + if (v === '' || /^\d*\.?\d*$/.test(v)) setAmount(v); + }} + placeholder="0.00" + className="flex-1 min-w-0 px-4 py-3 bg-transparent text-primary font-mono text-lg placeholder:text-muted/30 focus:outline-none" + /> +
    + + {selectedToken.token} +
    +
    + + {/* Privacy recommendation chips — amounts that blend on both source chain and ZEC side */} + {recommendations && recommendations.amounts.length > 0 && (() => { + const chips = recommendations.amounts + .filter(a => a.sourceAmount && a.sourceAmount > 0) + .filter(a => !a.sourceToken || a.sourceToken.toUpperCase() === selectedToken.token.toUpperCase()) + .slice(0, 4); + if (chips.length === 0) return null; + return ( +
    + {chips.map((rec, i) => { + const label = getBlendingLabel(rec.blendingScore, rec.dualBlendScore); + const token = rec.sourceToken || selectedToken.token; + return ( + + ); + })} +
    + ); + })()} +
    + + {/* Arrow divider */} +
    +
    +
    + + + +
    +
    +
    + + {/* To section */} +
    + +
    + setZecAddress(e.target.value)} + placeholder="Paste t1 or u1 address" + className="flex-1 min-w-0 px-4 py-3 bg-transparent text-primary font-mono text-sm placeholder:text-muted/30 focus:outline-none" + /> +
    + + ZEC +
    +
    + {zecAddress && zecAddrError && ( +

    {zecAddrError}

    + )} +
    + + {/* Return address — hidden when wallet connected, shown as fallback for manual users */} + {wallet.connected ? ( +
    +
    + + Returns to {wallet.address?.slice(0, 6)}...{wallet.address?.slice(-4)} if swap fails +
    + +
    + ) : ( +
    +
    + + +
    + setRefundAddress(e.target.value)} + placeholder={`The address you're sending from`} + className="w-full px-4 py-3 rounded-lg bg-glass-3 border border-glass-6 text-primary font-mono text-sm placeholder:text-muted/30 focus:outline-none focus:border-cipher-cyan/40 focus:shadow-[0_0_0_3px_rgb(var(--color-cyan-rgb)_/_0.06)] transition-all" + /> + {!refundAddress && ( +

    + Paste the {selectedToken.chainLabel} address you'll send from. Funds return here if the swap can't complete. +

    + )} +
    + )} + + {/* Slippage (expandable) */} + {showSlippage && ( +
    + +
    + {[{ label: '0.5%', value: 50 }, { label: '1%', value: 100 }, { label: '2%', value: 200 }].map(opt => ( + + ))} +
    +
    + )} + + {/* Error */} + {(error || walletError) && ( +
    + {error || walletError} +
    + )} + + {/* CTA — smart contextual button */} + {(() => { + const needsWallet = !wallet.connected && chainWallets.length > 0 && amount && zecAddress && !zecAddrError && !effectiveRefundAddress; + return ( + + ); + })()} + + {/* Fee note */} +

    + Powered by NEAR Intents · Slippage: {slippage / 100}% +

    +
    + )} + + {/* ─── QUOTE REVIEW ─── */} + {step === 'quote' && quote && ( +
    + {/* Summary */} +
    +
    +
    + +
    +
    {amount} {selectedToken.token}
    +
    {selectedToken.chainLabel}
    +
    +
    + + + +
    +
    +
    {estimatedZec || '~'} ZEC
    +
    Estimated
    +
    + +
    +
    + +
    +
    + Slippage + {slippage / 100}% +
    +
    + Destination + {zecAddress.slice(0, 10)}...{zecAddress.slice(-6)} +
    +
    +
    + + {quoteTimeLeft > 0 && ( +
    +
    30 ? 'bg-cipher-green' : quoteTimeLeft > 10 ? 'bg-cipher-yellow' : 'bg-red-500 animate-pulse'}`} /> + 30 ? 'text-muted' : quoteTimeLeft > 10 ? 'text-cipher-yellow' : 'text-red-500'}> + Quote expires in {Math.floor(quoteTimeLeft / 60)}:{(quoteTimeLeft % 60).toString().padStart(2, '0')} + +
    + )} + +
    + + +
    +
    + )} + + {/* ─── WAITING FOR DEPOSIT ─── */} + {step === 'waiting' && ( +
    + {/* Swap summary row */} +
    +
    +
    + +
    +
    {amount} {selectedToken.token}
    +
    {selectedToken.chainLabel}
    +
    +
    + + + +
    +
    +
    {estimatedZec || '~'} ZEC
    +
    Estimated
    +
    + +
    +
    +
    + + {/* Status indicator */} +
    +
    + + {swapStatus ? swapStatus.replace(/_/g, ' ') : 'Waiting for deposit'} + +
    + + {/* One-click wallet send */} + {wallet.connected && !txHash && ( + + )} + + {txHash && ( +
    +
    +
    + + Sent + {txHash.slice(0, 8)}...{txHash.slice(-6)} +
    +
    + + {CHAIN_EXPLORERS[selectedToken.chain] && ( + + + + )} +
    +
    +
    + )} + + {walletError &&

    {walletError}

    } + + {/* Manual deposit section */} + {(!wallet.connected || (wallet.connected && !txHash)) && ( + <> + {wallet.connected && ( +
    +
    + or send manually +
    +
    + )} + +
    +
    Deposit address
    +
    + {depositAddress} + +
    +
    + + )} + + {txHash ? ( +
    + + Swap will complete automatically + + +
    + ) : ( + + )} +
    + )} + + {/* ─── COMPLETE ─── */} + {step === 'complete' && ( +
    +
    +
    +
    + + + +
    +
    +

    Swap Complete

    +

    {estimatedZec} ZEC sent to your address

    +
    +
    +
    + +
    + )} + + {/* ─── ERROR ─── */} + {step === 'error' && ( +
    +
    +
    +
    + + + +
    +
    +

    Swap Failed

    +

    {error}

    +
    +
    +
    + +
    + )} +
    +
    +
    + + {/* ─── Sidebar ─── */} +
    + + {/* Why connect — only on connect step */} + {step === 'connect' && ( +
    +
    + > Why_connect +
    +
    + {[ + { label: 'Filtered tokens', desc: 'Only see assets your wallet supports' }, + { label: 'Auto-fill addresses', desc: 'No copy-pasting needed for refunds' }, + { label: 'One-click send', desc: 'Send directly from CipherScan' }, + ].map(item => ( +
    + +
    +
    {item.label}
    +
    {item.desc}
    +
    +
    + ))} +
    +
    + )} + + {/* Privacy tips — only on form step */} + {step === 'form' && ( +
    +
    + > Privacy_tips +
    +
    + {recommendations && recommendations.amounts.length > 0 ? (() => { + const withSource = recommendations.amounts + .filter(a => a.sourceAmount && a.sourceAmount > 0) + .filter(a => !a.sourceToken || a.sourceToken.toUpperCase() === selectedToken.token.toUpperCase()); + return ( +
    +

    + {withSource.length > 0 + ? `Amounts that blend in on both the ${selectedToken.chain.toUpperCase()} and Zcash sides for maximum privacy.` + : 'Common ZEC shielding amounts. Using popular amounts improves privacy.'} +

    +
    + {(withSource.length > 0 ? withSource : recommendations.amounts).slice(0, 5).map((rec, i) => { + const label = getBlendingLabel(rec.blendingScore, rec.dualBlendScore); + const token = rec.sourceToken || selectedToken.token; + const displayAmount = rec.sourceAmount || rec.amountZec; + const displayToken = rec.sourceAmount ? token : 'ZEC'; + return ( + + ); + })} +
    + {recommendations.tip && ( +

    {recommendations.tip}

    + )} +
    + ); + })() : ( +

    + Privacy recommendations appear once swap data is collected. +

    + )} +
    +
    + )} + + {/* Estimated time — shown during quote/waiting */} + {(step === 'quote' || step === 'waiting') && ( +
    +
    + > Estimated_time +
    +
    +
    +
    + +
    +
    +
    + {(() => { + const c = selectedToken.chain; + if (c === 'sol') return '~1-2 min'; + if (['near', 'ton', 'sui'].includes(c)) return '~2-5 min'; + if (['eth', 'base', 'arb', 'op', 'pol', 'avax', 'bsc', 'gnosis', 'bera', 'scroll'].includes(c)) return '~5-15 min'; + if (['tron'].includes(c)) return '~3-10 min'; + if (['btc', 'ltc', 'bch', 'doge', 'dash'].includes(c)) return '~20-60 min'; + return '~5-30 min'; + })()} +
    +
    via {selectedToken.chainLabel}
    +
    +
    + +
    +
    +
    + Deposit to bridge address +
    +
    +
    + NEAR Intents bridging +
    +
    +
    + ZEC sent to your address +
    +
    + +

    + Times depend on {selectedToken.chainLabel} block confirmations and NEAR solver availability. +

    +
    +
    + )} + + {/* Complete/error — minimal */} + {(step === 'complete' || step === 'error') && ( +
    +
    +

    + {step === 'complete' + ? 'Your ZEC has arrived. For maximum privacy, shield your balance using a wallet that supports Orchard.' + : 'If your swap failed, funds are returned to your refund address automatically.'} +

    +
    +
    + )} + +
    + + View Crosschain Analytics + + +
    +
    +
    +
    + ); } From 05e8f813b1a94209506f1534de5bc10e9bf42433 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 12:31:35 +0530 Subject: [PATCH 33/35] feat(faucet): replace 404 with friendly Testnet Only screen on mainnet --- app/faucet/FaucetClient.tsx | 21 +++++++++++++++++++++ app/faucet/page.tsx | 3 --- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index c5b5179..5874460 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -9,6 +9,7 @@ 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 || ''; @@ -132,6 +133,26 @@ export default function FaucetClient() { setResult(null); } + if (!isTestnet) { + return ( +
    +
    +
    + + + +
    +

    Testnet Only

    +

    The faucet dispenses testnet ZEC (TAZ).

    + + Go to Testnet + + +
    +
    + ); + } + return (
    {/* Header */} diff --git a/app/faucet/page.tsx b/app/faucet/page.tsx index e73555c..67213d3 100644 --- a/app/faucet/page.tsx +++ b/app/faucet/page.tsx @@ -1,7 +1,5 @@ import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; import FaucetClient from './FaucetClient'; -import { isTestnet } from '@/lib/config'; export const metadata: Metadata = { title: 'Testnet Faucet | CipherScan', @@ -19,7 +17,6 @@ export const metadata: Metadata = { }; export default function FaucetPage() { - if (!isTestnet) notFound(); return (
    From 7afd3d27ba13ce7df1432dfdd9b57ee63b3fcf58 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 15:32:07 +0530 Subject: [PATCH 34/35] fix(faucet): per-IP rate limit + server-side cap from /status --- .env.example | 4 - app/faucet/FaucetClient.tsx | 29 +++-- server/api/routes/faucet.js | 229 ++++++++++++++++++++++-------------- 3 files changed, 161 insertions(+), 101 deletions(-) diff --git a/.env.example b/.env.example index 59a89bf..a76988e 100644 --- a/.env.example +++ b/.env.example @@ -29,10 +29,6 @@ ZEBRA_GRPC_URL=127.0.0.1:8230 TAPS_URL=https://light.zcash.me/taps TAPS_API_KEY= -# Fallback dispense amount in TAZ. The UI sends `amountTaz` per request via the -# slider (0.001 – 1 TAZ); this only applies if a client omits that field. -FAUCET_DISPENSE_AMOUNT_TAZ=1 - # Cloudflare Turnstile (https://dash.cloudflare.com/?to=/:account/turnstile). # Set both to enable captcha; leave both empty to disable. TURNSTILE_SECRET_KEY= diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx index 5874460..a6d5343 100644 --- a/app/faucet/FaucetClient.tsx +++ b/app/faucet/FaucetClient.tsx @@ -13,8 +13,6 @@ import { isTestnet } from '@/lib/config'; const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''; -const DEFAULT_DISPENSE_TAZ = 0.1; - // Slider events emit 0.30000000000000004 etc. — snap to the increment. function snapToStep(v: number, step: number): number { return Math.round(v / step) * step; @@ -54,7 +52,7 @@ function errorMessage(data: { error?: string; detail?: string }): string { export default function FaucetClient() { const [address, setAddress] = useState(''); - const [amountTaz, setAmountTaz] = useState(DEFAULT_DISPENSE_TAZ); + 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); @@ -68,7 +66,7 @@ export default function FaucetClient() { status != null && status.maxSpendTaz > 0 && status.maxDispensableTaz < status.maxSpendTaz * SYNC_NOTICE_THRESHOLD; - const overSpendable = status != null && amountTaz > status.maxDispensableTaz + 1e-9; + const overSpendable = status != null && amountTaz != null && amountTaz > status.maxDispensableTaz + 1e-9; useEffect(() => { let cancelled = false; @@ -88,6 +86,13 @@ export default function FaucetClient() { }; }, []); + 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(); @@ -99,6 +104,10 @@ export default function FaucetClient() { setNotice('complete the captcha first'); return; } + if (amountTaz == null) { + setNotice('still loading — try again in a moment'); + return; + } setNotice(null); setPending(true); @@ -247,7 +256,7 @@ export default function FaucetClient() { {'>'} AMOUNT
    - {formatTaz(amountTaz)} TAZ + {formatTaz(amountTaz ?? 0)} TAZ
    {status ? ( @@ -255,9 +264,9 @@ export default function FaucetClient() { setAmountTaz(snapToStep(parseFloat(e.target.value), status.stepTaz))} disabled={pending} aria-label="Dispense amount in TAZ" @@ -265,7 +274,7 @@ export default function FaucetClient() { />
    {formatTaz(status.minSpendTaz)} TAZ - {formatTaz(status.maxSpendTaz)} TAZ + {formatTaz(status.maxDispensableTaz)} TAZ
    ) : ( @@ -327,11 +336,11 @@ export default function FaucetClient() { d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> - Sending {formatTaz(amountTaz)} TAZ… + Sending {formatTaz(amountTaz ?? 0)} TAZ… ) : ( <> - {'>'} Send {formatTaz(amountTaz)} TAZ + {'>'} Send {formatTaz(amountTaz ?? 0)} TAZ )} diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js index 5b1524c..d17f748 100644 --- a/server/api/routes/faucet.js +++ b/server/api/routes/faucet.js @@ -3,42 +3,55 @@ * /api/faucet/status, /api/faucet/dispense — proxies to taps, verifies Turnstile */ -const express = require('express'); +const express = require("express"); +const rateLimit = require("express-rate-limit"); const router = express.Router(); -const DEFAULT_DISPENSE_TAZ = 1; +const DEFAULT_DISPENSE_TAZ = 0.5; const UA_REGEX = /^utest1[02-9ac-hj-np-z]{40,}$/; -const TAPS_URL = process.env.TAPS_URL || ''; +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'); + console.error("[faucet] TAPS_URL not set — /api/faucet/* will 503"); } -const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY || ''; +const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY || ""; if (!TURNSTILE_SECRET) { - console.error('[faucet] TURNSTILE_SECRET_KEY not set — /api/faucet/dispense will 503'); -} - -function dispenseAmountTaz() { - const raw = parseFloat(process.env.FAUCET_DISPENSE_AMOUNT_TAZ); - return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DISPENSE_TAZ; + 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 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); + console.error("[faucet] Turnstile verify failed:", err.message); return false; } } @@ -50,12 +63,12 @@ async function tapsStatus() { } async function tapsSend({ recipient, amountTaz }) { - const apiKey = process.env.TAPS_API_KEY || ''; + const apiKey = process.env.TAPS_API_KEY || ""; const res = await fetch(`${TAPS_URL}/send`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', - 'X-Api-Key': apiKey, + "Content-Type": "application/json", + "X-Api-Key": apiKey, }, body: JSON.stringify({ recipient, amount: amountTaz }), }); @@ -63,8 +76,8 @@ async function tapsSend({ recipient, amountTaz }) { 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' }); +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; @@ -74,73 +87,115 @@ router.get('/api/faucet/status', async (_req, res) => { 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, + 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' }); + console.error("[faucet] status failed:", err.message); + res.status(502).json({ error: "wallet unreachable" }); } }); -router.post('/api/faucet/dispense', 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 = dispenseAmountTaz(); - } 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' }); - } - - 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 }); -}); +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; From 464494281e6eda4e818a52980c9f9b09c4cccc78 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sun, 24 May 2026 15:35:03 +0530 Subject: [PATCH 35/35] feat(faucet): attach memo plugging zipher wallet on every dispense --- server/api/routes/faucet.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js index d17f748..5d9ffac 100644 --- a/server/api/routes/faucet.js +++ b/server/api/routes/faucet.js @@ -9,6 +9,8 @@ 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, @@ -70,7 +72,7 @@ async function tapsSend({ recipient, amountTaz }) { "Content-Type": "application/json", "X-Api-Key": apiKey, }, - body: JSON.stringify({ recipient, amount: amountTaz }), + body: JSON.stringify({ recipient, amount: amountTaz, memo: FAUCET_MEMO }), }); const body = await res.json().catch(() => ({})); return { status: res.status, body };