Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 309 additions & 0 deletions packages/ndk/src/user/namecoin-proxy/electrumx-client.mjs
Original file line number Diff line number Diff line change
@@ -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 <name> <empty> 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 <name>
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 <empty> (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<any>, 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 <name> <value> OP_2DROP OP_DROP <normal-script>
*
* @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();
}
}
Loading