]> zoso.dev Git - nano-pow.git/commitdiff
Extract some basic shared code into utils file and bundle it. Simplify lib exports...
authorChris Duncan <chris@zoso.dev>
Tue, 6 May 2025 21:41:36 +0000 (14:41 -0700)
committerChris Duncan <chris@zoso.dev>
Tue, 6 May 2025 21:41:36 +0000 (14:41 -0700)
13 files changed:
esbuild.mjs
src/bin/cli.ts
src/bin/nano-pow.sh
src/bin/server.ts
src/lib/gl/index.ts
src/lib/gpu/compute.wgsl
src/lib/gpu/index.ts
src/main.ts
src/types.d.ts
src/utils/index.ts [new file with mode: 0644]
test/index.html
test/script.sh
tsconfig.json

index 2d9ade2c36108d5e6ca5de46f3ce2192fe00347b..f99961006a775054556679c97e6fcc2450f9d68e 100644 (file)
@@ -27,7 +27,7 @@ await build({
 })
 
 await build({
-       bundle: false,
+       bundle: true,
        platform: 'node',
        entryPoints: [
                './src/bin/cli.ts',
index baac0600f4c097a9d68ce5a84678b23fb6915b37..80ace2d4e37d8d188aeed227febc1239f71130d3 100755 (executable)
@@ -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: <bug-nano-pow@zoso.dev>
 Full documentation: <https://www.npmjs.com/package/nano-pow>
@@ -64,7 +61,7 @@ Full documentation: <https://www.npmjs.com/package/nano-pow>
 }
 
 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<void> => {
+       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<void> {
+       // 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()
+}
index b9f930df38fe67d799dcca70a202718360a6c6a7..a751efa75dc16d7183d1fdf9a579a2fd4e5c9096 100755 (executable)
@@ -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;
index 298d7777dfe7560bed6ea3ec3eaff1a2b11ed8b9..3d4fe0600ed3a2258bdc599a7b722ed27ce80d0f 100755 (executable)
@@ -2,51 +2,55 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 //! 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<string, { tokens: number, time: number }> = 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<void> {
-       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<void> => {
-       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<void> {
-       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 = `
+       <!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}';">
+       <script type="module">${src}</script>
+`
+
+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<string, { time: number, tokens: number }>()
+
+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: <bug-nano-pow@zoso.dev>
+Full documentation: <https://www.npmjs.com/package/nano-pow>
+`
+               )
+}
+
+async function post (res: http.ServerResponse, reqData: Buffer[]): Promise<void> {
+       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<WorkGenerateResponse | WorkValidateResponse> => {
-                                       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<WorkGenerateResponse | WorkValidateResponse> => {
-                                       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<WorkGenerateResponse> => {
+                               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<WorkValidateResponse> => {
+                               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<Buffer[]> {
+       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<void> => {
-                       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: <bug-nano-pow@zoso.dev>
-Full documentation: <https://www.npmjs.com/package/nano-pow>
-`
-               )
+/**
+* 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<never> => {
                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 = `
-       <!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}';">
-       <script type="module">${src}</script>
-`
-
-await page.setRequestInterception(true)
-page.on('request', async (req): Promise<void> => {
-       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<boolean> => {
-       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<void> => {
-       const { port } = server.address() as AddressInfo
-       CONFIG.PORT = port
-       log(`Server listening on port ${port}`)
-       process.send?.({ type: 'listening', port })
-})
+listen()
index c277d7bb50c685b0faf8f7834700ba3e872fa5cb..0c510d5b50625eb30626319d7492b9e3af6188cc 100644 (file)
@@ -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<WorkGenerateResponse> {
-               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<WorkValidateResponse> {
-               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))
index e06df02425009b9313d23b274e6d402569908c33..00cfb64d2ec97e69d23f203e25596435fa97d5fb 100644 (file)
@@ -6,8 +6,9 @@
 */
 struct UBO {
        blockhash: array<vec4<u32>, 2>,
-       seed: vec2<u32>,
-       difficulty: vec2<u32>
+       difficulty: vec2<u32>,
+       validate: u32,
+       seed: vec2<u32>
 };
 @group(0) @binding(0) var<uniform> 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<workgroup> 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<workgroup> m3: vec2<u32>;
 var<workgroup> m4: vec2<u32>;
 
 /**
-* 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<u32>,
+* 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<u32>, @builtin(local_invocation_id) local_id: vec3<u32>) {
+fn main(@builtin(global_invocation_id) global_id: vec3<u32>, @builtin(local_invocation_id) local_id: vec3<u32>) {
        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<u32>, @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<u32>) {
-       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<u32>,
-* 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<u32>, validate: bool) {
        /**
        * Initialize unique nonce
        */
-       let m0: vec2<u32> = seed ^ id.xy;
+       let m0: vec2<u32> = seed ^ global_id.xy;
 
        /**
        * Compression buffer copied from the modified initialization vector.
@@ -435,10 +425,18 @@ fn main(id: vec3<u32>, validate: bool) {
        * Set nonce if it passes the difficulty threshold and no other thread has set it.
        */
        var result: vec2<u32> = 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;
 }
index b9a8b13db7e3e482bd3ee0666b14a5659f3c8eaf..514fb671829c3d923ff110b7ff67e12d2ec67829 100644 (file)
@@ -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<void> {
-               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<void> {
-               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<void> {
+               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<void> {
-               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<void> {
+               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<any> {
+               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<WorkGenerateResponse> {
-               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<void> => {
-                                       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<WorkGenerateResponse> => {
+                       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<WorkValidateResponse> {
-               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<void> => {
-                                       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<WorkValidateResponse> => {
+                       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
+               })
        }
 }
index 414717a6c17337b83325f7e68c77cb18a3bc2fb6..df706486431ae2cd35d7c95608a3206942639f52 100644 (file)
@@ -1,6 +1,4 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 //! 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'
index 4780ed3e0c4eacc7267b461c930b2e818ae0c144..a30b57075ef964bb2513370510893a47402fe7fa 100644 (file)
@@ -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<void>
-       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 (file)
index 0000000..f22af8e
--- /dev/null
@@ -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 })
+       }
+}
index 397ef8bba719e51f68c33e0da9858bde82edbe04..6b224386f82c47ebe90ec82fdd51f7a3d10f0848 100644 (file)
@@ -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}<br/>`
                        }
                        document.getElementById('output').innerHTML += `<hr/>`
@@ -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)
        </script>
        <style>
                body{background:black;color:white;}a{color:darkcyan;}input[type=number]{width:5em;}span{margin:0.5em;}
index 65bd8ce69ae2febf867587993f932c3ba8bba686..33694c0e1c87a60ba19de408c06d9d5ff9c00b21 100755 (executable)
@@ -1,3 +1,4 @@
+#!/usr/bin/env bash
 # SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 # SPDX-License-Identifier: GPL-3.0-or-later
 
@@ -11,7 +12,7 @@ export NANO_POW_EFFORT=4
 export NANO_POW_PORT=3001
 
 "$SCRIPT_DIR"/../dist/bin/nano-pow.sh --server
-sleep 2s
+sleep 3s
 
 printf '\nGet documentation\n'
 curl localhost:3001
@@ -19,29 +20,28 @@ curl localhost:3001
 printf '\nExpect errors. Server should not crash when bad data is received like missing end quote or requests exceeding max size\n'
 curl -d '{ "action": "work_validate", "work": "47c83266398728cf", "hash: "92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D" }' localhost:3001
 curl -d '{ "action": "work_validate", "work": "47c83266398728cf", "hash": "92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D }' localhost:3001
-curl -d '{ "action": "work_validate", "work": "47c83266398728cf", "hash": "92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D", "difficulty": "0000000000000000", "foo": "bar" }' localhost:3001
+curl -d '{ "action": "work_validate", "work": "47c83266398728cf", "hash": "92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D", "difficulty": "-1", "foo": "bar" }' localhost:3001
 
 printf '\nValidate good hashes\n'
 curl -d '{ "action": "work_validate", "work": "47c83266398728cf", "hash": "92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D" }' localhost:3001
 curl -d '{ "action": "work_validate", "work": "4a8fb104eebbd336", "hash": "8797585D56B8AEA3A62899C31FC088F9BE849BA8298A88E94F6E3112D4E55D01" }' localhost:3001
 curl -d '{ "action": "work_validate", "work": "c5d5d6f7c5d6ccd1", "hash": "281E89AC73B1082B464B9C3C1168384F846D39F6DF25105F8B4A22915E999117" }' localhost:3001
-curl -d '{ "action": "work_validate", "work": "326f310d629a8a98", "hash": "204076E3364D16A018754FF67D418AB2FBEB38799FF9A29A1D5F9E34F16BEEEA", "difficulty": "ffffffff00000000" }' localhost:3001
+curl -d '{ "action": "work_validate", "work": "326f310d629a8a98", "hash": "204076E3364D16A018754FF67D418AB2FBEB38799FF9A29A1D5F9E34F16BEEEA", "difficulty": "fffffff700000000" }' localhost:3001
 curl -d '{ "action": "work_validate", "work": "6866c1ac3831a891", "hash": "7069D9CD1E85D6204301D254B0927F06ACC794C9EA5DF70EA5578458FB597090", "difficulty": "fffffe0000000000" }' localhost:3001
 
 printf '\nValidate bad hashes\n'
 curl -d '{ "action": "work_validate", "work": "0000000000000000", "hash": "0000000000000000000000000000000000000000000000000000000000000000" }' localhost:3001
 curl -d '{ "action": "work_validate", "work": "c5d5d6f7c5d6ccd1", "hash": "BA1E946BA3D778C2F30A83D44D2132CC6EEF010D8D06FF10A8ABD0100D8FB47E" }' localhost:3001
 curl -d '{ "action": "work_validate", "work": "29a9ae0236990e2e", "hash": "32721F4BD2AFB6F6A08D41CD0DF3C0D9C0B5294F68D0D12422F52B28F0800B5F" }' localhost:3001
-curl -d '{ "action": "work_validate", "work": "ae238556213c3624", "hash": "BF41D87DA3057FDC6050D2B00C06531F89F4AA6195D7C6C2EAAF15B6E703F8F6", "difficulty": "ffffffff00000000" }' localhost:3001
+curl -d '{ "action": "work_validate", "work": "ae238556213c3624", "hash": "BF41D87DA3057FDC6050D2B00C06531F89F4AA6195D7C6C2EAAF15B6E703F8F6", "difficulty": "fffffff700000000" }' localhost:3001
 curl -d '{ "action": "work_validate", "work": "7d903b18d03f9820", "hash": "39C57C28F904DFE4012288FFF64CE80C0F42601023A9C82108E8F7B2D186C150", "difficulty": "fffffe0000000000" }' localhost:3001
-curl -d '{ "action": "work_validate", "work": "e45835c3b291c3d1", "hash": "9DCD89E2B92FD59D7358C2C2E4C225DF94C88E187B27882F50FEFC3760D3994F", "difficulty": "ffffffff00000000" }' localhost:3001
-
+curl -d '{ "action": "work_validate", "work": "e45835c3b291c3d1", "hash": "9DCD89E2B92FD59D7358C2C2E4C225DF94C88E187B27882F50FEFC3760D3994F", "difficulty": "fffffff700000000" }' localhost:3001
 
 printf '\nGenerate\n'
-curl -d '{ "action": "work_generate", "hash": "92BA74A7D6DC7557F3EDA95ADC6341D51AC777A0A6FF0688A5C492AB2B2CB40D" }' localhost:3001 &
-curl -d '{ "action": "work_generate", "hash": "8797585D56B8AEA3A62899C31FC088F9BE849BA8298A88E94F6E3112D4E55D01" }' localhost:3001 &
-curl -d '{ "action": "work_generate", "hash": "281E89AC73B1082B464B9C3C1168384F846D39F6DF25105F8B4A22915E999117" }' localhost:3001 &
-curl -d '{ "action": "work_generate", "hash": "204076E3364D16A018754FF67D418AB2FBEB38799FF9A29A1D5F9E34F16BEEEA", "difficulty": "ffffffff00000000" }' localhost:3001 &
-curl -d '{ "action": "work_generate", "hash": "7069D9CD1E85D6204301D254B0927F06ACC794C9EA5DF70EA5578458FB597090", "difficulty": "fffffe0000000000" }' localhost:3001 &
-wait
+curl -d '{ "action": "work_generate", "hash": "0D653EBFE692CA655449B301DBCEEF4E4A47454E3DFDF8C87B87C50DB6530A25" }' localhost:3001
+curl -d '{ "action": "work_generate", "hash": "E93104CFC1CE40B2A29F0E85807E7E51E521C30E2B79A8EDDDB2D771DCF5C06B" }' localhost:3001
+curl -d '{ "action": "work_generate", "hash": "472F8F5D908AF51E9583950D50BAE6A8C5FD66D6984FA62D4D31ADDEA5AA3AC7" }' localhost:3001
+curl -d '{ "action": "work_generate", "hash": "B349DA23CB6979DF5894C22C427D19DD8B54B94F1F9A1F82EF74495993C750D7", "difficulty": "fffffff700000000" }' localhost:3001
+curl -d '{ "action": "work_generate", "hash": "93C2252F4D4F3EF84BFF88233289046F82245245C00A064C370BAF9889824DE9", "difficulty": "fffffe0000000000" }' localhost:3001
+
 kill $(cat "$HOME"/.nano-pow/server.pid) && rm "$HOME"/.nano-pow/server.pid
index edd4c53dd3405950a5595888504966e2242248fa..2e481babb2e8c42a6b5f1bc845ee810fab741f85 100644 (file)
@@ -17,6 +17,9 @@
                "paths": {
                        "#types": [
                                "./src/types.d.ts"
+                       ],
+                       "#utils": [
+                               "./src/utils/index.ts"
                        ]
                },
                "types": [