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}
+ copyTxid(state.txid)}
+ className="text-muted hover:text-cipher-cyan flex-shrink-0 font-mono"
+ aria-label="Copy txid"
+ >
+ {copied ? '✓' : '⎘'}
+
+
+
+ Likely unconfirmed — confirmation in ~75 seconds.
+
+
+
+
+
+ view tx →
+
+
+ send to another address
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+ {/* 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}
+
+ {addrCopied ? '✓' : '⎘'}
+
+
+
+ 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}
+
+ )}
{isSubmitting ? (
@@ -207,11 +294,11 @@ export default function FaucetClient() {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
- Sending {DISPENSE_AMOUNT_TAZ} TAZ…
+ Sending {dispenseAmount} TAZ…
>
) : (
<>
- {'>'} Send {DISPENSE_AMOUNT_TAZ} TAZ
+ {'>'} Send {dispenseAmount} TAZ
>
)}
@@ -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',
+ }}
+ />
+
+ )}
+
{isSubmitting ? (
diff --git a/package-lock.json b/package-lock.json
index 7d0e3f1..5c748a0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
+ "@marsidev/react-turnstile": "^1.5.2",
"@solana/spl-token": "^0.4.14",
"@solana/web3.js": "^1.98.4",
"@types/node": "^22.10.5",
@@ -179,10 +180,35 @@
"url": "https://opencollective.com/js-sdsl"
}
},
+ "node_modules/@marsidev/react-turnstile": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.5.2.tgz",
+ "integrity": "sha512-+3aBPxp86JzSC0ZmgyonoGoUEENcUkH3LGahXSpkV87ArvD2DzRCmPgh0FyQk6PQRmJwQJDAfwNavFsxUxMQWA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^17.0.2 || ^18.0.0 || ^19.0",
+ "react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
+ }
+ },
"node_modules/@next/env": {
"version": "15.5.7",
"license": "MIT"
},
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
+ "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.7",
"cpu": [
@@ -197,6 +223,96 @@
"node": ">= 10"
}
},
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
+ "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
+ "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
+ "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
+ "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
+ "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
+ "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"license": "MIT",
@@ -3784,111 +3900,6 @@
"optional": true
}
}
- },
- "node_modules/@next/swc-darwin-arm64": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
- "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-arm64-gnu": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
- "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-arm64-musl": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
- "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-x64-gnu": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
- "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-x64-musl": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
- "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-win32-arm64-msvc": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
- "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-win32-x64-msvc": {
- "version": "15.5.7",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
- "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
}
}
}
diff --git a/package.json b/package.json
index 1d0e584..370f75f 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
+ "@marsidev/react-turnstile": "^1.5.2",
"@solana/spl-token": "^0.4.14",
"@solana/web3.js": "^1.98.4",
"@types/node": "^22.10.5",
From aa1ddf7eddefd04d549bc3c8303973d00659c0ad Mon Sep 17 00:00:00 2001
From: Julian Abraham
Date: Sat, 23 May 2026 14:57:30 +0530
Subject: [PATCH 08/35] feat(faucet): 404 /faucet on non-testnet networks
---
app/faucet/page.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/app/faucet/page.tsx b/app/faucet/page.tsx
index 90c78c0..43d6237 100644
--- a/app/faucet/page.tsx
+++ b/app/faucet/page.tsx
@@ -1,5 +1,7 @@
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 — Get free TAZ | CipherScan',
@@ -18,6 +20,7 @@ export const metadata: Metadata = {
};
export default function FaucetPage() {
+ if (!isTestnet) notFound();
return (
From 50cbd58b657172b7db069b7931d13464e9309191 Mon Sep 17 00:00:00 2001
From: Julian Abraham
Date: Sat, 23 May 2026 14:59:35 +0530
Subject: [PATCH 09/35] feat(swap): 404 /swap on non-mainnet networks
---
app/swap/SwapClient.tsx | 1535 ++++++++++++++++++++++++++++++++++++++
app/swap/page.tsx | 1558 +--------------------------------------
2 files changed, 1539 insertions(+), 1554 deletions(-)
create mode 100644 app/swap/SwapClient.tsx
diff --git a/app/swap/SwapClient.tsx b/app/swap/SwapClient.tsx
new file mode 100644
index 0000000..3036bf3
--- /dev/null
+++ b/app/swap/SwapClient.tsx
@@ -0,0 +1,1535 @@
+'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' && (
+
+
{
+ if (manualMode && !wallet.connected && wallet.allWallets.length > 0) {
+ setStep('connect');
+ setManualMode(false);
+ } else {
+ setShowWalletPicker(!showWalletPicker);
+ }
+ }}
+ disabled={wallet.switching}
+ className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-mono transition-all disabled:opacity-40 ${
+ wallet.connected
+ ? 'bg-cipher-green/8 hover:bg-glass-6 text-secondary'
+ : chainWallets.length === 0
+ ? 'text-muted hover:text-muted/80'
+ : 'text-muted hover:text-cipher-cyan'
+ }`}
+ >
+ {wallet.connected ? (
+ <>
+
+ {wallet.walletName} · {wallet.address?.slice(0, 6)}...{wallet.address?.slice(-4)}
+ {wallet.address?.slice(0, 4)}...{wallet.address?.slice(-4)}
+ >
+ ) : (
+ <>
+
+
+
+ {wallet.switching ? 'Connecting...' : chainWallets.length === 0 ? `No ${selectedToken.chainLabel} wallet detected` : 'Connect'}
+ >
+ )}
+
+
+ {/* Wallet picker dropdown — wallets available */}
+ {showWalletPicker && chainWallets.length > 0 && (
+
+
+ Select wallet
+ {wallet.connected && (
+ { wallet.disconnect(); setShowWalletPicker(false); setManualMode(false); setStep('connect'); }}
+ className="text-[10px] font-mono text-red-400 hover:text-red-300 transition-colors"
+ >
+ Disconnect
+
+ )}
+
+ {chainWallets.map((w) => (
+
connectToWallet(w)}
+ className={`w-full flex items-center gap-3 px-3 py-3 transition-colors text-left ${
+ wallet.connected && wallet.walletName === w.name
+ ? 'bg-glass-6'
+ : 'hover:bg-glass-4'
+ }`}
+ >
+
+
+ {wallet.connected && wallet.walletName === w.name && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* 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;
+ })()}
+ .
+
+
+
+
+ setShowWalletPicker(false)}
+ className="w-full py-2 rounded-lg text-[11px] font-mono text-cipher-cyan bg-cipher-cyan/8 hover:bg-cipher-cyan/12 transition-colors"
+ >
+ Got it
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+ {/* ─── 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 => (
+ {
+ setWalletError('');
+ try {
+ await wallet.connect(w);
+ } catch (err: any) {
+ setWalletError(err.message || 'Connection failed');
+ }
+ }}
+ disabled={wallet.switching}
+ className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-glass-3 border border-glass-6 hover:border-cipher-cyan/30 hover:bg-glass-4 transition-all disabled:opacity-50"
+ >
+
+ {w.name}
+
+ ))}
+
+
+ ));
+ })()}
+
+ ) : (
+
+
+
Detecting wallets...
+
+ )}
+
+ {walletError && (
+
+ {walletError}
+
+ )}
+
+
+ { setManualMode(true); setStep('form'); }}
+ className="text-[11px] font-mono text-muted hover:text-cipher-cyan transition-colors"
+ >
+ Continue without connecting a wallet
+
+
+
+ )}
+
+ {/* ─── FORM ─── */}
+ {step === 'form' && (
+
+
+ {/* Step guide */}
+
+ 1. Pick asset
+ {'>'}
+ 2. Amount & addresses
+ {'>'}
+ 3. Get quote
+
+
+ {/* From — asset selector (prominent first row) */}
+
+
You send
+
+
{ setShowTokenPicker(!showTokenPicker); setTokenSearch(''); }}
+ className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-glass-3 border border-glass-6 hover:border-cipher-cyan/30 transition-all"
+ >
+
+
+
{selectedToken.token}
+
{selectedToken.chainLabel}
+
+ Change
+
+
+
+
+
+ {/* 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 ? (
+
+ ) : filteredTokens.length === 0 ? (
+
No tokens found
+ ) : (
+ filteredTokens.map(t => (
+
{
+ setSelectedToken(t);
+ setAmount('');
+ setShowTokenPicker(false);
+ setTokenSearch('');
+ setShowWalletPicker(false);
+ const newWallets = wallet.getWalletsForChain(t.chain);
+ const sameType = newWallets.some(w => w.type === wallet.walletType);
+ if (wallet.connected && !sameType) wallet.disconnect();
+ }}
+ className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${
+ selectedToken.id === t.id ? 'bg-glass-6' : 'hover:bg-glass-3'
+ }`}
+ >
+
+
+
{t.token}
+
{t.chainLabel}
+
+ {selectedToken.id === t.id && (
+
+
+
+ )}
+
+ ))
+ )}
+
+
+ >
+ )}
+
+
+
+ {/* Amount input (own row) */}
+
+
+
Amount
+ {wallet.connected && nativeBalance && (
+
+
+ {parseFloat(nativeBalance).toLocaleString(undefined, { maximumFractionDigits: 4 })} {selectedToken.token}
+
+ setAmount(String(parseFloat(nativeBalance) * 0.5))}
+ className="text-[10px] font-mono text-cipher-cyan hover:text-cipher-cyan/80 transition-colors"
+ >
+ 50%
+
+ setAmount(nativeBalance)}
+ className="text-[10px] font-mono text-cipher-cyan hover:text-cipher-cyan/80 transition-colors"
+ >
+ MAX
+
+
+ )}
+
+
+
{
+ 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 (
+ setAmount(String(rec.sourceAmount))}
+ className={`group flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-mono transition-all border ${
+ label === 'high'
+ ? 'border-cipher-green/30 text-cipher-green hover:bg-cipher-green/10'
+ : label === 'medium'
+ ? 'border-cipher-yellow/30 text-cipher-yellow hover:bg-cipher-yellow/10'
+ : 'border-glass-12 text-muted hover:bg-glass-6'
+ }`}
+ title={`≈${rec.amountZec} ZEC · ${rec.txCount} shielding txs · ${rec.chainSwapCount || 0} ${selectedToken.chain.toUpperCase()} swaps`}
+ >
+ {formatRecAmount(rec.sourceAmount!, token)} {token}
+
+ );
+ })}
+
+ );
+ })()}
+
+
+ {/* Arrow divider */}
+
+
+ {/* To section */}
+
+
Your ZEC address
+
+
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
+
+
setShowSlippage(!showSlippage)} className="text-[10px] font-mono text-muted hover:text-secondary transition-colors">
+ {showSlippage ? 'Less' : 'More options'}
+
+
+ ) : (
+
+
+
+ Your {selectedToken.chainLabel} address
+
+ setShowSlippage(!showSlippage)} className="text-[10px] font-mono text-muted hover:text-secondary transition-colors">
+ {showSlippage ? 'Less' : 'More options'}
+
+
+
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 && (
+
+
Slippage
+
+ {[{ label: '0.5%', value: 50 }, { label: '1%', value: 100 }, { label: '2%', value: 200 }].map(opt => (
+ setSlippage(opt.value)}
+ className={`px-3 py-1.5 rounded-lg text-xs font-mono transition-all ${
+ slippage === opt.value
+ ? 'bg-cipher-cyan/10 text-cipher-cyan'
+ : 'text-muted hover:text-secondary bg-glass-2 hover:bg-glass-4'
+ }`}
+ >
+ {opt.label}
+
+ ))}
+
+
+ )}
+
+ {/* Error */}
+ {(error || walletError) && (
+
+ {error || walletError}
+
+ )}
+
+ {/* CTA — smart contextual button */}
+ {(() => {
+ const needsWallet = !wallet.connected && chainWallets.length > 0 && amount && zecAddress && !zecAddrError && !effectiveRefundAddress;
+ return (
+
setShowWalletPicker(true) : getQuote}
+ disabled={needsWallet ? false : ctaDisabled}
+ className={`w-full py-3.5 rounded-lg font-mono font-semibold text-sm transition-all duration-150 ${
+ needsWallet
+ ? 'bg-cipher-cyan-bright text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-cyan-rgb)_/_0.25)] hover:-translate-y-[1px] active:translate-y-0 active:shadow-none'
+ : 'disabled:opacity-30 disabled:cursor-not-allowed bg-cipher-cyan-bright text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-cyan-rgb)_/_0.25)] hover:-translate-y-[1px] active:translate-y-0 active:shadow-none'
+ }`}
+ >
+ {ctaText}
+
+ );
+ })()}
+
+ {/* 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')}
+
+
+ )}
+
+
+
+ Back
+
+ setStep('waiting')}
+ className="flex-[2] py-3 rounded-lg font-mono font-semibold text-sm bg-cipher-green text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-green-rgb)_/_0.2)] hover:-translate-y-[1px] active:translate-y-0 transition-all"
+ >
+ {wallet.connected ? 'Confirm & Send' : 'Confirm Swap'}
+
+
+
+ )}
+
+ {/* ─── 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 && (
+
+ {sendingTx ? 'Confirm in wallet...' : `Send ${amount} ${selectedToken.token}`}
+
+ )}
+
+ {txHash && (
+
+
+
+
+
Sent
+
{txHash.slice(0, 8)}...{txHash.slice(-6)}
+
+
+
{ navigator.clipboard.writeText(txHash); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
+ className="p-1.5 rounded-md hover:bg-glass-4 transition-colors"
+ title="Copy tx hash"
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ {CHAIN_EXPLORERS[selectedToken.chain] && (
+
+
+
+ )}
+
+
+
+ )}
+
+ {walletError &&
{walletError}
}
+
+ {/* Manual deposit section */}
+ {(!wallet.connected || (wallet.connected && !txHash)) && (
+ <>
+ {wallet.connected && (
+
+ )}
+
+
+
Deposit address
+
+
{depositAddress}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
+ )}
+
+ {txHash ? (
+
+
+ Swap will complete automatically
+
+
+ New Swap →
+
+
+ ) : (
+
+ Cancel
+
+ )}
+
+ )}
+
+ {/* ─── COMPLETE ─── */}
+ {step === 'complete' && (
+
+
+
+
+
+
Swap Complete
+
{estimatedZec} ZEC sent to your address
+
+
+
+
+ New Swap
+
+
+ )}
+
+ {/* ─── ERROR ─── */}
+ {step === 'error' && (
+
+
+
+
+
+
Swap Failed
+
{error}
+
+
+
+
+ Try Again
+
+
+ )}
+
+
+
+
+ {/* ─── 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 (
+
rec.sourceAmount ? setAmount(String(rec.sourceAmount)) : undefined}
+ className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-glass-3 transition-all group text-left"
+ title={`≈${rec.amountZec} ZEC · ${rec.txCount} shielding txs`}
+ >
+
+
+ {formatRecAmount(displayAmount, displayToken)} {displayToken}
+
+
+ {label}
+
+
+
+ {rec.chainSwapCount ? `${rec.chainSwapCount} swaps` : `${rec.txCount} txs`}
+
+
+ );
+ })}
+
+ {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 (
-
- );
- }
-
- 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' && (
-
-
{
- if (manualMode && !wallet.connected && wallet.allWallets.length > 0) {
- setStep('connect');
- setManualMode(false);
- } else {
- setShowWalletPicker(!showWalletPicker);
- }
- }}
- disabled={wallet.switching}
- className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-mono transition-all disabled:opacity-40 ${
- wallet.connected
- ? 'bg-cipher-green/8 hover:bg-glass-6 text-secondary'
- : chainWallets.length === 0
- ? 'text-muted hover:text-muted/80'
- : 'text-muted hover:text-cipher-cyan'
- }`}
- >
- {wallet.connected ? (
- <>
-
- {wallet.walletName} · {wallet.address?.slice(0, 6)}...{wallet.address?.slice(-4)}
- {wallet.address?.slice(0, 4)}...{wallet.address?.slice(-4)}
- >
- ) : (
- <>
-
-
-
- {wallet.switching ? 'Connecting...' : chainWallets.length === 0 ? `No ${selectedToken.chainLabel} wallet detected` : 'Connect'}
- >
- )}
-
-
- {/* Wallet picker dropdown — wallets available */}
- {showWalletPicker && chainWallets.length > 0 && (
-
-
- Select wallet
- {wallet.connected && (
- { wallet.disconnect(); setShowWalletPicker(false); setManualMode(false); setStep('connect'); }}
- className="text-[10px] font-mono text-red-400 hover:text-red-300 transition-colors"
- >
- Disconnect
-
- )}
-
- {chainWallets.map((w) => (
-
connectToWallet(w)}
- className={`w-full flex items-center gap-3 px-3 py-3 transition-colors text-left ${
- wallet.connected && wallet.walletName === w.name
- ? 'bg-glass-6'
- : 'hover:bg-glass-4'
- }`}
- >
-
-
- {wallet.connected && wallet.walletName === w.name && (
-
- )}
-
- ))}
-
- )}
-
- {/* 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;
- })()}
- .
-
-
-
-
- setShowWalletPicker(false)}
- className="w-full py-2 rounded-lg text-[11px] font-mono text-cipher-cyan bg-cipher-cyan/8 hover:bg-cipher-cyan/12 transition-colors"
- >
- Got it
-
-
-
- )}
-
- )}
-
-
-
-
-
- {/* ─── 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 => (
- {
- setWalletError('');
- try {
- await wallet.connect(w);
- } catch (err: any) {
- setWalletError(err.message || 'Connection failed');
- }
- }}
- disabled={wallet.switching}
- className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-glass-3 border border-glass-6 hover:border-cipher-cyan/30 hover:bg-glass-4 transition-all disabled:opacity-50"
- >
-
- {w.name}
-
- ))}
-
-
- ));
- })()}
-
- ) : (
-
-
-
Detecting wallets...
-
- )}
-
- {walletError && (
-
- {walletError}
-
- )}
-
-
- { setManualMode(true); setStep('form'); }}
- className="text-[11px] font-mono text-muted hover:text-cipher-cyan transition-colors"
- >
- Continue without connecting a wallet
-
-
-
- )}
-
- {/* ─── FORM ─── */}
- {step === 'form' && (
-
-
- {/* Step guide */}
-
- 1. Pick asset
- {'>'}
- 2. Amount & addresses
- {'>'}
- 3. Get quote
-
-
- {/* From — asset selector (prominent first row) */}
-
-
You send
-
-
{ setShowTokenPicker(!showTokenPicker); setTokenSearch(''); }}
- className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-glass-3 border border-glass-6 hover:border-cipher-cyan/30 transition-all"
- >
-
-
-
{selectedToken.token}
-
{selectedToken.chainLabel}
-
- Change
-
-
-
-
-
- {/* 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 ? (
-
- ) : filteredTokens.length === 0 ? (
-
No tokens found
- ) : (
- filteredTokens.map(t => (
-
{
- setSelectedToken(t);
- setAmount('');
- setShowTokenPicker(false);
- setTokenSearch('');
- setShowWalletPicker(false);
- const newWallets = wallet.getWalletsForChain(t.chain);
- const sameType = newWallets.some(w => w.type === wallet.walletType);
- if (wallet.connected && !sameType) wallet.disconnect();
- }}
- className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${
- selectedToken.id === t.id ? 'bg-glass-6' : 'hover:bg-glass-3'
- }`}
- >
-
-
-
{t.token}
-
{t.chainLabel}
-
- {selectedToken.id === t.id && (
-
-
-
- )}
-
- ))
- )}
-
-
- >
- )}
-
-
-
- {/* Amount input (own row) */}
-
-
-
Amount
- {wallet.connected && nativeBalance && (
-
-
- {parseFloat(nativeBalance).toLocaleString(undefined, { maximumFractionDigits: 4 })} {selectedToken.token}
-
- setAmount(String(parseFloat(nativeBalance) * 0.5))}
- className="text-[10px] font-mono text-cipher-cyan hover:text-cipher-cyan/80 transition-colors"
- >
- 50%
-
- setAmount(nativeBalance)}
- className="text-[10px] font-mono text-cipher-cyan hover:text-cipher-cyan/80 transition-colors"
- >
- MAX
-
-
- )}
-
-
-
{
- 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 (
- setAmount(String(rec.sourceAmount))}
- className={`group flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-mono transition-all border ${
- label === 'high'
- ? 'border-cipher-green/30 text-cipher-green hover:bg-cipher-green/10'
- : label === 'medium'
- ? 'border-cipher-yellow/30 text-cipher-yellow hover:bg-cipher-yellow/10'
- : 'border-glass-12 text-muted hover:bg-glass-6'
- }`}
- title={`≈${rec.amountZec} ZEC · ${rec.txCount} shielding txs · ${rec.chainSwapCount || 0} ${selectedToken.chain.toUpperCase()} swaps`}
- >
- {formatRecAmount(rec.sourceAmount!, token)} {token}
-
- );
- })}
-
- );
- })()}
-
-
- {/* Arrow divider */}
-
-
- {/* To section */}
-
-
Your ZEC address
-
-
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
-
-
setShowSlippage(!showSlippage)} className="text-[10px] font-mono text-muted hover:text-secondary transition-colors">
- {showSlippage ? 'Less' : 'More options'}
-
-
- ) : (
-
-
-
- Your {selectedToken.chainLabel} address
-
- setShowSlippage(!showSlippage)} className="text-[10px] font-mono text-muted hover:text-secondary transition-colors">
- {showSlippage ? 'Less' : 'More options'}
-
-
-
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 && (
-
-
Slippage
-
- {[{ label: '0.5%', value: 50 }, { label: '1%', value: 100 }, { label: '2%', value: 200 }].map(opt => (
- setSlippage(opt.value)}
- className={`px-3 py-1.5 rounded-lg text-xs font-mono transition-all ${
- slippage === opt.value
- ? 'bg-cipher-cyan/10 text-cipher-cyan'
- : 'text-muted hover:text-secondary bg-glass-2 hover:bg-glass-4'
- }`}
- >
- {opt.label}
-
- ))}
-
-
- )}
-
- {/* Error */}
- {(error || walletError) && (
-
- {error || walletError}
-
- )}
-
- {/* CTA — smart contextual button */}
- {(() => {
- const needsWallet = !wallet.connected && chainWallets.length > 0 && amount && zecAddress && !zecAddrError && !effectiveRefundAddress;
- return (
-
setShowWalletPicker(true) : getQuote}
- disabled={needsWallet ? false : ctaDisabled}
- className={`w-full py-3.5 rounded-lg font-mono font-semibold text-sm transition-all duration-150 ${
- needsWallet
- ? 'bg-cipher-cyan-bright text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-cyan-rgb)_/_0.25)] hover:-translate-y-[1px] active:translate-y-0 active:shadow-none'
- : 'disabled:opacity-30 disabled:cursor-not-allowed bg-cipher-cyan-bright text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-cyan-rgb)_/_0.25)] hover:-translate-y-[1px] active:translate-y-0 active:shadow-none'
- }`}
- >
- {ctaText}
-
- );
- })()}
-
- {/* 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')}
-
-
- )}
-
-
-
- Back
-
- setStep('waiting')}
- className="flex-[2] py-3 rounded-lg font-mono font-semibold text-sm bg-cipher-green text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-green-rgb)_/_0.2)] hover:-translate-y-[1px] active:translate-y-0 transition-all"
- >
- {wallet.connected ? 'Confirm & Send' : 'Confirm Swap'}
-
-
-
- )}
-
- {/* ─── 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 && (
-
- {sendingTx ? 'Confirm in wallet...' : `Send ${amount} ${selectedToken.token}`}
-
- )}
-
- {txHash && (
-
-
-
-
-
Sent
-
{txHash.slice(0, 8)}...{txHash.slice(-6)}
-
-
-
{ navigator.clipboard.writeText(txHash); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
- className="p-1.5 rounded-md hover:bg-glass-4 transition-colors"
- title="Copy tx hash"
- >
- {copied ? (
-
- ) : (
-
- )}
-
- {CHAIN_EXPLORERS[selectedToken.chain] && (
-
-
-
- )}
-
-
-
- )}
-
- {walletError &&
{walletError}
}
-
- {/* Manual deposit section */}
- {(!wallet.connected || (wallet.connected && !txHash)) && (
- <>
- {wallet.connected && (
-
- )}
-
-
-
Deposit address
-
-
{depositAddress}
-
- {copied ? (
-
- ) : (
-
- )}
-
-
-
- >
- )}
-
- {txHash ? (
-
-
- Swap will complete automatically
-
-
- New Swap →
-
-
- ) : (
-
- Cancel
-
- )}
-
- )}
-
- {/* ─── COMPLETE ─── */}
- {step === 'complete' && (
-
-
-
-
-
-
Swap Complete
-
{estimatedZec} ZEC sent to your address
-
-
-
-
- New Swap
-
-
- )}
-
- {/* ─── ERROR ─── */}
- {step === 'error' && (
-
-
-
-
-
-
Swap Failed
-
{error}
-
-
-
-
- Try Again
-
-
- )}
-
-
-
-
- {/* ─── 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 (
-
rec.sourceAmount ? setAmount(String(rec.sourceAmount)) : undefined}
- className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-glass-3 transition-all group text-left"
- title={`≈${rec.amountZec} ZEC · ${rec.txCount} shielding txs`}
- >
-
-
- {formatRecAmount(displayAmount, displayToken)} {displayToken}
-
-
- {label}
-
-
-
- {rec.chainSwapCount ? `${rec.chainSwapCount} swaps` : `${rec.txCount} txs`}
-
-
- );
- })}
-
- {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}
-
- {addrCopied ? '✓' : '⎘'}
-
+ {status?.donateAddress ? (
+ <>
+ {status.donateAddress}
+
+ {addrCopied ? '✓' : '⎘'}
+
+ >
+ ) : (
+ 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}
- copyTxid(state.txid)}
- className="text-muted hover:text-cipher-cyan flex-shrink-0 font-mono"
- aria-label="Copy txid"
- >
- {copied ? '✓' : '⎘'}
-
+
Likely unconfirmed — confirmation in ~75 seconds.
@@ -441,14 +419,7 @@ export default function FaucetClient() {
{status?.donateAddress ? (
<>
{status.donateAddress}
-
- {addrCopied ? '✓' : '⎘'}
-
+
>
) : (
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.
+
+ )}
+
Date: Sun, 24 May 2026 11:29:25 +0530
Subject: [PATCH 27/35] refactor(faucet): flatten submit state, drop
isDark/isSubmitting aliases, use bg token
---
app/faucet/FaucetClient.tsx | 108 ++++++++++++++----------------------
server/api/routes/faucet.js | 1 -
2 files changed, 41 insertions(+), 68 deletions(-)
diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx
index 8ef38eb..dcbbfa4 100644
--- a/app/faucet/FaucetClient.tsx
+++ b/app/faucet/FaucetClient.tsx
@@ -35,7 +35,6 @@ interface FaucetStatus {
balanceTaz: number;
maxDispensableTaz: number;
maxSpendTaz: number;
- dispenseAmountTaz: number;
captchaEnabled: boolean;
donateAddress: string | null;
}
@@ -44,29 +43,35 @@ interface FaucetStatus {
// 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' }
- | { kind: 'success'; txid: string; amountTaz: number }
- | { kind: 'invalid' }
- | { kind: 'drained' }
- | { kind: 'error'; message: string };
-
// 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());
}
+function errorMessage(data: { error?: string; detail?: string }): string {
+ switch (data.error) {
+ case 'invalid address':
+ return 'invalid testnet address — expected utest1…';
+ case 'drained':
+ return 'faucet is dry — mining the next refill, check back later';
+ case 'captcha failed':
+ return 'captcha verification failed';
+ default:
+ return data.error || data.detail || 'something broke, try again';
+ }
+}
+
export default function FaucetClient() {
const [address, setAddress] = useState('');
const [amountTaz, setAmountTaz] = useState(DEFAULT_DISPENSE_TAZ);
- const [state, setState] = useState({ kind: 'idle' });
+ const [pending, setPending] = useState(false);
+ const [notice, setNotice] = useState(null);
+ const [result, setResult] = useState<{ txid: string; amountTaz: number } | null>(null);
const [status, setStatus] = useState(null);
const [captchaToken, setCaptchaToken] = useState(null);
const turnstileRef = useRef(null);
const { theme, mounted: themeMounted } = useTheme();
- const isDark = theme === 'dark';
// 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.
@@ -102,14 +107,15 @@ export default function FaucetClient() {
e.preventDefault();
const trimmed = address.trim();
if (!isValidTestnetUnifiedAddress(trimmed)) {
- setState({ kind: 'invalid' });
+ setNotice('invalid testnet address — expected utest1…');
return;
}
if (captchaRequired && !captchaToken) {
- setState({ kind: 'error', message: 'complete the captcha first' });
+ setNotice('complete the captcha first');
return;
}
- setState({ kind: 'submitting' });
+ setNotice(null);
+ setPending(true);
try {
const res = await fetch(`${getApiUrl()}/api/faucet/dispense`, {
@@ -120,7 +126,7 @@ export default function FaucetClient() {
const data = await res.json().catch(() => ({}));
if (res.ok && data.txid) {
- setState({ kind: 'success', txid: data.txid, amountTaz });
+ setResult({ txid: data.txid, amountTaz });
return;
}
@@ -128,41 +134,22 @@ export default function FaucetClient() {
// 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' });
- 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',
- });
- }
+ setNotice(errorMessage(data));
} catch (err) {
turnstileRef.current?.reset();
setCaptchaToken(null);
- setState({
- kind: 'error',
- message: err instanceof Error ? err.message : 'network error',
- });
+ setNotice(err instanceof Error ? err.message : 'network error');
+ } finally {
+ setPending(false);
}
}
function reset() {
setAddress('');
- setState({ kind: 'idle' });
+ setNotice(null);
+ setResult(null);
}
- const isSubmitting = state.kind === 'submitting';
- const isSuccess = state.kind === 'success';
-
return (
{/* Header */}
@@ -179,13 +166,13 @@ export default function FaucetClient() {
{/* Form / Result */}
- {isSuccess ? (
+ {result ? (
SENT
- {formatTaz(state.amountTaz)} TAZ dispatched to your address
+ {formatTaz(result.amountTaz)} TAZ dispatched to your address
@@ -194,8 +181,8 @@ export default function FaucetClient() {
{'>'} TXID
- {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() {
- {isSubmitting ? (
+ {pending ? (
<>
)}
diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js
index 81d5f19..b3a10a5 100644
--- a/server/api/routes/faucet.js
+++ b/server/api/routes/faucet.js
@@ -81,7 +81,6 @@ router.get('/api/faucet/status', async (_req, res) => {
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 6b46aacc35f9e45a632f9de6e9864adf23e399ff Mon Sep 17 00:00:00 2001
From: Julian Abraham
Date: Sun, 24 May 2026 11:40:44 +0530
Subject: [PATCH 28/35] refactor(faucet): pull slider bounds from /status, drop
default TAPS_URL
---
app/faucet/FaucetClient.tsx | 47 ++++++++++++++++---------------------
server/api/routes/faucet.js | 37 ++++++++++++-----------------
2 files changed, 35 insertions(+), 49 deletions(-)
diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx
index dcbbfa4..a529c81 100644
--- a/app/faucet/FaucetClient.tsx
+++ b/app/faucet/FaucetClient.tsx
@@ -12,21 +12,17 @@ import { getApiUrl } from '@/lib/api-config';
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '';
-// 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;
+// First-paint fallbacks until /status lands.
+const FALLBACK_MIN_TAZ = 0.001;
+const FALLBACK_MAX_TAZ = 1;
+const FALLBACK_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;
+// Slider events emit 0.30000000000000004 etc. — snap to the increment.
+function snapToStep(v: number, step: number): number {
+ return Math.round(v / step) * step;
}
-// 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();
}
@@ -35,16 +31,14 @@ interface FaucetStatus {
balanceTaz: number;
maxDispensableTaz: number;
maxSpendTaz: number;
+ minSpendTaz: number;
+ stepTaz: 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;
-// 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());
}
@@ -72,13 +66,14 @@ export default function FaucetClient() {
const [captchaToken, setCaptchaToken] = useState(null);
const turnstileRef = useRef(null);
const { theme, mounted: themeMounted } = useTheme();
- // 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 minTaz = status?.minSpendTaz || FALLBACK_MIN_TAZ;
+ const maxTaz = status?.maxSpendTaz || FALLBACK_MAX_TAZ;
+ const stepTaz = status?.stepTaz || FALLBACK_STEP_TAZ;
+
const lowSpendable =
status != null &&
status.maxSpendTaz > 0 &&
@@ -130,8 +125,6 @@ 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);
setNotice(errorMessage(data));
@@ -249,18 +242,18 @@ 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.
-
- )}
-
diff --git a/server/api/routes/faucet.js b/server/api/routes/faucet.js
index 2e7e762..5b1524c 100644
--- a/server/api/routes/faucet.js
+++ b/server/api/routes/faucet.js
@@ -14,18 +14,21 @@ if (!TAPS_URL) {
console.error('[faucet] TAPS_URL not set — /api/faucet/* will 503');
}
+const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY || '';
+if (!TURNSTILE_SECRET) {
+ console.error('[faucet] TURNSTILE_SECRET_KEY not set — /api/faucet/dispense will 503');
+}
+
function dispenseAmountTaz() {
const raw = parseFloat(process.env.FAUCET_DISPENSE_AMOUNT_TAZ);
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DISPENSE_TAZ;
}
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 });
+ 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', {
@@ -76,7 +79,6 @@ router.get('/api/faucet/status', async (_req, res) => {
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,
});
} catch (err) {
@@ -87,6 +89,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' });
+ 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())) {
From 8d502785f88e5f0556251a27b3f6177710623769 Mon Sep 17 00:00:00 2001
From: Julian Abraham
Date: Sun, 24 May 2026 11:45:52 +0530
Subject: [PATCH 30/35] refactor(faucet): gate slider on /status, drop
hardcoded bound fallbacks
---
app/faucet/FaucetClient.tsx | 47 ++++++++++++++++++-------------------
1 file changed, 23 insertions(+), 24 deletions(-)
diff --git a/app/faucet/FaucetClient.tsx b/app/faucet/FaucetClient.tsx
index 74d438c..c5b5179 100644
--- a/app/faucet/FaucetClient.tsx
+++ b/app/faucet/FaucetClient.tsx
@@ -12,10 +12,6 @@ import { getApiUrl } from '@/lib/api-config';
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '';
-// First-paint fallbacks until /status lands.
-const FALLBACK_MIN_TAZ = 0.001;
-const FALLBACK_MAX_TAZ = 1;
-const FALLBACK_STEP_TAZ = 0.0001;
const DEFAULT_DISPENSE_TAZ = 0.1;
// Slider events emit 0.30000000000000004 etc. — snap to the increment.
@@ -67,10 +63,6 @@ export default function FaucetClient() {
const { theme, mounted: themeMounted } = useTheme();
const captchaMisconfigured = !TURNSTILE_SITE_KEY;
- const minTaz = status?.minSpendTaz || FALLBACK_MIN_TAZ;
- const maxTaz = status?.maxSpendTaz || FALLBACK_MAX_TAZ;
- const stepTaz = status?.stepTaz || FALLBACK_STEP_TAZ;
-
const lowSpendable =
status != null &&
status.maxSpendTaz > 0 &&
@@ -237,21 +229,27 @@ export default function FaucetClient() {
{formatTaz(amountTaz)} TAZ
- 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' && (
-
-
{
- if (manualMode && !wallet.connected && wallet.allWallets.length > 0) {
- setStep('connect');
- setManualMode(false);
- } else {
- setShowWalletPicker(!showWalletPicker);
- }
- }}
- disabled={wallet.switching}
- className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-mono transition-all disabled:opacity-40 ${
- wallet.connected
- ? 'bg-cipher-green/8 hover:bg-glass-6 text-secondary'
- : chainWallets.length === 0
- ? 'text-muted hover:text-muted/80'
- : 'text-muted hover:text-cipher-cyan'
- }`}
- >
- {wallet.connected ? (
- <>
-
- {wallet.walletName} · {wallet.address?.slice(0, 6)}...{wallet.address?.slice(-4)}
- {wallet.address?.slice(0, 4)}...{wallet.address?.slice(-4)}
- >
- ) : (
- <>
-
-
-
- {wallet.switching ? 'Connecting...' : chainWallets.length === 0 ? `No ${selectedToken.chainLabel} wallet detected` : 'Connect'}
- >
- )}
-
-
- {/* Wallet picker dropdown — wallets available */}
- {showWalletPicker && chainWallets.length > 0 && (
-
-
- Select wallet
- {wallet.connected && (
- { wallet.disconnect(); setShowWalletPicker(false); setManualMode(false); setStep('connect'); }}
- className="text-[10px] font-mono text-red-400 hover:text-red-300 transition-colors"
- >
- Disconnect
-
- )}
-
- {chainWallets.map((w) => (
-
connectToWallet(w)}
- className={`w-full flex items-center gap-3 px-3 py-3 transition-colors text-left ${
- wallet.connected && wallet.walletName === w.name
- ? 'bg-glass-6'
- : 'hover:bg-glass-4'
- }`}
- >
-
-
- {wallet.connected && wallet.walletName === w.name && (
-
- )}
-
- ))}
-
- )}
-
- {/* 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;
- })()}
- .
-
-
-
-
- setShowWalletPicker(false)}
- className="w-full py-2 rounded-lg text-[11px] font-mono text-cipher-cyan bg-cipher-cyan/8 hover:bg-cipher-cyan/12 transition-colors"
- >
- Got it
-
-
-
- )}
-
- )}
-
-
-
-
-
- {/* ─── 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 => (
- {
- setWalletError('');
- try {
- await wallet.connect(w);
- } catch (err: any) {
- setWalletError(err.message || 'Connection failed');
- }
- }}
- disabled={wallet.switching}
- className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-glass-3 border border-glass-6 hover:border-cipher-cyan/30 hover:bg-glass-4 transition-all disabled:opacity-50"
- >
-
- {w.name}
-
- ))}
-
-
- ));
- })()}
-
- ) : (
-
-
-
Detecting wallets...
-
- )}
-
- {walletError && (
-
- {walletError}
-
- )}
-
-
- { setManualMode(true); setStep('form'); }}
- className="text-[11px] font-mono text-muted hover:text-cipher-cyan transition-colors"
- >
- Continue without connecting a wallet
-
-
-
- )}
-
- {/* ─── FORM ─── */}
- {step === 'form' && (
-
-
- {/* Step guide */}
-
- 1. Pick asset
- {'>'}
- 2. Amount & addresses
- {'>'}
- 3. Get quote
-
-
- {/* From — asset selector (prominent first row) */}
-
-
You send
-
-
{ setShowTokenPicker(!showTokenPicker); setTokenSearch(''); }}
- className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-glass-3 border border-glass-6 hover:border-cipher-cyan/30 transition-all"
- >
-
-
-
{selectedToken.token}
-
{selectedToken.chainLabel}
-
- Change
-
-
-
-
-
- {/* 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 ? (
-
- ) : filteredTokens.length === 0 ? (
-
No tokens found
- ) : (
- filteredTokens.map(t => (
-
{
- setSelectedToken(t);
- setAmount('');
- setShowTokenPicker(false);
- setTokenSearch('');
- setShowWalletPicker(false);
- const newWallets = wallet.getWalletsForChain(t.chain);
- const sameType = newWallets.some(w => w.type === wallet.walletType);
- if (wallet.connected && !sameType) wallet.disconnect();
- }}
- className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${
- selectedToken.id === t.id ? 'bg-glass-6' : 'hover:bg-glass-3'
- }`}
- >
-
-
-
{t.token}
-
{t.chainLabel}
-
- {selectedToken.id === t.id && (
-
-
-
- )}
-
- ))
- )}
-
-
- >
- )}
-
-
-
- {/* Amount input (own row) */}
-
-
-
Amount
- {wallet.connected && nativeBalance && (
-
-
- {parseFloat(nativeBalance).toLocaleString(undefined, { maximumFractionDigits: 4 })} {selectedToken.token}
-
- setAmount(String(parseFloat(nativeBalance) * 0.5))}
- className="text-[10px] font-mono text-cipher-cyan hover:text-cipher-cyan/80 transition-colors"
- >
- 50%
-
- setAmount(nativeBalance)}
- className="text-[10px] font-mono text-cipher-cyan hover:text-cipher-cyan/80 transition-colors"
- >
- MAX
-
-
- )}
-
-
-
{
- 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 (
- setAmount(String(rec.sourceAmount))}
- className={`group flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-mono transition-all border ${
- label === 'high'
- ? 'border-cipher-green/30 text-cipher-green hover:bg-cipher-green/10'
- : label === 'medium'
- ? 'border-cipher-yellow/30 text-cipher-yellow hover:bg-cipher-yellow/10'
- : 'border-glass-12 text-muted hover:bg-glass-6'
- }`}
- title={`≈${rec.amountZec} ZEC · ${rec.txCount} shielding txs · ${rec.chainSwapCount || 0} ${selectedToken.chain.toUpperCase()} swaps`}
- >
- {formatRecAmount(rec.sourceAmount!, token)} {token}
-
- );
- })}
-
- );
- })()}
-
-
- {/* Arrow divider */}
-
-
- {/* To section */}
-
-
Your ZEC address
-
-
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
-
-
setShowSlippage(!showSlippage)} className="text-[10px] font-mono text-muted hover:text-secondary transition-colors">
- {showSlippage ? 'Less' : 'More options'}
-
-
- ) : (
-
-
-
- Your {selectedToken.chainLabel} address
-
- setShowSlippage(!showSlippage)} className="text-[10px] font-mono text-muted hover:text-secondary transition-colors">
- {showSlippage ? 'Less' : 'More options'}
-
-
-
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 && (
-
-
Slippage
-
- {[{ label: '0.5%', value: 50 }, { label: '1%', value: 100 }, { label: '2%', value: 200 }].map(opt => (
- setSlippage(opt.value)}
- className={`px-3 py-1.5 rounded-lg text-xs font-mono transition-all ${
- slippage === opt.value
- ? 'bg-cipher-cyan/10 text-cipher-cyan'
- : 'text-muted hover:text-secondary bg-glass-2 hover:bg-glass-4'
- }`}
- >
- {opt.label}
-
- ))}
-
-
- )}
-
- {/* Error */}
- {(error || walletError) && (
-
- {error || walletError}
-
- )}
-
- {/* CTA — smart contextual button */}
- {(() => {
- const needsWallet = !wallet.connected && chainWallets.length > 0 && amount && zecAddress && !zecAddrError && !effectiveRefundAddress;
- return (
-
setShowWalletPicker(true) : getQuote}
- disabled={needsWallet ? false : ctaDisabled}
- className={`w-full py-3.5 rounded-lg font-mono font-semibold text-sm transition-all duration-150 ${
- needsWallet
- ? 'bg-cipher-cyan-bright text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-cyan-rgb)_/_0.25)] hover:-translate-y-[1px] active:translate-y-0 active:shadow-none'
- : 'disabled:opacity-30 disabled:cursor-not-allowed bg-cipher-cyan-bright text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-cyan-rgb)_/_0.25)] hover:-translate-y-[1px] active:translate-y-0 active:shadow-none'
- }`}
- >
- {ctaText}
-
- );
- })()}
-
- {/* 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')}
-
-
- )}
-
-
-
- Back
-
- setStep('waiting')}
- className="flex-[2] py-3 rounded-lg font-mono font-semibold text-sm bg-cipher-green text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-green-rgb)_/_0.2)] hover:-translate-y-[1px] active:translate-y-0 transition-all"
- >
- {wallet.connected ? 'Confirm & Send' : 'Confirm Swap'}
-
-
-
- )}
-
- {/* ─── 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 && (
-
- {sendingTx ? 'Confirm in wallet...' : `Send ${amount} ${selectedToken.token}`}
-
- )}
-
- {txHash && (
-
-
-
-
-
Sent
-
{txHash.slice(0, 8)}...{txHash.slice(-6)}
-
-
-
{ navigator.clipboard.writeText(txHash); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
- className="p-1.5 rounded-md hover:bg-glass-4 transition-colors"
- title="Copy tx hash"
- >
- {copied ? (
-
- ) : (
-
- )}
-
- {CHAIN_EXPLORERS[selectedToken.chain] && (
-
-
-
- )}
-
-
-
- )}
-
- {walletError &&
{walletError}
}
-
- {/* Manual deposit section */}
- {(!wallet.connected || (wallet.connected && !txHash)) && (
- <>
- {wallet.connected && (
-
- )}
-
-
-
Deposit address
-
-
{depositAddress}
-
- {copied ? (
-
- ) : (
-
- )}
-
-
-
- >
- )}
-
- {txHash ? (
-
-
- Swap will complete automatically
-
-
- New Swap →
-
-
- ) : (
-
- Cancel
-
- )}
-
- )}
-
- {/* ─── COMPLETE ─── */}
- {step === 'complete' && (
-
-
-
-
-
-
Swap Complete
-
{estimatedZec} ZEC sent to your address
-
-
-
-
- New Swap
-
-
- )}
-
- {/* ─── ERROR ─── */}
- {step === 'error' && (
-
-
-
-
-
-
Swap Failed
-
{error}
-
-
-
-
- Try Again
-
-
- )}
-
-
-
-
- {/* ─── 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 (
-
rec.sourceAmount ? setAmount(String(rec.sourceAmount)) : undefined}
- className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-glass-3 transition-all group text-left"
- title={`≈${rec.amountZec} ZEC · ${rec.txCount} shielding txs`}
- >
-
-
- {formatRecAmount(displayAmount, displayToken)} {displayToken}
-
-
- {label}
-
-
-
- {rec.chainSwapCount ? `${rec.chainSwapCount} swaps` : `${rec.txCount} txs`}
-
-
- );
- })}
-
- {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 (
+
+ );
+ }
+
+ 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' && (
+
+
{
+ if (manualMode && !wallet.connected && wallet.allWallets.length > 0) {
+ setStep('connect');
+ setManualMode(false);
+ } else {
+ setShowWalletPicker(!showWalletPicker);
+ }
+ }}
+ disabled={wallet.switching}
+ className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-mono transition-all disabled:opacity-40 ${
+ wallet.connected
+ ? 'bg-cipher-green/8 hover:bg-glass-6 text-secondary'
+ : chainWallets.length === 0
+ ? 'text-muted hover:text-muted/80'
+ : 'text-muted hover:text-cipher-cyan'
+ }`}
+ >
+ {wallet.connected ? (
+ <>
+
+ {wallet.walletName} · {wallet.address?.slice(0, 6)}...{wallet.address?.slice(-4)}
+ {wallet.address?.slice(0, 4)}...{wallet.address?.slice(-4)}
+ >
+ ) : (
+ <>
+
+
+
+ {wallet.switching ? 'Connecting...' : chainWallets.length === 0 ? `No ${selectedToken.chainLabel} wallet detected` : 'Connect'}
+ >
+ )}
+
+
+ {/* Wallet picker dropdown — wallets available */}
+ {showWalletPicker && chainWallets.length > 0 && (
+
+
+ Select wallet
+ {wallet.connected && (
+ { wallet.disconnect(); setShowWalletPicker(false); setManualMode(false); setStep('connect'); }}
+ className="text-[10px] font-mono text-red-400 hover:text-red-300 transition-colors"
+ >
+ Disconnect
+
+ )}
+
+ {chainWallets.map((w) => (
+
connectToWallet(w)}
+ className={`w-full flex items-center gap-3 px-3 py-3 transition-colors text-left ${
+ wallet.connected && wallet.walletName === w.name
+ ? 'bg-glass-6'
+ : 'hover:bg-glass-4'
+ }`}
+ >
+
+
+ {wallet.connected && wallet.walletName === w.name && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* 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;
+ })()}
+ .
+
+
+
+
+ setShowWalletPicker(false)}
+ className="w-full py-2 rounded-lg text-[11px] font-mono text-cipher-cyan bg-cipher-cyan/8 hover:bg-cipher-cyan/12 transition-colors"
+ >
+ Got it
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+ {/* ─── 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 => (
+ {
+ setWalletError('');
+ try {
+ await wallet.connect(w);
+ } catch (err: any) {
+ setWalletError(err.message || 'Connection failed');
+ }
+ }}
+ disabled={wallet.switching}
+ className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-glass-3 border border-glass-6 hover:border-cipher-cyan/30 hover:bg-glass-4 transition-all disabled:opacity-50"
+ >
+
+ {w.name}
+
+ ))}
+
+
+ ));
+ })()}
+
+ ) : (
+
+
+
Detecting wallets...
+
+ )}
+
+ {walletError && (
+
+ {walletError}
+
+ )}
+
+
+ { setManualMode(true); setStep('form'); }}
+ className="text-[11px] font-mono text-muted hover:text-cipher-cyan transition-colors"
+ >
+ Continue without connecting a wallet
+
+
+
+ )}
+
+ {/* ─── FORM ─── */}
+ {step === 'form' && (
+
+
+ {/* Step guide */}
+
+ 1. Pick asset
+ {'>'}
+ 2. Amount & addresses
+ {'>'}
+ 3. Get quote
+
+
+ {/* From — asset selector (prominent first row) */}
+
+
You send
+
+
{ setShowTokenPicker(!showTokenPicker); setTokenSearch(''); }}
+ className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-glass-3 border border-glass-6 hover:border-cipher-cyan/30 transition-all"
+ >
+
+
+
{selectedToken.token}
+
{selectedToken.chainLabel}
+
+ Change
+
+
+
+
+
+ {/* 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 ? (
+
+ ) : filteredTokens.length === 0 ? (
+
No tokens found
+ ) : (
+ filteredTokens.map(t => (
+
{
+ setSelectedToken(t);
+ setAmount('');
+ setShowTokenPicker(false);
+ setTokenSearch('');
+ setShowWalletPicker(false);
+ const newWallets = wallet.getWalletsForChain(t.chain);
+ const sameType = newWallets.some(w => w.type === wallet.walletType);
+ if (wallet.connected && !sameType) wallet.disconnect();
+ }}
+ className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${
+ selectedToken.id === t.id ? 'bg-glass-6' : 'hover:bg-glass-3'
+ }`}
+ >
+
+
+
{t.token}
+
{t.chainLabel}
+
+ {selectedToken.id === t.id && (
+
+
+
+ )}
+
+ ))
+ )}
+
+
+ >
+ )}
+
+
+
+ {/* Amount input (own row) */}
+
+
+
Amount
+ {wallet.connected && nativeBalance && (
+
+
+ {parseFloat(nativeBalance).toLocaleString(undefined, { maximumFractionDigits: 4 })} {selectedToken.token}
+
+ setAmount(String(parseFloat(nativeBalance) * 0.5))}
+ className="text-[10px] font-mono text-cipher-cyan hover:text-cipher-cyan/80 transition-colors"
+ >
+ 50%
+
+ setAmount(nativeBalance)}
+ className="text-[10px] font-mono text-cipher-cyan hover:text-cipher-cyan/80 transition-colors"
+ >
+ MAX
+
+
+ )}
+
+
+
{
+ 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 (
+ setAmount(String(rec.sourceAmount))}
+ className={`group flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-mono transition-all border ${
+ label === 'high'
+ ? 'border-cipher-green/30 text-cipher-green hover:bg-cipher-green/10'
+ : label === 'medium'
+ ? 'border-cipher-yellow/30 text-cipher-yellow hover:bg-cipher-yellow/10'
+ : 'border-glass-12 text-muted hover:bg-glass-6'
+ }`}
+ title={`≈${rec.amountZec} ZEC · ${rec.txCount} shielding txs · ${rec.chainSwapCount || 0} ${selectedToken.chain.toUpperCase()} swaps`}
+ >
+ {formatRecAmount(rec.sourceAmount!, token)} {token}
+
+ );
+ })}
+
+ );
+ })()}
+
+
+ {/* Arrow divider */}
+
+
+ {/* To section */}
+
+
Your ZEC address
+
+
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
+
+
setShowSlippage(!showSlippage)} className="text-[10px] font-mono text-muted hover:text-secondary transition-colors">
+ {showSlippage ? 'Less' : 'More options'}
+
+
+ ) : (
+
+
+
+ Your {selectedToken.chainLabel} address
+
+ setShowSlippage(!showSlippage)} className="text-[10px] font-mono text-muted hover:text-secondary transition-colors">
+ {showSlippage ? 'Less' : 'More options'}
+
+
+
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 && (
+
+
Slippage
+
+ {[{ label: '0.5%', value: 50 }, { label: '1%', value: 100 }, { label: '2%', value: 200 }].map(opt => (
+ setSlippage(opt.value)}
+ className={`px-3 py-1.5 rounded-lg text-xs font-mono transition-all ${
+ slippage === opt.value
+ ? 'bg-cipher-cyan/10 text-cipher-cyan'
+ : 'text-muted hover:text-secondary bg-glass-2 hover:bg-glass-4'
+ }`}
+ >
+ {opt.label}
+
+ ))}
+
+
+ )}
+
+ {/* Error */}
+ {(error || walletError) && (
+
+ {error || walletError}
+
+ )}
+
+ {/* CTA — smart contextual button */}
+ {(() => {
+ const needsWallet = !wallet.connected && chainWallets.length > 0 && amount && zecAddress && !zecAddrError && !effectiveRefundAddress;
+ return (
+
setShowWalletPicker(true) : getQuote}
+ disabled={needsWallet ? false : ctaDisabled}
+ className={`w-full py-3.5 rounded-lg font-mono font-semibold text-sm transition-all duration-150 ${
+ needsWallet
+ ? 'bg-cipher-cyan-bright text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-cyan-rgb)_/_0.25)] hover:-translate-y-[1px] active:translate-y-0 active:shadow-none'
+ : 'disabled:opacity-30 disabled:cursor-not-allowed bg-cipher-cyan-bright text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-cyan-rgb)_/_0.25)] hover:-translate-y-[1px] active:translate-y-0 active:shadow-none'
+ }`}
+ >
+ {ctaText}
+
+ );
+ })()}
+
+ {/* 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')}
+
+
+ )}
+
+
+
+ Back
+
+ setStep('waiting')}
+ className="flex-[2] py-3 rounded-lg font-mono font-semibold text-sm bg-cipher-green text-[#08090F] hover:shadow-[0_4px_20px_rgb(var(--color-green-rgb)_/_0.2)] hover:-translate-y-[1px] active:translate-y-0 transition-all"
+ >
+ {wallet.connected ? 'Confirm & Send' : 'Confirm Swap'}
+
+
+
+ )}
+
+ {/* ─── 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 && (
+
+ {sendingTx ? 'Confirm in wallet...' : `Send ${amount} ${selectedToken.token}`}
+
+ )}
+
+ {txHash && (
+
+
+
+
+
Sent
+
{txHash.slice(0, 8)}...{txHash.slice(-6)}
+
+
+
{ navigator.clipboard.writeText(txHash); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
+ className="p-1.5 rounded-md hover:bg-glass-4 transition-colors"
+ title="Copy tx hash"
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ {CHAIN_EXPLORERS[selectedToken.chain] && (
+
+
+
+ )}
+
+
+
+ )}
+
+ {walletError &&
{walletError}
}
+
+ {/* Manual deposit section */}
+ {(!wallet.connected || (wallet.connected && !txHash)) && (
+ <>
+ {wallet.connected && (
+
+ )}
+
+
+
Deposit address
+
+
{depositAddress}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
+ )}
+
+ {txHash ? (
+
+
+ Swap will complete automatically
+
+
+ New Swap →
+
+
+ ) : (
+
+ Cancel
+
+ )}
+
+ )}
+
+ {/* ─── COMPLETE ─── */}
+ {step === 'complete' && (
+
+
+
+
+
+
Swap Complete
+
{estimatedZec} ZEC sent to your address
+
+
+
+
+ New Swap
+
+
+ )}
+
+ {/* ─── ERROR ─── */}
+ {step === 'error' && (
+
+
+
+
+
+
Swap Failed
+
{error}
+
+
+
+
+ Try Again
+
+
+ )}
+
+
+
+
+ {/* ─── 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 (
+
rec.sourceAmount ? setAmount(String(rec.sourceAmount)) : undefined}
+ className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-glass-3 transition-all group text-left"
+ title={`≈${rec.amountZec} ZEC · ${rec.txCount} shielding txs`}
+ >
+
+
+ {formatRecAmount(displayAmount, displayToken)} {displayToken}
+
+
+ {label}
+
+
+
+ {rec.chainSwapCount ? `${rec.chainSwapCount} swaps` : `${rec.txCount} txs`}
+
+
+ );
+ })}
+
+ {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 (
+
+ );
+ }
+
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 };