diff --git a/ZNS_INTEGRATION.md b/ZNS_INTEGRATION.md new file mode 100644 index 0000000..ce787da --- /dev/null +++ b/ZNS_INTEGRATION.md @@ -0,0 +1,228 @@ +# ZNS Integration — Zcash Name Service for CipherScan + +## Overview + +Add human-readable name resolution to CipherScan via the Zcash Name Service (ZNS). Users can type a name like `alice` in the search bar and land on a name detail page showing the resolved address, registration transaction, event history, and marketplace status — or see that it's available with pricing info. + +ZNS maps names to Zcash Unified Addresses via shielded Orchard memos on-chain. An indexer watches the chain, trial-decrypts notes using the registry's viewing key, and serves results over a JSON-RPC API. CipherScan talks to that API through the official TypeScript SDK. + +**Testnet only for now.** Mainnet support is a config change (new indexer URL + UFVK) — the code is network-aware from day one. + +--- + +## What we're building + +### 1. Search bar — name resolution + +**The killer feature.** When a user types a query into the search bar: + +- If the query matches ZNS name rules (1-62 chars, lowercase alphanumeric + hyphens), resolve it via the SDK's `resolve()` in parallel with existing block/tx/address detection. +- If a name is found, show a **"ZNS Name"** suggestion in the dropdown with the name and a truncated address. +- Clicking it navigates to `/name/[name]`. +- Existing search behavior (block heights, tx hashes, addresses) is unchanged — ZNS is additive. + +**Search priority:** Address > Block height > Tx hash > Block hash > ZNS name > Address label. Names should not shadow any existing result type. + +### 2. `/name/[name]` — name detail page + +A detail page for any ZNS name, whether registered or not. + +**If the name is registered:** + +- **Header:** The name in large text, with a "Registered" badge. +- **Resolved address:** Full unified address, copyable, with a link to `/address/[address]`. +- **Registration details:** + - Transaction ID (links to `/tx/[txid]`) + - Block height (links to `/block/[height]`) + - Current nonce +- **Marketplace status:** If listed for sale, show the price in ZEC, listing tx, and listing block height. If not listed, show "Not for sale". +- **Event history:** Timeline of all actions (CLAIM, UPDATE, LIST, DELIST, BUY) from `events({ name })`, each with: + - Action badge (color-coded) + - Transaction link + - Block height + - Relevant data (new address for UPDATE, price for LIST/BUY, etc.) + - Paginated if history is long + +**If the name is NOT registered (availability checker):** + +- **Header:** The name, with an "Available" badge in green. +- **Claim cost:** Based on name length, pulled from SDK's `claimCost()`. +- **Pricing table:** Full pricing breakdown (1 char = 6 ZEC → 7+ chars = 0.25 ZEC). +- **How to register:** Brief explanation of the claim process — send a shielded transaction with a `ZNS:CLAIM::` memo to the registry address. + +**If the name is invalid** (fails `isValidName()`): + +- Show "Invalid name" with the naming rules (1-62 chars, lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens). + +### 3. ZNS stats + +Displayed on the `/name/[name]` page (sidebar or header section): + +- **Registered names** count +- **Listed for sale** count +- **Indexer synced height** + +From the SDK's `status()` method. Cached — this data changes slowly. + +--- + +## What we're NOT building (this PR) + +- Reverse resolving on address/tx pages (adds latency, low hit rate) +- Claim/buy/list/delist actions from the UI (wallet territory) +- Dedicated `/names` browse page (unclear value without more registered names) +- Homepage changes +- WebSocket live feed of new registrations + +--- + +## Technical approach + +### SDK dependency + +```json +"zcashname-sdk": "github:zcashme/ZNS#main&path=sdk/typescript" +``` + +Zero dependencies, ~7 small files. Provides typed methods for `resolve`, `listings`, `status`, `events`, `isAvailable`, plus validation and pricing utilities. + +### Architecture change — server-side API proxy (Phase 1) + +> **What changed:** The original integration had the browser calling the ZNS indexer directly via the SDK inside a `useEffect`. This was wrong. It caused CORS failures (the indexer is a JSON-RPC service, not a web app — it doesn't serve CORS headers) and broke the pattern every other data source in CipherScan follows. +> +> **The fix:** The SDK now lives entirely server-side. Names are a first-class resource in CipherScan's API — same as blocks, transactions, and addresses. +> +> **Before:** `browser → SDK → indexer` (CORS blocked) +> **After:** `browser → /api/name/[name] → SDK → indexer` (server-to-server, no CORS) + +#### Files changed + +``` +app/api/name/[name]/route.ts — NEW: GET /api/name/[name] — resolve a name +app/api/name/[name]/events/route.ts — NEW: GET /api/name/[name]/events — event history +lib/zns.ts — CHANGED: server-only SDK client, per-network env vars +app/name/[name]/page.tsx — CHANGED: fetches from /api/name/ instead of SDK +app/docs/endpoints.ts — CHANGED: new "Names" category +components/SearchBar.tsx — CHANGED: removed isZnsEnabled() guard +``` + +#### API routes + +Names are a resource, not an action. The API follows the same pattern as every other resource: + +``` +GET /api/name/:name → registration, address, listing (60s cache) +GET /api/name/:name/events → event history (30s cache) +``` + +Both routes import `getClient()` from `lib/zns.ts`, call the SDK, and pass through the indexer response as-is. Error responses follow CipherScan conventions: 404 for not found, 502 for indexer failures. + +#### `lib/zns.ts` — server-only client + +The SDK singleton is only imported by the API routes. Per-network env var overrides: + +```ts +const ZNS_URLS: Record = { + 'mainnet': process.env.ZNS_MAINNET_URL || 'https://light.zcash.me/zns-mainnet-test', + 'testnet': process.env.ZNS_TESTNET_URL || 'https://light.zcash.me/zns-testnet', + 'crosslink-testnet': process.env.ZNS_TESTNET_URL || 'https://light.zcash.me/zns-testnet', +}; +``` + +#### `app/name/[name]/page.tsx` — client component + +No longer imports the SDK. The `useEffect` fetches from CipherScan's own API: + +```ts +const res = await fetch(`/api/name/${encodeURIComponent(name)}`); +``` + +Events are fetched separately and may fail silently — the page works without them. + +### Search bar integration + +In `SearchBar.tsx`, after existing detection logic: + +```ts +import { isValidName } from 'zcashname-sdk'; + +if (isValidName(trimmedQuery.toLowerCase())) { + router.push(`/name/${encodeURIComponent(trimmedQuery.toLowerCase())}`); +} +``` + +ZNS is enabled on all networks. Name detection is the lowest priority in the search chain: Address > Block height > Tx hash > Block hash > Address label > ZNS name. + +### Name detail page + +`page.tsx` is a `'use client'` component that fetches from `/api/name/` and handles all states (loading / error / registered / not found / invalid). + +`layout.tsx` is a server component that generates SEO metadata. + +Uses existing UI components: `Card`, `Badge`, monospace text for addresses/hashes, `Link` for cross-references to `/tx/` and `/block/`. + +### Caching strategy + +- `resolve` — 60s cache (names change rarely, but can be updated) +- `status` — 60s cache (counts change slowly) +- `events` — no cache (fresh per page load) + +### Error handling + +- ZNS indexer down: Search bar silently skips ZNS results (no error shown). Name page shows "Unable to reach ZNS indexer" with retry button. +- Network mismatch: SDK's UIVK verification catches this at client creation time (server-side). +- Invalid names: Caught client-side by `isValidName()` before any network call. + +--- + +## Design notes + +### Colors & badges + +- **Registered** name badge: `cipher-cyan` (matches primary accent) +- **Available** name badge: `cipher-green` +- **For sale** listing badge: `cipher-yellow` +- **Invalid** name: `cipher-orange` or muted +- Event action badges: CLAIM=green, LIST=yellow, BUY=cyan, UPDATE=purple, DELIST=muted + +### Typography + +- Name displayed in large monospace: `font-mono text-2xl` +- Addresses in monospace with truncation + copy button +- Prices formatted as ZEC with 2-8 decimal places + +### Responsive + +- Single column on mobile +- Event history as stacked cards on mobile, table-like on desktop +- Pricing table stays readable at all breakpoints + +--- + +## Network support + +The integration is network-aware from day one: + +| Network | ZNS URL | Env Override | Status | +|---------|---------|--------------|--------| +| Testnet | `https://light.zcash.me/zns-testnet` | `ZNS_TESTNET_URL` | Active | +| Mainnet | `https://light.zcash.me/zns-mainnet-test` | `ZNS_MAINNET_URL` | Active | +| Crosslink | `https://light.zcash.me/zns-testnet` | `ZNS_TESTNET_URL` | Same testnet indexer | + +Env vars are server-only. The SDK handles UIVK verification per-network. + +--- + +## Testing checklist + +- [ ] Search "alice" → ZNS suggestion appears → navigates to `/name/alice` +- [ ] Search a block height → ZNS does not interfere +- [ ] Search an invalid name (uppercase, double hyphens) → no ZNS suggestion +- [ ] `/name/alice` with registered name → shows address, tx, events +- [ ] `/name/nonexistent` → shows "Available" + pricing +- [ ] `/name/INVALID` → shows naming rules +- [ ] `/name/alice` when indexer is down → graceful error +- [ ] Name with active listing → shows marketplace info +- [ ] Event history pagination works +- [ ] All links to `/tx/` and `/block/` resolve correctly +- [ ] Mobile layout renders cleanly diff --git a/app/api/name/[name]/events/route.ts b/app/api/name/[name]/events/route.ts new file mode 100644 index 0000000..6e08482 --- /dev/null +++ b/app/api/name/[name]/events/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getClient } from '@/lib/zns'; + +/** + * API Route: Get event history for a ZNS name + * GET /api/name/[name]/events + * + * CACHE STRATEGY: + * - Events: 30 seconds cache (new events are infrequent but should appear promptly) + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + try { + const { name } = await params; + const zns = await getClient(); + const result = await zns.events({ name }); + + return NextResponse.json(result, { + headers: { + 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=60', + 'CDN-Cache-Control': 'public, s-maxage=30', + 'Vercel-CDN-Cache-Control': 'public, s-maxage=30', + 'X-Cache-Duration': '30s', + 'X-Data-Source': 'zns-indexer', + }, + }); + } catch (error) { + console.error('Error fetching name events:', error); + return NextResponse.json( + { error: 'Failed to fetch name events' }, + { status: 502 } + ); + } +} diff --git a/app/api/name/[name]/route.ts b/app/api/name/[name]/route.ts new file mode 100644 index 0000000..928581e --- /dev/null +++ b/app/api/name/[name]/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getClient } from '@/lib/zns'; + +/** + * API Route: Get name by ZNS lookup + * GET /api/name/[name] + * + * CACHE STRATEGY: + * - Registered names: 60 seconds cache (on-chain, rarely changes) + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + try { + const { name } = await params; + const zns = await getClient(); + const result = await zns.resolve(name); + + if (!result) { + return NextResponse.json( + { error: 'Name not found' }, + { status: 404 } + ); + } + + return NextResponse.json(result, { + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120', + 'CDN-Cache-Control': 'public, s-maxage=60', + 'Vercel-CDN-Cache-Control': 'public, s-maxage=60', + 'X-Cache-Duration': '60s', + 'X-Data-Source': 'zns-indexer', + }, + }); + } catch (error) { + console.error('Error fetching name:', error); + return NextResponse.json( + { error: 'Failed to fetch name' }, + { status: 502 } + ); + } +} diff --git a/app/docs/endpoints.ts b/app/docs/endpoints.ts index e43d584..6e09807 100644 --- a/app/docs/endpoints.ts +++ b/app/docs/endpoints.ts @@ -395,6 +395,76 @@ export const getEndpoints = (baseUrl: string): ApiEndpoint[] => [ }, note: 'Zebra 3.0+ health endpoints available' } + }, + + // ============================================================================ + // NAMES (ZNS — Zcash Name Service) + // ============================================================================ + { + id: 'name-resolve', + category: 'Names', + method: 'GET', + path: '/api/name/:name', + description: 'Resolve a ZNS name to its registration, address, and marketplace listing', + params: [ + { name: 'name', type: 'string', description: 'ZNS name (e.g., zechariah)' } + ], + example: `curl ${baseUrl}/api/name/zechariah`, + response: { + name: 'zechariah', + address: 'utest104mqp98n7awydj5ja3vux...', + txid: '6f6fbbce9c597f9ae7d5877e...', + height: 3932504, + nonce: 1, + signature: '8PEjZeZDg/v7xkS/...', + listing: { + name: 'zechariah', + price: 10000000000, + txid: '7ac64ad08dc8a85d...', + height: 3932507, + signature: 'eaBfFGlJeGAuL6S6...' + } + }, + note: 'Returns 404 if the name is not registered. Listing is null if the name is not for sale. Price is in zatoshi (1 ZEC = 100,000,000 zatoshi).' + }, + { + id: 'name-events', + category: 'Names', + method: 'GET', + path: '/api/name/:name/events', + description: 'Get the event history for a ZNS name (claims, listings, sales, updates)', + params: [ + { name: 'name', type: 'string', description: 'ZNS name (e.g., zechariah)' } + ], + example: `curl ${baseUrl}/api/name/zechariah/events`, + response: { + events: [ + { + id: 7, + name: 'zechariah', + action: 'LIST', + txid: '7ac64ad08dc8a85d...', + height: 3932507, + ua: 'utest104mqp98n7awydj5ja3vux...', + price: 10000000000, + nonce: 1, + signature: 'eaBfFGlJeGAuL6S6...' + }, + { + id: 6, + name: 'zechariah', + action: 'CLAIM', + txid: '6f6fbbce9c597f9ae7d5877e...', + height: 3932504, + ua: 'utest104mqp98n7awydj5ja3vux...', + price: null, + nonce: null, + signature: '8PEjZeZDg/v7xkS/...' + } + ], + total: 2 + }, + note: 'Actions: CLAIM, LIST, DELIST, UPDATE, BUY. Returns events for names that were registered and later released too.' } ]; diff --git a/app/name/[name]/layout.tsx b/app/name/[name]/layout.tsx new file mode 100644 index 0000000..d9e0243 --- /dev/null +++ b/app/name/[name]/layout.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from 'next'; +import { getBaseUrl } from '@/lib/seo'; + +type Props = { + params: Promise<{ name: string }>; + children: React.ReactNode; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { name } = await params; + const baseUrl = getBaseUrl(); + + const title = `${name} — ZNS Name | CipherScan`; + const description = `View Zcash Name Service (ZNS) details for "${name}" — resolved address, registration info, event history, and marketplace status on CipherScan.`; + + return { + title, + description, + openGraph: { + title, + description, + url: `${baseUrl}/name/${name}`, + siteName: 'CipherScan', + type: 'website', + }, + twitter: { + card: 'summary', + title, + description, + }, + alternates: { + canonical: `${baseUrl}/name/${name}`, + }, + }; +} + +export default function NameLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/app/name/[name]/page.tsx b/app/name/[name]/page.tsx new file mode 100644 index 0000000..7b612bd --- /dev/null +++ b/app/name/[name]/page.tsx @@ -0,0 +1,274 @@ +'use client'; + +import { useParams, notFound } from 'next/navigation'; +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { isValidName } from 'zcashname-sdk'; +import { Badge } from '@/components/ui/Badge'; +import { Card } from '@/components/ui/Card'; + +const EVENT_COLORS: Record = { + CLAIM: 'green', + LIST: 'orange', + BUY: 'cyan', + UPDATE: 'purple', + DELIST: 'muted', +}; + +function truncateAddress(addr: string): string { + if (addr.length <= 30) return addr; + return `${addr.slice(0, 20)}...${addr.slice(-8)}`; +} + +export default function NamePage() { + const params = useParams(); + const name = params.name as string; + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [resolved, setResolved] = useState(null); + const [events, setEvents] = useState(null); + const [copiedText, setCopiedText] = useState(null); + + const valid = isValidName(name); + + const copyToClipboard = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedText(label); + setTimeout(() => setCopiedText(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const CopyButton = ({ text, label }: { text: string; label: string }) => ( + + ); + + useEffect(() => { + if (!valid) { + setLoading(false); + return; + } + + let cancelled = false; + + async function fetchData() { + try { + const res = await fetch(`/api/name/${encodeURIComponent(name)}`); + + if (cancelled) return; + + if (res.status === 404) { + setLoading(false); + return; + } + + if (!res.ok) { + setError('Unable to reach ZNS indexer. Please try again.'); + setLoading(false); + return; + } + + setResolved(await res.json()); + + // Events may fail — not critical + try { + const eventsRes = await fetch(`/api/name/${encodeURIComponent(name)}/events`); + if (!cancelled && eventsRes.ok) { + setEvents(await eventsRes.json()); + } + } catch {} + } catch (err) { + if (cancelled) return; + setError('Unable to reach ZNS indexer. Please try again.'); + } finally { + if (!cancelled) setLoading(false); + } + } + + fetchData(); + return () => { cancelled = true; }; + }, [name, valid]); + + // Loading + if (loading) { + return ( +
+
+
+
+
+
+
+ ); + } + + // Error + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + // Invalid name → 404 + if (!valid) notFound(); + + // Name not registered → nothing on-chain to show + if (!resolved) notFound(); + + // Registered name + return ( +
+
+

