UNPKG

nano-pow

Version:

Proof-of-work generation and validation with WebGPU/WebGL/WASM for Nano cryptocurrency.

535 lines (519 loc) 16.5 kB
#!/usr/bin/env node // src/bin/server.ts import * as http from "node:http"; import { hash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { launch } from "puppeteer"; // src/utils/api-support/wasm.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later var wasm = { isSupported: false }; Object.defineProperty(wasm, "isSupported", { get: async function() { let isWasmSupported = false; try { const wasmBuffer = new Uint8Array([ 0, 97, 115, 109, 1, 0, 0, 0, // WASM magic header and version 1, 4, // Type section, 4 byte descriptor 1, 96, 0, 0, // 1 type, type function, 0 params, 0 returns 3, 2, // Function section, 2 byte descriptor 1, 0, // 1 function, ID 0 10, 23, // Code section, 23 bytes 1, 21, // 1 function body, 21 bytes 0, // 0 local variables 253, 12, // v128.const 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // assign i64x2 values 26, 11 // Drop result, end function ]); const module = await WebAssembly.compile(wasmBuffer); const instance = await WebAssembly.instantiate(module); isWasmSupported = instance instanceof WebAssembly.Instance; } catch (err) { console.warn("WASM is not supported in this environment.\n", err.message ?? err); isWasmSupported = false; } finally { delete this.isSupported; this.isSupported = isWasmSupported; return this.isSupported; } } }); // src/utils/api-support/webgl.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later var webgl = { isSupported: false }; Object.defineProperty(webgl, "isSupported", { get: async function() { let isWebglSupported = false; try { const gl = new OffscreenCanvas(0, 0)?.getContext?.("webgl2"); isWebglSupported = gl instanceof WebGL2RenderingContext; } catch (err) { console.warn("WebGL is not supported in this environment.\n", err); isWebglSupported = false; } finally { delete this.isSupported; this.isSupported = isWebglSupported; return this.isSupported; } } }); // src/utils/api-support/webgpu.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later var webgpu = { isSupported: false }; Object.defineProperty(webgpu, "isSupported", { get: async function() { let isWebgpuSupported = false; try { const adapter = await navigator?.gpu?.requestAdapter?.(); isWebgpuSupported = adapter instanceof GPUAdapter; } catch (err) { console.warn("WebGPU is not supported in this environment.\n", err); isWebgpuSupported = false; } finally { delete this.isSupported; this.isSupported = isWebgpuSupported; return this.isSupported; } } }); // src/utils/api-support/index.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later // src/utils/bigint.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later // src/utils/cache.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later // src/utils/logger.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later var Logger = class { isEnabled = false; groups = {}; groupStart(name) { if (this.isEnabled) { console.groupCollapsed(name); this.groups[name] = true; } } groupEnd(name) { if (this.groups[name]) { console.groupEnd(); delete this.groups[name]; } } log(...args) { if (this.isEnabled) { const datetime = new Date(Date.now()).toLocaleString(Intl.DateTimeFormat().resolvedOptions().locale ?? "en-US", { hour12: false, dateStyle: "medium", timeStyle: "medium" }); for (let i = 0; i < args.length; i++) { if (typeof args[i] === "string") { args[i] = args[i].replace(datetime, "").trimStart(); } if (args[i] instanceof Error) { if ("stack" in args[i]) { args.splice(i + 1, 0, args[i].stack); } else if ("message" in args[i]) { args.splice(i + 1, 0, args[i].message); } } } const entry = `${datetime} ${globalThis.process?.title ?? "NanoPow"}[${globalThis.process?.pid ?? "browser"}]:`; console.log(entry, ...args); globalThis.process?.send?.({ type: "console", data: `${entry} ${args}` }); } } }; // src/utils/queue.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later // src/utils/index.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later function isHexN(value, byteLength) { if (typeof value !== "string") return false; const v = value.replace(/^0[Xx]/, ""); const length = byteLength === null ? "+" : `{${2 * byteLength}}`; const r = new RegExp(`^[A-Fa-f\\d]${length}$`); return r.test(v); } function isHex8(value) { return isHexN(value, 8); } function isHex32(value) { return isHexN(value, 32); } // src/bin/server.ts //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev> //! SPDX-License-Identifier: GPL-3.0-or-later process.title = "NanoPow Server"; await writeFile(`${homedir()}/.nano-pow/server.pid`, `${process.pid} `); var logger = new Logger(); var MAX_CONNECTIONS = 1024; var MAX_HEADER_COUNT = 32; var MAX_IDLE_TIME = 3e4; var MAX_REQUEST_COUNT = 10; var MAX_REQUEST_SIZE = 256; var MAX_REQUEST_TIME = 6e4; var CONFIG = { DEBUG: false, EFFORT: 4, PORT: 5040 }; var configPatterns = { DEBUG: { r: /^[ \t]*debug[ \t]*(true|false)[ \t]*(#.*)?$/i, v: (b) => ["1", "true", "yes"].includes(b) }, EFFORT: { r: /^[ \t]*effort[ \t]*(\d{1,2})[ \t]*(#.*)?$/i, v: (n) => +n }, PORT: { r: /^[ \t]*port[ \t]*(\d{1,5})[ \t]*(#.*)?$/i, v: (n) => +n } }; async function loadConfig() { let configFile = ""; try { configFile = await readFile(join(homedir(), ".nano-pow", "config"), "utf-8"); } catch { } if (configFile.length > 0) { for (const line of configFile.split("\n")) { for (const [k, { r, v }] of Object.entries(configPatterns)) { const m = r.exec(line); if (m) CONFIG[k] = v(m[1]); } } } CONFIG.DEBUG = ["1", "true", "yes"].includes(process.env.NANO_POW_DEBUG ?? "") || CONFIG.DEBUG; CONFIG.EFFORT = +(process.env.NANO_POW_EFFORT ?? "") || CONFIG.EFFORT; CONFIG.PORT = process.send ? 0 : +(process.env.NANO_POW_PORT ?? "") || CONFIG.PORT; logger.isEnabled = CONFIG.DEBUG; if (configFile.length === 0) LOG: logger.log("config file not found"); LOG: logger.log(`config set: ${JSON.stringify(CONFIG)}`); } await loadConfig(); LOG: logger.log("starting work server"); var NanoPow = await readFile(new URL("../main.min.js", import.meta.url), "utf-8"); LOG: logger.log("launching puppeteer browser"); var browser = await launch({ handleSIGHUP: false, handleSIGINT: false, handleSIGTERM: false, headless: true, args: [ "--disable-vulkan-surface", "--enable-features=Vulkan,DefaultANGLEVulkan,VulkanFromANGLE", "--enable-gpu", "--enable-unsafe-webgpu" ] }); LOG: logger.log("creating puppeteer page"); var page = await browser.newPage(); var src = `${NanoPow};window.NanoPow=NanoPow;`; var enc = `sha256-${hash("sha256", src, "base64")}`; var body = ` <!DOCTYPE html> <link rel="icon" href="data:,"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; form-action 'none'; script-src '${enc}' 'wasm-unsafe-eval'; worker-src blob: https://nanopow.invalid;"> <script type="module">${src}</script> `; LOG: logger.log("setting puppeteer request interception"); await page.setRequestInterception(true); page.on("request", (req) => { if (req.isInterceptResolutionHandled()) return; if (req.url() === "https://nanopow.invalid/") { req.respond({ status: 200, contentType: "text/html", body }); } else { req.continue(); } }); page.on("console", (msg) => logger.log(msg.text())); LOG: logger.log("navigating to https://nanopow.invalid/ to be intercepted"); await page.goto("https://nanopow.invalid/"); var NanoPowHandle = await page.waitForFunction(async () => { await window.navigator.gpu.requestAdapter(); return window.NanoPow; }); LOG: logger.log("puppeteer initialized"); var requests = /* @__PURE__ */ new Map(); setInterval(() => { const now = Date.now(); for (const [ip, { time }] of requests) { if (time < now - MAX_REQUEST_TIME) { requests.delete(ip); } } }, MAX_REQUEST_TIME); async function work(data) { let resBody = "request failed"; if (data == null || typeof data !== "object") { LOG: logger.log("Failed to parse request"); throw new Error(resBody); } if (Object.getPrototypeOf(data) !== Object.prototype) { LOG: logger.log("Data corrupted."); throw new Error(resBody); } const dataParsed = data; let { action, hash: hash2, work: work2, difficulty } = dataParsed; if (action !== "work_generate" && action !== "work_validate") { LOG: logger.log("Action must be work_generate or work_validate."); throw new Error(resBody); } resBody = `${action} failed`; if (!isHex32(hash2)) { LOG: logger.log("Hash must be hex char(64)"); throw new Error(resBody); } if (difficulty !== void 0 && !isHex8(difficulty)) { LOG: logger.log("Difficulty must be hex char(16)."); throw new Error(resBody); } if (action === "work_validate") { if (!isHex8(work2)) { LOG: logger.log("Work must be hex char(16)."); throw new Error(resBody); } } else { let f2 = function(n) { if (false) throw new Error(); }; var f = f2; f2(work2); } const options = { api: "webgpu", debug: CONFIG.DEBUG, effort: CONFIG.EFFORT, difficulty }; try { const result = action === "work_validate" ? await page.evaluate((n, w, h, o) => { if (n == null) throw new Error("NanoPow not found"); return n.work_validate(w, h, o); }, NanoPowHandle, work2, hash2, options) : await page.evaluate((n, h, o) => { if (n == null) throw new Error("NanoPow not found"); return n.work_generate(h, o); }, NanoPowHandle, hash2, options); resBody = JSON.stringify(result); return resBody; } catch (err) { LOG: logger.log(err); throw new Error(resBody); } } function get(res) { res.writeHead(200, { "Content-Type": "text/plain" }).end(`Usage: Send POST request to server URL to generate or validate Nano proof-of-work Generate work for a HASH with an optional DIFFICULTY: curl -d '{ action: "work_generate", hash: HASH, difficulty?: DIFFICULTY }' Validate WORK previously calculated for a HASH with an optional DIFFICULTY: curl -d '{ action: "work_validate", work: WORK, hash: HASH, difficulty?: DIFFICULTY }' HASH is a big-endian 64-character hexadecimal string. WORK is little-endian 16-character hexadecimal string. DIFFICULTY is an optional 16-character hexadecimal string (default: FFFFFFF800000000) Report bugs: <bug-nano-pow@zoso.dev> Full documentation: <https://www.npmjs.com/package/nano-pow> `); } async function post(res, reqData) { const resHeaders = { "Content-Type": "application/json" }; let resStatusCode = 500; let resBody = "request failed"; try { resBody = await work(JSON.parse(Buffer.concat(reqData).toString())); resStatusCode = 200; } catch (err) { resBody = err.message; resStatusCode = 400; } finally { res.writeHead(resStatusCode, resHeaders).end(resBody); } } function getIp(req) { const xff = req.headers["x-forwarded-for"]; const ip = typeof xff === "string" ? xff.split(",")[0].trim().replace(/^::ffff:/, "") : req.socket.remoteAddress; return ip; } function isRateLimited(ip) { if (ip === "127.0.0.1" || ip === "::1" || process.send) { return false; } const client = requests.get(ip); if (client && client.tokens-- <= 0) { LOG: logger.log(`==== Potential Abuse: ${ip} ====`); return true; } if (Date.now() - MAX_REQUEST_TIME > (client?.time ?? 0)) { requests.set(ip, { tokens: MAX_REQUEST_COUNT, time: Date.now() }); } return false; } function listen() { if (process.channel) { process.on("message", async (msg) => { if (typeof msg === "object" && msg != null && "type" in msg && typeof msg.type === "string" && "data" in msg && typeof msg.data === "string") { if (msg.type === "ipc") { const data = JSON.parse(msg.data); try { const result = await work(data); process.send?.({ type: "ipc", data: result }); } catch (err) { process.send?.({ type: "ipc", data: err.stack }); } } } }); LOG: logger.log(`server listening on ipc`); process.send?.({ type: "listening", data: "ipc" }); } else { server.listen(CONFIG.PORT, "127.0.0.1", () => { const address = server.address(); if (address == null) { LOG: logger.log(`server closed`); } else if (typeof address === "string") { LOG: logger.log(`server listening on ${address}`); process.send?.({ type: "listening", data: address }); } else { CONFIG.PORT = address.port; LOG: logger.log(`server listening on port ${address.port}`); process.send?.({ type: "listening", data: address.port }); } }); } } async function readIncomingMessage(req) { return new Promise((resolve, reject) => { const contentLength = +(req.headers["content-length"] ?? 0); if (contentLength === 0 || contentLength > MAX_REQUEST_SIZE) { reject(new Error("Content Too Large", { cause: { code: 413 } })); } const kill = setTimeout(() => { reject(new Error("Request Timeout", { cause: { code: 408 } })); }, MAX_IDLE_TIME); const reqData = []; let reqSize = 0; req.on("data", (chunk) => { reqSize += chunk.byteLength; if (reqSize > MAX_REQUEST_SIZE) { reject(new Error("Content Too Large", { cause: { code: 413 } })); } reqData.push(chunk); }); req.on("end", () => { clearTimeout(kill); resolve(reqData); }); req.once("error", reject); }); } var server = http.createServer(async (req, res) => { const ip = getIp(req); if (!ip) return res.writeHead(401).end("Unauthorized"); if (isRateLimited(ip)) return res.writeHead(429).end("Too Many Requests"); if (req.method === "POST") { try { const reqData = await readIncomingMessage(req); post(res, reqData); } catch (err) { req.socket.destroy(); return res.writeHead(err.cause?.code ?? 500).end(err.message ?? "Internal Server Error"); } } else { get(res); } }); server.headersTimeout = MAX_IDLE_TIME; server.keepAliveTimeout = MAX_IDLE_TIME; server.maxConnections = MAX_CONNECTIONS; server.maxHeadersCount = MAX_HEADER_COUNT; server.on("connection", (s) => { s.setTimeout(MAX_IDLE_TIME, () => s.destroy()); }); server.on("error", (serverErr) => { LOG: logger.log("server error", serverErr); try { shutdown(); } catch (shutdownErr) { LOG: logger.log("server failed to shut down", shutdownErr); process.exit(1); } }); function shutdown() { LOG: logger.log("shutdown signal received"); const kill = setTimeout(() => { LOG: logger.log("server unresponsive, forcefully stopped"); process.exit(1); }, 1e4); server.close(async () => { await browser.close(); clearTimeout(kill); LOG: logger.log("server stopped"); process.exit(0); }); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); process.on("SIGHUP", async () => { LOG: logger.log("reloading configuration"); server.close(async () => { await loadConfig(); await page.reload(); NanoPowHandle = await page.waitForFunction(() => { return window.NanoPow; }); listen(); }); }); listen();