From 84455220190ebb7243a22cdfa7511bcaf85cf755 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 00:14:10 +0530 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20ZNS=20integration=20boilerplate?= =?UTF-8?q?=20=E2=80=94=20SDK=20dep,=20client=20singleton,=20name=20page?= =?UTF-8?q?=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/name/[name]/layout.tsx | 39 +++++++++++++ app/name/[name]/page.tsx | 110 +++++++++++++++++++++++++++++++++++++ lib/zns.ts | 20 +++++++ package-lock.json | 18 ++++++ package.json | 1 + 5 files changed, 188 insertions(+) create mode 100644 app/name/[name]/layout.tsx create mode 100644 app/name/[name]/page.tsx create mode 100644 lib/zns.ts 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..01afaf5 --- /dev/null +++ b/app/name/[name]/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { getZnsClient } from '@/lib/zns'; +import { isValidName } from 'zcashname-sdk'; +import type { ResolveResult, StatusResult, EventsResult } from 'zcashname-sdk'; + +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 [status, setStatus] = useState(null); + + const valid = isValidName(name); + + useEffect(() => { + if (!valid) { + setLoading(false); + return; + } + + let cancelled = false; + + async function fetchData() { + try { + const client = await getZnsClient(); + const [resolveResult, eventsResult, statusResult] = await Promise.all([ + client.resolve(name), + client.events({ name }), + client.status(), + ]); + + if (cancelled) return; + + setResolved(resolveResult); + setEvents(eventsResult); + setStatus(statusResult); + } 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 + if (!valid) { + return ( +
+

TODO: Invalid state for "{name}"

+
+ ); + } + + // Available name + if (!resolved) { + return ( +
+

TODO: Available state for "{name}"

+
+ ); + } + + // Registered name + return ( +
+

TODO: Registered state for "{name}" → {resolved.address}

+
+ ); +} diff --git a/lib/zns.ts b/lib/zns.ts new file mode 100644 index 0000000..98a6c16 --- /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'; + +const ZNS_URLS: Record = { + 'mainnet': 'https://names.zcash.me', + 'testnet': 'https://names.zcash.me', + 'crosslink-testnet': 'https://names.zcash.me', +}; + +const ZNS_URL = process.env.NEXT_PUBLIC_ZNS_URL || ZNS_URLS[NETWORK]; + +let client: ZNSClient | null = null; + +export async function getZnsClient(): Promise { + if (!client) { + client = await createClient(ZNS_URL); + } + return client; +} diff --git a/package-lock.json b/package-lock.json index c577928..516058e 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.2.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.2.0", + "resolved": "https://registry.npmjs.org/zcashname-sdk/-/zcashname-sdk-0.2.0.tgz", + "integrity": "sha512-1OGUki0wupAz00M6mWazgG8dBcXxZpFDAgh4nkdxqftUnvuuUz/ziZoU3UcIn9gL6zGtY1HHV4H9tMW8b+9YRg==", + "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..e8b9d7e 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.2.0", "zod": "^4.3.6" } } From 3b9424fdad054b33572b01174d829b4e958d829e Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 00:17:58 +0530 Subject: [PATCH 02/18] feat: name page header with badge for all three states --- app/name/[name]/page.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/name/[name]/page.tsx b/app/name/[name]/page.tsx index 01afaf5..727020c 100644 --- a/app/name/[name]/page.tsx +++ b/app/name/[name]/page.tsx @@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { getZnsClient } from '@/lib/zns'; import { isValidName } from 'zcashname-sdk'; +import { Badge } from '@/components/ui/Badge'; import type { ResolveResult, StatusResult, EventsResult } from 'zcashname-sdk'; export default function NamePage() { @@ -87,7 +88,10 @@ export default function NamePage() { if (!valid) { return (
-

TODO: Invalid state for "{name}"

+
+

{name}