{name}

+ REGISTERED +
+ + {/* Resolved Address */} + +

+ {'>'} + RESOLVED_ADDRESS +

+
+ + {truncateAddress(resolved.address)} + + +
+
+ + {/* Registration Details */} + +

+ {'>'} + REGISTRATION +

+
+
+ Transaction +
+ + {resolved.txid.slice(0, 16)}... + + +
+
+
+ Block + + #{resolved.height.toLocaleString()} + +
+
+ Nonce + {resolved.nonce} +
+
+
+ + {/* Marketplace */} + +

+ {'>'} + MARKETPLACE +

+ {resolved.listing ? ( +
+
+ FOR SALE + + {(resolved.listing.price / 1e8).toFixed(2)} ZEC + +
+
+ Listing Tx +
+ + {resolved.listing.txid.slice(0, 16)}... + + +
+
+
+ Block + + #{resolved.listing.height.toLocaleString()} + +
+
+ ) : ( +

Not for sale

+ )} +
+ + {/* Event History */} + {events && events.events.length > 0 && ( + +

+ {'>'} + EVENT_HISTORY +

+
+ {events.events.map((event: any) => ( +
+ + {event.action} + + + {event.txid.slice(0, 12)}... + + + #{event.height.toLocaleString()} + + {event.action === 'UPDATE' && event.ua && ( + + → {truncateAddress(event.ua)} + + )} + {(event.action === 'LIST' || event.action === 'BUY') && event.price != null && ( + + {(event.price / 1e8).toFixed(2)} ZEC + + )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx index f9b8469..11a8d26 100644 --- a/components/SearchBar.tsx +++ b/components/SearchBar.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { detectAddressType } from '@/lib/zcash'; import { findAddressByLabel, searchAddressesByLabel, fetchOfficialLabels } from '@/lib/address-labels'; +import { isValidName } from 'zcashname-sdk'; interface SearchBarProps { compact?: boolean; // Mode compact pour la navbar @@ -138,6 +139,8 @@ export function SearchBar({ compact = false }: SearchBarProps) { const addressByLabel = findAddressByLabel(trimmedQuery); if (addressByLabel) { router.push(`/address/${encodeURIComponent(addressByLabel)}`); + } else if (isValidName(trimmedQuery.toLowerCase())) { + router.push(`/name/${encodeURIComponent(trimmedQuery.toLowerCase())}`); } else { console.warn('No matching address, transaction, or label found'); } @@ -264,7 +267,7 @@ export function SearchBar({ compact = false }: SearchBarProps) { onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyDown} onFocus={() => query.length >= 2 && suggestions.length > 0 && setShowSuggestions(true)} - placeholder="Search address, tx hash, block number..." + placeholder="Search address, tx, block, or name..." className="w-full pl-8 pr-3 py-2 text-sm search-input" /> @@ -303,7 +306,7 @@ export function SearchBar({ compact = false }: SearchBarProps) { if (query.length >= 2 && suggestions.length > 0) setShowSuggestions(true); }} onBlur={() => setIsFocused(false)} - placeholder="Search address, tx, block, or label..." + placeholder="Search address, tx, block, or name..." className={`w-full pl-10 sm:pl-12 pr-28 sm:pr-36 py-4 sm:py-5 text-sm sm:text-base font-mono search-input-hero border-2 rounded-xl text-primary placeholder:text-muted transition-all duration-300 diff --git a/lib/zns.ts b/lib/zns.ts new file mode 100644 index 0000000..8a8eaff --- /dev/null +++ b/lib/zns.ts @@ -0,0 +1,20 @@ +import { createClient, type ZNSClient } from 'zcashname-sdk'; +import { NETWORK } from './api-config'; +import type { Network } from './api-config'; + +// Server-side only (used by API routes) + +const ZNS_URLS: Record = { + 'mainnet': process.env.ZNS_MAINNET_URL || 'https://light.zcash.me/zns-mainnet-test', + 'testnet': process.env.ZNS_TESTNET_URL || 'https://light.zcash.me/zns-testnet', + 'crosslink-testnet': process.env.ZNS_TESTNET_URL || 'https://light.zcash.me/zns-testnet', +}; + +let client: ZNSClient | null = null; + +export async function getClient(): Promise { + if (!client) { + client = await createClient(ZNS_URLS[NETWORK]); + } + return client; +} diff --git a/package-lock.json b/package-lock.json index c577928..163f924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "topojson-client": "^3.1.0", "typescript": "^5.7.2", "viem": "^2.47.0", + "zcashname-sdk": "^0.3.0", "zod": "^4.3.6" } }, @@ -48,6 +49,17 @@ "extraneous": true, "license": "MIT OR Apache-2.0" }, + "../ZNS/sdk/typescript": { + "name": "zcashname-sdk", + "version": "0.2.0", + "extraneous": true, + "license": "MIT", + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.4.0", + "vitest": "^2.0.0" + } + }, "node_modules/@adraffy/ens-normalize": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", @@ -4946,6 +4958,12 @@ "node": ">=0.4" } }, + "node_modules/zcashname-sdk": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/zcashname-sdk/-/zcashname-sdk-0.3.0.tgz", + "integrity": "sha512-dT4GIOqQoJoPkjgG+rzGKx/sfY9V1xM7/x8RaqaWE2FWQf/eWza+Az5R3kXQzIQYRnbgQ8tMsCPpkYLKtrhw5Q==", + "license": "MIT" + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index a4ef06e..d4e8355 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "topojson-client": "^3.1.0", "typescript": "^5.7.2", "viem": "^2.47.0", + "zcashname-sdk": "^0.3.0", "zod": "^4.3.6" } }