diff --git a/src/components/elements/User/UserNIP05.tsx b/src/components/elements/User/UserNIP05.tsx index 83aa0b58..1d809181 100644 --- a/src/components/elements/User/UserNIP05.tsx +++ b/src/components/elements/User/UserNIP05.tsx @@ -3,7 +3,7 @@ import { Text } from '@/components/ui/Text/Text' import { useNip05 } from '@/hooks/query/useNIP05' import { useUserState } from '@/hooks/state/useUser' import { palette } from '@/themes/palette.stylex' -import { IconAt, IconExclamationCircle } from '@tabler/icons-react' +import { IconAt, IconExclamationCircle, IconShieldCheck } from '@tabler/icons-react' import { memo } from 'react' import { css } from 'react-strict-dom' @@ -14,22 +14,35 @@ type Props = Omit & { export const UserNIP05 = memo(function UserNIP05(props: Props) { const { pubkey, ...rest } = props const user = useUserState(pubkey) - const validNIP05 = useNip05(pubkey, user.metadata?.nip05).data + const nip05Result = useNip05(pubkey, user.metadata?.nip05).data const nip05 = user.metadata?.nip05?.replace(/^_@/, '') if (!nip05) { return } + + const isValid = nip05Result?.valid + const isNamecoin = nip05Result?.isNamecoin + return ( - - {validNIP05 !== false ? ( + + {isValid === false ? ( <> - {!nip05.includes('@') && } + + {nip05.replace(/^_@/, '')} + + ) : isNamecoin ? ( + <> + {nip05} ) : ( <> - - {nip05.replace(/^_@/, '')} + {!nip05.includes('@') && } + {nip05} )} @@ -41,6 +54,11 @@ const styles = css.create({ display: 'inline-block', verticalAlign: 'text-bottom', }, + icon$namecoin: { + display: 'inline-block', + verticalAlign: 'text-bottom', + color: '#009688', // teal — distinct from standard NIP-05 + }, root: { whiteSpace: 'nowrap', maxWidth: 250, @@ -50,4 +68,7 @@ const styles = css.create({ root$invalid: { color: palette.error, }, + root$namecoin: { + color: '#009688', + }, }) diff --git a/src/components/modules/Search/SearchContent.tsx b/src/components/modules/Search/SearchContent.tsx index ccc431fd..f21e3d8e 100644 --- a/src/components/modules/Search/SearchContent.tsx +++ b/src/components/modules/Search/SearchContent.tsx @@ -10,7 +10,7 @@ import type { SxProps } from '@/components/ui/types' import { palette } from '@/themes/palette.stylex' import { shape } from '@/themes/shape.stylex' import { spacing } from '@/themes/spacing.stylex' -import { IconSearch, IconServerBolt } from '@tabler/icons-react' +import { IconSearch, IconServerBolt, IconShieldCheck } from '@tabler/icons-react' import type { ReactNode, Ref } from 'react' import React, { memo, useCallback, useImperativeHandle, useState } from 'react' import { css, html } from 'react-strict-dom' @@ -162,6 +162,23 @@ export const SearchContent = memo(function SearchContent(props: Props) { ) + case 'user_namecoin': { + return ( + + + {item.address} + + } + onClick={() => handleSelect(item)} + leadingIcon={}> + + + ) + } case 'user': { return ( @@ -221,4 +238,13 @@ const styles = css.create({ paddingBottom: spacing.padding1, paddingLeft: spacing.padding1, }, + namecoinLabel: { + color: '#009688', + }, + namecoinIcon: { + display: 'inline-block', + verticalAlign: 'text-bottom', + color: '#009688', + marginRight: 4, + }, }) diff --git a/src/components/modules/Search/SearchDialog.tsx b/src/components/modules/Search/SearchDialog.tsx index dc9f6c5c..87823fea 100644 --- a/src/components/modules/Search/SearchDialog.tsx +++ b/src/components/modules/Search/SearchDialog.tsx @@ -44,6 +44,12 @@ export const SearchDialog = memo(function SearchDialog() { }) break } + case 'user_namecoin': { + const nmcRelays = ('relays' in item && item.relays?.slice(0, 4)) || [] + const nostrNmc = nip19.nprofileEncode({ pubkey: item.pubkey, relays: nmcRelays }) + navigate({ to: '/$nostr', params: { nostr: nostrNmc } }) + break + } case 'user_relay': case 'user': { const relays = getUserRelaysFromCache(item.pubkey, WRITE).map((x) => x.relay).slice(0, 4) @@ -51,7 +57,6 @@ export const SearchDialog = memo(function SearchDialog() { navigate({ to: '/$nostr', params: { nostr }, - // params: { nostr: user?.nprofile ? user.nprofile : nip19.nprofileEncode({ pubkey: item.pubkey }) }, }) break } diff --git a/src/components/modules/Search/SearchRoute.tsx b/src/components/modules/Search/SearchRoute.tsx index 70ca4a73..ba13f970 100644 --- a/src/components/modules/Search/SearchRoute.tsx +++ b/src/components/modules/Search/SearchRoute.tsx @@ -1,10 +1,12 @@ import { Stack } from '@/components/ui/Stack/Stack' import { useSearchFeed } from '@/hooks/state/useSearchFeed' import { useResetScroll } from '@/hooks/useResetScroll' +import { isNamecoinIdentifier, resolveNamecoin } from '@/services/namecoin' import { searchRoute } from '@/Router' import { spacing } from '@/themes/spacing.stylex' import { useNavigate } from '@tanstack/react-router' -import { memo } from 'react' +import { nip19 } from 'nostr-tools' +import { memo, useEffect } from 'react' import { css } from 'react-strict-dom' import { FeedRoute } from '../Feed/FeedRoute' import { SearchHeader } from './SearchHeader' @@ -15,6 +17,18 @@ export const SearchRoute = memo(function SearchRoute() { const { q } = searchRoute.useSearch() const navigate = useNavigate() const feed = useSearchFeed(q) + + // Resolve Namecoin identifiers and redirect to user profile + useEffect(() => { + if (!q || !isNamecoinIdentifier(q)) return + let cancelled = false + resolveNamecoin(q).then((result) => { + if (cancelled || !result?.pubkey) return + const nostr = nip19.nprofileEncode({ pubkey: result.pubkey, relays: result.relays?.slice(0, 4) }) + navigate({ to: '/$nostr', params: { nostr }, replace: true }) + }) + return () => { cancelled = true } + }, [q, navigate]) return ( { + const result = await resolveNamecoin(query) + if (!result) return null + return { pubkey: result.pubkey, address: query, relays: result.relays } + }, + }) +} + export function useSearchSuggestions(options: SearchOptions) { const usersRelay = useSearchOnRelays(options) const usersLocal = useSearchLocalUsers(options) + const namecoinResult = useSearchNamecoin(options.query) const querySuggestion = options.suggestQuery !== false && options.query ? { type: 'query', query: options.query } : undefined const relaySuggestion = options.suggestRelays !== false && options.query ? { - type: 'relay', - relay: - (options.query.startsWith('wss://') || options.query.startsWith('ws://') ? '' : 'wss://') + options.query, - } + type: 'relay', + relay: + (options.query.startsWith('wss://') || options.query.startsWith('ws://') ? '' : 'wss://') + options.query, + } : undefined + + const namecoinSuggestion = namecoinResult.data + ? { type: 'user_namecoin' as const, pubkey: namecoinResult.data.pubkey, address: namecoinResult.data.address, relays: namecoinResult.data.relays } + : undefined + return [ + namecoinSuggestion, querySuggestion, relaySuggestion, ...(usersLocal.data?.map((user) => ({ type: 'user' as const, pubkey: user.pubkey })) || []), diff --git a/src/hooks/query/useNIP05.ts b/src/hooks/query/useNIP05.ts index ac5eb56b..64fff796 100644 --- a/src/hooks/query/useNIP05.ts +++ b/src/hooks/query/useNIP05.ts @@ -1,5 +1,6 @@ import type { Nip05DB } from '@/db/types' import { dbSqlite } from '@/nostr/db' +import { isNamecoinIdentifier, resolveNamecoin } from '@/services/namecoin' import type { UseQueryOptions } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query' @@ -8,7 +9,42 @@ export type Nip05Response = { relays?: Record } -export function nip05QueryOptions(nip05: string = ''): UseQueryOptions { +/** Result type extended with Namecoin flag */ +export type Nip05Result = Nip05DB & { + isNamecoin?: boolean +} + +function nip05QueryOptionsNamecoin(nip05: string): UseQueryOptions { + return { + queryKey: ['nip05', 'namecoin', nip05] as const, + enabled: !!nip05, + staleTime: 5 * 60 * 1000, // 5 min — Namecoin names change rarely + queryFn: async () => { + const result = await resolveNamecoin(nip05) + if (!result) return null + + const nip05Record: Nip05Result = { + nip05, + pubkey: result.pubkey, + relays: result.relays || [], + timestamp: Date.now(), + isNamecoin: true, + } + + // Store in DB for offline access + dbSqlite.insertNip05({ + nip05, + pubkey: result.pubkey, + relays: result.relays || [], + timestamp: Date.now(), + }) + + return nip05Record + }, + } +} + +function nip05QueryOptionsStandard(nip05: string): UseQueryOptions { const [name, host] = nip05.split('@') return { queryKey: ['nip05', nip05] as const, @@ -23,36 +59,49 @@ export function nip05QueryOptions(nip05: string = ''): UseQueryOptions { + Object.entries(nip05Response.names || {}).forEach(([username, pubkey]) => { dbSqlite.insertNip05({ nip05: `${username}@${host}`, pubkey, - relays: nip05.relays?.[pubkey] || [], + relays: nip05Response.relays?.[pubkey] || [], timestamp: Date.now(), }) }) - const pubkey = nip05.names?.[name] - const relays = pubkey ? nip05.relays?.[pubkey] || [] : [] + const pubkey = nip05Response.names?.[name] + const relays = pubkey ? nip05Response.relays?.[pubkey] || [] : [] if (pubkey) { return { nip05, pubkey, relays, timestamp: Date.now(), - } as Nip05DB + } as Nip05Result } return null }, } } +export function nip05QueryOptions(nip05: string = ''): UseQueryOptions { + if (isNamecoinIdentifier(nip05)) { + return nip05QueryOptionsNamecoin(nip05) + } + return nip05QueryOptionsStandard(nip05) +} + export function useNip05(pubkey: string, nip05: string | undefined) { + const isNmc = nip05 ? isNamecoinIdentifier(nip05) : false + const queryOpts = nip05QueryOptions(nip05 || '') return useQuery({ - ...nip05QueryOptions(nip05 || ''), + ...queryOpts, select: (res) => { - return res ? res.pubkey === pubkey : undefined + if (!res) return undefined + return { + valid: res.pubkey === pubkey, + isNamecoin: isNmc || res.isNamecoin === true, + } }, }) } diff --git a/src/services/namecoin/constants.ts b/src/services/namecoin/constants.ts new file mode 100644 index 00000000..10df06ad --- /dev/null +++ b/src/services/namecoin/constants.ts @@ -0,0 +1,37 @@ +/** Namecoin protocol constants */ + +/** + * Default ElectrumX WebSocket servers for Namecoin. + * + * The browser connects directly via ws:// or wss:// — no backend proxy needed. + * On HTTPS pages, prefer wss:// first to avoid mixed-content blocks. + */ +export interface ElectrumxWsServer { + /** WebSocket URL (ws:// or wss://) */ + url: string + /** Human-readable label */ + label: string +} + +export const DEFAULT_ELECTRUMX_SERVERS: ElectrumxWsServer[] = + typeof window !== 'undefined' && window.location?.protocol === 'https:' + ? [ + { url: 'wss://electrumx.testls.space:50004', label: 'testls.space' }, + { url: 'ws://electrumx.testls.space:50003', label: 'testls.space (plain)' }, + ] + : [ + { url: 'ws://electrumx.testls.space:50003', label: 'testls.space (plain)' }, + { url: 'wss://electrumx.testls.space:50004', label: 'testls.space' }, + ] + +/** Namecoin names expire after this many blocks without renewal (~250 days) */ +export const NAME_EXPIRE_DEPTH = 36000 + +/** Cache TTL in ms (5 minutes) */ +export const DEFAULT_CACHE_TTL = 5 * 60 * 1000 + +/** OP codes for Namecoin name scripts */ +export const OP_NAME_UPDATE = 0x53 +export const OP_2DROP = 0x6d +export const OP_DROP = 0x75 +export const OP_RETURN = 0x6a diff --git a/src/services/namecoin/electrumx-ws.ts b/src/services/namecoin/electrumx-ws.ts new file mode 100644 index 00000000..4cc11c71 --- /dev/null +++ b/src/services/namecoin/electrumx-ws.ts @@ -0,0 +1,383 @@ +/** + * Browser-native ElectrumX WebSocket client for Namecoin name resolution. + * + * Connects directly from the browser to ElectrumX servers over WebSocket + * (ws:// or wss://) — no backend proxy or server-side code needed. + * + * Protocol: JSON-RPC 1.0 over WebSocket text frames (newline-delimited). + * ElectrumX 1.16.0+ with websockets support required on the server side. + * + * Resolution strategy: + * 1. Build a canonical name index script for the identifier + * 2. Compute the Electrum-style scripthash (reversed SHA-256) + * 3. Query blockchain.scripthash.get_history to find the latest tx + * 4. Fetch the verbose transaction and parse the name value from the script + * 5. Check current block height for name expiry + */ + +import { + OP_NAME_UPDATE, + OP_2DROP, + OP_DROP, + OP_RETURN, + NAME_EXPIRE_DEPTH, + DEFAULT_ELECTRUMX_SERVERS, + type ElectrumxWsServer, +} from './constants' +import type { NameShowResult } from './types' + +// ── Crypto helpers (Web Crypto API) ───────────────────────────────── + +/** SHA-256 hash using the browser's Web Crypto API */ +async function sha256(data: Uint8Array): Promise { + if (!crypto?.subtle) { + throw new Error('crypto.subtle unavailable (insecure context?). Use https:// or localhost.') + } + const hash = await crypto.subtle.digest('SHA-256', data.buffer as ArrayBuffer) + return new Uint8Array(hash) +} + +/** Convert Uint8Array to hex string */ +function toHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +/** Convert hex string to Uint8Array */ +function hexToBytes(hex: string): Uint8Array { + const len = hex.length + const arr = new Uint8Array(len / 2) + for (let i = 0; i < len; i += 2) { + arr[i / 2] = parseInt(hex.substring(i, i + 2), 16) + } + return arr +} + +// ── Script building ───────────────────────────────────────────────── + +/** Bitcoin-style push data encoding */ +function pushData(data: Uint8Array): Uint8Array { + const len = data.length + if (len === 0) { + return new Uint8Array([0x00]) + } + if (len < 0x4c) { + const result = new Uint8Array(1 + len) + result[0] = len + result.set(data, 1) + return result + } + if (len <= 0xff) { + const result = new Uint8Array(2 + len) + result[0] = 0x4c // OP_PUSHDATA1 + result[1] = len + result.set(data, 2) + return result + } + const result = new Uint8Array(3 + len) + result[0] = 0x4d // OP_PUSHDATA2 + result[1] = len & 0xff + result[2] = (len >> 8) & 0xff + result.set(data, 3) + return result +} + +/** + * Build the canonical name index script for ElectrumX lookup. + * + * Format: OP_NAME_UPDATE OP_2DROP OP_DROP OP_RETURN + * + * This matches the script pattern indexed by the Namecoin ElectrumX fork + * (electrumx/lib/coins.py: NamecoinMixin.build_name_index_script). + */ +function buildNameIndexScript(nameBytes: Uint8Array): Uint8Array { + const namePush = pushData(nameBytes) + const emptyPush = pushData(new Uint8Array(0)) + + const result = new Uint8Array(1 + namePush.length + emptyPush.length + 3) + let offset = 0 + result[offset++] = OP_NAME_UPDATE + result.set(namePush, offset) + offset += namePush.length + result.set(emptyPush, offset) + offset += emptyPush.length + result[offset++] = OP_2DROP + result[offset++] = OP_DROP + result[offset++] = OP_RETURN + + return result +} + +/** + * Compute the Electrum-style scripthash: SHA-256 of the script, byte-reversed, hex-encoded. + */ +async function electrumScripthash(script: Uint8Array): Promise { + const hash = await sha256(script) + hash.reverse() + return toHex(hash) +} + +// ── Transaction parsing ───────────────────────────────────────────── + +/** Read a push-data encoded byte sequence from script at pos */ +function readPushData(script: Uint8Array, pos: number): { data: Uint8Array; next: number } | null { + if (pos >= script.length) return null + const opcode = script[pos] + + if (opcode === 0x00) { + return { data: new Uint8Array(0), next: pos + 1 } + } + if (opcode >= 0x01 && opcode <= 0x4b) { + const end = pos + 1 + opcode + if (end > script.length) return null + return { data: script.slice(pos + 1, end), next: end } + } + if (opcode === 0x4c) { + if (pos + 2 > script.length) return null + const len = script[pos + 1] + const end = pos + 2 + len + if (end > script.length) return null + return { data: script.slice(pos + 2, end), next: end } + } + if (opcode === 0x4d) { + if (pos + 3 > script.length) return null + const len = script[pos + 1] | (script[pos + 2] << 8) + const end = pos + 3 + len + if (end > script.length) return null + return { data: script.slice(pos + 3, end), next: end } + } + return null +} + +interface VerboseTxVout { + scriptPubKey?: { hex?: string; asm?: string } +} + +interface VerboseTxResult { + vout?: VerboseTxVout[] +} + +/** Parse NAME_UPDATE name and value from a verbose transaction response */ +function parseNameFromVerboseTx( + txResult: VerboseTxResult, + expectedName: string, +): { name: string; value: string } | null { + const nameBytes = new TextEncoder().encode(expectedName) + + for (const vout of txResult.vout || []) { + const hex = vout.scriptPubKey?.hex + if (!hex || !hex.startsWith('53')) continue // Must start with OP_NAME_UPDATE + + const script = hexToBytes(hex) + if (script[0] !== OP_NAME_UPDATE) continue + + const nameParsed = readPushData(script, 1) + if (!nameParsed) continue + + if (nameParsed.data.length !== nameBytes.length) continue + let match = true + for (let i = 0; i < nameBytes.length; i++) { + if (nameParsed.data[i] !== nameBytes[i]) { + match = false + break + } + } + if (!match) continue + + const valueParsed = readPushData(script, nameParsed.next) + if (!valueParsed) continue + + const name = new TextDecoder('ascii').decode(nameParsed.data) + const value = new TextDecoder('utf-8').decode(valueParsed.data) + return { name, value } + } + return null +} + +// ── WebSocket JSON-RPC client ─────────────────────────────────────── + +interface HistoryEntry { + tx_hash: string + height: number +} + +interface RpcResponse { + jsonrpc?: string + id: number + result?: unknown + error?: { code: number; message: string } +} + +/** + * Batch multiple JSON-RPC calls over a single WebSocket connection. + * Sends requests sequentially but keeps the socket open until all are done. + */ +function wsRpcBatch( + url: string, + calls: Array<{ method: string; params: unknown[] }>, + timeoutMs = 20000, +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const results: unknown[] = [] + let callIndex = 0 + const ws = new WebSocket(url) + const timer = setTimeout(() => { + if (!settled) { + settled = true + ws.close() + reject(new Error(`WebSocket batch timeout after ${timeoutMs}ms`)) + } + }, timeoutMs) + + ws.addEventListener('open', () => { + sendNext() + }) + + function sendNext() { + if (callIndex >= calls.length) return + const { method, params } = calls[callIndex] + ws.send(JSON.stringify({ jsonrpc: '2.0', method, params, id: callIndex + 1 }) + '\n') + } + + ws.addEventListener('message', (ev) => { + if (settled) return + try { + const data = typeof ev.data === 'string' ? ev.data : String(ev.data) + const msg: RpcResponse = JSON.parse(data.trim()) + if (msg.error) { + settled = true + clearTimeout(timer) + ws.close() + reject(new Error(msg.error.message || `RPC error ${msg.error.code}`)) + return + } + results.push(msg.result) + callIndex++ + if (callIndex >= calls.length) { + settled = true + clearTimeout(timer) + ws.close() + resolve(results) + } else { + sendNext() + } + } catch (err) { + settled = true + clearTimeout(timer) + ws.close() + reject(err) + } + }) + + ws.addEventListener('error', () => { + if (!settled) { + settled = true + clearTimeout(timer) + reject(new Error(`WebSocket connection failed: ${url}`)) + } + }) + + ws.addEventListener('close', (ev) => { + if (!settled) { + settled = true + clearTimeout(timer) + reject(new Error(`WebSocket closed unexpectedly: code=${ev.code}`)) + } + }) + }) +} + +// ── Public API ────────────────────────────────────────────────────── + +/** + * Resolve a Namecoin name via WebSocket to an ElectrumX server. + * + * Connects directly from the browser — no proxy needed. + * Uses a single WebSocket connection for all RPCs in the lookup sequence. + * + * @param fullName The Namecoin name, e.g. "d/example" or "id/alice" + * @param serverUrl WebSocket URL of the ElectrumX server + * @returns NameShowResult if found, null if the name doesn't exist + */ +export async function nameShowWs(fullName: string, serverUrl?: string): Promise { + const url = serverUrl || DEFAULT_ELECTRUMX_SERVERS[0].url + + // 1. Compute scripthash + const nameBytes = new TextEncoder().encode(fullName) + const script = buildNameIndexScript(nameBytes) + const scripthash = await electrumScripthash(script) + + // 2. Negotiate version + get history in one connection + const batch1Results = (await wsRpcBatch(url, [ + { method: 'server.version', params: ['nosotros/0.1', '1.4'] }, + { method: 'blockchain.scripthash.get_history', params: [scripthash] }, + ])) as [unknown, HistoryEntry[]] + + const history = batch1Results[1] + if (!history || !history.length) return null + + // 3. Get latest transaction + current block height + const latest = history.reduce((a, b) => (a.height > b.height ? a : b)) + + const batch2Results = (await wsRpcBatch(url, [ + { method: 'blockchain.transaction.get', params: [latest.tx_hash, true] }, + { method: 'blockchain.headers.subscribe', params: [] }, + ])) as [VerboseTxResult, { height?: number; block_height?: number }] + + const txResult = batch2Results[0] + const headersResult = batch2Results[1] + const currentHeight = headersResult?.height || headersResult?.block_height || 0 + + // 4. Check expiry + const expired = currentHeight > 0 && latest.height > 0 && currentHeight - latest.height >= NAME_EXPIRE_DEPTH + if (expired) { + return { + name: fullName, + value: '', + txid: latest.tx_hash, + height: latest.height, + expired: true, + expiresIn: 0, + } + } + + // 5. Parse name value from transaction + const parsed = parseNameFromVerboseTx(txResult, fullName) + if (!parsed) return null + + const expiresIn = + currentHeight > 0 && latest.height > 0 ? NAME_EXPIRE_DEPTH - (currentHeight - latest.height) : undefined + + return { + name: parsed.name, + value: parsed.value, + txid: latest.tx_hash, + height: latest.height, + expired: false, + expiresIn, + } +} + +/** + * Try multiple servers in order until one succeeds. + */ +export async function nameShowWithFallback( + fullName: string, + servers?: ElectrumxWsServer[], +): Promise { + const serverList = servers || DEFAULT_ELECTRUMX_SERVERS + let lastError: Error | null = null + + for (const server of serverList) { + try { + return await nameShowWs(fullName, server.url) + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)) + console.warn(`[Namecoin] Server ${server.label} failed:`, lastError.message) + } + } + + throw lastError || new Error('All ElectrumX servers unreachable') +} diff --git a/src/services/namecoin/index.ts b/src/services/namecoin/index.ts new file mode 100644 index 00000000..d5ea70e8 --- /dev/null +++ b/src/services/namecoin/index.ts @@ -0,0 +1,2 @@ +export { isNamecoinIdentifier, parseNamecoinIdentifier, resolveNamecoin } from './resolver' +export type { NamecoinNostrResult, ParsedNamecoinIdentifier, NameShowResult } from './types' diff --git a/src/services/namecoin/resolver.ts b/src/services/namecoin/resolver.ts new file mode 100644 index 00000000..209b125d --- /dev/null +++ b/src/services/namecoin/resolver.ts @@ -0,0 +1,223 @@ +/** + * Namecoin NIP-05 identity resolver + * + * Resolves .bit domains, d/ and id/ Namecoin names to Nostr pubkeys + * by connecting directly to ElectrumX servers via WebSocket from the browser. + * + * No backend proxy needed — the browser connects to ElectrumX over ws:// or wss:// + * and performs the full scripthash-based name lookup natively. + */ + +import type { ParsedNamecoinIdentifier, NamecoinNostrResult } from './types' +import { DEFAULT_CACHE_TTL } from './constants' +import { nameShowWithFallback } from './electrumx-ws' + +// ── Identifier detection & parsing ────────────────────────────────── + +/** Check if a NIP-05-style address is a Namecoin identifier */ +export function isNamecoinIdentifier(address: string): boolean { + if (!address) return false + const lower = address.toLowerCase() + return lower.endsWith('.bit') || lower.startsWith('d/') || lower.startsWith('id/') +} + +/** + * Parse a NIP-05 address or raw Namecoin name into its components. + * + * Supported formats: + * - alice@example.bit → d/example, localPart=alice + * - _@example.bit → d/example, root + * - example.bit → d/example, root + * - d/example → d/example, root + * - id/alice → id/alice + */ +export function parseNamecoinIdentifier(raw: string): ParsedNamecoinIdentifier | null { + if (!raw) return null + const input = raw.trim().toLowerCase() + + // Direct namespace format: d/name or id/name + if (input.startsWith('d/')) { + const name = input.slice(2) + if (!name) return null + return { namecoinName: `d/${name}`, namespace: 'd', name, originalAddress: raw } + } + if (input.startsWith('id/')) { + const name = input.slice(3) + if (!name) return null + return { namecoinName: `id/${name}`, namespace: 'id', name, originalAddress: raw } + } + + // NIP-05 style: [user@]domain.bit + if (input.endsWith('.bit')) { + const atIndex = input.indexOf('@') + let localPart: string | undefined + let domain: string + + if (atIndex !== -1) { + localPart = input.slice(0, atIndex) + domain = input.slice(atIndex + 1) + if (localPart === '_') localPart = undefined + } else { + domain = input + } + + const name = domain.slice(0, -4) // remove ".bit" + if (!name) return null + + return { + namecoinName: `d/${name}`, + namespace: 'd', + name, + localPart, + originalAddress: raw, + } + } + + return null +} + +// ── Simple LRU Cache ──────────────────────────────────────────────── + +const SENTINEL_NOT_FOUND: NamecoinNostrResult = { pubkey: '' } +const MAX_CACHE_ENTRIES = 200 + +const cache = new Map() + +function cacheGet(key: string): NamecoinNostrResult | undefined { + const entry = cache.get(key) + if (!entry) return undefined + if (Date.now() - entry.timestamp > DEFAULT_CACHE_TTL) { + cache.delete(key) + return undefined + } + return entry.value +} + +function cacheSet(key: string, value: NamecoinNostrResult): void { + // Simple LRU: if at capacity, delete the oldest entry + if (cache.size >= MAX_CACHE_ENTRIES) { + const firstKey = cache.keys().next().value + if (firstKey !== undefined) cache.delete(firstKey) + } + cache.set(key, { value, timestamp: Date.now() }) +} + +// ── WebSocket resolution ──────────────────────────────────────────── + +/** + * Resolve a parsed Namecoin identifier via WebSocket to ElectrumX. + * Connects directly from the browser — no proxy needed. + * Returns null if the name doesn't exist or has no Nostr data. + */ +async function resolveNamecoinViaWs(parsed: ParsedNamecoinIdentifier): Promise { + const result = await nameShowWithFallback(parsed.namecoinName) + if (!result || result.expired) return null + + const value = result.value + if (!value) return null + + let parsedValue: Record + try { + parsedValue = typeof value === 'string' ? JSON.parse(value) : value + } catch { + return null + } + + return extractNostrData(parsedValue, parsed) +} + +/** + * Extract Nostr pubkey and relays from a Namecoin name value. + * + * d/ namespace format: + * {"nostr": {"names": {"alice": "hex64"}, "relays": {"hex64": ["wss://..."]}}} + * {"nostr": "hex64"} (shorthand, root identity) + * + * id/ namespace format: + * {"nostr": "hex64"} + * {"nostr": {"pubkey": "hex64", "relays": ["wss://..."]}} + */ +function extractNostrData( + value: Record, + parsed: ParsedNamecoinIdentifier, +): NamecoinNostrResult | null { + const nostr = value.nostr + if (!nostr) return null + + // id/ namespace + if (parsed.namespace === 'id') { + if (typeof nostr === 'string' && isValidHexPubkey(nostr)) { + return { pubkey: nostr } + } + if (typeof nostr === 'object' && nostr !== null) { + const obj = nostr as Record + const pubkey = obj.pubkey + if (typeof pubkey === 'string' && isValidHexPubkey(pubkey)) { + const relays = Array.isArray(obj.relays) ? (obj.relays as string[]) : undefined + return { pubkey, relays } + } + } + return null + } + + // d/ namespace + if (typeof nostr === 'string' && isValidHexPubkey(nostr)) { + if (!parsed.localPart) return { pubkey: nostr } + return null + } + + if (typeof nostr === 'object' && nostr !== null) { + const obj = nostr as Record + const names = obj.names as Record | undefined + if (!names) return null + + const lookupKey = parsed.localPart || '_' + let pubkey = names[lookupKey] + + // Fallback: bare domain with no "_" entry — use sole entry if exactly one + if (!pubkey && !parsed.localPart) { + const entries = Object.entries(names).filter(([, v]) => isValidHexPubkey(v)) + if (entries.length === 1) { + pubkey = entries[0][1] + } + } + + if (!pubkey || !isValidHexPubkey(pubkey)) return null + + const relaysMap = obj.relays as Record | undefined + const relays = relaysMap?.[pubkey] + + return { pubkey, relays: relays?.length ? relays : undefined } + } + + return null +} + +function isValidHexPubkey(s: string): boolean { + return /^[0-9a-f]{64}$/.test(s) +} + +// ── Main resolution function ──────────────────────────────────────── + +/** + * Resolve a Namecoin identifier to a Nostr pubkey. + * Uses an in-memory LRU cache and direct WebSocket connection to ElectrumX. + */ +export async function resolveNamecoin(address: string): Promise { + const parsed = parseNamecoinIdentifier(address) + if (!parsed) return null + + const cacheKey = parsed.localPart ? `${parsed.namecoinName}:${parsed.localPart}` : parsed.namecoinName + + const cached = cacheGet(cacheKey) + if (cached !== undefined) return cached === SENTINEL_NOT_FOUND ? null : cached + + try { + const result = await resolveNamecoinViaWs(parsed) + cacheSet(cacheKey, result ?? SENTINEL_NOT_FOUND) + return result + } catch (err) { + console.warn('[Namecoin] Resolution failed for', address, err) + return null + } +} diff --git a/src/services/namecoin/types.ts b/src/services/namecoin/types.ts new file mode 100644 index 00000000..bf9dcde5 --- /dev/null +++ b/src/services/namecoin/types.ts @@ -0,0 +1,37 @@ +/** Namecoin NIP-05 identity resolution types */ + +/** Result of resolving a Namecoin name's Nostr data */ +export interface NamecoinNostrResult { + pubkey: string + relays?: string[] +} + +/** Parsed Namecoin identifier */ +export interface ParsedNamecoinIdentifier { + /** The raw Namecoin name, e.g. "d/example" or "id/alice" */ + namecoinName: string + /** Namespace: "d" (domain) or "id" (identity) */ + namespace: 'd' | 'id' + /** The name within the namespace, e.g. "example" */ + name: string + /** Local part for domain namespace (from user@domain.bit), undefined for root */ + localPart?: string + /** Original NIP-05 style address if applicable */ + originalAddress?: string +} + +/** Result from ElectrumX name resolution */ +export interface NameShowResult { + /** The Namecoin name (e.g. "d/example") */ + name: string + /** The name's current value (JSON string) */ + value: string + /** Transaction ID of the latest name_update */ + txid: string + /** Block height of the latest name_update */ + height: number + /** Whether the name has expired (>36000 blocks since last update) */ + expired: boolean + /** Blocks until expiry, or undefined if current height is unknown */ + expiresIn?: number +}