diff --git a/docs/guide/widget.md b/docs/guide/widget.md
index 5733056b..6188c148 100644
--- a/docs/guide/widget.md
+++ b/docs/guide/widget.md
@@ -4,7 +4,7 @@ outline: [2, 3, 4]
# Widget
-Cap's client-side widget handles requesting, solving and displaying challenges using a native web component and rust-flavoured WASM. It also includes the [programmatic mode](./programmatic).
+Cap's client-side widget handles requesting, solving and displaying challenges using a native web component and a C-based WASM solver. It also includes the [programmatic mode](./programmatic).
## Installation
diff --git a/docs/guide/workings.md b/docs/guide/workings.md
index cfa6cff5..ebf8df4a 100644
--- a/docs/guide/workings.md
+++ b/docs/guide/workings.md
@@ -15,7 +15,7 @@ By the way, this is a more technical explanation of how Cap works. If you're loo
#### Computing the solution
-5. The widget uses Rust-flavoured WASM and Web Workers to solve the challenges in parallel:
+5. The widget uses a C-based WASM solver and Web Workers to solve the challenges in parallel:
- Each worker attempts to find a valid nonce by repeatedly:
- Combining the salt with different nonce values
- Computing the SHA-256 hash of this combination
diff --git a/wasm/benchmark/README.md b/wasm/benchmark/README.md
new file mode 100644
index 00000000..737b652f
--- /dev/null
+++ b/wasm/benchmark/README.md
@@ -0,0 +1,42 @@
+# Benchmark
+
+This benchmark compares the historical Rust solver from commit `da725dba93f61099d264bc597d22ff388d09d2ad` against the current C solver at `HEAD`.
+
+## What it does
+
+- Rebuilds the Rust implementation from `benchmark/rust`
+- Rebuilds the current C implementation from `src/c`
+- Loads both Node targets
+- Verifies the same challenge cases before timing them
+- Reports average time per solve and the relative speedup
+
+## Run it
+
+```bash
+bun --cwd benchmark run bench
+```
+
+Optional environment variables:
+
+- `BENCH_ITERATIONS` controls the timed loops, default `10`
+- `BENCH_WARMUP` controls warmup rounds, default `2`
+
+## Browser version
+
+This repo also includes a browser-hosted version that loads the wasm modules in a real browser
+context.
+
+```bash
+bun --cwd benchmark run bench:browser
+```
+
+Then open the printed local URL. You can override the browser benchmark loop counts with query
+parameters like `?iterations=20&warmup=3`.
+
+## Requirements
+
+- `make`
+- `clang`
+- `git`
+- `rustup`
+- the `wasm32-unknown-unknown` target installed for the rustup-managed toolchain
diff --git a/wasm/benchmark/bench.js b/wasm/benchmark/bench.js
new file mode 100644
index 00000000..65e3c259
--- /dev/null
+++ b/wasm/benchmark/bench.js
@@ -0,0 +1,62 @@
+import path from "node:path";
+import { createRequire } from "node:module";
+import { buildAll, headDir, rustDir } from "./build-artifacts.js";
+import { benchmarkSolver, rustBaselineCommit, verifySolver } from "./shared.js";
+
+const iterations = Number.parseInt(process.env.BENCH_ITERATIONS ?? "10", 10);
+const warmupRounds = Number.parseInt(process.env.BENCH_WARMUP ?? "2", 10);
+
+if (!Number.isFinite(iterations) || iterations < 1) {
+ throw new Error("BENCH_ITERATIONS must be a positive integer");
+}
+
+if (!Number.isFinite(warmupRounds) || warmupRounds < 0) {
+ throw new Error("BENCH_WARMUP must be a non-negative integer");
+}
+
+function loadSolvePow(modulePath) {
+ const require = createRequire(import.meta.url);
+ const mod = require(modulePath);
+
+ if (typeof mod.solve_pow !== "function") {
+ throw new Error(`missing solve_pow in ${modulePath}`);
+ }
+
+ return mod.solve_pow;
+}
+
+buildAll();
+
+const headSolvePow = loadSolvePow(path.join(headDir, "cap_wasm.js"));
+const rustSolvePow = loadSolvePow(path.join(rustDir, "cap_wasm.js"));
+
+verifySolver("HEAD (C)", headSolvePow);
+verifySolver(`Rust (${rustBaselineCommit.slice(0, 7)})`, rustSolvePow);
+
+const headResult = benchmarkSolver("HEAD (C)", headSolvePow, {
+ iterations,
+ warmupRounds,
+});
+const rustResult = benchmarkSolver(`Rust (${rustBaselineCommit.slice(0, 7)})`, rustSolvePow, {
+ iterations,
+ warmupRounds,
+});
+
+if (headResult.checksum !== rustResult.checksum) {
+ throw new Error(
+ `checksum mismatch: ${headResult.checksum.toString()} != ${rustResult.checksum.toString()}`,
+ );
+}
+
+console.log(`\nBenchmark results`);
+console.log(
+ `${headResult.label.padEnd(14)} ${headResult.elapsedMs.toFixed(2)} ms total ` +
+ `(${headResult.perSolveMs.toFixed(4)} ms/solve)`,
+);
+console.log(
+ `${rustResult.label.padEnd(14)} ${rustResult.elapsedMs.toFixed(2)} ms total ` +
+ `(${rustResult.perSolveMs.toFixed(4)} ms/solve)`,
+);
+console.log(
+ `Speedup: ${(rustResult.perSolveMs / headResult.perSolveMs).toFixed(2)}x faster on HEAD`,
+);
diff --git a/wasm/benchmark/browser/bench.js b/wasm/benchmark/browser/bench.js
new file mode 100644
index 00000000..22c58dfa
--- /dev/null
+++ b/wasm/benchmark/browser/bench.js
@@ -0,0 +1,103 @@
+import { benchmarkSolver, challengeCases, rustBaselineCommit, verifySolver } from "../shared.js";
+
+const statusEl = document.getElementById("status");
+const metaEl = document.getElementById("meta");
+const outputEl = document.getElementById("output");
+
+function log(line) {
+ outputEl.textContent += `${line}\n`;
+ console.log(line);
+}
+
+function fail(error) {
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
+ statusEl.textContent = "Failed.";
+ outputEl.textContent += `${message}\n`;
+ console.error(error);
+}
+
+window.addEventListener("error", (event) => {
+ fail(event.error ?? event.message);
+});
+
+window.addEventListener("unhandledrejection", (event) => {
+ fail(event.reason);
+});
+
+function readIntParam(name, fallback) {
+ const value = new URLSearchParams(location.search).get(name);
+
+ if (value === null) {
+ return fallback;
+ }
+
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ throw new Error(`${name} must be a non-negative integer`);
+ }
+
+ return parsed;
+}
+
+const iterations = readIntParam("iterations", 10);
+const warmupRounds = readIntParam("warmup", 2);
+
+metaEl.textContent = `iterations=${iterations}, warmup=${warmupRounds}, cases=${challengeCases.length}`;
+
+try {
+ const cacheToken = Date.now();
+ const headModule = await import(`/out/head/browser/cap_wasm.js?cache=${cacheToken}`);
+ const rustModule = await import(`/out/rust/browser/cap_wasm.js?cache=${cacheToken}`);
+
+ const initHead = headModule.default;
+ const headSolvePow = headModule.solve_pow;
+ const initRust = rustModule.default;
+ const rustSolvePow = rustModule.solve_pow;
+
+ statusEl.textContent = "Loading wasm modules...";
+ log("Loading wasm modules...");
+ await Promise.all([initHead(), initRust()]);
+ log("Wasm modules loaded.");
+
+ statusEl.textContent = "Verifying solver outputs...";
+ log("Verifying solver outputs...");
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+
+ verifySolver("HEAD (C)", headSolvePow);
+ verifySolver(`Rust (${rustBaselineCommit.slice(0, 7)})`, rustSolvePow);
+ log("Verification complete.");
+
+ statusEl.textContent = "Running benchmark...";
+ log("Running benchmark...");
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+
+ const headResult = benchmarkSolver("HEAD (C)", headSolvePow, {
+ iterations,
+ warmupRounds,
+ });
+ const rustResult = benchmarkSolver(`Rust (${rustBaselineCommit.slice(0, 7)})`, rustSolvePow, {
+ iterations,
+ warmupRounds,
+ });
+
+ if (headResult.checksum !== rustResult.checksum) {
+ throw new Error(
+ `checksum mismatch: ${headResult.checksum.toString()} != ${rustResult.checksum.toString()}`,
+ );
+ }
+
+ log("Benchmark results");
+ log(
+ `${headResult.label.padEnd(14)} ${headResult.elapsedMs.toFixed(2)} ms total ` +
+ `(${headResult.perSolveMs.toFixed(4)} ms/solve)`,
+ );
+ log(
+ `${rustResult.label.padEnd(14)} ${rustResult.elapsedMs.toFixed(2)} ms total ` +
+ `(${rustResult.perSolveMs.toFixed(4)} ms/solve)`,
+ );
+ log(`Speedup: ${(rustResult.perSolveMs / headResult.perSolveMs).toFixed(2)}x faster on HEAD`);
+
+ statusEl.textContent = "Done.";
+} catch (error) {
+ fail(error);
+}
diff --git a/wasm/benchmark/browser/index.html b/wasm/benchmark/browser/index.html
new file mode 100644
index 00000000..dba2e4ca
--- /dev/null
+++ b/wasm/benchmark/browser/index.html
@@ -0,0 +1,109 @@
+
+
+
+
+
+ Wasm Browser Benchmark
+
+
+
+
+
+ Wasm Browser Benchmark
+
+ Runs the C and Rust solvers in an actual browser context and compares their average solve
+ time.
+
+ Waiting to load wasm modules...
+
+
+
+
+
+
diff --git a/wasm/benchmark/build-artifacts.js b/wasm/benchmark/build-artifacts.js
new file mode 100644
index 00000000..bdfbd299
--- /dev/null
+++ b/wasm/benchmark/build-artifacts.js
@@ -0,0 +1,144 @@
+import fs from "node:fs";
+import path from "node:path";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+export const __dirname = path.dirname(fileURLToPath(import.meta.url));
+export const rootDir = path.join(__dirname, "..");
+export const rustupBin = path.join(process.env.HOME ?? "", ".cargo", "bin", "rustup");
+export const rustcBin = path.join(process.env.HOME ?? "", ".cargo", "bin", "rustc");
+export const rustBaselineCommit = "da725dba93f61099d264bc597d22ff388d09d2ad";
+export const outDir = path.join(__dirname, "out");
+export const headDir = path.join(outDir, "head");
+export const headBuildDir = path.join(headDir, "build");
+export const headBrowserDir = path.join(headDir, "browser");
+export const nodeLoaderSrc = path.join(rootDir, "src", "node", "cap_wasm.js");
+export const browserLoaderSrc = path.join(rootDir, "src", "browser", "cap_wasm.js");
+export const headLoaderDest = path.join(headDir, "cap_wasm.js");
+export const headWasm = path.join(headDir, "cap_wasm_bg.wasm");
+export const rustDir = path.join(outDir, "rust");
+export const rustBrowserDir = path.join(rustDir, "browser");
+export const rustSrcDir = path.join(__dirname, "rust");
+
+function resetDir(dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ fs.mkdirSync(dir, { recursive: true });
+}
+
+export function buildHead() {
+ resetDir(headDir);
+ fs.mkdirSync(headBrowserDir, { recursive: true });
+
+ execFileSync(
+ "make",
+ [
+ "-C",
+ path.join(rootDir, "src", "c"),
+ `BUILD_DIR=${headBuildDir}`,
+ `OUT=${path.join(headBuildDir, "cap_wasm.wasm")}`,
+ `NODE_WASM=${headWasm}`,
+ `BROWSER_WASM=${path.join(headBrowserDir, "cap_wasm_bg.wasm")}`,
+ ],
+ {
+ cwd: rootDir,
+ stdio: "inherit",
+ },
+ );
+
+ fs.copyFileSync(nodeLoaderSrc, headLoaderDest);
+ fs.copyFileSync(browserLoaderSrc, path.join(headBrowserDir, "cap_wasm.js"));
+}
+
+export function buildRust() {
+ resetDir(rustDir);
+ fs.mkdirSync(rustBrowserDir, { recursive: true });
+
+ execFileSync(
+ rustupBin,
+ [
+ "run",
+ "stable",
+ "cargo",
+ "build",
+ "--manifest-path",
+ path.join(rustSrcDir, "Cargo.toml"),
+ "--target",
+ "wasm32-unknown-unknown",
+ "--release",
+ ],
+ {
+ cwd: rootDir,
+ env: {
+ ...process.env,
+ CARGO_TARGET_DIR: path.join(rustDir, "target"),
+ RUSTC: rustcBin,
+ },
+ stdio: "inherit",
+ },
+ );
+
+ const rustWasmSource = path.join(
+ rustDir,
+ "target",
+ "wasm32-unknown-unknown",
+ "release",
+ "cap_wasm.wasm",
+ );
+
+ const rustNodeLoader = execFileSync(
+ "git",
+ ["show", `${rustBaselineCommit}:wasm/src/node/cap_wasm.js`],
+ {
+ cwd: rootDir,
+ encoding: "utf8",
+ maxBuffer: 16 * 1024 * 1024,
+ },
+ );
+
+ const rustNodeLoaderWithStubs = rustNodeLoader
+ .replace(
+ "imports = {};",
+ [
+ "imports = {};",
+ "module.exports.__wbindgen_describe = function () {};",
+ "module.exports.__wbindgen_externref_xform__ = {",
+ " __wbindgen_externref_table_set_null: function () {},",
+ " __wbindgen_externref_table_grow: function () { return 0; },",
+ "};",
+ "imports.__wbindgen_externref_xform__ = module.exports.__wbindgen_externref_xform__;",
+ ].join("\n"),
+ )
+ .replace("wasm.__wbindgen_start());", "wasm.__wbindgen_start && wasm.__wbindgen_start());");
+
+ fs.writeFileSync(path.join(rustDir, "cap_wasm.js"), rustNodeLoaderWithStubs);
+ fs.copyFileSync(rustWasmSource, path.join(rustDir, "cap_wasm_bg.wasm"));
+ const rustBrowserTemplate = fs.readFileSync(browserLoaderSrc, "utf8");
+ const rustBrowserLoader = rustBrowserTemplate.replace(
+ /function __wbg_get_imports\(\) \{[\s\S]*?\n\}\nfunction __wbg_init_memory/,
+ `function __wbg_get_imports() {
+ const e = { __wbindgen_placeholder__: {}, __wbindgen_externref_xform__: {} };
+ e.__wbindgen_placeholder__.__wbindgen_describe = function () {};
+ e.__wbindgen_externref_xform__.__wbindgen_externref_table_set_null = function () {};
+ e.__wbindgen_externref_xform__.__wbindgen_externref_table_grow = function () { return 0; };
+ e.__wbindgen_placeholder__.__wbindgen_externref_xform__ = e.__wbindgen_externref_xform__;
+ e.__wbindgen_placeholder__.__wbindgen_init_externref_table = function () {
+ const e = wasm.__wbindgen_export_0,
+ t = e.grow(4);
+ e.set(0, void 0);
+ e.set(t + 0, void 0);
+ e.set(t + 1, null);
+ e.set(t + 2, !0);
+ e.set(t + 3, !1);
+ };
+ return e;
+}
+function __wbg_init_memory`,
+ ).replace("wasm.__wbindgen_start(),", "wasm.__wbindgen_start && wasm.__wbindgen_start(),");
+ fs.writeFileSync(path.join(rustBrowserDir, "cap_wasm.js"), rustBrowserLoader);
+ fs.copyFileSync(rustWasmSource, path.join(rustBrowserDir, "cap_wasm_bg.wasm"));
+}
+
+export function buildAll() {
+ buildHead();
+ buildRust();
+}
diff --git a/wasm/benchmark/package.json b/wasm/benchmark/package.json
new file mode 100644
index 00000000..49020cda
--- /dev/null
+++ b/wasm/benchmark/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@cap.js/wasm-benchmark",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "bench": "bun run bench.js",
+ "bench:browser": "bun run serve-browser.js"
+ }
+}
diff --git a/wasm/benchmark/rust/.gitignore b/wasm/benchmark/rust/.gitignore
new file mode 100644
index 00000000..fede2a05
--- /dev/null
+++ b/wasm/benchmark/rust/.gitignore
@@ -0,0 +1,3 @@
+Cargo.lock
+pkg
+target
diff --git a/wasm/src/rust/Cargo.toml b/wasm/benchmark/rust/Cargo.toml
similarity index 100%
rename from wasm/src/rust/Cargo.toml
rename to wasm/benchmark/rust/Cargo.toml
diff --git a/wasm/src/rust/src/lib.rs b/wasm/benchmark/rust/src/lib.rs
similarity index 96%
rename from wasm/src/rust/src/lib.rs
rename to wasm/benchmark/rust/src/lib.rs
index ed70b2a0..5f07e0ff 100644
--- a/wasm/src/rust/src/lib.rs
+++ b/wasm/benchmark/rust/src/lib.rs
@@ -1,29 +1,29 @@
+use sha2::{Digest, Sha256};
use wasm_bindgen::prelude::*;
-use sha2::{Sha256, Digest};
#[wasm_bindgen]
pub fn solve_pow(salt: String, target: String) -> u64 {
let salt_bytes = salt.as_bytes();
-
+
let target_bytes = parse_hex_target(&target);
let target_bits = target.len() * 4; // each hex char = 4 bits
-
+
let mut nonce_buffer = [0u8; 20]; // u64::MAX has at most 20 digits
-
+
for nonce in 0..u64::MAX {
let nonce_len = write_u64_to_buffer(nonce, &mut nonce_buffer);
let nonce_bytes = &nonce_buffer[..nonce_len];
-
+
let mut hasher = Sha256::new();
hasher.update(salt_bytes);
hasher.update(nonce_bytes);
let hash_result = hasher.finalize();
-
+
if hash_matches_target(&hash_result, &target_bytes, target_bits) {
return nonce;
}
}
-
+
unreachable!("Solution should be found before exhausting u64::MAX");
}
@@ -45,37 +45,37 @@ fn write_u64_to_buffer(mut value: u64, buffer: &mut [u8]) -> usize {
buffer[0] = b'0';
return 1;
}
-
+
let mut len = 0;
let mut temp = value;
-
+
while temp > 0 {
len += 1;
temp /= 10;
}
-
+
for i in (0..len).rev() {
buffer[i] = (value % 10) as u8 + b'0';
value /= 10;
}
-
+
len
}
fn hash_matches_target(hash: &[u8], target_bytes: &[u8], target_bits: usize) -> bool {
let full_bytes = target_bits / 8;
let remaining_bits = target_bits % 8;
-
+
if hash[..full_bytes] != target_bytes[..full_bytes] {
return false;
}
-
+
if remaining_bits > 0 && full_bytes < target_bytes.len() {
let mask = 0xFF << (8 - remaining_bits);
let hash_masked = hash[full_bytes] & mask;
let target_masked = target_bytes[full_bytes] & mask;
return hash_masked == target_masked;
}
-
+
true
-}
\ No newline at end of file
+}
diff --git a/wasm/benchmark/serve-browser.js b/wasm/benchmark/serve-browser.js
new file mode 100644
index 00000000..59d134f3
--- /dev/null
+++ b/wasm/benchmark/serve-browser.js
@@ -0,0 +1,95 @@
+import fs from "node:fs";
+import path from "node:path";
+import { buildAll, __dirname } from "./build-artifacts.js";
+
+const rootDir = __dirname;
+const requestedPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : null;
+const host = process.env.HOST ?? "127.0.0.1";
+const mimeTypes = new Map([
+ [".html", "text/html; charset=utf-8"],
+ [".js", "text/javascript; charset=utf-8"],
+ [".mjs", "text/javascript; charset=utf-8"],
+ [".css", "text/css; charset=utf-8"],
+ [".wasm", "application/wasm"],
+ [".json", "application/json; charset=utf-8"],
+]);
+
+function resolveFile(requestUrl) {
+ const url = new URL(requestUrl, "http://localhost");
+ const pathname = url.pathname === "/" ? "/browser/index.html" : url.pathname;
+ const filePath = path.join(rootDir, pathname);
+ const normalizedRoot = path.resolve(rootDir);
+ const normalizedFile = path.resolve(filePath);
+
+ if (!normalizedFile.startsWith(normalizedRoot + path.sep) && normalizedFile !== normalizedRoot) {
+ return null;
+ }
+
+ return normalizedFile;
+}
+
+await buildAll();
+
+function createServer(port) {
+ return Bun.serve({
+ hostname: host,
+ port,
+ fetch(req) {
+ const filePath = resolveFile(new URL(req.url).pathname);
+
+ if (!filePath) {
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ let stat;
+ try {
+ stat = fs.statSync(filePath);
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const targetPath = stat.isDirectory() ? path.join(filePath, "index.html") : filePath;
+ let body;
+ try {
+ body = fs.readFileSync(targetPath);
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const ext = path.extname(targetPath);
+ return new Response(body, {
+ headers: {
+ "Content-Type": mimeTypes.get(ext) ?? "application/octet-stream",
+ "Cache-Control": "no-cache",
+ },
+ });
+ },
+ });
+}
+
+const candidatePorts =
+ requestedPort !== null && Number.isFinite(requestedPort)
+ ? [requestedPort]
+ : Array.from({ length: 200 }, (_, index) => 49152 + ((Date.now() + index) % 1000)).concat([
+ 8787,
+ 4173,
+ 8081,
+ 18080,
+ 3000,
+ ]);
+
+let lastError;
+for (const candidatePort of candidatePorts) {
+ try {
+ const server = createServer(candidatePort);
+ console.log(`Serving ${path.join(rootDir, "browser", "index.html")} at ${server.url}`);
+ lastError = null;
+ break;
+ } catch (error) {
+ lastError = error;
+ }
+}
+
+if (lastError) {
+ throw lastError;
+}
diff --git a/wasm/benchmark/shared.js b/wasm/benchmark/shared.js
new file mode 100644
index 00000000..6b402a02
--- /dev/null
+++ b/wasm/benchmark/shared.js
@@ -0,0 +1,69 @@
+export const rustBaselineCommit = "da725dba93f61099d264bc597d22ff388d09d2ad";
+
+export const challengeCases = [
+ ["e455cea65e98bc3c36287f43769da211", "dceb", 63625],
+ ["fb8d25f6abac5aa9b6360051f37e010b", "93f1", 62420],
+ ["91ef47db578fbeb2565d3f9c82bb7960", "3698", 42515],
+ ["b7ad7667486a691cda8ef297098f64a7", "d72a", 32395],
+ ["1aca3fb7cef7a2be0dee563ed4136758", "3b58", 53368],
+ ["d9ec39af92b430e5a329274d8aa58fa8", "e1d3", 52431],
+ ["781a3cc9217d73c908a321d3fdabd62f", "22c6", 102156],
+ ["e37a0752c9ac2f3d2517747fde373ac9", "f6f1", 118925],
+ ["bba070197569f322beda5b240f639a95", "4751", 25523],
+ ["89297515aeac646bee9653ba405e0beb", "a7de", 47980],
+ ["444571a0d5039c15be6141d6cd8434f9", "a783", 38735],
+ ["ba75f2bf8e9b92cc32caa17237a52d14", "7e30", 67790],
+ ["22bfc18ba8e3ecee080c5d1ef64ed6e9", "5fcf", 14836],
+ ["885fb78ff76b4eddd2f5bc04ac5ee673", "93e5", 91445],
+ ["308758072931bb3b254a7b1ed351d04a", "3e49", 19889],
+ ["724f89bb167db4b881e1dc7b0949ac8f", "b82e", 28170],
+ ["8b79506e4630de15be225c18623eff65", "f0e5", 9820],
+ ["0c21ade6e63a4e37b13cb8b087f31863", "65c9", 190704],
+];
+
+export function verifySolver(label, solvePow) {
+ for (const [salt, target, expectedNonce] of challengeCases) {
+ const actualNonce = solvePow(salt, target).toString();
+
+ if (actualNonce !== String(expectedNonce)) {
+ throw new Error(
+ `${label} mismatch for ${salt}:${target} - expected ${expectedNonce}, got ${actualNonce}`,
+ );
+ }
+ }
+}
+
+export function benchmarkSolver(label, solvePow, { iterations, warmupRounds }) {
+ if (!Number.isFinite(iterations) || iterations < 1) {
+ throw new Error("iterations must be a positive integer");
+ }
+
+ if (!Number.isFinite(warmupRounds) || warmupRounds < 0) {
+ throw new Error("warmupRounds must be a non-negative integer");
+ }
+
+ for (let round = 0; round < warmupRounds; round += 1) {
+ for (const [salt, target] of challengeCases) {
+ solvePow(salt, target);
+ }
+ }
+
+ const start = performance.now();
+ let checksum = 0n;
+
+ for (let round = 0; round < iterations; round += 1) {
+ for (const [salt, target] of challengeCases) {
+ checksum ^= solvePow(salt, target);
+ }
+ }
+
+ const elapsedMs = performance.now() - start;
+ const totalCalls = iterations * challengeCases.length;
+
+ return {
+ label,
+ checksum,
+ elapsedMs,
+ perSolveMs: elapsedMs / totalCalls,
+ };
+}
diff --git a/wasm/build.js b/wasm/build.js
index bc977bd4..b4abc177 100644
--- a/wasm/build.js
+++ b/wasm/build.js
@@ -3,38 +3,18 @@ import fs from "node:fs";
import path from "node:path";
import { minify } from "terser";
-const rustSrcDir = path.join(process.cwd(), "./src/rust");
+const cSrcDir = path.join(process.cwd(), "./src/c");
const nodeOutDir = path.join(process.cwd(), "./src/node");
const browserOutDir = path.join(process.cwd(), "./src/browser");
-const packageName = "cap_wasm";
+const wasmOutPath = path.join(cSrcDir, "build", "cap_wasm.wasm");
-console.log(`Cleaning old build directories...`);
-try {
- fs.rmdirSync(nodeOutDir);
-} catch {}
-try {
- fs.rmdirSync(browserOutDir);
-} catch {}
+console.log(`Building C wasm...`);
+execSync(`make -C "${cSrcDir}" clean`, { stdio: "inherit" });
+execSync(`make -C "${cSrcDir}"`, { stdio: "inherit" });
-console.log(`\n Building for Node...`);
-execSync(
- `wasm-pack build "${rustSrcDir}" --target nodejs --out-dir "${nodeOutDir}" --out-name "${packageName}"`,
- { stdio: "inherit" },
-);
-
-console.log(`\n Building for web...`);
-execSync(
- `wasm-pack build "${rustSrcDir}" --target web --out-dir "${browserOutDir}" --out-name "${packageName}"`,
- { stdio: "inherit" },
-);
-
-console.log(`\n Removing .gitignore...`);
-
-[browserOutDir, nodeOutDir].forEach((dir) => {
- try {
- fs.rmSync(path.join(dir, ".gitignore"));
- } catch {}
-});
+console.log(`\n Syncing wasm binary to package outputs...`);
+fs.copyFileSync(wasmOutPath, path.join(nodeOutDir, "cap_wasm_bg.wasm"));
+fs.copyFileSync(wasmOutPath, path.join(browserOutDir, "cap_wasm_bg.wasm"));
console.log(`\n Minifing loaders...`);
@@ -56,10 +36,14 @@ if (!doTest) {
}
console.log(`\n test...`);
-execSync(`bun ${path.join("test", "node.js")}`, { stdio: "inherit" });
+execSync(`bun test ${path.join("c", "test-regression.test.js")}`, {
+ stdio: "inherit",
+ cwd: "./src",
+});
+execSync(`bun test ${path.join("test", "node.test.js")}`, { stdio: "inherit" });
console.log(`\n testing odd difficulty...`);
-execSync(`bun ${path.join("test", "node_odd_difficulty.js")}`, {
+execSync(`bun test ${path.join("test", "node_odd_difficulty.test.js")}`, {
stdio: "inherit",
});
diff --git a/wasm/src/README.md b/wasm/src/README.md
index 06463799..8d049394 100644
--- a/wasm/src/README.md
+++ b/wasm/src/README.md
@@ -1 +1,3 @@
# Cap WASM
+
+C implementation of Cap's proof-of-work solver, compiled to WebAssembly for the browser and Node.js.
diff --git a/wasm/src/browser/cap_wasm_bg.wasm b/wasm/src/browser/cap_wasm_bg.wasm
index e2134b54..d3b96da6 100644
Binary files a/wasm/src/browser/cap_wasm_bg.wasm and b/wasm/src/browser/cap_wasm_bg.wasm differ
diff --git a/wasm/src/c/Makefile b/wasm/src/c/Makefile
new file mode 100644
index 00000000..597b48a5
--- /dev/null
+++ b/wasm/src/c/Makefile
@@ -0,0 +1,31 @@
+CC = clang
+TARGET ?= wasm32-unknown-unknown
+BUILD_DIR ?= build
+OUT ?= $(BUILD_DIR)/cap_wasm.wasm
+NODE_WASM := ../node/cap_wasm_bg.wasm
+BROWSER_WASM := ../browser/cap_wasm_bg.wasm
+
+CFLAGS ?= -O3 -ffreestanding -fno-builtin -fvisibility=hidden --target=$(TARGET)
+INCLUDES := -Iinclude
+LDFLAGS ?= -nostdlib -Wl,--no-entry -Wl,--export=solve_pow -Wl,--export=__wbindgen_malloc -Wl,--export=__wbindgen_realloc -Wl,--export=__wbindgen_start -Wl,--export-memory
+
+SRC := src/cap_wasm.c
+
+.PHONY: all clean sync-node sync-browser
+
+all: $(OUT) sync-node sync-browser
+
+$(BUILD_DIR):
+ mkdir -p $(BUILD_DIR)
+
+$(OUT): $(SRC) include/cap_wasm.h | $(BUILD_DIR)
+ $(CC) $(CFLAGS) $(INCLUDES) $(SRC) $(LDFLAGS) -o $(OUT)
+
+sync-node: $(OUT)
+ cp $(OUT) $(NODE_WASM)
+
+sync-browser: $(OUT)
+ cp $(OUT) $(BROWSER_WASM)
+
+clean:
+ rm -rf $(BUILD_DIR)
diff --git a/wasm/src/c/include/cap_wasm.h b/wasm/src/c/include/cap_wasm.h
new file mode 100644
index 00000000..babd0a89
--- /dev/null
+++ b/wasm/src/c/include/cap_wasm.h
@@ -0,0 +1,20 @@
+#ifndef CAP_WASM_H
+#define CAP_WASM_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+uint64_t solve_pow(const char *salt, uint32_t salt_len, const char *target, uint32_t target_len);
+
+void *__wbindgen_malloc(uint32_t size, uint32_t align);
+void *__wbindgen_realloc(void *ptr, uint32_t old_size, uint32_t new_size, uint32_t align);
+void __wbindgen_start(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/wasm/src/c/src/cap_wasm.c b/wasm/src/c/src/cap_wasm.c
new file mode 100644
index 00000000..e43536a2
--- /dev/null
+++ b/wasm/src/c/src/cap_wasm.c
@@ -0,0 +1,339 @@
+#include "cap_wasm.h"
+
+#include
+
+extern unsigned char __heap_base;
+
+#define SHA256_BLOCK_SIZE 64u
+#define SHA256_DIGEST_SIZE 32u
+#define WASM_PAGE_SIZE 65536u
+
+typedef struct {
+ uint32_t state[8];
+ uint64_t bitlen;
+ uint8_t buffer[SHA256_BLOCK_SIZE];
+ uint32_t buffer_len;
+} sha256_ctx;
+
+static uint32_t heap_cursor = 0;
+
+static void trap(void) {
+ __builtin_trap();
+}
+
+static uint32_t align_up(uint32_t value, uint32_t align) {
+ if (align <= 1u) {
+ return value;
+ }
+ return (value + (align - 1u)) & ~(align - 1u);
+}
+
+static void copy_bytes(uint8_t *dst, const uint8_t *src, uint32_t len) {
+ for (uint32_t i = 0; i < len; ++i) {
+ dst[i] = src[i];
+ }
+}
+
+static void ensure_capacity(uint32_t end) {
+ uint32_t current_pages = (uint32_t)__builtin_wasm_memory_size(0);
+ uint32_t current_bytes = current_pages * WASM_PAGE_SIZE;
+
+ if (end <= current_bytes) {
+ return;
+ }
+
+ uint32_t needed_bytes = end - current_bytes;
+ uint32_t grow_pages = (needed_bytes + WASM_PAGE_SIZE - 1u) / WASM_PAGE_SIZE;
+ int32_t previous_pages = __builtin_wasm_memory_grow(0, (int32_t)grow_pages);
+ if (previous_pages < 0) {
+ trap();
+ }
+}
+
+static void *wasm_alloc(uint32_t size, uint32_t align) {
+ if (size == 0u) {
+ return (void *)0;
+ }
+
+ if (heap_cursor == 0u) {
+ heap_cursor = (uint32_t)(uintptr_t)&__heap_base;
+ }
+
+ uint32_t cursor = align_up(heap_cursor, align);
+ uint32_t end = cursor + size;
+ if (end < cursor) {
+ trap();
+ }
+
+ ensure_capacity(end);
+ heap_cursor = end;
+ return (void *)(uintptr_t)cursor;
+}
+
+static uint32_t hex_value(char c) {
+ if (c >= '0' && c <= '9') {
+ return (uint32_t)(c - '0');
+ }
+ if (c >= 'a' && c <= 'f') {
+ return (uint32_t)(c - 'a' + 10);
+ }
+ if (c >= 'A' && c <= 'F') {
+ return (uint32_t)(c - 'A' + 10);
+ }
+
+ trap();
+ return 0;
+}
+
+static void parse_hex_target(const char *target, uint32_t target_len, uint8_t *out) {
+ uint32_t padded_len = target_len + (target_len & 1u);
+ uint32_t out_len = padded_len / 2u;
+
+ for (uint32_t i = 0; i < out_len; ++i) {
+ uint32_t src_index = i * 2u;
+ char hi = target[src_index];
+ char lo = (src_index + 1u < target_len) ? target[src_index + 1u] : '0';
+ out[i] = (uint8_t)((hex_value(hi) << 4u) | hex_value(lo));
+ }
+}
+
+static uint32_t write_u64_to_buffer(uint64_t value, uint8_t *buffer) {
+ if (value == 0u) {
+ buffer[0] = (uint8_t)'0';
+ return 1u;
+ }
+
+ uint32_t len = 0u;
+ uint64_t temp = value;
+
+ while (temp > 0u) {
+ ++len;
+ temp /= 10u;
+ }
+
+ for (uint32_t i = len; i-- > 0u;) {
+ buffer[i] = (uint8_t)('0' + (value % 10u));
+ value /= 10u;
+ }
+
+ return len;
+}
+
+static int hash_matches_target(const uint8_t *hash, const uint8_t *target_bytes, uint32_t target_bits, uint32_t target_bytes_len) {
+ uint32_t full_bytes = target_bits / 8u;
+ uint32_t remaining_bits = target_bits % 8u;
+
+ for (uint32_t i = 0; i < full_bytes; ++i) {
+ if (hash[i] != target_bytes[i]) {
+ return 0;
+ }
+ }
+
+ if (remaining_bits > 0u && full_bytes < target_bytes_len) {
+ uint8_t mask = (uint8_t)(0xFFu << (8u - remaining_bits));
+ uint8_t hash_masked = (uint8_t)(hash[full_bytes] & mask);
+ uint8_t target_masked = (uint8_t)(target_bytes[full_bytes] & mask);
+ return hash_masked == target_masked;
+ }
+
+ return 1;
+}
+
+static uint32_t rotr32(uint32_t value, uint32_t bits) {
+ return (value >> bits) | (value << (32u - bits));
+}
+
+static uint32_t load_be32(const uint8_t *p) {
+ return ((uint32_t)p[0] << 24u) | ((uint32_t)p[1] << 16u) | ((uint32_t)p[2] << 8u) | (uint32_t)p[3];
+}
+
+static void store_be32(uint8_t *p, uint32_t value) {
+ p[0] = (uint8_t)(value >> 24u);
+ p[1] = (uint8_t)(value >> 16u);
+ p[2] = (uint8_t)(value >> 8u);
+ p[3] = (uint8_t)value;
+}
+
+static void store_be64(uint8_t *p, uint64_t value) {
+ p[0] = (uint8_t)(value >> 56u);
+ p[1] = (uint8_t)(value >> 48u);
+ p[2] = (uint8_t)(value >> 40u);
+ p[3] = (uint8_t)(value >> 32u);
+ p[4] = (uint8_t)(value >> 24u);
+ p[5] = (uint8_t)(value >> 16u);
+ p[6] = (uint8_t)(value >> 8u);
+ p[7] = (uint8_t)value;
+}
+
+static void sha256_transform(sha256_ctx *ctx, const uint8_t block[SHA256_BLOCK_SIZE]) {
+ static const uint32_t k[64] = {
+ 0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u, 0x3956c25bu, 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u,
+ 0xd807aa98u, 0x12835b01u, 0x243185beu, 0x550c7dc3u, 0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, 0xc19bf174u,
+ 0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu, 0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau,
+ 0x983e5152u, 0xa831c66du, 0xb00327c8u, 0xbf597fc7u, 0xc6e00bf3u, 0xd5a79147u, 0x06ca6351u, 0x14292967u,
+ 0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, 0x53380d13u, 0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u,
+ 0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u, 0xd192e819u, 0xd6990624u, 0xf40e3585u, 0x106aa070u,
+ 0x19a4c116u, 0x1e376c08u, 0x2748774cu, 0x34b0bcb5u, 0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, 0x682e6ff3u,
+ 0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u, 0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u
+ };
+
+ uint32_t w[64];
+
+ for (uint32_t i = 0; i < 16u; ++i) {
+ w[i] = load_be32(block + (i * 4u));
+ }
+
+ for (uint32_t i = 16u; i < 64u; ++i) {
+ uint32_t s0 = rotr32(w[i - 15u], 7u) ^ rotr32(w[i - 15u], 18u) ^ (w[i - 15u] >> 3u);
+ uint32_t s1 = rotr32(w[i - 2u], 17u) ^ rotr32(w[i - 2u], 19u) ^ (w[i - 2u] >> 10u);
+ w[i] = w[i - 16u] + s0 + w[i - 7u] + s1;
+ }
+
+ uint32_t a = ctx->state[0];
+ uint32_t b = ctx->state[1];
+ uint32_t c = ctx->state[2];
+ uint32_t d = ctx->state[3];
+ uint32_t e = ctx->state[4];
+ uint32_t f = ctx->state[5];
+ uint32_t g = ctx->state[6];
+ uint32_t h = ctx->state[7];
+
+ for (uint32_t i = 0; i < 64u; ++i) {
+ uint32_t s1 = rotr32(e, 6u) ^ rotr32(e, 11u) ^ rotr32(e, 25u);
+ uint32_t ch = (e & f) ^ ((~e) & g);
+ uint32_t temp1 = h + s1 + ch + k[i] + w[i];
+ uint32_t s0 = rotr32(a, 2u) ^ rotr32(a, 13u) ^ rotr32(a, 22u);
+ uint32_t maj = (a & b) ^ (a & c) ^ (b & c);
+ uint32_t temp2 = s0 + maj;
+
+ h = g;
+ g = f;
+ f = e;
+ e = d + temp1;
+ d = c;
+ c = b;
+ b = a;
+ a = temp1 + temp2;
+ }
+
+ ctx->state[0] += a;
+ ctx->state[1] += b;
+ ctx->state[2] += c;
+ ctx->state[3] += d;
+ ctx->state[4] += e;
+ ctx->state[5] += f;
+ ctx->state[6] += g;
+ ctx->state[7] += h;
+}
+
+static void sha256_init(sha256_ctx *ctx) {
+ ctx->state[0] = 0x6a09e667u;
+ ctx->state[1] = 0xbb67ae85u;
+ ctx->state[2] = 0x3c6ef372u;
+ ctx->state[3] = 0xa54ff53au;
+ ctx->state[4] = 0x510e527fu;
+ ctx->state[5] = 0x9b05688cu;
+ ctx->state[6] = 0x1f83d9abu;
+ ctx->state[7] = 0x5be0cd19u;
+ ctx->bitlen = 0u;
+ ctx->buffer_len = 0u;
+}
+
+static void sha256_update(sha256_ctx *ctx, const uint8_t *data, uint32_t len) {
+ ctx->bitlen += (uint64_t)len * 8u;
+
+ while (len > 0u) {
+ uint32_t space = SHA256_BLOCK_SIZE - ctx->buffer_len;
+ uint32_t to_copy = len < space ? len : space;
+
+ copy_bytes(ctx->buffer + ctx->buffer_len, data, to_copy);
+ ctx->buffer_len += to_copy;
+ data += to_copy;
+ len -= to_copy;
+
+ if (ctx->buffer_len == SHA256_BLOCK_SIZE) {
+ sha256_transform(ctx, ctx->buffer);
+ ctx->buffer_len = 0u;
+ }
+ }
+}
+
+static void sha256_final(sha256_ctx *ctx, uint8_t out[SHA256_DIGEST_SIZE]) {
+ uint32_t i = ctx->buffer_len;
+
+ ctx->buffer[i++] = 0x80u;
+
+ if (i > 56u) {
+ while (i < SHA256_BLOCK_SIZE) {
+ ctx->buffer[i++] = 0u;
+ }
+ sha256_transform(ctx, ctx->buffer);
+ i = 0u;
+ }
+
+ while (i < 56u) {
+ ctx->buffer[i++] = 0u;
+ }
+
+ store_be64(ctx->buffer + 56u, ctx->bitlen);
+ sha256_transform(ctx, ctx->buffer);
+
+ for (uint32_t j = 0; j < 8u; ++j) {
+ store_be32(out + (j * 4u), ctx->state[j]);
+ }
+}
+
+uint64_t solve_pow(const char *salt, uint32_t salt_len, const char *target, uint32_t target_len) {
+ uint8_t nonce_buffer[20];
+ uint32_t target_bits = target_len * 4u;
+ uint32_t target_bytes_len = (target_len + (target_len & 1u)) / 2u;
+ uint8_t *target_bytes = 0;
+
+ if (target_bytes_len > 0u) {
+ target_bytes = (uint8_t *)__builtin_alloca(target_bytes_len);
+ parse_hex_target(target, target_len, target_bytes);
+ }
+
+ for (uint64_t nonce = 0u; nonce != UINT64_MAX; ++nonce) {
+ uint32_t nonce_len = write_u64_to_buffer(nonce, nonce_buffer);
+ uint8_t hash[SHA256_DIGEST_SIZE];
+
+ sha256_ctx ctx;
+ sha256_init(&ctx);
+ sha256_update(&ctx, (const uint8_t *)salt, salt_len);
+ sha256_update(&ctx, nonce_buffer, nonce_len);
+ sha256_final(&ctx, hash);
+
+ if (hash_matches_target(hash, target_bytes, target_bits, target_bytes_len)) {
+ return nonce;
+ }
+ }
+
+ trap();
+ return 0u;
+}
+
+void *__wbindgen_malloc(uint32_t size, uint32_t align) {
+ return wasm_alloc(size, align);
+}
+
+void *__wbindgen_realloc(void *ptr, uint32_t old_size, uint32_t new_size, uint32_t align) {
+ if (new_size == 0u) {
+ return (void *)0;
+ }
+
+ void *new_ptr = wasm_alloc(new_size, align);
+ if (new_ptr == 0) {
+ trap();
+ }
+
+ uint32_t copy_size = old_size < new_size ? old_size : new_size;
+ if (ptr != 0 && copy_size > 0u) {
+ copy_bytes((uint8_t *)new_ptr, (const uint8_t *)ptr, copy_size);
+ }
+
+ return new_ptr;
+}
+
+void __wbindgen_start(void) {
+}
diff --git a/wasm/src/c/test-regression.test.js b/wasm/src/c/test-regression.test.js
new file mode 100644
index 00000000..cbf6add2
--- /dev/null
+++ b/wasm/src/c/test-regression.test.js
@@ -0,0 +1,74 @@
+import fs from "node:fs";
+import path from "node:path";
+import { createRequire } from "node:module";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { execFileSync } from "node:child_process";
+import { test, expect } from "bun:test";
+
+const require = createRequire(import.meta.url);
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+function build() {
+ execFileSync("make", ["-C", "c"], { stdio: "inherit" });
+}
+
+function loadSolvePow(modulePath) {
+ const mod = require(modulePath);
+ if (typeof mod.solve_pow !== "function") {
+ throw new Error(`missing solve_pow in ${modulePath}`);
+ }
+ return mod.solve_pow;
+}
+
+async function loadBrowserSolvePow() {
+ const browserModule = await import(
+ pathToFileURL(path.join(__dirname, "..", "browser", "cap_wasm.js")).href
+ );
+ browserModule.initSync({
+ module: fs.readFileSync(path.join(__dirname, "..", "browser", "cap_wasm_bg.wasm")),
+ });
+ return browserModule.solve_pow;
+}
+
+function assertCases(solvePow, cases) {
+ for (const [salt, target, expectedNonce] of cases) {
+ const actualNonce = solvePow(salt, target).toString();
+ expect(actualNonce).toBe(String(expectedNonce));
+ }
+}
+
+build();
+
+const nodeSolvePow = loadSolvePow(path.join("..", "node", "cap_wasm.js"));
+
+const regressionCases = [
+ ["e455cea65e98bc3c36287f43769da211", "dceb", 63625],
+ ["fb8d25f6abac5aa9b6360051f37e010b", "93f1", 62420],
+ ["91ef47db578fbeb2565d3f9c82bb7960", "3698", 42515],
+ ["b7ad7667486a691cda8ef297098f64a7", "d72a", 32395],
+ ["1aca3fb7cef7a2be0dee563ed4136758", "3b58", 53368],
+ ["d9ec39af92b430e5a329274d8aa58fa8", "e1d3", 52431],
+ ["781a3cc9217d73c908a321d3fdabd62f", "22c6", 102156],
+ ["e37a0752c9ac2f3d2517747fde373ac9", "f6f1", 118925],
+ ["bba070197569f322beda5b240f639a95", "4751", 25523],
+ ["89297515aeac646bee9653ba405e0beb", "a7de", 47980],
+ ["444571a0d5039c15be6141d6cd8434f9", "a783", 38735],
+ ["ba75f2bf8e9b92cc32caa17237a52d14", "7e30", 67790],
+ ["22bfc18ba8e3ecee080c5d1ef64ed6e9", "5fcf", 14836],
+ ["885fb78ff76b4eddd2f5bc04ac5ee673", "93e5", 91445],
+ ["308758072931bb3b254a7b1ed351d04a", "3e49", 19889],
+ ["724f89bb167db4b881e1dc7b0949ac8f", "b82e", 28170],
+ ["8b79506e4630de15be225c18623eff65", "f0e5", 9820],
+ ["0c21ade6e63a4e37b13cb8b087f31863", "65c9", 190704],
+];
+
+const browserCases = [regressionCases[0], regressionCases[7], regressionCases[17]];
+
+test("node wrapper regression", () => {
+ assertCases(nodeSolvePow, regressionCases);
+});
+
+test("browser wrapper regression", async () => {
+ const browserSolvePow = await loadBrowserSolvePow();
+ assertCases(browserSolvePow, browserCases);
+});
diff --git a/wasm/src/node/cap_wasm_bg.wasm b/wasm/src/node/cap_wasm_bg.wasm
index cb33c138..d3b96da6 100644
Binary files a/wasm/src/node/cap_wasm_bg.wasm and b/wasm/src/node/cap_wasm_bg.wasm differ
diff --git a/wasm/src/package.json b/wasm/src/package.json
index 7c55c7cb..406eed99 100644
--- a/wasm/src/package.json
+++ b/wasm/src/package.json
@@ -1,7 +1,7 @@
{
"name": "@cap.js/wasm",
"version": "0.0.6",
- "description": "WASM solver for Cap, a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.",
+ "description": "C-based WASM solver for Cap, a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.",
"keywords": [
"algorithm",
"anti-abuse",
@@ -52,6 +52,7 @@
},
"main": "./node/cap_wasm.js",
"scripts": {
+ "test": "bun test ./c/test-regression.test.js",
"npm:publish": "bun publish --access public"
}
}
diff --git a/wasm/src/rust/Cargo.lock b/wasm/src/rust/Cargo.lock
deleted file mode 100644
index 6d1978a1..00000000
--- a/wasm/src/rust/Cargo.lock
+++ /dev/null
@@ -1,218 +0,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 4
-
-[[package]]
-name = "block-buffer"
-version = "0.10.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
-dependencies = [
- "generic-array",
-]
-
-[[package]]
-name = "bumpalo"
-version = "3.17.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
-
-[[package]]
-name = "cap_wasm"
-version = "0.1.0"
-dependencies = [
- "hex",
- "sha2",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "cfg-if"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
-
-[[package]]
-name = "cpufeatures"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "crypto-common"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
-dependencies = [
- "generic-array",
- "typenum",
-]
-
-[[package]]
-name = "digest"
-version = "0.10.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
-dependencies = [
- "block-buffer",
- "crypto-common",
-]
-
-[[package]]
-name = "generic-array"
-version = "0.14.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
-dependencies = [
- "typenum",
- "version_check",
-]
-
-[[package]]
-name = "hex"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-
-[[package]]
-name = "libc"
-version = "0.2.172"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
-
-[[package]]
-name = "log"
-version = "0.4.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
-
-[[package]]
-name = "once_cell"
-version = "1.21.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.95"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "quote"
-version = "1.0.40"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "rustversion"
-version = "1.0.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
-
-[[package]]
-name = "sha2"
-version = "0.10.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
-dependencies = [
- "cfg-if",
- "cpufeatures",
- "digest",
-]
-
-[[package]]
-name = "syn"
-version = "2.0.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "typenum"
-version = "1.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
-
-[[package]]
-name = "unicode-ident"
-version = "1.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
-
-[[package]]
-name = "version_check"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
-
-[[package]]
-name = "wasm-bindgen"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
-dependencies = [
- "cfg-if",
- "once_cell",
- "rustversion",
- "wasm-bindgen-macro",
-]
-
-[[package]]
-name = "wasm-bindgen-backend"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
-dependencies = [
- "bumpalo",
- "log",
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
-dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
-]
-
-[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-backend",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
-dependencies = [
- "unicode-ident",
-]
diff --git a/wasm/test/node.js b/wasm/test/node.js
deleted file mode 100644
index fd4e21af..00000000
--- a/wasm/test/node.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import { performance } from "node:perf_hooks";
-import { solve_pow } from "../src/node/cap_wasm.js";
-
-const challenges = [
- ["e455cea65e98bc3c36287f43769da211", "dceb"],
- ["fb8d25f6abac5aa9b6360051f37e010b", "93f1"],
- ["91ef47db578fbeb2565d3f9c82bb7960", "3698"],
- ["b7ad7667486a691cda8ef297098f64a7", "d72a"],
- ["1aca3fb7cef7a2be0dee563ed4136758", "3b58"],
- ["d9ec39af92b430e5a329274d8aa58fa8", "e1d3"],
- ["781a3cc9217d73c908a321d3fdabd62f", "22c6"],
- ["e37a0752c9ac2f3d2517747fde373ac9", "f6f1"],
- ["bba070197569f322beda5b240f639a95", "4751"],
- ["89297515aeac646bee9653ba405e0beb", "a7de"],
- ["444571a0d5039c15be6141d6cd8434f9", "a783"],
- ["ba75f2bf8e9b92cc32caa17237a52d14", "7e30"],
- ["22bfc18ba8e3ecee080c5d1ef64ed6e9", "5fcf"],
- ["885fb78ff76b4eddd2f5bc04ac5ee673", "93e5"],
- ["308758072931bb3b254a7b1ed351d04a", "3e49"],
- ["724f89bb167db4b881e1dc7b0949ac8f", "b82e"],
- ["8b79506e4630de15be225c18623eff65", "f0e5"],
- ["0c21ade6e63a4e37b13cb8b087f31863", "65c9"],
-];
-
-async function runSolverTest() {
- const startTime = performance.now();
-
- for (let i = 0; i < challenges.length; i++) {
- const [salt, target] = challenges[i];
- const nonce = solve_pow(salt, target);
-
- console.log(`[${i + 1}/${challenges.length}] ${salt}:${target}:${nonce}`);
- }
-
- const endTime = performance.now();
- const totalTime = (endTime - startTime) / 1000;
-
- console.log(`Solved challenges in ${totalTime.toFixed(3)}s`);
-}
-
-runSolverTest();
diff --git a/wasm/test/node.test.js b/wasm/test/node.test.js
new file mode 100644
index 00000000..55d3bc02
--- /dev/null
+++ b/wasm/test/node.test.js
@@ -0,0 +1,41 @@
+import { performance } from "node:perf_hooks";
+import { test, expect } from "bun:test";
+import { solve_pow } from "../src/node/cap_wasm.js";
+
+const challenges = [
+ ["e455cea65e98bc3c36287f43769da211", "dceb", 63625],
+ ["fb8d25f6abac5aa9b6360051f37e010b", "93f1", 62420],
+ ["91ef47db578fbeb2565d3f9c82bb7960", "3698", 42515],
+ ["b7ad7667486a691cda8ef297098f64a7", "d72a", 32395],
+ ["1aca3fb7cef7a2be0dee563ed4136758", "3b58", 53368],
+ ["d9ec39af92b430e5a329274d8aa58fa8", "e1d3", 52431],
+ ["781a3cc9217d73c908a321d3fdabd62f", "22c6", 102156],
+ ["e37a0752c9ac2f3d2517747fde373ac9", "f6f1", 118925],
+ ["bba070197569f322beda5b240f639a95", "4751", 25523],
+ ["89297515aeac646bee9653ba405e0beb", "a7de", 47980],
+ ["444571a0d5039c15be6141d6cd8434f9", "a783", 38735],
+ ["ba75f2bf8e9b92cc32caa17237a52d14", "7e30", 67790],
+ ["22bfc18ba8e3ecee080c5d1ef64ed6e9", "5fcf", 14836],
+ ["885fb78ff76b4eddd2f5bc04ac5ee673", "93e5", 91445],
+ ["308758072931bb3b254a7b1ed351d04a", "3e49", 19889],
+ ["724f89bb167db4b881e1dc7b0949ac8f", "b82e", 28170],
+ ["8b79506e4630de15be225c18623eff65", "f0e5", 9820],
+ ["0c21ade6e63a4e37b13cb8b087f31863", "65c9", 190704],
+];
+
+test("node solver regression cases", () => {
+ const startTime = performance.now();
+
+ for (let i = 0; i < challenges.length; i++) {
+ const [salt, target, expectedNonce] = challenges[i];
+ const nonce = solve_pow(salt, target);
+ expect(nonce.toString()).toBe(String(expectedNonce));
+
+ console.log(`[${i + 1}/${challenges.length}] ${salt}:${target}:${nonce}`);
+ }
+
+ const endTime = performance.now();
+ const totalTime = (endTime - startTime) / 1000;
+
+ console.log(`Solved challenges in ${totalTime.toFixed(3)}s`);
+});
diff --git a/wasm/test/node_odd_difficulty.js b/wasm/test/node_odd_difficulty.test.js
similarity index 53%
rename from wasm/test/node_odd_difficulty.js
rename to wasm/test/node_odd_difficulty.test.js
index a6a369fd..0f506f40 100644
--- a/wasm/test/node_odd_difficulty.js
+++ b/wasm/test/node_odd_difficulty.test.js
@@ -1,15 +1,19 @@
import { createHash } from "node:crypto";
+import { test, expect } from "bun:test";
import { solve_pow } from "../src/node/cap_wasm.js";
// salt, target
const challenge = ["02679e6558", "eeffc"];
-const [salt, target] = challenge;
-const nonce = solve_pow(salt, target);
+test("odd difficulty challenge", () => {
+ const [salt, target] = challenge;
+ const nonce = solve_pow(salt, target);
+ expect(nonce.toString()).toBe("1127415");
-const actualHash = createHash("sha256").update(`${salt}${nonce}`).digest("hex");
+ const actualHash = createHash("sha256").update(`${salt}${nonce}`).digest("hex");
+ expect(actualHash.slice(0, target.length)).toBe(target);
-console.log(`salt: ${salt}
+ console.log(`salt: ${salt}
target: ${target}
nonce: ${nonce}
@@ -19,3 +23,4 @@ should start with ${target}
is ${actualHash.slice(0, target.length)} (${actualHash})
${actualHash.slice(0, target.length) === target ? "✅ success!" : "❌ invalid"}`);
+});