diff --git a/packages/ndk/src/user/namecoin-proxy/electrumx-client.mjs b/packages/ndk/src/user/namecoin-proxy/electrumx-client.mjs new file mode 100644 index 0000000..c0e6d00 --- /dev/null +++ b/packages/ndk/src/user/namecoin-proxy/electrumx-client.mjs @@ -0,0 +1,309 @@ +// Kept for reference — the browser now connects directly via WebSocket. See ../namecoin/electrumx-ws.ts +/** + * Minimal ElectrumX JSON-RPC client over TCP/TLS for Namecoin name lookups. + * + * This is a server-side module (Node.js only) — browsers cannot open raw + * TCP sockets. See `electrumx-proxy.mjs` for an HTTP wrapper and + * `vite-plugin-namecoin.mjs` for Vite dev-server integration. + */ + +import { createHash } from "node:crypto"; +import net from "node:net"; +import tls from "node:tls"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default ElectrumX server for Namecoin. */ +export const DEFAULT_HOST = "nmc2.bitcoins.sk"; +export const DEFAULT_PORT = 57001; +export const DEFAULT_TLS = true; + +/** Namecoin names expire after this many blocks without renewal. */ +export const NAME_EXPIRE_DEPTH = 36000; + +// --------------------------------------------------------------------------- +// Script-hash computation +// --------------------------------------------------------------------------- + +/** + * Build the canonical name-index script for a Namecoin name. + * + * Layout: OP_NAME_UPDATE OP_2DROP OP_DROP OP_RETURN + * 0x53 push push 0x6d 0x75 0x6a + * + * @param {string} name Full name including namespace, e.g. "d/example". + * @returns {Buffer} + */ +export function buildNameScript(name) { + const nameBytes = Buffer.from(name, "utf-8"); + + const parts = []; + + // OP_NAME_UPDATE (0x53) + parts.push(Buffer.from([0x53])); + + // push + if (nameBytes.length < 0x4c) { + parts.push(Buffer.from([nameBytes.length])); + } else if (nameBytes.length <= 0xff) { + parts.push(Buffer.from([0x4c, nameBytes.length])); + } else { + // OP_PUSHDATA2 + const buf = Buffer.alloc(3); + buf[0] = 0x4d; + buf.writeUInt16LE(nameBytes.length, 1); + parts.push(buf); + } + parts.push(nameBytes); + + // push (zero-length push) + parts.push(Buffer.from([0x00])); + + // OP_2DROP OP_DROP OP_RETURN + parts.push(Buffer.from([0x6d, 0x75, 0x6a])); + + return Buffer.concat(parts); +} + +/** + * Compute the ElectrumX script-hash for a name. + * + * This is SHA-256 of the script, byte-reversed (big-endian hex). + * + * @param {string} name Full name, e.g. "d/example". + * @returns {string} Hex script-hash. + */ +export function nameScripthash(name) { + const script = buildNameScript(name); + const hash = createHash("sha256").update(script).digest(); + // Reverse for ElectrumX convention + const reversed = Buffer.from(hash); + reversed.reverse(); + return reversed.toString("hex"); +} + +// --------------------------------------------------------------------------- +// ElectrumX JSON-RPC client +// --------------------------------------------------------------------------- + +/** + * Open a connection to an ElectrumX server. + * + * @param {{ host?: string, port?: number, useTls?: boolean, timeout?: number }} opts + * @returns {Promise<{ call: (method: string, params: any[]) => Promise, close: () => void }>} + */ +export function connect(opts = {}) { + const { + host = DEFAULT_HOST, + port = DEFAULT_PORT, + useTls = DEFAULT_TLS, + timeout = 15_000, + } = opts; + + return new Promise((resolve, reject) => { + let buffer = ""; + let nextId = 1; + const pending = new Map(); + + const socketOpts = { host, port, rejectUnauthorized: false }; + const socket = useTls ? tls.connect(socketOpts) : net.createConnection(socketOpts); + + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`ElectrumX connect timeout (${host}:${port})`)); + }, timeout); + + socket.setEncoding("utf-8"); + + socket.on("connect", () => { + clearTimeout(timer); + resolve({ call, close }); + }); + + // TLS uses 'secureConnect' instead of 'connect' + if (useTls) { + socket.on("secureConnect", () => { + clearTimeout(timer); + resolve({ call, close }); + }); + } + + socket.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + try { + const msg = JSON.parse(line); + const cb = pending.get(msg.id); + if (cb) { + pending.delete(msg.id); + if (msg.error) cb.reject(new Error(JSON.stringify(msg.error))); + else cb.resolve(msg.result); + } + } catch { /* ignore malformed lines */ } + } + }); + + socket.on("error", (err) => { + clearTimeout(timer); + for (const cb of pending.values()) cb.reject(err); + pending.clear(); + reject(err); + }); + + socket.on("close", () => { + for (const cb of pending.values()) cb.reject(new Error("Connection closed")); + pending.clear(); + }); + + function call(method, params = []) { + return new Promise((res, rej) => { + const id = nextId++; + pending.set(id, { resolve: res, reject: rej }); + const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; + socket.write(msg); + + setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + rej(new Error(`RPC timeout: ${method}`)); + } + }, timeout); + }); + } + + function close() { + socket.destroy(); + } + }); +} + +// --------------------------------------------------------------------------- +// NAME_UPDATE value parser +// --------------------------------------------------------------------------- + +/** + * Parse the value from a NAME_UPDATE transaction's raw hex. + * + * Scans the scriptPubKey outputs for the pattern: + * OP_NAME_UPDATE OP_2DROP OP_DROP + * + * @param {string} rawHex Raw transaction hex. + * @param {string} expectedName The name we expect (e.g. "d/example"). + * @returns {{ value: string, name: string } | null} + */ +export function parseNameValue(rawHex, expectedName) { + const tx = Buffer.from(rawHex, "hex"); + // Quick scan: find OP_NAME_UPDATE (0x53) followed by the name bytes + const nameBytes = Buffer.from(expectedName, "utf-8"); + + for (let i = 0; i < tx.length - nameBytes.length - 4; i++) { + if (tx[i] !== 0x53) continue; + + // Read push length for name + let pos = i + 1; + let nameLen; + if (tx[pos] < 0x4c) { + nameLen = tx[pos]; + pos += 1; + } else if (tx[pos] === 0x4c) { + nameLen = tx[pos + 1]; + pos += 2; + } else if (tx[pos] === 0x4d) { + nameLen = tx.readUInt16LE(pos + 1); + pos += 3; + } else { + continue; + } + + if (pos + nameLen > tx.length) continue; + const foundName = tx.slice(pos, pos + nameLen).toString("utf-8"); + if (foundName !== expectedName) continue; + pos += nameLen; + + // Read push length for value + if (pos >= tx.length) continue; + let valLen; + if (tx[pos] < 0x4c) { + valLen = tx[pos]; + pos += 1; + } else if (tx[pos] === 0x4c) { + valLen = tx[pos + 1]; + pos += 2; + } else if (tx[pos] === 0x4d) { + valLen = tx.readUInt16LE(pos + 1); + pos += 3; + } else if (tx[pos] === 0x4e) { + valLen = tx.readUInt32LE(pos + 1); + pos += 5; + } else { + continue; + } + + if (pos + valLen > tx.length) continue; + const value = tx.slice(pos, pos + valLen).toString("utf-8"); + return { name: foundName, value }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// High-level resolver +// --------------------------------------------------------------------------- + +/** + * Resolve a Namecoin name to its current JSON value via ElectrumX. + * + * @param {string} name Full name, e.g. "d/example" or "id/alice". + * @param {{ host?: string, port?: number, useTls?: boolean, timeout?: number }} opts + * @returns {Promise<{ name: string, value: object|string, height: number, currentHeight: number, expired: boolean } | null>} + */ +export async function resolveName(name, opts = {}) { + const client = await connect(opts); + + try { + const scripthash = nameScripthash(name); + + // Get history for this name script + const history = await client.call("blockchain.scripthash.get_history", [scripthash]); + if (!history || history.length === 0) return null; + + // Latest transaction (highest height or mempool) + const latest = history[history.length - 1]; + const txHash = latest.tx_hash; + const txHeight = latest.height; + + // Get raw transaction + const rawHex = await client.call("blockchain.transaction.get", [txHash]); + const parsed = parseNameValue(rawHex, name); + if (!parsed) return null; + + // Get current blockchain height for expiry check + const headerHex = await client.call("blockchain.headers.subscribe", []); + const currentHeight = headerHex?.height ?? headerHex?.block_height ?? 0; + + const expired = txHeight > 0 && currentHeight - txHeight > NAME_EXPIRE_DEPTH; + + let value; + try { + value = JSON.parse(parsed.value); + } catch { + value = parsed.value; + } + + return { + name, + value, + height: txHeight, + currentHeight, + expired, + }; + } finally { + client.close(); + } +} diff --git a/packages/ndk/src/user/namecoin-proxy/electrumx-proxy.mjs b/packages/ndk/src/user/namecoin-proxy/electrumx-proxy.mjs new file mode 100644 index 0000000..5697199 --- /dev/null +++ b/packages/ndk/src/user/namecoin-proxy/electrumx-proxy.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// Kept for reference — the browser now connects directly via WebSocket. See ../namecoin/electrumx-ws.ts +/** + * Standalone HTTP proxy that translates browser-friendly GET requests into + * ElectrumX JSON-RPC calls for Namecoin name resolution. + * + * Usage: + * node electrumx-proxy.mjs [--port 8080] [--host nmc2.bitcoins.sk] [--electrum-port 57001] + * + * API: + * GET /?name=d/example + * → { "name": "d/example", "value": { ... }, "height": 123456, "currentHeight": 654321, "expired": false } + * + * GET /?name=id/alice + * → { "name": "id/alice", "value": { ... }, ... } + * + * Errors: + * 404 — name not found + * 400 — missing ?name= parameter + * 502 — ElectrumX unreachable + */ + +import http from "node:http"; +import { resolveName } from "./electrumx-client.mjs"; + +// --------------------------------------------------------------------------- +// CLI args +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +function arg(name, fallback) { + const idx = args.indexOf(name); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; +} + +const HTTP_PORT = Number(arg("--port", "8080")); +const ELECTRUMX_HOST = arg("--host", undefined); +const ELECTRUMX_PORT = Number(arg("--electrum-port", "0")) || undefined; + +const electrumOpts = {}; +if (ELECTRUMX_HOST) electrumOpts.host = ELECTRUMX_HOST; +if (ELECTRUMX_PORT) electrumOpts.port = ELECTRUMX_PORT; + +// --------------------------------------------------------------------------- +// In-memory cache (5 min TTL) +// --------------------------------------------------------------------------- + +const cache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; + +function cached(name) { + const entry = cache.get(name); + if (!entry) return undefined; + if (Date.now() - entry.ts > CACHE_TTL) { + cache.delete(name); + return undefined; + } + return entry.data; +} + +function cacheStore(name, data) { + if (cache.size > 2000) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(name, { data, ts: Date.now() }); +} + +// --------------------------------------------------------------------------- +// HTTP server +// --------------------------------------------------------------------------- + +const server = http.createServer(async (req, res) => { + // CORS + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url, `http://${req.headers.host}`); + const name = url.searchParams.get("name"); + + if (!name) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing ?name= parameter" })); + return; + } + + // Validate namespace + if (!name.startsWith("d/") && !name.startsWith("id/")) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Name must start with d/ or id/" })); + return; + } + + // Check cache + const hit = cached(name); + if (hit !== undefined) { + res.writeHead(hit ? 200 : 404, { "Content-Type": "application/json" }); + res.end(JSON.stringify(hit || { error: "Name not found", name })); + return; + } + + try { + const result = await resolveName(name, electrumOpts); + if (!result) { + cacheStore(name, null); + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Name not found", name })); + return; + } + + cacheStore(name, result); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result)); + } catch (err) { + console.error("ElectrumX error:", err.message); + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "ElectrumX unavailable", detail: err.message })); + } +}); + +server.listen(HTTP_PORT, () => { + console.log(`Namecoin ElectrumX proxy listening on http://localhost:${HTTP_PORT}`); + console.log(` ElectrumX: ${ELECTRUMX_HOST || "nmc2.bitcoins.sk"}:${ELECTRUMX_PORT || 57001}`); + console.log(` Try: curl "http://localhost:${HTTP_PORT}/?name=d/example"`); +}); diff --git a/packages/ndk/src/user/namecoin-proxy/vite-plugin-namecoin.mjs b/packages/ndk/src/user/namecoin-proxy/vite-plugin-namecoin.mjs new file mode 100644 index 0000000..ffcd9f1 --- /dev/null +++ b/packages/ndk/src/user/namecoin-proxy/vite-plugin-namecoin.mjs @@ -0,0 +1,109 @@ +// Kept for reference — the browser now connects directly via WebSocket. See ../namecoin/electrumx-ws.ts +/** + * Vite dev-server plugin that adds a `/__namecoin` middleware, proxying + * Namecoin name lookups to an ElectrumX server via the built-in client. + * + * Usage in `vite.config.ts`: + * ```ts + * import namecoinPlugin from "@anthropic/ndk/src/user/namecoin-proxy/vite-plugin-namecoin.mjs"; + * + * export default defineConfig({ + * plugins: [namecoinPlugin()], + * }); + * ``` + * + * The plugin only activates during `serve` (dev mode). + * + * Browser code can then call `fetch("/__namecoin?name=d/example")` — which is + * exactly what `resolveNamecoinIdentifier()` does by default. + */ + +import { resolveName } from "./electrumx-client.mjs"; + +/** + * @param {{ path?: string, host?: string, port?: number, useTls?: boolean, cacheTtl?: number }} opts + */ +export default function namecoinPlugin(opts = {}) { + const { + path: prefix = "/__namecoin", + cacheTtl = 5 * 60 * 1000, + ...electrumOpts + } = opts; + + // Simple in-memory cache + const cache = new Map(); + + function cached(name) { + const entry = cache.get(name); + if (!entry) return undefined; + if (Date.now() - entry.ts > cacheTtl) { + cache.delete(name); + return undefined; + } + return entry; + } + + function cacheStore(name, data) { + if (cache.size > 2000) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(name, { data, ts: Date.now() }); + } + + return { + name: "vite-plugin-namecoin", + apply: "serve", + + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url, "http://localhost"); + if (!url.pathname.startsWith(prefix)) return next(); + + // CORS + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + const name = url.searchParams.get("name"); + if (!name || (!name.startsWith("d/") && !name.startsWith("id/"))) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing or invalid ?name= parameter" })); + return; + } + + // Check cache + const hit = cached(name); + if (hit !== undefined) { + const status = hit.data ? 200 : 404; + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(hit.data || { error: "Name not found", name })); + return; + } + + try { + const result = await resolveName(name, electrumOpts); + cacheStore(name, result); + + if (!result) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Name not found", name })); + return; + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result)); + } catch (err) { + console.error("[namecoin-plugin] ElectrumX error:", err.message); + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "ElectrumX unavailable", detail: err.message })); + } + }); + }, + }; +} diff --git a/packages/ndk/src/user/namecoin.ts b/packages/ndk/src/user/namecoin.ts new file mode 100644 index 0000000..9ce9b74 --- /dev/null +++ b/packages/ndk/src/user/namecoin.ts @@ -0,0 +1,13 @@ +/** + * @deprecated — This file is kept for backward compatibility. + * All Namecoin logic has moved to ./namecoin/ (WebSocket-based, no proxy). + * See ./namecoin/index.ts for the new entry point. + */ +export { + isNamecoinIdentifier, + parseNamecoinIdentifier, + extractProfileFromValue, + resolveNamecoinIdentifier, +} from "./namecoin/index.js"; + +export type { ParsedNamecoinId } from "./namecoin/index.js"; diff --git a/packages/ndk/src/user/namecoin/constants.ts b/packages/ndk/src/user/namecoin/constants.ts new file mode 100644 index 0000000..d413380 --- /dev/null +++ b/packages/ndk/src/user/namecoin/constants.ts @@ -0,0 +1,31 @@ +/** + * Constants for Namecoin ElectrumX WebSocket communication. + */ + +/** WebSocket server endpoint. */ +export interface ElectrumxWsServer { + url: string; + /** Optional label for logging. */ + label?: string; +} + +/** Default ElectrumX WebSocket servers (TLS primary, plaintext fallback). */ +export const DEFAULT_ELECTRUMX_SERVERS: ElectrumxWsServer[] = [ + { url: "wss://electrumx.testls.space:50004", label: "wss-primary" }, + { url: "ws://electrumx.testls.space:50003", label: "ws-fallback" }, +]; + +/** Names expire after this many blocks without renewal. */ +export const NAME_EXPIRE_DEPTH = 36000; + +/** Default cache TTL for resolved names (5 minutes). */ +export const DEFAULT_CACHE_TTL = 5 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Namecoin script OP codes used to decode name_show values +// --------------------------------------------------------------------------- + +export const OP_NAME_UPDATE = 0x04; +export const OP_NAME_NEW = 0x01; +export const OP_DROP = 0x75; +export const OP_2DROP = 0x6d; diff --git a/packages/ndk/src/user/namecoin/electrumx-ws.ts b/packages/ndk/src/user/namecoin/electrumx-ws.ts new file mode 100644 index 0000000..17a64d7 --- /dev/null +++ b/packages/ndk/src/user/namecoin/electrumx-ws.ts @@ -0,0 +1,248 @@ +/** + * Browser-native WebSocket client for ElectrumX Namecoin name lookups. + * + * No Node.js dependencies — uses the browser WebSocket API and Web Crypto API. + * JSON-RPC messages are newline-delimited text frames. + * + * Based on the approach from hzrd149/nostrudel#352. + */ + +import { + DEFAULT_ELECTRUMX_SERVERS, + NAME_EXPIRE_DEPTH, + type ElectrumxWsServer, +} from "./constants.js"; +import type { NameShowResult } from "./types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** SHA-256 hash using Web Crypto API, returns hex string. */ +async function sha256Hex(data: Uint8Array): Promise { + const hash = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** Encode a string to UTF-8 bytes. */ +function utf8Encode(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +/** Build the script hash for an ElectrumX `name_show` query. */ +async function nameScriptHash(fullName: string): Promise { + // Namecoin name script: OP_NAME_UPDATE OP_2DROP OP_DROP + // For querying, ElectrumX uses a simplified script hash. + // The script is: 0x04 + const nameBytes = utf8Encode(fullName); + const script = new Uint8Array(2 + nameBytes.length); + script[0] = 0x04; // OP_NAME_UPDATE + script[1] = nameBytes.length; + script.set(nameBytes, 2); + + // ElectrumX expects the hash of the full output script + const hash = await sha256Hex(script); + // Reverse byte order (little-endian) + return hash.match(/../g)!.reverse().join(""); +} + +// --------------------------------------------------------------------------- +// JSON-RPC over WebSocket +// --------------------------------------------------------------------------- + +interface RpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params: unknown[]; +} + +interface RpcResponse { + jsonrpc: string; + id: number; + result?: unknown; + error?: { code: number; message: string }; +} + +/** + * Open a WebSocket, send a batch of JSON-RPC requests, collect responses, + * then close the connection. + */ +function wsRpcBatch( + serverUrl: string, + requests: RpcRequest[], + timeoutMs = 10_000, +): Promise { + return new Promise((resolve, reject) => { + const responses = new Map(); + let buffer = ""; + let settled = false; + + const ws = new WebSocket(serverUrl); + + const timer = setTimeout(() => { + if (!settled) { + settled = true; + ws.close(); + reject(new Error(`WebSocket RPC timeout after ${timeoutMs}ms`)); + } + }, timeoutMs); + + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + ws.close(); + resolve(requests.map((r) => responses.get(r.id)!).filter(Boolean)); + }; + + ws.onopen = () => { + for (const req of requests) { + ws.send(JSON.stringify(req) + "\n"); + } + }; + + ws.onmessage = (event: MessageEvent) => { + buffer += typeof event.data === "string" ? event.data : ""; + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const resp = JSON.parse(trimmed) as RpcResponse; + if (resp.id !== undefined) { + responses.set(resp.id, resp); + } + } catch { + // skip malformed lines + } + } + + if (responses.size >= requests.length) { + finish(); + } + }; + + ws.onerror = (err) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(new Error(`WebSocket error: ${err}`)); + } + }; + + ws.onclose = () => { + if (!settled) { + // Process any remaining buffer + if (buffer.trim()) { + try { + const resp = JSON.parse(buffer.trim()) as RpcResponse; + if (resp.id !== undefined) responses.set(resp.id, resp); + } catch { + // ignore + } + } + finish(); + } + }; + }); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Look up a Namecoin name via ElectrumX WebSocket. + * + * @param fullName Full Namecoin name, e.g. "d/example" or "id/alice" + * @param serverUrl WebSocket URL of the ElectrumX server + * @returns NameShowResult or null if not found / expired + */ +export async function nameShowWs( + fullName: string, + serverUrl: string = DEFAULT_ELECTRUMX_SERVERS[0].url, +): Promise { + const scriptHash = await nameScriptHash(fullName); + + const responses = await wsRpcBatch(serverUrl, [ + { jsonrpc: "2.0", id: 1, method: "blockchain.scripthash.listunspent", params: [scriptHash] }, + { jsonrpc: "2.0", id: 2, method: "blockchain.headers.subscribe", params: [] }, + ]); + + const unspentResp = responses.find((r) => r.id === 1); + const headerResp = responses.find((r) => r.id === 2); + + if (!unspentResp?.result || !headerResp?.result) return null; + + const utxos = unspentResp.result as Array<{ tx_hash: string; tx_pos: number; height: number; value: number }>; + if (utxos.length === 0) return null; + + // Use the UTXO with the greatest height (most recent update) + const utxo = utxos.reduce((a, b) => (a.height > b.height ? a : b)); + const currentHeight = (headerResp.result as { height: number }).height; + const expiresIn = NAME_EXPIRE_DEPTH - (currentHeight - utxo.height); + const expired = expiresIn <= 0; + + // Now fetch the actual transaction to get the name value + const txResponses = await wsRpcBatch(serverUrl, [ + { jsonrpc: "2.0", id: 3, method: "blockchain.transaction.get", params: [utxo.tx_hash, true] }, + ]); + + const txResp = txResponses.find((r) => r.id === 3); + if (!txResp?.result) return null; + + const tx = txResp.result as { vout: Array<{ scriptPubKey: { nameOp?: { op: string; name: string; value: string } } }> }; + + // Find the vout with a nameOp + let nameValue: string | null = null; + for (const vout of tx.vout) { + const nameOp = vout.scriptPubKey?.nameOp; + if (nameOp && (nameOp.op === "name_update" || nameOp.op === "name_firstupdate")) { + nameValue = nameOp.value; + break; + } + } + + if (nameValue === null) return null; + + return { + name: fullName, + value: nameValue, + txid: utxo.tx_hash, + height: utxo.height, + expired, + expiresIn, + }; +} + +/** + * Try multiple ElectrumX WebSocket servers in order, returning the first + * successful result. + * + * @param fullName Full Namecoin name, e.g. "d/example" + * @param servers List of servers to try (defaults to DEFAULT_ELECTRUMX_SERVERS) + */ +export async function nameShowWithFallback( + fullName: string, + servers: ElectrumxWsServer[] = DEFAULT_ELECTRUMX_SERVERS, +): Promise { + let lastError: unknown; + + for (const server of servers) { + try { + const result = await nameShowWs(fullName, server.url); + return result; + } catch (e) { + lastError = e; + console.warn(`ElectrumX WS failed for ${server.label ?? server.url}:`, e); + } + } + + console.error("All ElectrumX WebSocket servers failed for", fullName, lastError); + return null; +} diff --git a/packages/ndk/src/user/namecoin/index.ts b/packages/ndk/src/user/namecoin/index.ts new file mode 100644 index 0000000..f71895e --- /dev/null +++ b/packages/ndk/src/user/namecoin/index.ts @@ -0,0 +1,21 @@ +/** + * Namecoin NIP-05 identity resolution — barrel re-exports. + */ + +export type { ParsedNamecoinId } from "./types.js"; +export type { NameShowResult } from "./types.js"; + +export { + isNamecoinIdentifier, + parseNamecoinIdentifier, + extractProfileFromValue, + resolveNamecoinIdentifier, +} from "./resolver.js"; + +export { nameShowWs, nameShowWithFallback } from "./electrumx-ws.js"; + +export { + DEFAULT_ELECTRUMX_SERVERS, + NAME_EXPIRE_DEPTH, + DEFAULT_CACHE_TTL, +} from "./constants.js"; diff --git a/packages/ndk/src/user/namecoin/namecoin.test.ts b/packages/ndk/src/user/namecoin/namecoin.test.ts new file mode 100644 index 0000000..c34ad7f --- /dev/null +++ b/packages/ndk/src/user/namecoin/namecoin.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "vitest"; +import { + isNamecoinIdentifier, + parseNamecoinIdentifier, + extractProfileFromValue, + type ParsedNamecoinId, +} from "./index"; + +// --------------------------------------------------------------------------- +// isNamecoinIdentifier +// --------------------------------------------------------------------------- + +describe("isNamecoinIdentifier", () => { + it("detects .bit domains", () => { + expect(isNamecoinIdentifier("example.bit")).toBe(true); + expect(isNamecoinIdentifier("alice@example.bit")).toBe(true); + expect(isNamecoinIdentifier("_@example.bit")).toBe(true); + }); + + it("detects d/ namespace", () => { + expect(isNamecoinIdentifier("d/example")).toBe(true); + expect(isNamecoinIdentifier("d/my-domain")).toBe(true); + }); + + it("detects id/ namespace", () => { + expect(isNamecoinIdentifier("id/alice")).toBe(true); + expect(isNamecoinIdentifier("id/bob")).toBe(true); + }); + + it("rejects standard NIP-05 identifiers", () => { + expect(isNamecoinIdentifier("alice@example.com")).toBe(false); + expect(isNamecoinIdentifier("bob@nostr.com")).toBe(false); + expect(isNamecoinIdentifier("example.com")).toBe(false); + }); + + it("rejects empty/invalid input", () => { + expect(isNamecoinIdentifier("")).toBe(false); + expect(isNamecoinIdentifier(" ")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// parseNamecoinIdentifier +// --------------------------------------------------------------------------- + +describe("parseNamecoinIdentifier", () => { + it("parses user@domain.bit", () => { + const result = parseNamecoinIdentifier("alice@example.bit"); + expect(result).toEqual({ + namecoinName: "d/example", + localPart: "alice", + namespace: "d", + }); + }); + + it("parses _@domain.bit as root", () => { + const result = parseNamecoinIdentifier("_@example.bit"); + expect(result).toEqual({ + namecoinName: "d/example", + localPart: "_", + namespace: "d", + }); + }); + + it("parses bare domain.bit as root", () => { + const result = parseNamecoinIdentifier("example.bit"); + expect(result).toEqual({ + namecoinName: "d/example", + localPart: "_", + namespace: "d", + }); + }); + + it("parses d/ namespace", () => { + const result = parseNamecoinIdentifier("d/myname"); + expect(result).toEqual({ + namecoinName: "d/myname", + localPart: "_", + namespace: "d", + }); + }); + + it("parses id/ namespace", () => { + const result = parseNamecoinIdentifier("id/alice"); + expect(result).toEqual({ + namecoinName: "id/alice", + namespace: "id", + }); + }); + + it("normalises to lowercase", () => { + const result = parseNamecoinIdentifier("Alice@Example.BIT"); + expect(result.namecoinName).toBe("d/example"); + expect(result.localPart).toBe("alice"); + }); + + it("throws on invalid input", () => { + expect(() => parseNamecoinIdentifier("alice@example.com")).toThrow(); + expect(() => parseNamecoinIdentifier("d/")).toThrow(); + expect(() => parseNamecoinIdentifier("id/")).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// extractProfileFromValue +// --------------------------------------------------------------------------- + +const FAKE_PUBKEY = "a".repeat(64); +const FAKE_PUBKEY2 = "b".repeat(64); + +describe("extractProfileFromValue (d/ domain)", () => { + const domainParsed: ParsedNamecoinId = { namecoinName: "d/example", localPart: "alice", namespace: "d" }; + const rootParsed: ParsedNamecoinId = { namecoinName: "d/example", localPart: "_", namespace: "d" }; + + it("extracts from NIP-05-style names map", () => { + const value = { + nostr: { + names: { alice: FAKE_PUBKEY }, + relays: { [FAKE_PUBKEY]: ["wss://relay.example.com"] }, + }, + }; + const result = extractProfileFromValue(value, domainParsed); + expect(result).toEqual({ + pubkey: FAKE_PUBKEY, + relays: ["wss://relay.example.com"], + }); + }); + + it("extracts root from simple string form", () => { + const value = { nostr: FAKE_PUBKEY }; + const result = extractProfileFromValue(value, rootParsed); + expect(result).toEqual({ pubkey: FAKE_PUBKEY }); + }); + + it("returns null for sub-user with simple string form", () => { + const value = { nostr: FAKE_PUBKEY }; + const result = extractProfileFromValue(value, domainParsed); + expect(result).toBeNull(); + }); + + it("returns null when user not in names map", () => { + const value = { nostr: { names: { bob: FAKE_PUBKEY2 } } }; + const result = extractProfileFromValue(value, domainParsed); + expect(result).toBeNull(); + }); + + it("returns null for missing nostr key", () => { + const value = { dns: "1.2.3.4" }; + const result = extractProfileFromValue(value, domainParsed); + expect(result).toBeNull(); + }); +}); + +describe("extractProfileFromValue (id/ identity)", () => { + const idParsed: ParsedNamecoinId = { namecoinName: "id/alice", namespace: "id" }; + + it("extracts from simple string form", () => { + const value = { nostr: FAKE_PUBKEY }; + const result = extractProfileFromValue(value, idParsed); + expect(result).toEqual({ pubkey: FAKE_PUBKEY }); + }); + + it("extracts from object form with relays", () => { + const value = { + nostr: { + pubkey: FAKE_PUBKEY, + relays: ["wss://relay1.example.com", "wss://relay2.example.com"], + }, + }; + const result = extractProfileFromValue(value, idParsed); + expect(result).toEqual({ + pubkey: FAKE_PUBKEY, + relays: ["wss://relay1.example.com", "wss://relay2.example.com"], + }); + }); + + it("returns null for invalid pubkey", () => { + const value = { nostr: "not-a-hex-pubkey" }; + const result = extractProfileFromValue(value, idParsed); + expect(result).toBeNull(); + }); + + it("returns null for null/undefined value", () => { + expect(extractProfileFromValue(null, idParsed)).toBeNull(); + expect(extractProfileFromValue(undefined, idParsed)).toBeNull(); + }); +}); diff --git a/packages/ndk/src/user/namecoin/resolver.ts b/packages/ndk/src/user/namecoin/resolver.ts new file mode 100644 index 0000000..864338d --- /dev/null +++ b/packages/ndk/src/user/namecoin/resolver.ts @@ -0,0 +1,253 @@ +/** + * Namecoin NIP-05 identity resolution. + * + * Namecoin is a decentralised naming blockchain. Names in the d/ (domain) and + * id/ (identity) namespaces can store Nostr public keys, enabling NIP-05-style + * identity verification without centralised HTTP servers. + * + * Resolution uses browser-native WebSocket connections to ElectrumX servers — + * no backend proxy required. See `./electrumx-ws.ts`. + */ + +import type { NDK } from "../../ndk/index.js"; +import type { ProfilePointer } from "../index.js"; +import type { ParsedNamecoinId } from "./types.js"; +import { nameShowWithFallback } from "./electrumx-ws.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Hex regex for a 64-char lowercase hex pubkey. */ +const HEX64 = /^[0-9a-f]{64}$/; + +// --------------------------------------------------------------------------- +// LRU cache +// --------------------------------------------------------------------------- + +interface CacheEntry { + result: ProfilePointer | null; + ts: number; +} + +const CACHE_MAX = 500; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +const cache = new Map(); + +function cacheGet(key: string): ProfilePointer | null | undefined { + const entry = cache.get(key); + if (!entry) return undefined; + if (Date.now() - entry.ts > CACHE_TTL_MS) { + cache.delete(key); + return undefined; + } + // LRU: move to end + cache.delete(key); + cache.set(key, entry); + return entry.result; +} + +function cacheSet(key: string, result: ProfilePointer | null): void { + cache.delete(key); // ensure fresh position + if (cache.size >= CACHE_MAX) { + // evict oldest (first key) + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, { result, ts: Date.now() }); +} + +// --------------------------------------------------------------------------- +// Public helpers +// --------------------------------------------------------------------------- + +/** + * Returns `true` when the identifier looks like a Namecoin NIP-05 address. + * + * Recognised formats: + * - `alice@example.bit` (domain .bit suffix) + * - `_@example.bit` / `example.bit` + * - `d/example` (direct domain namespace) + * - `id/alice` (identity namespace) + */ +export function isNamecoinIdentifier(id: string): boolean { + if (!id) return false; + const s = id.trim().toLowerCase(); + if (s.startsWith("d/") || s.startsWith("id/")) return true; + // .bit domain – either bare or user@domain.bit + if (s.endsWith(".bit")) return true; + if (s.includes("@") && s.split("@").pop()?.endsWith(".bit")) return true; + return false; +} + +/** + * Parse a raw identifier string into its Namecoin components. + * + * @throws if the identifier is not a recognised Namecoin format. + */ +export function parseNamecoinIdentifier(raw: string): ParsedNamecoinId { + const s = raw.trim().toLowerCase(); + + // Direct namespace: d/name or id/name + if (s.startsWith("d/")) { + const name = s.slice(2); + if (!name) throw new Error(`Invalid Namecoin identifier: ${raw}`); + return { namecoinName: `d/${name}`, localPart: "_", namespace: "d" }; + } + if (s.startsWith("id/")) { + const name = s.slice(3); + if (!name) throw new Error(`Invalid Namecoin identifier: ${raw}`); + return { namecoinName: `id/${name}`, namespace: "id" }; + } + + // .bit domain: [user@]domain.bit + if (s.endsWith(".bit") || (s.includes("@") && s.split("@").pop()?.endsWith(".bit"))) { + let localPart = "_"; + let domain: string; + + if (s.includes("@")) { + const atIdx = s.indexOf("@"); + localPart = s.slice(0, atIdx) || "_"; + domain = s.slice(atIdx + 1); + } else { + domain = s; + } + + // strip .bit + const domainBase = domain.replace(/\.bit$/, ""); + if (!domainBase) throw new Error(`Invalid Namecoin identifier: ${raw}`); + return { namecoinName: `d/${domainBase}`, localPart, namespace: "d" }; + } + + throw new Error(`Not a Namecoin identifier: ${raw}`); +} + +// --------------------------------------------------------------------------- +// Value extraction +// --------------------------------------------------------------------------- + +/** + * Extract a ProfilePointer from a Namecoin JSON value. + * + * Supports the following value layouts: + * + * **d/ domain:** + * ```json + * { "nostr": { "names": { "alice": "" }, "relays": { "": ["wss://..."] } } } + * { "nostr": "" } + * ``` + * + * **id/ identity:** + * ```json + * { "nostr": "" } + * { "nostr": { "pubkey": "", "relays": ["wss://..."] } } + * ``` + */ +export function extractProfileFromValue( + value: unknown, + parsed: ParsedNamecoinId, +): ProfilePointer | null { + if (!value || typeof value !== "object") return null; + const obj = value as Record; + const nostr = obj.nostr; + if (nostr === undefined) return null; + + if (parsed.namespace === "id") { + return extractIdProfile(nostr); + } + + // d/ namespace + return extractDomainProfile(nostr, parsed.localPart ?? "_"); +} + +function extractIdProfile(nostr: unknown): ProfilePointer | null { + // Simple string form: { "nostr": "" } + if (typeof nostr === "string" && HEX64.test(nostr)) { + return { pubkey: nostr }; + } + // Object form: { "nostr": { "pubkey": "", "relays": [...] } } + if (typeof nostr === "object" && nostr !== null) { + const n = nostr as Record; + const pubkey = n.pubkey; + if (typeof pubkey === "string" && HEX64.test(pubkey)) { + const relays = Array.isArray(n.relays) + ? (n.relays as unknown[]).filter((r): r is string => typeof r === "string") + : undefined; + return { pubkey, relays: relays?.length ? relays : undefined }; + } + } + return null; +} + +function extractDomainProfile(nostr: unknown, localPart: string): ProfilePointer | null { + // Simple string form: { "nostr": "" } — root identity + if (typeof nostr === "string" && HEX64.test(nostr)) { + if (localPart === "_" || localPart === "") return { pubkey: nostr }; + return null; // simple form doesn't support sub-users + } + + if (typeof nostr !== "object" || nostr === null) return null; + const n = nostr as Record; + + // NIP-05-style names map: { "names": { "": "" }, "relays": { ... } } + const names = n.names; + if (typeof names === "object" && names !== null) { + const nm = names as Record; + const pubkey = nm[localPart.toLowerCase()]; + if (typeof pubkey === "string" && HEX64.test(pubkey)) { + const relayMap = n.relays as Record | undefined; + let relays: string[] | undefined; + if (relayMap && typeof relayMap === "object") { + const r = relayMap[pubkey]; + if (Array.isArray(r)) { + relays = r.filter((v): v is string => typeof v === "string"); + } + } + return { pubkey, relays: relays?.length ? relays : undefined }; + } + } + + return null; +} + +// --------------------------------------------------------------------------- +// Main resolver +// --------------------------------------------------------------------------- + +/** + * Resolve a Namecoin identifier to a Nostr ProfilePointer via ElectrumX + * WebSocket. No backend proxy required — connects directly from the browser. + */ +export async function resolveNamecoinIdentifier( + _ndk: NDK, + fullname: string, +): Promise { + const cached = cacheGet(fullname); + if (cached !== undefined) return cached; + + let parsed: ParsedNamecoinId; + try { + parsed = parseNamecoinIdentifier(fullname); + } catch { + return null; + } + + try { + const result = await nameShowWithFallback(parsed.namecoinName); + + if (!result || result.expired) { + cacheSet(fullname, null); + return null; + } + + const value = typeof result.value === "string" ? JSON.parse(result.value) : result.value; + const profile = extractProfileFromValue(value, parsed); + cacheSet(fullname, profile); + return profile; + } catch (e) { + console.error("Namecoin resolution failed for", fullname, e); + cacheSet(fullname, null); + return null; + } +} diff --git a/packages/ndk/src/user/namecoin/types.ts b/packages/ndk/src/user/namecoin/types.ts new file mode 100644 index 0000000..79d3ba5 --- /dev/null +++ b/packages/ndk/src/user/namecoin/types.ts @@ -0,0 +1,29 @@ +/** + * Type definitions for Namecoin name resolution. + */ + +/** Result of an ElectrumX `name_show` call. */ +export interface NameShowResult { + /** The full Namecoin name, e.g. "d/example". */ + name: string; + /** The JSON value string stored in the name record. */ + value: string; + /** Transaction ID of the last update. */ + txid: string; + /** Block height of the last update. */ + height: number; + /** Whether the name has expired. */ + expired: boolean; + /** Approximate number of blocks until expiration. */ + expiresIn: number; +} + +/** Parsed Namecoin identifier components. */ +export interface ParsedNamecoinId { + /** The Namecoin name, e.g. "d/example" or "id/alice". */ + namecoinName: string; + /** The local-part (user) within a domain name. Undefined for id/ lookups. */ + localPart?: string; + /** The namespace: "d" for domains, "id" for identities. */ + namespace: "d" | "id"; +} diff --git a/packages/ndk/src/user/nip05.ts b/packages/ndk/src/user/nip05.ts index 440b8eb..829391c 100644 --- a/packages/ndk/src/user/nip05.ts +++ b/packages/ndk/src/user/nip05.ts @@ -1,6 +1,7 @@ import type { NDK } from "../ndk"; import type { Hexpubkey, ProfilePointer } from "."; import { NDKUser } from "."; +import { isNamecoinIdentifier, resolveNamecoinIdentifier } from "./namecoin/index.js"; export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/; @@ -33,6 +34,24 @@ export async function getNip05For( } } + // Route Namecoin identifiers (.bit, d/, id/) to the + // decentralised resolver instead of the HTTP path. + if (isNamecoinIdentifier(fullname)) { + try { + const profile = await resolveNamecoinIdentifier(ndk, fullname); + if (ndk?.cacheAdapter?.saveNip05) { + ndk.cacheAdapter.saveNip05(fullname, profile); + } + return profile; + } catch (e) { + console.error("Namecoin resolution failed for", fullname, e); + if (ndk?.cacheAdapter?.saveNip05) { + ndk.cacheAdapter.saveNip05(fullname, null); + } + return null; + } + } + const match = fullname.match(NIP05_REGEX); if (!match) return null;