Parallel proof-of-work search
What is this about?
Section titled “What is this about?”This example searches for a nonce whose SHA-256 hash starts with a requested number of hexadecimal zeroes:
SHA-256("knitting-proof-of-work-v1:<nonce>") -> 0000........................................................Every nonce is independent, so each worker can scan a different stride through the nonce space. The first match wins and aborts the other workers.
This is the core idea behind proof-of-work puzzles. It is a teaching example, not a cryptocurrency miner: the prefix and difficulty are fixed, and the search has a finite region.
How it works
Section titled “How it works”- The host divides a nonce region into disjoint strides, one per worker.
- Each worker hashes its nonce values with the built-in Web Crypto SHA-256 API.
- The worker checks whether the digest has the requested number of leading zeroes.
- The first match wins; the host aborts the other hash loops through shared signals.
- Workers stop at their next signal check and return compact summaries.
Each extra hexadecimal zero makes the search about 16 times harder. The default uses four zeroes, so it remains practical across Node.js, Deno, and Bun.
bun src/run_hash.ts --threads 4 --zeroes 4 --region 50000000deno run -A src/run_hash.ts --threads 4 --zeroes 4 --region 50000000npx tsx src/run_hash.ts --threads 4 --zeroes 4 --region 50000000Expected output:
threads: 4prefix: knitting-proof-of-work-v1zeroes: 4region: 50,000,000 noncesworkers: 4tested: variesnonce: a matching noncehash: 0000........................................................cancelled: yeselapsed: runtime-dependentThe region and prefix are deterministic, but the first winning nonce can vary with worker scheduling. The tested count and elapsed time also depend on how quickly the other workers observe the abort signal.
Why this pattern works
Section titled “Why this pattern works”- Hash attempts are independent and need no shared mutable state.
- Each worker owns a disjoint nonce stride, so no nonce is checked twice.
- The worker returns one match instead of transferring every failed hash.
- Abort-aware tasks stop wasted hashing as soon as another worker succeeds.
- The finite region keeps the no-match case safe and testable.
import { createPool, isMain } from "knitting";import { findHash, type HashResult } from "./find_hash.ts";
type Options = { threads: number; zeroes: number; region: number;};
type AbortablePromise<T> = Promise<T> & { reject: (reason?: unknown) => void;};
const PREFIX = "knitting-proof-of-work-v1";
function positiveIntArg(name: string, fallback: number): number { const index = process.argv.indexOf(`--${name}`); const value = index === -1 ? undefined : Number(process.argv[index + 1]); return Number.isSafeInteger(value) && value > 0 ? value : fallback;}
function readOptions(): Options { return { threads: positiveIntArg("threads", 4), zeroes: Math.min(positiveIntArg("zeroes", 4), 64), region: positiveIntArg("region", 50_000_000), };}
function waitForFirstHash( jobs: AbortablePromise<HashResult>[],): Promise<HashResult | null> { return new Promise((resolve, reject) => { let remaining = jobs.length;
for (const job of jobs) { job.then( (result) => { if (result.nonce !== null) { resolve(result); return; }
remaining--; if (remaining === 0) resolve(null); }, reject, ); } });}
async function main() { const options = readOptions(); const workerCount = Math.min(options.threads, options.region);
using pool = createPool({ threads: options.threads, abortSignalCapacity: workerCount, })({ findHash });
const started = performance.now(); const jobs: AbortablePromise<HashResult>[] = [];
for (let worker = 0; worker < workerCount; worker++) { const count = Math.ceil((options.region - worker) / options.threads);
jobs.push( pool.call.findHash([ PREFIX, worker, count, options.threads, options.zeroes, ]), ); }
let winner: HashResult | null; try { winner = await waitForFirstHash(jobs); if (winner !== null) { for (const job of jobs) job.reject(); }
const results = await Promise.all(jobs); const tested = results.reduce((total, result) => total + result.tested, 0); const cancelled = results.some((result) => result.aborted); const elapsed = performance.now() - started;
console.log(`threads: ${options.threads}`); console.log(`prefix: ${PREFIX}`); console.log(`zeroes: ${options.zeroes}`); console.log(`region: ${options.region.toLocaleString()} nonces`); console.log(`workers: ${workerCount}`); console.log(`tested: ${tested.toLocaleString()}`); console.log(`nonce: ${winner?.nonce ?? "not found"}`); console.log(`hash: ${winner?.hash ?? "-"}`); console.log(`cancelled: ${cancelled ? "yes" : "no"}`); console.log(`elapsed: ${elapsed.toFixed(0)} ms`); } catch (error) { for (const job of jobs) job.reject(); await Promise.allSettled(jobs); throw error; }}
if (isMain) { await main();}import { task } from "knitting";
type HashJob = readonly [ prefix: string, start: number, count: number, step: number, zeroes: number,];
export type HashResult = { nonce: number | null; hash: string | null; tested: number; aborted: boolean;};
const encoder = new TextEncoder();
export const findHash = task< HashJob, HashResult, { readonly hasAborted: true }>({ abortSignal: { hasAborted: true }, f: async ([prefix, start, count, step, zeroes], signal) => { let nonce = start;
for (let i = 0; i < count; i++, nonce += step) { if (signal.hasAborted()) { return { nonce: null, hash: null, tested: i, aborted: true }; }
const input = encoder.encode(`${prefix}:${nonce}`); const bytes = new Uint8Array( await crypto.subtle.digest("SHA-256", input), );
if (signal.hasAborted()) { return { nonce: null, hash: null, tested: i + 1, aborted: true }; }
if (hasLeadingZeroes(bytes, zeroes)) { return { nonce, hash: toHex(bytes), tested: i + 1, aborted: false, }; } }
return { nonce: null, hash: null, tested: count, aborted: false }; },});
function hasLeadingZeroes(bytes: Uint8Array, zeroes: number): boolean { const fullBytes = Math.floor(zeroes / 2);
for (let index = 0; index < fullBytes; index++) { if (bytes[index] !== 0) return false; }
return zeroes % 2 === 0 || (bytes[fullBytes]! & 0xf0) === 0;}
function toHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( "", );}CLI knobs
Section titled “CLI knobs”--zeroes— leading hexadecimal zeroes required in the digest, from 1 to 64.--region— total nonce values available to all workers.--threads— number of workers scanning the region.
Things to try
Section titled “Things to try”- Use
--zeroes 3 --region 100000for a quick run. - Increase to
--zeroes 5and watch the work grow by roughly 16 times. - Compare one worker with four workers on the same difficulty.
- Change the prefix in
run_hash.tsand confirm that the winning nonce changes.