+ INVALID +
); } @@ -96,7 +100,10 @@ export default function NamePage() { if (!resolved) { return (
-

TODO: Available state for "{name}"

+
+

{name}

+ AVAILABLE +
); } @@ -104,7 +111,10 @@ export default function NamePage() { // Registered name return (
-

TODO: Registered state for "{name}" → {resolved.address}

+
+

{name}

+ REGISTERED +
); } From 4026425e4738da49ce17d47ea5dad8d9ecd90012 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 01:14:06 +0530 Subject: [PATCH 03/18] feat: resolved address card with copy button for registered names --- app/name/[name]/page.tsx | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/app/name/[name]/page.tsx b/app/name/[name]/page.tsx index 727020c..0765180 100644 --- a/app/name/[name]/page.tsx +++ b/app/name/[name]/page.tsx @@ -6,8 +6,14 @@ import Link from 'next/link'; import { getZnsClient } from '@/lib/zns'; import { isValidName } from 'zcashname-sdk'; import { Badge } from '@/components/ui/Badge'; +import { Card } from '@/components/ui/Card'; import type { ResolveResult, StatusResult, EventsResult } from 'zcashname-sdk'; +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; @@ -17,9 +23,42 @@ export default function NamePage() { const [resolved, setResolved] = useState(null); const [events, setEvents] = useState(null); const [status, setStatus] = 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); @@ -115,6 +154,23 @@ export default function NamePage() {

{name}

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

+ {'>'} + RESOLVED_ADDRESS +

+
+ + {truncateAddress(resolved.address)} + + +
+
); } From 33f5a29881341e858feb01cb951e4f4543c15a13 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 01:15:50 +0530 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20registration=20details=20card=20?= =?UTF-8?q?=E2=80=94=20tx=20link,=20block=20height,=20nonce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/name/[name]/page.tsx | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/name/[name]/page.tsx b/app/name/[name]/page.tsx index 0765180..a43a009 100644 --- a/app/name/[name]/page.tsx +++ b/app/name/[name]/page.tsx @@ -171,6 +171,35 @@ export default function NamePage() {
+ + {/* Registration Details */} + +

+ {'>'} + REGISTRATION +

+
+
+ Transaction +
+ + {resolved.txid.slice(0, 16)}... + + +
+
+
+ Block + + #{resolved.height.toLocaleString()} + +
+
+ Nonce + {resolved.nonce} +
+
+
); } From 51df39c6e70cd5e6c006381d3fe04b0a24329d23 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 01:17:45 +0530 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20marketplace=20card=20=E2=80=94=20?= =?UTF-8?q?listing=20status,=20price,=20and=20tx=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/name/[name]/page.tsx | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/name/[name]/page.tsx b/app/name/[name]/page.tsx index a43a009..b5cbbc7 100644 --- a/app/name/[name]/page.tsx +++ b/app/name/[name]/page.tsx @@ -200,6 +200,41 @@ export default function NamePage() { + + {/* 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

+ )} +
); } From ce6e2a194cf69fd767ca6ee705369b4501df1ea8 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 01:28:06 +0530 Subject: [PATCH 06/18] feat: event history card with graceful fallback for undeployed endpoint --- app/name/[name]/page.tsx | 53 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/app/name/[name]/page.tsx b/app/name/[name]/page.tsx index b5cbbc7..05df039 100644 --- a/app/name/[name]/page.tsx +++ b/app/name/[name]/page.tsx @@ -9,6 +9,14 @@ import { Badge } from '@/components/ui/Badge'; import { Card } from '@/components/ui/Card'; import type { ResolveResult, StatusResult, EventsResult } from 'zcashname-sdk'; +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)}`; @@ -70,17 +78,21 @@ export default function NamePage() { async function fetchData() { try { const client = await getZnsClient(); - const [resolveResult, eventsResult, statusResult] = await Promise.all([ + const [resolveResult, statusResult] = await Promise.all([ client.resolve(name), - client.events({ name }), client.status(), ]); if (cancelled) return; setResolved(resolveResult); - setEvents(eventsResult); setStatus(statusResult); + + // Events endpoint may not be deployed yet — fail silently + try { + const eventsResult = await client.events({ name }); + if (!cancelled) setEvents(eventsResult); + } catch {} } catch (err) { if (cancelled) return; setError('Unable to reach ZNS indexer. Please try again.'); @@ -235,6 +247,41 @@ export default function NamePage() {

Not for sale

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

+ {'>'} + EVENT_HISTORY +

+
+ {events.events.map((event) => ( +
+ + {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 + + )} +
+ ))} +
+
+ )} ); } From a1185671881e81dfc0f8047ee36e0c68e4685d05 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 02:16:13 +0530 Subject: [PATCH 07/18] feat: invalid names return 404 instead of blank page --- app/name/[name]/page.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/app/name/[name]/page.tsx b/app/name/[name]/page.tsx index 05df039..f380fea 100644 --- a/app/name/[name]/page.tsx +++ b/app/name/[name]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useParams } from 'next/navigation'; +import { useParams, notFound } from 'next/navigation'; import { useState, useEffect } from 'react'; import Link from 'next/link'; import { getZnsClient } from '@/lib/zns'; @@ -135,17 +135,8 @@ export default function NamePage() { ); } - // Invalid name - if (!valid) { - return ( -
-
-

{name}

- INVALID -
-
- ); - } + // Invalid name → 404 + if (!valid) notFound(); // Available name if (!resolved) { From eaab95e25069e6783f267c814f123e7c4873e86b Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 02:17:51 +0530 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20unregistered=20names=20return=204?= =?UTF-8?q?04=20=E2=80=94=20only=20on-chain=20names=20get=20a=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/name/[name]/page.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/app/name/[name]/page.tsx b/app/name/[name]/page.tsx index f380fea..2fc9281 100644 --- a/app/name/[name]/page.tsx +++ b/app/name/[name]/page.tsx @@ -138,17 +138,8 @@ export default function NamePage() { // Invalid name → 404 if (!valid) notFound(); - // Available name - if (!resolved) { - return ( -
-
-

{name}

- AVAILABLE -
-
- ); - } + // Name not registered → nothing on-chain to show + if (!resolved) notFound(); // Registered name return ( From 4f03c901433a1ad770e72034ec900a80eb8d5521 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 02:22:03 +0530 Subject: [PATCH 09/18] feat: search bar resolves ZNS names as lowest priority --- components/SearchBar.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx index f9b8469..e154fd4 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'); } From 783efc83dcf6e3ce28a00617472bc1e20a20ccc6 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 02:44:48 +0530 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20bump=20zcashname-sdk=20to=200.2.3?= =?UTF-8?q?=20=E2=80=94=20ESM=20exports=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 516058e..82ee298 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "topojson-client": "^3.1.0", "typescript": "^5.7.2", "viem": "^2.47.0", - "zcashname-sdk": "^0.2.0", + "zcashname-sdk": "^0.2.3", "zod": "^4.3.6" } }, @@ -4959,9 +4959,9 @@ } }, "node_modules/zcashname-sdk": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/zcashname-sdk/-/zcashname-sdk-0.2.0.tgz", - "integrity": "sha512-1OGUki0wupAz00M6mWazgG8dBcXxZpFDAgh4nkdxqftUnvuuUz/ziZoU3UcIn9gL6zGtY1HHV4H9tMW8b+9YRg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/zcashname-sdk/-/zcashname-sdk-0.2.3.tgz", + "integrity": "sha512-jQ77IxkIsz1adxmVBH36/cDDPodtNq6eJPx1RLlQYpoZKPKVHqOSsQSao71orFDZstoZ5osMfLG50I6lycNmpw==", "license": "MIT" }, "node_modules/zod": { diff --git a/package.json b/package.json index e8b9d7e..38b685c 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "topojson-client": "^3.1.0", "typescript": "^5.7.2", "viem": "^2.47.0", - "zcashname-sdk": "^0.2.0", + "zcashname-sdk": "^0.2.3", "zod": "^4.3.6" } } From 265182223e4502cec0e254bbd2d0179ffc4c4c6b Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Thu, 26 Mar 2026 02:54:51 +0530 Subject: [PATCH 11/18] feat: disable ZNS on mainnet, use light.zcash.me/zns for testnet, update search placeholder --- components/SearchBar.tsx | 7 ++++--- lib/zns.ts | 13 +++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx index e154fd4..08848a0 100644 --- a/components/SearchBar.tsx +++ b/components/SearchBar.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { detectAddressType } from '@/lib/zcash'; import { findAddressByLabel, searchAddressesByLabel, fetchOfficialLabels } from '@/lib/address-labels'; import { isValidName } from 'zcashname-sdk'; +import { isZnsEnabled } from '@/lib/zns'; interface SearchBarProps { compact?: boolean; // Mode compact pour la navbar @@ -139,7 +140,7 @@ export function SearchBar({ compact = false }: SearchBarProps) { const addressByLabel = findAddressByLabel(trimmedQuery); if (addressByLabel) { router.push(`/address/${encodeURIComponent(addressByLabel)}`); - } else if (isValidName(trimmedQuery.toLowerCase())) { + } else if (isZnsEnabled() && isValidName(trimmedQuery.toLowerCase())) { router.push(`/name/${encodeURIComponent(trimmedQuery.toLowerCase())}`); } else { console.warn('No matching address, transaction, or label found'); @@ -267,7 +268,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" /> @@ -306,7 +307,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 index 98a6c16..c6ae995 100644 --- a/lib/zns.ts +++ b/lib/zns.ts @@ -2,17 +2,22 @@ import { createClient, type ZNSClient } from 'zcashname-sdk'; import { NETWORK } from './api-config'; import type { Network } from './api-config'; -const ZNS_URLS: Record = { - 'mainnet': 'https://names.zcash.me', - 'testnet': 'https://names.zcash.me', - 'crosslink-testnet': 'https://names.zcash.me', +const ZNS_URLS: Record = { + 'mainnet': null, + 'testnet': 'https://light.zcash.me/zns', + 'crosslink-testnet': 'https://light.zcash.me/zns', }; const ZNS_URL = process.env.NEXT_PUBLIC_ZNS_URL || ZNS_URLS[NETWORK]; let client: ZNSClient | null = null; +export function isZnsEnabled(): boolean { + return ZNS_URL !== null; +} + export async function getZnsClient(): Promise { + if (!ZNS_URL) throw new Error('ZNS is not available on this network'); if (!client) { client = await createClient(ZNS_URL); } From 82613d97391943b3f5d8a8776bb481d645de9604 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Fri, 27 Mar 2026 16:40:51 +0530 Subject: [PATCH 12/18] Update ZNS testnet endpoint to /zns-testnet --- lib/zns.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/zns.ts b/lib/zns.ts index c6ae995..d812348 100644 --- a/lib/zns.ts +++ b/lib/zns.ts @@ -4,8 +4,8 @@ import type { Network } from './api-config'; const ZNS_URLS: Record = { 'mainnet': null, - 'testnet': 'https://light.zcash.me/zns', - 'crosslink-testnet': 'https://light.zcash.me/zns', + 'testnet': 'https://light.zcash.me/zns-testnet', + 'crosslink-testnet': 'https://light.zcash.me/zns-testnet', }; const ZNS_URL = process.env.NEXT_PUBLIC_ZNS_URL || ZNS_URLS[NETWORK]; From 5c1783c3d33e6b937962718a9d1fc3fe4f971f21 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Mon, 30 Mar 2026 22:09:20 +0530 Subject: [PATCH 13/18] =?UTF-8?q?feat:=20bump=20zcashname-sdk=20to=200.3.0?= =?UTF-8?q?=20=E2=80=94=20no=20code=20changes=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82ee298..163f924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "topojson-client": "^3.1.0", "typescript": "^5.7.2", "viem": "^2.47.0", - "zcashname-sdk": "^0.2.3", + "zcashname-sdk": "^0.3.0", "zod": "^4.3.6" } }, @@ -4959,9 +4959,9 @@ } }, "node_modules/zcashname-sdk": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/zcashname-sdk/-/zcashname-sdk-0.2.3.tgz", - "integrity": "sha512-jQ77IxkIsz1adxmVBH36/cDDPodtNq6eJPx1RLlQYpoZKPKVHqOSsQSao71orFDZstoZ5osMfLG50I6lycNmpw==", + "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": { diff --git a/package.json b/package.json index 38b685c..d4e8355 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "topojson-client": "^3.1.0", "typescript": "^5.7.2", "viem": "^2.47.0", - "zcashname-sdk": "^0.2.3", + "zcashname-sdk": "^0.3.0", "zod": "^4.3.6" } } From f16a4a360c51a55062bc0dd57fe6fdbed23af8de Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sat, 4 Apr 2026 08:20:42 +0530 Subject: [PATCH 14/18] =?UTF-8?q?wip:=20gut=20browser-side=20SDK=20calls?= =?UTF-8?q?=20=E2=80=94=20API=20proxy=20next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ZNS_INTEGRATION.md | 234 +++++++++++++++++++++++++++++++++++++++ app/name/[name]/page.tsx | 42 +------ lib/zns.ts | 23 +--- 3 files changed, 241 insertions(+), 58 deletions(-) create mode 100644 ZNS_INTEGRATION.md diff --git a/ZNS_INTEGRATION.md b/ZNS_INTEGRATION.md new file mode 100644 index 0000000..21de274 --- /dev/null +++ b/ZNS_INTEGRATION.md @@ -0,0 +1,234 @@ +# 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. A new Next.js API route (`/api/zns`) proxies requests to the indexer. The browser only talks to CipherScan's own API, same as blocks, transactions, and addresses. +> +> **Before:** `browser → SDK → indexer` (CORS blocked) +> **After:** `browser → /api/zns → SDK → indexer` (server-to-server, no CORS) + +#### Files changed + +``` +app/api/zns/route.ts — NEW: API proxy, forwards JSON-RPC to indexer via SDK +lib/zns.ts — CHANGED: server-only, env var renamed to ZNS_URL +app/name/[name]/page.tsx — CHANGED: fetches from /api/zns instead of SDK +app/docs/endpoints.ts — CHANGED: new "Names" category documenting ZNS endpoints +.env.example — CHANGED: added ZNS_URL (server-only) +``` + +#### `app/api/zns/route.ts` — API proxy + +Accepts POST requests with `{ method, params }`, forwards to the indexer via the SDK client, returns the JSON-RPC result. Follows the same error handling and caching pattern as `/api/tx/`, `/api/block/`, etc. + +Supported methods: +- `resolve` — resolve a name to its registration + listing +- `status` — indexer health, registered/listed counts, pricing +- `events` — event history for a name + +#### `lib/zns.ts` — server-only client + +The SDK singleton is now only imported by the API route. The `NEXT_PUBLIC_ZNS_URL` env var was renamed to `ZNS_URL` — no reason to expose the indexer URL to the browser anymore. + +`isZnsEnabled()` remains available client-side (it checks the network, not the URL). + +```ts +// Server-only — imported by app/api/zns/route.ts +const ZNS_URL = process.env.ZNS_URL || ZNS_URLS[NETWORK]; + +export async function getZnsClient(): Promise { + if (!ZNS_URL) throw new Error('ZNS is not available on this network'); + if (!client) { + client = await createClient(ZNS_URL); + } + return client; +} +``` + +#### `app/name/[name]/page.tsx` — client component + +No longer imports the SDK. The `useEffect` now calls CipherScan's own API: + +```ts +const [resolved, status] = await Promise.all([ + fetch('/api/zns', { method: 'POST', body: JSON.stringify({ method: 'resolve', params: { query: name } }) }), + fetch('/api/zns', { method: 'POST', body: JSON.stringify({ method: 'status' }) }), +]); +``` + +### Search bar integration + +In `SearchBar.tsx`, after existing detection logic: + +```ts +import { isValidName } from 'zcashname-sdk'; + +// Pure regex check — no network call, safe on every keystroke +if (isZnsEnabled() && isValidName(trimmedQuery.toLowerCase())) { + router.push(`/name/${encodeURIComponent(trimmedQuery.toLowerCase())}`); +} +``` + +ZNS 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/zns` 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 | Status | +|---------|---------|--------| +| Testnet | `https://light.zcash.me/zns-testnet` | Active | +| Mainnet | `null` (disabled) | When mainnet indexer launches | +| Crosslink | `https://light.zcash.me/zns-testnet` | Same testnet indexer | + +Switching networks is a URL change in `lib/zns.ts`. Override with `ZNS_URL` env var (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/name/[name]/page.tsx b/app/name/[name]/page.tsx index 2fc9281..610fc46 100644 --- a/app/name/[name]/page.tsx +++ b/app/name/[name]/page.tsx @@ -3,11 +3,9 @@ import { useParams, notFound } from 'next/navigation'; import { useState, useEffect } from 'react'; import Link from 'next/link'; -import { getZnsClient } from '@/lib/zns'; import { isValidName } from 'zcashname-sdk'; import { Badge } from '@/components/ui/Badge'; import { Card } from '@/components/ui/Card'; -import type { ResolveResult, StatusResult, EventsResult } from 'zcashname-sdk'; const EVENT_COLORS: Record = { CLAIM: 'green', @@ -28,9 +26,8 @@ export default function NamePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [resolved, setResolved] = useState(null); - const [events, setEvents] = useState(null); - const [status, setStatus] = useState(null); + const [resolved, setResolved] = useState(null); + const [events, setEvents] = useState(null); const [copiedText, setCopiedText] = useState(null); const valid = isValidName(name); @@ -67,42 +64,13 @@ export default function NamePage() { ); + // TODO: fetch from /api/zns once the route exists useEffect(() => { if (!valid) { setLoading(false); return; } - - let cancelled = false; - - async function fetchData() { - try { - const client = await getZnsClient(); - const [resolveResult, statusResult] = await Promise.all([ - client.resolve(name), - client.status(), - ]); - - if (cancelled) return; - - setResolved(resolveResult); - setStatus(statusResult); - - // Events endpoint may not be deployed yet — fail silently - try { - const eventsResult = await client.events({ name }); - if (!cancelled) setEvents(eventsResult); - } catch {} - } catch (err) { - if (cancelled) return; - setError('Unable to reach ZNS indexer. Please try again.'); - } finally { - if (!cancelled) setLoading(false); - } - } - - fetchData(); - return () => { cancelled = true; }; + setLoading(false); }, [name, valid]); // Loading @@ -238,7 +206,7 @@ export default function NamePage() { EVENT_HISTORY
- {events.events.map((event) => ( + {events.events.map((event: any) => (
{event.action} diff --git a/lib/zns.ts b/lib/zns.ts index d812348..d715903 100644 --- a/lib/zns.ts +++ b/lib/zns.ts @@ -1,25 +1,6 @@ -import { createClient, type ZNSClient } from 'zcashname-sdk'; import { NETWORK } from './api-config'; -import type { Network } from './api-config'; - -const ZNS_URLS: Record = { - 'mainnet': null, - 'testnet': 'https://light.zcash.me/zns-testnet', - 'crosslink-testnet': 'https://light.zcash.me/zns-testnet', -}; - -const ZNS_URL = process.env.NEXT_PUBLIC_ZNS_URL || ZNS_URLS[NETWORK]; - -let client: ZNSClient | null = null; +// Mainnet has no indexer yet export function isZnsEnabled(): boolean { - return ZNS_URL !== null; -} - -export async function getZnsClient(): Promise { - if (!ZNS_URL) throw new Error('ZNS is not available on this network'); - if (!client) { - client = await createClient(ZNS_URL); - } - return client; + return NETWORK !== 'mainnet'; } From 54a45f4363300dc6644f4ae5d5b6b1958966c8c7 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sat, 4 Apr 2026 09:17:12 +0530 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20add=20/api/name/[name]=20and=20/a?= =?UTF-8?q?pi/name/[name]/events=20=E2=80=94=20server-side=20ZNS=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/name/[name]/events/route.ts | 36 ++++++++++++++++++++++++ app/api/name/[name]/route.ts | 43 +++++++++++++++++++++++++++++ components/SearchBar.tsx | 3 +- lib/zns.ts | 22 +++++++++++++-- 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 app/api/name/[name]/events/route.ts create mode 100644 app/api/name/[name]/route.ts 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/components/SearchBar.tsx b/components/SearchBar.tsx index 08848a0..11a8d26 100644 --- a/components/SearchBar.tsx +++ b/components/SearchBar.tsx @@ -5,7 +5,6 @@ import { useRouter } from 'next/navigation'; import { detectAddressType } from '@/lib/zcash'; import { findAddressByLabel, searchAddressesByLabel, fetchOfficialLabels } from '@/lib/address-labels'; import { isValidName } from 'zcashname-sdk'; -import { isZnsEnabled } from '@/lib/zns'; interface SearchBarProps { compact?: boolean; // Mode compact pour la navbar @@ -140,7 +139,7 @@ export function SearchBar({ compact = false }: SearchBarProps) { const addressByLabel = findAddressByLabel(trimmedQuery); if (addressByLabel) { router.push(`/address/${encodeURIComponent(addressByLabel)}`); - } else if (isZnsEnabled() && isValidName(trimmedQuery.toLowerCase())) { + } else if (isValidName(trimmedQuery.toLowerCase())) { router.push(`/name/${encodeURIComponent(trimmedQuery.toLowerCase())}`); } else { console.warn('No matching address, transaction, or label found'); diff --git a/lib/zns.ts b/lib/zns.ts index d715903..50add20 100644 --- a/lib/zns.ts +++ b/lib/zns.ts @@ -1,6 +1,22 @@ +import { createClient, type ZNSClient } from 'zcashname-sdk'; import { NETWORK } from './api-config'; +import type { Network } from './api-config'; -// Mainnet has no indexer yet -export function isZnsEnabled(): boolean { - return NETWORK !== 'mainnet'; +// Server-side only (used by API routes) + +const ZNS_URLS: Record = { + 'mainnet': 'https://light.zcash.me/zns-mainnet-test', + 'testnet': 'https://light.zcash.me/zns-testnet', + 'crosslink-testnet': 'https://light.zcash.me/zns-testnet', +}; + +const ZNS_URL = process.env.ZNS_URL || ZNS_URLS[NETWORK]; + +let client: ZNSClient | null = null; + +export async function getClient(): Promise { + if (!client) { + client = await createClient(ZNS_URL); + } + return client; } From 0ca19da6042d32a220d882aa4e37242366d69ef9 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sat, 4 Apr 2026 09:23:54 +0530 Subject: [PATCH 16/18] =?UTF-8?q?feat:=20wire=20name=20page=20to=20/api/na?= =?UTF-8?q?me/[name]=20=E2=80=94=20no=20more=20browser-side=20SDK=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/name/[name]/page.tsx | 41 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/app/name/[name]/page.tsx b/app/name/[name]/page.tsx index 610fc46..7b612bd 100644 --- a/app/name/[name]/page.tsx +++ b/app/name/[name]/page.tsx @@ -64,13 +64,50 @@ export default function NamePage() { ); - // TODO: fetch from /api/zns once the route exists useEffect(() => { if (!valid) { setLoading(false); return; } - setLoading(false); + + 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 From 2435b408c0cbbc509918cb927bb2df22f6b232b8 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sat, 4 Apr 2026 09:30:07 +0530 Subject: [PATCH 17/18] =?UTF-8?q?feat:=20per-network=20ZNS=20env=20vars=20?= =?UTF-8?q?=E2=80=94=20ZNS=5FMAINNET=5FURL=20and=20ZNS=5FTESTNET=5FURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/zns.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/zns.ts b/lib/zns.ts index 50add20..8a8eaff 100644 --- a/lib/zns.ts +++ b/lib/zns.ts @@ -5,18 +5,16 @@ import type { Network } from './api-config'; // Server-side only (used by API routes) const ZNS_URLS: Record = { - 'mainnet': 'https://light.zcash.me/zns-mainnet-test', - 'testnet': 'https://light.zcash.me/zns-testnet', - 'crosslink-testnet': 'https://light.zcash.me/zns-testnet', + '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', }; -const ZNS_URL = process.env.ZNS_URL || ZNS_URLS[NETWORK]; - let client: ZNSClient | null = null; export async function getClient(): Promise { if (!client) { - client = await createClient(ZNS_URL); + client = await createClient(ZNS_URLS[NETWORK]); } return client; } From d89a02b15643ea2c5e6b14727a763a730a809941 Mon Sep 17 00:00:00 2001 From: Julian Abraham Date: Sat, 4 Apr 2026 09:30:14 +0530 Subject: [PATCH 18/18] =?UTF-8?q?docs:=20add=20Names=20category=20to=20API?= =?UTF-8?q?=20docs=20=E2=80=94=20/api/name/:name=20and=20/api/name/:name/e?= =?UTF-8?q?vents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ZNS_INTEGRATION.md | 76 ++++++++++++++++++++----------------------- app/docs/endpoints.ts | 70 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 41 deletions(-) diff --git a/ZNS_INTEGRATION.md b/ZNS_INTEGRATION.md index 21de274..ce787da 100644 --- a/ZNS_INTEGRATION.md +++ b/ZNS_INTEGRATION.md @@ -90,60 +90,55 @@ Zero dependencies, ~7 small files. Provides typed methods for `resolve`, `listin > **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. A new Next.js API route (`/api/zns`) proxies requests to the indexer. The browser only talks to CipherScan's own API, same as blocks, transactions, and addresses. +> **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/zns → SDK → indexer` (server-to-server, no CORS) +> **After:** `browser → /api/name/[name] → SDK → indexer` (server-to-server, no CORS) #### Files changed ``` -app/api/zns/route.ts — NEW: API proxy, forwards JSON-RPC to indexer via SDK -lib/zns.ts — CHANGED: server-only, env var renamed to ZNS_URL -app/name/[name]/page.tsx — CHANGED: fetches from /api/zns instead of SDK -app/docs/endpoints.ts — CHANGED: new "Names" category documenting ZNS endpoints -.env.example — CHANGED: added ZNS_URL (server-only) +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 ``` -#### `app/api/zns/route.ts` — API proxy +#### API routes -Accepts POST requests with `{ method, params }`, forwards to the indexer via the SDK client, returns the JSON-RPC result. Follows the same error handling and caching pattern as `/api/tx/`, `/api/block/`, etc. +Names are a resource, not an action. The API follows the same pattern as every other resource: -Supported methods: -- `resolve` — resolve a name to its registration + listing -- `status` — indexer health, registered/listed counts, pricing -- `events` — event history for a name +``` +GET /api/name/:name → registration, address, listing (60s cache) +GET /api/name/:name/events → event history (30s cache) +``` -#### `lib/zns.ts` — server-only client +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. -The SDK singleton is now only imported by the API route. The `NEXT_PUBLIC_ZNS_URL` env var was renamed to `ZNS_URL` — no reason to expose the indexer URL to the browser anymore. +#### `lib/zns.ts` — server-only client -`isZnsEnabled()` remains available client-side (it checks the network, not the URL). +The SDK singleton is only imported by the API routes. Per-network env var overrides: ```ts -// Server-only — imported by app/api/zns/route.ts -const ZNS_URL = process.env.ZNS_URL || ZNS_URLS[NETWORK]; - -export async function getZnsClient(): Promise { - if (!ZNS_URL) throw new Error('ZNS is not available on this network'); - if (!client) { - client = await createClient(ZNS_URL); - } - return client; -} +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` now calls CipherScan's own API: +No longer imports the SDK. The `useEffect` fetches from CipherScan's own API: ```ts -const [resolved, status] = await Promise.all([ - fetch('/api/zns', { method: 'POST', body: JSON.stringify({ method: 'resolve', params: { query: name } }) }), - fetch('/api/zns', { method: 'POST', body: JSON.stringify({ method: 'status' }) }), -]); +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: @@ -151,17 +146,16 @@ In `SearchBar.tsx`, after existing detection logic: ```ts import { isValidName } from 'zcashname-sdk'; -// Pure regex check — no network call, safe on every keystroke -if (isZnsEnabled() && isValidName(trimmedQuery.toLowerCase())) { +if (isValidName(trimmedQuery.toLowerCase())) { router.push(`/name/${encodeURIComponent(trimmedQuery.toLowerCase())}`); } ``` -ZNS name detection is the lowest priority in the search chain: Address > Block height > Tx hash > Block hash > Address label > ZNS name. +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/zns` and handles all states (loading / error / registered / not found / invalid). +`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. @@ -209,13 +203,13 @@ Uses existing UI components: `Card`, `Badge`, monospace text for addresses/hashe The integration is network-aware from day one: -| Network | ZNS URL | Status | -|---------|---------|--------| -| Testnet | `https://light.zcash.me/zns-testnet` | Active | -| Mainnet | `null` (disabled) | When mainnet indexer launches | -| Crosslink | `https://light.zcash.me/zns-testnet` | Same testnet indexer | +| 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 | -Switching networks is a URL change in `lib/zns.ts`. Override with `ZNS_URL` env var (server-only). The SDK handles UIVK verification per-network. +Env vars are server-only. The SDK handles UIVK verification per-network. --- 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.' } ];