From: Chris Duncan Date: Tue, 6 May 2025 21:41:36 +0000 (-0700) Subject: Extract some basic shared code into utils file and bundle it. Simplify lib exports... X-Git-Tag: v4.1.6~1 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=7381e1476808ef52ebf8e637afedce9778b6b558;p=nano-pow.git Extract some basic shared code into utils file and bundle it. Simplify lib exports. Introduce new typings for server config and PoW results. Clean up CLI and fix some minor issues. Limit Node heap to improve CLI and server performance. Refactor server to extract code into manageable helper functions, improve organization, and fix some minor issues. Restrict difficulty to maximum of network difficulty to mitigate DoS attacks. Merge shared code in work calls in GPU implementation and extract it into modular functions. Implement work queue to better manage inbound requests. Fix nonce check in compute shader. Fix test page benchmark results from not being sorted properly. Update server test script. --- diff --git a/esbuild.mjs b/esbuild.mjs index 2d9ade2..f999610 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -27,7 +27,7 @@ await build({ }) await build({ - bundle: false, + bundle: true, platform: 'node', entryPoints: [ './src/bin/cli.ts', diff --git a/src/bin/cli.ts b/src/bin/cli.ts index baac060..80ace2d 100755 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -5,7 +5,8 @@ import { spawn } from 'node:child_process' import { getRandomValues } from 'node:crypto' import { createInterface } from 'node:readline/promises' -import type { WorkGenerateResponse, WorkValidateResponse } from '#types' +import { average, isHex, isNotHex, log } from '../utils' +import { WorkGenerateResponse, WorkValidateResponse } from '#types' process.title = 'NanoPow CLI' @@ -13,10 +14,6 @@ delete process.env.NANO_POW_DEBUG delete process.env.NANO_POW_EFFORT delete process.env.NANO_POW_PORT -function log (...args: any[]): void { - if (process.env.NANO_POW_DEBUG) console.log(new Date(Date.now()).toLocaleString(), 'NanoPow', args) -} - const hashes: string[] = [] const stdinErrors: string[] = [] @@ -27,7 +24,7 @@ if (!process.stdin.isTTY) { let i = 0 for await (const line of stdin) { i++ - if (/^[0-9A-Fa-f]{64}$/.test(line)) { + if (isHex(line, 64)) { hashes.push(line) } else { stdinErrors.push(`Skipping invalid stdin input line ${i}`) @@ -54,7 +51,7 @@ If using --validate, results will also include validity properties. If validating a nonce, it must be a 16-character hexadecimal value. Effort must be a decimal number between 1-32. -Difficulty must be a hexadecimal string between 1-FFFFFFFFFFFFFFFF. +Difficulty must be a hexadecimal string between 0-FFFFFFFFFFFFFFFF. Report bugs: Full documentation: @@ -64,7 +61,7 @@ Full documentation: } const inArgs: string[] = [] -while (/^[0-9A-Fa-f]{64}$/.test(args[args.length - 1] ?? '')) { +while (isHex(args[args.length - 1], 64)) { inArgs.unshift(args.pop() as string) } hashes.push(...inArgs) @@ -79,34 +76,39 @@ for (let i = 0; i < args.length; i++) { switch (args[i]) { case ('--validate'): case ('-v'): { - if (args[i + 1] == null) throw new Error('Missing argument for work validation') - if (!/^[0-9A-Fa-f]{16}$/.test(args[i + 1])) throw new Error('Invalid work to validate') + const v = args[i + 1] + if (v == null) throw new Error('Missing argument for work validation') + if (isNotHex(v, 16)) throw new Error('Invalid work to validate') + if (hashes.length !== 1) throw new Error('Validate accepts exactly one hash') body.action = 'work_validate' - body.work = args[i + 1] + body.work = v break } case ('--difficulty'): case ('-d'): { - if (args[i + 1] == null) throw new Error('Missing argument for difficulty') - if (!/^[0-9A-Fa-f][0-9A-Fa-f]{0,15}$/.test(args[i + 1])) throw new Error('Invalid difficulty') - body.difficulty = args[i + 1] + const d = args[i + 1] + if (d == null) throw new Error('Missing argument for difficulty') + if (isNotHex(d, 1, 16)) throw new Error('Invalid difficulty') + body.difficulty = d break } case ('--effort'): case ('-e'): { - if (args[i + 1] == null) throw new Error('Missing argument for effort') - if (!/^[0-9]{0,2}$/.test(args[i + 1])) throw new Error('Invalid effort') - process.env.NANO_POW_EFFORT = args[i + 1] + const e = args[i + 1] + if (e == null) throw new Error('Missing argument for effort') + if (parseInt(e) < 1 || parseInt(e) > 32) throw new Error('Invalid effort') + process.env.NANO_POW_EFFORT = e break } case ('--benchmark'): { - if (args[i + 1] == null) throw new Error('Missing argument for benchmark') - if (!(+args[i + 1] > 0)) throw new Error('Invalid benchmark count') + const b = args[i + 1] + if (b == null) throw new Error('Missing argument for benchmark') + const count = +b + if (count < 1) throw new Error('Invalid benchmark count') const random = new Uint8Array(32) - while (hashes.length < +args[i + 1]) { + while (hashes.length < count) { getRandomValues(random) - const byteArray = [...random].map(byte => byte.toString(16).padStart(2, '0')) - hashes.push(byteArray.join('')) + hashes.push(Buffer.from(random).toString('hex')) } isBenchmark = true break @@ -135,55 +137,72 @@ for (const stdinErr of stdinErrors) { // Initialize server log('Starting NanoPow CLI') -const server = spawn(process.execPath, [new URL(import.meta.resolve('./server.js')).pathname], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }) -const port = await new Promise((resolve, reject): void => { - server.on('message', (msg: { type: string, port: number, text: string }): void => { - if (msg.type === 'console') { - log(msg.text) - } - if (msg.type === 'listening') { - if (msg.port != null) { - log(`Server listening on port ${msg.port}`) - resolve(msg.port) - } else { - reject('Server failed to provide port') - } - } - }) + +const server = spawn( + process.execPath, + [new URL(import.meta.resolve('./server.js')).pathname], + { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] } +) + +server.once('error', err => { + log(err) + process.exit(1) }) -// Execution must be sequential else GPU cannot map to CPU and will throw -const results: (WorkGenerateResponse | WorkValidateResponse)[] = [] -const aborter = new AbortController() -if (isBenchmark) console.log('Starting benchmark...') -const start = performance.now() -for (const hash of hashes) { - try { - body.hash = hash - const kill = setTimeout(() => aborter.abort(), 60000) - const response = await fetch(`http://localhost:${port}`, { - method: 'POST', - body: JSON.stringify(body), - signal: aborter.signal - }) - clearTimeout(kill) - const result = await response.json() - if (isBatch || isBenchmark) { - results.push(result) +server.on('message', async (msg: { type: string, message: number | string }): Promise => { + if (msg.type === 'console') { + log(msg.message) + } + if (msg.type === 'listening') { + const port = +msg.message + if (port > -1) { + log(`CLI server listening on port ${port}`) + try { + await execute(port) + } catch { + log(`Error executing ${body.action}`) + } } else { - console.log(result) + log('Server failed to provide port') } - } catch (err) { - log(err) } -} -const end = performance.now() -if (isBatch && !isBenchmark) console.log(results) -if (process.env.NANO_POW_DEBUG || isBenchmark) { - console.log(end - start, 'ms total |', (end - start) / hashes.length, 'ms avg') -} +}) + server.on('close', code => { log(`Server closed with exit code ${code}`) process.exit(code) }) -server.kill() + +async function execute (port: number): Promise { + // Execution must be sequential else GPU cannot map to CPU and will throw + const results: (WorkGenerateResponse | WorkValidateResponse)[] = [] + if (isBenchmark) console.log('Running benchmark...') + let start = 0 + const times: number[] = [] + for (const hash of hashes) { + try { + const aborter = new AbortController() + const kill = setTimeout(() => aborter.abort(), 60_000) + body.hash = hash + start = performance.now() + const response = await fetch(`http://localhost:${port}`, { + method: 'POST', + body: JSON.stringify(body), + signal: aborter.signal + }) + clearTimeout(kill) + const result = await response.json() + if (isBatch || isBenchmark) { + results.push(result) + times.push(performance.now() - start) + } else { + console.log(result) + } + } catch (err) { + log(err) + } + } + if (isBatch && !isBenchmark) console.log(results) + if (process.env.NANO_POW_DEBUG || isBenchmark) console.log(average(times)) + server.kill() +} diff --git a/src/bin/nano-pow.sh b/src/bin/nano-pow.sh index b9f930d..a751efa 100755 --- a/src/bin/nano-pow.sh +++ b/src/bin/nano-pow.sh @@ -10,11 +10,11 @@ NANO_POW_LOGS="$NANO_POW_HOME"/logs; mkdir -p "$NANO_POW_LOGS"; if [ "$1" = '--server' ]; then shift; - node "$SCRIPT_DIR"/server.js --max-http-header-size=1024 >> "$NANO_POW_LOGS"/nano-pow-server-$(date -I).log 2>&1 & echo "$!" > "$NANO_POW_HOME"/server.pid; + node "$SCRIPT_DIR"/server.js --max-http-header-size=1024 --max-old-space-size=256 >> "$NANO_POW_LOGS"/nano-pow-server-$(date -I).log 2>&1 & echo "$!" > "$NANO_POW_HOME"/server.pid; sleep 0.1; if [ "$(ps | grep $(cat $NANO_POW_HOME/server.pid))" = '' ]; then cat $(ls -td "$NANO_POW_LOGS"/* | head -n1); fi; else - node "$SCRIPT_DIR"/cli.js "$@"; + node "$SCRIPT_DIR"/cli.js --max-old-space-size=256 "$@"; fi; diff --git a/src/bin/server.ts b/src/bin/server.ts index 298d777..3d4fe06 100755 --- a/src/bin/server.ts +++ b/src/bin/server.ts @@ -2,51 +2,55 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { launch } from 'puppeteer' -import { subtle } from 'node:crypto' -import { readFile } from 'node:fs/promises' import * as http from 'node:http' -import { AddressInfo, Socket } from 'node:net' +import { hash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { AddressInfo } from 'node:net' import { homedir } from 'node:os' import { join } from 'node:path' -import type { NanoPowOptions, WorkGenerateRequest, WorkGenerateResponse, WorkValidateRequest, WorkValidateResponse } from '#types' -/** -* Override console logging to provide an informative prefix for each entry and -* to only output when debug mode is enabled. -*/ -function log (...args: any[]): void { - if (CONFIG.DEBUG) { - const text = `${new Date(Date.now()).toLocaleString(Intl.DateTimeFormat().resolvedOptions().locale ?? 'en-US', { hour12: false, dateStyle: 'medium', timeStyle: 'medium' })} NanoPow[${process.pid}]: ${args}` - console.log(text) - process.send?.({ type: 'console', text }) - } -} +import { launch } from 'puppeteer' + +import { isNotHex, log } from '../utils' +import { + NanoPowOptions, + NanoPowServerConfig, + WorkGenerateRequest, + WorkGenerateResponse, + WorkValidateRequest, + WorkValidateResponse +} from '#types' process.title = 'NanoPow Server' -const MAX_BODY_SIZE = 256 + const MAX_CONNECTIONS = 1024 const MAX_HEADER_COUNT = 32 -const MAX_IDLE_TIME = 5000 +const MAX_IDLE_TIME = 5_000 const MAX_REQUEST_COUNT = 10 -const MAX_REQUEST_SIZE = 1024 -const MAX_REQUEST_TIME = 60000 +const MAX_REQUEST_SIZE = 256 +const MAX_REQUEST_TIME = 60_000 -const requests: Map = new Map() -setInterval(() => { - for (const [i, t] of requests) { - if (t.time < Date.now() - MAX_REQUEST_TIME) { - requests.delete(i) - } - } -}, Math.max(MAX_REQUEST_TIME, 0)) - -const CONFIG = { +const CONFIG: NanoPowServerConfig = { DEBUG: false, EFFORT: 8, PORT: 5040 } +const configPatterns = { + DEBUG: { + r: /^[ \t]*debug[ \t]*(true|false)[ \t]*(#.*)?$/i, + v: (b: string) => ['1', 'true', 'yes'].includes(b) + }, + EFFORT: { + r: /^[ \t]*effort[ \t]*(\d{1,2})[ \t]*(#.*)?$/i, + v: (n: string) => +n + }, + PORT: { + r: /^[ \t]*port[ \t]*(\d{1,5})[ \t]*(#.*)?$/i, + v: (n: string) => +n + } +} as const + /** * Loads the server configuration, preferring environment variables over the * config file in the `.nano-pow` directory, and falling back to default values @@ -54,160 +58,256 @@ const CONFIG = { * server is running will require a server restart to take effect. */ async function loadConfig (): Promise { - let contents = null + let configFile = '' try { - contents = await readFile(join(homedir(), '.nano-pow', 'config'), 'utf-8') - } catch (err) { + configFile = await readFile(join(homedir(), '.nano-pow', 'config'), 'utf-8') + } catch { log('Config file not found') } - if (typeof contents === 'string') { - for (const line of contents.split('\n')) { - const debugMatch = line.match(/^[ \t]*debug[ \t]*(true|false)[ \t]*(#.*)?$/i) - if (Array.isArray(debugMatch)) { - CONFIG.DEBUG = debugMatch[1] === 'true' - } - const effortMatch = line.match(/^[ \t]*effort[ \t]*(\d{1,2})[ \t]*(#.*)?$/i) - if (Array.isArray(effortMatch)) { - CONFIG.EFFORT = +effortMatch[1] - } - - const portMatch = line.match(/^[ \t]*port[ \t]*(\d{1,5})[ \t]*(#.*)?$/i) - if (Array.isArray(portMatch)) { - CONFIG.PORT = +portMatch[1] + 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 = !!(process.env.NANO_POW_DEBUG) || CONFIG.DEBUG + + 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 + log(`Config loaded ${JSON.stringify(CONFIG)}`) } + await loadConfig() -process.on('SIGHUP', async (): Promise => { - log('Reloading configuration') - await loadConfig() + +// Initialize puppeteer +log('Starting NanoPow work server') +const NanoPow = await readFile(new URL('../main.min.js', import.meta.url), 'utf-8') +const browser = await launch({ + handleSIGHUP: false, + handleSIGINT: false, + handleSIGTERM: false, + headless: true, + args: [ + '--headless=new', + '--disable-vulkan-surface', + '--enable-features=Vulkan,DefaultANGLEVulkan,VulkanFromANGLE', + '--enable-gpu', + '--enable-unsafe-webgpu' + ] }) +const page = await browser.newPage() -async function respond (res: http.ServerResponse, dataBuffer: Buffer[]): Promise { - let statusCode: number = 500 - let headers: http.OutgoingHttpHeaders = { 'Content-Type': 'application/json' } - let response: string = 'request failed' - try { - const datastring = Buffer.concat(dataBuffer).toString() - if (Buffer.byteLength(datastring) > MAX_BODY_SIZE) { - throw new Error('Data too large.') +const src = `${NanoPow};window.NanoPow=NanoPowGpu;` +const enc = `sha256-${hash('sha256', src, 'base64')}` +const body = ` + + + + +` + +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 => log(msg.text())) +await page.goto('https://nanopow.invalid/') + +let NanoPowHandle = await page.waitForFunction(() => { + return window.NanoPow +}) + +log('Puppeteer initialized') + +// Track requests by IP address, and let them fall off over time +const requests = new Map() + +setInterval(() => { + const now = Date.now() + for (const [ip, { time }] of requests) { + if (time < now - MAX_REQUEST_TIME) { + requests.delete(ip) } - const data: WorkGenerateRequest | WorkValidateRequest = JSON.parse(datastring) - if (Object.getPrototypeOf(data) !== Object.prototype) { + } +}, MAX_REQUEST_TIME) + +function get (res: http.ServerResponse): void { + 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 BLOCKHASH with an optional DIFFICULTY: + curl -d '{ action: "work_generate", hash: BLOCKHASH, difficulty?: DIFFICULTY }' + +Validate WORK previously calculated for a BLOCKHASH with an optional DIFFICULTY: + curl -d '{ action: "work_validate", work: WORK, hash: BLOCKHASH, difficulty?: DIFFICULTY }' + +BLOCKHASH is a 64-character hexadecimal string. +WORK is 16-character hexadecimal string. +DIFFICULTY is a 16-character hexadecimal string (default: FFFFFFF800000000) + +Report bugs: +Full documentation: +` + ) +} + +async function post (res: http.ServerResponse, reqData: Buffer[]): Promise { + const resHeaders: http.OutgoingHttpHeaders = { 'Content-Type': 'application/json' } + let resStatusCode = 500 + let resBody = 'request failed' + + try { + const reqBody: WorkGenerateRequest | WorkValidateRequest = JSON.parse(Buffer.concat(reqData).toString()) + if (Object.getPrototypeOf(reqBody) !== Object.prototype) { throw new Error('Data corrupted.') } - const { action, hash, work, difficulty } = data + + const { action, hash, work, difficulty } = reqBody if (action !== 'work_generate' && action !== 'work_validate') { throw new Error('Action must be work_generate or work_validate.') } - response = `${action} failed` - if (!/^[0-9A-Fa-f]{64}$/.test(hash ?? '')) { + resBody = `${action} failed` + + if (isNotHex(hash, 64)) { throw new Error('Hash must be a 64-character hex string.') } - if (difficulty && !/^[0-9A-Fa-f]{0,16}$/.test(difficulty)) { + if (difficulty && isNotHex(difficulty, 1, 16)) { throw new Error('Difficulty must be a hex string between 0-FFFFFFFFFFFFFFFF.') } - if (action === 'work_validate' && !/^[0-9A-Fa-f]{16}$/.test(work ?? '')) { + if (action === 'work_validate' && isNotHex(work, 16)) { throw new Error('Work must be a 16-character hex string.') } + const options: NanoPowOptions = { debug: CONFIG.DEBUG, effort: CONFIG.EFFORT, difficulty } - switch (action) { - case ('work_generate'): { - response = JSON.stringify(await page.evaluate(async (np, hash: string, options: NanoPowOptions): Promise => { - if (np == null) throw new Error('NanoPow not found') - return await np.work_generate(hash, options) - }, npHandle, hash, options)) - break - } - case ('work_validate'): { - response = JSON.stringify(await page.evaluate(async (np, work: string, hash: string, options: NanoPowOptions): Promise => { - if (np == null) throw new Error('NanoPow not found') - return await np.work_validate(work, hash, options) - }, npHandle, work, hash, options)) - break - } - default: { - throw new Error('Action must be work_generate or work_validate.') - } - } - statusCode = 200 + const result = (action === 'work_generate') + ? await page.evaluate((n, h, o): Promise => { + if (n == null) throw new Error('NanoPow not found') + return n.work_generate(h, o) + }, NanoPowHandle, hash, options) + : await page.evaluate((n, w, h, o): Promise => { + if (n == null) throw new Error('NanoPow not found') + return n.work_validate(w, h, o) + }, NanoPowHandle, work, hash, options) + resBody = JSON.stringify(result) + resStatusCode = 200 } catch (err) { log(err) - statusCode = 400 + resStatusCode = 400 } finally { - res.writeHead(statusCode, headers).end(response) + res.writeHead(resStatusCode, resHeaders).end(resBody) } } -// Create server -const server = http.createServer((req, res): void => { +/** +* Parses headers to retrieve true client IP address. +* +* @param {IncomingMessage} req - Client request to parse +* @returns IP address from headers or socket or undefined +*/ +function getIp (req: http.IncomingMessage): string | undefined { const xff = req.headers['x-forwarded-for'] const ip = (typeof xff === 'string') ? xff.split(',')[0].trim().replace(/^::ffff:/, '') : req.socket.remoteAddress - if (ip == null) { - res.writeHead(401).end('Unauthorized') - return + return ip +} + +/** +* Checks if IP address has requested too much work recently. Ignores IPC and +* local clients which are used by the CLI. +* +* @param ip - Client IP address, possibly local +* @returns True if IP is out of time-limited tokens and should be throttled +*/ +function isRateLimited (ip: string): boolean { + if (ip === '127.0.0.1' || ip === '::1' || process.send) { + return false } const client = requests.get(ip) - if (ip === '127.0.0.1' || process.send != null || client == null || client.time < Date.now() - MAX_REQUEST_TIME) { + if (client && client.tokens-- <= 0) { + 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() }) - } else { - if (--client.tokens <= 0) { - log(`${ip} potential abuse`) - res.writeHead(429).end('Too Many Requests') - return - } } - let data: Buffer[] = [] - let reqSize = 0 - if (req.method === 'POST') { + return false +} + +/** +* Starts server listening on configured port. If spawned by IPC, sends a message +* to the parent process with the port number assigned by the operating system. +*/ +function listen (): void { + server.listen(CONFIG.PORT, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + CONFIG.PORT = port + log(`Server listening on port ${port}`) + process.send?.({ type: 'listening', message: port }) + }) +} + +/** +* Reads streaming data from an inbound client request. +* +* @param {IncomingMessage} req - Client request for which to process data +* @returns Buffer array of final data +*/ +async function readIncomingMessage (req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { const contentLength = +(req.headers['content-length'] ?? 0) - if (contentLength == 0 || contentLength > MAX_BODY_SIZE) { - res.writeHead(413).end('Content Too Large') - req.socket.destroy() - return + if (contentLength === 0 || contentLength > MAX_REQUEST_SIZE) { + reject(new Error('Content Too Large', { cause: { code: 413 } })) } - req.on('data', (chunk: Buffer): void => { + const kill = setTimeout(() => { + reject(new Error('Request Timeout', { cause: { code: 408 } })) + }, MAX_IDLE_TIME) + const reqData: Buffer[] = [] + let reqSize = 0 + req.on('data', chunk => { reqSize += chunk.byteLength if (reqSize > MAX_REQUEST_SIZE) { - res.writeHead(413).end('Content Too Large') - req.socket.destroy() - return + reject(new Error('Content Too Large', { cause: { code: 413 } })) } - data.push(chunk) + reqData.push(chunk) }) - req.on('end', async (): Promise => { - if (!req.socket.destroyed) { - await respond(res, data) - } + req.on('end', () => { + clearTimeout(kill) + resolve(reqData) }) - } else { - res.writeHead(200, { 'Content-Type': 'text/plain' }) - res.end(`Usage: Send POST request to server URL to generate or validate Nano proof-of-work - -Generate work for a BLOCKHASH with an optional DIFFICULTY: - curl -d '{ action: "work_generate", hash: BLOCKHASH, difficulty?: DIFFICULTY }' - -Validate WORK previously calculated for a BLOCKHASH with an optional DIFFICULTY: - curl -d '{ action: "work_validate", work: WORK, hash: BLOCKHASH, difficulty?: DIFFICULTY }' - -BLOCKHASH is a 64-character hexadecimal string. -WORK is 16-character hexadecimal string. -DIFFICULTY is a 16-character hexadecimal string (default: FFFFFFF800000000) + req.once('error', reject) + }) +} -Report bugs: -Full documentation: -` - ) +/** +* Creates server and configures POST data aggregation and GET response call. +*/ +const 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: any) { + req.socket.destroy() + return res.writeHead(err.cause?.code ?? 500).end(err.message ?? 'Internal Server Error') + } + } else { + get(res) } }) @@ -216,27 +316,30 @@ server.keepAliveTimeout = MAX_IDLE_TIME server.maxConnections = MAX_CONNECTIONS server.maxHeadersCount = MAX_HEADER_COUNT -server.on('connection', (c: Socket): void => { - c.setTimeout(MAX_IDLE_TIME, () => c.destroy()) +server.on('connection', s => { + s.setTimeout(MAX_IDLE_TIME, () => s.destroy()) }) -server.on('error', (e: Error): void => { - log('Server error', e) +server.on('error', serverErr => { + log('Server error', serverErr) try { shutdown() - } catch (err) { - log('Failed to shut down', err) + } catch (shutdownErr) { + log('Failed to shut down', shutdownErr) process.exit(1) } }) -// Shut down server gracefully when process is terminated +/** +* Shuts down server gracefully when process is terminated or forcefully if it +* does not respond within 10 seconds. +*/ function shutdown (): void { log('Shutdown signal received') const kill = setTimeout((): never => { log('Server unresponsive, forcefully stopped') process.exit(1) - }, 10000) + }, 10_000) server.close(async (): Promise => { await browser.close() clearTimeout(kill) @@ -244,59 +347,20 @@ function shutdown (): void { process.exit(0) }) } + process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) -// Initialize puppeteer -log('Starting NanoPow work server') -const NanoPow = await readFile(new URL('../main.min.js', import.meta.url), 'utf-8') -const browser = await launch({ - handleSIGHUP: false, - handleSIGINT: false, - handleSIGTERM: false, - headless: true, - args: [ - '--headless=new', - '--disable-vulkan-surface', - '--enable-features=Vulkan,DefaultANGLEVulkan,VulkanFromANGLE', - '--enable-gpu', - '--enable-unsafe-webgpu' - ] -}) -const page = await browser.newPage() - -const src = `${NanoPow};window.NanoPow=NanoPowGpu;` -const hash = await subtle.digest('SHA-256', Buffer.from(src)) -const enc = `sha256-${Buffer.from(hash).toString('base64')}` -const body = ` - - - - -` - -await page.setRequestInterception(true) -page.on('request', async (req): Promise => { - 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 => log(msg.text())) -await page.goto('https://nanopow.invalid/') -await page.waitForFunction(async (): Promise => { - return window.NanoPow != null +process.on('SIGHUP', async () => { + log('Reloading configuration') + server.close(async () => { + await loadConfig() + await page.reload() + NanoPowHandle = await page.waitForFunction(() => { + return window.NanoPow + }) + listen() + }) }) -const npHandle = await page.evaluateHandle(() => window.NanoPow) -log('Puppeteer initialized') - -// Listen on configured port -server.listen(CONFIG.PORT, '127.0.0.1', async (): Promise => { - const { port } = server.address() as AddressInfo - CONFIG.PORT = port - log(`Server listening on port ${port}`) - process.send?.({ type: 'listening', port }) -}) +listen() diff --git a/src/lib/gl/index.ts b/src/lib/gl/index.ts index c277d7b..0c510d5 100644 --- a/src/lib/gl/index.ts +++ b/src/lib/gl/index.ts @@ -5,6 +5,7 @@ import { default as NanoPowGlDownsampleShader } from './gl-downsample.frag' import { default as NanoPowGlDrawShader } from './gl-draw.frag' import { default as NanoPowGlVertexShader } from './gl-vertex.vert' +import { isNotHex } from '#utils' import type { FBO, NanoPowOptions, WorkGenerateResponse, WorkValidateResponse } from '#types' /** @@ -176,13 +177,13 @@ export class NanoPowGl { /** Finalize configuration */ this.#query = this.#gl.createQuery() this.#pixels = new Uint32Array(this.#gl.drawingBufferWidth * this.#gl.drawingBufferHeight * 4) + console.log(`NanoPow WebGL initialized. Maximum nonces checked per frame: ${this.#gl.drawingBufferWidth * this.#gl.drawingBufferHeight}`) + this.#isInitialized = true } catch (err) { throw new Error('WebGL initialization failed.', { cause: err }) } finally { this.#busy = false } - this.#isInitialized = true - console.log(`NanoPow WebGL initialized. Maximum nonces checked per frame: ${this.#gl.drawingBufferWidth * this.#gl.drawingBufferHeight}`) } /** @@ -357,7 +358,7 @@ export class NanoPowGl { * @param {NanoPowOptions} options - Options used to configure search execution */ static async work_generate (hash: string, options?: NanoPowOptions): Promise { - if (!/^[A-Fa-f0-9]{64}$/.test(hash)) throw new Error(`Invalid hash ${hash}`) + if (isNotHex(hash, 64)) throw new Error(`Invalid hash ${hash}`) if (this.#busy) { console.log('NanoPowGl is busy. Retrying search...') return new Promise(resolve => { @@ -368,22 +369,33 @@ export class NanoPowGl { }) } if (this.#isInitialized === false) this.init() - this.#busy = true - if (typeof options?.difficulty === 'string') { - try { - options.difficulty = BigInt(`0x${options.difficulty}`) - } catch (err) { - throw new TypeError(`Invalid difficulty ${options.difficulty}`) - } + options ??= {} + options.debug ??= false + options.difficulty ??= 0xfffffff800000000n + options.effort ??= 0x4 + + if (typeof options?.difficulty !== 'string' + && typeof options?.difficulty !== 'bigint' + ) { + throw new TypeError(`Invalid difficulty ${options?.difficulty}`) } - const difficulty = (typeof options?.difficulty !== 'bigint' || options.difficulty < 1n || options.difficulty > 0xffffffffffffffffn) - ? 0xfffffff800000000n + const difficulty = typeof options.difficulty === 'string' + ? BigInt(`0x${options.difficulty}`) : options.difficulty + + if (difficulty < 0x0n || difficulty > 0xfffffff800000000n) { + throw new TypeError(`Invalid difficulty ${options.difficulty}`) + } + const effort = (typeof options?.effort !== 'number' || options.effort < 0x1 || options.effort > 0x20) ? this.#cores : options.effort + + this.#busy = true + this.#debug = !!(options?.debug) + if (this.#debug) console.log('NanoPowGl.work_generate()') if (this.#debug) console.log('blockhash', hash) if (this.#debug) console.log('search options', JSON.stringify(options, (k, v) => typeof v === 'bigint' ? v.toString(16) : v)) @@ -456,8 +468,8 @@ export class NanoPowGl { * @param {NanoPowOptions} options - Options used to configure search execution */ static async work_validate (work: string, hash: string, options?: NanoPowOptions): Promise { - if (!/^[A-Fa-f0-9]{16}$/.test(work)) throw new Error(`Invalid work ${work}`) - if (!/^[A-Fa-f0-9]{64}$/.test(hash)) throw new Error(`Invalid hash ${hash}`) + if (isNotHex(work, 16)) throw new Error(`Invalid work ${work}`) + if (isNotHex(hash, 64)) throw new Error(`Invalid hash ${hash}`) if (this.#busy) { console.log('NanoPowGl is busy. Retrying validate...') return new Promise(resolve => { @@ -468,19 +480,29 @@ export class NanoPowGl { }) } if (this.#isInitialized === false) this.init() - this.#busy = true - if (typeof options?.difficulty === 'string') { - try { - options.difficulty = BigInt(`0x${options.difficulty}`) - } catch (err) { - throw new TypeError(`Invalid difficulty ${options.difficulty}`) - } + options ??= {} + options.debug ??= false + options.difficulty ??= 0xfffffff800000000n + options.effort ??= 0x4 + + if (typeof options?.difficulty !== 'string' + && typeof options?.difficulty !== 'bigint' + ) { + throw new TypeError(`Invalid difficulty ${options?.difficulty}`) } - const difficulty = (typeof options?.difficulty !== 'bigint' || options.difficulty < 1n || options.difficulty > 0xffffffffffffffffn) - ? 0xfffffff800000000n + const difficulty = typeof options.difficulty === 'string' + ? BigInt(`0x${options.difficulty}`) : options.difficulty + + if (difficulty < 0x0n || difficulty > 0xfffffff800000000n) { + throw new TypeError(`Invalid difficulty ${options.difficulty}`) + } + + this.#busy = true + this.#debug = !!(options?.debug) + if (this.#debug) console.log('NanoPowGl.work_validate()') if (this.#debug) console.log('blockhash', hash) if (this.#debug) console.log('validate options', JSON.stringify(options, (k, v) => typeof v === 'bigint' ? v.toString(16) : v)) diff --git a/src/lib/gpu/compute.wgsl b/src/lib/gpu/compute.wgsl index e06df02..00cfb64 100644 --- a/src/lib/gpu/compute.wgsl +++ b/src/lib/gpu/compute.wgsl @@ -6,8 +6,9 @@ */ struct UBO { blockhash: array, 2>, - seed: vec2, - difficulty: vec2 + difficulty: vec2, + validate: u32, + seed: vec2 }; @group(0) @binding(0) var ubo: UBO; @@ -121,6 +122,12 @@ fn G ( */ const Z = vec2(0u); +/** +* Shared flag to prevent execution for all workgroup threads based on the +* atomicLoad() result of a single member thread. +*/ +var validate: bool; + /** * Shared flag to prevent execution for all workgroup threads based on the * atomicLoad() result of a single member thread. @@ -138,15 +145,25 @@ var m3: vec2; var m4: vec2; /** -* Search compute function -* Calls main with a workgroup size of 64 which balances warps between NVIDIA and +* Main compute function +* +* Computes with a workgroup size of 64 which balances warps between NVIDIA and * AMD cards while still considering the power-sensitive requirements of mobile * devices. The entire workgroup exits immediately if a nonce was already found * by a previous workgroup. +* +* Each component of a random 8-byte value, provided by the UBO as a vec2, +* is XOR'd with a different dimensional index from the global thread identifier +* to create a unique nonce value for each thread. +* +* Where the reference implementation uses array lookups, the NanoPow +* implementation assigns each array element to its own variable to enhance +* performance, but the variable name still contains the original index digit. */ @compute @workgroup_size(64) -fn search(@builtin(global_invocation_id) global_id: vec3, @builtin(local_invocation_id) local_id: vec3) { +fn main(@builtin(global_invocation_id) global_id: vec3, @builtin(local_invocation_id) local_id: vec3) { if (local_id.x == 0u) { + validate = ubo.validate == 1u; found = atomicLoad(&work.found) != 0u; seed = ubo.seed; m1 = ubo.blockhash[0u].xy; @@ -156,38 +173,11 @@ fn search(@builtin(global_invocation_id) global_id: vec3, @builtin(local_in } workgroupBarrier(); if (found) { return; } - main(global_id, false); -} - -/** -* Validate compute function -* Calls main with a workgroup size of 1 so that only one value is tested. -*/ -@compute @workgroup_size(1) -fn validate(@builtin(global_invocation_id) global_id: vec3) { - seed = ubo.seed; - m1 = ubo.blockhash[0u].xy; - m2 = ubo.blockhash[0u].zw; - m3 = ubo.blockhash[1u].xy; - m4 = ubo.blockhash[1u].zw; - main(global_id, true); -} -/** -* Main compute function -* Each component of a random 8-byte value, provided by the UBO as a vec2, -* is XOR'd with a different dimensional index from the global thread identifier -* to create a unique nonce value for each thread. -* -* Where the reference implementation uses array lookups, the NanoPow -* implementation assigns each array element to its own variable to enhance -* performance, but the variable name still contains the original index digit. -*/ -fn main(id: vec3, validate: bool) { /** * Initialize unique nonce */ - let m0: vec2 = seed ^ id.xy; + let m0: vec2 = seed ^ global_id.xy; /** * Compression buffer copied from the modified initialization vector. @@ -435,10 +425,18 @@ fn main(id: vec3, validate: bool) { * Set nonce if it passes the difficulty threshold and no other thread has set it. */ var result: vec2 = BLAKE2B_INIT[0u] ^ v0 ^ v8; - if (validate || ((result.y > ubo.difficulty.y || (result.y == ubo.difficulty.y && result.x >= ubo.difficulty.x)) && atomicLoad(&work.found) == 0u)) { - atomicStore(&work.found, 1u); - work.nonce = m0; - work.result = result; + if (select((result.y > ubo.difficulty.y || (result.y == ubo.difficulty.y && result.x >= ubo.difficulty.x)), all(global_id == vec3(0u)), validate)) { + loop { + let swap = atomicCompareExchangeWeak(&work.found, 0u, 1u); + if (swap.exchanged) { + work.nonce = m0; + work.result = result; + break; + } + if (swap.old_value != 0u) { + break; + } + } } return; } diff --git a/src/lib/gpu/index.ts b/src/lib/gpu/index.ts index b9a8b13..514fb67 100644 --- a/src/lib/gpu/index.ts +++ b/src/lib/gpu/index.ts @@ -2,7 +2,8 @@ //! SPDX-License-Identifier: GPL-3.0-or-later import { default as NanoPowGpuComputeShader } from './compute.wgsl' -import type { NanoPowOptions, WorkGenerateResponse, WorkValidateResponse } from '#types' +import { isNotHex } from '#utils' +import type { NanoPowOptions, NanoPowResult, WorkGenerateResponse, WorkValidateResponse } from '#types' /** * Nano proof-of-work using WebGPU. @@ -13,24 +14,27 @@ export class NanoPowGpu { // Initialize WebGPU static #isInitialized: boolean = false - static #busy: boolean = false static #debug: boolean = false + static #action: 'work_generate' | 'work_validate' = 'work_generate' + static #difficulty: bigint = this.#SEND + static #effort: number = 4 + static #queue: any[] = [] static #device: GPUDevice | null = null - static #bufferReset: BigUint64Array = new BigUint64Array([0n, 0n, 0n, 0n]) + static #bufferReset: BigUint64Array = new BigUint64Array(4) + static #uboArray: BigUint64Array = new BigUint64Array(8) + static #uboView: DataView + static #uboBuffer: GPUBuffer static #gpuBuffer: GPUBuffer static #cpuBuffer: GPUBuffer - static #uboBuffer: GPUBuffer - static #uboView: DataView + static #bindGroupLayout: GPUBindGroupLayout | null + static #bindGroup: GPUBindGroup | null + static #pipeline: GPUComputePipeline | null static #resultView: DataView - static #bindGroupLayout: GPUBindGroupLayout - static #bindGroup: GPUBindGroup - static #searchPipeline: GPUComputePipeline - static #validatePipeline: GPUComputePipeline + static #result: NanoPowResult // Initialize WebGPU static async init (): Promise { - if (this.#busy) return - this.#busy = true + console.log('Initializing NanoPowGpu.') // Request device and adapter try { if (navigator.gpu == null) throw new Error('WebGPU is not supported in this browser.') @@ -40,93 +44,62 @@ export class NanoPowGpu { if (!(device instanceof GPUDevice)) throw new Error('WebGPU device failed to load.') device.lost?.then(this.reset) this.#device = device - await this.setup() + + // Create buffers for writing GPU calculations and reading from Javascript + this.#uboView = new DataView(this.#uboArray.buffer) + this.#uboBuffer = this.#device.createBuffer({ + label: 'ubo', + size: 64, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }) + this.#gpuBuffer = this.#device.createBuffer({ + label: 'gpu', + size: 32, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC + }) + this.#cpuBuffer = this.#device.createBuffer({ + label: 'cpu', + size: 32, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ + }) + + // Create binding group data structure and use it later once UBO is known + this.#bindGroupLayout = this.#device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' }, }, + { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' }, }, + ], + }) + // Create pipeline to connect compute shader to binding layout + this.#pipeline = this.#device.createComputePipeline({ + layout: this.#device.createPipelineLayout({ + bindGroupLayouts: [this.#bindGroupLayout] + }), + compute: { + entryPoint: 'main', + module: this.#device.createShaderModule({ + code: NanoPowGpuComputeShader + }) + } + }) + // Bind UBO read and GPU write buffers + this.#bindGroup = this.#device.createBindGroup({ + layout: this.#bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.#uboBuffer }, }, + { binding: 1, resource: { buffer: this.#gpuBuffer }, }, + ], + }) + // Compile and cache shader prior to actual dispatch + const cmd = this.#device.createCommandEncoder() + cmd.beginComputePass().end() + this.#device.queue.submit([cmd.finish()]) + await this.#device.queue.onSubmittedWorkDone() + console.log(`NanoPow WebGPU initialized.`) + this.#isInitialized = true } catch (err) { throw new Error('WebGPU initialization failed.', { cause: err }) - } finally { - this.#busy = false } - this.#isInitialized = true - } - - static async setup (): Promise { - if (this.#device == null) throw new Error(`WebGPU device failed to load.`) - // Create buffers for writing GPU calculations and reading from Javascript - this.#gpuBuffer = this.#device.createBuffer({ - size: 32, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC - }) - this.#cpuBuffer = this.#device.createBuffer({ - size: 32, - usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ - }) - this.#uboBuffer = this.#device.createBuffer({ - size: 48, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST - }) - this.#uboView = new DataView(new ArrayBuffer(48)) - // Create binding group data structure and use it later once UBO is known - this.#bindGroupLayout = this.#device.createBindGroupLayout({ - entries: [ - { - binding: 0, - visibility: GPUShaderStage.COMPUTE, - buffer: { type: 'uniform' }, - }, - { - binding: 1, - visibility: GPUShaderStage.COMPUTE, - buffer: { type: 'storage' }, - } - ] - }) - const shaderModule = this.#device.createShaderModule({ - code: NanoPowGpuComputeShader - }) - // Create pipeline to connect compute shader to binding layout - this.#searchPipeline = this.#device.createComputePipeline({ - layout: this.#device.createPipelineLayout({ - bindGroupLayouts: [this.#bindGroupLayout] - }), - compute: { - entryPoint: 'search', - module: shaderModule - } - }) - // Bind UBO read and GPU write buffers - this.#bindGroup = this.#device.createBindGroup({ - layout: this.#bindGroupLayout, - entries: [ - { - binding: 0, - resource: { - buffer: this.#uboBuffer - }, - }, - { - binding: 1, - resource: { - buffer: this.#gpuBuffer - }, - }, - ], - }) - // Create pipeline to connect compute shader to binding layout - this.#validatePipeline = this.#device.createComputePipeline({ - layout: this.#device.createPipelineLayout({ - bindGroupLayouts: [this.#bindGroupLayout] - }), - compute: { - entryPoint: 'validate', - module: shaderModule - } - }) - // Compile and cache shader prior to actual dispatch - const cmd = this.#device.createCommandEncoder() - cmd.beginComputePass().end() - this.#device.queue.submit([cmd.finish()]) - await this.#device.queue.onSubmittedWorkDone() - console.log(`NanoPow WebGPU initialized. Recommended effort: ${Math.max(1, Math.floor(navigator.hardwareConcurrency / 2))}`) } static reset (): void { @@ -134,72 +107,109 @@ export class NanoPowGpu { NanoPowGpu.#cpuBuffer?.destroy() NanoPowGpu.#gpuBuffer?.destroy() NanoPowGpu.#uboBuffer?.destroy() - NanoPowGpu.#busy = false + NanoPowGpu.#bindGroupLayout = null + NanoPowGpu.#bindGroup = null + NanoPowGpu.#pipeline = null NanoPowGpu.#isInitialized = false - NanoPowGpu.init() + queueMicrotask(() => NanoPowGpu.init().catch(console.log)) } - static #logAverages (times: number[]): void { - let count = times.length, truncatedCount = 0, truncated = 0, sum = 0, reciprocals = 0, logarithms = 0, min = Number.MAX_SAFE_INTEGER, max = 0, median = 0, rate = 0 - times.sort() - for (let i = 0; i < count; i++) { - sum += times[i] - reciprocals += 1 / times[i] - logarithms += Math.log(times[i]) - min = Math.min(min, times[i]) - max = Math.max(max, times[i]) - if (i === Math.ceil(count / 2)) { - median = times[i] - } - if (count < 3 || (i > (0.1 * count) && i < (0.9 * (count - 1)))) { - truncated += times[i] - truncatedCount++ - } + /** + * Validate work, if present, and blockhash. + * Validate options and normalize its properties. + */ + static async #work_init (work: string | null, hash: string, options?: NanoPowOptions): Promise { + if (this.#isInitialized === false) await this.init() + if (this.#debug) console.log(this.#action) + if (isNotHex(hash, 64)) throw new TypeError(`Invalid hash ${hash}`) + if (work != null && isNotHex(work, 16)) throw new TypeError(`Invalid work ${work}`) + options ??= {} + options.debug ??= false + options.difficulty ??= this.#SEND + options.effort ??= 0x4 + + if (typeof options.effort !== 'number' + || options.effort < 0x1 + || options.effort > 0x20 + ) { + throw new TypeError(`Invalid effort ${options.effort}`) } - const averages = { - "Count (dispatches)": count, - "Total (ms)": sum, - "Rate (d/s)": 1000 * truncatedCount / (truncated || sum), - "Minimum (ms)": min, - "Maximum (ms)": max, - "Median (ms)": median, - "Arithmetic Mean (ms)": sum / count, - "Truncated Mean (ms)": truncated / truncatedCount, - "Harmonic Mean (ms)": count / reciprocals, - "Geometric Mean (ms)": Math.exp(logarithms / count) + this.#effort = this.#action === 'work_generate' + ? options.effort * 0x100 + : 1 + + if (typeof options.difficulty !== 'string' + && typeof options.difficulty !== 'bigint' + ) { + throw new TypeError(`Invalid difficulty ${options.difficulty}`) + } + this.#difficulty = typeof options.difficulty === 'string' + ? BigInt(`0x${options.difficulty}`) + : options.difficulty + if (this.#difficulty < 0x0n || this.#difficulty > this.#SEND) { + throw new TypeError(`Invalid difficulty ${options.difficulty}`) } - console.table(averages) - } - static async #dispatch (pipeline: GPUComputePipeline, seed: bigint, hash: string, difficulty: bigint, passes: number): Promise { - if (this.#device == null) throw new Error(`WebGPU device failed to load.`) - // Set up uniform buffer object - // Note: u32 size is 4, but total alignment must be multiple of 16 - for (let i = 0; i < this.#uboView.byteLength; i++) this.#uboView.setUint8(i, 0) - for (let i = 0; i < 64; i += 16) { - const u64 = hash.slice(i, i + 16) - this.#uboView.setBigUint64(i / 2, BigInt(`0x${u64}`)) + this.#debug = !!options.debug + if (this.#debug) { + if (work) console.log('work', work) + console.log('blockhash', hash) + console.log(`options`, JSON.stringify(options, (k, v) => typeof v === 'bigint' ? v.toString(16) : v)) } - this.#uboView.setBigUint64(32, seed, true) - this.#uboView.setBigUint64(40, difficulty, true) - if (this.#debug) console.log('UBO', this.#uboView) - this.#device.queue.writeBuffer(this.#uboBuffer, 0, this.#uboView) + // Ensure WebGPU is initialized before calculating + let loads = 0 + while (this.#device == null && loads++ < 20) { + await new Promise(resolve => { + setTimeout(resolve, 100) + }) + } + if (this.#device == null) { + throw new Error(`WebGPU device failed to load.`) + } // Reset WORK properties to 0u before each calculation this.#device.queue.writeBuffer(this.#gpuBuffer, 0, this.#bufferReset) this.#device.queue.writeBuffer(this.#cpuBuffer, 0, this.#bufferReset) + // Write data that will not change per dispatch to uniform buffer object + // Note: u32 size is 4, but total alignment must be multiple of 16 + this.#uboArray.fill(0n) + for (let i = 0; i < 64; i += 16) { + this.#uboView.setBigUint64(i / 2, BigInt(`0x${hash.slice(i, i + 16)}`)) + } + this.#uboView.setBigUint64(32, this.#difficulty, true) + this.#uboView.setUint32(40, this.#action === 'work_generate' ? 0 : 1, true) + this.#device.queue.writeBuffer(this.#uboBuffer, 0, this.#uboView) + + this.#result = { + found: false, + work: 0n, + difficulty: 0n + } + } + + static async #work_dispatch (seed: bigint, hash: string): Promise { + if (this.#device == null) throw new Error(`WebGPU device failed to load.`) + if (this.#pipeline == null) throw new Error(`WebGPU pipeline failed to load.`) + if (this.#debug) console.log('seed', seed.toString(16).padStart(16, '0')) + + // Copy seed into UBO + this.#uboView.setBigUint64(48, seed, true) + if (this.#debug) console.log('UBO', this.#uboView) + this.#device.queue.writeBuffer(this.#uboBuffer, 0, this.#uboView) + // Create command encoder to issue commands to GPU and initiate computation const commandEncoder = this.#device.createCommandEncoder() const passEncoder = commandEncoder.beginComputePass() // Issue commands and end compute pass structure - passEncoder.setPipeline(pipeline) + passEncoder.setPipeline(this.#pipeline) passEncoder.setBindGroup(0, this.#bindGroup) - passEncoder.dispatchWorkgroups(passes, passes) + passEncoder.dispatchWorkgroups(this.#effort, this.#effort) passEncoder.end() - // Copy 8-byte nonce and 4-byte found flag from GPU to CPU for reading + // Copy 8-byte result, 8-byte nonce, and 4-byte found flag from GPU to CPU + // for reading commandEncoder.copyBufferToBuffer(this.#gpuBuffer, 0, this.#cpuBuffer, 0, 32) // End computation by passing array of command buffers to command queue for execution @@ -218,6 +228,27 @@ export class NanoPowGpu { } if (this.#debug) console.log('gpuBuffer data', this.#resultView) if (this.#resultView == null) throw new Error(`Failed to get data from buffer.`) + + this.#result.found = !!this.#resultView.getUint32(0) + this.#result.work = this.#resultView.getBigUint64(8, true) + this.#result.difficulty = this.#resultView.getBigUint64(16, true) + + if (this.#debug) { + console.log('nonce', this.#result.work, this.#result.work.toString(16).padStart(16, '0')) + console.log('result', this.#result.difficulty, this.#result.difficulty.toString(16).padStart(16, '0')) + } + } + + static #work_process (): void { + const { task, resolve, reject } = this.#queue.shift() ?? {} + task?.().then(resolve).catch(reject).finally(() => { this.#work_process() }) + } + + static async #work_queue (task: Function): Promise { + return new Promise((resolve, reject): void => { + this.#queue.push({ task, resolve, reject }) + this.#work_process() + }) } /** @@ -227,75 +258,24 @@ export class NanoPowGpu { * @param {NanoPowOptions} options - Used to configure search execution */ static async work_generate (hash: string, options?: NanoPowOptions): Promise { - if (!/^[A-Fa-f0-9]{64}$/.test(hash)) throw new TypeError(`Invalid hash ${hash}`) - if (this.#busy) { - console.log('NanoPowGpu is busy. Retrying search...') - return new Promise(resolve => { - setTimeout(async (): Promise => { - const result = this.work_generate(hash, options) - resolve(result) - }, 100) - }) - } - if (this.#isInitialized === false) this.init() - this.#busy = true + return this.#work_queue(async (): Promise => { + this.#action = 'work_generate' + await this.#work_init(null, hash, options) - if (typeof options?.difficulty === 'string') { - try { - options.difficulty = BigInt(`0x${options.difficulty}`) - } catch (err) { - throw new TypeError(`Invalid difficulty ${options.difficulty}`) - } - } - const difficulty = (typeof options?.difficulty !== 'bigint' || options.difficulty < 0n || options.difficulty > 0xffffffffffffffffn) - ? 0xfffffff800000000n - : options.difficulty - const effort = (typeof options?.effort !== 'number' || options.effort < 0x1 || options.effort > 0x20) - ? 0x800 - : options.effort * 0x100 - this.#debug = !!(options?.debug) - if (this.#debug) console.log('NanoPowGpu.work_generate()') - if (this.#debug) console.log('blockhash', hash) - if (this.#debug) console.log('search options', JSON.stringify(options, (k, v) => typeof v === 'bigint' ? v.toString(16) : v)) - - // Ensure WebGPU is initialized before calculating - let loads = 0 - while (this.#device == null && loads++ < 20) { - await new Promise(resolve => { - setTimeout(resolve, 500) - }) - } - if (this.#device == null) { - this.#busy = false - throw new Error(`WebGPU device failed to load.`) - } + let random = BigInt(Math.floor(Math.random() * 0xffffffff)) + let seed = random + do { + random = BigInt(Math.floor(Math.random() * 0xffffffff)) + seed = (seed & 0xffffffffn) << 32n | random + await this.#work_dispatch(seed, hash) + } while (!this.#result.found) - let times = [] - let start = performance.now() - let nonce = 0n - let result = 0n - let random = BigInt(Math.floor(Math.random() * 0xffffffff)) - let seed = random - do { - start = performance.now() - random = BigInt(Math.floor(Math.random() * 0xffffffff)) - seed = (seed & 0xffffffffn) << 32n | random - if (this.#debug) console.log('seed', seed.toString(16).padStart(16, '0')) - await this.#dispatch(this.#searchPipeline, seed, hash, difficulty, effort) - const found = !!this.#resultView.getUint32(0) - nonce = this.#resultView.getBigUint64(8, true) - result = this.#resultView.getBigUint64(16, true) - this.#busy = !found - times.push(performance.now() - start) - } while (this.#busy) - if (this.#debug) this.#logAverages(times) - if (this.#debug) console.log('nonce', nonce, nonce.toString(16).padStart(16, '0')) - if (this.#debug) console.log('result', result, result.toString(16).padStart(16, '0')) - return { - hash, - work: nonce.toString(16).padStart(16, '0'), - difficulty: result.toString(16).padStart(16, '0') - } + return { + hash, + work: this.#result.work.toString(16).padStart(16, '0'), + difficulty: this.#result.difficulty.toString(16).padStart(16, '0') + } + }) } /** @@ -306,67 +286,25 @@ export class NanoPowGpu { * @param {NanoPowOptions} options - Options used to configure search execution */ static async work_validate (work: string, hash: string, options?: NanoPowOptions): Promise { - if (!/^[A-Fa-f0-9]{16}$/.test(work)) throw new TypeError(`Invalid work ${work}`) - if (!/^[A-Fa-f0-9]{64}$/.test(hash)) throw new TypeError(`Invalid hash ${hash}`) - if (this.#busy) { - console.log('NanoPowGpu is busy. Retrying validate...') - return new Promise(resolve => { - setTimeout(async (): Promise => { - const result = this.work_validate(work, hash, options) - resolve(result) - }, 100) - }) - } - if (this.#isInitialized === false) this.init() - this.#busy = true + return this.#work_queue(async (): Promise => { + this.#action = 'work_validate' + await this.#work_init(work, hash, options) - if (typeof options?.difficulty === 'string') { - try { - options.difficulty = BigInt(`0x${options.difficulty}`) - } catch (err) { - throw new TypeError(`Invalid difficulty ${options.difficulty}`) - } - } - const difficulty = (typeof options?.difficulty !== 'bigint' || options.difficulty < 0n || options.difficulty > 0xffffffffffffffffn) - ? 0xfffffff800000000n - : options.difficulty - this.#debug = !!(options?.debug) - if (this.#debug) console.log('NanoPowGpu.work_validate()') - if (this.#debug) console.log('blockhash', hash) - if (this.#debug) console.log('validate options', JSON.stringify(options, (k, v) => typeof v === 'bigint' ? v.toString(16) : v)) + const seed = BigInt(`0x${work}`) + await this.#work_dispatch(seed, hash) + if (seed !== this.#result.work) throw new Error('Result does not match work') - // Ensure WebGPU is initialized before calculating - let loads = 0 - while (this.#device == null && loads < 20) { - await new Promise(resolve => { - setTimeout(resolve, 500) - }) - } - if (this.#device == null) { - this.#busy = false - throw new Error(`WebGPU device failed to load.`) - } - - let result = 0n - let nonce = 0n - - const seed = BigInt(`0x${work}`) - if (this.#debug) console.log('work', work) - await this.#dispatch(this.#validatePipeline, seed, hash, difficulty, 1) - nonce = this.#resultView.getBigUint64(8, true) - result = this.#resultView.getBigUint64(16, true) - this.#busy = false - if (seed !== nonce) throw new Error('Result does not match work') - if (this.#debug) console.log('nonce', nonce, nonce.toString(16).padStart(16, '0')) - if (this.#debug) console.log('result', result, result.toString(16).padStart(16, '0')) - const response: WorkValidateResponse = { - hash, - work: nonce.toString(16).padStart(16, '0'), - difficulty: result.toString(16).padStart(16, '0'), - valid_all: (result >= this.#SEND) ? '1' : '0', - valid_receive: (result >= this.#RECEIVE) ? '1' : '0', - } - if (options?.difficulty != null) response.valid = (result >= difficulty) ? '1' : '0' - return response + const response: WorkValidateResponse = { + hash, + work: this.#result.work.toString(16).padStart(16, '0'), + difficulty: this.#result.difficulty.toString(16).padStart(16, '0'), + valid_all: (this.#result.difficulty >= this.#SEND) ? '1' : '0', + valid_receive: (this.#result.difficulty >= this.#RECEIVE) ? '1' : '0' + } + if (options?.difficulty != null) { + response.valid = (this.#result.difficulty >= this.#difficulty) ? '1' : '0' + } + return response + }) } } diff --git a/src/main.ts b/src/main.ts index 414717a..df70648 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,4 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { NanoPow, NanoPowGl, NanoPowGpu } from './lib' -export { NanoPow, NanoPowGl, NanoPowGpu } -export default NanoPow +export { NanoPow as default, NanoPow, NanoPowGl, NanoPowGpu } from './lib' diff --git a/src/types.d.ts b/src/types.d.ts index 4780ed3..a30b570 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -13,7 +13,6 @@ declare const NanoPow: typeof NanoPowGl | typeof NanoPowGpu | null * Nano proof-of-work using WebGL 2.0. */ export declare class NanoPowGl { - static [key: string]: (...args: any[]) => any #private /** * Constructs canvas, gets WebGL context, initializes buffers, and compiles @@ -47,9 +46,7 @@ export declare class NanoPowGl { */ export declare class NanoPowGpu { #private - static [key: string]: (...args: any[]) => any static init (): Promise - static setup (): void static reset (): void /** * Finds a nonce that satisfies the Nano proof-of-work requirements. @@ -81,12 +78,18 @@ export type NanoPowOptions = { difficulty?: bigint | string } +export type NanoPowResult = { + found: boolean + work: bigint + difficulty: bigint +} + /** * Used to create WebGL framebuffer objects. * -* @param {WebGLTexture} - Defines storage size -* @param {WebGLFramebuffer} - Holds texture data -* @param {size} - 2D lengths of texture +* @param {WebGLTexture} texture - Defines storage size +* @param {WebGLFramebuffer} framebuffer - Holds texture data +* @param {size} size - 2D lengths of texture */ export type FBO = { texture: WebGLTexture @@ -97,6 +100,20 @@ export type FBO = { } } +/** +* Used to define NanoPow server configuration. +* +* @param {boolean} DEBUG - Enables additional log output +* @param {number} EFFORT - Defines dispatch size per compute pass +* @param {number} PORT - TCP port on which to listen for requests +*/ +export type NanoPowServerConfig = { + [key: string]: boolean | number + DEBUG: boolean + EFFORT: number + PORT: number +} + export declare const NanoPowGlDownsampleShader: string export declare const NanoPowGlDrawShader: string export declare const NanoPowGlVertexShader: string diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..f22af8e --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,68 @@ +export function average (times: number[]) { + if (times == null || times.length === 0) return {} + + let count = times.length + let min = Number.MAX_SAFE_INTEGER + let logarithms, max, median, rate, reciprocals, total, truncated, truncatedCount + logarithms = max = median = rate = reciprocals = total = truncated = truncatedCount = 0 + + times.sort((a, b) => a - b) + for (let i = 0; i < count; i++) { + const time = times[i] + total += time + reciprocals += 1 / time + logarithms += Math.log(time) + min = Math.min(min, time) + max = Math.max(max, time) + if (i + 1 === Math.ceil(count / 2)) median = time + if (count < 3 || (i > (0.1 * count) && i < (0.9 * (count - 1)))) { + truncated += time + truncatedCount++ + } + } + return { + count, + total, + min, + max, + median, + arithmetic: total / count, + harmonic: count / reciprocals, + geometric: Math.exp(logarithms / count), + truncated: truncated / truncatedCount, + rate: 1000 * truncatedCount / (truncated || total), + } +} + +export function isHex (input: string, min?: number, max?: number): boolean { + if (typeof input !== 'string') { + return false + } + if (typeof min !== 'undefined' && typeof min !== 'number') { + throw new Error(`Invalid argument for parameter 'min'`) + } + if (typeof max !== 'undefined' && typeof max !== 'number') { + throw new Error(`Invalid argument for parameter 'max'`) + } + const range = min === undefined && max === undefined + ? '+' + : `{${min ?? '0'},${max ?? ''}}` + const regexp = new RegExp(`^[0-9A-Fa-f]${range}$`, 'm') + return regexp.test(input) +} + +export function isNotHex (input: string, min?: number, max?: number): boolean { + return !isHex(input, min, max) +} + +/** +* Override console logging to provide an informative prefix for each entry and +* to only output when debug mode is enabled. +*/ +export function log (...args: any[]): void { + if (process?.env?.NANO_POW_DEBUG) { + const entry = `${new Date(Date.now()).toLocaleString(Intl.DateTimeFormat().resolvedOptions().locale ?? 'en-US', { hour12: false, dateStyle: 'medium', timeStyle: 'medium' })} NanoPow[${process.pid}]: ${args}` + console.log(entry) + process.send?.({ type: 'console', message: entry }) + } +} diff --git a/test/index.html b/test/index.html index 397ef8b..6b22438 100644 --- a/test/index.html +++ b/test/index.html @@ -35,7 +35,7 @@ SPDX-License-Identifier: GPL-3.0-or-later function average (times, type, effort) { let count = times.length, truncatedCount = 0, sum = 0, truncated = 0, reciprocals = 0, logarithms = 0, min = Number.MAX_SAFE_INTEGER, max = 0, median = 0, rate = 0 - times.sort() + times.sort((a, b) => a - b) for (let i = 0; i < count; i++) { sum += times[i] reciprocals += 1 / times[i] @@ -88,11 +88,6 @@ SPDX-License-Identifier: GPL-3.0-or-later console.log(`work_validate() output for good nonce 2 is ${result === true ? 'correct' : 'incorrect'}`) expect.push(result) - result = await NP.work_validate('326f310d629a8a98', '204076E3364D16A018754FF67D418AB2FBEB38799FF9A29A1D5F9E34F16BEEEA', { difficulty: 0xffffffff00000000n, debug: isDebug }) - result = result.valid === '1' && result.valid_all === '1' && result.valid_receive === '1' - console.log(`work_validate() output for good max difficulty nonce is ${result === true ? 'correct' : 'incorrect'}`) - expect.push(result) - result = await NP.work_validate('c5d5d6f7c5d6ccd1', '281E89AC73B1082B464B9C3C1168384F846D39F6DF25105F8B4A22915E999117', { debug: isDebug }) result = result.valid_all === '1' console.log(`work_validate() output for colliding nonce is ${result === true ? 'correct' : 'incorrect'}`) @@ -114,9 +109,14 @@ SPDX-License-Identifier: GPL-3.0-or-later console.log(`work_validate() output for bad nonce 2 is ${result === true ? 'correct' : 'incorrect'}`) expect.push(result) - result = await NP.work_validate('ae238556213c3624', 'BF41D87DA3057FDC6050D2B00C06531F89F4AA6195D7C6C2EAAF15B6E703F8F6', { difficulty: 0xffffffff00000000n, debug: isDebug }) - result = result.valid === '0' && result.valid_all === '0' && result.valid_receive === '1' - console.log(`work_validate() output for bad max difficulty nonce is ${result === true ? 'correct' : 'incorrect'}`) + try { + result = await NP.work_validate('ae238556213c3624', 'BF41D87DA3057FDC6050D2B00C06531F89F4AA6195D7C6C2EAAF15B6E703F8F6', { difficulty: 0xfffffff800000001n, debug: isDebug }) + console.log('boo') + } catch (err) { + result = null + } + result = result === null + console.log(`work_validate() output for bad difficulty beyond max is ${result === true ? 'correct' : 'incorrect'}`) expect.push(result) result = await NP.work_validate('29a9ae0236990e2e', '32721F4BD2AFB6F6A08D41CD0DF3C0D9C0B5294F68D0D12422F52B28F0800B5F', { debug: isDebug }) @@ -129,11 +129,6 @@ SPDX-License-Identifier: GPL-3.0-or-later console.log(`work_validate() output for bad receive difficulty nonce is ${result === true ? 'correct' : 'incorrect'}`) expect.push(result) - result = await NP.work_validate('e45835c3b291c3d1', '9DCD89E2B92FD59D7358C2C2E4C225DF94C88E187B27882F50FEFC3760D3994F', { difficulty: 0xffffffff00000000n, debug: isDebug }) - result = result.valid === '0' && result.valid_all === '1' && result.valid_receive === '1' - console.log(`work_validate() output for send difficulty nonce that does not meet custom difficulty is ${result === true ? 'correct' : 'incorrect'}`) - expect.push(result) - try { if (!expect.every(result => result)) throw new Error(`Validation is not working`) } catch (err) { @@ -162,7 +157,7 @@ SPDX-License-Identifier: GPL-3.0-or-later const check = await NP.work_validate(result.work, result.hash, { difficulty, debug: isDebug }) const isValid = (result.hash === hash && check.valid === '1') ? 'VALID' : 'INVALID' times.push(end - start) - const msg = `${isValid} [${result.work}] ${result.hash} (${end - start} ms)` + const msg = `${isValid} (${end - start} ms)\n${JSON.stringify(result, ' ', 2)}` if (isOutputShown) document.getElementById('output').innerHTML += `${msg}
` } document.getElementById('output').innerHTML += `
` @@ -205,7 +200,7 @@ SPDX-License-Identifier: GPL-3.0-or-later run(difficulty.value, +size.value, +effort.value, isOutputShown.checked, isGlForced.checked, isDebug.checked) } document.getElementById('btnStartTest').addEventListener('click', startTest) - document.getElementById('effort').value = Math.max(1, Math.floor(navigator.hardwareConcurrency)) + document.getElementById('effort').value = Math.max(1, Math.floor(navigator.hardwareConcurrency) / 2)